Ctrl K
ComunidadGuías…

server.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
const http = require('http');
const https = require('https');
const net = require('net');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const zlib = require('zlib');
const { execFile, spawn } = require('child_process');
const db = require('./db.js');
const ship = require('./shipping.js');
const channels = require('./channels.js');
const harvestFeed = require('./harvest-feed.js');
const syringeStockModule = require('./syringe-stock.js');
const orderBoard = require('./order-board.js');
const duckdns = require('./duckdns.js');
const { createMcpServer } = require('./mcp-server.js');
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');

// ── CONFIGURATION ────────────────────────────────────────────
function loadEnv() {
  try {
    const envPath = path.join(__dirname, '.env');
    if (fs.existsSync(envPath)) {
      fs.readFileSync(envPath, 'utf8')
        .split('\n')
        .forEach((line) => {
          const match = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
          if (match) {
            let val = match[2];
            if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
              val = val.slice(1, -1);
            if (!process.env[match[1]]) process.env[match[1]] = val;
          }
        });
    }
  } catch (e) {
    /* .env is optional */
  }
}
loadEnv();

// ── STRUCTURED LOGGING ───────────────────────────────────────
// Outputs JSON lines for structured log aggregation (PM2, journald, etc.)
// Falls back to human-readable format when LOG_FORMAT=text
const LOG_FORMAT = process.env.LOG_FORMAT || 'json';
// S-13: the record's own three fields are written first and meta is copied in
// afterwards *without* being allowed to replace them. `...meta` used to be
// spread last, so any caller handing this function attacker-shaped data could
// forge the whole line — and /api/csp-reports did exactly that, passing a
// parsed request body straight through. A report of
//   {"time":"1999-01-01T00:00:00.000Z","level":"INFO","msg":"nothing to see here"}
// became precisely that entry in the JSON stream that DEPLOYMENT.md tells
// operators to feed to pm2/journald, which is enough to fabricate an audit
// trail or to bury a real event under lines a log search filters out.
const LOG_RESERVED = new Set(['time', 'level', 'msg']);
function log(level, msg, meta) {
  const ts = new Date().toISOString();
  if (LOG_FORMAT === 'json') {
    const record = { time: ts, level: level.toUpperCase(), msg };
    if (meta && typeof meta === 'object') {
      for (const k of Object.keys(meta)) {
        if (!LOG_RESERVED.has(k)) record[k] = meta[k];
      }
    }
    const entry = JSON.stringify(record);
    if (level === 'error') console.error(entry);
    else console.log(entry);
  } else {
    const entry = `${ts} [${level.toUpperCase()}] ${msg}`;
    if (level === 'error') console.error(entry, meta || '');
    else console.log(entry, meta ? JSON.stringify(meta) : '');
  }
}

// The fields a CSP report is worth keeping, in the shape the spec defines them.
// Everything else the client sent is dropped: `original-policy` repeats a header
// we already know, and an arbitrary key set from an unauthenticated endpoint is
// not something a log stream should have to carry. Strings are truncated so one
// report cannot dominate a line.
const CSP_REPORT_FIELDS = [
  'document-uri',
  'violated-directive',
  'effective-directive',
  'blocked-uri',
  'source-file',
  'line-number',
  'column-number',
  'disposition'
];
function cspReportFields(report) {
  const out = {};
  if (!report || typeof report !== 'object') return out;
  for (const k of CSP_REPORT_FIELDS) {
    const v = report[k];
    if (v == null) continue;
    if (typeof v === 'number') out[k] = Number.isFinite(v) ? v : null;
    else out[k] = String(v).slice(0, 300);
  }
  return out;
}

const PORT_RAW = parseInt(process.env.PORT, 10) || 3000;
if (PORT_RAW < 1 || PORT_RAW > 65535) {
  log('error', 'Invalid PORT, using default 3000', { value: PORT_RAW });
}
const PORT = PORT_RAW >= 1 && PORT_RAW <= 65535 ? PORT_RAW : 3000;
// Set by update_server.sh / START.bat when launched from inside a git worktree.
// Surfaced via /api/health so the UI can render a "this is not production"
// banner — prevents people from confidently entering real data into a feature
// branch instance running alongside prod.
const WORKTREE_MODE = process.env.WORKTREE_MODE === '1' || process.env.WORKTREE_MODE === 'true';
// The farm PC has to be reachable from the phones in the growing rooms, so the
// default stays 0.0.0.0. HOST exists for the instances that must NOT be: the
// measuring stand spawns this file against a fixture database whose only
// account is an admin, and on the LAN that is an open door for as long as the
// run takes. Set HOST=127.0.0.1 there.
const HOST = process.env.HOST || '0.0.0.0';
const DIR = __dirname;
const CERT_KEY = path.join(DIR, 'certs', 'server.key');
const CERT_CRT = path.join(DIR, 'certs', 'server.crt');
// Beside the code by default, which is what the farm PC and every worktree
// expect. MT_DB_FILE exists for instances that must NOT write there: the
// measuring stand runs this file against a seeded fixture, and a fixture
// landing on top of real records is the one accident worth designing out
// rather than guarding against.
const DB_FILE = process.env.MT_DB_FILE || path.join(DIR, 'meistertracker.db');
const CAL_DIR = path.join(DIR, 'calendars');

// Which state of the code is running: the commit and its date. package.json
// has said 1.0.0 all along, so whether an update actually arrived was only
// visible on the machine itself — and an update that cannot fetch carries on
// with the old code without a word. Read once at start-up, because the code
// only changes with a restart; asynchronously, so start-up does not wait for
// git. Without git (a ZIP copy) it stays null. Admin-only, like the Node
// version: /api/health.
function readCodeState(dir, done) {
  execFile('git', ['log', '-1', '--format=%h%x09%cI'], { cwd: dir, timeout: 5000, windowsHide: true }, (err, out) => {
    if (err) return done(null);
    const [commit, date] = String(out).trim().split('\t');
    done(/^[0-9a-f]{7,40}$/.test(commit || '') ? { commit, date: date || null } : null);
  });
}
let codeState = null;
readCodeState(DIR, (state) => {
  codeState = state;
});

// Windows printer name — must match exactly what shows in Devices and Printers
// Validated to prevent command injection via PowerShell/WMI
const PRINTER_NAME_RAW = process.env.PRINTER_NAME || 'ZDesigner GK420d';
if (!/^[\w\s.\-()]+$/u.test(PRINTER_NAME_RAW)) {
  log('error', 'PRINTER_NAME contains unsafe characters, falling back to default', { value: PRINTER_NAME_RAW });
}
const PRINTER_NAME = /^[\w\s.\-()]+$/u.test(PRINTER_NAME_RAW) ? PRINTER_NAME_RAW : 'ZDesigner GK420d';

// PM2 process name — used by the GitHub-webhook auto-deploy chain
// (`pm2 restart <name>` after `git pull`). Forks running PM2 with a
// different process name should set PM2_PROCESS_NAME in their env so
// the auto-deploy lands on the right process. Validated to a strict
// charset before any shell interpolation.
const PM2_PROCESS_NAME_RAW = process.env.PM2_PROCESS_NAME || 'meisterpilze';
const PM2_PROCESS_NAME = /^[A-Za-z0-9_\-]{1,64}$/.test(PM2_PROCESS_NAME_RAW) ? PM2_PROCESS_NAME_RAW : 'meisterpilze';
if (PM2_PROCESS_NAME !== PM2_PROCESS_NAME_RAW) {
  log('error', 'PM2_PROCESS_NAME contains unsafe characters, falling back to default', {
    value: PM2_PROCESS_NAME_RAW
  });
}

// ZPL label dimensions in dots. Default 400×240 = 50×30mm at 203dpi
// (Zebra GK420d / ZD420 standard small label). Forks with different
// label stock can override via env, but note the field positions in
// app.js and mcp-server.js itemsToZPL() are laid out for 400 dots
// wide — significantly different sizes will need layout tweaks too.
function _intEnv(name, def, min, max) {
  const v = parseInt(process.env[name], 10);
  if (Number.isFinite(v) && v >= min && v <= max) return v;
  return def;
}
const LABEL_WIDTH_DOTS = _intEnv('LABEL_WIDTH_DOTS', 400, 100, 4000);
const LABEL_HEIGHT_DOTS = _intEnv('LABEL_HEIGHT_DOTS', 240, 100, 4000);

// Optional Windows print bridge (scripts/print-bridge.ps1). When configured,
// the server forwards print + status calls to this URL instead of running
// PowerShell locally. Required for label printing when the server runs on
// Linux while the Zebra is attached to a Windows PC on the LAN.
//
// The env vars are a fallback for the DB-stored config (Settings → Drucker)
// so existing .env-based setups keep working untouched. UI-saved values
// take precedence over env values when present.
const PRINT_BRIDGE_URL_ENV = (process.env.PRINT_BRIDGE_URL || '').replace(/\/+$/, '');
const PRINT_BRIDGE_TOKEN_ENV = process.env.PRINT_BRIDGE_TOKEN || '';

const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB max request body
const SESSION_TTL_SECONDS = db.SESSION_TTL_MS / 1000; // keep in sync with db.js
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';

/**
 * The address to key a limit on.
 *
 * ⚠️ **X-Forwarded-For's first entry is the caller's, not the proxy's.** nginx's
 * `$proxy_add_x_forwarded_for` APPENDS the real peer to whatever the client
 * sent, so entry [0] is attacker-supplied — the comment on setupFromLoopback
 * below has said so for as long as that guard has existed, and used it as the
 * reason not to trust this function there. Every throttle in this file is keyed
 * on the value returned here, so reading [0] handed an attacker a fresh
 * identity per request and let them aim any per-address penalty at somebody
 * else.
 *
 * X-Real-IP instead: it is single-valued, and the nginx configuration in
 * DEPLOYMENT.md sets it from `$remote_addr` at every location, so a client
 * cannot prepend to it. Falling back to the socket keeps the direct-TLS
 * deployment (Path A, the recommended one) working unchanged, where
 * TRUST_PROXY is unset and no header is read at all.
 */
function getClientIP(req) {
  if (TRUST_PROXY) {
    const real = req.headers['x-real-ip'];
    if (typeof real === 'string' && real.trim()) return real.trim();
    // No X-Real-IP behind a proxy that was told to send one: the socket is
    // the proxy itself, so everyone shares a key. Said plainly rather than
    // silently, because a shared key turns every per-address limit into a
    // shared one — see the throttles' own comments.
    return (req.socket.remoteAddress || 'unknown') + '|proxy-ohne-real-ip';
  }
  return req.socket.remoteAddress || 'unknown';
}

/**
 * May this caller run first-time setup without the setup token?
 *
 * The loopback shortcut exists for the operator standing at the machine. Behind
 * a reverse proxy it stops meaning that: `proxy_pass http://localhost:3000`
 * makes the TCP peer 127.0.0.1 for *every* request, so the shortcut would wave
 * the entire network through to claim the first admin account on an
 * uninitialised install. Measured against a real proxy before this was pinned:
 * setup succeeded from a LAN address, and set TRUST_PROXY=true — exactly what
 * DEPLOYMENT.md tells a Path B operator to do — and it still succeeded.
 *
 * getClientIP() is deliberately NOT used here. It trusts the first
 * X-Forwarded-For entry, and nginx's $proxy_add_x_forwarded_for appends the
 * real address after the client's, so the first entry is attacker-supplied:
 * sending `X-Forwarded-For: 127.0.0.1` would restore the hole. When a proxy is
 * in front, there is no way to recognise the operator from the socket, so the
 * token — printed to the log on first start — becomes the only way in.
 */
function setupFromLoopback(remoteAddress, trustProxy) {
  if (trustProxy) return false;
  const ip = remoteAddress || '';
  return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
}

let database = db.openDb(DB_FILE);
// S-18: createUser rejects a name that would share a CalDAV calendar with an
// existing account, but a database created before that guard can already hold
// one. Those calendars fail closed in checkCalendarAccess, which is silent from
// the operator's side — so say it once at startup, with the names to rename.
for (const clash of db.findCaldavSlugCollisions(database)) {
  log('warn', 'Users share a CalDAV calendar name — their personal calendars are unreachable until renamed', clash);
}
let protocol = 'http'; // set to 'https' at startup if TLS certs are found
// I-17: serialize backup restores. Two admins kicking off concurrent
// /api/backup/restore requests would race on the close→rename→reopen path
// against the same DB file, leaving a corrupt or wrong-version DB. The
// flag is process-local (good enough — only one node process owns the DB).
let restoreInProgress = false;
if (!fs.existsSync(CAL_DIR)) fs.mkdirSync(CAL_DIR);

// ── First-run setup token (S-06) ───────────────────────────
// On a fresh deployment with zero users, the operator must either:
//   1) call /api/auth/setup from loopback (default for the START.bat flow), or
//   2) call /api/auth/setup with header `X-Setup-Token: <generated>`.
// The token is process-lifetime only (never persisted) and printed via the
// structured logger so PM2/journald operators can copy it. Once setup
// completes, the in-memory value is cleared so a stolen log line can't be
// reused.
let SETUP_TOKEN = null;
try {
  if (db.countUsers(database) === 0) {
    SETUP_TOKEN = crypto.randomBytes(32).toString('hex');
    log('warn', 'SETUP TOKEN: ' + SETUP_TOKEN, {
      hint: 'send X-Setup-Token header to POST /api/auth/setup, or call from localhost'
    });
  }
} catch (e) {
  log('error', 'countUsers failed at startup', { error: e.message });
}

// ── Constant-time login dummy hash (defense against username enumeration) ──
// If a login request hits a non-existent username, we still run a full scrypt
// against this throwaway hash so the response time matches a real user with
// a wrong password. Without this, attackers could enumerate valid accounts
// by measuring 10 ms (no scrypt) vs 50-200 ms (real scrypt) responses.
// Salt+hash are randomized per process, never persisted.
// S-14: minted through db.hashPassword so it carries the current parameters —
// a dummy that stayed on the old cost would answer faster than a real account
// and re-open the enumeration channel this exists to close.
const { salt: DUMMY_PASSWORD_SALT, hash: DUMMY_PASSWORD_HASH } = db.hashPassword(
  crypto.randomBytes(32).toString('hex')
);

// ── MCP (Model Context Protocol) server ────────────────────
// Each session gets its own McpServer + transport (SDK requires one server per transport).
const mcpSessions = new Map(); // sessionId → { transport, server, lastActive }
const MCP_SESSION_TTL = 30 * 60 * 1000; // 30 minutes
// Tear down all live MCP sessions. Each session's server captured the `database`
// handle at creation time; after a restore swaps `database`, those captured
// handles are closed/stale, so the sessions must be dropped and the clients
// forced to re-initialize against the new database.
function closeAllMcpSessions() {
  for (const session of mcpSessions.values()) {
    try {
      session.server.close();
    } catch (e) {
      /* best-effort */
    }
  }
  mcpSessions.clear();
}
setInterval(
  () => {
    const now = Date.now();
    for (const [sid, s] of mcpSessions) {
      if (now - s.lastActive > MCP_SESSION_TTL) {
        s.server.close().catch(() => {});
        mcpSessions.delete(sid);
      }
    }
  },
  5 * 60 * 1000
); // check every 5 minutes
// Clean up expired OAuth codes and revoked tokens every hour — and, since
// S-25, auto-registered clients that never completed a flow (see
// db.deleteExpiredOAuthData for why each condition is there).
setInterval(
  () => {
    try {
      db.deleteExpiredOAuthData(database);
    } catch (e) {
      log('error', 'OAuth cleanup failed', { error: e.message });
    }
  },
  60 * 60 * 1000
);

// S-01: returns { userId, role } on success, null on failure.
//   - Legacy static MCP token → role: 'admin' (preserves historical
//     behaviour; the static token has always granted full access).
//   - OAuth access token → role looked up from the OAuth user.
// S-08: passes `touchLastUsed:true` to bump the static token's
// audit timestamp on each verification, and uses Buffer.from(..., 'hex')
// explicitly so the encoding is unambiguous.
function checkMcpAuth(req) {
  const auth = req.headers.authorization || '';
  if (!auth.startsWith('Bearer ')) return null;
  const token = auth.slice(7);
  const hash = crypto.createHash('sha256').update(token).digest('hex');

  // Try legacy static API token
  const stored = db.getMcpToken(database);
  if (stored) {
    const a = Buffer.from(hash, 'hex');
    const b = Buffer.from(stored, 'hex');
    if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
      // S-17: after the comparison, never before it. Stamping on the way in
      // meant every failed bearer probe refreshed "last used", so an admin
      // deciding whether a token is still needed saw the attacker's traffic as
      // evidence that it was. It also made an unauthenticated request write to
      // the database.
      db.touchMcpTokenUsed(database);
      return { userId: null, role: 'admin', username: null };
    }
  }

  // Try OAuth access token — look up the user's current role.
  const oauthToken = db.getOAuthAccessToken(database, hash);
  if (oauthToken) {
    let role = 'user';
    let username = null;
    try {
      const user = database.prepare('SELECT role, username FROM users WHERE id = ?').get(oauthToken.userId);
      if (user && user.role) role = user.role;
      // The name as well as the role: the task filter asks whether a row is
      // assigned to *this person*, and an assignee is stored as a name. Without
      // it a worker's own private tasks would disappear from the briefing —
      // fail-closed, but wrong in a way they would report as data loss.
      if (user && user.username) username = user.username;
    } catch (_) {
      // best effort — fall through with role: 'user' (least privilege)
    }
    return { userId: oauthToken.userId, role, username };
  }

  return null;
}

// Simple sliding-window rate limiter (shared for MCP + OAuth endpoints)
const MCP_RATE_WINDOW = 60 * 1000;
const rateLimits = new Map(); // ip → { timestamps[] }
function checkRate(req, limit) {
  // R-08: respect TRUST_PROXY so the rate-limit key is the real client IP
  // when running behind a reverse proxy (otherwise everyone shares the
  // proxy's loopback address and trips the limit collectively).
  const ip = getClientIP(req);
  const now = Date.now();
  let bucket = rateLimits.get(ip);
  if (!bucket) {
    bucket = { timestamps: [] };
    rateLimits.set(ip, bucket);
  }
  bucket.timestamps = bucket.timestamps.filter((ts) => now - ts < MCP_RATE_WINDOW);
  if (bucket.timestamps.length >= limit) return false;
  bucket.timestamps.push(now);
  return true;
}
function checkMcpRate(req) {
  return checkRate(req, 60);
} // 60 req/min for MCP
function checkOAuthRate(req) {
  return checkRate(req, 20);
} // 20 req/min for OAuth endpoints
// Evict stale IPs every 10 minutes
setInterval(
  () => {
    const cutoff = Date.now() - MCP_RATE_WINDOW;
    for (const [ip, bucket] of rateLimits) {
      if (bucket.timestamps.every((ts) => ts < cutoff)) rateLimits.delete(ip);
    }
  },
  10 * 60 * 1000
);

// ── SSE (Server-Sent Events) for real-time multi-client sync ──
// Uses a Set for O(1) add/delete instead of array splice.
const sseClients = new Set();
// Set further down, once the database and the logger exist. Every write that
// tells the screens "data changed" also asks for a syringe recount — see
// syringe-stock.js for why that beats hooking each write path.
let syringeStock = null;
// Same moment for "bestellt, aber nicht da" (checkSaleAlerts below): an order
// sync ends here, so a new order is checked a few seconds after it arrives.
let saleAlertTimer = null;
const SALE_ALERT_DEBOUNCE_MS = 5000;
function broadcastSSE(excludeRes) {
  if (syringeStock) syringeStock.schedule();
  scheduleSaleAlerts();
  const msg = 'data: {"type":"data-changed"}\n\n';
  for (const c of sseClients) {
    if (c === excludeRes) continue;
    try {
      c.write(msg);
    } catch {
      sseClients.delete(c);
    }
  }
}
// A named event and nothing else. Every connected client gets it, admin or
// not, so it carries no details: a client that cares asks the matching route,
// which checks who is asking.
function broadcastSSEType(type) {
  const msg = 'data: ' + JSON.stringify({ type }) + '\n\n';
  for (const c of sseClients) {
    try {
      c.write(msg);
    } catch {
      sseClients.delete(c);
    }
  }
}
setInterval(() => {
  const hb = 'data: {"type":"heartbeat"}\n\n';
  for (const c of sseClients) {
    try {
      c.write(hb);
    } catch {
      sseClients.delete(c);
    }
  }
}, 15000);

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.json': 'application/json',
  '.js': 'application/javascript',
  '.css': 'text/css; charset=utf-8',
  '.png': 'image/png',
  '.ico': 'image/x-icon',
  '.svg': 'image/svg+xml'
};

// ── P-01: Pre-compressed static assets ─────────────────────────
// Pre-gzip + pre-brotli text/JS/CSS/HTML/JSON assets at startup so each
// request just streams the cached bytes. Skip images / favicon / PDFs:
// already-compressed payloads can't be gzipped meaningfully and brotli
// makes them slightly larger. Recompresses only when the source mtime
// is newer than the cached .gz/.br (avoid touching disk on every boot).
const COMPRESSIBLE_EXT = new Set(['.html', '.js', '.css', '.json', '.svg']);
function precompressOne(file) {
  if (!fs.existsSync(file)) return;
  const ext = path.extname(file).toLowerCase();
  if (!COMPRESSIBLE_EXT.has(ext)) return;
  const stat = fs.statSync(file);
  const data = fs.readFileSync(file);
  // gzip
  const gzPath = file + '.gz';
  const gzStale = !fs.existsSync(gzPath) || fs.statSync(gzPath).mtimeMs < stat.mtimeMs;
  if (gzStale) {
    try {
      fs.writeFileSync(gzPath, zlib.gzipSync(data, { level: 9 }));
    } catch (e) {
      log('warn', 'gzip precompress failed', { file, error: e.message });
    }
  }
  // brotli (Node 11+)
  if (typeof zlib.brotliCompressSync === 'function') {
    const brPath = file + '.br';
    const brStale = !fs.existsSync(brPath) || fs.statSync(brPath).mtimeMs < stat.mtimeMs;
    if (brStale) {
      try {
        fs.writeFileSync(
          brPath,
          zlib.brotliCompressSync(data, {
            params: {
              [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
              [zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length
            }
          })
        );
      } catch (e) {
        log('warn', 'brotli precompress failed', { file, error: e.message });
      }
    }
  }
}
function precompressStaticAssets() {
  const root = DIR;
  const files = ['app.js', 'styles.css', 'index.html', 'login.html', 'login.js', 'manifest.json'];
  for (const f of files) precompressOne(path.join(root, f));
  // lib/* — recurse one level
  const libDir = path.join(root, 'lib');
  if (fs.existsSync(libDir)) {
    for (const entry of fs.readdirSync(libDir)) {
      if (entry.endsWith('.gz') || entry.endsWith('.br')) continue;
      precompressOne(path.join(libDir, entry));
    }
  }
  // lang/* — pre-compress the per-locale files (added by P-03)
  const langDir = path.join(root, 'lang');
  if (fs.existsSync(langDir)) {
    for (const entry of fs.readdirSync(langDir)) {
      if (entry.endsWith('.gz') || entry.endsWith('.br')) continue;
      precompressOne(path.join(langDir, entry));
    }
  }
}
// ── App-Stand ───────────────────────────────────────────────────
// Die Dateien, die zusammen die App im Browser sind, und ein Fingerabdruck über
// sie. Ändert sich eine davon — START.bat zieht main und startet neu —, ändert
// sich der Stand, und jedes offene Telefon merkt, dass es alten Code ausführt:
// der Stand steht in /api/data, in der ersten Zeile des Ereignisstroms und als
// X-App-Build an jeder dieser Dateien. Vorher lief eine App, die im Hintergrund
// offen blieb, tagelang mit dem Code von vor dem Deploy weiter, und niemand
// konnte es sehen.
//
// Je Datei wird nur neu gerechnet, wenn sich Größe oder Änderungszeit bewegt
// haben; ein Aufruf kostet sonst ein paar stat()-Aufrufe.
function shellFiles() {
  const out = ['index.html', 'app.js', 'styles.css', 'sw.js'];
  try {
    for (const f of fs.readdirSync(path.join(DIR, 'lang')).sort()) if (f.endsWith('.js')) out.push('lang/' + f);
  } catch {
    /* no lang dir — nothing to add */
  }
  return out;
}
const _fileTags = new Map();
function fileTag(absPath) {
  const st = fs.statSync(absPath);
  const hit = _fileTags.get(absPath);
  if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.tag;
  const tag = crypto.createHash('sha256').update(fs.readFileSync(absPath)).digest('hex').slice(0, 16);
  _fileTags.set(absPath, { mtimeMs: st.mtimeMs, size: st.size, tag });
  return tag;
}
function appBuild() {
  const h = crypto.createHash('sha256');
  for (const f of shellFiles()) {
    let tag = 'missing';
    try {
      tag = fileTag(path.join(DIR, f));
    } catch {
      /* a file that cannot be read counts as missing */
    }
    h.update(f + ':' + tag + '\n');
  }
  return h.digest('hex').slice(0, 12);
}
// Die URLs dieser Dateien. Sie werden mit ETag und "no-cache" ausgeliefert
// statt fünf Minuten lang ungefragt aus dem Browser-Cache: ein Neuladen gleich
// nach dem Deploy hätte sonst das alte app.js bekommen, und das Neuladen, das
// den neuen Stand holen soll, hätte den alten geholt.
function isShellUrl(url) {
  return (
    url === '/' ||
    url === '/index.html' ||
    url === '/app.js' ||
    url === '/styles.css' ||
    url === '/sw.js' ||
    /^\/lang\/[a-zA-Z-]+\.js$/.test(url)
  );
}
// If-None-Match kann eine Liste sein, und "*" passt auf alles.
function etagMatches(header, tag) {
  if (!header || !tag) return false;
  return String(header)
    .split(',')
    .map((s) => s.trim())
    .some((s) => s === '*' || s === tag);
}

// Files already reported as having a stale compressed copy, so the warning is
// logged once per asset per boot instead of once per request.
const _staleCompressed = new Set();

// Pick the best encoding based on Accept-Encoding. Returns { encoding, path }
// or null if the client doesn't accept any pre-compressed variant, or the
// compressed variant is missing — or is older than the source it was made
// from.
//
// That last case is the one that bites. precompressStaticAssets() runs at
// startup and nowhere else, so editing app.js under a running server leaves
// last boot's app.js.br on disk. Serving it silently hands every browser the
// previous build: the edit is on disk, the file mtime is new, a hard reload
// changes nothing, and the only symptom is that the change appears not to have
// been made. Compare mtimes and drop to the uncompressed source instead —
// bigger on the wire, but it is the code the operator actually wrote. Restart
// to get compression back.
function pickEncoding(acceptEncoding, filePath) {
  if (!acceptEncoding) return null;
  const ae = String(acceptEncoding).toLowerCase();
  let srcMtime;
  try {
    srcMtime = fs.statSync(filePath).mtimeMs;
  } catch {
    // No source to compare against — let the caller read filePath and 404.
    return null;
  }
  const usable = (variant) => {
    let s;
    try {
      s = fs.statSync(variant);
    } catch {
      return false; // never precompressed, or cleaned away
    }
    if (s.mtimeMs >= srcMtime) return true;
    if (!_staleCompressed.has(variant)) {
      _staleCompressed.add(variant);
      log('warn', 'Stale pre-compressed asset, serving uncompressed — restart to recompress', {
        file: path.basename(variant)
      });
    }
    return false;
  };
  if (ae.includes('br') && usable(filePath + '.br')) {
    return { encoding: 'br', path: filePath + '.br' };
  }
  if (ae.includes('gzip') && usable(filePath + '.gz')) {
    return { encoding: 'gzip', path: filePath + '.gz' };
  }
  return null;
}

// The names and addresses the certificate on disk actually vouches for.
//
// Read per call rather than cached at boot: the ACME renewal rewrites the file
// under a running process, and a stale answer here would be worse than none —
// it would state confidently that an address is covered when it stopped being
// covered an hour ago.
function certCoveredHosts() {
  try {
    if (!fs.existsSync(CERT_CRT)) return [];
    const x = new crypto.X509Certificate(fs.readFileSync(CERT_CRT));
    // subjectAltName reads "DNS:a, IP Address:1.2.3.4"; the CN is the fallback
    // for certificates old enough to predate SAN being mandatory.
    const san = x.subjectAltName || '';
    const names = [...san.matchAll(/(?:DNS|IP Address):([^,]+)/g)].map((m) => m[1].trim());
    if (names.length) return names;
    const cn = (x.subject || '').match(/CN=([^\n,]+)/);
    return cn ? [cn[1].trim()] : [];
  } catch {
    // An unreadable or malformed certificate is not something this function
    // should turn into a boot failure — it exists to print a hint.
    return [];
  }
}

// The issuer of every certificate Let's Encrypt signs carries this line.
// X509Certificate#issuer gives one attribute per line.
const LE_ISSUER = /^O=Let's Encrypt$/m;

// What the certificate on disk says about itself: when it expires, and whether
// Let's Encrypt issued it. null when there is none or it cannot be read. It
// never throws: checkCertRenewal() runs while the server loads.
//
// The issuer decides, not the PEM text. PEM is base64, so "Let's Encrypt" never
// appears in it, and a needle like "R3" turns up by chance: measured, about one
// self-signed certificate in six contains one of the old needles.
function certOnDisk() {
  try {
    if (!fs.existsSync(CERT_CRT)) return null;
    const x = new crypto.X509Certificate(fs.readFileSync(CERT_CRT));
    const validTo = new Date(x.validTo);
    return { validTo: isNaN(validTo) ? null : validTo, letsEncrypt: LE_ISSUER.test(x.issuer || '') };
  } catch {
    return null;
  }
}

/** Which of `hosts` the certificate does NOT cover. Empty when it covers them all. */
function certUncoveredHosts(hosts) {
  const covered = certCoveredHosts();
  if (!covered.length) return []; // nothing to compare against; stay quiet
  const matches = (host) =>
    covered.some((c) => {
      if (c === host) return true;
      // A wildcard covers exactly one label: *.example.com matches a.example.com
      if (c.startsWith('*.')) return host.endsWith(c.slice(1)) && host.split('.').length === c.split('.').length;
      return false;
    });
  return hosts.filter((h) => h && !matches(h));
}

function getLocalIP() {
  for (const ifaces of Object.values(os.networkInterfaces()))
    for (const i of ifaces) if (i.family === 'IPv4' && !i.internal) return i.address;
  return 'localhost';
}

function readData() {
  return db.readAll(database, { inventoryLogLimit: 500 });
}

function writeData(data, wer) {
  db.writeAll(database, data, { wer });
}

// The request body parsed as JSON, whatever its shape: an array, a string,
// null. Only for routes that check the shape themselves: /mcp, where a JSON-RPC
// batch is an array and the SDK answers anything else malformed, and the order
// import, which also takes a bare array of orders. Every other route uses
// jsonBody.
function anyJsonBody(req, res, cb) {
  const body = [];
  let sz = 0;
  let aborted = false;
  req.on('data', (c) => {
    sz += c.length;
    if (sz > MAX_BODY_SIZE) {
      aborted = true;
      jsonErr(res, 413, 'Payload too large');
      req.destroy();
      return;
    }
    // Collect Buffers and decode once at the end. `body += c` decoded each chunk
    // independently, so a multi-byte character split across a ~64 KiB stream
    // boundary became two U+FFFD replacement chars — "Kräuterseitling" arriving
    // in two pieces persisted as "Kr��uterseitling", still valid JSON,
    // so it reached the database with no error. In a German-language app every
    // umlaut in a body over one chunk was at risk.
    body.push(c);
  });
  req.on('end', () => {
    if (aborted) return;
    let parsed;
    try {
      parsed = JSON.parse(Buffer.concat(body).toString('utf8'));
    } catch (e) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end('{"error":"bad json"}');
      return;
    }
    // Many routes validate `data` before their own try opens, so the route itself
    // can throw here. Uncaught, that would reach the process-level
    // uncaughtException handler, which only logs, and the request would never be
    // answered: Node's requestTimeout stops counting once the body is in.
    try {
      cb(null, parsed);
    } catch (err) {
      if (!res.headersSent) {
        safeErr(res, err);
        return;
      }
      // Too late for a status code. Log it, as uncaughtException would have, and
      // cut off a half-written response rather than leave it open.
      log('error', 'Unexpected error after the response started', {
        error: String((err && err.message) || ''),
        stack: err && err.stack
      });
      if (!res.writableEnded) res.destroy();
    }
  });
}
// The request body as a JSON object, for every route not on anyJsonBody. Most
// read `data.<field>` without checking what `data` is, so null would throw, and
// a number, a string or an array would pass as a body with no fields: a 200
// that changed nothing.
function jsonBody(req, res, cb) {
  anyJsonBody(req, res, (e, data) => {
    if (data === null || typeof data !== 'object' || Array.isArray(data)) {
      jsonErr(res, 400, 'Request body must be a JSON object');
      return;
    }
    cb(null, data);
  });
}
function formBody(req, res, cb) {
  const body = [];
  let sz = 0;
  let aborted = false;
  req.on('data', (c) => {
    sz += c.length;
    if (sz > MAX_BODY_SIZE) {
      aborted = true;
      jsonErr(res, 413, 'Payload too large');
      req.destroy();
      return;
    }
    // See anyJsonBody: decode once, or a multi-byte char split across a chunk
    // boundary is silently replaced with U+FFFD.
    body.push(c);
  });
  req.on('end', () => {
    if (aborted) return;
    let parsed;
    try {
      const params = new URLSearchParams(Buffer.concat(body).toString('utf8'));
      parsed = Object.fromEntries(params.entries());
    } catch (e) {
      jsonErr(res, 400, 'bad form data');
      return;
    }
    cb(null, parsed);
  });
}

function verifyPkce(codeVerifier, storedCodeChallenge) {
  return crypto.createHash('sha256').update(codeVerifier).digest('base64url') === storedCodeChallenge;
}

// Track whether we've already warned about a missing PUBLIC_HOSTNAME so the
// log line doesn't repeat on every request.
let _hostHeaderWarned = false;
function getBaseUrl(req) {
  const reqHost = req.headers.host || 'localhost:' + PORT;
  const expected = (process.env.PUBLIC_HOSTNAME || '').trim();
  if (expected) {
    // PUBLIC_HOSTNAME may be host or host:port — normalise both sides for
    // comparison. If the request's Host header doesn't match, return the
    // configured hostname instead of reflecting attacker-controlled input
    // into OAuth issuer metadata. We don't 502 because legitimate
    // localhost-from-LAN access still has to work.
    const reqHostName = reqHost.split(':')[0].toLowerCase();
    const expHostName = expected.split(':')[0].toLowerCase();
    if (reqHostName !== expHostName) {
      return protocol + '://' + expected;
    }
    return protocol + '://' + reqHost;
  }
  if (!_hostHeaderWarned) {
    _hostHeaderWarned = true;
    log('warn', 'PUBLIC_HOSTNAME not configured — Host header reflected into OAuth metadata', {});
  }
  return protocol + '://' + reqHost;
}

function jsonOk(res, data) {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data || { ok: true }));
}
function jsonErr(res, code, msg) {
  res.writeHead(code, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: msg }));
}
/**
 * A whole number inside a range, with a fallback for anything unusable.
 *
 * Clamping rather than rejecting: these are interval and window settings from a
 * number field, where "0 days" or "every 0 minutes" is a slip, not an attack.
 * Silently taking the nearest sane value beats an error message about a field
 * the person did not mean to touch.
 */
function clampInt(raw, min, max, fallback) {
  const n = parseInt(raw, 10);
  if (!Number.isFinite(n)) return fallback;
  return Math.min(max, Math.max(min, n));
}
// Safe error response: log internals, send generic message to client for
// unexpected errors. R-23: classifier lives in db.js (db.isSafeError) since
// the allowlist is fundamentally a registry of every validator message db.js
// emits — keeping it next to the throw sites makes drift easier to catch.
function safeErr(res, err) {
  const msg = String((err && err.message) || '');
  if (db.isSafeError(msg)) {
    jsonErr(res, 400, msg);
  } else {
    log('error', 'Unexpected error', { error: msg, stack: err && err.stack });
    jsonErr(res, 500, 'Internal server error');
  }
}

