diff --git a/.gitignore b/.gitignore index 8dcbb27..c721982 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ server/data/unsupported-plugins.json server/data/*.sqlite server/data/*.sqlite-shm server/data/*.sqlite-wal +.worktrees/ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index add2968..352e63c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,10 +4,12 @@ import './App.css'; import { ScanProvider, useScanShellContext } from './context/ScanContext.jsx'; const loadScanPage = () => import('./components/pages/ScanPage.jsx'); +const loadDomainsPage = () => import('./components/pages/DomainsPage.jsx'); const loadAdminPage = () => import('./components/pages/AdminPage.jsx'); const loadHistoryPage = () => import('./components/pages/HistoryPage.jsx'); const ScanPage = lazy(loadScanPage); +const DomainsPage = lazy(loadDomainsPage); const AdminPage = lazy(loadAdminPage); const HistoryPage = lazy(loadHistoryPage); @@ -33,6 +35,10 @@ function AppContent() { void loadHistoryPage(); return; } + if (page === 'domains') { + void loadDomainsPage(); + return; + } if (page === 'admin') { void loadAdminPage(); } @@ -53,6 +59,16 @@ function AppContent() { > Current scan + + + + +
+
+ setDomainInput(event.target.value)} + placeholder="example.com" + /> + +
+ {selectedDomain ? ( +

+ {isLoading ? 'Loading trust snapshot…' : `Status: ${trustLabel}`} + {trust.envelope?.scannedAt ? ` · Last scan: ${new Date(trust.envelope.scannedAt).toLocaleString()}` : ''} + {trust.unresolvedCount ? ` · Open warnings: ${trust.unresolvedCount}` : ''} +

+ ) : ( +

Enter a domain to view trust details.

+ )} + {error ?

{error.message}

: null} +
+ + +
+
+
+

Consistency warnings

+

Resolve or ignore warnings to track reconciliation progress.

+
+
+
+ {!selectedDomain ? ( +

Load a domain to inspect warnings.

+ ) : unresolvedWarnings.length === 0 ? ( +

No open warnings for this domain.

+ ) : ( +
+
+ Rule + Severity + Reason + Actions +
+ {unresolvedWarnings.map((warning) => ( +
+ {warning.ruleCode} + {warning.severity} + {warning.reason} + + + + +
+ ))} +
+ )} +
+
+ +
+
+
+

Deep content audit

+

Queue an async sitemap crawl and review per-page content findings.

+
+
+
+
+ setMaxPagesInput(event.target.value)} + /> + +
+ {deepAuditResult ? ( +

+ Audit complete. Pages: {deepAuditResult.pages?.length ?? 0} · Indexed: + {' '} + {deepAuditResult.totals?.indexedCount ?? '—'} +

+ ) : ( +

No deep audit run in this session.

+ )} +
+
+ + ); +} + +DomainsPage.propTypes = { + headerActions: PropTypes.node, +}; + +DomainsPage.defaultProps = { + headerActions: null, +}; + +export default DomainsPage; diff --git a/frontend/src/components/pages/DomainsPage.test.jsx b/frontend/src/components/pages/DomainsPage.test.jsx new file mode 100644 index 0000000..673ee1d --- /dev/null +++ b/frontend/src/components/pages/DomainsPage.test.jsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import DomainsPage from './DomainsPage.jsx'; + +const setDomain = vi.fn(); +const startScan = vi.fn(); +const setActivePage = vi.fn(); +const updateWarningStatus = vi.fn(async () => ({})); +const startSitemapScan = vi.fn(); + +vi.mock('../../context/ScanContext.jsx', () => ({ + useScanShellContext: () => ({ + activeDomain: 'example.com', + setDomain, + startScan, + setActivePage, + }) +})); + +vi.mock('../../hooks/useDomainTrust.js', () => ({ + useDomainTrust: () => ({ + trust: { + status: 'warning', + unresolvedCount: 1, + envelope: { scannedAt: '2026-04-21T00:00:00.000Z' }, + warnings: [{ id: 7, ruleCode: 'SCAN_CATALOG_MISMATCH', severity: 'warning', reason: 'Missing catalog match', status: 'open' }] + }, + isLoading: false, + isUpdating: false, + error: null, + updateWarningStatus, + }) +})); + +vi.mock('../../hooks/useSitemapScan.js', () => ({ + useSitemapScan: () => ({ + startSitemapScan, + result: null, + isRunning: false, + }) +})); + +describe('DomainsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows trust warnings and supports remediation actions', async () => { + render(); + + expect(screen.getByText('Missing catalog match')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Resolve' })); + expect(updateWarningStatus).toHaveBeenCalledWith({ id: 7, status: 'resolved' }); + }); + + it('triggers deep audit and re-scan actions', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Run deep audit' })); + expect(startSitemapScan).toHaveBeenCalledWith({ + domain: 'example.com', + sitemapUrl: 'https://example.com/sitemap.xml', + maxPages: 25, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Re-scan now' })); + expect(startScan).toHaveBeenCalledWith('example.com'); + expect(setActivePage).toHaveBeenCalledWith('scan'); + }); +}); diff --git a/frontend/src/hooks/useDomainTrust.js b/frontend/src/hooks/useDomainTrust.js new file mode 100644 index 0000000..11f1b99 --- /dev/null +++ b/frontend/src/hooks/useDomainTrust.js @@ -0,0 +1,29 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { fetchDomainTrust, setWarningStatus } from '../api/trust.js'; +import { mapTrustSnapshot } from '../services/trust.js'; + +export function useDomainTrust(domain) { + const queryClient = useQueryClient(); + + const trustQuery = useQuery({ + queryKey: ['trust', domain], + queryFn: () => fetchDomainTrust(domain), + enabled: Boolean(domain), + }); + + const statusMutation = useMutation({ + mutationFn: ({ id, status }) => setWarningStatus(id, status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trust', domain] }); + }, + }); + + return { + trust: mapTrustSnapshot(trustQuery.data), + rawTrust: trustQuery.data, + isLoading: trustQuery.isLoading, + isUpdating: statusMutation.isPending, + error: trustQuery.error, + updateWarningStatus: statusMutation.mutateAsync, + }; +} diff --git a/frontend/src/hooks/useScan.js b/frontend/src/hooks/useScan.js index ab00218..8b6a55a 100644 --- a/frontend/src/hooks/useScan.js +++ b/frontend/src/hooks/useScan.js @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { scanDomain } from '../services/scan.js'; import { upsertUnsupportedPlugin } from '../api/client.js'; +import { createTrustEnvelope, evaluateTrustEnvelope } from '../api/trust.js'; import { logEvent, rotateActivityLog } from '../services/logger.js'; export function useScan() { @@ -94,6 +95,44 @@ export function useScan() { unsupportedPersistence: persistenceReport.slice(0, 50), snapshotBytes: JSON.stringify(data).length }); + + try { + const envelopeResponse = await createTrustEnvelope({ + domain: data.domain, + scanRunId: `scan_${Date.now()}`, + scannedAt: data.fetchedAt, + schemaVersion: 1, + coreFindings: { + namespaces: data.namespaces, + matchedPluginIds: data.plugins.matched.map(({ plugin }) => plugin?.id).filter(Boolean) + }, + trustInputs: { + metrics: data.metrics, + unsupportedNamespaces: data.plugins.unsupportedNamespaces + } + }); + + const envelopeId = envelopeResponse?.envelope?.envelopeId; + if (envelopeId) { + const knownCatalogNamespaces = data.plugins.matched.flatMap(({ namespaces }) => namespaces ?? []); + await evaluateTrustEnvelope({ + envelopeId, + domain: data.domain, + findings: { + namespaces: data.namespaces + }, + catalog: { + namespaces: knownCatalogNamespaces + } + }); + queryClient.invalidateQueries({ queryKey: ['trust', data.domain] }); + } + } catch (trustError) { + logEvent('scan.trust.sync_error', { + domain: data.domain, + message: trustError?.message ?? 'Failed to sync trust envelope' + }); + } }, onError: (error) => { const friendlyMessage = diff --git a/frontend/src/hooks/useSitemapScan.js b/frontend/src/hooks/useSitemapScan.js index 7999615..0c022b0 100644 --- a/frontend/src/hooks/useSitemapScan.js +++ b/frontend/src/hooks/useSitemapScan.js @@ -1,22 +1,37 @@ import { useMutation } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; import toast from 'react-hot-toast'; -import { runSitemapScan } from '../api/client.js'; +import { createDeepAuditJob, fetchDeepAuditJob } from '../api/trust.js'; import { logEvent } from '../services/logger.js'; +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'capped']); +const POLL_INTERVAL_MS = 1000; + export function useSitemapScan() { + const [result, setResult] = useState(null); + const activePollRef = useRef(null); + const sitemapMutation = useMutation({ - mutationFn: runSitemapScan, - onSuccess: (data) => { - toast.success(`Sitemap scan complete (${data?.pages?.length ?? 0} pages)`); + mutationFn: createDeepAuditJob, + onSuccess: async ({ job }) => { + if (!job?.jobId) { + throw new Error('Deep audit job id missing'); + } + + const data = await pollJobUntilComplete(job.jobId); + const pageCount = data?.result?.pages?.length ?? 0; + setResult(data?.result ?? null); + toast.success(`Sitemap scan complete (${pageCount} pages)`); logEvent('sitemap.scan.complete', { domain: data?.domain, - totals: data?.totals, - sitemapCount: data?.sitemap?.sitemaps?.length ?? 0, - pages: (data?.pages ?? []).map((page) => ({ + totals: data?.result?.totals, + sitemapCount: data?.result?.sitemap?.sitemaps?.length ?? 0, + pages: (data?.result?.pages ?? []).map((page) => ({ url: page.url, statusCode: page.statusCode, flags: page.flags - })) + })), + jobId: job.jobId, }); }, onError: (error) => { @@ -31,12 +46,36 @@ export function useSitemapScan() { toast.error('Run a domain scan first.'); return; } + setResult(null); sitemapMutation.mutate({ domain, sitemapUrl, maxPages }); }; + async function pollJobUntilComplete(jobId) { + if (activePollRef.current) { + clearTimeout(activePollRef.current); + activePollRef.current = null; + } + + while (true) { + const data = await fetchDeepAuditJob(jobId); + const status = data?.job?.status; + + if (TERMINAL_STATUSES.has(status)) { + if (status === 'failed') { + throw new Error(data?.job?.errorMessage ?? 'Sitemap scan failed'); + } + return data.job; + } + + await new Promise((resolve) => { + activePollRef.current = setTimeout(resolve, POLL_INTERVAL_MS); + }); + } + } + return { startSitemapScan, - result: sitemapMutation.data, + result, isRunning: sitemapMutation.isPending, error: sitemapMutation.error }; diff --git a/frontend/src/services/trust.js b/frontend/src/services/trust.js new file mode 100644 index 0000000..d283589 --- /dev/null +++ b/frontend/src/services/trust.js @@ -0,0 +1,23 @@ +const BLOCKING_SEVERITY = 'blocking'; + +export function mapTrustSnapshot(snapshot = {}) { + const envelope = snapshot.envelope ?? null; + const warnings = Array.isArray(snapshot.warnings) ? snapshot.warnings : []; + const unresolved = warnings.filter((warning) => warning.status === 'open'); + const hasBlocking = unresolved.some((warning) => warning.severity === BLOCKING_SEVERITY); + const hasEnvelope = Boolean(envelope?.envelopeId); + + return { + status: !hasEnvelope + ? 'unknown' + : hasBlocking + ? 'blocked' + : unresolved.length > 0 + ? 'warning' + : 'pass', + unresolvedCount: unresolved.length, + warnings, + envelope, + domain: snapshot.domain ?? null, + }; +} diff --git a/server/src/db/client.js b/server/src/db/client.js index 26c5857..3f05e10 100644 --- a/server/src/db/client.js +++ b/server/src/db/client.js @@ -109,6 +109,59 @@ const MIGRATIONS = [ `create index if not exists idx_plugin_registry_label on plugin_registry(label);`, `create index if not exists idx_theme_registry_label on theme_registry(label);` ] + }, + { + version: 5, + statements: [ + ` + create table if not exists trust_envelopes ( + envelope_id text primary key, + domain text not null, + scan_run_id text not null, + scanned_at text not null, + schema_version integer not null, + core_findings_json text not null, + trust_inputs_json text not null, + created_at text not null + ); + `, + `create index if not exists idx_trust_envelopes_domain_scanned on trust_envelopes(domain, scanned_at desc);`, + ` + create table if not exists trust_warnings ( + id integer primary key autoincrement, + envelope_id text not null references trust_envelopes(envelope_id) on delete cascade, + rule_code text not null, + severity text not null, + status text not null, + entity_ref_json text not null, + reason text not null, + remediation_hint text not null, + emitted_at text not null, + resolved_at text + ); + `, + `create index if not exists idx_trust_warnings_open on trust_warnings(status, severity, emitted_at desc);` + ] + }, + { + version: 6, + statements: [ + ` + create table if not exists deep_audit_jobs ( + job_id text primary key, + domain text not null, + sitemap_url text not null, + status text not null, + max_pages integer not null, + started_at text, + completed_at text, + error_message text, + result_json text, + created_at text not null + ); + `, + `create index if not exists idx_deep_audit_jobs_domain_created on deep_audit_jobs(domain, created_at desc);` + ] } ]; diff --git a/server/src/index.js b/server/src/index.js index 25320f1..e26d507 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -23,6 +23,18 @@ import { apiRateLimiter } from './middleware/rateLimiter.js'; import { requireAdminApiKey } from './middleware/adminAuth.js'; import { wrapAsync } from './utils/route.js'; import { execute, getDb, queryAll, queryOne } from './db/client.js'; +import { + mapEnvelopeRow, + mapWarningRow, + normalizeEnvelope, + normalizeTrustWarningStatus, +} from './trust/contracts.js'; +import { evaluateConsistency } from './trust/consistency.js'; +import { + createDeepAuditJob, + getDeepAuditJob, + updateDeepAuditJobState, +} from './jobs/deepAuditQueue.js'; import { assertPluginRegistryReady, loadPlugins, @@ -71,6 +83,7 @@ const ALLOWED_CLIENT_LOG_TYPES = new Set([ 'homepage.scan.error', 'sitemap.scan.complete', 'sitemap.scan.error', + 'scan.trust.sync_error', 'logs.rotation_triggered', 'logs.rotation_failed' ]); @@ -535,6 +548,48 @@ app.get('/api/scan-history/:domain', wrapAsync(async (req, res) => { }); })); +app.post('/api/deep-audit/jobs', wrapAsync(async (req, res) => { + const { domain, sitemapUrl, maxPages = MAX_SITEMAP_PAGES } = req.body ?? {}; + const sanitizedDomain = sanitizeDomain(domain); + if (!sanitizedDomain) { + throw new ValidationError('domain is required'); + } + + const parsedPageLimit = Number.parseInt(maxPages, 10); + const pageLimit = Number.isFinite(parsedPageLimit) && parsedPageLimit > 0 + ? Math.min(parsedPageLimit, MAX_SITEMAP_PAGES) + : MAX_SITEMAP_PAGES; + + const resolvedSitemapUrl = resolveSitemapUrl({ + sitemapUrl, + domain: sanitizedDomain, + }); + + const job = await createDeepAuditJob({ + domain: sanitizedDomain, + sitemapUrl: resolvedSitemapUrl, + maxPages: pageLimit, + }); + + triggerDeepAuditWorker(job.jobId).catch((error) => { + logSilently('deep-audit.worker_error', { + jobId: job.jobId, + domain: job.domain, + message: error.message, + }); + }); + + res.status(202).json({ job }); +})); + +app.get('/api/deep-audit/jobs/:jobId', wrapAsync(async (req, res) => { + const job = await getDeepAuditJob(req.params.jobId); + if (!job) { + throw new AppError('Deep audit job not found', 404); + } + res.json({ job }); +})); + app.post('/api/sitemap-scan', wrapAsync(async (req, res) => { const { domain, sitemapUrl, maxPages = MAX_SITEMAP_PAGES } = req.body ?? {}; @@ -583,6 +638,67 @@ app.post('/api/sitemap-scan', wrapAsync(async (req, res) => { }); })); +async function triggerDeepAuditWorker(jobId) { + setTimeout(async () => { + await runDeepAuditWorker(jobId); + }, 0); +} + +async function runDeepAuditWorker(jobId) { + const existing = await getDeepAuditJob(jobId); + if (!existing) { + return; + } + + await updateDeepAuditJobState(jobId, { + status: 'running', + startedAt: new Date().toISOString(), + }); + + try { + const startedAt = Date.now(); + const { sitemapSummaries, seenPages } = await fetchAndParseSitemap(existing.sitemapUrl, existing.maxPages); + const pages = await fetchAndProcessPageDetails( + Array.from(seenPages).slice(0, existing.maxPages), + existing.domain, + ); + + const completedAt = Date.now(); + const invalidSchemaCount = pages.filter((page) => page.flags.includes('schema_invalid')).length; + const noindexCount = pages.filter((page) => page.flags.includes('noindex')).length; + + const result = { + domain: existing.domain, + startedAt: new Date(startedAt).toISOString(), + completedAt: new Date(completedAt).toISOString(), + durationMs: completedAt - startedAt, + sitemap: { + root: existing.sitemapUrl, + sitemaps: sitemapSummaries, + }, + pages, + totals: { + pagesScanned: pages.length, + invalidSchema: invalidSchemaCount, + noindex: noindexCount, + }, + }; + + await updateDeepAuditJobState(jobId, { + status: pages.length >= existing.maxPages ? 'capped' : 'completed', + completedAt: new Date().toISOString(), + result, + errorMessage: null, + }); + } catch (error) { + await updateDeepAuditJobState(jobId, { + status: 'failed', + completedAt: new Date().toISOString(), + errorMessage: error.message, + }); + } +} + app.post('/api/homepage-scan', wrapAsync(async (req, res) => { const { domain } = req.body ?? {}; @@ -704,6 +820,329 @@ app.post('/api/logs/rotate', wrapAsync(async (_req, res) => { } })); +app.post('/api/admin/trust/envelopes', wrapAsync(async (req, res) => { + if (process.env.ADMIN_ENABLED === 'false') { + throw new AppError('Admin endpoints are disabled', 403); + } + + const envelope = normalizeEnvelope(req.body ?? {}); + await execute( + ` + insert into trust_envelopes ( + envelope_id, + domain, + scan_run_id, + scanned_at, + schema_version, + core_findings_json, + trust_inputs_json, + created_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + envelope.envelopeId, + envelope.domain, + envelope.scanRunId, + envelope.scannedAt, + envelope.schemaVersion, + JSON.stringify(envelope.coreFindings), + JSON.stringify(envelope.trustInputs), + envelope.createdAt, + ], + ); + + res.status(201).json({ envelope }); +})); + +app.get('/api/admin/trust/envelopes', wrapAsync(async (req, res) => { + if (process.env.ADMIN_ENABLED === 'false') { + throw new AppError('Admin endpoints are disabled', 403); + } + + const domain = typeof req.query.domain === 'string' + ? sanitizeDomain(req.query.domain) + : null; + + const limitRaw = Number.parseInt(req.query.limit ?? '100', 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(limitRaw, 500) + : 100; + + const rows = domain + ? await queryAll( + ` + select + envelope_id, + domain, + scan_run_id, + scanned_at, + schema_version, + core_findings_json, + trust_inputs_json, + created_at + from trust_envelopes + where domain = ? + order by scanned_at desc + limit ? + `, + [domain, limit], + ) + : await queryAll( + ` + select + envelope_id, + domain, + scan_run_id, + scanned_at, + schema_version, + core_findings_json, + trust_inputs_json, + created_at + from trust_envelopes + order by scanned_at desc + limit ? + `, + [limit], + ); + + res.json({ envelopes: rows.map(mapEnvelopeRow) }); +})); + +app.get('/api/admin/trust/domains/:domain', wrapAsync(async (req, res) => { + if (process.env.ADMIN_ENABLED === 'false') { + throw new AppError('Admin endpoints are disabled', 403); + } + + const domain = sanitizeDomain(req.params.domain); + if (!domain) { + throw new ValidationError('domain must be a valid domain'); + } + + const envelopeRow = await queryOne( + ` + select + envelope_id, + domain, + scan_run_id, + scanned_at, + schema_version, + core_findings_json, + trust_inputs_json, + created_at + from trust_envelopes + where domain = ? + order by scanned_at desc + limit 1 + `, + [domain], + ); + + const warningsRows = envelopeRow + ? await queryAll( + ` + select + id, + envelope_id, + rule_code, + severity, + status, + entity_ref_json, + reason, + remediation_hint, + emitted_at, + resolved_at + from trust_warnings + where envelope_id = ? + order by emitted_at desc, id desc + `, + [envelopeRow.envelope_id], + ) + : []; + + res.json({ + domain, + envelope: mapEnvelopeRow(envelopeRow), + warnings: warningsRows.map(mapWarningRow), + }); +})); + +app.post('/api/admin/trust/evaluate', wrapAsync(async (req, res) => { + if (process.env.ADMIN_ENABLED === 'false') { + throw new AppError('Admin endpoints are disabled', 403); + } + + const envelopeId = typeof req.body?.envelopeId === 'string' + ? req.body.envelopeId.trim() + : ''; + if (!envelopeId) { + throw new ValidationError('envelopeId is required'); + } + + const envelope = await queryOne( + 'select envelope_id, domain from trust_envelopes where envelope_id = ? limit 1', + [envelopeId], + ); + if (!envelope) { + throw new AppError('Trust envelope not found', 404); + } + + const warnings = evaluateConsistency({ + envelopeId, + domain: sanitizeDomain(req.body?.domain) ?? envelope.domain, + findings: req.body?.findings, + catalog: req.body?.catalog, + history: req.body?.history, + }); + + for (const warning of warnings) { + await execute( + ` + insert into trust_warnings ( + envelope_id, + rule_code, + severity, + status, + entity_ref_json, + reason, + remediation_hint, + emitted_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + warning.envelopeId, + warning.ruleCode, + warning.severity, + warning.status, + JSON.stringify(warning.entityRef), + warning.reason, + warning.remediationHint, + warning.emittedAt, + ], + ); + } + + const persisted = await queryAll( + ` + select + id, + envelope_id, + rule_code, + severity, + status, + entity_ref_json, + reason, + remediation_hint, + emitted_at, + resolved_at + from trust_warnings + where envelope_id = ? + order by emitted_at desc, id desc + `, + [envelopeId], + ); + + res.json({ warnings: persisted.map(mapWarningRow) }); +})); + +app.get('/api/admin/trust/warnings', wrapAsync(async (req, res) => { + if (process.env.ADMIN_ENABLED === 'false') { + throw new AppError('Admin endpoints are disabled', 403); + } + + const statusFilter = typeof req.query.status === 'string' + ? req.query.status.trim() + : ''; + + const rows = statusFilter + ? await queryAll( + ` + select + id, + envelope_id, + rule_code, + severity, + status, + entity_ref_json, + reason, + remediation_hint, + emitted_at, + resolved_at + from trust_warnings + where status = ? + order by emitted_at desc, id desc + `, + [statusFilter], + ) + : await queryAll( + ` + select + id, + envelope_id, + rule_code, + severity, + status, + entity_ref_json, + reason, + remediation_hint, + emitted_at, + resolved_at + from trust_warnings + order by emitted_at desc, id desc + `, + ); + + res.json({ warnings: rows.map(mapWarningRow) }); +})); + +app.put('/api/admin/trust/warnings/:id', wrapAsync(async (req, res) => { + if (process.env.ADMIN_ENABLED === 'false') { + throw new AppError('Admin endpoints are disabled', 403); + } + + const warningId = Number.parseInt(req.params.id, 10); + if (!Number.isFinite(warningId) || warningId <= 0) { + throw new ValidationError('warning id must be a positive integer'); + } + + const status = normalizeTrustWarningStatus(req.body?.status); + await execute( + ` + update trust_warnings + set status = ?, resolved_at = ? + where id = ? + `, + [status, status === 'open' ? null : new Date().toISOString(), warningId], + ); + + const warning = await queryOne( + ` + select + id, + envelope_id, + rule_code, + severity, + status, + entity_ref_json, + reason, + remediation_hint, + emitted_at, + resolved_at + from trust_warnings + where id = ? + limit 1 + `, + [warningId], + ); + + if (!warning) { + throw new AppError('Trust warning not found', 404); + } + + res.json({ warning: mapWarningRow(warning) }); +})); + app.get('/api/admin/db-snapshot', wrapAsync(async (req, res) => { const limitRaw = Number.parseInt(req.query.limit ?? '50', 10); const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 200) : 50; diff --git a/server/src/index.test.js b/server/src/index.test.js index ca39bef..cee3e75 100644 --- a/server/src/index.test.js +++ b/server/src/index.test.js @@ -293,6 +293,117 @@ describe('API routes', () => { expect(res.body.themes.some((theme) => theme.id === 'astra')).toBe(true); }); + it('persists and returns trust envelope records', async () => { + process.env.ADMIN_ENABLED = 'true'; + + const createRes = await request(app) + .post('/api/admin/trust/envelopes') + .set(adminHeaders) + .send({ + domain: 'example.com', + scanRunId: 'run_123', + scannedAt: '2026-04-21T12:00:00.000Z', + schemaVersion: 1, + coreFindings: { namespaces: ['wp/v2'] }, + trustInputs: { projectionLagMs: 0 } + }); + + expect(createRes.statusCode).toBe(201); + expect(createRes.body.envelope.domain).toBe('example.com'); + expect(createRes.body.envelope.envelopeId).toBeTruthy(); + + const listRes = await request(app) + .get('/api/admin/trust/envelopes?domain=example.com') + .set(adminHeaders); + + expect(listRes.statusCode).toBe(200); + expect(Array.isArray(listRes.body.envelopes)).toBe(true); + expect(listRes.body.envelopes.length).toBeGreaterThan(0); + expect(listRes.body.envelopes[0].domain).toBe('example.com'); + }); + + it('emits SCAN_CATALOG_MISMATCH warning when namespace lacks catalog support', async () => { + process.env.ADMIN_ENABLED = 'true'; + + const createEnvelopeRes = await request(app) + .post('/api/admin/trust/envelopes') + .set(adminHeaders) + .send({ + domain: 'mismatch-example.com', + scanRunId: 'run_mismatch', + scannedAt: '2026-04-21T13:00:00.000Z', + }); + + expect(createEnvelopeRes.statusCode).toBe(201); + const { envelopeId } = createEnvelopeRes.body.envelope; + + const evaluateRes = await request(app) + .post('/api/admin/trust/evaluate') + .set(adminHeaders) + .send({ + envelopeId, + domain: 'mismatch-example.com', + findings: { namespaces: ['unknown-plugin/v1'] }, + catalog: { namespaces: ['wp/v2'] } + }); + + expect(evaluateRes.statusCode).toBe(200); + expect(Array.isArray(evaluateRes.body.warnings)).toBe(true); + expect(evaluateRes.body.warnings.some((warning) => warning.ruleCode === 'SCAN_CATALOG_MISMATCH')).toBe(true); + }); + + it('updates trust warning status transitions', async () => { + process.env.ADMIN_ENABLED = 'true'; + + const createEnvelopeRes = await request(app) + .post('/api/admin/trust/envelopes') + .set(adminHeaders) + .send({ + domain: 'warning-status-example.com', + scanRunId: 'run_warning_status', + }); + + const evaluateRes = await request(app) + .post('/api/admin/trust/evaluate') + .set(adminHeaders) + .send({ + envelopeId: createEnvelopeRes.body.envelope.envelopeId, + domain: 'warning-status-example.com', + findings: { namespaces: ['unknown-status/v1'] }, + catalog: { namespaces: [] } + }); + + const warningId = evaluateRes.body.warnings?.[0]?.id; + const updateRes = await request(app) + .put(`/api/admin/trust/warnings/${warningId}`) + .set(adminHeaders) + .send({ status: 'resolved' }); + + expect(updateRes.statusCode).toBe(200); + expect(updateRes.body.warning.status).toBe('resolved'); + expect(updateRes.body.warning.resolvedAt).toBeTruthy(); + }); + + it('creates deep-audit job and returns queued status', async () => { + const createRes = await request(app) + .post('/api/deep-audit/jobs') + .send({ + domain: 'example.com', + sitemapUrl: 'https://example.com/sitemap.xml', + maxPages: 25, + }); + + expect(createRes.statusCode).toBe(202); + expect(createRes.body.job.status).toBe('queued'); + expect(createRes.body.job.jobId).toBeTruthy(); + + const getRes = await request(app) + .get(`/api/deep-audit/jobs/${createRes.body.job.jobId}`); + + expect(getRes.statusCode).toBe(200); + expect(getRes.body.job.jobId).toBe(createRes.body.job.jobId); + }); + it('reconciles unsupported namespaces after plugin creation', async () => { await request(app) .post('/api/unsupported-plugins') diff --git a/server/src/jobs/deepAuditQueue.js b/server/src/jobs/deepAuditQueue.js new file mode 100644 index 0000000..099a875 --- /dev/null +++ b/server/src/jobs/deepAuditQueue.js @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto'; +import { execute, queryOne } from '../db/client.js'; + +export async function createDeepAuditJob({ domain, sitemapUrl, maxPages }) { + const job = { + jobId: randomUUID(), + domain, + sitemapUrl, + status: 'queued', + maxPages, + startedAt: null, + completedAt: null, + errorMessage: null, + result: null, + createdAt: new Date().toISOString(), + }; + + await execute( + ` + insert into deep_audit_jobs ( + job_id, + domain, + sitemap_url, + status, + max_pages, + started_at, + completed_at, + error_message, + result_json, + created_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + job.jobId, + job.domain, + job.sitemapUrl, + job.status, + job.maxPages, + null, + null, + null, + null, + job.createdAt, + ], + ); + + return job; +} + +export async function updateDeepAuditJobState(jobId, patch = {}) { + const current = await getDeepAuditJob(jobId); + if (!current) { + return null; + } + + const next = { + ...current, + ...patch, + }; + + await execute( + ` + update deep_audit_jobs + set + status = ?, + started_at = ?, + completed_at = ?, + error_message = ?, + result_json = ? + where job_id = ? + `, + [ + next.status, + next.startedAt, + next.completedAt, + next.errorMessage, + next.result ? JSON.stringify(next.result) : null, + jobId, + ], + ); + + return getDeepAuditJob(jobId); +} + +export async function getDeepAuditJob(jobId) { + const row = await queryOne( + ` + select + job_id, + domain, + sitemap_url, + status, + max_pages, + started_at, + completed_at, + error_message, + result_json, + created_at + from deep_audit_jobs + where job_id = ? + limit 1 + `, + [jobId], + ); + + if (!row) { + return null; + } + + return mapDeepAuditJob(row); +} + +function mapDeepAuditJob(row) { + return { + jobId: row.job_id, + domain: row.domain, + sitemapUrl: row.sitemap_url, + status: row.status, + maxPages: Number(row.max_pages ?? 0), + startedAt: row.started_at ?? null, + completedAt: row.completed_at ?? null, + errorMessage: row.error_message ?? null, + result: parseResult(row.result_json), + createdAt: row.created_at, + }; +} + +function parseResult(raw) { + if (!raw || typeof raw !== 'string') { + return null; + } + + try { + return JSON.parse(raw); + } catch { + return null; + } +} diff --git a/server/src/trust/consistency.js b/server/src/trust/consistency.js new file mode 100644 index 0000000..a105b45 --- /dev/null +++ b/server/src/trust/consistency.js @@ -0,0 +1,57 @@ +export function evaluateConsistency({ envelopeId, domain, findings = {}, catalog = {}, history = {} }) { + const warnings = []; + const findingNamespaces = new Set(normalizeStringArray(findings.namespaces)); + const catalogNamespaces = new Set(normalizeStringArray(catalog.namespaces)); + + for (const namespace of findingNamespaces) { + if (!catalogNamespaces.has(namespace)) { + warnings.push(createWarning({ + envelopeId, + ruleCode: 'SCAN_CATALOG_MISMATCH', + severity: 'warn', + entityRef: { domain, namespace }, + reason: `Namespace ${namespace} not represented in catalog`, + remediationHint: 'Add or map the plugin/theme in Catalog and rerun scan.', + })); + } + } + + const historyNamespaces = new Set(normalizeStringArray(history.namespaces)); + for (const namespace of findingNamespaces) { + if (historyNamespaces.size > 0 && !historyNamespaces.has(namespace)) { + warnings.push(createWarning({ + envelopeId, + ruleCode: 'SCAN_HISTORY_DRIFT', + severity: 'info', + entityRef: { domain, namespace }, + reason: `Namespace ${namespace} differs from recent history`, + remediationHint: 'Re-run scan to confirm drift or reconcile expected plugin changes.', + })); + } + } + + return warnings; +} + +function createWarning({ envelopeId, ruleCode, severity, entityRef, reason, remediationHint }) { + return { + envelopeId, + ruleCode, + severity, + status: 'open', + entityRef, + reason, + remediationHint, + emittedAt: new Date().toISOString(), + }; +} + +function normalizeStringArray(value) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter(Boolean); +} diff --git a/server/src/trust/contracts.js b/server/src/trust/contracts.js new file mode 100644 index 0000000..6094d75 --- /dev/null +++ b/server/src/trust/contracts.js @@ -0,0 +1,103 @@ +import { randomUUID } from 'node:crypto'; +import { ValidationError } from '../utils/errors.js'; +import { sanitizeDomain } from '../utils/domain.js'; + +export function normalizeEnvelope(input = {}) { + const domain = sanitizeDomain(input.domain); + if (!domain) { + throw new ValidationError('domain is required'); + } + + if (typeof input.scanRunId !== 'string' || !input.scanRunId.trim()) { + throw new ValidationError('scanRunId is required'); + } + + const scannedAt = normalizeIsoDate(input.scannedAt, 'scannedAt'); + + const schemaVersion = Number.isFinite(input.schemaVersion) + ? Math.trunc(input.schemaVersion) + : 1; + + return { + envelopeId: typeof input.envelopeId === 'string' && input.envelopeId.trim() + ? input.envelopeId.trim() + : randomUUID(), + domain, + scanRunId: input.scanRunId.trim(), + scannedAt, + schemaVersion, + coreFindings: normalizeObject(input.coreFindings), + trustInputs: normalizeObject(input.trustInputs), + createdAt: new Date().toISOString(), + }; +} + +export function normalizeTrustWarningStatus(value) { + const status = typeof value === 'string' ? value.trim() : ''; + if (!['open', 'resolved', 'ignored'].includes(status)) { + throw new ValidationError('status must be open, resolved, or ignored'); + } + return status; +} + +export function mapEnvelopeRow(row) { + if (!row) { + return null; + } + + return { + envelopeId: row.envelope_id, + domain: row.domain, + scanRunId: row.scan_run_id, + scannedAt: row.scanned_at, + schemaVersion: Number(row.schema_version ?? 1), + coreFindings: safeParseJson(row.core_findings_json, {}), + trustInputs: safeParseJson(row.trust_inputs_json, {}), + createdAt: row.created_at, + }; +} + +export function mapWarningRow(row) { + if (!row) { + return null; + } + + return { + id: Number(row.id), + envelopeId: row.envelope_id, + ruleCode: row.rule_code, + severity: row.severity, + status: row.status, + entityRef: safeParseJson(row.entity_ref_json, {}), + reason: row.reason, + remediationHint: row.remediation_hint, + emittedAt: row.emitted_at, + resolvedAt: row.resolved_at ?? null, + }; +} + +function normalizeIsoDate(value, field) { + const date = new Date(value ?? Date.now()); + if (Number.isNaN(date.getTime())) { + throw new ValidationError(`${field} must be a valid date`); + } + return date.toISOString(); +} + +function normalizeObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value; +} + +function safeParseJson(raw, fallback) { + if (typeof raw !== 'string') { + return fallback; + } + try { + return JSON.parse(raw); + } catch { + return fallback; + } +}