All files / src/routes system.js

100% Statements 51/51
75% Branches 12/16
100% Functions 11/11
100% Lines 49/49

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                                              2x 2x 2x 2x 2x 2x 2x 2x   2x             5x   5x 1x                                       2x 2x     2x               17x 3x 3x   14x                                                           4x   4x 27x 10x   17x   17x 17x 17x 17x 2x       17x 11x   6x 6x           17x 17x       4x 4x 70x       4x     2x             2x           3x     2x 2x         2x 2x 35x 35x       2x  
/**
 * System endpoints for external schedulers + health monitoring.
 *
 * GET  /api/system/health                   — public, Better Stack heartbeat.
 *                                             Returns 200 immediately,
 *                                             async-fires the serverHealth()
 *                                             metrics check (memory + PM2
 *                                             restart detection).
 *
 * POST /api/system/sweep-account-deletions  — requires Bearer auth.
 *                                             Synchronously runs
 *                                             accountDeletion() — daily
 *                                             03:00 UTC sweep scheduled
 *                                             by .github/workflows/
 *                                             cron-account-deletion.yml.
 *
 * Architecture: replaces in-process node-cron schedules with either
 * external monitors (Better Stack for serverHealth) or GitHub Actions
 * scheduled workflows (for sweep operations). Public repo means GH
 * Actions is free + unlimited, so the scheduling layer is fully $0
 * with the auditability bonus of having the cron expressions in-repo.
 */
 
const router = require('express').Router();
const log = require('../utils/log');
const serverHealth = require('../cron/serverHealth');
const accountDeletion = require('../cron/accountDeletion');
const alertManager = require('../utils/alertManagerInstance');
const { requireSystemAuth } = require('../middleware/system-auth');
const { getDefaultMissQueue } = require('../utils/translation-miss-queue');
const { appCheckCounters, appCheckMode } = require('../middleware/app-check');
 
router.get('/system/health', (req, res) => {
  // Respond immediately so the external monitor sees a fast 200. The
  // metrics check (memory threshold + PM2 restart detection) runs
  // fire-and-forget — a failure there is logged but doesn't affect
  // the heartbeat response. translationQueueLength (SHY-0072) is the
  // admin backlog signal for untranslated public strings — counted
  // lazily from the queue file so the reader never races the writer.
  res.json({ status: 'ok', translationQueueLength: getDefaultMissQueue().length() });
 
  serverHealth(alertManager).catch((err) => {
    log.error('system', 'serverHealth metrics check failed', { error: err.message });
  });
});
 
/**
 * GET /api/system/app-check — the SHY-0300 rollout instrument.
 *
 * The enforcement flip is a config change with no deploy, so the thing most
 * likely to go wrong is flipping it while a meaningful share of clients still
 * cannot attest; the symptom is users locked out with a 401 they cannot act
 * on. Mode plus counts makes a botched flip one curl away instead of a log
 * dive, and `refused` climbing is the signal to flip back.
 *
 * **Authenticated, deliberately.** The obvious home was `/system/health`, but
 * that is PUBLIC — the Better Stack heartbeat reads it — and publishing
 * `mode` there would tell an unauthenticated caller precisely when
 * attestation is off, which is the one thing an abuser of this endpoint needs
 * to know. The counts are operational, not secret; the MODE is a timing
 * signal, so the whole block sits behind the shared secret.
 */
router.get('/system/app-check', requireSystemAuth, (req, res) => {
  res.json({ mode: appCheckMode(), ...appCheckCounters() });
});
 
const SWEEP_TIMEOUT_DEFAULT_MS = 20 * 60 * 1000;
 
// Read the sweep timeout per-request so tests can inject a short
// timeout via `process.env.SWEEP_TIMEOUT_MS_OVERRIDE` without faking
// the system clock (supertest's HTTP transport relies on real timers).
// Production paths never set the override, so the default 20-min bound
// applies in deploys.
function getSweepTimeoutMs() {
  if (process.env.NODE_ENV === 'test' && process.env.SWEEP_TIMEOUT_MS_OVERRIDE) {
    const override = Number(process.env.SWEEP_TIMEOUT_MS_OVERRIDE);
    Eif (Number.isFinite(override) && override > 0) return override;
  }
  return SWEEP_TIMEOUT_DEFAULT_MS;
}
 
