Repository navigation
feat(web): prefill the farm code from ?farm= and remember it (#535) - #588
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1e7648013a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| + "the farm code and sign in. The sign-in page also remembers farm codes you have signed in with on this " | ||
| + "device and offers them as a picker, and a ?farm= link prefills the field. No other farm's session is " |
There was a problem hiding this comment.
Update the in-app glossary for farm-code remembering
This updates the Help text and product glossary for the picker and ?farm= prefill, but the in-app glossaryFarmCodeDef at lines 2865–2868 still describes only typing the code and omits both new behaviors. Update that glossary entry, including its translated counterparts, so the user-facing documentation remains consistent.
AGENTS.md reference: AGENTS.md:L198-L198
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 73f6379f. You were right and this was a miss against the repo's own rule, not an oversight in a grey area.
AGENTS.md's docs-in-sync directive covers specs/product/GLOSSARY.md and the SPA Help page and the in-app glossary. This PR updated the first two — the product glossary and the signingInMultiTabResync Help copy — and left glossaryFarmCodeDef describing only typing the code, while quoting that same directive in its own planning notes.
glossaryFarmCodeDef now covers both new behaviours: that the sign-in screen remembers each farm code successfully used on the device and offers them, and that a /login?farm=<code> link fills it in. Mirrored into es.ts and tl.ts with matching meaning, not just matching keys — the parity test enforces presence, not semantics, so the translations were checked by reading them.
| const next = [code, ...readFarmCodes().filter((c) => c !== code)].slice(0, MAX_REMEMBERED); | ||
| try { | ||
| localStorage.setItem(KEY, JSON.stringify(next)); |
There was a problem hiding this comment.
Serialize farm-roster updates across tabs
When two tabs complete successful logins for different farms concurrently, both can read the same existing roster here and then independently write their own updated array; the last setItem wins and silently drops the other successfully used farm code. Since multi-farm sessions explicitly coexist across tabs, make this read-modify-write operation cross-tab-safe rather than relying on separate atomic localStorage calls.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 73f6379f. Confirmed as a real defect, not mitigated — thank you.
rememberFarmCode now serialises its read-modify-write with the Web Locks API, following the existing AUTH_COOKIE_LOCK precedent at client.ts:402 rather than inventing a mechanism — same degradation stance for browsers without navigator.locks (older Safari, insecure origins), which fall back to the unsynchronised path: no cross-tab guarantee, never worse than before. The roster is re-read inside the lock callback, since a read taken before acquisition is the stale value the lock exists to prevent. It never rejects: AuthContext.login now awaits it after apiLogin has already succeeded, so a storage or lock failure must not turn a completed sign-in into a thrown login.
On testing it honestly: the true cross-tab lost update is not expressible in Vitest — it needs two JS contexts sharing one localStorage, and jsdom has one, where the read-modify-write is synchronous and cannot interleave. A test claiming to reproduce the race would pass with the lock removed. So the guard is structural, and the first draft of it was itself a false green: asserting "storage still holds the old value when the callback is entered" survives hoisting the next computation outside the lock, because with an unchanged seed the pre-computed array and the in-lock re-read are identical.
The shipped test instead injects a concurrent tab's write into localStorage after the lock is acquired but before the callback runs, so only a genuine re-read preserves it.
Verification (driver-run, not attested):
| mutation | result |
|---|---|
remove the lock entirely, call write() directly |
red — "re-reads INSIDE the lock callback: a roster change that lands while the lock is held is not overwritten" |
hoist next outside write (lock still held, read now stale) |
red — same named test |
Both restored, touched, re-run green. Full gate: 86 files / 1866 tests, exit 0; src/auth/** at 98.75 / 94.64 / 100 / 100 against its 98 / 90 / 100 / 100 lock.
Review round 1 — 4 reviewers, 2 confirmed guard defects in shipped codecodex is unavailable (credits exhausted), so this round ran three Claude reviewers scoped to one defect class each, plus a local pi pass. Findings below are dispositioned; fixes are in flight as one increment. Merge-blocking — both CONFIRMED BY EXECUTION, not by predictionThe false-green reviewer predicted these statically; I applied each mutation and ran the whole suite. 1.
2. The remembered-flock prefill is untested. Deleting while leaving the This is the inverse of the masking problem the slice already handled: the test moved off the UI observable because Wrong claims in comments — four, all mineStated plainly because it is the same defect class this slice exists to remove:
Test quality — three, all real
Rejected, with evidence
Accepted as follow-up, not fixed here
Also confirmed by reviewThe invariants reviewer walked every Round-1 count for the stop rule: 2 confirmed defects in product code. |
|
Round-1 fixes are written but not yet applied — the loop is paused on infrastructure, not on a decision. The implementer backend for this slice (a self-hosted endpoint) went unreachable mid-round: the dispatch failed with The fix increment for every item in the round-1 comment above is written and ready to dispatch; it is queued until that host returns, by owner decision. Nothing is dropped, and this pause should not be read as the round being finished. Still outstanding, in priority order:
|
…le text (#535) Round-2 mutation found the negative half of the notice test could not fail: removing the `urlFarmCode !== null` gate so the notice always renders left the whole suite green. Testing Library normalises the rendered text but compares a string matcher literally, so `i18n.t("auth:farmFromLink", { farmCode: "" })` — which ends in a trailing space because the interpolated value is empty — matches nothing. Both the original English literal and its round-1 i18n-pinned replacement were unfalsifiable for that reason. Asserting on the `.auth-farm-source` node is immune to text normalisation. Verified: the gate-removal mutant now reddens this test by name.
Round 1 fixes applied (
|
| Mutant | Before | Now |
|---|---|---|
delete purgeUnscopedAccountState()'s call site |
survived (1857 green) | red — "purges pre-namespacing browser state on mount" |
delete purgeLegacyTokens()'s call site (pre-existing gap) |
survived | red — same test |
| delete the remembered-flock prefill branch | survived (1857 green) | red — "actually selects the remembered flock" |
delete the picker's role="group" / aria-labelledby |
survived | red — "labels the recent-farms picker group with its heading" |
The four claim corrections landed and were checked against source: both #586 references now say "the per-farm fix is tracked in #586" with no clearing claim, farmCodeCache.ts points at epic #530 decision 9 with the ADR noted as pending in #537, and the two line pins now read client.ts:144 / client.ts:242.
The implementer caught a factual error in my runbook
I specified a fixture asserting isFarmCode("sunny-acres\n") === true, on the reasoning that $ without the m flag matches before a trailing newline. That is Python's semantics, not JavaScript's — in JS, $ without /m matches only at true end of input, so it returns false. Verified:
JS $ no m flag → false JS $ with m flag → true Python $ → True
I took the claim from a reviewer and propagated it without checking. Transcribing it verbatim would have committed a permanently-red test. The implementer refused to encode it, pinned the true behaviour both ways (isFarmCode rejects the newline; canonicalFarmCode trims it first, which is why every live caller is safe), and reported the deviation.
Round 2 — one finding, against round 1's own fix
Round 1 replaced the notice test's English literal /Signing in to farm/ with an i18n-pinned string. That was the right instinct and it did not work: removing the urlFarmCode !== null gate so the notice always renders still left the whole suite green.
Cause: Testing Library normalises the rendered text but compares a string matcher literally. i18n.t("auth:farmFromLink", { farmCode: "" }) ends in a trailing space, because the interpolated value is empty — so it can never match anything. Demonstrated:
queryByText("Signing in to farm: ") → null (trailing space: never matches)
queryByText("Signing in to farm:") → found
queryByText(/Signing in to farm:/) → found
So the assertion went from vacuous-and-obvious to vacuous-and-principled-looking. Both null and "" interpolate to the same string, so the i18n pinning was not the problem — the trailing space was. Fixed by asserting on the .auth-farm-source node, which is immune to text normalisation. The gate-removal mutant now reddens that test by name.
Applied directly rather than routed back: one mechanical line with a verified diagnosis. Driver fix budget: 1 of 2 spent.
Invariant re-enumerated before closing the loop
A clean round says the diff is clean and nothing about untouched code. All 20 localStorage/sessionStorage sites in web/src plus theme-init.js were re-walked and dispositioned: 3 namespaced, 2 deliberately cross-account (the roster, circular to namespace), 6 correctly device/user-scoped, 3 tab-scoped and cleared on login, 4 infrastructure, and one known gap — cluckwork.brand — deferred by owner decision and tracked in #586.
State
npm run test:coverage — 86 files, 1862 tests, exit 0. build and verify:sw clean. Suite ledger 1817 → 1862 (+45); nothing shrank. src/lib/** and src/auth/** still meet their high-water locks with no floor moved in either direction.
Stop-rule tally: round 1 = 2 confirmed product defects; round 2 = 0 (its single finding was a test guard, in round 1's own fix).
Round 3 — 0 product defects. Stop rule fired; this is the last round.Three reviewers against Correction to my own round-2 comment — I had the provenance backwardsI wrote above that "both the original English literal and its i18n-pinned replacement were unfalsifiable". That is wrong, and the same wrong claim is in
So round 1 did not fail to close a pre-existing hole — it broke a working guard, and round 2 repaired round 1's own regression. The original F4 finding was about future-proofing an assertion that already worked, and the fix I specified converted a hypothetical future vacuity into a real present one. That is precisely the loop's convergence signal — a round whose findings are breakage the previous round introduced — and it is a second, independent reason to stop here rather than run a round 4. The other finding: a wrong claim three lines from the wrong claims it was fixing
Found independently by the contract-drift reviewer and by me on re-read. The security half ("never an accepted-invalid one") holds both ways; the stated cost does not. Both corrections are dispatched as a comment-only increment. Verified sound by real mutation (not prediction)The false-green reviewer ran its checks as actual mutations in throwaway worktrees:
pi: 3 findings, 0 validClaimed the newline assertion was "unfalsifiable by design" (adding Tally
Driver fix budget: 1 of 2 — both round-3 corrections were routed back to the implementer rather than hand-applied. |
|
@codex both of your findings are addressed in P2 (cross-tab roster race) — real, and fixed properly rather than mitigated. P1 (in-app glossary) — a genuine miss against AGENTS.md's docs-in-sync directive. Two things worth your attention on re-review, because both are places where a guard could read as safety without being one:
Full gate at Process note, since it affects how much weight to give this PR's earlier comments. Your review of |
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
The test claiming to guard `if (locks === undefined) { write(); return; }` was a
tautology: it created a `vi.fn()`, never wired it to anything, and asserted it
had not been called. Nothing could have called it.
Fixing the spy would not have helped. The branch is behaviourally REDUNDANT with
the catch beneath it — with `locks` undefined, the guard writes and returns;
delete the guard and `locks.request` throws a TypeError that the catch answers
with the identical write. Same storage, same call count, no externally
observable difference. Confirmed by running the mutation: deleting the guard
leaves the whole suite green, before and after that test existed.
The branch stays, because it states intent and relying on `undefined.request()`
throwing is accidental control flow that a later narrowing of the catch would
silently break. What changes is the claim: the test now asserts only what it can
actually observe (the lock-free path still writes), and a comment records why
there is no branch pin and that the previous attempt was a fake one.
The observable mutations remain covered: removing the lock, and hoisting the
re-read out of it, both redden the concurrent-write test.
|
@codex please re-review at
The one worth your attention
The branch is behaviourally redundant with the The first attempt to "fix" this shipped a tautology: a The branch stays because it states intent — relying on What is genuinely pinned
Gate at |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🤖 I have created a release *beep* *boop* --- ## [0.1.0](v0.0.4...v0.1.0) (2026-09-12) ### ⚠ BREAKING CHANGES * log in by farm code, with per-account email identity ([#532](#532)) (#564) ### Features * **accounts:** add Account.Slug (farm code), suspend/reactivate, list-accounts verb ([#531](#531)) ([3fe9754](3fe9754)) * **accounts:** provision additional farms ([#581](#581)) ([006f298](006f298)) * add Aspire local development AppHost ([#567](#567)) ([2c9e6b9](2c9e6b9)) * add configurable worker sale allocation ([#619](#619)) ([0955095](0955095)) * add searchable entity pickers ([#642](#642)) ([60d2053](60d2053)) * **api:** provision-account takes an optional --timezone at creation ([#603](#603)) ([#694](#694)) ([a0aee39](a0aee39)) * **audit:** show the sales-line audit payload as a readable Details column ([#745](#745)) ([#749](#749)) ([d26d389](d26d389)) * **auth:** add ApplicationUser.StepUpLogoutEpoch column ([#338](#338)) ([#554](#554)) ([18306ee](18306ee)) * certify over-cap simulation fixture bands ([#633](#633)) ([a67b2e1](a67b2e1)), closes [#627](#627) * **cli:** rename-account verb to change a farm code ([#732](#732)) ([#733](#733)) ([4b70559](4b70559)) * **customers:** edit existing customer details ([#625](#625)) ([#626](#626)) ([062a55c](062a55c)) * **jobs:** single-runner leader gate for the durable job worker ([#271](#271)) ([#555](#555)) ([4148f9b](4148f9b)) * let owners change user email addresses ([#605](#605)) ([842347b](842347b)) * log in by farm code, with per-account email identity ([#532](#532)) ([#564](#564)) ([68adb62](68adb62)) * **ratelimit:** distributed IP-keyed auth limiters ([#544](#544)) ([#558](#558)) ([ec14972](ec14972)) * **ratelimit:** distributed per-account report concurrency cap with local-ceiling fallback ([#545](#545)) ([#559](#559)) ([1522e4e](1522e4e)) * **sales:** mark discounted lines, total the discount, and show it in the Orders list ([#723](#723), [#724](#724)) ([#741](#741)) ([1a07441](1a07441)) * **sales:** record list, old and new price in the order-line audit payload ([#722](#722)) ([#742](#742)) ([97c866f](97c866f)) * **sales:** refuse an over-ceiling confirm from a Sales user ([#727](#727)) ([#766](#766)) ([8c0792a](8c0792a)) * **sales:** show what each order still owes, and filter the list to unpaid ([#771](#771)) ([ca59d68](ca59d68)) * **sales:** snapshot the list price on the order line and show the discount ([#734](#734)) ([cffed5e](cffed5e)) * **sales:** snapshot the product name and unit in the order-line audit payload ([#747](#747)) ([#748](#748)) ([0481c06](0481c06)) * scope Worker reads to assigned flocks ([#388](#388)) ([#611](#611)) ([5884a9a](5884a9a)) * shared-state ports with Redis + in-process fallback ([#543](#543)) ([#552](#552)) ([f767fa9](f767fa9)) * suspend-account / reactivate-account operator verbs ([#534](#534)) ([#573](#573)) ([d0be26c](d0be26c)) * **tenancy:** write-side tenant guard + single-assignment TenantContext ([#546](#546)) ([#561](#561)) ([f371f1d](f371f1d)) * **web:** dashboard rework — capture-status tiles, 14-day trend, stock as a stacked bar ([#654](#654)) ([396ba23](396ba23)) * **web:** date-range filters on audit and expenses, and the stock lot filter gets its bounded toolbar ([#666](#666), [#667](#667), [#653](#653)) ([94b188f](94b188f)) * **web:** elevation hierarchy and sentence-case labels ([#651](#651), [#652](#652)) ([#661](#661)) ([28db4c7](28db4c7)) * **web:** Expenses and Audit keep a clear-filters control while rows are still showing ([#679](#679)) ([#697](#697)) ([b859982](b859982)) * **web:** expenses filters by a date range like its sibling screens ([#667](#667)) ([f13858f](f13858f)) * **web:** key the farm brand palette per farm ([#586](#586)) ([#600](#600)) ([7183a43](7183a43)) * **web:** let operators forget remembered farms ([#598](#598)) ([577d94e](577d94e)) * **web:** one-line provenance, bounded date filters, and empty states that invite action ([#653](#653), [#655](#655)) ([#668](#668)) ([80b53f4](80b53f4)) * **web:** prefill the farm code from ?farm= and remember it ([#535](#535)) ([#588](#588)) ([b7f5cc6](b7f5cc6)) * **web:** split authenticated routes into lazy chunks ([#620](#620)) ([5089271](5089271)) * **web:** the audit log filters by a date range, and says which window is empty ([#666](#666)) ([63027e0](63027e0)) * **web:** typeset numbers as numbers and refresh the Help glossary ([#650](#650), [#657](#657)) ([af4fe11](af4fe11)) ### Bug fixes * **api:** order same-instant audit events by a durable monotonic key ([#700](#700)) ([8fcf084](8fcf084)) * **api:** print the farm code from bootstrap-admin ([#589](#589)) ([#594](#594)) ([34032ac](34032ac)) * **audit:** show the price a line sold for, not its list price ([#759](#759)) ([e6b37d0](e6b37d0)) * **audit:** store catalog enums by name and guard the add-item transaction shape ([#751](#751)) ([23609ff](23609ff)) * **auth:** reject invalid account claims ([#622](#622)) ([8d6c7fe](8d6c7fe)) * **auth:** require step-up for durable user access ([#360](#360)) ([#607](#607)) ([f767dce](f767dce)) * **ci:** bound the npm audit calls and give the web job room to finish ([#686](#686)) ([153b7a8](153b7a8)) * **ci:** escalate the audit bound to SIGKILL, so it actually bounds ([#686](#686)) ([a0c8f4e](a0c8f4e)) * **ci:** fail closed on invalid vulnerability config ([#621](#621)) ([1690db8](1690db8)) * **ci:** lockfix covers the two AppHost lock files, derived from the sln ([efb05e6](efb05e6)) * **ci:** lockfix covers the two AppHost lock files, derived from the sln ([8986d77](8986d77)) * **ci:** remove invalid XML comment from nuget.lockfix.config ([#541](#541)) ([5f1bc0a](5f1bc0a)) * **ci:** the advisory vuln gate no longer blocks on an unusable report ([#686](#686)) ([aaf6934](aaf6934)) * **ci:** the advisory vuln gate no longer blocks on an unusable report ([#686](#686)) ([64f1f53](64f1f53)) * **i18n:** tl help text names the saleable flag and unit-system setting what their labels call them ([#688](#688)) ([#696](#696)) ([bfd24d7](bfd24d7)) * **infra:** AccountId must be a non-nullable Guid or both tenant write layers refuse ([#673](#673)) ([#695](#695)) ([2470c4e](2470c4e)) * require step-up for flock scope changes ([#609](#609)) ([4151f89](4151f89)) * **sales:** keep a line's discount markers agreeing while its price is edited ([#752](#752)) ([#753](#753)) ([c159b4b](c159b4b)) * **sales:** say which kind of missing list price a line has ([#774](#774)) ([489180e](489180e)) * scope legacy logout to selected farm ([#624](#624)) ([fae8d82](fae8d82)) * **seed:** drain the daily-entry lock sweep so deep simulation fixtures validate ([#644](#644)) ([730fa23](730fa23)), closes [#638](#638) * **tenancy:** AccountId is a concurrency token, so the database refuses a detached cross-tenant write ([#562](#562)) ([4d1dfa3](4d1dfa3)) * **tenancy:** AspNetUserRoles carries a tenant column, so a role write naming another farm's user is refused ([#670](#670)) ([fc0552a](fc0552a)) * **tests:** bump the image-pin allow-list counts for the AppHost LocalPorts tests ([#593](#593)) ([58d3056](58d3056)) * **tests:** the OTLP collector survives a lost port race and ignores traffic that is not an export ([#672](#672), [#676](#676)) ([#677](#677)) ([965c737](965c737)) * **web:** a scoped audit view filtered to nothing names both the record and the range ([#666](#666)) ([41bbfe1](41bbfe1)) * **web:** an abandoned dialog attempt's success no longer hijacks the replacement on Customers, Daily Entry, Flocks, Grades and Products ([#703](#703)) ([#705](#705)) ([85605db](85605db)) * **web:** an abandoned dialog attempt's success no longer hijacks the replacement on Inventory, Expenses, History and Stock ([#703](#703)) ([#706](#706)) ([60a4997](60a4997)) * **web:** an abandoned edit's success no longer hijacks the dialog that replaced it on Users ([#703](#703)) ([#710](#710)) ([778faab](778faab)) * **web:** an abandoned order attempt's success no longer hijacks the dialog that replaced it ([#702](#702)) ([522c699](522c699)) * **web:** capture screens open on the flock you last used, and assigning one no longer guesses ([#646](#646)) ([#699](#699)) ([7f8f317](7f8f317)) * **web:** constrain dialog session helpers to declared scopes ([#715](#715)) ([389e3c8](389e3c8)) * **web:** date validation gets one boundary table instead of one case per review round ([#666](#666)) ([215f830](215f830)) * **web:** keep a paged window and an item panel on the user's newest intent ([#645](#645)) ([d81bccf](d81bccf)) * **web:** keep Sales order panels closed after pending writes ([#711](#711)) ([f0f7492](f0f7492)) * **web:** keep Sales panels closed after pending Open reads ([#716](#716)) ([620411f](620411f)) * **web:** make login take the cross-tab cookie lock so a racing refresh cannot restore the wrong session ([#648](#648)) ([ff18beb](ff18beb)) * **web:** make the entity picker read as a search field and focus it on open ([#736](#736)) ([66ef667](66ef667)), closes [#735](#735) * **web:** page truncated customer and movement tables with usePagedList ([7cfe4d6](7cfe4d6)) * **web:** reconcile Sales line edits with refreshed orders ([#717](#717)) ([d7dd2c9](d7dd2c9)) * **web:** the audit date filter accepts low-numbered years, and its empty state covers every narrowing ([#666](#666)) ([af52d25](af52d25)) * **web:** the audit date filter rejects impossible dates, and its history guard actually guards ([#666](#666)) ([8d51846](8d51846)) * **web:** the expense range bounds are not capped at today, which the month-end default exceeds ([#667](#667)) ([7e01864](7e01864)) * **web:** the help text calls the expiry field what the field calls itself ([#666](#666)) ([2fd1f3c](2fd1f3c)) * **web:** the stock lot date range sits in the bounded toolbar ([#653](#653)) ([43dec5e](43dec5e)) ### Refactoring * **web:** extract SalesPage's dialog-write wrapper into a shared useDialogAction hook ([#703](#703)) ([#704](#704)) ([60ee9d9](60ee9d9)) ### Documentation * add k6 preparation steps to the dev-database fixture runbook ([#643](#643)) ([a4f1f09](a4f1f09)) * add runbook for loading the simulation fixture into a dev database ([#639](#639)) ([2d143b8](2d143b8)) * **agents:** a PR closes its issue from the body, not the title ([#744](#744)) ([39be13c](39be13c)) * **agents:** drop the commit and push gate, and require screenshots on UI changes ([#757](#757)) ([6225172](6225172)) * **agents:** find guards by grepping registry readers; amend issues a PR overtakes ([#580](#580)) ([fe3fde8](fe3fde8)) * **agents:** the Playwright specs have been in CI since 2026-08-08 ([#768](#768)) ([68ee612](68ee612)) * **aspire:** record the second local database and pin the AppHost dashboard ports ([#623](#623)) ([713b941](713b941)) * compress AGENTS.md to one paragraph per rule, and draw the two orders that matter ([#551](#551)) ([997ae8a](997ae8a)) * item 7 names each screen's actual initial filter value ([#666](#666)) ([70a53d8](70a53d8)) * multi-farm tenancy decision record and AGENTS/GLOSSARY sync ([#537](#537)) ([#601](#601)) ([2c34771](2c34771)) * name the scoped filtered-empty key and state the [#653](#653) relationship plainly ([#666](#666)) ([0e93dac](0e93dac)) * note that a PackageReference in Directory.Build.props is invisible to the dependency graph ([4845724](4845724)) * **plans:** commit the [#722](#722) and [#745](#745) design records ([#754](#754)) ([c942fcd](c942fcd)) * record [#579](#579) as won't-fix — suspension is immediate for use, not issuance ([#582](#582)) ([7a3be40](7a3be40)) * record the [#508](#508) audit ordering key and the tracked-file guard lesson ([#701](#701)) ([08964e9](08964e9)) * **runbooks:** add procedure to rename the default farm's code after upgrade ([#731](#731)) ([2f6e242](2f6e242)) * screenshots of the running SPA in the README ([#550](#550)) ([711488a](711488a)) * **sim:** commit the dashboard screenshot, capture the palette matrix, and record the [#651](https://github.com/mforce/cluckwork/issues/651)/[#652](https://github.com/mforce/cluckwork/issues/652) conventions ([#660](#660), [#662](#662), [#663](#663), [#664](#664)) ([#665](#665)) ([930ea30](930ea30)) * specify searchable entity picker ([#641](#641)) ([91d4300](91d4300)) * split the README into audience-scoped docs and adopt repo-template scaffolding ([#548](#548)) ([b3f3fcf](b3f3fcf)) * surface Aspire local development workflow ([#568](#568)) ([a343baa](a343baa)) * **web:** record the per-screen idempotency-key policies and runWrite's refresh contract ([#703](#703)) ([#707](#707)) ([8bee651](8bee651)) * **web:** the date-cap help text covers every stocked item, not only feed ([#666](#666), [#667](#667)) ([c8433c5](c8433c5)) * **web:** the help text claims only what is true of recording, and says nothing about filter caps ([#666](#666), [#667](#667)) ([e2f63d1](e2f63d1)) * **web:** the help text describes the date-range filters that shipped ([#666](#666), [#667](#667)) ([c3275b7](c3275b7)) * **web:** the help text stops describing a cap the filters no longer have ([#666](#666), [#667](#667)) ([49654cd](49654cd)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: cluckwork-lockfix[bot] <309265648+cluckwork-lockfix[bot]@users.noreply.github.com>
Closes #535. Slice T7 of epic #530 (Phase 1.6 — multi-farm tenancy). Frontend only — no API change, nothing under
src/.What ships
Prefill order on the login form, first match wins:
?farm=<slug>(validated) → the device's remembered codes (one prefills, several offer a picker) → empty.web/src/auth/farmCodeCache.ts(new) —cluckwork.farmCodes, most-recent-first, capped at 10. Deliberately not account-namespaced: this list is the cross-farm roster, so keying it per account would be circular. Written only afterapiLoginresolves, which is what makes "a typo is never remembered" true by control flow rather than by a check.web/src/lib/accountStorage.ts(new) — per-account browser state, namespaced by the account GUID the server minted into the access token (getBoundAccountId()), not by the farm code the user typed. A read with no bound account returnsnulland deliberately does not fall back to the bare key — falling back is precisely how farm B inherits farm A's state.cluckwork.lastFlockIdmoves onto it; the pre-namespacing value is purged rather than migrated, because the app cannot attribute it to a farm.Normalisation before validation.
AccountRepository.cs:44looks the slug up as(slug ?? "").Trim().ToLowerInvariant()andLoginRequestValidatorcarries no pattern rule by design. SoSunny-Acresand" sunny-acres "sign in successfully — and without normalising first they would have failed the client regex and been silently never remembered, with no error and a green suite. The picker would simply never have appeared for anyone whose keyboard capitalised the first letter.Client-side slug validation mirrors
Account.SlugPatternand is applied to?farm=and to every value read back out of the cache. Anchored, so an over-long value is ignored rather than truncated. It deliberately does not mirrorReservedSlugs— that list stays server-side, a reserved code is rejected at login, and a success-only cache can never contain one. The claim is bounded to shape, not acceptability.A visible notice when the code came from the URL. A same-origin
?farm=attacker-farmlink would otherwise silently replace the operator's own farm code in a field they did not type, while the password manager autofills for the origin. Naming the farm makes the substitution visible. Not a complete fix — the autocomplete username is still not farm-qualified (#585).Scope decisions
cluckwork.splashShownneeded no change — already tab-scoped and cleared on every login. Audited, recorded, untouched.theme-init.jsis render-blocking and runs before any token exists, so the account GUID is structurally unavailable to it; a GUID-keyed brand would have regressed Per-farm theme — curated accent palettes in farm settings (API + SPA) #149's no-flash guarantee on every cold start for the single-farm majority.Two comments corrected
brand.ts:5-8claimed the palette "is cleared on every path that ends a session, so farm A's colour never bleeds into farm B's login screen." Nothing clears it —AuthContext.tsx:118-121says so explicitly, justified by a "single-farm deployment" premise this epic falsifies. The repo was asserting the exact isolation property this slice was built to provide. Both comments now state the truth.Verification
Driver-run, not quoted from the implementer — its report was lost to a repetition loop, so nothing here is implementer-attested.
npm run test:coverage— 86 files, 1857 tests, exit 0, no threshold violations.src/lib/accountStorage.tsandsrc/auth/farmCodeCache.tsboth 100/100/100/100 (json-summary), so thesrc/lib/**high-water lock holds. No coverage floor was moved in either direction.npm run buildandnpm run verify:swclean.Mutation checks — 8 run, 8 killed by their named test
canonicalFarmCodefrom the?farm=initialiser?farm=cases, ontoHaveValueurlFarmCode === nullgate on the cache readonClickalways fillsrememberedCodes[0].slice(0, MAX_REMEMBERED).filtertry/catchfromreadAccountScopedrememberFarmCodeabove theawaitEach restored,
touched, and re-run green.M4 and M5 are the ones worth noting.
readFarmCodes()independently re-caps and re-dedupes, so any write-side test that read back through it would have stayed green under both mutants — the cap and dedupe would have been freely deletable. Those two tests assert on the raw stored value specifically for that reason.Likewise M7:
DailyEntryPage.tsx:161already membership-checks the remembered id against the farm-scoped flock list, so a UI-level assertion cannot see the namespacing at all. Confirmed empirically — all 58 pre-existing Daily Entry tests pass with the change applied. That test asserts on the storage key.Known, not fixed in this PR
accountStorage.test.tscallsspy.mockRestore()after its assertions, so a failure in one of the three storage-throws tests leaks a throwing spy into later tests in the file. Harmless while green; produces misleading collateral failures when something breaks.brand.ts's replacement comment describes SPA: per-farm brand palette, keyed by slug so it survives pre-paint #586 as "clearing the palette on every session-ending path". SPA: per-farm brand palette, keyed by slug so it survives pre-paint #586's actual approach is slug-keyed namespacing — clearing was rejected because it would break Per-farm theme — curated accent palettes in farm settings (API + SPA) #149's device-persistence.