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
12 changes: 12 additions & 0 deletions .changeset/29187-form-upfront-write-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/console': patch
---

fix(console): an internal form refuses a write the caller holds no grant for before it draws a field

Clause-②: no
Metadata-protocol: none

A member who may read an object but not create it could open a form link (`/forms/NAME` or `/apps/APP/forms/NAME`), type the whole record, and learn only on Submit that the server refuses it, losing the typing. The internal form page now asks the caller's grant as soon as the form has loaded: a create form the create grant, an edit form (`?recordId=`) the update grant, through the console's affordance map (`createFormFields`, `editFormFields`). Refused, the page draws the submit-time refusal's own localized sentence ("You don't have permission to save this record.") and no field. With the grant, the form is unchanged.

The app-less `/forms/NAME` route now sits under the same permissions layout as `/home` and `/apps/APP/*`, so both form URLs read the one `/auth/me/permissions` answer the console already holds, and a hop from Home or an app asks nothing again. The URL is unchanged. The anonymous `/f/SLUG` route is unchanged. No metadata key and no server change.
32 changes: 19 additions & 13 deletions ui/apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,19 +311,6 @@ export function App() {
* layer too late for a capability whose declared meaning is
* "Enter the Studio metadata-design surfaces". */}
{studioRoutes}
{/* Internal authed form — same renderer as `/f/:slug`, different
* submit path, and (objectui#4109) rendered INSIDE the console
* shell instead of as a bare page. The route stays exactly where
* it is: per the 2026-08-10 ruling on objectstack#7245 the missing
* chrome was the defect, not the navigation, so deep links keep
* working. `InternalFormRoute` owns the chrome + the "land on the
* created record" target; the public path above stays chrome-less
* on purpose (an anonymous visitor has no console to be in). */}
<Route path="/forms/:name" element={
<ProtectedRoute>
<InternalFormRoute />
</ProtectedRoute>
} />
{/* Package documentation (ADR-0046): a platform-level portal
* lists every installed `doc` (grouped by package), and one
* viewer route renders any item; cross-references between docs
Expand Down Expand Up @@ -367,6 +354,25 @@ export function App() {
}>
<Route path="/home" element={<HomeRoute />} />
<Route path="/apps/:appName/*" element={<AppContent />} />
{/* Internal authed form — same renderer as `/f/:slug`, different
* submit path, and (objectui#4109) rendered INSIDE the console
* shell instead of as a bare page. The URL is unchanged: per the
* 2026-08-10 ruling on objectstack#7245 the missing chrome was
* the defect, not the navigation, so deep links keep working.
* `InternalFormRoute` owns the chrome + the "land on the created
* record" target; the public `/f/:slug` above stays chrome-less
* and outside this layout on purpose (an anonymous visitor has
* no console to be in, and no permissions to ask about).
*
* Under this layout rather than beside it (objectstack#29187):
* `FormPage` refuses a form whose write the caller holds no
* grant for BEFORE drawing a field, and it reads that grant
* from this layout's `/auth/me/permissions` answer — the one
* its in-app twin `/apps/:appName/forms/:name` already reads.
* One answer for both URLs, so one form never behaves
* differently by URL; a hop from `/home` or an app keeps the
* layout, so the answer is not asked again. */}
<Route path="/forms/:name" element={<InternalFormRoute />} />
</Route>
{/* Dev-only: ADR-0001 master-detail subform verification harness. */}
<Route path="/dev/master-detail" element={
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack-ai/objectstack#29187 — where the form routes sit in `App`'s
* route tree, which decides what `FormPage`'s up-front write refusal reads.
*
* The app-less `/forms/:name` used to sit beside the console's permissions
* layout, under `ProtectedRoute` alone, while its in-app twin
* `/apps/:appName/forms/:name` sat under it. The refusal reads the caller's
* grants from that layout's `/auth/me/permissions` answer, so on the app-less
* URL it would have had none and the same form would behave differently by
* URL. `/forms/:name` is now a child of the same pathless layout route as
* `/home` and `/apps/:appName/*`: one answer, asked once per session load,
* held across a hop between them. `/f/:slug` stays outside it: anonymous,
* no answer, no request.
*
* What is real: `App`'s route tree, `ConsolePermissionsScope` and the
* `MePermissionsProvider` it mounts. The network is the console's
* authenticated fetcher, mocked. The probes stand where `InternalFormRoute`,
* the public `FormPage` and Home's page render, and report what they see.
* `ProtectedRoute` is reduced to the metadata context it provides.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
import { useContext, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
import { usePermissions, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions';
// Not on the package's entry: the context object itself, read by its module id
// (`@object-ui/permissions` resolves to `packages/permissions/src`, so this is
// the same module instance the provider writes).
import { PermCtx } from '../../../../packages/permissions/src/PermissionContext';

const net = vi.hoisted(() => ({
/** Every URL the console's authenticated fetcher was asked for. */
calls: [] as string[],
}));

const METADATA: MetadataContextValue = {
apps: [{ name: 'crm', label: 'CRM', active: true, navigation: [] }] as never,
objects: [],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
getTypeStatus: () => 'ready',
} as unknown as MetadataContextValue;

/** A member who may read `qar8_fsb` and not create it. */
function permissionsAnswer(): MePermissionsAuthenticatedResponse {
return {
authenticated: true,
userId: 'u_member',
tenantId: null,
positions: [],
hasAnyQueue: false,
permissionSets: ['member'],
systemPermissions: [],
objects: { qar8_fsb: { allowRead: true } },
fields: {},
} as MePermissionsAuthenticatedResponse;
}

function respond(input: unknown): Promise<Response> {
const url = String(input);
const body = url.includes('/auth/me/permissions') ? permissionsAnswer() : {};
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => body,
} as unknown as Response);
}

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
AuthProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
useAuth: () => ({ user: { id: 'u_member', name: 'Member', email: 'member@example.com' }, isAuthenticated: true, isLoading: false }),
createAuthenticatedFetch: () => (input: unknown) => {
net.calls.push(String(input));
return respond(input);
},
}));

vi.mock('../components/ProtectedRoute', () => ({
ProtectedRoute: ({ children }: { children?: ReactNode }) => (
<MetadataCtx.Provider value={METADATA}>{children}</MetadataCtx.Provider>
),
}));

/** What a form route sees of the caller's grants on the form's object. */
function GrantProbe({ testId }: { testId: string }) {
const ctx = useContext(PermCtx);
const { isLoaded, can } = usePermissions();
const verdict = ctx === null ? 'no-perm-ctx' : isLoaded ? `create:${String(can('qar8_fsb', 'create'))}` : 'not-loaded';
return <div data-testid={testId}>{verdict}</div>;
}

/** Stands where Home's page renders, with a way to follow a form link. */
function HomeProbe() {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate('/forms/qar8_fsb.form')}>
open form
</button>
);
}

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ConsoleShell: ({ children }: { children?: ReactNode }) => <>{children}</>,
ConsoleToaster: () => null,
LoadingScreen: () => <div data-testid="loading-screen" />,
DefaultHomePage: () => <HomeProbe />,
DefaultAppContent: () => null,
BuilderLanding: () => null,
StudioDesignSurface: () => null,
getProductName: () => 'ObjectOS',
getFaviconUrl: () => '',
}));

vi.mock('../../../../packages/app-shell/src/layout/AppHeader', () => ({ AppHeader: () => null }));
vi.mock('../../../../packages/app-shell/src/chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../../../packages/app-shell/src/hooks/useAiSurface', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }),
}));

vi.mock('../LocalizationFetchProvider', () => ({
LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null }));
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null }));
vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null }));
vi.mock('../components/FormPage', () => ({ FormPage: () => <GrantProbe testId="public-form" /> }));
vi.mock('../components/InternalFormRoute', () => ({
InternalFormRoute: () => <GrantProbe testId="internal-form" />,
InAppFormRoute: () => <GrantProbe testId="in-app-form" />,
}));
vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null }));
vi.mock('../components/FaviconSync', () => ({ FaviconSync: () => null }));
vi.mock('../pages/SharedRecordPage', () => ({ default: () => null }));
vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: () => null }));
vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: () => null }));
vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: () => null }));
vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: () => null }));
vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: () => null }));
vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: () => null }));
vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: () => null }));
vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: () => null }));
vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: () => null }));
vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: () => null }));
vi.mock('../dev/DevLists', () => ({ DevLists: () => null }));
vi.mock('../dev/DevModal', () => ({ DevModal: () => null }));
vi.mock('../dev/DevLookup', () => ({ DevLookup: () => null }));
vi.mock('../dev/DevRowActions', () => ({ DevRowActions: () => null }));

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

