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
14 changes: 14 additions & 0 deletions .changeset/30797-meta-refusal-override-install-token-wording.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion packages/cli/src/commands/package/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/test/package-install-url-token-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
105 changes: 105 additions & 0 deletions packages/runtime/src/domains/meta-permission-refusal-override.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any[]> = {
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 = <T>(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<string, unknown> = {
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);
});
}
}
});
17 changes: 17 additions & 0 deletions packages/runtime/src/domains/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>) => string;
return (key, locale, params) => t.call(i18n, key, locale, params);
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const offlineConfig = (installLocal: boolean) => ({
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
});

/** What `os package install <file> -r` leaves in the install-local ledger: an inline manifest's packageId IS its manifest id. */
/** What `os package install <file> -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',
Expand Down
2 changes: 1 addition & 1 deletion ui/packages/data-objectstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions ui/packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading