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
15 changes: 15 additions & 0 deletions .changeset/permission-matrix-honors-allow-runtime-create-4446.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
---

The permission matrix honors `allowRuntimeCreate` — and its read-only badge names the gate that actually tripped

On a stock boot the permission-matrix editor rendered every checkbox disabled, hid Save, and captioned itself `Read-only (OS_METADATA_WRITABLE not enabled)` — in a **writable** package, at both the metadata-admin route and inside Studio's Access pillar — while the server accepted the very write it was refusing to offer (`PUT /api/v1/meta/permission/<n>?package=<pkg>` → 200, measured on objectstack#7637).

The editor's writability switch read `allowOrgOverride` alone. That flag and `allowRuntimeCreate` are two different doors: the first is permission to OVERLAY a code-shipped item per organization, the second is permission to AUTHOR an item at runtime — and authoring is what this editor's Save does under a `packageId` (`mode: 'draft'` + `packageId`, ADR-0086 P0/P2). The server refuses only when BOTH are false; `permission` sits in exactly the gap, `allowOrgOverride: false` (ADR-0005 forbids per-org overlay of a packaged permission set — silent privilege drift) with `allowRuntimeCreate: true`, which objectstack#6483 kept open deliberately. The switch now reads the disjunction, off the raw server entry, the way `DirectoryPage`, `EmbeddedItemEditor` and `ResourceEditPage` already read it and the way `useMetadata.ts` documents on the field itself.

The package-level gate is untouched and still dominant: a read-only package locks this screen whatever the type permits, so a code-defined package behaves exactly as before.

The badge and the controls also stop disagreeing, and the fix is what closed the gap rather than a second edit. `PageShell`'s writability badge was already reading the full disjunction (`readOnly` → `allowOrgOverride` → `allowRuntimeCreate`); the controls were the outlier, so the two predicates are now byte-identical and a four-state table pins them that way. Previously this screen showed a "create-only" badge above 207 dead checkboxes.

The read-only caption is honest in both directions now. `OS_METADATA_WRITABLE` was never the type gate's cause — that variable does not sit beside `allowOrgOverride`, it flips it (`getMetaTypes` emits `allowOrgOverride: base.allowOrgOverride || isEnvOverridden`), so there was no reachable state in which the old sentence was the right explanation: whenever the hatch is on for a type, that type is writable and no read-only badge renders. The caption now names the per-type registry declaration that actually locked the surface, and keeps the env var where it belongs — as the documented remedy, in the hint.
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,41 @@
*
* Two independent gates share one `writable` switch inside the editor:
* • host gate — `readOnly` prop (read-only package in Studio), and
* • type gate — `allowOrgOverride: false` on the metadata type
* (environment-level OS_METADATA_WRITABLE).
* • type gate — the metadata type offers NO runtime write channel, i.e.
* `allowOrgOverride` AND `allowRuntimeCreate` are BOTH false.
* Either one must disable every checkbox / bulk button / input, hide Save,
* and show a read-only badge — worded for ITS reason, so the badge never
* contradicts the surrounding surface.
*
* ## The type gate is a DISJUNCTION, and this suite used to say otherwise
*
* objectui#4446. Until then the switch read `allowOrgOverride` alone and this
* header called that flag "environment-level OS_METADATA_WRITABLE". Both were
* wrong, and in the same way — they collapsed two different doors into one:
*
* • `allowOrgOverride` = may an ORG OVERLAY a code-shipped item;
* • `allowRuntimeCreate` = may an item be AUTHORED at runtime — which is
* what this editor's Save actually does under a `packageId`
* (`mode: 'draft'` + `packageId`, ADR-0086 P0/P2).
*
* The server refuses only when BOTH are false (`!isOverlayAllowed &&
* !isRuntimeCreateAllowed`, metadata-protocol `protocol.ts`), so gating on the
* first alone locked a surface the server accepts. `permission` is exactly that
* shape on a stock boot — `allowOrgOverride: false` (ADR-0005 forbids per-org
* overlay of a packaged permission set: silent privilege drift) with
* `allowRuntimeCreate: true`, which objectstack#6483 kept open on purpose.
*
* And `OS_METADATA_WRITABLE` was never the type gate's reason: it does not sit
* beside `allowOrgOverride`, it FLIPS it (`getMetaTypes` emits
* `allowOrgOverride: base.allowOrgOverride || isEnvOverridden`). So there is no
* reachable state in which the old badge text was the honest explanation —
* whenever the hatch is on for a type, that type is writable and no read-only
* badge renders at all.
*
* What this suite pins, therefore: the HOST gate is unchanged and still
* dominant (the "Studio 维持包级只读" half of the objectstack#5768 ruling), and
* the TYPE gate now trips only when the type really has no write channel — and
* says so.
*/

import '@testing-library/jest-dom/vitest';
Expand Down Expand Up @@ -58,9 +88,14 @@ function makeClient() {
} as any;
}

