Skip to content

Commit da3c742

Browse files
fix(runtime,cli): the dispatcher /meta refusal applies the errors.permission_denied override; install's token conflict names env credentials (#30839)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DFzKKudjNXHrpdfngPrHj6u)_ domain:server#4 · os-elon-musk · session_016WV4cyQMWcGmm84uezumhc Claim: 6677672237 Fixes #30797 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:三项复核跟进。① runtime 调度器那一侧的 `/meta` 读门,在拒绝无权打开的应用、仪表盘、列表视图时,也读部署自定义的 `errors.permission_denied` 文案(此前只有 REST 一侧读);② `os package install` 同时给了 `--token` 和邮箱密码时的拒绝句,写明 `OS_RUNTIME_EMAIL`/`OS_RUNTIME_PASSWORD` 也算 `--email`/`--password`;③ ui 三处注释和 README 里的旧拒绝句、旧 `-r` 参数改为现行写法。 - 为什么改:PR #30792 合约评审 6676098596 的 should-fix 三项。同一个用户走两个入口,看到的拒绝文案应一致;用户只在 shell 里 export 过邮箱变量时,看不出为什么带 `--token` 被拒。 - 风险与回滚:低。① 只多读一个可选服务,i18n 服务缺席、解析失败或 `t` 抛错都回落到内置句,仍是 403 不是 500;② 只改一句文案;③ 只改注释与 README,无 ui 行为变化。回滚即撤本 PR。 - 先看:`packages/runtime/src/domains/meta.ts` 的 `metaItemReadGateSources` 新增的 `resolveMessageTranslator`;`packages/cli/src/commands/package/install.ts` 的拒绝句。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-permission-refusal-override.test.ts`(12 条全绿)。 - 需要你做的:无,席位落地。 ## What changes 1. **`@objectstack/runtime`.** `metaItemReadGateSources` (the `HttpDispatcher` side of the shared `/meta` read gate) now hands `resolveMessageTranslator`: the request's `i18n` service `t`, as `RestServer.resolveMessageTranslator` hands it. A deployment's `errors.permission_denied` override therefore reads the same on both doors for the app, dashboard and list-view refusals. An `i18n` service that cannot be resolved answers `undefined`; a `t` that throws is already caught by `renderOperationMessage`. Either way the caller gets the built-in catalog sentence and a 403. 2. **`@objectstack/cli`.** The `os package install` token-conflict refusal now reads: `Pass either --token or --email/--password, not both (OS_RUNTIME_TOKEN counts as --token; OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD count as --email/--password).` 3. **ui prose, comments and README only.** `MarketplacePackagePage.offlineLocalMenu-11627.test.tsx:84` doc comment `-r` → `-u`; `ui/packages/data-objectstack/README.md:913` and the `MetaItemAccess.message` doc comment in `src/index.ts` quote the catalog's current English sentence. No ui behaviour change. Changeset: patch for `@objectstack/runtime`, `@objectstack/cli` and `@object-ui/data-objectstack` (its README ships in `files[]`). ## Tests - New `packages/runtime/src/domains/meta-permission-refusal-override.test.ts`, through `dispatch()`, on all three refusals (app, dashboard, list view), caller locale `zh-CN`: the override is the message and `t` is asked `errors.permission_denied` in `zh-CN`; CONTROL with no i18n service gives the built-in zh-CN sentence; an unresolvable i18n service and a throwing `t` give the built-in sentence and the 403. - `packages/cli/test/package-install-url-token-flags.test.ts`: a new case exports `OS_RUNTIME_EMAIL`/`OS_RUNTIME_PASSWORD`, passes only `--token`, and pins the full refusal sentence, exit 1 and no request sent. - Red first: with both fixes reverted to `origin/main` (tests at HEAD), 3 of 12 runtime cases (the three override cases) and 1 of 9 CLI cases fail; with the fix, 12/12 and 9/9 pass. ## Acceptance notes - The ui test fixtures that still mock the old by-name sentence as a server answer (`appAccessProbe.test.ts`, `metaItemAccessProbe-12112.test.ts`, two studio-design `draftUnreadable-29148` tests) are left alone: they are stand-in server answers, not prose, and the card scopes item 3 to the three residues it names. ## Verification All readings at head `6a24f4e1`. <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter @objectstack/cli typecheck`: exit 0. - `pnpm --filter @objectstack/runtime exec vitest run`: Test Files 551 passed (551), Tests 10124 passed, 19 skipped. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 386/388 files on the first run; the 2 failures were `published-subpath-console.pin.test.ts` and `published-subpath-hook-body.pin.test.ts` refusing because `packages/cli/dist` was not built yet. After `pnpm --filter @objectstack/cli build` both pass (Tests 29 passed). Integration tier declared to CI (no spawn entry touched). - ui: comments and README only, no ui test run locally; CI's ui lanes cover it. </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> - `dispatch-gates --ran`: 68 derived, 67 run, 1 NOT-MEASURED, 0 UNRUN; every run family exit 0. - `check:plugin-teardown-shape --self-test` first exited 3 on a shallow clone (its positive control commit was absent); after fetching that commit it exits 0. - `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity` first exited 3 (CLI not built); after building their prerequisite closure all exit 0. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: prerequisite not met, it reads every package's `dist/` and ten packages outside this diff's closure are unbuilt locally. CI runs it. </details> --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyQMWcGmm84uezumhc Co-authored-by: Claude <noreply@anthropic.com>
1 parent 677d542 commit da3c742

