Skip to content
19 changes: 19 additions & 0 deletions .changeset/20142-console-page-nav-entries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/platform-objects': patch
'@objectstack/plugin-audit': patch
---

fix(platform-objects,plugin-audit): Setup and Studio navigation entries for the console's Audit Log and Integrations & APIs pages (#20142)

The console retired its System Hub card wall and its Developer Hub, which had been the only in-app links to several pages, and registered each page under a component-registry key instead. Framework navigation reaches a console page only through a `type: 'component'` item that names such a key, and no item named them, so each page was reachable only by a typed URL. Two entries now name the pages whose capability ships in the open framework:

| Entry | App / group | `componentRef` | Contributed by | Gate |
| --- | --- | --- | --- | --- |
| `nav_audit_log_browser` ("Audit Log Browser") | Setup / Diagnostics, directly under Audit Logs | `audit:log` | `@objectstack/plugin-audit` | none: it lives and dies with the plugin that owns `sys_audit_log` |
| `nav_integrations` ("Integrations & APIs") | Studio / Developer, after Public Forms | `developer:integrations` | `@objectstack/platform-objects` | none beyond Studio's own `studio.access` |

**Two audit entries, on purpose.** The existing Audit Logs entry (the `sys_audit_log` object view) stays. It carries the named list views, search, and the actor and tenant rendered as resolved lookups. The new page adds one filterable table whose detail drawer pretty-prints a change's before and after JSON, where the record page shows `old_value` / `new_value` as raw text. Neither surface replaces the other.

**No entry for the console's AI Approvals page (`ai:approvals`) here.** Under ADR-0029 D7, each capability plugin contributes its own navigation entries into a Setup slot, and the Setup shell does not enumerate capability objects. The AI pending-action queue belongs to the AI capability, whose provider (`@objectstack/service-ai`) ships in Cloud/Enterprise, not in the open framework. Its entry is therefore that capability's to contribute.

The keys are the ones the console registers at the objectui commit this release's console is built from. Labels ship in all four locales (en, zh-CN, ja-JP, es-ES), with their source hashes recorded. Nothing is removed or renamed, and there is nothing to migrate.
2 changes: 1 addition & 1 deletion content/docs/ui/setup-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ anchors are:
| **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` |
| **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` |
| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` |
| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` |
| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs · Audit Log Browser — `plugin-audit` |
| **Integrations** (`group_integrations`) | `plugin-webhooks` |
| **Advanced** (`group_advanced`) | OAuth Applications · Identity Links · User Preferences — `platform-objects` |

Expand Down
144 changes: 144 additions & 0 deletions packages/cli/test/console-page-nav-entries.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #20142 — the Integrations & APIs page, one of the console pages that lost
// their only in-app link when objectui#10520 retired the Developer Hub, read
// back OVER THE WIRE from the composed Studio app.
//
// ---------------------------------------------------------------------------
// What this pins
// ---------------------------------------------------------------------------
// The card's acceptance: "each entry resolves to its registered page (a
// `componentRef` that is registered)". For the entry
// `@objectstack/platform-objects` declares — `nav_integrations` →
// `developer:integrations`, last in Studio's `group_developer` — this file
// reads it from the served body of `GET /api/v1/meta/:type/:name`, after the
// real `SchemaRegistry` registration and the real per-request filter, so a
// regression anywhere between the declaration and the wire turns it red.
//
// The other two pages are pinned where they belong:
// - `nav_audit_log_browser` → `audit:log` is contributed by
// `@objectstack/plugin-audit`; its ref is pinned beside it in
// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`.
// Importing that plugin from this package's tests would resolve to its
// `dist/`, which `check:test-source-alias` refuses for a new import.
// - `ai:approvals` is not this repository's entry: ADR-0029 D7 has each
// capability plugin contribute its own navigation, and the `ai`
// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise.
//
// "Registered" is judged against the keys MEASURED at the commit
// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d):
// `registerDeveloperComponents.tsx` registers `developer:integrations` and
// `registerSystemComponents.tsx` registers `audit:log`. objectui pins its
// registration half in
// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`.
//
// It lives in `packages/cli/test/` for the reason its sibling
// `connect-agent-both-halves-wire.pin.test.ts` states: `cli` is the one
// workspace package that depends on every piece at once — the app shells
// (`@objectstack/platform-objects`), the registry (`@objectstack/objectql`) and
// the per-request filter (`@objectstack/rest`).

