Last page: check the target, and let the server write the cookie #191

Closed
opened 2026-09-18 17:12:50 +00:00 by Grandiras · 2 comments
Owner

Part of #190. Reopening where you left off (#186, Services/LastPage.cs, /launch) works — checked on the dev stack — but it has two defects and has never been released: production is on v2.6.0, where /launch is a 404 and the manifest still says start_url: "/".

1. The target is not checked. /launch redirects to whatever path the cookie holds. If that is /board/7 and board 7 has since been deleted or taken away from this member, every launch opens on board.missing / board.denied until they navigate somewhere else — the one page in the app that is a dead end, as the first thing they see. For a path ShareLink reads as board-scoped, the endpoint should build a BoardViewer from the cookie principal the way the .ics endpoint does, ask BoardService.CanViewAsync, and fall back to ReturnUrl.Default. A visitor who is not signed in passes through untouched: the page's own authorization asks them to sign in, carrying the destination, as now.

2. The cookie is written by script, so on an iPhone the month is a week. WebKit caps the lifetime of anything set through document.cookie at 7 days, whatever max-age says — and every browser on iOS, installed apps included, is WebKit. A cookie the server sets is not capped. So: a small POST endpoint beside /launch that takes the path, runs it through LastPage.Remember (still the one rule in both directions) and answers 204 with Set-Cookie — HttpOnly, SameSite=Lax, the same 30 days. rememberPage in prefs-interop.js becomes a fetch with keepalive: true so a navigation that closes the page does not cancel it. The endpoint should refuse anything but Sec-Fetch-Site: same-origin; the cookie is harmless, but nothing off-site has a reason to set it.

3. The test does what no member does. prefs.spec.js proves goto('/launch') follows the cookie. Add: a launch onto a board that was deleted lands somewhere real, and the cookie a navigation leaves behind is HttpOnly with roughly a month to run.

Update Reopening where you left off in docs/ui.md — the "a cookie, not localStorage" bullet is still right, but who writes it and why changes. Once merged this wants a release, so the feature exists on production before the home page builds on it.

Not here: / itself resuming. That rule only makes sense once / is the home page and stops being the default board's address — it belongs to the home-page step in #190.

Part of #190. Reopening where you left off (#186, `Services/LastPage.cs`, `/launch`) works — checked on the dev stack — but it has two defects and has never been released: production is on v2.6.0, where `/launch` is a 404 and the manifest still says `start_url: "/"`. **1. The target is not checked.** `/launch` redirects to whatever path the cookie holds. If that is `/board/7` and board 7 has since been deleted or taken away from this member, every launch opens on `board.missing` / `board.denied` until they navigate somewhere else — the one page in the app that is a dead end, as the first thing they see. For a path `ShareLink` reads as board-scoped, the endpoint should build a `BoardViewer` from the cookie principal the way the `.ics` endpoint does, ask `BoardService.CanViewAsync`, and fall back to `ReturnUrl.Default`. A visitor who is not signed in passes through untouched: the page's own authorization asks them to sign in, carrying the destination, as now. **2. The cookie is written by script, so on an iPhone the month is a week.** WebKit caps the lifetime of anything set through `document.cookie` at 7 days, whatever `max-age` says — and every browser on iOS, installed apps included, is WebKit. A cookie the *server* sets is not capped. So: a small `POST` endpoint beside `/launch` that takes the path, runs it through `LastPage.Remember` (still the one rule in both directions) and answers 204 with `Set-Cookie` — `HttpOnly`, `SameSite=Lax`, the same 30 days. `rememberPage` in `prefs-interop.js` becomes a `fetch` with `keepalive: true` so a navigation that closes the page does not cancel it. The endpoint should refuse anything but `Sec-Fetch-Site: same-origin`; the cookie is harmless, but nothing off-site has a reason to set it. **3. The test does what no member does.** `prefs.spec.js` proves `goto('/launch')` follows the cookie. Add: a launch onto a board that was deleted lands somewhere real, and the cookie a navigation leaves behind is `HttpOnly` with roughly a month to run. Update *Reopening where you left off* in `docs/ui.md` — the "a cookie, not localStorage" bullet is still right, but who writes it and why changes. Once merged this wants a release, so the feature exists on production before the home page builds on it. **Not here:** `/` itself resuming. That rule only makes sense once `/` is the home page and stops being the default board's address — it belongs to the home-page step in #190.
Collaborator

🤖 On it — the reply will link the full session transcript.

🤖 On it — the reply will link the full session transcript. <!-- opencode-run:task -->
Collaborator

Both defects are fixed and verified, committed on opencode/issue-191 (fe3d09e).

1. The target is checked. /launch in Program.cs now builds a BoardViewer from the cookie principal the way the .ics endpoint does and asks BoardService.CanViewAsync about any remembered path ShareLink.TryParse reads as board-scoped (/board/7, /calendar/board/7); a board that is gone or no longer this member's falls back to ReturnUrl.Default. A visitor who is not signed in passes through untouched, so the page's own authorization still carries the destination. Verified with curl against a running app: signed-in launch with a cookie for a nonexistent board → 302 to /; live board → kept; anonymous → kept.

