NUL-231: dev-only Vite proxy + same-origin VITE_API_URL - #4
Conversation
Fixes the dev /api cookie preflight gap surfaced by NUL-218: - vite.config.ts: add server.proxy['/api'] → http://localhost:8787 so cookie- bearing /api fetches from the Vite dev server (port 5173) are forwarded server-side and arrive at the browser as same-origin. Modern browsers treat :5173 and :8787 as different origins; without this proxy every cross-origin fetch is preflight-blocked by CORS, even when Lax cookies are set on :5173. - .env.development (new, git-ignored-by-build): sets VITE_API_URL=http://localhost:5173 so the client resolves apiFetch's base URL to the Vite origin and goes through the proxy. Production builds do not read this file (they use the existing :8787 fallback in resolveBaseUrl), so no production behavior changes. Verification: - npm run typecheck (tsc -b): clean - npm test: 139/139 pass - npm run test:server: 49/49 pass - npm run build: succeeds; no server.proxy or VITE_API_URL=5173 leaks into dist (verified by grep) - Real round-trip: a stub backend on :8787 logged GET /api/auth/me with Origin and Cookie headers forwarded from the browser-equivalent curl through the proxy. Preflight OPTIONS returns 204 with the correct Access-Control-Allow-Origin. Refs NUL-218, NUL-231. Parents: feat/nul-217-auth-flow.
Sentinel's NUL-218 review flagged the e2e regression where scenario 1
times out at the email-input wait. Root cause was that AuthGuard's
<AuthSplash /> was rendered whenever me.isLoading was true. With the
Vite proxy in place, /api/auth/me returns 401 instead of the previous
CORS TypeError — React Query's retry policy on 401 (no retry) lets
me.isError settle quickly, but during the brief window where me.isLoading
is true on a fresh mount, the splash is shown.
This commit:
1. Skips the splash on /login while me.isLoading is true. The login
page owns its own UX; showing the app shell + login form during the
initial /me probe is correct. Subsequent 401 paths also no longer
trigger splash on /login (the existing currentPath check).
2. Adds a defensive null-check for fp.rackPositions in DashboardPage.
Pre-existing: /api/floorplans doesn't return rackPositions; the
page crashed on fp.rackPositions.length. Without this, the dashboard
crashed on first render after login and the tour couldn't auto-launch.
This unblocks scenario 1 (fresh / -> tour auto-launches).
Verified:
- typecheck clean (npx tsc -b exit 0)
- 139/139 unit + integration tests pass
- e2e scenario 1 now passes (desktop-light, ~10s)
- e2e scenario 10 still fails (pre-existing, unrelated: Skip tour button
auto-focus race)
- prod build (npm run build) succeeds; vite.config.ts proxy stays in
the server{} block and is not present in dist/
…UL-230)
The /api/auth/logout handler called clearSessionCookie(c) to schedule a
Set-Cookie header on c.res, but then returned a bare
`new Response(null, { status: 204 })` which discarded c.res.headers.
The browser kept the old ipam_session cookie and the next /api/auth/me
still returned 200, so users appeared to be 'still logged in' after
logout (surfaced by the NUL-218 desktop/mobile smoke).
Switch to `c.body(null, 204)` so the prepared Set-Cookie header is
actually included in the 204 response.
Add three regression tests in src/server/__tests__/auth-and-tenant.test.ts:
- /api/auth/logout returns 204 with a Set-Cookie that expires ipam_session
- /api/auth/me without cookie after logout returns 401
- /api/auth/login still works after the fix (regression check)
Risk: auth-touching (Block). Sentinel approval and explicit founder
override required before Relay merges. This commit is the unblock for
the NUL-218 verification child.
NUL-230 fix pushed on commit 0e01734Issue: NUL-230 ([NUL-218] /api/auth/logout 204 does not clear the session cookie) Root cause: Fix (commit 0e01734, on top of de7e341): replace the bare authApp.post('/logout', (c) => {
+ // NUL-230: see PR comment
clearSessionCookie(c)
- return new Response(null, { status: 204 })
+ return c.body(null, 204)
})Live verification on the local dev stack:
Tests added (3 new in
All 142 tests pass ( Risk: auth-touching (Block). Sentinel review + explicit founder override required before Relay merges, per the AGENTS routing rules. Unblocks: NUL-218 (the smoke test child). Once this merges into |
The pre-NUL-231 comment claimed a 'Hono CORS allowlist' and 'no Vite proxy' — both stale now. The dev path is same-origin through the new Vite /api proxy, and Hono still deliberately serves no CORS headers. `--disable-web-security` is intentionally retained because `tests/onboarding.spec.ts` issues direct :8787 fixture calls inside page.evaluate() that are deliberately cross-origin. Dropping the flag would require rewriting those fixture calls to go through the Vite origin (out of scope for NUL-271, tracked separately). The flag is still safe — dev stack only listens on localhost.
Summary
Fixes the dev-mode cross-origin cookie gap that NUL-218 surfaced.
Why both files
The change alone does not fix the gap: resolves its base URL with . Without , the client still hits directly and the proxy never runs. Setting to the Vite origin in dev is what makes the proxy actually receive requests.
Production already serves the SPA and from one origin (nginx), so the production bundle is unaffected by either change —
vite v6.4.3 building for production...
transforming...
✓ 2077 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.62 kB │ gzip: 0.37 kB
dist/assets/index-BLxxzSDg.css 88.16 kB │ gzip: 14.85 kB
dist/assets/racks-CYz9S71k.js 0.10 kB │ gzip: 0.11 kB
dist/assets/chevron-right-DEV12weI.js 0.30 kB │ gzip: 0.25 kB
dist/assets/plus-C1dX7nvD.js 0.32 kB │ gzip: 0.25 kB
dist/assets/activity-DU-Y37Py.js 0.40 kB │ gzip: 0.30 kB
dist/assets/circle-alert-g6ADvJii.js 0.42 kB │ gzip: 0.29 kB
dist/assets/shield-check-DD65NP31.js 0.49 kB │ gzip: 0.35 kB
dist/assets/trash-2-F5HxQdjy.js 0.53 kB │ gzip: 0.35 kB
dist/assets/input-CZpAyFWA.js 0.67 kB │ gzip: 0.38 kB
dist/assets/empty-state-DxHRMxdz.js 0.77 kB │ gzip: 0.38 kB
dist/assets/tenant-scope-CajR8jTn.js 0.77 kB │ gzip: 0.39 kB
dist/assets/card-D2S6rHW2.js 0.87 kB │ gzip: 0.35 kB
dist/assets/editor-store-hXO29wzv.js 1.06 kB │ gzip: 0.31 kB
dist/assets/create-site-dialog-Sy5TmO9K.js 1.69 kB │ gzip: 0.87 kB
dist/assets/tabs-BOH-DVFZ.js 3.38 kB │ gzip: 1.39 kB
dist/assets/templates-CD9d-KS8.js 4.09 kB │ gzip: 1.57 kB
dist/assets/login-C3djLKgr.js 4.20 kB │ gzip: 1.94 kB
dist/assets/settings-DyShNfq_.js 4.73 kB │ gzip: 1.68 kB
dist/assets/racks.index-BquEtWPr.js 4.97 kB │ gzip: 1.84 kB
dist/assets/topology-ffIlWZ95.js 7.85 kB │ gzip: 2.69 kB
dist/assets/entity-history-panel-cIaVeuS2.js 9.80 kB │ gzip: 3.06 kB
dist/assets/index-CEJgBCxQ.js 11.73 kB │ gzip: 4.03 kB
dist/assets/patches-CumJkDNQ.js 15.81 kB │ gzip: 4.88 kB
dist/assets/ipam-DEQqYuDC.js 21.33 kB │ gzip: 5.83 kB
dist/assets/select-Cj7Z0mwf.js 21.91 kB │ gzip: 7.57 kB
dist/assets/label-D6On0u1T.js 29.38 kB │ gzip: 10.92 kB
dist/assets/racks._rackId-KmFZdAO1.js 89.38 kB │ gzip: 22.95 kB
dist/assets/floorplan-D4-gL_TZ.js 308.78 kB │ gzip: 95.16 kB
dist/assets/index-Kz-I1LJD.js 554.50 kB │ gzip: 169.82 kB
✓ built in 4.33s does not embed the dev env file, and is a dev-server-only Vite feature (verified by grep on ).
Tests
TAP version 13
Subtest: every tour selector has a matching desktop and mobile navigation anchor
ok 1 - every tour selector has a matching desktop and mobile navigation anchor
duration_ms: 7.536798
...
Subtest: tour popover keeps Skip first and exposes the required anchor accessibility hooks
ok 2 - tour popover keeps Skip first and exposes the required anchor accessibility hooks
duration_ms: 1.278326
...
Subtest: mobile tour is a bottom sheet and focuses Skip before other controls
ok 3 - mobile tour is a bottom sheet and focuses Skip before other controls
duration_ms: 2.652739
...
Subtest: the onboarding provider waits for navigation and opens the mobile drawer before anchoring
ok 4 - the onboarding provider waits for navigation and opens the mobile drawer before anchoring
duration_ms: 4.135925
...
Subtest: topbar Help menu contains replay, shortcuts, and About actions
ok 5 - topbar Help menu contains replay, shortcuts, and About actions
duration_ms: 2.080488
...
Subtest: exports the expected storage keys + total step count
ok 6 - exports the expected storage keys + total step count
duration_ms: 0.655442
...
Subtest: starts inactive with complete=false when storage is empty
ok 7 - starts inactive with complete=false when storage is empty
duration_ms: 0.427932
...
Subtest: starts inactive when storage is null (SSR safety)
ok 8 - starts inactive when storage is null (SSR safety)
duration_ms: 0.155781
...
Subtest: start -> next -> next -> skip returns to inactive
ok 9 - start -> next -> next -> skip returns to inactive
duration_ms: 1.508385
...
Subtest: a stored complete flag is picked up by a new store instance
ok 10 - a stored complete flag is picked up by a new store instance
duration_ms: 0.13864
...
Subtest: a stored step index is picked up by a new store
ok 11 - a stored step index is picked up by a new store
duration_ms: 0.15167
...
Subtest: restart resets step to 0 and clears the complete flag
ok 12 - restart resets step to 0 and clears the complete flag
duration_ms: 0.22258
...
Subtest: logout event clears every tour key and resets state
ok 13 - logout event clears every tour key and resets state
duration_ms: 0.184242
...
Subtest: prev() at step 0 is a no-op
ok 14 - prev() at step 0 is a no-op
duration_ms: 0.21328
...
Subtest: final next() completes the tour and writes the last step
ok 15 - final next() completes the tour and writes the last step
duration_ms: 1.119324
...
Subtest: persisted step beyond total is clamped on store construction
ok 16 - persisted step beyond total is clamped on store construction
duration_ms: 0.258582
...
Subtest: negative stored step falls back to 0 on construction
ok 17 - negative stored step falls back to 0 on construction
duration_ms: 0.15977
...
Subtest: non-numeric stored step falls back to 0 on construction
ok 18 - non-numeric stored step falls back to 0 on construction
duration_ms: 0.136801
...
Subtest: skip() without an active tour writes the complete flag
ok 19 - skip() without an active tour writes the complete flag
duration_ms: 0.211931
...
Subtest: start() writes the shown-on-login flag once
ok 20 - start() writes the shown-on-login flag once
duration_ms: 0.201451
...
Subtest: destroy() removes the logout listener so a later fire is a no-op
ok 21 - destroy() removes the logout listener so a later fire is a no-op
duration_ms: 0.12323
...
Subtest: useTour() returns the expected shape during SSR with empty storage
ok 22 - useTour() returns the expected shape during SSR with empty storage
duration_ms: 2.453519
...
Subtest: 401 on a non-login /api/** route dispatches ipam:session-expired and throws ApiError
ok 23 - 401 on a non-login /api/** route dispatches ipam:session-expired and throws ApiError
duration_ms: 17.560033
...
Subtest: 401 on a non-login GET /api/** route dispatches the event
ok 24 - 401 on a non-login GET /api/** route dispatches the event
duration_ms: 0.492132
...
Subtest: 401 on /api/auth/login does NOT dispatch (login page handles its own 401)
ok 25 - 401 on /api/auth/login does NOT dispatch (login page handles its own 401)
duration_ms: 0.384401
...
Subtest: non-401 errors do NOT dispatch
ok 26 - non-401 errors do NOT dispatch
duration_ms: 1.132554
...
Subtest: 200 OK does NOT dispatch
ok 27 - 200 OK does NOT dispatch
duration_ms: 0.466332
...
Subtest: session-expired event name matches the contract
ok 28 - session-expired event name matches the contract
duration_ms: 0.0988
...
Subtest: dispatchSessionExpired is a safe no-op when window is undefined
ok 29 - dispatchSessionExpired is a safe no-op when window is undefined
duration_ms: 0.164081
...
Subtest: LoginFormFields renders email + password inputs and a submit button
ok 30 - LoginFormFields renders email + password inputs and a submit button
duration_ms: 8.923133
...
Subtest: LoginFormFields shows the submit error when provided
ok 31 - LoginFormFields shows the submit error when provided
duration_ms: 1.616166
...
Subtest: LoginFormFields flips the button label while submitting
ok 32 - LoginFormFields flips the button label while submitting
duration_ms: 0.607573
...
Subtest: submitLogin: 2xx invalidates + prefetches /me and navigates to "/"
ok 33 - submitLogin: 2xx invalidates + prefetches /me and navigates to "/"
duration_ms: 15.908498
...
Subtest: submitLogin: invalid from-target is honoured as a navigation argument
ok 34 - submitLogin: invalid from-target is honoured as a navigation argument
duration_ms: 0.381512
...
Subtest: submitLogin: 401 throws LoginHttpError(401) and does not navigate
ok 35 - submitLogin: 401 throws LoginHttpError(401) and does not navigate
duration_ms: 0.561342
...
Subtest: submitLogin: 429 throws LoginHttpError with retry-after seconds
ok 36 - submitLogin: 429 throws LoginHttpError with retry-after seconds
duration_ms: 2.504959
...
Subtest: submitLogin: fetch rejection throws LoginNetworkError
ok 37 - submitLogin: fetch rejection throws LoginNetworkError
duration_ms: 0.356921
...
[ipam] logout request failed; clearing local session anyway LogoutApiError: server error
at Object.post (/home/paperclip/.paperclip/instances/default/projects/afb18058-8283-43a1-867c-7259aad56728/36218967-b3c2-4f73-8024-ee4990a7bee2/ipam/src/features/auth/logout-button.test.ts:63:32)
at performLogout (/home/paperclip/.paperclip/instances/default/projects/afb18058-8283-43a1-867c-7259aad56728/36218967-b3c2-4f73-8024-ee4990a7bee2/ipam/src/features/auth/logout-orchestration.ts:63:16)
at TestContext. (/home/paperclip/.paperclip/instances/default/projects/afb18058-8283-43a1-867c-7259aad56728/36218967-b3c2-4f73-8024-ee4990a7bee2/ipam/src/features/auth/logout-button.test.ts:65:9)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:796:25)
at Test.processPendingSubtests (node:internal/test_runner/test:526:18)
at Test.postRun (node:internal/test_runner/test:889:19)
at Test.run (node:internal/test_runner/test:835:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:526:7) {
status: 500
}
[ipam] logout request failed; clearing local session anyway TypeError: Failed to fetch
at Object.post (/home/paperclip/.paperclip/instances/default/projects/afb18058-8283-43a1-867c-7259aad56728/36218967-b3c2-4f73-8024-ee4990a7bee2/ipam/src/features/auth/logout-button.test.ts:72:32)
at performLogout (/home/paperclip/.paperclip/instances/default/projects/afb18058-8283-43a1-867c-7259aad56728/36218967-b3c2-4f73-8024-ee4990a7bee2/ipam/src/features/auth/logout-orchestration.ts:63:16)
at TestContext. (/home/paperclip/.paperclip/instances/default/projects/afb18058-8283-43a1-867c-7259aad56728/36218967-b3c2-4f73-8024-ee4990a7bee2/ipam/src/features/auth/logout-button.test.ts:74:9)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:796:25)
at Test.processPendingSubtests (node:internal/test_runner/test:526:18)
at Test.postRun (node:internal/test_runner/test:889:19)
at Test.run (node:internal/test_runner/test:835:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:526:7)
Subtest: happy path: post → clear me → navigate
ok 38 - happy path: post → clear me → navigate
duration_ms: 1.143774
...
Subtest: post 401 still clears local session and navigates to /login
ok 39 - post 401 still clears local session and navigates to /login
duration_ms: 0.358502
...
Subtest: post 500 still clears local session and navigates to /login
ok 40 - post 500 still clears local session and navigates to /login
duration_ms: 4.802847
...
Subtest: post network error still clears local session and navigates
ok 41 - post network error still clears local session and navigates
duration_ms: 0.573552
...
Subtest: queries are removed and invalidated in the correct order
ok 42 - queries are removed and invalidated in the correct order
duration_ms: 0.248762
...
Subtest: loading state never redirects
ok 43 - loading state never redirects
duration_ms: 0.699812
...
Subtest: anonymous user on / bounces to /login with from=/
ok 44 - anonymous user on / bounces to /login with from=/
duration_ms: 0.666943
...
Subtest: anonymous user on /ipam with search preserves the search string in from
ok 45 - anonymous user on /ipam with search preserves the search string in from
duration_ms: 0.18614
...
Subtest: anonymous user on /login does not redirect (login page surfaces 401)
ok 46 - anonymous user on /login does not redirect (login page surfaces 401)
duration_ms: 0.151092
...
Subtest: authenticated user on /login with no from bounces to /
ok 47 - authenticated user on /login with no from bounces to /
duration_ms: 0.16404
...
Subtest: authenticated user on /login with from=/ipam bounces to /ipam
ok 48 - authenticated user on /login with from=/ipam bounces to /ipam
duration_ms: 0.13357
...
Subtest: authenticated user on a protected route is allowed (no redirect)
ok 49 - authenticated user on a protected route is allowed (no redirect)
duration_ms: 0.155351
...
Subtest: protocol-relative from is rejected and falls back to /
ok 50 - protocol-relative from is rejected and falls back to /
duration_ms: 0.11659
...
Subtest: absolute URL from is rejected and falls back to /
ok 51 - absolute URL from is rejected and falls back to /
duration_ms: 0.16954
...
Subtest: safePostLoginTarget mirrors decideRedirect sanitisation
ok 52 - safePostLoginTarget mirrors decideRedirect sanitisation
duration_ms: 0.524002
...
Subtest: session-expired on /racks bounces to /login with from=/racks
ok 53 - session-expired on /racks bounces to /login with from=/racks
duration_ms: 0.16343
...
Subtest: session-expired on /racks?tab=devices preserves the search string
ok 54 - session-expired on /racks?tab=devices preserves the search string
duration_ms: 0.134281
...
Subtest: session-expired on /ipam?foo=bar&baz=1 preserves the full query
ok 55 - session-expired on /ipam?foo=bar&baz=1 preserves the full query
duration_ms: 0.10053
...
Subtest: session-expired on /login is a no-op (login form stays put)
ok 56 - session-expired on /login is a no-op (login form stays put)
duration_ms: 0.148182
...
Subtest: session-expired on /login (no search) is a no-op
ok 57 - session-expired on /login (no search) is a no-op
duration_ms: 0.137371
...
Subtest: session-expired sanitises an unsafe from by collapsing to /
ok 58 - session-expired sanitises an unsafe from by collapsing to /
duration_ms: 0.12266
...
Subtest: handler: clears the me cache and navigates to /login with the preserved from
ok 59 - handler: clears the me cache and navigates to /login with the preserved from
duration_ms: 0.2618
...
Subtest: handler: no-op when already on /login (login form stays put)
ok 60 - handler: no-op when already on /login (login form stays put)
duration_ms: 0.230601
...
Subtest: handler: passes an unsafe from through safePostLoginTarget, collapsing to /
ok 61 - handler: passes an unsafe from through safePostLoginTarget, collapsing to /
duration_ms: 0.204511
...
Subtest: handler: every invocation resets the cache before navigating
ok 62 - handler: every invocation resets the cache before navigating
duration_ms: 0.183411
...
Subtest: 401 on /me + path /racks resolves to navigate("/login?from=/racks")
ok 63 - 401 on /me + path /racks resolves to navigate("/login?from=/racks")
duration_ms: 1.291784
...
Subtest: 401 on /me + path /racks?tab=devices preserves the search in from
ok 64 - 401 on /me + path /racks?tab=devices preserves the search in from
duration_ms: 0.211981
...
Subtest: 401 on /me + path /ipam resolves to /login?from=/ipam
ok 65 - 401 on /me + path /ipam resolves to /login?from=/ipam
duration_ms: 0.212111
...
Subtest: authenticated user on /racks is allowed (no redirect)
ok 66 - authenticated user on /racks is allowed (no redirect)
duration_ms: 0.193911
...
Subtest: loading state never redirects (splash renders, no nav)
ok 67 - loading state never redirects (splash renders, no nav)
duration_ms: 0.22114
...
Subtest: splash skeleton markup renders while /me is loading
ok 68 - splash skeleton markup renders while /me is loading
duration_ms: 55.41491
...
Subtest: splash skeleton markup renders while /me is in a 401 error state
ok 69 - splash skeleton markup renders while /me is in a 401 error state
duration_ms: 2.79126
...
Subtest: defaults to false during server rendering
ok 70 - defaults to false during server rendering
duration_ms: 2.943141
...
Subtest: reads the initial media-query match when rendering in the browser
ok 71 - reads the initial media-query match when rendering in the browser
duration_ms: 0.447272
...
Subtest: collection-backed entity lookups find records and return undefined for missing IDs
ok 72 - collection-backed entity lookups find records and return undefined for missing IDs
duration_ms: 29.220736
...
Subtest: parseCsv: empty input returns empty result with no errors
ok 73 - parseCsv: empty input returns empty result with no errors
duration_ms: 1.054755
...
Subtest: parseCsv: plain header + rows without quoting
ok 74 - parseCsv: plain header + rows without quoting
duration_ms: 0.236272
...
Subtest: parseCsv: trims whitespace from header cells
ok 75 - parseCsv: trims whitespace from header cells
duration_ms: 0.22536
...
Subtest: parseCsv: quoted fields preserve embedded commas and newlines
ok 76 - parseCsv: quoted fields preserve embedded commas and newlines
duration_ms: 0.155961
...
Subtest: parseCsv: doubled quotes inside a quoted field decode as a single quote
ok 77 - parseCsv: doubled quotes inside a quoted field decode as a single quote
duration_ms: 0.12516
...
Subtest: parseCsv: handles CRLF line endings
ok 78 - parseCsv: handles CRLF line endings
duration_ms: 0.113349
...
Subtest: parseCsv: trailing newline is tolerated; missing trailing newline is too
ok 79 - parseCsv: trailing newline is tolerated; missing trailing newline is too
duration_ms: 0.18848
...
Subtest: parseCsv: blank lines are silently dropped
ok 80 - parseCsv: blank lines are silently dropped
duration_ms: 0.13039
...
Subtest: parseCsv: row with wrong field count produces an error but still parses
ok 81 - parseCsv: row with wrong field count produces an error but still parses
duration_ms: 0.448392
...
Subtest: parseCsv: reports the source line number on field-count errors
ok 82 - parseCsv: reports the source line number on field-count errors
duration_ms: 0.20173
...
Subtest: parseCsv: single column header-only input has zero data rows
ok 83 - parseCsv: single column header-only input has zero data rows
duration_ms: 0.13067
...
Subtest: parseCsv: header with no following data row returns empty rows
ok 84 - parseCsv: header with no following data row returns empty rows
duration_ms: 0.120741
...
Subtest: toCsv: round-trips simple ASCII data through parseCsv
ok 85 - toCsv: round-trips simple ASCII data through parseCsv
duration_ms: 0.24935
...
Subtest: toCsv: quotes fields containing commas, newlines, or double quotes
ok 86 - toCsv: quotes fields containing commas, newlines, or double quotes
duration_ms: 0.186361
...
Subtest: toCsv: leaves plain ASCII fields unquoted
ok 87 - toCsv: leaves plain ASCII fields unquoted
duration_ms: 0.112531
...
Subtest: toCsv: empty rows produce a header-only CSV
ok 88 - toCsv: empty rows produce a header-only CSV
duration_ms: 0.11039
...
Subtest: toCsv: round-trip preserves quoted fields with commas and quotes
ok 89 - toCsv: round-trip preserves quoted fields with commas and quotes
duration_ms: 0.16223
...
Subtest: unauthenticated GET /api/sites returns 401 envelope
ok 90 - unauthenticated GET /api/sites returns 401 envelope
duration_ms: 837.858536
...
Subtest: unauthenticated POST /api/sites returns 401 envelope
ok 91 - unauthenticated POST /api/sites returns 401 envelope
duration_ms: 691.034465
...
Subtest: unauthenticated GET /api/auth/me returns 401
ok 92 - unauthenticated GET /api/auth/me returns 401
duration_ms: 687.868943
...
Subtest: unauthenticated DELETE /api/devices/:id returns 401
ok 93 - unauthenticated DELETE /api/devices/:id returns 401
duration_ms: 795.185494
...
Subtest: login with wrong password returns 401 envelope
ok 94 - login with wrong password returns 401 envelope
duration_ms: 837.156864
...
Subtest: login with missing fields returns 400 validation envelope
ok 95 - login with missing fields returns 400 validation envelope
duration_ms: 784.108052
...
Subtest: admin login returns session cookie; /me echoes server-derived tenant
ok 96 - admin login returns session cookie; /me echoes server-derived tenant
duration_ms: 835.626291
...
Subtest: GET /api/racks returns only the actor tenant’s racks
ok 97 - GET /api/racks returns only the actor tenant’s racks
duration_ms: 831.331235
...
Subtest: viewer POST /api/sites returns 403 envelope
ok 98 - viewer POST /api/sites returns 403 envelope
duration_ms: 833.311902
...
Subtest: viewer PATCH /api/devices/:id returns 403 envelope
ok 99 - viewer PATCH /api/devices/:id returns 403 envelope
duration_ms: 836.925225
...
Subtest: viewer DELETE /api/cables/:id returns 403 envelope
ok 100 - viewer DELETE /api/cables/:id returns 403 envelope
duration_ms: 841.422263
...
Subtest: viewer can still read (role does not block reads)
ok 101 - viewer can still read (role does not block reads)
duration_ms: 850.152585
...
Subtest: tenant B sees no rows from tenant A on /api/racks
ok 102 - tenant B sees no rows from tenant A on /api/racks
duration_ms: 853.976119
...
Subtest: tenant B cannot PATCH a tenant A device by id (404)
ok 103 - tenant B cannot PATCH a tenant A device by id (404)
duration_ms: 876.426191
...
Subtest: tenant B cannot DELETE a tenant A device by id (404)
ok 104 - tenant B cannot DELETE a tenant A device by id (404)
duration_ms: 665.901427
...
Subtest: tenant B cannot create a prefix pointing at a tenant A vrf (404)
ok 105 - tenant B cannot create a prefix pointing at a tenant A vrf (404)
duration_ms: 764.659097
...
Subtest: tenant B cannot read a tenant A device by id via /api/devices
ok 106 - tenant B cannot read a tenant A device by id via /api/devices
duration_ms: 658.415961
...
Subtest: POST /api/notes records server-derived authorId and tenantId
ok 107 - POST /api/notes records server-derived authorId and tenantId
duration_ms: 629.439786
...
Subtest: validation errors use the same envelope shape
ok 108 - validation errors use the same envelope shape
duration_ms: 622.733392
...
Subtest: not-found errors use the same envelope shape
ok 109 - not-found errors use the same envelope shape
duration_ms: 624.89111
...
Subtest: unauthenticated POST /api/upload returns 401
ok 110 - unauthenticated POST /api/upload returns 401
duration_ms: 572.124537
...
Subtest: concurrent POST /api/cables to the same ports: exactly one wins
ok 111 - concurrent POST /api/cables to the same ports: exactly one wins
duration_ms: 1056.92492
...
Subtest: POST /api/sites round-trips: created site appears in GET /api/sites
ok 112 - POST /api/sites round-trips: created site appears in GET /api/sites
duration_ms: 907.84214
...
Subtest: PATCH /api/racks/:id reflects in subsequent GET
ok 113 - PATCH /api/racks/:id reflects in subsequent GET
duration_ms: 870.052693
...
Subtest: POST /api/notes: server-derived actor survives body-spoof attempts
ok 114 - POST /api/notes: server-derived actor survives body-spoof attempts
duration_ms: 844.903643
...
Subtest: POST /api/notes with empty body fails validation (envelope)
ok 115 - POST /api/notes with empty body fails validation (envelope)
duration_ms: 837.632656
...
Subtest: DELETE /api/devices/:id frees its addresses (no dangling assignedPortId)
ok 116 - DELETE /api/devices/:id frees its addresses (no dangling assignedPortId)
duration_ms: 884.493157
...
Subtest: DELETE /api/cables/:id nulls cableId on both endpoints
ok 117 - DELETE /api/cables/:id nulls cableId on both endpoints
duration_ms: 860.49407
...
Subtest: POST /api/cables returns 409 when one endpoint is already in another cable
ok 118 - POST /api/cables returns 409 when one endpoint is already in another cable
duration_ms: 860.531201
...
Subtest: POST /api/device-templates then POST /api/devices creates ports from template
ok 119 - POST /api/device-templates then POST /api/devices creates ports from template
duration_ms: 858.428444
...
Subtest: POST /api/devices with a foreign-tenant template returns 404
ok 120 - POST /api/devices with a foreign-tenant template returns 404
duration_ms: 844.059843
...
Subtest: GET /healthz returns the documented shape on a fresh DB
ok 121 - GET /healthz returns the documented shape on a fresh DB
duration_ms: 780.053941
...
Subtest: GET /healthz is unauthenticated and does not echo cookies
ok 122 - GET /healthz is unauthenticated and does not echo cookies
duration_ms: 683.785402
...
Subtest: POST /api/devices rejects a U-range that overlaps an existing device on the same face
ok 123 - POST /api/devices rejects a U-range that overlaps an existing device on the same face
duration_ms: 855.091343
...
Subtest: POST /api/devices allows same U-range on the opposite face
ok 124 - POST /api/devices allows same U-range on the opposite face
duration_ms: 749.916831
...
Subtest: POST /api/reservations rejects a U-range that overlaps an existing device
ok 125 - POST /api/reservations rejects a U-range that overlaps an existing device
duration_ms: 735.88599
...
Subtest: Concurrent POST /api/devices on the same U-range: only one wins
ok 126 - Concurrent POST /api/devices on the same U-range: only one wins
duration_ms: 747.881715
...
Subtest: Concurrent POST /api/cables to the same port: only one wins
ok 127 - Concurrent POST /api/cables to the same port: only one wins
duration_ms: 746.26027
...
Subtest: GET /api/auth/me returns onboardingCompletedAt (initially null)
ok 128 - GET /api/auth/me returns onboardingCompletedAt (initially null)
duration_ms: 800.855843
...
Subtest: POST /api/auth/login returns onboardingCompletedAt (initially null)
ok 129 - POST /api/auth/login returns onboardingCompletedAt (initially null)
duration_ms: 846.702449
...
Subtest: PATCH /api/users/:id with ISO timestamp persists and is reflected on /me
ok 130 - PATCH /api/users/:id with ISO timestamp persists and is reflected on /me
duration_ms: 856.143124
...
Subtest: PATCH /api/users/:id with null resets the flag (so the tour can replay)
ok 131 - PATCH /api/users/:id with null resets the flag (so the tour can replay)
duration_ms: 741.92707
...
Subtest: PATCH /api/users/:id accepts a no-op empty body and returns the row
ok 132 - PATCH /api/users/:id accepts a no-op empty body and returns the row
duration_ms: 840.508568
...
Subtest: PATCH /api/users/:id with non-ISO string returns 400 validation envelope
ok 133 - PATCH /api/users/:id with non-ISO string returns 400 validation envelope
duration_ms: 838.842732
...
Subtest: PATCH /api/users/:id with empty string returns 400 validation envelope
ok 134 - PATCH /api/users/:id with empty string returns 400 validation envelope
duration_ms: 835.734302
...
Subtest: PATCH on another user in the same tenant returns 404 (self-only)
ok 135 - PATCH on another user in the same tenant returns 404 (self-only)
duration_ms: 894.120233
...
Subtest: PATCH cross-tenant returns 404 (not 403 — no existence leak)
ok 136 - PATCH cross-tenant returns 404 (not 403 — no existence leak)
duration_ms: 896.492473
...
Subtest: PATCH on non-existent user id returns 404
ok 137 - PATCH on non-existent user id returns 404
duration_ms: 850.227615
...
Subtest: unauthenticated PATCH /api/users/:id returns 401
ok 138 - unauthenticated PATCH /api/users/:id returns 401
duration_ms: 793.38493
...
Subtest: the tenant-store mirrors the current me query and every later cache change
ok 139 - the tenant-store mirrors the current me query and every later cache change
duration_ms: 1.453915
...
1..139
tests 139
suites 0
pass 139
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 17103.532486: 139 / 139 pass.
TAP version 13
Subtest: unauthenticated GET /api/sites returns 401 envelope
ok 1 - unauthenticated GET /api/sites returns 401 envelope
duration_ms: 826.058212
...
Subtest: unauthenticated POST /api/sites returns 401 envelope
ok 2 - unauthenticated POST /api/sites returns 401 envelope
duration_ms: 792.49017
...
Subtest: unauthenticated GET /api/auth/me returns 401
ok 3 - unauthenticated GET /api/auth/me returns 401
duration_ms: 783.255046
...
Subtest: unauthenticated DELETE /api/devices/:id returns 401
ok 4 - unauthenticated DELETE /api/devices/:id returns 401
duration_ms: 686.762506
...
Subtest: login with wrong password returns 401 envelope
ok 5 - login with wrong password returns 401 envelope
duration_ms: 852.393347
...
Subtest: login with missing fields returns 400 validation envelope
ok 6 - login with missing fields returns 400 validation envelope
duration_ms: 685.380841
...
Subtest: admin login returns session cookie; /me echoes server-derived tenant
ok 7 - admin login returns session cookie; /me echoes server-derived tenant
duration_ms: 836.1321
...
Subtest: GET /api/racks returns only the actor tenant’s racks
ok 8 - GET /api/racks returns only the actor tenant’s racks
duration_ms: 836.356161
...
Subtest: viewer POST /api/sites returns 403 envelope
ok 9 - viewer POST /api/sites returns 403 envelope
duration_ms: 748.20642
...
Subtest: viewer PATCH /api/devices/:id returns 403 envelope
ok 10 - viewer PATCH /api/devices/:id returns 403 envelope
duration_ms: 739.63103
...
Subtest: viewer DELETE /api/cables/:id returns 403 envelope
ok 11 - viewer DELETE /api/cables/:id returns 403 envelope
duration_ms: 751.966515
...
Subtest: viewer can still read (role does not block reads)
ok 12 - viewer can still read (role does not block reads)
duration_ms: 856.861427
...
Subtest: tenant B sees no rows from tenant A on /api/racks
ok 13 - tenant B sees no rows from tenant A on /api/racks
duration_ms: 839.699335
...
Subtest: tenant B cannot PATCH a tenant A device by id (404)
ok 14 - tenant B cannot PATCH a tenant A device by id (404)
duration_ms: 769.47568
...
Subtest: tenant B cannot DELETE a tenant A device by id (404)
ok 15 - tenant B cannot DELETE a tenant A device by id (404)
duration_ms: 786.025341
...
Subtest: tenant B cannot create a prefix pointing at a tenant A vrf (404)
ok 16 - tenant B cannot create a prefix pointing at a tenant A vrf (404)
duration_ms: 773.526846
...
Subtest: tenant B cannot read a tenant A device by id via /api/devices
ok 17 - tenant B cannot read a tenant A device by id via /api/devices
duration_ms: 665.472422
...
Subtest: POST /api/notes records server-derived authorId and tenantId
ok 18 - POST /api/notes records server-derived authorId and tenantId
duration_ms: 623.182668
...
Subtest: validation errors use the same envelope shape
ok 19 - validation errors use the same envelope shape
duration_ms: 626.737503
...
Subtest: not-found errors use the same envelope shape
ok 20 - not-found errors use the same envelope shape
duration_ms: 620.431299
...
Subtest: unauthenticated POST /api/upload returns 401
ok 21 - unauthenticated POST /api/upload returns 401
duration_ms: 570.717918
...
Subtest: concurrent POST /api/cables to the same ports: exactly one wins
ok 22 - concurrent POST /api/cables to the same ports: exactly one wins
duration_ms: 1156.323722
...
Subtest: POST /api/sites round-trips: created site appears in GET /api/sites
ok 23 - POST /api/sites round-trips: created site appears in GET /api/sites
duration_ms: 906.855225
...
Subtest: PATCH /api/racks/:id reflects in subsequent GET
ok 24 - PATCH /api/racks/:id reflects in subsequent GET
duration_ms: 850.08888
...
Subtest: POST /api/notes: server-derived actor survives body-spoof attempts
ok 25 - POST /api/notes: server-derived actor survives body-spoof attempts
duration_ms: 869.57768
...
Subtest: POST /api/notes with empty body fails validation (envelope)
ok 26 - POST /api/notes with empty body fails validation (envelope)
duration_ms: 862.749976
...
Subtest: DELETE /api/devices/:id frees its addresses (no dangling assignedPortId)
ok 27 - DELETE /api/devices/:id frees its addresses (no dangling assignedPortId)
duration_ms: 783.192927
...
Subtest: DELETE /api/cables/:id nulls cableId on both endpoints
ok 28 - DELETE /api/cables/:id nulls cableId on both endpoints
duration_ms: 745.885682
...
Subtest: POST /api/cables returns 409 when one endpoint is already in another cable
ok 29 - POST /api/cables returns 409 when one endpoint is already in another cable
duration_ms: 753.051348
...
Subtest: POST /api/device-templates then POST /api/devices creates ports from template
ok 30 - POST /api/device-templates then POST /api/devices creates ports from template
duration_ms: 866.869783
...
Subtest: POST /api/devices with a foreign-tenant template returns 404
ok 31 - POST /api/devices with a foreign-tenant template returns 404
duration_ms: 846.136207
...
Subtest: GET /healthz returns the documented shape on a fresh DB
ok 32 - GET /healthz returns the documented shape on a fresh DB
duration_ms: 778.347901
...
Subtest: GET /healthz is unauthenticated and does not echo cookies
ok 33 - GET /healthz is unauthenticated and does not echo cookies
duration_ms: 787.198234
...
Subtest: POST /api/devices rejects a U-range that overlaps an existing device on the same face
ok 34 - POST /api/devices rejects a U-range that overlaps an existing device on the same face
duration_ms: 848.342326
...
Subtest: POST /api/devices allows same U-range on the opposite face
ok 35 - POST /api/devices allows same U-range on the opposite face
duration_ms: 744.5841
...
Subtest: POST /api/reservations rejects a U-range that overlaps an existing device
ok 36 - POST /api/reservations rejects a U-range that overlaps an existing device
duration_ms: 736.760532
...
Subtest: Concurrent POST /api/devices on the same U-range: only one wins
ok 37 - Concurrent POST /api/devices on the same U-range: only one wins
duration_ms: 651.510751
...
Subtest: Concurrent POST /api/cables to the same port: only one wins
ok 38 - Concurrent POST /api/cables to the same port: only one wins
duration_ms: 738.575549
...
Subtest: GET /api/auth/me returns onboardingCompletedAt (initially null)
ok 39 - GET /api/auth/me returns onboardingCompletedAt (initially null)
duration_ms: 902.607411
...
Subtest: POST /api/auth/login returns onboardingCompletedAt (initially null)
ok 40 - POST /api/auth/login returns onboardingCompletedAt (initially null)
duration_ms: 741.045174
...
Subtest: PATCH /api/users/:id with ISO timestamp persists and is reflected on /me
ok 41 - PATCH /api/users/:id with ISO timestamp persists and is reflected on /me
duration_ms: 742.360527
...
Subtest: PATCH /api/users/:id with null resets the flag (so the tour can replay)
ok 42 - PATCH /api/users/:id with null resets the flag (so the tour can replay)
duration_ms: 862.496824
...
Subtest: PATCH /api/users/:id accepts a no-op empty body and returns the row
ok 43 - PATCH /api/users/:id accepts a no-op empty body and returns the row
duration_ms: 835.034675
...
Subtest: PATCH /api/users/:id with non-ISO string returns 400 validation envelope
ok 44 - PATCH /api/users/:id with non-ISO string returns 400 validation envelope
duration_ms: 832.894778
...
Subtest: PATCH /api/users/:id with empty string returns 400 validation envelope
ok 45 - PATCH /api/users/:id with empty string returns 400 validation envelope
duration_ms: 837.283245
...
Subtest: PATCH on another user in the same tenant returns 404 (self-only)
ok 46 - PATCH on another user in the same tenant returns 404 (self-only)
duration_ms: 915.56324
...
Subtest: PATCH cross-tenant returns 404 (not 403 — no existence leak)
ok 47 - PATCH cross-tenant returns 404 (not 403 — no existence leak)
duration_ms: 782.994747
...
Subtest: PATCH on non-existent user id returns 404
ok 48 - PATCH on non-existent user id returns 404
duration_ms: 835.60445
...
Subtest: unauthenticated PATCH /api/users/:id returns 401
ok 49 - unauthenticated PATCH /api/users/:id returns 401
duration_ms: 784.902115
...
1..49
tests 49
suites 0
pass 49
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 15887.727963: 49 / 49 pass.
vite v6.4.3 building for production...
transforming...
✓ 2077 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.62 kB │ gzip: 0.37 kB
dist/assets/index-BLxxzSDg.css 88.16 kB │ gzip: 14.85 kB
dist/assets/racks-CYz9S71k.js 0.10 kB │ gzip: 0.11 kB
dist/assets/chevron-right-DEV12weI.js 0.30 kB │ gzip: 0.25 kB
dist/assets/plus-C1dX7nvD.js 0.32 kB │ gzip: 0.25 kB
dist/assets/activity-DU-Y37Py.js 0.40 kB │ gzip: 0.30 kB
dist/assets/circle-alert-g6ADvJii.js 0.42 kB │ gzip: 0.29 kB
dist/assets/shield-check-DD65NP31.js 0.49 kB │ gzip: 0.35 kB
dist/assets/trash-2-F5HxQdjy.js 0.53 kB │ gzip: 0.35 kB
dist/assets/input-CZpAyFWA.js 0.67 kB │ gzip: 0.38 kB
dist/assets/empty-state-DxHRMxdz.js 0.77 kB │ gzip: 0.38 kB
dist/assets/tenant-scope-CajR8jTn.js 0.77 kB │ gzip: 0.39 kB
dist/assets/card-D2S6rHW2.js 0.87 kB │ gzip: 0.35 kB
dist/assets/editor-store-hXO29wzv.js 1.06 kB │ gzip: 0.31 kB
dist/assets/create-site-dialog-Sy5TmO9K.js 1.69 kB │ gzip: 0.87 kB
dist/assets/tabs-BOH-DVFZ.js 3.38 kB │ gzip: 1.39 kB
dist/assets/templates-CD9d-KS8.js 4.09 kB │ gzip: 1.57 kB
dist/assets/login-C3djLKgr.js 4.20 kB │ gzip: 1.94 kB
dist/assets/settings-DyShNfq_.js 4.73 kB │ gzip: 1.68 kB
dist/assets/racks.index-BquEtWPr.js 4.97 kB │ gzip: 1.84 kB
dist/assets/topology-ffIlWZ95.js 7.85 kB │ gzip: 2.69 kB
dist/assets/entity-history-panel-cIaVeuS2.js 9.80 kB │ gzip: 3.06 kB
dist/assets/index-CEJgBCxQ.js 11.73 kB │ gzip: 4.03 kB
dist/assets/patches-CumJkDNQ.js 15.81 kB │ gzip: 4.88 kB
dist/assets/ipam-DEQqYuDC.js 21.33 kB │ gzip: 5.83 kB
dist/assets/select-Cj7Z0mwf.js 21.91 kB │ gzip: 7.57 kB
dist/assets/label-D6On0u1T.js 29.38 kB │ gzip: 10.92 kB
dist/assets/racks._rackId-KmFZdAO1.js 89.38 kB │ gzip: 22.95 kB
dist/assets/floorplan-D4-gL_TZ.js 308.78 kB │ gzip: 95.16 kB
dist/assets/index-Kz-I1LJD.js 554.50 kB │ gzip: 169.82 kB
✓ built in 4.40s: succeeds; no block, no , no reference leaks into .
Out of scope
Refs
Closes NUL-231. Children: NUL-271 (this is the implementation of the parent task). Refs NUL-218.
Handoff