---
url: https://docs.nemo12.com/architecture/sdd-038/access-language.md
description: >-
  Sân luyện sau đăng nhập, khoá API, cổng vai, header sân luyện, luật tiếng Anh
  và tiếng Việt hướng dẫn
---

# SDD-038 · Đăng nhập, quyền và ngôn ngữ

> Một phần của [SDD-038 Cổng IELTS](index.md). Phần đầu là đặc tả đang áp dụng, mỗi mục giữ số § cũ; diễn biến và lý do các vòng quyết định nằm ở mục **Lịch sử quyết định** cuối trang.

## 8. Cả sân IELTS chỉ còn tiếng Anh, và dashboard nói về công sức (SRC-834, SRC-835, 2026-09-18)

### 8a. Bỏ công tắc VI/EN

Mọi trang trong sân IELTS — cả `/ielts` lẫn `/ielts/reading`, `/ielts/speaking/parts/...` — nay chỉ hiển thị tiếng Anh. Lý do nằm ở chính môn học: thứ learner cần tập là đọc thẳng tiếng Anh, và một nút đổi sang tiếng Việt ngay trên màn hình là lời mời bỏ việc ấy — lời mời luôn được nhận.

Đây là **ngoại lệ có phạm vi**, không phải đổi luật ngôn ngữ của repo: UI tiếng Việt vẫn là mặc định của Nemo12 (CLAUDE.md, SRC-604); ngoại lệ chỉ dành cho sân IELTS.

**Ranh giới: tên và nhãn, không phải thân bài dạy học** (ghi lại khi rà SRC-931). Câu "chuyển hết
thành tiếng Anh" áp cho thứ learner ĐỌC ĐỂ ĐIỀU HƯỚNG: tiêu đề, nhãn nút, tên phần thi, tên năng
lực, tên bậc. Phần THÂN BÀI DẠY thì theo thang bốn chặng §5f và vẫn bắt đầu bằng tiếng Việt —
`note_vi` / `measures_vi` / `tell_vi` của trang phần thi, `why` của năm bậc, và toàn bộ cổng đọc
hiểu trong `microCheckpoints.ts`. Lý do là cùng một lý do đã dựng nên thang bốn chặng: thao tác tư
duy gặp lần đầu bằng thứ tiếng learner đọc trôi chảy thì lúc sai, họ biết mình sai vì cách nghĩ chứ
không phải vì thiếu một chữ. Một trang phần thi viết trọn bằng tiếng Anh sẽ dạy đúng cái nó định
dạy cho người đã ở band 6.5, và không dạy được gì cho người ở band 4.5.

Ba điều đi kèm:

* **Hạ tầng song ngữ ở lại** (`pick`, `t` trong `apps/learn/src/ielts/lang.tsx`): dữ liệu nội dung vẫn mang hai trường `*_vi` / `*_en` và chặng dịch còn đang lấp dần bản tiếng Anh. `pick` chính là chỗ rơi về bản còn lại khi bản kia trống — bỏ nó đi thì mỗi ô chưa dịch xong thành một chỗ trống không hiểu vì sao trống.
* **Tên lĩnh vực (domain) tiếng Anh cả lúc chọn lẫn lúc hiển thị** trên chip ở header.
* **Thanh trên của `/ielts` mang tên `NEMO IELTS`**, không phải `Nemo12`: mọi trang con của sân này đều mang chữ IELTS trên vệt đường dẫn, nên một thanh trên gọi tên khác là chỗ duy nhất trong cả luồng nói sai nơi learner đang đứng.
* **Nhánh gốc "IELTS" của vệt đường dẫn trỏ về `/ielts`** (hub bốn kỹ năng), không còn trỏ `/ielts/courses` (danh sách 12 khoá) — một nhánh gốc mang tên kỹ năng mà dẫn sang chỗ đọc thiết kế chương trình là một cái bẫy im lặng.
* **Chân trang bỏ khẩu hiệu mặc định** "Học cho học sinh Việt Nam" ở cả ba bản sao `SiteFooter`: chân trang đã nói tên và năm, một khẩu hiệu lặp ở đáy mọi trang chỉ thêm một dòng không ai đọc lần thứ hai. Cổng nào có câu riêng thì vẫn truyền `tagline` (Marlins).

### 8b. Trang kỹ năng: ba card, mỗi card một câu hỏi

| Card | Hình | Nguồn |
| --- | --- | --- |
| Am I showing up? | bảy ô vuông của bảy ngày qua | `progress.active_days`, `streak` |
| Is my vocabulary growing? | đường vốn từ cộng dồn 14 ngày | `progress.lexis_by_day` |
| Are my answers right? | hai thanh: tuần này và tuần trước | `progress.accuracy` |

Dùng chung cho **cả bốn kỹ năng** (`SkillStatusCard` là chỗ duy nhất bốn trang gọi tới). Chưa có lịch sử thì không vẽ gì — ba thẻ toàn số 0 là bản kê những việc learner chưa làm, bày ra đúng lúc nó chưa kịp làm gì.

### 8c. `/ielts`: bốn mini-card ghi nhận, và ngày đủ 200 giờ