// ── REQUEST VALIDATION ──────────────────────────────────────
// Returns null if valid, or an error string if invalid.
function validateRequired(data, fields) {
  if (!data || typeof data !== 'object') return 'Request body must be a JSON object';
  for (const f of fields) {
    if (data[f] === undefined || data[f] === null || data[f] === '') return 'Missing required field: ' + f;
  }
  return null;
}
function validateTypes(data, schema) {
  for (const [field, type] of Object.entries(schema)) {
    if (data[field] === undefined || data[field] === null) continue;
    if (type === 'number' && typeof data[field] !== 'number') return field + ' must be a number';
    if (type === 'string' && typeof data[field] !== 'string') return field + ' must be a string';
    if (type === 'boolean' && typeof data[field] !== 'boolean') return field + ' must be a boolean';
    if (type === 'array' && !Array.isArray(data[field])) return field + ' must be an array';
  }
  return null;
}
// Validate numeric ranges: {field: {min, max}} — skips null/undefined fields
function validateRanges(data, ranges) {
  for (const [field, { min, max }] of Object.entries(ranges)) {
    const v = data[field];
    if (v === undefined || v === null) continue;
    if (typeof v !== 'number' || !Number.isFinite(v)) return field + ' must be a finite number';
    if (min !== undefined && v < min) return field + ' must be >= ' + min;
    if (max !== undefined && v > max) return field + ' must be <= ' + max;
  }
  return null;
}
// Validate string max lengths: {field: maxLen}
function validateLengths(data, limits) {
  for (const [field, maxLen] of Object.entries(limits)) {
    const v = data[field];
    if (v === undefined || v === null) continue;
    if (typeof v === 'string' && v.length > maxLen) return field + ' exceeds max length of ' + maxLen;
  }
  return null;
}
// Die Anteile, mit denen ein Ansatz vom Rezept der Sorte abweichen darf.
//
// Die Grenzen sind weit gefasst: sie fangen den Tippfehler ab (700 statt 70,
// ein leeres Feld als 0 % Feuchte), sie schreiben nicht vor, was ein sinnvolles
// Rezept ist. Ob die Zahlen zusammen aufgehen — Kleie und Mais unter 100 %,
// Zielfeuchte über der Restfeuchte der Sackware — entscheidet computeMixBatch,
// wo die Rechnung steht, die sonst daran scheitern würde.
function validateMixOverride(mix) {
  if (mix === undefined || mix === null) return null;
  if (typeof mix !== 'object' || Array.isArray(mix)) return 'mix must be an object';
  return validateRanges(mix, {
    branPct: { min: 0, max: 95 },
    cornPct: { min: 0, max: 95 },
    gypsumPct: { min: 0, max: 20 },
    calciumPct: { min: 0, max: 20 },
    moisturePct: { min: 20, max: 90 }
  });
}
// A substrate mix names its recipe one of two ways: a stored mix (recipeId) or,
// the older path, a Sorte whose recipe is used (recipeStrainId).
function mixSourceMissing(data) {
  if (data.recipeId == null && data.recipeStrainId == null) return 'Missing required field: recipeId';
  return null;
}
// Validate ISO date strings (YYYY-MM-DD or full ISO)
function validateDate(value, fieldName) {
  if (!value) return null;
  const d = new Date(value);
  if (isNaN(d.getTime())) return fieldName + ' is not a valid date';
  return null;
}
// A calendar day as a date input sends it: YYYY-MM-DD, and a day that exists.
// validateDate accepts whatever new Date() accepts, which includes 5, [2026] and
// 2026-02-31. Unlike the others this one does not let an empty value through;
// the caller decides whether a field may be left out.
function validateDay(value, fieldName) {
  const m = typeof value === 'string' && value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (m) {
    const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
    if (d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]) return null;
  }
  return fieldName + ' must be a date (YYYY-MM-DD)';
}
// Validate 24-hour time strings in HH:MM format
function validateTimeOfDay(value, fieldName) {
  if (!value) return null;
  if (typeof value !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) {
    return fieldName + ' must be a HH:MM time';
  }
  return null;
}
// Validate enum membership
// Culture types and statuses, as the client renders them into badges. The five
// types mirror VALID_CULTURE_PARENT_TYPES in db.js — GS included, which the MCP
// tool's shorter enum leaves out.
const CULTURE_TYPES = ['MC', 'PD', 'LC', 'G2G', 'GS', 'SY', 'DU'];
const CULTURE_STATUSES = ['active', 'stored', 'used', 'sold', 'contam'];
// The batch kinds the app knows, mirroring the buckets getProductionPipeline
// seeds. 'block' is the column default, so an omitted batchType is fine.
const BATCH_TYPES = ['block', 'grain', 'liquid'];
function validateEnum(value, allowed, fieldName) {
  if (value === undefined || value === null) return null;
  if (!allowed.includes(value)) return fieldName + ' must be one of: ' + allowed.join(', ');
  return null;
}
// Defense-in-depth content validation. Server-rendered fields elsewhere (and
// some innerHTML interpolations on the client) trust these strings, so we
// pin the character set + length up-front. Mirrors the batchId regex used
// by /api/batches.
const ID_CHARSET_RE = /^[A-Za-z0-9_\-@.:]+$/;
// S-16: the charset and length every account is created with — /api/auth/setup
// and POST /api/users both enforce it, and createUser is the only writer of the
// users table, so a name that fails this cannot belong to a real account. It is
// applied on the way *in* as well now: the login handler used to take the
// username straight from a body that may be up to 5 MB and make it a Map key in
// two brute-force tables, where the 60-second sweep leaves it for the full
// 15-minute lockout window.
// S-25: what an unauthenticated registration may carry. Generous against any
// real client, small against a five-megabyte body sent twenty times a minute.
const OAUTH_MAX_CLIENT_NAME = 128;
const OAUTH_MAX_REDIRECT_URIS = 5;
const OAUTH_MAX_URI_LENGTH = 512;

const USERNAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
// S-22: there was a minimum (8) and no maximum anywhere, so MAX_BODY_SIZE was
// the only bound on what reached scrypt — five megabytes of it. Set where the
// password is *chosen* as well as where it is checked, because a cap that only
// one of the two knows about is a lockout waiting to happen. Far above any real
// passphrase; the point is the megabyte, not the sentence.
const PASSWORD_MAX_LENGTH = 1024;
function validateMushroomStrain(data) {
  if (!data || typeof data !== 'object') return 'Request body must be a JSON object';
  if (data.name !== undefined && data.name !== null) {
    if (typeof data.name !== 'string') return 'name must be a string';
    if (data.name.length > 200) return 'name exceeds max length of 200';
  }
  if (data.kuerzel !== undefined && data.kuerzel !== null) {
    if (typeof data.kuerzel !== 'string') return 'kuerzel must be a string';
    if (data.kuerzel.length > 16) return 'kuerzel exceeds max length of 16';
    if (data.kuerzel.length > 0 && !/^[A-Za-z0-9_\-]+$/.test(data.kuerzel)) {
      return 'kuerzel must be alphanumeric with - or _';
    }
  }
  if (data.description !== undefined && data.description !== null) {
    if (typeof data.description !== 'string') return 'description must be a string';
    if (data.description.length > 2000) return 'description exceeds max length of 2000';
  }
  // Whether the farm is growing this Sorte at the moment (v78). Only a real
  // boolean is accepted: db.js stores `imProgramm ? 1 : 0`, so a string "false"
  // would be truthy and switch the flag the wrong way round without a word.
  if (data.imProgramm !== undefined && data.imProgramm !== null && typeof data.imProgramm !== 'boolean') {
    return 'imProgramm must be a boolean';
  }
  return null;
}
const SCAN_ACTIONS = ['ADD', 'MOVE', 'MOVE_BATCH', 'REMOVE', 'CONTAM'];
function validateScanEntries(entries) {
  if (!Array.isArray(entries)) return 'entries must be an array';
  if (entries.length > 1000) return 'too many entries (max 1000 per request)';
  for (let i = 0; i < entries.length; i++) {
    const e = entries[i];
    if (!e || typeof e !== 'object') return 'entry[' + i + '] must be an object';
    if (!SCAN_ACTIONS.includes(e.action)) {
      return 'entry[' + i + '].action must be one of: ' + SCAN_ACTIONS.join(', ');
    }
    if (!e.time || typeof e.time !== 'string' || isNaN(new Date(e.time).getTime())) {
      return 'entry[' + i + '].time must be an ISO timestamp';
    }
    // Canonicalise, do not merely accept. `new Date()` takes far more than ISO,
    // and V8's legacy parser ignores a trailing parenthesised group — so
    // "Aug 19 2099 12:00:00 GMT+0000 (' + fetch(...) + ')" is a *valid* date and
    // was stored verbatim. The client renders time into markup, which is the
    // same reason the charset pin below exists for batch/bag/from/to; time was
    // the field that list forgot. Writing the ISO form makes the charset a
    // property of the stored row instead of a promise about the caller.
    // Real clients already send `new Date().toISOString()`, so this changes
    // nothing for them — and it repairs the log's string-compare date filters
    // for anything that did not.
    e.time = new Date(e.time).toISOString();
    // Optional ID-like fields — pin charset + length to prevent stored XSS via
    // raw rendering in the client.
    for (const f of ['batch', 'bag', 'from', 'to', 'expected_current_zone']) {
      const v = e[f];
      if (v === undefined || v === null || v === '') continue;
      if (typeof v !== 'string') return 'entry[' + i + '].' + f + ' must be a string';
      if (v.length > 100) return 'entry[' + i + '].' + f + ' exceeds max length of 100';
      if (!ID_CHARSET_RE.test(v)) {
        return 'entry[' + i + '].' + f + ' must be alphanumeric with - _ @ . :';
      }
    }
    // Free-text-ish fields — only length-capped.
    for (const [f, max] of [
      ['species', 200],
      ['strain', 200],
      ['reason', 2000]
    ]) {
      const v = e[f];
      if (v === undefined || v === null) continue;
      if (typeof v !== 'string') return 'entry[' + i + '].' + f + ' must be a string';
      if (v.length > max) return 'entry[' + i + '].' + f + ' exceeds max length of ' + max;
    }
    if (e.client_uuid !== undefined && e.client_uuid !== null && e.client_uuid !== '') {
      if (typeof e.client_uuid !== 'string') return 'entry[' + i + '].client_uuid must be a string';
      if (e.client_uuid.length > 64) return 'entry[' + i + '].client_uuid exceeds max length of 64';
      if (!/^[A-Za-z0-9_\-]+$/.test(e.client_uuid)) {
        return 'entry[' + i + '].client_uuid must be alphanumeric with - or _';
      }
    }
  }
  return null;
}
// ── Sales-channel OAuth (eBay auth-code, Etsy PKCE) ──────────────────────────
// The login round-trip must hit the public callback registered with the provider
// (https://meistertracker.com/api/channels/<ch>/oauth/callback), so it only
// completes on prod or a tunnel that serves that host. PUBLIC_BASE_URL lets a
// tunnel override the host. State is held in memory for the brief authorize→
// callback window — a server restart mid-login just means clicking "Verbinden" again.
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://meistertracker.com').replace(/\/+$/, '');
const _channelOAuth = new Map(); // state -> { channel, codeVerifier, created }
// Order ids with a label purchase currently in flight — blocks a concurrent second
// buy (double-click / retry / replay) from billing the owner twice for one order.
const _labelBuysInFlight = new Set();
function _channelCallbackUrl(channel) {
  return PUBLIC_BASE_URL + '/api/channels/' + channel + '/oauth/callback';
}
function _oauthGc() {
  const cutoff = Date.now() - 15 * 60 * 1000;
  for (const [k, v] of _channelOAuth) if (v.created < cutoff) _channelOAuth.delete(k);
}
// Refresh a channel's OAuth access token if it's within 2 min of expiry, persist the
// new token, and return the fresh config + provider. No-op for API-key channels (Wix).
async function withFreshChannelToken(channel) {
  let cfg = db.getChannelConfig(database, channel);
  const prov = channels.getChannelProvider(channel);
  // Treat a missing/unparseable expiry as "needs refresh" so a stale token is never
  // used — a NaN must not silently skip the refresh (which would 401 the provider call).
  const exp = Date.parse(cfg.tokenExpires || '');
  const needsRefresh = isNaN(exp) || exp - Date.now() < 120000;
  if (typeof prov.refreshAccessToken === 'function' && cfg.refreshToken && needsRefresh) {
    const tok = await prov.refreshAccessToken(cfg);
    db.updateChannelConfig(database, channel, tok);
    cfg = db.getChannelConfig(database, channel);
  }
  return { cfg, prov };
}
// At most this many orders are handed to Billbee in one sync. Billbee's throttle
// spaces the calls about 0.55s apart, so 50 is roughly half a minute — enough to
// clear a backlog over a few polls, short enough that the first sync after
// connecting Billbee does not sit there for ten minutes.
const BILLBEE_PUSH_PER_RUN = 50;

/**
 * Hand Wix orders to Billbee.
 *
 * Billbee reads every other shop through its own API and needs nothing from this
 * server. Wix is the exception: Billbee's Wix connector parses the order mail Wix
 * sends the merchant, and that mail carries no order data at all — so left alone,
 * a Wix sale reaches Billbee only if somebody types it in by hand. This server
 * does read the Wix API, so it forwards what it finds. See BILLBEE_SUPERSEDES in
 * db.js for why Billbee cannot simply be pointed at Wix instead.
 *
 * Every Wix order the sync saw is a candidate, not only the newly imported ones,
 * because the marks in `meta` are what decide. So an order whose push failed last
 * time is retried on its own, and connecting Billbee later backfills the history
 * that predates it. Best-effort throughout, like pushTracking: one order Billbee
 * refuses must not stop the rest, and none of it may fail the sync.
 */
async function pushWixOrdersToBillbee(channel, orders) {
  if (channel !== 'wix') return { pushed: 0, error: null };
  const cfg = db.getChannelConfig(database, 'billbee');
  if (!cfg.enabled || !cfg.apiKey || !cfg.clientId || !cfg.clientSecret) return { pushed: 0, error: null };
  const waiting = orders.filter(
    (o) => o.channel === 'wix' && o.channelOrderId != null && !db.billbeePushedAt(database, 'wix', o.channelOrderId)
  );
  if (!waiting.length) return { pushed: 0, error: null };
  // Oldest first, so a backlog is filed in the order the sales actually happened.
  waiting.sort((a, b) => String(a.orderDate || '').localeCompare(String(b.orderDate || '')));
  const prov = channels.getChannelProvider('billbee');
  let pushed = 0;
  let error = null;
  let inARow = 0;
  for (const o of waiting.slice(0, BILLBEE_PUSH_PER_RUN)) {
    try {
      await prov.pushOrder(cfg, o);
      // Marked only once Billbee has taken it. An order stays unmarked on any
      // failure, and that is exactly what makes the next run pick it up again.
      db.markBillbeePushed(database, 'wix', o.channelOrderId);
      pushed++;
      inARow = 0;
    } catch (e) {
      if (!error) error = e.message || 'Billbee-Push fehlgeschlagen';
      log('warn', 'Billbee push failed', { order: String(o.channelOrderId), error: e.message });
      // Three failures in a row is not three bad orders — it is a key that was
      // never approved, or a Billbee that is down. Stop rather than spend the next
      // half minute proving it 47 more times; nothing was marked, so the next sync
      // resumes here.
      if (++inARow >= 3) break;
    }
  }
  if (pushed) log('info', 'Pushed Wix orders to Billbee', { pushed });
  return { pushed, error };
}

/**
 * Pull one channel's orders and commit them. Never throws: the outcome is the
 * return value, because both callers — the button and the timer below — have to
 * record it rather than crash on it.
 *
 * Fetch pages first (network I/O), then commit them in ONE transaction: one
 * fsync instead of one per order, and no write-lock held across the network
 * round-trips. upsertOrder dedupes by (channel, channelOrderId). A mid-run page
 * error (e.g. a 429) is captured, not thrown, so the pages already fetched still
 * get committed instead of the whole run being discarded.
 */
async function syncChannelOrders(channel, excludeRes) {
  let imported = 0;
  // Billbee stands in for the direct connections while it is on — see
  // BILLBEE_SUPERSEDES. Refused here rather than in the two callers so the timer
  // and the "Jetzt synchronisieren" button are held to the same rule: a button
  // that could still import the doubles the timer is avoiding would be a hole in
  // the shape of a person having a bad day.
  const cfgs = db.listChannelConfigs(database);
  if ((cfgs.find((c) => c.channel === channel) || {}).supersededBy === 'billbee') {
    const error = 'Aus, solange Billbee an ist: die Bestellungen kommen von dort';
    // lastSync and lastCursor are passed back unchanged: setChannelSyncState
    // writes null for anything left undefined, and nothing was synced here, so
    // stamping "zuletzt: jetzt" over the real last sync would be a lie.
    const prev = db.getChannelConfig(database, channel);
    db.setChannelSyncState(database, channel, {
      lastSync: prev.lastSync,
      lastCursor: prev.lastCursor,
      lastError: error
    });
    return { imported: 0, error, supersededBy: 'billbee' };
  }
  try {
    const { cfg, prov } = await withFreshChannelToken(channel);
    let cursor = null;
    let pages = 0;
    const collected = [];
    let fetchErr = null;
    try {
      do {
        const { orders, nextCursor } = await prov.fetchOrders(cfg, { cursor });
        for (const o of orders) collected.push(o);
        cursor = nextCursor;
        pages++;
      } while (cursor && pages < 20);
    } catch (ePage) {
      fetchErr = ePage;
    }
    database.exec('BEGIN');
    try {
      for (const o of collected) {
        // A savepoint per order keeps a malformed one from poisoning the batch:
        // it rolls back just that order, and the rest still commit together.
        try {
          db.withSavepoint(database, 'mt_order', () => db.upsertOrder(database, o));
          imported++;
        } catch (e2) {
          /* this order is skipped; the others are kept */
        }
      }
      database.exec('COMMIT');
    } catch (eTx) {
      try {
        database.exec('ROLLBACK');
      } catch (e4) {
        /* ignore */
      }
      throw eTx;
    }
    // Wix orders travel on to Billbee, which has no way to fetch them itself.
    // After the commit and outside the transaction: it is network I/O, and a
    // Billbee that is switched off or refusing must never cost an order that is
    // already safely imported here.
    const push = await pushWixOrdersToBillbee(channel, collected);
    // Partial success: earlier pages are committed. Record and report the error,
    // but keep the imported orders rather than throwing the whole run away
    // because a later page failed.
    const error = fetchErr ? fetchErr.message || 'sync failed' : null;
    db.setChannelSyncState(database, channel, { lastSync: new Date().toISOString(), lastError: error });
    broadcastSSE(excludeRes);
    return { imported, error, pushedToBillbee: push.pushed, billbeeError: push.error };
  } catch (err) {
    const error = err.message || 'sync failed';
    db.setChannelSyncState(database, channel, { lastSync: new Date().toISOString(), lastError: error });
    return { imported, error };
  }
}

/**
 * Fetch every switched-on channel on a timer.
 *
 * Until now a sales channel was pulled only when somebody opened Settings and
 * pressed "Jetzt synchronisieren" — so the production planning knew about an
 * order when a human remembered to ask, which is not a plan. Being switched on is
 * the statement that this channel's orders belong here; the timer is what makes
 * that true without anybody's memory.
 *
 * Sequential, one channel after another: they share the outbound connection and
 * Billbee's throttle is per account, so two at once buys nothing and risks a 429.
 * Skipped in worktree mode like the harvest feed — a second copy of the server
 * carries the same credentials and would double every import.
 */
const CHANNEL_POLL_MS = 15 * 60 * 1000;
let _channelPollTimer = null;
let _channelPollBusy = false;
async function pollSalesChannels() {
  if (_channelPollBusy) return;
  _channelPollBusy = true;
  try {
    for (const c of db.listChannelConfigs(database)) {
      if (!c.enabled || !c.connected) continue;
      // syncChannelOrders would refuse these anyway; skipping them here keeps the
      // refusal out of the log every quarter of an hour for as long as Billbee is
      // on. The settings page reads the same flag, so it stays visible where
      // somebody would look for it.
      if (c.supersededBy) continue;
      const r = await syncChannelOrders(c.channel);
      if (r.error) log('warn', 'Channel poll failed', { channel: c.channel, error: r.error });
      else if (r.imported) log('info', 'Channel poll imported orders', { channel: c.channel, imported: r.imported });
    }
  } finally {
    _channelPollBusy = false;
  }
}
function startChannelPoll() {
  if (WORKTREE_MODE) return false;
  if (_channelPollTimer) clearInterval(_channelPollTimer);
  _channelPollTimer = setInterval(() => {
    pollSalesChannels().catch((e) => log('warn', 'Channel poll errored', { error: e.message }));
  }, CHANNEL_POLL_MS);
  if (_channelPollTimer.unref) _channelPollTimer.unref();
  return true;
}
startChannelPoll();

// Liquid-culture syringes → Billbee → shops. Off until switched on in the
// settings, and a no-op in worktree mode like the poll above.
syringeStock = syringeStockModule.createSyringeStockSync({
  getDatabase: () => database,
  dbApi: db,
  channels,
  log,
  skip: WORKTREE_MODE
});
syringeStock.start();

// ── Nachproduzieren: an order the lab cannot cover ──
// Every signed-in user gets it in the bell, once per new order of that article
// (db.saleAlerts remembers what it announced). Only that, not "under target":
// a target is planning and lives on the Labor page; an order that cannot ship
// is waiting for someone. Checked shortly after any data change, and every
// quarter of an hour for what changed without one (a bag that finished, an
// order shipped elsewhere). A worktree copy stays quiet, like the sync above.
const SALE_ALERT_EVERY_MS = 15 * 60 * 1000;
function checkSaleAlerts() {
  if (WORKTREE_MODE) return;
  try {
    const neu = db.saleAlerts(database);
    if (!neu.length) return;
    const users = db.listUsers(database);
    for (const a of neu) {
      for (const u of users) {
        db.createNotificationOnce(database, {
          userId: u.id,
          type: 'sale_uncovered',
          title: a.title,
          body: a.body,
          linkType: 'sale',
          linkId: a.strainId + '/' + a.slot
        });
      }
    }
    log('info', 'Sale alerts raised', { articles: neu.length });
  } catch (e) {
    log('warn', 'Sale alert check failed', { error: e.message });
  }
}
function scheduleSaleAlerts() {
  if (WORKTREE_MODE) return;
  if (saleAlertTimer) clearTimeout(saleAlertTimer);
  saleAlertTimer = setTimeout(() => {
    saleAlertTimer = null;
    checkSaleAlerts();
  }, SALE_ALERT_DEBOUNCE_MS);
  if (saleAlertTimer.unref) saleAlertTimer.unref();
}
if (!WORKTREE_MODE) {
  const t = setInterval(checkSaleAlerts, SALE_ALERT_EVERY_MS);
  if (t.unref) t.unref();
}

// ── Printed, but not saved ──
// A label whose culture or bag is not in the app sits on a jar nobody counts.
// Each printed ID is checked LABEL_CHECK_GRACE_MS after the print (an offline
// save may still be on its way) and every signed-in user gets one bell notice
// per missing ID. Tapping it opens the intake, dated from the label.
const LABEL_CHECK_GRACE_MS = 5 * 60 * 1000;
let labelCheckTimer = null;
function checkUnsavedLabels() {
  if (WORKTREE_MODE) return;
  try {
    const fehlt = db.unsavedLabels(database, new Date(), LABEL_CHECK_GRACE_MS);
    if (!fehlt.length) return;
    const users = db.listUsers(database);
    for (const f of fehlt) {
      const um = new Date(f.printedAt).toLocaleTimeString('de-DE', {
        hour: '2-digit',
        minute: '2-digit',
        timeZone: 'Europe/Berlin'
      });
      for (const u of users) {
        db.createNotificationOnce(database, {
          userId: u.id,
          type: 'label_unsaved',
          title: 'Gedruckt, aber nicht gespeichert: ' + f.id,
          body:
            'Das Etikett kam um ' +
            um +
            ' Uhr aus dem Drucker, aber ' +
            f.id +
            ' steht nicht in der App und zählt nirgends. Antippen zum Aufnehmen, oder das Glas scannen.',
          linkType: 'label_unsaved',
          linkId: f.id
        });
      }
    }
    log('warn', 'Printed labels not in the app', { count: fehlt.length });
  } catch (e) {
    log('warn', 'Unsaved label check failed', { error: e.message });
  }
}
function rememberPrintedLabels(zpl) {
  if (WORKTREE_MODE) return;
  try {
    if (!db.recordPrintedLabels(database, zpl)) return;
  } catch (e) {
    log('warn', 'Could not remember printed labels', { error: e.message });
    return;
  }
  if (labelCheckTimer) return;
  labelCheckTimer = setTimeout(
    () => {
      labelCheckTimer = null;
      checkUnsavedLabels();
    },
    LABEL_CHECK_GRACE_MS + 10 * 1000
  );
  if (labelCheckTimer.unref) labelCheckTimer.unref();
}
if (!WORKTREE_MODE) {
  const t = setInterval(checkUnsavedLabels, LABEL_CHECK_GRACE_MS);
  if (t.unref) t.unref();
}

function _oauthResultHtml(channel, ok, msg) {
  const label = channel === 'etsy' ? 'Etsy' : 'eBay';
  const icon = ok ? '✓' : '⚠';
  const color = ok ? '#16a34a' : '#dc2626';
  const safe = String(msg).replace(
    /[<>&"']/g,
    (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' })[c]
  );
  const redirect = ok ? '<script>setTimeout(function(){location.href="/"},2500)</script>' : '';
  return `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${label}</title></head><body style="font-family:system-ui,sans-serif;background:#0b0f14;color:#e5e7eb;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center;max-width:420px;padding:24px"><div style="font-size:48px;color:${color}">${icon}</div><p style="font-size:16px;line-height:1.5">${safe}</p><p style="margin-top:18px"><a href="/" style="color:#60a5fa">Zurück zur App</a></p>${redirect}</div></body></html>`;
}
// Admin-only guard — returns true if blocked (response already sent)
/**
 * Does this calendar write create, move or withdraw a pickup window?
 *
 * A calendar event with category 'pickup' is not an entry in a diary — it is
 * what harvest-feed.js publishes to the shop as a bookable collection slot,
 * with its day, its clock, its address and its number of places. POST and PATCH
 * on /api/calendar-events carried no permission check at all, so any logged-in
 * worker could invent a 05:00 collection at an address the farm does not use,
 * or set the capacity of a window customers had already booked to zero. The
 * neighbours had long since decided this was not everyone's to do: DELETE on
 * the same row and POST /api/pickup-locations are both requireAdmin.
 *
 * Both directions have to be asked about, which is why the stored row is read
 * and not only the body. A PATCH that moves a window usually carries no
 * category at all, and a PATCH that carries `category: 'meeting'` takes a
 * published window off the shop without ever mentioning pickup.
 *
 * Ordinary calendar entries — meetings, deliveries, maintenance — stay
 * everyone's, as before. The guard is scoped to the one category that talks to
 * customers.
 */
function touchesPickupWindow(id, data) {
  if (data && data.category === 'pickup') return true;
  if (!id) return false;
  const stored = db.getCalendarEventById(database, id);
  return !!(stored && stored.category === 'pickup');
}

function requireAdmin(req, res) {
  if (!req.authUser || req.authUser.role !== 'admin') {
    jsonErr(res, 403, 'admin required');
    return true;
  }
  return false;
}
// Shipping guard — billable label purchase + customer-PII ship routes. Admins
// always qualify; other users need the per-user can_ship capability (granted by
// an admin). Prevents any logged-in non-admin from spending the owner's postage
// balance or harvesting customer addresses.
function requireShipping(req, res) {
  if (!req.authUser || (req.authUser.role !== 'admin' && req.authUser.can_ship !== 1)) {
    jsonErr(res, 403, 'shipping permission required');
    return true;
  }
  return false;
}

// ── Contamination-photo storage ─────────────────────────────
// The client compresses photos to ~200 KB JPEGs (canvas re-encode at 1280 px /
// quality 0.8) plus a ~15 KB thumbnail and sends both as base64 data URLs in
// the report POST body. Server validates magic bytes, sha256s, and writes to
// data/photos/{YYYY}/{MM}/{report_id}/{uuid}{,_thumb}.jpg.
const PHOTO_MAX_BYTES = 5 * 1024 * 1024;
const PHOTO_THUMB_MAX_BYTES = 200 * 1024;

// R-15: total-size cap on data/photos/. Without it, a stuck offsite-sync or
// pathological client can fill the disk and wedge the whole server. Default
// 100 GB; override with PHOTO_DIR_MAX_GB. Logic lives in scripts/photo-cap.js
// so the regression test can exercise it directly.
//
// It was 10 GB while every photo was a 200 KB JPEG. Experiment photos keep
// their original now, a few MB each, and 10 GB would be a year of a busy lab.
// What protects the disk is the free-space floor below, which the folder cap
// never was: PHOTO_MIN_FREE_GB (default 2) has to stay free on the disk after
// every original.
const photoCap = require('./scripts/photo-cap.js');
const jpegLocation = require('./scripts/jpeg-location.js');
const PHOTO_DIR_MAX_BYTES = (() => {
  const raw = parseFloat(process.env.PHOTO_DIR_MAX_GB);
  const gb = Number.isFinite(raw) && raw > 0 ? raw : 100;
  return Math.round(gb * 1024 * 1024 * 1024);
})();
const PHOTO_MIN_FREE_BYTES = (() => {
  const raw = parseFloat(process.env.PHOTO_MIN_FREE_GB);
  const gb = Number.isFinite(raw) && raw >= 0 ? raw : 2;
  return Math.round(gb * 1024 * 1024 * 1024);
})();
// The photos the app shows, a contamination report's among them, stop at a
// lower floor. They are a few hundred KB, and a report is not refused because
// the disk is below what originals of 25 MB need; the database still keeps
// its room. Never more than PHOTO_MIN_FREE_GB, so 0 turns both off.
const PHOTO_SMALL_MIN_FREE_BYTES = Math.min(PHOTO_MIN_FREE_BYTES, 256 * 1024 * 1024);
// An original as the camera took it. A 48-megapixel phone writes 15-25 MB.
const ORIGINAL_MAX_BYTES = 40 * 1024 * 1024;
// How far an original's aspect ratio may be from its scaled photo's: the
// scaling rounds to whole pixels, nothing more.
const ORIGINAL_ASPECT_TOLERANCE = 0.02;
// How long an original's upload may send nothing before it is dropped.
const ORIGINAL_IDLE_MS = 60 * 1000;
// Typed confirmation for the irreversible clean-slate reset. Must be echoed by
// the client exactly, so the endpoint can't be fired by a stray/replayed POST.
const RESET_CONFIRM_PHRASE = 'ALLES LOESCHEN';
const PHOTO_DIR = path.join(DIR, 'data', 'photos');
const PHOTO_SIZE_REFRESH_MS = 5 * 60 * 1000; // recompute every 5 min
let _photoDirSizeBytes = 0;
let _photoDirSizeStaleAt = 0;
function _ensurePhotoDirSize(force) {
  const now = Date.now();
  if (force || now > _photoDirSizeStaleAt) {
    try {
      _photoDirSizeBytes = photoCap.computePhotoDirSize(PHOTO_DIR);
    } catch (e) {
      log('warn', 'Failed to scan photo dir for size', { error: e.message });
    }
    _photoDirSizeStaleAt = now + PHOTO_SIZE_REFRESH_MS;
    if (_photoDirSizeBytes > PHOTO_DIR_MAX_BYTES * 0.9) {
      log('warn', 'Photo directory near cap', {
        usedMB: Math.round(_photoDirSizeBytes / 1e6),
        capMB: Math.round(PHOTO_DIR_MAX_BYTES / 1e6)
      });
    }
  }
  return _photoDirSizeBytes;
}

// Free space on the disk a photo goes to, checked before every write. The
// folder cap's warning above comes at 90 GB now, and the first sign of a
// filling disk should not be a worker's 507: below twice the originals'
// reserve the log says so, and a photo refused for space is logged too, each
// at most once an hour.
const PHOTO_SPACE_LOG_MS = 60 * 60 * 1000;
const _photoSpaceLoggedAt = { low: 0, refused: 0 };
function _ensurePhotoSpace(dir, addBytes, minFreeBytes) {
  const warn = (kind, msg, meta) => {
    const now = Date.now();
    if (now - _photoSpaceLoggedAt[kind] < PHOTO_SPACE_LOG_MS) return;
    _photoSpaceLoggedAt[kind] = now;
    log('warn', msg, meta);
  };
  let left;
  try {
    left = photoCap.enforceFreeSpace(dir, addBytes, minFreeBytes);
  } catch (e) {
    warn('refused', 'Photo refused, disk nearly full', {
      photoMB: Math.round(addBytes / 1e6),
      mustStayFreeMB: Math.round(minFreeBytes / 1e6)
    });
    throw e;
  }
  if (left !== null && left < 2 * PHOTO_MIN_FREE_BYTES) {
    warn('low', 'Photo disk getting full', {
      freeMB: Math.round(left / 1e6),
      reserveMB: Math.round(PHOTO_MIN_FREE_BYTES / 1e6)
    });
  }
}

function _parseImageDataUrl(s) {
  if (typeof s !== 'string') return null;
  const m = s.match(/^data:image\/jpeg;base64,([A-Za-z0-9+/=]+)$/);
  if (!m) return null;
  try {
    return Buffer.from(m[1], 'base64');
  } catch {
    return null;
  }
}
function _isJpegMagic(buf) {
  return buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
}
// R-15: PhotoCapError reused from scripts/photo-cap.js so the test and the
// server share the same error class.
function savePhotoToDisk(reportId, photo, authUser) {
  if (!photo || typeof photo !== 'object') return null;
  // reportId ist der Ordnername unter data/photos/{Jahr}/{Monat}/. Er kam immer
  // aus einer Zeilennummer und kommt jetzt auch als 'exp-12' herein; beides
  // baut der Server selbst. Die Pruefung steht hier trotzdem, weil ein Ordner-
  // name aus einem Aufrufer der Weg aus dem Verzeichnis heraus waere.
  if (!/^[A-Za-z0-9_-]+$/.test(String(reportId))) throw new Error('photo: invalid scope');
  const main = _parseImageDataUrl(photo.data_url);
  if (!main) throw new Error('photo: invalid data_url (must be data:image/jpeg;base64,...)');
  if (!_isJpegMagic(main)) throw new Error('photo: payload is not a JPEG');
  if (main.length > PHOTO_MAX_BYTES) throw new Error('photo: too large (max 5 MB)');
  const thumb = _parseImageDataUrl(photo.thumb_data_url || photo.data_url);
  if (!thumb) throw new Error('photo: invalid thumb_data_url');
  if (!_isJpegMagic(thumb)) throw new Error('photo: thumb is not a JPEG');
  if (thumb.length > PHOTO_THUMB_MAX_BYTES) throw new Error('photo: thumb too large (max 200 KB)');

  // R-15: enforce the photo-directory size cap before writing. The cached
  // total is refreshed every 5 minutes; we add the about-to-be-written
  // bytes to compare against the cap, then update the cache after the
  // successful write so the next call sees the new total.
  // TODO(R-15): orphan-photo cleanup is out of scope for this PR — once it
  // lands, the cap will breathe in normal operation rather than just stop
  // accepting writes when full.
  const currentSize = _ensurePhotoDirSize(false);
  photoCap.enforceCap(currentSize, main.length + thumb.length, PHOTO_DIR_MAX_BYTES);

  const uuid = crypto.randomUUID();
  const sha = crypto.createHash('sha256').update(main).digest('hex');
  const now = new Date();
  const yyyy = String(now.getUTCFullYear());
  const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
  const absDir = path.join(DIR, 'data', 'photos', yyyy, mm, String(reportId));
  fs.mkdirSync(absDir, { recursive: true });
  // The free space of the disk the photo goes to: data/photos, or a year of
  // it, can be a folder linked in from another disk.
  _ensurePhotoSpace(absDir, main.length + thumb.length, PHOTO_SMALL_MIN_FREE_BYTES);
  const relPath = `photos/${yyyy}/${mm}/${reportId}/${uuid}.jpg`;
  const thumbPath = `photos/${yyyy}/${mm}/${reportId}/${uuid}_thumb.jpg`;
  fs.writeFileSync(path.join(DIR, 'data', relPath), main);
  fs.writeFileSync(path.join(DIR, 'data', thumbPath), thumb);
  // Update the cached total so consecutive writes in the same 5-min window
  // are bounded correctly without re-scanning.
  _photoDirSizeBytes += main.length + thumb.length;

  return {
    uuid,
    rel_path: relPath,
    thumb_path: thumbPath,
    width: typeof photo.width === 'number' ? photo.width : null,
    height: typeof photo.height === 'number' ? photo.height : null,
    bytes: main.length,
    sha256: sha,
    uploaded_at: now.toISOString(),
    uploaded_by: authUser ? authUser.user_id : null
  };
}

// Undo savePhotoToDisk for files whose rows were rolled back: unlink them and
// take their bytes back out of the cached total. Without the second half a
// failed report kept counting against the cap until the next rescan, up to
// five minutes later, and near the cap a retry got a 507 for space that was
// already free again. Returns the files that are still there; one that is
// already gone counts as deleted.
function _discardPhotoFiles(files) {
  const left = [];
  for (const f of files) {
    try {
      const bytes = fs.statSync(f).size;
      fs.unlinkSync(f);
      _photoDirSizeBytes -= bytes;
    } catch (e) {
      if (e.code !== 'ENOENT') left.push(f);
    }
  }
  return left;
}

// Wieviele Beutel ein einzelner Bericht nennen darf. Eine Reihe im Regal passt
// locker hinein; die Grenze ist da, damit ein kaputter Aufrufer nicht 10.000 Berichte
// in eine Transaktion schreibt, nicht als Arbeitsgrenze.
const CONTAM_MAX_TARGETS = 50;
// Ein Griff zur Kamera sind ein paar Bilder, keine Bibliothek. Die Grenze
// schuetzt die Platte und den Arbeitsspeicher: jedes Bild reist als Base64
// durch den Hauptspeicher, bevor es geschrieben wird.
const EXP_MAX_PHOTOS_PER_CALL = 8;

// Die Felder eines Versuchs, fuer Anlegen und Aendern gleich. Titel und Beginn
// sind Pflicht in der Zeile und duerfen deshalb nicht leer werden; ein Datum ist
// ein Tag wie aus dem Datumsfeld; archived ist ja oder nein, und aus dem Text
// 'false' wurde vorher ein archivierter Versuch. Welche Status es gibt, weiss
// db.js.
function versuchPruefen(data) {
  const vr = validateRequired(data, []);
  if (vr) return vr;
  const vt = validateTypes(data, {
    code: 'string',
    title: 'string',
    recipe: 'string',
    outcome: 'string',
    status: 'string',
    archived: 'boolean',
    workPackageId: 'number'
  });
  if (vt) return vt;
  // Ob es das Paket gibt, weiss db.js; hier nur, dass es eine Kennung ist.
  if (data.workPackageId != null && !Number.isInteger(data.workPackageId))
    return 'workPackageId must be a whole number';
  if ('title' in data && !(typeof data.title === 'string' && data.title.trim())) return 'title must not be empty';
  if ('status' in data && typeof data.status !== 'string') return 'status must be a string';
  if ('archived' in data && typeof data.archived !== 'boolean') return 'archived must be a boolean';
  if ('started' in data) {
    const vd = validateDay(data.started, 'started');
    if (vd) return vd;
  }
  // Ein leeres Ende heisst: noch kein Ende.
  if (data.ended !== undefined && data.ended !== null && data.ended !== '') {
    const vd = validateDay(data.ended, 'ended');
    if (vd) return vd;
  }
  return null;
}
// Die Felder eines Gefaesses und ihre Typen, fuer Anlegen und Aendern gleich.
// cultureId und batchId sind Kennungen als Text. Kam dort ein Objekt, band
// node:sqlite es als benannte Parameter, das UPDATE traf keine Zeile, und der
// Verlauf verzeichnete trotzdem eine Aenderung.
const ANSATZ_TYPEN = {
  code: 'string',
  what: 'string',
  result: 'string',
  cultureId: 'string',
  batchId: 'string',
  sort: 'number'
};
// Was ueber die Typen hinaus gilt: ein Code ist nie leer (null traf die
// Pflichtspalte und endete als 500, '' wurde gespeichert), und die
// Reihenfolge ist eine ganze Zahl.
function ansatzPruefen(data) {
  if ('code' in data && !(typeof data.code === 'string' && data.code.trim())) return 'code must not be empty';
  if (data.sort !== undefined && !Number.isInteger(data.sort)) return 'sort must be a whole number';
  return null;
}

// Die Beutel, die ein Bericht abdeckt.
//
// Ein Formular, mehrere Beutel: wer eine kontaminierte Reihe abgeht, scannt sie
// am Stück und füllt Typ, Schwere, Foto und Notiz einmal aus. Jeder Eintrag
// bringt seine eigene report_uuid mit, sonst fielen beim Nachspielen aus der
// Offline-Warteschlange alle Beutel auf denselben Bericht zusammen.
//
// Ohne `bags` bleiben die flachen Felder das eine Ziel — so sehen die Rümpfe
// aus, die vor dieser Änderung in eine Warteschlange gelegt wurden, und die
// werden nach dem Update noch abgespielt.
function contamReportTargets(data) {
  if (!Array.isArray(data.bags)) {
    return [
      {
        bag_id: data.bag_id || null,
        batch_id: data.batch_id || null,
        report_uuid: data.report_uuid || null
      }
    ];
  }
  return data.bags.map((b) => ({
    bag_id: (b && b.bag_id) || null,
    batch_id: (b && b.batch_id) || null,
    report_uuid: (b && b.report_uuid) || null
  }));
}

// ── AUTH HELPERS ─────────────────────────────────────────────
// Session cookie name varies by transport:
//   - HTTPS → "__Host-session" (the __Host- prefix forces the browser to
//     reject any cookie that lacks Secure / Path=/ or that includes a
//     Domain attribute, blocking sibling-subdomain fixation attacks).
//   - HTTP  → "session"          (the __Host- prefix REQUIRES Secure, so
//     it cannot be used over plaintext; we keep the unprefixed name there).
// We always parse BOTH names on incoming requests so a session minted
// before the protocol changed (e.g. cert renewal cycle) still resolves.
function sessionCookieName() {
  return protocol === 'https' ? '__Host-session' : 'session';
}

function getSessionToken(req) {
  const cookies = req.headers.cookie || '';
  // Prefer the prefixed name, fall back to plain — covers both transports
  // and clients that sent a stale cookie from an earlier session.
  const m1 = cookies.match(/(?:^|;\s*)__Host-session=([a-f0-9]+)/);
  if (m1) return m1[1];
  // Over HTTPS, accept ONLY the __Host- prefixed cookie. The prefix is what makes
  // the browser refuse a same-name cookie that lacks Secure/Path=/ or carries a
  // Domain — accepting the bare name here gave that guarantee away: anyone able to
  // set a cookie over plaintext for this host (the port-80 listener, or any
  // network position — cookies are not scheme-isolated) could plant
  // `session=<their token>` and silently place a victim inside their account. It
  // also kept honouring non-Secure sessions minted during an HTTP fallback (what
  // happens on a cert-renewal failure) after TLS came back.
  if (protocol === 'https') return null;
  const m2 = cookies.match(/(?:^|;\s*)session=([a-f0-9]+)/);
  return m2 ? m2[1] : null;
}

function checkAuth(req) {
  const token = getSessionToken(req);
  if (!token) return null;
  return db.getSession(database, token) || null;
}

function sendUnauthorized(res, isApi) {
  if (isApi) {
    res.writeHead(401, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'unauthorized' }));
  } else {
    res.writeHead(302, { Location: '/login.html' });
    res.end();
  }
}

