You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(credentials): write an env value and its credential row together (#7160)
* fix(credentials): write an env value and its credential row together
Every writer of a workspace or personal environment map read-modify-writes a
single jsonb column, so they take an advisory lock on the map to serialize.
`deleteCredentialRecord` took none, and did the read, the edit and the
write-back outside a transaction: a secret written concurrently was read
before that write and silently dropped by the write-back.
The credential row was also written after its map transaction committed, in
four places. The delete direction left a row describing a value that was gone;
the create direction was worse than a stale row, because it cannot be repaired
by retrying — the key is in the map by then, so the next attempt reads it as
pre-existing, computes an empty `newKeys`, and never creates the row. Both
helpers already accept `executor`, and `setWorkspaceSecret` has been passing
the transaction since the parameter landed; these four were never migrated.
The personal reconcile stays outside its transaction: it opens its own and
takes the user-identity fence, so nesting it would have two transactions
taking two locks in opposite orders. It reconciles against the stored keys,
so a failure there is repaired by the next one rather than entrenched.
Also folds the four copies of the lock into one helper, since this would have
been the fifth.
* fix(workflows): say when a block is dropped before persistence
`workflow_blocks.name` is NOT NULL, so a block missing `type` or `name` has
to be dropped — but it was dropped silently. A block with no edges left no
trace anywhere: not in the returned warnings, not in a log line. The client
sanitizer warns on the identical condition; this is its server counterpart,
and the warnings array it feeds is already returned by the internal PUT, the
v2 write and the importer.
* improvement(chat): stop loading a transcript the v2 route never reads
Nothing caps a chat transcript — no per-chat message limit on write, no
pruning — and the v2 route keys continuity by `chatId`, so it read the whole
thing on every resumed turn and dropped it. Opt out there. The load stays the
default because the copilot send path does consume it.
* fix(credentials): serialize every personal env map writer
Exporting the personal lock while two writers skipped it left the map
unserialized: `upsertPersonalEnvVars` merged against a read taken outside any
lock, and the settings PUT replaced the map wholesale. A wholesale replace
landing between another writer's read and its write-back is discarded whole,
so it takes the lock too.
The delete path now removes the key's mirrors directly instead of reconciling
against a key list. The reconcile prunes every mirror absent from that list,
so a secret added between the read and the prune lost its mirror while its
value survived. `setPersonalSecret` already takes the map lock and then the
user-identity fence inside it, so the targeted delete introduces no new lock
order.
* fix(credentials): chunk the credential-ACL write the env save now depends on
`createWorkspaceEnvCredentials` wrote keys x members membership rows in one
statement, and neither side is bounded by the request contract. Past 65535
bind parameters that throws — previously a partial success, because the value
had already committed, but this now runs inside the value's transaction, so it
rolls the save back instead, deterministically, on every retry. A 50-member
workspace saving 150 keys reaches it. Chunked the same way the two personal
paths in this file already are.
Also from the audit:
- invalidate the decrypted-env cache after `deleteCredentialRecord` removes an
env value, matching the dedicated delete paths; without it a deleted secret
stayed resolvable for the cache TTL
- correct the comment claiming the personal reconcile "matches the replace" —
it prunes against this request's key list, so a secret added after the commit
still loses its mirror. Naming the gap instead of asserting it away
- name the one behavior change the in-transaction re-read introduces: a key
whose submitted value already matched is not re-encrypted, so a concurrent
write for that key now survives rather than being overwritten
- drop the lock-timeout constant and TSDoc left behind when the lock moved into
the shared helper, and stop shadowing `finalEncrypted`
0 commit comments