Attach workspace identity headers to publish-public, restore, delete, and rename requests

Same enforceWorkspaceProjectMutation / canShareProjectsForRequest bypass
as the earlier duplicate/design-system-copy/delete fixes: publishing a
file publicly, restoring a file version, deleting a file (including
brand logo/image cleanup), and renaming a file all sent no workspace
headers, so the daemon fell back to a headerless permission read
(default-deny for sharing, default-allow for the ownership gate)
instead of the caller's real identity.
This commit is contained in:
lefarcen
2026-07-22 17:42:48 +08:00
parent 95a938f8eb
commit 7a2b89941b
4 changed files with 59 additions and 16 deletions
+7 -1
View File
@@ -2864,6 +2864,7 @@ function FileVersionManagerModal({
}) {
const { locale, t } = useI18n();
const analytics = useAnalytics();
const { context: workspaceContext } = useWorkspaceContext();
const tRef = useRef(t);
const [versions, setVersions] = useState<ProjectFileVersion[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -3356,7 +3357,12 @@ function FileVersionManagerModal({
});
};
try {
const result = await restoreProjectFileVersion(projectId, file.name, selectedVersion);
const result = await restoreProjectFileVersion(
projectId,
file.name,
selectedVersion,
workspaceContext,
);
if (!result) {
fireRestoreResult('failed', 'restore_request_failed');
setError(t('fileViewer.versions.restoreFailed'));
+6 -5
View File
@@ -2340,7 +2340,7 @@ export function FileWorkspace({
async function handleDelete(name: string) {
if (viewerOnly) return; // read-only viewer of a team-shared project
if (!confirm(t('workspace.deleteFileConfirm', { name }))) return;
const ok = await deleteProjectFile(projectId, name);
const ok = await deleteProjectFile(projectId, name, workspaceContext);
if (ok) {
await onRefreshFiles();
const nextTabs = persistedTabs.filter((n) => n !== name);
@@ -2376,7 +2376,7 @@ export function FileWorkspace({
const deleted: string[] = [];
const failed: string[] = [];
for (const name of names) {
const ok = await deleteProjectFile(projectId, name);
const ok = await deleteProjectFile(projectId, name, workspaceContext);
if (ok) deleted.push(name);
else failed.push(name);
}
@@ -2419,7 +2419,7 @@ export function FileWorkspace({
);
}
const result = await renameProjectFile(projectId, oldName, nextName);
const result = await renameProjectFile(projectId, oldName, nextName, workspaceContext);
const renamed = result.file;
await onRefreshFiles();
await refreshProjectFolders();
@@ -4044,6 +4044,7 @@ function DesignSystemProjectPanel({
}) {
const t = useT();
const analytics = useAnalytics();
const { context: workspaceContext } = useWorkspaceContext();
const [reviewDecisions, setReviewDecisions] = useState<Record<string, DesignSystemReviewDecision>>({});
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [feedbackSection, setFeedbackSection] = useState<string | null>(null);
@@ -4297,7 +4298,7 @@ function DesignSystemProjectPanel({
setKitActionBusy(`delete-logo:${index}`);
notifyKitLoading(t('ds.deleteLogo'));
try {
const ok = await deleteBrandLogo(projectId, index);
const ok = await deleteBrandLogo(projectId, index, workspaceContext);
if (!ok) throw new Error(t('ds.actionFailed'));
await refreshKitDependencies({ finalizeBrand: true });
notifyKit('success', t('ds.actionDone'));
@@ -4313,7 +4314,7 @@ function DesignSystemProjectPanel({
setKitActionBusy(`delete-image:${index}`);
notifyKitLoading(t('ds.deleteImage', { caption: '' }).trim());
try {
const ok = await deleteBrandImage(projectId, index);
const ok = await deleteBrandImage(projectId, index, workspaceContext);
if (!ok) throw new Error(t('ds.actionFailed'));
await refreshKitDependencies({ finalizeBrand: true });
notifyKit('success', t('ds.actionDone'));
+33 -5
View File
@@ -23,6 +23,7 @@ import type {
RestoreProjectFileVersionResponse,
SocialShareRequest,
SocialShareResponse,
WorkspaceCollabContext,
} from '@open-design/contracts';
import type {
AgentInfo,
@@ -77,6 +78,7 @@ import {
openHostExternalUrl,
} from '@open-design/host';
import { coalescedGet } from '../lib/coalesced-get';
import { workspaceProjectHeaders } from '../state/projects';
export const DEFAULT_DEPLOY_PROVIDER_ID = 'vercel-self';
export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages';
@@ -1427,10 +1429,18 @@ export async function deployProjectFile(
export async function publishProjectFilePublic(
projectId: string,
fileName: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<WebPublicProjectFileResponse> {
// Carry the active workspace identity so the daemon's `canShareProjectsForRequest`
// gate (apps/daemon/src/routes/collab-sync.ts) reads the real permission bit
// instead of falling back to a headerless context read — see
// workspaceProjectHeaders' call sites in state/projects.ts for the same pattern.
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileName)}/publish-public`,
{ method: 'POST' },
{
method: 'POST',
...(workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : {}),
},
);
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
@@ -1450,9 +1460,11 @@ export async function publishProjectFilePublic(
export async function fetchProjectFilePublicPublication(
projectId: string,
fileName: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<WebPublicProjectFileResponse | null> {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileName)}/publish-public`,
workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : undefined,
);
if (!resp.ok) {
const payload = (await resp.json().catch(() => null)) as
@@ -1474,12 +1486,16 @@ export async function unpublishProjectFilePublic(
projectId: string,
fileName: string,
slug: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<{ ok: true; slug: string; fileName: string }> {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileName)}/publish-public`,
{
method: 'DELETE',
headers: { 'content-type': 'application/json' },
headers: {
'content-type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ slug }),
},
);
@@ -1959,13 +1975,17 @@ export async function restoreProjectFileVersion(
projectId: string,
name: string,
version: Pick<ProjectFileVersion, 'id'>,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<RestoreProjectFileVersionResponse | null> {
try {
const resp = await fetch(
`${projectFileVersionsUrl(projectId, name)}/${encodeURIComponent(version.id)}/restore`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({}),
},
);
@@ -2309,11 +2329,15 @@ function looksLikeImage(name: string): boolean {
export async function deleteProjectFile(
projectId: string,
name: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
try {
const resp = await fetch(
projectRawUrl(projectId, name),
{ method: 'DELETE' },
{
method: 'DELETE',
...(workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : {}),
},
);
return resp.ok;
} catch {
@@ -2325,10 +2349,14 @@ export async function renameProjectFile(
projectId: string,
from: string,
to: string,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<RenameProjectFileResponse> {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}),
},
body: JSON.stringify({ from, to }),
});
if (!resp.ok) {
+13 -5
View File
@@ -1,4 +1,4 @@
import type { Brand, BrandColorRole } from '@open-design/contracts';
import type { Brand, BrandColorRole, WorkspaceCollabContext } from '@open-design/contracts';
import {
deleteProjectFile,
fetchProjectFileText,
@@ -104,7 +104,11 @@ export function replaceDesignMdColorAtIndex(body: string, index: number, hex: st
return null;
}
export async function deleteBrandLogo(projectId: string, index: number): Promise<boolean> {
export async function deleteBrandLogo(
projectId: string,
index: number,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
let fileToDelete: string | null = null;
const ok = await patchBrand(projectId, (brand) => {
const logo = brand.logo;
@@ -119,18 +123,22 @@ export async function deleteBrandLogo(projectId: string, index: number): Promise
fileToDelete = relativeProjectAssetPath(alternates[index - 1]);
logo.alternates = alternates.filter((_, i) => i !== index - 1);
});
if (ok && fileToDelete) await deleteProjectFile(projectId, fileToDelete);
if (ok && fileToDelete) await deleteProjectFile(projectId, fileToDelete, workspaceContext);
return ok;
}
export async function deleteBrandImage(projectId: string, index: number): Promise<boolean> {
export async function deleteBrandImage(
projectId: string,
index: number,
workspaceContext?: WorkspaceCollabContext | null,
): Promise<boolean> {
let fileToDelete: string | null = null;
const ok = await patchBrand(projectId, (brand) => {
if (!brand.imagery?.samples) return;
fileToDelete = relativeProjectAssetPath(brand.imagery.samples[index]?.file);
brand.imagery.samples = brand.imagery.samples.filter((_, i) => i !== index);
});
if (ok && fileToDelete) await deleteProjectFile(projectId, fileToDelete);
if (ok && fileToDelete) await deleteProjectFile(projectId, fileToDelete, workspaceContext);
return ok;
}