All files / src/routes pm-lock-check.js

95.52% Statements 64/67
97.82% Branches 45/46
100% Functions 4/4
95.31% Lines 61/64

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194                                                            1x 1x   1x 1x 1x 1x       37x 37x       20x 18x 18x 18x 18x           18x     1x 22x 22x 1x     21x 21x 21x 21x 21x   21x 21x 21x 21x 1x 1x   20x 20x 20x 20x 20x       20x 20x 20x           20x 3x             3x               17x           3x           3x                               14x 14x 14x 14x   14x 14x   14x               21x 1x                       20x 10x 10x 10x 9x   1x 1x     20x                               1x  
/**
 * POST /api/users/:uniqueId/pm-lock-check
 *
 * First-of-day auto-unlock check (PR 11). Called by the client right
 * after successful sign-in. Reads the user doc, decides whether the
 * pmLocked state should change, and writes if yes — all server-side
 * because Firestore rules deny client writes to `pmLocked` /
 * `lastPmLockCheck` / `cohort`.
 *
 * Throttling: when `lastPmLockCheck` falls in the same UTC day as
 * `now()`, we skip the Firestore write entirely. Active users only
 * pay the cost once per day; dormant accounts pay nothing.
 *
 * Authorisation: the path uniqueId MUST match the caller's
 * `req.auth.uniqueId`. Defends against a malicious client trying to
 * trigger an unlock check on another user's behalf (would be moot
 * since rules deny direct writes anyway, but gate at the route layer
 * for defence in depth).
 *
 * UK OSA #17 segregation extension (PR 1): the same `>=18y` predicate
 * that drives `pmLocked` also drives the `cohort` field ("minor" |
 * "adult"). Re-using the `lastPmLockCheck` stamp lets both fields
 * recompute on the same daily cadence with no second throttle. The
 * custom-claim mint that completes the cohort transition is deferred
 * to PR 2 — until it lands, the response carries `cohortChanged` and
 * `cohort` but NOT `forceTokenRefresh: true` (refreshing a stale
 * claim wastes Firebase mint quota for no behavioral change). Spec:
 * `.project/plans/2026-05-13-age-segregation-design.md`.
 */
 
const express = require('express');
const router = express.Router();
 
const { db } = require('../utils/firebase');
const { mintClaimsMerging, effectiveCohort } = require('../utils/firebase-claims');
const { now } = require('../utils/helpers');
const log = require('../utils/log');
 