/**
 * Factory for sweep-style endpoint handlers.
 *
 * Each sweep endpoint shares the same shape: bearer-auth (applied
 * upstream by the route mounting), reject-if-in-flight with 409,
 * Promise.race with a hard timeout, error → 500 + log, and a
 * try/finally that guarantees the in-flight flag is cleared and the
 * timeout handle released.
 *
 * Returning a closure-captured handler gives each sweep its OWN
 * in-flight flag (independent of other sweeps), and exposes a
 * `_reset()` method behind a NODE_ENV=test guard so the test suite can
 * scrub any leaked flag from a Jest-aborted test without exposing the
 * mutation to production callers. Per the operator's
 * `[[feedback-test-isolation-no-leaks]]` directive.
 *
 * Covers the active sweep endpoint — sweep-account-deletions — with
 * one place to audit auth / timeout / error-handling semantics. New
 * sweep endpoints get the same defenses for free.
 *
 * @param {string} name short identifier used in log messages, e.g.
 *   'sweep-account-deletions'.
 * @param {() => Promise<unknown>} sweepFn the underlying worker that
 *   does the actual sweep — typically a function from src/cron/*.
 * @returns Express handler.
 */
function createSweepHandler(name, sweepFn) {
  let inFlight = false;
 
  const handler = async (req, res) => {
    if (inFlight) {
      return res.status(409).json({ error: 'sweep already in flight' });
    }
    inFlight = true;
    let timeoutHandle;
    try {
      const timeoutMs = getSweepTimeoutMs();
      const timeoutPromise = new Promise((_, reject) => {
        timeoutHandle = setTimeout(
          () => reject(new Error(`sweep timed out after ${timeoutMs}ms`)),
          timeoutMs,
        );
      });
      await Promise.race([sweepFn(), timeoutPromise]);
      res.json({ status: 'ok' });
    } catch (err) {
      log.error('system', `${name} failed`, { error: err.message });
      res.status(500).json({ error: 'sweep failed' });
    } finally {
      // Clear the timer first so a successful sweep doesn't leak the
      // pending setTimeout (the Promise.race winner ignores the loser
      // but the loser's setTimeout is still scheduled and would keep
      // Node alive past the response).
      Eif (timeoutHandle) clearTimeout(timeoutHandle);
      inFlight = false;
    }
  };
 
  Eif (process.env.NODE_ENV === 'test') {
    handler._reset = () => {
      inFlight = false;
    };
  }
 
  return handler;
}
 
const sweepAccountDeletions = createSweepHandler('sweep-account-deletions', accountDeletion);
 
// Support-ticket retention: closed tickets seven days after closure, taking
// their attachments with them, and uploads nobody ever sent (SHY-0436,
// SHY-0435). Both delete personal data — screenshots of private conversations,
// photographs of other people — so both belong on a schedule rather than
// waiting for somebody to remember.
const sweepSupportRetentionHandler = createSweepHandler('sweep-support-retention', () =>
  // Required HERE, not at module load. `cron/supportRetention` pulls in
  // utils/firebase, which exits the process when the environment is not set
  // up — so a top-level import makes this whole route file unloadable in every
  // suite that does not happen to mock firebase, including ones that have
  // nothing to do with support.
  require('../cron/supportRetention').sweepSupportRetention(),
);
 
router.post('/system/sweep-account-deletions', requireSystemAuth, sweepAccountDeletions);
router.post('/system/sweep-support-retention', requireSystemAuth, sweepSupportRetentionHandler);
 
// Test-only reset hook. Exported behind a NODE_ENV guard so production
// code can't accidentally clobber the in-flight flags. Calls each
// sweep handler's `_reset()` closure so all flags scrub in one go.
Eif (process.env.NODE_ENV === 'test') {
  router._resetInFlightForTesting = () => {
    sweepAccountDeletions._reset();
    sweepSupportRetentionHandler._reset();
  };
}
 
module.exports = router;