Đường dữ liệu: `GET /v1/learners/{id}/ielts-effort` (`workers/api/src/modules/ieltsSkills/effort.ts`), **gộp mọi kỹ năng** vì đích 200 giờ là đích chung của cả hành trình; tổng giờ đi bằng một `SUM` riêng chứ không cắt theo cửa sổ 30 ngày (vốn học không tự mất đi vì tuần này nghỉ).

* **Tiêu chuẩn ngày: 60 phút** (`DAILY_STANDARD_MINUTES`). Một giờ mỗi ngày đưa learner tới 200 giờ trong khoảng hơn nửa năm — đúng độ dài một mùa luyện thi, nên tiêu chuẩn và đích đến kể cùng một câu chuyện. Đây là quyết định sản phẩm, đổi nó là đổi lời hứa với phụ huynh.
* **Bốn mini-card**: Today (phút hôm nay so tiêu chuẩn) · Last 7 days · Last 30 days · 200 hours (tổng giờ và phần trăm).
* **Một thẻ tiên đoán riêng**: ngày đủ 200 giờ theo nhịp 7 ngày và theo nhịp 30 ngày, hai con số riêng chứ không trộn thành một nhịp trung bình — chính khoảng cách giữa hai ngày ấy mới nói được điều gì đó.
* **Nhịp bằng 0, hoặc ngày đích xa hơn 10 năm, thì IM LẶNG** (`reaches_target_on: null`). Chia cho 0 ra vô cực, và "con sẽ đủ 200 giờ vào năm 2189" là một câu vừa đúng về số học vừa tàn nhẫn.
* **Chưa có lịch sử thì không bày dashboard nào.** Bốn lối vào kỹ năng vẫn đủ để bắt đầu.
* Bốn link kỹ năng gộp vào **một card** để chúng đọc ra là một khối riêng, không phải phần đuôi của dashboard phía trên.

Ngày tháng in theo DD.MM.YYYY (SRC-748), kể cả trên các trang chỉ còn tiếng Anh.

## 26. Sân luyện đứng sau đăng nhập (SRC-883, 2026-09-19)

Mọi URL dưới `/ielts/**` đòi tài khoản trước khi vẽ bất cứ thứ gì.

**Cổng nằm ở ĐÚNG MỘT CHỖ**, `apps/learn/src/ielts/SignInGate.tsx`, bọc ngoài `App` tại điểm mount
`Practice.tsx`. Không phải mỗi trang tự gọi: mạch này có hơn hai chục trang, và một luật "trang nào
cũng phải nhớ gác" thì chỗ quên được sẽ đúng là chỗ không ai mở ra xem.

**Ba trạng thái, và "đang hỏi" KHÔNG phải "chưa đăng nhập"**: khi `/v1/me` chưa trả lời, màn hình
là một dòng chờ trên nền tối chứ không phải màn hình mời đăng nhập. Vẽ "Sign in" nửa giây rồi để nó
biến mất trước mắt một người ĐÃ đăng nhập đọc ra như một lần bị đá khỏi phiên.

**Cổng riêng, không dùng `me` của trang cha.** Cổng của `App.tsx` còn kèm cổng ra mắt và màn hình
Join; đi qua nó nghĩa là một learner hợp lệ có thể bị giữ lại vì một lý do chẳng liên quan gì tới
việc luyện đề. Hai lượt gọi `/v1/me` là cái giá, cùng cookie cùng phiên nên lượt thứ hai gần như
luôn trúng cache.

**Chữ trên cổng viết tiếng Anh** (SRC-834/835): đây là màn hình ĐẦU TIÊN của sân luyện tiếng Anh.
Nút Google cũng vậy — nó chỉ chịu đổi ngôn ngữ khi script GIS được tải kèm `?hl=en`, vì tuỳ chọn
`locale` truyền lúc `renderButton` không kéo lại được ngôn ngữ nó đã chọn theo trình duyệt lúc tải.
`GoogleButton` vì thế nhận thêm prop `locale`, mặc định `vi` cho mọi chỗ gọi cũ.

**Hệ quả phải biết**: bốn endpoint `modules/ieltsContent` ở §17 vẫn public ở tầng API — cổng này là
cổng GIAO DIỆN. Và 140 trang luyện nay không còn được máy tìm kiếm đọc; đó là cái đánh đổi của chỉ
đạo này, không phải một tác dụng phụ bị bỏ sót.

## 27. Khoá luôn API, không chỉ giao diện (SRC-885, 2026-09-20)

Đúng chỗ phải khoá. Một cổng chỉ ở giao diện thì `curl https://api.nemo12.com/v1/public/ielts/...`
vẫn kéo về trọn 140 trang — tức là cái khoá chỉ khoá **người dùng thật**, còn ai biết gõ lệnh thì
không. Cổng nào cũng vậy: nó chỉ có giá trị bằng lớp yếu nhất của nó.

**Mười đường rời danh sách công khai**, cả mười nay nằm dưới `requireSession`:

| Nhóm | Đường | Mã |
| --- | --- | --- |
| Nội dung luyện | `/v1/public/ielts/content/*` (cụm, một cụm, khởi động, kho từ) | `ieltsContent/routes.ts` |
| Lý thuyết năng lực nhỏ | `/v1/public/ielts/micro-skills`, `…/{skill}/{code}`, `…/levels/{level}/{seq}` | `microSkills/routes.ts` |
| Giọng đọc | `…/reading/{cluster}/{seq}/audio`, `…/listening/…`, `…/micro-items/{id}/audio` | `ieltsPractice/routes.ts` |

