Skip to content

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:

text
~/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ác

Hai 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ụcLà gìChạy ở đâu
www.conan.school/Site công khai. React Router 7 trên Vite, SSRTớ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 + motionCloudflare Pages cms-conan-school
api-cms.conan.school/API của CMS. Hono trên Workers, D1 conan-cmsWorker api-cms-conan-school
cms-docs.conan.school/Trang tài liệu này. VitePressCloudflare Pages cms-docs-conan-school
packages/cms-contract/Hợp đồng API giữa api-cms và cmsMã .ts thuần, hai app import qua alias
packages/design-system/Token thiết kế (theme.css), bản chép từ conanplatformMọ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 ​

text
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-cms

Vì 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-Assertion vắng mà cookie CF_Authorization có thì lấy JWT từ cookie đó (cùng một JWT).
  • Đặt cache-control: no-store và 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:

  1. Gác cùng origin ở Pages Function (functions/_lib/same-origin.ts), lớp chính. Mọi lượt ghi (method khác GET, HEAD) chỉ đi qua khi Sec-Fetch-Site: same-origin; trình duyệt cũ không gửi header đó thì Origin phả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ả 403 cross_site_request, và gác chạy TRƯỚC khi Function đụng tới cookie phiên.
  2. 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/json mà không qua preflight CORS, và /admin/* không mở CORS.

Người đọc mở www ​

text
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, JTBD

Route /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 fieldsToUpdateInput và fieldsToCreateInput củ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.ts của cms và ở vitest.config.ts củ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
idUUID, sinh lúc tạo
collectionpages, news, announcements hoặc playbooks
slugPhần cuối URL. UNIQUE (collection, slug, locale)
localevi (mặc định) hoặc en
title, summary, bodyTiêu đề, tóm tắt, thân bài Markdown
cover_urlĐịa chỉ ảnh bìa, phải là http hoặc https
statusdraft hoặc published (có CHECK)
published_atLầ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_byThời điểm tạo, email người lưu gần nhất
updated_atThờ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ện jose): chữ ký RS256 theo bộ khoá của team (https://<team>/cdn-cgi/access/certs), iss, aud, hạn dùng, và claim email. Cấu hình bằng hai biến CF_ACCESS_TEAM_DOMAIN và CF_ACCESS_AUD trong [vars] của wrangler.toml.
  • Email trong JWT thành updated_by của bài và author của bản lịch sử.
  • Thiếu một trong hai biến, mọi route /admin/* trả 503 access_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, áp migrations/ 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.

Tài liệu nội bộ của Conan CMS.