API
API của CMS là Worker api-cms-conan-school (Hono) ở api-cms.conan.school, dữ liệu ở D1 conan-cms. Mã nằm ở api-cms.conan.school/src/.
Có ba nhóm route:
/health: kiểm sống, trả{ "ok": true }./public/*: công khai, chỉ đọc, chỉ trả bài đã xuất bản. www dùng nhóm này. Mở CORS cho mọi origin, chỉGET./admin/*: cần JWT của Cloudflare Access. CMS gọi nhóm này qua Pages Function/api/*cùng origin, nên trên trình duyệt đường dẫn làcms.conan.school/api/admin/....
:collection là một trong pages, news, announcements, playbooks. Giá trị khác trả 404 collection_not_found.
Hợp đồng dùng chung
Kiểu bài, thân request, phản hồi từng route, mã lỗi, luật slug, giới hạn phân trang, If-Match và bộ đọc bản lịch sử nằm ở packages/cms-contract/ (@conan/cms-contract). Cả api-cms lẫn cms import từ đó; cms không tự khai lại kiểu nào của API.
- api-cms giữ zod làm lược đồ kiểm lúc chạy (
src/validation.ts), lấy mọi hằng số từ hợp đồng.src/contract-assertions.tskiểm lúc biên dịch rằng kiểu của từng lược đồ BẰNG kiểu của hợp đồng: sửa một bên mà quên bên kia thìnpm run checkđỏ. - Mỗi route trả JSON qua
satisfies <Response>của hợp đồng. - Gói không có phụ thuộc nào, kể cả zod, và không có bước build: hai app import thẳng mã
.tsqua alias. Lý do: CI chỉ chạynpm citrong thư mục app, nênpackages/không cónode_modulesriêng.
Vì sao có gói này: trước đó hợp đồng được khai hai lần (zod ở api-cms, kiểu viết tay ở cms) và không gì so hai bản với nhau. Ba lỗi đã lọt ra từ đúng chỗ đó: PUT gửi thừa collection nên bị 400, thanh bên đọc key trong khi API trả slug, và lịch sử đọc trường ở tầng trên trong khi bản chụp có dạng { action, entry }.
Route công khai
| Phương thức | Đường dẫn | Làm gì |
|---|---|---|
GET | /public/:collection | Danh sách bài đã xuất bản, mới xuất bản nhất trước |
GET | /public/:collection/:slug | Một bài đã xuất bản, kèm thân bài |
Tham số:
| Tham số | Route | Mặc định | Ghi chú |
|---|---|---|---|
locale | cả hai | vi | vi hoặc en |
limit | danh sách | 20 | 1 tới 50 |
cursor | danh sách | Lấy từ next_cursor của trang trước |
Danh sách trả { collection, locale, items, next_cursor }. Mỗi item chỉ có các trường thẻ danh sách cần: slug, title, summary, cover_url, published_at. Thân bài chỉ có ở route một bài ({ collection, entry }, entry có thêm locale, body, updated_at), để danh sách không kéo theo hàng trăm KB Markdown.
Ghi chú:
- Bài nháp không có mặt trong phản hồi: điều kiện
status = 'published'nằm ngay trong câu SQL. Slug của bài nháp trả 404entry_not_found, giống hệt slug không tồn tại, nên không lộ ra là có bài đang soạn. - Phản hồi có
Cache-Control: public, max-age=60, s-maxage=300. Xuất bản, sửa hay gỡ bài có thể mất tới 5 phút mới thấy trên www.
Route quản trị
Mọi route dưới /admin/* đi qua hai middleware, theo thứ tự: accessMiddleware (danh tính, nên người chưa đăng nhập luôn nhận 401 trước mọi lỗi khác), rồi requireJsonBody.
| Phương thức | Đường dẫn | If-Match | Làm gì |
|---|---|---|---|
GET | /admin/me | Email của người đang đăng nhập | |
GET | /admin/collections | Bốn bộ sưu tập, kèm số bài nháp, đã xuất bản và tổng | |
GET | /admin/entries | Danh sách bài, gồm cả nháp, mới sửa nhất trước. Xem Phân trang | |
POST | /admin/entries | Tạo bài. Luôn là nháp. Thân: collection, slug, title, tuỳ chọn locale, summary, body, cover_url. Trả 201 | |
GET | /admin/entries/:id | Đọc một bài, kèm header ETag | |
PUT | /admin/entries/:id | bắt buộc | Lưu. Chỉ gửi trường cần đổi (slug, locale, title, summary, body, cover_url). Không đổi được collection và status qua đây |
POST | /admin/entries/:id/publish | bắt buộc | Xuất bản. Giữ published_at cũ nếu bài từng xuất bản |
POST | /admin/entries/:id/unpublish | bắt buộc | Gỡ về nháp |
DELETE | /admin/entries/:id | bắt buộc | Xoá bài, sau khi ghi một bản lịch sử delete. Trả { deleted: true, id } |
GET | /admin/entries/:id/revisions | Lịch sử bản sửa, mới nhất trước, tối đa 200 |
Mọi route trả một bài (GET, POST, PUT, publish, unpublish) trả { entry } kèm header ETag: "<updated_at>".
Mọi route ghi (tạo, lưu, xuất bản, gỡ, xoá) thêm một dòng vào cms_revisions trong cùng batch với lệnh ghi bài.
Thân yêu cầu được kiểm bằng zod ở chế độ strict: gửi một trường lạ là 400, không bị lặng lẽ bỏ qua. PUT không có trường nào cũng là 400.
Khoá lạc quan: If-Match
Hai người cùng mở một bài. Người thứ nhất lưu. Người thứ hai lưu sau, cầm bản cũ: nếu không có gì chặn, bản của người thứ hai đè lên bản của người thứ nhất mà không ai được báo. If-Match chặn đúng chuyện đó.
PUT,publish,unpublishvàDELETEbắt buộc headerIf-Matchlàupdated_atcủa bản đã nạp, dạng ETag mạnh:If-Match: "2026-10-16T08:30:00.123Z". Dạng không ngoặc kép cũng nhận.*, ETag yếu (W/...) hay danh sách nhiều ETag bị coi như thiếu.- Thiếu: 428
precondition_required. - Bài đã đổi kể từ lúc nạp: 409
conflict. - Bài không còn: 404
entry_not_found. - Lấy giá trị từ header
ETagcủa lượt đọc hay lượt ghi gần nhất (hoặc từentry.updated_at).
Điều kiện thật nằm trong câu ghi (UPDATE ... WHERE id = ? AND updated_at = ?), không chỉ ở lượt kiểm sớm, vì giữa lúc đọc và lúc ghi vẫn có thể có người ghi. Bản lịch sử đi TRƯỚC trong cùng batch với cùng điều kiện (INSERT ... SELECT ... WHERE EXISTS), nên ghi khớp 0 hàng thì không có bản lịch sử nào. updated_at mới luôn lớn hơn bản cũ, kể cả khi hai lượt ghi rơi vào cùng một mili giây.
Thân request phải là JSON
Lượt ghi có thân vào /admin/* phải mang Content-Type: application/json (cho phép ; charset=..., không phân biệt hoa thường), nếu không trả 415 unsupported_media_type. Lượt ghi không thân (publish, unpublish, DELETE) đi qua.
Đây là một lớp chống CSRF: form HTML liên miền chỉ gửi được text/plain, form-urlencoded hay multipart, còn application/json liên miền thì trình duyệt buộc preflight CORS, mà /admin/* không mở CORS. Lớp chính là gác cùng origin ở Pages Function của cms, xem Kiến trúc.
Phân trang danh sách quản trị
GET /admin/entries nhận:
| Tham số | Mặc định | Ghi chú |
|---|---|---|
collection | Bộ sưu tập lạ trả 404 collection_not_found | |
status | draft hoặc published | |
q | Tìm trong tiêu đề và slug, tối đa 200 ký tự | |
limit | 100 | 1 tới 200 |
cursor | Lấy từ next_cursor của trang trước |
Trả { entries, next_cursor }. next_cursor khác null nghĩa là còn mục chưa trả: gửi lại làm cursor để lấy trang sau. Con trỏ mã hoá cặp (updated_at, id), nên trang sau bắt đầu đúng chỗ trang trước dừng kể cả khi nhiều bài cùng thời điểm. Con trỏ hỏng trả 400 invalid_cursor, không bị coi là "trang đầu". Mỗi mục trong entries có đủ trường trừ body.
Danh sách công khai dùng cùng cơ chế, với khoá là published_at.
Bản lịch sử
GET /admin/entries/:id/revisions trả { revisions }, mỗi phần tử:
{
"id": 42,
"entry_id": "3f0c...",
"author": "nguoi.soan@conan.school",
"created_at": "2026-10-16T08:30:00.123Z",
"snapshot": { "action": "update", "entry": { "id": "3f0c...", "title": "...", "...": "..." } }
}snapshotlà{ action, entry }:actionlà một trongcreate,update,publish,unpublish,delete;entrylà toàn bộ bài sau hành động đó (vớideletelà bản cuối cùng trước khi xoá).- Kiểu của
snapshottrong hợp đồng làunknown, có chủ ý: đó là JSON lưu trong D1 từ lúc ghi, không theo lược đồ hiện tại. Nơi đọc phải đi quareadRevisionSnapshotcủa hợp đồng. Hàm này không bao giờ ném lỗi; bản hỏng trả{ ok: false, error }để giao diện hiện dạng thô thay vì vỡ cả danh sách. - Cột hỏng trong D1 không làm hỏng cả phản hồi: API trả nguyên chuỗi và ghi log cảnh báo.
- Bài đã xoá vẫn đọc được lịch sử theo
idcũ.
Luật dữ liệu
| Trường | Luật |
|---|---|
slug | 1 tới 120 ký tự, chỉ a-z, 0-9, gạch nối; không mở đầu, kết thúc hay lặp gạch nối |
title | 1 tới 300 ký tự, cắt khoảng trắng hai đầu |
summary | Tối đa 2.000 ký tự, cho phép null |
body | Tối đa 500.000 ký tự, cho phép null |
cover_url | URL http hoặc https, tối đa 2.000 ký tự, cho phép null |
Các giới hạn này là hằng số trong packages/cms-contract/, nên trình soạn của cms kiểm đúng cùng một luật trước khi gửi.
Lỗi
Mọi lỗi có cùng một dạng, trả qua apiError ở src/http.ts:
{ "error": { "code": "duplicate_slug", "message": "...", "details": [] } }details chỉ có khi có chi tiết (ví dụ danh sách ô sai của validation_error).
| Mã HTTP | code | Khi nào |
|---|---|---|
400 | invalid_json, validation_error, invalid_cursor, bad_request | Dữ liệu gửi lên sai |
401 | unauthorized | Thiếu header Cf-Access-Jwt-Assertion, hoặc JWT không hợp lệ |
404 | not_found, collection_not_found, entry_not_found | Không có route, bộ sưu tập hay bài |
409 | duplicate_slug | Slug đã có trong cùng bộ sưu tập và ngôn ngữ |
409 | conflict | If-Match lệch: có người ghi trước |
415 | unsupported_media_type | Lượt ghi có thân mà không phải application/json |
428 | precondition_required | Thiếu If-Match ở PUT, publish, unpublish, DELETE |
503 | access_not_configured | Chưa đặt CF_ACCESS_TEAM_DOMAIN hoặc CF_ACCESS_AUD. Đóng hết /admin/* |
500 | internal_error | Lỗi chưa lường trước, có ghi log |
Pages Function của cms tự trả hai mã, trước khi request tới được api-cms (cùng dạng lỗi):
| Mã HTTP | code | Khi nào |
|---|---|---|
403 | cross_site_request | Lượt ghi không đến từ chính cms.conan.school |
502 | upstream_unreachable | Không gọi được api-cms |
Danh sách mã đầy đủ là API_ERROR_CODES và PROXY_ERROR_CODES trong hợp đồng.
Gọi thử
Route công khai gọi thẳng được:
curl https://api-cms.conan.school/health
curl https://api-cms.conan.school/public/news
curl "https://api-cms.conan.school/public/news/khai-giang-thang-11?locale=vi"Route quản trị cần JWT của Access, nên gọi qua CMS sau khi đăng nhập. Đừng tắt kiểm JWT để gọi thử cho tiện.
Đổi route trong mã thì sửa trang này trong cùng PR.