2. The server writes the cookie. New POST /launch/remember (LastPage.RememberRoute) takes the path, runs it through LastPage.Remember — still the one rule in both directions — and answers 204 with Set-Cookie: HttpOnly, SameSite=Lax, 30 days (LastPage.CookieLifetime). It refuses anything but Sec-Fetch-Site: same-origin; in practice the antiforgery middleware already 400s a cross-site POST before the endpoint is reached, so the header check is the second gate. rememberPage in prefs-interop.js is now a fetch with keepalive: true and no longer knows the cookie's name, flags or lifetime. Verified: same-origin POST → 204 + Set-Cookie: ponente-last-page=%2Fboard%2F3; max-age=2592000; path=/; samesite=lax; httponly; a door path (/login) → 204 with no cookie.

3. Tests. prefs.spec.js gained two tests: the cookie a navigation leaves behind is HttpOnly with roughly a month to run, and a launch onto a board that was deleted lands on the default board rather than board.missing (the stale cookie is restored by hand after the deletion goes through /admin/boards, which is itself a remembered page). All 6 tests in the file pass, plus the full unit suite (477 passed, including DocumentationTests).

Also updated: the Reopening where you left off section in docs/ui.md (who writes the cookie and why, the target check, the test list), and the Changelog.Unreleased bullet in both locales — amended rather than added to, since the feature has never shipped.

On verification setup: no container engine exists in this environment, so instead of the compose stack I ran the app directly (dotnet run, Development, port 8090) against a local Postgres 15 with the same connection string, and ran Playwright against it. The e2e run's cleanup helper is disabled without podman/docker, so I removed the boards the failed intermediate runs left behind directly in the database; the final passing run cleaned up after itself.

One note for after merge: this wants a release (production is on v2.6.0, where /launch is a 404) — that's the tag flow in the release skill, deliberately not done here.

→ Opened PR: #200


⏱ 18m 10s · 82 steps · $0.1988 · tokens: 499.7k in / 22.3k out (+3757.6k cache-read) · Go: 5h 27% · wk 35% · mo 20%

📜 Full session transcript

Both defects are fixed and verified, committed on `opencode/issue-191` (fe3d09e). **1. The target is checked.** `/launch` in `Program.cs` now builds a `BoardViewer` from the cookie principal the way the `.ics` endpoint does and asks `BoardService.CanViewAsync` about any remembered path `ShareLink.TryParse` reads as board-scoped (`/board/7`, `/calendar/board/7`); a board that is gone or no longer this member's falls back to `ReturnUrl.Default`. A visitor who is not signed in passes through untouched, so the page's own authorization still carries the destination. Verified with curl against a running app: signed-in launch with a cookie for a nonexistent board → 302 to `/`; live board → kept; anonymous → kept. **2. The server writes the cookie.** New `POST /launch/remember` (`LastPage.RememberRoute`) takes the path, runs it through `LastPage.Remember` — still the one rule in both directions — and answers 204 with `Set-Cookie`: `HttpOnly`, `SameSite=Lax`, 30 days (`LastPage.CookieLifetime`). It refuses anything but `Sec-Fetch-Site: same-origin`; in practice the antiforgery middleware already 400s a cross-site POST before the endpoint is reached, so the header check is the second gate. `rememberPage` in `prefs-interop.js` is now a `fetch` with `keepalive: true` and no longer knows the cookie's name, flags or lifetime. Verified: same-origin POST → 204 + `Set-Cookie: ponente-last-page=%2Fboard%2F3; max-age=2592000; path=/; samesite=lax; httponly`; a door path (`/login`) → 204 with no cookie. **3. Tests.** `prefs.spec.js` gained two tests: the cookie a navigation leaves behind is `HttpOnly` with roughly a month to run, and a launch onto a board that was deleted lands on the default board rather than `board.missing` (the stale cookie is restored by hand after the deletion goes through `/admin/boards`, which is itself a remembered page). All 6 tests in the file pass, plus the full unit suite (477 passed, including `DocumentationTests`). Also updated: the *Reopening where you left off* section in `docs/ui.md` (who writes the cookie and why, the target check, the test list), and the `Changelog.Unreleased` bullet in both locales — amended rather than added to, since the feature has never shipped. On verification setup: no container engine exists in this environment, so instead of the compose stack I ran the app directly (`dotnet run`, Development, port 8090) against a local Postgres 15 with the same connection string, and ran Playwright against it. The e2e run's cleanup helper is disabled without podman/docker, so I removed the boards the failed intermediate runs left behind directly in the database; the final passing run cleaned up after itself. One note for after merge: this wants a release (production is on v2.6.0, where `/launch` is a 404) — that's the tag flow in the release skill, deliberately not done here. → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/200 --- ⏱ 18m 10s · 82 steps · $0.1988 · tokens: 499.7k in / 22.3k out (+3757.6k cache-read) · Go: 5h 27% · wk 35% · mo 20% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/issue-191/run-2026-09-18T18-23-13-954Z.md) <!-- opencode-session:ses_f4a3ddc7affe0eaKzmvXnR0o3G -->
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#191
No description provided.