@goplusvn/core 0.1.67 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/bin/goerp-guardrails.mjs +45 -0
  3. package/eslint/index.mjs +120 -0
  4. package/package.json +10 -3
  5. package/scripts/doctor.ts +99 -0
  6. package/src/guardrails/__tests__/guardrails.test.ts +430 -0
  7. package/src/guardrails/index.ts +57 -0
  8. package/src/guardrails/preset.ts +75 -0
  9. package/src/guardrails/primitives.ts +307 -0
  10. package/src/guardrails/rules/auth.ts +178 -0
  11. package/src/guardrails/rules/debt.ts +71 -0
  12. package/src/guardrails/rules/design.ts +95 -0
  13. package/src/guardrails/rules/layering.ts +160 -0
  14. package/src/guardrails/rules/one-door.ts +115 -0
  15. package/src/guardrails/rules/rbac.ts +282 -0
  16. package/src/guardrails/rules/safety.ts +86 -0
  17. package/src/guardrails/rules/structure.ts +136 -0
  18. package/src/guardrails/run.ts +130 -0
  19. package/src/guardrails/scanner.ts +144 -0
  20. package/src/guardrails/types.ts +181 -0
  21. package/src/print/print-styles.tsx +4 -1
  22. package/src/types/index.ts +1 -1
  23. package/src/ui/data-display/shallow-pagination.tsx +189 -0
  24. package/src/ui/index.tsx +1 -0
  25. package/src/user/components/index.ts +1 -0
  26. package/src/user/components/user-toolbar.tsx +8 -2
  27. package/src/user/components/user-visuals.tsx +84 -0
  28. package/src/user/components/users-card-view.tsx +1 -26
  29. package/src/user/components/users-table.tsx +215 -0
  30. package/src/user/pages/users-client-page.tsx +84 -259
  31. package/templates/starter-app/AGENTS.md +39 -3
  32. package/templates/starter-app/eslint.config.mjs +85 -0
  33. package/templates/starter-app/package.json +19 -2
  34. package/templates/starter-app/prettier.config.mjs +54 -0
  35. package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
