Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d8d7945
wip(#32656): compose advisories' userMessage in the request's locale
claude Nov 14, 2026
f562cc5
feat(metadata-protocol): compose write findings' userMessage in the c…
claude Nov 14, 2026
c3de690
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 14, 2026
0b4a03f
fix(plugin-security): permission refusals speak the caller's language…
claude Nov 14, 2026
caeb904
fix(#32656): keep the batch publish body closed; pin the server-state…
claude Nov 14, 2026
725ea5e
Merge branch 'claude/project-thread-1hzwul' into claude/area-A2-32656…
claude Nov 14, 2026
fa8f2f5
test(#32656): pay the lint load at collection in the gate pass-throug…
claude Nov 14, 2026
fedb484
test(#32656): name the server-stated locales on the publish-then-read…
claude Nov 14, 2026
0a21737
Merge branch 'claude/project-thread-1hzwul' into claude/area-A2-32656…
claude Nov 14, 2026
085b953
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 14, 2026
cae401f
Merge branch 'claude/project-thread-1hzwul' into claude/area-A2-32656…
claude Nov 14, 2026
60c269e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 14, 2026
1cffbad
chore(spec): regenerate the reference page and authorable-surface sha…
claude Nov 14, 2026
979fbb3
Merge branch 'claude/project-thread-1hzwul' into claude/area-A2-32656…
claude Nov 14, 2026
03adc6e
Merge remote-tracking branch 'origin/main' into claude/area-A2-32656-…
claude Nov 14, 2026
24f2148
chore(spec): regenerate the API surface and protocol reference on the…
claude Nov 14, 2026
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/32656-refusal-user-text-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-security': patch
'@objectstack/metadata-protocol': patch
---

fix(plugin-security): permission refusals speak the caller's language, falling back to the workspace default

Clause-②: no
Metadata-protocol: none

Ruling C on #32440, second half of #32656: a refusal's user-facing sentence is composed in the request's language, else the workspace's default language, else English. Codes, statuses and the diagnostic `message` are unchanged.

- **Packaged permission-set lock.** Saving a permission set an installed package provides, and the fail-closed refusal when the system cannot tell, used to answer their `userMessage` in English only. They now answer in the caller's language (English, Simplified and Traditional Chinese, Japanese, Korean, Spanish, French, German, Brazilian Portuguese, Russian and Arabic). The metadata write door hands each type's authoring gate the caller's two languages (`MetadataAuthoringGateContext.userTextLocales`); a caller that states none gets English, as before.
- **Permission denials.** The record, field, owner and queue denials of the security middleware were rendered in the request's language only, so a request in a language the platform has no text in fell to English even when the workspace default had text. They now fall back to the workspace default first. A deployment's `errors.<key>` translation override counts as text for its language.
12 changes: 12 additions & 0 deletions packages/metadata-protocol/src/mutation-listeners.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
// Paid at collection, not inside a clocked case (check-test-source-alias).
import '@objectstack/lint';
import type { MetadataMutationEvent } from './protocol.js';

function makeProtocol() {
Expand Down Expand Up @@ -149,6 +151,16 @@ describe('ObjectStackProtocolImplementation.registerAuthoringGate (#3050)', () =
});
});

it('[#32656] hands the gate the caller\'s user-text locales, empty entries dropped; none stated, no key', async () => {
const p = makeProtocol();
const seen: any[] = [];
p.registerAuthoringGate('object', (ctx) => { seen.push(ctx); });
await (p as any).runAuthoringGate(save({ userTextLocales: ['zh-CN', undefined, '', 'de'] }));
await (p as any).runAuthoringGate(save());
expect(seen[0].userTextLocales).toEqual(['zh-CN', 'de']);
expect('userTextLocales' in seen[1]).toBe(false);
});

it('normalizes plural registrations to the singular type', async () => {
const p = makeProtocol();
const gate = vi.fn();
Expand Down
7 changes: 7 additions & 0 deletions packages/metadata-protocol/src/protocol-authoring-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface MetadataAuthoringGateContext {
isArtifactBacked: boolean;
/** The packaged (code-layer) baseline body when {@link isArtifactBacked}; the declaration an overlay customizes. */
declaredBody?: unknown;
/**
* [#32656] The locales a gate's refusal `userMessage` is composed in, in
* ladder order (the request's, then the workspace default), as the write
* door stated them (`SaveMetaItemRequestSchema.userTextLocales`). Absent
* when the door stated none: the gate composes `en`.
*/
userTextLocales?: readonly string[];
}

export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise<void>;
Expand Down
4 changes: 4 additions & 0 deletions packages/metadata-protocol/src/protocol-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export async function runAuthoringGate(
this: Pick<AuthoringGateHost, 'authoringGates' | 'engine' | 'isArtifactBacked'>,
evt: {
type: string; name: string; state: 'draft' | 'active'; body: unknown;
userTextLocales?: readonly (string | undefined)[];
}): Promise<void> {
const singular = PLURAL_TO_SINGULAR[evt.type] ?? evt.type;
const gate = this.authoringGates.get(singular);
Expand All @@ -203,6 +204,9 @@ export async function runAuthoringGate(
body: evt.body,
isArtifactBacked: artifactBacked,
...(declaredBody !== undefined ? { declaredBody } : {}),
...(evt.userTextLocales !== undefined
? { userTextLocales: evt.userTextLocales.filter((l): l is string => typeof l === 'string' && l.length > 0) }
: {}),
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/metadata-protocol/src/protocol-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,7 @@ export class ProtocolSaveFacet {
name: request.name,
state: mode === 'draft' ? 'draft' : 'active',
body: request.item,
...(request.userTextLocales !== undefined ? { userTextLocales: request.userTextLocales } : {}),
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,7 @@ export class ObjectStackProtocolImplementation implements
*/
private runAuthoringGate(evt: {
type: string; name: string; state: 'draft' | 'active'; body: unknown;
userTextLocales?: readonly (string | undefined)[];
}): Promise<void> {
// [#25467 cut 8] The body moved to `protocol-authoring-gate.ts` (see `AuthoringGateHost`).
const readAuthoringGates = () => this.authoringGates;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export interface PermissionSetLockGateContext {
type: string;
name: string;
body: unknown;
/** [#32656] The caller's language ladder, stated by the write door (`MetadataAuthoringGateContext`). */
userTextLocales?: readonly string[];
}

/**
Expand Down Expand Up @@ -111,7 +113,7 @@ export function registerPackagedPermissionSetLockGate(protocol: any, ql: any): b
// A metadata-door save that targets a package-declared name is an
// override of the shipped base whether or not an overlay row exists yet,
// so the remedy the refusal teaches is the clone path.
assertPermissionSetNotPackageDeclared(ctx.name, ql, 'update', probe);
assertPermissionSetNotPackageDeclared(ctx.name, ql, 'update', probe, ctx.userTextLocales);
});
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,46 @@ describe('the lock refusal carries its guidance as `userMessage` on the wire env
expect(String(rejection.message)).toContain('registry unavailable');
});

// [#32656] Ruling C on #32440: the producer composes the sentence in the
// request's language, else the workspace default, else `en`. The write door
// states the two locales on the gate's context (`userTextLocales`).
it('[#32656] the refusal speaks the caller\'s language: request locale, then the workspace default, then en', async () => {
const gate = lockGate(makeQl([shippedArtifact()]));
const refuse = (userTextLocales?: string[]) => gate({
type: 'permission', name: SET_NAME, body: packagedSet(), ...(userTextLocales ? { userTextLocales } : {}),
} as any).then(() => null, (e: any) => e);

const zh = await refuse(['zh-CN', 'en']);
expect(zh).toBeInstanceOf(PackagedPermissionSetLockedError);
expect(zh.userMessage).toContain('此权限集由已安装的软件包提供');
expectEndUserCopy(zh.userMessage, SET_NAME, PACKAGE_ID);
const ja = await refuse(['ja-JP', 'zh-CN']);
expect(ja.userMessage, 'the request language wins when it has text').toContain('この権限セット');
const workspace = await refuse(['tlh', 'zh-TW']);
expect(workspace.userMessage, 'an unsupported request language falls back to the workspace default').toContain('此權限集');
const floor = await refuse(['tlh', 'tlh']);
const unstated = await refuse();
expect(floor.userMessage).toBe(unstated.userMessage);
expect(String(unstated.userMessage)).toMatch(/clone it instead/i);
// Only the user's half moves: code, status and the diagnostic are the same in every language.
for (const r of [zh, ja, workspace, floor]) {
expect(r.code).toBe('NOT_OVERRIDABLE');
expect(r.status).toBe(403);
expect(r.message).toBe(unstated.message);
}
});

it('[#32656] the provenance-unknown refusal follows the same ladder', async () => {
const ql = makeQl([]);
ql.registry = { listItems: () => { throw new Error('registry unavailable'); } };
const gate = lockGate(ql, async () => { throw new Error('metadata store unavailable'); });
const rejection = await gate({ type: 'permission', name: 'unknown_provenance', body: {}, userTextLocales: ['de-DE'] } as any)
.then(() => null, (e: any) => e);
expect(rejection).toBeInstanceOf(PackagedPermissionSetProvenanceUnknownError);
expect(rejection.userMessage).toContain('kann gerade nicht gespeichert werden');
expectEndUserCopy(rejection.userMessage, 'unknown_provenance', 'registry unavailable', 'metadata store unavailable');
});

it('a SchemaRegistry that answers with a NON-LIST is the same ambiguity: the save is refused, not read as "nothing declared"', async () => {
const ql = makeQl([]);
ql.registry = { listItems: () => undefined as any };
Expand Down
Loading
Loading