diff --git a/.changeset/30797-meta-refusal-override-install-token-wording.md b/.changeset/30797-meta-refusal-override-install-token-wording.md new file mode 100644 index 00000000000..abc4d50ff19 --- /dev/null +++ b/.changeset/30797-meta-refusal-override-install-token-wording.md @@ -0,0 +1,14 @@ +--- +'@objectstack/runtime': patch +'@objectstack/cli': patch +'@object-ui/data-objectstack': patch +--- + +fix(runtime,cli): the dispatcher's `/meta` permission refusal applies a deployment's `errors.permission_denied` override; `os package install` says which env variables count as credentials + +Clause-②: no +Metadata-protocol: none + +- `@objectstack/runtime`: the `HttpDispatcher` meta read gate now hands the request's i18n service lookup, as `RestServer` does, so an `errors.permission_denied` override reads the same on both doors. Without an i18n service, or when it fails, the built-in sentence is served and the refusal stays a 403. +- `@objectstack/cli`: the `--token` conflict refusal of `os package install` now says that `OS_RUNTIME_EMAIL`/`OS_RUNTIME_PASSWORD` count as `--email`/`--password`. +- `@object-ui/data-objectstack`: the README and a doc comment quote the current refusal sentence. No behaviour change. diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index 2d8d9941b5e..7dd59d3734f 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -188,7 +188,10 @@ export default class PackageInstall extends Command { // identities, and picking one silently would install as whichever the // command happened to prefer. if (flags.token && (flags.email || flags.password)) { - printError('Pass either --token or --email/--password, not both (OS_RUNTIME_TOKEN counts as --token).'); + printError( + 'Pass either --token or --email/--password, not both ' + + '(OS_RUNTIME_TOKEN counts as --token; OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD count as --email/--password).', + ); this.exit(1); return; } diff --git a/packages/cli/test/package-install-url-token-flags.test.ts b/packages/cli/test/package-install-url-token-flags.test.ts index 16423a57371..b740cb2acfa 100644 --- a/packages/cli/test/package-install-url-token-flags.test.ts +++ b/packages/cli/test/package-install-url-token-flags.test.ts @@ -123,4 +123,19 @@ describe('os package install -u/--url and -t/--token (#30126 row 13)', () => { expect(out).toContain('Pass either --token or --email/--password, not both'); expect(sent).toHaveLength(0); }); + + // [#30797] A user who exported OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD once is + // refused with only --token on the line: the sentence says why. + it('refuses --token with OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD exported, and names both env families', async () => { + const { sent } = stubRuntime(); + vi.stubEnv('OS_RUNTIME_EMAIL', 'a@b.test'); + vi.stubEnv('OS_RUNTIME_PASSWORD', 'pw'); + const { out, exit } = await runInstall(['--url', 'http://runtime.test', '--token', 'osk_test']); + expect(exit).toBe(1); + expect(out).toContain( + 'Pass either --token or --email/--password, not both ' + + '(OS_RUNTIME_TOKEN counts as --token; OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD count as --email/--password).', + ); + expect(sent).toHaveLength(0); + }); }); diff --git a/packages/runtime/src/domains/meta-permission-refusal-override.test.ts b/packages/runtime/src/domains/meta-permission-refusal-override.test.ts new file mode 100644 index 00000000000..e575435c651 --- /dev/null +++ b/packages/runtime/src/domains/meta-permission-refusal-override.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30797] The dispatcher's `/meta` read gate applies a deployment's + * `errors.permission_denied` override, as `RestServer`'s does. + * + * #30126 row 12 made the gate's `403 PERMISSION_DENIED` (an app, a dashboard + * or a list view the caller may not open) render the operation catalog's + * `permission_denied` in the caller's locale. `RestServer` handed the gate the + * request's i18n lookup, so a deployment's override won there; the + * dispatcher's `metaItemReadGateSources` handed none, so the same caller read + * the built-in sentence through `HttpDispatcher` and the override through + * `RestServer`. The dispatcher now hands the `i18n` service's `t`. + * + * Pinned, through `dispatch()`, on all three refusals: + * - an override registered for the caller's locale is the message; + * - CONTROL: no i18n service gives the built-in catalog sentence; + * - an i18n service that cannot be resolved, or whose `t` throws, gives the + * built-in sentence and still the 403, never a 500. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const OVERRIDE_ZH = '请向 Acme 管理员申请访问权限。'; +const ZH = BUILTIN_OPERATION_MESSAGES['zh-CN'].permission_denied; + +const STORE: Record = { + app: [{ name: 'com.objectstack.setup', label: 'Setup', requiredPermissions: ['setup.access'], navigation: [] }], + dashboard: [{ name: 'legal_board', label: 'Legal board', requiredPermissions: ['legal.view'], widgets: [] }], + view: [{ name: 'contract.legal_queue', object: 'contract', viewKind: 'list', config: { type: 'grid', columns: ['name'], requiredPermissions: ['legal.view'] } }], +}; +const REFUSED: Array<[string, string]> = [ + ['app', 'com.objectstack.setup'], + ['dashboard', 'legal_board'], + ['view', 'contract.legal_queue'], +]; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); + +type I18n = 'override' | 'none' | 'unresolvable' | 't-throws'; + +function boot(i18n: I18n) { + const protocol = { + getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })), + getMetaItems: vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])), + getMetaItem: vi.fn(async ({ type, name }: any) => { + const found = (STORE[singular(type)] ?? []).find((i: any) => i.name === name); + return { type: singular(type), name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + }; + const t = vi.fn((key: string, locale: string) => { + if (i18n === 't-throws') throw new Error('bundle corrupt'); + return key === 'errors.permission_denied' && locale === 'zh-CN' ? OVERRIDE_ZH : key; + }); + const services: Record = { + protocol, + security: { resolvePermissionSetNames: async () => [], getMetadataReadableFields: async () => [] }, + ...(i18n === 'override' || i18n === 't-throws' ? { i18n: { t, getLocales: () => ['en', 'zh-CN'], getDefaultLocale: () => 'en' } } : {}), + }; + const get = (n: string) => { + if (n === 'i18n' && i18n === 'unresolvable') throw new Error('i18n service failed to start'); + return services[n] ?? null; + }; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => + ({ userId: 'u_member', isSystem: false, systemPermissions: [], locale: 'zh-CN' }); + const read = async (type: string, name: string) => { + const res = await dispatcher.dispatch('GET', `/meta/${type}/${name}`, undefined, {}, { request: { headers: {} } } as any); + return { status: res.response?.status ?? 0, body: res.response?.body }; + }; + return { read, t }; +} + +describe('[#30797] the dispatcher /meta 403 applies the errors.permission_denied override', () => { + for (const [type, name] of REFUSED) { + it(`${type}/${name}: the override registered for the caller's locale is the message`, async () => { + const { read, t } = boot('override'); + const a = await read(type, name); + expect(a.status).toBe(403); + expect(a.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(a.body?.error?.message).toBe(OVERRIDE_ZH); + expect(t).toHaveBeenCalledWith('errors.permission_denied', 'zh-CN', expect.anything()); + }); + + it(`${type}/${name}: CONTROL — no i18n service gives the built-in catalog sentence`, async () => { + const a = await boot('none').read(type, name); + expect(a.status).toBe(403); + expect(a.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(a.body?.error?.message).toBe(ZH); + }); + + for (const failure of ['unresolvable', 't-throws'] as const) { + it(`${type}/${name}: an i18n service that is ${failure} gives the built-in sentence and the 403`, async () => { + const a = await boot(failure).read(type, name); + expect(a.status).toBe(403); + expect(a.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(a.body?.error?.message).toBe(ZH); + }); + } + } +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index cd71c799856..f5554670f2f 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -555,6 +555,23 @@ function metaItemReadGateSources( ...(typeof protocol?.packagelessLayeredReadRefusal === 'function' ? { packagelessLayeredReadRefusal: (item: { type: string; name: string; member?: string }) => protocol.packagelessLayeredReadRefusal!(item) } : {}), + // [#30797] The deployment's `errors.permission_denied` override, as + // `RestServer.resolveMessageTranslator` hands it: the request's i18n + // service's `t`, or `undefined` when there is none. A service that + // cannot be resolved answers `undefined` — the refusal's wording must + // never turn its 403 into a 500 (a throwing `t` is caught by + // `renderOperationMessage` itself). + resolveMessageTranslator: async () => { + let i18n: { t?: unknown } | null | undefined; + try { + i18n = await deps.resolveService(context, 'i18n'); + } catch { + return undefined; + } + if (!i18n || typeof i18n.t !== 'function') return undefined; + const t = i18n.t as (key: string, locale: string, params?: Record) => string; + return (key, locale, params) => t.call(i18n, key, locale, params); + }, }; } diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.offlineLocalMenu-11627.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.offlineLocalMenu-11627.test.tsx index b08f5dfd1e8..77b06f86eea 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.offlineLocalMenu-11627.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.offlineLocalMenu-11627.test.tsx @@ -81,7 +81,7 @@ const offlineConfig = (installLocal: boolean) => ({ branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' }, }); -/** What `os package install -r` leaves in the install-local ledger: an inline manifest's packageId IS its manifest id. */ +/** What `os package install -u` leaves in the install-local ledger: an inline manifest's packageId IS its manifest id. */ const CRM_ENTRY = { packageId: 'com.example.crm', versionId: 'inline', diff --git a/ui/packages/data-objectstack/README.md b/ui/packages/data-objectstack/README.md index 90f511bfebb..ba80fe27141 100644 --- a/ui/packages/data-objectstack/README.md +++ b/ui/packages/data-objectstack/README.md @@ -910,7 +910,7 @@ import { ObjectStackAdapter, type MetaItemAccess } from '@object-ui/data-objects const adapter = new ObjectStackAdapter({ baseUrl: '' }); const access: MetaItemAccess = await adapter.probeMetaItemAccess('dashboard', 'legal_board'); -// { verdict: 'denied', message: "You do not have permission to open the 'legal_board' dashboard." } +// { verdict: 'denied', message: "You do not have permission to perform this action. Contact your administrator if you need access." } ``` `verdict` is `granted`, `denied`, `not_found`, `unreachable` (no answer was diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index 7e0f4bd3b36..670c1190c52 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -1163,9 +1163,9 @@ export type AppAccessVerdict = 'granted' | 'denied' | 'not_found' | 'unreachable export interface MetaItemAccess { readonly verdict: AppAccessVerdict; /** - * The refusal's own `message`, as the server wrote it — for a dashboard, - * `You do not have permission to open the 'NAME' dashboard.` Only on - * `denied`, and absent when the server wrote none. + * The refusal's own `message`, as the server wrote it, in the caller's + * locale — in English, `You do not have permission to perform this action. Contact your administrator if you need access.` + * Only on `denied`, and absent when the server wrote none. */ readonly message?: string; }