// Flipped per-test to drive the TYPE-level gate (resolveResourceConfig reads
// allowOrgOverride straight off the server entry).
// Flipped per-test to drive the TYPE-level gate. Both flags are read straight
// off the server entry, the way DirectoryPage / EmbeddedItemEditor /
// ResourceEditPage read them (objectui#4446).
let allowOrgOverride = true;
// The other half of the disjunction. `false` is the historical default of this
// suite (the key simply did not exist in the fixture, so it read `undefined`);
// the stock-boot `permission` shape sets it true with allowOrgOverride false.
let allowRuntimeCreate = false;
// One stable client per test — the editor's load effect depends on the client
// reference, so a fresh object per render would re-trigger it forever.
let clientImpl: any;
Expand All @@ -70,7 +105,7 @@ vi.mock('./useMetadata', () => ({
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride }],
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride, allowRuntimeCreate }],
}),
}));

Expand All @@ -79,6 +114,7 @@ import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
afterEach(() => {
cleanup();
allowOrgOverride = true;
allowRuntimeCreate = false;
server.saved = [];
});

Expand Down Expand Up @@ -150,14 +186,89 @@ describe('PermissionMatrixEditPage — read-only package gate (host readOnly)',
});

describe('PermissionMatrixEditPage — type-level gate keeps its own wording', () => {
it('allowOrgOverride=false hides Save and shows the environment badge', async () => {
it('no write channel at all (both flags false) hides Save and names the TYPE as the reason', async () => {
allowOrgOverride = false;
allowRuntimeCreate = false;
renderMatrix();
await screen.findByText('Account');

expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
expect(screen.getByLabelText('a_account Read')).toBeDisabled();
// Env-gate reason, untouched by the package gate.
expect(screen.getByText(/OS_METADATA_WRITABLE/)).toBeInTheDocument();

// Type-gate reason, untouched by the package gate — and it names the type,
// not a deployment env var (objectui#4446). The old wording claimed
// "OS_METADATA_WRITABLE not enabled", which no reachable state supports:
// that hatch FLIPS allowOrgOverride to true, so when it is on this badge
// does not render.
expect(screen.queryByText(/OS_METADATA_WRITABLE/)).toBeNull();
const badge = screen.getByText(/no runtime write channel/);
expect(badge).toBeInTheDocument();
// The env var survives as the documented REMEDY, in the hint only.
expect(badge).toHaveAttribute('title', expect.stringContaining('allowRuntimeCreate'));
expect(badge).toHaveAttribute('title', expect.stringContaining('OS_METADATA_WRITABLE'));
// Package wording must NOT be borrowed for a type-gate lock.
expect(screen.queryByText('Read-only', { exact: true })).toBeNull();
});

/**
* ── The defect objectui#4446 was filed for ────────────────────────────────
*
* The stock-boot `permission` shape, measured on a live QA run
* (objectstack#7637): the registry declares `allowOrgOverride: false` and
* `allowRuntimeCreate: true`, and the server ACCEPTS the package-door write
* (`PUT /api/v1/meta/permission/<n>?package=<pkg>` → 200). Pre-fix the editor
* disabled all 207 checkboxes and hid Save anyway, then blamed
* `OS_METADATA_WRITABLE`.
*
* RED-FIRST: this case fails on `origin/main` — Save is absent and every
* control disabled.
*/
it('stock-boot permission shape (allowRuntimeCreate only) is EDITABLE — the server accepts this write', async () => {
allowOrgOverride = false;
allowRuntimeCreate = true;
renderMatrix();
await screen.findByText('Account');

expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled();
expect(screen.getByLabelText('a_account Read')).toBeEnabled();

// Row bulk-set buttons are live too — `writable` drives all of them.
const row = screen.getByText('Account').closest('tr')!;
for (const name of ['R', 'CRUD', 'All', 'None']) {
expect(within(row).getByRole('button', { name })).toBeEnabled();
}

// …and no read-only badge of EITHER wording is claiming otherwise.
expect(screen.queryByText(/OS_METADATA_WRITABLE/)).toBeNull();
expect(screen.queryByText(/no runtime write channel/)).toBeNull();
expect(screen.queryByText('Read-only', { exact: true })).toBeNull();
});

it('MUST NOT CHANGE — the package gate still wins over allowRuntimeCreate', async () => {
// The "Studio 维持包级只读" half of the objectstack#5768 ruling: a
// code-defined package stays locked no matter what the type permits.
allowOrgOverride = false;
allowRuntimeCreate = true;
renderMatrix({ readOnly: true });
await screen.findByText('Account');

expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
for (const box of screen.getAllByRole('checkbox')) expect(box).toBeDisabled();
// …and it is still the PACKAGE that is named as the reason.
const badge = screen.getByText('Read-only', { exact: true });
expect(badge).toHaveAttribute('title', expect.stringContaining('Read-only package'));
expect(screen.queryByText(/no runtime write channel/)).toBeNull();
});

it('MUST NOT CHANGE — an allowOrgOverride package is byte-identical to before', async () => {
allowOrgOverride = true;
allowRuntimeCreate = false;
renderMatrix();
await screen.findByText('Account');

expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled();
expect(screen.getByLabelText('a_account Read')).toBeEnabled();
expect(screen.queryByText('Read-only', { exact: true })).toBeNull();
expect(screen.queryByText(/no runtime write channel/)).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,41 @@
* they disagreed, because they read different inputs:
*
* • the CONTROLS read `!!resolved.allowOrgOverride && !readOnly` — the type
* registry AND the host package gate, which is the right answer;
* • the BADGE read `entry.allowOrgOverride` alone. `permission` is one of the
* 15 overlay-allowed types, so that flag is true and the badge said
* registry AND the host package gate;
* • the BADGE read `entry.allowOrgOverride` alone. `permission` was then one
* of the 15 overlay-allowed types, so that flag was true and the badge said
* "writable" no matter which package you were in.
*
* This suite pins the display side only. The gating assertions here are
* CONTROLS: the B-half ruling explicitly keeps Studio's blanket package-level
* read-only intact ("Studio 维持包级只读") pending the per-type A-half review,
* so a fix that made anything editable would be the wrong fix even though the
* badge assertions would still pass.
*
* ## Amendment — objectui#4446: the CONTROLS were the outlier, not the badge
*
* The paragraph above called the controls' formula "the right answer". That was
* wrong on the type half, and this suite could not see it: every case here
* drives the HOST gate (`readOnly` true/absent) with `allowOrgOverride: true`
* throughout, so it never rendered the one shape where the two disagree.
*
* `WritabilityBadge` is read-only exactly when
* `readOnly || (!allowOrgOverride && !allowRuntimeCreate)`; the controls were
* read-only when `readOnly || !allowOrgOverride`. The gap is precisely
* `!allowOrgOverride && allowRuntimeCreate` — which is the stock-boot
* `permission` shape today (objectstack#6483 set `allowOrgOverride: false` and
* deliberately kept `allowRuntimeCreate: true`). There the badge said
* "create-only" over 207 dead checkboxes.
*
* So the badge already consulted the full disjunction and the controls did not.
* Fixing the controls (`allowOrgOverride || allowRuntimeCreate`) makes the two
* predicates byte-identical rather than merely closer — the convergence is
* pinned below, across all four states, so neither side can drift again.
*
* The host gate is UNTOUCHED and still dominant, so "Studio 维持包级只读" holds
* exactly as before: a read-only package locks this screen whatever the type
* permits. What changed is the type half, and only in the direction the server
* itself already allows (it refuses only when BOTH flags are false).
*/

import '@testing-library/jest-dom/vitest';
Expand Down Expand Up @@ -61,20 +86,31 @@ function makeClient() {
}

// The type registry says this type IS overlay-allowed — the exact condition the
// defect needed. `permission` really is in the server's overlay-allowed list.
// #4036 defect needed. (`permission` was in the server's overlay-allowed list
// when this suite was written; objectstack#6483 has since flipped it to
// `allowOrgOverride: false` + `allowRuntimeCreate: true`. These cases keep the
// original shape on purpose — it is the must-not-change control for #4446 —
// while the convergence describe at the bottom drives the current one.)
let typeFlags: { allowOrgOverride?: boolean; allowRuntimeCreate?: boolean } = {
allowOrgOverride: true,
};

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
entries: [{ type: 'permission', label: 'Permission', ...typeFlags }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

afterEach(cleanup);
afterEach(() => {
cleanup();
typeFlags = { allowOrgOverride: true };
});

async function renderMatrix(props?: { readOnly?: boolean }) {
clientImpl = makeClient();
Expand Down Expand Up @@ -151,3 +187,70 @@ describe('PermissionMatrixEditPage — header writability badge vs the package g
expect(screen.getByLabelText('a_account Read')).toBeEnabled();
});
});

/**
* The convergence itself (objectui#4446). One table, four states, and in each
* one the question "is the header badge read-only?" and the question "are the
* controls locked?" must get the SAME answer. Pre-fix row 3 is the divergence:
* badge "create-only" (not read-only) over disabled controls with no Save.
*/
describe('PermissionMatrixEditPage — badge and controls read ONE predicate (#4446)', () => {
const cases: Array<{
label: string;
flags: { allowOrgOverride?: boolean; allowRuntimeCreate?: boolean };
readOnly?: boolean;
expectWritable: boolean;
}> = [
{
label: 'read-only package dominates both flags',
flags: { allowOrgOverride: true, allowRuntimeCreate: true },
readOnly: true,
expectWritable: false,
},
{
label: 'overlay-allowed type in a writable package',
flags: { allowOrgOverride: true, allowRuntimeCreate: false },
expectWritable: true,
},
{
// The stock-boot `permission` shape — the #4446 defect.
label: 'runtime-creatable-only type in a writable package',
flags: { allowOrgOverride: false, allowRuntimeCreate: true },
expectWritable: true,
},
{
label: 'no write channel at all',
flags: { allowOrgOverride: false, allowRuntimeCreate: false },
expectWritable: false,
},
];

for (const c of cases) {
it(`${c.label} → ${c.expectWritable ? 'writable' : 'locked'}, and both renderings agree`, async () => {
typeFlags = c.flags;
await renderMatrix(c.readOnly ? { readOnly: true } : undefined);

// CONTROLS side.
const save = screen.queryByRole('button', { name: /^Save$/ });
const readBox = screen.getByLabelText('a_account Read');

// BADGE side — the header slot, addressed structurally (see above).
const badgeSaysReadOnly = headerBadgeTexts().includes('read-only');

if (c.expectWritable) {
expect(save).not.toBeNull();
expect(save).toBeEnabled();
expect(readBox).toBeEnabled();
expect(badgeSaysReadOnly).toBe(false);
} else {
expect(save).toBeNull();
expect(readBox).toBeDisabled();
expect(badgeSaysReadOnly).toBe(true);
}

// The invariant, stated as the equality it is: the header never claims a
// writability the controls do not deliver, and never withholds one they do.
expect(badgeSaysReadOnly).toBe(!c.expectWritable);
});
}
});
Loading
Loading