Skip to content

Commit 5970960

Browse files
committed
fix(workspace): ensure backing project before design share
1 parent 996a5c0 commit 5970960

3 files changed

Lines changed: 152 additions & 34 deletions

File tree

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

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,19 @@ export interface DesignSystemBackingProjectBinding {
3333
}
3434

3535
export interface CreateDesignSystemBackingProjectPreparerOptions {
36-
resolveProjectId(resourceId: string): Promise<string | null> | string | null;
36+
resolveProjectId(
37+
resourceId: string,
38+
scope: TeamResourceRequestScope,
39+
): Promise<string | null> | string | null;
40+
/**
41+
* Materialize and bind a missing backing project under the request's exact
42+
* Workspace identity. This is mutation-only; collection reads must never
43+
* invoke it.
44+
*/
45+
ensureProjectId?(
46+
resourceId: string,
47+
scope: TeamResourceRequestScope,
48+
): Promise<string | null> | string | null;
3749
projectExists(projectId: string): boolean;
3850
getProjectBinding(projectId: string): DesignSystemBackingProjectBinding | undefined;
3951
publishProject(
@@ -76,7 +88,10 @@ export function createDesignSystemBackingProjectPreparer(
7688
options: CreateDesignSystemBackingProjectPreparerOptions,
7789
): CreateLinkedProjectTeamResourceShareServiceOptions['prepare'] {
7890
return async (resourceId, scope) => {
79-
const projectId = (await options.resolveProjectId(resourceId))?.trim() ?? '';
91+
let projectId = (await options.resolveProjectId(resourceId, scope))?.trim() ?? '';
92+
if ((!projectId || !options.projectExists(projectId)) && options.ensureProjectId) {
93+
projectId = (await options.ensureProjectId(resourceId, scope))?.trim() ?? '';
94+
}
8095
if (!projectId || !options.projectExists(projectId)) {
8196
throw new Error('design system backing project is unavailable');
8297
}
@@ -247,19 +262,14 @@ export function createLinkedProjectTeamResourceShareService(
247262
sharedIds: (scope) => resource.sharedIds(scope),
248263
async sharedResources(scope, readOptions) {
249264
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-
}));
265+
for (const candidate of resources) {
266+
// Generic hub capability grants Workspace owner/admin broadly. Linked
267+
// design-system projects are single-writer, so the authoritative hub
268+
// owner id is the cheap collection-level creator evidence. Full local
269+
// project resolution/creation remains mutation-only.
270+
candidate.canUnshare = candidate.canUnshare === true
271+
&& candidate.ownerMemberId === scope.principal.memberId;
272+
}
263273
return resources;
264274
},
265275
isShared: (resourceId, scope) => resource.isShared(resourceId, scope),

apps/daemon/src/server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5782,10 +5782,18 @@ export async function startServer({
57825782
const designSystemsTeamShare = createLinkedProjectTeamResourceShareService({
57835783
resource: designSystemsTeamResourceShare,
57845784
prepare: createDesignSystemBackingProjectPreparer({
5785-
resolveProjectId: async (resourceId) =>
5786-
(await listAllDesignSystems())
5785+
resolveProjectId: async (resourceId, scope) =>
5786+
(await listAllDesignSystems({
5787+
workspaceId: scope.principal.teamId,
5788+
workspaceMemberId: scope.principal.memberId,
5789+
}))
57875790
.find((candidate) => candidate.id === resourceId)
57885791
?.projectId ?? null,
5792+
ensureProjectId: async (resourceId, scope) =>
5793+
(await ensureUserDesignSystemWorkspaceProject(db, resourceId, {
5794+
workspaceId: scope.principal.teamId,
5795+
workspaceMemberId: scope.principal.memberId,
5796+
}))?.project.id ?? null,
57895797
projectExists: (projectId) => Boolean(getProject(db, projectId)),
57905798
getProjectBinding: (projectId) =>
57915799
getWorkspaceProjectByProjectId(db, projectId),

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

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ const scope = (workspaceId = 'ws-a'): TeamResourceRequestScope => ({
2020
canShare: true,
2121
});
2222

23-
function fixture() {
23+
function fixture(options: { projectInitiallyExists?: boolean } = {}) {
2424
const calls: string[] = [];
2525
const shared = new Set<string>();
26-
const projectVisibility = new Map<string, 'personal' | 'team'>([
27-
['project-brand', 'personal'],
28-
]);
26+
const projectVisibility = new Map<string, 'personal' | 'team'>(
27+
options.projectInitiallyExists === false
28+
? []
29+
: [['project-brand', 'personal']],
30+
);
2931
let failResourceShare = false;
3032
let failResourceUnshare = false;
3133
let resourceUnshareFailuresRemaining = 0;
@@ -35,7 +37,16 @@ function fixture() {
3537
let authoritativeShared: Set<string> | null = null;
3638
let authoritativeReadError: Error | null = null;
3739
let authoritativeCanUnshare = true;
38-
let projectCreatorMemberId: string | null = 'member-owner';
40+
let hubOwnerMemberId: string | undefined = 'member-owner';
41+
let projectBinding: {
42+
workspaceId: string;
43+
createdByWorkspaceMemberId: string;
44+
} | undefined = options.projectInitiallyExists === false
45+
? undefined
46+
: {
47+
workspaceId: 'ws-a',
48+
createdByWorkspaceMemberId: 'member-owner',
49+
};
3950

4051
const resource: TeamResourceShareService = {
4152
configured: true,
@@ -61,24 +72,47 @@ function fixture() {
6172
if (readOptions?.authoritative) {
6273
if (authoritativeReadError) throw authoritativeReadError;
6374
return [...(authoritativeShared ?? shared)]
64-
.map((id) => ({ id, canUnshare: authoritativeCanUnshare }));
75+
.map((id) => ({
76+
id,
77+
...(hubOwnerMemberId ? { ownerMemberId: hubOwnerMemberId } : {}),
78+
canUnshare: authoritativeCanUnshare,
79+
}));
6580
}
66-
return [...shared].map((id) => ({ id, canUnshare: true }));
81+
return [...shared].map((id) => ({
82+
id,
83+
...(hubOwnerMemberId ? { ownerMemberId: hubOwnerMemberId } : {}),
84+
canUnshare: true,
85+
}));
6786
},
6887
isShared(resourceId) {
6988
return shared.has(resourceId);
7089
},
7190
};
91+
const ensureProjectId = vi.fn(async (
92+
resourceId: string,
93+
requestScope: TeamResourceRequestScope,
94+
) => {
95+
expect(resourceId).toBe('user:brand');
96+
expect(requestScope.principal).toMatchObject({
97+
teamId: 'ws-a',
98+
memberId: 'member-owner',
99+
});
100+
calls.push('project:ensure:project-brand');
101+
projectVisibility.set('project-brand', 'personal');
102+
projectBinding = {
103+
workspaceId: requestScope.principal.teamId,
104+
createdByWorkspaceMemberId: requestScope.principal.memberId,
105+
};
106+
return 'project-brand';
107+
});
72108
const prepare = vi.fn(createDesignSystemBackingProjectPreparer({
73109
resolveProjectId: (resourceId) => {
74110
expect(resourceId).toBe('user:brand');
75-
return 'project-brand';
111+
return projectVisibility.has('project-brand') ? 'project-brand' : null;
76112
},
113+
ensureProjectId,
77114
projectExists: (projectId) => projectVisibility.has(projectId),
78-
getProjectBinding: () => ({
79-
workspaceId: 'ws-a',
80-
createdByWorkspaceMemberId: projectCreatorMemberId,
81-
}),
115+
getProjectBinding: () => projectBinding,
82116
async publishProject(projectId) {
83117
calls.push(`project:team:${projectId}`);
84118
if (failNextProjectShare) {
@@ -109,6 +143,7 @@ function fixture() {
109143
shared,
110144
projectVisibility,
111145
prepare,
146+
ensureProjectId,
112147
service,
113148
failResourceShare: () => { failResourceShare = true; },
114149
failResourceUnshare: () => { failResourceUnshare = true; },
@@ -125,8 +160,8 @@ function fixture() {
125160
denyAuthoritativeUnshare: () => {
126161
authoritativeCanUnshare = false;
127162
},
128-
clearProjectCreator: () => {
129-
projectCreatorMemberId = null;
163+
clearHubOwner: () => {
164+
hubOwnerMemberId = undefined;
130165
},
131166
};
132167
}
@@ -149,6 +184,55 @@ describe('design-system team share linked backing project', () => {
149184
]);
150185
});
151186

187+
it('ensures and binds a missing backing project in the exact Workspace before direct share', async () => {
188+
const f = fixture({ projectInitiallyExists: false });
189+
190+
await expect(f.service.share('user:brand', scope())).resolves.toEqual({ version: 1 });
191+
192+
expect(f.ensureProjectId).toHaveBeenCalledOnce();
193+
expect(f.projectVisibility.get('project-brand')).toBe('team');
194+
expect([...f.shared]).toEqual(['user:brand']);
195+
expect(f.calls).toEqual([
196+
'project:ensure:project-brand',
197+
'resource:share:user:brand',
198+
'project:team:project-brand',
199+
]);
200+
});
201+
202+
it('compensates a failed direct share without leaving the ensured project in Team', async () => {
203+
const f = fixture({ projectInitiallyExists: false });
204+
f.failNextProjectShare();
205+
206+
await expect(f.service.share('user:brand', scope()))
207+
.rejects.toThrow('project publish failed');
208+
209+
expect(f.ensureProjectId).toHaveBeenCalledOnce();
210+
expect(f.projectVisibility.get('project-brand')).toBe('personal');
211+
expect(f.shared.size).toBe(0);
212+
expect(f.calls).toEqual([
213+
'project:ensure:project-brand',
214+
'resource:share:user:brand',
215+
'project:team:project-brand',
216+
'resource:unshare:user:brand',
217+
]);
218+
});
219+
220+
it('keeps an ensured backing project Personal when design-system publication fails', async () => {
221+
const f = fixture({ projectInitiallyExists: false });
222+
f.failResourceShare();
223+
224+
await expect(f.service.share('user:brand', scope()))
225+
.rejects.toThrow('design-system publish failed');
226+
227+
expect(f.ensureProjectId).toHaveBeenCalledOnce();
228+
expect(f.projectVisibility.get('project-brand')).toBe('personal');
229+
expect(f.shared.size).toBe(0);
230+
expect(f.calls).toEqual([
231+
'project:ensure:project-brand',
232+
'resource:share:user:brand',
233+
]);
234+
});
235+
152236
it('leaves the project Personal when the design-system publish fails', async () => {
153237
const f = fixture();
154238
f.failResourceShare();
@@ -349,20 +433,36 @@ describe('design-system team share linked backing project', () => {
349433
};
350434

351435
await expect(f.service.sharedResources(adminScope)).resolves.toEqual([
352-
{ id: 'user:brand', canUnshare: false },
436+
{ id: 'user:brand', ownerMemberId: 'member-owner', canUnshare: false },
353437
]);
354438
});
355439

356-
it('fails closed when no exact linked-project creator can be proven', async () => {
440+
it('fails closed when the hub omits exact linked-project creator evidence', async () => {
357441
const f = fixture();
358442
await f.service.share('user:brand', scope());
359-
f.clearProjectCreator();
443+
f.clearHubOwner();
360444

361445
await expect(f.service.sharedResources(scope())).resolves.toEqual([
362446
{ id: 'user:brand', canUnshare: false },
363447
]);
364448
});
365449

450+
it('derives list capability without running full project preparation per resource', async () => {
451+
const f = fixture();
452+
f.shared.add('user:brand');
453+
f.shared.add('user:brand-two');
454+
f.shared.add('user:brand-three');
455+
f.prepare.mockClear();
456+
457+
await expect(f.service.sharedResources(scope())).resolves.toEqual([
458+
{ id: 'user:brand', ownerMemberId: 'member-owner', canUnshare: true },
459+
{ id: 'user:brand-two', ownerMemberId: 'member-owner', canUnshare: true },
460+
{ id: 'user:brand-three', ownerMemberId: 'member-owner', canUnshare: true },
461+
]);
462+
463+
expect(f.prepare).not.toHaveBeenCalled();
464+
});
465+
366466
it('fails closed across Workspace A→B before either hub mutation runs', async () => {
367467
const f = fixture();
368468

0 commit comments

Comments
 (0)