πŸ‘¨β€πŸ‘©β€πŸ‘§ Parent Account Creation & Linking Contract

Page 14 Β· v1.9 Β· Implementation contract for Claude Code Β· Authoritative
This is the canonical implementation contract. All decisions here are final. Claude Code must implement exactly this flow β€” no deviations, no shortcuts. The existing 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:

NEVER: Show any child identity before email verification. Create a child from the parent flow. Allow parent to reset child PIN. Allow parent to transfer learner ownership. Use the old 6-char code system for new parent linking.

Flow Overview

StepWho actsWhat happens
0 β€” Token issuedTeacher (in portal)Teacher enables parent access for a student; system generates parent_link_token
1 β€” Parent opens linkParentOpens QR/URL, token validated, email form shown β€” no child info revealed yet
2 β€” Parent submits emailParentAccount created or reused; magic-link verification email sent
3 β€” Parent clicks email linkParentEmail verified, session created, child link written, token consumed β†’ redirect to /parent/onboarding
4 β€” Onboarding preferencesParentShown once after linking: choose notification preferences β†’ Save & Continue
5 β€” Parent homeParentRead-only child progress view

Step 0 β€” Teacher Issues a Token

0
Teacher Portal β†’ Generate parent link
Teacher Portal calls User Center internal endpoint

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).

POST /api/internal/students/:child_uuid/parent-link-token User Center Β· Internal key required

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 }
Token storage rule: Store only 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

1
GET /parent/link?token={link_token}
Served by Parent Portal frontend Β· Backend validates token only
GET /api/parent-link/validate?token={link_token} Parent Portal server Β· Public

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

2
POST /api/parent-link/start
Create or reuse parent account Β· Send magic link email
POST /api/parent-link/start Parent Portal server Β· Public Β· Rate limited: 5 req/15 min per IP + per 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 }
Always return 200 for valid email + valid token β€” even if the account already existed, to prevent email enumeration. The only 4xx responses are for bad token or bad email format.

Step 3 β€” Parent Clicks Email Link

3
GET /parent/verify?vt={verification_token}
Verify email Β· Create session Β· Write parent_child_links Β· Consume tokens
GET /api/parent-link/verify?vt={verification_token} Parent Portal server Β· Public Β· Rate limited: 10 attempts/15 min per IP

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."
Transaction guarantee: Steps 4–8 are a single DB transaction. If parent_child_links INSERT fails, the token is NOT consumed and the parent is NOT marked verified β€” they can retry.

Step 4 β€” Onboarding Preferences

4
GET /parent/onboarding
Shown once only, immediately after first linking Β· Not skippable
Shown once and only once β€” immediately after email verification completes. After the parent saves preferences, this screen never appears again. Subsequent logins go directly to /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?"

OptionDefaultDescription
Weekly summaryONA digest each week with reading activity, books finished, and miles earned.
Important alertsONSent when your child reaches a milestone or the teacher flags something.
RecommendationsOFFOccasional book and reading tips tailored to your child.

Single button: Save & Continue

Do NOT ask for preferences before linking is complete. Do NOT ask inside the verification email. Do NOT make this screen skippable β€” if the parent closes the tab, they see it again on next login until they submit.
POST /api/parent/preferences Parent Portal server Β· Authenticated

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
GET /api/parent/preferences Parent Portal server Β· Authenticated

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

5
GET /parent/home
Read-only child progress Β· No child credentials visible

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

What the parent does NOT see

Access permissions β€” enforced server-side

ActionParent 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).

actionWhenactor_idtarget_id
parent_link_token_createdTeacher generates tokenteacher UUIDchild UUID
parent_link_startedParent submits emailparent user IDlink token hash (truncated)
parent_verify_email_sentMagic link email sentparent user IDβ€”
parent_link_completedEmail verified, link writtenparent user IDchild UUID
parent_link_revokedTeacher or admin revokesrevoker UUIDchild UUID + parent user ID
parent_link_token_revokedTeacher revokes unused tokenteacher UUIDlink token hash (truncated)
parent_preferences_setOnboarding form submitted or preferences updatedparent user IDβ€”

Full API Contracts

GET /api/parent-link/validate?token={link_token}

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.

POST /api/parent-link/start

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 }
GET /api/parent-link/verify?vt={verification_token}

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.

POST /api/parent-link/revoke

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_log INSERT 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
POST /api/internal/students/:child_uuid/parent-link-token User Center Β· X-Internal-Key required

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:

ControlWhat 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.
Teacher must NOT: manually create parent accounts Β· type parent email as a required step in normal flow Β· approve one-by-one parent claims in the standard flow Β· see parent passwords (there are none) Β· see parent session tokens.

Migration: Replacing Existing Code

The following existing code must be removed or replaced as part of implementing this contract:

File / systemCurrent behaviourAction
parent-portal/server/src/router.ts
signup 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.ts
login 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.ts
linkChild mutation
Parent manually enters 6-char code to link child Remove. Replaced entirely by the token-based linking in Step 3.
user-center
learner_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.ts
parents 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.ts
parentChildren 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.
}
Do not add any branching in the response body, headers, or timing based on whether the email account existed. The 20–60ms jitter neutralises timing attacks from network observers.

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