function cookieFlags() {
  return 'HttpOnly; SameSite=Strict; Path=/;' + (protocol === 'https' ? ' Secure;' : '');
}

function setSessionCookie(res, token) {
  res.setHeader(
    'Set-Cookie',
    sessionCookieName() + '=' + token + '; ' + cookieFlags() + ' Max-Age=' + SESSION_TTL_SECONDS
  );
}

function clearSessionCookie(res) {
  // Clear both names — if we previously set a __Host-session cookie and
  // are now over HTTP, only clearing "session" would leave the prefixed
  // cookie behind (and vice versa). Most browsers ignore the second
  // header line if it duplicates the first so emit them as a single
  // multi-value array instead.
  res.setHeader('Set-Cookie', [
    'session=; ' + cookieFlags() + ' Max-Age=0',
    '__Host-session=; ' + cookieFlags() + ' Max-Age=0'
  ]);
}

// Clean expired sessions + OAuth data on startup and hourly
db.deleteExpiredSessions(database);
db.deleteExpiredOAuthData(database);
setInterval(
  () => {
    db.deleteExpiredSessions(database);
    db.deleteExpiredOAuthData(database);
  },
  60 * 60 * 1000
);

// R-10: periodic cleanup of expired sessions + read notifications. The
// session table also has lazy/hourly cleanup above, but the dedicated
// helpers return a row count so we can log how much we GC'd, and the
// notifications table had no GC at all before this. Runs every 6h plus
// once at startup so a server that's been stopped a long time doesn't
// accumulate forever.
function runPeriodicCleanup() {
  try {
    const sessions = db.cleanupExpiredSessions(database);
    const notifications = db.cleanupOldNotifications(database);
    // Recipient names on Billbee orders are for the packing list only.
    const billbeeNames = db.clearOldBillbeeNames(database);
    if (sessions > 0 || notifications > 0 || billbeeNames > 0) {
      log('info', 'Periodic cleanup', { sessions, notifications, billbeeNames });
    }
  } catch (e) {
    log('warn', 'Periodic cleanup failed', { error: e.message });
  }
}
runPeriodicCleanup();
setInterval(runPeriodicCleanup, 6 * 60 * 60 * 1000);

// ── DAILY AUTO-BACKUP ────────────────────────────────────────
// Every day at 00:00 writes a dated backup to /backups/
const BACKUP_DIR = path.join(DIR, 'backups');
const BACKUP_STATUS_FILE = path.join(BACKUP_DIR, '.backup-status.json');
const OFFSITE_MARKER_FILE = path.join(BACKUP_DIR, '.offsite-sync.json');
// R-14: webhook auto-deploy reliability — record each attempt + outcome to
// a sentinel JSON file so an admin can see whether the last deploy actually
// finished without scraping logs: in_progress, then success, or failed with
// the step it stopped at (fetch, snapshot, prepare, update, install,
// preflight, restart). See scripts/deploy-runner.js and deploy-chain.js.
const DEPLOY_STATE_FILE = path.join(DIR, 'data', 'deploy-state.json');
const deployRunner = require('./scripts/deploy-runner.js');
// R-01: auto-backup rotation only touches files matching its own pattern, so
// the pre-update snapshots (`meistertracker_YYYYMMDD_HHMMSS.db`) are never
// aged out with the auto-backups. They have a rotation of their own, by count.
// Any other artefact in the directory stays put. See scripts/rotate-backups.js
// for both helpers.
const { rotateAutoBackups, rotatePreUpdateSnapshots, BACKUP_PREFIX } = require('./scripts/rotate-backups.js');
const backupCrypto = require('./scripts/backup-crypto.js');
const usbStick = require('./scripts/usb-stick.js');
const photoMirror = require('./scripts/photo-mirror.js');
const { createUsbBackup, describeError: describeUsbError } = require('./scripts/usb-backup.js');
const AUTO_BACKUP_RETENTION_DAYS = 30;
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { mode: 0o700 });
// Clean up orphaned temp files from interrupted backup operations.
// R-13: also picks up `.backup-status.json.tmp.<hex>` leftovers if a process
// crashed between writeFileSync and renameSync, and the `.tmp-<hex>` an
// atomic write (scripts/atomic-file.js) leaves when it dies half-way, such as
// the off-site marker's.
const { LEFTOVER: ATOMIC_LEFTOVER } = require('./scripts/atomic-file.js');
try {
  fs.readdirSync(BACKUP_DIR)
    .filter((f) => f.startsWith('_') || /^\.backup-status\.json\.tmp\./.test(f) || ATOMIC_LEFTOVER.test(f))
    .forEach((f) => {
      try {
        fs.unlinkSync(path.join(BACKUP_DIR, f));
      } catch (e) {
        log('warn', 'Failed to clean orphaned temp file', { file: f, error: e.message });
      }
    });
} catch (e) {
  log('warn', 'Failed to scan backup dir for orphans', { error: e.message });
}

// Tauscht die laufende Datenbank gegen eine zurueckgespielte (tmpPath, schon
// geprueft). Danach ist `database` wieder offen: auf der neuen Datei, oder,
// wenn etwas schiefging, auf der alten. Geht keine von beiden auf, bleibt es
// zu, und die Antwort sagt das, statt die zurueckgespielte als den Stand davor
// auszugeben.
//
// Zuerst der Stand davor, mit VACUUM INTO. Das liest durch das WAL, die Kopie
// ist also ganz, auch wenn ein anderes Programm die Datei gerade offen hat;
// eine Dateikopie nach dem Schliessen war es dann nicht. Ohne diese Kopie wird
// nicht getauscht: sie ist das Netz, falls die neue Datei nicht aufgeht, und
// sie bleibt danach bei den Sicherungen liegen, als pre-restore-<Zeit>.db, nie
// rotiert. In ihr steht, was das Zurueckspielen verwirft, auch Eintraege des
// Laborbuch-Verlaufs und ihre Zeitstempel. Bis hierher wurde sie geloescht.
//
// Hat nach dem Schliessen noch ein anderes Programm die Datei offen (der
// DuckDNS-Ersatz liest sie alle fuenf Minuten, oder ein Datenbank-Werkzeug),
// steht ihr WAL noch da, mit Seiten der alten Datenbank. SQLite spielte es beim
// naechsten Oeffnen in die neue Datei, und die waere dann wieder die alte, ohne
// dass etwas auffiele. Dann wird nicht getauscht; eine Minute spaeter geht es.
//
// Gibt { ok: true, kept } oder { ok: false, status, error } zurueck.
function swapRestoredDatabase(tmpPath) {
  const kept = path.join(BACKUP_DIR, 'pre-restore-' + new Date().toISOString().replace(/[:.]/g, '-') + '.db');
  const weg = (p) => {
    try {
      fs.rmSync(p, { force: true });
    } catch (e) {
      log('warn', 'Restore: could not remove a file', { file: path.basename(p), error: e.message });
    }
  };
  try {
    db.backupDb(database, kept);
  } catch (e) {
    log('error', 'Restore refused: the current database could not be copied first', { error: e.message });
    weg(kept);
    return { ok: false, status: 500, error: 'Restore refused: the current database could not be secured first' };
  }
  try {
    database.close();
  } catch (e) {
    log('warn', 'Failed to close database before restore', { error: e.message });
  }
  // Drop live MCP sessions — their captured DB handle is now closed and every
  // reopen path below rebinds `database` to a new handle.
  closeAllMcpSessions();
  // DB_FILE wieder auf, so wie es jetzt ist; false, wenn das nicht geht.
  const wiederAuf = () => {
    try {
      database = db.openDb(DB_FILE);
      return true;
    } catch (e) {
      log('error', 'Failed to reopen the database after a restore went wrong', { error: e.message });
      return false;
    }
  };
  const nichtWiederAuf = { ok: false, status: 500, error: 'The database could not be reopened; restart the server' };
  let walBytes = 0;
  try {
    walBytes = fs.statSync(DB_FILE + '-wal').size;
  } catch {
    walBytes = 0;
  }
  let shmWeg = true;
  if (!walBytes) {
    // Ohne WAL ist eine uebrige -shm nur ein Rest; sie geht mit der alten Datei.
    try {
      fs.rmSync(DB_FILE + '-shm', { force: true });
    } catch {
      shmWeg = false;
    }
  }
  if (walBytes || !shmWeg) {
    log('warn', 'Restore postponed: the database is still open in another process', { walBytes });
    weg(kept);
    if (!wiederAuf()) return nichtWiederAuf;
    return { ok: false, status: 409, error: 'The database is open in another program; try again in a minute' };
  }
  try {
    fs.renameSync(tmpPath, DB_FILE);
  } catch (renameErr) {
    // The swap failed (e.g. Windows EBUSY/EPERM). The original DB_FILE is
    // untouched and its WAL was empty, so reopening it loses nothing.
    log('error', 'Failed to swap in restored database, reopening original', { error: renameErr.message });
    weg(kept);
    if (!wiederAuf()) return nichtWiederAuf;
    return { ok: false, status: 500, error: 'Restore failed, previous data has been preserved' };
  }
  // Die zurueckgespielte Datei ging schon einmal auf, als sie geprueft wurde;
  // dass sie es jetzt nicht tut, ist selten. openDb laesst sie dann nicht offen.
  try {
    database = db.openDb(DB_FILE);
  } catch (openErr) {
    // Zurueck zum Stand davor, aus der Kopie: erst neben die Datei, dann in
    // einem Schritt darueber, so steht DB_FILE nie halb da. Die Kopie bleibt
    // bei den Sicherungen liegen.
    log('error', 'Failed to open restored database, rolling back', { error: openErr.message });
    for (const s of ['-wal', '-shm']) weg(DB_FILE + s);
    let zurueck = false;
    try {
      fs.copyFileSync(kept, DB_FILE + '.rollback');
      fs.renameSync(DB_FILE + '.rollback', DB_FILE);
      zurueck = true;
    } catch (e) {
      log('error', 'Rollback copy also failed', { error: e.message });
      weg(DB_FILE + '.rollback');
    }
    // Geht keine Datei auf, sagt die Antwort, welche jetzt DB_FILE ist: nach
    // einem Neustart liefe der Server sonst unbemerkt auf der Sicherung, ohne
    // dass der Verlauf das Zurueckspielen kennt.
    if (!wiederAuf()) {
      return {
        ok: false,
        status: 500,
        error: zurueck
          ? 'Restore failed and the database could not be reopened. The database file holds the data from before again (also kept as ' +
            path.basename(kept) +
            '); restart the server.'
          : 'Restore failed and the database could not be reopened. The database file is still the restored backup: copy ' +
            path.basename(kept) +
            ' over it before restarting the server, or it starts on the backup.'
      };
    }
    // Kam die Kopie nicht zurueck, ist DB_FILE noch die zurueckgespielte, und
    // die ging nun doch auf: dann hat das Zurueckspielen geklappt.
    if (!zurueck) return { ok: true, kept: path.basename(kept) };
    return { ok: false, status: 500, error: 'Restore failed, previous data has been preserved' };
  }
  // Was das Lesen der Sicherung vor ihren Migrationen neben ihr liegen lassen
  // kann (historyDiscarded oeffnet sie nur lesend).
  for (const s of ['-wal', '-shm']) weg(tmpPath + s);
  return { ok: true, kept: path.basename(kept) };
}

function readBackupStatus() {
  try {
    if (!fs.existsSync(BACKUP_STATUS_FILE)) return {};
    const raw = fs.readFileSync(BACKUP_STATUS_FILE, 'utf8');
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === 'object' ? parsed : {};
  } catch (e) {
    log('warn', 'Could not read backup status file', { error: e.message });
    return {};
  }
}

// R-14: deploy-state sentinel I/O. Best-effort — we don't want a
// transient filesystem hiccup to break the webhook.
function readDeployState() {
  try {
    if (!fs.existsSync(DEPLOY_STATE_FILE)) return null;
    const raw = fs.readFileSync(DEPLOY_STATE_FILE, 'utf8');
    return JSON.parse(raw);
  } catch (e) {
    return null;
  }
}
function writeDeployState(patch) {
  try {
    const dataDir = path.dirname(DEPLOY_STATE_FILE);
    if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
    const tmp = DEPLOY_STATE_FILE + '.tmp.' + crypto.randomBytes(4).toString('hex');
    fs.writeFileSync(tmp, JSON.stringify(patch, null, 2));
    fs.renameSync(tmp, DEPLOY_STATE_FILE);
  } catch (e) {
    log('warn', 'Could not write deploy-state file', { error: e.message });
  }
}

function writeBackupStatus(patch) {
  try {
    const current = readBackupStatus();
    const next = Object.assign({}, current, patch);
    // Atomic write: write to temp then rename. R-13: random tmp suffix so
    // two concurrent writers don't truncate each other's tmp file before
    // either rename completes (e.g. backup completes at the same instant
    // an offsite-sync status update fires).
    const tmp = BACKUP_STATUS_FILE + '.tmp.' + crypto.randomBytes(4).toString('hex');
    fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
    fs.renameSync(tmp, BACKUP_STATUS_FILE);
  } catch (e) {
    log('warn', 'Could not write backup status file', { error: e.message });
  }
}

// Verify a freshly-written backup file on disk has the SQLite magic header
// and a plausible size. Returns null on success, an error string on failure.
function verifyBackupFile(dest) {
  let sizeBytes = 0;
  try {
    sizeBytes = fs.statSync(dest).size;
  } catch (e) {
    return 'stat failed: ' + e.message;
  }
  if (sizeBytes < 1024) return 'backup file suspiciously small: ' + sizeBytes + ' bytes';
  try {
    const fd = fs.openSync(dest, 'r');
    const header = Buffer.alloc(16);
    fs.readSync(fd, header, 0, 16, 0);
    fs.closeSync(fd);
    if (header.toString('utf8', 0, 15) !== 'SQLite format 3') {
      return 'backup file missing SQLite magic header';
    }
  } catch (e) {
    return 'verify read failed: ' + e.message;
  }
  return null;
}

// The photos a second time on this disk, at no cost in space: every photo gets
// a hard link under backups/photos (scripts/photo-mirror.js). Deleted from the
// app, a photo stays there for 90 days. Runs with the daily backup, so on
// every start and every midnight; incremental, so a run with nothing new only
// reads the folders.
const PHOTO_MIRROR_DIR = path.join(BACKUP_DIR, 'photos');
let photoMirrorRunning = false;
function runPhotoMirror() {
  if (photoMirrorRunning) return;
  photoMirrorRunning = true;
  const started = Date.now();
  photoMirror
    .mirrorPhotos({ src: PHOTO_DIR, dst: PHOTO_MIRROR_DIR })
    .then((r) => {
      const entry = {
        time: new Date().toISOString(),
        linked: r.linked,
        copied: r.copied,
        present: r.present,
        pending: r.pending,
        pruned: r.pruned,
        conflicts: r.conflicts,
        repaired: r.repaired,
        errors: r.errors.length,
        durationMs: Date.now() - started
      };
      writeBackupStatus({ photos: entry });
      log(
        r.errors.length || r.conflicts ? 'warn' : 'info',
        'Photo mirror',
        Object.assign({}, entry, { firstError: r.errors[0] })
      );
    })
    .catch((e) => {
      writeBackupStatus({ photos: { time: new Date().toISOString(), failed: e.message } });
      log('error', 'Photo mirror failed', { error: e.message });
    })
    .finally(() => {
      photoMirrorRunning = false;
    });
}

// The pre-update snapshot rotation (scripts/rotate-backups.js), logged when it
// did something. Called from runDailyBackup and right after the webhook's own
// snapshot.
function rotatePreUpdate() {
  try {
    const rot = rotatePreUpdateSnapshots(BACKUP_DIR);
    if (rot.error || rot.ledger || rot.deleted.length || rot.skipped.length) {
      log(rot.error ? 'warn' : 'info', 'Pre-update snapshot rotation', {
        kept: rot.kept.length,
        predatingRotation: rot.legacy.length,
        deleted: rot.deleted,
        skipped: rot.skipped,
        ledger: rot.ledger,
        error: rot.error
      });
    }
  } catch (e) {
    log('warn', 'Pre-update snapshot rotation failed', { error: e.message });
  }
}

// git in this checkout, for the deploy's fetch: no shell, a time limit, and no
// credential prompt that would wait for an answer nobody is there to give.
function deployGit(args) {
  return new Promise((resolve, reject) => {
    execFile(
      'git',
      args,
      {
        cwd: DIR,
        timeout: 120000,
        windowsHide: true,
        env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' })
      },
      (err, stdout, stderr) => {
        if (err) {
          const detail = String(stderr || '').trim();
          if (detail) err.message = detail;
          return reject(err);
        }
        resolve(String(stdout));
      }
    );
  });
}

// The GitHub webhook's deploy (scripts/deploy-runner.js). Its snapshot goes
// through this process's own handle on the database, so it is taken by the
// code that is running and the deploy chain never opens the live file. One
// deploy runs at a time; a push that arrives meanwhile, or during a restore,
// waits, across the restart if need be.
const deploys = deployRunner.createDeployRunner({
  platform: process.platform,
  dir: DIR,
  node: process.execPath,
  pm2Name: PM2_PROCESS_NAME,
  stateFile: DEPLOY_STATE_FILE,
  pendingFile: path.join(DIR, 'data', 'deploy-pending.json'),
  logFile: path.join(DIR, 'data', 'deploy.log'),
  backupDir: BACKUP_DIR,
  git: deployGit,
  snapshot: (dest) => {
    db.backupDb(database, dest);
    const bad = verifyBackupFile(dest);
    if (bad) throw new Error(bad);
  },
  rotate: rotatePreUpdate,
  restoreBusy: () => restoreInProgress,
  runningCommit: () => (codeState && codeState.commit) || null,
  readState: readDeployState,
  writeState: writeDeployState,
  log,
  spawn
});
// A push that queued behind the deploy that restarted this process. Late
// enough for the start-up to settle and for codeState to know the commit.
if (!WORKTREE_MODE) {
  setTimeout(() => {
    deploys.resumePending().catch((e) => log('error', 'Queued deploy failed', { error: e.message }));
  }, 15_000);
}

function runDailyBackup() {
  const startedAt = Date.now();
  const startedIso = new Date(startedAt).toISOString();
  // The pre-update snapshots go on every run, ahead of the early return below
  // for a day that already has its auto-backup. This runs once at start-up,
  // and a start-up is what follows an update: the moment one has just been
  // written. The newest is always kept; see scripts/rotate-backups.js.
  rotatePreUpdate();
  runPhotoMirror();
  try {
    // Snapshot KPIs before backup — always overwrite today's snapshot
    // so it captures the latest state (bags created after server start, etc.)
    try {
      const snapResult = db.snapshotDailyKPIs(database, { force: true });
      log('info', 'KPI snapshot', snapResult);
    } catch (e) {
      log('warn', 'KPI snapshot failed (backup continues)', { error: e.message });
    }
    const d = new Date();
    const stamp =
      d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
    const dest = path.join(BACKUP_DIR, BACKUP_PREFIX + stamp + '.db');
    if (fs.existsSync(dest)) {
      // Already have today's backup — record lastAttempt but don't overwrite.
      writeBackupStatus({ lastAttempt: { time: startedIso, success: true, skipped: 'already-exists' } });
      return;
    }
    // R-01: rotate BEFORE writing today's file so the new file is never a
    // candidate for deletion in the same pass. The 60s mtime guard inside
    // rotateAutoBackups is belt + suspenders against a future ordering
    // regression.
    try {
      const rot = rotateAutoBackups(BACKUP_DIR, AUTO_BACKUP_RETENTION_DAYS);
      log('info', 'Auto-backup rotation', {
        kept: rot.kept.length,
        deleted: rot.deleted,
        skipped: rot.skipped
      });
    } catch (e) {
      log('warn', 'Backup rotation failed', { error: e.message });
    }
    db.backupDb(database, dest)
      .then(() => {
        let sizeBytes = 0;
        try {
          sizeBytes = fs.statSync(dest).size;
        } catch {}
        const durationMs = Date.now() - startedAt;
        log('info', 'Auto-backup saved', { path: dest, sizeBytes, durationMs });
        // Sanity-check the written file before marking lastSuccess.
        const verifyError = verifyBackupFile(dest);
        if (verifyError) {
          log('error', 'Auto-backup verify failed', { path: dest, error: verifyError });
          writeBackupStatus({
            lastAttempt: { time: startedIso, success: false },
            lastFailure: { time: new Date().toISOString(), error: verifyError, path: dest }
          });
          return;
        }
        writeBackupStatus({
          lastAttempt: { time: startedIso, success: true },
          lastSuccess: {
            time: new Date().toISOString(),
            path: dest,
            sizeBytes,
            durationMs,
            verified: true
          }
        });
      })
      .catch((e) => {
        log('error', 'Auto-backup failed', { error: e.message });
        writeBackupStatus({
          lastAttempt: { time: startedIso, success: false },
          lastFailure: { time: new Date().toISOString(), error: e.message, path: dest }
        });
      });
  } catch (e) {
    log('error', 'Auto-backup failed', { error: e.message });
    writeBackupStatus({
      lastAttempt: { time: startedIso, success: false },
      lastFailure: { time: new Date().toISOString(), error: e.message }
    });
  }
}

function scheduleDailyBackup() {
  // Run one immediately on startup if today's doesn't exist yet
  runDailyBackup();
  // Schedule next at midnight
  const now = new Date();
  const next = new Date(now);
  next.setHours(24, 0, 0, 0); // next midnight
  const msUntil = next - now;
  log('info', 'Next auto-backup scheduled', { at: next.toISOString() });
  setTimeout(() => {
    runDailyBackup();
    setInterval(runDailyBackup, 24 * 60 * 60 * 1000); // then every 24h
  }, msUntil);
  // Refresh KPI snapshot every 4 hours so data stays current throughout the day
  setInterval(
    () => {
      try {
        db.snapshotDailyKPIs(database, { force: true });
      } catch (e) {
        /* ignore */
      }
    },
    4 * 60 * 60 * 1000
  );
}
scheduleDailyBackup();

// ── USB STICK BACKUP ───────────────────────────────────────
// The copy that leaves the building. scripts/usb-backup.js looks for a
// registered stick every few seconds and, once a day, writes the backup onto
// it by itself; the start screen tells admins when it is due and when the
// stick can come out. The routes are under /api/usb-backup. USB_BACKUP=off
// switches the looking off, for a server nobody can plug anything into.
fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
// A consistent copy to encrypt onto the stick. The "_" prefix is what the
// start-up sweep above removes, should a run die before cleaning up. A
// VACUUM INTO that fails half-way leaves its partial file (and a -journal)
// behind; the run never gets the name, so this removes them, or a copy that
// keeps failing would add one every ten minutes until the next restart.
async function snapshotForStick() {
  const tmp = path.join(BACKUP_DIR, '_usb_tmp_' + crypto.randomBytes(8).toString('hex') + '.db');
  try {
    await db.backupDb(database, tmp);
  } catch (e) {
    for (const p of [tmp, tmp + '-journal']) fs.rmSync(p, { force: true });
    throw e;
  }
  return tmp;
}
const usbBackup = createUsbBackup({
  stateFile: path.join(DIR, 'data', 'usb-backup.json'),
  photoDir: PHOTO_DIR,
  appDir: DIR,
  markerFile: OFFSITE_MARKER_FILE,
  snapshotDb: snapshotForStick,
  log,
  onChange: () => broadcastSSEType('usb-backup'),
  enabled: process.env.USB_BACKUP !== 'off'
});
usbBackup.start();

// Der Zeitstempel einer Stelle draussen auf die Pruefkette des Laborbuchs,
// einmal am Tag, wenn seit dem letzten etwas dazukam (scripts/lab-timestamps.js,
// "Pruefkette" in db.js). Dorthin geht nur ein Hash. LAB_TSA_URL waehlt die
// Stelle, "off" schaltet es ab; eine Worktree-Instanz stempelt nicht.
const { createLabTimestamps, tsaFromEnv } = require('./scripts/lab-timestamps.js');
const labStamps = createLabTimestamps({
  getDb: () => database,
  dbApi: db,
  url: tsaFromEnv(process.env, WORKTREE_MODE),
  log
});
labStamps.start();
// Das Laborbuch als ZIP (GET /api/lab-book/archive); eins zur Zeit.
const labArchive = require('./scripts/lab-archive.js');
let labArchiveRunning = false;
// Was /api/health und /api/health/full ueber die Kette sagen: der Befund aus
// db.js ohne Token, dazu, wohin gestempelt wird und ob es zuletzt klappte.
//
// Die Pruefung rechnet die ganze Kette nach, einige Millisekunden je tausend
// Eintraege, und die Signatur jedes Zeitstempels, beim ersten Mal etwa eine
// halbe Millisekunde je Token, danach gemerkt. Der Server-Reiter und der
// Neustart fragen oft; eine Minute lang gilt deshalb der letzte Befund,
// solange die Datenbank dieselbe ist. Das Laborbuch prueft jedes Mal selbst.
let labChainCache = null;
function labChainHealth() {
  const jetzt = Date.now();
  if (!labChainCache || labChainCache.database !== database || jetzt - labChainCache.at > 60 * 1000) {
    labChainCache = { database, at: jetzt, befund: db.labChainStatus(database) };
  }
  return { ...labChainCache.befund, stamping: labStamps.health() };
}

// What the stick routes answer when something is in the way. The code is what
// the interface translates; anything unexpected is logged and stays generic.
const USB_BACKUP_BAD_REQUEST = new Set(['NOT_A_DRIVE', 'BAD_KEY']);
const USB_BACKUP_CONFLICT = new Set([
  'SAME_DISK',
  'NEEDS_KEY',
  'HAS_KEY',
  'EXISTS',
  'WRONG_KEY',
  'FORMAT',
  'NO_STICK',
  'NOT_PRESENT',
  'BUSY',
  'FULL',
  'READ_ONLY',
  'GONE',
  'NOT_EMPTY',
  'SHRUNK',
  'HELD',
  'DAMAGED',
  'STATE'
]);
function usbBackupError(res, e) {
  const code =
    USB_BACKUP_BAD_REQUEST.has(e && e.code) || USB_BACKUP_CONFLICT.has(e && e.code) ? e.code : describeUsbError(e).code;
  if (USB_BACKUP_BAD_REQUEST.has(code) || USB_BACKUP_CONFLICT.has(code)) {
    res.writeHead(USB_BACKUP_BAD_REQUEST.has(code) ? 400 : 409, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: e.message, code }));
    return;
  }
  log('error', 'USB backup request failed', { error: e && e.message });
  res.writeHead(500, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'USB backup request failed', code: 'FAILED' }));
}
// The recovery key travels in a response exactly twice: when the first stick
// is set up, and when an admin asks to see it. Neither may be cached.
function jsonNoStore(res, data) {
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
  res.end(JSON.stringify(data));
}

// ── DUCKDNS IP UPDATE ──────────────────────────────────────
// The updater lives in duckdns.js. What used to be here was a bare five-minute
// timer that believed whatever duckdns.org replied; the retry, the check
// against the authoritative nameservers, and the heartbeat that notices a
// suspend are all over there, each with the reasoning for why it exists.
function startDuckdnsUpdater() {
  // A worktree instance shares the parent repo's settings DB only when it
  // points at the same file — but even with separate DBs the same DuckDNS
  // creds often get copied in. Skip the updater entirely so the worktree
  // never fights prod over the external A record.
  return duckdns.start({ database, dbApi: db, log, skip: WORKTREE_MODE });
}

// Never let the DNS updater be the reason this process does not come up.
//
// start() is called at module load, so anything it throws is a server that
// will not boot — and a lab that cannot be reached from the LAN either,
// over a feature whose entire job is remote access. Keeping a stale DNS
// record is bad; refusing to run the application because of it is worse.
// The scheduler's own ticks are already guarded; this covers the arming.
try {
  startDuckdnsUpdater();
} catch (e) {
  log('error', 'DuckDNS updater failed to start — continuing without it', { error: e.message });
}