import { describe, expect, it, vi } from 'vitest';
import { SchemaRegistry } from '@objectstack/objectql';
import { STUDIO_APP } from '@objectstack/platform-objects/apps';
import { RestServer } from '@objectstack/rest';

type AnyRec = Record<string, any>;

/** The registry keys the pinned console registers for the pages this repo links. */
const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations'];

/**
* The real registration of the Studio shell. `structuredClone` because
* `registerItem` writes the `_lock` envelope onto what it is handed
* (ADR-0010 §3.7).
*/
function composedRegistry(): SchemaRegistry {
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
(registry as AnyRec).logLevel = 'silent';
registry.registerApp(structuredClone(STUDIO_APP), '@objectstack/platform-objects');
return registry;
}

function createMockServer() {
return {
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
};
}

function makeRes() {
const res: AnyRec = { statusCode: 200, body: undefined };
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
res.json = vi.fn((b: unknown) => { res.body = b; return res; });
res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn();
return res;
}

/** A `RestServer` serving the composed Studio app to a caller holding `studio.access`. */
function serve() {
const registry = composedRegistry();
const protocol: AnyRec = {
getDiscovery: vi.fn().mockResolvedValue({
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
}),
getMetaTypes: vi.fn().mockResolvedValue([]),
getMetaItems: vi.fn(async ({ type }: AnyRec) => {
const t = String(type ?? '');
return t === 'app' || t === 'apps' ? registry.getAllApps() : [];
}),
getMetaItem: vi.fn(async ({ name }: AnyRec) => {
const item = registry.getApp(String(name));
return item ? { type: 'app', name, item } : undefined;
}),
findData: vi.fn().mockResolvedValue([]),
};
const rest: AnyRec = new RestServer(
createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any,
);
rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['studio.access'] });
rest.registerRoutes();
return rest;
}

/** `GET /api/v1/meta/apps/:name`, answered as `{ statusCode, body }`. */
async function getApp(rest: AnyRec, name: string) {
const route = rest.getRoutes().find(
(r: AnyRec) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name',
);
if (!route) throw new Error('meta/:type/:name route not registered');
const res = makeRes();
await route.handler(
{ method: 'GET', params: { type: 'apps', name }, query: {}, body: {}, headers: {} }, res,
);
return res;
}

/** The served children of one nav group, or `undefined` when the group is not served. */
function group(app: AnyRec | undefined, groupId: string): AnyRec[] | undefined {
const found = ((app?.navigation ?? []) as AnyRec[]).find((g) => g?.id === groupId);
return found ? ((found.children ?? []) as AnyRec[]) : undefined;
}

const ids = (items: AnyRec[] | undefined) => (items ?? []).map((i) => String(i?.id));