Mỗi nhóm khoá bằng MỘT dòng `use(prefix, requireSession)` chứ không phải mười lần gắn lẻ: đường
thứ mười một thêm vào mai sau được chặn sẵn, và quên gắn là đúng kiểu sai sót mà
`authCoverage.test.ts` sinh ra để bắt. Mười dòng tương ứng đã xoá khỏi danh sách `PUBLIC` của cổng
ấy — danh sách đó là nơi duy nhất nói route nào cố ý mở, nên để lại một dòng thừa là để lại một
lời khai sai.

**Ba cái bẫy đã gỡ, cả ba đều hỏng im lặng nếu bỏ qua:**

1. **`fetch` không tự gửi cookie qua tên miền khác.** App ở `learn.nemo12.com`, API ở
   `api.nemo12.com`. `data/store.ts` và ba lời gọi micro-skills trong `api.ts` nay nói
   `credentials: "include"`; thiếu nó thì mọi trang nội dung trả 401 và learner thấy "nội dung
   hỏng" chứ không thấy "bạn chưa đăng nhập".
2. **Thẻ media còn không gửi cookie nữa.** `<audio src>` mặc định gửi request ẩn danh, và không
   có cách nào đặt header cho nó. Hai chỗ phát tiếng nay khai `crossOrigin="use-credentials"`
   (CORS của worker đã trả `Access-Control-Allow-Credentials: true` cho `*.nemo12.com`, nên không
   phải đổi gì ở tầng đó). Quên dòng này thì hỏng ra thành "bấm nghe không kêu".
3. **`Cache-Control: public` trên một route đã đòi phiên là mâu thuẫn.** Một bản nằm trong cache
   dùng chung là một bản phát lại được cho người chưa đăng nhập. Đổi thành `private, max-age=300`
   — vẫn 300 giây, nhưng trong trình duyệt của từng người.

**Tên đường giữ nguyên chữ `public`**, và đó là cố ý: đổi đường là làm gãy mọi bundle app còn nằm
trong cache trình duyệt của learner đang mở dở một bài. Chữ ấy nay đọc là "nội dung đã công bố",
không phải "ai cũng gọi được".

**Cổng kiểm**: ba file test mới/ mở rộng gọi bằng request THẬT không mang cookie —
`ieltsContent/routes.test.ts`, `microSkills/routes.test.ts`, `ieltsPractice/audioAuth.test.ts`.
Chúng gác thêm hai điều mà một phép kiểm mã nguồn không thấy được: 401 **không mang theo** một mẩu
nội dung nào, và địa chỉ sai cũng trả 401 chứ không trả 404 — 404 cho địa chỉ sai trong khi 401
cho địa chỉ đúng là tự kể ra cụm nào có thật, tức là dò được cả danh mục mà không cần tài khoản.

**Còn mở, có chủ ý**: `/v1/public/ielts/catalog` (danh mục 12 khoá) và `/v1/public/band-pricing`.
Đó là thứ **phụ huynh** đọc trên `nemo12.com` trước khi tạo tài khoản, không phải thứ **learner**
làm bài trong sân luyện — cùng ranh giới mà CLAUDE.md đã vạch giữa trang công khai và sân luyện.

### Vá cùng ngày: một khoá theo TIỀN TỐ chỉ giữ được thứ mọc trong tiền tố ấy

Lời hứa ở §27 vì thế phải đọc chính xác hơn: một khoá theo tiền tố chặn sẵn đường mới **nằm trong
tiền tố**, không phải mọi đường mới của module. Đó là một lời hứa hẹp hơn nhiều, và chỗ chênh giữa
hai cách đọc chính là chỗ lỗi này chui qua.

**Vá** (commit `1472915f`): gắn thẳng
`ieltsContentRouter.use("/v1/public/ielts/exam-papers", requireSession)`. **Không** nới tiền tố
thành `/v1/public/ielts/*`: `index.ts` mount mọi router ở `/`, nên một wildcard rộng như thế khi
bị làm phẳng sẽ chụp luôn `/v1/public/ielts/catalog` của `curriculum/routes.ts` — đúng cái đường
mà đoạn trên vừa nói là cố ý mở cho phụ huynh. Chặn đúng thứ cần chặn, không chặn kèm thứ cố ý mở.

Không gãy gì ở phía trình duyệt: người gọi duy nhất là `apps/learn/src/ielts/data/store.ts`, và
hàm fetch của nó đã mang `credentials: "include"` cho mọi đường nội dung từ SRC-885.

**Ai bắt được, và ai đáng lẽ phải bắt.** `authCoverage.test.ts` bắt được ngay lượt CI đầu tiên —
cổng ấy đứng ở tầng **bảng route**, nên nó thấy mọi đường mới bất kể tiền tố. Còn
`ieltsContent/routes.test.ts` thì **không**: bộ ca của nó cho `exam-papers` gọi kèm cookie ở mọi
lượt, nên cụm "không có phiên" chưa từng chạm tới đường này. Nay đã có ca 401, và thêm một ca gác
rằng 401 ấy **không mang theo tên đề nào** — cùng lý lẽ chống dò danh mục đã dùng cho bài đọc: một
401 có kèm "Writing paper 1" vẫn nói cho người chưa đăng nhập biết có bao nhiêu đề và tên chúng là
gì.