// ── OUTBOUND HARVEST FEED ────────────────────────────────────
// Off unless HARVEST_WEBHOOK_URL is set. Pushes a signed summary of recorded
// harvests and upcoming batches to a URL you choose, so a shop, a listing page
// or a chat bot can answer "what do you have today?" without this machine being
// reachable from the internet.
//
// The reply to that push may carry pickups back — collection slots the receiver
// took bookings for. Still no inbound route: no listener, no port, no way to
// reach this machine unbidden. What changed is that data from the far end now
// reaches the database, which makes the reply a trust boundary. See
// harvest-feed.js for the payload, the validation, and the reasoning.
//
// Skipped in worktree mode for the same reason as DuckDNS: a second copy of the
// server usually inherits the same .env, and two of them posting different
// snapshots to one receiver is worse than one posting none.
function startHarvestFeed() {
  return harvestFeed.start({ database, env: process.env, log, skip: WORKTREE_MODE, dbApi: db });
}
startHarvestFeed();

/**
 * Tell the feed a release changed, so the shop hears it now.
 *
 * ⚠️ **The gap this closes runs the dangerous way round.** Saving a release only
 * wrote a row; the shop heard about it whenever the timer next fired, up to a
 * full interval later. Raising a release late is harmless — the shop offers too
 * little for a while. *Lowering* one is not: somebody sells 1.5 kg at the stall,
 * cuts the release from 2 kg to 0.5, and for the next quarter of an hour the
 * shop is still taking bookings against the old figure. The lab then shows
 * 0.5 kg released above a 1 kg pickup, and the person who typed the number had
 * no way to know.
 *
 * Fire-and-forget. The save has already succeeded and the row is the source of
 * truth; a receiver that is down must not turn a stored decision into an error
 * message. The timer carries the same numbers a few minutes later regardless.
 *
 * The guarding lives in harvest-feed.js — see pushNow() for why calling
 * deliver() from here was wrong.
 */
function pushHarvestFeedNow(why) {
  harvestFeed
    .pushNow({ database, env: process.env, log, dbApi: db, skip: WORKTREE_MODE })
    .then((wie) => {
      if (wie === 'busy') log('info', 'Harvest feed already sending — immediate push folded in', { why });
    })
    .catch((e) => log('warn', 'Immediate harvest feed push errored', { why, error: e.message }));
}

// ── LET'S ENCRYPT CERT MANAGEMENT (native ACME v2) ─────────
// Pure Node.js — no bash, curl, or acme.sh required.
// Uses built-in crypto + https for ACME v2 (RFC 8555) with DNS-01 challenge.

const ACME_DIR_URL = 'https://acme-v02.api.letsencrypt.org/directory';
const ACME_ACCOUNT_KEY_PATH = path.join(DIR, 'certs', 'acme-account-key.pem');

function base64url(data) {
  return (Buffer.isBuffer(data) ? data : Buffer.from(data)).toString('base64url');
}

// ── DER / ASN.1 encoding helpers ──
function derLen(len) {
  if (len < 0x80) return Buffer.from([len]);
  if (len < 0x100) return Buffer.from([0x81, len]);
  return Buffer.from([0x82, len >> 8, len & 0xff]);
}
function derWrap(tag, buf) {
  return Buffer.concat([Buffer.from([tag]), derLen(buf.length), buf]);
}
function derSeq(...items) {
  return derWrap(0x30, Buffer.concat(items));
}
function derSet(...items) {
  return derWrap(0x31, Buffer.concat(items));
}
function derOid(bytes) {
  return derWrap(0x06, Buffer.from(bytes));
}
function derUtf8(str) {
  return derWrap(0x0c, Buffer.from(str, 'utf8'));
}
function derBitStr(buf) {
  return derWrap(0x03, Buffer.concat([Buffer.from([0x00]), buf]));
}
function derOctStr(buf) {
  return derWrap(0x04, buf);
}
function derInt(n) {
  return derWrap(0x02, Buffer.from([n]));
}

// ── HTTPS JSON request helper ──
function _acmeHttps(method, url, body, extraHeaders, callback) {
  if (typeof extraHeaders === 'function') {
    callback = extraHeaders;
    extraHeaders = {};
  }
  const u = new URL(url);
  const opts = {
    hostname: u.hostname,
    port: u.port || 443,
    path: u.pathname + u.search,
    method,
    headers: Object.assign({}, extraHeaders)
  };
  let bodyStr = null;
  if (body) {
    bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
    if (!opts.headers['Content-Type']) opts.headers['Content-Type'] = 'application/jose+json';
    opts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
  }
  const req = https.request(opts, (res) => {
    let raw = '';
    res.on('data', (c) => {
      raw += c;
    });
    res.on('end', () => {
      let json = null;
      if (raw)
        try {
          json = JSON.parse(raw);
        } catch (_) {}
      callback(null, res.statusCode, res.headers, json || raw);
    });
  });
  req.on('error', (err) => {
    if (err.code === 'ENOTFOUND')
      return callback(new Error('Server hat keinen Internetzugang (DNS-Auflösung fehlgeschlagen)'));
    if (err.code === 'ECONNREFUSED') return callback(new Error('Verbindung zum ACME-Server abgelehnt'));
    if (err.code === 'ETIMEDOUT' || err.code === 'ESOCKETTIMEDOUT')
      return callback(new Error('Zeitüberschreitung bei Verbindung zum ACME-Server'));
    callback(err);
  });
  req.setTimeout(30000, () => req.destroy(new Error('ACME request timeout')));
  if (bodyStr) req.write(bodyStr);
  req.end();
}

// ── Sequential async helper ──
function waterfall(fns, done) {
  let i = 0;
  (function next(err) {
    if (err || i >= fns.length) return done(err);
    try {
      fns[i++](next);
    } catch (e) {
      done(e);
    }
  })(null);
}

// ── Private-key file handling ──
// S-12: fs.writeFileSync's `mode` only applies when it *creates* the file, and
// a renewal overwrites a key that is already there — which would keep whatever
// mode the old one had, including the 0644 this used to produce. So the mode is
// re-asserted after every write. chmod can fail when the file belongs to
// another account (a key dropped in by root, say); that is worth a warning, not
// a crash, because the alternative is a server that will not start.
function writePrivateFile(filePath, contents) {
  fs.writeFileSync(filePath, contents, { mode: 0o600 });
  try {
    fs.chmodSync(filePath, 0o600);
  } catch (e) {
    log('warn', 'Could not restrict permissions on private file', { path: filePath, error: e.message });
  }
}

function ensurePrivateDir(dir) {
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
  try {
    fs.chmodSync(dir, 0o700);
  } catch (e) {
    log('warn', 'Could not restrict permissions on key directory', { path: dir, error: e.message });
  }
}

// ── ECDSA P-256 account key (persists in certs/) ──
function loadOrCreateAccountKey() {
  if (fs.existsSync(ACME_ACCOUNT_KEY_PATH)) {
    return crypto.createPrivateKey(fs.readFileSync(ACME_ACCOUNT_KEY_PATH));
  }
  log('info', 'Generating ACME account key...');
  const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
  const dir = path.dirname(ACME_ACCOUNT_KEY_PATH);
  ensurePrivateDir(dir);
  // S-12: this key re-issues certificates for the domain. Without a mode it
  // lands at 0666 & ~umask — 0644 on a normal host, readable by every local
  // account. BACKUP_DIR (0700) and the restore temp file (0600) already had
  // this right; the two certs/ writes were simply missed.
  writePrivateFile(ACME_ACCOUNT_KEY_PATH, privateKey.export({ type: 'pkcs8', format: 'pem' }));
  return privateKey;
}

function getAccountJwk(key) {
  const j = crypto.createPublicKey(key).export({ format: 'jwk' });
  return { crv: j.crv, kty: j.kty, x: j.x, y: j.y };
}

function getJwkThumbprint(jwk) {
  const ordered = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
  return base64url(crypto.createHash('sha256').update(ordered).digest());
}

// ── JWS signing ──
function signJws(key, protectedHeader, payload) {
  const protB64 = base64url(JSON.stringify(protectedHeader));
  const payB64 = payload === '' ? '' : base64url(JSON.stringify(payload));
  const sig = crypto.sign('sha256', Buffer.from(protB64 + '.' + payB64), {
    key,
    dsaEncoding: 'ieee-p1363'
  });
  return JSON.stringify({ protected: protB64, payload: payB64, signature: base64url(sig) });
}

// ── PKCS#10 CSR construction ──
function buildCsr(domain) {
  const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
  const spki = publicKey.export({ type: 'spki', format: 'der' });

  // Subject: CN=domain
  const subject = derSeq(derSet(derSeq(derOid([0x55, 0x04, 0x03]), derUtf8(domain))));

  // SAN extension in extensionRequest attribute
  const dnsName = Buffer.concat([
    Buffer.from([0x82]),
    derLen(Buffer.byteLength(domain, 'ascii')),
    Buffer.from(domain, 'ascii')
  ]);
  const sanExt = derSeq(derOid([0x55, 0x1d, 0x11]), derOctStr(derSeq(dnsName)));
  const extReq = derSeq(derOid([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x0e]), derSet(derSeq(sanExt)));
  const attrs = derWrap(0xa0, extReq);

  // CertificationRequestInfo
  const reqInfo = derSeq(derInt(0), subject, spki, attrs);
  const sig = crypto.sign('sha256', reqInfo, privateKey);
  const sigAlg = derSeq(derOid([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b]), Buffer.from([0x05, 0x00]));

  return { der: derSeq(reqInfo, sigAlg, derBitStr(sig)), privateKey };
}

// ── Main certificate request orchestrator ──
function requestLetsEncryptCert(callback) {
  const cfg = db.getDuckdnsCfg(database);
  if (!cfg.domain || !cfg.token) {
    return callback(new Error('DuckDNS domain and token required'));
  }

  const fullDomain = cfg.domain + '.duckdns.org';
  let accountKey, jwk, thumbprint;
  let acmeDir, accountUrl, nonce;
  let order, orderUrl, challenge, certPem, domainKey;

  // Signed ACME POST with nonce tracking + badNonce retry
  function acmePost(url, payload, useJwk, cb) {
    let retries = 0;
    (function attempt() {
      const hdr = { alg: 'ES256', nonce, url };
      if (useJwk) hdr.jwk = jwk;
      else hdr.kid = accountUrl;
      const body = signJws(accountKey, hdr, payload);
      _acmeHttps('POST', url, body, (err, status, headers, data) => {
        if (err) return cb(err);
        nonce = headers['replay-nonce'] || nonce;
        if (data && data.type === 'urn:ietf:params:acme:error:badNonce' && ++retries < 3) {
          return attempt();
        }
        if (status >= 400) {
          const detail = data && data.detail ? data.detail : JSON.stringify(data);
          return cb(new Error('ACME error (' + status + '): ' + detail));
        }
        cb(null, status, headers, data);
      });
    })();
  }

  // Poll until target status
  function poll(url, target, maxAttempts, cb) {
    let attempts = 0;
    (function check() {
      acmePost(url, '', false, (err, _s, _h, data) => {
        if (err) return cb(err);
        if (data.status === target) return cb(null, data);
        if (data.status === 'invalid') {
          const msg = (data.challenges || [])
            .map((c) => c.error && c.error.detail)
            .filter(Boolean)
            .join('; ');
          return cb(new Error('Validation failed: ' + (msg || JSON.stringify(data))));
        }
        if (++attempts >= maxAttempts) return cb(new Error('ACME polling timed out'));
        setTimeout(check, 3000);
      });
    })();
  }

  log('info', "Requesting Let's Encrypt certificate...", { domain: fullDomain });

  waterfall(
    [
      // 1. Load/create account key
      (next) => {
        try {
          accountKey = loadOrCreateAccountKey();
        } catch (e) {
          return next(e);
        }
        jwk = getAccountJwk(accountKey);
        thumbprint = getJwkThumbprint(jwk);
        next(null);
      },
      // 2. Fetch ACME directory
      (next) => {
        _acmeHttps('GET', ACME_DIR_URL, null, (err, _s, _h, data) => {
          if (err) return next(err);
          acmeDir = data;
          next(null);
        });
      },
      // 3. Get initial nonce
      (next) => {
        _acmeHttps('HEAD', acmeDir.newNonce, null, (err, _s, headers) => {
          if (err) return next(err);
          nonce = headers['replay-nonce'];
          next(null);
        });
      },
      // 4. Create or find account
      (next) => {
        acmePost(acmeDir.newAccount, { termsOfServiceAgreed: true }, true, (err, _s, headers) => {
          if (err) return next(err);
          accountUrl = headers.location;
          log('info', 'ACME account ready', { url: accountUrl });
          next(null);
        });
      },
      // 5. Create order
      (next) => {
        acmePost(
          acmeDir.newOrder,
          {
            identifiers: [{ type: 'dns', value: fullDomain }]
          },
          false,
          (err, _s, headers, data) => {
            if (err) return next(err);
            order = data;
            orderUrl = headers.location;
            next(null);
          }
        );
      },
      // 6. Get authorization + dns-01 challenge
      (next) => {
        acmePost(order.authorizations[0], '', false, (err, _s, _h, data) => {
          if (err) return next(err);
          challenge = (data.challenges || []).find((c) => c.type === 'dns-01');
          if (!challenge) return next(new Error('No dns-01 challenge offered'));
          next(null);
        });
      },
      // 7. Set TXT record via DuckDNS
      (next) => {
        const keyAuth = challenge.token + '.' + thumbprint;
        const dns01 = base64url(crypto.createHash('sha256').update(keyAuth).digest());
        log('info', 'Setting DuckDNS TXT record...', { domain: cfg.domain });
        duckdns.setTxt({ domain: cfg.domain, token: cfg.token, value: dns01 }).then(() => next(null), next);
      },
      // 8. Wait for DNS propagation
      (next) => {
        log('info', 'Waiting for DNS propagation (15s)...');
        setTimeout(next, 15000);
      },
      // 9. Respond to challenge
      (next) => {
        acmePost(challenge.url, {}, false, (err) => next(err));
      },
      // 10. Poll authorization until valid
      (next) => {
        poll(order.authorizations[0], 'valid', 40, (err) => {
          if (err) return next(err);
          log('info', 'DNS-01 challenge validated');
          next(null);
        });
      },
      // 11. Build CSR and finalize order
      (next) => {
        const csr = buildCsr(fullDomain);
        domainKey = csr.privateKey;
        acmePost(order.finalize, { csr: base64url(csr.der) }, false, (err) => next(err));
      },
      // 12. Poll order until cert ready
      (next) => {
        poll(orderUrl, 'valid', 20, (err, data) => {
          if (err) return next(err);
          order = data;
          next(null);
        });
      },
      // 13. Download certificate
      (next) => {
        acmePost(order.certificate, '', false, (err, _s, _h, data) => {
          if (err) return next(err);
          certPem = typeof data === 'string' ? data : '';
          if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) {
            return next(new Error('Invalid certificate response'));
          }
          next(null);
        });
      },
      // 14. Save certificate and key
      (next) => {
        try {
          const certsDir = path.dirname(CERT_KEY);
          ensurePrivateDir(certsDir);
          // The certificate is public; the key is not (S-12).
          fs.writeFileSync(CERT_CRT, certPem);
          writePrivateFile(CERT_KEY, domainKey.export({ type: 'pkcs8', format: 'pem' }));
        } catch (e) {
          return next(e);
        }
        next(null);
      }
    ],
    (err) => {
      // Empty the challenge record, then re-assert the address. The cleanup
      // used to send `clear=true`, which DuckDNS documents as clearing the
      // address records too — on a path that runs after a *failed* renewal as
      // well, and retries every twelve hours. duckdns.clearTxt no longer sends
      // it, and the update behind it makes the record correct either way.
      duckdns
        .clearTxt({ domain: cfg.domain, token: cfg.token })
        .then(() => duckdns.updateNow({ database, dbApi: db, log, skip: WORKTREE_MODE }))
        .catch(() => {});
      if (err) {
        log('error', "Let's Encrypt request failed", { error: err.message });
        return callback(err);
      }
      const now = new Date();
      // The expiry the certificate itself carries. The lifetime is the CA's
      // choice and is getting shorter; a fixed 90 days would put the renewal
      // after the expiry of a shorter one. 90 days only if it cannot be read.
      let expiry = null;
      try {
        expiry = new Date(new crypto.X509Certificate(certPem).validTo);
      } catch {
        /* fall back below */
      }
      if (!expiry || isNaN(expiry)) expiry = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
      db.updateDuckdnsStatus(database, {
        leLastRenewal: now.toISOString(),
        leExpiry: expiry.toISOString()
      });
      reloadTlsCerts();
      log('info', "Let's Encrypt cert installed", { domain: fullDomain, expiry: expiry.toISOString() });
      callback(null, { domain: fullDomain, expiry: expiry.toISOString() });
    }
  );
}

// The expiry the renewal goes by, and whether le_expiry should be set to it.
//
// A Let's Encrypt certificate on disk is the fact. le_expiry is a note about
// the last issuance, written by it and never read back, so the note alone went
// wrong after an ordinary restore: a database from before the first issuance
// has no note, and the renewal returned on every tick while the certificate
// ran out; one from an earlier issuance has an old date, and the renewal asked
// for a certificate the disk already had. Without a Let's Encrypt certificate
// on disk (none, or the self-signed fallback) the note is all there is.
function renewalExpiry(note, disk) {
  const noted = note ? new Date(note) : null;
  if (disk && disk.letsEncrypt && disk.validTo) {
    const same = noted && noted.getTime() === disk.validTo.getTime();
    return { expiry: disk.validTo, writeBack: !same };
  }
  return { expiry: noted && !isNaN(noted) ? noted : null, writeBack: false };
}

function checkCertRenewal() {
  // Worktree instance must not race prod for the ACME challenge: both
  // would try to bind port 80 for http-01 and hammer the LE rate limit
  // on the same hostname. Cert files live in this worktree's certs/
  // (per-directory) so the renewal would be lost anyway.
  if (WORKTREE_MODE) return;
  // This runs while the server loads, so nothing in it may stop a start.
  try {
    const cfg = db.getDuckdnsCfg(database);
    if (!cfg.leEnabled || !cfg.domain || !cfg.token) return;
    const { expiry, writeBack } = renewalExpiry(cfg.leExpiry, certOnDisk());
    if (!expiry) return; // no certificate and no note: nothing to renew
    if (writeBack) {
      log('info', 'le_expiry did not match the certificate on disk; taken from the certificate', {
        note: cfg.leExpiry || null,
        expiry: expiry.toISOString()
      });
      db.updateDuckdnsStatus(database, { leExpiry: expiry.toISOString() });
    }
    const daysLeft = (expiry - Date.now()) / (24 * 60 * 60 * 1000);

    if (daysLeft < 30) {
      log('info', "Let's Encrypt cert expires in " + Math.round(daysLeft) + ' days, renewing...');
      requestLetsEncryptCert((err, result) => {
        if (err) log('error', 'Auto-renewal failed', { error: err.message });
        else log('info', 'Auto-renewal succeeded', result);
      });
    }
  } catch (e) {
    log('error', 'Certificate renewal check failed', { error: e.message });
  }
}

checkCertRenewal();
setInterval(checkCertRenewal, 12 * 60 * 60 * 1000);

// Short-lived cache for printer availability check (5 seconds)
let _printerStatusCache = null;
let _printerStatusCacheTime = 0;
// R-20: cached count of stuck print-spooler jobs (Paused/Error/Blocked/etc).
// queueStuck > 0 is a degraded signal even when the printer reports online.
let _printerQueueStuckCache = 0;

// Read the effective bridge configuration. DB values (Settings → Drucker)
// take precedence over env vars (PRINT_BRIDGE_URL/PRINT_BRIDGE_TOKEN), so a
// user can configure the bridge from the UI without restarting the server.
// Returns null if neither is configured — callers should treat that as
// "no bridge available" and fall back to the local PowerShell path.
function getEffectiveBridgeConfig() {
  try {
    const cfg = db.getPrintBridgeCfg(database);
    if (cfg.enabled && cfg.url) {
      return { url: cfg.url.replace(/\/+$/, ''), token: cfg.token || '', source: 'db' };
    }
  } catch (e) {
    log('warn', 'Failed to read print_bridge_config', { error: e.message });
  }
  if (PRINT_BRIDGE_URL_ENV) {
    return { url: PRINT_BRIDGE_URL_ENV, token: PRINT_BRIDGE_TOKEN_ENV, source: 'env' };
  }
  return null;
}

// S-23: the bridge's certificate is self-signed by `print-bridge.ps1 -Install`,
// so there is no chain to validate — and the request used to go out with
// rejectUnauthorized:false and nothing else. Encryption without authentication
// stops a passive listener and does nothing about an active one: anybody on the
// LAN able to answer for the bridge's address could present their own
// certificate, collect the X-Bridge-Token from the header, and hand back
// whatever printer status they liked.
//
// A CA is the wrong tool for a machine on a workshop LAN, so the certificate is
// pinned instead. The first connection to an address records its SHA-256
// fingerprint; every connection after that compares. The first one is still
// trusted blind — that is the known limit of trust-on-first-use — but it
// happens once, on the operator's own network, right after they ran the
// installer, rather than on every print for the life of the bridge.
//
// The check runs on 'secureConnect' and destroys the socket there. Verified: at
// that point Node has not yet written the request head, so a mismatch costs the
// attacker the token as well as the payload.
//
// Re-pinning is a deliberate act — saving the printer settings clears the pin.
// See db.updatePrintBridgeCfg.
function _pinnedBridgeFingerprint(origin) {
  try {
    const cfg = db.getPrintBridgeCfg(database);
    return cfg.certUrl === origin ? cfg.certFp || '' : '';
  } catch (e) {
    return '';
  }
}

// Make an HTTPS request to the print bridge with a 5s timeout.
// callback(err, { statusCode, body, raw }) — body is parsed JSON or null.
//
// HTTP URLs are still tolerated for backwards compatibility with .env-based
// setups predating the TLS switch — they log a warning every request so the
// operator notices.
function _bridgeRequest(method, urlPath, body, callback) {
  const cfg = getEffectiveBridgeConfig();
  if (!cfg) {
    return callback(new Error('No print bridge configured'));
  }
  let target;
  try {
    target = new URL(urlPath, cfg.url + '/');
  } catch (e) {
    return callback(new Error('Invalid bridge URL: ' + e.message));
  }
  const isHttps = target.protocol === 'https:';
  if (!isHttps) {
    log('warn', 'Print bridge using cleartext HTTP — re-run print-bridge.ps1 -Install on Windows to upgrade to TLS', {
      bridge: cfg.url
    });
  }
  const lib = isHttps ? https : http;
  const headers = {};
  if (cfg.token) headers['X-Bridge-Token'] = cfg.token;
  const bodyBuf = body == null ? null : Buffer.from(body, 'utf8');
  if (bodyBuf) {
    headers['Content-Type'] = 'text/plain; charset=utf-8';
    headers['Content-Length'] = bodyBuf.length;
  }
  const reqOpts = { method, headers, timeout: 5000 };
  if (isHttps) {
    // Self-signed, so the chain cannot be validated. The fingerprint check
    // below is what authenticates the peer (S-23).
    reqOpts.rejectUnauthorized = false;
  }
  const req = lib.request(target, reqOpts, (res) => {
    let data = '';
    res.setEncoding('utf8');
    res.on('data', (c) => (data += c));
    res.on('end', () => {
      let parsed = null;
      try {
        parsed = JSON.parse(data);
      } catch {
        /* not JSON — leave parsed null */
      }
      callback(null, { statusCode: res.statusCode, body: parsed, raw: data });
    });
  });
  if (isHttps) {
    req.on('socket', (socket) => {
      socket.on('secureConnect', () => {
        const cert = socket.getPeerCertificate();
        const fingerprint = (cert && cert.fingerprint256) || '';
        if (!fingerprint) {
          socket.destroy(new Error('Bridge presented no certificate'));
          return;
        }
        const pinned = _pinnedBridgeFingerprint(target.origin);
        if (!pinned) {
          try {
            db.setPrintBridgeCertPin(database, target.origin, fingerprint);
            log('info', 'Pinned print bridge certificate on first connection', {
              bridge: target.origin,
              fingerprint
            });
          } catch (e) {
            log('warn', 'Could not store the print bridge certificate pin', { error: e.message });
          }
          return;
        }
        if (fingerprint !== pinned) {
          log('error', 'Print bridge certificate does not match the pinned one — refusing to send', {
            bridge: target.origin,
            expected: pinned,
            got: fingerprint
          });
          socket.destroy(
            new Error(
              'Bridge certificate changed. If you re-installed the print bridge, save the printer settings again to re-pin it.'
            )
          );
        }
      });
    });
  }
  req.on('timeout', () => {
    req.destroy(new Error('Bridge timeout'));
  });
  req.on('error', (err) => callback(err));
  if (bodyBuf) req.write(bodyBuf);
  req.end();
}

// Returns a rich status object describing whether label printing is currently
// possible. State values:
//   'online'              — printer reachable and ready to receive ZPL
//   'printer_offline'     — bridge reachable but the printer is not connected
//   'bridge_unreachable'  — bridge configured but did not respond (Windows
//                           PC off, network issue)
//   'no_bridge'           — server is non-Windows and no bridge is
//                           configured; clients should use the ZPL-download
//                           flow
//   'local_unavailable'   — Windows-local PowerShell path could not find
//                           the printer
function getPrinterStatus(callback) {
  const cfg = getEffectiveBridgeConfig();
  if (cfg) {
    return _bridgeRequest('GET', 'status', null, (err, resp) => {
      if (err) {
        return callback(null, {
          state: 'bridge_unreachable',
          name: PRINTER_NAME,
          online: false,
          queueStuck: 0,
          bridge: cfg.url,
          error: err.message
        });
      }
      const printer = resp && resp.body && resp.body.printer;
      // R-20: pass queueStuck through unchanged. Bridge versions that
      // pre-date the audit will return undefined — coerce to 0 so consumers
      // can rely on the field being a number.
      const queueStuck = resp && resp.body && Number.isFinite(resp.body.queueStuck) ? resp.body.queueStuck : 0;
      if (resp && resp.statusCode === 200 && resp.body && resp.body.ok && printer && printer.online) {
        return callback(null, {
          state: 'online',
          name: printer.name || PRINTER_NAME,
          online: true,
          queueStuck
        });
      }
      return callback(null, {
        state: 'printer_offline',
        name: (printer && printer.name) || PRINTER_NAME,
        online: false,
        queueStuck,
        error: (resp && resp.body && resp.body.error) || null
      });
    });
  }
  if (process.platform !== 'win32') {
    return callback(null, { state: 'no_bridge', name: PRINTER_NAME, online: false, queueStuck: 0 });
  }
  execFile(
    'powershell',
    [
      '-NoProfile',
      '-Command',
      `Get-Printer -Name "${PRINTER_NAME.replace(/"/g, '')}" | Select-Object -Property Name,PrinterStatus | ConvertTo-Json`
    ],
    // Timeout so a hung spooler/PowerShell can't leave /api/printer-status and
    // any /api/print waiting on checkPrinterAvailable hanging forever (the
    // bridge and the print path already cap at 5 s / 15 s). On timeout execFile
    // kills the child and calls back with an error → local_unavailable below.
    { timeout: 10000, windowsHide: true },
    (err, stdout) => {
      if (err || !stdout.includes(PRINTER_NAME)) {
        return callback(null, { state: 'local_unavailable', name: PRINTER_NAME, online: false, queueStuck: 0 });
      }
      callback(null, { state: 'online', name: PRINTER_NAME, online: true, queueStuck: 0 });
    }
  );
}

function checkPrinterAvailable(callback) {
  const now = Date.now();
  if (_printerStatusCache !== null && now - _printerStatusCacheTime < 5000) {
    return callback(null, _printerStatusCache);
  }
  getPrinterStatus((err, status) => {
    const available = !err && status && status.state === 'online';
    _printerStatusCache = available;
    _printerQueueStuckCache = status && Number.isFinite(status.queueStuck) ? status.queueStuck : 0;
    _printerStatusCacheTime = Date.now();
    callback(null, available);
  });
}

// Send raw ZPL to the Zebra GK420d. Routes through the print bridge if one
// is configured (DB or env), otherwise prints locally via PowerShell
// (Windows-only — fails on Linux servers without a bridge).
// S-24: ZPL is a command language, not a document format, and /api/print took
// whatever the client sent and handed it straight to the printer. The label
// layout is generated in the browser, so "the client" is anybody with a login —
// and the language reaches well past drawing: ^JU and ~JS persist configuration,
// ~DY and ^DF write files onto the printer, the ^W* family reconfigures the
// wireless interface, ^KP sets a password. A Link-OS printer on the lab network
// is a small networked computer, and this endpoint was an unauthenticated shell
// on it for anybody the app had let in the front door.
//
// So: an allowlist of the commands the label layouts actually emit, plus a
// couple of unambiguously safe drawing ones. Anything else is refused. It has to
// be an allowlist rather than a blocklist — the command set is large, printer
// firmware adds to it, and ^CC/~CC can redefine the command character itself and
// walk straight through any blocklist written against the default one.
//
// Every ^ or ~ in the payload is treated as introducing a command, which is
// exactly how the printer reads it. Legitimate labels never carry a bare one:
// the layout code strips ^ and ~ out of every data field before it gets here
// (zplText in app.js and mcp-server.js), so a ^ inside ^FD data means the client
// is not the one we ship.
const ZPL_ALLOWED_COMMANDS = new Set([
  // Format boundaries
  'XA',
  'XZ',
  // Label geometry, orientation, encoding, copies
  'PW',
  'LL',
  'LH',
  'LT',
  'LS',
  'PO',
  'FW',
  'CI',
  'PQ',
  // Fields: origin, typeset origin, block, separator, data, comment, reverse
  'FO',
  'FT',
  'FB',
  'FS',
  'FD',
  'FX',
  'FR',
  // Drawing
  'GB',
  // Barcodes: defaults, Code 128, QR, Code 39
  'BY',
  'BC',
  'BQ',
  'B3'
]);
// ^A0..^A9 and ^AA..^AZ select a font that lives in firmware. ^A@ loads one from
// the printer's file system, which is the kind of thing this list exists to keep
// out, so the character class is deliberately narrow.
const ZPL_FONT_RE = /^A[0-9A-Z]$/;
// A label is on the order of a kilobyte. The body cap is 5 MB, which would be
// tens of thousands of them.
const ZPL_MAX_CHARS = 1024 * 1024;
const ZPL_MAX_LABELS = 2000;

/** null when the payload is safe to send, otherwise the reason it is not. */
function checkZpl(zpl) {
  if (typeof zpl !== 'string' || !zpl.trim()) return 'empty ZPL';
  if (zpl.length > ZPL_MAX_CHARS) return 'ZPL too large (' + zpl.length + ' chars)';
  if (!zpl.includes('^XA') || !zpl.includes('^XZ')) return 'ZPL must contain ^XA and ^XZ';
  const labels = (zpl.match(/\^XA/g) || []).length;
  if (labels > ZPL_MAX_LABELS) return 'too many labels in one job (' + labels + ')';

  const rejected = new Set();
  for (let i = 0; i < zpl.length; i++) {
    const lead = zpl[i];
    if (lead !== '^' && lead !== '~') continue;
    const cmd = zpl.slice(i + 1, i + 3).toUpperCase();
    // Tilde commands are all device control — none of them draw anything.
    if (lead === '^' && (ZPL_ALLOWED_COMMANDS.has(cmd) || ZPL_FONT_RE.test(cmd))) continue;
    rejected.add(lead + cmd);
    if (rejected.size >= 5) break;
  }
  if (rejected.size) return 'ZPL contains commands that are not allowed: ' + [...rejected].join(' ');
  return null;
}

function printZPL(zplData, callback) {
  // Checked here rather than in the route, so the /api/print handler, the bridge
  // self-test and the MCP print tools are all covered by the one gate.
  const bad = checkZpl(zplData);
  if (bad) {
    log('warn', 'Refused a print job', { reason: bad });
    return callback('Refused: ' + bad);
  }
  // Every label that left the printer is remembered by its ID, so one whose
  // culture or bag never got saved shows up in the bell the same day.
  const done = (err) => {
    if (!err) rememberPrintedLabels(zplData);
    callback(err);
  };
  if (getEffectiveBridgeConfig()) return _printViaBridge(zplData, done);
  return _printViaPowerShell(zplData, done);
}

function _printViaBridge(zplData, callback) {
  const zplFixed = zplData.replace(/\r?\n/g, '\r\n');
  _bridgeRequest('POST', 'print', zplFixed, (err, resp) => {
    if (err) {
      const cfg = getEffectiveBridgeConfig();
      log('error', 'Print bridge unreachable', { error: err.message, bridge: cfg && cfg.url });
      return callback('Print bridge unreachable: ' + err.message);
    }
    if (!resp || resp.statusCode !== 200 || !resp.body || !resp.body.ok) {
      const reason = (resp && resp.body && resp.body.error) || 'HTTP ' + (resp && resp.statusCode);
      log('error', 'Bridge print failed', { reason });
      return callback('Bridge print failed: ' + reason);
    }
    log('info', 'Print via bridge OK', { bytes: resp.body.bytes });
    callback(null);
  });
}

function _printViaPowerShell(zplData, callback) {
  // I-18: rapid-fire prints (label batch on a single click) can land in the
  // same millisecond, so two callers would share `mp_label_<ms>.zpl` and
  // overwrite each other. Random suffix keeps each call's tempfile unique.
  const tmp = path.join(os.tmpdir(), 'mp_label_' + crypto.randomBytes(8).toString('hex') + '.zpl');
  const zplFixed = zplData.replace(/\r?\n/g, '\r\n');

  fs.writeFile(tmp, zplFixed, 'binary', (err) => {
    if (err) return callback('Could not write temp file: ' + err.message);

    const ps = `
$printerName = "${PRINTER_NAME}"
$filePath = "${tmp.replace(/\\/g, '\\\\')}"
Try {
  Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class RawPrinter {
  [DllImport("winspool.drv", EntryPoint="OpenPrinterA", SetLastError=true)]
  public static extern bool OpenPrinter(string szPrinter, out IntPtr hPrinter, IntPtr pd);
  [DllImport("winspool.drv", EntryPoint="ClosePrinter", SetLastError=true)]
  public static extern bool ClosePrinter(IntPtr hPrinter);
  [DllImport("winspool.drv", EntryPoint="StartDocPrinterA", SetLastError=true)]
  public static extern int StartDocPrinter(IntPtr hPrinter, int level, [In, MarshalAs(UnmanagedType.LPStruct)] DOCINFO di);
  [DllImport("winspool.drv", EntryPoint="EndDocPrinter", SetLastError=true)]
  public static extern bool EndDocPrinter(IntPtr hPrinter);
  [DllImport("winspool.drv", EntryPoint="StartPagePrinter", SetLastError=true)]
  public static extern bool StartPagePrinter(IntPtr hPrinter);
  [DllImport("winspool.drv", EntryPoint="EndPagePrinter", SetLastError=true)]
  public static extern bool EndPagePrinter(IntPtr hPrinter);
  [DllImport("winspool.drv", EntryPoint="WritePrinter", SetLastError=true)]
  public static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
}
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]
public class DOCINFO {
  [MarshalAs(UnmanagedType.LPStr)] public string pDocName;
  [MarshalAs(UnmanagedType.LPStr)] public string pOutputFile;
  [MarshalAs(UnmanagedType.LPStr)] public string pDataType;
}
"@
  $bytes = [System.IO.File]::ReadAllBytes($filePath)
  $hPrinter = [IntPtr]::Zero
  $di = New-Object DOCINFO
  $di.pDocName = "ZPL Label"
  $di.pOutputFile = $null
  $di.pDataType = "RAW"
  if (![RawPrinter]::OpenPrinter($printerName, [ref]$hPrinter, [IntPtr]::Zero)) { throw "Cannot open printer: $printerName" }
  if ([RawPrinter]::StartDocPrinter($hPrinter, 1, $di) -eq 0) { [RawPrinter]::ClosePrinter($hPrinter); throw "StartDocPrinter failed" }
  [RawPrinter]::StartPagePrinter($hPrinter) | Out-Null
  $ptrBytes = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
  [System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $ptrBytes, $bytes.Length)
  $written = 0
  [RawPrinter]::WritePrinter($hPrinter, $ptrBytes, $bytes.Length, [ref]$written) | Out-Null
  [System.Runtime.InteropServices.Marshal]::FreeHGlobal($ptrBytes)
  [RawPrinter]::EndPagePrinter($hPrinter) | Out-Null
  [RawPrinter]::EndDocPrinter($hPrinter) | Out-Null
  [RawPrinter]::ClosePrinter($hPrinter) | Out-Null
  Write-Output "OK:$written bytes sent"
} Catch {
  Write-Error $_.Exception.Message
  exit 1
}
`.trim();

    // R-11: Phase 2 I-18 fixed Date.now() in the .zpl tempfile but missed
    // this .ps1 sibling. Two concurrent print calls would step on the same
    // tempfile and one would lose its script.
    const psTmp = path.join(os.tmpdir(), 'mp_print_' + crypto.randomBytes(8).toString('hex') + '.ps1');
    fs.writeFile(psTmp, ps, 'utf8', (err2) => {
      if (err2) {
        fs.unlink(tmp, () => {});
        return callback('Could not write PS script: ' + err2.message);
      }

      const cleanup = () => {
        fs.unlink(tmp, (e) => {
          if (e) log('warn', 'Failed to delete temp ZPL file', { error: e.message });
        });
        fs.unlink(psTmp, (e) => {
          if (e) log('warn', 'Failed to delete temp PS1 file', { error: e.message });
        });
      };

      const child = execFile(
        'powershell',
        ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', psTmp],
        (e, stdout, stderr) => {
          cleanup();
          if (e) {
            log('error', 'PowerShell print error', { error: stderr || e.message });
            callback('Print failed: ' + (stderr || e.message).trim());
          } else {
            log('info', 'Print OK', { output: stdout.trim() });
            callback(null);
          }
        }
      );

      const timeout = setTimeout(() => {
        child.kill();
        cleanup();
        log('warn', 'PowerShell print process timed out and was killed');
        callback('Print failed: PowerShell process timed out');
      }, 15000);

      child.on('close', () => clearTimeout(timeout));
    });
  });
}

