Skip to content

Commit 3d2e8fd

Browse files
fix(app-shell): Studio opens no buffer over a dashboard draft the server refuses to read (#29132)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbBQ4XUR5tGZokvWQhsUpFrY)_ Fixes #29015 Clause-②: no Metadata-protocol: none The two lines are copied from the claim (comment 6571613254) and its amendment (comment 6571673304). ## 维护者速读 - 改了什么:Studio 读草稿时,服务器因受众门(`requiredPermissions`)回 403 的草稿,不再被当成"没有草稿"。Interfaces 里这个项不再打开已发布版本供编辑,改为显示一条拒绝说明,自动保存因此没有可发送的内容。 - 为什么改:之前作者打开一个自己无权读取的仪表盘草稿时,Studio 显示已发布版本,下一次自动保存把它当草稿无前置条件写回,草稿丢掉权限门和其他未发布改动,发布后仪表盘对所有人可见(18.3.0 挡版项)。 - 风险与回滚:只影响草稿读取返回 403 的项;无草稿、可读草稿、其他读取失败的路径不变。回滚即撤销本 PR。 - 先看:`StudioDesignSurface.tsx` 叶子加载处新增的 `'refused' in draftRead` 分支;`DraftConflictDialog.tsx` 的 `readDraftForSave` 与 `isDraftReadRefused`;新测试 `StudioDesignSurface.draftUnreadable-29015.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.draftUnreadable-29015.test.tsx`,预期 2 个用例通过。 - 需要你做的:无,席位落地。 ## What changes - `readDraftForSave` (`metadata-admin/DraftConflictDialog.tsx`) keeps its answer and pin for every outcome, and now also hands a draft read refused `403` back as `refused`. `isDraftReadRefused` tells that refusal apart by status. A `403` on the draft read means the row is there and withheld (the read gate's audience refusal, `PERMISSION_DENIED`, by design), so it is no absence. Any other failure is handled exactly as before: no answer, no pin, no `refused`. - The Interfaces pillar's leaf load (`StudioDesignSurface.tsx`) installs no buffer when the draft read carries `refused`. It does not read the published layer to stand in for the draft, so the canvas and inspector show nothing to edit and `useDraftAutoSave` has nothing to send. The refusal strip says the item has an unpublished draft this author may not read, that editing the published version would overwrite it, and that someone who holds the draft's required permissions can edit or publish it. The server's sentence is behind Details. - One new Studio string, `engine.studio.refusal.draftUnreadable`, in the studio namespace tables (`en`, `zh`). - `packages/rest/src/meta-item-read-gate.ts` is unchanged: the server's `403` is the designed answer, and the defect was the client reading it as "no draft". ## Tests - New `StudioDesignSurface.draftUnreadable-29015.test.tsx`, over a server double whose draft read refuses the gated dashboard draft the way the client throws it (`status: 403`, `code: PERMISSION_DENIED`): - the refused leaf shows the strip with the server sentence in it, renders no canvas and no edit control, never reads `/layers` for the leaf, and after the autosave window has sent no save; the stored draft still carries `requiredPermissions`; - control: with no pending draft the published body opens and an edit saves pinned `If-None-Match: *`. - `DraftConflictDialog.test.tsx`: a `403` read returns `{ answer: null, pin: null, refused }`; a network failure carries no `refused`; `isDraftReadRefused` is false for a `500` and for `null`. - Ablation (one-shot, through `scripts/ablation-replace.mjs`, restored to HEAD's blob): with the new leaf-load branch disabled, the refused-leaf case goes red ("Unable to find an element with the text: This item has an unpublished draft…") and the control stays green. ## Acceptance notes - The same reading of a `403` draft read as "no draft" sits in other `readDraftForSave` callers, which this PR does not change: the app navigation load in the same pillar (an app draft carrying `requiredPermissions` is refused the same way, and the nav save goes unpinned), and `ResourceEditPage`, `ObjectListViewInspector` (list views carry the same gate), `StudioBundleItemEditor`, `PermissionMatrixEditor` and `ObjectHooksPanel`. Each can now ask `refused`; reported to the seat as a family, not filed from here. ## Verification All readings are on head `16e3c584`, the only commit on the branch, based on `origin/main` `5d3667e2`. <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.draftUnreadable-29015.test.tsx packages/app-shell/src/views/metadata-admin/DraftConflictDialog.test.tsx`: 2 files, 27 tests passed. - Nearby suites: every app-shell test file naming `readDraftForSave`, `DraftConflictDialog` or `InterfacesPillar`, plus the studio-namespace i18n readers (64 files): 737 tests passed. - `pnpm --dir ui exec vitest run packages/i18n/`: 95 files, 1443 passed, 13 skipped. - `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4900 tests passed. - Ablation of the leaf-load branch (see Tests): refused-leaf case red, control green; file restored to HEAD's blob, `git diff HEAD` empty. </details> <details><summary>Type-check and gates</summary> - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n`: 66 of 66 tasks successful. - `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families for this diff; all 34 ran with exit 0, and `--ran` reconciled 34 derived, 34 run, 0 NOT MEASURED. The changeset and closing-keyword gates ran against this body. - `node scripts/check-sdui-manifest.mjs`: exit 0 (no registry change). - `pnpm check:new-line-citations` (under `ui/`): 0 new citations. - The CI-only type-check lanes and the whole-tree families are CI's; their check-runs on this PR are the reading. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jT5xpWAfUCpLoqcxaT)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49aba82 commit 3d2e8fd

7 files changed

Lines changed: 292 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): Studio no longer edits the published version over a dashboard draft it is not allowed to read, so the autosave cannot overwrite that draft
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** A pending draft can carry an audience gate (`requiredPermissions`) its author does not hold, for example one written over the API or MCP. The draft read answers `403 PERMISSION_DENIED` for it, by design. Studio's draft reader (`readDraftForSave`) took every failed read for "no pending draft", so the Interfaces pillar opened the published body in its place and the next autosave sent that body as the draft, with no precondition: the draft lost the gate and every other unpublished change, and publishing it served the dashboard to everyone.
12+
- **Now.** `readDraftForSave` hands a read refused `403` back as `refused` (with `isDraftReadRefused` to tell it apart). The Interfaces pillar installs no buffer for such a leaf: nothing is shown to edit and the autosave has nothing to send. The refusal strip says the item has an unpublished draft this author may not read, that editing the published version would overwrite it, and that someone who holds the draft's required permissions can edit or publish it; the server's own sentence is behind Details.
13+
- **Unchanged.** A leaf with no pending draft opens the published body and its first save is still pinned to "no draft row"; a readable draft opens as served. Any other failed draft read is handled as before.

