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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Validate the installed router contract and real navigation controls

On 2026-10-27, the #186 router work started at dev
`8712389e24f567f5b3e6ed132c1ac5d4dafb21fa`. A fresh-cache production audit
reported five moderate entries: Router/DOM and UUID/federation/subgraph. The
[navigation advisory](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6) and
[hydration advisory](https://github.com/advisories/GHSA-337j-9hxr-rhxg) both
identify 7.18.0 as fixed. The selected Router/DOM 7.18.4 retains React 18 and the
existing declarative BrowserRouter; its Node 20 minimum fits supported Node 22/24.
The [versioned migration guide](https://reactrouter.com/7.18.4/upgrading/v6)
was checked. No multi-segment splat route, data router or component-local lazy
page declaration was found in the live app routes.

The first navigation test copied an older upstream normalization expectation.
Router 6.30.6 failed three mixed-separator cases; installed 7.18.4 rejected all four
external-looking destinations instead of navigating to a normalized internal
path. That four-case failure was a test-contract mismatch, not an application
regression. Inspection of the installed `validateNavigationTarget` established
the actual rejection boundary. The corrected control captures the thrown error
and requires the route to remain unchanged. Reinstalling the original locked 6.30.6
then running the final control produced **4 failures and 1 history pass**;
the 7.18.4 control plus existing role guards produced **9 passes**.

The first six actual-browser cases produced **4 failures and 2 passes**, with no
retries. The two failing assumptions on each viewport were that the sign-in
page always selected Password, and that Admin opened its Users tab. The current
UI preserves the sign-in method and opens Feedback & operations. The corrected
test explicitly selects Password and Users, retains the exact route/content
assertions, and checks that denied users cannot mount the administration heading.
History, lazy-page navigation, viewer guards and fresh-context callback routing
remain required. Final browser and integrated results belong in the PR.

Corrective rule: use the installed consumer and current UI to establish a
regression contract, then run the exact final negative control against the old
locked consumer. Preserve the earlier failing logs; do not treat a test rewrite
or a successful install as proof that navigation remains usable. The lock
regression checks every Router/DOM resolution, not just the manifest range.

Limits: MemoryRouter tests establish library navigation behavior, while the
actual-browser suite covers GraphDone routes at 1440px and 390px. No hostile
application-controlled destination, SSR hydration surface, live provider
callback, deployed revision or broad security clearance is claimed. Test tokens
are confined to disposable local/CI accounts; failure traces are not public
credential samples. Local evidence is retained in
`/tmp/graphdone-resolution-20261027/router-*` and this record travels with the fix.

At frozen `3e5a761dc0b067ed3827dd29fca50d45fb5d3952`, the corrected browser suite
passed 6/6 without retries, the full web suite passed 340/340 and the harness
passed 167/167. Review found the inventory overstated the installed-version guard:
it checked locked ranges and installed-pair equality, which could accept two
equally stale installed copies. The guard now requires each actual web consumer
to satisfy the fixed range and match its resolved lock entry. The inventory
states that exact scope. Refresh the changed harness and final CI before merge.

## Empty-account CI correction

Composed Router head `14a4fcc` [CI 40584829418](https://github.com/GraphDone/GraphDone-Core/actions/runs/40584829418)
completed 50 browser cases in 854 seconds: 48 passed, two viewer cases failed,
plus the harness's failed-process row. This was not a process-budget timeout.
Retained screenshots show the authenticated empty workspace at both widths.
The desktop assertion incorrectly required a populated graph's `view-content`;
the phone login helper did not recognize an authenticated account without a
graph selector. The local fixture had graphs and hid both assumptions.

Use the existing authenticated `workspace-content` container for account-level
route checks and keep login-form exclusion. Router cases additionally assert
`me.role === VIEWER` through the real API before and after the fresh-context
callback. A standalone real-browser control recognizes an empty workspace,
rejects a password form even with workspace markup, and rejects a loading page.
With the old helper, the empty-workspace positive failed deterministically;
this helper correction adds no retry, wait or timeout. Original CI artifacts
remain evidence, and final current-head browser/full CI gates still apply.

The first empty-workspace/password-form negative used a zero-height empty main;
independent review measured it invisible, so that case could pass for the wrong
reason. It now retains visible content and asserts both the workspace and the
password field are visible before requiring an unauthenticated result.

Gate capacity is separate from that failure: focus CI's 44-case new-user sequence
passed in 892 seconds, only eight below the 900-second process budget; UUID's
same suite took 768 seconds. Required LOD and route acceptance now run as separate
blocking sequences after the 42-case new-user suite. All 51 cases remain required
(42 existing, two LOD, seven route/helper controls), with unchanged per-action
and per-test deadlines, one worker and zero retries. CI runs and uploads each
result independently; pr/full profiles also include both new sequences. CLI
contract tests execute the enrollment/trace arguments. Do not describe this as
fixing the original two viewer failures or count superseded runs as passes.
1 change: 1 addition & 0 deletions .agents/issues/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| Symptom | Canonical lesson and regression |
| --- | --- |
| A router major update passes install but path handling or route UI assertions differ | [Validate installed navigation and current UI contracts](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); `routerNavigation.test.tsx`, `router-navigation.spec.ts`, dependency guard |
| Status-note text lands in the title and Add remains disabled after quick edit opens | [Delayed autofocus must preserve chosen focus](../inbox/2026-10-27-delayed-autofocus-must-preserve-chosen-focus.md); `QuickEditSurface.test.tsx` controls the pending frame |
| Minimap zoom or overview inspection is overwritten while a newly loaded graph settles | [Explicit camera input must stop automatic framing](../inbox/2026-10-27-explicit-camera-input-must-stop-auto-fit.md); timer-controlled `semantic-lod.spec.ts` negative control |
| Dense-graph browser case exhausts its timeout after slow API fixture creation; cleanup errors disappear | [Bound fixture setup separately and fail on cleanup errors](../inbox/2026-10-27-cold-lod-fixtures-need-separate-budgets.md); `tests/e2e/graph/semantic-lod.spec.ts` |
Expand Down
1 change: 1 addition & 0 deletions .agents/issues/security-audit-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| Symptom | Evidence and corrective rule |
| --- | --- |
| Router advisory requires a major update and older upstream navigation expectations differ | [Installed consumer and route compatibility](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); every locked Router path plus desktop/phone route gates |
| Fresh production audit flags Undici through optional SQLite build tooling without a direct application import | [Trace the native-build consumer and patch its actual resolved copy](../inbox/2026-10-24-undici-native-build-dependency.md); `tests/lib/dependencies/undici.test.mjs` rejects affected locked and installed consumer versions |
| Green production audit hides development-tool findings or package counts are treated as exploit counts | [Audit scope lesson](../inbox/2026-10-23-dependency-audit-scope.md); [dated security scope](../../docs/quality/SECURITY_SCOPE_2026-10-23.md) |
| Nested lint glob stalls the process | `tests/lib/dependencies/minimatch.test.mjs`; scoped parser matcher override and bounded child-process regression |
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ jobs:
env:
TEST_URL: http://localhost:3127
CI: true
- name: Semantic LOD acceptance
run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence semantic-lod --out test-artifacts/ci-semantic-lod
env:
TEST_URL: http://localhost:3127
CI: true
- name: Route compatibility acceptance
run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence router-navigation --out test-artifacts/ci-router-navigation
env:
TEST_URL: http://localhost:3127
CI: true
- name: Mobile UI audit (layout / contrast / dialogs across every screen)
run: node tests/run-unified.mjs --sequence mobile --out test-artifacts/ci-mobile
env:
Expand Down
23 changes: 23 additions & 0 deletions docs/quality/SECURITY_SCOPE_2026-10-23.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,29 @@ Follow-up implementation on 2026-10-23: [PR #169](https://github.com/GraphDone/G

Source locations are relative to `packages/server/src/` and anchored to the snapshot above. Local, secret-free evidence is under `/tmp/graphdone-residual-*-audit.json`, `/tmp/graphdone-minimatch-*.log`, and `/tmp/graphdone-residual-auth-probe.log`; the latter reports only `parallelMagicLinkSuccesses: 2` and `arbitraryCaptchaAccepted: true`.

## Router correction on 2026-10-27

The dedicated #186 router change replaces Router/DOM 6.30.6 with 7.18.4, removes
the old `@remix-run/router` resolution and retains the existing declarative SPA.
Official advisories [GHSA-wrjc-x8rr-h8h6](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6)
and [GHSA-337j-9hxr-rhxg](https://github.com/advisories/GHSA-337j-9hxr-rhxg)
identify 7.18.0 as fixed. Existing app routes use fixed destinations, with no
observed SSR hydration path; upgrading is not a claim that a live exploit was
demonstrated. The DOM compatibility package is retained to keep imports stable.

The dependency regression checks every locked path and the actual web consumers
against both the patched range and their resolved lock entries. Navigation
controls reject external-looking mixed separators and preserve internal query,
fragment and history behavior; existing role guards remain covered. Six required
browser cases exercise public and authenticated deep links, reload, lazy pages,
desktop links/phone navigation, back/forward, role rejection and a fresh-context
login callback. The callback uses an owned password-issued token and does not
establish provider/email authentication. Initial negative and fixture-contract
failures are retained in the [dated lesson](../../.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md).
Fresh full-suite, browser, audit and final composition results are recorded on
the reviewable PR; UUID, CSS evidence, tooling and hosted acceptance remain
separate work.

## Coverage still needed

The folder tests do not establish all generated Graph/WorkItem/Edge permissions, concurrent deletion integrity, MCP/agent grant revocation, share-link scopes, session/cookie policy, uploads, backups, email delivery, or deployed proxy controls. Those need isolated contracts and user-level tests with current roles, multiple accounts, races, and negative cases. Dependency counts, passing smoke tests, and absence of a discovered source sink are separate pieces of evidence; none is a full security assessment.
Expand Down
100 changes: 58 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.0",
"react-router-dom": "^7.18.4",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"tailwindcss": "^3.3.0",
Expand Down
45 changes: 45 additions & 0 deletions packages/web/src/lib/routerNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useState } from 'react';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';

function Navigation({ destination }: { destination: string }) {
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState('');
return <>
<button onClick={() => {
try { navigate(destination); } catch (failure) { setError((failure as Error).message); }
}}>Open destination</button>
<button onClick={() => navigate(-1)}>Back</button>
<button onClick={() => navigate(1)}>Forward</button>
<output aria-label="Current location">{location.pathname}{location.search}{location.hash}</output>
{error && <p role="alert">{error}</p>}
<Routes>
<Route path="/home" element={<h1>Home</h1>} />
<Route path="/settings" element={<h1>Settings</h1>} />
</Routes>
</>;
}

describe('installed router navigation contract', () => {
for (const prefix of ['//', '\\\\', '/\\', '\\/']) {
it(`rejects external-looking separators ${JSON.stringify(prefix)} before navigation`, async () => {
render(<MemoryRouter initialEntries={['/home']}><Navigation destination={`${prefix}settings`} /></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: 'Open destination' }));
expect(await screen.findByRole('alert')).toHaveTextContent('External navigation is not allowed');
expect(screen.getByRole('heading', { name: 'Home' })).toBeVisible();
expect(screen.getByLabelText('Current location')).toHaveTextContent(/^\/home$/);
});
}

it('preserves query, fragment and browser-style back/forward history', async () => {
render(<MemoryRouter initialEntries={['/home']}><Navigation destination="/settings?tab=profile#account" /></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: 'Open destination' }));
await waitFor(() => expect(screen.getByLabelText('Current location')).toHaveTextContent('/settings?tab=profile#account'));
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(await screen.findByRole('heading', { name: 'Home' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'Forward' }));
await waitFor(() => expect(screen.getByLabelText('Current location')).toHaveTextContent('/settings?tab=profile#account'));
});
});
Loading
Loading