Admin, Compliance & Deployment
Part of: Pickatale Master Build Wiki | Version: v1.7 | Last Updated: 2026-04-18
24. Admin and Operational Tools
Source: (D) | Status: Confirmed as spec (must build)
Access
- URL: admin.readingtester.com (or /admin route on Account Center)
- Access: platform_admin role only
- Auth: must pass 2FA (TOTP) β admin panel requires elevated session
- All actions: logged to
audit_log(actor, action, target, timestamp, IP)
24.1 Search User
- Search by: email, name, username (for students), school_name
- Returns: user type, state, subscription state, last login, school
24.2 Impersonate User
- Click "Impersonate" on user record
- Backend: creates
impersonation_sessionsrow (admin_id, target_id, started_at) - Sets uc_session cookie as target user (with
impersonated_byflag) - UI shows persistent orange bar: "Impersonating [name] β Exit"
- All actions by impersonator are logged to
audit_logwithimpersonated: true - Exit: destroys impersonation session, returns admin uc_session
24.3 Reset Password (Admin)
- Generates password reset token (flow 15.5 step 2 onward), sends PASSWORD_RESET email to user
- OR sets temporary password directly (with
force_change_on_login = true)
24.4 Grant / Revoke Entitlement
- Admin selects user β Grant Access β tier (full/limited), expires_at, reason
- Inserts
entitlement_grantsrow - Revoke: DELETE from
entitlement_grants checkEntitlement()always checksentitlement_grantsbeforesubscriptions
24.5 Move Child Between Accounts / Schools
- Admin searches child by username β Move β select target teacher or school
- Backend: UPDATE
students.teacher_id,students.school_id,students.class_id - All historical data follows learner_id
24.6 Merge Duplicate Accounts
- Admin selects source (to-merge) and target (to-keep) accounts
- Backend:
- Copy all telemetry events from source learner_id to target learner_id
- Copy all Learner Bot memories
- Copy all quiz results
- Archive source account (
users.state = 'archived') - Flag merge in
audit_log
24.7 View Audit Logs
- Filter by: user_id, action_type, date range, IP
- Actions logged: login, logout, impersonation, PIN reset, password reset, entitlement change, data export, account archive, class changes
24.8 Resend Invite
- Admin selects invite from invite list β Resend
- Generates new token, sends new INVITE_EMAIL, invalidates old token
24.9 Disable / Suspend Account
- Admin β Account β Suspend
- Backend:
users.state = 'suspended', invalidates all active sessions - Optional reason stored in
users.suspension_reason - Sends ACCOUNT_SUSPENDED email with reason
24.10 View Payment Status
- Admin searches school or user β Payment tab
- Shows: subscription state, current period dates, Stripe customer ID link, last 5 payments, failed payment log
24.11 View Learner Event History
- Admin searches child by username β Events tab
- Shows chronological list of all telemetry events (type, book, timestamp, session)
- Reads from Telemetry DB (no raw PII β no touch coords, only event types and word counts)
44. Admin APIs
Source: (D) | Status: Confirmed as spec. All admin endpoints live at /api/admin on Account Center (port 3126). MUST BUILD.**
44.1 Admin Endpoints
All admin endpoints require uc_session with role='platform_admin'.
All admin endpoints INSERT audit_log before executing.
GET /api/admin/users List users (filterable by role, state, school_id)
GET /api/admin/users/:id Get user detail
PATCH /api/admin/users/:id Update user (state, role, school_id)
POST /api/admin/users/:id/suspend Suspend account
POST /api/admin/users/:id/reactivate Reactivate account
POST /api/admin/users/:id/impersonate Start impersonation session
DELETE /api/admin/impersonate End impersonation session
GET /api/admin/schools List schools
GET /api/admin/schools/:id Get school detail + member list
PATCH /api/admin/schools/:id Update
GET /api/admin/subscriptions List subscriptions (filterable by state, tier)
PATCH /api/admin/subscriptions/:id Update subscription state manually
POST /api/admin/entitlement/grant Grant entitlement to user or school
DELETE /api/admin/entitlement/grant/:id Revoke entitlement grant
GET /api/admin/students/:id Get student detail (any school)
POST /api/admin/students/merge Merge duplicate students (see Flow 30.10)
PATCH /api/admin/students/:id/transfer Transfer student to different school
POST /api/admin/students/:id/reset-pin Reset PIN for any student
GET /api/admin/audit-log Query audit log (filters: actor_id, action, date range)
GET /api/admin/jobs/:name/runs View cron job run history
POST /api/admin/bot/run-learner/:id Manually trigger bot for one learner
POST /api/admin/bot/run-nightly Manually trigger full nightly run
44.2 Admin Permission Model
| Role | Can access /api/admin | Can impersonate | Can cross-tenant | Can grant entitlement |
|---|---|---|---|---|
| platform_admin | β all endpoints | β any user | β | β |
| school_admin | β | β | β | β |
| teacher | β | β | β | β |
school_admin manages their own school via regular /api/v1/ endpoints (not /api/admin/).
44.3 Impersonation Safeguards
POST /api/admin/users/:id/impersonate
Auth: uc_session role=platform_admin
Input: { "reason": "string (required, min 10 chars)" }
Backend:
1. Verify requestor is platform_admin
2. Target user must NOT be another platform_admin (no adminβadmin impersonation)
3. INSERT audit_log (action='impersonation_started', actor_id=admin_id,
target_id=user_id, metadata={reason, ip, user_agent})
4. INSERT sessions (user_id=target_id, role=target_role, impersonator_id=admin_id,
expires_at=now()+1h, ip, user_agent)
5. Set uc_session cookie with impersonated session
Active impersonation indicators:
- All API responses include header: X-Impersonated-By: {admin_name}
- Client must show orange "Impersonating {name}" banner at all times
- All mutations during impersonation: audit_log includes impersonator_id in metadata
End impersonation:
DELETE /api/admin/impersonate
1. Invalidate current session
2. INSERT audit_log (action='impersonation_ended', metadata={duration_seconds})
3. Restore admin's own session (stored in cookie as X-Admin-Session, httpOnly)
Constraint: Impersonation sessions expire in 1 hour. No extension. No impersonation of platform_admin by platform_admin.
44.4 Audit Log Schema (Final)
-- Already in Section 38.1 as CREATE TABLE audit_log
-- Action values (complete list):
Auth: register, email_verified, login, logout, forgot_password, password_reset,
invite_sent, invite_accepted, account_locked, account_suspended, account_reactivated
Identity: email_changed, password_changed
Session: session_created, session_expired, session_invalidated
Roster: create_class, archive_class, add_student, bulk_import, move_student,
transfer_student, archive_student, merge_students, reset_student_pin,
pin_revealed, print_login_cards, parent_claim_submitted, parent_claim_approved,
parent_claim_rejected
Entitlement: entitlement_grant_created, entitlement_grant_revoked, subscription_expired,
entitlement_check_failed
Billing: stripe_webhook, subscription_created, subscription_updated, payment_succeeded,
payment_failed, subscription_cancelled
Admin: impersonation_started, impersonation_ended, admin_user_updated,
admin_subscription_updated, admin_bot_run_triggered
GDPR: data_export_requested, data_export_ready, data_deletion_requested,
data_deletion_completed
Compliance: coppa_consent_received, coppa_consent_revoked
45. Compliance Workflows
Source: (D) | Status: [V1-PRODUCTION]
45.1 Student Activation β School DPA Model [V1-PRODUCTION]
v1 Rule: Student accounts are activated under the school's DPA. The school is the data controller. No per-student consent gate on the platform. No age-based activation restriction implemented in code.
- School signs DPA at setup (
school_dpa_agreementstable, IP + timestamp captured) - DPA covers all students enrolled by that school β no per-student activation required
- Child account activated immediately when teacher creates it β no waiting for parent or age check
- Platform does not send consent emails to parents for activation
- Platform does not gate activation on age (
age < 13or otherwise)
Age-related rules that DO exist in v1:
- RSVP speedread: ages 8+ only (UX guardrail, not a consent gate)
- WCPM/accuracy thresholds for multilingual learners (assessment calibration only)
Legal responsibility: Schools manage parental notification externally (letters, privacy notices). Platform does not verify, store, or gate on parental consent status.
45.2 GDPR Data Export and Deletion
Trigger: Authenticated user (or platform_admin on behalf) submits deletion or export request.
Export flow:
POST /api/gdpr/export
Auth: uc_session (any role β exports own data; platform_admin can target any user_id)
Input: { "user_id": optional (admin only) }
Backend:
1. INSERT gdpr_requests (user_id, type='export', state='pending')
2. Queue async export job (runs within 24h)
3. Job collects from: Account Center (users, sessions), Reader App (reading_sessions, quiz_attempts),
Telemetry (events), Learner Bot (memories, vocab_gaps, reports), LRS (xAPI statements),
Teacher Portal (student data if applicable)
4. Package as JSON. Upload to secure temporary URL (24h expiry).
5. UPDATE gdpr_requests.state='complete', download_url=...
6. Send DATA_EXPORT_READY email
7. INSERT audit_log (action='data_export_ready')
Deletion flow:
POST /api/gdpr/delete
Auth: uc_session OR platform_admin
Input: { "learner_id": "UUID (for child deletion)" OR user_id }
Backend:
1. INSERT gdpr_requests (type='deletion', state='pending')
2. Immediate: UPDATE students.state='archived'
3. Async job (within 30 days per GDPR):
a. Telemetry DB: DELETE events WHERE learner_id = target
b. Learner Bot DB: DELETE memories, vocab_gaps, assessments, reports WHERE learner_id = target
c. Reader App DB: DELETE reading_sessions, quiz_attempts, placement_results WHERE learner_id = target
d. LRS DB: DELETE xAPI statements WHERE actor = learner_id
e. Account Center: anonymise users row (name β 'Deleted User', email β uuid@deleted.invalid)
f. audit_log: RETAIN (legal requirement β action log, no PII in metadata after deletion)
4. UPDATE gdpr_requests.state='complete'
5. INSERT audit_log (action='data_deletion_completed')
45.3 Data Retention
| Data type | Retention | Notes |
|---|---|---|
| Telemetry events | 90 days | Cron deletes at 03:00 UTC |
| Raw touch data | Session only | Anonymised after session analysis (never stored raw) |
| Raw audio (fluency) | 0 days | Voice audio MUST NOT be stored. Audio may exist only in-memory during request processing and must be deleted immediately after transcription. No logging, caching, queues, or retries may contain raw audio. |
| Reading sessions | 3 years | Or until deletion request |
| Learner Bot memories | 3 years | Or until deletion request |
| LRS xAPI statements | Indefinite | Or until deletion request |
| Audit logs | 7 years | Retained for legal compliance (but PII removed on deletion) |
| nightly_reports | 3 years | Or until deletion request |
| emails sent | 90 days | email_log.status only |
| Session tokens | 7 days | Or until invalidated |
45.4 Audit Trace for Compliance
Every data access or mutation touching child data must INSERT audit_log.
For SOC2 Type II: All of the following must be logged:
- Auth events (login, logout, failed attempts, session creation)
- Data access events for child records (read of nightly_report, read of vocab_gaps)
- All mutations (create, update, delete on students, memories, assessments)
- Admin operations (impersonation, entitlement grants, cross-tenant reads)
- GDPR events (export, deletion, consent)
Audit log must be immutable: No UPDATE or DELETE on audit_log rows. Hard delete only on GDPR deletion β and only after anonymising the PII fields.
-- On GDPR deletion of a user:
UPDATE audit_log
SET metadata = JSON_REMOVE(metadata, '$.email', '$.name', '$.ip')
WHERE actor_id = :user_id;
-- Do NOT delete the row. The action record must be retained.
46. Deployment Model
Source: (D) + code audit | Status: Confirmed for existing services; MUST BUILD where noted
46.1 Environments
| Environment | URL pattern | Purpose |
|---|---|---|
| Production | *.readingtester.com | Live. Students use this. |
| Staging | *.staging.readingtester.com | Pre-release validation. Mirrors prod data schema, not data. |
| Local | localhost:* | Developer laptops. |
Rule: No staging environment exists yet (MUST BUILD). Until it does: test on production with test accounts only. Never use real student data for testing.
46.2 CI/CD (Recommended Default β Not Yet Built)
Target pipeline per service:
Push to main β
1. Install deps (pnpm install)
2. Type check (tsc --noEmit)
3. Lint (eslint)
4. Unit tests (vitest or jest)
5. Build (tsc || vite build)
6. Docker build
7. Push image to registry
8. Run migrations (prisma migrate deploy || sequelize db:migrate)
9. Deploy (docker pull + docker compose up -d on server via SSH)
10. Smoke test (curl health endpoint, assert 200)
Current state: Manual SSH deploy. No CI pipeline. (MUST BUILD for v2)
46.3 Migrations
Rule: Every DB schema change must be a migration file. No hand-editing production tables.
Migration tools by service:
- Learner Bot: use raw SQL files in
/migrations/directory, applied by node migrate script - Reader App: Drizzle ORM (
drizzle-kit generate:mysql+drizzle-kit push:mysql) - All other services: match the ORM in use (confirm in CLAUDE.md per service before touching)
Migration procedure:
- Write migration file (numbered:
001_create_users.sql) - Test on local
- Apply to staging (when exists)
- Apply to production during low-traffic window (UTC 02:00β04:00)
- Verify with health check before and after
Rollback: Every destructive migration must have a corresponding rollback file. Column drops require a two-phase migration: (1) stop writing, (2) drop β separated by at least one deploy.
46.4 Rollback Procedure
If production deploy fails:
- Re-deploy previous Docker image tag (keep last 3 tags in registry)
- If migration already ran: apply rollback SQL manually β never auto-rollback DB in production
- Alert: INSERT audit_log (action='rollback', metadata={service, version, reason})
46.5 Secrets Management
Rule: No secrets in source code or Docker images. Ever.
| Secret type | Storage |
|---|---|
| DB passwords | .env in service root (gitignored β see internal ops docs for paths) |
| API keys (SendGrid, Stripe, OpenAI, Azure) | Same .env |
| JWT secrets | Same .env |
| Internal shared key | Same .env β INTERNAL_KEY=<stored in .env, not committed> |
| Stripe webhook secret | Same .env β STRIPE_WEBHOOK_SECRET=... |
.env.example must exist for every service with all variable names and placeholder values.
Production .env files are on the server only. Never committed. Never in Docker image ENV instructions (use --env-file at runtime).
# Start pattern for all services:
docker compose --env-file .env up -d