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
41 changes: 41 additions & 0 deletions .changeset/approval-recall-refusal-localized.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
"@objectstack/plugin-approvals": minor
---

fix(plugin-approvals): the non-submitter recall refusal renders through the
Operation Message Catalog instead of a hardcoded English sentence (#11993, the
services-side half of the shape-A ruling)

A user who opened a record someone else had submitted for approval, clicked
Recall and was correctly refused read the reason in English regardless of their
own locale. `@objectstack/rest`'s `handleApprovalError` ships this service's
thrown reason as the 403 body's human-readable `error`, and Console splices it
under its own localized label — so an operator in a fully Chinese deployment
read a Chinese prefix glued onto an English sentence they could not act on
(`撤回审批失败: <English>`).

The refusal now renders through the shared Operation Message Catalog in
`@objectstack/spec/system` under the key `approval_recall_not_submitter` that
#12493 landed for it — the same mechanism `plugin-security`'s denial gates
already use, with the same resolution ladder (deployment override → the
caller's locale → `en` → the key) and the same guarantee that a misbehaving
i18n service cannot turn a 403 into a 500. All four platform locales (`en`,
`zh-CN`, `ja-JP`, `es-ES`) ship copy that names who *can* recall, rather than
dead-ending the reader.

`ApprovalServiceOptions` gains an optional `messageTranslator` — a lazily
resolved, `II18nService.t`-compatible lookup, wired by `ApprovalsServicePlugin`
the same way `tenancyPosture` and the field-visibility source are, because the
i18n service is contributed by another plugin and may start later. It is what
makes the override address the catalog documents,
`errors.approval_recall_not_submitter`, actually take effect for this emitter;
a stack without an i18n service still renders the built-in catalog in the
caller's locale.

**Not changed: who may recall an approval.** The gate is byte-identical — the
submitter, or a privileged admin releasing a stuck record (#3424). Only the
sentence the refusal carries is different, and the `FORBIDDEN:` code prefix
that the REST layer maps to 403 is untouched.

The button-visibility half of #11993 — a non-submitter seeing a live recall
button at all — is not addressed here; see the issue for the measurement.
86 changes: 85 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,6 +50,12 @@ import type {
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts';
import { isFileIdToken } from '@objectstack/spec/data';
// [#11993] The SANCTIONED renderer for OPERATION-level refusal copy. The
// Operation Message Catalog is the ONE seat for these sentences — its own
// header bars both a package-local string table and a second rendering
// mechanism for a second producer, and #12493 landed this service's key
// (`approval_recall_not_submitter`) into it ahead of this consumer half.
import { renderOperationMessage, type ValidationMessageTranslator } from '@objectstack/spec/system';
import { isGrantActive } from '@objectstack/core';
import {
filterApproversWhoCanRead,
Expand DownExpand Up@@ -598,6 +604,21 @@ export interface ApprovalServiceOptions {
* its boundaries.
*/
recordReaderVisibleObjects?: string[];
/**
* [#11993] Deployment i18n lookup for user-facing refusal copy — an
* `II18nService.t`-compatible function, resolved LAZILY per refusal for the
* same reason {@link ApprovalServiceOptions.tenancyPosture} is: the i18n
* service is registered by another plugin (ADR-0029 D8) which may start
* after this one, and a lookup captured at construction would pin
* `undefined` for the life of the process.
*
* Absent (or resolving to `undefined`) is a supported stack, not a
* degraded one: the built-in catalog still renders the caller's locale.
* What it adds is the override address the catalog documents —
* `errors.approval_recall_not_submitter` — so a deployment's own
* `translation` metadata wins over the built-in sentence.
*/
messageTranslator?: () => ValidationMessageTranslator | undefined;
}

export class ApprovalService implements IApprovalService {
Expand All@@ -609,6 +630,8 @@ export class ApprovalService implements IApprovalService {
private publicBaseUrl: string;
private tenancyPosture?: () => string | undefined;
private fieldVisibility?: FieldVisibilitySource;
/** [#11993] Lazily-resolved deployment i18n lookup for refusal copy. */
private messageTranslator?: () => ValidationMessageTranslator | undefined;
/**
* [#8652] The enabled object set for the record-reader visibility tier.
* EMPTY means the tier is off — the default, and the shape every existing
Expand All@@ -634,6 +657,7 @@ export class ApprovalService implements IApprovalService {
this.publicBaseUrl = (opts.publicBaseUrl ?? '').replace(/\/$/, '');
this.tenancyPosture = opts.tenancyPosture;
this.fieldVisibility = opts.fieldVisibility;
this.messageTranslator = opts.messageTranslator;
this.recordReaderVisibleObjects = new Set(
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : [])
.map((n) => String(n ?? '').trim())
Expand DownExpand Up@@ -825,6 +849,47 @@ export class ApprovalService implements IApprovalService {
return requestOrg == null || (actorTenant != null && String(requestOrg) === String(actorTenant));
}

/**
* [#11993] The END USER's half of an approval refusal.
*
* `handleApprovalError` in `@objectstack/rest` maps this service's
* `CODE: message` throws onto the wire by testing the prefix for the status
* and then STRIPPING it (a leading run of `[A-Z_]` plus a colon and any
* following whitespace), shipping what remains as the body's
* human-readable `error` — which Console splices under its own localized
* label ("撤回审批失败: …"). A hardcoded English reason therefore reaches an
* operator in a fully Chinese deployment as a Chinese prefix glued onto an
* English sentence they cannot act on.
*
* Rendered through the SHARED Operation Message Catalog
* (`@objectstack/spec/system`), not a second mechanism: same
* `errors.<key>` override address, same resolution ladder (deployment
* override -> locale catalog -> `en` -> the key), same guarantee that a
* misbehaving i18n service cannot turn a 403 into a 500. The catalog's
* header names this seat explicitly; `plugin-security`'s
* `userFacingDenialMessage` is the sibling consumer this mirrors.
*
* The CODE PREFIX stays on the message. It is not user copy — it is how the
* REST layer derives the status and the ADR-0112 wire code, and it is
* stripped before the sentence reaches a body. The developer's half moves
* to the log, where the ids it names are legible to an operator and to
* nobody else.
*/
private userFacingRefusal(
messageKey: 'approval_recall_not_submitter',
context: ExecutionContext,
): string {
let translate: ValidationMessageTranslator | undefined;
try {
translate = this.messageTranslator?.();
} catch {
// i18n is optional and late-bound; the built-in catalog still renders
// the caller's locale without it.
translate = undefined;
}
return renderOperationMessage({ messageKey }, { locale: context?.locale, translate });
}

/**
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
*
Expand DownExpand Up@@ -2701,9 +2766,28 @@ export class ApprovalService implements IApprovalService {
}
// The submitter withdraws their own request; a privileged admin may recall
// any pending request to release a stuck record (#3424).
//
// [#11993] The GATE is untouched — who may recall an approval is exactly
// what it was. Only the refusal's user-facing half changed: it used to be
// one hardcoded English sentence that Console rendered verbatim in a
// toast. See {@link ApprovalService.userFacingRefusal}.
if (!this.isOverrideActor(context, raw.organization_id ?? null)
&& raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
// The developer's half: the ids the catalog sentence deliberately does
// not name (the throw site knows the submitter only as an opaque user
// id), kept where a developer reads them and a user never does.
const developerMessage =
`[approvals] recall refused: actor '${actorId}' is not the submitter of request `
+ `'${requestId}' (submitter '${String(raw.submitter_id)}') and holds no #3424 override`;
this.logger?.warn?.(developerMessage, {
request: requestId,
actor: actorId,
submitter: String(raw.submitter_id),
status: raw.status,
});
throw new Error(
`FORBIDDEN: ${this.userFacingRefusal('approval_recall_not_submitter', context)}`,
);
}
// A returned request is only recallable while it is still the run's live
// frontier — a resubmitted (or later-node) request supersedes it.
Expand Down
18 changes: 18 additions & 0 deletions packages/plugins/plugin-approvals/src/approvals-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -199,6 +199,24 @@ export class ApprovalsServicePlugin implements Plugin {
return undefined;
}
},
// [#11993] Deployment override lookup for user-facing refusal copy.
// Resolved LAZILY for the reason the two providers above are: the i18n
// service is contributed by another plugin (ADR-0029 D8) and may start
// after this one. Without it the service still renders the built-in
// catalog in the caller's locale; with it, a deployment's own
// `translation` for `errors.approval_recall_not_submitter` wins — the
// override address the catalog documents, made real for this emitter.
messageTranslator: () => {
try {
const i18n = ctx.getService<II18nService>('i18n');
const t = i18n?.t;
if (typeof t !== 'function') return undefined;
return (key: string, locale: string, params?: Record<string, unknown>) =>
t.call(i18n, key, locale, params);
} catch {
return undefined;
}
},
});

// [#10749] Field-visibility authority for payload-snapshot redaction.
Expand Down
Loading
Loading