All files / src/utils banned-claim.js

100% Statements 39/39
96.87% Branches 31/32
100% Functions 2/2
100% Lines 38/38

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                                                                        322x 322x 322x 322x             322x 322x 322x       330x                                             588x 588x 588x 1x   587x 544x 544x     540x 540x 2x       583x   558x 558x 521x 521x   558x     502x     502x     56x 55x 55x 6x       49x 49x     42x     49x         49x   26x       26x       322x  
/**
 * `banned` custom-claim sync — the SHY-0150 chokepoint (EPIC-0005).
 *
 * The Firestore rules layer denies user writes when the caller's token
 * carries `banned: true` (see the `isBanned()` helper in firestore.rules).
 * This module is the ONLY writer of that claim. Callers:
 *
 *   - the admin ban routes (admin-bans.js) — after every ban/unban mutation,
 *   - the suspend/unsuspend auto-ban helpers (admin-users.js),
 *   - authMiddleware / authMiddlewareStrict — LAZILY, when the per-request
 *     ban verdict disagrees with the decoded token's `banned` claim. The
 *     lazy path is what covers standings no route ever mutates directly:
 *     lazy ban expiry, binding mint/unbind flips, and the UNLINKED network
 *     ban (no enumerable target at ban time — the live IP-match verdict is
 *     the only evidence it applies to this caller).
 *
 * Directional asymmetry, on purpose (both directions fail toward "banned"):
 *   - SET rides EITHER authority: an explicit recompute OR the live request
 *     verdict (`verdictBanned: true`) — evidence in hand is enough to mint.
 *   - CLEAR rides ONLY the full user-attributable recompute
 *     (computeUserBanStanding). The per-request verdict must never clear:
 *     it is IP-scoped, so a user with a LINKED network ban calling from a
 *     clean IP passes the API gate while their ban standing still holds.
 *
 * Setting the claim also revokes refresh tokens (prompt effect: the session
 * cannot outlive the current ID token, ≤1h). Clearing NEVER revokes — an
 * unban must not sign the restored user out; their stale banned:true token
 * simply ages out (or the app force-refreshes), exactly the story's
 * "block lifts after refresh" contract.
 *
 * Failure posture: NEVER throws. The ban/unban mutation this rides on has
 * already committed, and the middleware's outer catch turns exceptions into
 * 401s — a claim-sync failure must change neither. Failures are logged loud
 * (`banned-claim` tag) and the lazy path self-heals on a later request.
 */
 
const { auth, db } = require('./firebase');
const { computeUserBanStanding } = require('./bans');
const { mintClaimsMerging } = require('./firebase-claims');
const log = require('./log');
 
// Lazy-path dedup: a banned caller's stale token mismatches the verdict on
// EVERY request for up to ~1h; without a guard each one would re-read the
// live claims. Same idiom as middleware/auth.js's adminClaimCache (60s TTL,
// bounded, oldest-out). Route callers bypass this — a fresh mutation must
// always sync.
const SYNC_DEDUP_TTL = 60 * 1000;
const MAX_DEDUP_SIZE = 500;
const syncDedup = new Map(); // String(uniqueId) → expiresAt
 
/** Test-isolation helper — mirrors clearBanCache/clearAuthCaches. */
function clearBannedClaimSyncDedup() {
  syncDedup.clear();
}
 
/**
 * Reconcile the `banned` custom claim with the user's actual ban standing.
 *
 * @param {string|number} uniqueId  the target user's stable id
 * @param {object} [opts]
 * @param {string}  [opts.uid]           Firebase uid when the caller already
 *   has it (middleware) — skips the users-doc lookup.
 * @param {boolean} [opts.verdictBanned] pass `true` when a live request
 *   verdict says banned — mints WITHOUT a recompute (the unlinked-network
 *   case has nothing to recompute). Never used to clear.
 * @param {boolean} [opts.dedupe]        lazy-path callers only: skip when
 *   this uniqueId was synced within SYNC_DEDUP_TTL.
 * @returns {Promise<{synced: boolean, changed?: boolean, banned?: boolean,
 *   reason?: string}>} outcome report — informational; callers do not branch
 *   on it for control flow.
 */
async function syncBannedClaim(
  uniqueId,
  { uid = null, verdictBanned = false, dedupe = false } = {},
) {
  const key = String(uniqueId);
  try {
    if (uniqueId === null || uniqueId === undefined) {
      return { synced: false, reason: 'no-uniqueId' };
    }
    if (dedupe) {
      const until = syncDedup.get(key);
      if (until && Date.now() < until) return { synced: false, reason: 'dedup' };
      // Mark BEFORE the async work so a hammering stale-token caller cannot
      // stack N parallel syncs while the first is in flight.
      syncDedup.set(key, Date.now() + SYNC_DEDUP_TTL);
      if (syncDedup.size > MAX_DEDUP_SIZE) {
        syncDedup.delete(syncDedup.keys().next().value);
      }
    }
 
    const target = verdictBanned === true ? true : await computeUserBanStanding(uniqueId);
 
    let firebaseUid = uid;
    if (!firebaseUid) {
      const snap = await db.doc(`users/${uniqueId}`).get();
      firebaseUid = snap.exists ? snap.data().firebaseUid || null : null;
    }
    if (!firebaseUid) {
      // No Auth identity to carry the claim (user doc missing/legacy). The
      // API gate (SHY-0149) still enforces per-request; nothing to mint.
      log.warn('banned-claim', 'no firebaseUid resolvable — claim not synced', {
        uniqueId: key,
      });
      return { synced: false, reason: 'no-uid' };
    }
 
    const record = await auth.getUser(firebaseUid);
    const live = record.customClaims?.banned === true;
    if (live === target) {
      return { synced: true, changed: false, banned: target };
    }
 
    // Merge-mint (never replace): cohort/admin/uniqueId claims survive.
    await mintClaimsMerging(firebaseUid, { banned: target });
    if (target) {
      // Prompt effect on ban: the session cannot outlive the current ID
      // token. Clearing deliberately skips this — see module docblock.
      await auth.revokeRefreshTokens(firebaseUid);
    }
    // Observability AC: the claim lifecycle is auditable from server logs.
    log.info(
      'banned-claim',
      target ? 'banned claim SET + refresh tokens revoked' : 'banned claim CLEARED (recompute)',
      { uniqueId: key, uid: firebaseUid, verdictDriven: verdictBanned === true },
    );
    return { synced: true, changed: true, banned: target };
  } catch (err) {
    log.error('banned-claim', 'claim sync failed — will self-heal on a later request', {
      uniqueId: key,
      error: err.message,
    });
    return { synced: false, reason: 'error' };
  }
}
 
module.exports = { syncBannedClaim, clearBannedClaimSyncDedup, SYNC_DEDUP_TTL };