All files / src/utils bans.js

97.01% Statements 195/201
93.91% Branches 108/115
97.36% Functions 37/38
98.84% Lines 171/173

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 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611                                                                          334x 334x 334x           334x       334x                     334x             334x 334x                                     334x   334x 334x     334x 334x     334x 334x       1278x 14x 14x                           40795x 23x 23x 12x         63x     334x           10x 10x 10x 10x   40x   37x   10x                         526x         10831x 21x 17x     15x 7x   2x                 1932x 1932x 1932x 1932x                         1358x 1076x   282x   272x 272x                                 272x 272x 272x     272x 323x 323x 323x     272x 318x   316x 25805x 25805x 2x     316x 58x   51x         5x   3x       3x           260x   260x 260x   272x     272x                 260x 2x 2x                             1350x   1350x 1350x         45x 45x 45x     1305x 1305x   1296x 1296x 1296x   1296x     1558x             1547x 1547x                     1282x 1x         1x     1281x   1278x   1278x       1253x 41x   1253x   41x 10x       1278x 1278x 1278x 1278x   1296x     1296x 1296x                             811x                     811x 802x     800x 30x   770x         779x 779x 777x 10262x 10262x 11x   10251x 3x       763x                                                 548x 548x 530x 522x 522x 522x 515x   4061x 4103x                       67x 67x 66x 9x     57x 54x     558x 558x 10x       44x   4x 4x                                               114x 114x 114x       228x 228x       114x 114x                                               44x   20x 20x 20x 20x 19x 19x   19x 18x 18x         20x 18x         2x         20x 20x                     617x 50x 50x   567x 567x     334x                                    
/**
 * Ban checking — shared between the sign-in report (/api/device-info) and
 * the per-request ban gate in authMiddleware (SHY-0149, EPIC-0005).
 *
 * Two entry points with deliberately DIFFERENT error postures:
 *
 *  - `checkBans(deviceId, ip, asn)` — the sign-in ban REPORT. Fail-open
 *    (returns noBan on lookup error): it feeds the app's ban screen, and a
 *    transient error here must not turn a telemetry submission into a
 *    sign-in outage. The per-request gate below still enforces on every
 *    subsequent request, so nothing security-relevant rides on this path.
 *  - `checkUserBans(uniqueId, ip)` — the per-request GATE. Fail-closed BY
 *    PROPAGATION: no catch, so a lookup failure rejects up into
 *    authMiddleware's outer catch → 401. A safety control that fails open
 *    is not a control; this matches the suspension check's structural
 *    posture (whose rejection also lands in that catch).
 *
 * How a caller is matched to a device ban (both directions):
 *  - ban follows the ACCOUNT: `deviceBans.linkedUniqueId` == caller
 *    (String + legacy Number forms via Filter.or — same dual-form lesson
 *    as SHY-0165's participantIds).
 *  - ban follows the HARDWARE: any `deviceBindings` doc owned by the
 *    caller (`uniqueId`, legacy `userId`; String + Number forms) whose
 *    deviceId has an active `deviceBans` doc. This is what blocks a
 *    device-banned account on the WEB, where no deviceId exists.
 *
 * Network bans match the REAL edge IP the caller hands us (`req.ip` under
 * `trust proxy: 1` — never a client-forgeable X-Forwarded-For value) plus
 * the caller's STORED binding ASNs — no per-request geo lookup.
 *
 * Caching mirrors middleware/auth.js (Spark-tier read quota): 5-min TTL,
 * bounded size with eviction, in-flight Promise dedup, plus ONE global
 * cache of the active networkBans list shared across all requests.
 * `clearBanCache()` is invoked by the admin ban routes on every ban
 * mutation so a mid-session ban bites on the target's very next request.
 */
 
const { db, FieldValue } = require('./firebase');
const { Filter, FieldPath } = require('firebase-admin/firestore');
const log = require('./log');
 