## 28. Một cổng cho cả sân, và tên learner không còn là nút thoát (SRC-886, 2026-09-20)

Nghe như hai lỗi rời nhau. Thật ra chúng cách nhau đúng **một cú bấm**: bấm vào tên mình thì mất
phiên, rồi bấm vào nhãn IELTS để quay lại thì đứng trước một sản phẩm khác không có đường về. Cả
hai đều là chuyện của thanh trên, nên §28 sửa thanh trên chứ không sửa hai chỗ.

### a. Tên learner là cái cò thoát phiên

`AccountChip` trong `apps/learn/src/ielts/Shell.tsx` là một `<button>` mà toàn bộ nội dung nhìn
thấy được là tên người đang đăng nhập, còn `onClick` duy nhất của nó gọi thẳng `signOut()`. Tức
là thứ đọc ra như một cái **nhãn** ("đây là bạn") lại chính là **hành động phá nhiều nhất màn
hình**, và không có bước xác nhận nào ở giữa. Lời cảnh báo duy nhất là `title="Sign out"` — thứ
không bao giờ hiện ra trên màn cảm ứng, đúng nơi một cái chip hình viên thuốc nằm sát chip "Choose
a field" dễ bị chạm nhầm nhất.

Nay chip ấy là **đúng `AvatarMenu` mà `/ielts` đang dùng**, không phải một menu thứ hai giống
giống: hai nửa của cùng một sân mở ra cùng một menu, và "Sign out" là một **mục phải chọn** chứ
không phải thứ xảy ra khi chạm hụt. Nhánh "chưa đăng nhập" cũ của chip (một chữ "Đăng nhập" mở ra
nút Google tại chỗ) bị xoá luôn: từ §26, `SignInGate` bọc ngoài cả mạch nên nhánh ấy không còn
đường nào chạy tới, và một nhánh không ai kiểm được nữa là một nhánh sẽ mục.

Tên vì thế nằm trong `AvatarMenu` chứ không riêng sân luyện: cùng một menu ở sáu chỗ mà chỗ có
tên chỗ không là đúng kiểu lệch mà §28 vừa dọn. Hai ràng buộc đi kèm, cả hai vì chỗ chật:

* `hidden sm:block` — thanh trên của TRANG CHỦ learn mang sẵn năm nút (Lighthouse, Lịch, Khoá
  học, Rủ bạn, VI/EN) và không ẩn bớt cái nào ở màn hẹp. Thêm một chuỗi tên vào đó ở 375px là
  đẩy cả hàng xuống dòng. Tên là thứ thêm vào **khi có chỗ**; avatar thì ở mọi bề ngang, nên
  không màn nào mất đường vào menu.
* `max-w-28 truncate` — "Nguyễn Tất Đắc" thì vừa, nhưng một tên dài sẽ bóp vệt đường dẫn bên
  trái cho tới lúc nó vắt dòng.

### b. Nhãn IELTS dẫn ra khỏi sân IELTS

Nhánh gốc của vệt đường dẫn ở **mọi** trang luyện trỏ về `/ielts`. Nhưng `/ielts` TRẦN không thuộc
mạch luyện: `pathToRoute` đưa nó về `{ view: "ielts" }` của `App.tsx`, tức là **ra ngoài**
`IeltsPracticeApp` và rơi vào ba cổng của trang cha. Với người chưa/hết đăng nhập, cổng thứ ba là
màn `Join`: **"Nemos · Cổng học tập"**, tiếng Việt, logo Nemo12, và lối ra duy nhất là một link
sang `nemo12.com`. Cùng một sân IELTS mà hai nửa chặn bằng hai màn hình trông khác hẳn nhau — đó
là cái "loạn xạ" trong ảnh 2.

Nay `/ielts` đi qua **cùng một cổng** với các trang luyện. `SignInGate.tsx` xuất ra hai màn dùng
lại được (`IeltsLoadingScreen`, `IeltsSignInScreen`), và `App.tsx` gọi chúng ngay **sau** nhánh
`ieltsPractice`, tức là **trước** cổng ra mắt và màn Join — đúng cái lý lẽ đã đưa `ieltsPractice`
lên trước chúng ở §26. Dùng lại chứ không chép: một bản "gần giống" ở `App.tsx` sẽ trôi khỏi bản
gốc trong vài tuần, và lúc ấy lại có hai màn đăng nhập như cũ.

Hai thứ nhỏ đi kèm, cả hai đều là "bấm xong không có gì xảy ra":

* **Cổng có lối ra.** Màn `Join` mà nó thay chỗ vốn có "← Về Nemo12"; cổng mới không được nghèo
  hơn nên có "← Back to Nemo12". Một cổng bắt buộc mà không có đường nào khác là một ngõ cụt.
* **Đăng nhập hỏng không còn im lặng.** Chỗ gọi cũ là `void signIn(cred)`: lỗi bị nuốt thành một
  unhandled rejection, nút Google chớp một cái rồi **đúng màn hình ấy ở lại** — lại là "bấm vào
  thì lại hiện ra chính nó". `IeltsSignInScreen` nay giữ một dòng lỗi.

