mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-28 13:33:03 +08:00
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:
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user