T23: account page (disconnect everything, hard delete) #22

Merged
dzenda merged 34 commits from t23-account into t22-calendar 2026-11-14 17:49:45 +01:00
Owner

T23: Account (disconnect everything / hard delete)

Stacked on #21 (t22-calendar) → … → #1. Merge those first.

What

  • /account (en/cs): profile (name, FIT username, e-mail) and a danger zone with two forms. Each requires typing your FIT username, a session, and a same-origin Origin.
    • Disconnect everything: revokes GitLab OAuth tokens at GitLab (best effort; PATs can't be revoked by us), deletes all upstream connections, and revokes every MCP client grant. The account stays.
    • Delete account (hard delete, SPEC §7): everything above, then deletes the users row (sessions, accounts and connections cascade), clears the session cookies (plain and __Secure-), and redirects to /?deleted=true with a confirmation banner.
  • Exhaustive cleanup: revokeAllUserGrants revokes until nothing is left (there's no page cap). If grants can't all be revoked, deletion aborts and the account is kept, so you can retry. Nothing is half-deleted.
  • Race safety (an approval in flight while the account is deleted):
    • after deleting the user, a final revoke pass runs;
    • /authorize re-checks after completeAuthorization that the user still exists, and undoes the grant if not;
    • the MCP handler rejects tokens whose user row is gone and revokes those grants, so any orphan heals itself on first use.
  • Your name in the header links to /account, and an always-visible Account nav item covers mobile.
  • Test-infra fix: the SQLite test proxy answered an empty get with []. Drizzle turns that into a truthy all-undefined object, so findFirst "found" missing rows. It now returns undefined, like D1. All 259 tests still pass, so nothing else depended on it.

Verified

  • bun run lint && bun run typecheck && bun run test: 259 tests, 8 of them new.
    • Deletion: removes the user, sessions, accounts, connections and grants while another user stays untouched; GitLab revoke is called; cookies are cleared; confirmation, origin and session checks; the account is kept when revocation fails; cleanup goes beyond 2,000 grants.
    • Disconnect-all: keeps the account; a PAT isn't revoked remotely.
    • MCP: a deleted user's token → 401 and the orphaned grant is revoked; an approval racing deletion is undone.
  • Dev server, signed in: /account renders with both forms; a wrong confirmation → ?status=confirm_mismatch; cross-origin → 403. Your local data was verified intact afterwards. /?deleted=true shows the banner, and /mcp without a token → 401.
  • Found and fixed while smoke-testing: ?deleted=1 triggered a TanStack search-canonicalisation 307, so it now uses ?deleted=true.

Astra review (gpt-6-astra), 2 rounds

Fixed:

  1. (P2) Cleanup used the 20-page cap, so it could delete the user while grants remained → exhaustive revoke, aborting otherwise.
  2. (P2) An approval racing deletion could leave an orphan grant → post-approval check, a final revoke pass, and MCP-side orphan rejection and revocation.
  3. (P2, round 2) The account link was hidden on mobile → nav item.

Round 2 found no further backend defects.

🤖 Generated with Claude Code

## T23: Account (disconnect everything / hard delete) **Stacked on #21 (`t22-calendar`) → … → #1.** Merge those first. ### What - **`/account`** (en/cs): profile (name, FIT username, e-mail) and a **danger zone** with two forms. Each requires **typing your FIT username**, a session, and a same-origin `Origin`. - **Disconnect everything:** revokes GitLab OAuth tokens at GitLab (best effort; PATs can't be revoked by us), deletes all upstream connections, and **revokes every MCP client grant**. The account stays. - **Delete account (hard delete, SPEC §7):** everything above, then deletes the `users` row (sessions, accounts and connections cascade), clears the session cookies (plain and `__Secure-`), and redirects to `/?deleted=true` with a confirmation banner. - **Exhaustive cleanup:** `revokeAllUserGrants` revokes until nothing is left (there's no page cap). If grants can't all be revoked, deletion **aborts and the account is kept**, so you can retry. Nothing is half-deleted. - **Race safety** (an approval in flight while the account is deleted): - after deleting the user, a final revoke pass runs; - `/authorize` re-checks after `completeAuthorization` that the user still exists, and undoes the grant if not; - **the MCP handler rejects tokens whose user row is gone and revokes those grants**, so any orphan heals itself on first use. - Your name in the header links to `/account`, and an always-visible **Account** nav item covers mobile. - **Test-infra fix:** the SQLite test proxy answered an empty `get` with `[]`. Drizzle turns that into a truthy all-undefined object, so `findFirst` "found" missing rows. It now returns `undefined`, like D1. All 259 tests still pass, so nothing else depended on it. ### Verified - `bun run lint && bun run typecheck && bun run test`: 259 tests, 8 of them new. - **Deletion:** removes the user, sessions, accounts, connections and grants while **another user stays untouched**; GitLab revoke is called; cookies are cleared; confirmation, origin and session checks; the account is kept when revocation fails; cleanup goes **beyond 2,000 grants**. - **Disconnect-all:** keeps the account; a PAT isn't revoked remotely. - **MCP:** a deleted user's token → 401 and the orphaned grant is revoked; an approval racing deletion is undone. - **Dev server, signed in:** `/account` renders with both forms; a wrong confirmation → `?status=confirm_mismatch`; cross-origin → 403. Your local data was verified intact afterwards. `/?deleted=true` shows the banner, and `/mcp` without a token → 401. - Found and fixed while smoke-testing: `?deleted=1` triggered a TanStack search-canonicalisation 307, so it now uses `?deleted=true`. ### Astra review (gpt-6-astra), 2 rounds **Fixed:** 1. *(P2)* Cleanup used the 20-page cap, so it could delete the user while grants remained → exhaustive revoke, aborting otherwise. 2. *(P2)* An approval racing deletion could leave an orphan grant → post-approval check, a final revoke pass, and MCP-side orphan rejection and revocation. 3. *(P2, round 2)* The account link was hidden on mobile → nav item. Round 2 found no further backend defects. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ALLOWED_USERS enforced for MCP too (shared policy); excluded users' grants revoked on use
- telemetry: streaming 512-byte cap before parsing
- POST /oauth/register rate-limited per IP and instance-wide (Workers rate-limit bindings)
- FIT API bodies capped at 2 MB (streaming); Grades tree walk iterative and bounded
- readBounded moved to src/lib/bounded.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logos: FIT service marks vendored in public/services (see README there); assistant marks
from @lobehub/icons (MIT), simple-icons (CC0) and devicon (MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- service_settings table (migration 0002); MCP calls to a switched-off service fail with
  service_disabled, aggregates skip it with a warning, fit.status lists it
- setServiceEnabledFn: same-origin + session only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fit.sirius.courseEvents: every parallel of a course (room, teachers, capacity/occupied),
  shared paging helper with fit.sirius.events; respects the Sirius MCP switch
- getCourseWeek server fn for the calendar overlay; upcoming deadlines (60 days) beside the week

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- layoutLanes/pragueMinutes in src/lib/calendar.ts (tested)
- shared EventDetails popover; agenda list stays on mobile
- agentSetupPrompt in src/mcp/setup.ts (tested)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Referrer-Policy: same-origin (no-referrer made browsers send Origin: null on our form POSTs,
  so every management form was rejected with 403); shared isSameOriginRequest accepts
  Origin: null only with Sec-Fetch-Site: same-origin
- No COOP on OAuth/consent/login routes (popup-based web MCP clients keep window.opener)
- fit.sirius.icalUrl is websiteOnly: excluded from the MCP registry (URL embeds a credential)
- docs: telemetry wording (operator-enabled, consent advice), CSRF/COOP/credential notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #25
Reviewed-on: #24
Reviewed-on: #23
dzenda merged commit 394f05368b into t22-calendar 2026-11-14 17:49:45 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
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
dzenda/os!22
No description provided.