### c. Vệt đường dẫn trỏ về chính nó, và trên điện thoại nó là thứ duy nhất bấm được

`Shell` vẽ **mọi** nhánh, kể cả nhánh cuối, bằng `<Link>` — mà nhánh cuối gần như luôn là trang
đang mở. Bấm vào nó thì `pushState` đúng địa chỉ đang đứng và màn hình cũ hiện lại. Trên màn hẹp
thì tệ hơn hẳn: luật cũ ẩn **mọi nhánh trừ nhánh cuối**, nên thứ duy nhất bấm được trên điện thoại
là cái link trỏ về chính nó, còn "NEMO IELTS" — nhánh duy nhất thật sự đi đâu đó — không có trên
màn để mà bấm. Đó là câu "click vào thì lại xuất hiện chính nó", đọc theo đúng nghĩa đen.

Hai sửa:

1. **Nhánh trỏ đúng chỗ đang đứng thì không phải link** (`aria-current="page"`). So bằng **đường
   dẫn**, không bằng "nhánh cuối cùng": vài trang truyền vệt của trang CHA lúc đang tải hoặc khi
   nội dung hỏng (`Reading.tsx`), và ở đó nhánh cuối là một lối đi thật.
2. **Màn hẹp ẩn các nhánh GIỮA, giữ lại gốc.** Hai chữ "NEMO IELTS" không tốn một dòng; một
   header không có đường về nhà mới là thứ đắt.

### Còn mở, có chủ ý

* **Đã đăng nhập nhưng chưa có hồ sơ learner** (`me.learner == null` hoặc `status !== "active"`)
  thì `/ielts` vẫn rơi xuống màn `Join`. Ở đó Join **không** là màn đăng nhập mà là chỗ nhập mã
  mời / mở hồ sơ học thử — một việc thật sự phải làm, không phải một cái cổng đặt nhầm chỗ. Đổi
  nó là đổi luồng onboarding, nên để chủ dự án quyết.
* **ĐÃ CHỐT 2026-09-20 (SRC-909): nhãn "NEMO IELTS" ở hub cũng dẫn về `/ielts`.** Nay một cái tên, một cái đích, ở
  mọi bề mặt của sân. Khi ĐANG đứng ở `/ielts` (không có `route.task`) nó không còn là nút mà là
  chữ mang `aria-current="page"` — cùng luật đã áp cho vệt đường dẫn ở §28c, vì một nhãn bấm vào
  lại ra đúng trang đang mở là một lời mời giả.
  Lý lẽ "một trang không có đường ra là một cái bẫy" vẫn đúng và vẫn được giữ: đường ra nay nằm ở
  menu avatar ("Hồ sơ của con"), không nằm ở logo.
