diff --git a/TASKS.md b/TASKS.md index 0900790..f515676 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,18 @@ ## Current -### `feat/distributed-mcp-session-store` +### `feat/frontend-xss-hardening` Status: in_progress DoD: -- transport sessions are stored behind a shared store abstraction -- mcp-server no longer depends directly on process-local session storage implementation -- in-memory session store remains available for tests and local fallback -- production mcp-server uses a Postgres-backed transport session store -- expired transport sessions are evicted on read and no longer leak indefinitely +- API-provided values are not rendered into `innerHTML` on user-facing admin pages +- error strings from API responses are rendered through text nodes +- streaming admin pages use DOM construction instead of HTML string concatenation for dynamic cards ## Next -- `feat/runtime-rate-limiting-and-backpressure` +- `feat/distributed-mcp-session-store` ## Backlog diff --git a/apps/ui/js/async-jobs.js b/apps/ui/js/async-jobs.js index aa17190..a7adc06 100644 --- a/apps/ui/js/async-jobs.js +++ b/apps/ui/js/async-jobs.js @@ -33,8 +33,93 @@ document.addEventListener('DOMContentLoaded', function () { return new Date(value).toLocaleString(); } + function element(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + function renderEmpty(title, body) { - list.innerHTML = '
' + title + '
' + body + '
'; + list.innerHTML = ''; + var empty = element('div', 'empty-state'); + empty.appendChild(element('div', 'empty-state-title', title)); + empty.appendChild(element('div', 'empty-state-text', body)); + list.appendChild(empty); + } + + function appendMetaItem(parent, label, value) { + var item = element('div', 'resource-meta-item'); + item.appendChild(element('div', 'resource-meta-label', label)); + item.appendChild(element('div', 'resource-meta-value', value)); + parent.appendChild(item); + } + + function appendDetailBlock(parent, title, value, dataResultFor) { + var block = element('div', 'resource-detail-block'); + block.appendChild(element('div', 'resource-detail-title', title)); + var pre = element('pre', 'resource-detail-pre', value); + if (dataResultFor) pre.setAttribute('data-result-for', dataResultFor); + block.appendChild(pre); + parent.appendChild(block); + } + + function renderJobCard(item) { + var open = state.openId === item.id; + var card = element('div', 'resource-card'); + card.setAttribute('data-job-id', item.id); + + var header = element('div', 'resource-card-header'); + var headerMain = element('div'); + headerMain.appendChild(element('div', 'resource-card-title', item.id)); + headerMain.appendChild(element('div', 'resource-card-subtitle', tKey('async_jobs.operation', { operation: item.operation_id }))); + + var pillRow = element('div', 'resource-pill-row'); + pillRow.appendChild( + element('span', 'resource-status-pill ' + String(item.status || '').toLowerCase(), tKey('async_jobs.status.' + item.status)) + ); + headerMain.appendChild(pillRow); + header.appendChild(headerMain); + + var actions = element('div', 'resource-card-actions'); + if (item.status === 'created' || item.status === 'running') { + var cancelButton = element('button', 'btn-secondary', tKey('async_jobs.cancel')); + cancelButton.setAttribute('data-action', 'cancel'); + cancelButton.setAttribute('data-job-id', item.id); + actions.appendChild(cancelButton); + } + var toggleButton = element( + 'button', + 'btn-secondary', + open ? tKey('async_jobs.hide_details') : tKey('async_jobs.show_details') + ); + toggleButton.setAttribute('data-action', 'toggle'); + toggleButton.setAttribute('data-job-id', item.id); + actions.appendChild(toggleButton); + header.appendChild(actions); + card.appendChild(header); + + var metaGrid = element('div', 'resource-meta-grid'); + appendMetaItem(metaGrid, tKey('async_jobs.meta.created'), formatDateTime(item.created_at)); + appendMetaItem(metaGrid, tKey('async_jobs.meta.updated'), formatDateTime(item.updated_at)); + appendMetaItem(metaGrid, tKey('async_jobs.meta.finished'), formatDateTime(item.finished_at)); + appendMetaItem(metaGrid, tKey('async_jobs.meta.expires'), formatDateTime(item.expires_at)); + card.appendChild(metaGrid); + + if (open) { + appendDetailBlock(card, tKey('async_jobs.progress'), formatJson(item.progress)); + appendDetailBlock(card, tKey('async_jobs.error_preview'), formatJson(item.error)); + if ( + item.status === 'completed' || + item.status === 'failed' || + item.status === 'cancelled' || + item.status === 'expired' + ) { + appendDetailBlock(card, tKey('async_jobs.result'), tKey('async_jobs.result_loading'), item.id); + } + } + + return card; } function render() { @@ -55,44 +140,10 @@ document.addEventListener('DOMContentLoaded', function () { return; } - list.innerHTML = state.items.map(function(item) { - var open = state.openId === item.id; - var statusLabel = tKey('async_jobs.status.' + item.status); - return [ - '
', - '
', - '
', - '
' + item.id + '
', - '
' + tKey('async_jobs.operation', { operation: item.operation_id }) + '
', - '
', - ' ' + statusLabel + '', - '
', - '
', - '
', - item.status === 'created' || item.status === 'running' - ? ' ' - : '', - ' ', - '
', - '
', - '
', - '
' + tKey('async_jobs.meta.created') + '
' + formatDateTime(item.created_at) + '
', - '
' + tKey('async_jobs.meta.updated') + '
' + formatDateTime(item.updated_at) + '
', - '
' + tKey('async_jobs.meta.finished') + '
' + formatDateTime(item.finished_at) + '
', - '
' + tKey('async_jobs.meta.expires') + '
' + formatDateTime(item.expires_at) + '
', - '
', - open - ? '
' + tKey('async_jobs.progress') + '
' + formatJson(item.progress) + '
' - : '', - open - ? '
' + tKey('async_jobs.error_preview') + '
' + formatJson(item.error) + '
' - : '', - open && (item.status === 'completed' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'expired') - ? '
' + tKey('async_jobs.result') + '
' + tKey('async_jobs.result_loading') + '
' - : '', - '
' - ].join(''); - }).join(''); + list.innerHTML = ''; + state.items.forEach(function(item) { + list.appendChild(renderJobCard(item)); + }); list.querySelectorAll('[data-action="toggle"]').forEach(function(button) { button.addEventListener('click', function () { diff --git a/apps/ui/js/stream-sessions.js b/apps/ui/js/stream-sessions.js index 57e7b0d..aeae084 100644 --- a/apps/ui/js/stream-sessions.js +++ b/apps/ui/js/stream-sessions.js @@ -34,8 +34,83 @@ document.addEventListener('DOMContentLoaded', function () { return new Date(value).toLocaleString(); } + function element(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + function renderEmpty(title, body) { - list.innerHTML = '
' + title + '
' + body + '
'; + list.innerHTML = ''; + var empty = element('div', 'empty-state'); + empty.appendChild(element('div', 'empty-state-title', title)); + empty.appendChild(element('div', 'empty-state-text', body)); + list.appendChild(empty); + } + + function appendMetaItem(parent, label, value) { + var item = element('div', 'resource-meta-item'); + item.appendChild(element('div', 'resource-meta-label', label)); + item.appendChild(element('div', 'resource-meta-value', value)); + parent.appendChild(item); + } + + function appendDetailBlock(parent, title, value) { + var block = element('div', 'resource-detail-block'); + block.appendChild(element('div', 'resource-detail-title', title)); + block.appendChild(element('pre', 'resource-detail-pre', formatJson(value))); + parent.appendChild(block); + } + + function renderSessionCard(item) { + var open = state.openId === item.id; + var card = element('div', 'resource-card'); + card.setAttribute('data-session-id', item.id); + + var header = element('div', 'resource-card-header'); + var headerMain = element('div'); + headerMain.appendChild(element('div', 'resource-card-title', item.id)); + headerMain.appendChild(element('div', 'resource-card-subtitle', tKey('stream_sessions.operation', { operation: item.operation_id }))); + + var pillRow = element('div', 'resource-pill-row'); + var statusPill = element('span', 'resource-status-pill ' + String(item.status || '').toLowerCase(), tKey('stream_sessions.status.' + item.status)); + pillRow.appendChild(statusPill); + pillRow.appendChild(element('span', 'resource-status-pill', item.mode)); + pillRow.appendChild(element('span', 'resource-status-pill', item.protocol)); + headerMain.appendChild(pillRow); + header.appendChild(headerMain); + + var actions = element('div', 'resource-card-actions'); + if (item.status === 'created' || item.status === 'running') { + var stopButton = element('button', 'btn-secondary', tKey('stream_sessions.stop')); + stopButton.setAttribute('data-action', 'stop'); + stopButton.setAttribute('data-session-id', item.id); + actions.appendChild(stopButton); + } + var toggleButton = element( + 'button', + 'btn-secondary', + open ? tKey('stream_sessions.hide_details') : tKey('stream_sessions.show_details') + ); + toggleButton.setAttribute('data-action', 'toggle'); + toggleButton.setAttribute('data-session-id', item.id); + actions.appendChild(toggleButton); + header.appendChild(actions); + card.appendChild(header); + + var metaGrid = element('div', 'resource-meta-grid'); + appendMetaItem(metaGrid, tKey('stream_sessions.meta.created'), formatDateTime(item.created_at)); + appendMetaItem(metaGrid, tKey('stream_sessions.meta.last_poll'), formatDateTime(item.last_poll_at)); + appendMetaItem(metaGrid, tKey('stream_sessions.meta.expires'), formatDateTime(item.expires_at)); + appendMetaItem(metaGrid, tKey('stream_sessions.meta.agent'), item.agent_id || '—'); + card.appendChild(metaGrid); + + if (open) { + appendDetailBlock(card, tKey('stream_sessions.cursor'), item.cursor); + } + + return card; } function render() { @@ -56,40 +131,10 @@ document.addEventListener('DOMContentLoaded', function () { return; } - list.innerHTML = state.items.map(function(item) { - var open = state.openId === item.id; - var statusLabel = tKey('stream_sessions.status.' + item.status); - return [ - '
', - '
', - '
', - '
' + item.id + '
', - '
' + tKey('stream_sessions.operation', { operation: item.operation_id }) + '
', - '
', - ' ' + statusLabel + '', - ' ' + item.mode + '', - ' ' + item.protocol + '', - '
', - '
', - '
', - item.status === 'created' || item.status === 'running' - ? ' ' - : '', - ' ', - '
', - '
', - '
', - '
' + tKey('stream_sessions.meta.created') + '
' + formatDateTime(item.created_at) + '
', - '
' + tKey('stream_sessions.meta.last_poll') + '
' + formatDateTime(item.last_poll_at) + '
', - '
' + tKey('stream_sessions.meta.expires') + '
' + formatDateTime(item.expires_at) + '
', - '
' + tKey('stream_sessions.meta.agent') + '
' + (item.agent_id || '—') + '
', - '
', - open - ? '
' + tKey('stream_sessions.cursor') + '
' + formatJson(item.cursor) + '
' - : '', - '
' - ].join(''); - }).join(''); + list.innerHTML = ''; + state.items.forEach(function(item) { + list.appendChild(renderSessionCard(item)); + }); list.querySelectorAll('[data-action="toggle"]').forEach(function(button) { button.addEventListener('click', function () {