Attach workspace identity headers to write/upload/patch requests

Same enforceWorkspaceProjectMutation bypass as the earlier
duplicate/design-system-copy/restore/delete/rename fixes: the browser
client's writeProjectTextFile family, uploadProjectFile/uploadProjectFiles,
and patchProject never sent workspace headers at all, so a signed-in
member whose access was revoked (or a locked workspace) could still
write, upload, or rename project files through these paths — the daemon
falls back to a headerless legacy-caller read and skips its permission
check entirely.

Threads the new optional workspaceContext parameter through every call
site: React components pick it up via useWorkspaceContext() (adding the
hook where a component didn't already have it) or the existing
workspaceContextRef pattern in App.tsx; plain utility functions
(kit-edit.ts, kit-upload.ts, DesignSystemFlow.tsx's project-preparation
helpers) gained the parameter and pass it through to their own callers.

Also closes a gap in the deleteBrandLogo/deleteBrandImage fix from
7a2b89941: their patchBrand call (the actual brand.json write) wasn't
receiving the context, only the follow-up file delete was.
This commit is contained in:
lefarcen
2026-07-22 19:04:04 +08:00
parent c9bb2676c3
commit c0bce3b8f4
22 changed files with 421 additions and 73 deletions
+9 -4
View File
@@ -1760,7 +1760,12 @@ function AppInner() {
// `area='chat_composer'` so it's distinguishable from the
// file_manager Upload button and the chat_panel composer.
const cohort = deriveUploadCohort(pendingFiles);
const uploadResult = await uploadProjectFiles(result.project.id, pendingFiles);
const uploadResult = await uploadProjectFiles(
result.project.id,
pendingFiles,
undefined,
workspaceContextRef.current,
);
firstMessageAttachments = uploadResult.uploaded;
const partial = uploadResult.failed.length > 0;
if (partial) {
@@ -2175,7 +2180,7 @@ function AppInner() {
setProjects((curr) =>
curr.map((p) => (p.id === id ? { ...p, name: trimmed } : p)),
);
void patchProject(id, { name: trimmed });
void patchProject(id, { name: trimmed }, workspaceContextRef.current);
}, []);
// The project header back button is an escape hatch back to Home. Avoid
@@ -2199,7 +2204,7 @@ function AppInner() {
p.id === projectId ? { ...p, pendingPrompt: undefined } : p,
),
);
void patchProject(projectId, { pendingPrompt: null });
void patchProject(projectId, { pendingPrompt: null }, workspaceContextRef.current);
}, [route]);
const handleTouchProject = useCallback(() => {
@@ -2209,7 +2214,7 @@ function AppInner() {
setProjects((curr) =>
curr.map((p) => (p.id === projectId ? { ...p, updatedAt } : p)),
);
void patchProject(projectId, { updatedAt });
void patchProject(projectId, { updatedAt }, workspaceContextRef.current);
}, [route]);
const handleProjectChange = useCallback((updated: Project) => {
@@ -12,6 +12,7 @@ import {
} from "../runtime/in-project-link";
import { navigate } from "../router";
import { deleteProjectFile, projectFileUrl, uploadProjectFiles } from "../providers/registry";
import { useWorkspaceContext } from "../collab/useWorkspaceContext";
import { useAnalytics } from "../analytics/provider";
import {
trackAssistantFeedbackButtonClick,
@@ -2626,6 +2627,7 @@ function FormBlock({
}) {
const t = useT();
const analytics = useAnalytics();
const { context: workspaceContext } = useWorkspaceContext();
const formKey =
projectId && conversationId
? `${projectId}:${conversationId}:${assistantMessageId}:${form.id}`
@@ -2842,6 +2844,8 @@ function FormBlock({
const result = await uploadProjectFiles(
projectId,
flatFiles.map((entry) => entry.file),
undefined,
workspaceContext,
).catch((error) => ({
uploaded: [],
failed: flatFiles.map((entry) => ({
+10 -7
View File
@@ -5,6 +5,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -92,6 +93,7 @@ import {
type InlineMentionEntity,
} from '../utils/inlineMentions';
import { workspaceContextLinkedDir, workspaceContextLinkedDirs } from './workspace-context';
import { useWorkspaceContext } from '../collab/useWorkspaceContext';
import {
LexicalComposerInput,
type LexicalComposerInputHandle,
@@ -449,6 +451,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
) {
const { locale, t } = useI18n();
const analytics = useAnalytics();
const { context: workspaceContext } = useWorkspaceContext();
const activeFileContext =
projectMetadata?.importedFrom === 'folder' && activeProjectFileName
? activeProjectFileName
@@ -1345,7 +1348,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
if (changed) {
const metadata: ProjectMetadata = { ...base, linkedDirs: nextLinkedDirs };
const result = await patchProject(projectId, { metadata });
const result = await patchProject(projectId, { metadata }, workspaceContext);
if (!result?.metadata) {
onShowToast?.(t('homeWorkingDir.applyFailed'));
return false;
@@ -1675,7 +1678,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const currentLinkedDirs = base.linkedDirs ?? [...tracked.previousLinkedDirs, tracked.dir];
const nextLinkedDirs = currentLinkedDirs.filter((dir) => dir !== tracked.dir);
const metadata: ProjectMetadata = { ...base, linkedDirs: nextLinkedDirs };
const result = await patchProject(projectId, { metadata });
const result = await patchProject(projectId, { metadata }, workspaceContext);
if (!result?.metadata) {
onShowToast?.(t('homeWorkingDir.applyFailed'));
return false;
@@ -1733,7 +1736,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const cohort = deriveUploadCohort(files);
const orderStart = reserveAttachmentOrders(files.length);
try {
const result = await uploadProjectFiles(id, files);
const result = await uploadProjectFiles(id, files, undefined, workspaceContext);
if (result.uploaded.length > 0) {
const orderedUploaded = assignChatAttachmentOrders(result.uploaded, orderStart);
appendOrderedStagedAttachments(orderedUploaded);
@@ -1885,7 +1888,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
return;
}
setUploading(true);
const result = await uploadProjectFiles(id, annotationFiles);
const result = await uploadProjectFiles(id, annotationFiles, undefined, workspaceContext);
if (result.uploaded.length > 0) {
uploaded = assignChatAttachmentOrders(result.uploaded, orderStart);
}
@@ -2144,7 +2147,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
...base,
linkedDirs: linkedDirsWithWorkspaceContext(dir),
};
const result = await patchProject(projectId, { metadata });
const result = await patchProject(projectId, { metadata }, workspaceContext);
// The daemon rejects stale/inaccessible/system dirs with
// INVALID_LINKED_DIR (patchProject → null). Only commit the selection
// and promote it in recents when the project accepted it; otherwise
@@ -2180,7 +2183,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
...base,
linkedDirs: linkedDirsWithWorkspaceContext(null),
};
const result = await patchProject(projectId, { metadata });
const result = await patchProject(projectId, { metadata }, workspaceContext);
if (result?.metadata) {
setPromotedWorkspaceContextDir(null);
onProjectMetadataChange?.(result);
@@ -2427,7 +2430,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
async function applyProjectSkill(skill: SkillSummary): Promise<boolean> {
if (!projectId) return false;
const result = await patchProject(projectId, { skillId: skill.id });
const result = await patchProject(projectId, { skillId: skill.id }, workspaceContext);
if (!result) return false;
onProjectSkillChange?.(result.skillId ?? skill.id);
return true;
+10 -5
View File
@@ -62,6 +62,7 @@ import { Icon } from './Icon';
import { BoardComposerPopover } from './BoardComposerPopover';
import { PreviewDrawOverlay } from './PreviewDrawOverlay';
import { RemixIcon } from './RemixIcon';
import { useWorkspaceContext } from '../collab/useWorkspaceContext';
type BrowserHistoryEntry = {
iconUrl?: string;
@@ -785,6 +786,7 @@ export function DesignBrowserPanel({
browserTabId,
}: DesignBrowserPanelProps) {
const t = useT();
const { context: workspaceContext } = useWorkspaceContext();
const desktopHostAvailable = isOpenDesignHostAvailable();
const initialState = initialBrowserState(initialUrl, initialTitle);
// `loadUrl` is the navigation target bound to the <webview>/<iframe> `src`.
@@ -1478,6 +1480,7 @@ export function DesignBrowserPanel({
projectId,
browserFileName('browser-capture', currentUrl, 'png'),
base64,
workspaceContext,
);
if (!file) throw new Error(t('designBrowser.status.screenshotFailed'));
await onRefreshFiles();
@@ -1563,6 +1566,8 @@ export function DesignBrowserPanel({
projectId,
browserFileName('browser-brief', currentUrl, 'md'),
pageBriefMarkdown(brief, currentUrl),
undefined,
workspaceContext,
);
if (!file) throw new Error(t('designBrowser.status.briefSaveFailed'));
await onRefreshFiles();
@@ -1620,12 +1625,12 @@ export function DesignBrowserPanel({
const cssFile = `${dir}/styles.css`;
const manifestFile = `${dir}/manifest.json`;
const htmlSaved = await abortablePageSnapshotPromise(
writeProjectTextFile(projectId, htmlFile, capture.html),
writeProjectTextFile(projectId, htmlFile, capture.html, undefined, workspaceContext),
controller.signal,
);
if (!htmlSaved) throw new Error(t('designBrowser.status.pageSnapshotFailed'));
const cssSaved = await abortablePageSnapshotPromise(
writeProjectTextFile(projectId, cssFile, capture.css ?? ''),
writeProjectTextFile(projectId, cssFile, capture.css ?? '', undefined, workspaceContext),
controller.signal,
);
if (!cssSaved) throw new Error(t('designBrowser.status.pageSnapshotFailed'));
@@ -1650,11 +1655,11 @@ export function DesignBrowserPanel({
};
const manifestText = JSON.stringify(manifest, null, 2);
const savedManifest = await abortablePageSnapshotPromise(
writeProjectTextFile(projectId, manifestFile, manifestText),
writeProjectTextFile(projectId, manifestFile, manifestText, undefined, workspaceContext),
controller.signal,
);
const savedIndex = await abortablePageSnapshotPromise(
writeProjectTextFile(projectId, BROWSER_PAGE_ARCHIVE_INDEX_FILE, manifestText),
writeProjectTextFile(projectId, BROWSER_PAGE_ARCHIVE_INDEX_FILE, manifestText, undefined, workspaceContext),
controller.signal,
);
if (!savedManifest || !savedIndex) throw new Error(t('designBrowser.status.pageSnapshotFailed'));
@@ -1954,7 +1959,7 @@ export function DesignBrowserPanel({
setSavingDomEdit(true);
try {
const html = await webviewNode.executeJavaScript<string>(BROWSER_SERIALIZE_HTML_SCRIPT, true);
const file = await writeProjectTextFile(projectId, relativePath, html);
const file = await writeProjectTextFile(projectId, relativePath, html, undefined, workspaceContext);
if (!file) throw new Error(t('designBrowser.status.htmlSaveFailed'));
await onRefreshFiles();
setStatusMessage(t('designBrowser.status.htmlSaved'));
+29 -8
View File
@@ -6,6 +6,7 @@ import type {
ConnectorStatusResponse,
DesignSystemSummary,
LibraryAsset,
WorkspaceCollabContext,
} from '@open-design/contracts';
import { streamViaDaemon } from '../providers/daemon';
import {
@@ -129,6 +130,7 @@ import type {
TrackingDesignSystemsEntryFrom,
} from '@open-design/contracts/analytics';
import { useI18n } from '../i18n';
import { useWorkspaceContext } from '../collab/useWorkspaceContext';
// Source counts the embedded DS creation flow can report back to its
// wrapper at Generate-click time. OnboardingView uses this to emit the
@@ -343,6 +345,7 @@ export function DesignSystemCreationFlow({
designSystems = [],
}: CreationProps) {
const { t } = useI18n();
const { context: workspaceContext } = useWorkspaceContext();
const [step, setStep] = useState<SetupStep>('setup');
// A Library "create design system from selection" hand-off pre-fills the
// source material with the chosen assets (single-shot; cleared on read).
@@ -966,6 +969,7 @@ export function DesignSystemCreationFlow({
state,
composioConfigured,
githubConnector,
workspaceContext,
onProjectPrepared: (preparedProject) => {
projectForCreated = preparedProject;
onProjectPrepared?.(preparedProject);
@@ -1632,6 +1636,7 @@ export function DesignSystemDetailView({
onInitialRevisionJobConsumed,
}: DetailProps) {
const { locale, t } = useI18n();
const { context: workspaceContext } = useWorkspaceContext();
const [system, setSystem] = useState<DesignSystemDetail | null>(null);
const [body, setBody] = useState('');
const [tab, setTab] = useState<ReviewTab>('system');
@@ -2019,7 +2024,7 @@ export function DesignSystemDetailView({
const nextBody = body;
const updated = await savePatch({ body: nextBody });
if (updated && workspaceProjectId) {
await writeProjectTextFile(workspaceProjectId, 'DESIGN.md', nextBody);
await writeProjectTextFile(workspaceProjectId, 'DESIGN.md', nextBody, undefined, workspaceContext);
await refreshWorkspaceProjectFiles(workspaceProjectId);
}
setStatusLine(updated ? t('dsFlow.savedDesignMd') : t('dsFlow.saveChangesFailed'));
@@ -4118,6 +4123,7 @@ async function prepareCreatedDesignSystemProject({
state,
composioConfigured,
githubConnector,
workspaceContext,
onProjectPrepared,
onSystemsRefresh,
analyticsTrack,
@@ -4128,6 +4134,7 @@ async function prepareCreatedDesignSystemProject({
state: SetupState;
composioConfigured: boolean;
githubConnector: ConnectorDetail | null;
workspaceContext?: WorkspaceCollabContext | null;
onProjectPrepared?: (project: Project) => void;
onSystemsRefresh?: () => Promise<void> | void;
analyticsTrack: (
@@ -4162,7 +4169,7 @@ async function prepareCreatedDesignSystemProject({
});
}
const localStart = performance.now();
const stagedLocalCode = await stageLocalCodeFiles(project.id, state.codeFileObjects);
const stagedLocalCode = await stageLocalCodeFiles(project.id, state.codeFileObjects, workspaceContext);
if (state.codeFileObjects.length > 0 || state.codeFolders.length > 0) {
emitSourceIngestResult(analyticsTrack, {
sourceType: 'local_code',
@@ -4214,7 +4221,7 @@ async function prepareCreatedDesignSystemProject({
});
}
const assetStart = performance.now();
const stagedAssets = await stageAssetFiles(project.id, state.assetFileObjects);
const stagedAssets = await stageAssetFiles(project.id, state.assetFileObjects, workspaceContext);
if (state.assetFileObjects.length > 0) {
emitSourceIngestResult(analyticsTrack, {
sourceType: 'assets',
@@ -4249,6 +4256,8 @@ async function prepareCreatedDesignSystemProject({
stagedFigma,
stagedAssets,
}),
undefined,
workspaceContext,
);
const metadata = mergeLinkedCodeFolders(project.metadata, state.codeFolders);
const prompt = buildCreationAgentPrompt(
@@ -4258,7 +4267,11 @@ async function prepareCreatedDesignSystemProject({
stagedAssets,
stagedFigma,
);
const preparedProject = await patchProject(project.id, { pendingPrompt: prompt, metadata });
const preparedProject = await patchProject(
project.id,
{ pendingPrompt: prompt, metadata },
workspaceContext,
);
try {
window.sessionStorage.setItem(`od:auto-send-first:${project.id}`, '1');
} catch {
@@ -5011,13 +5024,17 @@ function mergeLinkedCodeFolders(metadata: ProjectMetadata | undefined, codeFolde
};
}
async function stageLocalCodeFiles(projectId: string, files: File[]): Promise<StagedLocalCodeContext> {
async function stageLocalCodeFiles(
projectId: string,
files: File[],
workspaceContext?: WorkspaceCollabContext | null,
): Promise<StagedLocalCodeContext> {
if (files.length === 0) return { uploadedPaths: [], skippedCount: 0 };
const selected = selectLocalCodeFiles(files);
const uploadedPaths: string[] = [];
for (const file of selected) {
const desiredName = `${LOCAL_CODE_UPLOAD_ROOT}/${localCodeRelativePath(file)}`;
const uploaded = await uploadProjectFile(projectId, file, desiredName);
const uploaded = await uploadProjectFile(projectId, file, desiredName, workspaceContext);
if (uploaded) {
uploadedPaths.push(uploaded.name);
}
@@ -5058,13 +5075,17 @@ async function stageFigmaFiles(projectId: string, files: File[]): Promise<Staged
};
}
async function stageAssetFiles(projectId: string, files: File[]): Promise<StagedAssetContext> {
async function stageAssetFiles(
projectId: string,
files: File[],
workspaceContext?: WorkspaceCollabContext | null,
): Promise<StagedAssetContext> {
if (files.length === 0) return { uploadedPaths: [], skippedCount: 0 };
const selected = selectAssetFiles(files);
const uploadedPaths: string[] = [];
for (const file of selected) {
const desiredName = `${ASSET_UPLOAD_ROOT}/${resourceRelativePath(file)}`;
const uploaded = await uploadProjectFile(projectId, file, desiredName);
const uploaded = await uploadProjectFile(projectId, file, desiredName, workspaceContext);
if (uploaded) {
uploadedPaths.push(uploaded.name);
}
+8 -7
View File
@@ -9559,7 +9559,7 @@ function HtmlViewer({
artifactManifest: file.artifactManifest,
versionSource: 'manual',
versionLabel: label,
});
}, workspaceContext);
if (!saved.ok) {
const status = 'status' in saved ? saved.status : undefined;
const code = 'code' in saved ? saved.code : undefined;
@@ -9680,7 +9680,7 @@ function HtmlViewer({
artifactManifest: file.artifactManifest,
versionSource: 'manual',
versionLabel: `Undo ${latest.label}`,
});
}, workspaceContext);
if (!saved) {
setManualEditError('Could not save the undo result.');
return;
@@ -9717,7 +9717,7 @@ function HtmlViewer({
artifactManifest: file.artifactManifest,
versionSource: 'manual',
versionLabel: `Redo ${latest.label}`,
});
}, workspaceContext);
if (!saved) {
setManualEditError('Could not save the redo result.');
return;
@@ -9852,7 +9852,7 @@ function HtmlViewer({
try {
const saved = await writeProjectTextFile(projectId, file.name, nextSource, {
artifactManifest: file.artifactManifest,
});
}, workspaceContext);
if (!saved) throw new Error('speaker_notes_save_failed');
setSource(nextSource);
sourceRef.current = nextSource;
@@ -11934,7 +11934,7 @@ function HtmlViewer({
: { top: 12, right: 12, width: 320 }}
onFloatingPositionChange={selectedManualEditTarget ? setManualEditPanelPosition : undefined}
onPickImage={async (pickedFile) => {
const result = await uploadProjectFiles(projectId, [pickedFile]);
const result = await uploadProjectFiles(projectId, [pickedFile], undefined, workspaceContext);
const uploaded = result.uploaded[0];
if (!uploaded?.path) {
setManualEditError(result.error ?? t('manualEdit.uploadImageFailed'));
@@ -14985,6 +14985,7 @@ function MarkdownViewer({
viewerOnly?: boolean;
}) {
const { t, locale } = useI18n();
const { context: workspaceContext } = useWorkspaceContext();
const [text, setText] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [downloadMenuOpen, setDownloadMenuOpen] = useState(false);
@@ -15121,7 +15122,7 @@ function MarkdownViewer({
const showSaving = saveOptions.showSaving !== false;
if (showSaving) setSaveState('saving');
try {
const saved = await writeProjectTextFile(projectId, file.name, nextValue);
const saved = await writeProjectTextFile(projectId, file.name, nextValue, undefined, workspaceContext);
if (!saved) throw new Error('write failed');
lastSavedTextRef.current = nextValue;
bumpSavedRevision((n) => n + 1);
@@ -15240,7 +15241,7 @@ function MarkdownViewer({
const images = files.filter((item) => isMarkdownImageFile(item));
if (images.length === 0) return false;
const targetDir = markdownDirectory(file.name);
const result = await uploadProjectFiles(projectId, images, targetDir);
const result = await uploadProjectFiles(projectId, images, targetDir, workspaceContext);
if (result.uploaded.length > 0) {
await onFileSaved?.();
const snippet = result.uploaded
+14 -7
View File
@@ -2136,7 +2136,7 @@ export function FileWorkspace({
const cohort = deriveUploadCohort(picked);
let result: UploadProjectFilesResult;
try {
result = await uploadProjectFiles(projectId, picked, uploadDir);
result = await uploadProjectFiles(projectId, picked, uploadDir, workspaceContext);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
setUploadError(`Upload failed for ${picked.length} file(s) (${detail}).`);
@@ -2491,7 +2491,13 @@ export function FileWorkspace({
async function createMarkdownDocument() {
const target = nextMarkdownDocumentPath(files, uploadDir);
const file = await writeProjectTextFile(projectId, target, initialMarkdownDocument(target, projectKind, t));
const file = await writeProjectTextFile(
projectId,
target,
initialMarkdownDocument(target, projectKind, t),
undefined,
workspaceContext,
);
if (!file) return;
await onRefreshFiles();
await refreshProjectFolders();
@@ -2508,7 +2514,7 @@ export function FileWorkspace({
const file = await writeProjectTextFile(projectId, target, content, {
versionSource: 'manual',
versionPrompt: pagePresetVersionPrompt(preset, t, locale),
});
}, workspaceContext);
if (!file) {
// Never let a failed create read as a silent no-op click.
setLauncherToast(t('workspace.pageCreateFailed'));
@@ -2664,7 +2670,7 @@ export function FileWorkspace({
const startedAt = Date.now();
let result: boolean | undefined;
try {
const file = await writeProjectTextFile(projectId, name, text);
const file = await writeProjectTextFile(projectId, name, text, undefined, workspaceContext);
const elapsed = Date.now() - startedAt;
// Ensures saving UI shows so the button does not flicker
if (showSaving && elapsed < 500) await new Promise((resolve) => setTimeout(resolve, 500 - elapsed));
@@ -2795,7 +2801,7 @@ export function FileWorkspace({
): Promise<{ fileName: string } | false> {
const targetDir = parentDirForProjectFile(sketchName);
const targetName = targetDir ? `${targetDir}/${imageFileName}` : imageFileName;
const file = await writeProjectBase64File(projectId, targetName, base64);
const file = await writeProjectBase64File(projectId, targetName, base64, workspaceContext);
if (!file) {
setUploadError(t('common.exportImageFailed'));
return false;
@@ -4137,6 +4143,7 @@ function DesignSystemProjectPanel({
const { uploading: kitUploading, uploadModule: kitUploadModule } = useKitModuleUpload({
projectId,
title: system.title,
workspaceContext,
onUploaded: (module) => {
setKitActionBusy(`upload:${module}`);
notifyKit('loading', t('ds.uploading'));
@@ -4163,7 +4170,7 @@ function DesignSystemProjectPanel({
async function persistDesignMd(nextBody: string) {
const updated = await updateDesignSystemDraft(system.id, { body: nextBody });
if (!updated) throw new Error(t('ds.actionFailed'));
const file = await writeProjectTextFile(projectId, 'DESIGN.md', nextBody);
const file = await writeProjectTextFile(projectId, 'DESIGN.md', nextBody, undefined, workspaceContext);
if (!file) throw new Error(t('ds.actionFailed'));
setDesignMdBody(nextBody);
await refreshKitDependencies();
@@ -4266,7 +4273,7 @@ function DesignSystemProjectPanel({
setKitActionBusy('color');
notifyKit('loading', t('ds.saving'));
try {
const ok = await updateBrandColor(projectId, index, nextHex);
const ok = await updateBrandColor(projectId, index, nextHex, workspaceContext);
if (!ok) {
const nextBody = designMdBodyWithColor(designMdBody, kit?.colors ?? [], index, nextHex);
await persistDesignMd(nextBody);
+3 -2
View File
@@ -85,7 +85,7 @@ import { consumePendingHomeChip, HOME_CHIP_INTENT_EVENT } from '../runtime/home-
import { navigate } from '../router';
import { setPendingDesignSystemCreateEntry } from '../analytics/ds-create-entry';
import { workspaceContextLinkedDirs } from './workspace-context';
import { useTeamProjects } from '../collab/useWorkspaceContext';
import { useTeamProjects, useWorkspaceContext } from '../collab/useWorkspaceContext';
import {
buildHomeMediaComposer,
homeMediaSurfaceForChipId,
@@ -352,6 +352,7 @@ export function HomeView({
}: Props) {
const { locale, t } = useI18n();
const analytics = useAnalytics();
const { context: workspaceContext } = useWorkspaceContext();
// Team-wide catalog from the resource hub via the daemon; empty off-team / when
// the hub is unconfigured. Only the creator attribution is derived here — the
// shared/not-shared answer arrives as `isSharedProject` from EntryShell, which
@@ -2351,7 +2352,7 @@ export function HomeView({
}}
onImported={(result, projectId) => {
void (async () => {
await patchProject(projectId, { pendingPrompt: result.suggestedPrompt });
await patchProject(projectId, { pendingPrompt: result.suggestedPrompt }, workspaceContext);
setFigmaModalOpen(false);
onOpenProject(projectId);
})();
+10 -8
View File
@@ -223,6 +223,7 @@ import { localizePluginTitle } from './plugins-home/localization';
import { DesignSystemPicker } from './DesignSystemPicker';
import { PresenceBar } from '../collab/PresenceBar';
import { useProjectCollab } from '../collab/useProjectCollab';
import { useWorkspaceContext } from '../collab/useWorkspaceContext';
import { CollabProvider, type CollabContextValue } from '../collab/collab-context';
import { persistCommentAnchors } from '../collab/comment-anchor-client';
import type { AnchorWriteBack } from '../comments';
@@ -1413,6 +1414,7 @@ export function ProjectView({
}: Props) {
const { locale, t } = useI18n();
const analytics = useAnalytics();
const { context: workspaceContext } = useWorkspaceContext();
// Onboarding first-generation funnel (spec §11.1). Consume the pending entry
// (set by the Home recommendation) exactly once on mount; the refs guard the
// two lifecycle events so each fires only for the genuine first send / first
@@ -2586,7 +2588,7 @@ export function ProjectView({
});
const file = await writeProjectTextFile(project.id, fileName, artifactToPersist.html, {
artifactManifest: manifest ?? undefined,
});
}, workspaceContext);
if (file) {
savedArtifactRef.current = file.name;
setFilesRefresh((n) => n + 1);
@@ -3548,7 +3550,7 @@ export function ProjectView({
// re-display the images instead of losing them on echo.
let uploadedAttachments: PreviewCommentAttachment[] | undefined;
if (images.length > 0) {
const result = await uploadProjectFiles(project.id, images);
const result = await uploadProjectFiles(project.id, images, undefined, workspaceContext);
if (result.uploaded.length !== images.length) return null;
uploadedAttachments = result.uploaded.map((file) => ({ path: file.path, name: file.name }));
}
@@ -5294,7 +5296,7 @@ export function ProjectView({
void patchProject(project.id, {
name: projectName,
...(metadata ? { metadata } : {}),
});
}, workspaceContext);
}
}
const canReplaceConversationTitle = (title: string | null | undefined) => {
@@ -5340,7 +5342,7 @@ export function ProjectView({
void patchProject(project.id, {
name: agentTitle,
...(metadata ? { metadata } : {}),
});
}, workspaceContext);
}
};
@@ -6677,7 +6679,7 @@ export function ProjectView({
// along the first task rather than being duplicated across every note.
let uploaded: ChatAttachment[] = [];
if (images.length > 0) {
const result = await uploadProjectFiles(project.id, images);
const result = await uploadProjectFiles(project.id, images, undefined, workspaceContext);
uploaded = result.uploaded;
}
if (commentAttachments.length === 0) {
@@ -7035,7 +7037,7 @@ export function ProjectView({
},
};
onProjectChange({ ...project, metadata });
void patchProject(project.id, { metadata });
void patchProject(project.id, { metadata }, workspaceContext);
}, [onProjectChange, project]);
const sendDesignSystemFeedback = useCallback((
@@ -7389,7 +7391,7 @@ export function ProjectView({
void patchProject(project.id, {
name: trimmed,
...(metadata ? { metadata } : {}),
});
}, workspaceContext);
},
[project, onProjectChange],
);
@@ -7509,7 +7511,7 @@ export function ProjectView({
updatedAt: Date.now(),
};
onProjectChange(updated);
void patchProject(project.id, { designSystemId: nextId });
void patchProject(project.id, { designSystemId: nextId }, workspaceContext);
},
[project, projectDesignSystemId, onProjectChange, designSystems, analytics.track],
);
+20 -4
View File
@@ -2083,8 +2083,9 @@ export async function writeProjectTextFile(
versionLabel?: string;
versionPrompt?: string | null;
},
workspaceContext?: WorkspaceCollabContext | null,
): Promise<ProjectFile | null> {
const result = await writeProjectTextFileDetailed(projectId, name, content, options);
const result = await writeProjectTextFileDetailed(projectId, name, content, options, workspaceContext);
return result.ok ? result.file : null;
}
@@ -2102,11 +2103,15 @@ export async function writeProjectTextFileDetailed(
versionLabel?: string;
versionPrompt?: string | null;
},
workspaceContext?: WorkspaceCollabContext | null,
): Promise<WriteProjectTextFileResult> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({
name,
content,
@@ -2136,11 +2141,15 @@ export async function writeProjectBase64File(
projectId: string,
name: string,
base64: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<ProjectFile | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ name, content: base64, encoding: 'base64' }),
});
if (!resp.ok) return null;
@@ -2155,6 +2164,7 @@ export async function uploadProjectFile(
projectId: string,
file: File,
desiredName?: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<ProjectFile | null> {
try {
const form = new FormData();
@@ -2162,6 +2172,7 @@ export async function uploadProjectFile(
if (desiredName) form.append('name', desiredName);
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files`, {
method: 'POST',
...(workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : {}),
body: form,
});
if (!resp.ok) return null;
@@ -2230,6 +2241,7 @@ export async function uploadProjectFiles(
projectId: string,
files: File[],
dir?: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<UploadProjectFilesResult> {
if (files.length === 0) return { uploaded: [], failed: [] };
@@ -2251,7 +2263,11 @@ export async function uploadProjectFiles(
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/upload`,
{ method: 'POST', body: form },
{
method: 'POST',
...(workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : {}),
body: form,
},
);
if (!resp.ok) {
+33 -10
View File
@@ -17,16 +17,30 @@ async function readBrand(projectId: string): Promise<Brand | null> {
}
}
async function writeBrand(projectId: string, brand: Brand): Promise<boolean> {
const file = await writeProjectTextFile(projectId, 'brand.json', JSON.stringify(brand, null, 2));
async function writeBrand(
projectId: string,
brand: Brand,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
const file = await writeProjectTextFile(
projectId,
'brand.json',
JSON.stringify(brand, null, 2),
undefined,
workspaceContext,
);
return Boolean(file);
}
export async function patchBrand(projectId: string, mutate: (brand: Brand) => void): Promise<boolean> {
export async function patchBrand(
projectId: string,
mutate: (brand: Brand) => void,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
const brand = await readBrand(projectId);
if (!brand) return false;
mutate(brand);
return writeBrand(projectId, brand);
return writeBrand(projectId, brand, workspaceContext);
}
type EditableBrand = Brand & { seed?: Record<string, unknown> };
@@ -73,7 +87,12 @@ function syncSeedForColor(brand: EditableBrand, role: BrandColorRole, hex: strin
brand.seed = seed;
}
export async function updateBrandColor(projectId: string, index: number, hex: string): Promise<boolean> {
export async function updateBrandColor(
projectId: string,
index: number,
hex: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
const nextHex = normalizeHex(hex);
if (!nextHex) return false;
const brand = await readBrand(projectId);
@@ -81,7 +100,7 @@ export async function updateBrandColor(projectId: string, index: number, hex: st
if (!brand || !color) return false;
color.hex = nextHex;
syncSeedForColor(brand as EditableBrand, color.role, nextHex);
return writeBrand(projectId, brand);
return writeBrand(projectId, brand, workspaceContext);
}
export function replaceDesignMdColorAtIndex(body: string, index: number, hex: string): string | null {
@@ -122,7 +141,7 @@ export async function deleteBrandLogo(
}
fileToDelete = relativeProjectAssetPath(alternates[index - 1]);
logo.alternates = alternates.filter((_, i) => i !== index - 1);
});
}, workspaceContext);
if (ok && fileToDelete) await deleteProjectFile(projectId, fileToDelete, workspaceContext);
return ok;
}
@@ -137,7 +156,7 @@ export async function deleteBrandImage(
if (!brand.imagery?.samples) return;
fileToDelete = relativeProjectAssetPath(brand.imagery.samples[index]?.file);
brand.imagery.samples = brand.imagery.samples.filter((_, i) => i !== index);
});
}, workspaceContext);
if (ok && fileToDelete) await deleteProjectFile(projectId, fileToDelete, workspaceContext);
return ok;
}
@@ -146,8 +165,12 @@ export async function readDesignMd(projectId: string): Promise<string> {
return (await fetchProjectFileText(projectId, 'DESIGN.md', { cache: 'no-store' })) ?? '';
}
export async function writeDesignMd(projectId: string, body: string): Promise<boolean> {
const file = await writeProjectTextFile(projectId, 'DESIGN.md', body);
export async function writeDesignMd(
projectId: string,
body: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
const file = await writeProjectTextFile(projectId, 'DESIGN.md', body, undefined, workspaceContext);
return Boolean(file);
}
+19 -6
View File
@@ -7,7 +7,7 @@
// daemon endpoint is required.
import { useCallback, useState } from 'react';
import type { Brand } from '@open-design/contracts';
import type { Brand, WorkspaceCollabContext } from '@open-design/contracts';
import {
fetchProjectFileText,
uploadProjectFile,
@@ -24,12 +24,13 @@ export interface KitModuleUpload {
export function useKitModuleUpload(opts: {
projectId?: string;
title?: string;
workspaceContext?: WorkspaceCollabContext | null;
onUploaded?: (module: KitUploadModule) => void;
/** Called when the upload or the brand.json write fails, so the host can
* surface a visible error instead of the action silently no-op'ing. */
onError?: (module: KitUploadModule, message: string) => void;
}): KitModuleUpload {
const { projectId, title, onUploaded, onError } = opts;
const { projectId, title, workspaceContext, onUploaded, onError } = opts;
const [uploading, setUploading] = useState<KitUploadModule | null>(null);
const uploadModule = useCallback(
@@ -41,7 +42,7 @@ export function useKitModuleUpload(opts: {
const safe =
file.name.replace(/[^\w.\-]+/g, '-').replace(/^-+|-+$/g, '') || `${module}-asset`;
const path = `${dir}/${safe}`;
const uploaded = await uploadProjectFile(projectId, file, path);
const uploaded = await uploadProjectFile(projectId, file, path, workspaceContext);
if (!uploaded) {
onError?.(module, 'upload-failed');
return;
@@ -70,7 +71,13 @@ export function useKitModuleUpload(opts: {
brand.typography.display = spec;
brand.typography.body = spec;
}
const wrote = await writeProjectTextFile(projectId, 'brand.json', JSON.stringify(brand, null, 2));
const wrote = await writeProjectTextFile(
projectId,
'brand.json',
JSON.stringify(brand, null, 2),
undefined,
workspaceContext,
);
if (!wrote) {
onError?.(module, 'write-failed');
return;
@@ -87,7 +94,13 @@ export function useKitModuleUpload(opts: {
file: storedBase,
format: fontFormat(storedBase),
});
await writeProjectTextFile(projectId, 'fonts/manifest.json', JSON.stringify(manifest, null, 2));
await writeProjectTextFile(
projectId,
'fonts/manifest.json',
JSON.stringify(manifest, null, 2),
undefined,
workspaceContext,
);
}
onUploaded?.(module);
} catch {
@@ -96,7 +109,7 @@ export function useKitModuleUpload(opts: {
setUploading(null);
}
},
[projectId, title, uploading, onUploaded, onError],
[projectId, title, workspaceContext, uploading, onUploaded, onError],
);
return { uploading, uploadModule };
+5 -1
View File
@@ -439,11 +439,15 @@ type ProjectPatch = Omit<Partial<Project>, 'pendingPrompt' | 'customInstructions
export async function patchProject(
id: string,
patch: ProjectPatch,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<Project | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify(patch),
});
if (!resp.ok) return null;
@@ -255,7 +255,7 @@ describe('ChatComposer /search command', () => {
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(mockedUploadProjectFiles).toHaveBeenCalledWith('project-1', [
expect.objectContaining({ name: 'drawing.png', type: 'image/png' }),
]);
], undefined, null);
expect(onSend).toHaveBeenCalledWith(
'please update this spot',
[{ path: 'uploads/drawing.png', name: 'drawing.png', kind: 'image', order: 0 }],
@@ -358,7 +358,13 @@ describe('DesignBrowserPanel <webview> navigation', () => {
});
// The listed image is never fetched, and the manifest records no resources.
expect(fetchMock).not.toHaveBeenCalled();
// (`/api/workspace/context` is the unrelated workspace-identity read every
// mounted DesignBrowserPanel now fires via useWorkspaceContext — filtered
// out here since it isn't a page asset.)
const assetFetchCalls = fetchMock.mock.calls.filter(
([url]) => url !== '/api/workspace/context',
);
expect(assetFetchCalls).toEqual([]);
expect(writes.find((w) => w.name.endsWith('/page.html'))?.content).toContain('Example');
expect(writes.find((w) => w.name.endsWith('/styles.css'))?.content).toContain('#111');
const manifestWrite = writes.find((w) => w.name.endsWith('/manifest.json'));
@@ -1345,27 +1345,35 @@ describe('DesignSystemCreationFlow', () => {
}),
pendingPrompt: expect.stringContaining('Read the linked local code folders'),
}),
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('tools connectors local-design-context --path'),
}),
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('/Users/qingyu/work/comfyui'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('## Local Folder Intake Runbook'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('tools connectors local-design-context --path'),
undefined,
null,
);
});
@@ -1426,17 +1434,21 @@ describe('DesignSystemCreationFlow', () => {
project.id,
tokenFile,
'context/local-code/comfyui/src/tokens.css',
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('context/local-code/comfyui/src/tokens.css'),
}),
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('context/local-code/comfyui/src/tokens.css'),
undefined,
null,
);
expect(window.sessionStorage.getItem(`od:auto-send-first:${project.id}`)).toBe('1');
expect(onCreated).toHaveBeenCalledWith(
@@ -1708,16 +1720,20 @@ describe('DesignSystemCreationFlow', () => {
project.id,
tokenFile,
'context/local-code/comfyui/src/tokens.css',
null,
);
expect(mocks.uploadProjectFile).toHaveBeenCalledWith(
project.id,
buttonFile,
'context/local-code/comfyui/src/Button.tsx',
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('context/local-code/comfyui/src/Button.tsx'),
undefined,
null,
);
});
@@ -1783,12 +1799,15 @@ describe('DesignSystemCreationFlow', () => {
project.id,
'context/source-context.md',
expect.stringContaining('figma/DESIGN-context.md'),
undefined,
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('Each .fig was decoded into a real design snapshot'),
}),
null,
);
expect(mocks.uploadProjectFile).not.toHaveBeenCalled();
});
@@ -1848,18 +1867,21 @@ describe('DesignSystemCreationFlow', () => {
confirmExtraction();
await waitFor(() => expect(mocks.uploadProjectFile).toHaveBeenCalledTimes(2));
expect(mocks.uploadProjectFile).toHaveBeenCalledWith(project.id, logoFile, 'assets/logo.svg');
expect(mocks.uploadProjectFile).toHaveBeenCalledWith(project.id, fontFile, 'assets/brand.woff2');
expect(mocks.uploadProjectFile).toHaveBeenCalledWith(project.id, logoFile, 'assets/logo.svg', null);
expect(mocks.uploadProjectFile).toHaveBeenCalledWith(project.id, fontFile, 'assets/brand.woff2', null);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('assets/logo.svg'),
undefined,
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('Use uploaded brand assets in `assets/`'),
}),
null,
);
});
@@ -2311,21 +2333,29 @@ describe('DesignSystemCreationFlow', () => {
project.id,
'context/source-context.md',
expect.stringContaining('Connector status: connected as qiongyu1999.'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('https://github.com/nexu-io/open-design'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('GitHub Connector Intake Runbook'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('"$OD_NODE_BIN" "$OD_BIN" tools connectors github-design-context --repo \'https://github.com/nexu-io/open-design\' --output context/github/nexu-io-open-design.md'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).not.toHaveBeenCalledWith(
project.id,
@@ -2337,71 +2367,91 @@ describe('DesignSystemCreationFlow', () => {
expect.objectContaining({
pendingPrompt: expect.stringContaining('GitHub repository intake is required before drafting the design system'),
}),
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('Do not call GitHub connector tree/content/raw tools directly from the agent.'),
}),
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('The command tries this-device access first'),
}),
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('GitHub evidence must come from the bounded `github-design-context` command'),
undefined,
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('Do not call GitHub connector tree/content/raw tools directly from the agent.'),
}),
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('Treat `Read method: git-clone` as the preferred this-device path.'),
}),
null,
);
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('selects design-system-relevant source files plus available logos/icons/fonts'),
}),
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('assets/, build/, fonts/, and context/ should preserve logos'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('Claude-style build asset contract:'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('copy representative runtime assets there with their original filenames'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('Copy those runtime assets byte-for-byte from the captured `context/.../files/...` snapshots.'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('Do not satisfy build/runtime icon evidence by only renaming those files into `assets/`'),
undefined,
null,
);
expect(mocks.writeProjectTextFile).toHaveBeenCalledWith(
project.id,
'context/source-context.md',
expect.stringContaining('preview/brand-assets.html should visibly reference preserved files'),
undefined,
null,
);
});
@@ -294,6 +294,8 @@ describe('FileWorkspace design-system project surface', () => {
'ds-acme',
'DESIGN.md',
expect.stringContaining('`#FF6A3D`'),
undefined,
null,
));
await flushKit();
@@ -316,6 +318,8 @@ describe('FileWorkspace design-system project surface', () => {
'ds-acme',
'DESIGN.md',
expect.stringContaining('`#10B981`'),
undefined,
null,
));
});
@@ -368,6 +372,12 @@ describe('FileWorkspace design-system project surface', () => {
headers: { 'Content-Type': 'application/json' },
});
}
if (url === '/api/workspace/context') {
return new Response(JSON.stringify({ context: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
events.push(url);
return new Response(JSON.stringify({ id: 'brand-acme' }), {
status: 200,
@@ -424,6 +434,8 @@ describe('FileWorkspace design-system project surface', () => {
'ds-acme',
'brand.json',
expect.stringContaining('"hex": "#FF6A3D"'),
undefined,
null,
));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/brands/brand-acme/finalize',
@@ -493,6 +505,12 @@ describe('FileWorkspace design-system project surface', () => {
headers: { 'Content-Type': 'application/json' },
});
}
if (url === '/api/workspace/context') {
return new Response(JSON.stringify({ context: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
events.push(url);
return new Response(JSON.stringify({ id: 'brand-acme' }), {
status: 200,
@@ -536,6 +554,8 @@ describe('FileWorkspace design-system project surface', () => {
'ds-acme',
'brand.json',
expect.not.stringContaining('imagery/hero.png'),
undefined,
null,
));
expect(registryMocks.deleteProjectFile).toHaveBeenCalledWith('ds-acme', 'imagery/hero.png');
expect(fetchMock).toHaveBeenCalledWith(
@@ -587,6 +607,12 @@ describe('FileWorkspace design-system project surface', () => {
headers: { 'Content-Type': 'application/json' },
});
}
if (url === '/api/workspace/context') {
return new Response(JSON.stringify({ context: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
events.push(url);
if (url === '/api/brands/brand-acme/finalize') {
return new Response(JSON.stringify({ id: 'brand-acme' }), {
@@ -4178,6 +4178,7 @@ describe('ProjectView daemon cleanup', () => {
expect.objectContaining({
artifactManifest: expect.objectContaining({ entry: 'theme.css' }),
}),
null,
);
});
@@ -4276,6 +4277,7 @@ describe('ProjectView daemon cleanup', () => {
expect.objectContaining({
artifactManifest: expect.objectContaining({ entry: 'real-daemon-smoke.html' }),
}),
null,
);
expect(saveTabs).not.toHaveBeenCalledWith('project-1', expect.objectContaining({ active: 'index.html' }));
});
@@ -1730,6 +1730,7 @@ describe('ProjectView conversation run isolation', () => {
name: 'Hello From B',
metadata: expect.objectContaining({ nameSource: 'prompt' }),
}),
null,
),
);
});
@@ -1781,6 +1782,7 @@ describe('ProjectView conversation run isolation', () => {
name: 'Agent Title',
metadata: expect.objectContaining({ nameSource: 'agent' }),
}),
null,
),
);
});
@@ -179,6 +179,8 @@ describe('FileViewer markdown code block copy', () => {
'project-1',
'notes.md',
'changed before close',
undefined,
null,
);
});
});
@@ -235,6 +237,8 @@ describe('FileViewer markdown code block copy', () => {
'project-1',
'notes.md',
'initial draft',
undefined,
null,
);
expect(onFileSaved).not.toHaveBeenCalled();
expect(screen.queryByText('Saving...')).toBeNull();
@@ -281,6 +285,8 @@ describe('FileViewer markdown code block copy', () => {
'project-1',
'document.md',
'# Document\n\nDraft',
undefined,
null,
);
expect(onFileSaved).not.toHaveBeenCalled();
expect(screen.getByRole('textbox')).toBe(editor);
@@ -311,6 +317,8 @@ describe('FileViewer markdown code block copy', () => {
'project-1',
'notes.md',
'initial draft',
undefined,
null,
);
await act(async () => {
+96
View File
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { installMockOpenDesignHost } from '@open-design/host/testing';
import {
buildWorkspacePermissions,
buildWorkspaceSeatSummary,
type WorkspaceCollabContext,
} from '@open-design/contracts';
import {
cancelConnectorAuthorization,
@@ -25,6 +30,22 @@ import {
writeProjectTextFileDetailed,
} from '../../src/providers/registry';
function personalWorkspaceContext(): WorkspaceCollabContext {
return {
workspaceId: 'ws-personal',
workspaceType: 'personal',
workspaceMemberId: 'wm-1',
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
billingState: 'active',
planId: null,
providerMode: 'platform_credits',
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }),
permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }),
};
}
function agentStreamResponse(text: string): Response {
const encoder = new TextEncoder();
return new Response(
@@ -152,6 +173,47 @@ describe('writeProjectTextFileDetailed', () => {
message: 'new artifact is smaller than the prior version',
});
});
it('attaches workspace identity headers when a workspace context is passed', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ file: { name: 'preview.html', path: 'preview.html', size: 0, mtime: 0 } }),
{ status: 200 },
));
vi.stubGlobal('fetch', fetchMock);
await writeProjectTextFileDetailed(
'project-1',
'preview.html',
'<html></html>',
undefined,
personalWorkspaceContext(),
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects/project-1/files',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-od-workspace-id': 'ws-personal',
'x-od-workspace-member-id': 'wm-1',
}),
}),
);
});
it('omits workspace headers when there is no workspace context (legacy local mode)', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ file: { name: 'preview.html', path: 'preview.html', size: 0, mtime: 0 } }),
{ status: 200 },
));
vi.stubGlobal('fetch', fetchMock);
await writeProjectTextFileDetailed('project-1', 'preview.html', '<html></html>');
const [, init] = fetchMock.mock.calls[0]! as [string, RequestInit];
expect(init.headers).toEqual({ 'Content-Type': 'application/json' });
});
});
describe('openFolderDialog', () => {
@@ -834,6 +896,40 @@ describe('uploadProjectFiles', () => {
expect(result.failed).toHaveLength(1);
expect(result.failed[0]).toMatchObject({ name: 'c.txt' });
});
it('attaches workspace identity headers when a workspace context is passed', async () => {
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
const fetchMock = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({
files: [{ name: 'hello.txt', path: 'hello.txt', size: 5, originalName: 'hello.txt' }],
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await uploadProjectFiles('project-1', [file], undefined, personalWorkspaceContext());
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects/project-1/upload',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'x-od-workspace-id': 'ws-personal',
'x-od-workspace-member-id': 'wm-1',
}),
}),
);
});
it('omits workspace headers when there is no workspace context (legacy local mode)', async () => {
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
const fetchMock = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({
files: [{ name: 'hello.txt', path: 'hello.txt', size: 5, originalName: 'hello.txt' }],
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await uploadProjectFiles('project-1', [file]);
const [, init] = fetchMock.mock.calls[0]! as [string, RequestInit];
expect(init.headers).toBeUndefined();
});
});
describe('deploy provider registry helpers', () => {
+53
View File
@@ -12,6 +12,7 @@ import {
installGeneratedPluginFolder,
listProjects,
listPlugins,
patchProject,
pickLocalFolderPath,
publishGeneratedPluginToGitHub,
} from '../../src/state/projects';
@@ -258,6 +259,58 @@ describe('duplicateProject', () => {
});
});
// Same enforceWorkspaceProjectMutation bypass as deleteProject/duplicateProject:
// a rename, metadata patch, or pendingPrompt clear sent no workspace headers,
// so a read-only team member could still push a PATCH through.
describe('patchProject', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('attaches workspace identity headers so the daemon can enforce ownership', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ id: 'leaked-team-project', name: 'Renamed' }),
{ status: 200 },
));
vi.stubGlobal('fetch', fetchMock);
await patchProject('leaked-team-project', { name: 'Renamed' }, personalWorkspaceContext());
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects/leaked-team-project',
expect.objectContaining({
method: 'PATCH',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-od-workspace-id': 'ws-personal',
'x-od-workspace-member-id': 'wm-1',
}),
}),
);
});
it('omits workspace headers when there is no workspace context (legacy local mode)', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ id: 'local-only-project', name: 'Renamed' }),
{ status: 200 },
));
vi.stubGlobal('fetch', fetchMock);
await patchProject('local-only-project', { name: 'Renamed' });
const [, init] = fetchMock.mock.calls[0]! as [string, RequestInit];
expect(init.headers).toEqual({ 'Content-Type': 'application/json' });
});
it('reports failure when the daemon refuses the patch', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(null, { status: 403 })));
await expect(
patchProject('someone-elses-project', { name: 'Renamed' }, personalWorkspaceContext()),
).resolves.toBeNull();
});
});
describe('createDesignSystemProjectFromProject', () => {
afterEach(() => {
vi.unstubAllGlobals();