fix(bank): never pre-check or mirror another company's accounts in the EB callback (#2116)
* fix(bank): never pre-check or mirror another company's accounts in the EB callback At one-session banks (SEB) the PSU's single consent can cover accounts a sibling company books. The OAuth callback stored whatever the session returned into the active company: all pre-enabled, mirrored into its cash_accounts, ledgers allocated from its chart: one 'Spara val' away from booking another aktiebolag's transactions (user report F1, 2026-09-01). The deliberate reuse path (findReusableSessions) already guards claimed IBANs; the callback now runs the same check via fetchCrossCompanyAccountContext: - accounts claimed by another of the user's companies are stored disabled + flagged (claimed_by_company_*), skipped by the cash_accounts mirror, and the picker names the claiming company - a 'Synkas ej' deselection made on any other connection row is carried onto fresh rows (the recurring came-back-pre-checked complaint, C2) - lookup failure fails closed: new accounts stored deselected - accounts the row itself already carried keep their own state, so a renewal can never switch a working feed off Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197wmwP6zNaYvsGfbZuQHGA * fix(bank): close the skeptic-found holes in the cross-company claim guard Consolidated fixes from the three-skeptic review of PR #2116 (all three refuted the first cut): - Active-company standing state (enabled cash_accounts + enabled accounts on its live-ish connection rows) now outranks sibling claims company-wide, not row-wide: a bank-list renewal arrives on a FRESH row with no priors, and the old row-local check would have let a sibling claim switch a working feed off while supersede demoted its cash row. - pending_selection rows no longer claim accounts or feed deselection memory: their flags are unconfirmed callback output (including this guard's own fail-closed writes), so an abandoned picker or a transient lookup error can no longer poison later connects. - Guard-disabled accounts are never mirrored from the callback: upsertFromPsd2 with enabled:false for a new-to-row account could promote the seeded primary 1930 manual row and flip it to disabled under a foreign identity. - The selection save skips ledger allocation and the cash_accounts mirror for disabled never-mirrored accounts, so 'no cash row, no 19xx slot burned' holds past the mandatory Spara val, and strips the claimed_by_*/deselected flags when the user deliberately enables an account. - Deselection carry is no longer silent: deselected_elsewhere flag + picker note 'Tidigare bortvald'. - Claim lookups paginate via fetchAllRows: the bare select's silent 1000-row PostgREST cap failed open for exactly the multi-company consultants the guard exists for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197wmwP6zNaYvsGfbZuQHGA * fix(bank): claim-guard round 2: pending_selection claims asymmetrically, paged reads ordered Skeptic re-verification of 25a339810 found two holes: - Excluding pending_selection rows from claims reopened the attach-to-picker window: an attach-created row holds deliberately offered enabled accounts with no cash_accounts rows until its picker is saved, and a full-OAuth connect in another company inside that window could take the same physical account. Enabled accounts on pending_selection rows claim again; their disabled flags still stay out of the deselection memory (unconfirmed callback output, including the guard's own fail-closed writes). - Both fetchAllRows claim queries now order('id'): unordered .range() pagination can silently skip rows at page boundaries, and a skipped row is a missed claim, failing open at exactly the 1000+-row scale the pagination was added for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197wmwP6zNaYvsGfbZuQHGA --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b1f7116231
commit
fa69174aa0
@@ -1134,7 +1134,7 @@ export const enableBankingExtension: Extension = {
|
||||
const mappingsByUid = new Map(mappings.map(m => [m.uid, m]))
|
||||
const updatedAccounts: StoredAccount[] = existing.map(a => {
|
||||
const mapping = mappingsByUid.get(a.uid)
|
||||
return {
|
||||
const next: StoredAccount = {
|
||||
...a,
|
||||
enabled: enabledSet.has(a.uid),
|
||||
// Apply ledger_account from mapping when present. Explicit null clears it.
|
||||
@@ -1144,8 +1144,29 @@ export const enableBankingExtension: Extension = {
|
||||
? { ledger_account: mapping.ledger_account ?? undefined }
|
||||
: {}),
|
||||
}
|
||||
// Enabling an account is the deliberate takeover the callback's
|
||||
// guard flags exist to force: once made, the flags are stale (the
|
||||
// account syncs HERE now) and would keep rendering a false
|
||||
// "synkas i annat bolag" note in every later picker.
|
||||
if (next.enabled) {
|
||||
delete next.claimed_by_company_id
|
||||
delete next.claimed_by_company_name
|
||||
delete next.deselected_elsewhere
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Accounts the callback guard left disabled AND unmirrored (no ledger
|
||||
// anywhere) stay that way through a save that does not enable them:
|
||||
// allocating a 19xx slot and upserting a cash_accounts row for a
|
||||
// still-disabled claimed account would recreate exactly the state the
|
||||
// guard exists to prevent (another company's IBAN and name in this
|
||||
// company's chart and routing table), one screen after the callback
|
||||
// avoided it. Disabled accounts that already have a ledger or a
|
||||
// mirrored row keep the existing behavior: their row's enabled flag
|
||||
// must still flip off.
|
||||
const neverMirroredDisabledUids = new Set<string>()
|
||||
|
||||
// Resolve the effective mirror ledger for every account up front and
|
||||
// reject collisions with a 400 — the mirror pass below writes into
|
||||
// cash_accounts, whose UNIQUE (company_id, ledger_account) constraint
|
||||
@@ -1268,6 +1289,13 @@ export const enableBankingExtension: Extension = {
|
||||
// mirror pass surface any collision per-account, as before.
|
||||
for (const a of updatedAccounts) {
|
||||
if (effectiveLedgerByUid.has(a.uid)) continue
|
||||
// See neverMirroredDisabledUids above: a disabled account that has
|
||||
// never held a ledger or a mirrored row gets neither allocated nor
|
||||
// mirrored by this save.
|
||||
if (!enabledSet.has(a.uid) && !reuseRowByUid.has(a.uid)) {
|
||||
neverMirroredDisabledUids.add(a.uid)
|
||||
continue
|
||||
}
|
||||
let allocated: string | null = null
|
||||
try {
|
||||
const resolved = await resolvePsd2LedgerAccount(supabase, companyId, user.id, {
|
||||
@@ -1290,8 +1318,10 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
|
||||
// accounts_data mirrors the resolved assignment so the picker
|
||||
// pre-fills reality on the next open.
|
||||
// pre-fills reality on the next open. Skipped disabled accounts keep
|
||||
// no assignment: their slot is only claimed if they are ever enabled.
|
||||
for (const a of updatedAccounts) {
|
||||
if (neverMirroredDisabledUids.has(a.uid)) continue
|
||||
a.ledger_account = effectiveLedgerByUid.get(a.uid)
|
||||
}
|
||||
|
||||
@@ -1326,6 +1356,9 @@ export const enableBankingExtension: Extension = {
|
||||
// without reading the JSONB column.
|
||||
{
|
||||
for (const a of updatedAccounts) {
|
||||
// Never-mirrored disabled accounts (callback-guard leftovers the
|
||||
// user did not enable) get no cash_accounts row: see above.
|
||||
if (neverMirroredDisabledUids.has(a.uid)) continue
|
||||
const ledgerAccount = a.ledger_account ?? '1930'
|
||||
// Only reuse the IBAN-matched row when it already sits on the
|
||||
// ledger we are about to write. If the user deliberately remapped
|
||||
|
||||
Reference in New Issue
Block a user