// Bound per-request network-ban reads to keep Spark-tier quota safe if
// the active-ban list ever grows. Matches the cap the old expireBans
// cron used; 500 simultaneously-active network bans is far above any
// realistic ShyTalk-scale value.
const NETWORK_BANS_QUERY_LIMIT = 500;
// How many pages of that size the gate will walk before refusing to guess.
// 5,000 network bans is far beyond any realistic ShyTalk-scale value, and the
// lazy reaper keeps the steady state at roughly the active count.
const NETWORK_BANS_MAX_PAGES = 10;
 
// How many devices one account may bind. Enforced at WRITE time by the two
// binding-minting routes (/api/devices/lock-check and /api/device-info).
//
// This is a security control, not a product limit. A hardware ban is resolved
// by scanning the caller's deviceBindings; deviceIds are attacker-chosen
// strings and Firestore paginates by document id, so WITHOUT a cap an
// attacker could bind decoy devices that sort ahead of the banned one until
// it fell outside the scan window — and both minting routes are exempt from
// the ban gate, so a banned account could do it too. (Reviewer C1, SHY-0149.)
const MAX_BOUND_DEVICES = 20;
 
// The gate scans generously beyond the write-time cap, so a legitimate caller
// is never truncated. Hitting the limit means the cap was bypassed or predates
// it: the gate then cannot PROVE the caller is unbanned, so it fails closed
// (the query throws → authMiddleware's outer catch → 401) rather than logging
// a warning and letting them through.
const BINDINGS_SCAN_LIMIT = MAX_BOUND_DEVICES * 5;
const LINKED_BANS_SCAN_LIMIT = MAX_BOUND_DEVICES * 5;
 
/**
 * Options for the binding-minting transactions (lock-check, device-info, admin
 * create). Those transactions contain DOCUMENT reads only — never a query.
 *
 * A `tx.get(query)` widens a transaction's conflict set to every document the
 * query matches, and under the Firestore emulator it also proved to break the
 * document-level conflict detection the device-lock depends on: with a cap
 * query inside the bind transaction, two concurrent sign-ins on ONE fresh
 * device could BOTH commit `allowed`, collapsing SHY-0170's
 * one-device-one-account invariant. That invariant outranks the cap, so the
 * cap is enforced around the transaction instead (see enforceBindingCap /
 * rollbackBindingIfOverCap) and the transaction stays doc-only.
 *
 * The raised attempt budget still helps: concurrent sign-ins on the same
 * device legitimately contend, and exhausting Firestore's default 5 surfaces
 * as a 500 on a routine race.
 */
const BINDING_TRANSACTION_OPTIONS = Object.freeze({ maxAttempts: 15 });
 
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes — mirrors middleware/auth.js
const MAX_CACHE_SIZE = 500;
 
// String(uniqueId) → { standing: { deviceBan, asns }, expiresAt }
const userBanCache = new Map();
const userBanInFlight = new Map(); // String(uniqueId) → Promise<standing>
 
// ONE process-wide cache of the active network-ban list.
let networkBansCache = null; // { bans: Array, expiresAt: number }
let networkBansInFlight = null; // Promise<Array> | null
 
/** Drop the oldest entry once a cache exceeds MAX_CACHE_SIZE. */
function evictOldest(cache) {
  if (cache.size > MAX_CACHE_SIZE) {
    const oldestKey = cache.keys().next().value;
    cache.delete(oldestKey);
  }
}
 
/**
 * Is this ban currently in force?
 *
 * No `expiresAt` means permanent. An expiry we cannot parse means we cannot
 * prove the ban has lapsed — so it stays in force. The naive
 * `new Date(x).getTime() > Date.now()` yields `NaN > now === false`, which
 * silently RETIRES a ban whose expiry is corrupt: a safety control that fails
 * open (SHY-0149, found while writing the malformed-expiry test).
 */
function isBanActive(ban) {
  if (!ban.expiresAt) return true; // permanent
  const expiry = new Date(ban.expiresAt).getTime();
  if (Number.isNaN(expiry)) return true; // unparseable → cannot prove it lapsed
  return expiry > Date.now();
}
 
