diff --git a/.changeset/30126-package-install-url-token-setup-refusal-locale.md b/.changeset/30126-package-install-url-token-setup-refusal-locale.md new file mode 100644 index 00000000000..d38a06e6be6 --- /dev/null +++ b/.changeset/30126-package-install-url-token-setup-refusal-locale.md @@ -0,0 +1,24 @@ +--- +'@objectstack/cli': patch +'@objectstack/rest': patch +'create-objectstack': patch +--- + +fix(cli,rest)!: `os package install` names its server and credential `-u/--url` and `-t/--token`, like `os package export`; the `/meta` read gate's permission refusal is localized + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** for a script that runs `os package install` with `-r` or `--runtime`. The option is renamed with no alias, so the old spelling is refused as an unknown flag. Write `-u` / `--url` in its place: + +- FROM `os package install -r ` / `os package install --runtime ` TO `os package install -u ` / `os package install --url `. + +The value, its default (`http://localhost:3000`) and its environment variable (`OS_RUNTIME_URL`) are unchanged. A script that sets only `OS_RUNTIME_URL` needs no change. + +`os package install` also takes `-t` / `--token` (environment variable `OS_RUNTIME_TOKEN`, new): a token the target runtime issued, sent as `Authorization: Bearer`, instead of signing in with `--email` / `--password`. Passing both is refused before any request. These are the spellings `os package export` and the other server-talking commands use. + +The scaffold's `AGENTS.md` and blank template README now spell the install command with `--url`. + +`@objectstack/rest`: an app, a dashboard or a list view a signed-in caller may not open is still refused `403 PERMISSION_DENIED`, and the sentence is now the operation catalog's `permission_denied` in the caller's locale, the sentence plugin-security's object refusals already use. Opening the Setup app without access in a Chinese console read `You do not have permission to open the 'com.objectstack.setup' app.`; it now reads 「您没有执行此操作的权限,如需访问请联系管理员。」. The sentence no longer carries the item's API name. A deployment's `errors.permission_denied` translation overrides it on the REST door, as it does for those refusals. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f1e87e11440..8fbd69a736d 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -3245,7 +3245,7 @@ Two modes, chosen by the shape of the argument: ```bash # catalog mode — the TARGET runtime resolves the version from its own catalog -os package install com.acme.crm --version 1.2.0 --runtime https://app.example.com +os package install com.acme.crm --version 1.2.0 --url https://app.example.com # air-gapped mode — the artifact is read locally and sent inline; no catalog, works offline os package install ./dist/objectstack.json @@ -3258,18 +3258,25 @@ catalog id that happens to match a file in the working directory is installed from that file instead. **The credential is the *runtime* identity, not your cloud login.** The target -runtime authenticates the call with its own session, so `--email` / `--password` -(or `OS_RUNTIME_EMAIL` / `OS_RUNTIME_PASSWORD`) name an account **on that -runtime**. A `401` means exactly that, and the command says so; `os cloud login` +runtime authenticates the call itself: pass a token it issued (`-t, --token`, or +`OS_RUNTIME_TOKEN`), or `--email` / `--password` (or `OS_RUNTIME_EMAIL` / +`OS_RUNTIME_PASSWORD`) for an account **on that runtime** — one or the other, +not both. A `401` means exactly that, and the command says so; `os cloud login` credentials do not apply here. +The server and the credential are spelled as in `os package export` and the +other server-talking commands: `-u, --url` and `-t, --token`. `-r, --runtime` +is gone with no alias, so a script that passes it is refused as an unknown flag; +write `-u URL` instead. + **Options:** | Flag | Env equivalent | Purpose | |---|---|---| | `package` (positional, required) | — | Package manifest id (`com.acme.crm`) **or** a path to a compiled artifact JSON | -| `-r, --runtime ` | `OS_RUNTIME_URL` | Base URL of the runtime to install **into** (default `http://localhost:3000`) | +| `-u, --url ` | `OS_RUNTIME_URL` | Base URL of the runtime to install **into** (default `http://localhost:3000`) | | `-v, --version ` | — | Version to install in catalog mode (default `latest`). Air-gapped mode takes the version from the artifact | +| `-t, --token ` | `OS_RUNTIME_TOKEN` | A token the target runtime issued (an API key); instead of `--email` / `--password` | | `--email ` | `OS_RUNTIME_EMAIL` | Account email on the target runtime | | `--password ` | `OS_RUNTIME_PASSWORD` | Account password on the target runtime | | `--confirm-global-uniques` | — | Affirm this app's installation-wide `unique` constraints are genuinely platform-wide — see below | diff --git a/content/docs/deployment/publish-and-preview.mdx b/content/docs/deployment/publish-and-preview.mdx index d998d6d19be..68c41eea643 100644 --- a/content/docs/deployment/publish-and-preview.mdx +++ b/content/docs/deployment/publish-and-preview.mdx @@ -198,7 +198,7 @@ and caches the manifest on disk so the install survives a restart: ```bash # catalog mode: the target runtime fetches the version from its own catalog -os package install com.acme.crm --version 1.2.0 --runtime https://app.example.com +os package install com.acme.crm --version 1.2.0 --url https://app.example.com # air-gapped mode: the artifact is read locally and sent inline, no catalog os package install ./dist/objectstack.json diff --git a/packages/cli/README.md b/packages/cli/README.md index 964e876db35..429655f0638 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -139,9 +139,9 @@ Two stored sessions exist, and each command authenticates with one of them: | `os environments list` / `show` / `create` / `bind` / `switch` | `-u, --url` (env `OS_CLOUD_URL`); else the URL of the stored session it uses; else `http://localhost:3000` | `-t, --token` (env `OS_TOKEN`) | No `--url`: `~/.objectstack/credentials.json` (the `os login` session), else `~/.objectstack/cloud.json` (the `os cloud login` session). With `--url`: the file that names that server, `credentials.json` first; when neither does, `credentials.json` as before. `cloud.json`'s token is never sent to another URL | `os package install` is not a cloud command: it installs into a running runtime -(`-r, --runtime`, env `OS_RUNTIME_URL`, default `http://localhost:3000`) and signs -in there with `--email` / `--password` (env `OS_RUNTIME_EMAIL` / -`OS_RUNTIME_PASSWORD`). +(`-u, --url`, env `OS_RUNTIME_URL`, default `http://localhost:3000`) and +authenticates there with `-t, --token` (env `OS_RUNTIME_TOKEN`) or `--email` / +`--password` (env `OS_RUNTIME_EMAIL` / `OS_RUNTIME_PASSWORD`). ### Plugin Management diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index a83d1ffc96e..2d8d9941b5e 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -43,9 +43,16 @@ * * This complements `os package publish` (which uploads to the CLOUD): the * pairing is publish-to-cloud / install-into-runtime. The target runtime - * authenticates the call with its own better-auth session — pass - * --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an - * account on the TARGET runtime, not your cloud login. + * authenticates the call itself — pass a token it issued (-t/--token, or + * OS_RUNTIME_TOKEN), or --email/--password (or OS_RUNTIME_EMAIL / + * OS_RUNTIME_PASSWORD) for an account on the TARGET runtime, not your cloud + * login. + * + * [#30126 row 13, ruling A] The server and the credential are spelled as + * `os package export` and the other server-talking commands spell them: + * `-u/--url` and `-t/--token`. `-r/--runtime` is retired with no alias — the + * startup rule keeps no window for an old spelling — so `os package install + * -r URL` is refused by the parser as an unknown flag and the fix is `-u URL`. */ import { readFile } from 'node:fs/promises'; @@ -88,10 +95,11 @@ export default class PackageInstall extends Command { static override examples = [ '$ os package install com.acme.crm', - '$ os package install com.acme.crm --version 1.2.0 --runtime http://localhost:3000', + '$ os package install com.acme.crm --version 1.2.0 --url http://localhost:3000', '$ os package install ./dist/objectstack.json # air-gapped, no catalog', '$ os package install ./dist/objectstack.json --install-mode unmanaged # copy into the environment as its own metadata', '$ OS_RUNTIME_EMAIL=admin@local.test OS_RUNTIME_PASSWORD=… os package install com.acme.crm', + '$ os package install ./dist/objectstack.json --url https://app.example.com --token osk_…', ]; static override args = { @@ -102,12 +110,17 @@ export default class PackageInstall extends Command { }; static override flags = { - runtime: Flags.string({ - char: 'r', - description: 'Target runtime base URL (the instance to install INTO)', + url: Flags.string({ + char: 'u', + description: 'Server URL of the runtime to install INTO', env: 'OS_RUNTIME_URL', default: 'http://localhost:3000', }), + token: Flags.string({ + char: 't', + description: 'Authentication token issued by the target runtime (an API key); instead of --email/--password', + env: 'OS_RUNTIME_TOKEN', + }), version: Flags.string({ char: 'v', description: "Version to install from the catalog (default: 'latest')", @@ -169,7 +182,16 @@ export default class PackageInstall extends Command { const { args, flags } = await this.parse(PackageInstall); printHeader('Install Package'); - const runtime = flags.runtime.replace(/\/+$/, ''); + const runtime = flags.url.replace(/\/+$/, ''); + + // One credential per call: a token and a password sign-in name two + // 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).'); + this.exit(1); + return; + } try { // ---- Resolve the source: inline artifact file vs catalog id --------- @@ -218,7 +240,9 @@ export default class PackageInstall extends Command { // ---- Authenticate against the TARGET runtime ------------------------ let cookie: string | undefined; - if (flags.email && flags.password) { + if (flags.token) { + printStep(`Authenticating to ${runtime} with a token`); + } else if (flags.email && flags.password) { printStep(`Signing in to ${runtime} as ${flags.email}...`); // `Origin` is required: better-auth's CSRF check 403s origin-less // non-browser POSTs to the sign-in route. @@ -252,6 +276,7 @@ export default class PackageInstall extends Command { headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}), + ...(flags.token ? { Authorization: `Bearer ${flags.token}` } : {}), }, body: JSON.stringify(body), }, flags.timeout); @@ -290,8 +315,9 @@ export default class PackageInstall extends Command { } if (res.status === 401) { printError( - 'The runtime rejected the call as unauthenticated. Pass --email/--password ' + - '(or set OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) for an account on the target runtime.', + 'The runtime rejected the call as unauthenticated. Pass --token (or OS_RUNTIME_TOKEN) with a ' + + 'token the target runtime issued, or --email/--password (or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) ' + + 'for an account on the target runtime.', ); } else if (res.status === 404) { printError( diff --git a/packages/cli/test/exit-signal.pin.test.ts b/packages/cli/test/exit-signal.pin.test.ts index e63791dc05e..083f0e55c8c 100644 --- a/packages/cli/test/exit-signal.pin.test.ts +++ b/packages/cli/test/exit-signal.pin.test.ts @@ -993,7 +993,7 @@ describe('the text-face members, driven — ONE error line, the exit status unch it('os package install, a runtime with no install-local endpoint (refused in the try itself): ONE error line, exit 1', async () => { fetchStub.mockImplementation(async () => answer(404, { error: { message: 'not found' } })); - const run = await driveText(PackageInstall, [ARTIFACT, '--runtime', RUNTIME]); + const run = await driveText(PackageInstall, [ARTIFACT, '--url', RUNTIME]); expectOneRefusal(run, RUNTIME); expect(fetchStub).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/test/package-export.test.ts b/packages/cli/test/package-export.test.ts index b49011691dc..5d9e83aff54 100644 --- a/packages/cli/test/package-export.test.ts +++ b/packages/cli/test/package-export.test.ts @@ -136,7 +136,7 @@ describe('os package export', () => { return new Response(JSON.stringify({ success: true, data: answered }), { status: 200 }); })); const file = join(out, exportedPackageDirectory(i, 3, entry.id), EXPORTED_PACKAGE_FILE); - const r = await run(PackageInstall, [file, '--runtime', 'http://target.test']); + const r = await run(PackageInstall, [file, '--url', 'http://target.test']); expect(r.exit, r.out).toBeUndefined(); expect(posted).toHaveLength(1); expect(posted[0].manifest).toEqual(entry); diff --git a/packages/cli/test/package-install-access-upgrade.test.ts b/packages/cli/test/package-install-access-upgrade.test.ts index 42d3ad897a8..2432fbf66ba 100644 --- a/packages/cli/test/package-install-access-upgrade.test.ts +++ b/packages/cli/test/package-install-access-upgrade.test.ts @@ -40,7 +40,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number | vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any); let exit: number | undefined; try { - await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]); + await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test', ...args]); } catch (err: any) { exit = err?.oclif?.exit ?? err?.code ?? 1; } diff --git a/packages/cli/test/package-install-customization-pair.test.ts b/packages/cli/test/package-install-customization-pair.test.ts index c6e7480afc3..25ae8f8d25c 100644 --- a/packages/cli/test/package-install-customization-pair.test.ts +++ b/packages/cli/test/package-install-customization-pair.test.ts @@ -43,7 +43,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number | vi.spyOn(console, 'error').mockImplementation(capture); let exit: number | undefined; try { - await PackageInstall.run([...args, '--runtime', 'http://runtime.test']); + await PackageInstall.run([...args, '--url', 'http://runtime.test']); } catch (err: any) { exit = err?.oclif?.exit ?? err?.code ?? 1; } diff --git a/packages/cli/test/package-install-hot-loaded.test.ts b/packages/cli/test/package-install-hot-loaded.test.ts index a95aeed1310..f66be8d51ea 100644 --- a/packages/cli/test/package-install-hot-loaded.test.ts +++ b/packages/cli/test/package-install-hot-loaded.test.ts @@ -50,7 +50,7 @@ async function runInstall(): Promise<{ out: string; exit: number | undefined }> vi.spyOn(console, 'error').mockImplementation(capture); let exit: number | undefined; try { - await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test']); + await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test']); } catch (err: any) { exit = err?.oclif?.exit ?? err?.code; } diff --git a/packages/cli/test/package-install-local-boot-steps.integration.test.ts b/packages/cli/test/package-install-local-boot-steps.integration.test.ts index bcbd2d9e66a..77e8c7ca94f 100644 --- a/packages/cli/test/package-install-local-boot-steps.integration.test.ts +++ b/packages/cli/test/package-install-local-boot-steps.integration.test.ts @@ -246,7 +246,7 @@ async function authenticate(live: LiveStart): Promise { */ function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { return new Promise((done) => { - const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], { cwd: appDir, env: childEnv({ NO_COLOR: '1' }), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/cli/test/package-install-local-handlers.integration.test.ts b/packages/cli/test/package-install-local-handlers.integration.test.ts index 1c8b25e3f50..478c4456a8a 100644 --- a/packages/cli/test/package-install-local-handlers.integration.test.ts +++ b/packages/cli/test/package-install-local-handlers.integration.test.ts @@ -247,7 +247,7 @@ async function authenticate(live: LiveStart): Promise { */ function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { return new Promise((done) => { - const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], { cwd: appDir, env: childEnv({ NO_COLOR: '1' }), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/cli/test/package-install-local-hooks.integration.test.ts b/packages/cli/test/package-install-local-hooks.integration.test.ts index ecdef90f1df..f0a13e7a713 100644 --- a/packages/cli/test/package-install-local-hooks.integration.test.ts +++ b/packages/cli/test/package-install-local-hooks.integration.test.ts @@ -314,7 +314,7 @@ async function authenticate(live: LiveStart): Promise { */ function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { return new Promise((done) => { - const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], { cwd: appDir, env: childEnv({ NO_COLOR: '1' }), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts index 4c13dc30006..3b6048d68ee 100644 --- a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts @@ -281,7 +281,7 @@ async function authenticate(live: LiveStart): Promise { */ function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { return new Promise((done) => { - const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], { cwd: appDir, env: childEnv({ NO_COLOR: '1' }), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts b/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts index ad6f98f0a6a..68f6982db58 100644 --- a/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts @@ -275,7 +275,7 @@ interface InstallRun { exit: number | null; output: string; elapsedMs: number } function packageInstall(appDir: string, live: LiveStart, deadline: number): Promise { return new Promise((done, fail) => { const started = Date.now(); - const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], { cwd: appDir, env: childEnv({ NO_COLOR: '1' }), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/cli/test/package-install-local-jobs.integration.test.ts b/packages/cli/test/package-install-local-jobs.integration.test.ts index 2cb1f6e7866..a0fe3ddba28 100644 --- a/packages/cli/test/package-install-local-jobs.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs.integration.test.ts @@ -300,7 +300,7 @@ async function authenticate(live: LiveStart): Promise { */ function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { return new Promise((done) => { - const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], { cwd: appDir, env: childEnv({ NO_COLOR: '1' }), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts index cff225fff07..ef9b6de11b3 100644 --- a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts +++ b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts @@ -226,7 +226,7 @@ async function authenticate(live: LiveStart): Promise { */ function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> { return new Promise((done) => { - const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], { + const child = spawn(process.execPath, [CLI, 'package', 'install', './dist/objectstack.json', '--url', live.base, '--email', EMAIL, '--password', PASSWORD], { cwd: appDir, env: childEnv({ NO_COLOR: '1' }), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/cli/test/package-install-mode.test.ts b/packages/cli/test/package-install-mode.test.ts index 77cef3ddc12..4caacaf20fc 100644 --- a/packages/cli/test/package-install-mode.test.ts +++ b/packages/cli/test/package-install-mode.test.ts @@ -51,7 +51,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number | vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any); let exit: number | undefined; try { - await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]); + await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test', ...args]); } catch (err: any) { exit = err?.oclif?.exit ?? err?.code ?? 1; } diff --git a/packages/cli/test/package-install-refusal-rendering.test.ts b/packages/cli/test/package-install-refusal-rendering.test.ts index add556ffccc..b6eaeef1fb2 100644 --- a/packages/cli/test/package-install-refusal-rendering.test.ts +++ b/packages/cli/test/package-install-refusal-rendering.test.ts @@ -35,7 +35,7 @@ async function runInstall(): Promise<{ out: string; exit: number | undefined }> vi.spyOn(console, 'error').mockImplementation(capture); let exit: number | undefined; try { - await PackageInstall.run(['com.example.handlerjobs', '--runtime', 'http://runtime.test']); + await PackageInstall.run(['com.example.handlerjobs', '--url', 'http://runtime.test']); } catch (err: any) { exit = err?.oclif?.exit ?? err?.code; } diff --git a/packages/cli/test/package-install-storage-dir.test.ts b/packages/cli/test/package-install-storage-dir.test.ts index 182a3366324..05813325ef1 100644 --- a/packages/cli/test/package-install-storage-dir.test.ts +++ b/packages/cli/test/package-install-storage-dir.test.ts @@ -43,7 +43,7 @@ async function runInstall(): Promise { const capture = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; vi.spyOn(console, 'log').mockImplementation(capture); vi.spyOn(console, 'error').mockImplementation(capture); - await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test']); + await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test']); return lines.join('\n'); } diff --git a/packages/cli/test/package-install-url-token-flags.test.ts b/packages/cli/test/package-install-url-token-flags.test.ts new file mode 100644 index 00000000000..16423a57371 --- /dev/null +++ b/packages/cli/test/package-install-url-token-flags.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30126 row 13, ruling A] `os package install` names its server and its + * credential the way `os package export` and the other server-talking + * commands do: `-u/--url` and `-t/--token`. + * + * Pinned: + * - `--url` (and `-u`) is the server the install POST goes to, trailing + * slashes dropped; + * - `-r` / `--runtime` are gone with NO alias: either is refused as an + * unknown flag before any request; + * - `--token` (and `-t`, and OS_RUNTIME_TOKEN) is sent as + * `Authorization: Bearer …` on the install POST, with no sign-in request; + * - a token together with `--email`/`--password` is refused before any + * request — the command never has to guess which one the caller meant. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import PackageInstall from '../src/commands/package/install.js'; + +interface Sent { url: string; method?: string; headers: Record; body?: string } + +const ANSWER = { + manifestId: 'com.acme.crm', version: '1.0.0', versionId: 'ver_1', installedAt: '2026-11-06T00:00:00.000Z', + hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages', +}; + +/** Stub every request the command sends; answer the install POST with success. */ +function stubRuntime(): { sent: Sent[] } { + const sent: Sent[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: { method?: string; headers?: Record; body?: string }) => { + sent.push({ url: String(url), method: init?.method, headers: { ...(init?.headers ?? {}) }, body: init?.body }); + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: async () => ({ success: true, data: ANSWER }), + } as any; + })); + return { sent }; +} + +async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> { + const lines: string[] = []; + const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any); + let exit: number | undefined; + try { + await PackageInstall.run(['com.acme.crm', ...args]); + } catch (err: any) { + lines.push(String(err?.message ?? err)); + exit = err?.oclif?.exit ?? err?.code ?? 1; + } + return { out: lines.join('\n'), exit }; +} + +describe('os package install -u/--url and -t/--token (#30126 row 13)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('posts the install to the server --url names, trailing slash dropped', async () => { + const { sent } = stubRuntime(); + const { exit } = await runInstall(['--url', 'http://runtime.test/']); + expect(exit).toBeUndefined(); + expect(sent).toHaveLength(1); + expect(sent[0].url).toBe('http://runtime.test/api/v1/marketplace/install-local'); + }); + + it('takes -u as the short spelling of --url', async () => { + const { sent } = stubRuntime(); + await runInstall(['-u', 'http://short.test']); + expect(sent[0].url).toBe('http://short.test/api/v1/marketplace/install-local'); + }); + + it.each([['--runtime'], ['-r']])('refuses the retired %s as an unknown flag before any request (no alias)', async (flag) => { + const { sent } = stubRuntime(); + const { out, exit } = await runInstall([flag, 'http://runtime.test']); + expect(exit).not.toBeUndefined(); + expect(out).toMatch(/Nonexistent flag|Unexpected argument/); + expect(sent).toHaveLength(0); + }); + + it('sends --token as a bearer token on the install POST, and no sign-in request', async () => { + const { sent } = stubRuntime(); + const { exit } = await runInstall(['--url', 'http://runtime.test', '--token', 'osk_test']); + expect(exit).toBeUndefined(); + expect(sent).toHaveLength(1); + expect(sent[0].url).toMatch(/\/api\/v1\/marketplace\/install-local$/); + expect(sent[0].headers.Authorization).toBe('Bearer osk_test'); + }); + + it('takes -t and OS_RUNTIME_TOKEN as the same credential', async () => { + const { sent } = stubRuntime(); + await runInstall(['--url', 'http://runtime.test', '-t', 'osk_short']); + expect(sent[0].headers.Authorization).toBe('Bearer osk_short'); + + vi.restoreAllMocks(); + const second = stubRuntime(); + vi.stubEnv('OS_RUNTIME_TOKEN', 'osk_env'); + await runInstall(['--url', 'http://runtime.test']); + expect(second.sent[0].headers.Authorization).toBe('Bearer osk_env'); + }); + + it('sends no Authorization header without a token', async () => { + const { sent } = stubRuntime(); + await runInstall(['--url', 'http://runtime.test']); + expect(sent[0].headers).not.toHaveProperty('Authorization'); + }); + + it('refuses a token together with --email/--password before any request', async () => { + const { sent } = stubRuntime(); + const { out, exit } = await runInstall([ + '--url', 'http://runtime.test', '--token', 'osk_test', '--email', 'a@b.test', '--password', 'pw', + ]); + expect(exit).toBe(1); + expect(out).toContain('Pass either --token or --email/--password, not both'); + expect(sent).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/package-install-withheld-maps.test.ts b/packages/cli/test/package-install-withheld-maps.test.ts index 977a9a02990..fb1d2963fff 100644 --- a/packages/cli/test/package-install-withheld-maps.test.ts +++ b/packages/cli/test/package-install-withheld-maps.test.ts @@ -45,7 +45,7 @@ async function runInstall(args: string[]): Promise<{ out: string; exit: number | vi.spyOn(console, 'error').mockImplementation(capture); let exit: number | undefined; try { - await PackageInstall.run([...args, '--runtime', 'http://runtime.test']); + await PackageInstall.run([...args, '--url', 'http://runtime.test']); } catch (err: any) { exit = err?.oclif?.exit ?? err?.code ?? 1; } diff --git a/packages/create-objectstack/src/templates/AGENTS.md b/packages/create-objectstack/src/templates/AGENTS.md index 6c4dcbb6c5b..2606ff3ebd3 100644 --- a/packages/create-objectstack/src/templates/AGENTS.md +++ b/packages/create-objectstack/src/templates/AGENTS.md @@ -100,7 +100,7 @@ deployment. holds that `src/` does not register, the `.custom` package among them, to `objectstack-export/NN-ID/objectstack.json`. Commit that directory. After the deployment is up, install each file into it, in the numbered order: - `npx os package install objectstack-export/NN-ID/objectstack.json --runtime URL`. + `npx os package install objectstack-export/NN-ID/objectstack.json --url URL`. `{{PM}} run build` prints a notice when the local dev database holds such a customization. diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 4f1eccb0f2b..42bc5ca9832 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -185,7 +185,7 @@ database (locally, `.objectstack/data/`) as an item of the package `src/` does not register, the `.custom` package among them, to `objectstack-export/NN-ID/objectstack.json`. Commit that directory, and once the deployment is up install each file into it in the numbered order with - `npx os package install objectstack-export/NN-ID/objectstack.json --runtime URL`. + `npx os package install objectstack-export/NN-ID/objectstack.json --url URL`. `pnpm build` prints a notice when your local dev database holds such a change. diff --git a/packages/rest/src/meta-app-publish-gate.test.ts b/packages/rest/src/meta-app-publish-gate.test.ts index 530a7290914..677be0893a4 100644 --- a/packages/rest/src/meta-app-publish-gate.test.ts +++ b/packages/rest/src/meta-app-publish-gate.test.ts @@ -565,7 +565,11 @@ describe('#8013 — by-name: a permission denial is REPORTED, absence still is n // accessor for both answers, not a second dialect to special-case. expect(denied.body?.success).toBe(false); expect(typeof refusal(denied.body).message).toBe('string'); - expect(refusal(denied.body).message).toContain('finance'); + // [#30126 row 12] The operation catalog's `permission_denied`, in the + // caller's locale (English here: the session names none). It names + // no app: the API name is not a person's word, and the caller named it. + expect(refusal(denied.body).message).toBe( + 'You do not have permission to perform this action. Contact your administrator if you need access.'); // Refused means refused: the document did not ride along with the // refusal, and neither did the objects it would have exposed. diff --git a/packages/rest/src/meta-item-absent-404.test.ts b/packages/rest/src/meta-item-absent-404.test.ts index c986aafcc56..b66d9f24825 100644 --- a/packages/rest/src/meta-item-absent-404.test.ts +++ b/packages/rest/src/meta-item-absent-404.test.ts @@ -245,7 +245,9 @@ describe('[#18066] §2 — the #8013 partition survives, in both directions', () expect(res.statusCode).toBe(403); expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); expect(res.body?.success).toBe(false); - expect(res.body?.error?.message).toContain('finance'); + // [#30126 row 12] The catalog's `permission_denied`, which names no app. + expect(res.body?.error?.message).toBe( + 'You do not have permission to perform this action. Contact your administrator if you need access.'); expect(res.body?.item).toBeUndefined(); expect(JSON.stringify(res.body ?? {})).not.toContain('invoice'); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 2fe995deac0..6be225ec2f4 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -66,7 +66,7 @@ */ import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; -import { preferredLocaleFromHeader } from '@objectstack/spec/system'; +import { preferredLocaleFromHeader, renderOperationMessage } from '@objectstack/spec/system'; import { apiExposureDenialReason } from '@objectstack/spec/data'; import { resolveObjectSortability } from '@objectstack/spec/api'; import { canonicalMetaUrlType, pluralToSingular, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; @@ -143,8 +143,20 @@ export interface MetaReadGateAudienceSources { * gated audiences DENY (ADR-0049). */ resolveSecurityService(): Promise; + /** + * [#30126 row 12] The deployment's `errors.*` override lookup (an + * `II18nService.t`-compatible function), or `undefined` when there is none — + * the hook `renderOperationMessage` reads first, as plugin-security's + * refusals hand it. Absent: the built-in catalog alone, still in the + * caller's locale. A rejection is read as "no override", never as a fault: + * a refusal's wording must not turn a 403 into a 500. + */ + resolveMessageTranslator?(): Promise; } +/** An `II18nService.t`-compatible lookup, as {@link MetaReadGateAudienceSources.resolveMessageTranslator} answers it. */ +export type MetaReadGateMessageTranslator = (key: string, locale: string, params?: Record) => string; + /** How a transport reads a whole metadata type (the list half of the ports). */ export interface MetaReadGateListSource { /** @@ -790,11 +802,44 @@ function callerSystemPermissions(ctx: MetaReadGateCaller | undefined): Set(Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : []); } +/** + * [#30126 row 12] The sentence a `403 PERMISSION_DENIED` of this gate carries: + * the operation catalog's `permission_denied`, rendered in the caller's locale + * (`ExecutionContext.locale`, which the shared assembler resolved from the + * request's `Accept-Language`, then the `localization` settings) — the one + * catalog plugin-security's object and capability refusals render, ⛔ never a + * second one here. It names nothing, as that key never does + * (`operation-message.ts` states why): the caller already knows which app, + * dashboard or view they opened, and its API name (`com.objectstack.setup`) + * is exactly the vocabulary that must not reach a person. The name stays on + * the request URL. + * + * Resolution is `renderOperationMessage`'s: the deployment's + * `errors.permission_denied` override where the transport hands a lookup in, + * then the built-in catalog for the locale, then `en`. + */ +async function permissionDeniedMessage( + sources: MetaReadGateAudienceSources, + ctx: MetaReadGateCaller | undefined, +): Promise { + const locale = typeof ctx?.locale === 'string' && ctx.locale.length > 0 ? ctx.locale : undefined; + let translate: MetaReadGateMessageTranslator | undefined; + try { + translate = await sources.resolveMessageTranslator?.(); + } catch { + translate = undefined; + } + return renderOperationMessage({ messageKey: 'permission_denied' }, { locale, translate }); +} + /** [#22639] The by-name refusal of a list view or a dashboard the caller may not open — the app arm's whole refusal (#8013). */ -function audienceRefusal(kind: 'view' | 'dashboard', name: string): MetaItemReadRefusal { +async function audienceRefusal( + sources: MetaReadGateAudienceSources, + ctx: MetaReadGateCaller | undefined, +): Promise { return { reason: 'audience-permission', status: 403, code: 'PERMISSION_DENIED', - message: `You do not have permission to open the '${name}' ${kind}.`, + message: await permissionDeniedMessage(sources, ctx), }; } @@ -1618,7 +1663,9 @@ const DOCS_HOLDER_MESSAGE = 'This documentation is limited to holders of a permi export function createMetaItemReadGate( sources: MetaItemReadGateSources, metaType: string, - name: string, + // [#30126 row 12] Read by no gate since the refusals stopped naming the + // item; kept so both transports' calls stay as they are. + _name: string, documents: readonly any[], policy: MetaReadGatePolicy, ): (document: any) => Promise { @@ -1632,6 +1679,8 @@ export function createMetaItemReadGate( if (metaType === 'app') { type AppGateInputs = { + /** [#30126 row 12] The caller, kept for the refusal's locale. */ + ctx: MetaReadGateCaller; sysPerms: Set; serviceGate?: (n: string) => boolean; servabilityGate?: NavServabilityGate; @@ -1668,7 +1717,7 @@ export function createMetaItemReadGate( const docAudienceGate = authorExempt ? undefined : await resolveNavDocAudience(sources, documents); - return { sysPerms, serviceGate, servabilityGate, docAudienceGate, authorExempt }; + return { ctx, sysPerms, serviceGate, servabilityGate, docAudienceGate, authorExempt }; })()); return async (document) => { if (document == null) return serve(document); @@ -1690,7 +1739,7 @@ export function createMetaItemReadGate( if (gated.withheld === 'permission') { return refuse({ reason: 'app-permission', status: 403, code: 'PERMISSION_DENIED', - message: `You do not have permission to open the '${name}' app.`, + message: await permissionDeniedMessage(sources, gateInputs.ctx), }); } // [#18066] The transport's own absence answer, so this arm @@ -1722,7 +1771,7 @@ export function createMetaItemReadGate( // whole refusal, author or not. const sysPerms = callerSystemPermissions(ctx); if (sysPerms && !holdsRequiredPermissions(document.requiredPermissions, sysPerms)) { - return refuse(audienceRefusal('dashboard', name)); + return refuse(await audienceRefusal(sources, ctx)); } if (policy.arms !== 'all') return serve(document); // ADR-0057 D10: gate dashboard widgets by `requiresService` (mirrors @@ -1747,7 +1796,7 @@ export function createMetaItemReadGate( const gated = filterViewForUser(document, sysPerms, isAggregatedViewContainer); // ONE view the caller does not hold: refused whole, on every door, // author or not — the app arm's whole refusal. - if (gated == null) return refuse(audienceRefusal('view', name)); + if (gated == null) return refuse(await audienceRefusal(sources, ctx)); // A container's entries are pruned per caller; on a door that // honours ruling 5856774816's author exemption, a caller who may // write the container reads it as stored (see diff --git a/packages/rest/src/meta-permission-refusal-locale.test.ts b/packages/rest/src/meta-permission-refusal-locale.test.ts new file mode 100644 index 00000000000..2f5b76d91f8 --- /dev/null +++ b/packages/rest/src/meta-permission-refusal-locale.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30126 row 12] The `/meta` read gate's `403 PERMISSION_DENIED` — an app, + * a dashboard or a list view the caller may not open — speaks the caller's + * language, as the object and capability refusals of plugin-security do. + * + * Opening the Setup app without access answered, in a Chinese console, + * `You do not have permission to open the 'com.objectstack.setup' app.`: an + * English sentence around an API name. The gate now renders the operation + * catalog's `permission_denied` (`@objectstack/spec/system`) in the + * caller's `ExecutionContext.locale`, through `renderOperationMessage`. + * + * Pinned: + * - a `zh-CN` caller reads the catalog's Chinese sentence, on all three + * refusals, under the same status and code; + * - a caller with no locale reads the English one; + * - the sentence never carries the item's API name; + * - a deployment's `errors.permission_denied` override wins, as it does for + * plugin-security's refusals; a lookup that throws leaves the built-in + * sentence and the 403. + */ + +import { describe, it, expect, vi } from 'vitest'; +// Explicit `.js` extension: NodeNext resolution (see the sibling gate tests). +import { RestServer } from './rest-server.js'; + +const ZH = '您没有执行此操作的权限,如需访问请联系管理员。'; +const EN = 'You do not have permission to perform this action. Contact your administrator if you need access.'; + +const SETUP_ID = 'com.objectstack.setup'; +const STORE: Record = { + app: [{ name: SETUP_ID, 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', order: 0, config: { type: 'grid', columns: ['name'], requiredPermissions: ['legal.view'] } }], +}; + +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.send = vi.fn(() => res); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +interface Setup { + locale?: string; + translate?: (key: string, locale: string) => string; + translatorThrows?: boolean; +} + +async function read(type: string, name: string, opts: Setup = {}) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue({ types: Object.keys(STORE) }), + getMetaItems: vi.fn(async ({ type: t }: any) => ({ type: singular(t), items: clone(STORE[singular(t)] ?? []) })), + getMetaItem: vi.fn(async ({ type: t, name: n }: any) => { + const found = (STORE[singular(t)] ?? []).find((i: any) => i.name === n); + return { type: singular(t), name: n, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + }; + const server = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + const rest: any = new RestServer(server as any, protocol, {} as any); + rest.resolveExecCtx = async () => ({ + userId: 'u_member', systemPermissions: [] as string[], + ...(opts.locale ? { locale: opts.locale } : {}), + }); + rest.securityServiceProvider = async () => ({ resolvePermissionSetNames: async () => [] }); + rest.serviceExistsProvider = () => true; + rest.resolveMessageTranslator = async () => { + if (opts.translatorThrows) throw new Error('i18n service unavailable'); + if (!opts.translate) return undefined; + const t = opts.translate; + return (key: string, locale: string) => t(key, locale); + }; + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + const res = makeRes(); + await route.handler({ method: 'GET', path: `/api/v1/meta/${type}/${name}`, params: { type, name }, query: {}, body: {}, headers: {} }, res); + return res; +} + +const REFUSALS = [['app', SETUP_ID], ['dashboard', 'legal_board'], ['view', 'contract.legal_queue']] as const; + +describe('[#30126 row 12] the /meta read gate\'s PERMISSION_DENIED speaks the caller\'s locale', () => { + for (const [type, name] of REFUSALS) { + it(`${type}/${name}: a zh-CN caller reads the catalog's Chinese sentence, 403 PERMISSION_DENIED`, async () => { + const res = await read(type, name, { locale: 'zh-CN' }); + expect(res.statusCode).toBe(403); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(res.body?.error?.message).toBe(ZH); + }); + + it(`${type}/${name}: a caller with no locale reads the English sentence, which names no API name`, async () => { + const res = await read(type, name); + expect(res.statusCode).toBe(403); + expect(res.body?.error?.message).toBe(EN); + expect(JSON.stringify(res.body)).not.toContain(name); + }); + } + + it('a deployment\'s errors.permission_denied override wins, in the caller\'s locale', async () => { + const res = await read('app', SETUP_ID, { + locale: 'zh-CN', + translate: (key, locale) => (key === 'errors.permission_denied' && locale === 'zh-CN' ? '请向 IT 部门申请设置权限。' : key), + }); + expect(res.statusCode).toBe(403); + expect(res.body?.error?.message).toBe('请向 IT 部门申请设置权限。'); + }); + + it('a lookup that echoes the key back is no override: the built-in sentence', async () => { + const res = await read('dashboard', 'legal_board', { locale: 'zh-CN', translate: (key) => key }); + expect(res.body?.error?.message).toBe(ZH); + }); + + it('a lookup that cannot be resolved leaves the built-in sentence and the 403, never a 500', async () => { + const res = await read('view', 'contract.legal_queue', { locale: 'zh-CN', translatorThrows: true }); + expect(res.statusCode).toBe(403); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(res.body?.error?.message).toBe(ZH); + }); +}); diff --git a/packages/rest/src/meta-view-dashboard-audience-gate.test.ts b/packages/rest/src/meta-view-dashboard-audience-gate.test.ts index 825d1818fd2..f96d55c8440 100644 --- a/packages/rest/src/meta-view-dashboard-audience-gate.test.ts +++ b/packages/rest/src/meta-view-dashboard-audience-gate.test.ts @@ -258,7 +258,8 @@ describe('[#22639] the by-name read: a list view or a dashboard the caller does const refused = (await item('non-holder', type, name)).res; expect(envelope(refused)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); // The app's whole refusal, written by the same emitter: the same - // envelope keys, only the message names the item. + // envelope keys. [#30126 row 12] And the same localized sentence, + // which names no item (`meta-permission-refusal-locale.test.ts`). const app = (await item('non-holder', 'app', 'payroll')).res; expect(Object.keys(refused.body).sort()).toEqual(Object.keys(app.body).sort()); expect(Object.keys(refused.body.error).sort()).toEqual(Object.keys(app.body.error).sort()); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5d315ce0be7..55fd1ad6103 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3723,6 +3723,9 @@ export class RestServer { ? this.securityServiceProvider(await this.resolveRequestEnvironmentId(environmentId, req)) : undefined ), + // [#30126 row 12] A refusal's `errors.*` override, as the import + // runner's messages resolve theirs. + resolveMessageTranslator: () => this.resolveMessageTranslator(environmentId, req), }; }