// ══════════════════════════════════════════════════════════════
// ── BUILT-IN CalDAV SERVER ───────────────────────────────────
// ══════════════════════════════════════════════════════════════
// Serves VTODO calendars at /caldav/ — compatible with any
// CalDAV client (Apple Calendar, Thunderbird, DAVx5, etc.)
// Data stored in /calendars/<cal-name>/<uid>.ics
// No npm dependencies — pure Node.js

// RFC 5545 §3.1: fold content lines longer than 75 octets
function foldIcsLines(icsText) {
  return icsText
    .split('\r\n')
    .map((line) => {
      if (Buffer.byteLength(line, 'utf8') <= 75) return line;
      const parts = [];
      let remaining = line;
      let first = true;
      while (Buffer.byteLength(remaining, 'utf8') > 75) {
        const limit = first ? 75 : 74; // subsequent lines have leading space
        let cut = limit;
        while (cut > 0 && Buffer.byteLength(remaining.slice(0, cut), 'utf8') > limit) cut--;
        parts.push(remaining.slice(0, cut));
        remaining = remaining.slice(cut);
        first = false;
      }
      if (remaining) parts.push(remaining);
      return parts.join('\r\n ');
    })
    .join('\r\n');
}

// Unfold RFC 5545 §3.1 folded lines (CRLF + space/tab → join)
function unfoldIcs(text) {
  return text.replace(/\r?\n[ \t]/g, '');
}

function generateUID() {
  return 'mp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}

// Read only the first N bytes of a file (for orphan cleanup — avoids reading entire ICS)
function readFileHead(filePath, bytes) {
  const fd = fs.openSync(filePath, 'r');
  try {
    const buf = Buffer.alloc(bytes);
    const bytesRead = fs.readSync(fd, buf, 0, bytes, 0);
    return buf.toString('utf8', 0, bytesRead);
  } finally {
    fs.closeSync(fd);
  }
}

// Escape iCalendar TEXT values per RFC 5545 §3.3.11
function escapeIcsText(s) {
  return String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}

// I-15: build a single-line RRULE property from our internal recurrence enum.
// Returns null when recurrence is unset or unsupported (so the caller can omit
// the property entirely). UNTIL is encoded in UTC RFC-5545 form
// (YYYYMMDDTHHMMSSZ) when `until` is provided as an ISO date / datetime.
function buildRRuleLine(recurrence, until) {
  const freqMap = { daily: 'DAILY', weekly: 'WEEKLY', monthly: 'MONTHLY', yearly: 'YEARLY' };
  const freq = freqMap[String(recurrence || '').toLowerCase()];
  if (!freq) return null;
  let line = 'RRULE:FREQ=' + freq;
  if (until) {
    // Accept "YYYY-MM-DD" or full ISO; emit UTC "YYYYMMDDTHHMMSSZ".
    const d = new Date(until);
    if (!isNaN(d.getTime())) {
      const u = d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
      line += ';UNTIL=' + u;
    }
  }
  return line;
}

// I-15: format a single EXDATE value to match the event's DTSTART encoding.
// `dateStr` is whatever the JSON-array column holds — typically YYYY-MM-DD or
// a full ISO datetime. `dtstartIsAllDay` controls whether we emit a date-only
// value (VALUE=DATE) or a TZID-qualified local datetime.
function formatExceptionDate(dateStr, dtstartIsAllDay, dtstartTime) {
  if (!dateStr) return null;
  const trimmed = String(dateStr).trim();
  if (!trimmed) return null;
  if (dtstartIsAllDay) {
    // Date-only: strip any time portion, emit YYYYMMDD.
    const dateOnly = trimmed.slice(0, 10).replace(/-/g, '');
    if (!/^\d{8}$/.test(dateOnly)) return null;
    return dateOnly;
  }
  // Datetime: combine the exception date with the original event time so the
  // EXDATE matches the DTSTART instance exactly. dtstartTime is "HH:MM".
  const dateOnly = trimmed.slice(0, 10).replace(/-/g, '');
  if (!/^\d{8}$/.test(dateOnly)) return null;
  const hhmm = (dtstartTime || '00:00').replace(':', '') + '00';
  return dateOnly + 'T' + hhmm;
}

// I-15: render the full EXDATE line(s) for an event. CalDAV clients accept a
// single EXDATE with comma-separated values. Returns null when there are no
// exceptions so the caller can skip pushing the property.
function buildExdateLine(exceptionDates, dtstartIsAllDay, dtstartTime, tzid) {
  if (!Array.isArray(exceptionDates) || !exceptionDates.length) return null;
  const formatted = exceptionDates.map((d) => formatExceptionDate(d, dtstartIsAllDay, dtstartTime)).filter(Boolean);
  if (!formatted.length) return null;
  if (dtstartIsAllDay) return 'EXDATE;VALUE=DATE:' + formatted.join(',');
  return 'EXDATE;TZID=' + (tzid || 'Europe/Berlin') + ':' + formatted.join(',');
}

// Sanitize URL path parts to prevent directory traversal attacks.
// Platform-independent: rejects every byte that could mean "another path
// component" (forward slash, backslash, NUL) regardless of whether the
// process is running on POSIX or Windows. Also rejects empty / dot / dot-dot.
function sanitizePart(s) {
  if (typeof s !== 'string' || !s) return null;
  // Reject explicit traversal characters and dotfile names
  if (/[\/\\\0]/.test(s)) return null;
  if (s === '.' || s === '..') return null;
  return s;
}

function escapeXml(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// Extract username from Basic auth header (without verifying password)
function extractBasicAuthUsername(req) {
  const authHeader = req.headers['authorization'] || '';
  if (!authHeader.startsWith('Basic ')) return null;
  const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
  const idx = decoded.indexOf(':');
  if (idx < 0) return null;
  const username = decoded.slice(0, idx);
  // S-16: this value is only ever used to build brute-force throttle keys, and
  // a name that cannot belong to an account has no business becoming one. The
  // header is bounded by maxHeaderSize so the exposure is far smaller than on
  // the JSON login route, but it is the same shape.
  return USERNAME_RE.test(username) ? username : null;
}

// Check CalDAV basic auth against user accounts.
// Handles charset mismatches: iOS/Safari may send Basic Auth as ISO-8859-1
// (latin1) when the WWW-Authenticate header lacks charset="UTF-8".
// Also tries case-insensitive username lookup as fallback.
// S-14: CalDAV re-verifies Basic auth on every single request, so raising the
// account KDF to N=131072 would put a few hundred milliseconds of CPU behind
// each PROPFIND — a phone syncing eight calendars would keep a core busy on its
// own. Only the *outcome of the password check* is cached: the account row is
// re-read from the database on every hit, so a role change, a revoked
// capability or a deleted account still takes effect immediately. Failures are
// never cached — brute force is already bounded by the per-user and per-IP
// lockout above, and caching a miss would only raise an attacker's throughput.
//
// The key is peppered with a per-process random value, so the cache is
// meaningless to anyone who reads it out of a core dump on another machine.
const CALDAV_AUTH_CACHE_TTL = 5 * 60 * 1000;
const CALDAV_AUTH_CACHE_MAX = 200;
const CALDAV_AUTH_PEPPER = crypto.randomBytes(32);
const caldavAuthCache = new Map(); // key → { username, expires }

function caldavAuthCacheKey(user, pass) {
  return crypto
    .createHash('sha256')
    .update(CALDAV_AUTH_PEPPER)
    .update(user + '\u0000' + pass)
    .digest('hex');
}

// Called whenever a password changes or an account goes away. The TTL alone
// would get there within five minutes; this makes it immediate.
function clearCaldavAuthCache() {
  caldavAuthCache.clear();
}

function checkCaldavAuth(req) {
  const authHeader = req.headers['authorization'] || '';
  if (!authHeader.startsWith('Basic ')) return false;
  const raw = Buffer.from(authHeader.slice(6), 'base64');

  // Try UTF-8 first (standard), then latin1 (legacy Basic Auth default per RFC 2617)
  for (const encoding of ['utf8', 'latin1']) {
    const decoded = raw.toString(encoding);
    const idx = decoded.indexOf(':');
    if (idx < 0) continue;
    const user = decoded.slice(0, idx);
    const pass = decoded.slice(idx + 1);
    if (!USERNAME_RE.test(user)) continue; // S-16: cannot be an account

    // S-25: an app-specific password first. It is a SHA-256 lookup rather than
    // a scrypt verify, so it costs nothing — which is also why it needs no
    // cache entry. The username still has to match its owner: an app password
    // opens that person's calendars, not whoever presents it.
    const appPw = db.findCaldavAppPassword(database, pass);
    if (appPw) {
      const owner = db.getUserByUsername(database, user) || db.getUserByUsernameCaseInsensitive(database, user);
      if (owner && owner.id === appPw.userId) {
        try {
          db.touchCaldavAppPassword(database, appPw.id);
        } catch (e) {
          /* the audit stamp is best effort; it must not cost somebody their calendar */
        }
        return owner;
      }
    }

    const cacheKey = caldavAuthCacheKey(user, pass);
    const cached = caldavAuthCache.get(cacheKey);
    if (cached) {
      if (cached.expires > Date.now()) {
        const fresh = db.getUserByUsername(database, cached.username);
        if (fresh) return fresh;
      }
      caldavAuthCache.delete(cacheKey);
    }

    // Try exact username match, then case-insensitive fallback
    const account = db.getUserByUsername(database, user) || db.getUserByUsernameCaseInsensitive(database, user);
    if (!account) continue;
    if (db.verifyPassword(account.hash, account.salt, pass)) {
      // Insertion order is eviction order — drop the oldest entry at the cap so
      // a client cycling credentials cannot grow this without bound.
      if (caldavAuthCache.size >= CALDAV_AUTH_CACHE_MAX) {
        caldavAuthCache.delete(caldavAuthCache.keys().next().value);
      }
      caldavAuthCache.set(cacheKey, { username: account.username, expires: Date.now() + CALDAV_AUTH_CACHE_TTL });
      return account;
    }
  }
  return false;
}

// ── CalDAV category calendars with colors matching web calendar ──
const CALDAV_CATEGORY_CALS = {
  // The key is the on-disk directory and URL segment and cannot be renamed
  // without breaking every existing subscription; the display name is what the
  // calendar client actually shows, so it carries no operator name.
  meisterpilze: { displayName: 'Aufgaben (geteilt)', color: '#16a34a' },
  faelligkeiten: { displayName: 'Fälligkeiten', color: '#ef4444' },
  aufgaben: { displayName: 'Aufgaben', color: '#3b82f6' },
  'eigene-termine': { displayName: 'Eigene Termine', color: '#22c55e' },
  meetings: { displayName: 'Meetings', color: '#8b5cf6' },
  lieferungen: { displayName: 'Lieferungen', color: '#14b8a6' },
  wartung: { displayName: 'Wartung', color: '#64748b' },
  abholung: { displayName: 'Abholfenster', color: '#f59e0b' }
};
const CALDAV_EVENT_CATEGORY_MAP = {
  custom: 'eigene-termine',
  meeting: 'meetings',
  delivery: 'lieferungen',
  maintenance: 'wartung',
  pickup: 'abholung'
};
const USER_CALENDAR_COLORS = ['#f97316', '#ec4899', '#eab308', '#6366f1', '#06b6d4', '#84cc16', '#d946ef'];

// Compute dominant batch location from scan log (server-side equivalent of web getBatchLoc)
function getBatchLocServer(batch, scanLog) {
  const locs = {};
  (batch.bags || []).forEach((bag) => {
    // A new Charge posts its bags as { id, bagKg }; one read back from the
    // database has plain IDs. Both are a bag. Taking only the string form
    // threw on every new Charge, and its due date never reached the calendar.
    const id = String((bag && typeof bag === 'object' ? bag.id : bag) || '').toUpperCase();
    if (!id) return;
    const last = [...scanLog].reverse().find((e) => (e.bag || '').toUpperCase() === id);
    if (last && last.action !== 'REMOVE' && last.to) locs[last.to] = (locs[last.to] || 0) + 1;
  });
  const entries = Object.entries(locs);
  if (!entries.length) return '';
  entries.sort((a, b) => b[1] - a[1]);
  return entries[0][0];
}

// Per-calendar access control: personal calendars are only accessible by that user (or admins)
// Shared 'meisterpilze' calendar is accessible by all authenticated users
function checkCalendarAccess(req, calName) {
  if (CALDAV_CATEGORY_CALS[calName]) return true;
  if (req.caldavUser.role === 'admin') return true;
  if (req.caldavUserSlug !== calName) return false;
  // S-18: createUser rejects a colliding name now, but a database created
  // before that guard can already hold one — and a personal calendar shared
  // between two people is worse than a personal calendar that stops answering.
  // Deny, and say who has to be renamed.
  const owners = db.listUsers(database).filter((u) => db.caldavSlug(u.username) === calName);
  if (owners.length > 1) {
    log('warn', 'CalDAV calendar name is ambiguous — access denied until one account is renamed', {
      calendar: calName,
      users: owners.map((u) => u.username)
    });
    return false;
  }
  return true;
}

// Get display name and color for a calendar
function getCalDisplayInfo(calName) {
  if (CALDAV_CATEGORY_CALS[calName]) {
    return { displayName: CALDAV_CATEGORY_CALS[calName].displayName, color: CALDAV_CATEGORY_CALS[calName].color };
  }
  const users = db.listUsers(database);
  const userIdx = users.findIndex((u) => db.caldavSlug(u.username) === calName);
  if (userIdx >= 0) {
    return { displayName: users[userIdx].username, color: USER_CALENDAR_COLORS[userIdx % USER_CALENDAR_COLORS.length] };
  }
  return { displayName: calName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), color: null };
}

// Ensure a calendar directory exists
function ensureCalDir(calName) {
  const dir = path.join(CAL_DIR, calName);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  return dir;
}

// List all calendar directories
function listCalendars() {
  if (!fs.existsSync(CAL_DIR)) return [];
  return fs.readdirSync(CAL_DIR).filter((f) => {
    return fs.statSync(path.join(CAL_DIR, f)).isDirectory();
  });
}

// List all .ics files in a calendar
function listIcsFiles(calName) {
  const dir = path.join(CAL_DIR, calName);
  if (!fs.existsSync(dir)) return [];
  return fs.readdirSync(dir).filter((f) => f.endsWith('.ics'));
}

// CTag cache — avoids filesystem scan on every PROPFIND poll
const ctagCache = new Map();
// ETag cache — avoids stat() on every PROPFIND/REPORT item
const etagCache = new Map();

function invalidateCalendarCache(calName) {
  ctagCache.delete(calName);
  // Clear all etags for this calendar
  const prefix = calName + '/';
  for (const key of etagCache.keys()) {
    if (key.startsWith(prefix)) etagCache.delete(key);
  }
}

function invalidateCtag(calName) {
  invalidateCalendarCache(calName);
}

function getEtag(calName, fileName) {
  const key = calName + '/' + fileName;
  if (etagCache.has(key)) return etagCache.get(key);
  try {
    const stat = fs.statSync(path.join(CAL_DIR, calName, fileName));
    const etag = '"' + stat.mtimeMs.toString(36) + '"';
    etagCache.set(key, etag);
    return etag;
  } catch {
    return null;
  }
}

// ── RFC 6578 sync-token tracking ──
const syncTokens = new Map(); // calName → current monotonic counter
const changeLog = new Map(); // calName → Map<fileName, {action:'changed'|'deleted', token}>
const CHANGE_LOG_MAX = 1000; // max entries per calendar before trimming

function bumpSyncToken(calName) {
  const next = (syncTokens.get(calName) || 0) + 1;
  syncTokens.set(calName, next);
  return next;
}

function recordChange(calName, fileName, action) {
  const token = bumpSyncToken(calName);
  if (!changeLog.has(calName)) changeLog.set(calName, new Map());
  const log = changeLog.get(calName);
  log.set(fileName, { action, token });
  // Trim oldest entries if over limit
  if (log.size > CHANGE_LOG_MAX) {
    const entries = [...log.entries()].sort((a, b) => a[1].token - b[1].token);
    while (log.size > CHANGE_LOG_MAX) log.delete(entries.shift()[0]);
  }
}

function getSyncToken(calName) {
  return syncTokens.get(calName) || 0;
}

// Write an .ics file and invalidate caches / record change
// S-23: the calendar file name, checked where it becomes a path.
//
// Every writeIcsFile caller builds the name as `uid + '.ics'`, and on the
// push-one / push-event routes that uid is `task.caldavUid` straight out of a
// request body — any logged-in user, no role required. path.join() will follow
// a "../" chain out of the calendar directory without complaint, so a worker
// could drop an .ics carrying their own SUMMARY and DESCRIPTION anywhere the
// server process can write, including the Windows profile it runs under.
//
// The charset is the one the *read* side has always demanded of a uid
// (/^[A-Za-z0-9\-_.@]+$/ at the sync-back paths); it simply never held on the
// way in. It is sufficient on its own: with the separators and NUL excluded, a
// name cannot address a second path component at all, on POSIX or on Windows —
// so there is no need to also hunt for "..", and no legitimate uid gets refused
// for containing one.
//
// The check lives here rather than at each caller because there are fourteen of
// them, and calName is already safe (db.caldavSlug reduces it to [a-z0-9-]).
function icsFileName(fileName) {
  const stem = typeof fileName === 'string' && fileName.endsWith('.ics') ? fileName.slice(0, -4) : null;
  if (!db.isValidCaldavUid(stem)) {
    // The 'caldav:' prefix is on db.isSafeError's list, so the caller answers
    // 400 with this text rather than a 500 that reads like our own fault.
    throw new Error('caldav: rejected calendar file name');
  }
  return fileName;
}

function writeIcsFile(calName, fileName, content) {
  icsFileName(fileName);
  const dir = ensureCalDir(calName);
  fs.writeFileSync(path.join(dir, fileName), content, 'utf8');
  invalidateCtag(calName);
  recordChange(calName, fileName, 'changed');
}

// Delete an .ics file and invalidate caches / record change
function deleteIcsFile(calName, fileName) {
  icsFileName(fileName);
  const filePath = path.join(CAL_DIR, calName, fileName);
  if (fs.existsSync(filePath)) {
    fs.unlinkSync(filePath);
    invalidateCtag(calName);
    recordChange(calName, fileName, 'deleted');
  }
}

// Compute a stable ctag for a calendar based on file contents (cached)
function computeCtag(calName) {
  if (ctagCache.has(calName)) return ctagCache.get(calName);
  const dir = path.join(CAL_DIR, calName);
  if (!fs.existsSync(dir)) return '0';
  const files = fs
    .readdirSync(dir)
    .filter((f) => f.endsWith('.ics'))
    .sort();
  const hash = crypto.createHash('md5');
  for (const f of files) {
    const stat = fs.statSync(path.join(dir, f));
    hash.update(f + ':' + stat.mtimeMs + ':' + stat.size + '\n');
  }
  const ctag = hash.digest('hex').slice(0, 16);
  ctagCache.set(calName, ctag);
  return ctag;
}

// CalDAV event color map — matches CATEGORY_COLORS in app.js
const CALDAV_CATEGORY_COLORS = {
  custom: '#16a34a',
  meeting: '#8b5cf6',
  delivery: '#14b8a6',
  maintenance: '#64748b',
  pickup: '#f59e0b'
};

// RFC 5545 PRIORITY → app priority mapping (1=highest, 9=lowest, 0=undefined)
const CALDAV_PRIO_MAP = {
  1: 'high',
  2: 'high',
  3: 'high',
  4: 'high',
  5: 'med',
  6: 'low',
  7: 'low',
  8: 'low',
  9: 'low',
  0: 'med'
};

// Known calendar event categories.
//
// `pickup` is the odd one out and deliberately so: the other four describe work
// this lab does to itself, while this one states when someone from outside may
// come and collect. That statement has to be positive. Deriving it negatively —
// block out meetings and maintenance, call the rest open — reads an empty
// calendar as "always, including Sunday at 03:00".
const KNOWN_CATEGORIES = { custom: 1, meeting: 1, delivery: 1, maintenance: 1, pickup: 1 };

// Supported CalDAV report set XML (used in PROPFIND responses)
const SUPPORTED_REPORT_SET =
  '<d:supported-report-set><d:supported-report><d:report><c:calendar-multiget/></d:report></d:supported-report><d:supported-report><d:report><c:calendar-query/></d:report></d:supported-report><d:supported-report><d:report><d:sync-collection/></d:report></d:supported-report></d:supported-report-set>';

// Extract the VEVENT block from ICS content (avoids parsing VTIMEZONE properties)
function extractVeventBlock(ics) {
  const m = ics.match(/BEGIN:VEVENT([\s\S]*?)END:VEVENT/);
  return m ? m[1] : '';
}

// Convert a task object to VTODO .ics content
function taskToVTODO(task) {
  const uid = task.caldavUid || generateUID();
  const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
  const created = task.created ? new Date(task.created).toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '') : now;
  const lines = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//Meisterpilze Lab Tracker//EN',
    'BEGIN:VTODO',
    'UID:' + uid,
    'DTSTAMP:' + now,
    'CREATED:' + created,
    'LAST-MODIFIED:' + now,
    // I-15: SEQUENCE counter (RFC 5545 §3.8.7.4) — CalDAV clients use this
    // to detect that an event/todo has been updated. Bumped on every
    // meaningful edit by db.updateTaskById.
    'SEQUENCE:' + (Number(task.sequence) || 0),
    'SUMMARY:' + escapeIcsText(task.text || '')
  ];
  if (task.dueDate) {
    const d = new Date(task.dueDate).toISOString().replace(/[-:]/g, '').split('T')[0];
    if (task.dueTime && /^([01]\d|2[0-3]):[0-5]\d$/.test(task.dueTime)) {
      // Floating local-time DUE so CalDAV clients render it at the wall-clock
      lines.push('DUE:' + d + 'T' + task.dueTime.replace(':', '') + '00');
    } else {
      lines.push('DUE;VALUE=DATE:' + d);
    }
  }
  // I-15: emit RRULE (RFC 5545 §3.8.5.3) when the task is recurring. UNTIL is
  // expressed in UTC RFC-5545 form (YYYYMMDDTHHMMSSZ) for time-bounded series.
  const rruleLine = buildRRuleLine(task.recurrence, task.recurrenceUntil);
  if (rruleLine) lines.push(rruleLine);
  const prioMap = { high: 1, med: 5, low: 9 };
  lines.push('PRIORITY:' + (prioMap[task.priority] || 0));
  lines.push('STATUS:' + (task.done ? 'COMPLETED' : 'NEEDS-ACTION'));
  if (task.done) lines.push('PERCENT-COMPLETE:100');
  lines.push('X-MEISTERPILZE-TYPE:task');
  if (task.assignee) lines.push('X-MEISTERPILZE-ASSIGNEE:' + task.assignee);
  if (task.description) lines.push('DESCRIPTION:' + escapeIcsText(task.description));
  lines.push('COLOR:#3b82f6');
  lines.push('END:VTODO', 'END:VCALENDAR');
  return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}

// Write a task as .ics file to the appropriate calendar
function writeTaskToCalendar(task, calName) {
  calName = calName || 'meisterpilze';
  if (!task.caldavUid) task.caldavUid = generateUID();
  const { uid, ics } = taskToVTODO(task);
  writeIcsFile(calName, uid + '.ics', ics);
  task.caldavSynced = new Date().toISOString();
  return uid;
}

// YYYYMMDD of `date` in Europe/Berlin (the .ics TZID), independent of the
// server's own timezone. Deriving an all-day date via toISOString() (UTC)
// shifts it a day earlier for a due date stored as Berlin local midnight.
function _berlinDateCompact(date) {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Europe/Berlin',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
  })
    .format(date)
    .replace(/-/g, '');
}

// The calendar day after a YYYYMMDD date, counted in UTC, where every day has
// 864e5 ms. Adding 864e5 to the due time and reading the Berlin date instead
// went wrong around a change: a due in the first hour of the autumn change day
// ended on that same day, and one in the last hour of the day before the spring
// change spanned two days. See test/sommerzeit.test.js.
function _nextDayCompact(ymd) {
  const d = new Date(Date.UTC(+ymd.slice(0, 4), +ymd.slice(4, 6) - 1, +ymd.slice(6, 8) + 1));
  return d.toISOString().slice(0, 10).replace(/-/g, '');
}

// The last day of an all-day event as YYYY-MM-DD, from the year, month and day
// of its DTEND;VALUE=DATE. RFC 5545 makes that DTEND exclusive, so this is the
// day before, counted in UTC like _nextDayCompact. The CalDAV PUT used to parse
// the DTEND with new Date('YYYY-MM-DD'), which is UTC midnight, step it back
// with the local setDate() and read it out with toISOString(). Around the
// autumn change that came out a day early: in Europe/Berlin an event ending on
// the change Sunday arrived ending on the Saturday. See test/sommerzeit.test.js.
function _allDayLastDate(y, m, d) {
  return new Date(Date.UTC(+y, +m - 1, +d - 1)).toISOString().slice(0, 10);
}

// Convert a batch to VEVENT .ics content (all-day event for due date)
function batchToVEVENT(batch, scanLog) {
  const uid = 'batch-' + batch.batchId + '@meisterpilze';
  const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
  const dueDate = _berlinDateCompact(new Date(batch.due));
  // DTEND is next day for all-day events per RFC 5545
  const endDate = _nextDayCompact(dueDate);
  const loc = scanLog ? getBatchLocServer(batch, scanLog) : '';
  const summary = escapeIcsText(batch.batchId + (loc ? ' — ' + loc : ''));
  const lines = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//Meisterpilze Lab Tracker//EN',
    'BEGIN:VEVENT',
    'UID:' + uid,
    'DTSTAMP:' + now,
    // I-15: SEQUENCE on batch-due VEVENTs. Batches don't have an in-DB
    // sequence column (the iCal artifact is one-shot per due-date), but
    // emitting 0 for completeness keeps client validators quiet.
    'SEQUENCE:0',
    'DTSTART;VALUE=DATE:' + dueDate,
    'DTEND;VALUE=DATE:' + endDate,
    'SUMMARY:' + summary,
    'CATEGORIES:Fälligkeiten'
  ];
  if (loc) lines.push('LOCATION:' + escapeIcsText(loc));
  // Prefer the joined mushroom_strains values; fall back to legacy free-text
  // fields for historical batches without a strain_id.
  const descName = batch.strainName || batch.species || '';
  const descKuerzel = batch.strainKuerzel || batch.strain || '';
  const descText = descName + (descKuerzel ? ' (' + descKuerzel + ')' : '');
  lines.push(
    'DESCRIPTION:' + escapeIcsText(descText),
    'TRANSP:TRANSPARENT',
    'X-MEISTERPILZE-TYPE:batch-due',
    'X-MEISTERPILZE-BATCH:' + batch.batchId,
    'COLOR:#ef4444',
    'END:VEVENT',
    'END:VCALENDAR'
  );
  return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}

// Convert a task with due date to VEVENT .ics content
function taskDueToVEVENT(task) {
  const uid = (task.caldavUid || generateUID()) + '-event';
  const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
  const dueDate = new Date(task.dueDate).toISOString().replace(/[-:]/g, '').split('T')[0];
  const timeRe = /^([01]\d|2[0-3]):[0-5]\d$/;
  const hasTime = task.dueTime && timeRe.test(task.dueTime);
  let dtStartLine, dtEndLine;
  if (hasTime) {
    const startRaw = dueDate + 'T' + task.dueTime.replace(':', '') + '00';
    let endRaw;
    if (task.dueEndTime && timeRe.test(task.dueEndTime) && task.dueEndTime > task.dueTime) {
      endRaw = dueDate + 'T' + task.dueEndTime.replace(':', '') + '00';
    } else {
      // Default to 1h slot if no end given
      const [sh, sm] = task.dueTime.split(':').map(Number);
      const eh = String(Math.min(23, sh + 1)).padStart(2, '0');
      endRaw = dueDate + 'T' + eh + String(sm).padStart(2, '0') + '00';
    }
    dtStartLine = 'DTSTART:' + startRaw;
    dtEndLine = 'DTEND:' + endRaw;
  } else {
    const endDate = new Date(new Date(task.dueDate).getTime() + 86400000)
      .toISOString()
      .replace(/[-:]/g, '')
      .split('T')[0];
    dtStartLine = 'DTSTART;VALUE=DATE:' + dueDate;
    dtEndLine = 'DTEND;VALUE=DATE:' + endDate;
  }
  const lines = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//Meisterpilze Lab Tracker//EN',
    'BEGIN:VEVENT',
    'UID:' + uid,
    'DTSTAMP:' + now,
    // I-15: SEQUENCE — bumped by db.updateTaskById on meaningful edits.
    'SEQUENCE:' + (Number(task.sequence) || 0),
    dtStartLine,
    dtEndLine,
    'SUMMARY:' + escapeIcsText(task.text || ''),
    'CATEGORIES:Aufgaben',
    'STATUS:' + (task.done ? 'CANCELLED' : 'CONFIRMED'),
    'TRANSP:TRANSPARENT',
    'X-MEISTERPILZE-TYPE:task-due',
    'COLOR:#3b82f6'
  ];
  // I-15: emit RRULE for recurring tasks rendered as VEVENT.
  const rruleLine = buildRRuleLine(task.recurrence, task.recurrenceUntil);
  if (rruleLine) lines.push(rruleLine);
  lines.push('END:VEVENT', 'END:VCALENDAR');
  return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}

// Convert a custom calendar event to VEVENT .ics content
/**
 * "Name, address" for a location id, or '' when there is none.
 *
 * Address included: this string goes into a VEVENT, which stays inside the lab's
 * own calendar clients. The harvest feed sends the name alone — see
 * buildPickupWindows() over in harvest-feed.js.
 */
function pickupLocationLabel(id) {
  if (!id) return '';
  try {
    const l = db.getPickupLocation(database, id);
    if (!l) return '';
    return l.address ? l.name + ', ' + l.address : l.name;
  } catch {
    // A missing table on a database mid-migration. An event without its place
    // is worth writing; a calendar sync that stops halfway is not.
    return '';
  }
}

function customEventToVEVENT(event) {
  const uid = event.caldavUid || 'cev-' + event.id + '@meisterpilze';
  const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
  let dtstart, dtend;
  if (event.allDay || !event.startTime) {
    const d = new Date(event.startDate).toISOString().replace(/[-:]/g, '').split('T')[0];
    dtstart = 'DTSTART;VALUE=DATE:' + d;
    const endD = event.endDate
      ? new Date(new Date(event.endDate).getTime() + 86400000).toISOString().replace(/[-:]/g, '').split('T')[0]
      : new Date(new Date(event.startDate).getTime() + 86400000).toISOString().replace(/[-:]/g, '').split('T')[0];
    dtend = 'DTEND;VALUE=DATE:' + endD;
  } else {
    const d = event.startDate.replace(/-/g, '');
    // Use endDate for the DTEND day so a timed event spanning multiple days
    // isn't collapsed to its start day (which also produced DTEND < DTSTART when
    // endTime < startTime). Falls back to startDate for same-day events.
    const dEnd = (event.endDate || event.startDate).replace(/-/g, '');
    const st = (event.startTime || '09:00').replace(':', '') + '00';
    const et = (event.endTime || '10:00').replace(':', '') + '00';
    dtstart = 'DTSTART;TZID=Europe/Berlin:' + d + 'T' + st;
    dtend = 'DTEND;TZID=Europe/Berlin:' + dEnd + 'T' + et;
  }
  const needsTZ = dtstart.includes('TZID=');
  const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Meisterpilze Lab Tracker//EN'];
  if (needsTZ) {
    lines.push(
      'BEGIN:VTIMEZONE',
      'TZID:Europe/Berlin',
      'BEGIN:STANDARD',
      'DTSTART:19701025T030000',
      'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10',
      'TZOFFSETFROM:+0200',
      'TZOFFSETTO:+0100',
      'TZNAME:CET',
      'END:STANDARD',
      'BEGIN:DAYLIGHT',
      'DTSTART:19700329T020000',
      'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3',
      'TZOFFSETFROM:+0100',
      'TZOFFSETTO:+0200',
      'TZNAME:CEST',
      'END:DAYLIGHT',
      'END:VTIMEZONE'
    );
  }
  lines.push(
    'BEGIN:VEVENT',
    'UID:' + uid,
    'DTSTAMP:' + now,
    // I-15: SEQUENCE counter (RFC 5545 §3.8.7.4) — bumped by db.updateCalendarEvent.
    'SEQUENCE:' + (Number(event.sequence) || 0),
    dtstart,
    dtend,
    'SUMMARY:' + escapeIcsText(event.title || ''),
    'CATEGORIES:' + (event.category || 'Benutzerdefiniert'),
    'TRANSP:TRANSPARENT',
    'X-MEISTERPILZE-TYPE:custom-event'
  );
  // I-15: emit RRULE when the event is recurring.
  const rruleLine = buildRRuleLine(event.recurrence, event.recurrenceUntil);
  if (rruleLine) lines.push(rruleLine);
  // I-15: emit EXDATE for per-occurrence cancellations. Format must match
  // DTSTART (date-only vs datetime). exceptionDates may arrive as a JSON
  // string (from older code paths) or as a parsed array.
  let exDates = event.exceptionDates;
  if (typeof exDates === 'string') {
    try {
      exDates = JSON.parse(exDates);
    } catch {
      exDates = exDates
        .split(',')
        .map((s) => s.trim())
        .filter(Boolean);
    }
  }
  const exdateLine = buildExdateLine(
    exDates,
    event.allDay || !event.startTime,
    event.startTime || '00:00',
    'Europe/Berlin'
  );
  if (exdateLine) lines.push(exdateLine);
  // #480: a standard iCalendar property, so every subscribed client shows the
  // place without anything further. Name and address on one line, which is what
  // clients expect and what a map link would be built from later.
  const place = pickupLocationLabel(event.locationId != null ? event.locationId : event.location_id);
  if (place) lines.push('LOCATION:' + escapeIcsText(place));
  if (event.description) lines.push('DESCRIPTION:' + escapeIcsText(event.description));
  if (event.assignees && event.assignees.length) {
    for (const a of event.assignees) lines.push('ATTENDEE;CN=' + (a.username || a) + ':mailto:noreply@localhost');
  }
  lines.push('COLOR:' + (event.color || CALDAV_CATEGORY_COLORS[event.category] || '#16a34a'));
  lines.push('END:VEVENT', 'END:VCALENDAR');
  return { uid, ics: foldIcsLines(lines.join('\r\n')) };
}

