@goplusvn/core 0.1.66 → 0.1.69
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.
- package/CHANGELOG.md +116 -0
- package/bin/goerp-guardrails.mjs +45 -0
- package/eslint/index.mjs +120 -0
- package/package.json +10 -3
- package/scripts/doctor.ts +99 -0
- package/src/auth/__tests__/permissions.test.ts +162 -0
- package/src/auth/index.ts +113 -34
- package/src/guardrails/__tests__/guardrails.test.ts +430 -0
- package/src/guardrails/index.ts +57 -0
- package/src/guardrails/preset.ts +75 -0
- package/src/guardrails/primitives.ts +307 -0
- package/src/guardrails/rules/auth.ts +178 -0
- package/src/guardrails/rules/debt.ts +71 -0
- package/src/guardrails/rules/design.ts +95 -0
- package/src/guardrails/rules/layering.ts +160 -0
- package/src/guardrails/rules/one-door.ts +115 -0
- package/src/guardrails/rules/rbac.ts +282 -0
- package/src/guardrails/rules/safety.ts +86 -0
- package/src/guardrails/rules/structure.ts +136 -0
- package/src/guardrails/run.ts +130 -0
- package/src/guardrails/scanner.ts +144 -0
- package/src/guardrails/types.ts +181 -0
- package/src/rbac/index.ts +6 -9
- package/src/types/index.ts +17 -0
- package/src/ui/data-display/shallow-pagination.tsx +189 -0
- package/src/ui/index.tsx +1 -0
- package/src/user/components/index.ts +1 -0
- package/src/user/components/user-toolbar.tsx +8 -2
- package/src/user/components/user-visuals.tsx +84 -0
- package/src/user/components/users-card-view.tsx +1 -26
- package/src/user/components/users-table.tsx +215 -0
- package/src/user/pages/users-client-page.tsx +84 -259
- package/templates/starter-app/AGENTS.md +39 -3
- package/templates/starter-app/eslint.config.mjs +85 -0
- package/templates/starter-app/package.json +19 -2
- package/templates/starter-app/prettier.config.mjs +54 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { forbidFile } from "../primitives";
|
|
2
|
+
import type { GuardrailRule } from "../types";
|
|
3
|
+
|
|
4
|
+
const BASE_DISPLAY = /^(flex|inline-flex|grid|inline-grid|block|inline-block|table)$/;
|
|
5
|
+
const BP_HIDDEN = /^(sm|md|lg|xl|2xl):hidden$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hệ thiết kế.
|
|
9
|
+
*
|
|
10
|
+
* Hai rule đầu giữ cho token ở MỘT nguồn (core), rule thứ ba là một bẫy
|
|
11
|
+
* Tailwind v4 rất khó truy, rule cuối giữ bảng danh sách không phân mảnh trở lại.
|
|
12
|
+
*/
|
|
13
|
+
export const designRules: GuardrailRule[] = [
|
|
14
|
+
forbidFile({
|
|
15
|
+
id: "design/no-tailwind-config",
|
|
16
|
+
title: "không có tailwind.config.ts — Tailwind v4 đọc @theme trong globals.css",
|
|
17
|
+
why:
|
|
18
|
+
"File config JS bị v4 bỏ qua, nên nó là code chết trông như đang có tác " +
|
|
19
|
+
"dụng: sửa token ở đó không đổi gì và người sửa mất khá lâu mới hiểu.",
|
|
20
|
+
fix: "Xoá tailwind.config.ts; khai token trong `@theme` của globals.css.",
|
|
21
|
+
paths: ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs"],
|
|
22
|
+
}),
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
id: "design/globals-imports-core-tokens",
|
|
26
|
+
title: "globals.css nạp tầng token chuẩn từ @goerp/core",
|
|
27
|
+
why:
|
|
28
|
+
"Hệ token (Radix → shadcn → semantic) sống ở `@goerp/core/styles/base.css` " +
|
|
29
|
+
"và được mọi app dùng chung. App tự khai lại tầng `@theme` là fork âm thầm: " +
|
|
30
|
+
"nâng cấp core không tới được app đó nữa.",
|
|
31
|
+
fix: "Giữ `@import \"@goerp/core/styles/base.css\"` trong src/app/globals.css.",
|
|
32
|
+
run(ctx) {
|
|
33
|
+
if (!ctx.existsInSrc("app/globals.css")) return null;
|
|
34
|
+
const css = ctx.readRel("app/globals.css");
|
|
35
|
+
return css.includes("@goerp/core/styles/base.css")
|
|
36
|
+
? []
|
|
37
|
+
: ["app/globals.css — thiếu @import @goerp/core/styles/base.css"];
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
id: "design/no-fragile-flex-hidden",
|
|
43
|
+
title: "không dùng `flex {bp}:hidden` — dùng `hidden max-{bp}:flex`",
|
|
44
|
+
why:
|
|
45
|
+
"Tailwind v4 + Next chia CSS thành chunk: class `.flex` cơ sở có thể được " +
|
|
46
|
+
"emit ở chunk tải SAU rồi đè `.lg:hidden` (media query KHÔNG tăng độ ưu " +
|
|
47
|
+
"tiên → cái đứng sau thắng). Hậu quả: phần tử 'chỉ-mobile' vẫn hiện trên " +
|
|
48
|
+
"desktop → header/thanh công cụ NHÂN ĐÔI. Đã dính ở 4 màn cùng lúc.",
|
|
49
|
+
fix: "Đảo lại: cơ sở là `hidden`, hiện lại bằng `max-{bp}:flex` — không có `.flex` cơ sở để bị đè.",
|
|
50
|
+
run(ctx) {
|
|
51
|
+
const offenders: string[] = [];
|
|
52
|
+
for (const file of ctx.files) {
|
|
53
|
+
if (!file.endsWith(".tsx")) continue;
|
|
54
|
+
const code = ctx.readCode(file);
|
|
55
|
+
const classStrings = [
|
|
56
|
+
...code.matchAll(/className=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g),
|
|
57
|
+
].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
|
|
58
|
+
for (const cls of classStrings) {
|
|
59
|
+
const tokens = cls.split(/\s+/).filter(Boolean);
|
|
60
|
+
if (
|
|
61
|
+
tokens.some((t) => BASE_DISPLAY.test(t)) &&
|
|
62
|
+
tokens.some((t) => BP_HIDDEN.test(t))
|
|
63
|
+
) {
|
|
64
|
+
offenders.push(`${ctx.rel(file)} ["${cls.slice(0, 70)}"]`);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return offenders;
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
id: "design/list-table-uses-kit",
|
|
75
|
+
title: "bảng mới trong app/ dùng DataTableWrapper, không tự dựng <Table>",
|
|
76
|
+
why:
|
|
77
|
+
"Bảng tự dựng lại thiếu một trong: header dính, phân trang, trạng thái " +
|
|
78
|
+
"rỗng, hàng bấm được, footer tổng. Mỗi bảng thiếu một thứ khác nhau, và " +
|
|
79
|
+
"người dùng học lại thao tác ở mỗi trang.",
|
|
80
|
+
fix: "Dùng `DataTableWrapper` + cột tanstack trong `*-columns.tsx`. Thật sự phải tự dựng thì thêm vào allowlist kèm lý do.",
|
|
81
|
+
run(ctx) {
|
|
82
|
+
const allowed = new Set(ctx.options.allowlists["design/list-table-uses-kit"] ?? []);
|
|
83
|
+
const handRolled = ctx.files
|
|
84
|
+
.map(ctx.rel)
|
|
85
|
+
.filter((rel) => rel.startsWith("app/") && /<TableBody/.test(ctx.readCodeRel(rel)));
|
|
86
|
+
return handRolled.filter((rel) => !allowed.has(rel));
|
|
87
|
+
},
|
|
88
|
+
staleAllowlist(ctx) {
|
|
89
|
+
const allowed = ctx.options.allowlists["design/list-table-uses-kit"] ?? [];
|
|
90
|
+
return allowed.filter(
|
|
91
|
+
(rel) => !ctx.existsInSrc(rel) || !/<TableBody/.test(ctx.readCodeRel(rel)),
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
];
|
|
@@ -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
|
+
];
|