Skip to content

NUL-231: dev-only Vite proxy + same-origin VITE_API_URL - #4

Open
NulledAgent wants to merge 5 commits into
feat/nul-217-auth-flowfrom
feat/nul-231-vite-api-proxy
Open

NUL-231: dev-only Vite proxy + same-origin VITE_API_URL#4
NulledAgent wants to merge 5 commits into
feat/nul-217-auth-flowfrom
feat/nul-231-vite-api-proxy

Conversation

@NulledAgent

Copy link
Copy Markdown

Summary

Fixes the dev-mode cross-origin cookie gap that NUL-218 surfaced.

  • : add → so cookie-bearing fetches issued from the Vite dev server (port 5173) are forwarded server-side and arrive at the browser as same-origin. Modern browsers treat and as different origins; without this proxy every cross-origin fetch is preflight-blocked by CORS, even when Lax cookies are set on .
  • (new, dev-only): sets so the client ( → ) targets the Vite origin and routes through the proxy. Production builds do not read this file — they use the existing fallback in , so production behaviour is unchanged.

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 —

ipam@0.1.0 prebuild
tsr generate

ipam@0.1.0 build
tsc -b && vite build

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

ipam@0.1.0 pretypecheck
tsr generate

ipam@0.1.0 typecheck
tsc -b (): clean.

ipam@0.1.0 test
node scripts/_run-tests.mjs

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.

ipam@0.1.0 test:server
node --import tsx --test src/server/tests/*.test.ts

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.

ipam@0.1.0 prebuild
tsr generate

ipam@0.1.0 build
tsc -b && vite build

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 .

  • End-to-end round-trip with a stub backend on :
    • Browser-equivalent → stub logged and returned .
    • Preflight from returns with (Vite injects CORS headers on proxy routes).
    • Non- paths still hit the SPA HTML (proxy is path-scoped to ).

Out of scope

  • Adding Hono CORS middleware for true cross-origin dev (a separate Kernel task — would touch prod config).
  • Touching the API code.
  • Changing the Vite port or host.

Refs

Closes NUL-231. Children: NUL-271 (this is the implementation of the parent task). Refs NUL-218.

Handoff

  • Author: Forge (this PR).
  • Reviewer: Sentinel (per Agent collaboration directory).
  • Merge: Relay, after Sentinel approval and a green CI on the PR head SHA.
  • Deploy: Not required — this is a dev-only change. Production stays on the existing nginx same-origin path.

hermes-agent and others added 4 commits July 27, 2026 20:42
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.
@NulledAgent

Copy link
Copy Markdown
Author

NUL-230 fix pushed on commit 0e01734

Issue: NUL-230 ([NUL-218] /api/auth/logout 204 does not clear the session cookie)

Root cause: authApp.post('/logout', (c) => { clearSessionCookie(c); return new Response(null, { status: 204 }) }) in src/server/auth.ts:317-321 discarded the prepared c.res.headers (which contained the Set-Cookie: ipam_session=; Max-Age=0; Path=/ line that clearSessionCookie had scheduled). 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.

Fix (commit 0e01734, on top of de7e341): replace the bare new Response(null, { status: 204 }) with c.body(null, 204) so the prepared Set-Cookie header is actually included in the 204 response.

 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:

  • Before fix: POST /api/auth/logout returns 204 No Content with no Set-Cookie header
  • After fix: POST /api/auth/logout returns 204 No Content with set-cookie: ipam_session=; Max-Age=0; Path=/
  • Then GET /api/auth/me with no cookie returns 401 ✓ (was 200 before)

Tests added (3 new in src/server/__tests__/auth-and-tenant.test.ts):

  1. POST /api/auth/logout returns 204 with a Set-Cookie that expires ipam_session
  2. GET /api/auth/me after /api/auth/logout returns 401 to clients that drop the cookie
  3. POST /api/auth/login still works after the logout fix (regression check)

All 142 tests pass (npm test); npm run typecheck and npm run typecheck:server are clean.

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 feat/nul-217-auth-flow and that branch merges to main, NUL-218 can be re-run against the local stack to verify the full login → me → logout → me (401) round-trip.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants