Skip to content

Commit a8cd808

Browse files
committed
feat(daemon): team resource sharing + copy red-line guard
Shares design systems, plugins, and skills to the team through the resource hub via a kind-parametrized share service and per-kind routes (/api/workspace/{design-systems,plugins,skills}/{team,:id/share}), and enforces the team-resource copy red-line (a frozen team resource can't be copied to personal) at the plugin-duplicate and skill-edit escape routes. Wires all the collab + sharing routes together in the server. Verified against a local resource-hub fixture: owner shares -> member pulls byte-identical, per kind.
1 parent 4a383d6 commit a8cd808

8 files changed

Lines changed: 413 additions & 2 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Team resource sharing. A member with publish rights promotes a personal
2+
// resource — a design system, plugin, or skill — into the team scope: the
3+
// resource's directory is packed and pushed to the resource hub under its kind,
4+
// so teammates can pull it into their own workspace. This reuses the same
5+
// content-addressed publish machinery as project sync — only the resource kind
6+
// and id namespace differ — and degrades to a no-op when there is no team
7+
// identity or the hub is not configured (the same identity gate as the rest of
8+
// the collab surface).
9+
10+
import {
11+
createResourceHubClient,
12+
readResourceHubConfig,
13+
type ResourceHubClient,
14+
type ResourceHubPrincipal,
15+
} from '../integrations/resource-hub.js';
16+
import { createResourceHubPublishAdapter } from './resource-hub-publish-adapter.js';
17+
18+
export interface TeamResourceShareService {
19+
/** Share a resource to the team. Returns the published version, or null off-team. */
20+
share(resourceId: string): Promise<{ version: number } | null>;
21+
/** Ids of resources shared to the team in this session. */
22+
sharedIds(): string[];
23+
/** True once a resource has been shared to the team. */
24+
isShared(resourceId: string): boolean;
25+
/** Whether the hub is reachable (share is a no-op otherwise). */
26+
readonly configured: boolean;
27+
}
28+
29+
export interface CreateTeamResourceShareOptions {
30+
/** Resource hub kind, e.g. `design_system` | `plugin` | `skill`. */
31+
kind: string;
32+
/** Colon-free id-namespace prefix distinguishing this kind on the shared hub. */
33+
idPrefix: string;
34+
/** Resolve a resource's source directory (what gets packed and pushed). */
35+
resolveDir: (resourceId: string) => string;
36+
/** Resolve the current principal (null = no team identity → share no-ops). */
37+
getPrincipal: () => ResourceHubPrincipal | null | Promise<ResourceHubPrincipal | null>;
38+
/** Injectable client for tests; built from env when omitted. */
39+
client?: ResourceHubClient;
40+
env?: NodeJS.ProcessEnv;
41+
}
42+
43+
export function createTeamResourceShareService(
44+
options: CreateTeamResourceShareOptions,
45+
): TeamResourceShareService {
46+
const env = options.env ?? process.env;
47+
const client =
48+
options.client ??
49+
(env.OD_RESOURCE_HUB_URL?.trim()
50+
? createResourceHubClient({ config: readResourceHubConfig(env) })
51+
: null);
52+
// Ids shared this session. The published resources are the durable record on
53+
// the hub; this is the fast local view the team collection reads until a hub
54+
// listing query lands.
55+
const shared = new Set<string>();
56+
57+
if (!client) {
58+
return {
59+
share: async () => null,
60+
sharedIds: () => [],
61+
isShared: () => false,
62+
configured: false,
63+
};
64+
}
65+
66+
const adapter = createResourceHubPublishAdapter({
67+
client,
68+
getPrincipal: options.getPrincipal,
69+
resolveProjectDir: options.resolveDir,
70+
// Distinct, colon-free id namespace on the shared hub. The caller's id (e.g.
71+
// `user:palette-x`) is sanitized to path-safe chars — the hub routes the
72+
// resource id as a path param, so a colon would 404.
73+
resourceIdFor: (id) => `${options.idPrefix}-${id.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
74+
kind: options.kind,
75+
});
76+
77+
return {
78+
async share(resourceId) {
79+
const result = await adapter.publish({ projectId: resourceId, reason: 'share' });
80+
if (result) shared.add(resourceId);
81+
return result;
82+
},
83+
sharedIds: () => [...shared],
84+
isShared: (resourceId) => shared.has(resourceId),
85+
configured: true,
86+
};
87+
}

apps/daemon/src/design-systems/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2390,7 +2390,7 @@ window.Composer = Composer;
23902390
`;
23912391
}
23922392

2393-
function stripPrefixAndValidateId(id: string, prefix = ''): string | null {
2393+
export function stripPrefixAndValidateId(id: string, prefix = ''): string | null {
23942394
if (typeof id !== 'string') return null;
23952395
if (prefix && !id.startsWith(prefix)) return null;
23962396
const dirId = prefix ? id.slice(prefix.length) : id;

apps/daemon/src/routes/plugins/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ import type {
66
Project,
77
ProjectMetadata,
88
} from '@open-design/contracts';
9+
import { TeamResourceCopyForbiddenError } from '@open-design/contracts';
910
import {
1011
duplicatePluginExampleIntoProject,
1112
PluginDuplicateProjectError,
1213
} from '../../plugins/duplicate-project.js';
14+
import {
15+
enforceTeamResourceCopyAllowed,
16+
type TeamResourceStateProvider,
17+
} from '../../collab/team-resource-state.js';
1318
import type { PluginShareAction } from '../../services/plugin-share-tasks.js';
1419

1520
export interface RegisterPluginEventRoutesDeps {
@@ -105,6 +110,9 @@ interface PluginRouteHelpers {
105110

106111
export interface RegisterPluginRoutesDeps {
107112
db: SqliteDbLike;
113+
/** Team-resource copy red-line (D3). When present, a frozen team plugin cannot
114+
* be duplicated into a personal project. Omit to skip the guard (no-op). */
115+
teamResources?: TeamResourceStateProvider;
108116
paths: { PROJECTS_DIR: string; PLUGIN_REGISTRY_ROOTS: string[]; PLUGIN_LOCKFILE_PATH: string };
109117
ids: { randomId(): string };
110118
projectStore: {
@@ -169,7 +177,7 @@ export function registerPluginEventRoutes(app: Express, deps: RegisterPluginEven
169177
}
170178

171179
export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDeps): void {
172-
const { db, paths, ids, projectStore, conversations, plugins, helpers } = deps;
180+
const { db, paths, ids, projectStore, conversations, plugins, helpers, teamResources } = deps;
173181
app.get('/api/plugins', async (_req, res) => { try { res.json({ plugins: helpers.applyBakedPreviews(plugins.listInstalledPlugins(db), helpers.PLUGIN_PREVIEWS_DIR) }); } catch (err) { res.status(500).json({ error: String(err) }); } });
174182
app.get('/api/plugins/:id', async (req, res) => { try { const plugin = plugins.getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); res.json(plugin); } catch (err) { res.status(500).json({ error: String(err) }); } });
175183
app.post('/api/plugins/upload-zip', (req, res) => helpers.pluginUpload.single('file')(req, res, async (err: unknown) => { if (err) return helpers.sendMulterError(res, err); try { const file = req.file; if (!file?.buffer) return res.status(400).json({ error: 'file is required' }); const result = await helpers.pluginInstallation.stageUploadedPluginZip(file.buffer, `upload:zip:${helpers.decodeMultipartFilename(file.originalname || 'plugin.zip')}`); res.status((result as { ok?: boolean }).ok ? 200 : 400).json(result); } catch (uploadErr: unknown) { res.status(400).json({ ok: false, warnings: [], message: uploadErr instanceof Error ? uploadErr.message : String(uploadErr), log: [] }); } }));
@@ -188,6 +196,13 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
188196
if (typeof plugin.id !== 'string' || typeof plugin.fsPath !== 'string') {
189197
return res.status(422).json({ error: { code: 'plugin-not-duplicable', message: 'plugin record is missing a filesystem source' } });
190198
}
199+
// AC-9 copy red-line (D3): a frozen team plugin cannot be duplicated into a
200+
// personal project. Runs before any project is created (nothing to clean up
201+
// if it throws). No-op until the resource-hub reports this plugin as a
202+
// frozen team resource.
203+
if (teamResources) {
204+
await enforceTeamResourceCopyAllowed(teamResources, { kind: 'plugin', resourceId: plugin.id });
205+
}
191206
const body = req.body && typeof req.body === 'object'
192207
? req.body as PluginDuplicateProjectRequest
193208
: {};
@@ -258,6 +273,9 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
258273
if (insertedProject) projectStore.dbDeleteProject(db, cleanupProjectId);
259274
await projectStore.removeProjectDir(paths.PROJECTS_DIR, cleanupProjectId).catch(() => {});
260275
}
276+
if (err instanceof TeamResourceCopyForbiddenError) {
277+
return res.status(403).json({ error: { code: err.code, message: err.message } });
278+
}
261279
if (err instanceof PluginDuplicateProjectError) {
262280
return res.status(err.status).json({ error: { code: err.code, message: err.message } });
263281
}

apps/daemon/src/routes/static-resource.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import type Database from 'better-sqlite3';
33
import path from 'node:path';
44
import fs from 'node:fs';
55
import type { DesignSystemTokenContractRebuildJobResponse } from '@open-design/contracts';
6+
import { TeamResourceCopyForbiddenError } from '@open-design/contracts';
7+
import {
8+
enforceTeamResourceCopyAllowed,
9+
type TeamResourceStateProvider,
10+
} from '../collab/team-resource-state.js';
611
import { detectAgents, detectAgentsStream } from '../agents.js';
712
import {
813
SkillImportError,
@@ -40,6 +45,9 @@ export interface RegisterStaticResourceRoutesDeps extends RouteDeps<'http' | 'pa
4045
designSystemId: string,
4146
) => Promise<DesignSystemTokenContractRebuildJobResponse | undefined>;
4247
};
48+
/** Team-resource copy red-line (D3). When present, a frozen team skill cannot
49+
* be edit-shadowed into a personal editable copy. Omit to skip (no-op). */
50+
teamResources?: TeamResourceStateProvider;
4351
}
4452

4553
export function registerAtomRoutes(app: Express, ctx: RegisterAtomRoutesDeps) {
@@ -90,6 +98,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
9098
mimeFor,
9199
} = ctx.resources;
92100
const { isLocalSameOrigin, resolvedPortRef, sendApiError } = ctx.http;
101+
const teamResources = ctx.teamResources;
93102
const requireLocalOrigin = (req: any, res: any) => {
94103
if (isLocalSameOrigin(req, resolvedPortRef.current)) return true;
95104
sendApiError(res, 403, 'FORBIDDEN', 'local origin required');
@@ -263,6 +272,12 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
263272
if (!skill) {
264273
return sendApiError(res, 404, 'NOT_FOUND', 'skill not found');
265274
}
275+
// AC-9 copy red-line (D3): a frozen team skill cannot be edit-shadowed into
276+
// a personal editable copy. No-op until the resource-hub reports this skill
277+
// as a frozen team resource.
278+
if (teamResources) {
279+
await enforceTeamResourceCopyAllowed(teamResources, { kind: 'skill', resourceId: skill.id });
280+
}
266281
const result = await updateUserSkill(USER_SKILLS_DIR, {
267282
...(req.body || {}),
268283
id: skill.id,
@@ -286,6 +301,9 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
286301
},
287302
});
288303
} catch (err: any) {
304+
if (err instanceof TeamResourceCopyForbiddenError) {
305+
return sendApiError(res, 403, err.code, err.message);
306+
}
289307
if (err instanceof SkillImportError) {
290308
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500;
291309
return sendApiError(res, status, err.code, err.message);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Express } from 'express';
2+
import type { TeamResourceShareService } from '../collab/team-resource-share.js';
3+
4+
export interface RegisterTeamResourceShareRoutesDeps {
5+
/** URL segment for this resource kind: `design-systems` | `plugins` | `skills`. */
6+
basePath: string;
7+
share: TeamResourceShareService;
8+
}
9+
10+
/**
11+
* Team resource sharing routes for one resource kind. A member promotes a
12+
* personal resource into the team scope; the share service packs its directory
13+
* and pushes it to the resource hub so teammates can pull it. When there is no
14+
* team identity (or the hub is not configured), share returns `shared: false`
15+
* so the client keeps a local-only view instead of erroring. Mounted once per
16+
* kind (design systems, plugins, skills).
17+
*/
18+
export function registerTeamResourceShareRoutes(
19+
app: Express,
20+
deps: RegisterTeamResourceShareRoutesDeps,
21+
): void {
22+
const { basePath, share } = deps;
23+
const root = `/api/workspace/${basePath}`;
24+
25+
// Ids shared to the team — drives the "team" collection for this kind.
26+
app.get(`${root}/team`, (_req, res) => {
27+
res.json({ ids: share.sharedIds() });
28+
});
29+
30+
// Share a personal resource to the team.
31+
app.post(`${root}/:id/share`, async (req, res) => {
32+
const id = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id) : '';
33+
if (!id) return res.status(400).json({ error: 'invalid resource id' });
34+
try {
35+
const result = await share.share(id);
36+
if (!result) return res.json({ shared: false });
37+
res.json({ shared: true, version: result.version });
38+
} catch (error) {
39+
res.status(500).json({ error: error instanceof Error ? error.message : 'share failed' });
40+
}
41+
});
42+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Express } from 'express';
2+
import {
3+
assertTeamResourceCopyAllowed,
4+
createApiError,
5+
createApiErrorResponse,
6+
TeamResourceCopyForbiddenError,
7+
type TeamResourceState,
8+
} from '@open-design/contracts';
9+
import type {
10+
TeamResourceKind,
11+
TeamResourceKey,
12+
TeamResourceStateProvider,
13+
} from '../collab/team-resource-state.js';
14+
15+
export interface RegisterTeamResourceRoutesDeps {
16+
teamResources: TeamResourceStateProvider;
17+
}
18+
19+
const KINDS: ReadonlySet<TeamResourceKind> = new Set(['design-system', 'plugin', 'skill']);
20+
const STATES: ReadonlySet<TeamResourceState> = new Set(['active', 'frozen', 'deleted']);
21+
22+
function readKey(params: { kind?: string; id?: string }): TeamResourceKey | null {
23+
const kind = params.kind;
24+
const resourceId = typeof params.id === 'string' ? decodeURIComponent(params.id) : '';
25+
if (!kind || !KINDS.has(kind as TeamResourceKind) || !resourceId) return null;
26+
return { kind: kind as TeamResourceKind, resourceId };
27+
}
28+
29+
/**
30+
* Team-resource routes (D1 state model + D3 enforcement). The copy-check runs
31+
* the real copy red-line guard against the resolved resource state, so a frozen
32+
* team resource is rejected with a 403 the same way the copy-out routes will be.
33+
* The state provider is the E-resource-hub seam (the resource-hub owner).
34+
*/
35+
export function registerTeamResourceRoutes(app: Express, deps: RegisterTeamResourceRoutesDeps): void {
36+
const { teamResources } = deps;
37+
38+
app.get('/api/workspace/resources/:kind/:id/state', async (req, res) => {
39+
const key = readKey(req.params);
40+
if (!key) return res.status(400).json({ error: 'invalid resource key' });
41+
res.json(await teamResources.resolve(key));
42+
});
43+
44+
// Enforce the AC-9 copy red-line for a resource about to be copied to personal.
45+
app.post('/api/workspace/resources/:kind/:id/copy-check', async (req, res) => {
46+
const key = readKey(req.params);
47+
if (!key) return res.status(400).json({ error: 'invalid resource key' });
48+
const target = await teamResources.resolve(key);
49+
try {
50+
assertTeamResourceCopyAllowed(target);
51+
res.json({ allowed: true });
52+
} catch (error) {
53+
if (error instanceof TeamResourceCopyForbiddenError) {
54+
return res.status(403).json(createApiErrorResponse(createApiError(error.code, error.message)));
55+
}
56+
throw error;
57+
}
58+
});
59+
60+
// Dev/demo seam: mark a resource team-shared with a state (real hub-backed
61+
// provider omits `set`, so this 404s in production instead of spoofing state).
62+
app.put('/api/workspace/resources/:kind/:id/state', (req, res) => {
63+
const key = readKey(req.params);
64+
if (!key) return res.status(400).json({ error: 'invalid resource key' });
65+
if (!teamResources.set) return res.status(404).json({ error: 'resource state is not settable' });
66+
const body = (req.body ?? {}) as { scope?: unknown; state?: unknown };
67+
if (body.scope === 'personal') {
68+
teamResources.set(key, { scope: 'personal' });
69+
return res.json({ scope: 'personal' });
70+
}
71+
if (body.scope === 'team' && typeof body.state === 'string' && STATES.has(body.state as TeamResourceState)) {
72+
teamResources.set(key, { scope: 'team', state: body.state as TeamResourceState });
73+
return res.json({ scope: 'team', state: body.state });
74+
}
75+
res.status(400).json({ error: 'invalid resource state' });
76+
});
77+
}

0 commit comments

Comments
 (0)