π¨βπ©βπ§ Parent Account Creation & Linking Contract
parent-portal signup/password flow is replaced by this. The old 6-char link-code system is replaced by this. Do not retain both.
Canonical Principle
Parent account creation is:
- Token-initiated β parent arrives via a secure token link, never a public signup page
- Email-verified β magic link sent to parent email before any child data is shown
- Backend-linked to existing child β the child record already exists; the parent flow only creates the link
- Read-only by default β parent has no write access to child, class, or school data
Flow Overview
| Step | Who acts | What happens |
|---|---|---|
| 0 β Token issued | Teacher (in portal) | Teacher enables parent access for a student; system generates parent_link_token |
| 1 β Parent opens link | Parent | Opens QR/URL, token validated, email form shown β no child info revealed yet |
| 2 β Parent submits email | Parent | Account created or reused; magic-link verification email sent |
| 3 β Parent clicks email link | Parent | Email verified, session created, child link written, token consumed β redirect to /parent/onboarding |
| 4 β Onboarding preferences | Parent | Shown once after linking: choose notification preferences β Save & Continue |
| 5 β Parent home | Parent | Read-only child progress view |
Step 0 β Teacher Issues a Token
In the Teacher Portal, under a student's profile, the teacher sees a Parent Access toggle (Off by default). When toggled On, the portal calls the User Center to generate a token. The token URL is displayed as a QR code and a short link the teacher can share with the parent (printed, emailed, or sent via school system).
Request
{
"issued_by_user_id": "teacher-uuid",
"expires_in_hours": 72 // default 72h, max 168h (7d)
}
Response 201
{
"token": "plt_a3f8...z9", // full token β shown once, not stored in DB
"link_url": "https://parents.readingtester.com/link?token=plt_a3f8...z9",
"expires_at": "2026-04-22T02:00:00Z"
}
DB write
INSERT INTO parent_link_tokens (child_id, issued_by_user_id, token_hash, status, expires_at)
VALUES (child_uuid_lookup, teacher_uuid, sha256(token), 'active', expires_at)
Audit log
{ action: "parent_link_token_created", actor_id: teacher_uuid, child_id: child_uuid }
sha256(token) in the DB. The raw token is returned once to the Teacher Portal for QR generation and never stored anywhere. This matches the same pattern as PIN hashes.
Step 1 β Parent Opens Link Page
Called by the frontend on page load. Returns only enough to render the UI β no child identity.
Response 200 β valid token
{
"valid": true,
"school_name": "Greenwood Primary", // for branding only
"school_logo_url": "https://..." // optional, may be null
}
Response 200 β invalid / expired / used
{
"valid": false,
"reason": "expired" | "not_found" | "already_used" | "revoked"
}
Backend logic
token_hash = sha256(link_token)
row = SELECT * FROM parent_link_tokens WHERE token_hash = token_hash
if (!row) β { valid: false, reason: "not_found" }
if (row.status != 'active') β { valid: false, reason: row.status }
if (row.expires_at < NOW()) β { valid: false, reason: "expired" }
// do NOT return child_id, child name, or any learner data here
β { valid: true, school_name: ... }
UI shown to parent (valid token)
- Headline: "Connect to your child's reading updates"
- Email input + Continue button
- School logo/name (if provided)
- No child name, no child photo, no school class info
UI shown to parent (invalid token)
- Expired: "This link has expired. Ask your child's teacher for a new one."
- Already used: "This link has already been used. Visit parents.readingtester.com to log in."
- Not found / revoked: "This link is not valid. Ask your child's teacher for a new one."
Step 2 β Parent Submits Email
Request body
{
"link_token": "plt_a3f8...z9",
"email": "parent@example.com"
}
Backend logic (exact sequence)
1. token_hash = sha256(link_token)
2. Re-validate token (same checks as Step 1 β token can expire between steps)
β if invalid: 400 { error: "LINK_INVALID", reason: "..." }
3. email = normalize(input.email).toLowerCase().trim()
4. user = SELECT * FROM users WHERE email = email AND role = 'parent'
if (!user):
INSERT INTO users (uuid, email, name='', role='parent', email_verified_at=NULL, ...)
user = new row
5. verification_token = crypto.randomBytes(32).hex()
INSERT INTO email_verifications (
user_id = user.id,
token_hash = sha256(verification_token),
link_token_hash = token_hash, // β store association
expires_at = NOW() + 30 minutes,
consumed_at = NULL
)
6. Send PARENT_LINK_VERIFY email to parent:
Subject: "Confirm your email to connect to Pickatale"
Body: magic link β https://parents.readingtester.com/verify?vt={verification_token}
email_log INSERT
7. audit_log INSERT: { action: "parent_link_started", parent_user_id: user.id,
link_token_hash: token_hash }
Response 200 (always β don't leak whether account existed)
{
"message": "Check your email for a confirmation link."
}
Error responses
400 { "error": "LINK_INVALID", "reason": "expired" | "not_found" | "already_used" | "revoked" }
400 { "error": "INVALID_EMAIL" }
429 { "error": "RATE_LIMITED", "retryAfter": 900 }
Step 3 β Parent Clicks Email Link
Backend logic (exact sequence β atomic where noted)
1. vt_hash = sha256(verification_token)
2. ev = SELECT * FROM email_verifications WHERE token_hash = vt_hash
if (!ev) β 400 { error: "INVALID_LINK" }
if (ev.consumed_at) β 400 { error: "LINK_ALREADY_USED" }
if (ev.expires_at < NOW()) β 400 { error: "LINK_EXPIRED" }
3. plt = SELECT * FROM parent_link_tokens WHERE token_hash = ev.link_token_hash
if (!plt) β 400 { error: "LINK_INVALID", reason: "not_found" }
if (plt.status != 'active') β 400 { error: "LINK_INVALID", reason: plt.status }
if (plt.expires_at < NOW()) β 400 { error: "LINK_INVALID", reason: "expired" }
// BEGIN TRANSACTION
4. UPDATE users SET email_verified_at = NOW() WHERE id = ev.user_id
5. UPDATE email_verifications SET consumed_at = NOW() WHERE id = ev.id
6. UPDATE parent_link_tokens SET status = 'used', used_at = NOW() WHERE id = plt.id
7. INSERT INTO parent_child_links (parent_user_id, child_id, access_level, status)
VALUES (ev.user_id, plt.child_id, 'read_only', 'active')
ON DUPLICATE KEY UPDATE status = 'active' // idempotent if re-linked
8. INSERT INTO sessions (user_id, token, expires_at)
session_token = crypto.randomBytes(32).hex()
expires_at = NOW() + 30 days
// COMMIT TRANSACTION
9. audit_log INSERT: { action: "parent_link_completed",
parent_user_id: ev.user_id, child_id: plt.child_id }
10. Set-Cookie: parent_session={session_token}; HttpOnly; Secure; SameSite=Lax; Path=/
Redirect: 302 β /parent/onboarding // first login always goes to onboarding
// server checks: parent_notification_preferences row exists? β /parent/home instead
Error responses
400 { "error": "INVALID_LINK" } // vt not found in DB
400 { "error": "LINK_ALREADY_USED" } // email_verifications.consumed_at set
400 { "error": "LINK_EXPIRED" } // email_verifications.expires_at passed
400 { "error": "LINK_INVALID", "reason": "revoked" | "expired" | ... } // parent_link_token bad
UI for error states
- LINK_EXPIRED: "This confirmation link has expired (they last 30 minutes). Go back and enter your email again." + button β /parent/link?token={original_link_token}
- LINK_ALREADY_USED: "You've already confirmed. Log in to view your child's progress." + Login link
- LINK_INVALID: "Something went wrong. Ask your child's teacher for a new parent link."
parent_child_links INSERT fails, the token is NOT consumed and the parent is NOT marked verified β they can retry.
Step 4 β Onboarding Preferences
/parent/home. Preferences are editable later at /parent/settings.
Route guard
On every authenticated request to the Parent Portal, the server checks:
prefs = SELECT * FROM parent_notification_preferences WHERE parent_user_id = session.user_id
if (!prefs AND currentPath != '/parent/onboarding'):
redirect β /parent/onboarding // enforce the one-time screen
if (prefs AND currentPath == '/parent/onboarding'):
redirect β /parent/home // already done, skip
UI
Headline: "How would you like to receive updates about your child's reading?"
| Option | Default | Description |
|---|---|---|
| Weekly summary | ON | A digest each week with reading activity, books finished, and miles earned. |
| Important alerts | ON | Sent when your child reaches a milestone or the teacher flags something. |
| Recommendations | OFF | Occasional book and reading tips tailored to your child. |
Single button: Save & Continue
Request body
{
"weekly_summary_enabled": boolean, // required
"alerts_enabled": boolean, // required
"recommendations_enabled": boolean // required
}
Backend logic
INSERT INTO parent_notification_preferences
(parent_user_id, weekly_summary_enabled, alerts_enabled, recommendations_enabled, updated_at)
VALUES
(session.user_id, body.weekly_summary_enabled, body.alerts_enabled, body.recommendations_enabled, NOW())
ON DUPLICATE KEY UPDATE
weekly_summary_enabled = VALUES(weekly_summary_enabled),
alerts_enabled = VALUES(alerts_enabled),
recommendations_enabled = VALUES(recommendations_enabled),
updated_at = NOW()
audit_log INSERT: { action: "parent_preferences_set", parent_user_id: session.user_id }
Response 200
{ "saved": true }
Frontend action on success
redirect β /parent/home
Used by /parent/settings to pre-populate the form for editing.
Response 200
{
"weekly_summary_enabled": boolean,
"alerts_enabled": boolean,
"recommendations_enabled": boolean,
"updated_at": "ISO-8601"
}
Response 404
{ "error": "NOT_SET" } // preferences not yet saved β should not happen after onboarding
Step 5 β Parent Home View
Authenticated via parent_session cookie. Parent can have multiple linked children (one parent_child_links row per child). All data fetched from Reader API and Learner Bot via internal calls using child's learner_id.
What the parent sees
- Last reading activity (last book, last active date)
- Progress summary (books read this week, miles earned, reading streak)
- Latest parent digest (from Learner Bot nightly report)
- Talking points: "Things to ask your child about" (generated by bot)
- Email preference settings (Weekly digest / Nightly digest / Off)
What the parent does NOT see
- Child's username or PIN β never
- FK reading level number (show age-appropriate language: "Reading well for their age")
- Class name, school name, teacher name
- Other children in the class
- Assessment scores or technical reading data
Access permissions β enforced server-side
| Action | Parent allowed? |
|---|---|
| View child reading progress | β Yes β read only |
| View parent digest / bot report | β Yes β read only |
| Set email notification preference | β Yes |
| Reset child PIN | β No β HTTP 403 |
| Change child reading level | β No β HTTP 403 |
| View child username or PIN | β No β never returned by API |
| Add/remove child from class | β No β HTTP 403 |
| Delete child account | β No β HTTP 403 |
| Transfer learner ownership | β No β HTTP 403 |
Required Data Model
The following tables must be created or modified. All tables live in the user_center database.
parent_link_tokens NEW TABLE
CREATE TABLE parent_link_tokens (
id INT PRIMARY KEY AUTO_INCREMENT,
child_id VARCHAR(36) NOT NULL, -- learner UUID (from users.uuid)
issued_by_user_id VARCHAR(36) NOT NULL, -- teacher UUID
token_hash VARCHAR(64) NOT NULL UNIQUE, -- sha256(raw_token), hex
status ENUM('active','used','expired','revoked') NOT NULL DEFAULT 'active',
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
used_at DATETIME,
revoked_by VARCHAR(36), -- user UUID of revoker if revoked
revoked_at DATETIME,
INDEX idx_child (child_id),
INDEX idx_status_expires (status, expires_at)
);
email_verifications NEW TABLE
CREATE TABLE email_verifications (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL, -- FK β users.id
token_hash VARCHAR(64) NOT NULL UNIQUE, -- sha256(raw_vt), hex
link_token_hash VARCHAR(64) NOT NULL, -- FK β parent_link_tokens.token_hash
expires_at DATETIME NOT NULL, -- NOW() + 30 minutes
consumed_at DATETIME, -- set when used; NULL = not used
created_at DATETIME NOT NULL DEFAULT NOW(),
INDEX idx_user (user_id)
);
parent_child_links NEW TABLE
CREATE TABLE parent_child_links (
id INT PRIMARY KEY AUTO_INCREMENT,
parent_user_id INT NOT NULL, -- FK β users.id (role='parent')
child_id VARCHAR(36) NOT NULL, -- learner UUID
access_level ENUM('read_only') NOT NULL DEFAULT 'read_only', -- v1 only
status ENUM('active','revoked') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT NOW(),
revoked_at DATETIME,
revoked_by VARCHAR(36),
UNIQUE KEY uq_parent_child (parent_user_id, child_id)
);
parent_notification_preferences NEW TABLE
CREATE TABLE parent_notification_preferences (
id INT PRIMARY KEY AUTO_INCREMENT,
parent_user_id INT NOT NULL UNIQUE, -- FK β users.id (role='parent')
weekly_summary_enabled TINYINT(1) NOT NULL DEFAULT 1,
alerts_enabled TINYINT(1) NOT NULL DEFAULT 1,
recommendations_enabled TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW()
);
Presence of a row for a given parent_user_id is the signal that onboarding is complete. Absence β redirect to /parent/onboarding.
users MODIFY
The existing users table already has role enum. Add the following column:
ALTER TABLE users ADD COLUMN email_verified_at DATETIME DEFAULT NULL
AFTER email;
-- Also ensure role enum includes 'parent':
-- ENUM('teacher', 'parent', 'student', 'admin') β already present, confirm
Parent accounts created via this flow have passwordHash = NULL β they authenticate via session cookie only (magic link flow, no password). This is intentional: no password to steal, no password reset to exploit.
Audit Log Events
All events written to audit_log table. The immutability trigger applies (no DELETEs).
| action | When | actor_id | target_id |
|---|---|---|---|
parent_link_token_created | Teacher generates token | teacher UUID | child UUID |
parent_link_started | Parent submits email | parent user ID | link token hash (truncated) |
parent_verify_email_sent | Magic link email sent | parent user ID | β |
parent_link_completed | Email verified, link written | parent user ID | child UUID |
parent_link_revoked | Teacher or admin revokes | revoker UUID | child UUID + parent user ID |
parent_link_token_revoked | Teacher revokes unused token | teacher UUID | link token hash (truncated) |
parent_preferences_set | Onboarding form submitted or preferences updated | parent user ID | β |
Full API Contracts
Auth
None β public endpoint.
Rate limit
20 req / 15 min per IP.
Success 200
{ "valid": true, "school_name": string | null, "school_logo_url": string | null }
Invalid 200
{ "valid": false, "reason": "expired" | "not_found" | "already_used" | "revoked" }
Always 200 β never 4xx for token state, to avoid leaking token existence to attackers.
Auth
None β public endpoint.
Rate limit
5 req / 15 min per IP AND 5 req / 15 min per normalized email.
Request body
{
"link_token": string, // required
"email": string // required, must be valid email format
}
Success 200
{ "message": "Check your email for a confirmation link." }
Errors
400 { "error": "LINK_INVALID", "reason": "expired" | "not_found" | "already_used" | "revoked" }
400 { "error": "INVALID_EMAIL" }
429 { "error": "RATE_LIMITED", "retryAfter": 900 }
Auth
None β public endpoint. Authenticated by possession of the token.
Rate limit
10 attempts / 15 min per IP.
Success
302 Redirect β /parent/home
Set-Cookie: parent_session={session_token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000
Errors
400 { "error": "INVALID_LINK" }
400 { "error": "LINK_ALREADY_USED" }
400 { "error": "LINK_EXPIRED" }
400 { "error": "LINK_INVALID", "reason": "revoked" | "expired" | "not_found" }
All error states render a user-friendly HTML page (not raw JSON) β this is a browser GET.
Auth
Teacher session (from Teacher Portal) OR platform_admin session. Internal key not needed β teacher authenticated via their own JWT.
Request body β revoke an active link (already connected parent)
{
"child_id": "learner-uuid",
"parent_user_id": 123, // DB id of the parent user
"reason": string // free text, stored in audit_log
}
Request body β revoke an unused token (before parent has clicked)
{
"link_token_hash": "sha256hex...", // token hash, from Teacher Portal state
"reason": string
}
Success 200
{ "revoked": true }
Effects
- If revoking active link:
parent_child_links.status = 'revoked',revoked_at = NOW(),revoked_by = actor UUID - If revoking unused token:
parent_link_tokens.status = 'revoked',revoked_at = NOW() - Parent's existing session NOT invalidated immediately (session expires naturally within 30 days) β but
parent_child_links.status='revoked'is checked on every data request audit_logINSERT for either action
Errors
403 { "error": "FORBIDDEN" } // requester is not the issuing teacher or admin
404 { "error": "NOT_FOUND" } // link or token not found
409 { "error": "ALREADY_REVOKED" } // idempotent β no error, just 200
Request
{
"issued_by_user_id": "teacher-uuid",
"expires_in_hours": 72 // integer, 1β168
}
Response 201
{
"token": "plt_...",
"link_url": "https://parents.readingtester.com/link?token=plt_...",
"expires_at": "ISO-8601"
}
Response 404
{ "error": "CHILD_NOT_FOUND" }
Teacher Controls
In the Teacher Portal, under a student's profile:
| Control | What it does |
|---|---|
| Parent Access toggle Off (default) / On |
Off β no parent link tokens exist. On β generates token, shows QR + short URL for sharing. Toggle back to Off β revokes all active tokens for this child, but does NOT revoke existing parent_child_links (connected parents stay connected until explicitly unlinked). |
| Parent Visibility Basic / Full |
Basic (default) β reading activity + digest only. Full β adds curriculum-aligned progress summary. Stored as parent_child_links.access_level (v1: read_only with visibility hint in metadata column). Note: v1 implements Basic only; Full is a future tier. |
| Connected parents list | Shows list of parent email addresses linked to this child. Teacher can click Unlink next to any parent (calls /api/parent-link/revoke). |
| Regenerate link | Revokes current active token (if unused) and creates a new one. Use if teacher shared the link with the wrong person. |
Migration: Replacing Existing Code
The following existing code must be removed or replaced as part of implementing this contract:
| File / system | Current behaviour | Action |
|---|---|---|
parent-portal/server/src/router.tssignup procedure |
Public signup with name + email + password β creates parent account directly | Remove. Parent accounts are now created only via the token flow (Step 2). No public signup page. |
parent-portal/server/src/router.tslogin procedure |
Email + password login β JWT cookie | Keep for returning parents β but password is now optional (parents created via magic link have no password). Add a "Send me a login link" option that uses the same email_verifications table (with no link_token association). This is a separate flow from the parent-linking flow. |
parent-portal/server/src/router.tslinkChild mutation |
Parent manually enters 6-char code to link child | Remove. Replaced entirely by the token-based linking in Step 3. |
user-centerlearner_profiles.link_code |
6-char code stored per learner | Deprecate column. Stop generating new codes. Existing codes may remain in DB but the public endpoint /api/public/link-code/:code should be removed after migration. |
parent-portal/server/src/db/schema.tsparents table |
Separate parents table with passwordHash |
Migrate to users table (role='parent'). Move existing rows. Drop parents table after migration. |
parent-portal/server/src/db/schema.tsparentChildren table |
Stores learnerServiceId from 6-char code lookup |
Replace with parent_child_links table defined in this contract. Migrate existing links. |
Security Rules β Hardened v1.1
GAP 1: Email Enumeration β Fully Closed
"Always 200" is not sufficient if response time differs. The server must spend the same amount of time whether the email exists or not.
// /api/parent-link/start β constant-time enforcement
async function start(link_token, email) {
await validateToken(link_token); // always runs
const normalized = normalizeEmail(email);
const existing = await db.findParentByEmail(normalized); // always runs
// Whether account exists or not, always:
const user = existing ?? await db.createParentAccount(normalized);
await db.insertEmailVerification(user.id, link_token);
await sendVerificationEmail(normalized); // always sends (idempotent β see GAP 3)
await sleep(crypto_random_ms(20, 60)); // constant-time padding: 20β60ms random jitter
return 200 { message: "Check your email for a confirmation link." };
// Response body IDENTICAL in all cases. No branch in response.
}
GAP 2: Token Reuse Race Condition β DB Constraint + SELECT FOR UPDATE
Step 3 (verify) must use SELECT FOR UPDATE inside the transaction to prevent two simultaneous requests both reading status='active' before either updates it.
// Step 3 verify β exact transaction with row lock
BEGIN TRANSACTION;
-- Row-level lock: only one concurrent request can proceed
SELECT id, status, expires_at, child_id FROM parent_link_tokens
WHERE token_hash = ? FOR UPDATE;
-- Guard checks inside the lock
if (status != 'active') β ROLLBACK β 400 LINK_INVALID
if (expires_at < NOW()) β ROLLBACK β 400 LINK_EXPIRED
UPDATE parent_link_tokens SET status='used', used_at=NOW() WHERE id=?;
UPDATE email_verifications SET consumed_at=NOW() WHERE id=?;
UPDATE users SET email_verified_at=NOW() WHERE id=?;
-- Unique constraint on (parent_user_id, child_id) handles duplicate-link attempts
INSERT INTO parent_child_links (parent_user_id, child_id, access_level, status)
VALUES (?, ?, 'read_only', 'active')
ON DUPLICATE KEY UPDATE status='active';
INSERT INTO sessions ...;
COMMIT;
-- DB schema enforcement:
ALTER TABLE parent_link_tokens ADD UNIQUE KEY uq_token_hash (token_hash);
-- The UNIQUE on token_hash means even if two requests pass the application check,
-- only one UPDATE can succeed β the other will see status='used' on retry.
GAP 3: Idempotency β /start and /verify
/api/parent-link/start is idempotent: calling it twice with the same link_token + email before the first magic link expires sends a second email and overwrites the email_verifications row (upsert by user_id + link_token_hash). The parent gets a fresh link; the old link is invalidated.
-- /start idempotency: upsert, not insert
INSERT INTO email_verifications (user_id, token_hash, link_token_hash, expires_at)
VALUES (?, sha256(new_vt), ?, NOW() + INTERVAL 30 MINUTE)
ON DUPLICATE KEY UPDATE
token_hash = VALUES(token_hash),
expires_at = VALUES(expires_at),
consumed_at = NULL; -- reset if previously consumed (re-link after unlink scenario)
/api/parent-link/verify is idempotent: if called twice (e.g. browser refresh), the SELECT FOR UPDATE + consumed_at check catches it. Second call returns 400 LINK_ALREADY_USED with UI: "You're already connected. Log in to view your child's progress."
GAP 4: Exact Rate Limit Thresholds
| Endpoint | Key | Limit | Window | 429 response |
|---|---|---|---|---|
/api/parent-link/validate | IP | 20 req | 15 min sliding | { error: "RATE_LIMITED", retryAfter: 900 } |
/api/parent-link/start | IP | 5 req | 15 min sliding | same |
/api/parent-link/start | normalized email | 3 req | 15 min sliding | same |
/api/parent-link/start | link_token_hash | 5 req | 1 hour sliding | same β prevents token brute-force |
/api/parent-link/verify | IP | 10 req | 15 min sliding | same |
/api/parent-link/verify | vt_hash (per token) | 3 req | 5 min | same β prevents verification brute-force |
All limits use in-process sliding window counter (Redis optional β acceptable at current scale). Counters keyed by the values above, stored with TTL = window duration.
GAP 5: Session Invalidation on Revoke
When a teacher revokes a parent_child_links row, the parent's existing session is NOT immediately terminated β but every data request checks link status:
// Middleware on all /parent/* authenticated routes:
async function requireActiveParentLink(req, res, next) {
const session = verifySessionCookie(req);
if (!session) return res.redirect('/parent/login');
// Check at least one active link exists for this parent
const links = await db.query(
`SELECT id FROM parent_child_links
WHERE parent_user_id = ? AND status = 'active' LIMIT 1`,
[session.user_id]
);
if (links.length === 0) {
// All links revoked β destroy session, redirect
clearSessionCookie(res);
return res.redirect('/parent/link-revoked');
// /parent/link-revoked page: "Your access has been removed by the school.
// Contact your child's teacher if you think this is a mistake."
}
next();
}
Result: session invalidation is near-real-time (takes effect on the parent's next request, typically within seconds for an active browser session).
GAP 6: Parent Linked to Multiple Children
Explicitly allowed. One parent email can link to multiple children via separate parent_child_links rows β each from a separate teacher-issued token.
- Allowed: parent@example.com linked to Child A (Class 3B) AND Child B (Class 4A) β siblings in different classes, or cousins with the same guardian
- UI:
/parent/homeshows a child selector at the top when multiple children are linked. Data displayed is per-selected-child. - Unique constraint:
UNIQUE KEY uq_parent_child (parent_user_id, child_id)β same parent cannot be linked to the same child twice (idempotent viaON DUPLICATE KEY UPDATE status='active') - Max children per parent: no hard cap in v1. Audit log records every link.
- Revocation scope: revoking one link does not affect other links. If ALL links are revoked, session is terminated (see GAP 5).
GAP 7: Audit Log β Failed Attempts Included
The following events must be logged in addition to the success events already defined:
| action | When | Fields logged |
|---|---|---|
parent_link_token_invalid | Any validate/start/verify call with bad/expired/used token | token_hash (truncated 8 chars), reason, ip_address |
parent_verify_failed | Verification token not found, already consumed, or expired | vt_hash (truncated), reason, ip_address |
parent_link_rate_limited | 429 returned on any endpoint | endpoint, key_type (ip/email/token), ip_address |
parent_session_revoked_on_access | Parent's session cleared because all links revoked | parent_user_id, ip_address |
GAP 8: MFA / Secondary Check β School Context Decision
Magic link via email is the authentication factor. In a school context, this is the correct tradeoff:
- Parents are not verified identity holders β schools do not have parent ID documents
- The token is the primary trust anchor (teacher issued it; teacher knows the parent)
- The email is the secondary factor (proves parent controls that inbox)
- Two-factor combined: something you were given (token) + something you control (email inbox)
v1 decision: no additional MFA. Rationale: adding SMS MFA requires phone number collection (GDPR data minimisation violation β we have no legitimate basis to require it from parents). Adding an app-based TOTP requires parent to install software (disproportionate friction for a read-only view).
If a school requires stronger auth (enterprise tier), the parent_child_links.access_level field can be extended to require re-verification via fresh magic link after 24h. This is a future config flag, not v1.
GAP 9: GDPR / Consent Layer
Parent linking has two GDPR obligations:
- Parent's own data: By submitting their email and creating an account, the parent consents to Pickatale processing their email for the purpose of receiving reading updates. This consent is captured at onboarding (Step 4) via the notification preferences screen β the act of saving preferences constitutes consent to processing for that specific purpose.
- Accessing child data: The school's DPA (already signed in Teacher Portal setup) covers the school's right to share child progress data with parents. Pickatale is the processor; the school controls who sees the child's data. The teacher's act of issuing the token is the school's authorisation.
-- Add to parent_child_links table:
ALTER TABLE parent_child_links
ADD COLUMN consent_captured_at DATETIME NOT NULL DEFAULT NOW(),
ADD COLUMN consent_basis VARCHAR(100) NOT NULL DEFAULT 'school_dpa_teacher_token';
-- consent_basis values: 'school_dpa_teacher_token' (v1 only)
What to show on the onboarding screen (Step 4): Below the notification preferences form, add a single line in small text:
"By continuing, you agree to Pickatale's Privacy Policy. Your child's school has authorised this connection under their data agreement with Pickatale."
No additional checkbox required β consent is captured via the school DPA chain.
-- Add to parent_notification_preferences:
ALTER TABLE parent_notification_preferences
ADD COLUMN privacy_policy_version VARCHAR(20) NOT NULL DEFAULT 'v1.0',
ADD COLUMN consent_captured_at DATETIME NOT NULL DEFAULT NOW();
GAP 10: Teacher Visibility Controls β Exact Field List
"Basic" and "Full" are now defined exactly. Stored as parent_child_links.visibility_level ENUM('basic', 'full') DEFAULT 'basic'.
| Data field | Basic | Full |
|---|---|---|
| Last book read + date | β | β |
| Books read this week / month | β | β |
| Miles earned | β | β |
| Reading streak | β | β |
| Bot digest (talking points) | β | β |
| Recommended books | β | β |
| Reading level (age-appropriate language only: "Reading well for their age") | β | β |
| Reading level as FK number | β | β |
| Curriculum objective progress | β | β |
| Quiz scores | β | β |
| Vocabulary gaps list | β | β |
| Assessment history | β | β |
| Child username or PIN | β never | β never |
| Class name or teacher name | β never | β never |
| Other students in class | β never | β never |
v1 implements Basic only. Full is available in a future enterprise tier. The visibility_level column is created now so no migration is needed when Full ships.
-- Add to parent_child_links:
ALTER TABLE parent_child_links
ADD COLUMN visibility_level ENUM('basic', 'full') NOT NULL DEFAULT 'basic';
All API responses serving parent data must check visibility_level before including Full-tier fields. Any Full-tier field requested with visibility_level='basic' returns null silently (not 403 β avoids leaking that the field exists).
Security Hardening, Legal Basis & Incident Rules
Threat Model & Residual Risk Acceptance
The threat model for parent linking is scoped to the realistic adversarial context: schools, not nation-state attackers.
| Threat | Attack vector | Mitigation | Residual risk | Accepted? |
|---|---|---|---|---|
| Wrong parent gets access to a child's data | Teacher shares token link with wrong person by mistake | Token revocation + teacher connected-parents list | Teacher may not notice quickly | β Yes β same risk as a paper letter to wrong address. Teacher is accountable. |
| Attacker intercepts token URL | Link shared via insecure channel (SMS, unencrypted email) | Token expires 72h; one-time use; no child data before email verify | Attacker who intercepts URL AND controls victim email inbox could link | β Yes β attacker controlling both URL and email inbox = full account compromise anyway. Not a Pickatale-specific risk. |
| Parent email account compromised | Attacker logs into parent Gmail β follows magic link | Rate limiting; audit log; teacher can revoke | Attacker gains read-only view of one child's reading stats | β Yes β data is low-sensitivity (books read, miles). Not financial, medical, or locational. Risk is low even in compromise scenario. |
| Parent submits another child's token | Parent A shares token meant for Parent B | Token is child-specific; link writes to the specific child_id in the token β not whatever the parent claims | None β child_id is resolved server-side from token, not from parent input | β Fully mitigated by design. |
| Mass scraping of child progress via parent accounts | Attacker creates many parent accounts, links to many children | Each link requires a unique teacher-issued token; teachers would need to be compromised or colluding | Insider threat from compromised teacher accounts | β Yes β teacher account security is a separate control (2FA on teacher accounts, separate from parent flow). |
| Token brute-force | Attacker submits random tokens at /start to find valid ones | Token format: plt_ prefix + 32 random bytes = 2^256 space; rate limit 5/hr per IP on /start |
Negligible β token space is astronomically large | β Fully mitigated. |
Explicit residual risk acceptance (v1): The school-context threat model does not require MFA, phone verification, or government ID. The combination of teacher-issued token + email inbox ownership is sufficient for the data sensitivity level (read-only child reading activity). This decision is accepted by Pickatale and documented here.
Stronger Parent Auth β When and How
v1 uses magic link (token + email). The following upgrade path is defined for future tiers:
| Tier | Auth method | Trigger |
|---|---|---|
| v1 (current) | Magic link email (30 min expiry) | All schools |
| v1.5 (optional per school) | Magic link + school-configurable session duration (default 30d, min 1d) | School admin sets parent_session_max_days |
| v2 enterprise | Magic link + school SSO (Google Workspace, Microsoft Entra) | School has SSO configured |
| v2 enterprise | Magic link + re-verify after 24h inactivity | School enables parent_strict_session flag |
No TOTP, no SMS in roadmap β TOTP requires app install (disproportionate); SMS requires phone collection (GDPR basis problem). SSO is the correct enterprise upgrade path.
-- School-level config (future, create now to avoid migration):
ALTER TABLE schools ADD COLUMN parent_session_max_days INT NOT NULL DEFAULT 30;
ALTER TABLE schools ADD COLUMN parent_strict_session TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE schools ADD COLUMN parent_sso_provider VARCHAR(50) DEFAULT NULL;
-- parent_sso_provider: NULL (magic link) | 'google' | 'microsoft'
Explicit Legal Basis by Market
Pickatale operates in multiple jurisdictions. The legal basis for processing parent data differs by market:
| Market | Regulation | Legal basis for parent linking | Legal basis for child data shown to parent | Action required |
|---|---|---|---|---|
| UK / EU | UK GDPR / EU GDPR | Legitimate interest (parent receiving updates on child's education) OR contract (parent is party to school's service agreement with Pickatale) | School DPA β school controls; Pickatale is processor; parent access authorised by school as controller | Confirm with DPO. Likely legitimate interest. Record in Article 30 register. |
| Norway | GDPR + Datatilsynet guidance | Same as EU. Datatilsynet has strict guidance on children's data β school DPA must explicitly name parent-portal access. | School DPA must be updated to include parent-portal clause. Pickatale provides standard DPA addendum. | Update standard DPA template to include parent-portal clause. Required before Norway launch. |
| UAE / KSA | UAE PDPL / KSA PDPL | Consent (explicit). Parent must tick a consent checkbox β legitimate interest basis is narrower in these markets. | School data processing agreement. PDPL requires data localisation β check if UAE/KSA school data can be processed on EU/Ukraine servers. | Add consent checkbox to onboarding screen for UAE/KSA market (feature-flagged by school.country_code). Data localisation TBD β flag for legal review before these markets go live. |
| Turkey | KVKK | Explicit consent required. Parent must provide written (or equivalent digital) consent. | Data localisation required β Turkey data must stay in Turkey. Not currently compliant. Block Turkey parent portal until localisation resolved. | Add Turkish consent flow. Block parent portal for Turkish schools until data localisation is resolved. Flag for legal. |
| US Schools | FERPA + state laws | FERPA grants parents the right to access education records β Pickatale's parent portal is aligned with FERPA. No separate consent needed if school has authorised. | School is the FERPA-covered entity. School authorising parent access via token is FERPA-compliant. | Confirm school contract includes FERPA data sharing clause. US launch requires this. |
-- Market-specific consent flag (implement now):
ALTER TABLE parent_child_links
ADD COLUMN explicit_consent_required TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN explicit_consent_given_at DATETIME DEFAULT NULL;
-- Set explicit_consent_required=1 for UAE, KSA, Turkey based on school.country_code
-- Onboarding screen shows consent checkbox if explicit_consent_required=1
-- Block /parent/home until explicit_consent_given_at IS NOT NULL when required
Abuse & Dispute Workflow
Three scenarios with exact handling steps:
Scenario A β Parent reports they did not link / did not request a link
- Parent contacts school (not Pickatale directly in v1 β school is the controller)
- Teacher revokes the link immediately via Teacher Portal β
POST /api/parent-link/revoke - Session is terminated on next request (via route guard middleware)
- Platform admin is notified if 3+ revocations occur from the same school within 24h β
audit_alert: suspicious_link_activity - If school suspects data breach: escalate to Pickatale DPO β 72h GDPR breach notification window starts
Scenario B β Parent disputes data shown (claims it belongs to wrong child)
- Parent contacts school
- Teacher checks connected-parents list in Teacher Portal β confirms which child the parent is linked to
- If wrong link: teacher revokes + re-issues correct token
- Audit log provides full history: which teacher issued the token, when, for which child
- Pickatale support can query:
SELECT * FROM audit_log WHERE action IN ('parent_link_token_created','parent_link_completed') AND (actor_id=? OR target_id=?)
Scenario C β Parent is abusing access (e.g. harassing teacher based on child data)
- Teacher revokes link immediately
- Teacher can set a school-level block:
INSERT INTO parent_access_blocks (school_id, parent_email_hash, blocked_by, reason)β blocked email cannot be linked to any child in that school - Block is enforced at
/api/parent-link/start: checkparent_access_blockstable before creating account
CREATE TABLE parent_access_blocks (
id INT PRIMARY KEY AUTO_INCREMENT,
school_id INT NOT NULL,
email_hash VARCHAR(64) NOT NULL, -- sha256(normalized_email)
blocked_by VARCHAR(36) NOT NULL, -- teacher/admin UUID
reason TEXT,
created_at DATETIME NOT NULL DEFAULT NOW(),
UNIQUE KEY uq_school_email (school_id, email_hash)
);
Alerting & Incident Rules
The following conditions must trigger an automated alert to Pickatale platform admins (write to audit_alerts table + send internal notification):
| Condition | Threshold | Alert level | Action |
|---|---|---|---|
Rate limit hits on /api/parent-link/start |
>20 hits from same IP in 1 hour | β οΈ Warning | Log to audit_alerts. No automated block (IP may be school NAT). Human review within 4h. |
Rate limit hits on /api/parent-link/verify |
>10 hits from same IP in 15 min | π΄ High | Auto-block IP for 1 hour. Log. Alert admin immediately. |
| Multiple failed token attempts for same child | >5 parent_link_token_invalid events for same child_id in 1 hour |
π΄ High | Notify teacher of anomalous activity on their student's token. Log. Do not block β teacher may have reshared. |
| Suspicious link revocations | >3 parent_link_revoked from same school in 24h |
β οΈ Warning | Log. Flag school for support review. |
| Token consumed twice (race condition escaped app layer) | Any parent_link_token.status='used' targeted by a second verify |
π΄ High | Log as audit_alert: token_double_consume_attempt. Investigate β indicates possible exploit attempt or bug. |
| Parent linked to >5 children | COUNT(parent_child_links) > 5 for one parent_user_id |
β οΈ Warning | Log. Flag for review β likely a school admin or data entry error, not a real parent. |
CREATE TABLE audit_alerts (
id INT PRIMARY KEY AUTO_INCREMENT,
condition VARCHAR(100) NOT NULL,
level ENUM('warning','high','critical') NOT NULL,
details_json JSON,
created_at DATETIME NOT NULL DEFAULT NOW(),
resolved_at DATETIME,
resolved_by VARCHAR(36)
);
Implementation Checklist for Claude Code
Complete in this order:
- Create
parent_link_tokenstable (DDL above) in user_center DB - Create
email_verificationstable in user_center DB - Create
parent_child_linkstable in user_center DB - Add
email_verified_atcolumn touserstable; confirmroleenum includesparent - Implement
POST /api/internal/students/:child_uuid/parent-link-tokenin User Center - Implement
GET /api/parent-link/validatein Parent Portal server - Implement
POST /api/parent-link/startin Parent Portal server (with rate limiting + email send) - Implement
GET /api/parent-link/verifyin Parent Portal server (atomic transaction) - Implement
POST /api/parent-link/revokein Parent Portal server - Build Parent Portal frontend:
/parent/linkpage,/parent/verifypage (error states),/parent/onboardingpage,/parent/homepage,/parent/settingspage (preference editing) - Implement route guard: unauthenticated β
/parent/login; authenticated + no preferences row β/parent/onboarding; authenticated + preferences exist β allow through - Implement
GET /api/parent/preferencesandPOST /api/parent/preferences - Create
parent_notification_preferencestable (DDL above) in user_center DB - Build Teacher Portal UI: Parent Access toggle, QR display, connected parents list, Unlink button, Regenerate link button
- Remove old
signupprocedure,linkChildmutation, and 6-char code public endpoint - Migrate existing
parentsrows βuserstable; migrateparentChildrenrows βparent_child_links - Send email_log rows on every
PARENT_LINK_VERIFYemail attempt - Create
parent_access_blockstable; enforce block check in/api/parent-link/start - Create
audit_alertstable; implement the 6 alerting conditions with exact thresholds - Add
consent_captured_at+privacy_policy_versiontoparent_notification_preferences - Add
explicit_consent_required+explicit_consent_given_attoparent_child_links; enforce byschool.country_code - Add
parent_session_max_days,parent_strict_session,parent_sso_providertoschoolstable (create now, use later) - Add
visibility_leveltoparent_child_links; enforce field filtering in all parent data endpoints - Verify end-to-end: teacher generates token β parent opens link β submits email β clicks magic link β sees onboarding screen β saves preferences β lands on
/parent/home. Re-login goes directly to home (no onboarding repeat). Revoke link β confirm parent session terminates on next request.
Parent Linking Contract v1.3 β 2026-04-19 Β· Authoritative Β· All decisions final Β· Ref: Wiki v1.9