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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/21972-caller-scoped-views-not-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/platform-objects": patch
---

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.

Clause-②: no

- 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.
- 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.
- 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.
- The generated translation bundles follow the new view order. No translated text changed.
- ⛔ No schema, parse, export or accept-set change.
12 changes: 12 additions & 0 deletions .changeset/21972-record-shares-all-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/plugin-sharing": patch
---

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.

Clause-②: no

- `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.
- The Setup entry `nav_record_shares` now names `all_shares` with `viewName`, so it does not depend on the declared order.
- The generated translation bundles follow the new view order. No translated text changed.
- ⛔ No schema, parse, export or accept-set change.
4 changes: 4 additions & 0 deletions packages/platform-objects/src/apps/account.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,14 @@ export const ACCOUNT_APP: App = {
expanded: true,
children: [
{
// Names `mine` like every other self-service entry here (#21972):
// `sys_account` no longer declares its caller-scoped view first,
// so an entry naming no view would open the `all_links` tab.
id: 'nav_account_linked',
type: 'object',
label: 'Linked Accounts',
objectName: 'sys_account',
viewName: 'mine',
icon: 'link-2',
requiresObject: 'sys_account',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// A caller-scoped list view is never an object's FIRST, and every entry that
// wants one names it (#21972 — the family #21960 opened with Setup → Users).
//
// The mechanism: when a route names no view, the console opens the object's
// first declared list view (objectui `ObjectView`: the URL view id, then
// `?view=`, then a view marked `isDefault`, then `views[0]`; these objects mark
// none). So a `{current_user_id}`-filtered view declared first is what an
// administrator lands on from a Setup entry that names no view — their own
// rows, on the page meant for administering everyone's — and what every
// bare-object door opens (the record page's object breadcrumb, the object
// switcher).
//
// Both pins are DERIVED from this package's navigation, never from a hand list
// of objects: the population is every `type: 'object'` entry of
// `SETUP_NAV_CONTRIBUTIONS` and of `ACCOUNT_APP`, and the object each names,
// resolved from this package's own exports.
//
// (a) every object such an entry names declares a first list view that is
// not caller-scoped;
// (b) every such entry whose object declares a caller-scoped view names a
// view (`viewName`) the object declares under that name — and a Setup
// entry names one that is not caller-scoped.
//
// "Caller-scoped" is read off the view itself: `{current_user_id}` anywhere in
// it (the `${current_user_id}` spelling contains it too). That token is
// presentation scope, not access: which rows a caller may read is RLS's
// decision, so the declared order decides which view opens and nothing else.
//
// Reach, stated so a green run is not read wider than it is:
// - Setup entries a PLUGIN contributes at runtime (`nav_record_shares` from
// `@objectstack/plugin-sharing`, `nav_approval_requests` from
// `@objectstack/plugin-approvals`, …) are not in this population: they are
// not visible from here, and this package cannot import the plugins (they
// depend on it).
// - An object an entry names that this package does not declare cannot be
// judged by (a). (b) therefore requires its entry to name a view, and the
// set is pinned exactly below so it cannot grow unnoticed.
// - An object that declares ONLY caller-scoped list views cannot meet (a)
// without a new view, which is not this file's to add. That set is pinned
// exactly too, and (b) holds every entry naming it to a named view.
import { describe, it, expect } from 'vitest';
import { AppSchema, NavigationContributionSchema } from '@objectstack/spec/ui';

import * as PlatformObjects from '../index.js';
import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js';
import { ACCOUNT_APP } from './account.app.js';

type NavItem = {
id?: string;
type?: string;
objectName?: string;
viewName?: string;
children?: NavItem[];
};

type ListView = { name?: string };

type ObjectDef = {
name: string;
fields: Record<string, unknown>;
listViews?: Record<string, ListView>;
};

type Entry = {
id: string;
surface: 'setup' | 'account';
objectName: string;
viewName?: string;
};

const CALLER_TOKEN = '{current_user_id}';

const isCallerScoped = (view: unknown): boolean =>
JSON.stringify(view ?? {}).includes(CALLER_TOKEN);

/** Every `type: 'object'` nav item under `items`, depth-first. */
function objectEntries(items: unknown[] | undefined, surface: Entry['surface']): Entry[] {
const out: Entry[] = [];
const walk = (list: unknown[] | undefined) => {
for (const raw of list ?? []) {
const item = raw as NavItem;
if (!item) continue;
if (item.type === 'object') {
// Thrown, never skipped: an entry this walk cannot read is an entry
// neither pin judges.
if (typeof item.id !== 'string' || typeof item.objectName !== 'string') {
throw new Error(`a ${surface} object entry without an id or objectName: ${JSON.stringify(item)}`);
}
out.push({ id: item.id, surface, objectName: item.objectName, viewName: item.viewName });
}
if (Array.isArray(item.children)) walk(item.children);
}
};
walk(items);
return out;
}

const ENTRIES: Entry[] = [
...SETUP_NAV_CONTRIBUTIONS.flatMap((c) => objectEntries(c.items as unknown[], 'setup')),
...objectEntries(ACCOUNT_APP.navigation as unknown[], 'account'),
];

/** This package's objects by name. Two different definitions under one name is a failure, not a pick. */
const CATALOGUE: Map<string, ObjectDef> = (() => {
const byName = new Map<string, ObjectDef>();
for (const value of Object.values(PlatformObjects)) {
const def = value as unknown as ObjectDef;
if (!def || typeof def !== 'object' || typeof def.name !== 'string') continue;
if (!def.fields || typeof def.fields !== 'object') continue;
const seen = byName.get(def.name);
if (seen && seen !== def) throw new Error(`two different object definitions export the name ${def.name}`);
byName.set(def.name, def);
}
return byName;
})();

const listViewsOf = (name: string): Record<string, ListView> => CATALOGUE.get(name)?.listViews ?? {};

const NAMED_OBJECTS = [...new Set(ENTRIES.map((e) => e.objectName))].sort();
const RESOLVED = NAMED_OBJECTS.filter((name) => CATALOGUE.has(name));
const UNRESOLVED = NAMED_OBJECTS.filter((name) => !CATALOGUE.has(name));

/** Resolved objects whose every declared list view is caller-scoped. */
const ONLY_CALLER_SCOPED = RESOLVED.filter((name) => {
const views = Object.values(listViewsOf(name));
return views.length > 0 && views.every(isCallerScoped);
});

/** The objects (a) judges: resolved, and declaring no list view or at least one unscoped one. */
const JUDGED_BY_A = RESOLVED.filter((name) => !ONLY_CALLER_SCOPED.includes(name));

/** The entries (b) judges: the object declares a caller-scoped view, or cannot be read from here. */
const JUDGED_BY_B = ENTRIES.filter(
(e) => !CATALOGUE.has(e.objectName) || Object.values(listViewsOf(e.objectName)).some(isCallerScoped),
);

describe('the population is derived from Setup and Account navigation (#21972)', () => {
it('reaches object entries in both apps', () => {
expect(ENTRIES.filter((e) => e.surface === 'setup').length).toBeGreaterThan(0);
expect(ENTRIES.filter((e) => e.surface === 'account').length).toBeGreaterThan(0);
});

// Non-vacuity control, not the population: the objects this card reordered
// must still be judged by both pins, or a green run says nothing about them.
it('judges every object whose caller-scoped first view this card moved', () => {
for (const name of ['sys_user', 'sys_api_key', 'sys_session', 'sys_oauth_application', 'sys_account', 'sys_user_preference']) {
expect(JUDGED_BY_A, `(a) no longer judges ${name}`).toContain(name);
expect(JUDGED_BY_B.map((e) => e.objectName), `(b) no longer judges an entry naming ${name}`).toContain(name);
}
});

it('cannot read exactly these named objects from this package', () => {
// `sys_inbox_message` is `@objectstack/service-messaging`'s; the Account
// app's Notifications entry names it, and names `mine`.
expect(UNRESOLVED).toEqual(['sys_inbox_message']);
});

it('finds exactly these named objects declaring only caller-scoped list views', () => {
// `sys_member` declares `mine` alone; only the Account app names it, with
// `viewName: 'mine'`. Meeting (a) would take a new unscoped view, which is
// a decision this card reported rather than made.
expect(ONLY_CALLER_SCOPED).toEqual(['sys_member']);
});
});

describe('(a) a named object declares a first list view that is not caller-scoped (#21972)', () => {
it.each(JUDGED_BY_A)('%s', (name) => {
const [firstName, firstView] = Object.entries(listViewsOf(name))[0] ?? [];
expect(
isCallerScoped(firstView),
`${name} declares the caller-scoped list view "${firstName}" first, so a route naming no view opens the caller's own rows`,
).toBe(false);
});
});

