---
url: https://docs.nemo12.com/quality/audits/2026-08-20-003.md
description: >-
  Audit #006 chuyên đề hệ admin ngày 20.08.2026: admin.nemo12.com và endpoint
  /v1/admin, phát hiện, việc sinh ra và đề xuất cho bộ chuẩn.
---

# Audit #006 — Hệ admin

Audit **chuyên đề** cho `admin.nemo12.com` và các endpoint `/v1/admin` đứng sau nó. Bổ trợ [Audit #004](2026-08-20.md), không đặt thang điểm mới ([lý do](../audit-standard/history.md#_1-2-g-s-m-khong-đuoc-dung-trong-v0-2)).

Câu hỏi dẫn đường: **người vận hành mở admin ra thì làm được gì, và khi hệ hỏng thì họ có nhìn ra không.** Hai câu đó quan trọng hơn danh sách tính năng, vì admin là chỗ duy nhất một người thật nhìn vào hệ thống mà không phải đọc log.

## 1. Hệ admin đang là gì

| | |
| --- | --- |
| Mã nguồn | 1.854 dòng, 10 tệp |
| Màn hình | **5 tab**: Overview · Learners · Models · Runs · Access & Mentors |
| Endpoint `/v1/admin` trên production | **22** |
| Bảo vệ | Cloudflare Access (chỉ `dac2205@gmail.com`) + `requireSession` + role `admin` ở API |
| Ngôn ngữ | nhãn giao diện tiếng Anh toàn bộ (SRC-098); phần giải thích trong popover tiếng Việt có chủ đích (SRC-143) |

Nền móng thì chắc: 0 lời gọi `fetch` ngoài `api.ts`, 36/36 chỗ bấm được đều là `<button>` thật (không có `<div onClick>` nào), phân quyền có test chạy trên D1 thật, và Access chắn ở tầng hạ tầng trước khi tới app.

## 2. Phát hiện

### A1 · Năm endpoint chạy trên production mà không màn hình nào gọi tới

Đối chiếu 22 route `/v1/admin` trong spec với mọi đường dẫn `api.ts` gọi:

| Endpoint | Nó phục vụ việc gì | Chỉ báo AS liên quan |
| --- | --- | --- |
| `GET /v1/admin/audit-log` | Điều tra sự cố: ai đã đụng vào learner nào, lúc nào | **AS-07.4.5** |
| `GET /v1/admin/dead-letters` · `POST .../replay` | Message hỏng nằm ở đâu, và chạy lại nó | **AS-08.2.3** |
| `GET /v1/admin/retention-policies` · `POST .../sync` | Chính sách giữ dữ liệu trẻ em | **AS-04.5.1** |
| `GET /v1/admin/run-metrics` | Số run, số lỗi theo thời gian | **AS-08.3.4** |

Bốn chỉ báo trên đang PASS trong Audit #004 với bằng chứng *"endpoint tồn tại"*. Đúng chữ nhưng hụt nghĩa: **người vận hành không tới được chúng**. Muốn đọc nhật ký điều tra sự cố hôm nay thì phải tự dựng lời gọi HTTP kèm cookie phiên — tức là đúng cái việc mà admin sinh ra để khỏi phải làm.

Đây là phát hiện nặng nhất của bản audit này, vì nó đúng vào lúc cần nhất: khi có sự cố.

### A2 · Overview quay vòng mãi mãi khi API lỗi

```ts
const [ov, setOv] = useState<AdminOverview | null>(null);
useEffect(() => { admin.overview().then(setOv).catch(() => setOv(null)); }, []);
if (!ov) return <Loading />;          // ← lỗi cũng rơi vào đây
```

API chết, mạng rớt, phiên hết hạn: cả ba đều cho ra **spinner "Loading…" quay không dừng**, không một chữ nào nói có chuyện gì. Đây là màn hình ĐẦU TIÊN người vận hành mở.

### A3 · Lỗi hạ tầng trông y hệt "không có dữ liệu" (5 chỗ)

| Màn hình | Khi API lỗi | Người vận hành đọc ra |
| --- | --- | --- |
| Learners | `catch(() => setLearners([]))` | "No learners." |
| Models · chọn learner | `catch(() => setLearners([]))` | "No learners." |
| Runs · tab Workflows | `catch(() => setWorkflows([]))` | không có run nào |
| Runs · tab Engines | `catch(() => setEngines([]))` | không có run nào |
| Overview | xem A2 | quay mãi |

Component `ErrorBox` đã có sẵn và **có nút thử lại**, nhưng chỉ được dùng ở các trang sâu của Models. Năm màn hình trên nuốt lỗi im lặng.

Hại thật sự nằm ở chỗ hiểu ngược: "0 workflow run" là **tín hiệu báo động** (cron chết, engine không chạy), còn "API lỗi" là chuyện khác hẳn. Trộn hai cái vào cùng một màn hình trắng là làm hỏng đúng công dụng của trang Runs.

### A4 · Cắt danh sách âm thầm — 586/686 dòng đang bị giấu

Trang Runs xin `limit: 100`. Production hiện có **686 dòng `engine_runs`**. Không có phân trang, không có dòng "còn n dòng nữa", không có bộ lọc theo thời gian. Người vận hành nhìn 100 dòng mới nhất và **tin rằng đó là tất cả**.

Cùng lỗi ở `GET /v1/admin/learners`: `LIMIT 100` cứng trong SQL, không báo. Hiện mới 12 learner nên chưa đau, nhưng nó sẽ đau đúng lúc hệ đông người.

Chuẩn audit đã có nguyên tắc cho ca này ở phần workflow (*"no silent caps: nếu bound coverage thì phải `log()` cái bị bỏ"*) nhưng chưa có chỉ báo nào áp cho giao diện.

### A5 · Cấp quyền được, thu quyền thì không

`api.ts` khai đủ hai chiều: `grantRole(email, role, "grant" | "revoke")` và `assignMentor(..., "assign" | "unassign")`. Nhưng màn hình Access chỉ gọi nhánh mặc định:

```ts
admin.grantRole(roleEmail, roleKind)      // luôn là "grant"
admin.assignMentor(assignEmail, assignLearner)   // luôn là "assign"
```

Nghĩa là qua giao diện, người vận hành **cấp được quyền `admin` cho một email nhưng không rút lại được**. Khi một tài khoản bị lộ, đường xử lý nhanh nhất lại không nằm trong công cụ.

### A6 · Không có xác nhận cho bất kỳ hành động ghi nào

`grep confirm(` trong `apps/admin/src` → **không có dòng nào**. Trong khi đó admin có năm hành động ghi, gồm hai cái đáng phải hỏi lại:

* **cấp quyền `admin`** cho một email: một cú bấm, không hỏi lại, không hoàn tác được (xem A5);
* **`refreshRetention`**: chạm model của **mọi** learner trong hệ.

Đối chiếu: cổng learn đã có dialog xác nhận chỉ để rời khỏi Dashboard (SRC-397), vì "trên màn hình có mấy chục thẻ nằm sát nhau nên một cú bấm nhầm ném learner sang trang khác". Cấp quyền admin thì rủi ro lớn hơn nhiều mà lại không có lớp chặn nào.

### A7 · Phiên hết hạn không được nhận ra

`request()` trong `api.ts` gom mọi lỗi thành một `Error` chung; **không có nhánh nào xét 401**. Phiên hết hạn vì vậy đi thẳng vào A2 và A3: spinner quay mãi hoặc danh sách rỗng, thay vì một câu "phiên đã hết hạn, đăng nhập lại".

### A8 · Không có đường tự phục vụ cho hai việc vận hành đã có API

`POST /v1/admin/retention-policies/sync` và `POST /v1/admin/dead-letters/{id}/replay` là hai thao tác **sửa chữa** đã được xây và có test, nhưng nằm ngoài tầm với của giao diện (hệ quả của A1). Chúng đáng được lên màn hình hơn phần lớn thứ đang có, vì đó là lúc hệ đang hỏng.

## 3. Việc sinh ra từ audit này

| # | Việc | Đóng | Cỡ |
| --- | --- | --- | --- |
| **M1** | `ErrorBox` cho cả 5 màn hình danh sách; Overview thôi quay vòng khi lỗi | A2, A3 | nhỏ |
| **M2** | `request()` nhận diện 401 và đưa về màn đăng nhập kèm lời giải thích | A7 | nhỏ |
| **M3** | Xác nhận trước khi cấp quyền `admin` và trước khi chạy refresh toàn hệ | A6 | nhỏ |
| **M4** | Nút thu quyền và gỡ phân công mentor trên màn Access | A5 | nhỏ |
| **M5** | Nói rõ danh sách đang bị cắt: "hiện 100 / 686", kèm nút xem thêm | A4 | vừa |
| **M6** | Tab **Ops**: audit log tra cứu được, dead letters kèm nút replay, retention policies kèm nút sync, run metrics | A1, A8 · AS-07.4.5, AS-08.2.3, AS-08.3.4, AS-04.5.1 | LỚN |

Thứ tự đề xuất: M1 → M2 → M3 → M4 → M5 → M6. Năm việc đầu đều nhỏ và đóng đúng lớp "hệ hỏng mà người vận hành không nhìn ra"; M6 là màn hình mới nên để sau cùng.

## 4. Đề xuất cho bộ chuẩn

Ba phát hiện A1, A4, A6 đều **không vi phạm chỉ báo AS nào đang có**, vì các chỉ báo hiện hỏi "có endpoint không", "có audit log không" chứ không hỏi "người vận hành có tới được không". Đề xuất bổ sung cho lần cập nhật chuẩn kế tiếp, dưới dạng ba chỉ báo của AS-09 (Trải nghiệm) hoặc AS-08 (Vận hành):

1. Mọi endpoint vận hành (`/v1/admin/*`) hoặc có đường vào từ giao diện, hoặc được liệt kê tường minh là "chỉ dùng bằng script" kèm lý do.
2. Không màn hình nào cắt danh sách mà không nói ra tổng số thật.
3. Hành động không hoàn tác được (cấp quyền, chạy tác vụ toàn hệ, xoá) phải có bước xác nhận.

## 5. Trace

| Nguồn | Chuẩn | Ghi vào |
| --- | --- | --- |
| SRC-416 (chỉ đạo: "audit toàn bộ hệ thống tài liệu, hệ thống admin này, tạo ra các audit report, sau đó fix dần") | [audit-standard/index.md](../audit-standard/index.md) v0.2 — bổ trợ, không thay thang điểm | báo cáo này + [index](index.md) + việc M1..M6 |

## 6. Đã sửa — 2026-08-20, ngay sau khi chấm

| Việc | Kết quả kiểm được |
| --- | --- |
| **M1** | Thêm `Loadable` vào `ui.tsx`: một chỗ duy nhất quyết định ba nhánh **đang tải · rỗng thật · hỏng**, để không màn hình nào tự chế lại rồi chế lệch. Nối vào cả 5 màn hình. Overview thôi rơi vào nhánh `Loading` khi lỗi. → **A2, A3 đóng** |
| **M2** | `request()` nay ném `ApiError` mang `status` và `code`, có `.expired` cho 401; mất mạng (chưa có `Response`) cũng thành lỗi nói được thành lời thay vì mảng rỗng. → **A7 đóng** |
| **M3** | Cấp quyền `admin` phải xác nhận, kèm câu nói rõ hậu quả. Cấp `mentor`/`staff` **không** hỏi, vì rủi ro thấp và đó là thao tác làm hàng loạt. → **A6 đóng phần nặng nhất** |
| **M4** | Màn Access có `grant`/`revoke` và `assign`/`unassign`; nhãn nút đổi theo lựa chọn. → **A5 đóng** |
| **M5** | Trang Runs nói ra khi danh sách bị cắt: "Showing the 100 most recent runs. There are probably more." kèm nút xin thêm 100. Đổi tab hoặc đổi bộ lọc thì quay về trang đầu. → **A4 đóng** |

**Cách kiểm:** dựng admin ở máy, giả lập tầng `fetch` để ép đúng từng ca, rồi đọc lại màn hình thật:

| Ca ép ra | Trước | Sau (đo được trên màn hình) |
| --- | --- | --- |
| `GET /overview` trả 500 | spinner quay mãi | "Something went wrong · D1 is unreachable · Try again" |
| Bấm "Try again" sau khi API sống lại | không có nút | bảng số liệu hiện ra đầy đủ |
| `GET /learners` trả `[]` | "No learners." | "No learners." (giữ nguyên, đúng) |
| `GET /learners` trả 500 | "No learners." | "Something went wrong · D1 is unreachable · Try again" |
| Chọn `revoke` | không có lựa chọn | nút đổi thành "Revoke" |
| Cấp `admin`, bấm Huỷ ở hộp xác nhận | không có hộp nào | API **không** được gọi (`grantCalled: false`) |
| Cấp `mentor` | không có hộp nào | không hỏi lại, API được gọi ngay |

| **M6** | **Tab Ops** với bốn khu, xếp theo mức cấp bách khi có sự cố chứ không theo thứ tự API: **Metrics** (engine nào đang fail, cửa sổ 1h/24h/7d/30d) · **Queue** (message kẹt + nút Replay) · **Audit log** (lọc theo target và action) · **Retention** (chính sách đã khai vs bảng thật, kèm nút Sync). → **A1 và A8 đóng; bốn chỉ báo AS-07.4.5, AS-08.2.3, AS-08.3.4, AS-04.5.1 nay người vận hành với tới được, không còn PASS bằng lý do "endpoint tồn tại"** |

### Kiểm tab Ops

Giả lập bốn endpoint rồi đọc lại màn hình thật:

| Khu | Đo được trên màn hình |
| --- | --- |
| Metrics | 3 engine fail và 1 workflow fail hiện **đỏ** ở ô thống kê, kèm câu "4 failed run(s) in this window"; window đổi được 1h → 30 ngày |
| Queue | Dòng dead letter hiện đủ queue, message id, nguyên văn lỗi; bấm **Replay** gọi đúng `POST /v1/admin/dead-letters/dl1/replay` |
| Audit log | `mentor@example.com · mentor · learner_data.read · learner 8933bc48 · 203.0.113.9` — đúng bộ câu hỏi ai/làm gì/với ai/từ đâu |
| Retention | Cảnh báo đỏ "Declared in code but missing from the database: learner_context_events" kèm nút Sync |

Cả hai hành động ghi của tab này (Replay, Sync) đều hỏi lại trước khi chạy, đúng luật vừa đặt ở M3.

Vậy là **M1..M6 đóng hết**. Ba đề xuất bổ sung chỉ báo ở §4 vẫn để nguyên cho lần cập nhật chuẩn kế tiếp.