describe('#20142 — the Integrations & APIs nav entry, served from the composed Studio app', () => {
it('developer:integrations is served last in Studio\'s Developer group', async () => {
const studio = await getApp(serve(), 'studio');
expect(studio.statusCode).toBe(200);
const developer = group(studio.body?.item, 'group_developer');
expect(ids(developer)).toEqual([
'nav_api_console', 'nav_flow_runs', 'nav_public_forms', 'nav_integrations',
]);
expect(developer?.at(-1)).toMatchObject({ type: 'component', componentRef: 'developer:integrations' });
});

it('the served ref is a key the pinned console registers', async () => {
const studio = await getApp(serve(), 'studio');
const served = ((studio.body?.item?.navigation ?? []) as AnyRec[])
.flatMap((g) => (g?.children ?? []) as AnyRec[])
.filter((i) => i?.id === 'nav_integrations');
expect(served).toHaveLength(1);
expect(MEASURED_CONSOLE_KEYS).toContain(served[0].componentRef);
});
});
106 changes: 106 additions & 0 deletions packages/platform-objects/src/apps/console-page-nav-entries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #20142 — the framework half of the console pages' navigation contract, for
// the entry this package declares.
//
// objectui#10520 (PR objectui#10576) retired the console's System Hub card wall
// and its Developer Hub, which had been the only in-app links to three pages,
// and registered each page under a component-registry key instead. Framework
// navigation reaches a console page only through a `type: 'component'` item
// whose `componentRef` is such a key, so until an entry names the key the page
// is reachable by typed URL alone. Of the three:
// - `developer:integrations` is Studio's, declared here and pinned below;
// - `audit:log` is contributed by `@objectstack/plugin-audit` (it lives and
// dies with that plugin) and is pinned beside it in
// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`;
// - `ai:approvals` is NOT this repository's to contribute: ADR-0029 D7 has
// each capability plugin contribute its own entries, and the `ai`
// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise.
//
// Why a pin rather than a code comment: `componentRef` is a free string, and
// `validate-nav-target-refs` deliberately does not resolve component refs (the
// registry is not visible to the linter). A misspelt key parses, merges and
// ships, and the console renders "Component not registered" in its place.
// Each repo pins its own half of the seam: objectui pins the registration in
// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`, this
// file pins the ref the entry names.
//
// The keys below were MEASURED, not recalled: read from objectui at the commit
// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d),
// where `registerSystemComponents.tsx` registers `audit:log` and
// `registerDeveloperComponents.tsx` registers `developer:integrations`, both
// imported for their side effect by `apps/console/src/main.tsx`. A pin bump
// that renames one of them must change this list in the same PR.

import { describe, it, expect } from 'vitest';
import { AppSchema } from '@objectstack/spec/ui';

import { STUDIO_APP } from './studio.app.js';

/** The registry keys the pinned console registers for the pages this repo links. */
const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations'] as const;

type NavItem = {
id?: string;
type?: string;
label?: string;
componentRef?: string;
requiresService?: string;
requiredPermissions?: string[];
children?: NavItem[];
};

/** The children of one Studio group, by id. */
const studioGroup = (id: string): NavItem[] => {
const group = ((STUDIO_APP.navigation ?? []) as NavItem[]).find((g) => g.id === id);
expect(group, `Studio lost its ${id} group`).toBeDefined();
return group?.children ?? [];
};

describe('the Integrations & APIs entry targets the console page (#20142)', () => {
const entry = (): NavItem => {
const found = studioGroup('group_developer').find((i) => i.id === 'nav_integrations');
expect(found, 'Studio lost its nav_integrations entry').toBeDefined();
return found!;
};

it('routes to the `developer:integrations` registry key, last in group_developer', () => {
expect(entry()).toMatchObject({
type: 'component',
componentRef: 'developer:integrations',
label: 'Integrations & APIs',
});
expect(studioGroup('group_developer').map((i) => i.id)).toEqual([
'nav_api_console',
'nav_flow_runs',
'nav_public_forms',
'nav_integrations',
]);
});

it('carries no gate beyond Studio\'s own, like its neighbours', () => {
expect(entry().requiresService).toBeUndefined();
expect(entry().requiredPermissions).toBeUndefined();
});

it('the Studio app still satisfies AppSchema', () => {
expect(() => AppSchema.parse(STUDIO_APP)).not.toThrow();
});
});

