All files / src/safety safety-audit.js

88.88% Statements 8/9
100% Branches 6/6
66.66% Functions 2/3
88.88% Lines 8/9

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                                40x 40x 40x   40x       236x                                         124x               124x                     40x  
'use strict';
 
/**
 * SHY-0060 — audit log of BLOCKED age-gate attempts (AC53).
 *
 * Every block writes one row to the `safetyAudit` collection for compliance
 * reporting + abuse detection (repeated failed attempts on age-gated features).
 * The write is FIRE-AND-FORGET (AC79): the caller does not await it and it can
 * never fail or delay the gate response — a Firestore hiccup is swallowed with
 * a logged error, not propagated.
 *
 * PII: the userId is SHA-256 hashed (AC53/AC85 — never plaintext in audit
 * logs). Only the numeric age + ISO region + threshold are stored alongside;
 * the DOB is never written here.
 */
 
const crypto = require('node:crypto');
const log = require('../utils/log');
const { now } = require('../utils/helpers');
 
const SAFETY_AUDIT_COLLECTION = 'safetyAudit';
 
/** Stable SHA-256 of a userId — the only identifier persisted to the audit. */
function hashUserId(userId) {
  return crypto
    .createHash('sha256')
    .update(String(userId ?? ''))
    .digest('hex');
}
 
/**
 * Fire-and-forget: record one blocked attempt. Returns the write Promise (so a
 * test can await it) but callers on the request path must NOT await — the
 * `.catch` keeps a failed write from becoming an unhandled rejection.
 *
 * @param {FirebaseFirestore.Firestore} db
 * @param {object} p
 * @param {number|string} p.userId  hashed before storage
 * @param {string} p.feature
 * @param {number} p.threshold      the age the feature required
 * @param {number|null} p.userAge   the user's verified age, or null (unverified)
 * @param {string|null} p.region    ISO alpha-2, or null (undetected)
 * @returns {Promise<unknown>}
 */
function logBlockedFeatureAttempt(db, { userId, feature, threshold, userAge, region }) {
  const entry = {
    userIdHash: hashUserId(userId),
    feature,
    threshold,
    userAge: userAge ?? null,
    region: region ?? null,
    timestamp: now(),
  };
  return db
    .collection(SAFETY_AUDIT_COLLECTION)
    .add(entry)
    .catch((err) => {
      log.error('safety-audit', 'Failed to write blocked-attempt audit', {
        feature,
        error: err?.message,
      });
    });
}
 
module.exports = { logBlockedFeatureAttempt, hashUserId, SAFETY_AUDIT_COLLECTION };