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/30326-install-result-protocol-check-skipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(console): the install result says when a package was installed without a protocol compatibility check

Clause-②: yes (widening)
Metadata-protocol: none

- **What the administrator now reads.** `POST /api/v1/marketplace/install-local` answers `data.protocolCheckSkipped` when its protocol handshake admitted the package without checking it: the package declares no `engines.protocol` (`no-range`), or a range the runtime cannot read (`unparsed-range`). The three Console surfaces that show an install result now draw it as a caution line under the success sentence: the package page's Install result, the catalog's organization install message, and Setup → Packages → Import. The line names the package, the platform protocol it was installed under and, for `unparsed-range`, the range it declared, in the reader's locale. A `reason` this Console does not know shows the server's own `message`. An install whose range was checked shows nothing new.
- **Types.** `LocalManagedInstallResult` and `LocalUnmanagedInstallResult` gain the optional `protocolCheckSkipped`.
- **New strings.** `@object-ui/i18n` gains `marketplace.install.protocolCheckSkipped.noRange` and `.unparsedRange` in all 10 locales.
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import {
type CloudInstallationInfo,
} from './marketplaceApi.js';
import { useDependencyMissingText } from './dependencyMissing.js';
import { useProtocolCheckSkippedText } from './protocolCheckSkipped.js';
import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js';
import { getCloudBase, getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
Expand Down Expand Up @@ -148,6 +150,7 @@ export function MarketplacePackagePage() {
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
const { t, language } = useObjectTranslation();
const dependencyMissingText = useDependencyMissingText();
const protocolCheckSkippedText = useProtocolCheckSkippedText();
// Dates and numbers on this surface read the display locale; a bare
// `toLocale*()` call used the MACHINE's locale (objectui#9909).
const displayLocale = useDisplayLocale();
Expand Down Expand Up @@ -267,6 +270,9 @@ export function MarketplacePackagePage() {
message: string;
unmanaged?: { strippedLocks: LocalInstallStrippedLock[] };
credentials?: { packageId: string; note?: string };
// objectstack#30326 — the install answer's `protocolCheckSkipped`, drawn:
// installed, but its platform protocol range was not checked.
protocolNote?: string;
} | null>(null);
// ADR-0131 D6 — the install modes the package's latest manifest declares,
// read from the manifest install-local judges, and the operator's pick among
Expand Down Expand Up @@ -601,6 +607,10 @@ export function MarketplacePackagePage() {
try {
const result = await installLocal({ packageId, ...(installMode ? { installMode } : {}) });
const name = data?.package ? localizePackage(data.package as any, language).displayName : result.manifestId;
// objectstack#30326 — every success answer can say the package was
// admitted with its protocol range unchecked; each note below carries it.
const protocolNote = protocolCheckSkippedText(result) ?? undefined;
const withProtocolNote = protocolNote ? { protocolNote } : {};
// An unmanaged copy (ADR-0131 D6): the package's metadata is this
// runtime's own from now on, in the base the answer names; the answer
// lists every protection lock the copy dropped, with the package's reason.
Expand All @@ -614,6 +624,7 @@ export function MarketplacePackagePage() {
ok: true,
message: t('marketplace.install.unmanagedSuccess', { version: result.version, name, base: result.targetPackageId }),
unmanaged: { strippedLocks: result.strippedLocks },
...withProtocolNote,
});
emitMetadataRefresh();
return;
Expand All @@ -629,8 +640,8 @@ export function MarketplacePackagePage() {
// and the page says when the app will appear.
if (!result.hotLoaded) {
setLocalResult(ignoredCopy
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }) }
: { ok: true, message: t('marketplace.install.localLoadsAtRestart', { version: result.version, name }) });
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }), ...withProtocolNote }
: { ok: true, message: t('marketplace.install.localLoadsAtRestart', { version: result.version, name }), ...withProtocolNote });
return;
}
try {
Expand All @@ -639,11 +650,12 @@ export function MarketplacePackagePage() {
// Non-fatal: the install succeeded; the app list will refresh on next navigation.
}
setLocalResult(ignoredCopy
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }) }
? { ok: false, message: t('marketplace.install.unmanagedIgnored', { name }), ...withProtocolNote }
: {
ok: true,
message: t('marketplace.install.localSuccess', { version: result.version, name }),
credentials: { packageId: result.manifestId, note: result.credentialNote },
...withProtocolNote,
});
emitMetadataRefresh();
} catch (e: any) {
Expand Down Expand Up @@ -996,6 +1008,7 @@ export function MarketplacePackagePage() {
{localResult.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
<div className="flex-1">
{localResult.message}
{localResult.protocolNote ? <ProtocolCheckSkippedNote text={localResult.protocolNote} /> : null}
{/* ADR-0131 D6 — each protection lock the unmanaged copy dropped, as
the answer states it: the item, the lock and the package's reason
verbatim, so the operator reads why the vendor locked an item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import { appRouteSegment, filterActiveApps } from '../../utils/index.js';
import { waitForServedApp, type ServedApp } from './waitForServedApp.js';
import { splitPackageSuffix } from './middleTruncate.js';
import { useDependencyMissingText } from './dependencyMissing.js';
import { useProtocolCheckSkippedText } from './protocolCheckSkipped.js';
import { ProtocolCheckSkippedNote } from './ProtocolCheckSkippedNote.js';
import { ConnectorCredentialsStep } from './ConnectorCredentialsStep.js';
import { packagesHref } from '../../views/metadata-admin/packageSource.js';

Expand Down Expand Up @@ -310,6 +312,7 @@ export function MarketplacePage() {
const { isAdmin, isResolved } = useWorkspaceAdminStatus();
const { t, language } = useObjectTranslation();
const dependencyMissingText = useDependencyMissingText();
const protocolCheckSkippedText = useProtocolCheckSkippedText();
const { appLabel } = useServedAppLabel();
// The adapter `MetadataProvider` reads through: the deploy wait asks the
// same `GET /meta/app` the cache would, without the cache persisting it.
Expand Down Expand Up @@ -356,6 +359,8 @@ export function MarketplacePage() {
ok: boolean;
text: string;
credentials?: { packageId: string; note?: string };
// objectstack#30326 — the answer's `protocolCheckSkipped`, drawn.
protocolNote?: string;
} | null>(null);
const [orgDeploy, setOrgDeploy] = useState<OrgDeployState | null>(null);
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -491,6 +496,8 @@ export function MarketplacePage() {
try {
if (getRuntimeConfig().features.installLocal) {
const result = await installLocal({ packageId: pkg.manifest_id || pkg.id, versionId: 'latest' });
// objectstack#30326 — installed, its platform protocol range unchecked.
const protocolNote = protocolCheckSkippedText(result);
// objectui#12098 — `hotLoaded: false` is the runtime's lenient path
// (objectstack#22695): installed, loaded at its next restart. Nothing
// of it is served until then, so the strip says so and nothing is
Expand All @@ -501,6 +508,7 @@ export function MarketplacePage() {
? t('marketplace.org.installed', { defaultValue: `Installed ${pkg.display_name}`, name: pkg.display_name })
: t('marketplace.install.localLoadsAtRestart', { version: result.version, name: pkg.display_name }),
...(result.hotLoaded ? { credentials: { packageId: result.manifestId, note: result.credentialNote } } : {}),
...(protocolNote ? { protocolNote } : {}),
});
await load();
// On `hotLoaded` the runtime registered the package before it answered,
Expand Down Expand Up @@ -738,6 +746,7 @@ export function MarketplacePage() {
{orgMsg && (
<div className={`whitespace-pre-wrap rounded-md border p-3 text-sm ${orgMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
{orgMsg.text}
{orgMsg.protocolNote ? <ProtocolCheckSkippedNote text={orgMsg.protocolNote} /> : null}
{orgMsg.ok && orgMsg.credentials ? (
<ConnectorCredentialsStep
packageId={orgMsg.credentials.packageId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { AlertTriangle } from 'lucide-react';

/**
* The install result's caution line for an answer that carries
* `protocolCheckSkipped` (objectstack#30326): drawn inside the install result
* note, under its own sentence, in a caution colour of its own, because the
* install succeeded and the note around it says so. `text` is the sentence
* from {@link import('./protocolCheckSkipped.js').protocolCheckSkippedText}.
*/
export function ProtocolCheckSkippedNote({ text, className }: { text: string; className?: string }) {
return (
<p
data-testid="protocol-check-skipped"
className={`mt-2 flex items-start gap-1.5 whitespace-normal text-amber-700 dark:text-amber-400 ${className ?? ''}`}
>
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />
<span>{text}</span>
</p>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The package page's Install result says when the install door admitted the
* package WITHOUT checking its platform protocol range: the answer's
* `data.protocolCheckSkipped` (objectstack#30326), drawn under the success
* sentence. Managed (loaded now, loaded at restart) and unmanaged answers all
* carry it.
*
* `marketplaceApi` is NOT mocked: one stubbed `fetch` answers by path, so the
* notice the page draws is the one that crossed the wire. `t` echoes the KEY
* and the notice's facts.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ packageId: 'pkg_psa' }),
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => {
const facts = ['packageId', 'protocolVersion', 'requiredRange'].filter((k) => o?.[k] !== undefined).map((k) => `${k}=${String(o?.[k])}`);
return facts.length > 0 ? `«${key}|${facts.join('|')}»` : `«${key}»`;
},
language: 'en',
}),
}));

vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} }));
vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: async () => {} }) }));
vi.mock('../../../components/SuggestedBindingsPanel', () => ({
SuggestedBindingsPanel: () => <div data-testid="suggested-bindings" />,
}));
vi.mock('../ConnectorCredentialsStep', () => ({
ConnectorCredentialsStep: () => <div data-testid="credential-step" />,
}));

