fix(collab): 别把 daemon 已确认的个人项目当成共享只读 (OPEND-2624)

`sharedReadOnly` 里的 `unknownStatusReadOnly` 是一条 fail-closed 且**没有出口**
的判据。`statusUnknown` 的条件是 `syncState === null`,而 `CollabClient.get()`
对任何非 2xx 直接 throw、`pollStatus` 吞掉异常 —— 所以 `/collab/status` 只要对
这一个项目持续失败,`syncState` 就永远停在 null。

唯一的解药 `knownUnshared` 读 `cachedTeamProjects(context)`,而那个缓存的 key
含 `role`。实测两个端点的 role 不一致:`/api/workspace/context` 给 `owner`
(目录里的真实角色),而 `/api/projects/:id/workspace-scope` 是
`project-workspace-scope.ts:65` **硬编码的 `member`**。两个 key 不同 ⇒
**只要你是 workspace 的 owner/admin,这个缓存必然 miss**,历史上加过的两条
「别 fail-closed」补丁对 owner 全部失效,一次 status 失败就是永久只读:
聊天、新建、上传、编辑、导出全灭。

「为什么同工作区其他个人项目正常」也是这条解释的:缓存 key 不匹配是工作区级的,
但只有在 status 失败时才致命 —— 其他项目 status 正常返回 local_only 就没事。

改法是给判据一个出口:daemon 已经确认这个项目是 `personal` 时,让它压过一次
拿不到的 `/collab/status`。这不是新规则 —— `ProjectWorkspaceScope.visibility`
的契约注释原文就是「visibility answers whether the project itself is a private
draft or shared with the team」,而 `materializePulledTeamMirror` 对每一个拉下来
的 team mirror 都写 `visibility: 'team'`,所以本机的 `personal` 严格意味着
「这台 daemon 没有任何记录说它是谁的共享项目」。daemon 的写闸读的正是这一行,
所以这是让 UI 的只读闸和执行闸用同一个权威。工作区级冻结和 materializationPending
两条保守路径原样未动。

