Unverified 提交 ef4ec841 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

fix: prevent supabase org credentials from being lost due to stale settings reads (#2821)

## Summary - Fix stale-read race condition in `handleSupabaseOAuthReturn` and `refreshSupabaseTokenForOrganization` where settings are read before an async network call, then written back using the stale snapshot — silently overwriting any concurrent credential writes from other orgs - Fix legacy `refreshSupabaseToken` to preserve the `organizations` map by spreading existing supabase settings before writing ## Test plan - Verify connecting multiple Supabase organizations no longer causes previously-connected orgs to lose their credentials - Verify token refresh for one org does not clobber another org's credentials - All existing unit tests pass (842/842) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2821" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 4b30281f
...@@ -51,6 +51,23 @@ ipc.chatStream.start(params, { onChunk, onEnd, onError }); ...@@ -51,6 +51,23 @@ ipc.chatStream.start(params, { onChunk, onEnd, onError });
- `createStreamClient(...).start()` returns `void`, not a cleanup/unsubscribe function. You cannot capture a handle to abort or clean up an active stream from the caller side. - `createStreamClient(...).start()` returns `void`, not a cleanup/unsubscribe function. You cannot capture a handle to abort or clean up an active stream from the caller side.
- To guard against duplicate streams, use a module-level `Set` (like `pendingStreamChatIds` in `useStreamChat.ts`) or a React state/ref-based lock, not the return value. - To guard against duplicate streams, use a module-level `Set` (like `pendingStreamChatIds` in `useStreamChat.ts`) or a React state/ref-based lock, not the return value.
## Settings write safety (`writeSettings`)
`writeSettings(partial)` does a **shallow top-level merge**: `{ ...currentSettings, ...partial }`. This means passing `{ supabase: { organizations: { ... } } }` replaces the entire `supabase` key, losing sibling fields like legacy tokens. Callers must spread the existing parent object:
```ts
// WRONG — destroys supabase.organizations and other fields
writeSettings({ supabase: { accessToken: { value: newToken } } });
// RIGHT — preserves sibling fields
const settings = readSettings();
writeSettings({
supabase: { ...settings.supabase, accessToken: { value: newToken } },
});
```
**Stale-read race condition:** If you call `readSettings()` before an async operation (network call, file I/O), then use the snapshot to construct the write, any concurrent settings changes during the async gap will be silently overwritten. Always call `readSettings()` immediately before `writeSettings()` — never across an `await` boundary.
## Handler expectations ## Handler expectations
- Handlers should `throw new Error("...")` on failure instead of returning `{ success: false }` style payloads. - Handlers should `throw new Error("...")` on failure instead of returning `{ success: false }` style payloads.
......
...@@ -144,9 +144,12 @@ export async function refreshSupabaseToken(): Promise<void> { ...@@ -144,9 +144,12 @@ export async function refreshSupabaseToken(): Promise<void> {
expiresIn, expiresIn,
} = await response.json(); } = await response.json();
// Update settings with new tokens // Re-read settings right before writing to get latest state
const freshSettings = readSettings();
// Update settings with new tokens, preserving existing fields (e.g. organizations map)
writeSettings({ writeSettings({
supabase: { supabase: {
...freshSettings.supabase,
accessToken: { accessToken: {
value: accessToken, value: accessToken,
}, },
...@@ -273,15 +276,18 @@ async function refreshSupabaseTokenForOrganization( ...@@ -273,15 +276,18 @@ async function refreshSupabaseTokenForOrganization(
expiresIn, expiresIn,
} = await response.json(); } = await response.json();
// Update the specific organization in settings // Re-read settings right before writing to avoid stale-read race conditions.
const existingOrgs = settings.supabase?.organizations ?? {}; // The async fetch above may take time, during which other org credentials
// could be written. Reading here ensures we merge into the latest state.
const freshSettings = readSettings();
const existingOrgs = freshSettings.supabase?.organizations ?? {};
writeSettings({ writeSettings({
supabase: { supabase: {
...settings.supabase, ...freshSettings.supabase,
organizations: { organizations: {
...existingOrgs, ...existingOrgs,
[organizationSlug]: { [organizationSlug]: {
...org, ...existingOrgs[organizationSlug],
accessToken: { accessToken: {
value: accessToken, value: accessToken,
}, },
......
...@@ -20,7 +20,6 @@ export async function handleSupabaseOAuthReturn({ ...@@ -20,7 +20,6 @@ export async function handleSupabaseOAuthReturn({
refreshToken, refreshToken,
expiresIn, expiresIn,
}: SupabaseOAuthReturnParams) { }: SupabaseOAuthReturnParams) {
const settings = readSettings();
let orgs: any[] = []; let orgs: any[] = [];
let errorOccurred = false; let errorOccurred = false;
...@@ -31,6 +30,12 @@ export async function handleSupabaseOAuthReturn({ ...@@ -31,6 +30,12 @@ export async function handleSupabaseOAuthReturn({
errorOccurred = true; errorOccurred = true;
} }
// Re-read settings right before writing to avoid stale-read race conditions.
// The async listSupabaseOrganizations call above may take time, during which
// other org credentials could be written (e.g. token refreshes). Reading here
// ensures we merge into the latest state.
const settings = readSettings();
if (!errorOccurred && orgs.length > 0) { if (!errorOccurred && orgs.length > 0) {
if (orgs.length > 1) { if (orgs.length > 1) {
logger.warn( logger.warn(
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论