const permissionsCalls = () => net.calls.filter((u) => u.includes('/auth/me/permissions'));

function renderAt(url: string) {
window.history.pushState({}, '', url);
return render(<App />);
}

beforeEach(() => {
net.calls = [];
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
cleanup();
window.history.pushState({}, '', '/');
});

describe('the form routes and the permissions answer (#29187)', () => {
it('/forms/NAME renders under the permissions answer, asked once', async () => {
renderAt('/forms/qar8_fsb.form');
await waitFor(() => expect(screen.getByTestId('internal-form')).toHaveTextContent('create:false'));
expect(permissionsCalls()).toHaveLength(1);
});

it('/forms/NAME waits on the answer the way /home and /apps do: the loading screen first, no form', async () => {
renderAt('/forms/qar8_fsb.form');
expect(screen.getByTestId('loading-screen')).toBeInTheDocument();
expect(screen.queryByTestId('internal-form')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByTestId('internal-form')).toBeInTheDocument());
});

it('/home then a form link keeps the one answer: /auth/me/permissions asked ONCE in total', async () => {
renderAt('/home');
await waitFor(() => expect(screen.getByRole('button', { name: 'open form' })).toBeInTheDocument());
await act(async () => {
screen.getByRole('button', { name: 'open form' }).click();
});
await waitFor(() => expect(screen.getByTestId('internal-form')).toHaveTextContent('create:false'));
expect(permissionsCalls()).toHaveLength(1);
});

it('/f/SLUG stays outside it: no permission context and no /auth/me/permissions request', async () => {
renderAt('/f/feedback');
await waitFor(() => expect(screen.getByTestId('public-form')).toHaveTextContent('no-perm-ctx'));
expect(permissionsCalls()).toHaveLength(0);
});
});
41 changes: 41 additions & 0 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,12 @@ import {
isMissingForRequired,
isRuntimeDefault,
isServerOwnedValue,
resolveAffordance,
resolveFieldRuleState,
type FieldRuleFaults,
type FieldRulePredicate,
} from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import {
omitServerResolvedDefaults,
optionListDefault,
Expand Down Expand Up @@ -2547,6 +2549,15 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
*/
const hostApiFetch = useContext(SchemaRendererContext)?.apiFetch;
const channel = mode === 'internal' ? hostApiFetch : undefined;
/**
* The caller's permissions, for the up-front write refusal below
* (objectstack-ai/objectstack#29187): the `/auth/me/permissions` answer the
* console's permissions layout holds over BOTH internal routes —
* `/forms/:name` and `/apps/:appName/forms/:name` (`App.tsx`). With no
* provider above (the public `/f/:slug`, a bare test harness) every grant
* reads open, so nothing changes there and the server still enforces.
*/
const perms = usePermissions();

const [loading, setLoading] = useState(true);
/** One string, or one line per reason a server refusal marked on no row. */
Expand Down Expand Up @@ -3116,6 +3127,36 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
}
if (!loaded) return null;

// A write this caller holds no grant for is refused BEFORE a field is drawn
// (objectstack-ai/objectstack#29187). The server refuses it at Submit anyway,
// and since #29019 says so in a sentence; but a create form offered to a
// member who may only read the object let them type the whole record first,
// and lose it. The verdict is the affordance map's form row, the one the
// app's own create and edit forms read (`createFormFields`, `editFormFields`):
// a create form asks the create grant, an edit form (`?recordId=`) the update
// grant, each intersected with the object's effective API operations. The sentence
// is the submit-time refusal's own (`refusalText`), so the two read alike.
// Internal mode only: `/f/:slug` is anonymous and has no grants to read.
if (
mode === 'internal' &&
!resolveAffordance(isCreateForm ? 'createFormFields' : 'editFormFields', {
objectName: loaded.object,
perms,
}).allowed
) {
return (
<div className="mx-auto max-w-2xl p-6">
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
data-testid="form-write-refused"
>
{t('form.noPermissionToSave')}
</div>
</div>
);
}

// `created-record` reaches this branch only on the fallback path above (no
// id in the response, or nowhere to land) — a successful redirect navigates
// away instead. It carries no author-supplied title/message, so it renders
Expand Down
Loading
Loading