Security and frontend-correctness hardening: analytics IDOR fix, scraper
log scrubbing, lowercase email normalisation (migration 009), stale-response
guards in the plan hooks, busy flags cleared unconditionally across all
useLatestRequest consumers, Calendar no longer blanks on mutation, plus
dependabot/CI config and removal of the stray frontend/out.js.
Conflicts came from main's later "day assertions" restructure, which made
usePlannedDays read-only and replaced the calendar's per-day add/update with
useDayAssertions.assertDay. Both intents kept:
- usePlannedDays: branch's fetch machinery (isRefreshing, in-flight dedup,
unguarded busy clear); mutators dropped as on main.
- usePlanSync: branch's useLatestRequest guard also applied to main's new
syncWithWish.
- useDayAssertions / useLatestBalance (new on main): busy flag now cleared
unconditionally, as the branch did for every other consumer.
- useDayAssertions.assertDay gains the branch's `skipRefresh` option and a
`refresh`, so Calendar's multi-day save keeps the batch shape (N writes,
one trailing bumpPlanVersion + one assertions refresh) on main's
assertDay path. Branch's M7 tests ported from the removed add/update API
to assertDay.
- PlanSyncProvider: branch's isAuthenticated gate kept; eviction-preview
state stays removed as on main.
- usePlannedDayExclusions.ts stays deleted (superseded by useDayAssertions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.
Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.
On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.
Bugs found and fixed along the way:
- The plan erased its own history. Fixed windows clip to today, so an elapsed
auto-placed day fell into `stale_keys` and was deleted on the next sync —
and a background sync runs on every workspace load. Past rows are now frozen
and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
(`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
without a transaction; a crash mid-write diverged the tables or half-deleted
a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
edited occurrence's outcome, so a fully unfunded later year rendered as an
empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
have failed the whole suite before its first test.
Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.
Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.
Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
child_id as an unchecked query param, and get_overview called
get_days_left_at_age_4 without workspace_id, so the ownership guard
never ran. Any member could read another workspace's child projection.
Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
failure's raw exception text and the FK maintenance banner's scraped
text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
lowercasing existing rows (aborts naming colliding rows rather than
failing opaquely on the UNIQUE constraint).
Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
consumers. setBusy(true) was unconditional while the clear was guarded
by isCurrent, so a superseded call stranded its flag forever — under
StrictMode this hung every in-app navigation behind the PageSkeleton.
isCurrent decides which response's data wins, never whether a call
releases the flag it raised.
Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
GET /workspace/:id from every cold load.
CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).
Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.
Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only
visited ansok-och-planera-foraldrapenning#/valjdagar and derived the
reserved/transferable split in build_snapshot, assuming transferable days
are spent before reserved ones. On real data that gave 49 for the user and
0 for the partner where FK states 129 and 71.
The assumption cannot be repaired: reserved-first, transferable-first,
proportional and cap-based models were all checked against real figures and
none reproduces FK's accounting (reserved-first fits the partner but is 10
days off for the user). FK publishes the real numbers per parent per tier on
the min-foraldrapenning page, which the scraper had never visited.
- Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction,
summing sjukpenningnivå + lägstanivå per parent. The totals are not
literals on the page. Parse only direct-child <p> elements: the first
group embeds a tooltip with decoy "45 dagar"/"90 dagar" text.
- build_snapshot accepts the reported value and derives reserved days from
it; the old arithmetic remains only as a fallback. An implausible FK value
degrades to that fallback rather than failing the fetch.
- Record provenance in transferable_days_reported (migration 006) and badge
derived values as estimates in the dashboard breakdown. Drop the footnote's
claim that at most 150 days are transferable — real data contradicts it as
a per-family figure.
- Select the child's section by name when FK lists several; never guess.
Verified against a live BankID scrape: scraped 240/101/139 with 129
transferable, deduced partner 240/169/71 with 71, both flagged as reported.
Incidental fixes found along the way:
- The fetch router never forwarded the partner's transferable figure to
persist_partner_deduction, so the partner's stored snapshot would have kept
the flawed split even once the parse landed.
- 'deduced' snapshots were labelled "Manually entered" by a two-way ternary
over a three-value union. Replaced with a compiler-checked exhaustive map.
- Alembic resolved its database from alembic.ini alone, ignoring
APP_SQLITE_PATH, so migrations silently targeted the developer's real
database even when pointed elsewhere. env.py now resolves it, preferring
-x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value.
- backend/captures/ was not ignored: the root pattern was anchored as
/captures/ despite a comment saying scraper dumps must never be tracked.
- CLAUDE.md quoted test counts and coverage percentages that had gone stale,
which made a correct verification report look suspect. Gate thresholds kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
Security
- Every route now requires a login and refuses ids outside the caller's
workspace with 404: plan, leave-wishes, day-overrides and vacation-days
were fully anonymous; every other router trusted client-supplied
workspace/child/parent ids. Scraper sessions are bound to their owner.
- Invitations can no longer be created for a foreign workspace, and a
redeemer who is already a member (or a full workspace) is refused.
- Production refuses the default JWT secret; API docs are hidden in
production; a failed migration aborts startup instead of serving.
- Login and register no longer reveal whether an email exists; inactive
accounts are rejected; /auth/refresh is rate limited.
- The FK scraper writes debug artefacts only when explicitly enabled,
logs no scraped values or page text, clears the BankID QR as soon as it
is scanned and purges finished sessions after five minutes.
- Real FK page captures are removed from the tree (history rewrite
follows); the parser tests use a synthetic fixture.
Performance and correctness
- Plan apply/sync run inside one locked transaction with batched writes
instead of one commit per row, so a failure mid-batch leaves nothing
half-applied and concurrent syncs no longer corrupt each other.
- SQLite runs in WAL mode with a busy timeout; 14 indexes cover the
filters that were full scans; argon2 hashing runs off the event loop;
responses are gzipped.
- Frontend: a 401 now refreshes the token once and retries, so sessions
no longer die silently after 15 minutes; every data hook discards
stale responses; logout clears state even when the server call fails.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
Browser checks leave screenshots behind, and they had been accumulating in the
same directory as the Försäkringskassan captures, which are reference material
for the scraper and belong in the repository. The two are now separated by
where they sit rather than by what they happen to be called: verification
output goes in an ignored subdirectory, and the note saying so sits next to
the layout it describes.
The ignore rule for package-lock.json had no leading slash, so it matched at
any depth and kept frontend/package-lock.json out of the repository. The
frontend CI job meanwhile installs with npm ci and caches against that exact
path, neither of which can work when the file is absent from a clean checkout.
The rule is now anchored to the repository root and the lockfile is tracked.