---
url: https://docs.nemo12.com/reference/config.md
description: >-
  Danh sách đóng mọi binding, var, secret và tài nguyên cấu hình, được
  verify-bindings.mjs đối chiếu với wrangler.jsonc trong CI.
---

# Config & Bindings

**Danh sách đóng.** Mọi biến cấu hình đang chạy phải có mặt ở trang này (AS-03.4.5). Đây không phải quy ước lịch sự — `scripts/verify-bindings.mjs` (chạy trong CI) đọc **chính trang này** và fail nếu một binding hoặc một `var` trong `wrangler.jsonc` không được nhắc tới ở đây. Thêm cấu hình mà quên tài liệu = CI đỏ, không phải nợ âm thầm.

Ba lớp kiểm của `verify:bindings`:

| Lớp | Kiểm gì | Chạy khi nào |
| --- | --- | --- |
| **A — Kiểu** | Mọi binding/var của `nemo12-api` được khai kiểu trong `workers/api/src/env.ts` (AS-03.4.3) | luôn |
| **B — Tài liệu** | Mọi binding/var có trong trang này (AS-03.4.5) | luôn |
| **C — Thật** | Hỏi thẳng Cloudflare xem tài nguyên có tồn tại (RISK-026) | khi có `CLOUDFLARE_API_TOKEN` |

***

## 1. Bindings của `nemo12-api`

Nguồn: `workers/api/wrangler.jsonc`. Kiểu: `workers/api/src/env.ts`.

| Binding | Loại | Tài nguyên | Dùng cho |
| --- | --- | --- | --- |
| `DB` | D1 | `nemo12-platform` (`f4275a70-…`) | Toàn bộ dữ liệu quan hệ — 76 bảng |
| `CONFIG` | KV | `f08c2394…` | Cấu hình runtime đọc nhiều, ghi hiếm |
| `CONTENT` | R2 | `nemo12-content` | Media, tài sản nội dung (SDD-009), **và snapshot context AI** (`ai-context/…`, AS-10.3.4) |
| `AI` | Workers AI | tài nguyên cấp tài khoản | LLM — **luôn** qua Gateway `nemo12` |
| `EVENTS` | Queue producer | `nemo12-events` | Domain events |
| `FOUNDRY` | Service | `nemo12-foundry` | Xưởng sinh course content (SRC-632). Worker ấy **không có route công khai**, nên binding này là đường vào duy nhất — và nó nằm sau cổng admin của api |
| `EVENT_STAGE` | Durable Object | lớp `EventStage` (migration DO `v1`, `new_sqlite_classes`) | Live Stage của buổi sự kiện (SRC-711). **Durable Object đầu tiên của hệ thống.** Một instance cho mỗi buổi (`idFromName(event_id)`): giữ các WebSocket đang mở và phát lại ảnh chụp **đọc từ D1**. Nó KHÔNG giữ trạng thái nghiệp vụ — DO bị thu hồi giữa buổi thì không mất phiếu nào |

Consumer `nemo12-events`: `max_batch_size` 25, `max_retries` 5, DLQ `nemo12-events-dlq`. Message chết được ghi bền vào bảng `queue_dead_letters` để replay được — xem [queues](queues.md).

> **RISK-026**: binding sai **không** làm `wrangler deploy` fail. Worker lên xanh, rồi request đầu tiên chạm `c.env.DB` mới nổ — trên production, trước mặt phụ huynh. Đó là lý do lớp C tồn tại.

## 2. Vars của `nemo12-api` (công khai, nằm trong `wrangler.jsonc`)

Đủ 13 var, khớp `Env` trong `workers/api/src/env.ts`:

| Var | Giá trị production | Ý nghĩa và hệ quả nếu sai |
| --- | --- | --- |
| `ENVIRONMENT` | `production` | Phân biệt môi trường trong log và thông điệp lỗi |
| `COOKIE_DOMAIN` | `.nemo12.com` | Domain cookie phiên. Sai → đăng nhập ở `learn.` không nhận ở `marlins.` |
| `ALLOWED_ORIGIN_SUFFIX` | `nemo12.com` | CORS + CSRF: request đổi dữ liệu phải có `Origin` thuộc hậu tố này. **Không hardcode domain trong code** (REQ-SEC-06) |
| `AI_GATEWAY_ID` | `nemo12` | Gateway bắt buộc cho mọi lời gọi AI (QG-010). Sai → lời gọi đi ngoài gateway, mất cả log lẫn hạn mức |
| `CF_ACCOUNT_ID` | `ff302e77…` | Id tài khoản Cloudflare, dùng gọi API RealtimeKit (SRC-1168). Công khai, không phải bí mật |
| `RTK_APP_ID` | `ee728643-…` | Id app RealtimeKit `nemo12-speak-rooms` (SRC-1168). Công khai |
| `RTK_WEBHOOK_PUBLIC_KEY` | khoá PEM | Khoá công khai của RealtimeKit để kiểm chữ ký webhook `recording.statusUpdate` (SRC-1168). Nguồn: `api.realtime.cloudflare.com/.well-known/webhooks.json` |
| `SPEAK_ROOMS_PROVIDER` | `realtimekit` | Nhà cung cấp phòng nói Speak rooms (SRC-1162, SDD-052 §4): `off` | `mock` | `realtimekit`. `off` thì tạo phòng trả lỗi, không gọi mạng; đổi sang `realtimekit` chỉ sau khi chủ dự án đã đặt các secret `RTK_*` và `CF_ACCOUNT_ID` (xem `.claude/memory/viec-dang-cho.md`) |
| `AI_DAILY_USD_CAP` | `2` | Trần USD/ngày (ƯỚC theo đơn giá token công bố; hoá đơn thật tính theo neuron — neo: tháng 8/2026 = 7,84M neuron = $86,28) cho cron AI `*/5` (xưởng curriculum + IELTS + dịch b21), cộng dồn ở KV `ai:usage:YYYY-MM-DD` mỗi nhịp, log `cron_ai_tick_done` ghi `usd_tick`/`usd_today`. Chạm trần là nhịp không gọi model (`cron_ai_daily_cap_reached`); `0` = tắt trần. Trần MỀM (đọc-rồi-ghi KV); trần cứng là công tắc KV `ai_cron_paused`. Foundry có trần riêng bên dưới. Mức `2` (~$60/tháng) là trần CHẶN do audit đặt, chủ dự án chỉnh theo nhu cầu sinh nội dung (Audit #013, P-1) |
| `GOOGLE_CLIENT_ID` | `856179425546-…` | Audience để verify `id_token`. Client ID là **public theo thiết kế của OAuth**, không phải secret |
| `GOOGLE_MOBILE_CLIENT_IDS` | chưa đặt | Client id Google của app NEMO IELTS (iOS, Android), cách nhau dấu phẩy (SRC-1100, SDD-055 §4). Chỉ được nhận khi `POST /v1/auth/google` gửi `client: "mobile"`; đăng nhập web vẫn chỉ nhận `GOOGLE_CLIENT_ID`. Public như mọi client id. Chưa đặt thì app chỉ đăng nhập được bằng token có `aud` là client web |
| `APPLE_BUNDLE_ID` | `com.nemo12.ielts` | `aud` bắt buộc khi verify identity token của Sign in with Apple (SRC-1100, SDD-055 §4). Thiếu hoặc rỗng thì `POST /v1/auth/apple` trả 503, không bao giờ xác thực. Public, không phải secret |
| `ACCESS_TEAM_DOMAIN` | `dac2205.cloudflareaccess.com` | Team Zero Trust phát Access JWT. Dùng làm `issuer` + nguồn JWKS khi verify. Sai → nhận token của team khác (SRC-129) |
| `ACCESS_AUD` | `261f5250…` | AUD tag của app **Nemo12 Admin**. Là `audience` bắt buộc khi verify. Thiếu/sai → JWT của **bất kỳ app nào khác cùng team** (Coral, Dolphin…) cũng mở được admin |
| `ACCESS_AUD_DOLPHIN` | `5b5df250…` | AUD tag của app **Nemo12 Dolphin** (SRC-1262). Dùng khi `POST /v1/auth/mentor-grant` verify JWT để cấp vai mentor cho ai qua Access của dolphin. Phải KHÁC `ACCESS_AUD`: JWT của admin không được lấy vai mentor, JWT của dolphin không được mở admin. Thiếu → endpoint trả lỗi cấu hình, không cấp gì |
| `DIGEST_FROM` | `NEMO <support@nemo12.com>` | Địa chỉ gửi thư qua Email Service (SRC-602/604). Đổi được không cần deploy code. Sai domain → `E_SENDER_DOMAIN_NOT_CONFIGURED`, thư ghi `failed` |
| `DIGEST_TEST_RECIPIENT` | `""` (đang TẮT — gửi thật) | Chốt xem trước: còn đặt thì **mọi thư của cả 10 loại** đi về đây thay vì tới phụ huynh thật, tiêu đề mang `[THỬ → ai-đáng-lẽ-nhận]`. Đặt `""` là bật gửi thật |
| `EMAIL_COPY_TO` | `dac2205@gmail.com` | Bản đối chứng mọi thư gửi phụ huynh, để chủ dự án đọc được đúng thứ họ nhận. Để trống thì không gắn bản sao. **Đây là địa chỉ thật của một người**, đổi người nhận là đổi ai đọc được thư gửi cho gia đình |
| `IELTS_REPORT_TO` | `dac2205@gmail.com` | Người nhận báo cáo tiến độ IELTS hằng ngày (SRC-928), gửi trong lượt cron 12:00 UTC. Để trống thì báo cáo về đúng `EMAIL_COPY_TO`, tức là vẫn tới chủ dự án. Thư vận hành: miễn trần ngày, và gửi cả vào ngày không ai học |
| `EMAIL_BCC_TO` | `dangtuyethong2324@gmail.com` | BCC cố định cho MỌI lá thư (SRC-1311). Phụ huynh không thấy địa chỉ này; trang minh bạch dữ liệu khai nó. Đổi người nhận là đổi ai đọc được thư gửi cho gia đình. Rỗng = tắt |
| `PARENT_REPORTS_ENABLED` | `1` | Thư báo cáo phụ huynh sau buổi học + thư tuần cho mọi phụ huynh (SRC-1311). `0` = thư sau buổi chạy khô, thư tuần cũ giữ nguyên. `1` = gửi thật, thư tuần mới thay `weekly-report` |
| `PLAN_MAILS_ENABLED` | `1` | Thư Learning Plan IELTS và SAT (SRC-1327): tuần (tối Thứ Năm, cho 7 ngày từ Thứ Sáu) và tháng (ngày 25, cho tháng sau), bản phụ huynh và bản học sinh. `0` = chạy khô, `1` = gửi thật |
| `WHATS_NEW_ENABLED` | `1` | Thư Có gì mới hằng tuần (SRC-1328): bản nháp tối Thứ Năm luôn về `EMAIL_COPY_TO`; lá thật tối Chủ nhật cho phụ huynh và học sinh chỉ khi bằng `1` và tuần có tin `published` |
| `PARENT_REPORT_TEST_ON` | `2026-10-10#4` | Ngày (giờ VN) chạy MỘT lượt gửi thử thư báo cáo phụ huynh: 3 học sinh mỗi chương trình, dữ liệu thật, chỉ gửi về `EMAIL_COPY_TO` (và BCC). Ngày khác thì không chạy |
| `SAMPLE_SEND_ON` | `2026-10-11#1` | Ngày (giờ VN) gửi TOÀN BỘ thư mẫu một lần tới `EMAIL_SAMPLE_TO`, qua cron mỗi giờ, không cần ai đăng nhập admin. Ngày khác thì không chạy; chạy lại cùng ngày không gửi lần hai. Dạng `YYYY-MM-DD#n`: gửi lại trong cùng ngày thì tăng `n` |
| `EMAIL_SAMPLE_TO` | `dac2207@gmail.com` | Hộp thư thử của chủ dự án: nút "Gửi thư mẫu" ở admin.nemo12.com/#emails gửi về đây (07.10.2026), để xem thư như một người nhận thật. Rỗng thì về `EMAIL_COPY_TO`. Endpoint vẫn không nhận địa chỉ tuỳ ý: đổi người nhận thư mẫu là đổi cấu hình |
| `MAIL_SERIES_ENABLED` | `1` | Công tắc chuỗi thư dài hạn của sub-brand (SRC-1289, ba lá mỗi tuần). `0` = lượt cron chạy KHÔ: tính đủ ai sẽ nhận lá nào, ghi log, không gửi, không giành hàng chống trùng. `1` = gửi thật tới learner. Chỉ bật sau khi chủ dự án đã đọc bản mẫu |
| `INBOUND_FORWARD_TO` | `dac2205@gmail.com` | Thư tới mọi hộp thư sub-brand (`ielts@`, `sat@`, `grammar@`..., SRC-1282) được Email Worker chuyển tiếp về đây. Phải là *Destination address* **đã xác minh** trong Email Routing, nếu không thư bị trả về người gửi. Để trống thì về `EMAIL_COPY_TO`. Đổi địa chỉ này là đổi ai đọc thư người ngoài gửi cho Nemo12 |
| `EMAIL_COPY_MODE` | `bcc` | `cc` = phụ huynh NHÌN THẤY địa chỉ đối chứng và có thể Trả lời tất cả vào đó (chỉ đạo 2026-09-07); `bcc` = đối chứng vô hình. Đổi được mà không cần deploy |
| `EMAIL_MAX_PER_DAY` | `3` | Trần số thư một người nhận trong một **ngày lịch Việt Nam** (chỉ đạo 2026-09-07). Thư theo lịch vượt trần bị **hoãn** sang hôm sau chứ không mất; thư trả lời hành động vừa xảy ra (vé sự kiện, chào mừng, kết quả bài đầu vào) không bị trần chặn |
| `OUTREACH_SEND_ENABLED` | `1` | Công tắc thư mời trường tháng 10.2026 từ `hello@nemo12.com` (SRC-1110). Chỉ đúng `1` mới gửi thật; giá trị khác thì "Gửi lô đã duyệt" bị từ chối, chỉ xem trước được. Bật sau khi `hello@nemo12.com` nhận được thư trả lời (Email Routing), xem [SDD-049](../architecture/sdd-049-school-outreach.md) §5 |
| `PUBLIC_API_ORIGIN` | `https://api.nemo12.com` | Gốc URL dựng địa chỉ ảnh 1×1 theo dõi lượt mở thư (SRC-681). Sai gốc thì ảnh không tải được và mọi thư trông như chưa ai mở |

## 1b. Binding của `nemo12-foundry` (SRC-632)

Nguồn: `workers/foundry/wrangler.jsonc`. Kiểu: `workers/foundry/src/env.ts`.

| Binding | Loại | Tài nguyên | Dùng cho |
| --- | --- | --- | --- |
| `DB` | D1 | `nemo12-platform` (`f4275a70-…`) | Sổ lượt sinh: `content_runs`, `content_run_trials` |
| `CONTENT` | R2 | `nemo12-content` | Bản THÔ của mỗi lượt gọi model (`foundry/<run>/<model>.json`) |
| `AI` | Workers AI | tài nguyên cấp tài khoản | Mọi model, kể cả model bên thứ ba, **luôn** qua Gateway `nemo12` |
| `LESSON_FORGE` | Workflow | `nemo12-lesson-forge` | Workflow sinh một Lesson và chấm bằng nhiều model |

| Var | Giá trị | Ý nghĩa |
| --- | --- | --- |
| `AI_GATEWAY_ID` | `nemo12` | Cùng gateway với api để log và hạn mức nằm chung một chỗ |
| `FOUNDRY_DAILY_USD_CAP` | `5` | Trần chi phí (USD niêm yết ước) cộng dồn 24h. Chạm trần là mọi lượt mới bị từ chối; `0` = tắt chốt |
| `FOUNDRY_MAX_CONCURRENT_RUNS` | `8` | Bao nhiêu lượt chạy cùng lúc. Nâng 2→8 (2026-08-28) sau khi ĐO được chi phí thật: $0,003/bài học, $0,017/bài kho câu hỏi — trần song song chỉ chặn tốc độ, trần chi phí ngày mới chặn tiền |
| `FOUNDRY_TARGET_COOLDOWN_MIN` | `15` | Cùng một bài phải cách bao nhiêu phút mới được chạy lại |
| `FOUNDRY_MAX_ATTEMPTS_PER_TARGET` | `3` | Thử quá số này mà vẫn thiếu thì **dừng và ghi sổ việc tắc** (SRC-636), chờ người chỉnh thuật toán |
| `FOUNDRY_MAX_BATCH` | `10` | Trần cho một cú bấm "lấy việc" và cho mỗi lượt cron kéo |
| `FOUNDRY_AUTO_DRAIN` | `1` | `1` = cron mỗi 15 phút tự kéo việc (vẫn qua đủ năm chốt). Khác `1` = tắt. **Đang bật cho đợt 1** (3 khoá Toán + 3 khoá Ngữ văn); xong đợt thì đổi về `0` |

Năm trần trên là luật chống "chạy loạn workflow" (SRC-634). Chúng ở `vars` chứ không ở D1 là cố ý:
**trần sửa được từ giao diện thì không còn là trần** — nới phải qua một lần deploy.

Worker này khai `workers_dev: false` và không có `routes`: nó chỉ nhận request qua service binding
`FOUNDRY` của api. Một endpoint đốt tiền model mà phơi ra Internet thì sớm muộn cũng có người bấm hộ.

## 1c. Binding của `nemo12-tool-plane` (SRC-667)

Nguồn: `workers/tool-plane/wrangler.jsonc`. Kiểu: `workers/tool-plane/src/env.ts`. Route công khai
`mcp.nemo12.com` (custom domain) — cố ý, vì Claude Managed Agent chạy ở Anthropic nên không có
service binding nào tới được; không token của agent là 401 trước khi chạm bảng nào (SDD-030 §5).

| Binding | Loại | Tài nguyên | Dùng cho |
| --- | --- | --- | --- |
| `DB` | D1 | `nemo12-platform` (`f4275a70-…`) | Đọc learner data qua binding trực tiếp; bảng riêng `tp_agents`, `tp_applications`, `tp_tools`, `tp_agent_tools`, `tp_policies`, `tp_audit_logs` (migration 0188), `tp_agent_tokens` (0189) |

| Var | Giá trị | Ý nghĩa |
| --- | --- | --- |
| `TOOL_PLANE_ENVIRONMENT` | `production` | Tầng môi trường mà grant `environment_scope` phải phủ; khai sai thì code coi là `production` (sai về phía chặt) |

Không có secret nào ở worker: token của agent chỉ lưu SHA-256 (`tp_agent_tokens`, hoặc `tp_agents.token_hash`
cho token không trói user). Cấp token bằng `node scripts/tool-plane/issue-agent-token.mjs <agent_id> [--user <id>]`
(in token một lần + SQL nạp hash). Managed Agents chỉ gửi được `Authorization`, nên user nằm trong token (SDD-030 §6).

## 2b. Vars của `nemo12-admin` (worker phục vụ admin.nemo12.com)

`apps/admin` không còn là app tĩnh thuần: nó có `main` (worker.ts) để nhận danh tính Cloudflare
Access và đổi lấy session (SRC-129). Kiểu `Env` khai ngay trong `apps/admin/worker.ts`.

| Binding / Var | Giá trị production | Ý nghĩa và hệ quả nếu sai |
| --- | --- | --- |
| `ASSETS` | binding tới `./dist` | Phục vụ file tĩnh của app. `run_worker_first: ["/auth/*"]` để SPA fallback không nuốt mất `/auth/access-session` |
| `API_ORIGIN` | `https://api.nemo12.com` | Nơi worker chuyển tiếp Access assertion. Sai → admin không đăng nhập được (không còn đường đăng nhập nào khác) |

## 3. Biến runtime không nằm trong `vars`

Ba thứ hành xử như cấu hình nhưng **không** đi qua `wrangler.jsonc` — kê ra để danh sách thật sự đóng:

| Thứ | Ở đâu | Vì sao không phải var |
| --- | --- | --- |
| `POLICY_VERSION` = `2026-08-15` | `shared/privacy.ts` | Phiên bản văn bản chính sách. Đổi nó là một thay đổi **có review**, phải đi qua PR và commit — không phải nút vặn lúc runtime |
| `RATE_LIMITS.*` (5 quy tắc) | `shared/ratelimit.ts` | Hạn mức là hợp đồng với người dùng; đổi lén qua env sẽ khiến không ai biết vì sao hôm nay bị chặn. Xem §4 |
| `TIMEOUT_MS` (`fetch` 8s, `ai` 30s) | `shared/http.ts` | Hạn giờ gắn với hành vi fallback trong code, đọc cùng chỗ với chỗ dùng |

`AI_MODELS` (`shared/prompts.ts`) cũng vậy: model id khai tường minh trong code để `grep` ra được đang chạy model nào (AS-10.1.3), **không** đọc từ env — env đọc được nghĩa là đổi được model production mà không ai thấy trong git.

## 4. Hạn mức đang áp

Khai tập trung tại `shared/ratelimit.ts`, đếm bằng D1 (`rate_limit_buckets`):

| Scope | Hạn | Cửa sổ | Đặt ở đâu | Đếm theo |
| --- | --- | --- | --- | --- |
| `login` | 20 | 5 phút | `POST /v1/auth/google` | IP |
| `ai-generate` | 30 | 1 giờ | `POST /v1/coral/generate-items`, `POST /v1/coral/blueprints/{id}/generate` | user |
| `crawl` | 60 | 1 giờ | dành cho thu thập nguồn ngoài — **chưa gắn route nào** |  user/IP |
| `submit` | 120 | 1 phút | dành cho gửi bài — **chưa gắn route nào** | user |
| `privacy-write` | 30 | 10 phút | 3 route ghi của `/v1/consents` + `/v1/privacy/deletion-requests` | user |

Hai dòng "chưa gắn route" được ghi ra thay vì giấu: quy tắc đã có, chỗ dùng thì chưa. Chạm hạn mức → `RATE_LIMITED` (429) kèm `Retry-After`. Bộ đếm hỏng thì **fail-open** có chủ đích (chặn nhầm phụ huynh đang dùng thật tệ hơn là lọt vài lượt gọi) và ghi `RATE_LIMIT_CHECK_FAILED` vào log.

## 5. Secrets

**Không có secret nào trong repo, trong `vars`, hay trong git history** (AS-07.5.1 PASS ở audit #001 — đừng làm hỏng). Đặt qua `wrangler secret put` / GitHub Secrets:

| Secret | Nơi dùng | Quyền cần |
| --- | --- | --- |
| `CLOUDFLARE_API_TOKEN` | GitHub Actions: deploy, rollback, verify:bindings lớp C | Edit Workers, D1, KV, R2, Queues |
| `CLOUDFLARE_ACCOUNT_ID` | như trên | — |

Luật: **cấm hardcode secret, domain, audience** (QG-008). Client secret của Google **không** cần cho luồng ID-token hiện tại — đó là lý do nó không có trong bảng.

## 6. Cấu hình từng worker (9 worker)

Mọi worker: `compatibility_date` `2026-08-01`, `observability.enabled` = **true** (AS-08.3.1).

| Worker (`name`) | Config | Domain | Kiểu | Binding | Cron |
| --- | --- | --- | --- | --- | --- |
| `nemo12-api` | `workers/api/wrangler.jsonc` | `api.nemo12.com` | code (`src/index.ts`) | 5 (§1) | `0 21 * * *` |
| `nemo12-web` | `apps/web/wrangler.jsonc` | `nemo12.com`, `www.nemo12.com` | assets (SPA) | — | — |
| `nemo12-learn` | `apps/learn/wrangler.jsonc` | `learn.nemo12.com` | assets (SPA) | — | — |
| `nemo12-marlins` | `apps/marlins/wrangler.jsonc` | `marlins.nemo12.com` | assets (SPA) | — | — |
| `nemo12-dolphin` | `apps/mentors/wrangler.jsonc` | `dolphin.nemo12.com` | assets (SPA) | — | — |
| `nemo12-admin` | `apps/admin/wrangler.jsonc` | `admin.nemo12.com` | assets (SPA) | — | — |
| `nemo12-coral` | `apps/coral/wrangler.jsonc` | `coral.nemo12.com` | assets (SPA) | — | — |
| `nemo12-ielts` | `apps/ielts/wrangler.jsonc` | `ielts.nemo12.com` | assets (SPA) | — | — |
| `nemo12-docs` | `apps/docs/wrangler.jsonc` | `docs.nemo12.com` | assets (VitePress) | — | — |
| `nemo12-pearl` | `apps/pearl/wrangler.jsonc` | `pearl.nemo12.com` | assets (VitePress) | — | — |

Hai điều dễ vấp, ghi thẳng ra:

* **Thư mục `apps/mentors/` deploy ra worker tên `nemo12-dolphin`.** Tên thư mục theo persona kỹ thuật, tên worker theo cách gọi của sản phẩm. Mọi lệnh `wrangler … --name` phải dùng `nemo12-dolphin`.
* `data.nemo12.com` xuất hiện trong tài liệu cũ như một surface nội bộ nhưng **không có `wrangler.jsonc` nào trong repo** — không phải worker của monorepo này.

Cron duy nhất: `0 21 * * *` (04:00 giờ Việt Nam — giờ yên, learner không đang học) chạy WF-17 Retention Refresh, bọc `withLock` TTL 6 giờ để không chạy chồng (AS-08.2.5). Xem [schedules](schedules.md).

Surface nội bộ (`admin`, `coral`, `docs`) được bảo vệ thêm bằng **Cloudflare Access** ở tầng hạ tầng — cấu hình ở dashboard Cloudflare, không nằm trong repo.

## 7. CI/CD

| Workflow | Kích hoạt | Việc |
| --- | --- | --- |
| `.github/workflows/ci.yml` | mọi push, mọi PR, dispatch | Cổng chất lượng (xem §8) **và**, trên `main`, deploy mọi app bị đụng — **migration trước, code sau** (AS-08.4.3 🔴) |
| `.github/workflows/deploy-app.yml` | `workflow_dispatch`, chọn một app | Deploy lại một app hoặc quay về commit cũ. Đọc kết quả ci trên đúng commit ấy qua `wait-for-ci.sh` nên không lách được cổng |
| `.github/workflows/rollback.yml` | `workflow_dispatch` thủ công | Quay ngược một worker về version trước — xem [SDD-006 §17](../architecture/sdd-006-reliability.md#_17-rollback-quay-nguoc-trong-vai-giay) |
| `.github/workflows/seed-data.yml` | `workflow_dispatch`, một file `scripts/*.sql` | Nạp dữ liệu vào D1 production |
| `.github/workflows/check-production.yml` | 23:00 UTC hằng ngày, dispatch | Đối chiếu commit đang chạy trên production với `main` |

App nào gồm đường dẫn nào, build bằng workspace nào, có cần áp migration không — tất cả khai ở
**`.github/app-paths.json`**, và cả `ci.yml`, `deploy-app.yml`, `scope.sh` lẫn cổng đối chiếu
production đều đọc từ đó. Trước 22.09.2026 câu trả lời ấy nằm chép tay ở 13 file `deploy-*.yml`
cộng 13 bước trong `ci.yml` (SRC-948).

## 8. Cổng CI — 12 cổng, không cổng nào được `|| true`

Tất cả nằm trong một job của `ci.yml`, và mỗi cổng chỉ chạy khi push ĐỤNG tới thứ nó gác
(`.github/scripts/scope.sh` quyết định). Cột "chạy khi" là cờ phạm vi.

| # | Cổng | Lệnh | Chạy khi |
| --- | --- | --- | --- |
| 1 | typecheck | `npm run typecheck` (cả monorepo) | `code` |
| 2 | lint | `npm run lint` | `code` |
| 3 | test | `npm test` của workspace bị đụng | `code` |
| 4 | smoke giao diện | `npm run test:e2e` trong `apps/learn` (AS-05.3.5) | `e2e` |
| 5 | docs integrity | `npm run check:docs` — 11 cổng con (QG-001) | `docs` |
| 6 | cổng kiểm code | `npm run check:code` — 27 cổng con: em dash, ngày tháng, shadcn/token, gói cấm, định danh tiếng Anh, tương phản WCAG… | `code` |
| 7 | sổ chung không bị ghi đè | `scripts/check-no-clobber.mjs` (SRC-457) | `docs` |
| 8 | reference khớp source | `scripts/gen-reference.mjs --check` (AS-01.4.1 🔴) | `reference` |
| 9 | hợp đồng `/v1` | `npm run contract:diff` (AS-03.2.4 🔴) | `api` |
| 10 | số migration liên tục | `scripts/check-migration-numbers.mjs` (AS-04.1.1 🔴) | `migrations` |
| 11 | migration dry-run từ D1 rỗng | `npm run migrate:dryrun` (AS-04.1.4 🔴) | `migrations` |
| 12 | binding tồn tại thật | `npm run verify:bindings` (AS-03.4.4) | `bindings` |

Cổng 5 và 6 mỗi cái là một NHÓM chạy qua `scripts/run-checks.mjs`: nhóm chạy hết rồi in bảng tổng
kết, thay vì dừng ở cổng đỏ đầu tiên. Một lượt chạy chỉ trả về một lỗi là một lượt chạy gần như
không có thông tin (SRC-948).

Cổng 8 dùng `--check` nên chỉ ĐỌC và so, không sửa cây làm việc — nhờ vậy chạy được cả ở máy
(`npm run check:docs` gọi nó) lẫn trong CI. Trước đó nó sinh ra rồi `git diff`, và vì thế là cổng
chặn deploy duy nhất không ai chạy được trước khi push.

Nguyên tắc: **một cổng không bao giờ đỏ là một cổng không tồn tại.** Đường `workflow_dispatch`
(`deploy-app.yml`, `rollback.yml`) không chạy lại bộ cổng mà ĐỌC kết quả `ci` trên đúng commit
đang deploy qua `.github/scripts/wait-for-ci.sh`, và tự chạy cổng tại chỗ nếu commit đó chưa từng
qua `ci`. Trước 23.08.2026 mỗi workflow deploy chạy lại typecheck/lint/test — đúng thứ vừa xanh
trên cùng commit, và GitHub tính tiền theo SỐ JOB làm tròn lên phút.

Hai cổng cố ý đứng NGOÀI `ci` vì chúng phụ thuộc mạng: `npm run check:ui-latest` (độ mới của bộ
ba UI, DS-001 §0) và `check-production.yml` (đối chiếu production với `main`, chạy 23:00 UTC).
Một cổng phụ thuộc mạng đứng chắn mọi push thì đến ngày mạng nấc là mọi phiên đứng.

## 8b. Nâng phụ thuộc

Dependabot mở PR theo lịch (`.github/dependabot.yml`): npm hằng tuần sáng thứ Hai, GitHub Actions
hằng tháng. Bản **minor và patch gom làm một PR**; bản **major đứng riêng**, vì một major cần đọc
changelog chứ không nên trôi lẫn trong một PR hai mươi gói.

**Nâng nhiều gói thì phải thử CHUNG, không thử riêng** (SRC-951). Ba PR đầu tiên của dependabot
ngày 22.09.2026 cho thấy vì sao: mỗi PR xanh trên CI của chính nó, nhưng chúng không gộp được rời
nhau — `wrangler` 4.136 đòi `@cloudflare/workers-types ^5`, nên PR nâng wrangler KHÔNG cài được
nếu thiếu PR nâng workers-types. Gộp lần lượt thì lượt gộp thứ nhất làm `main` đỏ.

Hai lớp hỏng hay gặp khi nâng, và cách nhận ra:

| Dấu hiệu trong log | Nghĩa là | Cách chữa |
| --- | --- | --- |
| `Two different types with this name exist, but they are unrelated` | **Hai bản cùng một gói type** trong cây. Xảy ra khi workspace khai bản mới còn một gói trung gian xin `*` và npm giữ bản cũ ở gốc | Khai gói type ấy làm `devDependencies` của **package.json gốc** để chỉ một bản được hoist |
| `Handler<...> is not assignable to Context<...>` hàng loạt ở `workers/api` | Một gói trong mạch Hono/zod đổi kiểu | Cô lập bằng cách nâng RIÊNG từng gói trong mạch, rồi `ignore` trong `dependabot.yml` |

**Chặn theo RANH GIỚI, đừng chặn theo bản đã đo.** Chuỗi sai lầm ngày 22.09.2026 với
`@hono/zod-openapi` đáng chép lại nguyên vẹn:

1. Đo thấy `1.6.3` đỏ → `ignore: ["1.6.3"]`. Dependabot đề xuất **1.6.2** → cũng đỏ.
2. Kết luận "cả dòng 1.6" → `ignore: ["1.6.x"]`. Dependabot đề xuất **1.5.3** → cũng đỏ.
3. Thật ra chỗ đổi kiểu nằm ở **1.5.3**, và bản cuối cùng còn lành là **1.5.2** →
   `ignore: [">1.5.2"]`.

Sai lầm ở bước 1 và 2 giống nhau: chặn đúng những bản MÌNH ĐÃ ĐO, rồi suy ra phần còn lại an toàn.
Bước 2 còn suy sai một lần nữa — thấy 1.5.2 lành thì tưởng cả 1.5.x lành. Dependabot sẽ tìm ra
đúng cái bản mình chưa đo, mỗi tuần một lần, cho tới khi chặn đúng ranh giới.

Và nói cho rõ — `@hono/zod-openapi` **không hỏng**: nó đổi kiểu handler có chủ đích ở 1.5.3. Muốn
lên bất kỳ bản nào cao hơn thì phải sửa 8 module route của `workers/api` cho khớp, và đó là một
việc CODE có commit và có test, không phải một lượt bấm gộp. Lúc làm việc ấy thì gỡ dòng `ignore`
trong `.github/dependabot.yml`.

`@types/react` và `@types/react-dom` khai ở `package.json` gốc chính vì lớp thứ nhất: chúng không
được dùng trực tiếp ở gốc, chúng nằm đó để cả monorepo chỉ có MỘT bản.

## 8c. Lỗ hổng phụ thuộc còn tồn (rà 22.09.2026)

`npm audit` còn **3 lỗ hổng** và cả ba **chưa có bản vá thượng nguồn** — ghi ở đây để lần rà sau
không phải điều tra lại từ đầu:

| Gói | Mức | Vì sao chưa vá được |
| --- | --- | --- |
| `vite` (lồng trong `vitepress`) | cao | `vitepress` 1.6.4 **đã là bản mới nhất** và nó ghim một bản `vite` cũ. Không có đường nâng. |
| `esbuild` (lồng trong `vite` ấy) | vừa | cùng lý do |
| `vitepress` | vừa | cùng lý do |

**Cả ba đều là lỗ của DEV SERVER**: đọc phản hồi từ `vite dev`, path traversal trong `.map` của
optimized deps, `server.fs.deny` bị lách trên Windows. Bốn site VitePress (docs · pearl · compass ·
playbooks) **build tĩnh rồi deploy làm asset** — dev server không chạy trên production, nên bề mặt
tấn công nằm ở máy người phát triển, không ở người dùng.

`js-yaml` (mức cao) đã vá ngày 22.09.2026 bằng `npm audit fix`: ba dòng trong `package-lock.json`,
không đụng semver trong `package.json`.

Rà lại khi `vitepress` ra bản mới. `npm audit` KHÔNG nằm trong CI, cùng lý do với
`check:ui-latest`: nó hỏi registry, mà một cổng phụ thuộc mạng đứng chắn mọi push thì đến ngày
mạng nấc là mọi phiên đứng.

## 9. Lệnh hay dùng

```bash
npm run typecheck        # toàn monorepo
npm run lint             # eslint — chỉ rule bắt lỗi thật, không bắt phong cách
npm test                 # unit + behavior tests
npm run check:docs       # QG-001 — frontmatter, trace, link gãy
npm run gen:reference    # sinh lại data-dictionary / api / events
npm run contract:diff    # so hợp đồng /v1 với snapshot
npm run verify:bindings  # binding có kiểu, có tài liệu, có thật
npm run migrate:dryrun   # áp toàn bộ migration lên D1 local rỗng
```

## 10. Bắt buộc khi thêm cấu hình mới

1. Thêm binding/var vào `wrangler.jsonc`.
2. Khai kiểu trong `workers/api/src/env.ts` (lớp A sẽ bắt nếu quên).
3. **Thêm dòng vào §1 hoặc §2 của trang này** kèm tên tài nguyên trong dấu backtick (lớp B đọc đúng dấu backtick).
4. Tạo tài nguyên thật trên Cloudflare (lớp C sẽ bắt nếu quên).
5. Là secret thì **không** đưa vào `vars` — dùng `wrangler secret put`, và thêm dòng vào §5.

## Trace

* REQ-PLT-01/02 (Cloudflare-first), REQ-SEC-06 (CORS/CSRF), REQ-NFR-\*.
* Rủi ro: RISK-022 (cấm probe-fallback AI), RISK-026 (binding verify), RISK-025 (observability).
* Thiết kế: [SDD-001](../architecture/sdd-001-platform.md), [SDD-006](../architecture/sdd-006-reliability.md), [SDD-009](../architecture/sdd-009-media.md).
* Trang anh em: [Queues](queues.md), [Schedules](schedules.md), [AI Registry](ai-registry.md).
* Kiểm chứng: QG-008, QG-009, QG-010.