/** Build a ban result object. */
function buildBanResult(banType, ban) {
  return { isBanned: true, banType, reason: ban.reason || null, expiresAt: ban.expiresAt || null };
}
 
const NO_BAN = Object.freeze({ isBanned: false, banType: null, reason: null, expiresAt: null });
 
/**
 * Check whether an IPv4 address falls within a CIDR range.
 */
function isIpInSubnet(ip, cidr) {
  try {
    const [subnet, bits] = cidr.split('/');
    const prefixLen = Number.parseInt(bits, 10);
    const mask = prefixLen === 0 ? 0 : (~0 << (32 - prefixLen)) >>> 0;
    const ipNum =
      ip.split('.').reduce((acc, oct) => ((acc << 8) >>> 0) + Number.parseInt(oct, 10), 0) >>> 0;
    const subNum =
      subnet.split('.').reduce((acc, oct) => ((acc << 8) >>> 0) + Number.parseInt(oct, 10), 0) >>>
      0;
    return (ipNum & mask) === (subNum & mask);
  } catch {
    return false;
  }
}
 
/**
 * Normalize an ASN for comparison: geo enrichment stores 'AS64500'
 * (ip-api format) while the admin route validates ban values as
 * digits-only ('64500') — strict equality could never match (pre-existing
 * defect fixed under SHY-0149).
 */
function normalizeAsn(value) {
  return String(value).replace(/^AS/i, '');
}
 
/** Check if a network ban matches the given IP/ASN. */
function networkBanMatches(ban, ip, asn) {
  if (ban.type === 'ip') return ban.value === ip;
  if (ban.type === 'subnet') return isIpInSubnet(ip, ban.value);
  if (ban.type === 'asn') {
    // An absent caller ASN must never match — normalizeAsn(null) would
    // stringify to 'null' and could collide with a literal ban value.
    if (asn === null || asn === undefined) return false;
    return normalizeAsn(ban.value) === normalizeAsn(asn);
  }
  return false;
}
 
/**
 * Both String and (legacy) Number forms of an id, for Filter.or matching
 * against docs written by old clients. Guards the NaN case (SHY-0166): a
 * non-numeric uniqueId contributes only its String form.
 */
function idForms(uniqueId) {
  const forms = [String(uniqueId)];
  const numeric = Number(uniqueId);
  if (Number.isFinite(numeric)) forms.push(numeric);
  return forms;
}
 
/**
 * Fetch the currently-active network bans, through the process-wide cache.
 *
 * There is NO server-side expiry filter: `isBanActive` is the sole arbiter, in
 * JS (a Firestore range filter compares ISO strings by codepoint, which is not
 * an expiry check — see the note above). The scan is paged and fails CLOSED if
 * the collection outgrows its page budget, and genuinely-lapsed docs are lazily
 * reaped on the way through — the on-access reaping that retired `expireBans`.
 */
