Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/25894-no-drafts-request-without-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the Console no longer sends two requests it knows will be refused, the drafts ledger for a member and the published body of a new draft

Clause-②: no
Metadata-protocol: none

- **Home's drafts row** read `GET /api/v1/meta/_drafts` for whoever opened Home. A member holding none of the capabilities the ledger admits (`studio.access`, `setup.access` or `manage_metadata`) got a 403, and the console logged it as a failed request on every visit. The row now reads the ledger only for a session that holds one of them, through the new `useCanReadPendingDrafts()`. An unreported capability set still reads it, and the server still enforces the gate.
- **The pending-changes sheet** (Studio's Publish and Changes, and the draft-preview bar's Changes) read each draft's published body beside its draft. Under Publish, where every row opens expanded, that read was sent before the sheet knew which drafts are new, so a draft-only object such as one just saved from New object got `GET /api/v1/meta/object/NAME?package=PKG` → 404. A row now waits for its new-or-update classification, and a new draft is not asked for a published body. A row whose classification read failed is read both ways, as before.

What to do: nothing. The pages render exactly as before.
10 changes: 8 additions & 2 deletions ui/packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-cha
// May this session author metadata? Shared with the chat surfaces since the
// maker convergence (cloud#1674) — the doctrine (objectstack#8270 posture,
// unknown→fail-open) lives with the hook.
import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js';
import { useCanAuthorMetadata, useCanReadPendingDrafts } from '../../hooks/useCanAuthorMetadata.js';
import { HomeAppsStrip } from './HomeAppsStrip.js';
import { appSegmentOf, lastOpenedByApp } from './appLastOpened.js';
import { recentItemObject, recentItemTypeLabel } from './recentItemTypeLabel.js';
Expand Down Expand Up @@ -292,10 +292,16 @@ function usePackageName(packageId: string | null): string | undefined {
* publish. `usePublishAllDrafts` is the shared one-click publish, also run by
* the ADR-0037 draft-preview bar. Returns the row, or `null` with nothing to
* publish.
*
* Read only for a session the ledger admits (objectstack-ai/objectstack#25894):
* Home is every member's landing, and a member holding none of the ledger's
* capabilities was sent a `GET /meta/_drafts` that answers 403 on every visit.
* Held, the read is held too, so the row is `null`, as with nothing to publish.
*/
function usePendingDraftsRow(t: (key: string, opts?: any) => string, language: string): React.ReactNode {
const { publishAll, publishing } = usePublishAllDrafts(t);
const { count, entries } = usePendingDrafts({});
const canReadDrafts = useCanReadPendingDrafts();
const { count, entries } = usePendingDrafts({ enabled: canReadDrafts });
const drafted = (count ?? 0) > 0;
// Named only when every draft is in one package: a row naming one package
// for drafts spread over several would misplace the rest. Named by the name
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack-ai/objectstack#25894 — Home reads the pending-drafts ledger only
* for a session the ledger admits.
*
* Measured on the 2026-10-25 browser pass: signed in as a member with no
* metadata capability, the console sent `GET /api/v1/meta/_drafts` and logged
* its 403 as a resource error. Home is every member's landing (the approvals
* inbox lives here), and its drafts row read the env-wide ledger for whoever
* opened it. The door admits `studio.access`, `setup.access` or
* `manage_metadata` (`mayReadPendingDrafts`); a session holding none of them
* is now sent no read.
*
* Drives the REAL `MePermissionsProvider` over a `/me/permissions` payload, as
* `HomePage.authoringCapabilityGate.test.tsx` does, so the gate consumes the
* server's answer through `hasCapabilities`.
*
* Pinned:
* - a reported set holding none of the three: no `_drafts` read, no row;
* - CONTROL: each of the three alone reads the ledger and renders the row;
* - CONTROL: an unreported set (no `systemPermissions`) fails OPEN and reads.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { act, render, screen, cleanup } from '@testing-library/react';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';

vi.mock('react-router-dom', async () => {
const { createElement } = await import('react');
return {
useNavigate: () => vi.fn(),
Link: ({ to, ...rest }: { to: string } & Record<string, unknown>) => createElement('a', { ...rest, href: to }),
};
});

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({ t: (key: string) => `«${key}»`, language: 'en' }),
useObjectLabel: () => ({ appLabel: (app: any) => String(app?.label ?? app?.name ?? '') }),
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'Sales Manager', email: 'sales.manager@example.com' } }),
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
}));

vi.mock('@object-ui/plugin-chatbot/core', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-chatbot/core')>()),
useAgents: () => ({ agents: [] }),
isAskAgent: () => false,
agentHasCapability: () => false,
}));

