Skip to content

Commit 122d716

Browse files
committed
feat(admin): opt out of implicit response translation via x-skip-translation
Admin always edits/manages original records; no admin endpoint consumes translated payloads. Send `x-skip-translation: 1` on every outbound request (shared $api instance plus the raw agent-chat SSE fetch) so the backend ignores browser-implicit Accept-Language and NEXT_LOCALE cookie. Explicit `?lang=xx` query and `x-lang` header remain respected for business filters (enrichment per-locale rows, search index).
1 parent 40c0434 commit 122d716

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

apps/admin/src/components/editor/rich/agent-chat/composables/use-agent-loop.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ function createAdminTransport(providerId: string): TransportAdapter {
4646
return async (messages, tools, model, signal) => {
4747
const response = await fetch(`${API_URL}/ai/agent/chat`, {
4848
method: 'POST',
49-
headers: { 'Content-Type': 'application/json' },
49+
headers: {
50+
'Content-Type': 'application/json',
51+
'x-skip-translation': '1',
52+
},
5053
credentials: 'include',
5154
body: JSON.stringify({ model, messages, tools, providerId }),
5255
signal,

apps/admin/src/utils/request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const $api = ofetch.create({
4040
onRequest({ options }) {
4141
const headers = new Headers(options.headers)
4242
headers.set('x-uuid', _uuid)
43+
headers.set('x-skip-translation', '1')
4344

4445
// GET 请求添加时间戳防缓存
4546
if (options.method?.toUpperCase() === 'GET') {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Admin: Skip Implicit Translation in API Requests
2+
3+
**Date:** 2026-05-22
4+
**Status:** Approved (pending implementation plan)
5+
**Scope:** `admin-vue3` (frontend) + `mx-core` (backend)
6+
7+
## Problem
8+
9+
The admin dashboard (`admin-vue3`) loads content the user is *editing* or *managing*. When a post has been translated (e.g., zh → en), the backend currently honors the browser's implicit `Accept-Language: zh-CN` header and returns the translated body in place of the original. The version-diff dialog (see screenshot of `/posts/edit?id=...`) shows the translated content as the "old" baseline, which is misleading: the editor must always operate on the original record.
10+
11+
Root cause: `RequestContextMiddleware` (`mx-core/apps/core/src/common/middlewares/request-context.middleware.ts`) resolves the request language from four sources in priority order:
12+
13+
1. `x-lang` header
14+
2. `NEXT_LOCALE` cookie
15+
3. `Accept-Language` header
16+
4. `undefined`
17+
18+
Admin never sets `x-lang` and has no `NEXT_LOCALE` cookie, so requests fall through to `Accept-Language` (set automatically by the browser). Downstream translation logic (post hydration, enrichment hydration via `RequestContext.currentLang()`) then substitutes the translated record.
19+
20+
## Audit: No Admin API Consumes Translated Payloads
21+
22+
A scan of `apps/admin/src/api/*.ts` shows lang/locale references appear only in three files, all of which use lang as a **business parameter** (not as a "render this response in lang X" signal):
23+
24+
| File | Purpose | lang role |
25+
|---|---|---|
26+
| `ai.ts` | CRUD on AI translation entries | data field of the translation record itself |
27+
| `enrichment.ts` | List/refresh/invalidate per-locale enrichment cache rows | filter dimension for cache-row selection |
28+
| `search-index.ts` | Per-language search index management | filter dimension for index selection |
29+
30+
All three pass lang via query string or request body. **No admin endpoint relies on automatic translation of the response body.** Therefore admin can safely opt out of implicit translation globally.
31+
32+
## Design
33+
34+
### Semantics of `x-skip-translation: 1`
35+
36+
Admin asserts on every outbound request: "ignore implicit locale signals; honor only explicit ones."
37+
38+
| Source | When skip is on | When skip is off (current behavior) |
39+
|---|---|---|
40+
| `?lang=xx` query | **respected** (via existing `@Lang()` decorator) | respected |
41+
| `x-lang` header | **respected** | respected |
42+
| `NEXT_LOCALE` cookie | **ignored** | respected |
43+
| `Accept-Language` header | **ignored** | respected |
44+
45+
Rationale: skip-translation guards against the *implicit* leak from the browser environment (Accept-Language, cross-origin cookie). Explicit signals (`?lang=` or `x-lang:`) remain valid business intent — admin's `enrichment.refresh(..., locale='zh')` still passes `?lang=zh` to filter the correct cache row.
46+
47+
### Frontend (admin-vue3)
48+
49+
#### F1 — `apps/admin/src/utils/request.ts`
50+
51+
Inject the header unconditionally in the shared ofetch instance's `onRequest` hook. Covers all `request.{get, post, put, patch, delete}` calls.
52+
53+
```ts
54+
onRequest({ options }) {
55+
const headers = new Headers(options.headers)
56+
headers.set('x-uuid', _uuid)
57+
headers.set('x-skip-translation', '1')
58+
// ...rest unchanged
59+
}
60+
```
61+
62+
#### F2 — `apps/admin/src/components/editor/rich/agent-chat/composables/use-agent-loop.ts:47`
63+
64+
The agent-chat SSE stream uses raw `fetch()`, bypassing the shared instance. Add the header to its `headers` literal.
65+
66+
```ts
67+
const response = await fetch(`${API_URL}/ai/agent/chat`, {
68+
method: 'POST',
69+
headers: {
70+
'Content-Type': 'application/json',
71+
'x-skip-translation': '1',
72+
},
73+
credentials: 'include',
74+
body: JSON.stringify({ model, messages, tools, providerId }),
75+
signal,
76+
})
77+
```
78+
79+
Other raw `fetch()` usages in admin (`github-repo.ts`, `hitokoto.ts`) target external services and are out of scope.
80+
81+
### Backend (mx-core)
82+
83+
#### B1 — `apps/core/src/common/middlewares/request-context.middleware.ts`
84+
85+
Adjust the lang-resolution chain. When `x-skip-translation: 1`, restrict the chain to `x-lang` only. No new fields on `RequestContext`. No change to `@Lang()` decorator.
86+
87+
```ts
88+
use(req: BizIncomingMessage, res: ServerResponse, next: () => any) {
89+
const requestContext = new RequestContext(req, res)
90+
91+
const skip = req.headers['x-skip-translation'] === '1'
92+
const headerLang = req.headers['x-lang']
93+
const fromHeader =
94+
typeof headerLang === 'string'
95+
? normalizeLanguageCode(headerLang)
96+
: undefined
97+
98+
requestContext.lang = skip
99+
? fromHeader
100+
: fromHeader ||
101+
parseCookieLocale(req.headers.cookie) ||
102+
parseAcceptLanguage(req.headers['accept-language']) ||
103+
undefined
104+
105+
RequestContext.run(requestContext, () => next())
106+
}
107+
```
108+
109+
#### B2 — `@Lang()` decorator: unchanged
110+
111+
`apps/core/src/common/decorators/lang.decorator.ts` is not modified. Its existing query-precedence logic (and ctxStore writeback) continues to apply uniformly. When admin sends `?lang=zh` to e.g. `/enrichment/admin/refresh/...?lang=zh`, the decorator resolves `'zh'`, returns it to the controller, and writes `'zh'` to `ctxStore.lang` — which is the expected behavior for an explicit, scoped business request.
112+
113+
## Verification
114+
115+
### Backend unit tests
116+
117+
`apps/core/src/common/middlewares/request-context.middleware.spec.ts` (or equivalent):
118+
119+
- `x-skip-translation: 1` + `Accept-Language: zh-CN``ctxStore.lang === undefined`
120+
- `x-skip-translation: 1` + `x-lang: ja``ctxStore.lang === 'ja'`
121+
- `x-skip-translation: 1` + cookie `NEXT_LOCALE=fr``ctxStore.lang === undefined`
122+
- No `x-skip-translation` + `Accept-Language: zh-CN``ctxStore.lang === 'zh'` (regression guard for non-admin callers)
123+
124+
### Backend integration smoke
125+
126+
A controller that translates based on `RequestContext.currentLang()` (e.g., post detail with hydrated enrichments): request with `x-skip-translation: 1` returns original payload regardless of `Accept-Language`.
127+
128+
### Frontend manual verification
129+
130+
1. Open admin `/posts/edit?id=...` for a post with translations. Confirm both the editor body and the version-diff dialog show the original (untranslated) text.
131+
2. Open the enrichment admin panel, filter by `locale='zh'`. Confirm only zh cache rows render — i.e., explicit `?lang=zh` still works.
132+
3. Run an AI agent-chat session. Confirm the SSE stream still functions (no regression from the header addition).
133+
4. Network tab: every request to the admin backend carries `x-skip-translation: 1`.
134+
135+
### Compatibility
136+
137+
- Yohaku / Shiroi and other public callers do not send `x-skip-translation`. Their language resolution chain is unchanged.
138+
- `?lang=original` (existing escape hatch via `@Lang()` decorator) continues to work for ad-hoc opt-out by other callers.
139+
140+
## Out of Scope
141+
142+
- Refactoring `RequestContext.lang` into a richer locale object (no demand)
143+
- Adding a `bypassTransform`-style per-request opt-in/opt-out on the admin side (global opt-out is sufficient given the audit)
144+
- Backend-side audit of which admin controllers do or do not have `@Lang()` (orthogonal — the middleware change is sufficient because `@Lang()` only fires when a controller opts in)
145+
146+
## Risks
147+
148+
- **External callers that piggyback on the admin origin** (none today) could be surprised by the global header. Mitigation: the header is set only inside admin-vue3's request utility; not exported, not on `window`.
149+
- **Future admin endpoint that *does* want browser-locale-based translation**: would have to opt back in per-call. Considered acceptable: such a case has not arisen and admin's purpose is to manage records, not consume rendered translations.
150+
151+
## Files Touched
152+
153+
Frontend:
154+
- `apps/admin/src/utils/request.ts`
155+
- `apps/admin/src/components/editor/rich/agent-chat/composables/use-agent-loop.ts`
156+
157+
Backend:
158+
- `mx-core/apps/core/src/common/middlewares/request-context.middleware.ts`
159+
- `mx-core/apps/core/src/common/middlewares/request-context.middleware.spec.ts` (new or extended)

0 commit comments

Comments
 (0)