@@ -0,0 +1,160 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { GuardrailContext, GuardrailRule } from "../types";
5
+
6
+ /** Tên các module nghiệp vụ dưới `src/modules/`. Rỗng = app chưa module hoá. */
7
+ function moduleNames(ctx: GuardrailContext): string[] {
8
+ const dir = join(ctx.srcDir, "modules");
9
+ if (!existsSync(dir)) return [];
10
+ return readdirSync(dir).filter((e) => statSync(join(dir, e)).isDirectory());
11
+ }
12
+
13
+ /**
14
+ * Stub route hợp lệ: `app/api/**\/route.ts` CHỈ re-export handler từ
15
+ * `modules/<domain>/api/**`. Next cần một file trong `app/` để mount URL, còn
16
+ * logic sống trong module. Nội dung phải là re-export thuần, không thêm code.
17
+ */
18
+ const isRouteStub = (rel: string, code: string): boolean =>
19
+ /^app\/api\/.*\/route\.ts$/.test(rel) &&
20
+ /^export \* from ["']@\/modules\/[a-z-]+\/api\/[a-zA-Z0-9/[\]-]+["']$/.test(code.trim());
21
+
22
+ /**
23
+ * Deep-import hợp lệ thứ hai: file đích là SERVER ACTION.
24
+ *
25
+ * Client component BẮT BUỘC import thẳng file action (đó là ranh giới RPC) —
26
+ * đi qua barrel sẽ kéo cả module (prisma/auth) vào bundle client và vỡ build
27
+ * production. Sự cố này đã xảy ra một lần, mất nửa ngày để lần ra.
28
+ */
29
+ const isServerActionTarget = (ctx: GuardrailContext, spec: string): boolean => {
30
+ const p = join(ctx.srcDir, spec.replace(/^@\//, "") + ".ts");
31
+ if (!existsSync(p)) return false;
32
+ return readFileSync(p, "utf8").trimStart().startsWith('"use server"');
33
+ };
34
+
35
+ /**
36
+ * Ranh giới tầng.
37
+ *
38
+ * Module hoá chỉ có giá trị nếu ranh giới được CANH. Không canh thì mỗi lần
39
+ * vội một người deep-import xuyên qua, và sau ba tháng cấu trúc thư mục còn
40
+ * đó nhưng đồ thị phụ thuộc đã là mì spaghetti như cũ.
41
+ */
42
+ export const layeringRules: GuardrailRule[] = [
43
+ {
44
+ id: "layering/module-public-api",
45
+ title: "bên ngoài chỉ được import module qua public API (index)",
46
+ why:
47
+ "Deep-import xuyên ranh giới tái tạo đúng mớ bòng bong mà việc module hoá " +
48
+ "đang gỡ: đổi một file nội bộ là gãy chỗ không liên quan.",
49
+ fix: "Import `@/modules/<domain>` (barrel). Ngoại lệ: stub app/api re-export thuần, và file server action (ranh giới RPC).",
50
+ run(ctx) {
51
+ const modules = moduleNames(ctx);
52
+ if (modules.length === 0) return null;
53
+ const offenders: string[] = [];
54
+ for (const file of ctx.files) {
55
+ const rel = ctx.rel(file);
56
+ const code = ctx.readCode(file);
57
+ if (isRouteStub(rel, code)) continue;
58
+ for (const m of modules) {
59
+ if (rel.startsWith(`modules/${m}/`)) continue; // nội bộ module đó
60
+ const deep = new RegExp(`["'](@/modules/${m}/[^"']+)["']`, "g");
61
+ for (const match of code.matchAll(deep)) {
62
+ if (isServerActionTarget(ctx, match[1])) continue;
63
+ offenders.push(`${rel} — deep-import ${match[1]} (dùng @/modules/${m})`);
64
+ }
65
+ }
66
+ }
67
+ return offenders;
68
+ },
69
+ },
70
+
71
+ {
72
+ id: "layering/no-cross-module-internals",
73
+ title: "module này không deep-import ruột module khác",
74
+ why:
75
+ "Hai module dính ruột nhau thì không tách được cái nào ra package riêng, " +
76
+ "và một thay đổi nội bộ lan sang domain khác.",
77
+ fix: "Đi qua public API `@/modules/<domain>`, hoặc kéo phần dùng chung xuống `@/lib`.",
78
+ run(ctx) {
79
+ const modules = moduleNames(ctx);
80
+ if (modules.length === 0) return null;
81
+ const offenders: string[] = [];
82
+ for (const file of ctx.files) {
83
+ const rel = ctx.rel(file);
84
+ const mine = modules.find((m) => rel.startsWith(`modules/${m}/`));
85
+ if (!mine) continue;
86
+ for (const other of modules) {
87
+ if (other === mine) continue;
88
+ if (new RegExp(`["']@/modules/${other}/`).test(ctx.readCode(file))) {
89
+ offenders.push(`${rel} — import ruột @/modules/${other}/...`);
90
+ }
91
+ }
92
+ }
93
+ return offenders;
94
+ },
95
+ },
96
+
97
+ {
98
+ id: "layering/lib-below-modules",
99
+ title: "lib KHÔNG được import @/modules (lib là tầng dưới)",
100
+ why:
101
+ "`lib` là hạ tầng — module dựng trên nó. Cho `lib` gọi ngược lên module " +
102
+ "là tạo vòng phụ thuộc: import một tiện ích nhỏ kéo theo cả domain.",
103
+ fix: "Wiring theo entity của app đặt ở `server/` hoặc `app/`, hoặc inject từ caller.",
104
+ run(ctx) {
105
+ if (moduleNames(ctx).length === 0) return null;
106
+ return ctx.files
107
+ .map(ctx.rel)
108
+ .filter((rel) => rel.startsWith("lib/"))
109
+ .filter((rel) => /["']@\/modules\//.test(ctx.readCodeRel(rel)))
110
+ .map((rel) => `${rel} — dời wiring sang server/ hoặc inject từ caller`);
111
+ },
112
+ },
113
+
114
+ {
115
+ id: "layering/module-not-import-app",
116
+ title: "module KHÔNG được import @/app (ngược tầng)",
117
+ why:
118
+ "Module là code nghiệp vụ độc lập. Import vào thư mục app là lộn ngược " +
119
+ "tầng: di chuyển một trang admin làm gãy module và cron, và khi promote " +
120
+ "module thành package thì path `@/app` không tồn tại.",
121
+ fix: "Đưa code dùng chung vào chính module hoặc xuống `@/lib`.",
122
+ run(ctx) {
123
+ const modules = moduleNames(ctx);
124
+ if (modules.length === 0) return null;
125
+ const offenders: string[] = [];
126
+ for (const file of ctx.files) {
127
+ const rel = ctx.rel(file);
128
+ if (!modules.some((m) => rel.startsWith(`modules/${m}/`))) continue;
129
+ if (/["']@\/app\//.test(ctx.readCode(file))) {
130
+ offenders.push(`${rel} — import @/app/... (dời code chung vào module/@/lib)`);
131
+ }
132
+ }
133
+ return offenders;
134
+ },
135
+ },
136
+
137
+ {
138
+ id: "layering/domain-framework-free",
139
+ title: "modules ngoài */api/ không dính Next (chạy được ngoài framework)",
140
+ why:
141
+ "Đây là điều kiện để sau này tách backend độc lập. Tầng domain lẫn " +
142
+ "`next/cache`, `next/server` hay chỉ thị `use server` thì nó không còn là " +
143
+ "domain nữa — nó là transport, và không test được ngoài Next.",
144
+ fix: "Dời phần Next (session/revalidatePath/'use server') ra `src/actions/` hoặc `modules/*/api/`.",
145
+ run(ctx) {
146
+ if (moduleNames(ctx).length === 0) return null;
147
+ const offenders: string[] = [];
148
+ for (const file of ctx.files) {
149
+ const rel = ctx.rel(file);
150
+ if (!rel.startsWith("modules/")) continue;
151
+ if (/^modules\/[a-z-]+\/api\//.test(rel)) continue;
152
+ const code = ctx.readCode(file);
153
+ if (/from ["']next\//.test(code) || /^\s*["']use server["']/.test(code)) {
154
+ offenders.push(`${rel} — tách phần Next ra src/actions/ hoặc modules/*/api/`);
155
+ }
156
+ }
157
+ return offenders;
158
+ },
159
+ },
160
+ ];
@@ -0,0 +1,115 @@
1
+ import { forbidPattern, singleDoorImport } from "../primitives";
2
+ import type { GuardrailRule } from "../types";
3
+
4
+ /**
5
+ * Một cửa.
6
+ *
7
+ * Mấy engine của core (`storage`, `branch-scope`) là **singleton phải cấu hình
8
+ * một lần**. Import thẳng từ core lấy được hàm nhưng KHÔNG kéo theo lời gọi cấu
9
+ * hình, nên code trông đúng và chỉ nổ lúc chạy thật. Với branch-scope thì "nổ"
10
+ * nghĩa là người chi nhánh này nhìn thấy dữ liệu chi nhánh khác — im lặng và
11
+ * nguy hiểm hơn nhiều so với một exception.
12
+ *
13
+ * Cùng logic áp cho những đường mà việc có HAI lối đi là bug: PrismaClient thứ
14
+ * hai = pool kết nối thứ hai + bỏ qua extension audit/branch-guard.
15
+ */
16
+ export const oneDoorRules: GuardrailRule[] = [
17
+ {
18
+ id: "one-door/single-prisma-client",
19
+ title: "chỉ một file được tạo PrismaClient",
20
+ why:
21
+ "Client thứ hai mở pool kết nối thứ hai và bỏ qua mọi extension đã cắm " +
22
+ "(audit log, chặn phạm vi chi nhánh) — dữ liệu ghi qua đường đó không có vết.",
23
+ fix: "Import `db` từ cửa duy nhất (mặc định `@/lib/prisma`). Script vận hành chạy độc lập ngoài Next thì thêm vào allowlist.",
24
+ run(ctx) {
25
+ const door = ctx.options.doors.prisma;
26
+ const allowed = new Set(
27
+ ctx.options.allowlists["one-door/single-prisma-client"] ?? [],
28
+ );
29
+ return ctx.files
30
+ .map(ctx.rel)
31
+ .filter(
32
+ (rel) =>
33
+ rel !== door && !allowed.has(rel) && /new PrismaClient\(/.test(ctx.readCodeRel(rel)),
34
+ );
35
+ },
36
+ staleAllowlist(ctx) {
37
+ const allowed = ctx.options.allowlists["one-door/single-prisma-client"] ?? [];
38
+ return allowed.filter(
39
+ (rel) => !ctx.existsInSrc(rel) || !/new PrismaClient\(/.test(ctx.readCodeRel(rel)),
40
+ );
41
+ },
42
+ },
43
+
44
+ singleDoorImport({
45
+ id: "one-door/storage",
46
+ title: "chỉ cửa kho tập tin được import @goerp/core/storage",
47
+ why:
48
+ "Engine kho là singleton cần `configureStorage()` một lần. Đã dính một " +
49
+ "lần khi rút ruột adapter MinIO: adapter hết import prisma nên composition " +
50
+ "root không còn chạy, và mọi lời gọi upload nổ 'chưa configureStorage'.",
51
+ fix: "Vào kho qua cửa của app (mặc định `@/lib/storage`), chấm hết.",
52
+ pattern: /from\s+["']@goerp\/core\/storage(\/s3)?["']/,
53
+ doors: (ctx) => ctx.options.doors.storage,
54
+ mustConfigure: /configureStorage\s*\(/,
55
+ }),
56
+
57
+ singleDoorImport({
58
+ id: "one-door/branch-scope",
59
+ title: "chỉ file hạ tầng được import @goerp/core/branch-scope",
60
+ why:
61
+ "Cùng bẫy singleton với kho tập tin, nhưng hậu quả nặng hơn: đây là đường " +
62
+ "dữ liệu. Quên cấu hình = phạm vi chi nhánh không áp = rò dữ liệu chéo chi nhánh.",
63
+ fix: "Import qua cửa của app (mặc định `@/lib/branch-scope`); composition root phải gọi `configureBranchScope`.",
64
+ pattern: /from\s+["']@goerp\/core\/branch-scope["']/,
65
+ doors: (ctx) => ctx.options.doors.branchScope,
66
+ mustConfigure: /configureBranchScope\s*[<(]/,
67
+ // Lớp 2 (`createBranchGuardExtension`) không cần cấu hình singleton; chỉ lớp
68
+ // 1 mới cần, và thiếu thì nó NÉM ở mọi truy vấn có phạm vi chi nhánh.
69
+ mustConfigureWhen: (ctx) =>
70
+ ctx.files.some((f) => /\b(getBranchScope|scopedBranchWhere)\s*\(/.test(ctx.readCode(f))),
71
+ }),
72
+
73
+ forbidPattern({
74
+ id: "one-door/export-no-window-open",
75
+ title: "không window.open(...) tới endpoint /export",
76
+ why:
77
+ "`window.open` tới API xuất file bỏ qua toàn bộ phần xử lý lỗi và trạng " +
78
+ "thái chờ: lỗi 500 hiện ra thành một tab trắng có JSON, người dùng tưởng " +
79
+ "app hỏng. Nó cũng bị trình duyệt chặn popup không báo.",
80
+ fix: "Dùng `useExport()` / `exportFile()` (@goerp/core/export/use-export).",
81
+ pattern: /window\.open\([^)\n]*export/,
82
+ }),
83
+
84
+ forbidPattern({
85
+ id: "one-door/import-dialog",
86
+ title: "không dùng lại CrudImportDialog đã gỡ",
87
+ why: "Hai hộp thoại nhập liệu song song thì sửa một bên, bên kia lặng lẽ giữ hành vi cũ.",
88
+ fix: "Dùng `ImportDialog` hợp nhất (@goerp/core/import).",
89
+ pattern: /import\s*\{[^}]*\bCrudImportDialog\b|<CrudImportDialog\b/,
90
+ }),
91
+
92
+ {
93
+ id: "one-door/api-500-persists",
94
+ title: "mọi đường trả 500 của route API đều ghi vào error_logs",
95
+ why:
96
+ "500 không ghi lại thì lỗi production chỉ còn là ảnh chụp màn hình của " +
97
+ "người dùng. Bảng error_logs là thứ duy nhất giữ stack trace.",
98
+ fix: "Bọc catch bằng `serverError(error, request, { message })` (hoặc `logServerError` nếu cần giữ response riêng).",
99
+ run(ctx) {
100
+ const offenders: string[] = [];
101
+ for (const file of ctx.files) {
102
+ const rel = ctx.rel(file);
103
+ if (!rel.startsWith("app/api/") || !rel.endsWith("/route.ts")) continue;
104
+ // Chính route ghi log bị loại trừ — nếu không thì đệ quy.
105
+ if (rel.startsWith("app/api/error-logs/")) continue;
106
+ const code = ctx.readCode(file);
107
+ if (!/status:\s*500/.test(code)) continue;
108
+ if (!/serverError|logServerError|apiHandler|withErrorHandler/.test(code)) {
109
+ offenders.push(rel);
110
+ }
111
+ }
112
+ return offenders;
113
+ },
114
+ },
115
+ ];
@@ -0,0 +1,282 @@
1
+ import { forbidPattern } from "../primitives";
2
+ import type { GuardrailContext, GuardrailRule } from "../types";
3
+
4
+ /** Bộ action ai cũng hiểu — mặc định, trùng `scripts/rbac-sync.ts` của starter. */
5
+ export const STANDARD_ACTIONS = [
6
+ "view",
7
+ "create",
8
+ "update",
9
+ "delete",
10
+ "export",
11
+ "import",
12
+ ];
13
+
14
+ const registryOf = (ctx: GuardrailContext) => ctx.options.permissionRegistry;
15
+
16
+ const declaredResources = (ctx: GuardrailContext) =>
17
+ new Set(
18
+ (registryOf(ctx) ?? []).flatMap((f) => f.resources.map((r) => r.code)),
19
+ );
20
+
21
+ const isApiFile = (rel: string) =>
22
+ (rel.startsWith("app/api/") && rel.endsWith("/route.ts")) ||
23
+ /^modules\/[a-z-]+\/api\//.test(rel);
24
+
25
+ /** Hàm CỔNG: thứ nhận `{ resource, action }` làm KHAI BÁO quyền. */
26
+ const DEFAULT_GATE_CALLS = [
27
+ "apiHandler",
28
+ "requirePermission",
29
+ "requireApiPermission",
30
+ ];
31
+
32
+ const gateCallsOf = (ctx: GuardrailContext) =>
33
+ ctx.options.domain?.gateCalls ?? DEFAULT_GATE_CALLS;
34
+
35
+ /**
36
+ * Đối tượng tuỳ chọn truyền vào một lời gọi cổng, trong một file.
37
+ *
38
+ * Quét trần `resource:\s*"..."` trên cả file đọc nhầm hai thứ rất phổ biến:
39
+ * `orderBy: { _count: { resource: "desc" } }` của Prisma (mã quyền tên `desc`),
40
+ * và payload nhật ký `logEntityAction({ resource: "stock-movement", action:
41
+ * "add-document" })` — ở đó `resource` là tên THỰC THỂ bị ghi vết, không phải
42
+ * mã quyền, nên đòi nó có trong registry là đòi sai. Cả năm "vi phạm" đầu tiên
43
+ * thước này tìm được ở vinhhoa đều thuộc hai loại đó.
44
+ *
45
+ * Nên chỉ đọc đúng chỗ khai: các đối số dạng object literal ở TẦNG NGOÀI CÙNG
46
+ * của lời gọi cổng. Thân handler nằm ở đối số khác (bắt đầu bằng `async`/`(`)
47
+ * nên mọi lời gọi lồng bên trong không lọt vào.
48
+ */
49
+ function gateOptionObjects(code: string, names: string[]): string[] {
50
+ const call = new RegExp(
51
+ `\\b(?:${names.join("|")})\\s*(?:<[^>()]*>)?\\s*\\(`,
52
+ "g",
53
+ );
54
+ const objects: string[] = [];
55
+ for (const m of code.matchAll(call)) {
56
+ let i = (m.index ?? 0) + m[0].length;
57
+ let depth = 0;
58
+ let segment = "";
59
+ const take = () => {
60
+ if (segment.trim().startsWith("{")) objects.push(segment);
61
+ segment = "";
62
+ };
63
+ while (i < code.length) {
64
+ const ch = code[i];
65
+ if ("([{".includes(ch)) depth++;
66
+ else if (")]}".includes(ch)) {
67
+ if (ch === ")" && depth === 0) break; // hết lời gọi
68
+ depth--;
69
+ } else if (ch === "," && depth === 0) {
70
+ take();
71
+ i++;
72
+ continue;
73
+ }
74
+ segment += ch;
75
+ i++;
76
+ }
77
+ take();
78
+ }
79
+ return objects;
80
+ }
81
+
82
+ const literal = (obj: string, key: string) =>
83
+ obj.match(new RegExp(`${key}:\\s*["']([^"']+)["']`))?.[1];
84
+
85
+ /**
86
+ * Toàn vẹn của registry quyền.
87
+ *
88
+ * Cả nhóm chống đúng một loại lỗi: **hỏng im lặng**. Khai sai quyền không làm
89
+ * app nổ — nó làm nút biến mất, mục menu ẩn vĩnh viễn kể cả với admin, hoặc
90
+ * grant chết không cấp cho ai. Không có thông báo nào, nên chỉ có phép quét
91
+ * tĩnh mới thấy.
92
+ */
93
+ export const rbacRules: GuardrailRule[] = [
94
+ {
95
+ id: "rbac/known-actions",
96
+ title:
97
+ "mọi action được dùng đều là action chuẩn hoặc đã khai customActions",
98
+ why: "rbac-sync ném lỗi khi gặp action lạ — bắt ở test rẻ hơn nhiều so với phát hiện lúc deploy.",
99
+ fix: "Thêm action vào `customActions` của feature, hoặc dùng một trong bộ chuẩn.",
100
+ run(ctx) {
101
+ const registry = registryOf(ctx);
102
+ if (!registry) return null;
103
+ const standard = ctx.options.standardActions ?? STANDARD_ACTIONS;
104
+ const custom = new Set(
105
+ registry.flatMap((f) => (f.customActions ?? []).map((a) => a.code)),
106
+ );
107
+ const unknown: string[] = [];
108
+ for (const feature of registry) {
109
+ for (const r of feature.resources) {
110
+ for (const a of r.actions) {
111
+ if (!standard.includes(a) && !custom.has(a))
112
+ unknown.push(`${r.code}:${a}`);
113
+ }
114
+ }
115
+ }
116
+ return unknown;
117
+ },
118
+ },
119
+
120
+ {
121
+ id: "rbac/default-grants-declared",
122
+ title: "defaultGrants chỉ trỏ tới action đã khai trên chính resource đó",
123
+ why:
124
+ "Grant trỏ vào action không tồn tại là grant CHẾT: seed chạy xong, vai trò " +
125
+ "trông như có quyền, thực tế không cấp gì. Không có cảnh báo.",
126
+ fix: "Khai action đó trong `resources[].actions`, hoặc bỏ khỏi `defaultGrants`.",
127
+ run(ctx) {
128
+ const registry = registryOf(ctx);
129
+ if (!registry) return null;
130
+ const bad: string[] = [];
131
+ for (const feature of registry) {
132
+ for (const r of feature.resources) {
133
+ for (const [role, grant] of Object.entries(r.defaultGrants ?? {})) {
134
+ if (grant === "*") continue;
135
+ for (const a of grant) {
136
+ if (!r.actions.includes(a)) bad.push(`${role} → ${r.code}:${a}`);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ return bad;
142
+ },
143
+ },
144
+
145
+ {
146
+ id: "rbac/resource-code-kebab-case",
147
+ title:
148
+ "mã resource dùng kebab-case (khớp chuỗi trong checkPermission và URL)",
149
+ why: "Mã lệch quy ước thì so chuỗi ở cổng trượt, mà trượt thì chỉ biểu hiện thành 403 khó truy.",
150
+ fix: "Đổi mã về dạng `a-b-c` (thường, số, gạch nối).",
151
+ run(ctx) {
152
+ const registry = registryOf(ctx);
153
+ if (!registry) return null;
154
+ return registry
155
+ .flatMap((f) => f.resources.map((r) => r.code))
156
+ .filter((code) => !/^[a-z][a-z0-9-]*$/.test(code));
157
+ },
158
+ },
159
+
160
+ {
161
+ id: "rbac/no-duplicate-resource",
162
+ title: "registry không khai trùng resource giữa các feature",
163
+ why:
164
+ "Hai feature cùng khai một mã thì bản nạp sau đè bản trước — action của " +
165
+ "bản trước biến mất khỏi seed, và diff không cho thấy gì bất thường.",
166
+ fix: "Gộp về một feature, hoặc đổi mã.",
167
+ run(ctx) {
168
+ const registry = registryOf(ctx);
169
+ if (!registry) return null;
170
+ const seen = new Set<string>();
171
+ const dup = new Set<string>();
172
+ for (const f of registry) {
173
+ for (const r of f.resources) {
174
+ if (seen.has(r.code)) dup.add(r.code);
175
+ seen.add(r.code);
176
+ }
177
+ }
178
+ return [...dup];
179
+ },
180
+ },
181
+
182
+ {
183
+ id: "rbac/nav-resource-declared",
184
+ title: "mọi mục navigation có `resource` đều đã khai trong registry",
185
+ why:
186
+ "Mục nav trỏ tới resource chưa khai sẽ ẨN VĨNH VIỄN với mọi người dùng, " +
187
+ "kể cả admin, mà không có thông báo nào — cả trang coi như biến mất.",
188
+ fix: "Khai resource đó trong registry, hoặc bỏ trường `resource` khỏi mục nav.",
189
+ run(ctx) {
190
+ const registry = registryOf(ctx);
191
+ const navs = ctx.options.navigations;
192
+ if (!registry || !navs) return null;
193
+ const declared = declaredResources(ctx);
194
+ const missing: string[] = [];
195
+ for (const group of navs) {
196
+ for (const item of group.items ?? []) {
197
+ if (item.resource && !declared.has(item.resource))
198
+ missing.push(item.resource);
199
+ }
200
+ }
201
+ return missing;
202
+ },
203
+ },
204
+
205
+ {
206
+ id: "rbac/gate-resource-declared",
207
+ title: "resource mà cổng gác đều đã khai trong registry",
208
+ why:
209
+ "Cổng khai một mã chưa có trong registry thì rbac-sync không seed mã đó → " +
210
+ "không vai trò nào cấp được → 403 vĩnh viễn. Runtime chỉ phát hiện khi route " +
211
+ "được gọi; quét tĩnh thấy cả route chưa ai đụng tới.",
212
+ fix: "Khai resource trong `src/configs/permissions`, rồi chạy lại `pnpm rbac-sync`.",
213
+ run(ctx) {
214
+ const registry = registryOf(ctx);
215
+ if (!registry) return null;
216
+ const declared = declaredResources(ctx);
217
+ const missing: string[] = [];
218
+ for (const file of ctx.files) {
219
+ const rel = ctx.rel(file);
220
+ if (!isApiFile(rel)) continue;
221
+ for (const obj of gateOptionObjects(
222
+ ctx.readCode(file),
223
+ gateCallsOf(ctx),
224
+ )) {
225
+ const code = literal(obj, "resource");
226
+ if (code && !declared.has(code)) missing.push(`${rel} → ${code}`);
227
+ }
228
+ }
229
+ return missing;
230
+ },
231
+ },
232
+
233
+ {
234
+ id: "rbac/gate-action-declared",
235
+ title: "mọi cặp (resource, action) khai ở cổng đều có trong registry",
236
+ why:
237
+ "Đây là lỗi tốn thời gian nhất của cả hệ: cổng đòi `orders:approve` mà " +
238
+ "registry chỉ khai `view/create/update` → nút hiện ra rồi bấm vào 403, " +
239
+ "hoặc UI gate ẩn nút vĩnh viễn vì `canCreate` fail-closed.",
240
+ fix: "Thêm action vào resource tương ứng trong registry (và `customActions` nếu ngoài bộ chuẩn).",
241
+ run(ctx) {
242
+ const registry = registryOf(ctx);
243
+ if (!registry) return null;
244
+ const byResource = new Map<string, Set<string>>();
245
+ for (const f of registry) {
246
+ for (const r of f.resources) {
247
+ const set = byResource.get(r.code) ?? new Set<string>();
248
+ r.actions.forEach((a) => set.add(a));
249
+ byResource.set(r.code, set);
250
+ }
251
+ }
252
+
253
+ const missing: string[] = [];
254
+ const gateCalls = gateCallsOf(ctx);
255
+ for (const file of ctx.files) {
256
+ const rel = ctx.rel(file);
257
+ for (const obj of gateOptionObjects(ctx.readCode(file), gateCalls)) {
258
+ const resource = literal(obj, "resource");
259
+ const action = literal(obj, "action");
260
+ if (!resource || !action) continue;
261
+ const actions = byResource.get(resource);
262
+ // Resource chưa khai đã có rule riêng lo — ở đây chỉ xét action.
263
+ if (actions && !actions.has(action)) {
264
+ missing.push(`${rel} → ${resource}:${action}`);
265
+ }
266
+ }
267
+ }
268
+ return missing;
269
+ },
270
+ },
271
+
272
+ forbidPattern({
273
+ id: "rbac/permissions-config-is-pure-data",
274
+ title: "cấu hình quyền là dữ liệu thuần — không kéo prisma/next/react vào",
275
+ why:
276
+ "`scripts/rbac-sync.ts` và test đọc thẳng các file này qua tsx, ngoài môi " +
277
+ "trường Next. Kéo `next/*` hay `@prisma/client` vào là seed gãy ngay lệnh đầu.",
278
+ fix: "Giữ `src/configs/permissions` là object literal + type; phần cần Prisma đặt ở script.",
279
+ pattern: /from ["'](@\/lib\/prisma|next|react|@prisma)/,
280
+ include: (rel) => rel.startsWith("configs/permissions"),
281
+ }),
282
+ ];
@@ -0,0 +1,86 @@
1
+ import { forbidPattern } from "../primitives";
2
+ import type { GuardrailRule } from "../types";
3
+
4
+ /**
5
+ * An toàn giao dịch & khoá.
6
+ *
7
+ * Hai rule đầu chặn kiểu lỗi làm TREO CẢ APP chứ không phải sai một màn hình:
8
+ * giữ kết nối DB trong lúc chờ mạng thì pool cạn, và khoá hàng bằng `FOR UPDATE`
9
+ * thì deadlock với advisory lock đánh số chứng từ. Rule thứ ba chặn kiểu chậm
10
+ * dần đều: truy vấn không chặn số dòng, chạy tốt hôm nay và chết theo lịch sử.
11
+ */
12
+ export const safetyRules: GuardrailRule[] = [
13
+ forbidPattern({
14
+ id: "safety/no-raw-for-update",
15
+ title: "không dùng `FOR UPDATE` thô — nối tiếp qua advisory lock",
16
+ why:
17
+ "Khoá hàng bằng FOR UPDATE cùng lúc với advisory lock đánh số chứng từ tạo " +
18
+ "hai thứ tự khoá khác nhau → deadlock, và deadlock ở đây biểu hiện thành " +
19
+ "'lưu đơn bị treo' chứ không thành lỗi rõ ràng.",
20
+ fix: "Dùng advisory lock của bộ đánh số chứng từ (@goerp/core/document-number).",
21
+ pattern: /\bFOR\s+UPDATE\b/i,
22
+ }),
23
+
24
+ {
25
+ id: "safety/no-network-in-transaction",
26
+ title: "không gọi mạng/tích hợp bên trong thân $transaction",
27
+ why:
28
+ "Chờ HTTP trong khi đang giữ một kết nối DB và các khoá của nó: một tích " +
29
+ "hợp chậm là đủ làm cạn pool và cả app đứng, chứ không chỉ tính năng đó lỗi.",
30
+ fix: "Gọi tích hợp TRƯỚC hoặc SAU transaction; trong transaction chỉ đọc/ghi DB.",
31
+ run(ctx) {
32
+ const danger = ctx.options.domain?.networkInTransaction ?? /\bfetch\(/;
33
+ const test = new RegExp(danger.source, danger.flags.replace(/[gy]/g, ""));
34
+ const offenders: string[] = [];
35
+ for (const file of ctx.files) {
36
+ const code = ctx.readCode(file);
37
+ if (!code.includes("$transaction(")) continue;
38
+ if (ctx.callBodies(code, "$transaction(").some((b) => test.test(b))) {
39
+ offenders.push(ctx.rel(file));
40
+ }
41
+ }
42
+ return offenders;
43
+ },
44
+ },
45
+
46
+ {
47
+ id: "safety/page-findmany-capped",
48
+ title: "findMany của bảng giao dịch trong page phải chặn số dòng bằng `take`",
49
+ why:
50
+ "Một Server Component nạp cả bảng giao dịch, JSON.stringify nó, rồi đổ vào " +
51
+ "`<Select>` sẽ render mọi hàng — chi phí lớn dần theo lịch sử, nên nó chạy " +
52
+ "tốt lúc viết và chậm dần tới mức không mở nổi trang sau vài tháng.",
53
+ fix: "Thêm `take` (và chèn riêng hàng đang được tham chiếu nếu nó có thể nằm ngoài giới hạn).",
54
+ run(ctx) {
55
+ const models = ctx.options.domain?.cappedFindManyModels;
56
+ if (!models || models.length === 0) return null;
57
+ const allowed = new Set(ctx.options.allowlists["safety/page-findmany-capped"] ?? []);
58
+ const offenders: string[] = [];
59
+ for (const file of ctx.files) {
60
+ const rel = ctx.rel(file);
61
+ if (!rel.startsWith("app/") || !rel.endsWith("page.tsx")) continue;
62
+ if (allowed.has(rel)) continue;
63
+ const code = ctx.readCode(file);
64
+ for (const model of models) {
65
+ for (const body of ctx.callBodies(code, `db.${model}.findMany(`)) {
66
+ if (!/\btake\s*:/.test(body)) {
67
+ offenders.push(`${rel} — db.${model}.findMany thiếu take`);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return offenders;
73
+ },
74
+ staleAllowlist(ctx) {
75
+ const models = ctx.options.domain?.cappedFindManyModels ?? [];
76
+ const allowed = ctx.options.allowlists["safety/page-findmany-capped"] ?? [];
77
+ return allowed.filter((rel) => {
78
+ if (!ctx.existsInSrc(rel)) return true;
79
+ const code = ctx.readCodeRel(rel);
80
+ return models.every((m) =>
81
+ ctx.callBodies(code, `db.${m}.findMany(`).every((b) => /\btake\s*:/.test(b)),
82
+ );
83
+ });
84
+ },
85
+ },
86
+ ];