diff --git a/backend/__tests__/integration/adminBackupIntegrity.test.js b/backend/__tests__/integration/adminBackupIntegrity.test.js new file mode 100644 index 00000000..9a046cc8 --- /dev/null +++ b/backend/__tests__/integration/adminBackupIntegrity.test.js @@ -0,0 +1,140 @@ +/** + * Integration test for GET /api/admin/system-health/backup-integrity. + * + * Auth + permission middleware are mocked to pass-through so the test + * focuses on the route's own behaviour: scope-param validation, the + * successResponse envelope, and that the underlying service report + * surfaces correctly in the JSON body. + * + * The verifier service itself is exercised against the real schema + * (bootCrmDb) and real filesystem — only the auth gate is stubbed. + */ + +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const request = require('supertest'); + +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +// Pass-through auth so we don't need to mint JWTs. +jest.mock('../../src/middleware/auth', () => ({ + adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); }, + customerAuth: (_req, _res, next) => next(), + galleryAuth: (_req, _res, next) => next(), +})); + +// Pass-through permissions so settings.view always allows. +jest.mock('../../src/middleware/permissions', () => ({ + requirePermission: () => (_req, _res, next) => next(), +})); + +jest.setTimeout(30000); + +describe('GET /api/admin/system-health/backup-integrity', () => { + let cleanup; + let db; + let customerId; + let app; + let storagePath; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ customerId } = await seedMinimal(db)); + storagePath = process.env.STORAGE_PATH; + + // Mount the route on a minimal Express app. Cold-require after + // bootCrmDb so the route's downstream `require('../database/db')` + // sees the same db instance. + const route = require('../../src/routes/adminSystemHealth'); + app = express(); + app.use(express.json()); + app.use('/api/admin/system-health', route); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + beforeEach(async () => { + await db('contracts').del().catch(() => {}); + await db('invoices').del().catch(() => {}); + await db('quotes').del().catch(() => {}); + }); + + it('returns a report envelope when nothing references any path', async () => { + const res = await request(app).get('/api/admin/system-health/backup-integrity'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('report'); + expect(res.body.report.summary).toMatchObject({ + totalRows: 0, + missingFiles: 0, + hashMismatches: 0, + verifiedOk: 0, + existsButNoHash: 0, + }); + expect(res.body.report.scopes).toEqual(expect.arrayContaining([ + 'quote', 'contract', 'contract-signature', 'invoice', + ])); + }); + + it('surfaces a missing file in the response payload', async () => { + await db('contracts').insert({ + customer_account_id: customerId, + contract_number: 'C-B7-MISSING', + status: 'sent', + issue_date: '2026-01-01', + signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf', + created_at: new Date(), + }); + + const res = await request(app).get('/api/admin/system-health/backup-integrity'); + expect(res.status).toBe(200); + expect(res.body.report.summary.missingFiles).toBe(1); + expect(res.body.report.missing[0]).toMatchObject({ + table: 'contracts', + column: 'signed_pdf_path', + expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf', + }); + }); + + it('honours the ?scope=invoice filter', async () => { + // Seed both an invoice and a contract with missing files. With + // scope=invoice the contract row must not appear. + await db('invoices').insert({ + customer_account_id: customerId, + invoice_number: 'INV-B7-SCOPE', + status: 'sent', + issue_date: '2026-01-01', + due_date: '2026-01-31', + pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf', + created_at: new Date(), + }); + await db('contracts').insert({ + customer_account_id: customerId, + contract_number: 'C-B7-SCOPE', + status: 'sent', + issue_date: '2026-01-01', + signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf', + created_at: new Date(), + }); + + const res = await request(app) + .get('/api/admin/system-health/backup-integrity') + .query({ scope: 'invoice' }); + expect(res.status).toBe(200); + expect(res.body.report.scopes).toEqual(['invoice']); + expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true); + }); + + it('rejects an unknown scope with 400 + a code', async () => { + const res = await request(app) + .get('/api/admin/system-health/backup-integrity') + .query({ scope: 'gallery' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE'); + expect(res.body.validScopes).toEqual(expect.arrayContaining([ + 'quote', 'contract', 'contract-signature', 'invoice', + ])); + }); +}); diff --git a/backend/__tests__/services/backupIntegrityService.test.js b/backend/__tests__/services/backupIntegrityService.test.js new file mode 100644 index 00000000..4ab52b2e --- /dev/null +++ b/backend/__tests__/services/backupIntegrityService.test.js @@ -0,0 +1,216 @@ +/** + * Verifies the backup-integrity check covers every CRM document + * artefact column and correctly buckets each row into: + * - verifiedOk — file exists AND hash matches (when hash is stored) + * - missing — `*_path` set but file is not on disk + * - hashMismatches — file exists but bytes don't hash to `*_sha256` + * - existsButNoHash — file exists, no `*_sha256` column for this row + * + * Uses the CRM integration harness (bootCrmDb) so the schema + + * STORAGE_PATH wiring exactly mirrors production behaviour. + * + * Background: this service is the diagnostic for the + * `storage/business-docs/` gap fixed in the same PR — without it, + * a restored install would have audit-trail columns referencing + * files that no longer exist, but admins would have no way to see + * the breakage until a customer asked for their contract back. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +jest.setTimeout(30000); + +describe('backupIntegrityService.verifyDocumentArtefacts', () => { + let db; + let cleanup; + let customerId; + let storagePath; + let backupIntegrityService; + + function seedFile(relPath, content) { + const abs = path.join(storagePath, relPath); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + return { abs, relPath, sha: sha256(content) }; + } + + function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); + } + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ customerId } = await seedMinimal(db)); + storagePath = process.env.STORAGE_PATH; + backupIntegrityService = require('../../src/services/backupIntegrityService'); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + beforeEach(async () => { + // Wipe CRM rows between tests so each scenario sees a clean slate. + // Order matters: child tables before parents. + await db('invoice_line_items').del().catch(() => {}); + await db('invoice_payment_log').del().catch(() => {}); + await db('invoices').del().catch(() => {}); + await db('quote_line_items').del().catch(() => {}); + await db('quotes').del().catch(() => {}); + await db('contracts').del().catch(() => {}); + }); + + it('returns an empty report when no documents reference any path', async () => { + const report = await backupIntegrityService.verifyDocumentArtefacts(); + expect(report.summary.totalRows).toBe(0); + expect(report.summary.verifiedOk).toBe(0); + expect(report.missing).toEqual([]); + expect(report.hashMismatches).toEqual([]); + expect(report.existsButNoHash).toEqual([]); + expect(report.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(report.scopes).toEqual(expect.arrayContaining(['quote', 'contract', 'contract-signature', 'invoice'])); + }); + + it('flags a contract whose signed_pdf_path file is missing', async () => { + // Reference a file that we deliberately never create on disk. + const [{ id }] = await db('contracts').insert({ + customer_account_id: customerId, + contract_number: 'C-2026-MISSING', + status: 'sent', + issue_date: '2026-01-01', + signed_pdf_path: 'business-docs/contract/2026/C-2026-MISSING.pdf', + created_at: new Date(), + }).returning('id'); + const contractId = typeof id === 'object' ? id.id : id; + + const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] }); + const hit = report.missing.find((m) => m.rowId === contractId); + expect(hit).toMatchObject({ + table: 'contracts', + column: 'signed_pdf_path', + expectedPath: 'business-docs/contract/2026/C-2026-MISSING.pdf', + }); + expect(report.summary.missingFiles).toBe(1); + }); + + it('verifies a contract whose file exists AND hash matches', async () => { + const { relPath, sha } = seedFile( + 'business-docs/contract/2026/C-2026-OK.pdf', + 'this is the signed contract content', + ); + await db('contracts').insert({ + customer_account_id: customerId, + contract_number: 'C-2026-OK', + status: 'fully_signed', + issue_date: '2026-01-01', + signed_pdf_path: relPath, + signed_pdf_sha256: sha, + created_at: new Date(), + }); + + const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] }); + expect(report.summary.verifiedOk).toBeGreaterThanOrEqual(1); + expect(report.summary.missingFiles).toBe(0); + expect(report.summary.hashMismatches).toBe(0); + }); + + it('flags a hash mismatch when the file exists but bytes differ from signed_pdf_sha256', async () => { + const { relPath } = seedFile( + 'business-docs/contract/2026/C-2026-TAMPER.pdf', + 'tampered bytes on disk', + ); + const [{ id }] = await db('contracts').insert({ + customer_account_id: customerId, + contract_number: 'C-2026-TAMPER', + status: 'fully_signed', + issue_date: '2026-01-01', + signed_pdf_path: relPath, + // Hash for completely different content — simulates tampering or + // bit-rot between sign-time and now. + signed_pdf_sha256: sha256('the ORIGINAL bytes the customer signed'), + created_at: new Date(), + }).returning('id'); + const contractId = typeof id === 'object' ? id.id : id; + + const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] }); + const hit = report.hashMismatches.find((m) => m.rowId === contractId); + expect(hit).toBeDefined(); + expect(hit.expectedSha).not.toBe(hit.actualSha); + expect(hit.column).toBe('signed_pdf_path'); + }); + + it('buckets signature PNGs into existsButNoHash (no hash column)', async () => { + const { relPath } = seedFile( + 'business-docs/contract/signatures/99/customer-1700000000000.png', + '\x89PNG\r\n\x1a\n', // doesn't have to be a real PNG, just bytes + ); + await db('contracts').insert({ + customer_account_id: customerId, + contract_number: 'C-2026-SIG', + status: 'fully_signed', + issue_date: '2026-01-01', + signed_customer_signature_path: relPath, + created_at: new Date(), + }); + + const report = await backupIntegrityService.verifyDocumentArtefacts({ + scope: ['contract-signature'], + }); + expect(report.summary.existsButNoHash).toBeGreaterThanOrEqual(1); + expect(report.summary.verifiedOk).toBe(0); // no hash → not "verified ok" + expect(report.summary.missingFiles).toBe(0); + const hit = report.existsButNoHash.find((r) => r.column === 'signed_customer_signature_path'); + expect(hit).toBeDefined(); + }); + + it('respects the scope filter — contract scope skips quote/invoice tables', async () => { + // Seed an invoice with a missing pdf_path AND a contract with a + // missing signed_pdf_path. Scoping to contract should only flag + // the contract. + await db('invoices').insert({ + customer_account_id: customerId, + invoice_number: 'INV-2026-SCOPE', + status: 'sent', + pdf_path: 'business-docs/invoice/2026/INV-2026-SCOPE.pdf', + issue_date: '2026-01-01', + due_date: '2026-01-31', + created_at: new Date(), + }); + await db('contracts').insert({ + customer_account_id: customerId, + contract_number: 'C-2026-SCOPE', + status: 'sent', + issue_date: '2026-01-01', + signed_pdf_path: 'business-docs/contract/2026/C-2026-SCOPE.pdf', + created_at: new Date(), + }); + + const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] }); + expect(report.scopes).toEqual(['contract']); + expect(report.missing.every((m) => m.table === 'contracts')).toBe(true); + expect(report.missing.some((m) => m.table === 'invoices')).toBe(false); + }); + + it('covers invoices.imported_pdf_path (admin-uploaded historical scans)', async () => { + // Imported invoices are the most catastrophic case — there's no + // renderer that can reproduce them. Verifier must check this column + // alongside invoices.pdf_path. + await db('invoices').insert({ + customer_account_id: customerId, + invoice_number: 'IMP-2025-001', + status: 'sent', + imported_pdf_path: 'business-docs/invoice-imports/2025/legacy.pdf', + issue_date: '2025-06-01', + due_date: '2025-07-01', + created_at: new Date(), + }); + + const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['invoice'] }); + const hit = report.missing.find((m) => m.column === 'imported_pdf_path'); + expect(hit).toBeDefined(); + }); +}); diff --git a/backend/server.js b/backend/server.js index e7d57663..f7c5d7c0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -660,6 +660,7 @@ app.use('/api/admin/contracts', require('./src/routes/adminContracts')); app.use('/api/admin/calendar', require('./src/routes/adminCalendar')); app.use('/api/admin/deals', require('./src/routes/adminDeals')); app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport')); +app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth')); app.use('/api/admin/dev', require('./src/routes/adminDev')); app.use('/api/public/quotes', require('./src/routes/publicQuotes')); app.use('/api/public/contracts', require('./src/routes/publicContracts')); diff --git a/backend/src/routes/adminSystemHealth.js b/backend/src/routes/adminSystemHealth.js new file mode 100644 index 00000000..e638cad4 --- /dev/null +++ b/backend/src/routes/adminSystemHealth.js @@ -0,0 +1,64 @@ +/** + * Admin → System Health + * + * Endpoint mounted at /api/admin/system-health. The "Backup + * integrity" sub-endpoint is the on-demand verifier for CRM + * document artefacts — confirms every `*_path` column on quotes / + * contracts / invoices points at a file that actually exists on + * disk and (where a `*_sha256` column is set) the file's bytes + * still hash to the expected value. + * + * Per the design decisions locked with the maintainer: + * - On-demand only; no scheduler (D1) + * - Not auto-triggered after restore (D2) + * - Wet-upload contracts are hash-verified same as system-rendered (D3) + * + * Read-only. Returns a JSON report — never mutates DB or fs. + */ + +const express = require('express'); +const { query } = require('express-validator'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { verifyDocumentArtefacts } = require('../services/backupIntegrityService'); + +const router = express.Router(); + +router.use(adminAuth); + +const VALID_SCOPES = ['quote', 'contract', 'contract-signature', 'invoice']; + +router.get( + '/backup-integrity', + requirePermission('settings.view'), + [ + // CSV string like `?scope=contract,invoice`. Each member must be + // one of the four known scopes. Empty / omitted means full scan. + query('scope').optional({ values: 'falsy' }).isString().isLength({ max: 128 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + let scope; + if (req.query.scope) { + scope = String(req.query.scope) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + // Defense-in-depth: reject unknown scope tokens so a typo doesn't + // silently scan everything when the caller wanted just one slice. + const unknown = scope.filter((s) => !VALID_SCOPES.includes(s)); + if (unknown.length > 0) { + return res.status(400).json({ + error: `Unknown scope(s): ${unknown.join(', ')}`, + code: 'BACKUP_INTEGRITY_UNKNOWN_SCOPE', + validScopes: VALID_SCOPES, + }); + } + } + const report = await verifyDocumentArtefacts({ scope }); + return successResponse(res, { report }); + }), +); + +module.exports = router; diff --git a/backend/src/services/backupIntegrityService.js b/backend/src/services/backupIntegrityService.js new file mode 100644 index 00000000..eb273f73 --- /dev/null +++ b/backend/src/services/backupIntegrityService.js @@ -0,0 +1,228 @@ +/** + * Backup-integrity verifier — walks every CRM document-artefact path + * column and confirms (a) the file exists on disk, (b) when a SHA-256 + * is stored, the file's actual bytes hash to the stored value. + * + * **Why this is a separate service** + * + * The audit trail captured at issue / sign time (signed_customer_ip, + * signed_by_customer_at, signed_pdf_sha256, signed_*_signature_path, + * issue_date, etc.) is worth exactly nothing on its own — what makes + * it legally meaningful is being able to produce the document the + * audit trail refers to. A backup that captures the DB but skips + * `storage/business-docs/` (the bug fixed in this same PR) leaves + * every `*_path` column a broken FK and every `*_sha256` column with + * nothing to verify against. This service is the diagnostic for + * exactly that drift — runs on demand, surfaces missing files + + * hash mismatches without making any changes. + * + * **Verification modes** + * + * - existence — file at `*_path` must exist on disk + * - sha256 — file at `*_path` must exist AND its sha256 must + * equal `*_sha256` column (when that column is set) + * + * Per-table coverage (lines reference migrations/core/107_crm_consolidated.js): + * + * quotes.pdf_path line 844 (existence) + * contracts.pdf_path line 1245 (existence + sha256 via contracts.pdf_sha256) + * contracts.signed_pdf_path line 1246 (existence + sha256 via contracts.signed_pdf_sha256) + * contracts.signed_customer_signature_path line 1269 (existence — drawn signatures, no hash column) + * contracts.signed_admin_signature_path line 1273 (existence — admin counter-signature drawing) + * invoices.pdf_path line 1026 (existence) + * invoices.imported_pdf_path line 1020 (existence — admin-uploaded scans) + * + * Wet uploads (`contracts.signed_pdf_is_wet_upload = true`) DO have a + * `signed_pdf_sha256` computed at upload time (contractService.js + * upload route), so they're hash-verified the same as system-rendered + * contracts — no special case here. + * + * **What this service does NOT do** + * + * - Does not write anything (no DB mutations, no fs touches) + * - Does not fail the request when a mismatch is found — the + * report shape carries the data, the caller decides what to do + * - Does not auto-trigger after restore (D2 decision: surface a + * CTA on the restore-completed screen instead) + * - Does not run on a schedule (D1 decision: on-demand v1; revisit + * once we have runtime data on large installs) + */ + +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage'); + +/** + * Every column the verifier walks, declared once so the test suite + * and the service share a single source of truth. Order is the order + * the report lists rows in — table-major, then column-by-column. + */ +const CHECKS = [ + { table: 'quotes', pathColumn: 'pdf_path', shaColumn: null, scope: 'quote' }, + { table: 'contracts', pathColumn: 'pdf_path', shaColumn: 'pdf_sha256', scope: 'contract' }, + { table: 'contracts', pathColumn: 'signed_pdf_path', shaColumn: 'signed_pdf_sha256', scope: 'contract' }, + { table: 'contracts', pathColumn: 'signed_customer_signature_path', shaColumn: null, scope: 'contract-signature' }, + { table: 'contracts', pathColumn: 'signed_admin_signature_path', shaColumn: null, scope: 'contract-signature' }, + { table: 'invoices', pathColumn: 'pdf_path', shaColumn: null, scope: 'invoice' }, + { table: 'invoices', pathColumn: 'imported_pdf_path', shaColumn: null, scope: 'invoice' }, +]; + +/** Stream-hash a file to sha256 hex without buffering the whole thing. */ +function hashFile(absPath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(absPath); + stream.on('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +/** + * @param {object} [options] + * @param {string[]} [options.scope] Filter checks by scope tag: + * 'quote' | 'contract' | 'contract-signature' | 'invoice'. + * Defaults to all four (full scan). + * @returns {Promise<{ + * scannedAt: string, + * scopes: string[], + * summary: { + * totalRows: number, + * verifiedOk: number, + * missingFiles: number, + * hashMismatches: number, + * existsButNoHash: number, + * }, + * missing: Array<{ table, rowId, column, expectedPath }>, + * hashMismatches: Array<{ table, rowId, column, expectedPath, expectedSha, actualSha }>, + * existsButNoHash: Array<{ table, rowId, column, path }>, + * }>} + * + * `existsButNoHash` is the existence-only-verified bucket — the file + * was found but no `*_sha256` column exists for it (quote/invoice PDFs, + * signature PNGs). Surfaced separately so admins can distinguish + * "verified by hash" from "verified by existence only" — the latter + * is weaker evidence in a legal dispute. + */ +async function verifyDocumentArtefacts(options = {}) { + const scopes = Array.isArray(options.scope) && options.scope.length > 0 + ? options.scope.slice() + : Array.from(new Set(CHECKS.map((c) => c.scope))); + + const checksToRun = CHECKS.filter((c) => scopes.includes(c.scope)); + const storageRoot = STORAGE_ROOT(); + + const missing = []; + const hashMismatches = []; + const existsButNoHash = []; + let totalRows = 0; + let verifiedOk = 0; + + for (const check of checksToRun) { + // Skip the check cleanly when the column or table doesn't exist + // on this install — keeps the verifier safe to run on partial + // migrations or installs that have features disabled. + if (!(await db.schema.hasTable(check.table))) continue; + if (!(await db.schema.hasColumn(check.table, check.pathColumn))) continue; + + const select = ['id', check.pathColumn]; + const hasHashColumn = check.shaColumn + && (await db.schema.hasColumn(check.table, check.shaColumn)); + if (hasHashColumn) select.push(check.shaColumn); + + const rows = await db(check.table) + .whereNotNull(check.pathColumn) + .select(...select); + + for (const row of rows) { + totalRows += 1; + const storedPath = row[check.pathColumn]; + // Stored paths can be absolute (older rows) or relative-to- + // storage (newer rows). Normalize: resolve relative paths + // against STORAGE_PATH; absolute paths are used verbatim. + const absPath = path.isAbsolute(storedPath) + ? storedPath + : path.join(storageRoot, storedPath); + + let exists = false; + try { + exists = fs.existsSync(absPath); + } catch (_) { exists = false; } + + if (!exists) { + missing.push({ + table: check.table, + rowId: row.id, + column: check.pathColumn, + expectedPath: storedPath, + }); + continue; + } + + const expectedSha = hasHashColumn ? row[check.shaColumn] : null; + if (!expectedSha) { + // File exists but we have no hash to verify it against. + existsButNoHash.push({ + table: check.table, + rowId: row.id, + column: check.pathColumn, + path: storedPath, + }); + continue; + } + + let actualSha; + try { + actualSha = await hashFile(absPath); + } catch (err) { + logger.warn(`backupIntegrity: failed to hash ${absPath}: ${err.message}`); + missing.push({ + table: check.table, + rowId: row.id, + column: check.pathColumn, + expectedPath: storedPath, + }); + continue; + } + + if (actualSha !== expectedSha) { + hashMismatches.push({ + table: check.table, + rowId: row.id, + column: check.pathColumn, + expectedPath: storedPath, + expectedSha, + actualSha, + }); + continue; + } + + verifiedOk += 1; + } + } + + return { + scannedAt: new Date().toISOString(), + scopes, + summary: { + totalRows, + verifiedOk, + missingFiles: missing.length, + hashMismatches: hashMismatches.length, + existsButNoHash: existsButNoHash.length, + }, + missing, + hashMismatches, + existsButNoHash, + }; +} + +module.exports = { + verifyDocumentArtefacts, + // Exported for tests; not part of the route API. + _internal: { CHECKS, hashFile }, +};