Phần chung cho mọi FE (CMS / admin và storefront) #
Đọc trước cms.md hoặc storefront.md: khởi tạo
client, envelope + lỗi, phiên bản API, danh sách, ngôn ngữ. Mục lục và nơi tra
hợp đồng: README.md.
1. Khởi tạo #
1.1. Server phải mở CORS cho domain của FE #
FE (CMS, storefront web) chạy khác domain với API thì trình duyệt cần CORS.
Token đi bằng header Authorization (không cookie) nên không cần
credentials: "include". Server nhận origin theo MỘT trong hai đường:
1. Danh sách tĩnh CORS_ORIGINS (biến env của server, đổi phải khởi động
lại). Origin CHÍNH XÁC, cách nhau bằng dấu phẩy — không *, không path, không
/ cuối:
CORS_ORIGINS=https://admin.example.com,https://shop.example.com,http://localhost:5173
Sai dạng → server không khởi động (báo lỗi CORS_ORIGINS). Áp cho /api/v1/*
(cả storefront) và /openapi.json.
2. Tên miền riêng của khách (nhà cung cấp đã gán cho khách, ADR-0029): không
cần khai vào CORS_ORIGINS, /api/v1/* tự nhận https://<tên miền>, không
phải khởi động lại. Điều kiện:
- tên miền đã được gán VÀ đã xác minh (bản ghi DNS) — tên miền đang chờ xác minh chưa được nhận;
- chỉ có ở bản cài có danh bạ tên miền (bản nhà cung cấp vận hành). Server chạy
riêng / cài tại chỗ của một khách không có danh bạ → dùng
CORS_ORIGINS; - production chỉ khớp
https://<tên miền>không cổng (http://hayhttps://<tên miền>:8443phải khai ởCORS_ORIGINS); ngoài production nhận cảhttp://và cổng (vdhttp://shop.localhost:5173); - vừa gán / xác minh xong: các tiến trình server thấy sau tối đa vài giây (danh bạ được cache);
/openapi.jsonKHÔNG theo đường này — chỉCORS_ORIGINS.
Ở tên miền đó, passkey dùng RP ID = chính tên miền, và link trong thư quên mật
khẩu / xác minh email trỏ về tên miền đó — FE đặt trang /reset-password,
/verify-email cùng path với mẫu của server.
Database dùng chung (nhiều khách một database): link trong thư chỉ trỏ về tên miền ghim vào một store của chính khách người nhận; tên miền không ghim store, hoặc của khách khác → link theo mẫu mặc định của server.
Tên miền ghim store là store mặc định (không gửi x-store = store đó). Tên
miền được đặt khoá store thì chỉ phục vụ store đó: gửi x-store khác → 404
store_not_found — storefront một chi nhánh một tên miền dùng khoá; sàn / chuỗi
cho chọn chi nhánh trên cùng tên miền thì không khoá. Khoá chỉ là ràng buộc theo
tên miền, không thay cho phân quyền: dữ liệu vẫn chỉ theo token / quyền.
Gỡ lỗi CORS (trình duyệt báo lỗi CORS, request không tới được code của FE):
| Triệu chứng / tình huống | Nguyên nhân thường gặp → cách sửa |
|---|---|
Lỗi ở mọi request, CORS_ORIGINS đã có origin | Lệch từng ký tự: http / https, thiếu cổng, www., / cuối → chép đúng giá trị Origin trong DevTools |
| Chạy trên tên miền riêng mà vẫn lỗi | Tên miền chưa xác minh, đang chạy bản không có danh bạ, hoặc dùng http:// / có cổng ở production → khai vào CORS_ORIGINS |
Vừa sửa CORS_ORIGINS mà chưa ăn | Server chưa khởi động lại |
/api/v1/* được nhưng /openapi.json lỗi | Origin chỉ qua đường tên miền riêng → thêm vào CORS_ORIGINS |
| Lỗi CORS kèm status 5xx / không có response | Không phải CORS: server lỗi hoặc không tới được (proxy, DNS) — xem tab Network |
| Gọi từ server (BFF, SSR) mà vẫn nghĩ là lỗi CORS | Server gọi server không có CORS — xem lỗi thật trong response |
1.2. Tạo client với phiên tự lo #
import { type SessionStore, type Tokens, XforgeClient } from "./sdk/api.ts";
// localStorage: DÙNG CHUNG giữa các tab — runtime dựa vào đó khi hai tab cùng
// refresh (xem cms.md §2.2). Muốn an toàn hơn trước XSS thì giữ trong bộ nhớ + BroadcastChannel.
const KEY = "xforge.session";
const session: SessionStore = {
get: () => JSON.parse(localStorage.getItem(KEY) ?? "null") as Tokens | null,
set: (t) =>
t
? localStorage.setItem(KEY, JSON.stringify(t))
: localStorage.removeItem(KEY),
};
let database = "acme"; // x-database (bỏ được nếu tên miền đã gán database)
let store: string | undefined; // x-store (mã store đang chọn)
let lang: string | undefined; // x-lang (ngôn ngữ nội dung §3 + thông báo lỗi §1.3)
export const api = new XforgeClient({
baseUrl: "https://api.example.com",
session,
headers: () => ({
"x-database": database,
...(store ? { "x-store": store } : {}),
...(lang ? { "x-lang": lang } : {}),
}),
onSessionExpired: () => router.push("/login"),
// "Ghi nhớ thiết bị" (cms.md §1.10): SDK tự gửi / lưu token thiết bị. BỀN qua lần mở
// trình duyệt (localStorage) — đó là ý nghĩa của "ghi nhớ".
deviceTokens: {
get: (k) => localStorage.getItem(`xforge.device.${k}`),
set: (k, t) =>
t
? localStorage.setItem(`xforge.device.${k}`, t)
: localStorage.removeItem(`xforge.device.${k}`),
},
});
Có session, client tự xử lý — FE KHÔNG tự viết interceptor:
| Server trả | SDK làm |
|---|---|
401 invalid_token (access hết hạn) | refresh MỘT lần cho mọi request đang hỏng, lưu token, chạy lại |
refresh bị 401 refresh_token_superseded | đọc lại session (tab khác vừa refresh) rồi chạy lại |
401 token_store_mismatch | gọi switchStore theo x-store hiện tại, chạy lại |
refresh thất bại / token_revoked | session.set(null) + onSessionExpired(reason) |
Body là ReadableStream không chạy lại được → nhận lỗi gốc (dùng Blob /
ArrayBuffer cho upload).
1.2.1. Gửi lại an toàn: Idempotency-Key #
Bật idempotency: true khi tạo client (nên bật):
export const api = new XforgeClient({ /* …như trên… */ idempotency: true });
- Mọi lời gọi GHI (POST / PUT / PATCH / DELETE, body JSON, ngoài
/api/v1/auth/*) mang một khoá mới (UUID). Lần SDK tự chạy lại sau refresh / switch-store dùng lại ĐÚNG khoá đó → server không làm hai lần. - Tự thử lại sau lỗi mạng / timeout (vd nút "Thử lại" của form tạo đơn): sinh khoá MỘT lần khi mở form, truyền ở tham số thứ hai cho mọi lần gửi:
const key = crypto.randomUUID(); // một khoá cho MỘT thao tác của người dùng
await api.createBrand({ body }, { idempotencyKey: key }); // gửi lại: cùng key
| Server trả | Nghĩa / FE nên |
|---|---|
2xx/4xx + header Idempotent-Replayed: true | kết quả của lần trước (thao tác KHÔNG chạy lại) — xử lý như bình thường |
409 idempotency_in_progress (+ retry-after) | lần trước còn đang chạy — chờ retry-after giây rồi gửi lại cùng khoá |
422 idempotency_key_reused | khoá đã dùng cho request KHÁC — lỗi lập trình: mỗi thao tác một khoá |
409 idempotency_response_withheld | thao tác ĐÃ chạy xong nhưng response chứa bí mật dùng một lần (mã, khoá) nên không phát lại — đừng gửi lại; đọc lại tài nguyên bằng route đọc |
400 invalid_idempotency_key | khoá 1–255 ký tự ASCII in được |
400 idempotency_unsupported | upload file (multipart / nhị phân) không nhận khoá — đừng gửi |
Server nhớ kết quả 24 giờ. Lỗi 5xx và 401 / 403 / 409 / 429 KHÔNG được nhớ: gửi
lại cùng khoá là chạy thật lần nữa (thao tác lần trước chưa xảy ra) — trừ 409
idempotency_response_withheld (thao tác đã chạy; lỗi này lặp lại tới hết 24
giờ). Header gửi tới /api/v1/auth/* bị bỏ qua. Không dùng SDK: tự gửi header
Idempotency-Key (server đã mở CORS cho nó và expose idempotent-replayed).
1.3. Envelope và lỗi #
thành công: { success: true, data, meta? } ← SDK trả nguyên envelope
lỗi: { success: false, error: { code, message, details?, at? } } ← SDK ném ApiError
Một chuẩn cho mọi response (ADR-0052, có guardrail — không route nào lệch):
- Tên field camelCase.
- Field LUÔN có mặt: không có giá trị là
null, không bao giờ vắng khoá. - Mốc thời gian: chuỗi ISO 8601 UTC dạng
2026-10-23T08:15:30.123Z(new Date(x)đọc được). Khoảng thời gian: số giây (expiresIn). - Tiền: chuỗi thập phân (
"125000.0000") kèmcurrency— §tiền bên dưới. - Tham chiếu
<x>Idluôn đi kèm<x>Info(object hiển thị,nullkhi không trỏ / không thấy):createdBy+createdByInfo: { id, login, name },ownerId+ownerInfo,logoFileId+logoFileInfo,intentId+intentInfo… — hiện tên / nhãn mà không gọi thêm request. Ngoại lệ: id của ngữ cảnh hiện tại (storeId,accountId), của chính bạn (userId), mã của hệ thống ngoài (externalId,orderId). - Danh sách:
data: [...]LUÔN kèmmeta: { nextCursor }(null= hết trang hoặc danh sách không phân trang) — một hàm đọc mọi danh sách. - Bản ghi có field dịch được: §3.3.
import { ApiError } from "./sdk/api.ts";
try {
await api.createUser({ body });
} catch (e) {
if (e instanceof ApiError && e.code === "validation_failed") {
// details: FieldIssue[] — gắn lỗi vào đúng ô, dịch theo `rule`:
// [{ path: "login", rule: "required", message: "…" },
// { path: "tags", rule: "max_items", requirement: 5, message: "…" }]
}
}
Lỗi theo ô (FieldIssue, VALIDATION_RULES trong SDK) mang rule máy đọc
được + requirement (ngưỡng) / expected (kiểu hoặc lựa chọn), và message ĐÃ
DỊCH theo ngôn ngữ của request (riêng check — luật riêng của schema — và
invalid giữ câu tiếng Anh). Độ dài mảng có rule riêng (min_items /
max_items — "chọn tối đa 5 mục", không phải "5 ký tự").
Thao tác nhiều dòng (phiếu nhập kho, điều chỉnh, sau này đơn hàng…): lỗi
nghiệp vụ của MỘT dòng (hết hàng, sai lô…) mang error.at — đường dẫn tới phần
tử của body, vd lines.3 (ADR-0071; ApiError.at trong SDK). Tô dòng đó (chỉ
số theo body BẠN gửi, kể cả khi server xử lý theo thứ tự khác); lỗi theo ô của
validation_failed thì vẫn ở details[].path (lines.3.qty). at có thể vắng
(vd lỗi trùng khoá duplicate_value dịch từ ràng buộc DB) — khi đó hiện câu
chung.
Body gửi field mà server không biết (gõ sai tên, field đã bỏ) → 400
validation_failed với rule: "unknown_key" ở đúng path — không còn chuyện
"200 mà không có gì xảy ra" (ADR-0022). Mọi route tenant nằm dưới /api/v1 (SDK
đã gắn sẵn).
Server dịch message (ADR-0038) theo x-lang → Accept-Language → mặc
định en (ngôn ngữ chưa có catalog cũng ra en; response lỗi mang
Content-Language). Hiện có: en, vi. FE cứ hiện error.message — kể cả mã
lỗi mới mà bản FE đã phát hành chưa biết. Rẽ nhánh xử lý thì theo code (ổn
định) + details (tham số), KHÔNG so message (đổi theo ngôn ngữ / phiên bản);
muốn câu khác cho đúng ngữ cảnh màn hình thì tự viết câu theo code, phần còn
lại dùng message. Mã hay gặp:
code | Status | FE nên |
|---|---|---|
validation_failed | 400 | details[] (FieldIssue) → lỗi theo ô, dịch theo rule |
modules_not_installed | 400 | bật module cho store mà database chưa cài: details.modules |
invalid_credentials | 401 | "Sai tên đăng nhập hoặc mật khẩu" (không nói cái nào sai) |
account_locked / too_many_attempts | 429 | khoá nút, đếm ngược details.retryAfter giây (header retry-after) |
forbidden | 403 | thiếu quyền: details.missing = mã quyền thiếu |
quota_exceeded / feature_not_in_plan | 403 | giới hạn / tính năng của gói (details.quota, limit / feature). quota_exceeded có details.scope: store = gói của cửa hàng (gợi ý nâng gói), account = hạn mức của khách hàng (liên hệ nhà cung cấp). Dung lượng file: GET /api/v1/files/usage |
subscription_expired | 403 | store hết hạn thuê bao (đang ân hạn: header Subscription-Grace-Until); /auth/* và /plan, /plan/* vẫn gọi được — hiện gói + hoá đơn để khách tự trả |
customer_auth_required | 401 | storefront: cần đăng nhập tài khoản khách |
version_conflict | 409 | người khác đã sửa trước: tải lại (details.currentVersion), hỏi người dùng |
outside_store_scope / account_scope_required | 403 | admin cấp store vượt phạm vi store mình / thao tác cần admin cấp account |
condition_not_met | 403 | có quyền nhưng điều kiện không thoả với bản ghi này (ADR-0021) |
record_rule_violation | 403 | bản ghi mới nằm ngoài phạm vi dữ liệu của user |
field_write_forbidden | 403 | không được ghi details.fields — bỏ các field đó khỏi form |
store_required | 400 | bộ chọn store từ details.stores (cms §1.1) |
password_change_required | 403 | user phải đổi mật khẩu trước (ADR-0100): mở màn đổi mật khẩu (cms §1.8) |
mfa_enrollment_required | 403 | account bắt buộc MFA mà user chưa có yếu tố (ADR-0101): mở màn đăng ký TOTP / passkey (cms §1.8) |
last_factor | 409 | account bắt buộc MFA: thêm yếu tố khác trước khi gỡ yếu tố cuối |
account_disabled / store_inactive | 403 | thông báo, về màn đăng nhập |
module_not_installed / module_not_enabled | 404 | gọi lại listMenu() (cms §3); module ngoài gói của cửa hàng cũng trả mã này — GET /api/v1/plan modules cho biết gói gồm module nào (gợi ý nâng gói) |
not_found, *_not_found | 404 | trang 404 / đóng form |
duplicate_value | 409 | trùng giá trị: details.resource (tên trong ERROR_RESOURCES) + details.fields (field camelCase của khoá) → báo ở đúng ô |
resource_in_use | 409 | xoá / sửa bản ghi còn được dùng: details.resource + details.referencedBy (null = không rõ) |
conflict, *_exists, login_taken | 409 | trùng giá trị — báo ở ô tương ứng (conflict trơn: bảng chưa khai tài nguyên) |
rate_limited | 429 | gọi quá dày: chờ details.retryAfter giây rồi thử lại, đừng lặp ngay |
database_maintenance | 503 | database đang chuyển server (vài chục giây): báo "đang bảo trì", tự thử lại sau details.retryAfter giây (header retry-after) — KHÔNG coi như database_not_found |
email_taken | 409 | email đã có user khác dùng (không phân biệt hoa thường) |
reset_token_invalid / verify_token_invalid | 401 | link sai / đã dùng / hết hạn → mời gửi lại link (cms §1.5, cms §1.6) |
Danh sách đầy đủ (status + thông điệp mặc định): ERROR_CODES.
Kiểu của details (ADR-0058): mã có tham số có kiểu trong ErrorParams
(cùng file SDK). Thu hẹp bằng isApiError:
import { isApiError } from "./sdk/api.ts";
try {
await api.createBrand({ body });
} catch (e) {
if (isApiError(e, "duplicate_value")) {
// e.details: { resource: string; fields: string[] }
for (const f of e.details.fields) form.setError(f, e.message);
} else if (isApiError(e, "quota_exceeded")) {
// e.details: { quota: string; limit: number; scope: "store" | "account" }
} else throw e;
}
Server chỉ thêm tham số theo hướng không phá (khoá mới, có thể bắt buộc); bỏ /
đổi kiểu tham số bị guard openapi:check chặn như đổi response.
Mỗi response có header x-request-id — hiện nó trong thông báo lỗi 5xx (vd "Mã
lỗi: …") để đội vận hành tra đúng dòng log.
1.4. Phiên bản API và route ngừng dùng (ADR-0031) #
- Mọi response dưới
/api/v1/*mangApi-Version: v1.GET /healthtrả{ status, apiVersions: ["v1"], build }(build= mã bản dựng server, có thểnull) — dùng để biết server phục vụ phiên bản nào trước khi gọi. - Hợp đồng
v1không bị phá: field / route mới chỉ được THÊM. Code FE phải bỏ qua field response lạ. - Route sắp gỡ: method SDK có
@deprecated(IDE gạch ngang), response mangDeprecation(RFC 9745),Sunset(ngày sớm nhất server được gỡ, RFC 8594) vàLink: <…>; rel="successor-version"khi có route thay thế. Chuyển sang route mới TRƯỚC ngàySunset; app di động nên log / báo khi gặp headerDeprecation.
2. Danh sách (phân trang theo con trỏ) #
listUsers({ query: { limit: "50" } }) → data[], meta.nextCursor
listUsers({ query: { limit: "50", cursor: meta.nextCursor } }) → trang sau
meta.nextCursor === null → hết
Một khuôn cho mọi danh sách (listBrands, listFiles, listAuditLog…). Con
trỏ là chuỗi mờ — chỉ gửi lại, không tự dựng. Tham số query là chuỗi
(limit: "50").
Danh sách dùng khung chung (vd listBrands) có thêm — xem kiểu query của từng
method trong SDK để biết field nào được phép:
sort: "name" | "-name" sắp tăng / giảm (chỉ các giá trị SDK liệt kê)
q: "nguyen" tìm gần đúng, không phân biệt hoa thường
owner_id: "<uuid>" lọc bằng
"owner_id.in": "<id1>,<id2>" thuộc danh sách
"created_at.gte": "2026-10-01T00:00:00+07:00", "created_at.lt": "…"
- Đổi
sort/ bộ lọc → bỏcursor(con trỏ của kiểu sắp khác bị 400invalid_cursor). - Tham số lạ / sai kiểu → 400
validation_failed(không âm thầm trả hết). - Khoảng "hôm nay" phải theo múi giờ của STORE (
timezonetronglistMyStores), không theo máy người dùng.
Tiền luôn là chuỗi thập phân ("125000.0000"), kèm currency (ISO 4217).
Gửi lên cũng là chuỗi ("9.99"); đừng parseFloat để tính — định dạng hiển thị
bằng Intl.NumberFormat(locale, { style: "currency", currency }) từ chuỗi. Tiền
tệ mặc định của store: currency trong listMyStores.
3. Ngôn ngữ nội dung (ADR-0020) #
Hai chuyện KHÁC nhau:
- Chữ của giao diện + thông báo lỗi: chữ giao diện FE tự dịch (i18n của FE);
messagecủa lỗi server đã dịch theo ngôn ngữ request (§1.3). - Nội dung do người dùng nhập nhiều thứ tiếng (tên, nhãn…): lưu ở server dạng "trường dịch được", server chọn bản dịch theo ngôn ngữ của request.
3.1. Ngôn ngữ của request #
x-lang: en ← FE chọn tường minh (nút đổi ngôn ngữ)
Accept-Language: vi-VN,vi;q=0.9,en;q=0.8 ← trình duyệt tự gửi, dùng khi thiếu x-lang
Server thử x-lang rồi từng mã của Accept-Language (q giảm dần), lấy mã đầu
tiên khớp ngôn ngữ của store (vi-VN khớp vi, pt khớp pt-BR); không
mã nào khớp → ngôn ngữ mặc định của store → en. x-lang sai / không hỗ
trợ KHÔNG gây lỗi — chỉ bị bỏ qua. Ngôn ngữ đã chọn: getMe().data.lang, và
header Content-Language trên response có nội dung đã dịch (những response đó
kèm Vary: x-lang, Accept-Language).
3.2. Nút đổi ngôn ngữ #
listMyStores() → [{ code, name, defaultLang, languages }] (màn chọn store)
getMe() → { lang, defaultLang, languages, … } (store đang chọn)
nút đổi ngôn ngữ = languages; đang chọn = lang
chọn "en" → đặt x-lang = "en" (headers() của client) → tải lại dữ liệu đang xem
đổi store → languages có thể khác: getMe() lại; lang cũ không còn hỗ trợ thì server tự về defaultLang
Storefront (không đăng nhập) lấy cùng thông tin từ getStorefrontStore()
(storefront §1) — đừng ghi cứng danh sách ngôn ngữ.
languages / defaultLang là null khi superuser đứng ở cấp database (không
store): mọi mã hợp lệ đều nhận. Superuser đổi ngôn ngữ của store:
createStore({ body: { …, defaultLang, languages } }),
updateStore({ params: { code }, body: { defaultLang?, languages? } })
(defaultLang phải nằm trong languages — 400
default_lang_not_in_languages).
3.3. Field dịch được (ADR-0037) #
Quy ước MỘT kiểu cho mọi module (ADR-0052) — bản ghi có field dịch được (vd
name, description) thì response có:
lang: "vi" ← ngôn ngữ CỦA bản đã chọn (null khi chưa có bản dịch nào)
name: "Khách VIP" ┐ bản đã chọn theo ngôn ngữ request:
description: "…" ┘ ĐỦ mọi field dịch được, null khi chưa có bản dịch
translations: [ { lang: "en", name: "VIP customer", description: null },
{ lang: "vi", name: "Khách VIP", description: "…" } ] ← MỌI bản dịch, sắp theo lang
- Bản đã chọn luôn có
lang+ ĐỦ mọi field dịch được, ở mọi chỗ trả nó (chi tiết, danh sách, picker…/options, storefront) — không route nào trả một phần. langkhác ngôn ngữ đang chọn (x-lang) = server đang dùng bản dự phòng → có thể hiện gợi ý "chưa có bản dịch tiếng Việt".translationscó ở MỌI response của bản ghi ở API quản trị (danh sách lẫn chi tiết); storefront chỉ trả bản đã chọn.- Hiển thị (bảng, chi tiết, dropdown): dùng field đã chọn (
name) — server đã lo dự phòng (ngôn ngữ request → mặc định store → ngôn ngữ khác của store →en→ bản dịch đầu tiên có). - Form sửa: dùng
translations— đúng dạng body ghi, một nhóm ô nhập cho mỗi ngôn ngữ tronglanguagescủa store. Ghi = gửi LẠI cả mảng (thay toàn bộ): ngôn ngữ vắng mặt bị xoá, field không gửi thànhnull. langphải thuộclanguagescủa store → nếu không: 400unsupported_language(details.languages= mã thừa,details.allowed). Mảng rỗng (khi field bắt buộc) hoặc trùnglang→ 400validation_failed.- Gỡ một ngôn ngữ khỏi store KHÔNG xoá bản dịch đã lưu; nó vẫn nằm trong
translations(form sửa nên giữ nguyên khi gửi lại, hoặc bỏ đi có chủ ý).
Ví dụ có sẵn: module brand —
createBrand({ body: { code, translations: [{ lang: "vi", name: "Nike Việt Nam" }] } }),
sửa từng bản dịch updateBrandTranslation({ params: { id, lang }, body }); item
có lang, name, description, slug, metaTitle, metaDescription (đã
chọn) +
translations: [{ lang, name, description, slug, metaTitle, metaDescription }].
3.4. Bản ghi được trỏ tới: <field>Info #
FK sang module khác trả kèm thông tin hiển thị, khỏi gọi thêm request:
logoFileId: "…" +
logoFileInfo: { id, name, contentType, size, sha256 } | null (null khi không
gắn). Tải nội dung file: GET /api/v1/files/{id}/content.