Skip to content

Commit 6c20e0b

Browse files
fix(collab): keep workspace projects usable during outages and first open (#7027) (#7069)
* fix(daemon): decouple local data plane from workspace authority * test(e2e): align workspace authority contracts * fix(daemon): preserve read-only team mirrors offline * test(daemon): preserve retryable result literal * fix(web): keep projects usable during workspace outages * test(web): keep collaboration mounted during outages * test(daemon): keep viewer comments local during outages * fix(collab): open team projects during materialization * fix(collab): keep project placeholders fail-closed (cherry picked from commit 67576cb) Co-authored-by: lefarcen <935902669@qq.com>
1 parent 05079ac commit 6c20e0b

63 files changed

Lines changed: 2894 additions & 1959 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
import type {
2-
WorkspaceCollabContext,
3-
WorkspaceDirectoryItem,
4-
} from '@open-design/contracts';
5-
import type { WorkspaceDirectoryFetchResult } from '../collab/vela-workspace-context.js';
6-
import { workspaceContextFromDirectoryItem } from '../collab/vela-workspace-context.js';
7-
81
export interface PersistedAutomationWorkspaceScope {
92
workspaceId: string;
103
workspaceMemberId: string;
@@ -13,9 +6,7 @@ export interface PersistedAutomationWorkspaceScope {
136
export class AutomationWorkspaceScopeError extends Error {
147
constructor(
158
readonly code:
16-
| 'WORKSPACE_AUTHORITY_UNAVAILABLE'
17-
| 'WORKSPACE_ACCESS_DENIED'
18-
| 'WORKSPACE_PROJECT_PERMISSION_DENIED',
9+
| 'WORKSPACE_ACCESS_DENIED',
1910
message: string,
2011
readonly retryable: boolean,
2112
) {
@@ -78,87 +69,3 @@ export function bindProjectToPersistedAutomationWorkspace(
7869
updatedAt: now,
7970
});
8071
}
81-
82-
async function fetchDirectoryOrThrow(
83-
fetchWorkspaceDirectory: (() => Promise<WorkspaceDirectoryFetchResult>) | undefined,
84-
): Promise<WorkspaceDirectoryItem[]> {
85-
if (!fetchWorkspaceDirectory) {
86-
throw new AutomationWorkspaceScopeError(
87-
'WORKSPACE_AUTHORITY_UNAVAILABLE',
88-
'workspace membership authority is not configured',
89-
true,
90-
);
91-
}
92-
let directory: WorkspaceDirectoryFetchResult;
93-
try {
94-
directory = await fetchWorkspaceDirectory();
95-
} catch {
96-
directory = { ok: false, items: [] };
97-
}
98-
if (!directory.ok) {
99-
throw new AutomationWorkspaceScopeError(
100-
'WORKSPACE_AUTHORITY_UNAVAILABLE',
101-
'workspace membership authority is temporarily unavailable',
102-
true,
103-
);
104-
}
105-
return directory.items;
106-
}
107-
108-
function activeWritableContext(
109-
context: WorkspaceCollabContext | null,
110-
): WorkspaceCollabContext | null {
111-
return context
112-
&& context.memberStatus === 'active'
113-
&& context.lifecycleState === 'active'
114-
&& context.permissions.canWriteSyncedFiles
115-
? context
116-
: null;
117-
}
118-
119-
/**
120-
* Re-authorize a Workspace/member pair captured when an unattended automation
121-
* was configured. No daemon-global current/active Workspace participates.
122-
*/
123-
export async function authorizePersistedAutomationWorkspaceScope(
124-
scope: PersistedAutomationWorkspaceScope,
125-
fetchWorkspaceDirectory: (() => Promise<WorkspaceDirectoryFetchResult>) | undefined,
126-
): Promise<WorkspaceCollabContext> {
127-
const items = await fetchDirectoryOrThrow(fetchWorkspaceDirectory);
128-
const item = items.find(
129-
(candidate) =>
130-
candidate.workspaceId === scope.workspaceId
131-
&& candidate.workspaceMemberId === scope.workspaceMemberId,
132-
);
133-
const context = activeWritableContext(item ? workspaceContextFromDirectoryItem(item) : null);
134-
if (!context) {
135-
throw new AutomationWorkspaceScopeError(
136-
'WORKSPACE_ACCESS_DENIED',
137-
'the automation Workspace is no longer writable by this member',
138-
false,
139-
);
140-
}
141-
return context;
142-
}
143-
144-
/**
145-
* Resolve a reused project's persisted binding. The project row chooses the
146-
* Workspace; the signed-in directory supplies the current member and authority.
147-
*/
148-
export async function authorizePersistedProjectWorkspace(
149-
workspaceIdInput: string,
150-
fetchWorkspaceDirectory: (() => Promise<WorkspaceDirectoryFetchResult>) | undefined,
151-
): Promise<WorkspaceCollabContext> {
152-
const workspaceId = workspaceIdInput.trim();
153-
const items = await fetchDirectoryOrThrow(fetchWorkspaceDirectory);
154-
const item = items.find((candidate) => candidate.workspaceId === workspaceId);
155-
const context = activeWritableContext(item ? workspaceContextFromDirectoryItem(item) : null);
156-
if (!context) {
157-
throw new AutomationWorkspaceScopeError(
158-
'WORKSPACE_PROJECT_PERMISSION_DENIED',
159-
'the reused project Workspace is no longer writable by this member',
160-
false,
161-
);
162-
}
163-
return context;
164-
}

apps/daemon/src/collab/created-project-workspace.ts

Lines changed: 9 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import type { ApiErrorResponse } from '@open-design/contracts';
22
import type { Response } from 'express';
33
import {
4-
isWorkspaceResourceLocked,
54
workspaceResourceContextFromRequest,
65
type WorkspaceResourceContext,
76
} from './workspace-resource-mutation.js';
8-
import {
9-
workspaceContextFromDirectoryItem,
10-
type WorkspaceDirectoryFetchResult,
11-
} from './vela-workspace-context.js';
7+
import type { WorkspaceDirectoryFetchResult } from './vela-workspace-context.js';
128
import { sendApiError } from '../http/api-errors.js';
139

1410
export type CreatedProjectWorkspaceResolution =
@@ -43,43 +39,6 @@ export function sendCreatedProjectWorkspaceError(
4339
);
4440
}
4541

46-
/**
47-
* Resolve the workspace authority for a route that creates a project.
48-
*
49-
* A completely headerless request is a legal legacy/anonymous caller and
50-
* intentionally leaves the new project unbound. Once either workspace
51-
* identity header is present, however, the request is a workspace-aware
52-
* caller: partial, removed, locked, or non-writing identities must fail
53-
* closed instead of silently creating an unbound orphan.
54-
*/
55-
export function resolveCreatedProjectWorkspace(
56-
req: unknown,
57-
): CreatedProjectWorkspaceResolution {
58-
const context = workspaceResourceContextFromRequest(req);
59-
if (context === null) return { ok: true, context: null };
60-
if (context === 'missing') {
61-
return {
62-
ok: false,
63-
status: 400,
64-
code: 'WORKSPACE_CONTEXT_INCOMPLETE',
65-
message: 'workspace project creation requires both workspace and member identity',
66-
};
67-
}
68-
if (
69-
context.memberStatus !== 'active'
70-
|| !context.canWriteSyncedFiles
71-
|| isWorkspaceResourceLocked(context)
72-
) {
73-
return {
74-
ok: false,
75-
status: 403,
76-
code: 'WORKSPACE_PROJECT_PERMISSION_DENIED',
77-
message: 'workspace project creation is not allowed',
78-
};
79-
}
80-
return { ok: true, context };
81-
}
82-
8342
/**
8443
* Capture optional local attribution for an ordinary local project create.
8544
*
@@ -98,90 +57,13 @@ export function localProjectWorkspaceAttribution(
9857
return context === null || context === 'missing' ? null : context;
9958
}
10059

101-
/**
102-
* Authorize an explicitly-scoped project create against the signed-in
103-
* membership directory. The caller-selected workspace/member pair is the
104-
* lookup key; the daemon's ambient active workspace is deliberately absent
105-
* from this contract.
106-
*
107-
* A missing fetcher is the local/dev compatibility path. Production Vela
108-
* mode injects one and therefore fails closed when AMR is unavailable.
109-
*/
60+
/** Capture local attribution for project creation without a network gate. */
11061
export async function authorizeCreatedProjectWorkspace(
11162
req: unknown,
112-
fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>,
113-
configuredEnv?: Record<string, string>,
63+
_fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>,
64+
_configuredEnv?: Record<string, string>,
11465
): Promise<CreatedProjectWorkspaceResolution> {
115-
const claimed = resolveCreatedProjectWorkspace(req);
116-
if (!claimed.ok || claimed.context === null || !fetchWorkspaceDirectory) {
117-
return claimed;
118-
}
119-
const claimedContext = claimed.context;
120-
121-
let directory: WorkspaceDirectoryFetchResult;
122-
try {
123-
directory = await fetchWorkspaceDirectory();
124-
} catch {
125-
directory = { ok: false, items: [] };
126-
}
127-
if (!directory.ok) {
128-
if (directory.reason === 'unauthorized') {
129-
return {
130-
ok: false,
131-
status: 401,
132-
code: 'AMR_AUTH_REQUIRED',
133-
message: 'AMR authorization expired. Sign in again to continue.',
134-
};
135-
}
136-
return {
137-
ok: false,
138-
status: 503,
139-
code: 'WORKSPACE_AUTHORITY_UNAVAILABLE',
140-
message: 'workspace membership authority is temporarily unavailable',
141-
retryable: true,
142-
};
143-
}
144-
145-
const item = directory.items.find(
146-
(candidate) =>
147-
candidate.workspaceId === claimedContext.workspaceId
148-
&& candidate.workspaceMemberId === claimedContext.workspaceMemberId,
149-
);
150-
if (!item) {
151-
return {
152-
ok: false,
153-
status: 403,
154-
code: 'WORKSPACE_PROJECT_PERMISSION_DENIED',
155-
message: 'workspace project creation is not allowed',
156-
};
157-
}
158-
159-
const authoritative = workspaceContextFromDirectoryItem(item, configuredEnv);
160-
const context: WorkspaceResourceContext = {
161-
workspaceId: authoritative.workspaceId,
162-
workspaceType: authoritative.workspaceType,
163-
workspaceTypeAsserted: authoritative.workspaceType,
164-
appUserId: claimedContext.appUserId,
165-
workspaceMemberId: authoritative.workspaceMemberId,
166-
role: authoritative.role,
167-
memberStatus: authoritative.memberStatus,
168-
lifecycleState: authoritative.lifecycleState,
169-
canShareProjects: authoritative.permissions.canShareProjects,
170-
canWriteSyncedFiles: authoritative.permissions.canWriteSyncedFiles,
171-
};
172-
if (
173-
context.memberStatus !== 'active'
174-
|| !context.canWriteSyncedFiles
175-
|| isWorkspaceResourceLocked(context)
176-
) {
177-
return {
178-
ok: false,
179-
status: 403,
180-
code: 'WORKSPACE_PROJECT_PERMISSION_DENIED',
181-
message: 'workspace project creation is not allowed',
182-
};
183-
}
184-
return { ok: true, context };
66+
return { ok: true, context: localProjectWorkspaceAttribution(req) };
18567
}
18668

18769
/**
@@ -203,12 +85,7 @@ export class CreatedProjectWorkspaceResolutionError extends Error {
20385
}
20486
}
20587

206-
/**
207-
* Resolve an exact creation scope. Headerless legacy requests remain unbound.
208-
* Once either identity field is asserted, any incomplete, removed, denied, or
209-
* unavailable authority fails closed; it never degrades to ambient/current or
210-
* silently creates an unbound project.
211-
*/
88+
/** Resolve the optional local creation scope. */
21289
export async function createdProjectWorkspaceHome(
21390
req: unknown,
21491
fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>,
@@ -255,8 +132,9 @@ export function createCreatedProjectWorkspaceResolver(deps: {
255132
* the signed-in account wallet, but any later request that asserts a Workspace
256133
* still needs an exact persisted binding before workspace mutation gates allow it.
257134
*
258-
* `context` is the caller's exact verified Workspace when the request named
259-
* one. A headerless legacy request supplies null and remains unbound.
135+
* `context` is the caller's complete local Workspace attribution when the
136+
* request named one. A headerless legacy request supplies null and remains
137+
* unbound; remote authority is checked only at a later cloud boundary.
260138
*/
261139
export function bindCreatedProjectToWorkspace(
262140
ensureWorkspaceProject: (input: {

0 commit comments

Comments
 (0)