xforge · tài liệu FE

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 đó, 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ốngNguyên nhân thường gặp → cách sửa
Lỗi ở mọi request, CORS_ORIGINS đã có originLệ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ỗiTê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 ănServer chưa khởi động lại
/api/v1/* được nhưng /openapi.json lỗiOrigin 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ó responseKhô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 CORSServer 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_mismatchgọi switchStore theo x-store hiện tại, chạy lại
refresh thất bại / token_revokedsession.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 });
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: truekế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_reusedkhoá đã dùng cho request KHÁC — lỗi lập trình: mỗi thao tác một khoá
409 idempotency_response_withheldthao 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_keykhoá 1–255 ký tự ASCII in được
400 idempotency_unsupportedupload 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):

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:

codeStatusFE nên
validation_failed400details[] (FieldIssue) → lỗi theo ô, dịch theo rule
modules_not_installed400bật module cho store mà database chưa cài: details.modules
invalid_credentials401"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_attempts429khoá nút, đếm ngược details.retryAfter giây (header retry-after)
forbidden403thiếu quyền: details.missing = mã quyền thiếu
quota_exceeded / feature_not_in_plan403giớ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_expired403store 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_required401storefront: cần đăng nhập tài khoản khách
version_conflict409người khác đã sửa trước: tải lại (details.currentVersion), hỏi người dùng
outside_store_scope / account_scope_required403admin cấp store vượt phạm vi store mình / thao tác cần admin cấp account
condition_not_met403có quyền nhưng điều kiện không thoả với bản ghi này (ADR-0021)
record_rule_violation403bản ghi mới nằm ngoài phạm vi dữ liệu của user
field_write_forbidden403không được ghi details.fields — bỏ các field đó khỏi form
store_required400bộ chọn store từ details.stores (cms §1.1)
password_change_required403user phải đổi mật khẩu trước (ADR-0100): mở màn đổi mật khẩu (cms §1.8)
mfa_enrollment_required403account 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_factor409account bắt buộc MFA: thêm yếu tố khác trước khi gỡ yếu tố cuối
account_disabled / store_inactive403thông báo, về màn đăng nhập
module_not_installed / module_not_enabled404gọ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_found404trang 404 / đóng form
duplicate_value409trùng giá trị: details.resource (tên trong ERROR_RESOURCES) + details.fields (field camelCase của khoá) → báo ở đúng ô
resource_in_use409xoá / sửa bản ghi còn được dùng: details.resource + details.referencedBy (null = không rõ)
conflict, *_exists, login_taken409trùng giá trị — báo ở ô tương ứng (conflict trơn: bảng chưa khai tài nguyên)
rate_limited429gọi quá dày: chờ details.retryAfter giây rồi thử lại, đừng lặp ngay
database_maintenance503database đ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_taken409email đã có user khác dùng (không phân biệt hoa thường)
reset_token_invalid / verify_token_invalid401link 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) #


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": "…"

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:

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

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.

Nguồn: xforge-core: docs/fe/common.md