8 files changed

Lines changed: 160 additions & 6 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cli': patch
4+
'@object-ui/data-objectstack': patch
5+
---
6+
7+
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
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- `@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.
13+
- `@objectstack/cli`: the `--token` conflict refusal of `os package install` now says that `OS_RUNTIME_EMAIL`/`OS_RUNTIME_PASSWORD` count as `--email`/`--password`.
14+
- `@object-ui/data-objectstack`: the README and a doc comment quote the current refusal sentence. No behaviour change.

‎packages/cli/src/commands/package/install.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,10 @@ export default class PackageInstall extends Command {
188188
// identities, and picking one silently would install as whichever the
189189
// command happened to prefer.
190190
if (flags.token && (flags.email || flags.password)) {
191-
printError('Pass either --token or --email/--password, not both (OS_RUNTIME_TOKEN counts as --token).');
191+
printError(
192+
'Pass either --token or --email/--password, not both '
193+
+ '(OS_RUNTIME_TOKEN counts as --token; OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD count as --email/--password).',
194+
);
192195
this.exit(1);
193196
return;
194197
}

‎packages/cli/test/package-install-url-token-flags.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,19 @@ describe('os package install -u/--url and -t/--token (#30126 row 13)', () => {
123123
expect(out).toContain('Pass either --token or --email/--password, not both');
124124
expect(sent).toHaveLength(0);
125125
});
126+
127+
// [#30797] A user who exported OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD once is
128+
// refused with only --token on the line: the sentence says why.
129+
it('refuses --token with OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD exported, and names both env families', async () => {
130+
const { sent } = stubRuntime();
131+
vi.stubEnv('OS_RUNTIME_EMAIL', 'a@b.test');
132+
vi.stubEnv('OS_RUNTIME_PASSWORD', 'pw');
133+
const { out, exit } = await runInstall(['--url', 'http://runtime.test', '--token', 'osk_test']);
134+
expect(exit).toBe(1);
135+
expect(out).toContain(
136+
'Pass either --token or --email/--password, not both '
137+
+ '(OS_RUNTIME_TOKEN counts as --token; OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD count as --email/--password).',
138+
);
139+
expect(sent).toHaveLength(0);
140+
});
126141
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30797] The dispatcher's `/meta` read gate applies a deployment's
5+
* `errors.permission_denied` override, as `RestServer`'s does.
6+
*
7+
* #30126 row 12 made the gate's `403 PERMISSION_DENIED` (an app, a dashboard
8+
* or a list view the caller may not open) render the operation catalog's
9+
* `permission_denied` in the caller's locale. `RestServer` handed the gate the
10+
* request's i18n lookup, so a deployment's override won there; the
11+
* dispatcher's `metaItemReadGateSources` handed none, so the same caller read
12+
* the built-in sentence through `HttpDispatcher` and the override through
13+
* `RestServer`. The dispatcher now hands the `i18n` service's `t`.
14+
*
15+
* Pinned, through `dispatch()`, on all three refusals:
16+
* - an override registered for the caller's locale is the message;
17+
* - CONTROL: no i18n service gives the built-in catalog sentence;
18+
* - an i18n service that cannot be resolved, or whose `t` throws, gives the
19+
* built-in sentence and still the 403, never a 500.
20+
*/
21+
22+
import { describe, it, expect, vi } from 'vitest';
23+
import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system';
24+
import { HttpDispatcher } from '../http-dispatcher.js';
25+
26+
const OVERRIDE_ZH = '请向 Acme 管理员申请访问权限。';
27+
const ZH = BUILTIN_OPERATION_MESSAGES['zh-CN'].permission_denied;
28+
29+
const STORE: Record<string, any[]> = {
30+
app: [{ name: 'com.objectstack.setup', label: 'Setup', requiredPermissions: ['setup.access'], navigation: [] }],
31+
dashboard: [{ name: 'legal_board', label: 'Legal board', requiredPermissions: ['legal.view'], widgets: [] }],
32+
view: [{ name: 'contract.legal_queue', object: 'contract', viewKind: 'list', config: { type: 'grid', columns: ['name'], requiredPermissions: ['legal.view'] } }],
33+
};
34+
const REFUSED: Array<[string, string]> = [
35+
['app', 'com.objectstack.setup'],
36+
['dashboard', 'legal_board'],
37+
['view', 'contract.legal_queue'],
38+
];
39+
40+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
41+
const singular = (type: unknown): string => String(type ?? '').replace(/s$/, '');
42+
43+
type I18n = 'override' | 'none' | 'unresolvable' | 't-throws';
44+
45+
function boot(i18n: I18n) {
46+
const protocol = {
47+
getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })),
48+
getMetaItems: vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])),
49+
getMetaItem: vi.fn(async ({ type, name }: any) => {
50+
const found = (STORE[singular(type)] ?? []).find((i: any) => i.name === name);
51+
return { type: singular(type), name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false };
52+
}),
53+
};
54+
const t = vi.fn((key: string, locale: string) => {
55+
if (i18n === 't-throws') throw new Error('bundle corrupt');
56+
return key === 'errors.permission_denied' && locale === 'zh-CN' ? OVERRIDE_ZH : key;
57+
});
58+
const services: Record<string, unknown> = {
59+
protocol,
60+
security: { resolvePermissionSetNames: async () => [], getMetadataReadableFields: async () => [] },
61+
...(i18n === 'override' || i18n === 't-throws' ? { i18n: { t, getLocales: () => ['en', 'zh-CN'], getDefaultLocale: () => 'en' } } : {}),
62+
};
63+
const get = (n: string) => {
64+
if (n === 'i18n' && i18n === 'unresolvable') throw new Error('i18n service failed to start');
65+
return services[n] ?? null;
66+
};
67+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
68+
const dispatcher = new HttpDispatcher(kernel);
69+
(dispatcher as any).timedResolveExecutionContext = async () =>
70+
({ userId: 'u_member', isSystem: false, systemPermissions: [], locale: 'zh-CN' });
71+
const read = async (type: string, name: string) => {
72+
const res = await dispatcher.dispatch('GET', `/meta/${type}/${name}`, undefined, {}, { request: { headers: {} } } as any);
73+
return { status: res.response?.status ?? 0, body: res.response?.body };
74+
};
75+
return { read, t };
76+
}
77+
78+
describe('[#30797] the dispatcher /meta 403 applies the errors.permission_denied override', () => {
79+
for (const [type, name] of REFUSED) {
80+
it(`${type}/${name}: the override registered for the caller's locale is the message`, async () => {
81+
const { read, t } = boot('override');
82+
const a = await read(type, name);
83+
expect(a.status).toBe(403);
84+
expect(a.body?.error?.code).toBe('PERMISSION_DENIED');
85+
expect(a.body?.error?.message).toBe(OVERRIDE_ZH);
86+
expect(t).toHaveBeenCalledWith('errors.permission_denied', 'zh-CN', expect.anything());
87+
});
88+
89+
it(`${type}/${name}: CONTROL — no i18n service gives the built-in catalog sentence`, async () => {
90+
const a = await boot('none').read(type, name);
91+
expect(a.status).toBe(403);
92+
expect(a.body?.error?.code).toBe('PERMISSION_DENIED');
93+
expect(a.body?.error?.message).toBe(ZH);
94+
});
95+
96+
for (const failure of ['unresolvable', 't-throws'] as const) {
97+
it(`${type}/${name}: an i18n service that is ${failure} gives the built-in sentence and the 403`, async () => {
98+
const a = await boot(failure).read(type, name);
99+
expect(a.status).toBe(403);
100+
expect(a.body?.error?.code).toBe('PERMISSION_DENIED');
101+
expect(a.body?.error?.message).toBe(ZH);
102+
});
103+
}
104+
}
105+
});

‎packages/runtime/src/domains/meta.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,23 @@ function metaItemReadGateSources(
555555
...(typeof protocol?.packagelessLayeredReadRefusal === 'function'
556556
? { packagelessLayeredReadRefusal: (item: { type: string; name: string; member?: string }) => protocol.packagelessLayeredReadRefusal!(item) }
557557
: {}),
558+
// [#30797] The deployment's `errors.permission_denied` override, as
559+
// `RestServer.resolveMessageTranslator` hands it: the request's i18n
560+
// service's `t`, or `undefined` when there is none. A service that
561+
// cannot be resolved answers `undefined` — the refusal's wording must
562+
// never turn its 403 into a 500 (a throwing `t` is caught by
563+
// `renderOperationMessage` itself).
564+
resolveMessageTranslator: async () => {
565+
let i18n: { t?: unknown } | null | undefined;
566+
try {
567+
i18n = await deps.resolveService(context, 'i18n');
568+
} catch {
569+
return undefined;
570+
}
571+
if (!i18n || typeof i18n.t !== 'function') return undefined;
572+
const t = i18n.t as (key: string, locale: string, params?: Record<string, unknown>) => string;
573+
return (key, locale, params) => t.call(i18n, key, locale, params);
574+
},
558575
};
559576
}
560577