async function getActiveNetworkBans() {
  if (networkBansCache && Date.now() < networkBansCache.expiresAt) {
    return networkBansCache.bans;
  }
  if (networkBansInFlight) return networkBansInFlight;
 
  networkBansInFlight = (async () => {
    try {
      // NO server-side expiry filter. The obvious `where('expiresAt', '>', nowIso)`
      // compares ISO strings LEXICOGRAPHICALLY, which is not an expiry check: a
      // corrupt `expiresAt` of '' or '1999-13-45' sorts BELOW now and is dropped
      // before `isBanActive` can fail closed on it, and '2026-13-45' sorts above
      // now only until the calendar passes 2026 — a time bomb. String ordering
      // must never decide whether a safety control applies (reviewer R8-C1).
      //
      // `isBanActive` is therefore the sole arbiter, in JS. Genuinely-expired
      // docs are lazily reaped below, so the collection stays small and the
      // limit is not consumed by history — the same on-access reaping the
      // cron-elimination cluster used to retire `expireBans`.
      // The 500-doc budget now covers ACTIVE + not-yet-reaped EXPIRED docs, so a
      // single page can silently omit an active ban → fail-open. Page through
      // the whole collection, and if it is bigger than we are willing to scan,
      // FAIL CLOSED — exactly what the device-ban scan below does. A truncated
      // scan cannot prove the caller is unbanned (reviewer R9-C2).
      const active = [];
      const expired = [];
      let cursor = null;
 
      /** One page of ids, or — with a size of 1 — a probe for "is there more?". */
      const pageQuery = (size) => {
        let query = db.collection('networkBans').orderBy(FieldPath.documentId()).limit(size);
        if (cursor) query = query.startAfter(cursor);
        return query.get();
      };
 
      for (let pagesRead = 1; ; pagesRead++) {
        const snap = await pageQuery(NETWORK_BANS_QUERY_LIMIT);
 
        for (const doc of snap.docs) {
          const ban = doc.data();
          if (isBanActive(ban)) active.push(ban);
          else expired.push(doc.ref);
        }
 
        if (snap.size < NETWORK_BANS_QUERY_LIMIT) break; // short page → the end
        cursor = snap.docs[snap.docs.length - 1].id;
 
        if (pagesRead >= NETWORK_BANS_MAX_PAGES) {
          // The budget is a DOCUMENT budget, not a page-index one. Having read
          // MAX_PAGES full pages says we scanned MAX_PAGES x LIMIT documents —
          // NOT that more exist. Only a probe distinguishes the two, and a
          // collection of exactly the budget was scanned completely (R10-I1).
          if ((await pageQuery(1)).size === 0) break;
 
          log.error('bans', 'networkBans scan exceeded its document budget — failing closed', {
            pages: NETWORK_BANS_MAX_PAGES,
            perPage: NETWORK_BANS_QUERY_LIMIT,
          });
          throw new Error('networkBans scan truncated; cannot determine standing');
        }
      }
 
      // Drain the whole expired backlog we just walked, so the next scan is one
      // page again and the page budget is never consumed by history.
      reapExpiredNetworkBans(expired);
 
      networkBansCache = { bans: active, expiresAt: Date.now() + CACHE_TTL };
      return active;
    } finally {
      networkBansInFlight = null;
    }
  })();
  return networkBansInFlight;
}
 
/**
 * Delete network bans that have genuinely lapsed (a parseable expiry in the
 * past — never a corrupt one, which `isBanActive` keeps in force). Fire and
 * forget: reaping is housekeeping, and a failure must never fail a request.
 */
function reapExpiredNetworkBans(refs) {
  if (refs.length === 0) return;
  Promise.all(refs.map((ref) => ref.delete()))
    .then(() => log.info('bans', 'reaped expired network bans', { count: refs.length }))
    .catch((err) =>
      log.warn('bans', 'reaping expired network bans failed', { error: err.message }),
    );
}
 
/**
 * Resolve a caller's device standing: an active device ban (linked to the
 * account OR targeting a device bound to it) + the ASNs recorded on their
 * bindings (for network ASN matching without a live geo call).
 * Cached per uniqueId; in-flight-deduped.
 *
 * @returns {Promise<{ deviceBan: object|null, asns: string[] }>}
 */