* **ĐÃ ĐÓNG 2026-09-21 (SRC-937): phiên chết giữa buổi nay được nhận ra.**
  Nay `ielts/api.ts` phát sự kiện ấy khi gặp đúng một 401 "chưa có phiên", chép nguyên luật hẹp
  của `apps/learn/src/api.ts` (Audit #013 U-5): một 401 vì THIẾU QUYỀN cho một việc không tính,
  vì phiên còn sống và đá learner về màn đăng nhập ở đó là mất bài đang làm dở. `auth.tsx` nghe và
  đặt `me = null`, nên `SignInGate` hiện "Sign in to practise" — URL không đổi, đăng nhập xong là
  về đúng trang đang dở. Gác hai nửa: luật ở `ielts/api.test.ts`, phản ứng của màn hình ở
  `e2e/ieltsGatedReading.spec.ts`.

## 30. Cổng vai người đọc: quyền đúng, người đọc sai (SRC-897, 2026-09-20)

### a. Lỗi ấy, và vì sao nó im lặng

`/ielts-recordings` trả thẳng `JSON.parse(ai_notes_json)`. Cột ấy chứa **hai hình dạng**: bài của
thang bậc là `{praise_vi, did_vi, one_step_vi, band}`, cả bốn viết cho learner; bài **chẩn đoán**
(SRC-792) là `{strengths_vi, gaps_vi, teaser_vi, ask_in_session_vi}`, và chỉ `teaser_vi` dành cho
learner — `IELTS_PRODUCTION_FEEDBACK_V1` nói thẳng `ask_in_session_vi` là *"một câu thầy cô nên
hỏi học sinh trong buổi gặp"*.

Trên màn hình nó hiện ra thành hai tiêu đề rỗng, nên trông như lỗi CSS. Trong body thì `gaps_vi`
và `ask_in_session_vi` nằm nguyên.

Đây là một LOẠI lỗi, không phải một lỗi lẻ. Lỗi phân quyền thì kêu: 401, 403, hoặc dữ liệu của
người lạ mà người dùng nhận ra ngay. Lỗi **sai vai** thì im — dữ liệu đúng người, chỉ sai người
đọc. Người review đọc `notes: JSON.parse(row.ai_notes_json)` cũng không thấy gì sai, vì phải biết
trước rằng cột ấy trộn vai mới thấy.

### b. Vì sao `authCoverage` không bắt được, và không nên bắt

`authCoverage.test.ts` (SRC-887) hỏi *route này có đi qua đăng nhập không*. Câu ấy đúng và đã bắt
được SRC-888 để hở đường đề thi. Nhưng ở đây route đi qua đăng nhập đầy đủ, người gọi là đúng
learner, đúng phiên, đúng gia đình. Hai cổng hỏi hai câu khác nhau và cả hai đều cần.

### c. Bất biến được gác, và nó vốn đã đúng sẵn

`scripts/check-audience.mjs` viết bất biến ấy thành lời và bắt máy giữ, bốn luật:

1. File đọc cột trộn vai phải khai trong `READERS`, kèm **vai của người cuối** và tên hàm chiếu.
2. File phục vụ learner phải gọi hàm chiếu đã khai, và không `JSON.parse` thẳng cột ấy.
3. File phục vụ learner không `SELECT` cột chỉ-mentor (`mentor_note`, `mentor_band`).
4. `READERS` không có dòng thừa.

Vai khai **bằng tay**, không suy từ đường dẫn: `report.ts` không mang route nào — nó dựng báo cáo
cho `modules/mentor/routes.ts` — nên đoán vai của nó từ tên thư mục là đúng kiểu suy luận sẽ sai
vào ngày có người dùng lại nó.

### d. Cổng này KHÔNG làm gì

Nó không đọc được bên trong hàm chiếu. `learnerNotes` mà một ngày trả luôn `gaps_vi` thì cổng vẫn
xanh, vì với nó có hàm chiếu là đủ. Phần ấy do test giữ: `speakingTurn.test.ts` kiểm trên **chuỗi
JSON** trả về chứ không kiểm từng khoá, nên một trường mentor mới thêm vào cũng bị bắt.

Hai lớp bù nhau: cổng bắt người **quên chiếu**, test bắt hàm chiếu **chiếu sót**. Không lớp nào một
mình đủ, và nói rõ chỗ hở còn hơn để người sau tưởng cổng lo hết.

**AI ĐƯỢC XEM, và đó là một câu hỏi khác với "phạm vi mở ra cái gì"** (SRC-938, từ Audit #015
D-7). Bản đầu chỉ đòi `requireSession`. Nhưng "đã đăng nhập" không phải là "ở trong chương
trình": tài khoản thì ai cũng tự tạo được trong ba mươi giây, nên một cái cửa như thế rộng hơn
mức bất kỳ ai đã chủ đích mở — và thứ nằm sau cửa là **tên thật và ảnh thật của trẻ con**.

Vào được: **learner đang học** (`active`/`paused`, đúng bằng tập người XUẤT HIỆN trên bảng — hai
tập lệch nhau là một cách âm thầm để một tài khoản cũ giữ mãi quyền nhìn danh sách lớp) và
**người của nhà trường** (`mentor`/`staff`/`admin`).

`guardian` KHÔNG nằm trong danh sách, và đó là một lựa chọn chứ không phải bỏ sót. Quyền lợi
chính đáng của phụ huynh là thứ hạng **con mình**, mà ví và báo cáo đã phục vụ; bày danh sách con
nhà khác cho họ vẫn là cùng một sự phơi bày, chỉ qua một cái cửa nhỏ hơn. Trang này cũng nằm
trong sân luyện tiếng Anh dành cho learner, không phải bề mặt của phụ huynh.

Chỉ đạo SRC-901 được giữ nguyên vẹn: *"Mọi cá nhân đều biết thứ tự của chính mình, và thứ tự của
những người khác."* "Những người khác" ở đó là **bạn cùng học**. Thắt lại chỗ này không lấy đi
của learner một thứ gì họ đang có.

**Vì sao chọn hẹp khi còn phân vân:** hẹp thì đảo lại được bằng một chuỗi thêm vào mảng vai, còn
rộng thì không — một cái tên đã bị nhìn thấy là đã bị nhìn thấy. Ngày chủ dự án muốn mở cho phụ
huynh thì đó là một dòng sửa.

Câu từ chối viết **tiếng Anh** và nói rõ lý do, vì nó hiện trên màn learner trong sân luyện tiếng
Anh (SRC-834/835). Trang cũng phân biệt "bị từ chối" với "hỏng tạm thời": bảo một người vĩnh viễn
không có quyền rằng hãy "thử lại sau một lát" là mời họ bấm lại vô ích.

### e. Còn mở

* Chỉ `ielts_productions` được khai. Các chỗ khác có thể trộn vai (`showcase`, thư gửi phụ huynh)
  chưa rà; thêm một cột vào `MIXED` là cách mở rộng, và chi phí là mọi chỗ đọc phải khai vai.
* Cổng đọc bằng biểu thức chính quy trên mã nguồn, không phải cây cú pháp. Nó bỏ chú thích trước
  khi quét, nhưng một câu SQL ghép từ nhiều biến thì nó không nhìn thấy tên cột.

## 105. Membership nói tiếng Anh đơn giản (SRC-1021, 24.09.2026)

Cùng luật với §104. Hai đổi
đáng chú ý: tên đợt giảm và điều kiện trong bảng giá là tiếng Việt (dùng chung với trang công
khai), nên trang dựng câu từ con số ("Special price: save X", "Only until DD.MM.YYYY") thay vì
bày chữ gốc; và "balance/tuition/transfer" đổi thành "money/price/send money".

## 108. Learning Plan nói tiếng Anh đơn giản (SRC-1028, 24.09.2026)

Cùng luật §104. Trang vốn
đã tiếng Anh; việc làm là hạ độ khó từ ngữ: "Where you are, where you are going" thành "Your score
now and your goal", "Target reached" thành "You reach your goal", "milestone" thành "Check point",
ba luật lập kế hoạch viết lại bằng câu ngắn.

## 168. Trang bốn kỹ năng, Full tests và Grammar nói tiếng Anh 100% (SRC-1325, 10.10.2026)

Chủ dự án: *"In the pages of all 4 skills, nothing may use Vietnamese. Everything must be English."*
Ảnh chụp kèm theo là trang Full tests với đoạn giới thiệu tiếng Việt.

* **Phạm vi:** `/ielts/reading`, `/ielts/listening`, `/ielts/writing`, `/ielts/speaking` và mọi trang
  dưới chúng (cụm, bài đọc, clip, phần thi, đề luyện, phòng thi, level Nói/Viết, `/ielts/speaking/pair/**`),
  `/ielts/skills/**`, `/ielts/tasks/**`, `/ielts/tests/**` (cả phiếu kết quả), `/ielts/grammar/**`.
* **Thay SRC-953 cho đúng những trang này:** giới thiệu, dặn dò, giới hạn của phép đo, tóm tắt kết quả,
  nhãn nút, dòng lỗi, nội dung nút `?` (`helpContent.ts`), aria-label đều tiếng Anh đơn giản. Phiếu kết quả
  Full test do API sinh (`workers/api/src/modules/ieltsFullTests`) cũng tiếng Anh.
* **Nội dung chỉ có tiếng Việt trong dữ liệu** (ví dụ `note_vi` của cụm bài đọc/nghe, `title_vi` của chủ đề
  ngữ pháp) thì không hiện trên các trang này, không dịch bừa nội dung dài.
* Các trang hành trình (`/ielts`, `/ielts/today`, `/ielts/diagnostic`, `/ielts/progress`...) vẫn theo SRC-953.
  Hai thành phần dùng chung (`LoadFailedLine`, nút `?`) chọn thứ tiếng theo đường dẫn qua
  `isEnglishOnlyPage()` trong `helpContent.ts`.
* **Nhận xét AI cũng tiếng Anh:** `ielts.ladder-feedback@2` (nhận xét bài luyện Viết/Nói theo bậc, cả lượt nói
  theo năng lực ở trang micro-skill) và `ielts.full-test-comment@2` (nhận xét chung của phiếu Full test) viết tiếng
  Anh đơn giản; tên ô `*_vi` giữ nguyên để dữ liệu cũ không vỡ. Khuôn dự phòng `templateComment` cũng tiếng Anh.
  Thang bốn chặng của SRC-821 (tiếng Việt trước) không áp ở các trang này nữa.
* **Cùng ngày, chủ dự án chỉnh thêm:** thẻ "Where am I strong?" đổi tên thành "My sub-skills" trên cả bốn trang kỹ năng;
  bỏ chữ bậc (Building, Getting there, Solid, Strong) cạnh cụm vạch, bậc chỉ còn trong aria-label; chip tài khoản chỉ
  hiện tên gọi (chữ cuối của họ tên, `givenName` trong `apps/learn/src/ielts/givenName.ts`), họ tên đủ ở đầu menu;
  menu cấp 2 viết thường ("Reading", không "READING"); mục đầu của menu cấp 1 và cấp 2 thẳng một cột dọc ở màn rộng.
* **Việc nối tiếp:** nội dung D1/JSON chỉ có tiếng Việt đang bị ẩn; cần soạn bản tiếng Anh rồi hiện lại.
* **Cổng:** `apps/learn/e2e/ieltsSkillPagesEnglish.spec.ts` mở từng trang trong phạm vi với API mock và đỏ khi
  thấy bất kỳ chữ cái tiếng Việt nào trong nội dung trang (trừ header chung và tên learner).

## Lịch sử quyết định

### Từ mục 8. Cả sân IELTS chỉ còn tiếng Anh, và dashboard nói về công sức (SRC-834, SRC-835, 2026-09-18)

Chỉ đạo chủ dự án: *"Bỏ VI/EN ở góc trên phải đi, cho toàn bộ các trang IELTS. Bên trong trang này, chuyển hết thành tiếng Anh."*

Chỉ đạo: *"Các dashboard nhỏ ở trên cùng các trang này, có thể tách thành 3 cards nhỏ hơn. Mỗi card là một chart riêng, và trả lời một câu hỏi riêng."*

Chỉ đạo: *"Màn hình này cần gọn 4 cái links tới 4 skill lại, đưa vào 1 card cho gọn. \[...] hiển thị vài mini-cards \[...] ghi nhận những gì mà Learner đã làm được trong 30, và 7 ngày qua, và tiêu chuẩn chung mỗi ngày, và trạng thái ngày hôm nay. \[...] Và đưa ra Prediction, (nội suy từ dữ liệu cũ), rằng bao giờ (ngày nào) thì Learner học đủ 200 hours, nếu tiếp tục học với tiến độ của 7 ngày qua, 30 ngày qua."*

Bản trước (SRC-824) là một thẻ chứa bốn con số rồi một đường biểu đồ ở dưới. Bốn con số ấy đo bốn thứ khác nhau nên đứng chung một khung chúng đọc ra như một bảng kê, và learner phải tự ghép xem con số nào trả lời câu hỏi nào của mình.

### Từ mục 26. Sân luyện đứng sau đăng nhập (SRC-883, 2026-09-19)

Chỉ đạo chủ dự án, kèm ảnh chụp `/ielts/reading/clusters` mở ra với chip "Sign in" ở góc phải:
*"Nếu không signin, thì hệ thống bắt buộc phải signin."*

(Tách từ đầu §26.) Đây là **đảo lại SRC-792**, và đảo có ý thức. SRC-792 để mạch luyện mở công khai vì 140 trang nội
dung vốn sống trên `ielts.nemo12.com` cho người chưa có tài khoản và cho máy tìm kiếm; nay chủ dự
án chọn đổi.

### Từ mục 27. Khoá luôn API, không chỉ giao diện (SRC-885, 2026-09-20)

§26 dựng cổng đăng nhập ở giao diện. Chủ dự án đọc xong phần "hệ quả phải biết" và chốt ngay:
*"Khoá luôn API luôn đi."*

(Tách từ §27, mục "Vá cùng ngày".) §27 viết rằng một dòng `use(prefix, requireSession)` phủ cả nhóm, nên "đường thứ mười một thêm vào
mai sau được chặn sẵn". Chưa đầy một ngày sau, SRC-888 thêm `GET /v1/public/ielts/exam-papers` —
và nó **lọt**. Khoá là `/v1/public/ielts/content/*`; đường mới mọc ra **ngoài** tiền tố ấy, nên 20
đề Writing và Speaking phục vụ cho bất kỳ ai gõ `curl`, đúng cái mà §27 vừa dựng cổng để chặn.

### Từ mục 28. Một cổng cho cả sân, và tên learner không còn là nút thoát (SRC-886, 2026-09-20)

Chủ dự án gửi hai ảnh chụp `learn.nemo12.com` kèm ba câu:
*"Click vào tag 'Nguyễn Tất Đắc' ở góc trên phải, thì đang đồng nghĩa với Logout. Cần fix. Click
vào icon IELTS ở ảnh 2, thì lại xuất hiện ảnh 2. Fix đi. Quá là loạn xạ!"*

**Vòng sau, cùng ngày — tên quay lại cạnh avatar.** Bản đầu của vòng này chỉ giữ avatar (ảnh
Google, hoặc chữ tắt tên), nên tên learner biến mất khỏi thanh trên; chủ dự án xem xong nói
*"Hiện tên bên cạnh avatar đi."* Đúng: lỗi được báo là cái tên **bấm vào thì thoát phiên**, không
phải cái tên hiện ra — chữa bằng cách bỏ nó đi là chữa nhầm vế.

(Tách từ §28, mục "Còn mở, có chủ ý", dòng SRC-909.) Mục này từng
ghi là "còn mở": cùng một cái logo làm hai việc tuỳ chỗ đứng — ở trang luyện về `/ielts`, ở hub
thì rời hẳn sân IELTS — và lý lẽ giữ nguyên là "ở hub nó là đường ra duy nhất". Chủ dự án chốt:
*"Click vào logo thì ko được về HOME, mà cần về trang ielts."*

(Tách từ §28, mục "Còn mở, có chủ ý", dòng SRC-937.) Mục này từng ghi là
còn mở: `ielts/auth.tsx` hỏi `/v1/me` một lần lúc mount và không nghe `nemo12:session-expired`,
nên một cookie hết hạn lúc 9h05 vẫn để learner ngồi làm bài tới hết buổi — mọi lượt ghi bài làm
bị từ chối trong im lặng, vì `useRecordAttempt` nuốt lỗi có chủ đích.

### Từ mục 30. Cổng vai người đọc: quyền đúng, người đọc sai (SRC-897, 2026-09-20)

Sau khi §29 lên production, chủ dự án bảo mở trang thật lên xem. Trang chạy đúng — và để lộ một lỗi
mà 1771 test API cùng 221 e2e đều không thấy.

Trước SRC-884, **mọi** chỗ đọc `ai_notes_json` đều đi qua một hàm chiếu có tên: `teaserOf` lấy đúng
một câu cho learner, `parseNotes` dựng bản đầy đủ cho mentor. Không ai viết luật ấy ra, nên không
có gì ngăn người tiếp theo `JSON.parse` thẳng — và người tiếp theo đã làm đúng như vậy.

### Từ mục 105. Membership nói tiếng Anh đơn giản (SRC-1021, 24.09.2026)

(Tách từ đầu §105.) Chủ dự án: *"Chuyển trang Membership sang tiếng Anh đơn giản luôn."*

### Từ mục 108. Learning Plan nói tiếng Anh đơn giản (SRC-1028, 24.09.2026)

(Tách từ đầu §108.) Chủ dự án: *"Chuyển trang Learning Plan sang tiếng Anh đơn giản luôn."*