‎ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.offlineLocalMenu-11627.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ const offlineConfig = (installLocal: boolean) => ({
8181
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
8282
});
8383

84-
/** What `os package install <file> -r` leaves in the install-local ledger: an inline manifest's packageId IS its manifest id. */
84+
/** What `os package install <file> -u` leaves in the install-local ledger: an inline manifest's packageId IS its manifest id. */
8585
const CRM_ENTRY = {
8686
packageId: 'com.example.crm',
8787
versionId: 'inline',

‎ui/packages/data-objectstack/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,7 @@ import { ObjectStackAdapter, type MetaItemAccess } from '@object-ui/data-objects
910910
const adapter = new ObjectStackAdapter({ baseUrl: '' });
911911

912912
const access: MetaItemAccess = await adapter.probeMetaItemAccess('dashboard', 'legal_board');
913-
// { verdict: 'denied', message: "You do not have permission to open the 'legal_board' dashboard." }
913+
// { verdict: 'denied', message: "You do not have permission to perform this action. Contact your administrator if you need access." }
914914
```
915915

916916
`verdict` is `granted`, `denied`, `not_found`, `unreachable` (no answer was

‎ui/packages/data-objectstack/src/index.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,9 +1163,9 @@ export type AppAccessVerdict = 'granted' | 'denied' | 'not_found' | 'unreachable
11631163
export interface MetaItemAccess {
11641164
readonly verdict: AppAccessVerdict;
11651165
/**
1166-
* The refusal's own `message`, as the server wrote it — for a dashboard,
1167-
* `You do not have permission to open the 'NAME' dashboard.` Only on
1168-
* `denied`, and absent when the server wrote none.
1166+
* The refusal's own `message`, as the server wrote it, in the caller's
1167+
* locale — in English, `You do not have permission to perform this action. Contact your administrator if you need access.`
1168+
* Only on `denied`, and absent when the server wrote none.
11691169
*/
11701170
readonly message?: string;
11711171
}

0 commit comments

Comments
 (0)