@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +85 -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/types/index.ts +1 -1
  22. package/src/ui/data-display/shallow-pagination.tsx +189 -0
  23. package/src/ui/index.tsx +1 -0
  24. package/src/user/components/index.ts +1 -0
  25. package/src/user/components/user-toolbar.tsx +8 -2
  26. package/src/user/components/user-visuals.tsx +84 -0
  27. package/src/user/components/users-card-view.tsx +1 -26
  28. package/src/user/components/users-table.tsx +215 -0
  29. package/src/user/pages/users-client-page.tsx +84 -259
  30. package/templates/starter-app/AGENTS.md +39 -3
  31. package/templates/starter-app/eslint.config.mjs +85 -0
  32. package/templates/starter-app/package.json +19 -2
  33. package/templates/starter-app/prettier.config.mjs +54 -0
  34. package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
@@ -1,151 +1,43 @@
1
- import { readdirSync, readFileSync, statSync } from "node:fs"
2
- import { join, relative } from "node:path"
1
+ import { join } from "node:path"
3
2
 
4
- import { describe, expect, it } from "vitest"
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
- * RATCHET hàng rào kiến trúc. Mỗi kiểm tra ở đây tương ứng một lỗi im lặng
11
- * đã từng làm mất thời gian trong app thật: quyền khai sai thì nút biến mất
12
- * không báo lỗi, route quên gác thì thành lỗ hổng, hai Prisma client thì rò
13
- * connection pool. Thêm quy tắc mới khi bạn gặp lỗi loại đó; đừng nới lỏng.
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
- const SRC = join(__dirname, "..")
17
-
18
- function walk(dir: string, out: string[] = []): string[] {
19
- for (const entry of readdirSync(dir)) {
20
- const full = join(dir, entry)
21
- if (statSync(full).isDirectory()) {
22
- if (entry === "node_modules" || entry === "__tests__") continue
23
- walk(full, out)
24
- } else if (/\.(ts|tsx)$/.test(entry)) {
25
- out.push(full)
26
- }
27
- }
28
- return out
29
- }
30
-
31
- const allFiles = walk(SRC)
32
- const read = (f: string) => readFileSync(f, "utf8")
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
  })