Skip to content

Commit 996a5c0

Browse files
committed
fix(workspace): gate linked unshare on live authority
1 parent b4a0abe commit 996a5c0

6 files changed

Lines changed: 205 additions & 18 deletions

File tree

apps/daemon/src/collab/team-resource-share.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ export class TeamResourceShareForbiddenError extends Error {
2727
}
2828
}
2929

30+
/**
31+
* Thrown when an operation requires a live Team hub read but the authority
32+
* cannot be reached. Callers must distinguish this from an authoritative
33+
* empty list: retrying is safe, while proceeding from a cached fallback is
34+
* not.
35+
*/
36+
export class TeamResourceAuthorityUnavailableError extends Error {
37+
readonly status = 503;
38+
readonly code = 'WORKSPACE_RESOURCE_AUTHORITY_UNAVAILABLE';
39+
readonly retryable = true;
40+
41+
constructor(cause?: unknown) {
42+
super('team resource authority is temporarily unavailable', { cause });
43+
this.name = 'TeamResourceAuthorityUnavailableError';
44+
}
45+
}
46+
3047
export interface TeamResourceShareRecord {
3148
id: string;
3249
hubResourceId?: string;
@@ -281,10 +298,15 @@ export async function unshareIfCurrentlyShared(
281298
resourceId: string,
282299
scope: TeamResourceRequestScope,
283300
): Promise<boolean> {
284-
const resources = await service.sharedResources(scope);
301+
let resources: TeamResourceShareRecord[];
302+
try {
303+
resources = await service.sharedResources(scope, { authoritative: true });
304+
} catch (error) {
305+
if (error instanceof TeamResourceAuthorityUnavailableError) throw error;
306+
throw new TeamResourceAuthorityUnavailableError(error);
307+
}
285308
if (!resources.some((resource) => resource.id === resourceId)) return false;
286-
await service.unshare(resourceId, scope);
287-
return true;
309+
return service.unshare(resourceId, scope);
288310
}
289311

290312
interface SharedResourceListPayload {

apps/daemon/src/design-systems/team-project-share.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
TeamResourceAuthorityUnavailableError,
23
TeamResourceShareForbiddenError,
34
type TeamResourceRequestScope,
45
type TeamResourceShareService,
@@ -85,10 +86,7 @@ export function createDesignSystemBackingProjectPreparer(
8586
if (binding?.workspaceId && binding.workspaceId !== workspaceId) {
8687
throw new Error('design system backing project belongs to another workspace');
8788
}
88-
if (
89-
binding?.createdByWorkspaceMemberId
90-
&& binding.createdByWorkspaceMemberId !== memberId
91-
) {
89+
if (binding?.createdByWorkspaceMemberId !== memberId) {
9290
throw new TeamResourceShareForbiddenError();
9391
}
9492
options.onPrepared?.({ resourceId, projectId, scope });
@@ -207,12 +205,18 @@ export function createLinkedProjectTeamResourceShareService(
207205
return result;
208206
},
209207
async unshare(resourceId, scope) {
210-
// The linked project must not move before the resource's authoritative
211-
// owner/admin gate has approved this exact caller. `resource.unshare`
212-
// repeats the same check immediately before its write.
213-
const sharedResource = (await service.sharedResources(scope))
214-
.find((candidate) => candidate.id === resourceId);
215-
if (sharedResource && !sharedResource.canUnshare) {
208+
// This live read is the idempotency boundary. A cached/session fallback
209+
// may still remember an already-removed design system, so it must never
210+
// authorize moving the independently shareable backing project.
211+
let sharedResource;
212+
try {
213+
sharedResource = (await resource.sharedResources(scope, { authoritative: true }))
214+
.find((candidate) => candidate.id === resourceId);
215+
} catch (error) {
216+
throw new TeamResourceAuthorityUnavailableError(error);
217+
}
218+
if (!sharedResource) return false;
219+
if (!sharedResource.canUnshare) {
216220
throw new TeamResourceShareForbiddenError();
217221
}
218222
const linkedProject = await options.prepare(resourceId, scope);
@@ -241,8 +245,23 @@ export function createLinkedProjectTeamResourceShareService(
241245
}
242246
},
243247
sharedIds: (scope) => resource.sharedIds(scope),
244-
sharedResources: (scope, readOptions) =>
245-
resource.sharedResources(scope, readOptions),
248+
async sharedResources(scope, readOptions) {
249+
const resources = await resource.sharedResources(scope, readOptions);
250+
await Promise.all(resources.map(async (candidate) => {
251+
if (!candidate.canUnshare) return;
252+
try {
253+
// Reuse the exact same creator/Workspace preflight as the mutation
254+
// path. The generic hub grants owner/admin broadly, but linked
255+
// design-system projects are deliberately single-writer.
256+
await options.prepare(candidate.id, scope);
257+
} catch {
258+
// Mutate in place to preserve the non-enumerable hubResourceId used
259+
// by teammate materialization.
260+
candidate.canUnshare = false;
261+
}
262+
}));
263+
return resources;
264+
},
246265
isShared: (resourceId, scope) => resource.isShared(resourceId, scope),
247266
};
248267
return service;

apps/daemon/src/routes/team-resource-share.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Express, Request, Response } from 'express';
22
import {
3+
TeamResourceAuthorityUnavailableError,
34
TeamResourceShareForbiddenError,
45
type TeamResourceRequestScope,
56
type TeamResourceShareRecord,
@@ -179,6 +180,13 @@ export function registerTeamResourceShareRoutes(
179180
if (error instanceof TeamResourceShareForbiddenError) {
180181
return res.status(403).json({ error: 'WORKSPACE_RESOURCE_UNSHARE_DENIED' });
181182
}
183+
if (error instanceof TeamResourceAuthorityUnavailableError) {
184+
return res.status(503).json({
185+
error: error.code,
186+
message: error.message,
187+
retryable: true,
188+
});
189+
}
182190
res.status(500).json({ error: error instanceof Error ? error.message : 'unshare failed' });
183191
}
184192
});

apps/daemon/tests/design-systems/team-project-share.test.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createLinkedProjectTeamResourceShareService,
55
} from '../../src/design-systems/team-project-share.js';
66
import {
7+
TeamResourceAuthorityUnavailableError,
78
TeamResourceShareForbiddenError,
89
type TeamResourceRequestScope,
910
type TeamResourceShareService,
@@ -31,6 +32,10 @@ function fixture() {
3132
let failNextProjectShare = false;
3233
let projectUnshareFailuresRemaining = 0;
3334
let failNextProjectPersist = false;
35+
let authoritativeShared: Set<string> | null = null;
36+
let authoritativeReadError: Error | null = null;
37+
let authoritativeCanUnshare = true;
38+
let projectCreatorMemberId: string | null = 'member-owner';
3439

3540
const resource: TeamResourceShareService = {
3641
configured: true,
@@ -52,7 +57,12 @@ function fixture() {
5257
async sharedIds() {
5358
return [...shared];
5459
},
55-
async sharedResources() {
60+
async sharedResources(_scope, readOptions) {
61+
if (readOptions?.authoritative) {
62+
if (authoritativeReadError) throw authoritativeReadError;
63+
return [...(authoritativeShared ?? shared)]
64+
.map((id) => ({ id, canUnshare: authoritativeCanUnshare }));
65+
}
5666
return [...shared].map((id) => ({ id, canUnshare: true }));
5767
},
5868
isShared(resourceId) {
@@ -67,7 +77,7 @@ function fixture() {
6777
projectExists: (projectId) => projectVisibility.has(projectId),
6878
getProjectBinding: () => ({
6979
workspaceId: 'ws-a',
70-
createdByWorkspaceMemberId: 'member-owner',
80+
createdByWorkspaceMemberId: projectCreatorMemberId,
7181
}),
7282
async publishProject(projectId) {
7383
calls.push(`project:team:${projectId}`);
@@ -106,6 +116,18 @@ function fixture() {
106116
failNextProjectShare: () => { failNextProjectShare = true; },
107117
failNextProjectUnshare: () => { projectUnshareFailuresRemaining = 1; },
108118
failNextProjectPersist: () => { failNextProjectPersist = true; },
119+
setAuthoritativeShared: (ids: string[]) => {
120+
authoritativeShared = new Set(ids);
121+
},
122+
failAuthoritativeRead: () => {
123+
authoritativeReadError = new Error('authoritative hub unavailable');
124+
},
125+
denyAuthoritativeUnshare: () => {
126+
authoritativeCanUnshare = false;
127+
},
128+
clearProjectCreator: () => {
129+
projectCreatorMemberId = null;
130+
},
109131
};
110132
}
111133

@@ -264,7 +286,7 @@ describe('design-system team share linked backing project', () => {
264286
const f = fixture();
265287
await f.service.share('user:brand', scope());
266288
f.calls.length = 0;
267-
f.service.sharedResources = async () => [{ id: 'user:brand', canUnshare: false }];
289+
f.denyAuthoritativeUnshare();
268290

269291
await expect(f.service.unshare('user:brand', scope()))
270292
.rejects.toBeInstanceOf(TeamResourceShareForbiddenError);
@@ -273,6 +295,74 @@ describe('design-system team share linked backing project', () => {
273295
expect(f.calls).toEqual([]);
274296
});
275297

298+
it('treats an authoritative empty list as an idempotent unshare without touching the project', async () => {
299+
const f = fixture();
300+
await f.service.share('user:brand', scope());
301+
f.calls.length = 0;
302+
f.setAuthoritativeShared([]);
303+
304+
await expect(f.service.unshare('user:brand', scope())).resolves.toBe(false);
305+
306+
expect(f.projectVisibility.get('project-brand')).toBe('team');
307+
expect(f.calls).toEqual([]);
308+
});
309+
310+
it('does not undo an independent project share on a repeated design-system DELETE', async () => {
311+
const f = fixture();
312+
await f.service.share('user:brand', scope());
313+
await expect(f.service.unshare('user:brand', scope())).resolves.toBe(true);
314+
315+
// The design system is already absent from the authoritative Team index,
316+
// while the backing project has since been shared independently.
317+
f.projectVisibility.set('project-brand', 'team');
318+
f.calls.length = 0;
319+
320+
await expect(f.service.unshare('user:brand', scope())).resolves.toBe(false);
321+
322+
expect(f.projectVisibility.get('project-brand')).toBe('team');
323+
expect(f.calls).toEqual([]);
324+
});
325+
326+
it('fails before touching the project when the authoritative Team index is unavailable', async () => {
327+
const f = fixture();
328+
await f.service.share('user:brand', scope());
329+
f.calls.length = 0;
330+
f.failAuthoritativeRead();
331+
332+
await expect(f.service.unshare('user:brand', scope()))
333+
.rejects.toBeInstanceOf(TeamResourceAuthorityUnavailableError);
334+
335+
expect(f.projectVisibility.get('project-brand')).toBe('team');
336+
expect(f.calls).toEqual([]);
337+
});
338+
339+
it('downgrades hub owner/admin permission when the linked project creator gate denies mutation', async () => {
340+
const f = fixture();
341+
await f.service.share('user:brand', scope());
342+
const adminScope: TeamResourceRequestScope = {
343+
principal: {
344+
...scope().principal,
345+
memberId: 'member-admin',
346+
role: 'admin',
347+
},
348+
canShare: true,
349+
};
350+
351+
await expect(f.service.sharedResources(adminScope)).resolves.toEqual([
352+
{ id: 'user:brand', canUnshare: false },
353+
]);
354+
});
355+
356+
it('fails closed when no exact linked-project creator can be proven', async () => {
357+
const f = fixture();
358+
await f.service.share('user:brand', scope());
359+
f.clearProjectCreator();
360+
361+
await expect(f.service.sharedResources(scope())).resolves.toEqual([
362+
{ id: 'user:brand', canUnshare: false },
363+
]);
364+
});
365+
276366
it('fails closed across Workspace A→B before either hub mutation runs', async () => {
277367
const f = fixture();
278368

apps/daemon/tests/routes/design-system-delete-unshares-team-share.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { DesignSystemSummary } from '../../src/design-systems/index.js';
3232
import { closeDatabase, openDatabase } from '../../src/db.js';
3333
import {
3434
createTeamResourceShareService,
35+
TeamResourceAuthorityUnavailableError,
3536
unshareIfCurrentlyShared,
3637
type TeamResourceRequestScope,
3738
} from '../../src/collab/team-resource-share.js';
@@ -301,4 +302,29 @@ describe('DELETE /api/design-systems/:id unshares from the team hub first', () =
301302
// chain, not just the local delete.
302303
expect(hub.resources.has('ds-t-1-user-my-brand')).toBe(true);
303304
});
305+
306+
it('returns retryable 503 and preserves the local system when Team authority is unavailable', async () => {
307+
const hub = fakeHub();
308+
const app = express();
309+
app.use(express.json());
310+
const { deleteUserDesignSystem } = registerRoutes(app, {
311+
hub,
312+
unshareTeamDesignSystemIfShared: async () => {
313+
throw new TeamResourceAuthorityUnavailableError(new Error('hub offline'));
314+
},
315+
});
316+
const baseUrl = await listen(app);
317+
318+
const res = await fetch(`${baseUrl}/api/design-systems/user:my-brand`, {
319+
method: 'DELETE',
320+
});
321+
322+
expect(res.status).toBe(503);
323+
await expect(res.json()).resolves.toEqual({
324+
error: 'WORKSPACE_RESOURCE_AUTHORITY_UNAVAILABLE',
325+
message: 'team resource authority is temporarily unavailable',
326+
retryable: true,
327+
});
328+
expect(deleteUserDesignSystem).not.toHaveBeenCalled();
329+
});
304330
});

apps/daemon/tests/team-resource-share-list-cache.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
TeamResourceShareRecord,
1010
TeamResourceShareService,
1111
} from '../src/collab/team-resource-share.js';
12+
import { TeamResourceAuthorityUnavailableError } from '../src/collab/team-resource-share.js';
1213

1314
let server: http.Server | null = null;
1415
const SCOPE: TeamResourceRequestScope = {
@@ -102,6 +103,27 @@ const record = (id: string): TeamResourceShareRecord =>
102103
({ id, localId: id, version: 1 }) as unknown as TeamResourceShareRecord;
103104

104105
describe('team resource share /team listing', () => {
106+
it('returns retryable 503 when unshare cannot read the authoritative Team index', async () => {
107+
const service = {
108+
async unshare() {
109+
throw new TeamResourceAuthorityUnavailableError(new Error('hub offline'));
110+
},
111+
} as unknown as TeamResourceShareService;
112+
const req = await startServer({
113+
basePath: 'design-systems',
114+
share: service,
115+
});
116+
117+
const response = await req.del('/api/workspace/design-systems/user%3Abrand/share');
118+
119+
expect(response.status).toBe(503);
120+
expect(response.body).toEqual({
121+
error: 'WORKSPACE_RESOURCE_AUTHORITY_UNAVAILABLE',
122+
message: 'team resource authority is temporarily unavailable',
123+
retryable: true,
124+
});
125+
});
126+
105127
it('rejects a share when the resource-owner gate denies it', async () => {
106128
let shareCalls = 0;
107129
const service = {

0 commit comments

Comments
 (0)