|
| 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