All files / src/routes ban-status.js

88.88% Statements 16/18
100% Branches 6/6
100% Functions 1/1
88.88% Lines 16/18

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                                                                  3x 3x 3x 3x 3x   3x 21x         21x 3x   18x 1x     17x           17x 17x 17x                           3x  
/**
 * Unauthenticated, read-only ban check for the cold-start gate.
 *
 * GET /api/ban-status?deviceId=<id>  → { success, banStatus }
 *
 * **Why this exists (SHY-0143).** The client has to answer "is this device or
 * this network banned?" BEFORE it routes, and at that moment there may be no
 * Firebase session at all — a signed-out user, or one whose ban is the reason
 * they were signed out. `/api/device-info` cannot answer it: it sits behind
 * `authMiddleware`, so the client's `getIdToken()` throws before the request
 * is even built and the repository's catch reports "not banned". A banned user
 * with no session therefore reached the sign-in screen, which the story's
 * acceptance criteria name explicitly as the thing that must never happen.
 *
 * **Why a new route rather than opening up device-info.** That route upserts
 * `deviceBindings/{deviceId}` and runs a binding-cap transaction. It must stay
 * authenticated, and it should not be the thing a client calls on every cold
 * start (and, on Android, every rotation — the Activity is recreated and the
 * whole pre-routing sequence re-runs). This route writes NOTHING.
 *
 * **Exposure.** It is unauthenticated by necessity. What a caller can learn is
 * whether the deviceId they supplied, or the IP they are calling from, is
 * banned — which they can already learn by opening the app. It performs no
 * writes, so it cannot be used to create state, and it is rate-limited by IP
 * in `index.js`. It deliberately does not accept an arbitrary IP to test:
 * network matching always uses the caller's own address.
 *
 * **Fail-open.** `checkBans` reports "no ban" if Firestore errors, matching
 * the long-standing behaviour on the sign-in path: a ban-service blip must not
 * lock out a legitimate user. A real ban is authoritative; an unreachable
 * service is not evidence of one. The client applies the same rule.
 */
 
const router = require('express').Router();
const { checkBans } = require('../utils/bans');
const { isValidDeviceId } = require('../utils/deviceId');
const { getIpGeo } = require('../utils/ip-geo');
const log = require('../utils/log');
 
router.get('/ban-status', async (req, res) => {
  const deviceId = req.query?.deviceId;
 
  // Rejected loudly rather than answered with a cheerful "not banned".
  // Silently reporting no-ban on a malformed request is the exact failure
  // shape this endpoint was created to remove.
  if (!deviceId) {
    return res.status(400).json({ error: 'deviceId is required' });
  }
  if (!isValidDeviceId(deviceId)) {
    return res.status(400).json({ error: 'deviceId is malformed' });
  }
 
  try {
    // ASN-scoped network bans need the caller's ASN, so this resolves geo the
    // same way the authenticated device-info path does — via the SHARED
    // helper, so the two can never disagree about which ASN a ban is matched
    // against. Best-effort and bounded; on failure `asn` is null and IP and
    // subnet bans still match.
    const geo = await getIpGeo(req.ip);
    const banStatus = await checkBans(deviceId, req.ip, geo.asn || null);
    return res.json({ success: true, banStatus });
  } catch (err) {
    // Unreachable in practice — `checkBans` already swallows its own errors —
    // but an unhandled rejection here would 500 the cold-start gate, and a
    // 500 is one more thing the client would have to interpret. Log it and
    // fail open, consistently with everything else on this path.
    log.error('ban-status', 'ban check failed', { deviceId, error: err.message });
    return res.json({
      success: true,
      banStatus: { isBanned: false, banType: null, reason: null, expiresAt: null },
    });
  }
});
 
module.exports = router;