fix(organization): judge the exit/logout restriction location from what is stored
Summary
Follow-up to #11578 for #9873. When an organization is updated, the agent exit/logout restriction check now judges the location from what is stored, as the screen capture check does.
Before, the "after this update" location took its country from a top-level country in the request. The organization does not store that field (its country lives on its contact), so a value that is never saved could change the decision.
Now one helper, organizationLocations(), builds the locations both checks use:
- Stored: the region code, time zone and contact country as they are stored now.
- After the write: the region code and time zone the write sets (otherwise the stored ones), plus the country of the contact the organization is linked to after the save. If the save clears the link, there is no contact country. The exit/logout rule is judged against this location only.
- Requested: the location values the request names: the region code, the time zone, the country of a contact it carries, and the country of each contact it names by id. Only the screen capture check uses these. It asks for the acknowledgement when the organization is in the EEA/UK before or after the write, or when any of these values is in the EEA/UK.
Two more steps depend on the location:
- Unreadable contact: if the save would link a contact that cannot be read, the location after the write is unknown. The update is refused with "not found" before anything is judged, recorded or saved.
- Lifting employee restrictions: the step that runs when an organization moves into the EEA/UK reads the saved organization and its contact after the save. It lifts restrictions only when that row is in the EEA/UK.
What the save stores for the contact
organization-location.persistence.spec.ts pins this down. It runs the CRUD upsert on in-memory SQLite under both ORMs, mapped like the organization (the contact relation does not cascade, and contactId is a TypeORM relation id). For each request, it checks that savedContactLink() reads the same link as the stored row.
| Request (organization linked to contact A) | TypeORM | MikroORM |
|---|---|---|
contact: { id: B } | B, with B's own country | B, with B's own country |
contact: 'B' (an id alone) | link cleared | B |
contact: null | link cleared | link cleared |
contactId: B | B | not stored (the column is not mapped) |
contactId: null | link cleared | not stored |
contactId: B with contact: null | B | link cleared |
contact: { country } without an id | A kept, country not stored | link cleared, country not stored |
top-level country | not stored | not stored |
The "after the write" location follows this table, with one exception: for a contact without an id, MikroORM clears the link, but the check counts A as kept. That keeps A's country in the check, the stricter of the two readings.
Decisions
- The exit/logout restriction is judged against the location after the write only. That location is built from the stored values plus what the save stores. A top-level
countryis ignored. A contact sent with the request counts only where the save stores it (see the table).- Moving an organization out of the EEA/UK and restricting exit/logout in the same save is allowed when the stored result is outside the EEA/UK. This includes unlinking a UK contact.
- It is still refused when the stored result stays inside, for example when the time zone moves but the UK contact stays.
- The screen capture check also ignores a top-level
country, because the organization does not store it. Otherwise it asks as before: when the organization is in the EEA/UK before or after the write, or when any location value the request names is in the EEA/UK.
Behaviour
| Situation | Before | After |
|---|---|---|
UK/EEA organization (by its stored contact), request restricts exit with an acknowledgement and a top-level country outside the EEA/UK | allowed | refused, as without the country |
Organization outside the EEA/UK with a restriction in force, request carries only a top-level country inside the EEA/UK | refused as "moving into the EEA/UK" | saved |
Organization outside the EEA/UK, request carries only a top-level country inside the EEA/UK | treated as a move into the EEA/UK: employee restrictions lifted | no move, nothing lifted |
Organization outside the EEA/UK, request carries contact: { country: 'DE' } | treated as a move into the EEA/UK | no move (the save does not store that country) |
Request links a UK/EEA contact by contact.id while restricting exit | judged by the old contact | refused |
| Request moves the organization out of the EEA/UK (time zone, or unlinking a UK contact) and restricts exit in the same save | allowed with the acknowledgement when the time zone moved; refused when the UK contact was unlinked | allowed when the stored result is outside the EEA/UK |
| Request links a contact that cannot be read | judged by the old contact | refused: contact not found |
Screen capture acknowledgement, request with a top-level country inside the EEA/UK | asked | judged without it |
Changes
packages/core/src/lib/organization/organization-location.ts(new):currentContactIdandlinkedContactCountry, moved fromscreenshot-capture-acknowledgement.ts, with the same behavioursavedContactLink(): the contact linked after the save, per ORM (the table above)storedOrganizationLocation()organizationLocations(): takes the ORM type, reads each contact the request names by id once, and reports a linked contact that cannot be read
organization.update.handler.ts:- refuses an update that links a contact that cannot be read
- the exit/logout check uses the location after the write
- the screen capture check uses the stored, after and requested locations
- the lift step confirms the move on the saved row, with one extra read only when the write may have moved the organization
organization.create.handler.ts: import path only.- Specs:
organization-location.spec.ts(new; includes the moved helper tests)organization-location.persistence.spec.ts(new; real database, both ORMs)organization.update.handler.spec.ts
Tests
All run locally in the worktree (Node 26.10, Windows).
NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js -c packages/core/jest.config.ts --rootDir packages/core "<organization, employee, invite and GraphQL suites>": 21 suites, 447 tests passed.- Update handler: each case runs with no
country, an unstoredUSand an unstoredDE. The decision is the same in all three:- a UK organization cannot restrict exit;
- an organization elsewhere can, with the acknowledgement recorded;
- a restricted organization elsewhere is saved and nothing is lifted.
- Update handler, other cases:
- moving out of the EEA/UK and restricting in one save: allowed when the stored result is outside it (time zone moved, or UK contact unlinked), refused when the UK contact stays;
- a UK contact linked by
contact.idrefuses a restriction, and lifts employee restrictions once the saved row links it; - a carried
DEcontact moves nothing; - linking a contact that cannot be read is refused, and nothing is recorded or saved.
- Location helper:
- after, stored and requested values;
contactIdcounted only under TypeORM;- an id alone counted per ORM;
- a cleared link gives no country;
- unread contacts are reported;
- cleared time zones;
- how many contacts are read.
- Persistence spec: the table above, under TypeORM and MikroORM.
- Against the previous handler: the first commit's specs had 7 failures, as expected; the last commit's two new handler cases fail too.
tsc --noEmitonpackages/core/tsconfig.lib.jsonandpackages/core/tsconfig.spec.json: no errors.- Prettier and ESLint on the touched files: no new findings (the remaining ones are on lines this PR does not change).
Follow-up (not in this PR)
- The organization location page in the web app sends the address as a nested
contactin the organization update. The organization save does not store a nested contact's fields under either ORM: TypeORM keeps the current link, and MikroORM clears it. It is worth checking whether the page's address changes are saved, and through which path.
Refs #9873
š¤ Generated with Claude Code