import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
import { MarketplacePackagePage } from '../MarketplacePackagePage';

const INSTALL_PATH = '/api/v1/marketplace/install-local';
const MANIFEST_PATH = '/api/v1/marketplace/packages/pkg_psa/versions/latest/manifest';
const CATALOG_PACKAGE = {
package: { id: 'pkg_psa', manifest_id: 'com.acme.psa', display_name: 'PSA', latest_version: { id: 'ver_1', version: '1.0.0' } },
versions: [{ id: 'ver_1', version: '1.0.0' }],
};
const SERVER_MESSAGE = 'SERVER-ENGLISH sentinel: installed without a compatibility check.';
const NO_RANGE = { reason: 'no-range', packageId: 'com.acme.psa', protocolVersion: '18.1.0', message: SERVER_MESSAGE };

const answer = (status: number, body: unknown) => ({
ok: status >= 200 && status < 300,
status,
statusText: '',
headers: new Headers({ 'content-type': 'application/json' }),
json: async () => body,
text: async () => JSON.stringify(body),
});

async function boot(installData: Record<string, unknown>, installModes?: string[]) {
resetRuntimeConfigForTesting();
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
const path = new URL(String(input), 'http://console.test').pathname;
const method = (init?.method ?? 'GET').toUpperCase();
if (path === '/api/v1/runtime/config') {
return answer(200, {
cloudUrl: '', singleEnvironment: true,
features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
});
}
if (path === INSTALL_PATH && method === 'GET') return answer(200, { success: true, data: { items: [], total: 0 } });
if (path === INSTALL_PATH && method === 'POST') return answer(200, { success: true, data: installData });
if (path === MANIFEST_PATH) {
return answer(200, {
success: true,
data: { manifest: { id: 'com.acme.psa', version: '1.0.0', type: 'app', ...(installModes ? { installModes } : {}) }, version: '1.0.0', version_id: 'ver_1' },
});
}
if (path === '/api/v1/marketplace/packages/pkg_psa') return answer(200, { success: true, data: CATALOG_PACKAGE });
return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
}));
await initRuntimeConfig();
}

