Skip to content

Commit 0df67f2

Browse files
feat(app-shell,fields,console): Setup's positions and permission sets read the registry, through the metadata-admin pages' environment scope (part of objectui#7611) (#12089)
Part of #7611 Clause-②: yes - The claim (6095693499) declared `no` provisionally and said it becomes `yes` if the public surface widens. It widens by one export: `@object-ui/app-shell` gains `ENVIRONMENT_SCOPE_QUERY`, a string constant. No accept set narrows. The changeset is `minor`. _Updated at round 2 (`bc7a86bb6f`): the sections below describe this head. The round reports are 6104663917 and 6106225321; the contract record is 6106517489._ **Draft by dispatch** (objectui AGENTS.md §9 ②). The epic PM of objectstack#15194 moves this PR after objectstack#15204 stage 1 has merged and been measured. Several halves of the card need server or spec changes first; they are listed below with the gate each one waits on. Size: 2,084 changed lines at round 2 (additions plus deletions, tests included), 26 files, against `206505c`. One PR, under the 3,000-line budget. ## What this PR does The Setup catalog for positions and permission sets reads the registry. It reuses the metadata-admin list and editors in an **environment scope** (`?scope=environment`) instead of adding a page family, as ruling 6094171670 asks ("Build it by reuse, not by new pages"): - **The list** (`…/metadata/position?scope=environment`, `…/metadata/permission?scope=environment`) shows every item `GET /api/v1/meta/TYPE` serves, the platform's own sets included. The Studio list still shows one project package's slice. Every link the list emits keeps the scope, and so does the editor's breadcrumb. - **Gated for Setup (#22621 → A).** A caller without `manage_metadata` gets no create affordance and a read-only editor, and the page says why. Under `single` the reason is that the platform administrator defines these items and the caller's organization assigns them. Under a wall the reason is that the operator defines them in Studio. Both editors (generic and permission matrix) now apply this caller gate wherever they render, because the metadata door refuses that caller's save in every scope. The lock banner's "New" offer follows the same gate. - **Holders.** In the scope, the permission-set editor shows the set's holders (`AssignedUsersSection`) and the position editor shows the position's holders (`PositionHoldersSection`, new). Both read and write assignment rows by the item's name. - **`AssignedUsersSection` reads by name.** Grants come from the `permission_set` name column. The positions that distribute a set come from the registry's position definitions (`permissionSets`, the shape on stage 1's branch). The section no longer reads `sys_position_permission_set` or `sys_position` rows. - **The recipient picker** (sharing rules) lists the `position` registry through the console's metadata store, and no longer reads `sys_position` rows. - **The console's `system/roles`, `system/positions` and `system/permissions`** go to the catalog list instead of the object pages. - **The active switch.** The list has an active column and an active/inactive filter, through one seam, `catalog-activation.ts`, described next. ### The active switch writes the activation ledger The switch shows the state of `sys_metadata_activation` (no row means active), read through the data door the way Setup's packaged-actions section already reads it. It writes through objectstack's stage-2c door, `POST /api/v1/security/_activation/:type/:name` with `{ enabled }`. It reads and writes no catalog row, and there is no row-flag fallback. The door's refusals reach the page in the server's own words. The audience anchors `everyone` and `guest` show a disabled switch with the reason, read from the spec's `AUDIENCE_ANCHOR_POSITIONS`. Capabilities have no switch. On objectstack `main`, the door still refuses an item saved through the metadata door (503, "Package is required"). The fix is objectstack#15204 stage 2d (finding F5). Until it lands, that refusal shows on the page. ## Measured against a running objectstack showcase (`main` `d85615dd`, fresh database) | Read | Result | |:--|:--| | `GET /api/v1/meta/permission` | 17 items. Equal to the 17 `sys_permission_set` rows. No item carries `active`, `managedBy` or `_lock`. | | `GET /api/v1/meta/position` | 16 items. Equal to the 16 `sys_position` rows. No item carries `active` or `is_default`. | | `GET /api/v1/meta/capability` | **2 items. There are 11 `sys_capability` rows.** The nine platform capabilities (`manage_metadata`, `manage_users`, `setup.access` and the others) exist only as rows. | | Platform admin `PUT /meta/permission/NAME` | 200. The set is projected to a row (`active: true`, `managed_by: admin`). | | Platform admin `PUT /meta/position/NAME` | 200. **No row is created** (the S7 write-through runs from row to definition only). | | Organization admin `GET /meta/permission`, `/position`, `/capability` | 200 for all three. | | Organization admin `PUT /meta/permission/…`, `/meta/position/…` | 403 `FORBIDDEN`, "Saving a metadata item requires the `manage_metadata` capability." | | Organization admin, the activation door | 403 `PERMISSION_DENIED` (round 2, on `490cb6d9`). The console no longer PATCHes the catalog row. | | Grant by name: `POST sys_user_permission_set` with only `permission_set` | **400 `VALIDATION_FAILED`.** `permission_set_id` is still required. | | Assignment by name to a registry-only position | 400 `VALIDATION_FAILED` on `d85615dd`. **201 since objectstack stage 2a** (round 2, on `490cb6d9`). | The organization-admin persona was the showcase's auditor demo user, promoted to org `admin` (`manage_org_users`, `setup.access`, `setup.write`; no `manage_metadata`). ## Browser verification (objectui at this branch's head, Vite on a private port, proxied to the showcase above) Platform administrator, `single`: - `system/permissions` lands on `/apps/setup/metadata/permission?scope=environment`. The list has 18 rows; every link carries the scope, New is offered, and no read-only reason shows. - **Create:** catalog New, name and label, Save (a draft), then the matrix editor's Save makes it live. `GET /meta/permission/c9_ui_set` answers 200 and the row is projected. **Edit:** granting Read on `showcase_account` and saving answers `{"showcase_account":{"allowRead":true}}`. **Deactivate and reactivate** (round 2, on `490cb6d9`): the switch posts to the activation door. One ledger row reads off and the holder loses the set; switching on returns it. The catalog row is untouched. - A packaged set opens locked, with Clone offered and no Save. - **Positions:** `system/positions` lands on the position catalog (17 rows). A position created from New (draft, then Publish) is live in the registry. The `auditor` page lists its holders. **Assign by name:** adding the phone persona landed `{"position":"auditor"}` in `sys_user_position`. - On a registry-only position (round 2, on `490cb6d9`), adding a holder answers 201. The switch shows the door's F5 refusal until objectstack stage 2d lands. Organization administrator: the list states the reason, offers no New, and its switches are disabled. The set editor has no Save and states the reason, and so does the position editor. Requests the catalog made: `/meta/permission`, `/meta/position`, `/meta/*/NAME/layers`, and, for the switch, `/data/sys_metadata_activation` and the activation door. No request fetched a merged list. The matrix editor's capability picker still reads `/data/sys_capability`; see the gates below. **NOT MEASURED:** a walled posture (`group` / `isolated`). The showcase runs `single`, and the walled reason text is covered only by a unit test. ## What this PR cannot do yet: the server and spec halves, each with its gate 1. **The active switch:** done. The door landed in objectstack stage 2c. Its remainder, for items saved through the metadata door, is objectstack#15204 stage 2d. 2. **Capabilities** (the Capabilities page and `CapabilityMultiSelectField`) still read `sys_capability`. The registry has served 11 of 11 since objectstack#22669, so the server half is met. The capability move stays in part 2. 3. **Granting a permission set by name.** The section reads by name, but a new grant still carries `permission_set_id`. That is one row lookup, `resolveGrantRowId`, at add time. **Gate:** the grant door accepts `permission_set` without `permission_set_id`. 4. **Assigning a registry-only position:** done on the server since objectstack stage 2a (201, measured at round 2). 5. **Approver pickers and the approver directory** (`approverIdentity`, `useApproverDirectory`, `FlowReferenceField`, `flow-node-config`) and the decision-output position picker (`decisionOutputParams`). These follow the **spec**: `APPROVER_VALUE_BINDINGS.position` is `{ source: 'record', object: 'sys_position', valueField: 'name' }`, and `DecisionOutputDef` says a `position` output collects `sys_position` record ids. Contract-first: the spec changes first, then these readers. **Gate:** a registry binding in `@objectstack/spec`, plus a name-valued `position` decision output. 6. **The Setup navigation** (`nav_positions`, `nav_permission_sets` and `nav_capabilities`, all `type: 'object'` in plugin-security) still opens the object pages until stage 8. The catalog URLs for stage 8 are `/apps/setup/metadata/position?scope=environment` and `/apps/setup/metadata/permission?scope=environment`. The object pages' special cases stay until then, because those pages remain reachable: the `RecordDetailView` assignments slot, `recordDelete`'s reset copy, `data-objectstack`'s facet-widget stamp and `PermissionFacetLink`. They become dead code at stage 8 and leave in a cleanup paired with it. 7. **Clone to customize** still runs the `clone_permission_set` row action, which stage 8 deletes. Rebuilding it on the metadata door means deciding which keys a clone carries. `adminScope` is ruled out. `isDefault` would silently add the copy to the everyone baseline. That decision is raised in the report rather than made here. 8. **The position → permission-set binding editor** needs a form field. Stage 1 as landed declares `permissionSets` in `position.form.ts` as a tags field. This PR builds the read side against that shape (`positionsDistributing`); what remains for the editor is a registry picker widget. ## Cloud: the `.objectui-sha` hold this PR needs cloud consumes objectui `main` on framework `56bf27affb` (the v17 line). I read that commit's source. It has no `security-catalog.ts`, no `builtin-positions.ts` registration and no position write-through. Positions are seeded and declared into rows only (`bootstrap-builtin-positions`, `bootstrap-declared-positions`). So `GET /api/v1/meta/position` there serves the declared positions only: no built-in position and no organization-created position. The recipient picker and the position catalog would show cloud's organizations fewer positions than they see today. **Hold:** cloud's `.objectui-sha` must not move past this PR's merge commit until cloud moves to v18 (after C7). ## objectui#7205 This PR does not close it. The Setup catalog pages are no longer `ObjectView`s, so a column-header click there no longer persists an org-wide overlay. That was this re-route's share of the card. The persistence in `ObjectView`'s sort handler is untouched, and it is still live for every other object, so objectui#7205 remains open. ## objectstack#11753 That card answers 404 on REST and on the web, while its neighbours answer 200, so it has been destroyed. What survives is its record in objectstack: the CHANGELOG entry for `ActionParamSchema.carryOver` (spec half, #11992) and ADR-0126 §7.1. objectui never rendered `carryOver`. Its only producers are the five `clone_permission_set` facet params, which stage 8 deletes with the action. A metadata-door clone copies the whole definition and collects only a name and a label, so it needs no carry-over param. Once stage 8 lands, `carryOver` has no producer; that is reported for the PM. ## Reader census (37 files at base `023f00d4`, tests, docs and Markdown excluded) - **Changed (reads):** `AppContent.tsx` (three routes), `AssignedUsersSection.tsx`, `RecipientPickerField.tsx`. - **Read, unchanged, and gated:** `CapabilityMultiSelectField.tsx` (gate 2); `approverIdentity.ts`, `useApproverDirectory.ts`, `decisionOutputParams.ts`, `FlowReferenceField.tsx` and `flow-node-config.ts` (gate 5); `permission-set-clone-dispatch.ts`, `PermissionMatrixEditor.tsx`'s clone path and its four `i18n.ts` clone strings (gate 7). - **Object-page special cases that stay until stage 8 (gate 6):** `RecordDetailView.tsx`, `RecordPermissionAssignmentsRenderer.tsx`, `recordDelete.ts`, `data-objectstack/src/index.ts` and `PermissionFacetLink.tsx`. - **Text only** (comments, docblocks and one component-input description): `ObjectView.tsx`, `capabilityLint.ts`, `fields/src/index.tsx`, `MetadataFieldsPage.tsx`, `InlineFieldInput.tsx`, `RelatedList.tsx`, `plugin-detail/src/index.tsx`, `record-related-list.tsx`, `ObjectForm.tsx` and `field-types.ts`, plus all ten locale files (11 hits, every one a comment). ## Verification Measured at the branch head named in the report. - Type-check (`type-check`, hyphenated): `@object-ui/fields`, `@object-ui/app-shell` and `@object-ui/console` all pass, each after building its dependency closure. - New and updated tests (vitest, run from the repo root): - `ResourceListPage.environmentScope-7611` 9/9; `ResourceEditPage.setupCatalog-7611` 5/5; `PermissionMatrixEditor.setupCatalog-7611` 5/5; - `PositionHoldersSection` 4/4; `catalog-activation` 5/5; `AssignedUsersSection` and its envelope test 9/9; - `RecipientPickerField` 11/11; `AppContent.systemHubRoutes` 22/22. - Full suites of the three affected packages: see the report's `tests` field. - **Ablation** (each mutation committed first, landed on disk with its anchor count 1 → 0, restored from `HEAD` and proven by blob hash and an empty `git diff HEAD`): - removing the environment-scope bypass in the list turned 5 of 9 list pins red; - removing `canAuthor` from the editor's `canWrite` turned exactly the caller-gate pin red (1 failed, 4 passed). - Gates passed: `check:new-line-citations` (0 new), `check:control-bytes`, `check:i18n-keys`, `check:i18n-dead-keys`, `check:side-effects-array`, `check:unreferenced-sources`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:doc-fences`, `check:doc-types` and `check:docs-route-closure`. The changeset gates pass too: presence (21 published source files, 1 changeset) and no-major. - **NOT MEASURED**, because a precondition was not met (unbuilt `@object-ui/cli` and `@object-ui/plugin-ai`, outside this diff's closure): `check:readme-exports` (the entries it could judge had 0 wrong-path and 0 fabricated) and `check:doc-snippets`. Also not run locally: `pnpm lint` and the eager-closure budget, which needs a console production build. Both run in CI. ## Acceptance notes - In the Setup catalog, a position or permission set created with New is saved as a draft first. The permission matrix's Save, or the position editor's Publish, makes it live. Today's Setup object pages wrote a live row in one step. - The `permission` list registration dropped its `managedBy` "Source" column. The registry serves no such key, so it read "Custom" for every set, the platform's own included. The list's own Source badge (Artifact or Runtime, from `_packageId` and `_provenance`) remains. - Until stage 1 lands, a set's "via position" holders are empty on `main`. On `main` the bindings are junction rows, which this section no longer reads. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a194b4e commit 0df67f2

26 files changed

Lines changed: 1948 additions & 136 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
'@object-ui/fields': minor
4+
'@object-ui/console': minor
5+
---
6+
7+
The Setup catalog for positions and permission sets reads the registry. It is built from the metadata-admin list and editors in their environment scope, not from new pages (objectui#7611, objectstack ADR-0131 D3/D7).
8+
9+
- **The environment scope (`?scope=environment`).** `…/metadata/position` and `…/metadata/permission` with this parameter list every item the registry serves for the type, including the platform's own sets. Without it, the list shows one project package's items, as Studio does. Every link the pages emit keeps the scope: the item links, the create link and the editor's breadcrumb. `ENVIRONMENT_SCOPE_QUERY` is exported for hosts that route their own URLs onto it.
10+
- **Gated for Setup.** A caller without `manage_metadata` gets no create affordance on the list and a read-only editor, and the page says why. Under `single`, the reason is that the platform administrator defines these items and the caller's organization assigns them. Under a wall, it is that the operator defines them for every organization. The generic editor and the permission-set editor now apply this caller gate wherever they render. The metadata door refuses that caller's save anyway; this states the refusal before the click.
11+
- **The active switch.** The catalog list has an active column and an active/inactive filter. Both show the activation ledger's state (`sys_metadata_activation`), which is what the authorization resolver reads; an item with no ledger row is active. The switch writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name` with `{ enabled }`, so switching a set or a position off takes it away from its holders, and switching it on gives it back. It reads and writes no catalog row, so the catalog row's own `active` column is neither shown nor changed. The door's refusals are shown in the server's own words. The audience anchors `everyone` and `guest`, which the door refuses to switch, show a disabled switch and the reason. The door is objectstack's ADR-0131 stage 2c.
12+
- **Holders.** In the environment scope, the permission-set editor shows who holds the set under its definition, and the position editor shows who holds the position. A set's holders are read by name: grants come from the `permission_set` column, and the positions that distribute the set come from the registry's position definitions (`permissionSets`). They are no longer read from `sys_position_permission_set` or `sys_position` rows, so on a framework from before objectstack's ADR-0131 stage 1, where positions declare no `permissionSets`, a set lists no holders through a position. A new grant still carries `permission_set_id`, because the server does not accept a grant by name alone yet. A position is assigned by its name.
13+
- **The sharing-rule recipient picker lists the `position` registry** through the console's metadata store. It no longer reads `sys_position` rows. Without a metadata store mounted, the `position` kind falls back to the plain text input.
14+
- **The console's `system/roles`, `system/positions` and `system/permissions`** now go to the catalog list, not to the object pages.

‎apps/console/src/AppContent.tsx‎

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { lazy, Suspense, useMemo } from 'react';
1212
import { Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom';
13-
import { DefaultAppContent, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell';
13+
import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell';
1414
import { MePermissionsProvider } from '@object-ui/permissions';
1515
import { createAuthenticatedFetch } from '@object-ui/auth';
1616
import type { DataSource } from '@object-ui/types';
@@ -129,17 +129,12 @@ function MetadataRedirect() {
129129
* record-scoped `nav_organization`
130130
* needs a runtime `{current_org_id}`
131131
* that a static redirect cannot resolve)
132-
* roles -> sys_position (ADR-0090 D3 renamed `sys_role` ->
133-
* `sys_position`; the sidebar's "Roles"
134-
* and the retired hub's "Positions" were
135-
* the same surface under old/new
136-
* vocabulary)
137-
* positions -> sys_position (`nav_positions`)
138-
* permissions -> sys_permission_set(`nav_permission_sets`; this one was
139-
* held back in PR #3673 and is resolved
140-
* by objectui#3655's decision A — the
141-
* reasoning is recorded at the route
142-
* block below)
132+
*
133+
* Three of the five no longer land on an object at all (objectui#7611):
134+
* `roles`, `positions` and `permissions` name the security CATALOG, which
135+
* ADR-0131 D3 moves into the environment registry, so they forward onto the
136+
* Setup catalog — the metadata-admin list in its environment scope — through
137+
* {@link SystemCatalogRedirect} below.
143138
*
144139
* Same shape as `ObjectRedirect` / `MetadataRedirect` above (a legacy URL is
145140
* translated, the page is not resurrected), including their treatment of
@@ -154,6 +149,27 @@ function SystemObjectRedirect({ objectName }: { objectName: string }) {
154149
return <Navigate to={`${prefix}/${objectName}`} replace />;
155150
}
156151

152+
/**
153+
* Forwards the retired `system/{roles,positions,permissions}` console pages
154+
* onto the Setup CATALOG (objectui#7611): the metadata-admin list of the
155+
* registry type, in its environment scope (`…/metadata/<type>?scope=environment`,
156+
* `catalog-scope.ts` in `@object-ui/app-shell`).
157+
*
158+
* ADR-0131 D3 gives positions and permission sets one home, the environment
159+
* registry, and D7 says Setup lists that registry; the ruling that unblocked
160+
* objectui#7611 says the Setup pages are the existing metadata-admin pages,
161+
* re-routed. The words map as they always did — `roles` was ADR-0090 D3's old
162+
* name for positions, and objectui#3655 decision A bound `permissions` to the
163+
* permission-set surface (layer 2), not the capability definitions (layer 1).
164+
* Same prefix handling, and the same treatment of `location.search` /
165+
* `location.hash`, as {@link SystemObjectRedirect}.
166+
*/
167+
function SystemCatalogRedirect({ type }: { type: 'position' | 'permission' }) {
168+
const location = useLocation();
169+
const prefix = location.pathname.replace(/\/system\/[^/]+\/?$/, '');
170+
return <Navigate to={`${prefix}/metadata/${type}?${ENVIRONMENT_SCOPE_QUERY}`} replace />;
171+
}
172+
157173
/**
158174
* The bare `…/system` landing: forwards onto `…/system/settings`, the settings
159175
* hub (objectui#3743).
@@ -238,8 +254,11 @@ export const systemRoutes = (
238254
<Route path="system/metadata" element={<MetadataRedirect />} />
239255
<Route path="system/metadata/:metadataType" element={<MetadataRedirect />} />
240256
<Route path="system/metadata/:metadataType/:itemName" element={<MetadataRedirect />} />
241-
{/* Legacy URL redirects → the framework-owned system objects (objectui#3655).
242-
All five resolve now. `system/permissions` was the one held back in PR
257+
{/* Legacy URL redirects → the framework-owned system objects (objectui#3655),
258+
and — since objectui#7611 — the Setup catalog for the last three
259+
(`SystemCatalogRedirect`: positions and permission sets are registry
260+
items, ADR-0131 D3). The history below is why `permissions` means the
261+
permission-set surface. All five resolve. `system/permissions` was the one held back in PR
243262
#3673: the framework splits what this console calls "Permissions" into
244263
TWO Setup entries, and picking one on a hunch would have bound every
245264
future click and bookmark to a surface nobody chose.
@@ -269,9 +288,9 @@ export const systemRoutes = (
269288
distinction that actually holds. */}
270289
<Route path="system/users" element={<SystemObjectRedirect objectName="sys_user" />} />
271290
<Route path="system/organizations" element={<SystemObjectRedirect objectName="sys_organization" />} />
272-
<Route path="system/roles" element={<SystemObjectRedirect objectName="sys_position" />} />
273-
<Route path="system/positions" element={<SystemObjectRedirect objectName="sys_position" />} />
274-
<Route path="system/permissions" element={<SystemObjectRedirect objectName="sys_permission_set" />} />
291+
<Route path="system/roles" element={<SystemCatalogRedirect type="position" />} />
292+
<Route path="system/positions" element={<SystemCatalogRedirect type="position" />} />
293+
<Route path="system/permissions" element={<SystemCatalogRedirect type="permission" />} />
275294
</>
276295
);
277296

‎apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx‎

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,9 @@ import { systemRoutes } from '../AppContent';
240240
const chain: string[] = [];
241241
function ChainRecorder() {
242242
const location = useLocation();
243-
const here = location.pathname;
243+
// The query string rides along: the Setup catalog's scope is a query
244+
// parameter (objectui#7611), and every other landing here carries none.
245+
const here = location.pathname + location.search;
244246
if (chain[chain.length - 1] !== here) chain.push(here);
245247
return null;
246248
}
@@ -269,19 +271,12 @@ beforeEach(() => {
269271

270272
describe('system-hub entries reach the framework system objects (objectui#3655)', () => {
271273
/**
272-
* All five. `roles` and `positions` converge on ONE object on purpose:
273-
* ADR-0090 D3 renamed `sys_role` -> `sys_position`, so the sidebar's "Roles"
274-
* and the retired hub's "Positions" were the same surface in old and new
275-
* vocabulary.
276-
* `permissions` -> `sys_permission_set` is objectui#3655's decision A (see
277-
* the file docblock); it landed one PR after the other four.
274+
* The two that still name an OBJECT. The other three name the security
275+
* catalog and are measured in the next describe (objectui#7611).
278276
*/
279277
it.each([
280278
['/apps/setup/system/users', '/apps/setup/sys_user', 'sys_user'],
281279
['/apps/setup/system/organizations', '/apps/setup/sys_organization', 'sys_organization'],
282-
['/apps/setup/system/roles', '/apps/setup/sys_position', 'sys_position'],
283-
['/apps/setup/system/positions', '/apps/setup/sys_position', 'sys_position'],
284-
['/apps/setup/system/permissions', '/apps/setup/sys_permission_set', 'sys_permission_set'],
285280
])('%s reaches %s in ONE hop', async (url, target, objectName) => {
286281
renderConsoleAt(url);
287282

@@ -347,6 +342,54 @@ describe('system-hub entries reach the framework system objects (objectui#3655)'
347342
});
348343
});
349344

345+
describe('the catalog entries reach the Setup catalog (objectui#7611)', () => {
346+
/**
347+
* `roles`, `positions` and `permissions` name the security CATALOG, which
348+
* ADR-0131 D3 moves into the environment registry, so they land on the
349+
* metadata-admin list of the registry type in its environment scope. `roles`
350+
* and `positions` still converge on ONE surface (ADR-0090 D3 renamed
351+
* `sys_role` -> `sys_position`), and `permissions` still means the
352+
* permission-set surface (objectui#3655 decision A).
353+
*/
354+
it.each([
355+
['/apps/setup/system/roles', '/apps/setup/metadata/position?scope=environment', 'position'],
356+
['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment', 'position'],
357+
['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment', 'permission'],
358+
])('%s reaches %s in ONE hop', async (url, target, type) => {
359+
renderConsoleAt(url);
360+
361+
expect(await screen.findByTestId('metadata-resource-list-page')).toHaveTextContent(type);
362+
expect(chain).toEqual([url, target]);
363+
// Never the row page the URL used to land on.
364+
expect(screen.queryByTestId('object-view')).not.toBeInTheDocument();
365+
});
366+
367+
it('preserves the app prefix', () => {
368+
renderConsoleAt('/apps/my-app/system/permissions');
369+
370+
expect(chain).toEqual([
371+
'/apps/my-app/system/permissions',
372+
'/apps/my-app/metadata/permission?scope=environment',
373+
]);
374+
});
375+
376+
/**
377+
* The zero-app branch declares the metadata routes too, so the catalog is
378+
* reachable on a deployment with no app — unlike the object pages the three
379+
* URLs used to land on, which end at the "No Apps Configured" guard there.
380+
*/
381+
it.each([
382+
['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment'],
383+
['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment'],
384+
])('with no apps, %s still reaches the catalog', async (url, target) => {
385+
metadataApps = [];
386+
renderConsoleAt(url);
387+
388+
expect(await screen.findByTestId('metadata-resource-list-page')).toBeInTheDocument();
389+
expect(chain).toEqual([url, target]);
390+
});
391+
});
392+
350393
describe('zero-app branch — measured, not asserted away (objectui#3655)', () => {
351394
/**
352395
* On a zero-app deployment this is the branch the sidebar's Users /
@@ -369,9 +412,6 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () =
369412
it.each([
370413
['/apps/setup/system/users', '/apps/setup/sys_user'],
371414
['/apps/setup/system/organizations', '/apps/setup/sys_organization'],
372-
['/apps/setup/system/roles', '/apps/setup/sys_position'],
373-
['/apps/setup/system/positions', '/apps/setup/sys_position'],
374-
['/apps/setup/system/permissions', '/apps/setup/sys_permission_set'],
375415
])('%s still redirects, and the target is the no-apps empty state', async (url, target) => {
376416
metadataApps = [];
377417
renderConsoleAt(url);

‎content/docs/guide/console-architecture.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ full list:
9898
Read those two route trees in the source rather than trusting a hand-copied table to stay
9999
current.
100100

101+
The Setup catalog for positions and permission sets is not a separate route family. It is
102+
the metadata-admin list and editor with the query parameter `scope=environment`, for
103+
example `/apps/setup/metadata/permission?scope=environment`. That scope lists every item
104+
the registry serves for the type and adds the Setup half of an item, which is who holds
105+
it. The console's legacy `system/roles`, `system/positions` and `system/permissions`
106+
URLs forward there. The `@object-ui/app-shell` README describes the scope under "The
107+
Setup catalog".
108+
101109
| Route Pattern | Component | Purpose |
102110
|---------------|-----------|---------|
103111
| `/apps/:appName` | Home redirect | Redirects to the first object in navigation |

‎packages/app-shell/README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,43 @@ enable, or disable packages; direct `/metadata/package` links redirect there.
478478
The Studio sidebar also flattens the root Overview group so Home and package
479479
navigation sit directly under the package selector.
480480

481+
### The Setup catalog: the environment scope (`?scope=environment`)
482+
483+
Positions and permission sets live in the environment registry (objectstack
484+
ADR-0131 D3), and Setup lists that registry (D7). Setup does not add a page
485+
family for them: it uses the same metadata-admin list and editors at
486+
`…/metadata/position` and `…/metadata/permission`, with the query parameter
487+
`scope=environment` (`ENVIRONMENT_SCOPE_QUERY`, from
488+
`views/metadata-admin/catalog-scope.ts`). The scope changes three things:
489+
490+
- **The list** shows every item the registry serves for the type, the
491+
platform's own sets included. It is not narrowed to one project package.
492+
Every link it emits keeps the scope, and so does the editor's breadcrumb.
493+
- **The gates.** A caller without `manage_metadata` gets no create affordance
494+
and a read-only editor, and the page says why in the deployment's own terms.
495+
Under `single` the platform administrator defines these items. Under a wall
496+
the operator defines them in Studio. In both cases the organization assigns
497+
them. The editors apply the caller gate in every scope, because the metadata
498+
door refuses that caller's save in every scope.
499+
- **The Setup half of an item.** It renders under the definition: a permission
500+
set's holders (`AssignedUsersSection`), and a position's holders
501+
(`PositionHoldersSection`). Both read and write assignment rows by the
502+
item's name.
503+
504+
The list has an active switch and an active/inactive filter, through
505+
`catalog-activation.ts`. Both show the activation ledger's state
506+
(`sys_metadata_activation`, read through the data door), which is what the
507+
authorization resolver reads: an item with no ledger row is active. The switch
508+
writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name`
509+
with `{ enabled }` (objectstack's ADR-0131 stage 2c), and reads or writes no
510+
catalog row. A refusal from the door (`403` for a caller who may not switch, or
511+
for switching off the last administrator who can sign in; `404`; `503`) is
512+
shown in the server's own words. The audience anchors `everyone` and `guest`,
513+
which the door refuses to switch, show a disabled switch and the reason.
514+
Capabilities are not in this scope yet: the Capabilities page and the
515+
capability picker still read `sys_capability` rows, and a capability has no
516+
switch.
517+
481518
### Package-less flows (`/studio/~org/automations`)
482519

483520
A flow that belongs to no package — a clone of a packaged flow is one, by

‎packages/app-shell/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,10 @@ export { MetadataQuickFind } from './views/metadata-admin/QuickFind.js';
425425
export { PageShell as MetadataPageShell } from './views/metadata-admin/PageShell.js';
426426
export { SchemaForm } from './views/metadata-admin/SchemaForm.js';
427427
export { LayeredDiff } from './views/metadata-admin/LayeredDiff.js';
428+
// objectui#7611 — the Setup catalog's URL scope (`?scope=environment`), for a
429+
// host that routes its own legacy URLs onto the catalog (the console's
430+
// `system/positions` / `system/permissions`). A dependency-free leaf.
431+
export { ENVIRONMENT_SCOPE_QUERY } from './views/metadata-admin/catalog-scope.js';
428432
export {
429433
registerMetadataResource,
430434
getMetadataResource,

‎packages/app-shell/src/services/builtinComponents.tsx‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,15 +197,14 @@ registerMetadataResource({
197197
domain: 'security',
198198
EditPage: PermissionMatrixEditPage,
199199
searchableFields: ['name', 'label'],
200+
// objectui#7611 — no `managedBy` column: the registry serves no such key on a
201+
// permission set (measured on objectstack main), so it read "Custom" for every
202+
// set, the platform's own included. Provenance is the list's own Source badge,
203+
// read from the served `_packageId` / `_provenance`.
200204
listColumns: [
201205
{ key: 'name', label: 'Name', width: '30%' },
202206
{ key: 'label', label: 'Label', width: '30%' },
203-
{
204-
key: 'managedBy',
205-
label: 'Source',
206-
width: '15%',
207-
render: (v) => (v === 'package' ? 'Package' : 'Custom'),
208-
},
207+
{ key: 'description', label: 'Description' },
209208
],
210209
});
211210

‎packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,17 @@ const asBareArray: Envelope = (rows) => rows;
4747
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });
4848
const asItems: Envelope = (rows) => ({ items: rows, total: rows.length });
4949

50-
/** A directory holding one set with one DIRECT grantee — nothing else. */
50+
/**
51+
* A directory holding one set with one DIRECT grantee — nothing else. The
52+
* grant names its set (objectui#7611: holders are read by the set's NAME); no
53+
* position distributes it (no metadata store is mounted, so the registry
54+
* answers no positions).
55+
*/
5156
const DIRECTORY: Record<string, Record<string, unknown>[]> = {
5257
sys_permission_set: [{ id: 'ps_1', name: 'showcase_contributor' }],
53-
sys_user_permission_set: [{ id: 'grant_1', permission_set_id: 'ps_1', user_id: 'u_direct' }],
54-
sys_position_permission_set: [],
55-
sys_position: [],
58+
sys_user_permission_set: [
59+
{ id: 'grant_1', permission_set_id: 'ps_1', permission_set: 'showcase_contributor', user_id: 'u_direct' },
60+
],
5661
sys_user_position: [],
5762
sys_user: [{ id: 'u_direct', name: 'Direct Dana', email: 'dana@example.com' }],
5863
};

0 commit comments

Comments
 (0)