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 [
- '',
- ' ',
- '
',
- 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 [
- '',
- ' ',
- '
',
- 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 () {