async function getUserDeviceStanding(uniqueId) {
  const key = String(uniqueId);
 
  const cached = userBanCache.get(key);
  if (cached && Date.now() < cached.expiresAt) {
    // Re-insert to move this key to the back of the Map's insertion order, so
    // `evictOldest` drops the least-recently-USED entry rather than the
    // least-recently-inserted one. Without this, a hot caller can be evicted
    // ahead of a cold one and pay a Firestore read (reviewer I4).
    userBanCache.delete(key);
    userBanCache.set(key, cached);
    return cached.standing;
  }
 
  const existing = userBanInFlight.get(key);
  if (existing) return existing;
 
  const promise = (async () => {
    try {
      const forms = idForms(uniqueId);
 
      const [linkedSnap, bindingsSnap] = await Promise.all([
        db
          .collection('deviceBans')
          .where(Filter.or(...forms.map((f) => Filter.where('linkedUniqueId', '==', f))))
          .limit(LINKED_BANS_SCAN_LIMIT)
          .get(),
        db
          .collection('deviceBindings')
          .where(
            Filter.or(
              ...forms.map((f) => Filter.where('uniqueId', '==', f)),
              ...forms.map((f) => Filter.where('userId', '==', f)),
            ),
          )
          .limit(BINDINGS_SCAN_LIMIT)
          .get(),
      ]);
 
      // Fail CLOSED on truncation. A partial scan cannot prove the caller is
      // unbanned, and the un-scanned tail is exactly where an evader would
      // hide their ban (reviewer C1). Refuse rather than assume innocence:
      // this rejection reaches authMiddleware's outer catch → 401.
      if (linkedSnap.size >= LINKED_BANS_SCAN_LIMIT || bindingsSnap.size >= BINDINGS_SCAN_LIMIT) {
        log.error('bans', 'ban lookup truncated — failing closed', {
          uniqueId: key,
          linkedBans: linkedSnap.size,
          bindings: bindingsSnap.size,
        });
        throw new Error('Ban lookup truncated; cannot determine standing');
      }
 
      let deviceBan = linkedSnap.docs.map((d) => d.data()).find(isBanActive) || null;
 
      const asns = [...new Set(bindingsSnap.docs.map((d) => d.data().asn).filter((asn) => !!asn))];
 
      if (!deviceBan) {
        // Hardware-targeted bans (no account linkage): check the caller's
        // bound devices. Bounded by BINDINGS_QUERY_LIMIT doc reads, and only
        // on cache miss.
        const banSnaps = await Promise.all(
          bindingsSnap.docs.map((d) => db.doc(`deviceBans/${d.id}`).get()),
        );
        deviceBan =
          banSnaps
            .filter((snap) => snap.exists)
            .map((snap) => snap.data())
            .find(isBanActive) || null;
      }
 
      const standing = { deviceBan, asns };
      userBanCache.set(key, { standing, expiresAt: Date.now() + CACHE_TTL });
      evictOldest(userBanCache);
      return standing;
    } finally {
      userBanInFlight.delete(key);
    }
  })();
  userBanInFlight.set(key, promise);
  return promise;
}
 
/**
 * Per-request ban verdict for the authMiddleware gate.
 *
 * FAIL-CLOSED: intentionally no try/catch — a lookup failure propagates to
 * authMiddleware's outer catch (→ 401), exactly like a suspension-lookup
 * failure. Do not add a catch that returns NO_BAN here.
 *
 * @param {string|number|null} uniqueId resolved caller id (null = no user doc yet)
 * @param {string} ip the REAL edge IP (`req.ip` under trust proxy) — never a
 *   client-supplied X-Forwarded-For value
 */
async function checkUserBans(uniqueId, ip) {
  let asns = [];
 
  // A caller with no `users` doc yet (uniqueId null) has no deviceBindings, so
  // there is nothing to resolve a DEVICE ban against — only network bans can
  // apply. This is a structural limit, not an oversight: the server cannot
  // attest which physical device a token came from unless the client tells it.
  // Two other controls cover that gap — /api/devices/lock-check refuses a new
  // account on a device already bound to someone else (SHY-0170), and
  // platform attestation (DeviceCheck / Play Integrity) makes a device ban
  // survive a reinstall (SHY-0151, this epic). A modified client that never
  // registers its device is SHY-0151's problem, by design.
  if (uniqueId !== null && uniqueId !== undefined) {
    const standing = await getUserDeviceStanding(uniqueId);
    // Re-check activity on the cached ban: a short-lived ban may expire
    // within the cache TTL and must stop blocking the moment it does.
    if (standing.deviceBan && isBanActive(standing.deviceBan)) {
      return buildBanResult('device', standing.deviceBan);
    }
    asns = standing.asns;
  }
 
  // Network bans apply even to callers with no user doc yet — a banned
  // network must not act through a fresh account.
  const normalizedAsns = asns.map(normalizeAsn);
  const networkBans = await getActiveNetworkBans();
  for (const ban of networkBans) {
    Iif (!isBanActive(ban)) continue; // expiry race between cache fill and use
    if (networkBanMatches(ban, ip, null)) {
      return buildBanResult(`network_${ban.type}`, ban);
    }
    if (ban.type === 'asn' && normalizedAsns.includes(normalizeAsn(ban.value))) {
      return buildBanResult('network_asn', ban);
    }
  }
 
  return NO_BAN;
}
 