‎ui/packages/app-shell/src/views/metadata-admin/DraftConflictDialog.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
DraftVersionGuard,
3030
draftPinOf,
3131
isDraftVersionConflict,
32+
isDraftReadRefused,
3233
readDraftForSave,
3334
type DraftConflictChoice,
3435
} from './DraftConflictDialog';
@@ -228,6 +229,22 @@ describe('DraftVersionGuard — the first save after a load is pinned to the rea
228229
expect(door.puts.map((p) => [p.ifMatch, p.ifNoneMatch])).toEqual([[null, null]]);
229230
});
230231

232+
it('a read refused 403 is no absence: it carries the refusal, pins nothing, and serves no answer (objectstack#29015)', async () => {
233+
const refusal = Object.assign(new Error("You do not have permission to open the 'acme_board' dashboard."), {
234+
status: 403,
235+
code: 'PERMISSION_DENIED',
236+
});
237+
const gated = { getDraft: vi.fn(async () => Promise.reject(refusal)) };
238+
const read = await readDraftForSave(gated, 'dashboard', 'acme_board', PKG);
239+
expect(read).toEqual({ answer: null, pin: null, refused: refusal });
240+
expect(isDraftReadRefused(read.refused)).toBe(true);
241+
// Any other failure says nothing about the row, and carries no refusal.
242+
const failing = { getDraft: vi.fn(async () => Promise.reject(new Error('network down'))) };
243+
expect('refused' in (await readDraftForSave(failing, 'dashboard', 'acme_board', PKG))).toBe(false);
244+
expect(isDraftReadRefused(Object.assign(new Error('boom'), { status: 500 }))).toBe(false);
245+
expect(isDraftReadRefused(null)).toBe(false);
246+
});
247+
231248
it('a pin belongs to its address: a read recorded for another package pins nothing here', async () => {
232249
const door = measuredDoor();
233250
const { guard } = guardOver(door);

‎ui/packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
* installed with no draft read behind it (a reset, a discard) holds nothing
5353
* either: the caller says so with `forget()`. A read-back of the guard's
5454
* OWN save is not an install.
55+
* A draft read REFUSED `403` is not a failure that says nothing: a draft
56+
* row is there that this session may not read (its audience gate,
57+
* `requiredPermissions`, names a capability the author does not hold).
58+
* {@link readDraftForSave} hands that refusal back as `refused`, and a load
59+
* that sees it installs no buffer: one seeded from the published layer
60+
* would be saved over the draft it never read.
5561
* 4. A `409 METADATA_CONFLICT` opens the conflict dialog: reload the saved
5662
* version (the caller's reload runs; the buffer is replaced), overwrite it
5763
* after a confirmation (re-sent pinned to the refusal's `currentVersion`,
@@ -150,14 +156,39 @@ function pinOfConflict(err: unknown): DraftPin | null {
150156
return body.success ? pinOfVersion(body.data.currentVersion) : null;
151157
}
152158

159+
/**
160+
* Whether a draft read was REFUSED for this session (`403`): the read reached
161+
* a draft row and the server withheld it, so the row exists and its body is
162+
* not this session's to hold. Told apart by status, never by prose.
163+
*/
164+
export function isDraftReadRefused(err: unknown): boolean {
165+
return (err as { status?: unknown } | null | undefined)?.status === 403;
166+
}
167+
168+
/** What {@link readDraftForSave} answers. */
169+
export interface DraftReadForSave<T> {
170+
/** The served draft envelope, or `null`: no pending draft, or a read that failed. */
171+
answer: T | null;
172+
/** The pin the next draft save at the read's address is sent with. */
173+
pin: DraftPin | null;
174+
/**
175+
* The server's refusal, present only when the read was refused `403`
176+
* ({@link isDraftReadRefused}). `answer` is then `null` although a draft is
177+
* pending: a load that sees `refused` installs no buffer, because one seeded
178+
* from the published layer would be saved over the draft it never read.
179+
*/
180+
refused?: unknown;
181+
}
182+
153183
/**
154184
* A load's draft read at `packageId` — the address the buffer's draft saves
155185
* write — with the pin it serves for that address. Read and pin come from one
156186
* call so a load cannot pin a save to a read of another address (see the
157187
* module header). A read that fails resolves `answer: null`, as the loads'
158188
* `.catch(() => null)` always did, and pins nothing: a failure says nothing
159-
* about the row. Record the pin with `recordRead` where the buffer is
160-
* installed, not here: a load that is cancelled installs nothing.
189+
* about the row. A read refused `403` says the row is there and withheld, and
190+
* carries the refusal as `refused`. Record the pin with `recordRead` where the
191+
* buffer is installed, not here: a load that is cancelled installs nothing.
161192
*/
162193
export async function readDraftForSave<T = unknown>(
163194
client: Pick<MetadataClient, 'getDraft'>,
@@ -167,15 +198,15 @@ export async function readDraftForSave<T = unknown>(
167198
// objectstack#26649 — the bundle member whose draft this is; omitted, the
168199
// canonical member's. The save it pins is addressed by its body's member.
169200
member?: string,
170-
): Promise<{ answer: T | null; pin: DraftPin | null }> {
201+
): Promise<DraftReadForSave<T>> {
171202
try {
172203
const answer = await client.getDraft<T>(type, name, {
173204
...(packageId ? { packageId } : {}),
174205
...(member ? { member } : {}),
175206
});
176207
return { answer, pin: draftPinOf(answer) };
177-
} catch {
178-
return { answer: null, pin: null };
208+
} catch (err) {
209+
return isDraftReadRefused(err) ? { answer: null, pin: null, refused: err } : { answer: null, pin: null };
179210
}
180211
}
181212

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29015 — a pending dashboard draft the server refuses to read
5+
* (`403 PERMISSION_DENIED`: its `requiredPermissions` name a capability the
6+
* author does not hold) is never taken for "no draft". The Interfaces pillar
7+
* installs no buffer over it: the published layer is not shown to edit, the
8+
* strip says why, and the autosave sends nothing, so the draft keeps its keys
9+
* and its audience gate.
10+
*
11+
* The client is a server double: `layered` serves the published row,
12+
* `getDraft` serves the stored draft row raw, or refuses it as the read gate
13+
* does (an `Error` carrying `status` and `code`, the shape the metadata
14+
* client throws), and `save` records the wire body and its precondition. The
15+
* dashboard canvas is a stub that prints the draft it received and offers one
16+
* edit, so nothing in the pillar itself is stubbed.
17+
*/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import * as React from 'react';
21+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22+
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
23+
import userEvent from '@testing-library/user-event';
24+
import { MemoryRouter } from 'react-router-dom';
25+
import { t } from '../metadata-admin/i18n';
26+
27+
const PKG = 'com.acme.app';
28+
const NAV = [{ id: 'nav_sales', type: 'dashboard', label: 'Sales', dashboardName: 'sales_board' }];
29+
const APP = { name: 'acme_app', label: 'Acme', navigation: NAV };
30+
const PUBLISHED_DASHBOARD = { name: 'sales_board', label: 'Sales', widgets: [] };
31+
/** The pending draft: the published body plus an audience gate the author does not hold. */
32+
const GATED_DRAFT = { ...PUBLISHED_DASHBOARD, requiredPermissions: ['showcase.export_data'] };
33+
const GATE_REFUSAL = "You do not have permission to open the 'sales_board' dashboard.";
34+
35+
const server = vi.hoisted(() => ({
36+
active: new Map<string, Record<string, unknown>>(),
37+
drafts: new Map<string, Record<string, unknown>>(),
38+
/** Draft rows whose read is refused as the audience gate refuses it. */
39+
gated: new Set<string>(),
40+
saves: [] as Array<{ type: string; name: string; body: Record<string, unknown>; opts: Record<string, unknown> | undefined }>,
41+
}));
42+
43+
const mockClient = vi.hoisted(() => {
44+
const k = (type: string, name: string) => `${type}/${name}`;
45+
const toWire = (doc: unknown) => JSON.parse(JSON.stringify(doc)) as Record<string, unknown>;
46+
return {
47+
list: vi.fn(async (type: string) =>
48+
[...server.active.entries()]
49+
.filter(([key]) => key.startsWith(`${type}/`))
50+
.map(([, row]) => ({ name: row.name, label: row.label ?? row.name })),
51+
),
52+
listDrafts: vi.fn(async () => []),
53+
listTypes: vi.fn(async () => ({ entries: [] })),
54+
get: vi.fn(async () => null),
55+
references: vi.fn(async () => []),
56+
layered: vi.fn(async (type: string, name: string) => {
57+
const eff = server.active.get(k(type, name)) ?? null;
58+
return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' };
59+
}),
60+
getDraft: vi.fn(async (type: string, name: string) => {
61+
if (server.gated.has(k(type, name))) {
62+
throw Object.assign(new Error(GATE_REFUSAL), {
63+
status: 403,
64+
code: 'PERMISSION_DENIED',
65+
body: { success: false, error: { code: 'PERMISSION_DENIED', message: GATE_REFUSAL } },
66+
});
67+
}
68+
const row = server.drafts.get(k(type, name));
69+
// The client resolves a 404 `NO_DRAFT` read as `null`.
70+
if (!row) return null;
71+
return { type, name, version: 'v1', item: { ...row } };
72+
}),
73+
save: vi.fn(async (type: string, name: string, item: unknown, opts?: Record<string, unknown>) => {
74+
const body = toWire(item);
75+
server.saves.push({ type, name, body, opts });
76+
if (opts?.mode === 'draft') server.drafts.set(k(type, name), body);
77+
else server.active.set(k(type, name), body);
78+
return { success: true, version: 'v2', state: 'draft' };
79+
}),
80+
publish: vi.fn(async () => ({ success: true })),
81+
reset: vi.fn(async () => ({})),
82+
};
83+
});
84+
85+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
86+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
87+
return {
88+
...mod,
89+
useMetadataClient: () => mockClient,
90+
useMetadataTypes: () => ({ entries: [] }),
91+
};
92+
});
93+
94+
vi.mock('./packages-io', async (importOriginal) => {
95+
const mod = await importOriginal<typeof import('./packages-io')>();
96+
return { ...mod, fetchPackages: vi.fn(async () => []) };
97+
});
98+
99+
vi.mock('@object-ui/react', async (importOriginal) => {
100+
const mod = await importOriginal<typeof import('@object-ui/react')>();
101+
return { ...mod, useAdapter: () => dataSource };
102+
});
103+
104+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
105+
106+
import { InterfacesPillar } from './StudioDesignSurface';
107+
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
108+
import { registerMetadataPreview, getMetadataPreview } from '../metadata-admin/preview-registry';
109+
110+
const dataSource = createEmptyDataSource();
111+
failOnAbsorbedFetchError();
112+
113+
/** Canvas stand-in: prints the draft it was handed and offers one edit through `onPatch`. */
114+
function StubCanvas({ draft, onPatch }: { draft: Record<string, unknown>; onPatch?: (patch: Record<string, unknown>) => void }) {
115+
return (
116+
<div>
117+
<pre data-testid="surface-draft">{JSON.stringify(draft)}</pre>
118+
{onPatch && (
119+
<button type="button" data-testid="stub-edit" onClick={() => onPatch({ label: 'Sales (edited)' })}>
120+
edit
121+
</button>
122+
)}
123+
</div>
124+
);
125+
}
126+
127+
const realPreview = getMetadataPreview('dashboard');
128+
const key = (type: string, name: string) => `${type}/${name}`;
129+
const wire = (doc: unknown) => JSON.parse(JSON.stringify(doc)) as Record<string, unknown>;
130+
131+
function renderPillar() {
132+
render(
133+
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
134+
<InterfacesPillar packageId={PKG} />
135+
</MemoryRouter>,
136+
);
137+
}
138+
139+
/** Longer than the autosave's debounce, so a save that was going to leave has left. */
140+
async function pastTheAutosave() {
141+
await act(async () => {
142+
await new Promise((resolve) => setTimeout(resolve, 2500));
143+
});
144+
}
145+
146+
beforeEach(() => {
147+
server.active.clear();
148+
server.drafts.clear();
149+
server.gated.clear();
150+
server.saves.length = 0;
151+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
152+
registerMetadataPreview('dashboard', StubCanvas as never);
153+
server.active.set(key('app', 'acme_app'), wire(APP));
154+
server.active.set(key('dashboard', 'sales_board'), wire(PUBLISHED_DASHBOARD));
155+
});
156+
157+
afterEach(() => {
158+
cleanup();
159+
if (realPreview) registerMetadataPreview('dashboard', realPreview);
160+
});
161+
162+
describe('StudioDesignSurface — a dashboard draft the server refuses to read is not "no draft" (objectstack#29015)', () => {
163+
it('opens no buffer over the refused draft: the strip says why, nothing is editable, and no save is sent', async () => {
164+
server.drafts.set(key('dashboard', 'sales_board'), wire(GATED_DRAFT));
165+
server.gated.add(key('dashboard', 'sales_board'));
166+
167+
renderPillar();
168+
169+
// The leaf's draft was asked for, at the address its saves write.
170+
await waitFor(
171+
() => expect(mockClient.getDraft).toHaveBeenCalledWith('dashboard', 'sales_board', { packageId: PKG }),
172+
{ timeout: 8000 },
173+
);
174+
// Red on base: the refusal was read as "no draft", and the published body
175+
// was installed as the buffer and handed to the canvas to edit.
176+
expect(
177+
await screen.findByText(t('engine.studio.refusal.draftUnreadable', 'en-US'), undefined, { timeout: 8000 }),
178+
).toBeInTheDocument();
179+
// The server's own sentence stays reachable, behind Details.
180+
expect(screen.getByTestId('studio-refusal')).toHaveTextContent(GATE_REFUSAL);
181+
expect(screen.queryByTestId('surface-draft')).toBeNull();
182+
expect(screen.queryByTestId('stub-edit')).toBeNull();
183+
// The published layer is never read to stand in for the draft.
184+
expect(mockClient.layered).not.toHaveBeenCalledWith('dashboard', 'sales_board', expect.anything());
185+
186+
await pastTheAutosave();
187+
expect(server.saves).toEqual([]);
188+
expect(server.drafts.get(key('dashboard', 'sales_board'))).toStrictEqual(wire(GATED_DRAFT));
189+
});
190+
191+
it('CONTROL: with no pending draft the published body opens, and an edit saves pinned to "no draft row"', async () => {
192+
renderPillar();
193+
194+
await waitFor(
195+
() =>
196+
expect(JSON.parse(screen.getByTestId('surface-draft').textContent ?? '{}')).toStrictEqual(
197+
wire(PUBLISHED_DASHBOARD),
198+
),
199+
{ timeout: 8000 },
200+
);
201+
expect(screen.queryByText(t('engine.studio.refusal.draftUnreadable', 'en-US'))).toBeNull();
202+
203+
await userEvent.click(screen.getByTestId('stub-edit'));
204+
await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 });
205+
const { type, name, body, opts } = server.saves[0]!;
206+
expect([type, name]).toEqual(['dashboard', 'sales_board']);
207+
expect(body.label).toBe('Sales (edited)');
208+
expect(opts).toMatchObject({ mode: 'draft', packageId: PKG, ifNoneMatch: '*' });
209+
expect(opts).not.toHaveProperty('ifMatch');
210+
});
211+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5154,6 +5154,18 @@ export function InterfacesPillar({
51545154
const readAt = current.addedIn ?? (pair && isStudioCustomizeType(current.type) ? pair : packageId);
51555155
const draftRead = await readDraftForSave<Record<string, unknown>>(client, current.type, current.name, readAt);
51565156
if (cancelled) return;
5157+
// A pending draft the server refused to read (`403`: its audience gate
5158+
// names a capability this author does not hold) is no absence. No
5159+
// buffer is installed over it, so nothing is shown to edit and the
5160+
// autosave has nothing to send: a buffer seeded from the published
5161+
// layer would be saved over the draft, dropping its keys and its gate.
5162+
if ('refused' in draftRead) {
5163+
setError({
5164+
message: t('engine.studio.refusal.draftUnreadable', locale),
5165+
detail: formatMetadataError(draftRead.refused),
5166+
});
5167+
return;
5168+
}
51575169
const body = extractDraftBody(draftRead.answer);
51585170
// objectui#11799 — the baseline is read only when it is used, for a
51595171
// leaf with no pending draft. An unsaved leaf always has one, so it

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,8 @@ const STUDIO_EN: Record<string, string> = {
11301130
'engine.studio.refusal.publishWhere': 'Not published. Check {where}: Details say what to change.',
11311131
'engine.studio.refusal.publishUnlocated': 'Not published: the server refused this draft. Details say why.',
11321132
'engine.studio.refusal.publishPlain': 'Not published: {reason}',
1133+
// A leaf whose pending draft the server refused to read (403): Studio opens no buffer over it.
1134+
'engine.studio.refusal.draftUnreadable': 'This item has an unpublished draft you are not allowed to read, so Studio has not opened it: editing the published version here would overwrite that draft. Someone who holds the permissions the draft requires can edit or publish it. Details show what the server answered.',
11331135
'engine.studio.publishRefused': 'Not published: {count} items need a change. Pending changes keeps each reason until it is fixed.',
11341136
'engine.studio.publishRefusedOne': 'Not published: {count} item needs a change. Pending changes keeps the reason until it is fixed.',
11351137
'engine.studio.publishRefusedBatch': 'Nothing went live: the package publishes as one batch, so its other drafts wait for this fix.',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,7 @@ const STUDIO_ZH: Record<string, string> = {
996996
'engine.studio.refusal.publishWhere': '未发布。请检查{where}:详情中说明了需要修改的内容。',
997997
'engine.studio.refusal.publishUnlocated': '未发布:服务端拒绝了此草稿。详情中说明了原因。',
998998
'engine.studio.refusal.publishPlain': '未发布:{reason}',
999+
'engine.studio.refusal.draftUnreadable': '此项有一份您无权读取的未发布草稿,因此 Studio 没有打开它:在这里编辑已发布版本会覆盖那份草稿。持有该草稿所需权限的人可以编辑或发布它。详情中有服务器的答复。',
9991000
'engine.studio.publishRefused': '未发布:{count} 项需要修改。「待发布的变更」会保留每项原因,直到修复为止。',
10001001
'engine.studio.publishRefusedOne': '未发布:{count} 项需要修改。「待发布的变更」会保留原因,直到修复为止。',
10011002
'engine.studio.publishRefusedBatch': '没有内容生效:软件包作为一个整体发布,其余草稿会等待这项修复。',

0 commit comments

Comments
 (0)