mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-28 05:22:59 +08:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user