/**
 * USER-ATTRIBUTABLE ban standing (SHY-0150) — the recompute authority for
 * the `banned` custom claim (see utils/banned-claim.js).
 *
 * Deliberately IP-FREE, so it can run with no request in hand (ban routes,
 * unban recompute, suspend/unsuspend). It covers:
 *   - device standing: an active deviceBan linked to the account OR
 *     targeting a device bound to it (getUserDeviceStanding), and
 *   - network bans LINKED to the account (`linkedUniqueId`, both id
 *     forms), and
 *   - ASN network bans matching the account's STORED binding ASNs.
 *
 * What it CANNOT see — an UNLINKED ip/subnet network ban — has no
 * enumerable target by construction; the lazy middleware sync mints from
 * the live per-request verdict when such a ban catches the caller
 * (the structural limit documented in the SHY-0150 story).
 *
 * Fail-closed BY PROPAGATION like checkUserBans: no catch, a lookup
 * failure rejects to the caller (syncBannedClaim logs and leaves the
 * claim untouched — never clears on partial evidence).
 */
async function computeUserBanStanding(uniqueId) {
  Iif (uniqueId === null || uniqueId === undefined) return false;
  const standing = await getUserDeviceStanding(uniqueId);
  if (standing.deviceBan && isBanActive(standing.deviceBan)) return true;
  const forms = idForms(uniqueId);
  const normalizedAsns = standing.asns.map(normalizeAsn);
  const networkBans = await getActiveNetworkBans();
  return networkBans.some(
    (ban) =>
      isBanActive(ban) &&
      (forms.some((form) => form === ban.linkedUniqueId) ||
        (ban.type === 'asn' && normalizedAsns.includes(normalizeAsn(ban.value)))),
  );
}
 
/**
 * Sign-in ban report for /api/device-info (moved verbatim from
 * device-info.js). Checks the SUBMITTED deviceId + the caller's network.
 * Fail-open by design — see the module docblock.
 * Returns { isBanned, banType, reason, expiresAt }.
 */
async function checkBans(deviceId, ip, asn) {
  try {
    const deviceBanSnap = await db.doc(`deviceBans/${deviceId}`).get();
    if (deviceBanSnap.exists && isBanActive(deviceBanSnap.data())) {
      return buildBanResult('device', deviceBanSnap.data());
    }
 
    const networkBans = await getActiveNetworkBans();
    for (const ban of networkBans) {
      // The query already excludes expired bans; the inline check is
      // defense-in-depth for the expiry race between cache fill and use.
      Iif (!isBanActive(ban)) continue;
      if (networkBanMatches(ban, ip, asn)) {
        return buildBanResult(`network_${ban.type}`, ban);
      }
    }
 
    return { ...NO_BAN };
  } catch (err) {
    log.error('bans', 'Error checking bans', { deviceId, error: err.message });
    return { ...NO_BAN };
  }
}
 
/**
 * How many devices this account already has bound. Used by the two
 * binding-minting routes to enforce MAX_BOUND_DEVICES at WRITE time — the
 * other half of the C1 fix, and the reason the gate's scan limit can never
 * be reached by a legitimate caller.
 *
 * Reads at most MAX_BOUND_DEVICES + 1 docs (we only need to know whether the
 * cap is reached, not the exact count).
 *
 * NEVER call this from inside a binding transaction. It issues a QUERY read,
 * and a query read inside those transactions widens the conflict set and
 * defeats the document-level conflict detection the device-lock relies on —
 * two concurrent sign-ins on one fresh device could both commit `allowed`.
 * The cap is deliberately enforced around the transaction instead (pre-check
 * here + `rollbackBindingIfOverCap` after). See BINDING_TRANSACTION_OPTIONS.
 *
 * @param {string|number|null} uniqueId
 * @returns {Promise<number>} count, saturating at MAX_BOUND_DEVICES + 1
 */
