@goplusvn/core 0.1.67 → 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 +85 -1
- 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/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/types/index.ts +1 -1
- 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
|
@@ -1,151 +1,43 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join, relative } from "node:path"
|
|
1
|
+
import { join } from "node:path"
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { runCoreGuardrails } from "@goerp/core/guardrails"
|
|
5
4
|
|
|
6
5
|
import { permissionRegistry } from "@/configs/permissions"
|
|
7
6
|
import { navigations } from "@/data/navigations"
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* HÀNG RÀO KIẾN TRÚC — mỗi thước ở đây ứng với một lỗi im lặng đã từng làm mất
|
|
10
|
+
* thời gian trong app thật: quyền khai sai thì nút biến mất mà không báo lỗi,
|
|
11
|
+
* route quên gác thì thành lỗ hổng, hai Prisma client thì rò connection pool.
|
|
12
|
+
*
|
|
13
|
+
* Luật KHÔNG nằm trong file này — nó nằm ở `@goerp/core/guardrails`, dùng chung
|
|
14
|
+
* cho mọi app. Lý do: một bài học rút ra ở app này phải tự đến được các app
|
|
15
|
+
* khác qua `pnpm up @goerp/core`. Chép luật ra file riêng là đóng băng nó ở
|
|
16
|
+
* ngày hôm nay.
|
|
17
|
+
*
|
|
18
|
+
* File này chỉ khai phần RIÊNG của app:
|
|
19
|
+
* - `allowlists` — nợ cũ đã biết, CHỈ ĐƯỢC RÚT BỚT (bỏ thêm entry mới = mở cửa);
|
|
20
|
+
* - `ceilings` — trần nợ theo thư mục, chỉ được HẠ và không được dư;
|
|
21
|
+
* - `skip` — tắt hẳn một thước, phải kèm lý do (thấy được lúc review).
|
|
22
|
+
*
|
|
23
|
+
* Chạy `pnpm guardrails` để xem toàn bộ danh sách thước và trạng thái hiện tại.
|
|
14
24
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const rel = (f: string) => relative(SRC, f)
|
|
34
|
-
|
|
35
|
-
/** Bộ action ai cũng hiểu — trùng danh sách trong scripts/rbac-sync.ts. */
|
|
36
|
-
const STANDARD_ACTIONS = ["view", "create", "update", "delete", "export", "import"]
|
|
37
|
-
|
|
38
|
-
describe("permission registry", () => {
|
|
39
|
-
const declaredCustom = new Set(
|
|
40
|
-
permissionRegistry.flatMap((f) => (f.customActions ?? []).map((a) => a.code)),
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
it("mọi action được dùng đều là action chuẩn hoặc đã khai customActions", () => {
|
|
44
|
-
const unknown: string[] = []
|
|
45
|
-
for (const feature of permissionRegistry) {
|
|
46
|
-
for (const r of feature.resources) {
|
|
47
|
-
for (const a of r.actions) {
|
|
48
|
-
if (!STANDARD_ACTIONS.includes(a) && !declaredCustom.has(a)) {
|
|
49
|
-
unknown.push(`${r.code}:${a}`)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
// rbac-sync ném lỗi khi gặp trường hợp này — bắt sớm ở test rẻ hơn nhiều
|
|
55
|
-
// so với phát hiện lúc deploy.
|
|
56
|
-
expect(unknown).toEqual([])
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it("defaultGrants chỉ trỏ tới action đã khai trên chính resource đó", () => {
|
|
60
|
-
const bad: string[] = []
|
|
61
|
-
for (const feature of permissionRegistry) {
|
|
62
|
-
for (const r of feature.resources) {
|
|
63
|
-
for (const [roleCode, grant] of Object.entries(r.defaultGrants ?? {})) {
|
|
64
|
-
if (grant === "*") continue
|
|
65
|
-
for (const a of grant) {
|
|
66
|
-
if (!r.actions.includes(a)) bad.push(`${roleCode} → ${r.code}:${a}`)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
expect(bad).toEqual([])
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it("mã resource dùng kebab-case (khớp chuỗi trong checkPermission và URL)", () => {
|
|
75
|
-
const bad = permissionRegistry
|
|
76
|
-
.flatMap((f) => f.resources.map((r) => r.code))
|
|
77
|
-
.filter((code) => !/^[a-z][a-z0-9-]*$/.test(code))
|
|
78
|
-
expect(bad).toEqual([])
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it("mọi mục navigation có `resource` đều đã khai trong registry", () => {
|
|
82
|
-
const declared = new Set(
|
|
83
|
-
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
|
|
84
|
-
)
|
|
85
|
-
const missing: string[] = []
|
|
86
|
-
for (const group of navigations) {
|
|
87
|
-
for (const item of group.items ?? []) {
|
|
88
|
-
const resource = (item as { resource?: string }).resource
|
|
89
|
-
if (resource && !declared.has(resource)) missing.push(resource)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
// Mục nav trỏ tới resource chưa khai sẽ ẨN VĨNH VIỄN với mọi người dùng
|
|
93
|
-
// (kể cả admin) mà không có thông báo nào.
|
|
94
|
-
expect(missing).toEqual([])
|
|
95
|
-
})
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
describe("ranh giới kiến trúc", () => {
|
|
99
|
-
it("không còn dấu vết NextAuth (app đã sang Better Auth)", () => {
|
|
100
|
-
const offenders = allFiles.filter((f) => /from ["']next-auth/.test(read(f)))
|
|
101
|
-
expect(offenders.map(rel)).toEqual([])
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it("chỉ src/lib/prisma.ts được tạo PrismaClient", () => {
|
|
105
|
-
const offenders = allFiles.filter(
|
|
106
|
-
(f) => /new PrismaClient\(/.test(read(f)) && rel(f) !== "lib/prisma.ts",
|
|
107
|
-
)
|
|
108
|
-
// Client thứ hai = pool kết nối thứ hai + bỏ qua audit extension.
|
|
109
|
-
expect(offenders.map(rel)).toEqual([])
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it("route API đều đi qua getSession hoặc handler có gác quyền của core", () => {
|
|
113
|
-
const routes = allFiles.filter((f) => /app\/api\/.*route\.ts$/.test(rel(f)))
|
|
114
|
-
expect(routes.length).toBeGreaterThan(0)
|
|
115
|
-
|
|
116
|
-
// better-auth tự lo xác thực cho chính nó (đăng nhập thì làm gì có phiên).
|
|
117
|
-
const exempt = ["app/api/better-auth"]
|
|
118
|
-
const offenders = routes.filter((f) => {
|
|
119
|
-
if (exempt.some((p) => rel(f).startsWith(p))) return false
|
|
120
|
-
const src = read(f)
|
|
121
|
-
// apiHandler( = cổng của app — chấp cả dạng có generic
|
|
122
|
-
// `apiHandler<{ key: string[] }>(` ở route catch-all; create*Handlers( =
|
|
123
|
-
// factory sẵn của core (rbac/crud) vốn đã tự gác session + quyền bên trong.
|
|
124
|
-
return !/apiHandler[<(]|create\w*Handlers\(|getSession/.test(src)
|
|
125
|
-
})
|
|
126
|
-
expect(offenders.map(rel)).toEqual([])
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it("resource mà route gác đều đã khai trong registry", () => {
|
|
130
|
-
const declared = new Set(
|
|
131
|
-
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
|
|
132
|
-
)
|
|
133
|
-
const missing: string[] = []
|
|
134
|
-
for (const file of allFiles.filter((f) => /app\/api\/.*route\.ts$/.test(rel(f)))) {
|
|
135
|
-
for (const [, code] of read(file).matchAll(/resource:\s*["']([^"']+)["']/g)) {
|
|
136
|
-
if (!declared.has(code)) missing.push(`${rel(file)} → ${code}`)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// assertResourceDeclared cũng bắt lỗi này, nhưng chỉ khi route được load.
|
|
140
|
-
// Test quét tĩnh nên thấy cả route chưa ai gọi tới.
|
|
141
|
-
expect(missing).toEqual([])
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
it("cấu hình quyền là dữ liệu thuần — không kéo prisma/next/react vào", () => {
|
|
145
|
-
const offenders = allFiles
|
|
146
|
-
.filter((f) => rel(f).startsWith("configs/permissions"))
|
|
147
|
-
.filter((f) => /from ["'](@\/lib\/prisma|next|react|@prisma)/.test(read(f)))
|
|
148
|
-
// scripts/rbac-sync.ts và test đọc thẳng các file này qua tsx.
|
|
149
|
-
expect(offenders.map(rel)).toEqual([])
|
|
150
|
-
})
|
|
25
|
+
runCoreGuardrails({
|
|
26
|
+
// Neo vào vị trí file này, không lấy cwd: chạy vitest từ thư mục khác (gốc
|
|
27
|
+
// monorepo chẳng hạn) mà vẫn quét đúng cây nguồn của app.
|
|
28
|
+
root: join(__dirname, "..", ".."),
|
|
29
|
+
permissionRegistry,
|
|
30
|
+
navigations,
|
|
31
|
+
|
|
32
|
+
ceilings: {
|
|
33
|
+
// Năm chỗ `any` này có sẵn trong bộ khung, mỗi chỗ đã kèm một dòng
|
|
34
|
+
// eslint-disable giải thích. Chúng được KHAI ở đây để mọi `any` bạn thêm về
|
|
35
|
+
// sau đều bị chặn ngay, thay vì lẫn vào đám cũ. Bỏ được chỗ nào thì hạ số
|
|
36
|
+
// tương ứng trong CÙNG commit — trần dư cũng bị báo đỏ.
|
|
37
|
+
"debt/any": {
|
|
38
|
+
app: 2, // Record<string, any> cho where của Prisma (audit, error-logs)
|
|
39
|
+
lib: 2, // session as any ở api-handler / page-guard
|
|
40
|
+
server: 1, // mask(config: any) trong system-config-service
|
|
41
|
+
},
|
|
42
|
+
},
|
|
151
43
|
})
|