describe('(b) an entry whose object declares a caller-scoped view names its view (#21972)', () => {
it.each(JUDGED_BY_B.map((e) => [`${e.surface} ${e.id} → ${e.objectName}`, e] as const))('%s', (_label, entry) => {
expect(entry.viewName, `${entry.id} names no view, so it opens whatever ${entry.objectName} declares first`).toBeTypeOf(
'string',
);
if (!CATALOGUE.has(entry.objectName)) return;
const view = listViewsOf(entry.objectName)[entry.viewName!];
expect(view, `${entry.objectName} declares no list view "${entry.viewName}"`).toBeDefined();
expect(view.name).toBe(entry.viewName);
if (entry.surface === 'setup') {
expect(isCallerScoped(view), `Setup's ${entry.id} names the caller-scoped view "${entry.viewName}"`).toBe(false);
}
});
});

describe('the named views reach the served apps (#21972)', () => {
it('every Setup contribution parses, keeping each object entry its `viewName`', () => {
for (const contribution of SETUP_NAV_CONTRIBUTIONS) {
const parsed = NavigationContributionSchema.safeParse(contribution);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
const kept = objectEntries(parsed.data?.items as unknown[], 'setup');
expect(kept).toEqual(objectEntries(contribution.items as unknown[], 'setup'));
}
});

it('the Account app parses, keeping each object entry its `viewName`', () => {
const parsed = AppSchema.safeParse(ACCOUNT_APP);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
const kept = objectEntries(parsed.data?.navigation as unknown[], 'account');
expect(kept).toEqual(objectEntries(ACCOUNT_APP.navigation as unknown[], 'account'));
});
});
19 changes: 10 additions & 9 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
items: [
// `viewName` names the tenant-wide list (#21960). With no view named,
// the console opens the object's FIRST declared list view, and
// `sys_user` declares `me` first — a one-row view of the caller — so an
// administrator landed on themselves and read "this organization has
// one user". `me` stays a tab in the view switcher; the Account app's
// profile entry is the `account:profile_card` component, not that view.
// `sys_user` used to declare `me` first — a one-row view of the caller —
// so an administrator landed on themselves and read "this organization
// has one user". Every object entry here names its unscoped view the
// same way (#21972), so no entry depends on the declared order; `me`
// stays a tab in the view switcher.
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', viewName: 'all_users', icon: 'user' },
// The ACTIVE organization's record page (Members / Invitations / Teams
// tabs with the better-auth row actions), rendered inside the app shell
Expand Down Expand Up @@ -105,7 +106,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
// and Sharing Rules / Record Shares by @objectstack/plugin-sharing
// (ADR-0029 K2). Only API Keys (sys_api_key, an identity object owned by
// plugin-auth) remains a platform-objects base entry here.
{ id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', icon: 'key', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', viewName: 'all_keys', icon: 'key', requiredPermissions: ['manage_platform_settings'] },
],
},
// group_approvals is contributed by @objectstack/plugin-approvals, which owns
Expand Down Expand Up @@ -142,7 +143,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
items: [
// Audit Logs (sys_audit_log) is contributed by @objectstack/plugin-audit
// which now owns it (ADR-0029 K2).
{ id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', icon: 'monitor' },
{ id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', viewName: 'all_sessions', icon: 'monitor' },
{ id: 'nav_notifications', type: 'object', label: 'Notification Events', objectName: 'sys_notification', viewName: 'recent', icon: 'bell', requiresObject: 'sys_notification' },
],
},
Expand All @@ -151,7 +152,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
group: 'group_advanced',
priority: BASE_PRIORITY,
items: [
{ id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', icon: 'app-window' },
{ id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', viewName: 'all_apps', icon: 'app-window' },
// No `nav_jwks` here (#7544). `sys_jwks` is the environment's JWT SIGNING
// KEY store (`private_key` — private key material), and it declares
// `enable.apiEnabled: false` / `apiMethods: []`, so the generic data API
Expand Down Expand Up @@ -182,8 +183,8 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
// nav entry for them can only ever render "failed to load". They're
// reachable by id (get) when needed; no browse menu. (Re-adding requires
// enabling `list` on the object — a security decision.)
{ id: 'nav_accounts', type: 'object', label: 'Identity Links', objectName: 'sys_account', icon: 'link-2' },
{ id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', icon: 'sliders' },
{ id: 'nav_accounts', type: 'object', label: 'Identity Links', objectName: 'sys_account', viewName: 'all_links', icon: 'link-2' },
{ id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', viewName: 'all_preferences', icon: 'sliders' },
],
},
];
Loading
Loading