async function countBoundDevices(uniqueId) {
  Iif (uniqueId === null || uniqueId === undefined) return 0;
  const forms = idForms(uniqueId);
  const query = db
    .collection('deviceBindings')
    .where(
      Filter.or(
        ...forms.map((f) => Filter.where('uniqueId', '==', f)),
        ...forms.map((f) => Filter.where('userId', '==', f)),
      ),
    )
    .limit(MAX_BOUND_DEVICES + 1);
  const snap = await query.get();
  return snap.size;
}
 
/**
 * Compensating half of the binding cap. Call AFTER a transaction has claimed
 * `deviceId` for `uniqueId`.
 *
 * The pre-check before the transaction stops the ordinary over-cap bind. It
 * cannot stop a RACE: N concurrent requests can each observe a pre-cap count
 * and all commit. We cannot close that inside the transaction, because a
 * count needs a query read and a query read breaks the device-lock's
 * conflict detection (see BINDING_TRANSACTION_OPTIONS). So we close it after:
 * re-count, and if the account now exceeds the cap, release the binding this
 * request just took — and only that one, and only while we still own it.
 *
 * Racing requests each release their own claim, so the account settles at or
 * below the cap. The device keeps its telemetry; it merely goes back to being
 * unclaimed, which is exactly the at-cap state.
 *
 * @returns {Promise<boolean>} true when the caller was over the cap (so the
 *   route must refuse), regardless of whether a doc was actually released —
 *   a concurrent actor may have deleted or re-claimed it first.
 */
async function rollbackBindingIfOverCap(uniqueId, deviceId) {
  if ((await countBoundDevices(uniqueId)) <= MAX_BOUND_DEVICES) return false;
 
  const ref = db.doc(`deviceBindings/${deviceId}`);
  const released = await db.runTransaction(async (tx) => {
    const snap = await tx.get(ref);
    if (!snap.exists) return false; // someone deleted it first
    const data = snap.data() || {};
    const owner = data.uniqueId ?? data.userId ?? null;
    // Never release someone else's claim — a concurrent winner may hold it.
    if (owner === null || String(owner) !== String(uniqueId)) return false;
    tx.update(ref, { uniqueId: FieldValue.delete(), boundAt: FieldValue.delete() });
    return true;
  }, BINDING_TRANSACTION_OPTIONS);
 
  // Log only what actually happened — an operator reading "binding released"
  // during an incident must be able to trust it (reviewer R6-I3).
  if (released) {
    log.warn('bans', 'binding released — cap exceeded by a concurrent bind', {
      uniqueId,
      deviceId,
    });
  } else {
    log.warn('bans', 'over cap, but the target binding was already gone or reclaimed', {
      uniqueId,
      deviceId,
    });
  }
  clearBanCache(uniqueId);
  return true;
}
 
/**
 * Invalidate ban caches. With a uniqueId: that caller only (both id forms
 * share the String key). Without: EVERYTHING, including the network-ban
 * list — the admin ban routes call this form on every ban mutation, so a
 * ban with no account linkage (hardware- or network-targeted) still bites
 * on the target's next request.
 */
function clearBanCache(uniqueId) {
  if (uniqueId !== undefined) {
    userBanCache.delete(String(uniqueId));
    return;
  }
  userBanCache.clear();
  networkBansCache = null;
}
 
module.exports = {
  checkBans,
  checkUserBans,
  computeUserBanStanding,
  clearBanCache,
  countBoundDevices,
  rollbackBindingIfOverCap,
  isBanActive,
  buildBanResult,
  networkBanMatches,
  isIpInSubnet,
  NETWORK_BANS_QUERY_LIMIT,
  NETWORK_BANS_MAX_PAGES,
  MAX_BOUND_DEVICES,
  BINDINGS_SCAN_LIMIT,
  BINDING_TRANSACTION_OPTIONS,
  MAX_CACHE_SIZE,
};