Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions services/oauth/test/console-layout-a.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Presentation regressions; these do not replace authentication or isolation suites.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {renderPage,sendPage,serveAsset,pages} from '../../../web/console/render.mjs';
import {overviewView,appearanceView,icon} from '../../../web/console/visuals.mjs';
import {text,catalog} from '../../../web/console/catalog.mjs';

const view = (data={}) => overviewView(data,{t:text,memoryRows:()=>'<div class="synthetic-row"></div>'});
const response = () => ({writeHead(status,headers){this.status=status;this.headers=headers;},end(body){this.body=body;}});

test('Layout A: overview uses source-backed counts, not demo trends or connection claims',()=>{
const html=view({counts:{memories:42,sources:68,summaries:7,jobs:3}});
for(const value of [42,68,7,3])assert.ok(html.includes(`<strong>${value}</strong>`));
assert.equal((html.match(/class="card metric"/g)||[]).length,4);
assert.match(html,/class="processing-card|class="card processing-card/);
assert.match(html,/class="synthetic-row"/);
assert.doesNotMatch(html,/1,248|24\s*\/\s*24|已连接|已完成|<canvas|<progress/);
});
test('Layout A: unavailable and invalid metric data never becomes a zero or HTML',()=>{
const html=view({counts:{memories:-1,sources:'<img src=x onerror=alert(1)>',summaries:NaN,jobs:Infinity}});
assert.equal((html.match(/<strong>—<\/strong>/g)||[]).length,7);
assert.doesNotMatch(html,/<img|onerror|<strong>0<\/strong>/);
const zero=view({counts:{memories:0}});assert.ok(zero.includes('<strong>0</strong>'));
});
test('Layout A: appearance contains three theme previews and independent mode/language controls',()=>{
const html=appearanceView(text);
assert.equal((html.match(/class="theme-option"/g)||[]).length,3);
assert.equal((html.match(/class="mini-shell"/g)||[]).length,3);
assert.equal((html.match(/aria-pressed="false" disabled/g)||[]).length,7);
for(const value of ['a','b','c','light','dark','zh-CN','en'])assert.ok(html.includes(`data-pref-value="${value}"`));
assert.doesNotMatch(html,/<form|https?:\/\//);
});
test('Layout A: both locales cover new shell and settings strings without unsafe interpolation',()=>{
assert.deepEqual(Object.keys(catalog.en).sort(),Object.keys(catalog['zh-CN']).sort());
for(const locale of ['zh-CN','en']){
const t=key=>text(key,locale),html=appearanceView(t)+overviewView({}, {t,memoryRows:()=>''});
for(const [,key] of html.matchAll(/data-i18n="([^"]+)"/g))assert.ok(Object.hasOwn(catalog[locale],key),key);
}
assert.ok(appearanceView(()=>'<script>synthetic</script>').includes('&lt;script&gt;'));
assert.doesNotMatch(appearanceView(()=>'<script>synthetic</script>'),/<script>/);
});
test('Layout A: auth has one H1 and retains the exact trusted form and OAuth purpose',()=>{
const body='<form method="post" action="/interaction/synthetic-uid/login"><input name="csrf" value="synthetic-csrf"><input name="password" type="password"></form>';
const html=renderPage({title:'oauthLogin',body,auth:true,authPurpose:'oauth'});
assert.equal((html.match(/<h1\b/g)||[]).length,1);
assert.ok(html.includes(body));
assert.match(html,/<span class="brand">/);
assert.doesNotMatch(html,/href="\/login"|src="\/assets\/app.mjs"/);
assert.match(html,/class="auth-story"/);
});
test('Layout A: all existing routes keep identity escaping and the original logout CSRF form',()=>{
for(const page of pages){
const html=renderPage({title:page,page,csrf:'" onfocus="synthetic',account:{account_id:'synthetic-account',username:'<script>synthetic</script>'}});
assert.match(html,/action="\/console-api\/logout" method="post"/);
assert.match(html,/name="csrf" value="&quot; onfocus=&quot;synthetic"/);
assert.ok(html.includes('&lt;script&gt;synthetic&lt;/script&gt;'));
assert.doesNotMatch(html,/<script>synthetic/);
assert.equal((html.match(/aria-current="page"/g)||[]).length,1);
}
});
test('Layout A: assets remain on a fixed same-origin allowlist',()=>{
const res=response();assert.equal(serveAsset({method:'GET'},res,'/assets/visuals.mjs'),true);
assert.equal(res.status,200);assert.match(res.headers['content-type'],/text\/javascript/);
for(const route of ['/assets/../catalog.mjs','/assets/secret.json','/v1/memories','/assets/visuals.mjs/extra'])
assert.equal(serveAsset({method:'GET'},response(),route),false);
assert.equal(serveAsset({method:'POST'},response(),'/assets/visuals.mjs'),false);
});
test('Layout A: UI additions preserve CSP, no-store and anti-framing headers',()=>{
const res=response();sendPage(res,{title:'oauthConsent',auth:true,authPurpose:'oauth'},{redirectUri:'https://callback.example.test/exact'});
assert.equal(res.status,200);assert.equal(res.headers['cache-control'],'no-store');
assert.equal(res.headers['x-frame-options'],'DENY');assert.equal(res.headers['referrer-policy'],'same-origin');
assert.equal(res.headers['content-security-policy'],"default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; form-action 'self' https://callback.example.test/exact; frame-ancestors 'none'; base-uri 'none'");
});
test('Layout A: desktop metric layout no longer collapses at 1280px',()=>{
const css=fs.readFileSync(new URL('../../../web/console/styles.css',import.meta.url),'utf8');
assert.match(css,/\.metrics\{display:grid;grid-template-columns:repeat\(4,minmax\(0,1fr\)\)/);
const start=css.indexOf('@media(max-width:1280px)');
const block=start===-1?'':css.slice(start,css.indexOf('@media',start+1)===-1?css.length:css.indexOf('@media',start+1));
assert.doesNotMatch(block,/\.metrics[^}]*grid-template-columns/);
assert.match(css,/\.auth-brand\{[^}]*background:var\(--surface\)/);
assert.match(css,/\.auth-form\{[^}]*background:var\(--bg\)/);
});
test('Layout A: decorative SVG cannot incorporate caller-supplied markup',()=>{
const svg=icon('<script>synthetic</script>');
assert.doesNotMatch(svg,/<script|<image|https?:\/\//);
assert.match(svg,/aria-hidden="true"/);
});
14 changes: 8 additions & 6 deletions web/console/app.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {translate as t} from './appearance.mjs';
import {translate as t, syncAppearance} from './appearance.mjs';
import {icon, overviewView, appearanceView} from './visuals.mjs';
import {SessionState} from './session-state.mjs';
const root=document.getElementById('console-root'),dialog=document.getElementById('memory-dialog'),detail=document.getElementById('memory-content');
const state=new SessionState(document.body.dataset.account);
Expand All @@ -18,23 +19,24 @@ async function api(view,params={}) {
if(!response.ok)throw new Error(data.error_code||'UNAVAILABLE');return data;
} finally {state.finish(ticket);}
}
const empty=()=>`<div class="empty">${l('empty')}</div>`;
function memoryRows(rows=[]) {return rows.length?rows.map(m=>`<div class="memory-row"><span class="glyph" aria-hidden="true">▤</span><button type="button" class="memory-link" data-memory="${esc(m.memory_id)}"><span>${esc([...String(m.content||m.summary||m.memory_id)].slice(0,160).join(''))}</span><small>${esc(m.memory_type||'')} · ${esc(date(m.created_at))}</small></button>${tag(m.status||'active')}<span aria-hidden="true">↗</span></div>`).join(''):empty();}
const empty=()=>`<div class="empty"><span class="empty-icon">${icon('memories')}</span>${l('empty','p')}</div>`;
function memoryRows(rows=[]) {return rows.length?rows.map(m=>`<div class="memory-row"><span class="glyph" aria-hidden="true">${icon('memories')}</span><button type="button" class="memory-link" data-memory="${esc(m.memory_id)}"><span>${esc([...String(m.content||m.summary||m.memory_id)].slice(0,160).join(''))}</span><small>${esc(m.memory_type||'')} · ${esc(date(m.created_at))}</small></button>${tag(m.status||'active')}<span aria-hidden="true">↗</span></div>`).join(''):empty();}
function table(rows,columns){return rows?.length?`<table><thead><tr>${columns.map(([key,label])=>`<th data-i18n="${label||key}">${esc(t(label||key))}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${columns.map(([key])=>`<td>${esc(r[key]??'—')}</td>`).join('')}</tr>`).join('')}</tbody></table>`:empty();}
function policy(note='blockedNote',actions=[]) {return `<div class="policy-box"><span class="tag">${l('blocked')}</span>${l(note,'p')}<div class="actions">${actions.map(disabled).join('')}</div></div>`;}
function render(data) {
let html='';const heading=`<div class="page-heading"><div><p class="eyebrow" data-i18n="workspaceLabel">${esc(t('workspaceLabel'))}</p>${l(page==='overview'?'hero':page,'h1')}${l(page==='overview'?'heroNote':'noDemo','p')}</div><span class="tag">${l('readOnly')}</span></div>`;
if(page==='overview')html=`<section class="hero"><div><p class="eyebrow" data-i18n="heroLabel">${esc(t('heroLabel'))}</p><h2>Mnemuron · ${l('workspace')}</h2>${l('consentNote','p')}<a href="/app/connections">${l('connections')} →</a></div><div class="orb" aria-hidden="true">M</div></section><div class="metrics">${[['memories','memoryCount'],['sources','sourceCount'],['summaries','summaryCount'],['jobs','jobCount']].map(([key,label])=>`<div class="card metric">${tag('↗')}${l(label)}<strong>${Number.isInteger(data.counts?.[key])?data.counts[key]:'—'}</strong>${l('workspace','small')}</div>`).join('')}</div><div class="columns"><section class="card"><div class="card-heading">${l('recent','h2')}<a href="/app/memories">${l('viewAll')} →</a></div>${memoryRows(data.recent)}</section><section class="card">${l('policy','h2')}${policy('consentNote')}${l('pendingPolicies','p')}<div class="actions">${disabled('newMemory')}${disabled('organize')}${disabled('export')}</div></section></div>`;
let html='';const heading=`<div class="page-heading"><div><p class="eyebrow" data-i18n="workspaceLabel">${esc(t('workspaceLabel'))}</p>${l(page==='overview'?'hero':page,'h1')}${l(page==='overview'?'heroNote':`pageNote_${page}`,'p')}</div>${page==='overview'?`<a class="button browse-link" href="/app/memories">${icon('search')}${l('browseMemories')}</a>`:`<span class="tag">${l('readOnly')}</span>`}</div>`;
if(page==='overview')html=overviewView(data,{t,memoryRows});
else if(page==='memories')html=`<section class="card"><form class="toolbar" id="search-form"><label>${l('query')}<input name="query" value="${esc(query)}" maxlength="2000" autocomplete="off"></label><button class="primary" type="submit" data-i18n="search">${esc(t('search'))}</button>${disabled('newMemory')}</form><div id="memory-rows">${memoryRows(data.results)}</div><div class="pagination">${offset?`<button type="button" data-offset="${Math.max(0,offset-25)}">${l('previous')}</button>`:''}${data.next_offset!==null&&data.next_offset!==undefined?`<button type="button" data-offset="${data.next_offset}">${l('next')}</button>`:''}</div></section>`;
else if(page==='summaries')html=`<div class="columns"><section class="card">${l('summaries','h2')}${data.summaries?.length?data.summaries.map(s=>`<div class="memory-row"><button type="button" class="memory-link" data-summary="${esc(s.summary_id)}" data-revision="${s.revision}"><strong>${esc(s.category)}</strong><small>${esc(s.summary_id)}</small><small>${l('revisions')} ${s.revision} · ${l('sourceCount')} ${s.coverage}</small></button>${tag(s.status)}</div>`).join(''):empty()}</section><section class="card">${table(data.categories,[['category','scope'],['count','memoryCount']])}${policy('blockedNote',['organize'])}</section></div>`;
else if(page==='jobs')html=`<section class="card">${table(data.jobs,[['job_id','identity'],['job_type','scope'],['state','state'],['processed','complete'],['total','sourceCount'],['last_error_code','error']])}${policy('blockedNote',['organize'])}</section>`;
else if(page==='connections')html=`<section class="card">${table(data.connections?.map(c=>({...c,expires:date(c.expires)})),[['client_id','identity'],['expires','state']])}${l('connections','h2')}${table(data.core_connections,[['label','identity'],['agent_id','scope'],['last_used_at','created']])}${policy('blockedNote',['revoke'])}${l('consentNote','p')}</section>`;
else if(page==='security')html=`<div class="columns"><section class="card"><h2>${esc(data.username)}</h2><span class="tag">${l(data.mfa_verified?'passwordTotp':'pending')}</span>${l('securityNote','p')}<a href="/recover">${l('recover')} →</a></section><section class="card">${table(data.sessions?.map(s=>({...s,created:date(s.created),expires:date(s.expires)})),[['purpose','scope'],['created','created'],['expires','state']])}</section></div>`;
else if(page==='audit')html=`<section class="card">${table([...data.entries||[],...(data.core_entries||[]).map(e=>({...e,created:e.created_at}))].map(e=>({...e,created:date(e.created)})),[['action','scope'],['outcome','state'],['created','created']])}</section>`;
else if(page==='storage')html=`<section class="card">${l('storageNote','p')}${table(Object.entries(data.counts||{}).map(([kind,count])=>({kind,count})),[['kind','scope'],['count','memoryCount']])}${policy('storageNote',['export','restore'])}</section>`;
else if(page==='appearance')html=`<section class="card">${l('appearanceNote','p')}<p>Neural Indigo · Signal Teal · Paper Amber</p>${l('theme','h2')}${l('appearanceNote','p')}<p>${l('mode')}: <span data-current-mode data-i18n="${document.documentElement.dataset.mode}">${esc(t(document.documentElement.dataset.mode))}</span></p></section>`;
else if(page==='appearance')html=appearanceView(t);
else if(['models','invitations','accounts'].includes(page))html=`<section class="card">${policy(page==='models'?'modelsNote':'platformNote',[page==='models'?'configure':page==='invitations'?'issue':'manage'])}</section>`;
root.innerHTML=heading+html;
syncAppearance();
}
async function load() {
const sequence=++requestSequence;
Expand Down
52 changes: 37 additions & 15 deletions web/console/appearance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,48 @@ const key=`mnemuron.appearance.v1.${account}`;
let prefs={...defaults};
try {const saved=JSON.parse(localStorage.getItem(key)||'{}');for(const k of Object.keys(valid))if(valid[k].includes(saved[k]))prefs[k]=saved[k];}catch{}
export const translate=key=>text(key,prefs.locale);
function apply() {

// Translate chrome in place: never replace a form, a drawer or user-supplied content.
export function syncAppearance() {
document.documentElement.dataset.theme=prefs.theme;document.documentElement.dataset.mode=prefs.mode;document.documentElement.lang=prefs.locale;
for(const node of document.querySelectorAll('[data-i18n]'))node.textContent=translate(node.dataset.i18n);
for(const node of document.querySelectorAll('[data-i18n-placeholder]'))node.placeholder=translate(node.dataset.i18nPlaceholder);
for(const node of document.querySelectorAll('[data-pref]')){node.value=prefs[node.dataset.pref];node.disabled=false;}
for(const node of document.querySelectorAll('[data-i18n-aria-label]'))node.setAttribute('aria-label',translate(node.dataset.i18nAriaLabel));
for(const node of document.querySelectorAll('[data-i18n-title]'))node.title=translate(node.dataset.i18nTitle);
for(const node of document.querySelectorAll('[data-pref]')) {
if(node.dataset.prefValue!==undefined)node.setAttribute('aria-pressed',String(node.dataset.prefValue===prefs[node.dataset.pref]));
else node.value=prefs[node.dataset.pref];
node.disabled=false;
}
if(document.body.dataset.title)document.title=`Mnemuron · ${translate(document.body.dataset.title)}`;
}
apply();
function setPreference(property,value) {
if(!valid[property]?.includes(value))return;
prefs={...prefs,[property]:value};
try{localStorage.setItem(key,JSON.stringify(prefs));}catch{}
syncAppearance();document.dispatchEvent(new CustomEvent('appearancechange',{detail:{...prefs}}));
}
syncAppearance();
document.addEventListener('change',event=>{
const property=event.target.dataset.pref;if(!valid[property]?.includes(event.target.value))return;
prefs={...prefs,[property]:event.target.value};
try{localStorage.setItem(key,JSON.stringify(prefs));}catch{}
apply();document.dispatchEvent(new CustomEvent('appearancechange',{detail:{...prefs}}));
const property=event.target.dataset.pref;
if(event.target.dataset.prefValue===undefined)setPreference(property,event.target.value);
});
document.addEventListener('click',async event=>{
const button=event.target.closest('[data-password-toggle],[data-copy]');if(!button)return;
if(button.dataset.passwordToggle) {
const field=document.getElementById(button.dataset.passwordToggle);if(!field)return;
field.type=field.type==='password'?'text':'password';button.dataset.i18n=field.type==='password'?'showPassword':'hidePassword';button.textContent=translate(button.dataset.i18n);
} else {
const node=document.getElementById(button.dataset.copy);if(!node)return;
try{await navigator.clipboard.writeText(node.textContent);document.getElementById('live-status').textContent=translate('copied');}catch{}
}
const choice=event.target.closest('button[data-pref-value]');
if(choice&&!choice.disabled){setPreference(choice.dataset.pref,choice.dataset.prefValue);return;}
const button=event.target.closest('[data-password-toggle],[data-copy]');if(!button)return;
if(button.dataset.passwordToggle) {
const field=document.getElementById(button.dataset.passwordToggle);if(!field)return;
field.type=field.type==='password'?'text':'password';button.dataset.i18n=field.type==='password'?'showPassword':'hidePassword';button.textContent=translate(button.dataset.i18n);
} else {
const node=document.getElementById(button.dataset.copy);if(!node)return;
try{await navigator.clipboard.writeText(node.textContent);document.getElementById('live-status').textContent=translate('copied');}catch{}
}
});
// Account menu is local UI only; Escape closes it and restores keyboard focus.
document.addEventListener('keydown',event=>{
if(event.key==='Escape')for(const menu of document.querySelectorAll('.account-menu[open]')){menu.open=false;menu.querySelector('summary')?.focus();}
});
document.addEventListener('click',event=>{
for(const menu of document.querySelectorAll('.account-menu[open]'))if(!menu.contains(event.target))menu.open=false;
});
Loading
Loading