Kiến trúc
Hai repo
Nền tảng Conan đã tách làm hai repo (16.10.2026). Trên máy, cả hai nằm cạnh nhau trong một folder tổng:
~/ConanCode/conan-school/
conan-web/ repo số 1: www, CMS, API của CMS, tài liệu CMS
conanplatform/ repo số 2: tokyo, docs, admin, api.conan.school và các site con khácHai repo nối với nhau chỉ bằng HTTP API: không import mã chéo repo, không đọc D1 của nhau. Ngoại lệ duy nhất là vài gác ở máy đọc thư mục anh em ../conanplatform để so sánh, xem Gác.
conan-web (repo này)
| Thư mục | Là gì | Chạy ở đâu |
|---|---|---|
www.conan.school/ | Site công khai. React Router 7 trên Vite, SSR | Tới ngày cắt: Pages ww3-conan-school, deploy từ conanplatform. Sau đó: Pages Git mới của repo này |
cms.conan.school/ | Giao diện CMS. Vite + React + shadcn/ui + Tailwind v4 + motion | Cloudflare Pages cms-conan-school |
api-cms.conan.school/ | API của CMS. Hono trên Workers, D1 conan-cms | Worker api-cms-conan-school |
cms-docs.conan.school/ | Trang tài liệu này. VitePress | Cloudflare Pages cms-docs-conan-school |
packages/cms-contract/ | Hợp đồng API giữa api-cms và cms | Mã .ts thuần, hai app import qua alias |
packages/design-system/ | Token thiết kế (theme.css), bản chép từ conanplatform | Mọi site Tailwind @import nó |
Cả bốn app do Cloudflare tự build từ GitHub, xem Deploy và cấu hình.
conanplatform (repo số 2)
Giữ tokyo.conan.school (cổng thành viên), docs.conan.school, api.conan.school, admin.conan.school và các site con còn lại.
www vẫn đọc khoá học, sách, cộng đồng, sự kiện, JTBD từ api.conan.school/api/public/* qua HTTP. Phần đó chưa chuyển và chưa có kế hoạch chuyển trong đợt này.
Luồng request
Người soạn dùng CMS
Trình duyệt
-> cms.conan.school Cloudflare Access chặn ở cửa: chưa đăng nhập thì không vào
-> /api/* Pages Function gác cùng origin cho lượt ghi (403 nếu không qua),
rồi chuyển tiếp kèm JWT của Access
-> api-cms.conan.school /admin/* kiểm JWT (401), kiểm JSON (415), kiểm If-Match (428, 409)
-> D1 conan-cmsVì sao có proxy cùng origin: trình duyệt gọi cms.conan.school/api/..., không gọi thẳng sang api-cms. Nhờ vậy không phải mở CORS, và JWT của Access đi theo request một cách tự nhiên. Pages Function cms.conan.school/functions/api/[[path]].ts:
- Chỉ chuyển các header api-cms cần:
content-type,accept,cf-access-jwt-assertion,if-match. Không chuyển cookie. - Header
Cf-Access-Jwt-Assertionvắng mà cookieCF_Authorizationcó thì lấy JWT từ cookie đó (cùng một JWT). - Đặt
cache-control: no-storevà bỏset-cookieở phản hồi trả về. - Không gọi được api-cms thì trả 502
upstream_unreachable.
Function không tự kiểm quyền. Quyền nằm ở API, không nằm ở giao diện hay ở proxy.
Chống CSRF
cms.conan.school nằm sau Access, và cookie phiên Access đi theo mọi request tới tên miền này, kể cả request do một trang khác kích hoạt (form tự gửi, fetch no-cors). Function lại chuyển cookie đó thành JWT cho api-cms. Không chặn ở đây thì một trang lạ có thể ghi nội dung dưới danh tính của biên tập viên đang đăng nhập.
Hai lớp:
- Gác cùng origin ở Pages Function (
functions/_lib/same-origin.ts), lớp chính. Mọi lượt ghi (method khácGET,HEAD) chỉ đi qua khiSec-Fetch-Site: same-origin; trình duyệt cũ không gửi header đó thìOriginphải bằng origin của chính request. Không có bằng chứng nào thì chặn: thiếu header không phải là "an toàn". Bị chặn trả 403cross_site_request, và gác chạy TRƯỚC khi Function đụng tới cookie phiên. - 415 ở api-cms cho lượt ghi có thân không phải JSON. Form HTML liên miền không gửi được
application/jsonmà không qua preflight CORS, và/admin/*không mở CORS.
Người đọc mở www
Trình duyệt
-> www.conan.school Worker cầu nối conan-www-stale-asset-bridge
-> Pages project của www (SSR)
-> api-cms.conan.school /public/:collection[/:slug] trang, tin tức, thông báo, playbook
-> api.conan.school/api/public/* khoá học, sách, cộng đồng, sự kiện, JTBDRoute /public/* chỉ trả bài đã xuất bản, lọc ngay trong câu SQL. Bài nháp không bao giờ có mặt trong phản hồi công khai, không phải bị ẩn ở giao diện.
Lưu ý: tới hết giai đoạn 4 của kế hoạch chuyển đổi, www vẫn đọc tin tức, thông báo, playbook từ api.conan.school. Nhánh api-cms ở trên là đích đến.
Hợp đồng giữa api-cms và cms
packages/cms-contract/ (@conan/cms-contract) là nơi duy nhất khai kiểu bài, thân request, phản hồi, mã lỗi, luật slug, giới hạn, If-Match và bộ đọc bản lịch sử.
- api-cms kiểm lúc biên dịch rằng lược đồ zod của nó bằng kiểu của hợp đồng (
src/contract-assertions.ts). - cms dựng thân PUT và POST bằng
fieldsToUpdateInputvàfieldsToCreateInputcủa hợp đồng, và test của api-cms (test/contract.test.ts) chạy đúng hai hàm đó qua lược đồ zod của API. - Gói không phụ thuộc gói npm nào và không có bước build. Alias khai ở
tsconfig(paths), ởvite.config.tscủa cms và ởvitest.config.tscủa api-cms.
Xem API cho lý do ra đời của gói.
Mô hình dữ liệu
Hai bảng, khai ở api-cms.conan.school/migrations/0001_init.sql.
cms_entries: mỗi dòng là một bài.
| Cột | Ý nghĩa |
|---|---|
id | UUID, sinh lúc tạo |
collection | pages, news, announcements hoặc playbooks |
slug | Phần cuối URL. UNIQUE (collection, slug, locale) |
locale | vi (mặc định) hoặc en |
title, summary, body | Tiêu đề, tóm tắt, thân bài Markdown |
cover_url | Địa chỉ ảnh bìa, phải là http hoặc https |
status | draft hoặc published (có CHECK) |
published_at | Lần xuất bản đầu tiên. Gỡ rồi xuất bản lại thì giữ ngày cũ |
created_at, updated_by | Thời điểm tạo, email người lưu gần nhất |
updated_at | Thời điểm lưu gần nhất, đồng thời là phiên bản của bài: giá trị của ETag và If-Match. Mỗi lượt ghi làm nó tăng ngặt, kể cả khi hai lượt rơi vào cùng một mili giây |
cms_revisions: mỗi lần ghi một bài (tạo, lưu, xuất bản, gỡ, xoá) thêm một dòng, kèm author và created_at. Cột snapshot là JSON dạng { action, entry }: hành động, và toàn bộ bài sau hành động đó (với delete là bản cuối cùng trước khi xoá). Bài và bản lịch sử được ghi trong cùng một batch mang cùng điều kiện phiên bản, nên không có lượt lưu nào thiếu lịch sử, và không có bản lịch sử nào cho một lượt ghi bị từ chối.
Bảng này không có khoá ngoại tới cms_entries, có chủ ý: xoá bài vẫn giữ lại lịch sử, và bản chụp cuối cùng (hành động delete) đủ để dựng lại bài.
Danh sách bộ sưu tập là hằng số trong packages/cms-contract/src/collections.ts. Thêm bộ sưu tập là thêm một dòng ở đó, không cần migration.
Đăng nhập và quyền
- CMS không có bảng người dùng hay mật khẩu. Danh tính là email trong JWT của Cloudflare Access.
- API tự kiểm JWT (
src/middleware/access.ts, thư việnjose): chữ ký RS256 theo bộ khoá của team (https://<team>/cdn-cgi/access/certs),iss,aud, hạn dùng, và claimemail. Cấu hình bằng hai biếnCF_ACCESS_TEAM_DOMAINvàCF_ACCESS_AUDtrong[vars]củawrangler.toml. - Email trong JWT thành
updated_bycủa bài vàauthorcủa bản lịch sử. - Thiếu một trong hai biến, mọi route
/admin/*trả 503access_not_configured. Đây là cố ý: đóng khi chưa chắc, thay vì mở cho tất cả. Một API admin quên cấu hình mà vẫn trả 200 là loại hỏng nguy hiểm nhất, vì nhìn từ ngoài nó trông như đang chạy đúng.
Test
- api-cms: vitest chạy trong workerd (
@cloudflare/vitest-plugin) trên D1 cục bộ thật, ápmigrations/trước mỗi file. Test tự ký JWT bằng cặp khoá RSA sinh tại chỗ.test/routes.test.tsđi trọn vòng đời một bài: tạo, sửa có If-Match, 409 khi cầm bản cũ, xuất bản, www thấy, gỡ, www 404, lịch sử, xoá giữ lịch sử; cùng các gác 401, 503, 415, trùng slug và phân trang. - cms: vitest (Node) cho thân request gửi đi, cách đọc bản lịch sử, và gác cùng origin của Pages Function.
Cả hai chạy trong lệnh build của Cloudflare, nên test đỏ là không deploy.