describe('each ref names a key the pinned console registers (#20142)', () => {
it('the ref this package declares is among the measured keys', () => {
const ref = studioGroup('group_developer').find((i) => i.id === 'nav_integrations')?.componentRef;
expect(MEASURED_CONSOLE_KEYS as readonly (string | undefined)[]).toContain(ref);
});

it('every measured key is a registry KEY, never a console path', () => {
// objectui#2763's boundary: navigation names a key and the console owns the
// URL it resolves to (`developer:integrations` →
// `/apps/APP/component/developer/integrations`).
for (const key of MEASURED_CONSOLE_KEYS) {
expect(key).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/);
expect(key).not.toContain('/');
}
});
});
20 changes: 17 additions & 3 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,9 @@ export const STUDIO_APP: App = {
},
{
// Developer — first-party developer tooling surfaces hosted by the
// console (API console, flow run inspector, public forms registry).
// Registered as built-in components in the console's
// ComponentRegistry under the `developer:*` namespace.
// console (API console, flow run inspector, public forms registry,
// integrations & APIs). Registered as built-in components in the
// console's ComponentRegistry under the `developer:*` namespace.
id: 'group_developer',
type: 'group',
label: 'Developer',
Expand All @@ -327,6 +327,20 @@ export const STUDIO_APP: App = {
componentRef: 'developer:public-forms',
icon: 'file-text',
},
{
// #20142 — the console's Integrations & APIs page: the environment's
// REST base URL, per-object endpoints and an `x-api-key` cURL
// sample. Its only in-app link was a card on the console's Developer
// Hub, which objectui#10520 retired once all four of the hub's
// destinations were registry keys; the console registers this one
// in `registerDeveloperComponents.tsx`. No gate beyond Studio's own
// `studio.access`, like its neighbours.
id: 'nav_integrations',
type: 'component',
label: 'Integrations & APIs',
componentRef: 'developer:integrations',
icon: 'plug-zap',
},
],
},
{
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export const en: TranslationData = {
// Diagnostics
nav_sessions: { label: 'Sessions' },
nav_audit_logs: { label: 'Audit Logs' },
// The console's Audit Log page, beside the object view (#20142).
nav_audit_log_browser: { label: 'Audit Log Browser' },
nav_notifications: { label: 'Notifications' },

// Integrations — every entry here is contributed at RUNTIME by the
Expand Down Expand Up @@ -191,6 +193,7 @@ export const en: TranslationData = {
nav_api_console: { label: 'API Console' },
nav_flow_runs: { label: 'Flow Runs' },
nav_public_forms: { label: 'Public Forms' },
nav_integrations: { label: 'Integrations & APIs' },
group_integration: { label: 'Integration' },
nav_email_templates: { label: 'Email Templates' },
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const esESSourceHashes: Readonly<Record<string, string>> = {
'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df',
'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9',
'apps.setup.navigation.nav_audit_logs.label': '700dd03639b82a6a',
'apps.setup.navigation.nav_audit_log_browser.label': 'f6932710da7ec071',
'apps.setup.navigation.nav_notifications.label': 'e0fdb90b1d31078d',
'apps.setup.navigation.nav_webhooks.label': '8fdd81bc76459a6a',
'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987',
Expand Down Expand Up @@ -152,6 +153,7 @@ export const esESSourceHashes: Readonly<Record<string, string>> = {
'apps.studio.navigation.nav_api_console.label': 'd90e508f1921e8d3',
'apps.studio.navigation.nav_flow_runs.label': '0bdee1504c505862',
'apps.studio.navigation.nav_public_forms.label': '4592e97959994e86',
'apps.studio.navigation.nav_integrations.label': 'edbd97d7f401a5b8',
'apps.studio.navigation.group_integration.label': 'a1f2ddfb5c00c83a',
'apps.studio.navigation.nav_email_templates.label': '3463bce831c833d5',
'dashboards.system_overview.label': '574b2a4b7fb7f979',
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const esES: TranslationData = {

nav_sessions: { label: 'Sesiones' },
nav_audit_logs: { label: 'Registros de Auditoría' },
nav_audit_log_browser: { label: 'Explorador de registros de auditoría' },
nav_notifications: { label: 'Notificaciones' },

// Integrations — contributed at RUNTIME by the owning capability
Expand Down Expand Up @@ -137,6 +138,7 @@ export const esES: TranslationData = {
nav_api_console: { label: 'Consola de API' },
nav_flow_runs: { label: 'Ejecuciones de flujo' },
nav_public_forms: { label: 'Formularios públicos' },
nav_integrations: { label: 'Integraciones y API' },
group_integration: { label: 'Integración' },
nav_email_templates: { label: 'Plantillas de correo' },
},
Expand Down
Loading
Loading