Skip to content

Commit 1c563af

Browse files
fix(platform-objects): Setup identity pages open on the tenant-wide list; a caller-scoped list view is never first (#21983)
Fixes #21972 Clause-②: no ## What changes An administrator who opens Setup → API Keys, Sessions, OAuth Applications, Identity Links, User Preferences or Record Shares now lands on the tenant-wide "All" list. Before this, each of those entries named no view. The console then opened the object's first declared list view, and on every one of them that view was filtered to the caller (`user_id = {current_user_id}`, or `recipient_id` for record shares). The administrator saw only their own rows. This applies triage's rule for the family (6012877503): a caller-scoped list view is never an object's first, and every entry that wants one names it. - **Seven objects are reordered, and nothing else in them changes.** In each, the unscoped "All" view moves to first place and the caller-scoped view follows it. The other views keep their relative order. No view is added, removed or edited. - **Six Setup entries name their unscoped view with `viewName`,** as `nav_users` already does. That is the key the spec already declares on an object navigation item, so there is no new key. - **The Account app's Linked Accounts entry (`nav_account_linked`) now names `mine`.** It was the one Account object entry that relied on the declared order. With `mine` no longer first, it would otherwise have opened `all_links`. - **Two comments are corrected.** - `sys_user`'s `me` comment said RLS stops non-admins reading other users' rows. `member_default` admits the caller's organization's users through `sys_user_org_members`, and the comment now says so. - `nav_users`' comment now says `me` *used to be* first. - **The generated translation bundles follow the new order.** Eight files changed (four in each package), regenerated with `node scripts/check-i18n-bundles.mjs --write`. These are pure reorders of the `_views` keys: no translated text changed (+84 / −84). - **New pins:** `packages/platform-objects/src/apps/caller-scoped-first-list-view.test.ts`, with 29 cases (described below). - **Two changesets, both `patch`:** `@objectstack/platform-objects` and `@objectstack/plugin-sharing`. Each carries the `Clause-②: no` line. ⛔ No new key, no objectui change, no `packages/spec` edit, and no governed surface. In `plugin-sharing`, only the two declared files change (`sys-record-share.object.ts` and `sharing-plugin.ts`), plus their four regenerated bundles. Implemented by the os-dev run of session `session_017ErfyP2Rx7XWHJA27QjyUi` on branch `claude/issue-21972-caller-scoped-views-not-first`. | object | listViews at | old first | new order | Setup entry · `viewName` | Account entry · `viewName` | |:--|:--|:--|:--|:--|:--| | `sys_user` | `sys-user.object.ts:603` | `me` | `all_users`, `me`, `unverified`, `two_factor`, `banned` | `nav_users` · `all_users` (already set by #21971) | none routes to `sys_user` | | `sys_api_key` | `sys-api-key.object.ts:121` | `mine` | `all_keys`, `mine`, `active`, `revoked` | `nav_api_keys` · `all_keys` | `nav_account_api_keys` · `mine` (unchanged) | | `sys_session` | `sys-session.object.ts:113` | `mine` | `all_sessions`, `mine`, `revoked` | `nav_sessions` · `all_sessions` | `nav_account_sessions` · `mine` (unchanged) | | `sys_oauth_application` | `sys-oauth-application.object.ts:210` | `mine` | `all_apps`, `mine`, `active`, `disabled_apps` | `nav_oauth_apps` · `all_apps` | `nav_account_oauth_apps` · `mine` (unchanged) | | `sys_account` | `sys-account.object.ts:80` | `mine` | `all_links`, `mine`, `by_provider` | `nav_accounts` · `all_links` | `nav_account_linked` · `mine` (**new**) | | `sys_user_preference` | `sys-user-preference.object.ts:36` | `mine` | `all_preferences`, `mine`, `by_user` | `nav_user_preferences` · `all_preferences` | none | | `sys_record_share` | `plugin-sharing/src/objects/sys-record-share.object.ts:46` | `granted_to_me` | `all_shares`, `granted_to_me`, `granted_by_me`, `by_object`, `manual_grants`, `rule_grants` | `nav_record_shares` (`sharing-plugin.ts:591`) · `all_shares` | none | ## The dispatch's hypotheses, measured - **H1 holds: each of the seven objects declares an unscoped list view.** Each one's "All" view carries no filter, except `all_sessions`, whose only filter is `revoked_at is_null`. That view is the one now first (table above). Stop condition 2 does not fire. - **H2 holds: the order changes which view opens, not which rows a caller may read.** This was read from `member_default`'s row-level security in `packages/plugins/plugin-security/src/objects/default-permission-sets.ts`. No real-door read was run. - **`sys_api_key`, `sys_session`, `sys_oauth_application` and `sys_user_preference`** carry `_self` policies with `user_id == current_user.id`, operation `all` (`:921`, `:891`, `:955`, `:915`). **`sys_account`** carries one for `select` (`:897`). Each is the same predicate as `mine`, so a member's "All" view returns exactly the rows `mine` returns. - **`sys_user`** carries `sys_user_self` (`:871`) and `sys_user_org_members`, `id in current_user.org_user_ids` (`:885`). So a member's "All Users" view returns their organization's users, and `me` did not. That is the declared staff-directory policy, and the header of the same file names it as intended. It is not an access defect: the member already had those rows through the existing "All Users" tab and through `GET /data/sys_user`, and this diff changes neither. I read stop condition 1 as not met. The reasoning is stated here so the seat can disagree. - **`sys_record_share`:** `member_default` has no wildcard object grant and names no `sys_record_share` permission, so a member reads no rows through either view. The Setup entry also requires `manage_platform_settings`. - **H3 holds: these are the readers of the declared order in this repository.** - **Account entries:** `nav_account_linked` was the only object entry without a view. All six Account object entries now name `mine`. - **Setup entries:** after this change, no Setup entry in `platform-objects` names an object whose first view is caller-scoped. The pin's (a) cases enumerate all of them. - **Code:** `packages/cli/src/commands/lint.ts:168` (`firstListViewKey`) reads the first key only to place a label diagnostic when no list view has a label. Every view here has one. - **Pages:** `sys-user.page.ts` related lists name these objects with `showViewAll: true` and no view. How objectui's "View all" picks a view was not read (NOT MEASURED). - **Dashboards:** `system.datasets.ts` reads `sys_user` and `sys_session` by object, not by view. - **Tests:** none assert a view index. `setup-users-nav-view.test.ts` and `i18n-resolver.object-list-views.test.ts` read views by name. - **objectui (out of scope):** the `views[0]` fallback, the breadcrumb and the object switcher are covered by the reorder itself. - **H4 holds: the reorder moved eight generated files.** These are `packages/platform-objects/src/apps/translations/{en,es-ES,ja-JP,zh-CN}.objects.generated.ts` and `packages/plugins/plugin-sharing/src/translations/{en,es-ES,ja-JP,zh-CN}.objects.generated.ts`. `pnpm check:i18n` first read "platform-objects DRIFTED (4)" and "plugins/plugin-sharing DRIFTED (4)". After `--write` it exits 0. The other seven packages regenerated with no diff. No count or order pin moved. ## Pins `caller-scoped-first-list-view.test.ts`, 29 cases. The population is derived from this package's navigation, not hand-listed. It is every `type: 'object'` entry of `SETUP_NAV_CONTRIBUTIONS` and `ACCOUNT_APP` (18 entries, 13 objects), looked up in this package's own exports. - **(a), 11 cases: a named object's first declared list view carries no `{current_user_id}`.** That is checked anywhere in the view, so the `${current_user_id}` spelling is included. - **(b), 12 cases:** every entry whose object declares a caller-scoped view names a `viewName`. The object must declare that view under that same name, and on a Setup entry the named view must not be caller-scoped. - **Population and non-vacuity, 4 cases:** - both apps contribute object entries; - the six objects this card reordered in this package are judged by both (a) and (b); - the named objects this package cannot read are exactly `sys_inbox_message`, owned by `service-messaging`. (b) therefore requires its entry to name a view, and it names `mine`; - the named objects that declare only caller-scoped views are exactly `sys_member`. Only the Account app names it, with `mine`. - **Parse, 2 cases:** every Setup contribution parses through `NavigationContributionSchema` and the Account app through `AppSchema`, and each object entry keeps its `viewName`. - **#21960's `setup-users-nav-view.test.ts`** stays green (7 of 7). **Reach of the pins.** - **Plugin-contributed entries are outside them.** Setup entries contributed by plugins at runtime (`nav_record_shares`, `nav_approval_requests`, …) are not visible from `platform-objects`, which cannot import the plugins because they depend on it. `plugin-sharing` gets no test file here: the dispatch declares only its two files. - **The sharing half was measured once instead.** Each built plugin was booted with a fake manifest context, the way `check-app-nav-i18n` boots them, at `a4d4688cfd`. That read gives `nav_record_shares → sys_record_share | first=all_shares (unscoped) | viewName=all_shares (unscoped)`. ## Reverse verification (on committed `f757947e6c`, through `scripts/ablation-replace.mjs`) The test imports its subjects by relative path from `src/`, so no `dist/` sits between the mutation and the run. Directions were predicted before each run: one red case each. - **(a): `sys_session` restored to its old order** (`mine`, `all_sessions`, `revoked`), by one literal swap of the two adjacent view blocks. - **Mutation landed:** anchor 1 → 0, replacement 0 → 1, blob `4e46fda0773c` → `884ccaa2b537`. - **Result: 1 failed | 28 passed (29).** The failing case is `(a) … › sys_session`: "sys_session declares the caller-scoped list view "mine" first". - **Restored:** blob `4e46fda0773c` equals HEAD, `git diff HEAD` is empty, and `git status --porcelain` is empty. - **(b): `viewName: 'all_sessions', ` deleted from `nav_sessions`.** - **Mutation landed:** anchor 1 → 0, blob `17f1725c3cad` → `2d7a16c6a894`. - **Result: 1 failed | 28 passed (29).** The failing case is `(b) … › setup nav_sessions → sys_session`: "nav_sessions names no view". - **Restored:** blob `17f1725c3cad` equals HEAD, `git diff HEAD` is empty, and `git status --porcelain` is empty. ## Tests and gates, at `a4d4688cfd` That commit is the merge of `origin/main` at `dcf3eb494a`, which brought only `packages/spec` test files. The whole workspace was built before it (`turbo run build --concurrency=2`, 72 tasks). - **`pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2`: Test Files 62 passed (62), Tests 996 passed (996).** - **`pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2`: Test Files 40 passed (40), Tests 980 passed (980).** - **`pnpm --filter @objectstack/platform-objects typecheck` and `pnpm --filter @objectstack/plugin-sharing typecheck`: both exit 0, with `check:test-typecheck: OK`.** The new test file is in the `tsconfig.test.json` program, counted with `--listFiles`. - **`node scripts/pm/dispatch-gates.mjs --commands` derived 66 commands, and all 66 exited 0.** `--ran` reads "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN". That zero is derived: every line carried its exit code. - **14 are new against the dispatch-time list:** `check-adr-0087-registration` (base and self-test), `check-empty-changeset` (base and self-test), `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - **`check:type-check-debt` ran twice.** The first run was killed by my own batch timeout. The rerun exited 0 in 221 s: "1 ledger entr(ies) re-measured … none above its recorded number". - **Artifact-roster block: 55 families, all run. 52 exited 0.** - `check-closing-target-claim.mjs`, `check-partof-closing-keyword.mjs` and `check-single-claim-paths.mjs` answer NOT WIRED without a PR context. CI runs them with one. - **Symbol-anchor sweeps:** `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all exited 0. - **ESLint, narrowed to the 19 touched TypeScript files:** 19 files, 0 errors, 0 warnings, none reported as ignored. - The population is read from `eslint.config.mjs`: its `packages/**/*.{ts,tsx,mts,cts}` blocks match all 19. - The count comes from `--format json`. - The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **One more member of the family sits outside this card's lane.** - **The defect:** Setup → Approvals → Requests (`nav_approval_requests`, `packages/plugins/plugin-approvals/src/approvals-plugin.ts:147`) names no view. `sys_approval_request` declares `my_pending` first, filtered on `pending_approvers contains {current_user_id}`, so an administrator sees only the requests pending on them. - **How it was measured:** by the same built-plugin read as above. - **Why it is not here:** it is in `domain:services`, and the dispatch allows nothing in that lane beyond the two `plugin-sharing` files. It is reported to the seat on the card. - **Two named objects declare only caller-scoped list views:** `sys_member` (`mine`) and `sys_inbox_message` (`mine`). Only Account entries name them, and those entries name `mine`. No unscoped view is added here; that is triage's decision. Both are pinned as exact sets, so a third object cannot join unnoticed. - **The bare-object doors now open "All" for members too.** RLS scopes the rows (H2). On `sys_user`, a member's object breadcrumb now opens their organization's user list rather than My Profile. - **Sidebar highlight.** Each entry that now names a view lights up only on that view, as `nav_users`, `nav_notifications` and the Account `mine` entries already do. This was not measured in a browser. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7df552 commit 1c563af

21 files changed

Lines changed: 433 additions & 166 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
Setup's identity pages open on the tenant-wide list, not on the administrator's own rows. Before this, Setup → API Keys, Sessions, OAuth Applications, Identity Links and User Preferences opened each object's first declared list view, which was the caller-scoped "My …" view (`user_id = {current_user_id}`), so an administrator saw only their own keys, sessions, applications, links and preferences.
6+
7+
Clause-②: no
8+
9+
- On `sys_api_key`, `sys_session`, `sys_oauth_application`, `sys_account`, `sys_user_preference` and `sys_user`, the unscoped "All" view (`all_keys`, `all_sessions`, `all_apps`, `all_links`, `all_preferences`, `all_users`) is now declared first, and the caller-scoped view (`mine`, `me`) second. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens the "All" view. No view is added, removed or changed.
10+
- The Setup entries `nav_api_keys`, `nav_sessions`, `nav_oauth_apps`, `nav_accounts` and `nav_user_preferences` now name that view with `viewName`, as `nav_users` already did. The Account app's Linked Accounts entry (`nav_account_linked`) now names `mine`, like the other Account entries, so neither app depends on the declared order.
11+
- The "My …" views are still tabs on each page. The declared order decides which view opens, not which rows a caller may read: row-level security still scopes a member's rows.
12+
- The generated translation bundles follow the new view order. No translated text changed.
13+
- ⛔ No schema, parse, export or accept-set change.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
Setup → Record Shares opens on every share, not on the shares granted to the administrator. Before this, the entry named no view, and `sys_record_share` declared the caller-scoped "Granted to Me" view (`recipient_id = {current_user_id}`) first.
6+
7+
Clause-②: no
8+
9+
- `sys_record_share` now declares its unscoped "All" view (`all_shares`) first. "Granted to Me" and "Granted by Me" follow it, still as tabs. No view is added, removed or changed.
10+
- The Setup entry `nav_record_shares` now names `all_shares` with `viewName`, so it does not depend on the declared order.
11+
- The generated translation bundles follow the new view order. No translated text changed.
12+
- ⛔ No schema, parse, export or accept-set change.

‎packages/platform-objects/src/apps/account.app.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,14 @@ export const ACCOUNT_APP: App = {
154154
expanded: true,
155155
children: [
156156
{
157+
// Names `mine` like every other self-service entry here (#21972):
158+
// `sys_account` no longer declares its caller-scoped view first,
159+
// so an entry naming no view would open the `all_links` tab.
157160
id: 'nav_account_linked',
158161
type: 'object',
159162
label: 'Linked Accounts',
160163
objectName: 'sys_account',
164+
viewName: 'mine',
161165
icon: 'link-2',
162166
requiresObject: 'sys_account',
163167
},
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// A caller-scoped list view is never an object's FIRST, and every entry that
4+
// wants one names it (#21972 — the family #21960 opened with Setup → Users).
5+
//
6+
// The mechanism: when a route names no view, the console opens the object's
7+
// first declared list view (objectui `ObjectView`: the URL view id, then
8+
// `?view=`, then a view marked `isDefault`, then `views[0]`; these objects mark
9+
// none). So a `{current_user_id}`-filtered view declared first is what an
10+
// administrator lands on from a Setup entry that names no view — their own
11+
// rows, on the page meant for administering everyone's — and what every
12+
// bare-object door opens (the record page's object breadcrumb, the object
13+
// switcher).
14+
//
15+
// Both pins are DERIVED from this package's navigation, never from a hand list
16+
// of objects: the population is every `type: 'object'` entry of
17+
// `SETUP_NAV_CONTRIBUTIONS` and of `ACCOUNT_APP`, and the object each names,
18+
// resolved from this package's own exports.
19+
//
20+
// (a) every object such an entry names declares a first list view that is
21+
// not caller-scoped;
22+
// (b) every such entry whose object declares a caller-scoped view names a
23+
// view (`viewName`) the object declares under that name — and a Setup
24+
// entry names one that is not caller-scoped.
25+
//
26+
// "Caller-scoped" is read off the view itself: `{current_user_id}` anywhere in
27+
// it (the `${current_user_id}` spelling contains it too). That token is
28+
// presentation scope, not access: which rows a caller may read is RLS's
29+
// decision, so the declared order decides which view opens and nothing else.
30+
//
31+
// Reach, stated so a green run is not read wider than it is:
32+
// - Setup entries a PLUGIN contributes at runtime (`nav_record_shares` from
33+
// `@objectstack/plugin-sharing`, `nav_approval_requests` from
34+
// `@objectstack/plugin-approvals`, …) are not in this population: they are
35+
// not visible from here, and this package cannot import the plugins (they
36+
// depend on it).
37+
// - An object an entry names that this package does not declare cannot be
38+
// judged by (a). (b) therefore requires its entry to name a view, and the
39+
// set is pinned exactly below so it cannot grow unnoticed.
40+
// - An object that declares ONLY caller-scoped list views cannot meet (a)
41+
// without a new view, which is not this file's to add. That set is pinned
42+
// exactly too, and (b) holds every entry naming it to a named view.
43+
import { describe, it, expect } from 'vitest';
44+
import { AppSchema, NavigationContributionSchema } from '@objectstack/spec/ui';
45+
46+
import * as PlatformObjects from '../index.js';
47+
import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js';
48+
import { ACCOUNT_APP } from './account.app.js';
49+
50+
type NavItem = {
51+
id?: string;
52+
type?: string;
53+
objectName?: string;
54+
viewName?: string;
55+
children?: NavItem[];
56+
};
57+
58+
type ListView = { name?: string };
59+
60+
type ObjectDef = {
61+
name: string;
62+
fields: Record<string, unknown>;
63+
listViews?: Record<string, ListView>;
64+
};
65+
66+
type Entry = {
67+
id: string;
68+
surface: 'setup' | 'account';
69+
objectName: string;
70+
viewName?: string;
71+
};
72+
73+
const CALLER_TOKEN = '{current_user_id}';
74+
75+
const isCallerScoped = (view: unknown): boolean =>
76+
JSON.stringify(view ?? {}).includes(CALLER_TOKEN);
77+
78+
/** Every `type: 'object'` nav item under `items`, depth-first. */
79+
function objectEntries(items: unknown[] | undefined, surface: Entry['surface']): Entry[] {
80+
const out: Entry[] = [];
81+
const walk = (list: unknown[] | undefined) => {
82+
for (const raw of list ?? []) {
83+
const item = raw as NavItem;
84+
if (!item) continue;
85+
if (item.type === 'object') {
86+
// Thrown, never skipped: an entry this walk cannot read is an entry
87+
// neither pin judges.
88+
if (typeof item.id !== 'string' || typeof item.objectName !== 'string') {
89+
throw new Error(`a ${surface} object entry without an id or objectName: ${JSON.stringify(item)}`);
90+
}
91+
out.push({ id: item.id, surface, objectName: item.objectName, viewName: item.viewName });
92+
}
93+
if (Array.isArray(item.children)) walk(item.children);
94+
}
95+
};
96+
walk(items);
97+
return out;
98+
}
99+
100+
const ENTRIES: Entry[] = [
101+
...SETUP_NAV_CONTRIBUTIONS.flatMap((c) => objectEntries(c.items as unknown[], 'setup')),
102+
...objectEntries(ACCOUNT_APP.navigation as unknown[], 'account'),
103+
];
104+
105+
/** This package's objects by name. Two different definitions under one name is a failure, not a pick. */
106+
const CATALOGUE: Map<string, ObjectDef> = (() => {
107+
const byName = new Map<string, ObjectDef>();
108+
for (const value of Object.values(PlatformObjects)) {
109+
const def = value as unknown as ObjectDef;
110+
if (!def || typeof def !== 'object' || typeof def.name !== 'string') continue;
111+
if (!def.fields || typeof def.fields !== 'object') continue;
112+
const seen = byName.get(def.name);
113+
if (seen && seen !== def) throw new Error(`two different object definitions export the name ${def.name}`);
114+
byName.set(def.name, def);
115+
}
116+
return byName;
117+
})();
118+
119+
const listViewsOf = (name: string): Record<string, ListView> => CATALOGUE.get(name)?.listViews ?? {};
120+
121+
const NAMED_OBJECTS = [...new Set(ENTRIES.map((e) => e.objectName))].sort();
122+
const RESOLVED = NAMED_OBJECTS.filter((name) => CATALOGUE.has(name));
123+
const UNRESOLVED = NAMED_OBJECTS.filter((name) => !CATALOGUE.has(name));
124+
125+
/** Resolved objects whose every declared list view is caller-scoped. */
126+
const ONLY_CALLER_SCOPED = RESOLVED.filter((name) => {
127+
const views = Object.values(listViewsOf(name));
128+
return views.length > 0 && views.every(isCallerScoped);
129+
});
130+
131+
/** The objects (a) judges: resolved, and declaring no list view or at least one unscoped one. */
132+
const JUDGED_BY_A = RESOLVED.filter((name) => !ONLY_CALLER_SCOPED.includes(name));
133+
134+
/** The entries (b) judges: the object declares a caller-scoped view, or cannot be read from here. */
135+
const JUDGED_BY_B = ENTRIES.filter(
136+
(e) => !CATALOGUE.has(e.objectName) || Object.values(listViewsOf(e.objectName)).some(isCallerScoped),
137+
);
138+
139+
describe('the population is derived from Setup and Account navigation (#21972)', () => {
140+
it('reaches object entries in both apps', () => {
141+
expect(ENTRIES.filter((e) => e.surface === 'setup').length).toBeGreaterThan(0);
142+
expect(ENTRIES.filter((e) => e.surface === 'account').length).toBeGreaterThan(0);
143+
});
144+
145+
// Non-vacuity control, not the population: the objects this card reordered
146+
// must still be judged by both pins, or a green run says nothing about them.
147+
it('judges every object whose caller-scoped first view this card moved', () => {
148+
for (const name of ['sys_user', 'sys_api_key', 'sys_session', 'sys_oauth_application', 'sys_account', 'sys_user_preference']) {
149+
expect(JUDGED_BY_A, `(a) no longer judges ${name}`).toContain(name);
150+
expect(JUDGED_BY_B.map((e) => e.objectName), `(b) no longer judges an entry naming ${name}`).toContain(name);
151+
}
152+
});
153+
154+
it('cannot read exactly these named objects from this package', () => {
155+
// `sys_inbox_message` is `@objectstack/service-messaging`'s; the Account
156+
// app's Notifications entry names it, and names `mine`.
157+
expect(UNRESOLVED).toEqual(['sys_inbox_message']);
158+
});
159+
160+
it('finds exactly these named objects declaring only caller-scoped list views', () => {
161+
// `sys_member` declares `mine` alone; only the Account app names it, with
162+
// `viewName: 'mine'`. Meeting (a) would take a new unscoped view, which is
163+
// a decision this card reported rather than made.
164+
expect(ONLY_CALLER_SCOPED).toEqual(['sys_member']);
165+
});
166+
});
167+
168+
describe('(a) a named object declares a first list view that is not caller-scoped (#21972)', () => {
169+
it.each(JUDGED_BY_A)('%s', (name) => {
170+
const [firstName, firstView] = Object.entries(listViewsOf(name))[0] ?? [];
171+
expect(
172+
isCallerScoped(firstView),
173+
`${name} declares the caller-scoped list view "${firstName}" first, so a route naming no view opens the caller's own rows`,
174+
).toBe(false);
175+
});
176+
});
177+
178+
describe('(b) an entry whose object declares a caller-scoped view names its view (#21972)', () => {
179+
it.each(JUDGED_BY_B.map((e) => [`${e.surface} ${e.id} → ${e.objectName}`, e] as const))('%s', (_label, entry) => {
180+
expect(entry.viewName, `${entry.id} names no view, so it opens whatever ${entry.objectName} declares first`).toBeTypeOf(
181+
'string',
182+
);
183+
if (!CATALOGUE.has(entry.objectName)) return;
184+
const view = listViewsOf(entry.objectName)[entry.viewName!];
185+
expect(view, `${entry.objectName} declares no list view "${entry.viewName}"`).toBeDefined();
186+
expect(view.name).toBe(entry.viewName);
187+
if (entry.surface === 'setup') {
188+
expect(isCallerScoped(view), `Setup's ${entry.id} names the caller-scoped view "${entry.viewName}"`).toBe(false);
189+
}
190+
});
191+
});
192+
193+
describe('the named views reach the served apps (#21972)', () => {
194+
it('every Setup contribution parses, keeping each object entry its `viewName`', () => {
195+
for (const contribution of SETUP_NAV_CONTRIBUTIONS) {
196+
const parsed = NavigationContributionSchema.safeParse(contribution);
197+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
198+
const kept = objectEntries(parsed.data?.items as unknown[], 'setup');
199+
expect(kept).toEqual(objectEntries(contribution.items as unknown[], 'setup'));
200+
}
201+
});
202+
203+
it('the Account app parses, keeping each object entry its `viewName`', () => {
204+
const parsed = AppSchema.safeParse(ACCOUNT_APP);
205+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
206+
const kept = objectEntries(parsed.data?.navigation as unknown[], 'account');
207+
expect(kept).toEqual(objectEntries(ACCOUNT_APP.navigation as unknown[], 'account'));
208+
});
209+
});

‎packages/platform-objects/src/apps/setup-nav.contributions.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
7070
items: [
7171
// `viewName` names the tenant-wide list (#21960). With no view named,
7272
// the console opens the object's FIRST declared list view, and
73-
// `sys_user` declares `me` first — a one-row view of the caller — so an
74-
// administrator landed on themselves and read "this organization has
75-
// one user". `me` stays a tab in the view switcher; the Account app's
76-
// profile entry is the `account:profile_card` component, not that view.
73+
// `sys_user` used to declare `me` first — a one-row view of the caller —
74+
// so an administrator landed on themselves and read "this organization
75+
// has one user". Every object entry here names its unscoped view the
76+
// same way (#21972), so no entry depends on the declared order; `me`
77+
// stays a tab in the view switcher.
7778
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', viewName: 'all_users', icon: 'user' },
7879
// The ACTIVE organization's record page (Members / Invitations / Teams
7980
// tabs with the better-auth row actions), rendered inside the app shell
@@ -105,7 +106,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
105106
// and Sharing Rules / Record Shares by @objectstack/plugin-sharing
106107
// (ADR-0029 K2). Only API Keys (sys_api_key, an identity object owned by
107108
// plugin-auth) remains a platform-objects base entry here.
108-
{ id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', icon: 'key', requiredPermissions: ['manage_platform_settings'] },
109+
{ id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', viewName: 'all_keys', icon: 'key', requiredPermissions: ['manage_platform_settings'] },
109110
],
110111
},
111112
// group_approvals is contributed by @objectstack/plugin-approvals, which owns
@@ -142,7 +143,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
142143
items: [
143144
// Audit Logs (sys_audit_log) is contributed by @objectstack/plugin-audit
144145
// which now owns it (ADR-0029 K2).
145-
{ id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', icon: 'monitor' },
146+
{ id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', viewName: 'all_sessions', icon: 'monitor' },
146147
{ id: 'nav_notifications', type: 'object', label: 'Notification Events', objectName: 'sys_notification', viewName: 'recent', icon: 'bell', requiresObject: 'sys_notification' },
147148
],
148149
},
@@ -151,7 +152,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
151152
group: 'group_advanced',
152153
priority: BASE_PRIORITY,
153154
items: [
154-
{ id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', icon: 'app-window' },
155+
{ id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', viewName: 'all_apps', icon: 'app-window' },
155156
// No `nav_jwks` here (#7544). `sys_jwks` is the environment's JWT SIGNING
156157
// KEY store (`private_key` — private key material), and it declares
157158
// `enable.apiEnabled: false` / `apiMethods: []`, so the generic data API
@@ -182,8 +183,8 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
182183
// nav entry for them can only ever render "failed to load". They're
183184
// reachable by id (get) when needed; no browse menu. (Re-adding requires
184185
// enabling `list` on the object — a security decision.)
185-
{ id: 'nav_accounts', type: 'object', label: 'Identity Links', objectName: 'sys_account', icon: 'link-2' },
186-
{ id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', icon: 'sliders' },
186+
{ id: 'nav_accounts', type: 'object', label: 'Identity Links', objectName: 'sys_account', viewName: 'all_links', icon: 'link-2' },
187+
{ id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', viewName: 'all_preferences', icon: 'sliders' },
187188
],
188189
},
189190
];

0 commit comments

Comments
 (0)