vi.mock('../../../providers/MetadataProvider', () => ({
useMetadata: () => ({ apps: [{ name: 'crm', label: 'CRM' }], loading: false }),
}));
vi.mock('../../../context/NavigationContext', () => ({
useNavigationContext: () => ({ currentAppName: 'crm' }),
}));
vi.mock('../../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) }));
vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) }));
vi.mock('../../../hooks/useHomeInbox', () => ({
useHomeInbox: () => ({
pendingApprovalsCount: 0,
pendingApprovals: [],
pendingApprovalsStatus: 'ready',
pendingApprovalsReadable: true,
notifications: [],
unreadTopicCount: 0,
notificationsStatus: 'ready',
notificationsReadable: true,
activity: { value: [], status: 'ready', readable: true },
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
vi.mock('../../../preview/usePublishAllDrafts', () => ({
usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }),
}));
vi.mock('../../../runtime-config', () => ({
getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }),
isMarketplaceEnabled: () => true,
isAiStudioEnabled: () => true,
}));

import { HomePage } from '../HomePage';

const DRAFTS_ROUTE = '/api/v1/meta/_drafts';
const routeOf = (url: string) => url.split('?')[0];

/** Two drafts in two packages: the row names no package, so no package list is read. */
const DRAFTS = [
{ type: 'object', name: 'visit', label: 'Visit', packageId: 'com.a' },
{ type: 'object', name: 'invoice', label: 'Invoice', packageId: 'com.b' },
];

let fetchCalls: string[] = [];

beforeEach(() => {
fetchCalls = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(input);
fetchCalls.push(url);
if (routeOf(url) === DRAFTS_ROUTE) return { ok: true, status: 200, json: async () => ({ drafts: DRAFTS }) };
return { ok: false, status: 404, json: async () => ({}) };
}),
);
});

afterEach(() => {
expect(fetchCalls.filter((url) => routeOf(url) !== DRAFTS_ROUTE)).toEqual([]);
// Unmount before restoring the real `fetch` (objectui#7439).
cleanup();
vi.unstubAllGlobals();
});

const payload = (systemPermissions?: string[]): MePermissionsResponse =>
({
authenticated: true,
userId: 'u1',
tenantId: 'acme',
roles: ['everyone'],
permissionSets: ['member_default'],
...(systemPermissions ? { systemPermissions } : {}),
objects: {},
fields: {},
}) as MePermissionsResponse;

async function renderHome(permissions: MePermissionsResponse) {
render(
<MePermissionsProvider initialPermissions={permissions}>
<HomePage />
</MePermissionsProvider>,
);
await act(async () => {
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
});
}

const draftsReads = () => fetchCalls.filter((url) => routeOf(url) === DRAFTS_ROUTE);