async function install() {
render(<MarketplacePackagePage />);
const button = await screen.findByRole('button', { name: '«marketplace.action.install»' });
await act(async () => {});
fireEvent.click(button);
}

const MANAGED = { manifestId: 'com.acme.psa', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-11-05T00:00:00Z', upgradedFrom: null };

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
resetRuntimeConfigForTesting();
});

describe('the package page\'s Install result draws protocolCheckSkipped (objectstack#30326)', () => {
it('a managed install the kernel loaded: the success sentence, then the unchecked-protocol line', async () => {
await boot({ ...MANAGED, hotLoaded: true, protocolCheckSkipped: NO_RANGE });
await install();

expect(await screen.findByText('«marketplace.install.localSuccess»')).toBeInTheDocument();
expect(screen.getByTestId('protocol-check-skipped')).toHaveTextContent(
'«marketplace.install.protocolCheckSkipped.noRange|packageId=com.acme.psa|protocolVersion=18.1.0|requiredRange=»',
);
expect(screen.queryByText(SERVER_MESSAGE)).not.toBeInTheDocument();
});

it('a managed install loaded at restart carries it too', async () => {
await boot({ ...MANAGED, hotLoaded: false, protocolCheckSkipped: NO_RANGE });
await install();

expect(await screen.findByText('«marketplace.install.localLoadsAtRestart»')).toBeInTheDocument();
expect(screen.getByTestId('protocol-check-skipped')).toHaveTextContent('marketplace.install.protocolCheckSkipped.noRange');
});

it('an unmanaged copy with an unreadable range: the range it declared', async () => {
await boot({
installMode: 'unmanaged', manifestId: 'com.acme.psa', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-11-05T00:00:00Z',
installedFrom: { packageId: 'com.acme.psa' }, targetPackageId: 'com.acme.psa_copy', namespace: 'psa', copiedCount: 0, copied: [], strippedLocks: [],
protocolCheckSkipped: {
reason: 'unparsed-range', packageId: 'com.acme.psa', protocolVersion: '18.1.0',
requiredRange: 'latest', rangeSource: 'engines.protocol', message: SERVER_MESSAGE,
},
}, ['unmanaged']);
await install();

expect(await screen.findByText('«marketplace.install.unmanagedSuccess»')).toBeInTheDocument();
expect(screen.getByTestId('protocol-check-skipped')).toHaveTextContent(
'«marketplace.install.protocolCheckSkipped.unparsedRange|packageId=com.acme.psa|protocolVersion=18.1.0|requiredRange=latest»',
);
});

it('CONTROL: an install whose range was checked draws no such line', async () => {
await boot({ ...MANAGED, hotLoaded: true });
await install();

expect(await screen.findByText('«marketplace.install.localSuccess»')).toBeInTheDocument();
expect(screen.queryByTestId('protocol-check-skipped')).not.toBeInTheDocument();
});
});
Loading
Loading