// Delete a task's .ics file
function deleteTaskFromCalendar(uid, calName) {
  calName = calName || 'meisterpilze';
  deleteIcsFile(calName, uid + '.ics');
}

// ── Auto CalDAV sync helpers ───────────────────────────────
// Push a batch due-date VEVENT to CalDAV (server-side, called after mutations)
function autoPushBatchCaldav(batch) {
  try {
    const cfg = db.readCaldavConfig(database);
    if (!cfg.enabled) return;
    if (!batch || !batch.due) return;
    const data = readData();
    const { uid, ics } = batchToVEVENT(batch, data.scanLog || []);
    writeIcsFile('faelligkeiten', uid + '.ics', ics);
    // Clean from old meisterpilze location
    deleteIcsFile('meisterpilze', uid + '.ics');
  } catch (e) {
    log('error', 'autoPushBatchCaldav failed', { error: e.message });
  }
}

// Remove a batch's CalDAV .ics file
function autoDeleteBatchCaldav(batchId) {
  try {
    const cfg = db.readCaldavConfig(database);
    if (!cfg.enabled) return;
    const uid = 'batch-' + batchId + '@meisterpilze';
    for (const cal of ['faelligkeiten', 'meisterpilze']) deleteIcsFile(cal, uid + '.ics');
  } catch (e) {
    log('error', 'autoDeleteBatchCaldav failed', { error: e.message });
  }
}

// Push a task's VTODO + due-date VEVENT to CalDAV
function autoPushTaskCaldav(task) {
  try {
    const cfg = db.readCaldavConfig(database);
    if (!cfg.enabled) return;
    const isPrivate = task.private === 1 || task.private === true;
    // Shared calendar: all non-private tasks
    if (!isPrivate) {
      writeTaskToCalendar(task, 'meisterpilze');
    }
    // Per-person calendar: if assigned
    if (task.assignee) {
      const slug = db.caldavSlug(task.assignee);
      writeTaskToCalendar(task, slug);
    }
    // Due-date VEVENT → aufgaben calendar: respect privacy
    if (task.dueDate && !isPrivate) {
      const { uid, ics } = taskDueToVEVENT(task);
      writeIcsFile('aufgaben', uid + '.ics', ics);
      deleteIcsFile('meisterpilze', uid + '.ics');
    }
  } catch (e) {
    log('error', 'autoPushTaskCaldav failed', { error: e.message });
  }
}

// Remove a task's CalDAV files (VTODO + VEVENT) from all calendars
// Pass task object (with caldavUid/assignee) BEFORE deleting from DB
function autoDeleteTaskCaldav(task) {
  try {
    const cfg = db.readCaldavConfig(database);
    if (!cfg.enabled) return;
    if (!task || !task.caldavUid) return;
    // Remove from shared calendar
    deleteTaskFromCalendar(task.caldavUid, 'meisterpilze');
    // Remove from personal calendar if assigned
    if (task.assignee) {
      const slug = db.caldavSlug(task.assignee);
      deleteTaskFromCalendar(task.caldavUid, slug);
    }
    // Also remove VEVENT for due date (check aufgaben + legacy meisterpilze)
    for (const cal of ['aufgaben', 'meisterpilze']) deleteIcsFile(cal, task.caldavUid + '-event.ics');
  } catch (e) {
    log('error', 'autoDeleteTaskCaldav failed', { error: e.message });
  }
}

// Push a custom calendar event to CalDAV
function autoSyncCalendarEvent(ev) {
  try {
    const cfg = db.readCaldavConfig(database);
    if (!cfg.enabled) return;
    if (!ev.startDate && !ev.start_date) return;
    const normalized = {
      id: ev.id,
      title: ev.title,
      startDate: ev.startDate || ev.start_date,
      endDate: ev.endDate || ev.end_date,
      allDay: ev.allDay != null ? ev.allDay : ev.all_day,
      startTime: ev.startTime || ev.start_time,
      endTime: ev.endTime || ev.end_time,
      category: ev.category,
      description: ev.description,
      caldavUid: ev.caldavUid || ev.caldav_uid,
      // Both spellings arrive here: readAll() hands out camelCase, while the
      // PATCH route re-reads the raw row and passes that.
      locationId: ev.locationId != null ? ev.locationId : ev.location_id
    };
    const aMap = db.getAllCalendarEventAssignees(database);
    normalized.assignees = aMap.get(ev.id) || [];
    const calSlug = CALDAV_EVENT_CATEGORY_MAP[normalized.category] || 'eigene-termine';
    const { uid, ics } = customEventToVEVENT(normalized);
    writeIcsFile(calSlug, uid + '.ics', ics);
    // Clean from other category calendars in case category changed
    for (const other of ['eigene-termine', 'meetings', 'lieferungen', 'wartung', 'abholung', 'meisterpilze']) {
      if (other !== calSlug) deleteIcsFile(other, uid + '.ics');
    }
  } catch (e) {
    log('error', 'autoSyncCalendarEvent failed', { error: e.message });
  }
}

// Resolve a list of usernames to user IDs. Unknown names are dropped silently.
// Note: the users table column is `id`, not `user_id` (sessions/authUser use
// `user_id` because they JOIN through sessions).
function resolveUsernamesToIds(names) {
  if (!Array.isArray(names) || !names.length) return [];
  const ids = [];
  for (const name of names) {
    if (!name || typeof name !== 'string') continue;
    const u = db.getUserByUsernameCaseInsensitive(database, name.trim());
    if (u && typeof u.id === 'number' && !ids.includes(u.id)) ids.push(u.id);
  }
  return ids;
}

// Single source of truth: given a calendar-event request body, return the
// user IDs that should be written to calendar_event_assignees. Prefer explicit
// `assignees` (already-resolved IDs); fall back to deriving them from
// `teamAssignees` names. Returns null if neither field is present so callers
// can distinguish "not provided" from "empty array".
function deriveEffectiveAssignees(data) {
  if (Array.isArray(data.assignees)) return data.assignees;
  if (Array.isArray(data.teamAssignees)) return resolveUsernamesToIds(data.teamAssignees);
  return null;
}

// Best-effort notifications when a task gains new assignees. Notifies the
// actor too — self-assignments still produce an inbox entry.
function notifyTaskAssignees(task, userIds, actor) {
  if (!Array.isArray(userIds) || !userIds.length) return;
  const actorName = (actor && actor.username) || 'Jemand';
  const title = task.text || 'Aufgabe';
  const dateStr = task.dueDate || task.due_date || '';
  const body = actorName + (dateStr ? ' · ' + dateStr : '');
  for (const uid of userIds) {
    if (typeof uid !== 'number') continue;
    try {
      db.createNotification(database, {
        userId: uid,
        type: 'task_assignment',
        title,
        body,
        linkType: 'task',
        linkId: String(task.id)
      });
    } catch (e) {
      log('warn', 'notifyTaskAssignees failed', { userId: uid, taskId: task.id, error: e.message });
    }
  }
}

// Parse a task.assignee CSV ("Alice,Bob") into an array of trimmed names.
function parseTaskAssigneeCsv(s) {
  if (!s || typeof s !== 'string') return [];
  return s
    .split(',')
    .map((x) => x.trim())
    .filter(Boolean);
}

// Create notification rows for each assignee who was added to a calendar event.
// Best-effort: failures are logged but do not block the primary request.
function notifyCalendarAssignees(ev, assigneeIds, actor) {
  if (!Array.isArray(assigneeIds) || !assigneeIds.length) return;
  const actorName = (actor && actor.username) || 'Jemand';
  const title = ev.title || 'Termin';
  // Body: creator + date range for quick context in the dropdown
  const dateStr = ev.startDate || ev.start_date || '';
  const body = actorName + (dateStr ? ' · ' + dateStr : '');
  for (const uid of assigneeIds) {
    if (typeof uid !== 'number') continue;
    try {
      db.createNotification(database, {
        userId: uid,
        type: 'calendar_assignment',
        title,
        body,
        linkType: 'calendar_event',
        linkId: ev.id
      });
    } catch (e) {
      log('warn', 'notifyCalendarAssignees failed', { userId: uid, eventId: ev.id, error: e.message });
    }
  }
}

// Remove a custom calendar event's CalDAV file (search all category calendars)
function autoDeleteCalendarEventCaldav(eventId, caldavUid) {
  try {
    const cfg = db.readCaldavConfig(database);
    if (!cfg.enabled) return;
    const uid = caldavUid || 'cev-' + eventId + '@meisterpilze';
    for (const cal of ['eigene-termine', 'meetings', 'lieferungen', 'wartung', 'meisterpilze']) {
      deleteIcsFile(cal, uid + '.ics');
    }
  } catch (e) {
    log('error', 'autoDeleteCalendarEventCaldav failed', { error: e.message });
  }
}

// CalDAV sync mutex — serializes sync operations to prevent concurrent file writes.
// Uses a queue with setImmediate to avoid re-entrant calls from the finally block.
let caldavSyncRunning = false;
let caldavSyncQueued = null;

function autoSyncAllCaldav(data) {
  if (caldavSyncRunning) {
    caldavSyncQueued = data; // queue latest, discard stale
    return;
  }
  caldavSyncRunning = true;
  try {
    _doAutoSyncAllCaldav(data);
  } catch (e) {
    log('error', 'CalDAV auto-sync failed', { error: e.message });
  } finally {
    const queued = caldavSyncQueued;
    caldavSyncQueued = null;
    caldavSyncRunning = false;
    // Defer queued sync to next tick to prevent re-entrant race
    if (queued) setImmediate(() => autoSyncAllCaldav(queued));
  }
}
function _doAutoSyncAllCaldav(data) {
  try {
    const cfg = db.readCaldavConfig(database);
    if (!cfg.enabled) return;
    const scanLog = data.scanLog || [];

    // Ensure all category calendars exist
    for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) ensureCalDir(calSlug);

    // Track written UIDs per calendar for orphan cleanup
    const writtenPerCal = {};
    for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) writtenPerCal[calSlug] = new Set();

    // Batch due dates → faelligkeiten calendar
    for (const b of data.batches || []) {
      if (!b.due) continue;
      try {
        const { uid, ics } = batchToVEVENT(b, scanLog);
        writeIcsFile('faelligkeiten', uid + '.ics', ics);
        writtenPerCal['faelligkeiten'].add(uid + '.ics');
      } catch (e) {
        log('warn', 'CalDAV: failed to write batch event', { batchId: b.batchId, error: e.message });
      }
    }
    // Track written UIDs per personal calendar for orphan cleanup
    const personalWrittenUids = new Map(); // calSlug → Set<filename>
    // Task VTODOs → meisterpilze + personal calendars
    for (const t of data.manualTasks || []) {
      try {
        const isPriv = t.private === 1 || t.private === true;
        if (!isPriv) {
          writeTaskToCalendar(t, 'meisterpilze');
        }
        if (t.assignee) {
          const slug = db.caldavSlug(t.assignee);
          writeTaskToCalendar(t, slug);
          if (t.caldavUid) {
            if (!personalWrittenUids.has(slug)) personalWrittenUids.set(slug, new Set());
            personalWrittenUids.get(slug).add(t.caldavUid + '.ics');
          }
        }
      } catch (e) {
        log('warn', 'CalDAV: failed to write task VTODO', { taskText: t.text?.slice(0, 50), error: e.message });
      }
    }
    // Task due dates → aufgaben calendar (respect privacy)
    for (const t of data.manualTasks || []) {
      if (!t.dueDate) continue;
      const isPrivate = t.private === 1 || t.private === true;
      if (isPrivate) continue;
      try {
        const { uid, ics } = taskDueToVEVENT(t);
        writeIcsFile('aufgaben', uid + '.ics', ics);
        writtenPerCal['aufgaben'].add(uid + '.ics');
      } catch (e) {
        log('warn', 'CalDAV: failed to write task due event', { taskText: t.text?.slice(0, 50), error: e.message });
      }
    }
    // Custom events → category-specific calendars
    for (const ev of data.calendarEvents || []) {
      const calSlug = CALDAV_EVENT_CATEGORY_MAP[ev.category] || 'eigene-termine';
      try {
        const { uid, ics } = customEventToVEVENT(ev);
        writeIcsFile(calSlug, uid + '.ics', ics);
        if (writtenPerCal[calSlug]) writtenPerCal[calSlug].add(uid + '.ics');
      } catch (e) {
        log('warn', 'CalDAV: failed to write calendar event', { eventId: ev.id, error: e.message });
      }
    }
    // Clean orphaned meisterpilze-generated files in all category calendars
    for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) {
      const dir = path.join(CAL_DIR, calSlug);
      if (!fs.existsSync(dir)) continue;
      const written = writtenPerCal[calSlug] || new Set();
      try {
        const existing = fs.readdirSync(dir).filter((f) => f.endsWith('.ics'));
        for (const f of existing) {
          const filePath = path.join(dir, f);
          const head = readFileHead(filePath, 500);
          if (head.includes('X-MEISTERPILZE-TYPE') && !written.has(f)) {
            fs.unlinkSync(filePath);
            invalidateCtag(calSlug);
            recordChange(calSlug, f, 'deleted');
          }
        }
      } catch (e) {
        log('warn', 'CalDAV: failed to clean orphaned files', { calendar: calSlug, error: e.message });
      }
    }
    // Clean old events from meisterpilze that were moved to category calendars
    try {
      const sharedDir = path.join(CAL_DIR, 'meisterpilze');
      if (fs.existsSync(sharedDir)) {
        for (const f of fs.readdirSync(sharedDir).filter((f) => f.endsWith('.ics'))) {
          const head = readFileHead(path.join(sharedDir, f), 500);
          if (
            head.includes('X-MEISTERPILZE-TYPE:batch-due') ||
            head.includes('X-MEISTERPILZE-TYPE:task-due') ||
            head.includes('X-MEISTERPILZE-TYPE:custom-event')
          ) {
            fs.unlinkSync(path.join(sharedDir, f));
            invalidateCtag('meisterpilze');
            recordChange('meisterpilze', f, 'deleted');
          }
        }
      }
    } catch (e) {
      log('warn', 'CalDAV: failed to clean migrated events from the shared calendar', { error: e.message });
    }
    // Clean orphaned VTODOs in personal calendars
    const categoryCals = new Set(Object.keys(CALDAV_CATEGORY_CALS));
    categoryCals.add('meisterpilze');
    try {
      const allCals = listCalendars();
      for (const cal of allCals) {
        if (categoryCals.has(cal)) continue; // already handled above
        const calDir = path.join(CAL_DIR, cal);
        const written = personalWrittenUids.get(cal) || new Set();
        const files = fs.readdirSync(calDir).filter((f) => f.endsWith('.ics'));
        for (const f of files) {
          if (written.has(f)) continue;
          const head = readFileHead(path.join(calDir, f), 500);
          if (head.includes('PRODID:-//Meisterpilze')) {
            fs.unlinkSync(path.join(calDir, f));
            invalidateCtag(cal);
            recordChange(cal, f, 'deleted');
          }
        }
      }
    } catch (e) {
      log('warn', 'CalDAV: failed to clean orphaned personal files', { error: e.message });
    }
  } catch (e) {
    log('error', 'autoSyncAllCaldav failed', { error: e.message });
  }
}

// Full sync: write all tasks to calendar directories
function syncAllTasksLocal(data) {
  const tasks = data.manualTasks || [];
  const results = { pushed: 0, errors: 0, calendarsCreated: 0 };
  const scanLog = data.scanLog || [];

  // Ensure all category calendars + user calendars exist
  for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) ensureCalDir(calSlug);
  const users = db.listUsers(database);
  for (const u of users) {
    const slug = db.caldavSlug(u.username);
    ensureCalDir(slug);
  }

  // Track written UIDs per calendar
  const writtenPerCal = {};
  for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) writtenPerCal[calSlug] = new Set();

  // Write each task as VTODO
  for (const task of tasks) {
    try {
      const isPrivate = task.private === 1 || task.private === true;
      if (!isPrivate) writeTaskToCalendar(task, 'meisterpilze');
      if (task.assignee) {
        const slug = db.caldavSlug(task.assignee);
        writeTaskToCalendar(task, slug);
      }
      results.pushed++;
    } catch (e) {
      results.errors++;
    }
  }

  // Batch due dates → faelligkeiten calendar
  const batches = data.batches || [];
  for (const b of batches) {
    if (!b.due) continue;
    try {
      const { uid, ics } = batchToVEVENT(b, scanLog);
      writeIcsFile('faelligkeiten', uid + '.ics', ics);
      writtenPerCal['faelligkeiten'].add(uid + '.ics');
      results.pushed++;
    } catch (e) {
      results.errors++;
    }
  }

  // Task due dates → aufgaben calendar (respect privacy)
  for (const task of tasks) {
    if (!task.dueDate) continue;
    const isPrivate = task.private === 1 || task.private === true;
    if (isPrivate) continue;
    try {
      const { uid, ics } = taskDueToVEVENT(task);
      writeIcsFile('aufgaben', uid + '.ics', ics);
      writtenPerCal['aufgaben'].add(uid + '.ics');
      results.pushed++;
    } catch (e) {
      results.errors++;
    }
  }

  // Custom calendar events → category-specific calendars
  const customEvents = data.calendarEvents || [];
  for (const ev of customEvents) {
    const calSlug = CALDAV_EVENT_CATEGORY_MAP[ev.category] || 'eigene-termine';
    try {
      const { uid, ics } = customEventToVEVENT(ev);
      writeIcsFile(calSlug, uid + '.ics', ics);
      if (writtenPerCal[calSlug]) writtenPerCal[calSlug].add(uid + '.ics');
      results.pushed++;
    } catch (e) {
      results.errors++;
    }
  }

  // Clean up orphaned .ics files in all category calendars
  for (const calSlug of Object.keys(CALDAV_CATEGORY_CALS)) {
    const dir = path.join(CAL_DIR, calSlug);
    if (!fs.existsSync(dir)) continue;
    const written = writtenPerCal[calSlug] || new Set();
    try {
      const existing = fs.readdirSync(dir).filter((f) => f.endsWith('.ics'));
      for (const f of existing) {
        const head = readFileHead(path.join(dir, f), 500);
        if (head.includes('X-MEISTERPILZE-TYPE') && !written.has(f)) {
          fs.unlinkSync(path.join(dir, f));
          invalidateCtag(calSlug);
          recordChange(calSlug, f, 'deleted');
        }
      }
    } catch (e) {
      /* ignore cleanup errors */
    }
  }
  // Clean old events from meisterpilze that were moved to category calendars
  try {
    const sharedDir = path.join(CAL_DIR, 'meisterpilze');
    if (fs.existsSync(sharedDir)) {
      for (const f of fs.readdirSync(sharedDir).filter((f) => f.endsWith('.ics'))) {
        const head = readFileHead(path.join(sharedDir, f), 500);
        if (
          head.includes('X-MEISTERPILZE-TYPE:batch-due') ||
          head.includes('X-MEISTERPILZE-TYPE:task-due') ||
          head.includes('X-MEISTERPILZE-TYPE:custom-event')
        ) {
          fs.unlinkSync(path.join(sharedDir, f));
          invalidateCtag('meisterpilze');
          recordChange('meisterpilze', f, 'deleted');
        }
      }
    }
  } catch (e) {
    /* ignore cleanup errors */
  }

  return results;
}

// ── CalDAV HTTP handler ─────────────────────────────────────
// Handles requests under /caldav/
function handleCaldav(req, res) {
  // Reject requests if CalDAV is not enabled in config
  const caldavCfg = db.readCaldavConfig(database);
  if (!caldavCfg.enabled) {
    res.writeHead(503, { 'Content-Type': 'text/plain' });
    res.end('CalDAV is not enabled');
    return;
  }

  // DAV headers for all CalDAV responses (no CORS — CalDAV clients don't need it)
  res.setHeader('DAV', '1, 2, 3, calendar-access, extended-mkcol');
  res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, PROPFIND, REPORT, MKCALENDAR, OPTIONS, PROPPATCH');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Depth, Authorization, If-Match, If-None-Match');

  // Reject Basic auth over plain HTTP (except localhost) to prevent credential sniffing
  if (!req.socket.encrypted) {
    const host = (req.headers.host || '').replace(/:.*$/, '');
    if (host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]') {
      res.writeHead(403);
      res.end('CalDAV requires HTTPS');
      return;
    }
  }

  // Brute-force protection for CalDAV basic auth
  const caldavIP = getClientIP(req);
  const caldavUsername = extractBasicAuthUsername(req);
  if (caldavUsername) {
    const caldavUserKey = caldavUsername.toLowerCase();
    const caldavThrottleKey = caldavUserKey + '@' + caldavIP;
    if (!checkLoginAllowed(caldavThrottleKey)) {
      res.writeHead(429, { 'Content-Type': 'text/plain' });
      res.end('Too many login attempts. Try again later.');
      return;
    }
  }

  // S-22: the same budget the login route spends from, because this reaches the
  // same KDF — checkCaldavAuth runs db.verifyPassword, and it runs the decode
  // loop twice, so a request here costs *two* hashes. Guarding only
  // /api/auth/login left the more expensive door open, and CalDAV is on by
  // default (migration v10). The username is spendable the same way there:
  // checkCaldavAuth skips hashing for an unknown account, so "admin" is as good
  // a guess as any and a fresh username buys nothing extra.
  if (!takeLoginKdfToken(caldavIP)) {
    res.writeHead(429, { 'Retry-After': '5' });
    res.end('Too many login attempts. Try again shortly.');
    return;
  }

  // Auth check — returns user account object or false
  const caldavUser = checkCaldavAuth(req);
  if (!caldavUser) {
    if (caldavUsername) {
      const caldavUserKey = caldavUsername.toLowerCase();
      recordLoginFailure(caldavUserKey + '@' + caldavIP);
      recordLoginFailurePerUser(caldavUserKey);
    }
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Meisterpilze CalDAV", charset="UTF-8"' });
    res.end('Unauthorized');
    return;
  }
  // Clear attempts on successful auth
  refundLoginKdfToken(caldavIP);
  const successKey = caldavUser.username.toLowerCase();
  clearLoginAttempts(successKey + '@' + caldavIP);
  clearLoginAttemptsPerUser(successKey);
  req.caldavUser = caldavUser;
  req.caldavUserSlug = db.caldavSlug(caldavUser.username);

  const method = req.method;
  // Normalize path: /caldav/calendars/calname/file.ics
  let rawPath;
  try {
    rawPath = decodeURIComponent(req.url.split('?')[0]).replace(/\/+/g, '/');
  } catch (e) {
    res.writeHead(400);
    res.end('Bad Request: malformed URL encoding');
    return;
  }
  const parts = rawPath
    .replace(/^\/caldav\/?/, '')
    .replace(/\/$/, '')
    .split('/')
    .filter(Boolean);
  // parts: [] = root, ['calendars'] = calendar-home, ['calendars','name'] = calendar, ['calendars','name','file.ics'] = item

  // Validate root path segment
  if (parts.length > 0 && parts[0] !== 'calendars' && parts[0] !== 'principal') {
    res.writeHead(400);
    res.end('Invalid path');
    return;
  }

  // Sanitize path parts to prevent directory traversal
  for (let i = 1; i < parts.length; i++) {
    const clean = sanitizePart(parts[i]);
    if (!clean) {
      res.writeHead(400);
      res.end('Invalid path');
      return;
    }
    parts[i] = clean;
  }

  if (method === 'OPTIONS') {
    res.writeHead(200, {
      Allow: 'OPTIONS, GET, PUT, DELETE, PROPFIND, REPORT, MKCALENDAR, PROPPATCH'
    });
    res.end();
    return;
  }

  // Collect request body
  let body = '';
  let bodySize = 0;
  req.on('data', (c) => {
    bodySize += c.length;
    if (bodySize > MAX_BODY_SIZE) {
      res.writeHead(413, { 'Content-Type': 'text/plain' });
      res.end('Request body too large');
      req.destroy();
      return;
    }
    body += c;
  });
  req.on('end', () => {
    try {
      if (method === 'PROPFIND') return handlePropfind(parts, body, req, res);
      if (method === 'REPORT') {
        // R-09: handleReport is now async — surface async rejections to the
        // same error path and avoid an unhandledRejection on disk failures.
        return Promise.resolve(handleReport(parts, body, req, res)).catch((e) => {
          log('error', 'CalDAV request error', { error: e.message });
          if (!res.writableEnded) {
            res.writeHead(500);
            res.end('Internal server error');
          }
        });
      }
      if (method === 'MKCALENDAR') return handleMkcalendar(parts, body, req, res);
      if (method === 'PUT') return handlePut(parts, body, req, res);
      if (method === 'GET') return handleGet(parts, req, res);
      if (method === 'DELETE') return handleDelete(parts, req, res);
      if (method === 'PROPPATCH') return handleProppatch(parts, body, req, res);
      res.writeHead(405);
      res.end('Method not allowed');
    } catch (e) {
      log('error', 'CalDAV request error', { error: e.message });
      res.writeHead(500);
      res.end('Internal server error');
    }
  });
}

function handlePropfind(parts, body, req, res) {
  const depth = req.headers['depth'] || '1';

  // Root /caldav/ — principal discovery
  if (parts.length === 0) {
    const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
  <d:response>
    <d:href>/caldav/</d:href>
    <d:propstat>
      <d:prop>
        <d:resourcetype><d:collection/></d:resourcetype>
        <d:current-user-principal><d:href>/caldav/principal/</d:href></d:current-user-principal>
        <d:displayname>Meistertracker</d:displayname>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>
</d:multistatus>`;
    res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
    res.end(xml);
    return;
  }

  // /caldav/principal/ — user principal resource (returns calendar-home-set)
  if (parts.length === 1 && parts[0] === 'principal') {
    const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
  <d:response>
    <d:href>/caldav/principal/</d:href>
    <d:propstat>
      <d:prop>
        <d:resourcetype><d:collection/><d:principal/></d:resourcetype>
        <d:current-user-principal><d:href>/caldav/principal/</d:href></d:current-user-principal>
        <d:principal-URL><d:href>/caldav/principal/</d:href></d:principal-URL>
        <c:calendar-home-set><d:href>/caldav/calendars/</d:href></c:calendar-home-set>
        <d:displayname>${escapeXml(req.caldavUser.username)}</d:displayname>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>
</d:multistatus>`;
    res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
    res.end(xml);
    return;
  }

  // /caldav/calendars/ — list all calendars
  if (parts.length === 1 && parts[0] === 'calendars') {
    const cals = listCalendars().filter((c) => checkCalendarAccess(req, c));
    let responses = `<d:response>
    <d:href>/caldav/calendars/</d:href>
    <d:propstat>
      <d:prop>
        <d:resourcetype><d:collection/></d:resourcetype>
        <d:displayname>Calendars</d:displayname>
        <d:current-user-principal><d:href>/caldav/principal/</d:href></d:current-user-principal>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;

    if (depth !== '0') {
      for (const cal of cals) {
        const { displayName, color } = getCalDisplayInfo(cal);
        const colorProp = color
          ? '\n        <x:calendar-color xmlns:x="http://apple.com/ns/ical/">' + color + '</x:calendar-color>'
          : '';
        responses += `\n  <d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(cal)}/</d:href>
    <d:propstat>
      <d:prop>
        <d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
        <d:displayname>${escapeXml(displayName)}</d:displayname>
        <c:supported-calendar-component-set><c:comp name="VTODO"/><c:comp name="VEVENT"/></c:supported-calendar-component-set>${colorProp}
        <cs:getctag>${computeCtag(cal)}</cs:getctag>
        <d:sync-token>http://meisterpilze/sync/${getSyncToken(cal)}</d:sync-token>
        ${SUPPORTED_REPORT_SET}
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;
      }
    }

    const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
  ${responses}
</d:multistatus>`;
    res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
    res.end(xml);
    return;
  }

  // /caldav/calendars/<cal>/ — list items in a calendar
  if (parts.length === 2 && parts[0] === 'calendars') {
    const calName = parts[1];
    if (!checkCalendarAccess(req, calName)) {
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }
    const calDir = path.join(CAL_DIR, calName);
    if (!fs.existsSync(calDir)) {
      res.writeHead(404);
      res.end('Calendar not found');
      return;
    }
    const { displayName, color } = getCalDisplayInfo(calName);
    const colorProp2 = color
      ? '\n        <x:calendar-color xmlns:x="http://apple.com/ns/ical/">' + color + '</x:calendar-color>'
      : '';
    let responses = `<d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(calName)}/</d:href>
    <d:propstat>
      <d:prop>
        <d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
        <d:displayname>${escapeXml(displayName)}</d:displayname>
        <c:supported-calendar-component-set><c:comp name="VTODO"/><c:comp name="VEVENT"/></c:supported-calendar-component-set>${colorProp2}
        <cs:getctag>${computeCtag(calName)}</cs:getctag>
        <d:sync-token>http://meisterpilze/sync/${getSyncToken(calName)}</d:sync-token>
        ${SUPPORTED_REPORT_SET}
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;

    if (depth !== '0') {
      const files = listIcsFiles(calName);
      for (const f of files) {
        const etag = getEtag(calName, f);
        if (!etag) continue;
        let fileSize = 0;
        try {
          fileSize = fs.statSync(path.join(CAL_DIR, calName, f)).size;
        } catch {}
        responses += `\n  <d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)}</d:href>
    <d:propstat>
      <d:prop>
        <d:getetag>${etag}</d:getetag>
        <d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>
        <d:getcontentlength>${fileSize}</d:getcontentlength>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;
      }
    }

    const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
  ${responses}
</d:multistatus>`;
    res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
    res.end(xml);
    return;
  }

  // /caldav/calendars/<cal>/<file>.ics — single item props
  if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
    if (!checkCalendarAccess(req, parts[1])) {
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }
    const filePath = path.join(CAL_DIR, parts[1], parts[2]);
    const etag = getEtag(parts[1], parts[2]);
    if (!etag) {
      res.writeHead(404);
      res.end('Not found');
      return;
    }
    let fileSize = 0;
    try {
      fileSize = fs.statSync(filePath).size;
    } catch {}
    const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
  <d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(parts[1])}/${encodeURIComponent(parts[2])}</d:href>
    <d:propstat>
      <d:prop>
        <d:getetag>${etag}</d:getetag>
        <d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>
        <d:getcontentlength>${fileSize}</d:getcontentlength>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>
</d:multistatus>`;
    res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
    res.end(xml);
    return;
  }

  res.writeHead(404);
  res.end('Not found');
}

async function handleReport(parts, body, req, res) {
  // calendar-multiget: client requests specific .ics files with their data
  if (parts.length === 2 && parts[0] === 'calendars') {
    const calName = parts[1];
    if (!checkCalendarAccess(req, calName)) {
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }
    const calDir = path.join(CAL_DIR, calName);
    if (!fs.existsSync(calDir)) {
      res.writeHead(404);
      res.end('Calendar not found');
      return;
    }

    // ── RFC 6578 sync-collection REPORT ──
    if (body.includes('sync-collection')) {
      const tokenMatch = body.match(/<d:sync-token>([^<]*)<\/d:sync-token>/i);
      const tokenStr = tokenMatch ? tokenMatch[1].trim() : '';
      const reqToken = parseInt((tokenStr.match(/\/sync\/(\d+)$/) || [])[1] || '0', 10);

      // Validate token — if stale or unknown, tell client to do full sync (RFC 6578 §3)
      const currentToken = getSyncToken(calName);
      const clog = changeLog.get(calName);
      if (reqToken > 0) {
        const invalid = !clog || (clog.size > 0 && reqToken < Math.min(...[...clog.values()].map((e) => e.token)));
        if (invalid) {
          const errXml = `<?xml version="1.0" encoding="utf-8"?>