/** UTC midnight (start-of-day) for the timestamp `ms`. */
function utcDayStart(ms) {
  const d = new Date(ms);
  return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
 
function isAtLeast18FromDob(dobMs, nowMs) {
  if (typeof dobMs !== 'number' || !Number.isFinite(dobMs)) return false;
  const today = new Date(nowMs);
  const dob = new Date(dobMs);
  let age = today.getUTCFullYear() - dob.getUTCFullYear();
  Iif (
    today.getUTCMonth() < dob.getUTCMonth() ||
    (today.getUTCMonth() === dob.getUTCMonth() && today.getUTCDate() < dob.getUTCDate())
  ) {
    age -= 1;
  }
  return age >= 18;
}
 
router.post('/users/:uniqueId/pm-lock-check', async (req, res) => {
  const pathUniqueId = parseInt(req.params.uniqueId, 10);
  if (!Number.isFinite(pathUniqueId) || pathUniqueId !== req.auth?.uniqueId) {
    return res.status(403).json({ error: 'You can only check your own pm-lock state' });
  }
 
  try {
    const nowMs = now();
    const todayStart = utcDayStart(nowMs);
    let result = { pmLocked: false, unlocked: false, cohort: 'minor', cohortChanged: false };
    let userDataForClaim = null;
 
    await db.runTransaction(async (tx) => {
      const userRef = db.doc(`users/${pathUniqueId}`);
      const snap = await tx.get(userRef);
      if (!snap.exists) {
        result = { __notFound: true };
        return;
      }
      const data = snap.data();
      const currentlyLocked = data.pmLocked === true;
      const currentCohort = typeof data.cohort === 'string' ? data.cohort : 'minor';
      const last = typeof data.lastPmLockCheck === 'number' ? data.lastPmLockCheck : null;
      const lastDay = last !== null ? utcDayStart(last) : null;
 
      // Derive desired state from DOB. Null DOB → minor + locked
      // (most-restrictive default per spec § Edge cases).
      const eligible = isAtLeast18FromDob(data.dateOfBirth, nowMs);
      const desiredPmLocked = !eligible;
      const desiredCohort = eligible ? 'adult' : 'minor';
 
      // Already checked today: idempotent skip. The cohort + pmLocked
      // surfaced in the response are the CURRENT stored values, not
      // the derived ones — between the morning and evening of the
      // same UTC day, the field is whatever yesterday's check wrote.
      if (lastDay === todayStart) {
        result = {
          pmLocked: currentlyLocked,
          unlocked: false,
          alreadyCheckedToday: true,
          cohort: currentCohort,
          cohortChanged: false,
        };
        return;
      }
 
      // Hot-path no-op: adult cohort AND unlocked AND derived state
      // matches stored state. Skip even the throttle bump — dormant
      // adult accounts must pay zero Firestore quota. Sub-18 users
      // and mismatched-state users fall through to the write branch
      // (even when pmLocked is false) so cohort gets backfilled.
      if (
        !currentlyLocked &&
        currentCohort === 'adult' &&
        desiredCohort === 'adult' &&
        !desiredPmLocked
      ) {
        result = {
          pmLocked: false,
          unlocked: false,
          cohort: 'adult',
          cohortChanged: false,
        };
        return;
      }
 
      // Write branch — minimal payload. Always bumps lastPmLockCheck
      // so the next call today is the same-day-throttle no-op.
      // Each field is only written if it would change — saves
      // Firestore quota on the common "minor stays minor" path.
      //
      // Field-vs-claim divergence (intentional): the `cohort` field
      // written here is always the DOB-derived value. The JWT claim
      // minted below uses `effectiveCohort` which respects
      // `cohortOverride`. So for a moderator with override='adult'
      // but DOB=16, the field stays 'minor' (audit trail / source
      // of truth for the underlying age) while the claim is 'adult'
      // (operational enforcement). Do NOT "fix" this by writing
      // override into the field — it would erase the audit trail.
      const update = { lastPmLockCheck: nowMs };
      if (currentlyLocked !== desiredPmLocked) update.pmLocked = desiredPmLocked;
      if (currentCohort !== desiredCohort) update.cohort = desiredCohort;
      tx.update(userRef, update);
 
      const cohortChanged = currentCohort !== desiredCohort;
      userDataForClaim = cohortChanged ? { ...data, cohort: desiredCohort } : null;
 
      result = {
        pmLocked: desiredPmLocked,
        unlocked: currentlyLocked && !desiredPmLocked,
        cohort: desiredCohort,
        cohortChanged,
      };
    });
 
    if (result.__notFound) {
      return res.status(404).json({ error: 'User not found' });
    }
 
    // UK OSA #17 PR 2: claim mint after Firestore commits. Field
    // write is the source of truth; mint failure leaves the JWT
    // stale (rules-layer lag up to the ~1h Firebase auto-refresh)
    // but Express + KMP read the fresh field. Per the partial-
    // failure contract, surface failures via `claimMintFailed`
    // instead of rolling back the cohort write. The flag is the
    // structured telemetry signal — the admin UI / dev dashboards
    // alert on it the same way `auditWritten=false` is alerted on
    // in admin-age-verification.
    if (result.cohortChanged && userDataForClaim && typeof req.auth?.uid === 'string') {
      const claimCohort = effectiveCohort(userDataForClaim);
      try {
        await mintClaimsMerging(req.auth.uid, { cohort: claimCohort });
        result.forceTokenRefresh = true;
      } catch (_mintErr) {
        result.forceTokenRefresh = false;
        result.claimMintFailed = true;
      }
    }
    return res.json(result);
  } catch (err) {
    // Include err.code + err.stack so Sentry/dashboards can triage by
    // failure class — Firestore SDK errors carry codes like ABORTED
    // (transaction-retry exhaustion), FAILED_PRECONDITION,
    // UNAUTHENTICATED. Without them every failure looks identical.
    log.error('pm-lock-check', 'failed', {
      uid: req.auth?.uniqueId,
      error: err?.message,
      code: err?.code,
      stack: err?.stack,
    });
    return res.status(500).json({ error: 'pm-lock-check failed', errorId: 'PM_LOCK_CHECK' });
  }
});
 
module.exports = router;