红测的夹具把壳层 context(owner)和项目 context(member)**拆开**喂 —— 既有的
`use-project-collab.context-seed.test.tsx` 用同一个 `role: 'member'` 对象喂进去,
所以它天然看不见这个缺陷。403 响应体是从跑着的 daemon 上 curl 实测抄来的。
This commit is contained in:
lefarcen
2026-09-04 18:33:03 +08:00
parent 1bc480a340
commit a17a22e32a
3 changed files with 308 additions and 3 deletions
+55 -3
View File
@@ -3,6 +3,7 @@ import type {
CollabMemberRole,
CollabPresenceMember,
ProjectContentTransferState,
ProjectVisibility,
WorkspaceCollabContext,
} from '@open-design/contracts';
import { resolveCollabSession } from './collab-session';
@@ -104,6 +105,14 @@ export interface UseProjectCollabOptions {
* first paint until this hook completes its own status check.
*/
initialMaterializationPending?: boolean;
/**
* `ProjectWorkspaceScope.visibility` for this exact project, as answered by
* `GET /api/projects/:id/workspace-scope`. See
* {@link projectIsDaemonConfirmedPersonal} for why the single-writer gate
* consumes it. Null/omitted while the scope read is pending, or for a legacy
* unbound project that has no workspace row at all.
*/
projectVisibility?: ProjectVisibility | null;
/** Injectable for tests. */
fetch?: typeof fetch;
baseUrl?: string;
@@ -238,6 +247,38 @@ export interface ProjectCollab {
applyContentTransferState?: (state: ProjectContentTransferState) => void;
}
/**
* Whether the daemon has already answered that this project is a private
* draft — nobody's team share, therefore nobody else's single-writer project.
*
* `ProjectWorkspaceScope.visibility` is the `workspace_projects` row itself:
* the row a share/unshare mutation writes, and the row the daemon's own write
* gate reads before it allows a file write, rename, or run. It is also the
* only surface that can answer `personal` — `materializePulledTeamMirror`
* stamps `visibility: 'team'` on every pulled mirror AND on the placeholder it
* creates before the first pull, so a project someone else shared is never
* recorded personal on this daemon.
*
* That makes it the right authority for the single-writer gate, and it must
* outrank `/collab/status`. Status has to consult the remote hub catalog for
* ownership, so it can fail (403/404/5xx, or simply never answer), and its
* catalog read is served from a stale-while-revalidate + persisted snapshot
* cache. The gate below used to fall back to the browser's team-project
* catalog cache in that window, which for a workspace owner/admin never
* matches: that cache is keyed by the full workspace identity, and the
* project-scope context carries the daemon's hardcoded `role: 'member'` while
* the shell context that filled the cache carries the member's REAL role. With
* no catalog and no status the gate failed closed forever, and the creator of
* a personal, local-only project was shown 「这是共享项目」 with Chat, upload,
* editing and export all disabled while the daemon would have accepted every
* one of those writes (OPEND-2624).
*/
export function projectIsDaemonConfirmedPersonal(
visibility: ProjectVisibility | null | undefined,
): boolean {
return visibility === 'personal';
}
export function resolveProjectWriterAuthority(options: {
workspaceReadOnly: boolean;
workspaceContextReadOnly: boolean;
@@ -246,12 +287,16 @@ export function resolveProjectWriterAuthority(options: {
isOwner: boolean;
knownOwnedByViewer: boolean;
createdByViewerThisSession: boolean;
daemonConfirmedPersonal?: boolean;
materializationPending?: boolean;
syncState: ProjectCollab['syncState'];
}): ProjectCollab['writerAuthority'] {
if (options.workspaceReadOnly || options.lostAccessAfterUnshare) return 'denied';
if (options.materializationPending) return 'pending';
if (options.workspaceContextReadOnly) return 'pending';
// The project's own workspace row already settled this: a private draft has
// exactly one writer and it is whoever is looking at it.
if (options.daemonConfirmedPersonal) return 'allowed';
// A settled daemon status outranks every provisional browser-side witness.
// Catalog ownership and same-session creation exist only to bridge the
// UNKNOWN window; once status names another writer they must not keep write
@@ -305,6 +350,9 @@ export function useProjectCollab(
...(options.statusPollMs !== undefined ? { statusPollMs: options.statusPollMs } : {}),
});
const workspaceIdentity = workspaceIdentityCacheKey(context);
const daemonConfirmedPersonal = projectIsDaemonConfirmedPersonal(
options.projectVisibility,
);
// Gate 1 (workspace-level): a non-writable workspace (locked/frozen billing or
// a removed member) freezes everyone — consume B's `canWriteSyncedFiles` bit
// rather than re-deriving from lifecycle so the two lanes cannot drift.
@@ -421,7 +469,8 @@ export function useProjectCollab(
confirmedOwnedBySomeoneElseRef.current = relationshipScopeKey;
}
const lostAccessAfterUnshare =
confirmedOwnedBySomeoneElseRef.current === relationshipScopeKey
!daemonConfirmedPersonal
&& confirmedOwnedBySomeoneElseRef.current === relationshipScopeKey
&& collab.syncState === 'local_only'
&& !isOwner;
// The project-level (single-writer) gate. Catalog ownership and the
@@ -434,7 +483,8 @@ export function useProjectCollab(
&& !knownOwnedByViewer
&& !createdByViewerThisSession;
const sharedReadOnly =
unknownStatusReadOnly || (shared && !isOwner) || lostAccessAfterUnshare;
!daemonConfirmedPersonal
&& (unknownStatusReadOnly || (shared && !isOwner) || lostAccessAfterUnshare);
const viewerOnly = workspaceContextReadOnly || workspaceReadOnly || sharedReadOnly;
const materializationPending =
collab.awaitingFirstMaterialization
@@ -450,6 +500,7 @@ export function useProjectCollab(
isOwner,
knownOwnedByViewer,
createdByViewerThisSession,
daemonConfirmedPersonal,
materializationPending,
syncState: collab.syncState,
});
@@ -457,7 +508,8 @@ export function useProjectCollab(
// not latch on `shared && !isOwner` while ownerMemberId is still missing from
// an otherwise-shared status payload for the real owner.
const isSharedNonOwner =
!isEffectiveOwner
!daemonConfirmedPersonal
&& !isEffectiveOwner
&& (knownOwnedBySomeoneElse
|| (shared && statusNamedDifferentOwner)
|| lostAccessAfterUnshare);
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ProjectVisibility,
ProjectWorkspaceScope,
ProjectWorkspaceScopeResponse,
WorkspaceCollabContext,
@@ -90,6 +91,20 @@ export function projectWorkspaceScopeReady(
return scope?.kind === 'unbound' || scope?.kind === 'personal' || scope?.kind === 'team';
}
/**
* The daemon's own visibility verdict for a resolved project scope.
*
* Deliberately independent of `kind`: a private draft can live in a team
* workspace and still be `personal`. Null when the scope has not resolved, or
* for a legacy unbound project that has no `workspace_projects` row to be
* visible in — neither case is evidence of anything.
*/
export function projectWorkspaceVisibility(
scope: ProjectWorkspaceScope | null | undefined,
): ProjectVisibility | null {
return scope && scope.kind !== 'unbound' ? scope.visibility : null;
}
function activePersonalAdoptionWitness(
caller: WorkspaceCollabContext | null | undefined,
): WorkspaceCollabContext | null {
@@ -0,0 +1,238 @@
// @vitest-environment jsdom
//
// OPEND-2624: a personal, local-only project the viewer created must never be
// presented as "This is a shared project — you can view and comment" with
// chat/upload/edit/export disabled.
//
// Every fixture below is copied from what the daemon at
// http://127.0.0.1:17466 actually answers (0.21.1-beta line). In particular:
//
// * `/api/workspace/context` reports the member's REAL directory role
// (`owner` for a workspace owner) and `planId: null` — the workspace
// directory rows Vela hands the daemon carry no plan field, so production
// never fills it.
// * `/api/projects/:id/workspace-scope` synthesises its context through
// `resolveLocalProjectWorkspaceScope`, which hardcodes `role: 'member'`.
// So in production the SAME workspace is described with two different roles
// depending on which endpoint answered. Tests that build one context object
// and hand it to both surfaces cannot see that, which is why this fixture
// keeps the two apart.
// * `/api/workspace/projects/team` answers `{"projects": []}` for a workspace
// with nothing shared.
import { cleanup, renderHook, waitFor } from '@testing-library/react';
import {
buildWorkspacePermissions,
buildWorkspaceSeatSummary,
type WorkspaceCollabContext,
} from '@open-design/contracts';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useProjectCollab } from '../../src/collab/useProjectCollab';
import {
resetTeamProjectsCache,
resetWorkspaceContextCache,
useTeamProjects,
useWorkspaceContext,
} from '../../src/collab/useWorkspaceContext';
const WORKSPACE_ID = 'l5hy8nbnym3pi07aasqekiz0';
const WORKSPACE_MEMBER_ID = 'dn87ohicuyq4o839pgi37op4';
const PROJECT_ID = '3c73bd04-ed0c-4aca-9a7a-85600977d5d8';
/**
* `GET /api/workspace/context` — the shell/navigation authority. `role` is the
* member's real directory role; a workspace owner reads back `owner`.
*/
function shellWorkspaceContext(): WorkspaceCollabContext {
const role = 'owner' as const;
const lifecycleState = 'active' as const;
return {
workspaceId: WORKSPACE_ID,
workspaceType: 'team',
workspaceMemberId: WORKSPACE_MEMBER_ID,
role,
memberStatus: 'active',
lifecycleState,
billingState: 'active',
// Directory rows carry no plan; production always answers null here.
planId: null,
providerMode: 'platform_credits',
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 0, usedSeats: 0 }),
permissions: buildWorkspacePermissions({ role, lifecycleState }),
teamId: WORKSPACE_ID,
};
}
/**
* `GET /api/projects/:id/workspace-scope` — the project-bound authority
* ProjectView actually hands to `useProjectCollab`. Its role is hardcoded to
* `member` by `resolveLocalProjectWorkspaceScope`, independent of the member's
* real workspace role.
*/
function projectScopeContext(): WorkspaceCollabContext {
const role = 'member' as const;
const lifecycleState = 'active' as const;
return {
workspaceId: WORKSPACE_ID,
workspaceType: 'team',
workspaceMemberId: WORKSPACE_MEMBER_ID,
role,
memberStatus: 'active',
lifecycleState,
billingState: 'active',
planId: null,
providerMode: 'platform_credits',
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 0, usedSeats: 0 }),
permissions: buildWorkspacePermissions({ role, lifecycleState }),
teamId: WORKSPACE_ID,
};
}
/**
* Stable identity, like production: ProjectView memoises the project's
* resolved scope context. A fresh object every render would restart the
* hook's context effect on every commit.
*/
const PROJECT_SCOPE_CONTEXT = projectScopeContext();
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response;
}
/** Shell reads succeed exactly as production does, so the caches warm. */
function installShellFetch(): void {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const pathname = new URL(String(input), 'http://d.local').pathname;
if (pathname.endsWith('/workspace/directory')) {
return jsonResponse({
items: [
{
workspaceId: WORKSPACE_ID,
workspaceName: 'OD E2E Team',
workspaceType: 'team',
workspaceMemberId: WORKSPACE_MEMBER_ID,
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
},
],
activeWorkspaceId: null,
});
}
if (pathname.endsWith('/workspace/context')) {
return jsonResponse({ context: shellWorkspaceContext() });
}
if (pathname.endsWith('/workspace/projects/team')) {
return jsonResponse({ projects: [] });
}
return jsonResponse({});
}) as typeof fetch;
}
async function warmShellCaches(): Promise<void> {
installShellFetch();
const ctx = renderHook(() => useWorkspaceContext());
await waitFor(() => expect(ctx.result.current.loading).toBe(false));
ctx.unmount();
const team = renderHook(() => useTeamProjects());
await waitFor(() => expect(team.result.current.loading).toBe(false));
team.unmount();
}
/**
* Shell reads keep working; only this ONE project's `/collab/status` refuses.
* That is the shape of the report: every other personal project in the same
* workspace stays editable.
*
* The body is the daemon's real refusal payload
* (`{"error":"WORKSPACE_ACCESS_DENIED", ...}` with HTTP 403).
*/
function installStatusDeniedFetch(): ReturnType<typeof vi.fn> {
const impl = vi.fn(async (input: RequestInfo | URL) => {
const pathname = new URL(String(input), 'http://d.local').pathname;
if (pathname.endsWith('/collab/status')) {
return jsonResponse(
{
error: 'WORKSPACE_ACCESS_DENIED',
message: 'the requested workspace does not own this project',
},
403,
);
}
if (pathname.endsWith('/workspace/directory')) {
return jsonResponse({
items: [
{
workspaceId: WORKSPACE_ID,
workspaceName: 'OD E2E Team',
workspaceType: 'team',
workspaceMemberId: WORKSPACE_MEMBER_ID,
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
},
],
activeWorkspaceId: null,
});
}
if (pathname.endsWith('/workspace/context')) {
return jsonResponse({ context: shellWorkspaceContext() });
}
if (pathname.endsWith('/workspace/projects/team')) {
return jsonResponse({ projects: [] });
}
return jsonResponse({});
});
globalThis.fetch = impl as unknown as typeof fetch;
return impl;
}
const originalFetch = globalThis.fetch;
beforeEach(() => {
resetWorkspaceContextCache();
resetTeamProjectsCache();
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
resetWorkspaceContextCache();
resetTeamProjectsCache();
vi.restoreAllMocks();
});
describe('OPEND-2624 personal local-only project stays writable', () => {
it('does not present a daemon-confirmed personal project as shared read-only when /collab/status refuses', async () => {
await warmShellCaches();
const fetchImpl = installStatusDeniedFetch();
const view = renderHook(() =>
useProjectCollab(PROJECT_ID, {
// Exactly what ProjectView passes: the project's own resolved scope.
workspaceContext: PROJECT_SCOPE_CONTEXT,
workspaceContextLoading: false,
// The daemon's authoritative answer for this project: a private draft.
projectVisibility: 'personal',
}),
);
await waitFor(() =>
expect(
fetchImpl.mock.calls.some(([input]) =>
String(input).endsWith('/collab/status'),
),
).toBe(true),
);
// Give the failed poll a chance to settle into state.
await waitFor(() => expect(view.result.current.viewerOnly).toBe(false));
expect(view.result.current.isSharedNonOwner).toBe(false);
expect(view.result.current.writerAuthority).toBe('allowed');
});
});