fix: fail closed on unresolved workspace authority

This commit is contained in:
lefarcen
2026-07-27 15:15:39 +08:00
parent 7b4a719e9e
commit 21f452ffe4
21 changed files with 912 additions and 115 deletions
@@ -0,0 +1,85 @@
import {
isWorkspaceResourceLocked,
workspaceResourceContextFromRequest,
type WorkspaceResourceContext,
} from './workspace-resource-mutation.js';
export type CreatedProjectWorkspaceResolution =
| { ok: true; context: WorkspaceResourceContext | null }
| {
ok: false;
status: 400 | 403;
code: 'WORKSPACE_CONTEXT_INCOMPLETE' | 'WORKSPACE_PROJECT_PERMISSION_DENIED';
message: string;
};
/**
* Resolve the workspace authority for a route that creates a project.
*
* A completely headerless request is a legal legacy/anonymous caller and
* intentionally leaves the new project unbound. Once either workspace
* identity header is present, however, the request is a workspace-aware
* caller: partial, removed, locked, or non-writing identities must fail
* closed instead of silently creating an unbound orphan.
*/
export function resolveCreatedProjectWorkspace(
req: unknown,
): CreatedProjectWorkspaceResolution {
const context = workspaceResourceContextFromRequest(req);
if (context === null) return { ok: true, context: null };
if (context === 'missing') {
return {
ok: false,
status: 400,
code: 'WORKSPACE_CONTEXT_INCOMPLETE',
message: 'workspace project creation requires both workspace and member identity',
};
}
if (
context.memberStatus !== 'active'
|| !context.canWriteSyncedFiles
|| isWorkspaceResourceLocked(context)
) {
return {
ok: false,
status: 403,
code: 'WORKSPACE_PROJECT_PERMISSION_DENIED',
message: 'workspace project creation is not allowed',
};
}
return { ok: true, context };
}
export function bindCreatedProjectToWorkspace(
ensureWorkspaceProject: (input: {
projectId: string;
workspaceId: string;
visibility: 'personal';
resourceState: 'active';
createdByWorkspaceMemberId: string;
updatedByWorkspaceMemberId: string;
syncState: 'local_only';
resourceHubResourceId: null;
cloudTombstonedAt: null;
createdAt: number;
updatedAt: number;
}) => unknown,
context: WorkspaceResourceContext | null,
projectId: string,
now: number,
): void {
if (!context) return;
ensureWorkspaceProject({
projectId,
workspaceId: context.workspaceId,
visibility: 'personal',
resourceState: 'active',
createdByWorkspaceMemberId: context.workspaceMemberId,
updatedByWorkspaceMemberId: context.workspaceMemberId,
syncState: 'local_only',
resourceHubResourceId: null,
cloudTombstonedAt: null,
createdAt: now,
updatedAt: now,
});
}
+19 -19
View File
@@ -87,6 +87,10 @@ import {
} from '../../collab/workspace-resource-mutation.js';
import type { WorkspaceContextProvider } from '../../collab/workspace-context.js';
import { resolveProjectWorkspaceScope } from '../../collab/project-workspace-scope.js';
import {
bindCreatedProjectToWorkspace,
resolveCreatedProjectWorkspace,
} from '../../collab/created-project-workspace.js';
import type { WorkspaceDirectoryFetchResult } from '../../collab/vela-workspace-context.js';
import { cancelRunsOwnedBy } from './cancel-owned-runs.js';
@@ -2725,6 +2729,15 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
app.post('/api/projects', async (req, res) => {
try {
const createWorkspace = resolveCreatedProjectWorkspace(req);
if (!createWorkspace.ok) {
return sendApiError(
res,
createWorkspace.status,
createWorkspace.code,
createWorkspace.message,
);
}
const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
req.body || {};
if (typeof id !== 'string' || !isSafeId(id)) {
@@ -2910,25 +2923,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
createdAt: now,
updatedAt: now,
});
const workspaceIdForCreate = headerValue(req, 'x-od-workspace-id');
const createWorkspaceContext = workspaceIdForCreate
? workspaceProjectContext(req, workspaceIdForCreate)
: null;
if (createWorkspaceContext && createWorkspaceContext.memberStatus === 'active') {
ensureWorkspaceProject(db, {
projectId: id,
workspaceId: createWorkspaceContext.workspaceId,
visibility: 'personal',
resourceState: 'active',
createdByWorkspaceMemberId: createWorkspaceContext.workspaceMemberId,
updatedByWorkspaceMemberId: createWorkspaceContext.workspaceMemberId,
syncState: 'local_only',
resourceHubResourceId: null,
cloudTombstonedAt: null,
createdAt: now,
updatedAt: now,
});
}
bindCreatedProjectToWorkspace(
(input) => ensureWorkspaceProject(db, input),
createWorkspace.context,
id,
now,
);
const explicitPlugin =
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
? true
@@ -8,11 +8,11 @@ type SqliteDb = Parameters<typeof getWorkspaceProjectByProjectId>[0];
export class ProjectWorkspaceScopeUnavailableError extends Error {
constructor(
readonly projectId: string,
readonly workspaceId: string,
readonly workspaceId: string | null,
) {
super(
`Cannot authorize project ${projectId}: its persisted workspace ` +
`${workspaceId} is unavailable for the signed-in member`,
`${workspaceId ?? 'binding'} is unavailable for the signed-in member`,
);
this.name = 'ProjectWorkspaceScopeUnavailableError';
}
@@ -59,20 +59,21 @@ export async function openDesignAmrTraceEnvForProject(
let workspaceId: string | null = null;
if (projectId) {
const binding = getWorkspaceProjectByProjectId(db, projectId);
if (binding) {
const directory = await deps.fetchWorkspaceDirectory().catch(
(): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }),
);
const scope = resolveProjectWorkspaceScope({
projectId,
binding,
directory,
});
if (scope.kind === 'unavailable') {
throw new ProjectWorkspaceScopeUnavailableError(projectId, scope.workspaceId);
}
if (scope.kind === 'team') workspaceId = scope.workspaceId;
if (!binding) {
throw new ProjectWorkspaceScopeUnavailableError(projectId, null);
}
const directory = await deps.fetchWorkspaceDirectory().catch(
(): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }),
);
const scope = resolveProjectWorkspaceScope({
projectId,
binding,
directory,
});
if (scope.kind === 'unbound' || scope.kind === 'unavailable') {
throw new ProjectWorkspaceScopeUnavailableError(projectId, scope.workspaceId);
}
if (scope.kind === 'team') workspaceId = scope.workspaceId;
}
return openDesignAmrTraceEnv({
...traceInput,
@@ -205,6 +205,44 @@ describe('workspace project routes', () => {
expect(allB.projects.map((item) => item.id)).not.toContain(projectId);
});
it('rejects partial or revoked workspace-aware creates without leaving an unbound project', async () => {
const suffix = Date.now();
const partialId = `workspace-create-partial-${suffix}`;
const partial = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-od-workspace-id': `${workspaceId}-partial`,
},
body: JSON.stringify({
id: partialId,
name: 'Must not become unbound',
skillId: null,
designSystemId: null,
}),
});
expect(partial.status).toBe(400);
expect(await fetch(`${baseUrl}/api/projects/${partialId}`).then((response) => response.status))
.toBe(404);
const revokedId = `workspace-create-revoked-${suffix}`;
const revoked = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: workspaceHeaders(`${workspaceId}-revoked`, 'member-revoked', {
'x-od-workspace-member-status': 'removed',
}),
body: JSON.stringify({
id: revokedId,
name: 'Must fail closed',
skillId: null,
designSystemId: null,
}),
});
expect(revoked.status).toBe(403);
expect(await fetch(`${baseUrl}/api/projects/${revokedId}`).then((response) => response.status))
.toBe(404);
});
it('keeps the persisted workspace binding on the project detail read model', async () => {
const suffix = Date.now();
const projectId = `workspace-detail-scope-${suffix}`;
@@ -41,6 +41,36 @@ describe('openDesignAmrTraceEnvForProject', () => {
expect(fetchWorkspaceDirectory).not.toHaveBeenCalled();
});
it('fails closed instead of charging the account wallet for an unbound AMR project', async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-amr-unbound-scope-'));
const db = openDatabase(tempDir);
const now = Date.now();
insertProject(db, {
id: 'project-unbound',
name: 'Unbound project',
createdAt: now,
updatedAt: now,
});
const fetchWorkspaceDirectory = vi.fn(async () => ({
ok: true as const,
items: [],
}));
await expect(openDesignAmrTraceEnvForProject(db, {
agentId: 'amr',
runId: 'run-unbound',
runAttempt: 0,
projectId: 'project-unbound',
}, {
fetchWorkspaceDirectory,
})).rejects.toEqual(expect.objectContaining({
name: ProjectWorkspaceScopeUnavailableError.name,
projectId: 'project-unbound',
workspaceId: null,
}));
expect(fetchWorkspaceDirectory).not.toHaveBeenCalled();
});
it('carries the real SQLite team binding into the final AMR spawn environment', async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-amr-project-scope-'));
const db = openDatabase(tempDir);
+11 -2
View File
@@ -125,6 +125,7 @@ import {
listTemplates,
deleteTemplate,
patchProject,
resolvedWorkspaceContextForWrite,
} from './state/projects';
import { useModalWindowDragGuard } from './hooks/useModalWindowDragGuard';
import type {
@@ -626,10 +627,16 @@ function AppInner() {
const iframeKeepAlivePool = useIframeKeepAlivePool();
const clientType = useMemo(() => detectClientType(), []);
useModalWindowDragGuard();
const { context: workspaceContext, loading: workspaceContextLoading } = useWorkspaceContext();
const workspaceContextState = useWorkspaceContext();
const {
context: workspaceContext,
loading: workspaceContextLoading,
} = workspaceContextState;
const workspaceBilling = useWorkspaceBilling();
const workspaceContextRef = useRef<WorkspaceCollabContext | null>(null);
const workspaceContextStateRef = useRef(workspaceContextState);
workspaceContextRef.current = workspaceContext;
workspaceContextStateRef.current = workspaceContextState;
const listCurrentWorkspaceProjects = useCallback(
(options?: { throwOnError?: boolean; workspaceView?: WorkspaceProjectListView }) => {
const context = workspaceContextRef.current;
@@ -1983,7 +1990,9 @@ function AppInner() {
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }
: {}),
...(input.pluginInputs ? { pluginInputs: input.pluginInputs } : {}),
workspaceContext: workspaceContextRef.current,
workspaceContext: resolvedWorkspaceContextForWrite(
workspaceContextStateRef.current,
),
});
} catch (err) {
const errorCode =
@@ -12,6 +12,7 @@ const PROJECT_SCOPE_RETRY_MS = 5_000;
export interface ProjectWorkspaceScopeState {
loading: boolean;
scope: ProjectWorkspaceScope | null;
failure?: 'unsupported' | 'forbidden' | 'unavailable';
}
export function projectWorkspaceContext(
@@ -28,6 +29,23 @@ export function projectWorkspaceScopeReady(
return scope?.kind === 'unbound' || scope?.kind === 'personal' || scope?.kind === 'team';
}
/** AMR must resolve an explicit personal/team billing principal. Unbound,
* revoked, loading and directory-outage states all fail closed. */
export function projectWorkspaceScopeAuthorizesAmr(
scope: ProjectWorkspaceScope | null | undefined,
): boolean {
return scope?.kind === 'personal' || scope?.kind === 'team';
}
class ProjectWorkspaceScopeFetchError extends Error {
constructor(
readonly failure: NonNullable<ProjectWorkspaceScopeState['failure']>,
) {
super(`project workspace scope ${failure}`);
this.name = 'ProjectWorkspaceScopeFetchError';
}
}
function validScopeForProject(
scope: ProjectWorkspaceScope,
projectId: string,
@@ -53,7 +71,15 @@ async function fetchProjectWorkspaceScope(
`/api/projects/${encodeURIComponent(projectId)}/workspace-scope`,
{ cache: 'no-store', signal },
);
if (!response.ok) throw new Error(`project workspace scope ${response.status}`);
if (!response.ok) {
throw new ProjectWorkspaceScopeFetchError(
response.status === 404
? 'unsupported'
: response.status === 403
? 'forbidden'
: 'unavailable',
);
}
const body = (await response.json()) as ProjectWorkspaceScopeResponse;
if (!body.scope || !validScopeForProject(body.scope, projectId)) {
throw new Error('project workspace scope identity mismatch');
@@ -135,14 +161,23 @@ export function useProjectWorkspaceScope(projectId: string): ProjectWorkspaceSco
if (scope.kind === 'unavailable') {
retryTimer = setTimeout(() => void load(), PROJECT_SCOPE_RETRY_MS);
}
} catch {
} catch (error) {
if (controller.signal.aborted || epoch !== epochRef.current) return;
const failure =
error instanceof ProjectWorkspaceScopeFetchError
? error.failure
: 'unavailable';
setState({
loading: false,
scope: null,
resolvedRevision: refreshRevision,
failure,
});
retryTimer = setTimeout(() => void load(), PROJECT_SCOPE_RETRY_MS);
// An old daemon has no endpoint to recover on a timer. Identity-change
// and page lifecycle invalidations still revalidate after an upgrade.
if (failure !== 'unsupported') {
retryTimer = setTimeout(() => void load(), PROJECT_SCOPE_RETRY_MS);
}
}
};
@@ -158,9 +193,13 @@ export function useProjectWorkspaceScope(projectId: string): ProjectWorkspaceSco
// transition frame: it could briefly enable B's composer with A's wallet.
if (
state.resolvedRevision !== refreshRevision ||
state.scope?.projectId !== projectId
(state.scope !== null && state.scope.projectId !== projectId)
) {
return { loading: true, scope: null };
}
return { loading: state.loading, scope: state.scope };
return {
loading: state.loading,
scope: state.scope,
...(state.failure ? { failure: state.failure } : {}),
};
}
+85 -22
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type {
TeamProject,
WorkspaceBillingResponse,
WorkspaceBillingSnapshot,
WorkspaceBillingSummary,
WorkspaceCollabContext,
WorkspaceContextResponse,
@@ -21,6 +22,13 @@ import { useWorkspaceInvalidation } from './workspace-events';
export interface WorkspaceContextState {
context: WorkspaceCollabContext | null;
loading: boolean;
/**
* `unsupported` is an old daemon with no workspace endpoint and retains the
* legal pre-workspace/headerless behavior. `unavailable` means a modern
* workspace answer is unknown; write paths must fail closed instead of
* treating that outage as an anonymous identity.
*/
failure?: 'unsupported' | 'unavailable';
}
/** Coalescing key for `GET /api/workspace/context`; shared so an identity
@@ -36,18 +44,19 @@ const WORKSPACE_CONTEXT_COALESCE_KEY = 'workspace-context';
let cachedWorkspaceContext: WorkspaceContextState['context'] = null;
let workspaceContextRevision = 0;
let volatileWorkspaceRuntimeClientId: string | null = null;
let workspaceRuntimeGeneration = 0n;
let lastWorkspaceRuntimeInterestKey: string | null = null;
let lastWorkspaceRuntimeInterestGeneration = '0';
let workspaceRuntimeInterestSequence = 0n;
const workspaceRuntimeInterests = new Map<
string,
{ clientId: string; generation: string }
>();
/** Test seam: clear the module-level context cache between tests. */
export function resetWorkspaceContextCache(): void {
cachedWorkspaceContext = null;
workspaceContextRevision = 0;
volatileWorkspaceRuntimeClientId = null;
workspaceRuntimeGeneration = 0n;
lastWorkspaceRuntimeInterestKey = null;
lastWorkspaceRuntimeInterestGeneration = '0';
workspaceRuntimeInterestSequence = 0n;
workspaceRuntimeInterests.clear();
}
function workspaceRuntimeClientId(): string {
@@ -63,14 +72,19 @@ function workspaceRuntimeClientId(): string {
return generated;
}
function workspaceRuntimeGenerationFor(interestKey: string): string {
if (lastWorkspaceRuntimeInterestKey === interestKey) {
return lastWorkspaceRuntimeInterestGeneration;
}
workspaceRuntimeGeneration += 1n;
lastWorkspaceRuntimeInterestKey = interestKey;
lastWorkspaceRuntimeInterestGeneration = workspaceRuntimeGeneration.toString();
return lastWorkspaceRuntimeInterestGeneration;
function workspaceRuntimeInterestFor(
interestKey: string,
): { clientId: string; generation: string } {
const existing = workspaceRuntimeInterests.get(interestKey);
if (existing) return existing;
workspaceRuntimeInterestSequence += 1n;
const interest = {
clientId:
`${workspaceRuntimeClientId()}:billing:${workspaceRuntimeInterestSequence}`,
generation: '1',
};
workspaceRuntimeInterests.set(interestKey, interest);
return interest;
}
/**
@@ -156,7 +170,13 @@ export function useWorkspaceContext(): WorkspaceContextState {
try {
const fetchContext = async () => {
const res = await fetch('/api/workspace/context', { cache: 'no-store' });
if (!res.ok) throw new Error(`workspace-context ${res.status}`);
if (!res.ok) {
const error = new Error(`workspace-context ${res.status}`) as Error & {
status?: number;
};
error.status = res.status;
throw error;
}
return (await res.json()) as WorkspaceContextResponse;
};
// Coalesced: every mounted consumer of this hook (and every focus/pageshow
@@ -177,13 +197,20 @@ export function useWorkspaceContext(): WorkspaceContextState {
}
cachedWorkspaceContext = nextContext;
setState({ context: cachedWorkspaceContext, loading: false });
} catch {
} catch (error) {
if (!mountedRef.current || requestEpochRef.current !== requestEpoch) return;
// Transient failure (offline, momentary daemon/hub hiccup): keep the
// last-known context instead of flashing the signed-out state. A never-
// signed-in / personal user has a null cache, so this still shows the local
// state for them.
setState({ context: cachedWorkspaceContext, loading: false });
setState({
context: cachedWorkspaceContext,
loading: false,
failure:
(error as { status?: unknown })?.status === 404
? 'unsupported'
: 'unavailable',
});
}
}, []);
@@ -356,9 +383,9 @@ export function useWorkspaceBillingResponse(explicitScope?: {
explicitScope?.revision ?? workspaceContextRevision
}`
: null;
const billingRuntimeGeneration =
const billingRuntimeInterest =
billingRequestKey && context?.workspaceType === 'team'
? workspaceRuntimeGenerationFor(billingRequestKey)
? workspaceRuntimeInterestFor(billingRequestKey)
: null;
const [state, setState] = useState<{
scopeKey: string;
@@ -409,8 +436,10 @@ export function useWorkspaceBillingResponse(explicitScope?: {
const runtimeHeaders =
context?.workspaceType === 'team'
? {
'x-od-workspace-runtime-client-id': workspaceRuntimeClientId(),
'x-od-workspace-runtime-generation': billingRuntimeGeneration ?? '0',
'x-od-workspace-runtime-client-id':
billingRuntimeInterest?.clientId ?? '',
'x-od-workspace-runtime-generation':
billingRuntimeInterest?.generation ?? '0',
}
: undefined;
const res = await fetch(billingUrl, {
@@ -484,7 +513,7 @@ export function useWorkspaceBillingResponse(explicitScope?: {
}
}, [
billingRequestKey,
billingRuntimeGeneration,
billingRuntimeInterest,
billingScopeKey,
billingUrl,
context?.workspaceType,
@@ -647,6 +676,40 @@ export function workspaceBillingBalanceUsd(
return balance || null;
}
/**
* Return the exact workspace/member snapshot authorized for this context.
* Account summaries and a snapshot for another membership epoch are never
* accepted as workspace plan authority.
*/
export function workspaceBillingSnapshotForContext(
response: WorkspaceBillingResponse | null | undefined,
context: WorkspaceCollabContext | null | undefined,
): WorkspaceBillingSnapshot | null {
if (!response || !context || context.workspaceType !== 'team') return null;
const snapshot = response.workspaceSnapshot;
if (
!snapshot ||
snapshot.billingScopeVersion !== 2 ||
snapshot.workspaceId !== context.workspaceId ||
snapshot.workspaceMemberId !== context.workspaceMemberId
) {
return null;
}
const runtime = response.workspaceRuntime;
if (
runtime &&
(
runtime.workspaceId !== context.workspaceId ||
runtime.workspaceMemberId !== context.workspaceMemberId ||
runtime.status === 'error' ||
runtime.status === 'access-revoked'
)
) {
return null;
}
return snapshot;
}
const WORKSPACE_BILLING_POLL_MS = 30_000;
const WORKSPACE_BILLING_RETRY_MS = 5_000;
const WORKSPACE_BILLING_RETRY_EVENT = 'od:workspace-billing-retry';
+32 -12
View File
@@ -29,6 +29,7 @@ import {
useWorkspaceBillingResponse,
useWorkspaceContext,
workspaceBillingBalanceUsd,
workspaceBillingSnapshotForContext,
} from '../collab/useWorkspaceContext';
import {
projectWorkspaceContext,
@@ -66,6 +67,13 @@ function displayAgentName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
return agent.id === 'amr' ? 'Open Design' : agent.name;
}
function displayAmrPlan(plan: string | null | undefined): string | null {
const normalized = plan?.trim().replace(/^team[_-]/i, '') ?? '';
return normalized
? normalized.charAt(0).toUpperCase() + normalized.slice(1)
: null;
}
/**
* Compact runtime control. Click opens a dropdown with the Open Design account
* and the model picker for the active agent. Execution wiring that is not a
@@ -268,12 +276,22 @@ export function AvatarMenu({
cancelled = true;
};
}, [open, amrAvailable, workspaceContext?.workspaceType]);
const amrPlanTrimmed = amrAccount?.loggedIn
? amrAccount.account?.plan?.trim() || ''
: '';
const amrPlanDisplay = amrPlanTrimmed
? amrPlanTrimmed.charAt(0).toUpperCase() + amrPlanTrimmed.slice(1)
: null;
const exactWorkspaceSnapshot = workspaceBillingSnapshotForContext(
workspaceBillingResponse,
workspaceContext,
);
const scopedPlanId =
workspaceContext?.workspaceType === 'team'
? exactWorkspaceSnapshot?.billing.planId?.trim() || null
: workspaceContext?.workspaceType === 'personal'
? workspaceBillingResponse?.summary?.membershipTier?.trim() || null
: null;
const amrPlanId = projectWorkspaceScope
? scopedPlanId
: scopedPlanId ?? (amrAccount?.loggedIn
? amrAccount.account?.plan?.trim() || null
: null);
const amrPlanDisplay = displayAmrPlan(amrPlanId);
const scopedWorkspaceBalance = formatVelaBalanceUsd(
workspaceBillingBalanceUsd(workspaceBillingResponse, workspaceContext),
);
@@ -285,11 +303,13 @@ export function AvatarMenu({
financialScopeResolved
? workspaceContext?.workspaceType === 'team'
? scopedWorkspaceBalance
: scopedWorkspaceBalance ??
formatVelaBalanceUsd(amrAccount.account?.balanceUsd) ??
(amrWalletSnapshot?.status === 'available'
? formatVelaBalanceUsd(amrWalletSnapshot.balanceUsd)
: null)
: projectWorkspaceScope
? scopedWorkspaceBalance
: scopedWorkspaceBalance ??
formatVelaBalanceUsd(amrAccount.account?.balanceUsd) ??
(amrWalletSnapshot?.status === 'available'
? formatVelaBalanceUsd(amrWalletSnapshot.balanceUsd)
: null)
: null;
const amrResolvedProfile = amrAccount?.profile ?? amrProfile;
const financialWorkspaceId =
@@ -309,7 +329,7 @@ export function AvatarMenu({
// path.
const amrCanUpgrade =
!!amrAccount?.loggedIn &&
canUpgradeVelaPlan(amrAccount.account?.plan) &&
canUpgradeVelaPlan(amrPlanId?.replace(/^team[_-]/i, '')) &&
Boolean(workspaceContext?.permissions?.canManageBilling) &&
amrPlansUrl !== null;
const openAmrTarget = (
+3
View File
@@ -1054,6 +1054,7 @@ export function EntryShell({
? {
workspaceType: workspaceContext.workspaceType,
workspaceId: workspaceContext.workspaceId,
workspaceMemberId: workspaceContext.workspaceMemberId,
}
: undefined,
);
@@ -1078,10 +1079,12 @@ export function EntryShell({
? {
workspaceType: workspaceContext.workspaceType,
workspaceId: workspaceContext.workspaceId,
workspaceMemberId: workspaceContext.workspaceMemberId,
}
: undefined,
);
}
if (gate.kind === 'unavailable') return 'blocked' as const;
if (gate.kind === 'soft') {
// Hold THIS submit while the reminder waits for a decision; 'proceed'
// resumes the same create-and-run below, so HomeView's normal accept
+6 -1
View File
@@ -42,6 +42,7 @@ import {
listPlugins,
listPluginsFresh,
patchProject,
resolvedWorkspaceContextForWrite,
renderPluginBriefTemplate,
resolvePluginQueryFallback,
} from '../state/projects';
@@ -405,7 +406,8 @@ export function HomeView({
}: Props) {
const { locale, t } = useI18n();
const analytics = useAnalytics();
const { context: workspaceContext } = useWorkspaceContext();
const workspaceContextState = useWorkspaceContext();
const { context: workspaceContext } = workspaceContextState;
// Team-wide catalog from the resource hub via the daemon; empty off-team / when
// the hub is unconfigured. Only the creator attribution is derived here — the
// shared/not-shared answer arrives as `isSharedProject` from EntryShell, which
@@ -1466,6 +1468,7 @@ export function HomeView({
// agent title arrives — see the matching note in
// EntryShell.startBlankProjectFromRail.
metadata: { kind: 'other', nameSource: 'generated' },
workspaceContext: resolvedWorkspaceContextForWrite(workspaceContextState),
});
onOpenProject(project.id);
} catch {
@@ -2469,6 +2472,7 @@ export function HomeView({
name: 'Imported from Figma',
skillId: null,
designSystemId: null,
workspaceContext: resolvedWorkspaceContextForWrite(workspaceContextState),
});
return project.id;
} catch {
@@ -2491,6 +2495,7 @@ export function HomeView({
skillId: null,
designSystemId: null,
pendingPrompt: reshapePrompt,
workspaceContext: resolvedWorkspaceContextForWrite(workspaceContextState),
});
setFigmaModalOpen(false);
onOpenProject(project.id);
+16 -7
View File
@@ -225,7 +225,7 @@ import { useProjectCollab } from '../collab/useProjectCollab';
import { useWorkspaceContext } from '../collab/useWorkspaceContext';
import {
projectWorkspaceContext,
projectWorkspaceScopeReady,
projectWorkspaceScopeAuthorizesAmr,
useProjectWorkspaceScope,
} from '../collab/useProjectWorkspaceScope';
import { CollabProvider, type CollabContextValue } from '../collab/collab-context';
@@ -1447,9 +1447,11 @@ export function ProjectView({
const projectRunWorkspaceContext = projectWorkspaceContext(
projectWorkspaceScopeState.scope,
);
const projectRunWorkspaceScopeReady = projectWorkspaceScopeReady(
projectWorkspaceScopeState.scope,
);
const projectRunRequiresWorkspaceScope =
config.mode === 'daemon' && config.agentId === 'amr';
const projectRunWorkspaceScopeReady =
!projectRunRequiresWorkspaceScope ||
projectWorkspaceScopeAuthorizesAmr(projectWorkspaceScopeState.scope);
// Onboarding first-generation funnel (spec §11.1). Consume the pending entry
// (set by the Home recommendation) exactly once on mount; the refs guard the
// two lifecycle events so each fires only for the genuine first send / first
@@ -5143,9 +5145,10 @@ export function ProjectView({
attachments.length === 0 &&
commentAttachments.length === 0
) return false;
// A bound project must resolve its own persisted workspace membership
// before any run can start. Never borrow the ambient navigation
// workspace while the exact project scope is loading or unavailable.
// AMR must resolve this project's persisted billing principal before a
// run can start. Local CLI and BYOK runtimes do not consume the Vela
// wallet, so old daemons without this endpoint and directory outages
// must not disable those runtimes.
if (!projectRunWorkspaceScopeReady) return false;
const effectiveAttachments = mergeChatAttachments(
attachments,
@@ -5216,6 +5219,8 @@ export function ProjectView({
? {
workspaceType: projectRunWorkspaceContext.workspaceType,
workspaceId: projectRunWorkspaceContext.workspaceId,
workspaceMemberId:
projectRunWorkspaceContext.workspaceMemberId,
}
: undefined,
);
@@ -5261,6 +5266,10 @@ export function ProjectView({
parkBlockedSend();
return false;
}
if (gate.kind === 'unavailable') {
parkBlockedSend();
return false;
}
if (gate.kind === 'soft') {
// Low balance: pause THIS send while the reminder dialog waits
// for a decision. 'proceed' resumes the very same send below —
+16 -10
View File
@@ -7,11 +7,10 @@
// the low-balance warning line. The user is warned once per send and
// may proceed anyway, top up first, or opt out of future warnings.
//
// Fail-open contract: only DEFINITIVE answers gate. An unavailable endpoint or
// unparseable balance never blocks — those states already have their own
// recovery paths (run-time failure cards), and a flaky wallet must never lock
// users out of starting tasks. The signed-out answer comes from the local
// config read, so it is reliable enough to hard-gate on.
// Account-scoped reads fail open when unavailable. An explicitly team-scoped
// project fails closed when its exact workspace/member epoch cannot be proven:
// falling back to the account wallet would make the preflight disagree with
// the final daemon spawn authority.
import type {
AmrWalletSnapshot,
@@ -38,6 +37,7 @@ const LOW_BALANCE_WARN_OPTOUT_KEY = 'open-design:amr-low-balance-warn-optout:v1'
export type AmrBalanceGateResult =
| { kind: 'allow' }
| { kind: 'unavailable' }
| { kind: 'hard'; reason: 'insufficient'; snapshot: AmrWalletSnapshot }
| { kind: 'hard'; reason: 'signed_out'; snapshot: AmrWalletSnapshot }
| { kind: 'soft'; snapshot: AmrWalletSnapshot };
@@ -45,6 +45,7 @@ export type AmrBalanceGateResult =
export interface AmrBalanceGateScope {
workspaceType: 'personal' | 'team';
workspaceId: string;
workspaceMemberId: string;
}
/** Parse a definitive balance from a snapshot; null when the answer is
@@ -104,7 +105,8 @@ async function fetchTeamWorkspaceWalletSnapshot(
accountSnapshot: AmrWalletSnapshot | null,
): Promise<AmrWalletSnapshot | null> {
const workspaceId = scope.workspaceId.trim();
if (!workspaceId) return null;
const workspaceMemberId = scope.workspaceMemberId.trim();
if (!workspaceId || !workspaceMemberId) return null;
const response = await fetch(
`/api/workspace/billing?scope=workspace&workspaceId=${encodeURIComponent(workspaceId)}`,
{ cache: 'no-store' },
@@ -115,7 +117,8 @@ async function fetchTeamWorkspaceWalletSnapshot(
if (
!workspaceBalance ||
workspaceBalance.billingScopeVersion !== 2 ||
workspaceBalance.workspaceId !== workspaceId
workspaceBalance.workspaceId !== workspaceId ||
workspaceBalance.workspaceMemberId !== workspaceMemberId
) {
return null;
}
@@ -155,7 +158,7 @@ async function checkTeamWorkspaceBalanceGate(
accountSnapshot,
).catch(() => null);
const balance = amrWalletBalanceUsd(workspaceSnapshot);
if (balance == null) return { kind: 'allow' };
if (balance == null) return { kind: 'unavailable' };
if (balance <= AMR_HARD_BLOCK_BALANCE_USD) {
return {
kind: 'hard',
@@ -214,7 +217,10 @@ export async function checkAmrBalanceGate(
}
return { kind: 'allow' };
} catch {
// Fail open: an unexpected wallet-path error must never block task starts.
return { kind: 'allow' };
// Account checks retain the legacy fail-open behavior. Team projects must
// prove the exact member-scoped wallet before proceeding.
return scope?.workspaceType === 'team'
? { kind: 'unavailable' }
: { kind: 'allow' };
}
}
+19
View File
@@ -44,6 +44,25 @@ export type { PluginShareAction } from '@open-design/contracts';
export type WorkspaceProjectListView = 'all' | 'recent' | 'drafts' | 'team';
export type WorkspaceContextForWrite = {
context: WorkspaceCollabContext | null;
loading: boolean;
failure?: 'unsupported' | 'unavailable';
};
/**
* Preserve headerless old-daemon/anonymous compatibility, but never collapse
* an unresolved or unavailable modern workspace authority into "anonymous".
*/
export function resolvedWorkspaceContextForWrite(
state: WorkspaceContextForWrite,
): WorkspaceCollabContext | null {
if (state.loading || state.failure === 'unavailable') {
throw new Error('Workspace context is unavailable. Try again when workspace sync finishes.');
}
return state.context;
}
export function workspaceProjectHeaders(context: WorkspaceCollabContext): HeadersInit {
return {
'x-od-workspace-id': context.workspaceId,
+108 -8
View File
@@ -88,6 +88,33 @@ function workspaceContextResponse(context: WorkspaceCollabContext | null) {
});
}
function workspaceSnapshot(
workspaceId: string,
workspaceMemberId: string,
planId: string,
balanceUsd: string,
) {
return {
schemaVersion: 1,
workspaceId,
workspaceMemberId,
billingScopeVersion: 2,
billing: {
billingState: 'active',
planId,
},
wallet: {
balanceUsd,
expiresAt: null,
updatedAt: '2026-07-27T00:00:00.000Z',
},
revisions: {
billing: 'billing-1',
wallet: 'wallet-1',
},
};
}
const codexAgent: AgentInfo = {
id: 'codex',
name: 'Codex CLI',
@@ -426,7 +453,7 @@ describe('AvatarMenu', () => {
});
it('fails closed for a locked model when the project scope is unavailable', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(JSON.stringify({
@@ -447,7 +474,8 @@ describe('AvatarMenu', () => {
}));
}
return new Response('{}', { status: 202 });
}));
});
vi.stubGlobal('fetch', fetchMock);
openExternalUrlMock.mockResolvedValue(true);
const { onAgentModelChange } = renderMenu({
@@ -481,7 +509,16 @@ describe('AvatarMenu', () => {
});
openMenu();
await screen.findByText('Plus');
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
'/api/integrations/vela/status',
expect.anything(),
),
);
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByText('Plus')).toBeNull();
const list = screen.getByTestId('avatar-model-list');
const locked = within(list).getByRole('radio', { name: /Paid model/i });
expect(locked.getAttribute('aria-disabled')).toBe('true');
@@ -503,7 +540,7 @@ describe('AvatarMenu', () => {
});
it('does not borrow account money for an unbound project', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
if (input.toString() === '/api/integrations/vela/status') {
return new Response(JSON.stringify({
loggedIn: true,
@@ -518,7 +555,8 @@ describe('AvatarMenu', () => {
});
}
return new Response('{}', { status: 202 });
}));
});
vi.stubGlobal('fetch', fetchMock);
renderMenu({
config: { ...baseConfig, agentId: 'amr' },
@@ -541,7 +579,16 @@ describe('AvatarMenu', () => {
});
openMenu();
await screen.findByText('Plus');
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
'/api/integrations/vela/status',
expect.anything(),
),
);
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByText('Plus')).toBeNull();
expect(screen.queryByText('$247.51')).toBeNull();
expect(screen.queryByRole('button', {
name: /settings\.amrBalance/,
@@ -553,19 +600,42 @@ describe('AvatarMenu', () => {
it('routes a locked model only when the exact project member can upgrade', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
if (input.toString() === '/api/integrations/vela/status') {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(JSON.stringify({
loggedIn: true,
loginInFlight: false,
profile: 'feature-test',
user: { id: 'u1', email: 'a@b.c' },
account: { plan: 'plus', balanceUsd: '9.12' },
account: { plan: 'max', balanceUsd: '9.12' },
configPath: '/Users/test/.amr/config.json',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/workspace/billing?scope=workspace&workspaceId=workspace-a') {
return new Response(JSON.stringify({
summary: null,
workspaceBalance: {
billingScopeVersion: 2,
workspaceId: 'workspace-a',
workspaceMemberId: 'member-a',
balanceUsd: '9.12',
expiresAt: null,
updatedAt: '2026-07-27T00:00:00.000Z',
},
workspaceSnapshot: workspaceSnapshot(
'workspace-a',
'member-a',
'team_pro',
'9.12',
),
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('{}', { status: 202 });
}));
openExternalUrlMock.mockResolvedValue(true);
@@ -720,6 +790,12 @@ describe('AvatarMenu', () => {
expiresAt: null,
updatedAt: '2026-07-26T00:00:00.000Z',
},
workspaceSnapshot: workspaceSnapshot(
'ws-team',
'wm-1',
'team_pro',
'7.8912',
),
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
@@ -747,6 +823,8 @@ describe('AvatarMenu', () => {
const dialog = openMenu();
expect(await within(dialog).findByText('$7.89')).toBeTruthy();
expect(within(dialog).queryByText('$247.51')).toBeNull();
expect(within(dialog).getByText('Pro')).toBeTruthy();
expect(within(dialog).queryByText('Plus')).toBeNull();
});
it('shows and opens only the daemon-authoritative project wallet while ambient navigation is elsewhere', async () => {
@@ -784,6 +862,12 @@ describe('AvatarMenu', () => {
expiresAt: null,
updatedAt: '2026-07-27T00:00:00.000Z',
},
workspaceSnapshot: workspaceSnapshot(
'workspace-a',
'member-a',
'team_max',
balance,
),
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -800,6 +884,12 @@ describe('AvatarMenu', () => {
expiresAt: null,
updatedAt: '2026-07-27T00:00:00.000Z',
},
workspaceSnapshot: workspaceSnapshot(
'workspace-b',
'member-b',
'team_plus',
'7.89',
),
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -831,6 +921,11 @@ describe('AvatarMenu', () => {
workspaceId: 'workspace-a',
workspaceMemberId: 'member-a',
teamId: 'workspace-a',
role: 'owner',
permissions: {
...teamMemberWorkspaceContext().permissions,
canManageBilling: true,
},
}) as WorkspaceCollabContext & { workspaceType: 'team' },
},
},
@@ -842,6 +937,11 @@ describe('AvatarMenu', () => {
});
expect(within(dialog).queryByText('$7.89')).toBeNull();
expect(within(dialog).queryByText('$247.51')).toBeNull();
expect(within(dialog).getByText('Max')).toBeTruthy();
expect(within(dialog).queryByText('Plus')).toBeNull();
expect(within(dialog).queryByRole('link', {
name: 'settings.amrUpgrade',
})).toBeNull();
fireEvent.click(wallet);
await waitFor(() => expect(openExternalUrlMock).toHaveBeenCalledTimes(1));
@@ -50,10 +50,15 @@ const workspaceScopeMocks = vi.hoisted(() => ({
projectScope: {
loading: false,
scope: {
kind: 'unbound' as const,
kind: 'personal' as const,
projectId: 'project-1',
workspaceId: null,
context: null,
workspaceId: 'workspace-personal',
visibility: 'personal' as const,
context: {
workspaceId: 'workspace-personal',
workspaceMemberId: 'member-personal',
workspaceType: 'personal',
} as WorkspaceCollabContext & { workspaceType: 'personal' },
},
} as ProjectWorkspaceScopeState,
}));
@@ -93,6 +98,9 @@ vi.mock('../../src/collab/useProjectWorkspaceScope', () => ({
: null,
projectWorkspaceScopeReady: (scope: ProjectWorkspaceScopeState['scope']) =>
scope?.kind === 'unbound' || scope?.kind === 'personal' || scope?.kind === 'team',
projectWorkspaceScopeAuthorizesAmr: (
scope: ProjectWorkspaceScopeState['scope'],
) => scope?.kind === 'personal' || scope?.kind === 'team',
}));
vi.mock('../../src/providers/daemon', () => ({
@@ -646,10 +654,15 @@ describe('ProjectView conversation run isolation', () => {
workspaceScopeMocks.projectScope = {
loading: false,
scope: {
kind: 'unbound',
kind: 'personal',
projectId: project.id,
workspaceId: null,
context: null,
workspaceId: 'workspace-personal',
visibility: 'personal',
context: {
workspaceId: 'workspace-personal',
workspaceMemberId: 'member-personal',
workspaceType: 'personal',
} as WorkspaceCollabContext & { workspaceType: 'personal' },
},
};
resolveConversationBMessages = null;
@@ -741,6 +754,82 @@ describe('ProjectView conversation run isolation', () => {
);
});
it.each([
['an old daemon', 'unsupported' as const],
['a revoked scope response', 'forbidden' as const],
['a workspace-directory outage', 'unavailable' as const],
])('keeps non-AMR runs available with %s', async (_label, failure) => {
conversationAMessages = [];
workspaceScopeMocks.projectScope = {
loading: false,
scope: null,
failure,
};
renderProjectView();
await waitFor(() =>
expect(screen.getByTestId('active-conversation').textContent).toBe(
'conv-a',
),
);
await waitFor(() =>
expect(screen.getByTestId('send-message')).toHaveProperty(
'disabled',
false,
),
);
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
});
it.each([
[
'an unbound project',
{
loading: false,
scope: {
kind: 'unbound' as const,
projectId: project.id,
workspaceId: null,
context: null,
},
},
],
[
'an old daemon',
{ loading: false, scope: null, failure: 'unsupported' as const },
],
[
'a workspace-directory outage',
{ loading: false, scope: null, failure: 'unavailable' as const },
],
])('fails closed for AMR with %s', async (_label, projectScope) => {
conversationAMessages = [];
workspaceScopeMocks.projectScope = projectScope;
renderProjectView(
{ ...config, agentId: 'amr' },
project,
[{
id: 'amr',
name: 'AMR',
bin: 'amr',
available: true,
models: [{ id: 'glm-5', label: 'GLM 5' }],
}],
);
await waitFor(() =>
expect(screen.getByTestId('active-conversation').textContent).toBe(
'conv-a',
),
);
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', true);
fireEvent.click(screen.getByTestId('send-message'));
expect(streamViaDaemon).not.toHaveBeenCalled();
});
it('uses the project-bound workspace instead of the ambient workspace for run authorization', async () => {
conversationAMessages = [];
const workspaceA = teamWorkspaceContext('workspace-a', 'member-a');
@@ -220,6 +220,7 @@ describe('checkAmrBalanceGate', () => {
const result = await checkAmrBalanceGate({
workspaceType: 'team',
workspaceId: 'ws-team-a',
workspaceMemberId: 'wm-a',
});
expect(result.kind).toBe('soft');
if (result.kind === 'soft') {
@@ -227,7 +228,7 @@ describe('checkAmrBalanceGate', () => {
}
});
it('fails open for an unavailable team workspace balance without using account zero', async () => {
it('fails closed for an unavailable team workspace balance without using account zero', async () => {
const emptyAccount = snapshot({ balanceUsd: '0' });
mockedFetch.mockResolvedValueOnce(emptyAccount).mockResolvedValueOnce(emptyAccount);
vi.stubGlobal(
@@ -239,8 +240,36 @@ describe('checkAmrBalanceGate', () => {
checkAmrBalanceGate({
workspaceType: 'team',
workspaceId: 'ws-team-a',
workspaceMemberId: 'wm-a',
}),
).resolves.toEqual({ kind: 'allow' });
).resolves.toEqual({ kind: 'unavailable' });
});
it('rejects a response from an older workspace-member epoch', async () => {
mockedFetch.mockResolvedValue(snapshot({ balanceUsd: '247.50' }));
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(
JSON.stringify({
summary: null,
workspaceBalance: {
billingScopeVersion: 2,
workspaceId: 'ws-team-a',
workspaceMemberId: 'wm-old',
balanceUsd: '50',
expiresAt: null,
updatedAt: '2026-07-26T00:00:00.000Z',
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
)),
);
await expect(checkAmrBalanceGate({
workspaceType: 'team',
workspaceId: 'ws-team-a',
workspaceMemberId: 'wm-new',
})).resolves.toEqual({ kind: 'unavailable' });
});
it('keeps concurrent team A/B checks keyed by explicit workspace id', async () => {
@@ -278,10 +307,12 @@ describe('checkAmrBalanceGate', () => {
const teamA = checkAmrBalanceGate({
workspaceType: 'team',
workspaceId: 'ws-team-a',
workspaceMemberId: 'wm-a',
});
const teamB = checkAmrBalanceGate({
workspaceType: 'team',
workspaceId: 'ws-team-b',
workspaceMemberId: 'wm-b',
});
await expect(teamB).resolves.toEqual({ kind: 'allow' });
+56
View File
@@ -16,6 +16,7 @@ import {
patchProject,
pickLocalFolderPath,
publishGeneratedPluginToGitHub,
resolvedWorkspaceContextForWrite,
} from '../../src/state/projects';
import {
buildWorkspacePermissions,
@@ -259,6 +260,61 @@ describe('createProject', () => {
}),
);
});
it('attaches the resolved workspace and member identity to project creation', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
project: { id: 'scoped-project' },
conversationId: 'scoped-conversation',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
await createProject({
name: 'Scoped project',
skillId: null,
designSystemId: null,
workspaceContext: teamWorkspaceContext(),
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'x-od-workspace-id': 'ws-team',
'x-od-workspace-member-id': 'wm-1',
'x-od-workspace-type': 'team',
}),
}),
);
});
it('fails closed while modern workspace authority is unresolved or unavailable', () => {
expect(() => resolvedWorkspaceContextForWrite({
context: null,
loading: true,
})).toThrow('Workspace context is unavailable');
expect(() => resolvedWorkspaceContextForWrite({
context: null,
loading: false,
failure: 'unavailable',
})).toThrow('Workspace context is unavailable');
});
it('preserves explicit anonymous and old-daemon headerless compatibility', () => {
expect(resolvedWorkspaceContextForWrite({
context: null,
loading: false,
})).toBeNull();
expect(resolvedWorkspaceContextForWrite({
context: null,
loading: false,
failure: 'unsupported',
})).toBeNull();
});
});
// recvq5ecTkar91: a team project that leaked into a personal workspace's 草稿
@@ -2,8 +2,12 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProjectWorkspaceScopeResponse } from '@open-design/contracts';
import { useProjectWorkspaceScope } from '../src/collab/useProjectWorkspaceScope';
import {
projectWorkspaceScopeAuthorizesAmr,
useProjectWorkspaceScope,
} from '../src/collab/useProjectWorkspaceScope';
import { WORKSPACE_CONTEXT_REFRESH_EVENT } from '../src/collab/useWorkspaceContext';
function deferred<T>() {
@@ -14,7 +18,11 @@ function deferred<T>() {
return { promise, resolve };
}
function teamScope(projectId: string, workspaceId: string, memberId: string) {
function teamScope(
projectId: string,
workspaceId: string,
memberId: string,
): ProjectWorkspaceScopeResponse {
return {
scope: {
kind: 'team',
@@ -60,6 +68,74 @@ describe('useProjectWorkspaceScope', () => {
vi.unstubAllGlobals();
});
it('distinguishes old-daemon, revoked and directory-outage failures', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('project-old-daemon')) return new Response('{}', { status: 404 });
if (url.includes('project-revoked')) return new Response('{}', { status: 403 });
if (url.includes('project-outage')) return new Response('{}', { status: 503 });
throw new Error(`Unexpected fetch: ${url}`);
}));
const oldDaemon = renderHook(() =>
useProjectWorkspaceScope('project-old-daemon'),
);
await waitFor(() => {
expect(oldDaemon.result.current).toEqual({
loading: false,
scope: null,
failure: 'unsupported',
});
});
oldDaemon.unmount();
const revoked = renderHook(() =>
useProjectWorkspaceScope('project-revoked'),
);
await waitFor(() => {
expect(revoked.result.current).toEqual({
loading: false,
scope: null,
failure: 'forbidden',
});
});
revoked.unmount();
const outage = renderHook(() =>
useProjectWorkspaceScope('project-outage'),
);
await waitFor(() => {
expect(outage.result.current).toEqual({
loading: false,
scope: null,
failure: 'unavailable',
});
});
outage.unmount();
});
it('authorizes AMR only for explicit personal or team scopes', () => {
expect(projectWorkspaceScopeAuthorizesAmr(null)).toBe(false);
expect(projectWorkspaceScopeAuthorizesAmr({
kind: 'unbound',
projectId: 'project-a',
workspaceId: null,
context: null,
})).toBe(false);
expect(projectWorkspaceScopeAuthorizesAmr({
kind: 'unavailable',
projectId: 'project-a',
workspaceId: 'workspace-a',
visibility: 'personal',
context: null,
})).toBe(false);
expect(
projectWorkspaceScopeAuthorizesAmr(
teamScope('project-a', 'workspace-a', 'member-a').scope,
),
).toBe(true);
});
it('drops a late response from the previously open project', async () => {
const projectA = deferred<Response>();
const projectB = deferred<Response>();
@@ -230,6 +230,108 @@ describe('useWorkspaceBilling explicit scope', () => {
expect(billingCalls.length - beforeAliases).toBe(1);
});
it('keeps ambient B and explicit project A as independent daemon interests', async () => {
const projectA = teamContext('workspace-a');
let ambientBalance = '1.25';
const acceptedByClient = new Map<
string,
{ generation: bigint; workspaceId: string }
>();
const observedClientIds = new Map<string, string>();
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === '/api/workspace/context') {
return new Response(
JSON.stringify({ context: teamContext('workspace-b') }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.startsWith('/api/workspace/billing?')) {
const workspaceId = new URL(
url,
'http://open-design.test',
).searchParams.get('workspaceId')!;
const headers = new Headers(init?.headers);
const clientId =
headers.get('x-od-workspace-runtime-client-id') ?? '';
const generation = BigInt(
headers.get('x-od-workspace-runtime-generation') ?? '0',
);
const current = acceptedByClient.get(clientId);
if (
current &&
(
generation < current.generation ||
(
generation === current.generation &&
current.workspaceId !== workspaceId
)
)
) {
return new Response(
JSON.stringify({ error: 'stale_generation' }),
{ status: 409, headers: { 'content-type': 'application/json' } },
);
}
acceptedByClient.set(clientId, { generation, workspaceId });
observedClientIds.set(workspaceId, clientId);
return new Response(
JSON.stringify(billingResponse(
workspaceId,
workspaceId === 'workspace-b' ? ambientBalance : '8.50',
)),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`unexpected fetch ${url}`);
}),
);
const hook = renderHook(() => ({
ambient: useWorkspaceBillingResponse(),
project: useWorkspaceBillingResponse({
context: projectA,
loading: false,
revision: 'project-a',
}),
}));
await waitFor(() => {
expect(hook.result.current.ambient?.workspaceBalance?.balanceUsd).toBe(
'1.25',
);
expect(hook.result.current.project?.workspaceBalance?.balanceUsd).toBe(
'8.50',
);
});
expect(observedClientIds.get('workspace-a')).toBeTruthy();
expect(observedClientIds.get('workspace-b')).toBeTruthy();
expect(observedClientIds.get('workspace-a')).not.toBe(
observedClientIds.get('workspace-b'),
);
ambientBalance = '3.75';
act(() => {
for (const source of MockWorkspaceEventSource.instances) {
source.dispatch('wallet-balance-changed', {
type: 'wallet-balance-changed',
workspaceId: 'workspace-b',
workspaceMemberId: 'member-workspace-b',
revision: 'wallet-b-2',
});
}
});
await waitFor(() => {
expect(hook.result.current.ambient?.workspaceBalance?.balanceUsd).toBe(
'3.75',
);
});
expect(hook.result.current.project?.workspaceBalance?.balanceUsd).toBe(
'8.50',
);
});
it('rejects a late response from the first A after switching A to B to A', async () => {
let currentContext = teamContext('workspace-a');
let billingACalls = 0;
@@ -485,10 +587,8 @@ describe('useWorkspaceBilling explicit scope', () => {
]);
expect(runtimeHeaders).toHaveLength(2);
expect(runtimeHeaders[0]!.clientId).not.toBe('');
expect(runtimeHeaders[1]!.clientId).toBe(runtimeHeaders[0]!.clientId);
expect(BigInt(runtimeHeaders[1]!.generation)).toBeGreaterThan(
BigInt(runtimeHeaders[0]!.generation),
);
expect(runtimeHeaders[1]!.clientId).not.toBe(runtimeHeaders[0]!.clientId);
expect(runtimeHeaders.map((header) => header.generation)).toEqual(['1', '1']);
});
it('gives each renderer page lifecycle an independent runtime client id', async () => {
@@ -123,4 +123,22 @@ describe('useWorkspaceContext sign-in refresh', () => {
await settleAll(SIGNED_IN);
});
it.each([
[404, 'unsupported'],
[503, 'unavailable'],
] as const)(
'distinguishes an old daemon (%s) from an unavailable workspace service',
async (status, failure) => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(async () => new Response(null, { status })),
);
const { result } = renderHook(() => useWorkspaceContext());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.context).toBeNull();
expect(result.current.failure).toBe(failure);
},
);
});