layout change ui fix
Some checks failed
CI / check (push) Failing after 5m6s

This commit is contained in:
2026-09-29 16:59:13 +05:30
parent e676d259b2
commit 1ef0c6fc8a
7 changed files with 475 additions and 86 deletions

View File

@@ -0,0 +1,134 @@
/**
* returnTo validation check.
*
* Runs the REAL module through Vite's SSR loader, the same way
* skill-check.mjs does, so the `@/` alias and the TypeScript compile are the
* app's own rather than a reimplementation of them. No test framework is added
* for a 130-line module; this follows the convention already in this directory.
*
* node scripts/authreturnto-check.mjs
*
* Exits non-zero on failure, so it can gate a build.
*
* WHAT THIS IS DEFENDING
*
* safeReturnTo decides whether a URL somebody else supplied may be navigated
* to. The cases below are therefore mostly hostile input, and each asserts the
* result is null rather than merely "not the attacker's value" — a wrong answer
* that is still a navigation is not a pass.
*/
import { createServer } from 'vite';
const results = [];
const record = (name, pass, detail = '') => {
results.push({ name, pass, detail });
console.log(`[${pass ? ' ok ' : ' FAIL '}] ${name}${detail ? ` — ${detail}` : ''}`);
};
const ORIGIN = 'https://platform.krowforce.com';
// A real authorization URL, with every parameter the flow depends on, built the
// way the Go server builds it: path + RawQuery, percent-escaped into ?returnTo=.
const AUTHORIZE =
'/oauth/authorize?client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22' +
'&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback' +
'&response_type=code' +
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' +
'&code_challenge_method=S256' +
'&resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp' +
'&scope=krow.read' +
'&state=vT7nQ2xK_Lp9';
const q = (v) => '?returnTo=' + encodeURIComponent(v);
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
try {
globalThis.window = { location: { origin: ORIGIN, search: '' } };
const { safeReturnTo } = await server.ssrLoadModule('/src/lib/authReturnTo.ts');
/* ── 1–2. The OAuth authorize URL, and its query byte for byte ─────────── */
const oauth = safeReturnTo(q(AUTHORIZE));
record('1. /oauth/authorize is accepted', oauth !== null && oauth.path === AUTHORIZE,
oauth ? `via=${oauth.via}` : 'returned null');
record('12. and is marked for full browser navigation', oauth?.via === 'browser',
`via=${oauth?.via} — React Router has no such route`);
for (const [name, literal] of [
['client_id', 'client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22'],
['redirect_uri', 'redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback'],
['response_type', 'response_type=code'],
['code_challenge', 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'],
['code_challenge_method', 'code_challenge_method=S256'],
['resource', 'resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp'],
['scope', 'scope=krow.read'],
['state', 'state=vT7nQ2xK_Lp9'],
]) {
record(`2. ${name} preserved exactly`, Boolean(oauth?.path.includes(literal)));
}
record('2. percent-encoding is not rewritten', Boolean(oauth?.path.includes('%2F')),
'%2F must not become /');
record('2. an encoded space survives',
safeReturnTo(q('/oauth/authorize?scope=krow.read%20krow.write'))?.path.includes('%20') === true,
'%20 must not become +');
/* ── 3, 13. Internal admin routes keep router navigation ───────────────── */
const admin = safeReturnTo(q('/admin/candidates?stage=applied'));
record('3. /admin/... is accepted', admin?.path === '/admin/candidates?stage=applied');
record('13. and is marked for router navigation', admin?.via === 'router',
`via=${admin?.via} — must not reload the app`);
record('3. bare /admin is accepted', safeReturnTo(q('/admin'))?.via === 'router');
/* ── 4–10. Hostile and malformed values are refused ────────────────────── */
const refuse = [
['4. external URL', 'https://evil.example'],
['4. external URL with our path', 'https://evil.example/oauth/authorize'],
['4. userinfo trick', 'https://platform.krowforce.com@evil.example/'],
['4. another port on our host', 'https://platform.krowforce.com:8443/admin'],
['5. protocol-relative', '//evil.example'],
['5. protocol-relative with path', '//evil.example/steal'],
['5. triple slash', '///evil.example'],
['6. javascript:', 'javascript:alert(document.cookie)'],
['6. javascript: mixed case', 'JaVaScRiPt:alert(1)'],
['6. tab-obfuscated scheme', 'java\tscript:alert(1)'],
['7. data:', 'data:text/html,<script>alert(1)</script>'],
['8. backslash', '/\\evil.example'],
// These two reach the slash/backslash guard specifically: the path is on
// the allowlist, so only the guard can refuse them. Without them the guard
// is unfalsifiable — removing it leaves every other case still passing,
// which a mutation run showed.
['8. backslash in the query of an allowed path', '/admin/candidates?a=\\evil'],
['8. backslash escape smuggled past the allowlist', '/admin/x?next=/\\evil.example'],
['8. dot-slash-slash', '/.//evil.example'],
['9. /admin/login itself', '/admin/login'],
['9. /admin/login with a query', '/admin/login?returnTo=%2Fadmin'],
['9. bare /login', '/login'],
['10. malformed', 'http://[::1'],
['10. file scheme', 'file:///etc/passwd'],
['10. unrelated backend route', '/oauth/token'],
['10. unrelated app route', '/apply'],
['10. the MCP endpoint', '/mcp'],
];
for (const [name, value] of refuse) {
const got = safeReturnTo(q(value));
record(`${name} is refused`, got === null, got ? `returned ${JSON.stringify(got)}` : '');
}
/* ── 11. Absent or empty falls back safely ─────────────────────────────── */
for (const [name, search] of [
['no query at all', ''],
['other parameters only', '?foo=bar'],
['empty returnTo', '?returnTo='],
]) {
record(`11. ${name} returns null`, safeReturnTo(search) === null);
}
} finally {
await server.close();
}
const failed = results.filter((r) => !r.pass).length;
console.log(`\n${results.length - failed} passed, ${failed} failed\n`);
process.exit(failed === 0 ? 0 : 1);

View File

@@ -7976,8 +7976,30 @@ console.log('\n── Candidates vs Talent Pool ──');
return html.slice(0, at) + html.slice(i);
};
record('the layout controls are present and separable',
now.includes('<div data-ui-controls') && stripControls(now).indexOf('data-ui-controls') === -1);
/**
* A page at rest draws no layout controls at all.
*
* This used to assert the opposite — that the controls were PRESENT — back
* when they led with a `Customise layout` button that was on screen whether
* or not anybody was arranging anything. That button is gone: Owliver is the
* only way to propose a layout change now, and what is left is the bar that
* lets a person accept or reject one. With nothing proposed and nothing
* saved there is nothing to accept, so the component renders nothing.
*
* `stripControls` is kept and still exercised. The bar does appear once a
* change is being previewed, and the comparison below must stay able to lift
* it out when it does.
*/
record('a page at rest draws no layout controls',
!now.includes('<div data-ui-controls'));
record('layout controls stay separable when they are drawn', (() => {
const withBar = `<main><p>kept</p><div data-ui-controls="editor"><div><span>Apply</span></div></div><p>also kept</p></main>`;
const stripped = stripControls(withBar);
return !stripped.includes('data-ui-controls')
&& stripped.includes('<p>kept</p>')
&& stripped.includes('<p>also kept</p>');
})());
now = stripControls(now);
@@ -8308,6 +8330,92 @@ console.log('\n── Candidates vs Talent Pool ──');
return r.ok && ids.indexOf('timeline') < ids.indexOf('activity-privileged-notice');
})());
/* ── Where "to the top" is allowed to be said ──────────────────────────── */
/**
* The destination phrasings, because the missing ones read as a broken feature.
*
* Reported from production: "show the pipeline move to top" was answered "I
* could not find that on this page." The target resolved perfectly well — the
* refusal came from `planMove`, which knew `to the top` and did not know
* `to top`, and a move with no destination falls through to `unknown`. The
* user cannot tell that apart from the section not existing.
*/
const movesFirst = (q) => {
const m = ask(q);
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
return m.op.order[0] === 'timeline';
};
const movesLast = (q) => {
const m = ask(q);
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
return m.op.order[m.op.order.length - 1] === 'timeline';
};
for (const q of [
'move timeline to top',
'move timeline to the top',
'move the timeline to the very top',
'move timeline up',
'move the timeline first',
'put the timeline at the top',
'show the timeline move to top',
]) {
record(`"${q}" → timeline first`, movesFirst(q));
}
for (const q of [
'move timeline to bottom',
'move timeline to the bottom',
'move the timeline to the end',
'move timeline down',
'move the timeline last',
]) {
record(`"${q}" → timeline last`, movesLast(q));
}
/**
* A destination word inside a section's NAME is not a destination.
*
* `over` lives inside `coverage`, `end` inside `trends`. Matched as
* substrings, "move coverage trends to the bottom" satisfied both the top
* reading and the bottom one — and the top is tested first, so it moved the
* opposite way from the one asked for. Whole-word matching is what fixed it;
* this is the case that proves it.
*/
const coverageCase = (() => {
const placed = opsMod4.applyOperation(tree4, {
op: 'add', parent: null,
node: {
id: 'cov-1', type: 'flow', data: { source: 'candidates.activity' },
props: { title: 'Coverage trends' },
},
}, { registry: reg4 });
if (!placed.ok) return { pass: false, detail: 'fixture could not be placed' };
/* Put it at the TOP first, so "to the bottom" is a real change. Added at
the end, it is already there and the answer is a refusal rather than a
plan — which would pass this check for the wrong reason. */
const atTop = opsMod4.applyOperation(placed.tree, {
op: 'reorder',
parent: null,
order: ['cov-1', ...placed.tree.map((n) => n.id).filter((id) => id !== 'cov-1')],
}, { registry: reg4 });
if (!atTop.ok) return { pass: false, detail: 'fixture could not be placed first' };
const m = intentMod.matchUiEdit('move coverage trends to the bottom',
{ tree: atTop.tree, registry: reg4 });
if (m?.kind !== 'plan' || m.op.op !== 'reorder') {
return { pass: false, detail: JSON.stringify(m) };
}
return {
pass: m.op.order[m.op.order.length - 1] === 'cov-1',
detail: m.op.order.join(', '),
};
})();
record('a section named "Coverage trends" still moves to the bottom',
coverageCase.pass, coverageCase.detail);
record('"Change the hiring activity to a table." → replace with table', (() => {
const m = ask('Change the hiring activity to a table.');
return m?.kind === 'plan' && m.op.op === 'replace' && m.op.target === 'flow-1' && m.op.type === 'table';