mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-28 15:03:48 +08:00
fix: fail closed on unresolved workspace authority
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user