Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
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
12 changes: 12 additions & 0 deletions .changeset/7611-registry-capability-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
---

Publish from Home no longer reports a `defineCapability` declaration as a capability that resolves nowhere (objectui#7611).

The advisory capability-reference lint that runs over pending drafts before **Publish** handed `@objectstack/lint`'s `validateCapabilityReferences` the drafts and the permission sets only, so a capability a package declares with `defineCapability` and no permission set grants yet was flagged on every object, field, action or app draft that required it. The lint now also reads the capabilities the registry serves (`GET /api/v1/meta/capability`) into the rule's `capabilities` source. A misspelled capability is still reported. If that read fails, the lint runs on the other sources as before and never blocks publishing.

Internal, no behaviour change: `approverIdentity` drops a display-column entry for `sys_position`, which no approver binding has named since `position` became registry-bound.

Clause-②: no
Metadata-protocol: none
4 changes: 4 additions & 0 deletions content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ that everyone will see the button there.
An unheld capability is not the same problem as a *misspelled* one: a
capability string registered nowhere is caught separately, by the advisory
capability-reference lint that runs over pending drafts during **Publish**.
It resolves a reference against the platform's built-in capabilities, the
capabilities the registry serves (a package's `defineCapability` declarations)
and every permission set's `systemPermissions`, so a declared capability that
no set grants yet is not reported.

## Configuration

Expand Down
12 changes: 6 additions & 6 deletions packages/app-shell/src/hooks/useApproverDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
*
* ## What it reads, and why the staffing leg is separate
*
* - one `find` per distinct directory OBJECT (`sys_position`, `sys_team`,
* `sys_business_unit`, `sys_user`), batched with `$in` over that object's
* values — the label leg for a kind the spec binds to a DATA row;
* - one `find` per distinct directory OBJECT a `data` binding names (a team,
* department or user row), batched with `$in` over that object's values —
* the label leg for a kind the spec binds to a DATA row;
* - one registry read per metadata TYPE, for a kind the spec binds to the
* registry instead (`{ source: 'registry', type }`, objectui#7611 Q3b):
* the console's metadata store, `ensureType(<type>)` — the
Expand All @@ -38,9 +38,9 @@
* the position's machine NAME under either binding, so this leg does not
* depend on which one the spec declares.
*
* The two legs fail INDEPENDENTLY. A business-app reader may hold list access to
* `sys_position` and none to `sys_user_position`; `find` rejects that with
* `OBJECT_API_DISABLED` rather than an empty page. Folding the legs together
* The two legs fail INDEPENDENTLY. A business-app reader may read the position
* registry and hold no list access to `sys_user_position`; `find` rejects that
* with `OBJECT_API_DISABLED` rather than an empty page. Folding the legs together
* would let a permission error render as `销售经理(暂无在岗人员)` — a fabricated
* fact on a governance surface. A failed staffing leg therefore leaves `holders`
* `undefined` (unknown), which `approverDisplay` renders as silence, while the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7611 — the pre-publish capability lint reads the capabilities the
* registry serves.
*
* `validateCapabilityReferences` resolves a reference against three sources:
* the built-in platform capabilities, `stack.capabilities` (the
* `defineCapability` declarations) and `systemPermissions` grants. The console
* assembled only the first and the last, so a capability declared with
* `defineCapability` and granted by no permission set was reported as
* resolving nowhere on Publish from Home. The pseudo-stack now carries the
* registry's capability list (`GET /api/v1/meta/capability`).
*
* The rule is `@objectstack/lint`'s own, not a stand-in: the verdict pinned
* here is the one the console's Publish toast shows.
*/

import { describe, it, expect, vi } from 'vitest';
import { validateCapabilityReferences } from '@objectstack/lint';
import { lintDraftCapabilityReferences } from './capabilityLint';

const draft = (item: Record<string, unknown>) => Promise.resolve({ item });

/** Two object drafts: one names a declared-only capability, one a misspelling. */
const REQUIRED: Record<string, string> = {
declared_only: 'acme.restricted_ops',
misspelled: 'acme.restricted_opz',
};

const PENDING = [
{ type: 'object', name: 'declared_only', packageId: null },
{ type: 'object', name: 'misspelled', packageId: null },
];

function fakeClient(capabilityList: () => Promise<unknown[]>) {
return {
getDraft: vi.fn((type: string, name: string) =>
type === 'object' ? draft({ name, requiredPermissions: [REQUIRED[name]] }) : Promise.resolve(null),
),
list: vi.fn((type: string) =>
type === 'capability'
? capabilityList()
: Promise.resolve([{ item: { name: 'member_default', systemPermissions: [] } }]),
),
};
}

describe('lintDraftCapabilityReferences — registry capabilities (objectui#7611)', () => {
it('resolves a capability the registry serves though no permission set grants it', async () => {
const client = fakeClient(() =>
Promise.resolve([{ name: 'acme.restricted_ops', label: 'Restricted Operations', scope: 'org' }]),
);

const warnings = await lintDraftCapabilityReferences(client, PENDING, validateCapabilityReferences);

expect(client.list).toHaveBeenCalledWith('capability', {});
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('object "misspelled"');
expect(warnings[0]).toContain('"acme.restricted_opz"');
});

it('reads the registry row as the list serves it, `{ item }` envelope included', async () => {
const client = fakeClient(() => Promise.resolve([{ item: { name: 'acme.restricted_ops', scope: 'org' } }]));

const warnings = await lintDraftCapabilityReferences(client, PENDING, validateCapabilityReferences);

expect(warnings.map((w) => w.split(':')[0])).toEqual(['object "misspelled"']);
});

it('CONTROL — the same drafts warn twice when the registry does not serve the capability', async () => {
const client = fakeClient(() => Promise.resolve([]));

const warnings = await lintDraftCapabilityReferences(client, PENDING, validateCapabilityReferences);

expect(warnings.map((w) => w.split(':')[0])).toEqual(['object "declared_only"', 'object "misspelled"']);
});

it('a failed capability read leaves the pass advisory: the rule still runs on the other sources', async () => {
const client = fakeClient(() => Promise.reject(new Error('network')));

const warnings = await lintDraftCapabilityReferences(client, PENDING, validateCapabilityReferences);

expect(warnings).toHaveLength(2);
});
});
18 changes: 14 additions & 4 deletions packages/app-shell/src/preview/capabilityLint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,11 @@ async function loadRule(): Promise<CapabilityRule | null> {
* human-readable warning strings (empty = clean or lint unavailable).
*
* The author-time "known capability" set the rule resolves against is the
* union of built-in platform capabilities and every `systemPermissions` grant on
* the environment's permission sets (published ∪ pending permission drafts) —
* so the assembled pseudo-stack includes the PUBLISHED permission sets, not
* just drafts.
* union of built-in platform capabilities, every capability the environment's
* registry serves (`defineCapability` declarations, the `stack.capabilities`
* source the rule reads), and every `systemPermissions` grant on the
* environment's permission sets (published ∪ pending permission drafts) — so
* the assembled pseudo-stack includes the PUBLISHED catalog, not just drafts.
*
* @param ruleOverride test seam — inject the rule instead of feature-detecting.
*/
Expand Down Expand Up @@ -126,12 +127,21 @@ export async function lintDraftCapabilityReferences(
.catch(() => null) ?? Promise.resolve(null),
),
);
// Declaration side: the capabilities the registry serves
// (`GET /api/v1/meta/capability`, objectui#7611). A capability declared
// with `defineCapability` and granted by no permission set is still
// declared; leaving this source out of the pseudo-stack told the author it
// "resolves nowhere" when the rule itself would have resolved it.
const capabilities = await (client.list?.('capability', {}) as Promise<unknown[]>)
?.then((rows) => (Array.isArray(rows) ? rows.map(unwrap).filter(Boolean) : []))
.catch(() => []) ?? [];

const stack: Record<string, unknown> = {
objects: bodies.filter((b, i) => b && linted[i].type === 'object'),
apps: bodies.filter((b, i) => b && linted[i].type === 'app'),
actions: bodies.filter((b, i) => b && linted[i].type === 'action'),
permissions: [...publishedPerms, ...permDrafts.filter(Boolean)],
capabilities,
};

return rule(stack)
Expand Down
8 changes: 4 additions & 4 deletions packages/app-shell/src/utils/approverIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ export interface ApproverDirectoryBinding {
* columns without a second edit here.
*/
const DISPLAY_FIELDS_BY_OBJECT: Record<string, string[]> = {
sys_position: ['label', 'name'],
sys_team: ['label', 'name'],
sys_business_unit: ['label', 'name'],
sys_user: ['full_name', 'name', 'display_name', 'email'],
Expand Down Expand Up @@ -149,9 +148,10 @@ const KNOWN_APPROVER_KINDS = new Set(Object.keys(APPROVER_VALUE_SOURCES));
*
* Read off the published binding, never off a list of kinds: a registry-bound
* kind (objectui#7611 Q3b) names a declared item and has no id at all, and a
* `data` kind commits a name exactly when its `valueField` is `name`. Under the
* installed spec `position` answers through the second arm, under a spec
* carrying objectstack#22710 through the first — the same answer either way.
* `data` kind commits a name exactly when its `valueField` is `name`. The
* installed spec binds `position` to the registry (objectstack#22710), so it
* answers through the first arm; the second arm reads a `data` binding the same
* way, so a kind the spec moves is followed with no edit here.
*/
export function approverValueIsName(kind: string): boolean {
return !!APPROVER_REGISTRY_TYPES[kind] || APPROVER_DIRECTORY_BINDINGS[kind]?.valueField === 'name';
Expand Down
Loading