All files / src/safety safety-gate.js

100% Statements 23/23
100% Branches 20/20
100% Functions 5/5
100% Lines 19/19

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                              44x     44x     44x 146x           44x               453x 453x 906x   453x         492x                     492x 492x   492x     20x         472x       142x 12x   130x     44x  
'use strict';
 
/**
 * SHY-0060 — JS SERVER PORT of the Kotlin SafetyGate
 * (shared/src/commonMain/kotlin/com/shyden/shytalk/core/safety/SafetyGate.kt).
 *
 * Pure logic over a resolved verified age + region, mirroring the Kotlin engine
 * exactly, so the server enforces the identical verdict the clients compute.
 * Thresholds come from the parity-pinned age-thresholds mirror.
 *
 * Enforcement is gated by a default-OFF operator feature flag at the call sites
 * (a later increment); this engine computes the verdict, it does not decide
 * whether gating is switched on.
 */
 
const { BASE, COPPA_FLOOR, REGION_OVERRIDES, thresholdFor } = require('./age-thresholds');
 
/** What a block requires before the user can retry (mirror of the Kotlin enum). */
const VERIFICATION = Object.freeze({ NONE: 'NONE', REVERIFY: 'REVERIFY' });
 
/** GateResult constructors (mirror of the Kotlin sealed GateResult). */
const ALLOWED = Object.freeze({ type: 'Allowed' });
const blockedUnderAge = (threshold, actualAge, requiredVerification) => ({
  type: 'BlockedUnderAge',
  threshold,
  actualAge,
  requiredVerification,
});
const blockedRegion = (threshold, reason) => ({ type: 'BlockedRegion', threshold, reason });
 
/**
 * The strictest threshold across the base value + every region override for
 * `feature` — applied when the region can't be detected, so an undetected user
 * never lands on a more permissive threshold than any region.
 */
function conservativeThreshold(feature) {
  let max = BASE[feature];
  for (const map of Object.values(REGION_OVERRIDES)) {
    if (map[feature] !== undefined && map[feature] > max) max = map[feature];
  }
  return max;
}
 
/** Region-aware threshold; a null/undefined (undetected) region uses the conservative max. */
function effectiveThreshold(feature, countryCode) {
  return (countryCode ?? null) === null
    ? conservativeThreshold(feature)
    : thresholdFor(feature, countryCode);
}
 
/**
 * Decide whether `feature` is permitted for a user of `verifiedAgeYears`
 * (null/undefined when the age is unverified) in `countryCode` (ISO alpha-2;
 * null/undefined when the region can't be detected → conservative threshold).
 */
function canAccess(feature, verifiedAgeYears, countryCode) {
  const base = BASE[feature];
  const effective = effectiveThreshold(feature, countryCode);
 
  if ((verifiedAgeYears ?? null) === null) {
    // Legacy/unverified: COPPA-floor features stay open (they cleared the
    // signup-13 gate); anything stricter needs re-verification.
    return effective <= COPPA_FLOOR
      ? ALLOWED
      : blockedUnderAge(effective, null, VERIFICATION.REVERIFY);
  }
 
  if (verifiedAgeYears >= effective) return ALLOWED;
 
  // Blocked. If the user would clear the BASE bar and only a regional rule lifts
  // it above them, that's a region block; otherwise plain under-age.
  if (effective > base && verifiedAgeYears >= base) {
    return blockedRegion(effective, `This region requires age ${effective} for ${feature}`);
  }
  return blockedUnderAge(effective, verifiedAgeYears, VERIFICATION.NONE);
}
 
module.exports = { canAccess, conservativeThreshold, ALLOWED, VERIFICATION };