<d:error xmlns:d="DAV:"><d:valid-sync-token/></d:error>`;
          res.writeHead(403, { 'Content-Type': 'application/xml; charset=utf-8' });
          res.end(errXml);
          return;
        }
      }

      let responses = '';
      if (reqToken === 0) {
        // Initial sync — return all items
        const files = listIcsFiles(calName);
        for (const f of files) {
          const etag = getEtag(calName, f);
          if (!etag) continue;
          responses += `\n  <d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)}</d:href>
    <d:propstat>
      <d:prop><d:getetag>${etag}</d:getetag></d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;
        }
      } else {
        // Incremental sync — only items changed since reqToken
        for (const [fileName, entry] of clog.entries()) {
          if (entry.token <= reqToken) continue;
          if (entry.action === 'deleted') {
            responses += `\n  <d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(fileName)}</d:href>
    <d:status>HTTP/1.1 404 Not Found</d:status>
  </d:response>`;
          } else {
            const etag = getEtag(calName, fileName);
            if (etag) {
              responses += `\n  <d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(fileName)}</d:href>
    <d:propstat>
      <d:prop><d:getetag>${etag}</d:getetag></d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;
            }
          }
        }
      }

      const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
  <d:sync-token>http://meisterpilze/sync/${currentToken}</d:sync-token>${responses}
</d:multistatus>`;
      res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
      res.end(xml);
      return;
    }

    // Parse requested hrefs from the XML body — handle any namespace prefix (d:href, D:href, href, etc.)
    // `(?:\s[^>]*)?` before the `>` made this catastrophically backtracking: on a
    // body of repeated `<href ` the `[^>]*` scans to end-of-string and backtracks
    // once per start position, so the match is O(n^2) in the body length. Measured
    // 26.8 s at 400 KB and 112 s at 800 KB, i.e. over an hour at the 5 MB body cap
    // — and Node is single-threaded, so that freezes the entire server (no HTTP,
    // no SSE, no health check) for any account that can reach CalDAV.
    // `[^<>]*` cannot cross the `>` that ends the tag, so each position fails in
    // constant time and the scan is linear.
    const hrefMatches = body.match(/<(?:[a-zA-Z0-9]+:)?href(?:\s[^<>]*)?>([^<]+)<\/(?:[a-zA-Z0-9]+:)?href>/gi) || [];
    let responses = '';

    // If calendar-multiget with specific hrefs
    if (hrefMatches.length > 0) {
      // R-09: parallelize disk reads. Build the work list first, then read
      // all files concurrently with Promise.all so a 100-item REPORT runs at
      // the speed of one read instead of N.
      const targets = [];
      for (const hrefTag of hrefMatches) {
        const href = hrefTag.replace(/<\/?(?:[a-zA-Z0-9]+:)?href(?:\s[^>]*)?>/gi, '');
        const filename = sanitizePart(decodeURIComponent(href.split('/').pop()));
        if (!filename) continue;
        const filePath = path.join(calDir, filename);
        if (fs.existsSync(filePath) && filename.endsWith('.ics')) {
          targets.push({ href, filename, filePath });
        }
      }
      const reads = await Promise.all(targets.map((t) => fs.promises.readFile(t.filePath, 'utf8').catch(() => null)));
      for (let i = 0; i < targets.length; i++) {
        const content = reads[i];
        if (content == null) continue; // file vanished between exists() and read()
        const t = targets[i];
        const etag = getEtag(calName, t.filename);
        responses += `\n  <d:response>
    <d:href>${escapeXml(t.href)}</d:href>
    <d:propstat>
      <d:prop>
        <d:getetag>${etag}</d:getetag>
        <c:calendar-data>${escapeXml(content)}</c:calendar-data>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;
      }
    } else {
      // calendar-query: return all items
      const files = listIcsFiles(calName);
      const reads = await Promise.all(
        files.map((f) => fs.promises.readFile(path.join(calDir, f), 'utf8').catch(() => null))
      );
      for (let i = 0; i < files.length; i++) {
        const content = reads[i];
        if (content == null) continue;
        const f = files[i];
        const etag = getEtag(calName, f);
        responses += `\n  <d:response>
    <d:href>/caldav/calendars/${encodeURIComponent(calName)}/${encodeURIComponent(f)}</d:href>
    <d:propstat>
      <d:prop>
        <d:getetag>${etag}</d:getetag>
        <c:calendar-data>${escapeXml(content)}</c:calendar-data>
      </d:prop>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>`;
      }
    }

    const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  ${responses}
</d:multistatus>`;
    res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
    res.end(xml);
    return;
  }

  res.writeHead(404);
  res.end('Not found');
}

function handleMkcalendar(parts, body, req, res) {
  if (req.caldavUser.role !== 'admin') {
    res.writeHead(403);
    res.end('Forbidden: only admins can create calendars');
    return;
  }
  if (parts.length === 2 && parts[0] === 'calendars') {
    const calName = parts[1];
    // Block UUID-like calendar names (auto-created by CalDAV clients like iOS)
    const stripped = calName.replace(/-/g, '');
    if (/^[0-9a-f]{32}$/i.test(stripped)) {
      log('info', 'CalDAV: blocked MKCALENDAR for UUID-like name', { name: calName, actor: req.caldavUser.username });
      res.writeHead(201); // Return 201 to avoid client retries
      res.end();
      return;
    }
    if (!checkCalendarAccess(req, calName)) {
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }
    ensureCalDir(calName);
    res.writeHead(201);
    res.end();
    log('info', 'CalDAV calendar created', { name: calName, actor: req.caldavUser.username });
    return;
  }
  res.writeHead(403);
  res.end('Forbidden');
}

// S-24: whose record it is — asked of the record, not of the URL.
//
// The CalDAV sync-back paths look a row up by the UID carried in the request
// body (PUT) or in the file being removed (DELETE) and then write or delete
// that row. Every guard above them is about the *calendar*: checkCalendarAccess,
// and "only from your own calendar, or from the shared one if you are admin".
// None of them says anything about the row the UID names, and a worker may
// write into their own personal calendar — so PUT an .ics carrying a
// colleague's task UID there, DELETE it again, and the colleague's task goes
// with it. The UIDs are not a secret: GET /api/data hands out caldavUid for
// every task.
//
// The HTTP twins of these operations already answer this question —
// PATCH /api/tasks/:id calls db.canUserModifyTask and returns "You are not
// allowed to modify this task", and DELETE /api/calendar-events/:id is
// requireAdmin. This asks those same two, so the two doors into the same room
// stop disagreeing.
//
// Anything the UID does not resolve to a row for is allowed through: creating a
// new task from a calendar client is a normal thing to do, and it takes nothing
// from anyone.
function caldavRecordAllowed(req, ics) {
  // ⚠️ **Anchored, and that is not tidiness.** `/UID:(.*)/` matches inside any
  // property whose name ends in UID — `X-DECOY-UID:harmless` in the first line
  // fed this guard a name that resolves to nothing, so it waved the request
  // through while the sync-back below read the real UID out of the VEVENT and
  // wrote it. One header line walked around the whole check. The text arrives
  // unfolded, so a line anchor is the same thing the writers see.
  const uidMatch = String(ics || '').match(/^UID:(.*)$/m);
  if (!uidMatch) return true;
  const uid = uidMatch[1].trim();
  const isAdmin = !!(req.caldavUser && req.caldavUser.role === 'admin');
  // A custom calendar event. Matched before the -event suffix is stripped, or
  // an event id that ends in "-event" would be mangled into a different one.
  if (/^cev-(.+)@meisterpilze$/.test(uid)) return isAdmin;
  // ⚠️ And the ones that do not wear that shape. The UID is only built as
  // `cev-<id>@meisterpilze` when the row has no caldav_uid of its own; an event
  // that arrived from a calendar client keeps the client's UID, and the pattern
  // above walks straight past it. That left the withdraw direction open for
  // exactly the windows an admin had created from their own calendar app.
  // Asking the database costs one indexed lookup and cannot be fooled by a
  // naming convention.
  //
  // ⚠️ **Only the pickup category.** The first version of this line returned
  // isAdmin for every calendar row that carried a caldav_uid, which took
  // meetings, deliveries and maintenance away from everyone over CalDAV — the
  // exact opposite of what its own commit said it did. What is being protected
  // is the shop's collection times, not the calendar.
  const ereignis = db.readCalendarEventByCaldavUid(database, uid);
  if (ereignis) return ereignis.category === 'pickup' ? isAdmin : true;
  // A task, either by its own uid or by its companion due-date event's.
  const taskUid = uid.replace(/-event$/, '');
  // ⚠️ **No charset pre-check here.** There was one, and it failed open on
  // exactly the values it rejected: isValidCaldavUid caps a uid at 120
  // characters, the sync-back's own regex does not, so a 150-character uid was
  // waved through by this guard and then happily resolved to a row and written.
  // The lookup answers the only question that matters — does this name a row? —
  // and cannot disagree with anything.
  const task = db.readTaskByCaldavUid(database, taskUid);
  if (!task) return true;
  return db.canUserModifyTask(database, req.caldavUser && req.caldavUser.username, task.id, isAdmin);
}

function handlePut(parts, body, req, res) {
  // PUT /caldav/calendars/<cal>/<uid>.ics
  if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
    const calName = parts[1];
    if (!checkCalendarAccess(req, calName)) {
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }
    const fileName = parts[2];
    const dir = ensureCalDir(calName);
    const filePath = path.join(dir, fileName);
    const existed = fs.existsSync(filePath);

    // If-None-Match: * means "create only, fail if exists"
    const ifNoneMatch = req.headers['if-none-match'];
    if (ifNoneMatch === '*' && existed) {
      res.writeHead(412);
      res.end('Precondition Failed');
      return;
    }

    // If-Match: check etag matches current version (conflict detection)
    const ifMatch = req.headers['if-match'];
    if (ifMatch && existed) {
      const stat = fs.statSync(filePath);
      const currentEtag = '"' + stat.mtimeMs.toString(36) + '"';
      if (ifMatch !== currentEtag) {
        res.writeHead(412);
        res.end('Precondition Failed');
        return;
      }
    }

    // Bidirectional sync: parse incoming content and update DB
    const unfolded = unfoldIcs(body);

    // S-24: asked before the write, not after it. The sync-back below is what
    // touches the row, but a refusal that still left the caller's .ics sitting
    // in a shared calendar would be a refusal in name only.
    if (!caldavRecordAllowed(req, unfolded)) {
      log('warn', 'CalDAV PUT: refused a record the caller may not modify', {
        calendar: calName,
        file: fileName,
        actor: req.caldavUser && req.caldavUser.username
      });
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }

    fs.writeFileSync(filePath, body, 'utf8');
    invalidateCtag(calName);
    recordChange(calName, fileName, 'changed');

    // ── VTODO sync-back: task text, priority, completion, due date ──
    if (unfolded.includes('VTODO')) {
      try {
        const uidMatch = unfolded.match(/UID:(.*)/);
        if (uidMatch) {
          const uid = uidMatch[1].trim();
          if (/^[A-Za-z0-9\-_.@]+$/.test(uid)) {
            const task = db.readTaskByCaldavUid(database, uid);
            if (task) {
              const fields = {};
              const sumMatch = unfolded.match(/SUMMARY:(.*)/);
              if (sumMatch) {
                const t = sumMatch[1].trim().replace(/\\n/g, '\n');
                if (t !== task.text) fields.text = t;
              }
              const prioMatch = unfolded.match(/PRIORITY:(\d+)/);
              if (prioMatch) {
                const p = CALDAV_PRIO_MAP[prioMatch[1]] || 'med';
                if (p !== task.priority) fields.priority = p;
              }
              const statusMatch = unfolded.match(/STATUS:(.*)/);
              if (statusMatch) {
                const done = statusMatch[1].trim() === 'COMPLETED';
                if (done !== task.done) fields.done = done;
              }
              const dueDateTimeMatch = unfolded.match(/DUE(?:;[^:]*)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/);
              const dueMatch = unfolded.match(/DUE;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
              if (dueDateTimeMatch) {
                const d = dueDateTimeMatch[1] + '-' + dueDateTimeMatch[2] + '-' + dueDateTimeMatch[3];
                const t = dueDateTimeMatch[4] + ':' + dueDateTimeMatch[5];
                if (d !== (task.dueDate || '').slice(0, 10)) fields.dueDate = d;
                if (t !== (task.dueTime || '')) fields.dueTime = t;
              } else if (dueMatch) {
                const d = dueMatch[1] + '-' + dueMatch[2] + '-' + dueMatch[3];
                if (d !== (task.dueDate || '').slice(0, 10)) fields.dueDate = d;
                if (task.dueTime) {
                  fields.dueTime = null;
                  fields.dueEndTime = null;
                }
              } else if (!unfolded.includes('DUE') && task.dueDate) {
                fields.dueDate = null;
                if (task.dueTime) {
                  fields.dueTime = null;
                  fields.dueEndTime = null;
                }
              }
              const descMatch = unfolded.match(/DESCRIPTION:(.*)/);
              if (descMatch) {
                const d = descMatch[1].trim().replace(/\\n/g, '\n');
                if (d !== (task.description || '')) fields.description = d;
              }
              const assigneeMatch = unfolded.match(/X-MEISTERPILZE-ASSIGNEE:(.*)/);
              if (assigneeMatch) {
                const a = assigneeMatch[1].trim();
                if (a !== (task.assignee || '')) fields.assignee = a;
              }

              if (Object.keys(fields).length > 0) {
                db.updateTaskById(database, task.id, fields);
                // Re-read and push to all calendars for consistency
                const updated = db.readTaskById(database, task.id);
                if (updated) autoPushTaskCaldav(updated);
                broadcastSSE();
              }
            } else {
              // New task created from external CalDAV client
              const sumMatch = unfolded.match(/SUMMARY:(.*)/);
              const text = sumMatch ? sumMatch[1].trim().replace(/\\n/g, '\n') : '(kein Titel)';
              const prioMatch = unfolded.match(/PRIORITY:(\d+)/);
              const priority = prioMatch ? CALDAV_PRIO_MAP[prioMatch[1]] || 'med' : 'med';
              const statusMatch = unfolded.match(/STATUS:(.*)/);
              const done = statusMatch ? statusMatch[1].trim() === 'COMPLETED' : false;
              const dueDateTimeMatch2 = unfolded.match(/DUE(?:;[^:]*)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/);
              const dueMatch = unfolded.match(/DUE;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
              let dueDate = null;
              let dueTime = null;
              if (dueDateTimeMatch2) {
                dueDate = dueDateTimeMatch2[1] + '-' + dueDateTimeMatch2[2] + '-' + dueDateTimeMatch2[3];
                dueTime = dueDateTimeMatch2[4] + ':' + dueDateTimeMatch2[5];
              } else if (dueMatch) {
                dueDate = dueMatch[1] + '-' + dueMatch[2] + '-' + dueMatch[3];
              }
              const descMatch = unfolded.match(/DESCRIPTION:(.*)/);
              const description = descMatch ? descMatch[1].trim().replace(/\\n/g, '\n') : null;
              db.insertTask(database, {
                text,
                priority,
                done,
                created: new Date().toISOString(),
                dueDate,
                dueTime,
                description,
                caldavUid: uid,
                caldavSynced: new Date().toISOString()
              });
              broadcastSSE();
            }
          }
        }
      } catch (e) {
        log('error', 'CalDAV VTODO bidirectional sync error', { error: e.message });
      }
    }

    // ── VEVENT sync-back: batch due dates, task due dates, custom events ──
    if (unfolded.includes('VEVENT')) {
      try {
        const veventBlock = extractVeventBlock(unfolded);
        const typeMatch = veventBlock.match(/X-MEISTERPILZE-TYPE:(.*)/);
        const evType = typeMatch ? typeMatch[1].trim() : null;

        if (evType === 'batch-due' || evType === 'task-due') {
          const dtMatch = veventBlock.match(/DTSTART;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
          if (dtMatch) {
            const newDate = dtMatch[1] + '-' + dtMatch[2] + '-' + dtMatch[3];
            if (evType === 'batch-due') {
              const batchMatch = veventBlock.match(/X-MEISTERPILZE-BATCH:(.*)/);
              if (batchMatch) {
                const batchId = batchMatch[1].trim();
                if (/^[A-Za-z0-9\-_.]+$/.test(batchId)) {
                  db.updateBatchDue(database, batchId, newDate + 'T12:00:00.000Z');
                  broadcastSSE();
                } else {
                  log('warn', 'CalDAV PUT rejected invalid batchId', { batchId });
                }
              }
            } else {
              const uidMatch = veventBlock.match(/UID:(.*)/);
              if (uidMatch) {
                const taskUid = uidMatch[1].trim().replace(/-event$/, '');
                if (/^[A-Za-z0-9\-_.@]+$/.test(taskUid)) {
                  db.updateTaskDueDate(database, taskUid, newDate);
                  broadcastSSE();
                } else {
                  log('warn', 'CalDAV PUT rejected invalid taskUid', { taskUid });
                }
              }
            }
          }
        } else if (evType === 'custom-event') {
          // Custom event edited from external CalDAV client
          const uidMatch = veventBlock.match(/UID:(.*)/);
          if (uidMatch) {
            const uid = uidMatch[1].trim();
            const idMatch = uid.match(/^cev-(.+)@meisterpilze$/);
            if (idMatch) {
              const fields = {};
              const sumMatch = veventBlock.match(/SUMMARY:(.*)/);
              if (sumMatch) fields.title = sumMatch[1].trim().replace(/\\n/g, '\n');
              const descMatch = veventBlock.match(/DESCRIPTION:(.*)/);
              if (descMatch) fields.description = descMatch[1].trim().replace(/\\n/g, '\n');
              const catMatch = veventBlock.match(/CATEGORIES:(.*)/);
              if (catMatch) {
                const cat = catMatch[1].trim().toLowerCase();
                if (KNOWN_CATEGORIES[cat]) fields.category = cat;
              }
              // Parse DTSTART from VEVENT block only (avoids VTIMEZONE false matches)
              const dtAllDay = veventBlock.match(/DTSTART;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
              const dtTimed = veventBlock.match(/DTSTART(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
              if (dtAllDay) {
                fields.startDate = dtAllDay[1] + '-' + dtAllDay[2] + '-' + dtAllDay[3];
                fields.allDay = true;
                fields.startTime = null;
              } else if (dtTimed) {
                fields.startDate = dtTimed[1] + '-' + dtTimed[2] + '-' + dtTimed[3];
                fields.startTime = dtTimed[4] + ':' + dtTimed[5];
                fields.allDay = false;
              }
              // Parse DTEND from VEVENT block only
              const deAllDay = veventBlock.match(/DTEND;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
              const deTimed = veventBlock.match(/DTEND(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
              if (deAllDay) {
                // RFC 5545: DTEND for all-day is exclusive, so the last day is the one before
                fields.endDate = _allDayLastDate(deAllDay[1], deAllDay[2], deAllDay[3]);
                fields.endTime = null;
              } else if (deTimed) {
                fields.endDate = deTimed[1] + '-' + deTimed[2] + '-' + deTimed[3];
                fields.endTime = deTimed[4] + ':' + deTimed[5];
              }
              if (Object.keys(fields).length > 0) {
                db.updateCalendarEvent(database, idMatch[1], fields);
                broadcastSSE();
              }
            }
          }
        } else if (!evType && calName === 'meisterpilze') {
          // New VEVENT from external CalDAV client — create as calendar event
          const uidMatch = veventBlock.match(/UID:(.*)/);
          if (uidMatch) {
            const uid = uidMatch[1].trim();
            // Check it doesn't already exist in DB
            const existing = db.readCalendarEventByCaldavUid(database, uid);
            if (!existing) {
              const sumMatch = veventBlock.match(/SUMMARY:(.*)/);
              const title = sumMatch ? sumMatch[1].trim().replace(/\\n/g, '\n') : '(kein Titel)';
              const descMatch = veventBlock.match(/DESCRIPTION:(.*)/);
              const description = descMatch ? descMatch[1].trim().replace(/\\n/g, '\n') : null;
              const catMatch = veventBlock.match(/CATEGORIES:(.*)/);
              let category = 'custom';
              if (catMatch) {
                const c = catMatch[1].trim().toLowerCase();
                if (KNOWN_CATEGORIES[c]) category = c;
              }
              // The second door to the same thing. Any user may mint a CalDAV
              // app password, and checkCalendarAccess lets everyone write into
              // the shared calendar — so without this, the permission asked for
              // on POST /api/calendar-events is answered by putting the same
              // event in an .ics instead. Editing an existing one this way was
              // already admin-only: caldavRecordAllowed returns isAdmin for any
              // cev-…@meisterpilze UID. Only creating a fresh one was open.
              //
              // The already-parsed `category` is used rather than reading
              // CATEGORIES a third time: two spellings of the same rule is how
              // one of them stops matching, and the loose one would read the
              // whole file instead of this VEVENT block.
              if (category === 'pickup' && req.caldavUser.role !== 'admin') {
                log('warn', 'CalDAV PUT: refused a pickup window from a non-admin', {
                  calendar: calName,
                  actor: req.caldavUser.username
                });
                res.writeHead(403);
                res.end('Forbidden');
                return;
              }

              let startDate = null,
                endDate = null,
                startTime = null,
                endTime = null,
                allDay = true;
              const dtAllDay = veventBlock.match(/DTSTART;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
              const dtTimed = veventBlock.match(/DTSTART(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
              if (dtAllDay) {
                startDate = dtAllDay[1] + '-' + dtAllDay[2] + '-' + dtAllDay[3];
              } else if (dtTimed) {
                startDate = dtTimed[1] + '-' + dtTimed[2] + '-' + dtTimed[3];
                startTime = dtTimed[4] + ':' + dtTimed[5];
                allDay = false;
              }
              if (!startDate) startDate = new Date().toISOString().split('T')[0];

              const deAllDay = veventBlock.match(/DTEND;VALUE=DATE:(\d{4})(\d{2})(\d{2})/);
              const deTimed = veventBlock.match(/DTEND(?:;TZID=[^:]+)?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
              if (deAllDay) {
                endDate = _allDayLastDate(deAllDay[1], deAllDay[2], deAllDay[3]);
              } else if (deTimed) {
                endDate = deTimed[1] + '-' + deTimed[2] + '-' + deTimed[3];
                endTime = deTimed[4] + ':' + deTimed[5];
              }

              const eventId = 'cev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
              const caldavUid = uid;
              db.insertCalendarEvent(
                database,
                {
                  id: eventId,
                  title,
                  description,
                  startDate,
                  endDate,
                  allDay,
                  startTime,
                  endTime,
                  category,
                  caldavUid,
                  caldavSynced: new Date().toISOString()
                },
                null
              );
              // Re-write .ics with X-MEISTERPILZE-TYPE marker so future syncs recognize it
              const ev = db.readCalendarEventByCaldavUid(database, caldavUid);
              if (ev) {
                ev.assignees = [];
                const { ics } = customEventToVEVENT(ev);
                fs.writeFileSync(filePath, ics, 'utf8');
                invalidateCtag(calName);
                recordChange(calName, fileName, 'changed');
              }
              broadcastSSE();
            }
          }
        }
      } catch (e) {
        log('error', 'CalDAV VEVENT bidirectional sync error', { error: e.message });
      }
    }

    const stat = fs.statSync(filePath);
    const etag = '"' + stat.mtimeMs.toString(36) + '"';
    res.writeHead(existed ? 204 : 201, { ETag: etag });
    res.end();
    return;
  }
  res.writeHead(403);
  res.end('Forbidden');
}

function handleGet(parts, req, res) {
  // GET /caldav/calendars/<cal>/<uid>.ics
  if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
    if (!checkCalendarAccess(req, parts[1])) {
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }
    const filePath = path.join(CAL_DIR, parts[1], parts[2]);
    if (!fs.existsSync(filePath)) {
      res.writeHead(404);
      res.end('Not found');
      return;
    }
    const content = fs.readFileSync(filePath, 'utf8');
    const etag = getEtag(parts[1], parts[2]);
    res.writeHead(200, {
      'Content-Type': 'text/calendar; charset=utf-8',
      ETag: etag
    });
    res.end(content);
    return;
  }

  // GET on a calendar collection — return empty (clients use PROPFIND/REPORT)
  if (parts.length <= 2) {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Meistertracker CalDAV Server');
    return;
  }

  res.writeHead(404);
  res.end('Not found');
}

function handleDelete(parts, req, res) {
  // DELETE /caldav/calendars/<cal>/<uid>.ics
  if (parts.length === 3 && parts[0] === 'calendars' && parts[2].endsWith('.ics')) {
    const calName = parts[1];
    const fileName = parts[2];
    if (!checkCalendarAccess(req, calName)) {
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }
    // Only allow deleting from own calendar or shared calendar if admin
    const userSlug = req.caldavUserSlug;
    if (calName !== userSlug && calName !== 'meisterpilze' && req.caldavUser.role !== 'admin') {
      res.writeHead(403);
      res.end("Forbidden: cannot delete from other users' calendars");
      return;
    }
    if (calName === 'meisterpilze' && req.caldavUser.role !== 'admin') {
      res.writeHead(403);
      res.end('Forbidden: only admins can delete from shared calendar');
      return;
    }
    const filePath = path.join(CAL_DIR, calName, fileName);
    if (!fs.existsSync(filePath)) {
      res.writeHead(404);
      res.end('Not found');
      return;
    }

    // Read content before deleting to sync back to DB
    let content = '';
    try {
      content = unfoldIcs(fs.readFileSync(filePath, 'utf8'));
    } catch {}

    // S-24: the calendar guards above allow deleting from your own calendar.
    // What is deleted from the *database* is decided by the UID inside the
    // file, which the caller put there — so the row gets its own question.
    if (!caldavRecordAllowed(req, content)) {
      log('warn', 'CalDAV DELETE: refused a record the caller may not modify', {
        calendar: calName,
        file: fileName,
        actor: req.caldavUser && req.caldavUser.username
      });
      res.writeHead(403);
      res.end('Forbidden');
      return;
    }

    const typeMatch = content.match(/X-MEISTERPILZE-TYPE:(.*)/);
    const evType = typeMatch ? typeMatch[1].trim() : null;

    if (evType === 'batch-due') {
      // Batch due dates are mandatory — ignore delete, autoSync will recreate the file
    } else if (evType === 'task-due') {
      // Clear task due date (don't delete the task)
      const uidMatch = content.match(/UID:(.*)/);
      if (uidMatch) {
        const taskUid = uidMatch[1].trim().replace(/-event$/, '');
        if (/^[A-Za-z0-9\-_.@]+$/.test(taskUid)) {
          try {
            db.updateTaskDueDate(database, taskUid, null);
            broadcastSSE();
          } catch (e) {
            log('warn', 'CalDAV DELETE task-due sync failed', { taskUid, error: e.message });
          }
        }
      }
    } else if (evType === 'custom-event') {
      // Delete custom calendar event from DB
      const uidMatch = content.match(/UID:(.*)/);
      if (uidMatch) {
        const uid = uidMatch[1].trim();
        const idMatch = uid.match(/^cev-(.+)@meisterpilze$/);
        if (idMatch) {
          try {
            db.deleteCalendarEvent(database, idMatch[1]);
            broadcastSSE();
          } catch (e) {
            log('warn', 'CalDAV DELETE custom-event sync failed', { uid, error: e.message });
          }
        }
      }
    } else if (!evType && content.includes('VTODO')) {
      // Task VTODO — delete the task from DB
      const uidMatch = content.match(/UID:(.*)/);
      if (uidMatch) {
        const uid = uidMatch[1].trim();
        const task = db.readTaskByCaldavUid(database, uid);
        if (task) {
          try {
            db.deleteTaskById(database, task.id);
            // Clean up companion due-date VEVENT (check aufgaben + legacy meisterpilze)
            for (const cal of ['aufgaben', 'meisterpilze']) deleteIcsFile(cal, uid + '-event.ics');
            // Clean up mirror in shared/personal calendar
            if (calName !== 'meisterpilze') {
              deleteIcsFile('meisterpilze', uid + '.ics');
            }
            if (task.assignee) {
              const slug = db.caldavSlug(task.assignee);
              if (calName !== slug) {
                deleteIcsFile(slug, uid + '.ics');
              }
            }
            broadcastSSE();
          } catch (e) {
            log('warn', 'CalDAV DELETE VTODO sync failed', { uid, error: e.message });
          }
        }
      }
    }
    // External events (no X-MEISTERPILZE-TYPE, no VTODO) — just delete file

    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
      invalidateCtag(calName);
      recordChange(calName, fileName, 'deleted');
    }
    res.writeHead(204);
    res.end();
    return;
  }
  res.writeHead(403);
  res.end('Forbidden');
}

function handleProppatch(parts, body, req, res) {
  // Minimal PROPPATCH support — just acknowledge
  const href = '/caldav/' + parts.join('/') + (parts.length > 0 ? '/' : '');
  const xml = `<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:">
  <d:response>
    <d:href>${escapeXml(href)}</d:href>
    <d:propstat>
      <d:prop/>
      <d:status>HTTP/1.1 200 OK</d:status>
    </d:propstat>
  </d:response>
</d:multistatus>`;
  res.writeHead(207, { 'Content-Type': 'application/xml; charset=utf-8' });
  res.end(xml);
}

// ══════════════════════════════════════════════════════════════
// ── HTTP SERVER ──────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════

// ── RATE LIMITING ────────────────────────────────────────────
const RATE_WINDOW_MS = 60000;
// 300 a minute per IP is generous for people and tight for a machine, which is
// the point. MT_RATE_MAX raises it for one case only: the measuring stand walks
// twelve pages at ninety-five widths on two pointer axes, and every station it
// opens makes the app fetch. A number, not an on/off, so a mistyped environment
// still leaves a limit standing.
const RATE_MAX_REQUESTS = parseInt(process.env.MT_RATE_MAX, 10) > 0 ? parseInt(process.env.MT_RATE_MAX, 10) : 300;
const httpRateLimits = new Map();

function checkRateLimit(ip) {
  const now = Date.now();
  let entry = httpRateLimits.get(ip);
  if (!entry || now - entry.start > RATE_WINDOW_MS) {
    entry = { start: now, count: 0 };
    httpRateLimits.set(ip, entry);
  }
  entry.count++;
  return entry.count <= RATE_MAX_REQUESTS;
}

setInterval(() => {
  const now = Date.now();
  for (const [ip, entry] of httpRateLimits) {
    if (now - entry.start > RATE_WINDOW_MS) httpRateLimits.delete(ip);
  }
}, RATE_WINDOW_MS);

// ── LOGIN BRUTE-FORCE PROTECTION ────────────────────────────
const LOGIN_MAX_ATTEMPTS = 5; // per username+IP
const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
const loginAttempts = new Map(); // username@IP → { count, firstAttempt, lockedUntil }
const loginAttemptsPerUser = new Map(); // username → { count, firstAttempt }

// S-19: the per-user tier used to be a hard lock — twenty failures from
// anywhere and the account was unreachable for fifteen minutes. That is real
// protection against distributed password spraying, and simultaneously a
// weapon: twenty wrong guesses against a known username ("admin" is a good
// guess) keeps that account out indefinitely at four requests a minute, spread
// over as many source addresses as the attacker likes, and the locked-out admin
// has no self-service way back in.
//
// It is an escalating delay instead. The per-*source* tier keeps its hard lock,
// because an attacker can only ever aim that at their own address. Spraying one
// account still collapses — each failure past the threshold doubles the wait, to
// a five-second ceiling — while the person who actually knows the password
// always gets in, just slowly, and one correct login clears the counter.
//
// Pending delays are counted and capped so the delay cannot itself be turned
// into a way to pin open sockets. Past the cap the answer is an immediate 429,
// which clears the moment the flood stops instead of fifteen minutes later.
const LOGIN_DELAY_AFTER = 5; // failures for one account before the wait starts
const LOGIN_DELAY_STEP_MS = 250;
const LOGIN_DELAY_MAX_MS = 5000;
const LOGIN_DELAY_MAX_PENDING = 64;
let loginDelaysPending = 0;

function loginDelayForUser(username) {
  const entry = loginAttemptsPerUser.get(username);
  if (!entry) return 0;
  if (Date.now() - entry.firstAttempt > LOGIN_LOCKOUT_MS) {
    loginAttemptsPerUser.delete(username);
    return 0;
  }
  const over = entry.count - LOGIN_DELAY_AFTER;
  if (over <= 0) return 0;
  return Math.min(LOGIN_DELAY_STEP_MS * 2 ** (over - 1), LOGIN_DELAY_MAX_MS);
}

// Runs `next` once this account's current penalty has elapsed. Returns false
// when the pending-delay budget is spent — the caller answers 429 then.
function afterLoginDelay(username, next) {
  const delay = loginDelayForUser(username);
  if (delay <= 0) {
    next();
    return true;
  }
  if (loginDelaysPending >= LOGIN_DELAY_MAX_PENDING) return false;
  loginDelaysPending++;
  setTimeout(() => {
    loginDelaysPending--;
    next();
  }, delay);
  return true;
}

// The per-source lock is short and grows. Five wrong passwords used to shut
// the person at the keyboard out for a quarter of an hour — the one who mistyped
// is far likelier than an attacker to be standing there, and the per-account
// wait and the hashing budget below already hold off guessing at speed. Now the
// first lock is half a minute; only a source that keeps getting it wrong right
// after each lock waits longer, doubling up to the old fifteen minutes.
const LOGIN_SOURCE_LOCK_MS = 30 * 1000;

function loginLockRemainingMs(key) {
  const entry = loginAttempts.get(key);
  if (!entry || !entry.lockedUntil) return 0;
  return Math.max(0, entry.lockedUntil - Date.now());
}

function checkLoginAllowed(key) {
  const entry = loginAttempts.get(key);
  if (!entry || !entry.lockedUntil) return true;
  if (Date.now() < entry.lockedUntil) return false;
  // Lock over: a fresh round of attempts, but the number of locks so far is
  // kept, so the next one is longer. A correct password clears both.
  entry.lockedUntil = null;
  entry.count = 0;
  return true;
}

function recordLoginFailure(key) {
  const now = Date.now();
  let entry = loginAttempts.get(key);
  if (!entry) {
    entry = { count: 0, locks: 0, firstAttempt: now, lockedUntil: null };
    loginAttempts.set(key, entry);
  }
  entry.count++;
  entry.lastAttempt = now;
  if (entry.count >= LOGIN_MAX_ATTEMPTS) {
    entry.locks = (entry.locks || 0) + 1;
    entry.lockedUntil = now + Math.min(LOGIN_SOURCE_LOCK_MS * 2 ** (entry.locks - 1), LOGIN_LOCKOUT_MS);
    log('warn', 'Login locked due to too many failed attempts', {
      key,
      attempts: entry.count,
      seconds: Math.round((entry.lockedUntil - now) / 1000)
    });
  }
}

// What the login page shows instead of "try again in 15 minutes".
function loginLockMessage(key) {
  const s = Math.max(1, Math.ceil(loginLockRemainingMs(key) / 1000));
  const wann = s < 90 ? s + ' Sekunden' : Math.ceil(s / 60) + ' Minuten';
  return 'Zu oft das falsche Passwort. In ' + wann + ' noch einmal versuchen.';
}

function recordLoginFailurePerUser(username) {
  const now = Date.now();
  let entry = loginAttemptsPerUser.get(username);
  if (!entry) {
    entry = { count: 0, firstAttempt: now };
    loginAttemptsPerUser.set(username, entry);
  }
  entry.count++;
  // Logged once, when the wait first bites, so a spray against one account is
  // visible without a line per attempt.
  if (entry.count === LOGIN_DELAY_AFTER + 1) {
    log('warn', 'Repeated failed logins for one account — throttling it', { username, attempts: entry.count });
  }
}

// ── S-22: what an unauthenticated caller may make this process compute ──────
//
// finishLogin runs a full scrypt whether or not the account exists, because the
// constant-time answer requires it, and S-14 raised that to N=2^17: ~276 ms of
// synchronous work on the only thread there is. Both older tiers key on a string
// the caller chooses — loginAttempts on username@IP, loginAttemptsPerUser on the
// username — so a fresh username per request lands in a fresh bucket, neither
// counter ever engages, and every request bought a full hash. At the
// 300-a-minute ceiling that is 82 s of CPU demanded of every 60 s of wall clock,
// from one address, with no account.
//
// Three things this must NOT be, each learned the hard way:
//
// 1. **Not a delay.** A wait that still ends in a hash only queues the work;
//    64 pending delays releasing 17 s of hashing every 5 s is the same overload
//    with extra steps.
//
// 2. **Not a hard lock.** The first version of this was one, and it repeated
//    S-19's mistake one level down. S-19 removed the per-account lock because
//    twenty wrong guesses could keep a named account out; a lock keyed on the
//    address does the same to everyone who shares one — an office NAT, a mobile
//    carrier, or the documented Path B nginx, where without TRUST_PROXY every
//    request arrives from 127.0.0.1. Worse, it could not be cleared: the refusal
//    ran before finishLogin, so the "one correct login clears it" escape was
//    unreachable once tripped. This budget refills on its own instead, so the
//    worst case is a wait measured in seconds.
//
// 3. **Not one global pot.** That was the second version, and it turned a CPU
//    exhaustion into a login outage: a flood that keeps the shared bucket empty
//    costs the attacker nothing and locks out everyone arriving without a
//    session, invisibly. A per-source budget cannot be spent on somebody else's
//    behalf, which is the whole property that was missing.
//
// What is left is a rate, not a verdict: an address may buy LOGIN_KDF_BURST
// hashes at once and one more every LOGIN_KDF_REFILL_MS. One address therefore
// holds about 5% of the thread, and a correct password hands its token straight
// back, so honest use costs nothing at all.
//
// A distributed flood across many addresses is not answered here and is not
// pretended to be: that is what the 300-a-minute limiter and a firewall are for.
// What this closes is one caller freezing the lab.
const LOGIN_KDF_BURST = 5;
const LOGIN_KDF_REFILL_MS = 5000;
const LOGIN_KDF_MAX_QUELLEN = 5000;
const loginKdfTokens = new Map(); // Adresse → { tokens, stand }

function _loginKdfEintrag(quelle, jetzt) {
  let e = loginKdfTokens.get(quelle);
  if (!e) {
    // Bounded, or the map is a memory leak with a rate limit in front of it.
    // Oldest-first: the entries that matter are the ones spending right now.
    if (loginKdfTokens.size >= LOGIN_KDF_MAX_QUELLEN) {
      const aeltester = loginKdfTokens.keys().next().value;
      if (aeltester !== undefined) loginKdfTokens.delete(aeltester);
    }
    e = { tokens: LOGIN_KDF_BURST, stand: jetzt };
    loginKdfTokens.set(quelle, e);
  }
  const dazu = Math.floor((jetzt - e.stand) / LOGIN_KDF_REFILL_MS);
  if (dazu > 0) {
    e.tokens = Math.min(LOGIN_KDF_BURST, e.tokens + dazu);
    e.stand += dazu * LOGIN_KDF_REFILL_MS;
  }
  return e;
}

/** One hash for this address, or false. Nothing is hashed on a false. */
function takeLoginKdfToken(quelle, jetzt = Date.now()) {
  const e = _loginKdfEintrag(quelle, jetzt);
  if (e.tokens <= 0) return false;
  e.tokens--;
  return true;
}

/** A correct password costs nothing — that is what keeps this off honest users. */
function refundLoginKdfToken(quelle, jetzt = Date.now()) {
  const e = _loginKdfEintrag(quelle, jetzt);
  e.tokens = Math.min(LOGIN_KDF_BURST, e.tokens + 1);
}

// An address that has not asked for a while is not worth remembering.
//
// ⚠️ **The count has to be refilled before it is judged.** This read `e.tokens`
// straight out of the entry, and that number is only ever brought up to date by
// a call — so an address that spent one token and never came back sat at
// burst-1 for ever and was never swept. The map then only shrank through the
// LRU eviction at LOGIN_KDF_MAX_QUELLEN, which exists as a backstop and was
// doing the whole job. Measured against the real block: 50 entries an hour old,
// 50 deleted, none.
setInterval(
  () => {
    const jetzt = Date.now();
    const alt = LOGIN_KDF_BURST * LOGIN_KDF_REFILL_MS;
    for (const [quelle, e] of loginKdfTokens) {
      if (jetzt - e.stand <= alt) continue;
      const voll = Math.min(LOGIN_KDF_BURST, e.tokens + Math.floor((jetzt - e.stand) / LOGIN_KDF_REFILL_MS));
      if (voll >= LOGIN_KDF_BURST) loginKdfTokens.delete(quelle);
    }
  },
  10 * 60 * 1000
).unref();

function clearLoginAttempts(key) {
  loginAttempts.delete(key);
}
function clearLoginAttemptsPerUser(username) {
  loginAttemptsPerUser.delete(username);
}

// The half of /api/auth/login that runs once the per-account wait has elapsed.
// Split out of the route handler for S-19: the delay is asynchronous, and this
// is what it defers.
function finishLogin(req, res, { username, password, userKey, throttleKey, sourceKey }) {
  const user = db.getUserByUsernameCaseInsensitive(database, username);
  // Constant-time login: always run scrypt, even when the username is
  // unknown — falling back to a process-local dummy hash keeps the
  // response time independent of whether the account exists. The
  // !user check below still rejects unknown accounts, but does so at
  // the same latency as a real user with a wrong password.
  const candidateHash = user ? user.hash : DUMMY_PASSWORD_HASH;
  const candidateSalt = user ? user.salt : DUMMY_PASSWORD_SALT;
  const passwordOk = db.verifyPassword(candidateHash, candidateSalt, password);
  if (!user || !passwordOk) {
    recordLoginFailure(throttleKey);
    recordLoginFailurePerUser(userKey);
    res.writeHead(401, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Invalid credentials' }));
    return;
  }
  // One correct password clears the account's penalty outright, which is what
  // keeps S-19's delay from being a lockout with extra steps.
  clearLoginAttempts(throttleKey);
  clearLoginAttemptsPerUser(userKey);
  // S-22: a correct password costs this address nothing, which is what keeps
  // the budget off the people it exists to protect.
  refundLoginKdfToken(sourceKey);
  // S-14: login is the only moment the plaintext password is in hand, so
  // it is the only chance to re-hash a row that still uses the old scrypt
  // cost. Best effort — a failure here must not cost the user their login.
  if (db.passwordNeedsUpgrade(user.salt)) {
    try {
      const upgraded = db.hashPassword(password);
      db.updateUserPassword(database, user.id, upgraded.hash, upgraded.salt);
      log('info', 'Upgraded password hash to current KDF parameters', { username: user.username });
    } catch (upgradeErr) {
      log('warn', 'Password hash upgrade failed', { username: user.username, error: upgradeErr.message });
    }
  }
  // Rotate: invalidate any pre-existing session token presented in
  // the request before minting a new one. Defends against stolen
  // pre-auth cookies surviving the legitimate user's login.
  const oldToken = getSessionToken(req);
  if (oldToken) {
    try {
      db.deleteSession(database, oldToken);
    } catch (_) {
      /* best effort — token may not exist anymore */
    }
  }
  const token = db.createSession(database, user.id);
  setSessionCookie(res, token);
  jsonOk(res, { username: user.username, role: user.role });
}

setInterval(() => {
  const now = Date.now();
  // A source is forgotten a quarter of an hour after its last attempt, once no
  // lock is running. Dropping it the moment a lock ran out would also drop the
  // count of locks, and every lock would be the first, short one.
  for (const [key, entry] of loginAttempts) {
    if (entry.lockedUntil && now < entry.lockedUntil) continue;
    if (now - (entry.lastAttempt || entry.firstAttempt) > LOGIN_LOCKOUT_MS) loginAttempts.delete(key);
  }
  for (const [key, entry] of loginAttemptsPerUser) {
    if (now - entry.firstAttempt > LOGIN_LOCKOUT_MS) loginAttemptsPerUser.delete(key);
  }
}, 60000);

// R-18: opt-in access logging. Default OFF — logs are noisy and we already
// have structured logs for everything that matters. Operators flip
// LOG_ACCESS=true when they need a request trail (incident response,
// debugging a misbehaving client). Skips SSE long-pollers and unauth health
// probes so we don't drown the log file.
const LOG_ACCESS = process.env.LOG_ACCESS === 'true' || process.env.LOG_ACCESS === '1';
function handleRequest(req, res) {
  const clientIP = getClientIP(req);
  if (!checkRateLimit(clientIP)) {
    res.writeHead(429, { 'Content-Type': 'application/json' });
    res.end('{"error":"Too many requests"}');
    return;
  }

  // R-18: optional access logging — wires up at request entry so we capture
  // the round-trip even when downstream code throws before res.end().
  if (LOG_ACCESS) {
    const reqStart = Date.now();
    res.once('finish', () => {
      try {
        const url = req.url || '';
        // Skip SSE long-polls (would log once per 30 min connection close)
        // and skip unauth /api/health probes (every prober hits this every
        // few seconds).
        if (url.startsWith('/api/events')) return;
        const isAuthed = !!req.authUser;
        if (url === '/api/health' && !isAuthed) return;
        log('info', 'http', {
          method: req.method,
          url,
          status: res.statusCode,
          ms: Date.now() - reqStart,
          user: req.authUser ? req.authUser.username : null,
          ip: clientIP
        });
      } catch (_) {
        /* never let access logging break a response */
      }
    });
  }

  // ── Security headers ──
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  // base-uri/form-action/object-src close injection pivots that the rest of the
  // policy leaves open: a <base> tag rewriting every relative script URL, a form
  // retargeted to an attacker origin, and plugin/embed content. script-src still
  // needs 'unsafe-inline' because the app uses inline event handlers (onclick=)
  // throughout index.html/app.js — removing it requires converting those to
  // addEventListener first, or every one of them silently stops working.
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /api/csp-reports"
  );
  if (protocol === 'https') res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  // Permissions-Policy: explicitly drop powerful APIs the app doesn't use.
  // camera=(self) is required for the html5-qrcode scanner; fullscreen=(self)
  // is left open for any future scanner-fullscreen UI. Everything else is
  // off — closes attack-surface that a future XSS could otherwise pivot
  // into (e.g. exfiltrating geolocation, capturing the microphone).
  res.setHeader(
    'Permissions-Policy',
    'camera=(self), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=(), midi=(), autoplay=(), fullscreen=(self)'
  );

  // ── Well-known CalDAV discovery (RFC 6764) ──
  // Use 308 (Permanent Redirect) instead of 301 so that iOS/CalDAV clients
  // preserve the PROPFIND method across the redirect (301 allows changing to GET).
  if (req.url.startsWith('/.well-known/caldav')) {
    res.writeHead(308, { Location: '/caldav/' });
    res.end();
    return;
  }

  // ── CalDAV requests ──
  if (req.url.startsWith('/caldav')) {
    // S-19: the per-account wait, applied before the handler runs. CalDAV
    // re-authenticates on every request, so this is the same throttle the login
    // route uses, on the same counters — a stale password in a calendar client
    // slows itself down instead of locking the person out of the web UI.
    const caldavUser = extractBasicAuthUsername(req);
    if (!caldavUser) return handleCaldav(req, res);
    const accepted = afterLoginDelay(caldavUser.toLowerCase(), () => handleCaldav(req, res));
    if (!accepted) {
      res.writeHead(429, { 'Content-Type': 'text/plain', 'Retry-After': '2' });
      res.end('Too many login attempts. Try again shortly.');
    }
    return;
  }

  // ── OAuth 2.0 well-known endpoints (public, no auth) ──
  // CORS preflight for well-known endpoints (MCP clients send MCP-Protocol-Version header)
  if (req.method === 'OPTIONS' && req.url.startsWith('/.well-known/oauth-')) {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Protocol-Version');
    res.writeHead(204);
    res.end();
    return;
  }
  // RFC 9728: serve protected resource metadata at both path-aware and root URLs
  const MCP_SCOPE = 'mcp:full';
  if (
    req.method === 'GET' &&
    (req.url === '/.well-known/oauth-protected-resource/mcp' || req.url === '/.well-known/oauth-protected-resource')
  ) {
    const base = getBaseUrl(req);
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Protocol-Version');
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(
      JSON.stringify({
        resource: base + '/mcp',
        authorization_servers: [base],
        bearer_methods_supported: ['header'],
        scopes_supported: [MCP_SCOPE]
      })
    );
    return;
  }
  if (req.method === 'GET' && req.url === '/.well-known/oauth-authorization-server') {
    const base = getBaseUrl(req);
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Protocol-Version');
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(
      JSON.stringify({
        issuer: base,
        authorization_endpoint: base + '/oauth/authorize',
        token_endpoint: base + '/oauth/token',
        registration_endpoint: base + '/oauth/register',
        response_types_supported: ['code'],
        grant_types_supported: ['authorization_code', 'refresh_token'],
        token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
        code_challenge_methods_supported: ['S256'],
        scopes_supported: [MCP_SCOPE]
      })
    );
    return;
  }

  // ── OAuth 2.0 endpoints (before auth gate) ──
  if (req.url.startsWith('/oauth/')) {
    // CORS for token + register endpoints
    if (req.url === '/oauth/token' || req.url === '/oauth/register') {
      res.setHeader('Access-Control-Allow-Origin', '*');
      res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, MCP-Protocol-Version');
      if (req.method === 'OPTIONS') {
        res.writeHead(204);
        res.end();
        return;
      }
    }

    // Dynamic Client Registration (RFC 7591) — required by MCP OAuth spec
    if (req.method === 'POST' && req.url === '/oauth/register') {
      if (!checkOAuthRate(req)) {
        res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
        res.end('{"error":"too_many_requests"}');
        return;
      }
      jsonBody(req, res, (e, data) => {
        if (e) return;
        try {
          const redirectUris = data.redirect_uris;
          if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
            res.writeHead(400, { 'Content-Type': 'application/json' });
            res.end('{"error":"invalid_client_metadata","error_description":"redirect_uris required"}');
            return;
          }
          // S-25: bounds, because this route is unauthenticated and every call
          // writes a row that nothing used to collect. Until here the only
          // limits were checkOAuthRate (20/min per address) and MAX_BODY_SIZE —
          // five megabytes of client_name, stored verbatim and logged verbatim,
          // twenty times a minute, for ever.
          //
          // The numbers are deliberately far above any real client: a name is a
          // label in an admin list, and RFC 7591 registrations carry one or two
          // redirect URIs, not fifty.
          if (redirectUris.length > OAUTH_MAX_REDIRECT_URIS) {
            res.writeHead(400, { 'Content-Type': 'application/json' });
            res.end('{"error":"invalid_client_metadata","error_description":"too many redirect_uris"}');
            return;
          }
          // Typed as well as bounded. `new URL(uri)` below throws on a
          // non-string, and that lands in safeErr as a 500 — our fault by the
          // response code, the caller's by the facts.
          for (const uri of redirectUris) {
            if (typeof uri !== 'string' || uri.length > OAUTH_MAX_URI_LENGTH) {
              res.writeHead(400, { 'Content-Type': 'application/json' });
              res.end(
                '{"error":"invalid_client_metadata","error_description":"redirect_uri must be a string within length limits"}'
              );
              return;
            }
          }
          if (typeof data.client_name === 'string' && data.client_name.length > OAUTH_MAX_CLIENT_NAME) {
            res.writeHead(400, { 'Content-Type': 'application/json' });
            res.end('{"error":"invalid_client_metadata","error_description":"client_name too long"}');
            return;
          }
          // Validate redirect URIs. RFC 8252 (OAuth 2.0 for Native Apps):
          // public clients must use https:// or loopback http://. Plain http
          // to a public host would let an attacker register a phishing
          // redirect — PKCE alone does not prevent that since the attacker
          // *is* the registered client. Accept exactly:
          //   - https://...   (any host)
          //   - http://127.0.0.1[:port][/...]
          //   - http://[::1][:port][/...]
          //   - http://localhost[:port][/...]   (dev convenience)
          for (const uri of redirectUris) {
            let u;
            try {
              u = new URL(uri);
            } catch {
              res.writeHead(400, { 'Content-Type': 'application/json' });
              res.end('{"error":"invalid_client_metadata","error_description":"invalid redirect_uri"}');
              return;
            }
            const isHttps = u.protocol === 'https:';
            const isLoopbackHttp =
              u.protocol === 'http:' &&
              (u.hostname === '127.0.0.1' ||
                u.hostname === '[::1]' ||
                u.hostname === '::1' ||
                u.hostname === 'localhost');
            if (!isHttps && !isLoopbackHttp) {
              res.writeHead(400, { 'Content-Type': 'application/json' });
              res.end(
                '{"error":"invalid_client_metadata","error_description":"redirect_uri must be https:// or http://(127.0.0.1|[::1]|localhost) per RFC 8252"}'
              );
              return;
            }
            if (u.hash) {
              res.writeHead(400, { 'Content-Type': 'application/json' });
              res.end(
                '{"error":"invalid_client_metadata","error_description":"redirect_uri must not contain a fragment"}'
              );
              return;
            }
          }
          const clientId = crypto.randomUUID();
          const clientName = typeof data.client_name === 'string' ? data.client_name : '';
          db.registerOAuthClient(database, { clientId, clientName, redirectUris });
          log('info', 'OAuth client registered via DCR', { clientId, clientName });
          res.writeHead(201, { 'Content-Type': 'application/json' });
          res.end(
            JSON.stringify({
              client_id: clientId,
              client_name: clientName,
              redirect_uris: redirectUris,
              grant_types: data.grant_types || ['authorization_code', 'refresh_token'],
              response_types: data.response_types || ['code'],
              token_endpoint_auth_method: 'none'
            })
          );
        } catch (err) {
          safeErr(res, err);
        }
      });
      return;
    }

    // Token endpoint — public
    if (req.method === 'POST' && req.url === '/oauth/token') {
      if (!checkOAuthRate(req)) {
        res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
        res.end('{"error":"too_many_requests"}');
        return;
      }
      const ct = (req.headers['content-type'] || '').split(';')[0].trim();
      const parser = ct === 'application/json' ? jsonBody : formBody;
      parser(req, res, (e, data) => {
        if (e) return;
        try {
          // Validate client_secret if client has one
          const tokenClient = db.getOAuthClient(database, data.client_id);
          if (!tokenClient) {
            res.writeHead(401, { 'Content-Type': 'application/json' });
            res.end('{"error":"invalid_client"}');
            return;
          }
          if (tokenClient.hasSecret) {
            if (!data.client_secret) {
              res.writeHead(401, { 'Content-Type': 'application/json' });
              res.end('{"error":"invalid_client","error_description":"client_secret required"}');
              return;
            }
            if (!db.verifyOAuthClientSecret(database, data.client_id, data.client_secret)) {
              res.writeHead(401, { 'Content-Type': 'application/json' });
              res.end('{"error":"invalid_client"}');
              return;
            }
          }

          if (data.grant_type === 'authorization_code') {
            if (!data.code || !data.code_verifier || !data.client_id || !data.redirect_uri) {
              return jsonErr(res, 400, 'missing required parameters');
            }
            const codeHash = crypto.createHash('sha256').update(data.code).digest('hex');
            const codeRow = db.getOAuthCode(database, codeHash);
            if (!codeRow) {
              log('warn', 'OAuth token failed: invalid code', { clientId: data.client_id });
              return jsonErr(res, 400, 'invalid_grant');
            }
            if (codeRow.clientId !== data.client_id) {
              log('warn', 'OAuth token failed: client mismatch', { clientId: data.client_id });
              return jsonErr(res, 400, 'invalid_grant');
            }
            if (codeRow.redirectUri !== data.redirect_uri) {
              log('warn', 'OAuth token failed: redirect mismatch', { clientId: data.client_id });
              return jsonErr(res, 400, 'invalid_grant');
            }
            if (!verifyPkce(data.code_verifier, codeRow.codeChallenge)) {
              log('warn', 'OAuth token failed: PKCE mismatch', { clientId: data.client_id });
              return jsonErr(res, 400, 'invalid_grant');
            }
            // RFC 8707: validate resource indicator if both sides provided one
            if (data.resource && codeRow.resource && data.resource !== codeRow.resource) {
              log('warn', 'OAuth token failed: resource mismatch', {
                clientId: data.client_id,
                expected: codeRow.resource,
                got: data.resource
              });
              return jsonErr(res, 400, 'invalid_grant');
            }

            const accessToken = crypto.randomBytes(32).toString('hex');
            const refreshToken = crypto.randomBytes(32).toString('hex');
            const accessHash = crypto.createHash('sha256').update(accessToken).digest('hex');
            const refreshHash = crypto.createHash('sha256').update(refreshToken).digest('hex');

            // I-08: atomic exchange — burn the auth code and issue both tokens in one txn
            // so a failed INSERT can't leave the code unusable while the access/refresh
            // tokens are missing (or vice versa).
            database.exec('BEGIN');
            try {
              db.markOAuthCodeUsed(database, codeHash);
              db.createOAuthToken(database, {
                token: accessHash,
                tokenType: 'access',
                clientId: data.client_id,
                userId: codeRow.userId,
                expiresInSeconds: 3600,
                refreshTokenRef: refreshHash
              });
              db.createOAuthToken(database, {
                token: refreshHash,
                tokenType: 'refresh',
                clientId: data.client_id,
                userId: codeRow.userId,
                expiresInSeconds: 30 * 24 * 3600
              });
              database.exec('COMMIT');
            } catch (txErr) {
              database.exec('ROLLBACK');
              throw txErr;
            }

            log('info', 'OAuth token issued', { clientId: data.client_id });
            res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
            res.end(
              JSON.stringify({
                access_token: accessToken,
                token_type: 'bearer',
                expires_in: 3600,
                refresh_token: refreshToken
              })
            );
          } else if (data.grant_type === 'refresh_token') {
            if (!data.refresh_token || !data.client_id) return jsonErr(res, 400, 'missing required parameters');
            const refreshHash = crypto.createHash('sha256').update(data.refresh_token).digest('hex');
            const refreshRow = db.getOAuthRefreshToken(database, refreshHash);
            if (!refreshRow) return jsonErr(res, 400, 'invalid_grant');
            if (refreshRow.clientId !== data.client_id) return jsonErr(res, 400, 'invalid_grant');

            const newAccessToken = crypto.randomBytes(32).toString('hex');
            const newRefreshToken = crypto.randomBytes(32).toString('hex');
            const newAccessHash = crypto.createHash('sha256').update(newAccessToken).digest('hex');
            const newRefreshHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');

            // I-08: atomic rotation — revoke previous pair and issue new pair in one txn
            // so a failed INSERT can't leave the user without working tokens.
            database.exec('BEGIN');
            try {
              db.revokeOAuthTokensByRefresh(database, refreshHash);
              db.createOAuthToken(database, {
                token: newAccessHash,
                tokenType: 'access',
                clientId: data.client_id,
                userId: refreshRow.userId,
                expiresInSeconds: 3600,
                refreshTokenRef: newRefreshHash
              });
              db.createOAuthToken(database, {
                token: newRefreshHash,
                tokenType: 'refresh',
                clientId: data.client_id,
                userId: refreshRow.userId,
                expiresInSeconds: 30 * 24 * 3600
              });
              database.exec('COMMIT');
            } catch (txErr) {
              database.exec('ROLLBACK');
              throw txErr;
            }

            res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
            res.end(
              JSON.stringify({
                access_token: newAccessToken,
                token_type: 'bearer',
                expires_in: 3600,
                refresh_token: newRefreshToken
              })
            );
          } else {
            jsonErr(res, 400, 'unsupported_grant_type');
          }
        } catch (err) {
          safeErr(res, err);
        }
      });
      return;
    }

    // Authorization endpoint — requires session cookie
    if (req.url.startsWith('/oauth/authorize')) {
      const parsedUrl = new URL(req.url, 'http://localhost');
      if (req.method === 'GET') {
        const authUser = checkAuth(req);
        if (!authUser) {
          res.writeHead(302, { Location: '/login.html?redirect=' + encodeURIComponent(req.url) });
          res.end();
          return;
        }
        const clientId = parsedUrl.searchParams.get('client_id') || '';
        const redirectUri = parsedUrl.searchParams.get('redirect_uri') || '';
        const state = parsedUrl.searchParams.get('state') || '';
        const codeChallenge = parsedUrl.searchParams.get('code_challenge') || '';
        const codeChallengeMethod = parsedUrl.searchParams.get('code_challenge_method') || '';
        const responseType = parsedUrl.searchParams.get('response_type') || '';
        const resource = parsedUrl.searchParams.get('resource') || '';

        if (responseType !== 'code') {
          jsonErr(res, 400, 'unsupported_response_type');
          return;
        }
        if (codeChallengeMethod !== 'S256') {
          jsonErr(res, 400, 'invalid code_challenge_method');
          return;
        }
        if (!codeChallenge) {
          jsonErr(res, 400, 'code_challenge required');
          return;
        }

        const client = db.getOAuthClient(database, clientId);
        if (!client) {
          // A browser is always the reader here — this route is where a client
          // sends a person — so the answer is a page and not the JSON the
          // machine endpoints return. It is also the far end of a trap worth
          // naming. A proxy that caches its registration (mcp-remote keeps one
          // per server under ~/.mcp-auth, for ever) hands over an id that
          // deleteExpiredOAuthData has since swept, and this error reaches only
          // the tab, never the proxy: it goes on presenting the same dead id at
          // every start, and opens a new tab each time. Told
          // {"error":"invalid client_id"} nobody guesses the fix is a file on
          // their own disk, so the page says so.
          log('warn', 'OAuth authorize: unknown client_id', { user: authUser.username });
          res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
          res.end(`<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Registration Expired – Meistertracker</title><link rel="icon" href="/favicon.ico">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f8fafc;color:#1e293b;font-size:15px;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:36px;width:100%;max-width:460px;margin:16px;box-shadow:0 4px 6px -1px rgba(0,0,0,.07)}
.logo{text-align:center;margin-bottom:8px;color:#d97706}
h1{font-size:20px;font-weight:700;margin-bottom:4px;text-align:center}
.sub{color:#64748b;font-size:14px;text-align:center;margin-bottom:24px}
.note{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:13px;color:#92400e}
ol{font-size:14px;color:#334155}
ol li{margin:10px 0 10px 20px}
code{background:#f1f5f9;border-radius:4px;padding:1px 5px;font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
</style></head><body>
<div class="card">
  <div class="logo"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 7v6M12 16.5v.5"/></svg></div>
  <h1>Registration Expired</h1>
  <p class="sub">The app that sent you here is using a client registration this server no longer has.</p>
  <div class="note">A registration that never gets this far is removed after a day. The app kept its copy and has no way to notice that this one is gone, so it will keep sending you here — nothing on this page can revive it.</div>
  <ol>
    <li>Delete the app's cached registration. <code>mcp-remote</code> keeps one per server under <code>~/.mcp-auth</code>; most other clients drop theirs when you remove and re-add the server.</li>
    <li>Start the app again and open the fresh authorization link it prints.</li>
    <li>Finish that one — click Allow — while you are still signed in here.</li>
  </ol>
</div></body></html>`);
          return;
        }
        if (!client.redirectUris.includes(redirectUri)) {
          jsonErr(res, 400, 'invalid redirect_uri');
          return;
        }

        const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
        const clientName = client.clientName || clientId;
        // Getting an authenticated person as far as this screen is what makes a
        // registration real, so it counts as use. Marked here and not only when
        // a code or a token is issued, because the gap between the two is where
        // clients got stuck: close the tab, think it over until tomorrow, and
        // deleteExpiredOAuthData swept a registration the client had already
        // cached and would never re-request. The bound S-25 put on
        // POST /oauth/register survives intact — reaching this line needs
        // checkAuth, and unauthenticated registration spam never can.
        db.touchOAuthClient(database, clientId);
        log('info', 'OAuth consent shown', { clientId, clientName, user: authUser.username });
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(`<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Authorize – Meistertracker</title><link rel="icon" href="/favicon.ico">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f8fafc;color:#1e293b;font-size:15px;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:36px;width:100%;max-width:420px;margin:16px;box-shadow:0 4px 6px -1px rgba(0,0,0,.07)}
.logo{text-align:center;margin-bottom:8px;color:#16a34a}
h1{font-size:20px;font-weight:700;margin-bottom:4px;text-align:center}
.sub{color:#64748b;font-size:14px;text-align:center;margin-bottom:24px}
.client{font-weight:600;color:#1e293b}
.perms{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:12px 16px;margin-bottom:24px;font-size:13px;color:#166534}
.perms li{margin:4px 0;list-style:disc inside}
.btns{display:flex;gap:12px}
.btn{flex:1;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}
.btn-allow{background:#16a34a;color:#fff}.btn-allow:hover{background:#15803d}
.btn-deny{background:#e2e8f0;color:#475569}.btn-deny:hover{background:#cbd5e1}
</style></head><body>
<div class="card">
  <div class="logo"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="5"/><path d="M12 13v5M8 21h8M9 3c0 0-2-2-2-3M15 3c0 0 2-2 2-3"/></svg></div>
  <h1>Authorize Access</h1>
  <p class="sub"><span class="client">${esc(clientName)}</span> wants to access<br>Meistertracker</p>
  <ul class="perms">
    <li>Read batches, tasks, calendar, inventory</li>
    <li>Create and update batches, tasks, events</li>
    <li>Log harvests and bag movements</li>
  </ul>
  <form method="POST" action="/oauth/authorize">
    <input type="hidden" name="client_id" value="${esc(clientId)}">
    <input type="hidden" name="redirect_uri" value="${esc(redirectUri)}">
    <input type="hidden" name="state" value="${esc(state)}">
    <input type="hidden" name="code_challenge" value="${esc(codeChallenge)}">
    <input type="hidden" name="code_challenge_method" value="${esc(codeChallengeMethod)}">
    <input type="hidden" name="response_type" value="code">
    <input type="hidden" name="resource" value="${esc(resource)}">
    <div class="btns">
      <button type="submit" name="action" value="deny" class="btn btn-deny">Deny</button>
      <button type="submit" name="action" value="allow" class="btn btn-allow">Allow</button>
    </div>
  </form>
</div></body></html>`);
        return;
      }

      if (req.method === 'POST') {
        const authUser = checkAuth(req);
        if (!authUser) {
          jsonErr(res, 401, 'unauthorized');
          return;
        }
        formBody(req, res, (e, data) => {
          if (e) return;
          try {
            const redirectUri = data.redirect_uri || '';
            const state = data.state || '';
            const clientId = data.client_id || '';

            // Validate client and redirect_uri BEFORE any redirect (prevents open redirect)
            const client = db.getOAuthClient(database, clientId);
            if (!client || !client.redirectUris.includes(redirectUri)) {
              jsonErr(res, 400, 'invalid client or redirect_uri');
              return;
            }

            if (data.action === 'deny') {
              const sep = redirectUri.includes('?') ? '&' : '?';
              res.writeHead(302, {
                Location:
                  redirectUri + sep + 'error=access_denied' + (state ? '&state=' + encodeURIComponent(state) : '')
              });
              res.end();
              return;
            }

            log('info', 'OAuth authorization granted', { clientId, user: authUser.username });
            const code = crypto.randomBytes(32).toString('hex');
            const codeHash = crypto.createHash('sha256').update(code).digest('hex');
            db.createOAuthCode(database, {
              code: codeHash,
              clientId,
              userId: authUser.user_id,
              redirectUri,
              codeChallenge: data.code_challenge || '',
              codeChallengeMethod: data.code_challenge_method || 'S256',
              resource: data.resource || ''
            });

            const sep = redirectUri.includes('?') ? '&' : '?';
            res.writeHead(302, {
              Location:
                redirectUri +
                sep +
                'code=' +
                encodeURIComponent(code) +
                (state ? '&state=' + encodeURIComponent(state) : '')
            });
            res.end();
          } catch (err) {
            log('error', 'OAuth authorize POST failed', { error: err.message });
            safeErr(res, err);
          }
        });
        return;
      }
    }

    // Unknown /oauth/ endpoint
    jsonErr(res, 404, 'not found');
    return;
  }

  // ── MCP endpoint (own CORS + bearer auth, before cookie auth gate) ──
  if (req.url === '/mcp' || req.url.startsWith('/mcp?')) {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version');
    res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
    if (req.method === 'OPTIONS') {
      res.writeHead(204);
      res.end();
      return;
    }
    const mcpCfg = db.getMcpCfg(database);
    if (!mcpCfg.enabled) {
      res.writeHead(404);
      res.end();
      return;
    }
    const mcpAuth = checkMcpAuth(req);
    if (!mcpAuth) {
      const base = getBaseUrl(req);
      res.writeHead(401, {
        'Content-Type': 'application/json',
        'WWW-Authenticate':
          'Bearer resource_metadata="' + base + '/.well-known/oauth-protected-resource/mcp", scope="mcp:full"'
      });
      res.end('{"error":"unauthorized"}');
      return;
    }
    if (!checkMcpRate(req)) {
      res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
      res.end('{"error":"rate limit exceeded"}');
      return;
    }
    const sessionId = req.headers['mcp-session-id'];
    // S-01 follow-up: createMcpServer bakes the caller's role into the cached
    // server at creation, so a reused session ignores the current request's
    // token role. Bind each session to the identity that created it — a later
    // request bearing a different token, or the same user after a role change,
    // must not ride on the original session's privileges. Returns true when the
    // presented auth no longer matches, so the caller rejects (client re-inits).
    const mcpSessionAuthMismatch = (session) =>
      !session.auth || session.auth.userId !== mcpAuth.userId || session.auth.role !== mcpAuth.role;
    if (req.method === 'POST') {
      anyJsonBody(req, res, async (e, body) => {
        if (e) return;
        try {
          let session = sessionId ? mcpSessions.get(sessionId) : null;
          if (session && mcpSessionAuthMismatch(session)) {
            res.writeHead(403, { 'Content-Type': 'application/json' });
            res.end('{"error":"session belongs to a different identity"}');
            return;
          }
          if (!session) {
            // S-01: pass the caller's auth context (userId, role) into
            // the MCP server so destructive tools can require admin role.
            const server = createMcpServer(database, () => broadcastSSE(null), {
              printZPL,
              checkPrinterAvailable,
              auth: mcpAuth
            });
            const transport = new StreamableHTTPServerTransport({
              sessionIdGenerator: () => crypto.randomUUID(),
              onsessioninitialized: (sid) => {
                mcpSessions.set(sid, { transport, server, lastActive: Date.now(), auth: mcpAuth });
              }
            });
            transport.onclose = () => {
              const sid = transport.sessionId;
              if (sid) mcpSessions.delete(sid);
              server.close().catch(() => {});
            };
            await server.connect(transport);
            session = { transport, server, lastActive: Date.now(), auth: mcpAuth };
          } else {
            session.lastActive = Date.now();
          }
          await session.transport.handleRequest(req, res, body);
        } catch (err) {
          // Without this, a rejection from server.connect()/handleRequest()
          // becomes an unhandledRejection and the client never gets a response.
          log('error', 'MCP request handling failed', { error: err && err.message });
          if (!res.headersSent) {
            res.writeHead(500, { 'Content-Type': 'application/json' });
            res.end('{"error":"internal error"}');
          } else {
            res.destroy();
          }
        }
      });
      return;
    }
    if (req.method === 'GET') {
      const session = sessionId ? mcpSessions.get(sessionId) : null;
      if (!session) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end('{"error":"no session"}');
        return;
      }
      if (mcpSessionAuthMismatch(session)) {
        res.writeHead(403, { 'Content-Type': 'application/json' });
        res.end('{"error":"session belongs to a different identity"}');
        return;
      }
      session.lastActive = Date.now();
      session.transport.handleRequest(req, res);
      return;
    }
    if (req.method === 'DELETE') {
      const session = sessionId ? mcpSessions.get(sessionId) : null;
      if (!session) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end('{"error":"no session"}');
        return;
      }
      if (mcpSessionAuthMismatch(session)) {
        res.writeHead(403, { 'Content-Type': 'application/json' });
        res.end('{"error":"session belongs to a different identity"}');
        return;
      }
      session.transport.handleRequest(req, res);
      return;
    }
    res.writeHead(405);
    res.end();
    return;
  }

  // CORS — only allow same-origin requests (no cross-origin API access)
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') {
    res.writeHead(204);
    res.end();
    return;
  }

  const url = req.url.split('?')[0];

  // ── Auth endpoints (public) ───────────────────────────────
  if (url === '/api/auth/setup-required' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ setupRequired: db.countUsers(database) === 0 }));
    return;
  }

  if (url === '/api/auth/setup' && req.method === 'POST') {
    if (db.countUsers(database) > 0) {
      res.writeHead(403, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'setup already completed' }));
      return;
    }
    // Defence: a fresh public-facing deployment should not allow ANY
    // remote unauthenticated caller to claim admin. Two acceptable paths:
    //   1. The request originates from loopback (operator on the box).
    //   2. The request carries the in-memory setup token printed to logs
    //      on first start (operator who can read PM2/journald).
    const ip = req.socket.remoteAddress || '';
    const isLoopback = setupFromLoopback(ip, TRUST_PROXY);
    const presentedToken = req.headers['x-setup-token'] || '';
    let tokenOk = false;
    if (SETUP_TOKEN && typeof presentedToken === 'string' && presentedToken.length === SETUP_TOKEN.length) {
      try {
        tokenOk = crypto.timingSafeEqual(Buffer.from(presentedToken, 'utf8'), Buffer.from(SETUP_TOKEN, 'utf8'));
      } catch (_) {
        tokenOk = false;
      }
    }
    if (!isLoopback && !tokenOk) {
      log('warn', 'Remote setup attempt rejected', { ip });
      jsonErr(res, 403, 'setup must run from localhost or with a valid X-Setup-Token header');
      return;
    }
    jsonBody(req, res, (e, data) => {
      if (e) {
        jsonErr(res, 400, e.message);
        return;
      }
      try {
        const { username, password } = data;
        if (!username || !password || password.length < 8 || password.length > PASSWORD_MAX_LENGTH) {
          jsonErr(res, 400, 'Username and password (min 8 chars) required');
          return;
        }
        if (!USERNAME_RE.test(username)) {
          jsonErr(res, 400, 'username must be alphanumeric with . _ - (max 64 chars)');
          return;
        }
        const user = db.createUser(database, username, password, 'admin');
        const dbUser = db.getUserByUsername(database, username);
        const token = db.createSession(database, dbUser.id);
        setSessionCookie(res, token);
        // Setup completed — invalidate the in-memory token so a stolen
        // log line can't be reused after the admin is created.
        SETUP_TOKEN = null;
        jsonOk(res, { username: user.username, role: 'admin' });
      } catch (err) {
        safeErr(res, err);
      }
    });
    return;
  }

  if (url === '/api/auth/login' && req.method === 'POST') {
    jsonBody(req, res, (e, data) => {
      if (e) {
        jsonErr(res, 400, e.message);
        return;
      }
      try {
        const { username, password } = data;
        if (!username || !password) {
          jsonErr(res, 400, 'Username and password required');
          return;
        }
        // S-16: before userKey is built, because that string becomes a key in
        // loginAttempts and loginAttemptsPerUser and stays there for the whole
        // lockout window. No account can have a name that fails this, so the
        // answer is the same one a wrong password gets.
        if (typeof username !== 'string' || !USERNAME_RE.test(username)) {
          // N