describe('Home reads the drafts ledger only for a session it admits (objectstack-ai/objectstack#25894)', () => {
it('a member holding none of the ledger capabilities: no `_drafts` read and no row', async () => {
await renderHome(payload(['export_reports']));

expect(screen.getByRole('heading', { level: 1 }), 'Home never rendered: the harness is dead').toBeInTheDocument();
expect(draftsReads()).toEqual([]);
expect(screen.queryByTestId('home-pending-drafts')).toBeNull();
});

it('a REPORTED empty set holds none of them either: no read', async () => {
await renderHome(payload([]));

expect(draftsReads()).toEqual([]);
});

it.each(['setup.access', 'studio.access', 'manage_metadata'])(
'CONTROL: `%s` alone reads the ledger and shows the row',
async (capability) => {
await renderHome(payload([capability]));

expect(draftsReads()).toEqual([DRAFTS_ROUTE]);
expect(await screen.findByTestId('home-pending-drafts')).toBeInTheDocument();
},
);

it('CONTROL: an unreported capability set fails OPEN and reads', async () => {
await renderHome(payload(undefined));

expect(draftsReads()).toEqual([DRAFTS_ROUTE]);
expect(await screen.findByTestId('home-pending-drafts')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ vi.mock('../../../context/NavigationContext', () => ({
}));
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
useCanAuthorMetadata: () => state.canAuthorMetadata,
useCanReadPendingDrafts: () => true,
}));
// The page's one inbox seam: the approvals count AND whether it is an answer.
vi.mock('../../../hooks/useHomeInbox', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ vi.mock('../../../providers/MetadataProvider', () => ({
vi.mock('../../../context/NavigationContext', () => ({
useNavigationContext: () => ({ currentAppName: 'crm' }),
}));
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true }));
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
useCanAuthorMetadata: () => true,
useCanReadPendingDrafts: () => true,
}));
// The page's one inbox seam: both sources, their answers and their read grants.
vi.mock('../../../hooks/useHomeInbox', () => ({
useHomeInbox: () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ vi.mock('../../../providers/MetadataProvider', () => ({
vi.mock('../../../context/NavigationContext', () => ({
useNavigationContext: () => ({ currentAppName: 'crm' }),
}));
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true }));
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
useCanAuthorMetadata: () => true,
useCanReadPendingDrafts: () => true,
}));
vi.mock('../../../hooks/useHomeInbox', () => ({
useHomeInbox: () => ({
pendingApprovals: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ vi.mock('../../../context/NavigationContext', () => ({
}));
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
useCanAuthorMetadata: () => state.canAuthorMetadata,
useCanReadPendingDrafts: () => true,
}));
// The page's one inbox seam: the approvals count AND whether it is an answer.
vi.mock('../../../hooks/useHomeInbox', () => ({
Expand Down
23 changes: 23 additions & 0 deletions ui/packages/app-shell/src/hooks/useCanAuthorMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,26 @@ export function useCanReadAuthoringTrail(): boolean {
const { hasCapabilities } = usePermissions();
return AUTHORING_TRAIL_CAPABILITIES.some((capability) => hasCapabilities([capability]));
}

/**
* objectstack-ai/objectstack#25894 — the capabilities the pending-drafts ledger
* (`GET /meta/_drafts`) admits. The door asks `mayReadPendingDrafts`, the
* ADR-0106 D4 mask-exemption cohort (`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES`
* in `@objectstack/metadata-core`): the write capability plus the two
* read-only exemptions. Any ONE of them opens it. `system` is a principal
* flag with no client half, as for the trail above.
*/
export const PENDING_DRAFTS_CAPABILITIES: readonly string[] = ['studio.access', 'setup.access', AUTHORING_CAPABILITY];

/**
* May this session read the pending-drafts ledger?
*
* Asked by a surface that reads the ledger for whoever opens it (Home's drafts
* row), so a member the door refuses is not sent a request that always answers
* 403. Unknown fails OPEN, as {@link useCanAuthorMetadata} does: the server
* enforces regardless.
*/
export function useCanReadPendingDrafts(): boolean {
const { hasCapabilities } = usePermissions();
return PENDING_DRAFTS_CAPABILITIES.some((capability) => hasCapabilities([capability]));
}
36 changes: 33 additions & 3 deletions ui/packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -656,13 +656,22 @@ function FieldRow({
* answer carries a numeric `total`: with none, the choice names no number
* rather than a guessed one. What the publish actually wrote is the door's
* receipt, which the host reports.
*
* `classifying` (objectstack-ai/objectstack#25894): the panel's new-vs-update
* reads are still in flight. A NEW entry has no published body, so its
* published read is not sent: it could only answer 404, logged as a failed
* request on the console for every draft-only item. The drill-in waits while
* the entry is unclassified and the reads are pending; one whose read failed
* (still unclassified once they settle) is read both ways, as before.
*/
function EntryDetail({
entry,
classifying = false,
onCompared,
backfill,
}: {
entry: DraftChangeEntry;
classifying?: boolean;
onCompared?: (unchanged: boolean) => void;
backfill?: EntryBackfillControl;
}) {
Expand All @@ -687,13 +696,21 @@ function EntryDetail({
onComparedRef.current = onCompared;
});

const classifiedNew = entry.kind === 'new';
const awaitingKind = entry.kind === undefined && classifying;
useEffect(() => {
if (awaitingKind) return;
let cancelled = false;
(async () => {
try {
const [published, draft] = await Promise.all([
// A NEW item 404s on the published read — that's data, not an error.
fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }),
// A NEW item has no published body: not asked
// (objectstack-ai/objectstack#25894). An item the
// classification could not answer still 404s here when it is new —
// that's data, not an error.
classifiedNew
? null
: fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }),
fetchItemBody(entry.type, entry.name, {
draft: true,
packageId: entry.packageId,
Expand All @@ -715,7 +732,7 @@ function EntryDetail({
return () => {
cancelled = true;
};
}, [entry.type, entry.name, entry.packageId, entry.member]);
}, [entry.type, entry.name, entry.packageId, entry.member, classifiedNew, awaitingKind]);

// objectui#12929 — how many records the choice would reach: asked once per
// object, and only where a choice is offered.
Expand Down Expand Up @@ -945,6 +962,14 @@ export function DraftChangesPanel({
const { t, language } = useObjectTranslation();
const [entries, setEntries] = useState<DraftChangeEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
/**
* The new-vs-update reads of the latest load are in flight
* (objectstack-ai/objectstack#25894): a drill-in holds its published read
* until its entry is classified or they settle. Keyed by load generation, so
* an earlier load settling late does not end a later one's.
*/
const [classifying, setClassifying] = useState(false);
const loadGenRef = useRef(0);
/**
* Author-time security findings that the publish door would REFUSE
* (`7a90afdf9`). Surfaced here, next to the button, so the refusal is
Expand Down Expand Up @@ -1034,6 +1059,8 @@ export function DraftChangesPanel({
};

const load = useCallback(async () => {
const gen = ++loadGenRef.current;
setClassifying(true);
setEntries(null);
setError(null);
setProblems([]);
Expand Down Expand Up @@ -1111,6 +1138,8 @@ export function DraftChangesPanel({
]);
} catch (e) {
setError((e as Error).message);
} finally {
if (loadGenRef.current === gen) setClassifying(false);
}
}, [packageId, include]);

Expand Down Expand Up @@ -1311,6 +1340,7 @@ export function DraftChangesPanel({
<div className="border-t bg-muted/20">
<EntryDetail
entry={entry}
classifying={classifying}
onCompared={(same) => markCompared(key, same)}
backfill={backfillControl}
/>
Expand Down
Loading
Loading