fix(web): wait for the workspace identity read before gating a Home AMR send

A Team member with no billing permission and a $0 Team wallet was shown the
personal upgrade dialog on Home while the project page correctly asked them
to find the owner. Home's balance gate read the shell's exact workspace
identity at the click; while that read was still in flight (cold start, a
switch or a sign-in resolving) it had no scope, fell back to the ACCOUNT
wallet and, with no context to name an audience, chose the owner branch.

`awaitHomeAmrGateWorkspaceContext` now holds the send until the read the
shell already has in flight settles (bounded; a read that never settles
returns the draft with the existing "couldn't confirm balance" notice). Send
still never starts identity discovery, and a shell that has settled without a
workspace identity keeps the legacy account-scoped gate.

Pins: settled Team member → owner dialog + Team scope; identity in flight →
the gate waits and then scopes the Team wallet; never settles → notice, no
gate, no dialog.
This commit is contained in:
Siri-Ray
2026-09-18 15:52:28 +08:00
parent 359a672952
commit 8ee3a105c1
3 changed files with 230 additions and 9 deletions
+18 -9
View File
@@ -126,6 +126,7 @@ import {
retryUnavailableAmrBalanceGate,
type AmrBalanceGateScope,
} from '../runtime/amr-balance-gate';
import { awaitHomeAmrGateWorkspaceContext } from '../runtime/home-amr-gate-context';
import { HomeView, seedHomeComposerPrompt } from './HomeView';
import { entryStrategyRoutingFields } from './entry-strategy-routing';
import { EntryBlankState } from './EntryBlankState';
@@ -1496,18 +1497,26 @@ export function EntryShell({
let amrGatePrecheckPassed = false;
if (isAmrSend) {
// PRODUCT INVARIANT: Send never starts Workspace identity discovery.
// Billing consumes the shell's current in-memory snapshot; if it has not
// arrived yet, the existing account-scoped gate is used. The daemon's
// ordinary project-create route is local and does not need live Workspace
// authority. Account/scope generation checks below only prevent a result
// from being reused after the user switches identity while the balance
// Billing consumes the shell's current in-memory snapshot. While that
// read is still in flight the send waits for it (bounded) instead of
// gating on the account wallet — that fallback showed a Team member the
// personal upgrade dialog; see `awaitHomeAmrGateWorkspaceContext`. The
// legacy account-scoped gate remains for a shell that has settled
// without a workspace identity. The daemon's ordinary project-create
// route is local and does not need live Workspace authority.
// Account/scope generation checks below only prevent a result from
// being reused after the user switches identity while the balance
// request or dialog is in flight.
for (let scopeAttempt = 0; scopeAttempt < 2; scopeAttempt += 1) {
const settledWorkspace = await awaitHomeAmrGateWorkspaceContext(
() => workspaceContextStateRef.current,
);
if (settledWorkspace.kind === 'unsettled') {
handoff.rollback({ notice: t('home.amrGateUnavailable') });
return false;
}
const gateAccountGeneration = currentWorkspaceAccountGeneration();
const gateWorkspaceState = workspaceContextStateRef.current;
const gateWorkspaceContext = gateWorkspaceState.failure === 'unsupported'
? null
: workspaceResourceReadContext(gateWorkspaceState);
const gateWorkspaceContext = settledWorkspace.context;
const gateWorkspaceIdentity = workspaceIdentityCacheKey(gateWorkspaceContext);
const gateScope = amrBalanceGateScopeForWorkspaceContext(gateWorkspaceContext);
let gate = await retryUnavailableAmrBalanceGate(
@@ -0,0 +1,67 @@
import type { WorkspaceCollabContext } from '@open-design/contracts';
import {
workspaceResourceReadContext,
type WorkspaceContextState,
} from '../collab/useWorkspaceContext';
/**
* How long a Home send waits for the shell's workspace identity read to
* settle before giving up on gating the run. A cold context read completes
* well inside this; a read that never completes is Cloud being unreachable.
*/
export const HOME_AMR_GATE_CONTEXT_SETTLE_MS = 6_000;
const POLL_MS = 50;
export type HomeAmrGateWorkspaceContext =
| { kind: 'settled'; state: WorkspaceContextState; context: WorkspaceCollabContext | null }
| { kind: 'unsettled' };
/**
* The workspace context a Home OpenDesign Cloud send may gate its wallet on.
*
* INVARIANT: a send never gates on the account wallet while the shell's
* workspace identity read is still in flight. The balance gate without a
* scope reads the ACCOUNT (personal) wallet and, with no context to name an
* audience, shows the owner's upgrade dialog. A Team member who clicks Send
* before the identity read has settled (cold start, a workspace switch or a
* sign-in still resolving) would therefore be told to upgrade a personal plan
* for a Team wallet they cannot manage — the dialog the in-project gate, which
* always has its project's scope, never shows them (OPEND-3300 follow-up).
*
* So: the exact read identity, when the shell has one; the legacy account path
* only when the shell has SETTLED without one (an old daemon with no workspace
* endpoint, or an authoritative "no workspace"); and while the read is in
* flight, wait for it — bounded, because Home has no queue to park a send in.
*
* Send still never STARTS identity discovery: this only observes the read the
* shell already has in flight.
*/
export async function awaitHomeAmrGateWorkspaceContext(
read: () => WorkspaceContextState,
options: { deadlineMs?: number } = {},
): Promise<HomeAmrGateWorkspaceContext> {
const deadlineMs = options.deadlineMs ?? HOME_AMR_GATE_CONTEXT_SETTLE_MS;
const startedAt = Date.now();
for (;;) {
const state = read();
const context = state.failure === 'unsupported'
? null
: workspaceResourceReadContext(state);
if (context || !workspaceIdentityReadInFlight(state)) {
return { kind: 'settled', state, context };
}
if (Date.now() - startedAt >= deadlineMs) return { kind: 'unsettled' };
await new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, POLL_MS);
});
}
}
/** The shell has announced an identity read it has not heard back from. */
export function workspaceIdentityReadInFlight(
state: Pick<WorkspaceContextState, 'loading' | 'identityChangePending' | 'failure'>,
): boolean {
if (state.failure === 'unsupported') return false;
return Boolean(state.loading) || Boolean(state.identityChangePending);
}
@@ -32,6 +32,7 @@ import type {
} from '../../src/components/EntryShell';
import { I18nProvider } from '../../src/i18n';
import { checkAmrBalanceGate } from '../../src/runtime/amr-balance-gate';
import { HOME_AMR_GATE_CONTEXT_SETTLE_MS } from '../../src/runtime/home-amr-gate-context';
import type { AgentInfo, AppConfig } from '../../src/types';
import { EntryShellWithGateHost } from '../helpers/entry-shell-gate-host';
import { setHomeHeroPrompt } from '../helpers/home-hero-lexical';
@@ -393,3 +394,147 @@ describe('OPEND-3300 · an empty in-memory wallet blocks on Home before the hand
expect(h.rollback).not.toHaveBeenCalled();
});
});
/**
* OPEND-3300 follow-up · a Team member with no billing permission at $0 is
* told to ask the workspace owner — on Home exactly as in the project. A
* tester (member, `canManageBilling=false`, Team wallet $0) got the personal
* upgrade dialog instead: with the shell's workspace identity read still in
* flight at the click, the gate had no scope, read the ACCOUNT wallet and
* named the owner audience. The send now waits for the read the shell already
* has in flight (`awaitHomeAmrGateWorkspaceContext`) and never gates the
* account wallet while it is.
*/
describe('OPEND-3300 · a Team member at $0 is asked to find the owner on Home', () => {
function teamMemberContext(): WorkspaceCollabContext {
const role = 'member' as const;
const lifecycleState = 'active' as const;
return {
workspaceId: 'ws-team-3300',
workspaceType: 'team',
workspaceMemberId: 'wm-team-3300-member',
role,
memberStatus: 'active',
lifecycleState,
billingState: 'active',
planId: 'team_pro',
providerMode: 'platform_credits',
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 2 }),
permissions: buildWorkspacePermissions({ role, lifecycleState }),
teamId: 'ws-team-3300',
teamName: 'Acme Design',
workspaceName: 'Acme Design',
};
}
const teamScope = {
workspaceType: 'team',
workspaceId: 'ws-team-3300',
workspaceMemberId: 'wm-team-3300-member',
};
/**
* The shell's reads. `holdIdentity` keeps the directory and context reads
* pending until `releaseIdentity()` — the shape of a click that lands
* before a cold start or a workspace switch has resolved.
*/
function installFetch(workspace: WorkspaceCollabContext, options: { holdIdentity?: boolean } = {}) {
let releaseIdentity: () => void = () => undefined;
const identityReleased = new Promise<void>((resolve) => {
releaseIdentity = resolve;
});
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith('/api/workspace/directory')) {
if (options.holdIdentity) await identityReleased;
return jsonResponse(workspaceDirectoryFixture([workspace]));
}
if (url.endsWith('/api/workspace/context')) {
if (options.holdIdentity) await identityReleased;
return jsonResponse({ context: workspace });
}
if (url.includes('/api/workspace/billing?')) {
// No in-memory reading: the verdict comes from the confirmed gate.
return jsonResponse({ summary: null, workspaceBalance: null });
}
if (url.endsWith('/api/workspace/projects/team')) return jsonResponse({ projects: [] });
if (url.endsWith('/api/plugins')) return jsonResponse({ plugins: [] });
if (url.endsWith('/api/mcp/servers')) return jsonResponse({ servers: [] });
if (url.endsWith('/api/community/discord')) return jsonResponse({ stale: true });
if (url.endsWith('/api/github/open-design')) return jsonResponse({ stale: true });
return jsonResponse({});
}) as typeof fetch;
return { releaseIdentity: () => releaseIdentity() };
}
beforeEach(() => {
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
window.sessionStorage.clear();
window.history.replaceState(null, '', '/');
resetWorkspaceContextCache();
resetWorkspaceBillingCache();
resetTeamProjectsCache();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
globalThis.fetch = originalFetch;
globalThis.ResizeObserver = originalResizeObserver;
mockedCheckAmrBalanceGate.mockReset();
resetWorkspaceContextCache();
resetWorkspaceBillingCache();
resetTeamProjectsCache();
});
it('settled member identity: the Team wallet is gated and the owner dialog shows, never the upgrade one', async () => {
installFetch(teamMemberContext());
const h = harness('amr');
h.setGate({ kind: 'hard', reason: 'insufficient', snapshot: emptyWallet() });
// Let the identity read settle the way it does before a user can type.
await waitFor(() => expect(screen.getByTestId('home-hero-input')).toBeTruthy());
await submitHome('Design the onboarding flow.');
await screen.findByTestId('amr-balance-owner-dialog');
expect(screen.queryByTestId('amr-balance-dialog')).toBeNull();
expect(mockedCheckAmrBalanceGate.mock.calls[0]?.[0]).toEqual(teamScope);
expect(h.onCreateProject).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('amr-balance-owner-dismiss'));
await waitFor(() => expect(h.rollback).toHaveBeenCalledTimes(1));
expect(h.rollback).toHaveBeenCalledWith();
});
it('identity read still in flight at the click: the send waits for it and gates the Team wallet, not the account', async () => {
const reads = installFetch(teamMemberContext(), { holdIdentity: true });
const h = harness('amr');
h.setGate({ kind: 'hard', reason: 'insufficient', snapshot: emptyWallet() });
await submitHome('Design the onboarding flow.');
await waitFor(() => expect(h.onBeginProjectCreation).toHaveBeenCalledTimes(1));
// Nothing may be gated yet: the shell has not heard which workspace pays.
await new Promise((resolve) => { setTimeout(resolve, 150); });
expect(mockedCheckAmrBalanceGate).not.toHaveBeenCalled();
expect(screen.queryByTestId('amr-balance-dialog')).toBeNull();
reads.releaseIdentity();
await screen.findByTestId('amr-balance-owner-dialog');
expect(screen.queryByTestId('amr-balance-dialog')).toBeNull();
expect(mockedCheckAmrBalanceGate.mock.calls[0]?.[0]).toEqual(teamScope);
expect(h.onCreateProject).not.toHaveBeenCalled();
});
it('identity read that never settles: the send returns to Home with the balance notice and gates nothing', async () => {
installFetch(teamMemberContext(), { holdIdentity: true });
const h = harness('amr');
await submitHome('Design the onboarding flow.');
await waitFor(() => expect(h.onBeginProjectCreation).toHaveBeenCalledTimes(1));
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(HOME_AMR_GATE_CONTEXT_SETTLE_MS + 200);
vi.useRealTimers();
await waitFor(() => expect(h.rollback).toHaveBeenCalledTimes(1));
expect(h.rollback).toHaveBeenCalledWith({
notice: 'Couldn\'t confirm your OpenDesign Cloud balance. Try sending again.',
});
expect(mockedCheckAmrBalanceGate).not.toHaveBeenCalled();
expect(screen.queryByTestId('amr-balance-dialog')).toBeNull();
expect(screen.queryByTestId('amr-balance-owner-dialog')).toBeNull();
expect(h.onCreateProject).not.toHaveBeenCalled();
});
});