CMS / admin của khách #
Giao diện quản trị của khách (nhân viên của từng database) + màn hình module:
XforgeClient (sdk/api.ts), token nhân viên, /api/v1/*. Phần chung (khởi
tạo client, lỗi, danh sách, ngôn ngữ): common.md. Luồng riêng của
từng module (thu tiền, giỏ…): FE.md của module đó.
1. Đăng nhập (nhân viên) #
1.1. Mật khẩu → (chọn store) → (MFA) → token #
login({ body: { login, password } }) ← header x-store nếu đã biết store
│
├─ 400 store_required ── details.stores = [{ code, name }]
│ → bộ chọn store → store = code → gọi login lại (giữ mật khẩu trong bộ nhớ)
│
├─ data.mfaRequired = true ── { ticket, methods: ["totp" | "passkey" | "recovery_code"], sms, expiresIn }
│ → màn nhập mã (hạn 5 phút):
│ verifyMfa({ body: { ticket, code } }) ← TOTP 6 số
│ verifyMfa({ body: { ticket, recoveryCode } }) ← mã khôi phục (dùng 1 lần)
│ sms = true → nút "Gửi mã qua SMS":
│ sendMfaSmsCode({ body: { ticket } }) ← 202, mã 6 số hạn 5 phút
│ verifyMfa({ body: { ticket, smsCode } })
│ + rememberDevice: true ← ô "ghi nhớ thiết bị này" (§1.10)
│ → sai: 401 invalid_mfa_code (đoán nhiều → 429)
│
└─ data = { accessToken, refreshToken, store, … }
→ session.set({ accessToken, refreshToken }); store = data.store
Sau đăng nhập: getMe() →
{ login, name, email, emailVerifiedAt, superuser, database, store, roles, permissions }.
1.2. Đăng nhập bằng passkey (không mật khẩu) #
Dùng API WebAuthn JSON của trình duyệt (Chrome / Edge 129+, Safari 18+, Firefox 119+):
const { data: o } = await api.getPasskeyLoginOptions({ body: { login } }); // login tuỳ chọn
const cred = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(o.publicKey),
}) as PublicKeyCredential;
const { data } = await api.verifyPasskeyLogin({
body: { challengeId: o.challengeId, credential: cred.toJSON() },
});
session.set({ accessToken: data.accessToken, refreshToken: data.refreshToken });
Passkey = cả hai yếu tố (không hỏi mã MFA), và vẫn vào được khi mật khẩu đang bị khoá.
1.3. Đổi store #
bộ chọn store: listMyStores() → [{ code, name }]
→ store = code (đổi header x-store)
→ request kế tiếp: 401 token_store_mismatch → SDK tự switchStore + chạy lại
Không cần gọi switchStore tay; refresh token giữ nguyên (nhiều tab đổi store
song song không giẫm nhau).
1.4. Đăng xuất #
logout({ body: { refreshToken } }) (thu hồi phiên này) rồi
session.set(null).
1.5. Quên mật khẩu #
Server gửi link PASSWORD_RESET_URL (vd
https://admin.example.com/reset-password?token={token}) — FE dựng trang đó.
Ngôn ngữ thư theo header Accept-Language của request (vi / en, mặc định
en).
Màn "Quên mật khẩu" (không cần đăng nhập, vẫn gửi x-database / x-store như login)
forgotPassword({ body: { login } }) ← login HOẶC email
→ 202 LUÔN (kể cả login không tồn tại) → hiện "Nếu tài khoản có email,
bạn sẽ nhận được link trong vài phút"
→ 429 too_many_attempts → đếm ngược details.retryAfter
→ 503 / 403 password_reset_disabled → ẩn link "Quên mật khẩu" (biết trước
qua getPublicConfigs()["core.auth_policy"].passwordReset, §1.7)
Email ──► https://admin.example.com/reset-password?token=XYZ (hạn 30 phút, 1 lần)
Trang reset-password (đọc token từ query, KHÔNG gọi API khi chỉ mở trang)
user nhập mật khẩu mới (≥ passwordMinLength của core.auth_policy, §1.7)
→ resetPassword({ body: { token, password } })
→ 204 → về màn đăng nhập ("Đã đổi mật khẩu")
→ 401 reset_token_invalid → "Link đã hết hạn hoặc đã dùng" + nút gửi lại
→ 429 too_many_attempts → đếm ngược
- Mọi phiên cũ của user chết ngay (token cũ → 401
token_revoked, refresh cũ bị từ chối) — tab khác đang mở sẽ về màn đăng nhập. - User đã bật MFA vẫn phải nhập mã sau khi đăng nhập bằng mật khẩu mới (§1.1).
- Gửi yêu cầu lần nữa → link cũ chết, chỉ link trong thư MỚI NHẤT dùng được.
- Đừng ghi URL có
tokenvào log / analytics; xoá token khỏi thanh địa chỉ (history.replaceState) sau khi đọc.
1.6. Xác minh email #
getMe() → emailVerifiedAt === null && email !== null → hiện banner "Xác minh email"
requestEmailVerification() ← cần đăng nhập
→ 202 → "Đã gửi link tới <email>"
→ 409 email_missing / email_already_verified · 429 · 503 email_verification_disabled
Email ──► EMAIL_VERIFY_URL, vd https://admin.example.com/verify-email?token=XYZ (hạn 24 giờ)
Trang verify-email (không cần đăng nhập)
confirmEmail({ body: { token } })
→ 204 → "Email đã được xác minh" (đang đăng nhập → getMe() lại)
→ 401 verify_token_invalid → link sai / đã dùng / hết hạn / email đã đổi
Admin đổi email của user (updateUser({ body: { email } })) → emailVerifiedAt
về null, link đã gửi tới địa chỉ cũ chết.
1.7. Cấu hình công khai cho màn đăng nhập (ADR-0028) #
getPublicConfigs() (không cần token, gửi x-database / x-store như login)
trả các cấu hình public của tenant, vd:
{
"core.auth_policy": {
"passwordMinLength": 12,
"passwordReset": true,
"emailVerification": true
},
"core.customer_auth": { "signup": true, "...": "..." }
}
Dùng để ẩn link "Quên mật khẩu", báo độ dài mật khẩu tối thiểu trước khi gửi.
Server vẫn kiểm (validation_failed, rule min_length, requirement = độ
dài).
1.8. Đổi mật khẩu, phiên (ADR-0030) #
Đổi mật khẩu (đang đăng nhập)
changePassword({ body: { password, newPassword, code? | recoveryCode? | passkey? } })
← mật khẩu cũ + yếu tố thứ hai NẾU đã bật MFA (như các bước step-up §4)
→ 200 TokenPair → LƯU NGAY: mọi phiên khác (cả tab / máy khác) đã chết
→ 401 invalid_credentials · 400 mfa_code_required · 400 validation_failed
Thiết bị đang đăng nhập
listSessions() → [{ id, startedAt, lastActiveAt, expiresAt, store, current }]
revokeSession({ params: { id } }) → 204 (phiên đó không refresh được nữa;
access token của nó còn tối đa expiresIn giây)
revokeAllSessions() → 200 TokenPair → LƯU NGAY (mọi phiên khác chết ngay)
Bắt đổi mật khẩu (ADR-0100) — admin tạo / sửa user kèm
passwordChangeRequired: true (mật khẩu tạm):
login(…) → TokenPair như thường (đăng nhập không đổi hình dạng)
getMe() → passwordChangeRequired: true → mở NGAY màn đổi mật khẩu, chưa tải menu
token này chỉ gọi được: getMe, listMyStores, listSessions / revoke…,
switchStore, changePassword, gửi lại link xác minh email
route khác → 403 password_change_required (kể cả bật MFA, passkey, API key)
changePassword({ body: { password: <mật khẩu tạm>, newPassword } })
→ 200 TokenPair → LƯU NGAY → getMe().passwordChangeRequired = false → vào app
→ 400 password_unchanged (newPassword trùng mật khẩu tạm)
Đặt lại qua link quên mật khẩu cũng xoá cờ.
Bắt buộc MFA (ADR-0101) — core.auth_policy.requireMfa của account:
getMe() → mfaEnrollmentRequired: true → mở màn đăng ký yếu tố thứ hai
(passwordChangeRequired cũng true → đổi mật khẩu TRƯỚC, rồi getMe lại)
được gọi: route tài khoản như trên + setupTotp / confirmTotp,
getPasskeyRegisterOptions / registerPasskey
route khác → 403 mfa_enrollment_required
confirmTotp / registerPasskey → data.tokens (TokenPair) → LƯU NGAY → vào app
Gỡ yếu tố cuối khi chính sách bật → 409 last_factor
1.9. Cấu hình tenant (core.manage_settings) #
listConfigs() → mỗi mã: value hiệu lực, source (store / account /
database / default), layers (giá trị + version từng tầng). Ghi CẢ object
ở một tầng: setConfig({ params: { code }, body: { scope, value, version } });
resetConfig({ params: { code }, query: { scope } }) để quay về tầng trên.
Field bí mật trả về "••••••••" — gửi lại nguyên chuỗi đó để giữ giá trị cũ.
Lỗi: config_scope_not_allowed, account_scope_required (cần admin cấp
account), database_scope_required, version_conflict,
secrets_not_configured.
1.10. Ghi nhớ thiết bị (bỏ qua MFA ở lần sau) #
Màn nhập mã có ô "Ghi nhớ thiết bị này" →
verifyMfa({ body: { ticket, code,
rememberDevice: true } }). Response có thêm
trustedDevice: { token, expiresAt } (null khi tenant tắt —
getPublicConfigs() → core.trusted_devices.days === 0 thì ẩn ô này). Có
deviceTokens trong client (common §1.2) thì SDK tự lưu token
theo (x-database, login) và tự gửi kèm deviceToken ở login sau đó: mật
khẩu đúng + token hợp lệ của CÙNG user → token luôn, không hỏi mã. Token bị từ
chối (login vẫn trả mfaRequired) → SDK xoá token đã lưu.
- Vẫn phải nhập mật khẩu; đăng nhập passkey không dùng token thiết bị.
- Token chết khi: hết
expiresAt, tenant hạ / tắt số ngày, user đổi / được đặt lại mật khẩu, đăng xuất mọi nơi, thêm / gỡ yếu tố MFA, sinh lại mã khôi phục. - Màn cài đặt:
listTrustedDevices()→[{ id, label, ip, createdAt, lastUsedAt, expiresAt }];revokeTrustedDevice({ params: { id } });revokeAllTrustedDevices(). - Không dùng SDK: tự lưu
trustedDevice.tokenvà gửideviceTokentrong bodylogin. Token là thông tin đăng nhập — chỉ gửi tới server này.
2. Phiên #
2.1. Thời hạn #
- Access token: ngắn (mặc định 15 phút) — SDK tự làm mới.
- Refresh token (tenant): trượt, mặc định 30 ngày; MỖI lần refresh đổi token mới.
2.2. Hai tab cùng refresh #
Tab A và B cùng thấy access hết hạn, cùng gửi một refresh token. Server cho A
token mới; B nhận refresh_token_superseded trong 15 giây ân hạn → SDK của B
đọc lại session (A đã lưu) và dùng tiếp. Vì vậy session phải dùng chung giữa
các tab. Gửi lại refresh token CŨ sau ân hạn = coi như bị lộ → server thu hồi cả
phiên.
3. Menu và quyền #
getMe() → permissions: ["brand.read", "core.manage_users", …], superuser
listMenu() → [{ key, module, group, label, permission, icon, order, screen }]
menu quản trị = listMenu() (đã lọc: module bật ở store, có quyền, gói có tính năng)
hiện nút / route ⇔ superuser || OPERATION_PERMISSIONS[op].every(p ⇒ permissions.includes(p))
import { OPERATION_PERMISSIONS } from "./sdk/api.ts";
const has = (p: string) =>
me.permissions.includes(p as never) ||
me.conditionalPermissions.includes(p as never);
const can = (op: keyof typeof OPERATION_PERMISSIONS) =>
me.superuser || OPERATION_PERMISSIONS[op].every(has);
can("createUser"); // nút "Thêm user"
listMenu() (ADR-0083): mỗi module khai mục menu của mình; nhóm theo group
(phân hệ: catalog, inventory…), xếp sẵn theo group → order → key.
key (brand.brands, order.orders…) là hợp đồng ổn định — CMS gắn màn hình
của mình theo key, bỏ qua key chưa có màn hình. label đã dịch theo
x-lang; icon chỉ là tên gợi ý (null = tự chọn). Đổi store → gọi lại.
Mọi key có trong bản server (chưa lọc theo store / người dùng), kèm quyền,
tính năng gói, nhãn theo ngôn ngữ: sdk/menu.json (máy đọc — vd kiểm mọi key
đã có màn hình) và sdk/menu.md (bảng theo phân hệ), sinh cùng SDK.
Menu đổi khi đang mở (module bị tắt / bật cho store, gói đổi, quyền đổi): luồng
realtime (§6) phát core.menu_changed → gọi lại listMenu() rồi vẽ lại; nếu
đang đứng ở màn hình có key không còn trong menu thì đưa người dùng về trang
chủ. Cũng gọi lại ở mỗi ready của luồng. Không mở luồng thì F5 là đủ (/menu
luôn theo trạng thái hiện tại). Gọi route của module đã bị thu hồi → 404
module_not_enabled / module_not_installed, hoặc tính năng đã ra khỏi gói →
403 feature_not_in_plan: báo "tính năng không còn" và gọi lại listMenu().
Quyền tính THEO STORE đang chọn: đổi store → gọi lại getMe(). Ẩn nút chỉ là
giao diện; server vẫn kiểm (403 forbidden).
ABAC (ADR-0021):
conditionalPermissionslà quyền được cấp KÈM điều kiện (vd duyệt đơn dưới 10 triệu). Hiện nút, nhưng server có thể trả 403condition_not_mettheo từng bản ghi.- Phạm vi dữ liệu (record rule) có thể khiến tạo / sửa bị 403
record_rule_violation, hoặc sửa một dòng sang giá trị ngoài phạm vi trả 404. - Cột nhạy cảm (giá vốn, lương…) trả
nullkhi thiếu quyền xem. Ghi vào cột đó khi thiếu quyền → 403field_write_forbidden(details.fields). Form nên ẩn ô đó, hoặc không gửi field đó.
3.1. Màn hình dựng từ mô tả (ADR-0116) #
Mục menu có screen khác null thì module đã MÔ TẢ màn hình bằng dữ liệu: CMS
dùng MỘT màn hình chung để vẽ, không cần code riêng cho key đó — module mới
phát hành là có màn hình, không build lại CMS.
listMenu() → [{ key, …, screen: { type, revision } | null }]
getMenuScreen({ params: { key } })
→ { schemaVersion, key, module, type, revision,
title, layout, operations }
client.call(operations[id], { params, query, body, form }) // gọi theo path
screen: null→ module không mô tả: CMS tự code theokeynhư trước (hoặc hiện "chưa hỗ trợ").type:crud(bảng + lọc + form tạo / sửa + xoá + thùng rác),custom(màn tay theokey— không có gì để vẽ). Sẽ có thêm loại (vddetail,workflow): gặptypelạ, hoặcschemaVersionlớn hơn bản CMS biết (hiện1) → hiện "chưa hỗ trợ" ở vùng nội dung, đừng làm hỏng trang. Bỏ qua field lạ ở mọi cấp.- Lỗi: 404
menu_entry_not_found— mục không có trong menu của người dùng ở store này (cùng luật vớilistMenu: module chưa bật, thiếu quyền, gói thiếu tính năng,keylạ) → đưa về trang chủ, gọi lạilistMenu(); 404menu_screen_not_declared— module không mô tả màn hình (đừng gọi khiscreennull).
layout của crud:
ops:{ list, get, create, update, delete }— operationId,null= không có (ẩn nút tương ứng).trash:{ list, restore, columns }hoặcnull.columns[]:{ field, label, widget, sort, options, currencyField, download, schema }—fieldlà đường trong mỗi phần tử củalist(supplierInfo.name);sortlà giá trị cho tham sốsort(thêm-để giảm dần);null= không sắp theo cột này.search: có ô tìm → tham sốqcủalist.filters[]:{ param, label, widget, options, schema }—paramlà tham số query củalist.form[]:{ field, label, widget, on, required, options, display, upload, download, currencyField, schema, fields }—on⊂["create","update"]: ô có trong form nào;required: bắt buộc ở form nào;display: đường trong bản ghi để hiện giá trị đang có (vd tên của id đã chọn);fields: ô của từng phần tử (widgettranslations). Form sửa đọc bản ghi bằngops.get(không có thì dùng dòng củalist); PATCH chỉ gửi field đã đổi.options:{ enum: <tên> }(nhãn ởenums[<tên>][<giá trị>] = { label, tone }) hoặc{ op, value, label, hint, query }— gọioperations[op]vớiquery(+qkhi gõ tìm), lấyvalue/label/hint(đường trong mỗi phần tử).upload/download: operation tải file lên (multipart/form-data) / tải nội dung (rawResponsekhác null →call()trảResponse).- Nhãn đã dịch theo
x-lang/Accept-Language(như menu).
Widget: tên chuỗi (text, textarea, url, email, number, qty,
money, date, datetime, switch, boolean (lọc: gửi "true" /
"false"), select, badge, picker, multipicker, file, image, user,
translations); null = theo schema. CMS chưa có widget đó → vẽ ô mặc định
theo schema (JSON Schema của field: string → ô chữ, format: date → ngày,
boolean → công tắc, enum → chọn, integer / number → số), không suy được thì
hiện "chưa hỗ trợ" ở ô đó. Danh mục sẽ có thêm tên.
operations[<id>]:
{ method, path, permissions, conditional, features,
idempotent, status, bodyContentType, rawResponse, params, query, body,
response, meta }
— đủ để gọi qua client.call(op, …) khi SDK đang chạy chưa có method của module
(tham số đường dẫn {id} lấy từ field cùng tên của bản ghi). Hiện nút theo đúng
luật ở trên: superuser || op.permissions.every(has) (has gồm cả
conditionalPermissions); conditional: true → có thể 403 condition_not_met
theo bản ghi; features khác rỗng → chỉ hiện khi getStorePlan().features null
hoặc chứa chúng. Thiếu quyền của operation trong options → ô nhập thường theo
schema. Ẩn nút chỉ là giao diện, server vẫn kiểm.
Cache: mô tả không phụ thuộc người gọi (quyền lấy từ getMe()). Chỉ mở màn
hình khi key có trong listMenu() của store HIỆN TẠI — đừng mở chỉ vì còn bản
cache (bản cache dùng chung giữa các store). Lưu theo (key, revision, ngôn
ngữ). revision đến từ listMenu().screen — mà listMenu() đã được gọi lại
khi có core.menu_changed VÀ ở mỗi ready của luồng realtime (§6): revision
khác bản đã lưu → gọi lại getMenuScreen. Server lên bản mới thì luồng realtime
đứt rồi nối lại (ready) nên CMS thấy revision mới. Đừng dùng tên + version
của module (listModules()) làm khoá cache: mô tả có thể đổi mà version giữ
nguyên (đổi nhãn, đổi schema route, nâng Core).
Mọi mô tả có trong bản server (mọi ngôn ngữ): sdk/screens.json (sinh cùng SDK)
— dùng để kiểm CMS đã hỗ trợ mọi type / widget đang có.
3.2. Module của cửa hàng: tắt / bật theo store (ADR-0117) #
Hai tầng: nhà cung cấp (gói, gán module) quyết module nào ĐƯỢC PHÉP; admin của
khách tắt bớt cho từng cửa hàng. Màn này viết tay (Core không có mô tả màn hình
cho nó), hiện khi người dùng có quyền core.manage_store_modules ở store đang
chọn — admin của riêng một store chỉ đổi được store đó; admin chung đổi store
khác bằng cách đổi store (§1.3). Mọi thao tác áp cho STORE ĐANG CHỌN.
listCurrentStoreModules() → GET /api/v1/core/store-modules
disableCurrentStoreModules({ modules: [...] }) → POST /api/v1/core/store-modules/disable
enableCurrentStoreModules({ modules: [...] }) → POST /api/v1/core/store-modules/enable
Mỗi phần tử (cả ba route trả cùng danh sách, sau thay đổi):
{ name, group, summary, depends, locked, usable, reason }.
reason | Nghĩa | Giao diện |
|---|---|---|
null | đang dùng được | công tắc BẬT (tắt được) |
disabled | khách đã tắt | công tắc TẮT (bật được) |
dependency_disabled | module nó dựa vào đang bị tắt | TẮT; bật = bật kéo cả phụ thuộc |
plan | ngoài gói | khoá, "nâng gói / liên hệ" |
platform | nhà cung cấp chưa gán cho cửa hàng này | khoá, "liên hệ nhà cung cấp" |
locked: true = module luôn bật (không có công tắc). Nhóm theo group như
menu. Tên hiển thị: summary (tiếng Anh) — có thể thay bằng nhãn menu đã dịch
của module.
Tắt:
- Module khác đang dùng mà dựa vào module muốn tắt → 409
store_module_in_use,details: { modules, dependents }. Hỏi người dùng ("Tắt X sẽ tắt luôn: …"), đồng ý thì gửi lại CẢ NHÓM trong một lệnh. Module "keo" tự tắt theo, không cần gửi. - Module có thể từ chối khi còn việc dở (vd còn khoản thu đang chờ) — 409 mang
mã của module, hiện
messageserver đã dịch; xong việc dở thì bấm lại. - 400
module_required— module luôn bật (không hiện công tắc thì không gặp). - Tắt không xoá dữ liệu, không giảm tiền (gói tính theo khách). Nói rõ trên màn hình.
Bật: kéo theo phụ thuộc khách đã tắt. Module ngoài phần được phép → 409
store_module_unavailable, details: { module, reason } (not_installed /
plan / platform).
Lỗi chung của cả hai lệnh: 400 unknown_modules (details.modules — tên server
không biết, vd CMS cũ hơn server); 400 store_required (chưa chọn store); 409
store_modules_unsupported — database chưa nâng cấp (trong lúc rollout): danh
sách vẫn đọc được, hiện "chưa đổi được lúc này" và ẩn công tắc.
Sau thay đổi: menu tự cập nhật qua core.menu_changed (§6) — hoặc gọi lại
listMenu() ngay cho người đang thao tác. Tiến trình server khác thấy thay đổi
trong vòng 5 giây.
4. Cài đặt bảo mật của user (MFA) #
Thêm / gỡ yếu tố đòi xác thực lại: password + (nếu đã có MFA) MỘT trong:
code (TOTP) · recoveryCode · passkey (chữ ký passkey). Thiếu → 400
mfa_code_required (user có TOTP) / mfa_proof_required (chỉ có passkey).
Bật TOTP
setupTotp({ body: { password, code? } }) → { secret, otpauthUri } ← vẽ QR từ otpauthUri
confirmTotp({ body: { code } }) → { recoveryCodes, tokens }
recoveryCodes: hiện MỘT lần, bắt user lưu
tokens ≠ null (yếu tố đầu tiên): phiên cũ ở mọi nơi bị thu hồi →
session.set(tokens) NGAY
Thêm passkey
getPasskeyRegisterOptions({ body: { password, … } }) → { challengeId, publicKey }
navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(publicKey) })
registerPasskey({ body: { challengeId, name, credential: cred.toJSON() } })
→ { passkey, recoveryCodes, tokens } (như trên)
Xác thực lại bằng passkey (user chỉ có passkey)
getStepUpPasskeyOptions() → { challengeId, publicKey }
navigator.credentials.get(…) → passkey: { challengeId, credential: cred.toJSON() }
rồi gửi kèm: deletePasskey({ params: { id }, body: { password, passkey } })
Trạng thái: getMfaStatus() →
{ enabled, totp, passkeys, recoveryCodesLeft, smsPhone }.
Số điện thoại DỰ PHÒNG (ADR-0104): chỉ thêm được khi đã có TOTP / passkey (409
mfa_sms_needs_factor); mã SMS chỉ dùng ở bước MFA lúc đăng nhập, không bật
MFA, không thoả "bắt buộc MFA", không dùng để gỡ yếu tố.
setupMfaSms({ body: { password, code | recoveryCode | passkey, phone } }) → 202 (nhắn mã)
confirmMfaSms({ body: { code } }) → { phone: "84912***678" }
disableMfaSms({ body: { password, code | recoveryCode | passkey } }) → 204
Sinh lại mã khôi phục (còn ít mã / nghi bị lộ):
regenerateRecoveryCodes({ body: { password, code | recoveryCode | passkey } })
→ { recoveryCodes } — hiện MỘT lần; mọi mã cũ (kể cả chưa dùng) và mọi thiết
bị đã ghi nhớ (§1.10) chết. Chưa có TOTP / passkey → 409 mfa_not_enabled.
5. Gói cước, khách của storefront (ADR-0025 / ADR-0027) #
GET /api/v1/plan: gói của store hiện tại —features(null = mọi tính năng),quotas[](limit,period,usedcủa kỳ này),state(active/grace/expired),modules(module gói cho dùng — phụ thuộc của chúng cũng dùng được; null = không giới hạn module). Ẩn / khoá nút theo đó; server vẫn kiểm. Menu (listMenu()) vàlistModules()(enabled) đã lọc module ngoài gói; màn "nâng gói" đọcmodulesđể biết gói hiện tại gồm gì.- Màn quản trị (nhân viên):
listCustomers(quyềncore.view_customers, lọcemail, tìmqtheo tên / email / số điện thoại — không phân biệt dấu,0912…khớp số lưu84912…; gõ từ 3 ký tự mới nên gọi;store= mã store, chỉ khách của store đó — màn tạo đơn truyền store hiện tại vì đơn chỉ nhận khách của store đó; store ngoài phạm vi / không có → rỗng) — khách đã ghé store trong phạm vi;purgeAfterkhác null = đang chờ xoá.getCustomercùng quyền, ngoài phạm vi = 404customer_not_foundnhư không tồn tại. Lịch sử đơn:listOrders?customer_id=của moduleorder.eraseCustomer({ confirm: <email của khách> })(quyềncore.erase_customers) xoá NGAY khi khách gửi yêu cầu xoá tới shop; chỉ hiện nút khierasable. 403account_scope_required(khách còn ở store khác cùng account — nhờ admin cấp tài khoản), 409customer_shared(khách còn mua ở shop khác trên sàn — chỉ nền tảng xoá), 400erase_confirm_mismatch. - Tạo khách:
createCustomer({ email, name, phone? })(quyềncore.manage_customers, cần store) — không mật khẩu, không gửi thư, liên kết store hiện tại. Email bắt buộc và duy nhất trong CẢ database. 409email_takenkhi email đã có tài khoản, như nhau dù khách đó của ai — tài khoản đó không bị liên kết hay sửa. Khi đó thửlistCustomers?email=: có dòng thì mở khách đó; không có thì báo nhân viên "email đã có tài khoản, nhờ khách đăng nhập ở cửa hàng" (đăng nhập là tự liên kết). Nhiều lần trùng liên tiếp → 429too_many_attempts. Khách tự vào bằng "quên mật khẩu" (storefrontForgotPassword; đăng ký bằng email đó sẽ gặpemail_taken, FE storefront dẫn sang quên mật khẩu) hoặc mạng xã hội có email đã xác minh. - Sửa khách:
updateCustomer({ name?, phone? })(quyềncore.view_customers+core.manage_customers); chỉ hiện nút khieditable(vẫn có thể bị từ chối khi store ngoài phạm vi còn giữ dữ liệu của khách). Cùng luật phạm vi với xoá (403account_scope_required, 409customer_shared), ngoài phạm vi xem hoặc sửa = 404. Đổi số xoá xác minh của số (phoneVerifiedAt), khách phải xác minh lại mới đăng nhập bằng số được. KHÔNG đổi được email (định danh đăng nhập, chưa có luồng xác minh email mới) và KHÔNG khoá được khách (activechỉ để hiển thị: khoá là cấp database, ảnh hưởng cả shop khác; không muốn khách ở store mình thìunlinkCustomer). Gửiemail/active→ 400unknown_key.
6. Realtime (SSE) + Web Push (ADR-0092 / ADR-0093) #
Cập nhật trực tiếp khi CMS đang mở — một luồng cho mọi màn hình:
import { eventStream } from "./sdk/runtime.ts";
const live = eventStream(
(lastEventId) => api.streamMyEvents({ lastEventId }),
{
// (Nối lại) xong: đọc lại thứ đang hiển thị — sự kiện chỉ là gợi ý.
onOpen: () => {
refreshBadge();
reloadMenu();
},
onEvent: ({ event, data }) => {
if (event === "core.notification") refreshBadge();
if (event === "core.menu_changed") reloadMenu(); // listMenu() (§3)
if (event === "stock.level_changed") reloadIfShowing(data);
},
onError: () => {/* 401 / 403: phiên chết hoặc API key — dừng */},
},
);
// Đổi store / đăng xuất: live.close() rồi mở lại.
datacủa sự kiện CHỈ là tham chiếu ({ topic, data: { …id }, createdAt }): đọc lại bản ghi qua API.- Luồng tự đóng khi access token hết hạn;
eventStreamtự nối lại (client tự refresh) kèmlastEventIdđể server phát lại phần lỡ (≤ 1 giờ). - Tối đa 5 luồng / người — mở MỘT luồng cho cả ứng dụng (dùng chung giữa các màn
hình); nhiều tab thì cân nhắc
BroadcastChannel+ một tab giữ luồng. - Proxy trước server phải cho HTTP/2 (HTTP/1.1: trình duyệt chỉ mở 6 kết nối /
tên miền) và không đệm
text/event-stream.
Web Push — thông báo khi tab đã đóng (chỉ thông báo module gửi kèm
push: true):
const { data: cfg } = await api.getMyPushConfig();
if (cfg.enabled && "serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.register("/sw.js");
if (await Notification.requestPermission() === "granted") {
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: cfg.publicKey!, // base64url — trình duyệt nhận chuỗi
});
const json = sub.toJSON(); // { endpoint, expirationTime, keys }
await api.createMyPushSubscription({
body: {
endpoint: json.endpoint!,
expirationTime: json.expirationTime ?? null,
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
label: "Chrome trên máy văn phòng",
},
});
}
}
// sw.js — payload: { id, kind, storeId, title, link: { key, id } | null }
self.addEventListener("push", (e) => {
const n = e.data.json();
e.waitUntil(
self.registration.showNotification(n.title, { tag: n.id, data: n }),
);
});
self.addEventListener("notificationclick", (e) => {
e.notification.close();
const n = e.notification.data;
// Mở màn hình theo khoá menu (ADR-0083) — cùng cách xử lý `link` ở hộp thư.
e.waitUntil(
clients.openWindow(
`/go?store=${n.storeId}&key=${n.link?.key ?? ""}&id=${n.link?.id ?? ""}`,
),
);
});
- Đăng ký theo store và theo PHIÊN: đổi store / đăng nhập lại thì gọi lại
createMyPushSubscriptionvới cùngPushSubscription(thay chỗ, không trùng). Đăng xuất là server thôi gửi; gọi thêmdeleteMyPushSubscriptioncho gọn. Tắt:deleteMyPushSubscription; danh sách trình duyệt đã bật:listMyPushSubscriptions. - iOS / iPadOS chỉ nhận Web Push khi CMS được "Thêm vào màn hình chính" (PWA).
- Tiêu đề push theo ngôn ngữ mặc định của store (không có tuỳ chọn ngôn ngữ theo người).
7. Xuất / nhập CSV / Excel (ADR-0094) #
Một màn hình chung cho mọi module:
const { data: sets } = await api.listDatasets(); // chỉ tập dùng được + có quyền
// Xuất: Response (file) — tải về bằng Blob
const res = await api.exportDataset({
params: { name },
query: { format: "xlsx" },
});
download(
await res.blob(),
filenameFrom(res.headers.get("content-disposition")),
);
// File mẫu để nhập (tiêu đề theo ngôn ngữ, cột bắt buộc có *)
await api.datasetImportTemplate({
params: { name },
query: { format: "xlsx" },
});
// Nhập: chạy thử trước, hiện lỗi theo dòng, rồi nhập thật
const file = input.files[0];
const fmt = file.name.endsWith(".xlsx") ? "xlsx" : "csv";
const dry = await api.importDataset({
params: { name },
query: { format: fmt, dryRun: "true" },
raw: file,
});
// dry.data.errors: [{ line, column, rule, message }] — line 1 = dòng tiêu đề
- Có lỗi →
applied: false, không ghi gì; sửa file rồi nhập lại CẢ file. - Nhập thật ghi cả file trong một transaction: dòng lỗi lúc ghi huỷ hết và nằm
trong
errors(đúngline). - Trần: 5 000 dòng / 10 MB mỗi lần nhập, 100 000 dòng mỗi lần xuất.
- Tập
core.customers(module: "core", quyềncore.view_customers): khách của store đang đứng, kể cả với admin cấp account. Chỉ xuất (canImport: false), có dữ liệu cá nhân nên hiện cảnh báo trước khi tải. - CSV: UTF-8 (Excel: "CSV UTF-8");
,;hay tab đều được. - Xuất LỚN (quá 100 000 dòng / sợ hết giờ của proxy): xuất nền —
startDatasetExport({ params: { name }, query: { format } })→ 202ExportView(id,status…); chờ sự kiện realtimecore.data_export({ exportId, status }) hoặc hỏilistDataExports(); xong thìdownloadDataExport({ params: { id } })(file sống 24 giờ, chỉ người xuất tải được).
8. Webhook gửi đi (ADR-0096) #
Màn hình "Tích hợp" của store (quyền core.manage_webhooks):
listWebhookEvents()— sự kiện chọn được;createWebhook({ body: { url, events, description } })→ hiệnsecretMỘT lần (nút "sao chép", cảnh báo không xem lại được);listWebhooks(),updateWebhook,deleteWebhook,rotateWebhookSecret(hiện bí mật mới một lần),testWebhook(gửicore.ping),listWebhookDeliveries+redeliverWebhook.active: false+disabledReason: "too_many_failures"→ URL tự tắt vì hỏng liên tiếp; nút "Bật lại" =updateWebhook({ body: { active: true } }).- Danh mục chỉ có sự kiện người dùng ĐỌC được (quyền đọc của module). Token
trong query của URL (
?token=…) hiện lại ở danh sách / nhật ký — khuyên bên nhận dùng chữ ký thay vì token trong URL.
Hệ thống NHẬN (đưa cho khách tích hợp) — hợp đồng gửi đi (thêm field được, đổi / bỏ = phá):
| Phần | Nội dung |
|---|---|
| Body (JSON) | { id, event, createdAt, storeId, data } — id = mã lần giao (gửi lại có id mới), data của sự kiện (core.ping: { message: "ping" }) |
X-Xforge-Event | tên sự kiện (<module>.<tên>) |
X-Xforge-Delivery | = id của body |
X-Xforge-Signature | t=<unix giây>,v1=<hex HMAC-SHA256(secret, "<t>.<body thô>")> — khoá HMAC là NGUYÊN chuỗi whsec_… (không bỏ tiền tố) |
Trả 2xx trong 10 giây = đã nhận. 5xx / 429 / 408 / quá giờ → gửi lại lùi dần (≤ 8 lần); 4xx khác → không gửi lại. Hỏng hẳn 20 lần liên tiếp → URL tự tắt.
// Kiểm chữ ký trước khi tin body — so thời gian hằng, từ chối nếu t quá 5 phút.
const [, t, v1] = /t=(\d+),v1=([0-9a-f]+)/.exec(
req.headers["x-xforge-signature"],
)!;
const expected = hmacSha256Hex(secret, `${t}.${rawBody}`);
if (!timingSafeEqual(expected, v1) || Math.abs(Date.now() / 1000 - +t) > 300) {
reject();
}
// Trả 2xx nhanh; xử lý nền. Trùng `id` (giao lại / thử lại) → bỏ qua.
9. Báo cáo (ADR-0097) #
const { data: reports } = await api.listReports(); // chỉ báo cáo xem được
// Form tham số dựng từ `report.params` (date / text / number / select + options;
// loại lạ sau này → hiện như ô chữ)
const params = { from: "2026-10-01", to: "2026-10-31" };
const { data } = await api.runReport({ params: { name }, body: { params } });
// data.columns (numeric / money), data.rows (khoá cột → ô), data.summary | null
await api.exportReport({ params: { name }, body: { format: "xlsx", params } }); // file
const { data: job } = await api.startReportExport({
params: { name },
body: { format: "xlsx", params },
});
// → chờ realtime `core.data_export` rồi `downloadDataExport({ params: { id: job.id } })`
- Chạy / tải là ĐỌC dù dùng POST (để mang tham số có kiểu) — không cần Idempotency-Key.
- Sai tham số → 400
validation_failed,detailstheoparams.<khoá>. - Cột
money: true→ ô là chuỗi (định dạng tiền);numeric: true→ căn phải. report_params_invalidchỉ xuất hiện ởerrorcủa lượt chạy nền (tham số đã lưu không còn hợp lệ).
10. Nhập chạy nền + báo cáo định kỳ (ADR-0098) #
// File lớn (> 5 000 dòng): gửi như importDataset, nhận 202 + lượt nhập
const { data: imp } = await api.startDatasetImport({
params: { name },
query: { format: "xlsx", dryRun: "true" },
body: file,
});
// → chờ realtime `core.data_import` ({ importId, status })
const { data } = await api.getDataImport({ params: { id: imp.id } });
// data.status: done | failed; data.applied; data.errors (theo dòng, như nhập trực tiếp)
// Lịch báo cáo: thư tới email ĐÃ XÁC MINH của chính người dùng
await api.createReportSchedule({
body: {
report: name,
format: "xlsx",
params,
frequency: "weekly",
hour: 7,
weekday: 1,
},
});
report_email_required(409) → đưa người dùng tới xác minh email.frequency:daily/weekly(weekday1 = thứ Hai … 7) /monthly(monthDay1–28); giờ theo múi giờ store. Tạm dừng:PATCH { active: false }.lastStatuscho người dùng biết lần gửi gần nhất (sent,forbidden— mất quyền xem,no_verified_email, …).
11. In chứng từ (ADR-0099) #
const { data: printables } = await api.listPrintables(); // mẫu dùng được
// Xem: fetch (kèm token) → hiện trong iframe SANDBOX (không allow-scripts)
const res = await api.printDocument({ params: { name, id } }); // Response
iframe.setAttribute("sandbox", ""); // chỉ hiển thị; srcdoc KHÔNG nhận CSP của response
iframe.srcdoc = await res.text();
// In: tải PDF (nếu có) hoặc tải file HTML rồi người dùng mở / in — KHÔNG gọi
// print() vào iframe sandbox, và KHÔNG BAO GIỜ bật cùng lúc allow-scripts +
// allow-same-origin (vô hiệu sandbox).
// Tải PDF (chỉ khi server có dịch vụ PDF — 409 pdf_unavailable thì ẩn nút):
// api.printDocument({ params: { name, id }, query: { format: "pdf" } })
// Gửi email (chỉ hiện khi canEmail); bật Idempotency-Key của client để bấm hai
// lần không gửi hai thư.
const { data } = await api.emailDocument({ params: { name, id } });
// data.to = địa chỉ đã che ("a***@example.com") → hiện "Đã gửi tới …"
- Đừng mở HTML bằng blob URL / tab cùng origin với app: blob thừa hưởng CSP và
quyền của trang tạo ra nó. Dùng
iframe sandbox+srcdoc. print_recipient_missing(409): chứng từ không có email người nhận hợp lệ.print_email_repeat(429,retry-after): người nhận này vừa được gửi trong 10 phút.print_too_large(413): chứng từ quá lớn — lọc / chia nhỏ.
12. Checklist trước khi ghép với server thật #
- Origin của CMS đã có trong
CORS_ORIGINS(hoặc chạy trên tên miền đã gán) -
sdk/lấy đúng phiên bản server đang chạy -
sessiondùng chung giữa các tab;onSessionExpiredđưa về đăng nhập - Màn đăng nhập xử lý:
store_required(bộ chọn store),mfaRequired(nhập mã),account_locked(đếm ngượcretryAfter) -
confirmTotp/registerPasskeytrảtokens→ lưu NGAY (phiên cũ đã bị thu hồi) - Menu dựng từ
listMenu()theokey(§3), gọi lại khi đổi store và khi nhậncore.menu_changed/ready(§6); nút theoOPERATION_PERMISSIONS - Màn "Module của cửa hàng" (§3.2): công tắc theo
reason, 409store_module_in_usehỏi lại rồi gửi cả nhóm - Trang
reset-password/verify-emailkhớpPASSWORD_RESET_URL/EMAIL_VERIFY_URLcủa server; quên mật khẩu luôn hiện cùng một thông báo - Client bật
idempotency: true; nút "thử lại" của thao tác ghi gửi lại cùngidempotencyKey(common §1.2.1) - Rẽ nhánh lỗi theo
ApiError.code+details, không theomessage; hiệnmessage(server đã dịch) - Nút đổi ngôn ngữ lấy
languagescủagetMe()và gửix-lang; hiển thị field đã chọn, form sửa dùngtranslations(common §3.3)