EndpointKeyLimitWindow429 response
/api/parent-link/validateIP20 req15 min sliding{ error: "RATE_LIMITED", retryAfter: 900 }
/api/parent-link/startIP5 req15 min slidingsame
/api/parent-link/startnormalized email3 req15 min slidingsame
/api/parent-link/startlink_token_hash5 req1 hour slidingsame β€” prevents token brute-force
/api/parent-link/verifyIP10 req15 min slidingsame
/api/parent-link/verifyvt_hash (per token)3 req5 minsame β€” 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.

GAP 7: Audit Log β€” Failed Attempts Included

The following events must be logged in addition to the success events already defined:

actionWhenFields logged
parent_link_token_invalidAny validate/start/verify call with bad/expired/used tokentoken_hash (truncated 8 chars), reason, ip_address
parent_verify_failedVerification token not found, already consumed, or expiredvt_hash (truncated), reason, ip_address
parent_link_rate_limited429 returned on any endpointendpoint, key_type (ip/email/token), ip_address
parent_session_revoked_on_accessParent's session cleared because all links revokedparent_user_id, ip_address
Never log raw tokens, raw emails beyond first 3 chars, or raw verification tokens. Log only: truncated hashes, ip_address, timestamp, reason enum.

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:

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:

  1. 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.
  2. 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 fieldBasicFull
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.

ThreatAttack vectorMitigationResidual riskAccepted?
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:

TierAuth methodTrigger
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 enterpriseMagic link + school SSO (Google Workspace, Microsoft Entra)School has SSO configured
v2 enterpriseMagic link + re-verify after 24h inactivitySchool 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'

Pickatale operates in multiple jurisdictions. The legal basis for processing parent data differs by market:

MarketRegulationLegal basis for parent linkingLegal basis for child data shown to parentAction 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

  1. Parent contacts school (not Pickatale directly in v1 β€” school is the controller)
  2. Teacher revokes the link immediately via Teacher Portal β†’ POST /api/parent-link/revoke
  3. Session is terminated on next request (via route guard middleware)
  4. Platform admin is notified if 3+ revocations occur from the same school within 24h β†’ audit_alert: suspicious_link_activity
  5. 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)

  1. Parent contacts school
  2. Teacher checks connected-parents list in Teacher Portal β€” confirms which child the parent is linked to
  3. If wrong link: teacher revokes + re-issues correct token
  4. Audit log provides full history: which teacher issued the token, when, for which child
  5. 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)

  1. Teacher revokes link immediately
  2. 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
  3. Block is enforced at /api/parent-link/start: check parent_access_blocks table 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):

ConditionThresholdAlert levelAction
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:

  1. Create parent_link_tokens table (DDL above) in user_center DB
  2. Create email_verifications table in user_center DB
  3. Create parent_child_links table in user_center DB
  4. Add email_verified_at column to users table; confirm role enum includes parent
  5. Implement POST /api/internal/students/:child_uuid/parent-link-token in User Center
  6. Implement GET /api/parent-link/validate in Parent Portal server
  7. Implement POST /api/parent-link/start in Parent Portal server (with rate limiting + email send)
  8. Implement GET /api/parent-link/verify in Parent Portal server (atomic transaction)
  9. Implement POST /api/parent-link/revoke in Parent Portal server
  10. Build Parent Portal frontend: /parent/link page, /parent/verify page (error states), /parent/onboarding page, /parent/home page, /parent/settings page (preference editing)
  11. Implement route guard: unauthenticated β†’ /parent/login; authenticated + no preferences row β†’ /parent/onboarding; authenticated + preferences exist β†’ allow through
  12. Implement GET /api/parent/preferences and POST /api/parent/preferences
  13. Create parent_notification_preferences table (DDL above) in user_center DB
  14. Build Teacher Portal UI: Parent Access toggle, QR display, connected parents list, Unlink button, Regenerate link button
  15. Remove old signup procedure, linkChild mutation, and 6-char code public endpoint
  16. Migrate existing parents rows β†’ users table; migrate parentChildren rows β†’ parent_child_links
  17. Send email_log rows on every PARENT_LINK_VERIFY email attempt
  18. Create parent_access_blocks table; enforce block check in /api/parent-link/start
  19. Create audit_alerts table; implement the 6 alerting conditions with exact thresholds
  20. Add consent_captured_at + privacy_policy_version to parent_notification_preferences
  21. Add explicit_consent_required + explicit_consent_given_at to parent_child_links; enforce by school.country_code
  22. Add parent_session_max_days, parent_strict_session, parent_sso_provider to schools table (create now, use later)
  23. Add visibility_level to parent_child_links; enforce field filtering in all parent data endpoints
  24. 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

On this page

Canonical Principle Flow Overview Step 0 β€” Teacher Issues Token Step 1 β€” Parent Opens Link Step 2 β€” Parent Submits Email Step 3 β€” Email Verification Step 4 β€” Onboarding Preferences Step 5 β€” Parent Home Data Model Audit Log Events Full API Contracts Teacher Controls Migration Security Rules GAP 1: Email Enumeration GAP 2: Race Condition GAP 3: Idempotency GAP 4: Rate Limits GAP 5: Session Invalidation GAP 6: Multi-Child Linking GAP 7: Audit Failures GAP 8: MFA Decision GAP 9: GDPR / Consent GAP 10: Visibility Fields Security Hardening & Legal Threat Model Stronger Auth Roadmap Legal Basis by Market Abuse & Dispute Workflow Alerting & Incidents Implementation Checklist