@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
package/src/auth/index.ts
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
// @goerp/core/auth
|
|
2
2
|
// Authentication and RBAC utilities for GoERP
|
|
3
3
|
|
|
4
|
-
import type { Session } from "../types";
|
|
4
|
+
import type { Permission, PermissionMap, Session } from "../types";
|
|
5
5
|
|
|
6
6
|
export * from "./auth-service";
|
|
7
7
|
|
|
8
|
+
// `Permission` từng được KHAI BÁO LẠI ở đây, y hệt bản trong ../types — hai
|
|
9
|
+
// nguồn sự thật cho cùng một shape. Giờ chỉ còn re-export.
|
|
10
|
+
export type { Permission, PermissionMap };
|
|
11
|
+
|
|
8
12
|
// ============================================================================
|
|
9
13
|
// Types
|
|
10
14
|
// ============================================================================
|
|
11
15
|
|
|
12
|
-
export interface Permission {
|
|
13
|
-
resourceCode: string;
|
|
14
|
-
actionCode: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
16
|
export interface ExtendedUser {
|
|
18
17
|
id: string;
|
|
19
18
|
name?: string | null;
|
|
20
19
|
email?: string | null;
|
|
21
20
|
image?: string | null;
|
|
22
21
|
roles?: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Dạng GỌN, nên dùng: resource → các action. Xem `PermissionMap`.
|
|
24
|
+
* Có `permissionMap` thì `permissions` bị BỎ QUA hoàn toàn.
|
|
25
|
+
*/
|
|
26
|
+
permissionMap?: PermissionMap;
|
|
27
|
+
/** Dạng cũ. Vẫn chạy nguyên vẹn, chỉ tốn ~4,5 lần payload. */
|
|
23
28
|
permissions?: Permission[];
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -72,17 +77,105 @@ export function getActionCode(action: string): string {
|
|
|
72
77
|
// Permission Functions
|
|
73
78
|
// ============================================================================
|
|
74
79
|
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Permission Map — chuyển đổi + tra cứu
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/** `Permission[]` → dạng gọn. Tự gộp trùng. */
|
|
85
|
+
export function toPermissionMap(permissions: Permission[]): PermissionMap {
|
|
86
|
+
const map: PermissionMap = {};
|
|
87
|
+
for (const p of permissions) {
|
|
88
|
+
const actions = (map[p.resourceCode] ??= []);
|
|
89
|
+
if (!actions.includes(p.actionCode)) actions.push(p.actionCode);
|
|
90
|
+
}
|
|
91
|
+
return map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Dạng gọn → `Permission[]`. Chỉ dùng ở ranh giới với code chưa chuyển. */
|
|
95
|
+
export function fromPermissionMap(map: PermissionMap): Permission[] {
|
|
96
|
+
const out: Permission[] = [];
|
|
97
|
+
for (const resourceCode of Object.keys(map)) {
|
|
98
|
+
for (const actionCode of map[resourceCode]) {
|
|
99
|
+
out.push({ resourceCode, actionCode });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Nhớ bản đã chuyển đổi theo CHÍNH mảng `permissions` của session cũ.
|
|
107
|
+
*
|
|
108
|
+
* Không có nó, mỗi lần `checkPermission` của app chưa chuyển sẽ dựng lại map
|
|
109
|
+
* từ đầu — một trang gọi 20 lần là 20 lần dựng, tệ hơn hẳn phép quét tuyến
|
|
110
|
+
* tính có thoát sớm mà nó thay thế. WeakMap khoá theo tham chiếu mảng nên
|
|
111
|
+
* session hết hạn là bản nhớ tự rụng, không cần dọn.
|
|
112
|
+
*/
|
|
113
|
+
const legacyMapCache = new WeakMap<Permission[], PermissionMap>();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Đọc quyền của session về dạng gọn, bất kể app gán kiểu nào.
|
|
117
|
+
* Trả `undefined` khi session không có user (khác hẳn "user không có quyền
|
|
118
|
+
* nào", vốn là `{}`) để chỗ gọi tự quyết cách xử lý.
|
|
119
|
+
*/
|
|
120
|
+
function readPermissionMap(session: Session | null): PermissionMap | undefined {
|
|
121
|
+
if (!session?.user) return undefined;
|
|
122
|
+
const user = session.user as ExtendedUser;
|
|
123
|
+
if (user.permissionMap) return user.permissionMap;
|
|
124
|
+
if (!user.permissions) return {};
|
|
125
|
+
|
|
126
|
+
let cached = legacyMapCache.get(user.permissions);
|
|
127
|
+
if (!cached) {
|
|
128
|
+
cached = toPermissionMap(user.permissions);
|
|
129
|
+
legacyMapCache.set(user.permissions, cached);
|
|
130
|
+
}
|
|
131
|
+
return cached;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isAdminUser(user: ExtendedUser): boolean {
|
|
135
|
+
return Boolean(
|
|
136
|
+
user.roles?.includes(ADMIN_ROLE_CODE) || user.roles?.includes("SUPER_ADMIN"),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Các resource user chạm được — dùng để lọc menu và App Launcher.
|
|
142
|
+
*
|
|
143
|
+
* `actionCode` bỏ trống = "có BẤT KỲ action nào" (thấy tile là vào được trang);
|
|
144
|
+
* truyền `"view"` = đúng nghĩa xem được. Admin trả `Set(["*"])`, khớp quy ước
|
|
145
|
+
* wildcard mà cây điều hướng đang dùng.
|
|
146
|
+
*
|
|
147
|
+
* Có sẵn ở core vì mọi app đều tự viết lại đúng vòng lặp này, mỗi nơi một kiểu.
|
|
148
|
+
*/
|
|
149
|
+
export function getAccessibleResources(
|
|
150
|
+
session: Session | null,
|
|
151
|
+
actionCode?: string,
|
|
152
|
+
): Set<string> {
|
|
153
|
+
if (!session?.user) return new Set();
|
|
154
|
+
if (isAdminUser(session.user as ExtendedUser)) return new Set(["*"]);
|
|
155
|
+
|
|
156
|
+
const map = readPermissionMap(session) ?? {};
|
|
157
|
+
const out = new Set<string>();
|
|
158
|
+
for (const resourceCode of Object.keys(map)) {
|
|
159
|
+
if (!actionCode || map[resourceCode].includes(actionCode)) {
|
|
160
|
+
out.add(resourceCode);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
75
166
|
/**
|
|
76
167
|
* Get all permissions from session
|
|
168
|
+
*
|
|
169
|
+
* Trả về dạng CŨ (`Permission[]`) nên phải bung map ra — mỗi lần gọi là một
|
|
170
|
+
* lần dựng lại vài nghìn object. Cần lọc theo resource thì dùng
|
|
171
|
+
* `getAccessibleResources`; cần kiểm một quyền thì dùng `checkPermission`.
|
|
77
172
|
*/
|
|
78
173
|
export function getUserPermissions(session: Session | null): Permission[] {
|
|
79
174
|
if (!session?.user) return [];
|
|
80
175
|
const user = session.user as ExtendedUser;
|
|
81
176
|
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
return user.permissions;
|
|
177
|
+
if (user.permissionMap) return fromPermissionMap(user.permissionMap);
|
|
178
|
+
return user.permissions ?? [];
|
|
86
179
|
}
|
|
87
180
|
|
|
88
181
|
/**
|
|
@@ -135,10 +228,7 @@ export function getCrudPermissionsFromSession(
|
|
|
135
228
|
}
|
|
136
229
|
|
|
137
230
|
// Check admin role first (fastest check)
|
|
138
|
-
|
|
139
|
-
user.roles?.includes(ADMIN_ROLE_CODE) ||
|
|
140
|
-
user.roles?.includes("SUPER_ADMIN");
|
|
141
|
-
if (isAdmin) {
|
|
231
|
+
if (isAdminUser(user)) {
|
|
142
232
|
return {
|
|
143
233
|
create: true,
|
|
144
234
|
view: true,
|
|
@@ -151,16 +241,12 @@ export function getCrudPermissionsFromSession(
|
|
|
151
241
|
};
|
|
152
242
|
}
|
|
153
243
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
);
|
|
244
|
+
// Chỉ lấy danh sách action CỦA RIÊNG entity này. Bản cũ dựng một Set
|
|
245
|
+
// "resource:action" cho TOÀN BỘ quyền của user (vài nghìn chuỗi) rồi hỏi
|
|
246
|
+
// đúng 8 lần — dựng lại ở mỗi lần gọi hàm.
|
|
247
|
+
const actions = readPermissionMap(session)?.[entity] ?? [];
|
|
159
248
|
|
|
160
|
-
const hasPermission = (action: string) =>
|
|
161
|
-
const key = `${entity}:${action}`;
|
|
162
|
-
return permissionKeys.has(key);
|
|
163
|
-
};
|
|
249
|
+
const hasPermission = (action: string) => actions.includes(action);
|
|
164
250
|
|
|
165
251
|
return {
|
|
166
252
|
create: hasPermission(getActionCode("create")),
|
|
@@ -191,20 +277,13 @@ export function checkPermission(
|
|
|
191
277
|
|
|
192
278
|
// Admin role bypass — checked BEFORE the empty-permissions guard so an admin
|
|
193
279
|
// who relies on their role (no explicit permission rows) is not locked out.
|
|
194
|
-
if (
|
|
195
|
-
user.roles?.includes(ADMIN_ROLE_CODE) ||
|
|
196
|
-
user.roles?.includes("SUPER_ADMIN")
|
|
197
|
-
) {
|
|
280
|
+
if (isAdminUser(user)) {
|
|
198
281
|
return true;
|
|
199
282
|
}
|
|
200
283
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return user.permissions.some(
|
|
206
|
-
(p) => p.resourceCode === resourceCode && p.actionCode === actionCode,
|
|
207
|
-
);
|
|
284
|
+
// Tra thẳng theo resource rồi quét danh sách action của riêng nó (vài chục
|
|
285
|
+
// phần tử), thay vì quét tuyến tính CẢ mảng quyền như bản `Permission[]`.
|
|
286
|
+
return readPermissionMap(session)?.[resourceCode]?.includes(actionCode) ?? false;
|
|
208
287
|
}
|
|
209
288
|
|
|
210
289
|
/**
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { evaluateGuardrails } from "../preset";
|
|
8
|
+
import { createContext, stripComments } from "../scanner";
|
|
9
|
+
import { forbidPattern, perDirectoryCeiling } from "../primitives";
|
|
10
|
+
import type { GuardrailOptions, GuardrailResult } from "../types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Test của chính bộ hàng rào.
|
|
14
|
+
*
|
|
15
|
+
* Guardrail sai còn tệ hơn không có: nó bật đèn xanh cho đúng thứ nó được lập
|
|
16
|
+
* ra để chặn. Nên mỗi rule ở đây được kiểm bằng một app giả có vi phạm THẬT,
|
|
17
|
+
* chứ không chỉ kiểm "chạy không nổ".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const roots: string[] = [];
|
|
21
|
+
|
|
22
|
+
/** Dựng một app giả trong thư mục tạm: `{ "lib/prisma.ts": "..." }`. */
|
|
23
|
+
function fixture(files: Record<string, string>): string {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), "goerp-guardrails-"));
|
|
25
|
+
roots.push(root);
|
|
26
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
27
|
+
const full = join(root, rel.startsWith("src/") ? rel : `src/${rel}`);
|
|
28
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
29
|
+
writeFileSync(full, content);
|
|
30
|
+
}
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Đặt file ở GỐC app (không phải trong src) — .dockerignore, tailwind.config… */
|
|
35
|
+
function atRoot(root: string, rel: string, content: string) {
|
|
36
|
+
const full = join(root, rel);
|
|
37
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
38
|
+
writeFileSync(full, content);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const resultOf = (results: GuardrailResult[], id: string) =>
|
|
42
|
+
results.find((r) => r.rule.id === id)!;
|
|
43
|
+
|
|
44
|
+
const run = (root: string, options: GuardrailOptions = {}) =>
|
|
45
|
+
evaluateGuardrails({ root, ...options });
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
for (const root of roots) rmSync(root, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("scanner", () => {
|
|
52
|
+
it("bỏ chú thích nhưng giữ nguyên URL trong chuỗi", () => {
|
|
53
|
+
const code = stripComments(
|
|
54
|
+
[
|
|
55
|
+
'const a = "http://x.test"',
|
|
56
|
+
"// window.open(export)",
|
|
57
|
+
"/* as any */",
|
|
58
|
+
].join("\n"),
|
|
59
|
+
);
|
|
60
|
+
expect(code).toContain("http://x.test");
|
|
61
|
+
expect(code).not.toContain("window.open");
|
|
62
|
+
expect(code).not.toContain("as any");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("callBodies cắt đúng thân lời gọi trải nhiều dòng", () => {
|
|
66
|
+
const bodies = createContext({ root: fixture({}) }).callBodies(
|
|
67
|
+
"await db.$transaction(async (tx) => {\n await fn(1)\n})\nother()",
|
|
68
|
+
"$transaction(",
|
|
69
|
+
);
|
|
70
|
+
expect(bodies).toHaveLength(1);
|
|
71
|
+
expect(bodies[0]).toContain("await fn(1)");
|
|
72
|
+
expect(bodies[0]).not.toContain("other()");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("không quét file test và thư mục build", () => {
|
|
76
|
+
const ctx = createContext({
|
|
77
|
+
root: fixture({
|
|
78
|
+
"lib/a.ts": "export const a = 1",
|
|
79
|
+
"lib/a.test.ts": "const x: any = 1",
|
|
80
|
+
".next/b.ts": "const y: any = 2",
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
expect(ctx.files.map(ctx.rel)).toEqual(["lib/a.ts"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("rule bắt được vi phạm thật", () => {
|
|
88
|
+
it("one-door/single-prisma-client — client thứ hai bị bắt, cửa chính thì không", () => {
|
|
89
|
+
const root = fixture({
|
|
90
|
+
"lib/prisma.ts": "export const db = new PrismaClient()",
|
|
91
|
+
"server/report.ts": "const other = new PrismaClient()",
|
|
92
|
+
});
|
|
93
|
+
expect(
|
|
94
|
+
resultOf(run(root), "one-door/single-prisma-client").violations,
|
|
95
|
+
).toEqual(["server/report.ts"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("one-door/storage — chỉ cửa được import core, và cửa phải cấu hình", () => {
|
|
99
|
+
const leaky = fixture({
|
|
100
|
+
"lib/storage.ts":
|
|
101
|
+
'import { configureStorage } from "@goerp/core/storage"\nconfigureStorage({ bucket: "x" })',
|
|
102
|
+
"app/api/upload/route.ts":
|
|
103
|
+
'import { putObject } from "@goerp/core/storage"\nexport const POST = apiHandler(fn)',
|
|
104
|
+
});
|
|
105
|
+
expect(resultOf(run(leaky), "one-door/storage").violations).toEqual([
|
|
106
|
+
"app/api/upload/route.ts",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const unconfigured = fixture({
|
|
110
|
+
"lib/storage.ts": 'export { putObject } from "@goerp/core/storage"',
|
|
111
|
+
});
|
|
112
|
+
expect(
|
|
113
|
+
resultOf(run(unconfigured), "one-door/storage").violations.join(),
|
|
114
|
+
).toContain("KHÔNG gọi configureStorage()");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("one-door/storage — app chưa có cửa nào thì rule tự bỏ qua", () => {
|
|
118
|
+
const root = fixture({ "lib/a.ts": "export const a = 1" });
|
|
119
|
+
expect(resultOf(run(root), "one-door/storage").status).toBe(
|
|
120
|
+
"not-applicable",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("auth/api-route-gated — không gác thì phải được KHAI công khai ở proxy.ts", () => {
|
|
125
|
+
const root = fixture({
|
|
126
|
+
"proxy.ts":
|
|
127
|
+
'createAuthProxy({ publicApiPrefixes: ["/api/better-auth", "/api/zma"] })',
|
|
128
|
+
"app/api/orders/route.ts":
|
|
129
|
+
"export async function GET() { return Response.json([]) }",
|
|
130
|
+
"app/api/roles/route.ts":
|
|
131
|
+
"export const GET = apiHandler(fn, { resource: 'roles' })",
|
|
132
|
+
// Khai công khai (self-auth) → không đòi cổng phiên.
|
|
133
|
+
"app/api/better-auth/[...all]/route.ts": "export const GET = handler",
|
|
134
|
+
"app/api/zma/orders/route.ts":
|
|
135
|
+
"export async function GET() { return Response.json([]) }",
|
|
136
|
+
});
|
|
137
|
+
expect(resultOf(run(root), "auth/api-route-gated").violations).toEqual([
|
|
138
|
+
"app/api/orders/route.ts",
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("auth/api-route-gated — stub app/api được lần tới handler thật trong module", () => {
|
|
143
|
+
const root = fixture({
|
|
144
|
+
"app/api/orders/route.ts": 'export * from "@/modules/sales/api/orders"',
|
|
145
|
+
"modules/sales/api/orders.ts":
|
|
146
|
+
"export const GET = apiHandler(fn, { resource: 'orders', action: 'view' })",
|
|
147
|
+
});
|
|
148
|
+
expect(resultOf(run(root), "auth/api-route-gated").violations).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("auth/server-action-bare-session — chỉ bắt file 'use server'", () => {
|
|
152
|
+
const root = fixture({
|
|
153
|
+
"actions/orders.ts": '"use server"\nconst s = await getSession()',
|
|
154
|
+
"lib/read.ts": "const s = await getSession()",
|
|
155
|
+
});
|
|
156
|
+
expect(
|
|
157
|
+
resultOf(run(root), "auth/server-action-bare-session").violations,
|
|
158
|
+
).toEqual(["actions/orders.ts"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("auth/proxy-matcher-covers-api — matcher loại trừ /api thì đỏ", () => {
|
|
162
|
+
const bad = fixture({
|
|
163
|
+
"proxy.ts": "export const config = { matcher: ['/((?!api|_next).*)'] }",
|
|
164
|
+
});
|
|
165
|
+
expect(
|
|
166
|
+
resultOf(run(bad), "auth/proxy-matcher-covers-api").violations,
|
|
167
|
+
).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("layering/domain-framework-free — domain dính next/* thì đỏ, modules/*/api thì không", () => {
|
|
171
|
+
const root = fixture({
|
|
172
|
+
"modules/sales/services/order-service.ts":
|
|
173
|
+
'import { revalidatePath } from "next/cache"',
|
|
174
|
+
"modules/sales/api/orders.ts":
|
|
175
|
+
'import { NextResponse } from "next/server"',
|
|
176
|
+
});
|
|
177
|
+
expect(
|
|
178
|
+
resultOf(run(root), "layering/domain-framework-free").violations.join(),
|
|
179
|
+
).toContain("modules/sales/services/order-service.ts");
|
|
180
|
+
expect(
|
|
181
|
+
resultOf(run(root), "layering/domain-framework-free").violations.join(),
|
|
182
|
+
).not.toContain("modules/sales/api/orders.ts");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("layering/module-public-api — deep-import bị bắt, server action thì được miễn", () => {
|
|
186
|
+
const root = fixture({
|
|
187
|
+
"modules/sales/index.ts": "export {}",
|
|
188
|
+
"modules/sales/services/x.ts": "export const x = 1",
|
|
189
|
+
"modules/sales/actions/save.ts":
|
|
190
|
+
'"use server"\nexport async function save() {}',
|
|
191
|
+
"app/page.tsx": 'import { x } from "@/modules/sales/services/x"',
|
|
192
|
+
"app/form.tsx": 'import { save } from "@/modules/sales/actions/save"',
|
|
193
|
+
});
|
|
194
|
+
const v = resultOf(run(root), "layering/module-public-api").violations;
|
|
195
|
+
expect(v.join()).toContain("app/page.tsx");
|
|
196
|
+
expect(v.join()).not.toContain("app/form.tsx");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("design/no-fragile-flex-hidden — `flex lg:hidden` đỏ, `hidden max-lg:flex` xanh", () => {
|
|
200
|
+
const root = fixture({
|
|
201
|
+
"app/bad.tsx": '<div className="flex items-center lg:hidden" />',
|
|
202
|
+
"app/good.tsx": '<div className="hidden items-center max-lg:flex" />',
|
|
203
|
+
});
|
|
204
|
+
const v = resultOf(run(root), "design/no-fragile-flex-hidden").violations;
|
|
205
|
+
expect(v.join()).toContain("app/bad.tsx");
|
|
206
|
+
expect(v.join()).not.toContain("app/good.tsx");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("safety/no-network-in-transaction — chỉ bắt lời gọi NẰM TRONG thân transaction", () => {
|
|
210
|
+
const root = fixture({
|
|
211
|
+
"modules/a/inside.ts":
|
|
212
|
+
"await db.$transaction(async (tx) => {\n await fetch(url)\n})",
|
|
213
|
+
"modules/a/outside.ts":
|
|
214
|
+
"await fetch(url)\nawait db.$transaction(async (tx) => {})",
|
|
215
|
+
});
|
|
216
|
+
expect(
|
|
217
|
+
resultOf(run(root), "safety/no-network-in-transaction").violations,
|
|
218
|
+
).toEqual(["modules/a/inside.ts"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("structure/dockerignore-keeps-source — glob nuốt mã nguồn thì đỏ", () => {
|
|
222
|
+
const root = fixture({
|
|
223
|
+
"modules/sales/api/check-resold.ts": "export const GET = apiHandler(fn)",
|
|
224
|
+
"app/page.tsx": "export default function Page() {}",
|
|
225
|
+
});
|
|
226
|
+
atRoot(root, ".dockerignore", "node_modules\n**/check-*.ts\n");
|
|
227
|
+
expect(
|
|
228
|
+
resultOf(run(root), "structure/dockerignore-keeps-source").violations,
|
|
229
|
+
).toEqual(["src/modules/sales/api/check-resold.ts"]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("rbac/gate-action-declared — cổng đòi action registry chưa khai thì đỏ", () => {
|
|
233
|
+
const root = fixture({
|
|
234
|
+
"app/api/orders/route.ts":
|
|
235
|
+
"export const POST = apiHandler(fn, { resource: 'orders', action: 'approve' })",
|
|
236
|
+
});
|
|
237
|
+
const results = run(root, {
|
|
238
|
+
permissionRegistry: [
|
|
239
|
+
{ resources: [{ code: "orders", actions: ["view", "create"] }] },
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
expect(resultOf(results, "rbac/gate-action-declared").violations).toEqual([
|
|
243
|
+
"app/api/orders/route.ts → orders:approve",
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("rbac/gate-* chỉ đọc đối tượng khai ở CỔNG, không đọc payload nhật ký", () => {
|
|
248
|
+
const root = fixture({
|
|
249
|
+
// Ba thứ cùng mang key `resource` trong một file, chỉ MỘT là khai quyền:
|
|
250
|
+
// sắp xếp của Prisma, payload ghi vết thực thể, và cổng thật.
|
|
251
|
+
"app/api/orders/route.ts": [
|
|
252
|
+
"export const GET = apiHandler(async (req) => {",
|
|
253
|
+
" await db.auditLog.groupBy({ by: ['resource'], orderBy: { _count: { resource: 'desc' } } })",
|
|
254
|
+
" await logEntityAction({ resource: 'stock-movement', action: 'add-document' })",
|
|
255
|
+
" return NextResponse.json({})",
|
|
256
|
+
"}, { resource: 'orders', action: 'view' })",
|
|
257
|
+
].join("\n"),
|
|
258
|
+
});
|
|
259
|
+
const results = run(root, {
|
|
260
|
+
permissionRegistry: [
|
|
261
|
+
{ resources: [{ code: "orders", actions: ["view"] }] },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
expect(resultOf(results, "rbac/gate-resource-declared").violations).toEqual(
|
|
265
|
+
[],
|
|
266
|
+
);
|
|
267
|
+
expect(resultOf(results, "rbac/gate-action-declared").violations).toEqual(
|
|
268
|
+
[],
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("rbac/gate-resource-declared — cổng khai resource ngoài registry thì đỏ", () => {
|
|
273
|
+
const root = fixture({
|
|
274
|
+
"app/api/orders/route.ts":
|
|
275
|
+
"export const GET = apiHandler(fn, { resource: 'ghost', action: 'view' })",
|
|
276
|
+
});
|
|
277
|
+
const results = run(root, {
|
|
278
|
+
permissionRegistry: [
|
|
279
|
+
{ resources: [{ code: "orders", actions: ["view"] }] },
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
expect(resultOf(results, "rbac/gate-resource-declared").violations).toEqual(
|
|
283
|
+
["app/api/orders/route.ts → ghost"],
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("rbac/nav-resource-declared — mục menu trỏ resource chưa khai thì đỏ", () => {
|
|
288
|
+
const results = run(fixture({ "lib/a.ts": "export const a = 1" }), {
|
|
289
|
+
permissionRegistry: [
|
|
290
|
+
{ resources: [{ code: "orders", actions: ["view"] }] },
|
|
291
|
+
],
|
|
292
|
+
navigations: [{ items: [{ resource: "orders" }, { resource: "ghost" }] }],
|
|
293
|
+
});
|
|
294
|
+
expect(resultOf(results, "rbac/nav-resource-declared").violations).toEqual([
|
|
295
|
+
"ghost",
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("rbac/* tự bỏ qua khi app không truyền registry", () => {
|
|
300
|
+
const results = run(fixture({ "lib/a.ts": "export const a = 1" }));
|
|
301
|
+
expect(resultOf(results, "rbac/known-actions").status).toBe(
|
|
302
|
+
"not-applicable",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("ratchet: allowlist chỉ được rút bớt", () => {
|
|
308
|
+
const spec = {
|
|
309
|
+
id: "test/forbid",
|
|
310
|
+
title: "t",
|
|
311
|
+
why: "w",
|
|
312
|
+
fix: "f",
|
|
313
|
+
pattern: /forbidden/,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
it("allowlist che được vi phạm đã biết", () => {
|
|
317
|
+
const ctx = createContext({
|
|
318
|
+
root: fixture({ "a.ts": "forbidden", "b.ts": "forbidden" }),
|
|
319
|
+
allowlists: { "test/forbid": ["a.ts"] },
|
|
320
|
+
});
|
|
321
|
+
expect(forbidPattern(spec).run(ctx)).toEqual(["b.ts"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("entry hết vi phạm (hoặc file đã xoá) bị báo là ôi", () => {
|
|
325
|
+
const ctx = createContext({
|
|
326
|
+
root: fixture({ "a.ts": "clean" }),
|
|
327
|
+
allowlists: { "test/forbid": ["a.ts", "deleted.ts"] },
|
|
328
|
+
});
|
|
329
|
+
expect(forbidPattern(spec).staleAllowlist!(ctx).sort()).toEqual([
|
|
330
|
+
"a.ts",
|
|
331
|
+
"deleted.ts",
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("entry kết thúc bằng `/` miễn trừ cả thư mục", () => {
|
|
336
|
+
const ctx = createContext({
|
|
337
|
+
root: fixture({ "_handlers/a.ts": "forbidden", "b.ts": "forbidden" }),
|
|
338
|
+
allowlists: { "test/forbid": ["_handlers/"] },
|
|
339
|
+
});
|
|
340
|
+
expect(forbidPattern(spec).run(ctx)).toEqual(["b.ts"]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("entry thư mục hết vi phạm cũng bị báo là ôi", () => {
|
|
344
|
+
const ctx = createContext({
|
|
345
|
+
root: fixture({ "_handlers/a.ts": "sạch" }),
|
|
346
|
+
allowlists: { "test/forbid": ["_handlers/"] },
|
|
347
|
+
});
|
|
348
|
+
expect(forbidPattern(spec).staleAllowlist!(ctx)).toEqual(["_handlers/"]);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("ratchet: trần theo thư mục", () => {
|
|
353
|
+
const [over, slack] = perDirectoryCeiling({
|
|
354
|
+
id: "test/any",
|
|
355
|
+
title: "t",
|
|
356
|
+
why: "w",
|
|
357
|
+
fix: "f",
|
|
358
|
+
pattern: /\bas any\b/g,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("app mới không khai trần = trần 0 ở mọi thư mục", () => {
|
|
362
|
+
const ctx = createContext({ root: fixture({ "lib/a.ts": "x as any" }) });
|
|
363
|
+
expect(over.run(ctx)).toEqual(["lib: 1 > trần 0"]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("trần đúng hiện trạng thì xanh cả hai chiều", () => {
|
|
367
|
+
const ctx = createContext({
|
|
368
|
+
root: fixture({ "lib/a.ts": "x as any" }),
|
|
369
|
+
ceilings: { "test/any": { lib: 1 } },
|
|
370
|
+
});
|
|
371
|
+
expect(over.run(ctx)).toEqual([]);
|
|
372
|
+
expect(slack.run(ctx)).toEqual([]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("trần dư cũng đỏ — trả nợ tới đâu hạ trần tới đó", () => {
|
|
376
|
+
const ctx = createContext({
|
|
377
|
+
root: fixture({ "lib/a.ts": "sạch" }),
|
|
378
|
+
ceilings: { "test/any": { lib: 5 } },
|
|
379
|
+
});
|
|
380
|
+
expect(slack.run(ctx)).toEqual(["lib: trần 5 → thực tế 0"]);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe("van an toàn `skip`", () => {
|
|
385
|
+
it("skip theo id và theo cả nhóm đều có tác dụng", () => {
|
|
386
|
+
const root = fixture({ "server/x.ts": "new PrismaClient()" });
|
|
387
|
+
expect(
|
|
388
|
+
resultOf(
|
|
389
|
+
run(root, { skip: { "one-door/single-prisma-client": "nợ cũ" } }),
|
|
390
|
+
"one-door/single-prisma-client",
|
|
391
|
+
).status,
|
|
392
|
+
).toBe("skipped");
|
|
393
|
+
expect(
|
|
394
|
+
resultOf(
|
|
395
|
+
run(root, { skip: { "one-door/*": "nợ cũ" } }),
|
|
396
|
+
"one-door/single-prisma-client",
|
|
397
|
+
).status,
|
|
398
|
+
).toBe("skipped");
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("app sạch", () => {
|
|
403
|
+
it("một app tối thiểu, đúng chuẩn thì không rule nào đỏ", () => {
|
|
404
|
+
const root = fixture({
|
|
405
|
+
"proxy.ts": [
|
|
406
|
+
'import { getSessionCookie } from "better-auth/cookies"',
|
|
407
|
+
'if (pathname.startsWith("/api") && !isPublicApiPath(pathname)) return deny()',
|
|
408
|
+
"export const config = { matcher: ['/((?!_next).*)'] }",
|
|
409
|
+
].join("\n"),
|
|
410
|
+
"lib/prisma.ts": "export const db = new PrismaClient()",
|
|
411
|
+
"app/globals.css": '@import "@goerp/core/styles/base.css";',
|
|
412
|
+
"app/api/roles/route.ts":
|
|
413
|
+
"export const GET = apiHandler(fn, { resource: 'roles', action: 'view' })",
|
|
414
|
+
"app/[lang]/(main)/roles/page.tsx":
|
|
415
|
+
"export default function Page() { return null }",
|
|
416
|
+
"app/[lang]/(main)/roles/roles-client-page.tsx":
|
|
417
|
+
"export function C() { return null }",
|
|
418
|
+
});
|
|
419
|
+
// globals.css không phải .ts nên fixture ghi thẳng; rule đọc bằng đường dẫn.
|
|
420
|
+
const failed = run(root, {
|
|
421
|
+
permissionRegistry: [
|
|
422
|
+
{ resources: [{ code: "roles", actions: ["view"] }] },
|
|
423
|
+
],
|
|
424
|
+
navigations: [{ items: [{ resource: "roles" }] }],
|
|
425
|
+
}).filter((r) => r.status === "fail");
|
|
426
|
+
expect(
|
|
427
|
+
failed.map((r) => `${r.rule.id}: ${r.violations.join(", ")}`),
|
|
428
|
+
).toEqual([]);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hàng rào kiến trúc dùng chung — luật đi theo core, dữ liệu ở lại app.
|
|
3
|
+
*
|
|
4
|
+
* Mỗi rule ở đây là một **ca lỗi có thật** đã tốn thời gian ở app gốc, viết lại
|
|
5
|
+
* thành phép quét tĩnh. Ba lý do để chúng sống trong core thay vì trong từng app:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Luật lan được.** Bài học rút ra hôm nay tới mọi app ở lần `pnpm up` sau,
|
|
8
|
+
* thay vì đóng băng trong bản chép tay của ngày app được scaffold.
|
|
9
|
+
* 2. **Thông báo lỗi mới là chỗ người ta đọc.** Rule mang theo `why` — nguyên
|
|
10
|
+
* văn sự cố — nên khi test đỏ, người/agent sửa hiểu vì sao chứ không đi tìm
|
|
11
|
+
* cách làm cho nó xanh.
|
|
12
|
+
* 3. **Van an toàn hiện hình.** `skip` bắt buộc kèm lý do và tự đỏ khi nợ đã
|
|
13
|
+
* trả, nên bump core không làm gãy app đang chạy mà cũng không đẻ ra chỗ trốn.
|
|
14
|
+
*
|
|
15
|
+
* Dùng trong `src/__tests__/architecture.test.ts` của app:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { runCoreGuardrails } from "@goerp/core/guardrails"
|
|
19
|
+
* import { permissionRegistry } from "@/configs/permissions"
|
|
20
|
+
* import { navigations } from "@/data/navigations"
|
|
21
|
+
*
|
|
22
|
+
* runCoreGuardrails({ permissionRegistry, navigations })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export { runCoreGuardrails } from "./run";
|
|
27
|
+
export { coreGuardrails, evaluateGuardrails, skipReasonFor } from "./preset";
|
|
28
|
+
export { createContext, callBodies, stripComments, resolveOptions } from "./scanner";
|
|
29
|
+
export {
|
|
30
|
+
allowlistFor,
|
|
31
|
+
fileContract,
|
|
32
|
+
forbidFile,
|
|
33
|
+
forbidPattern,
|
|
34
|
+
perDirectoryCeiling,
|
|
35
|
+
singleDoorImport,
|
|
36
|
+
} from "./primitives";
|
|
37
|
+
export { anyDebtRules, statusLiteralCeiling } from "./rules/debt";
|
|
38
|
+
export type { StatusLiteralSpec } from "./rules/debt";
|
|
39
|
+
export { STANDARD_ACTIONS } from "./rules/rbac";
|
|
40
|
+
export { authRules } from "./rules/auth";
|
|
41
|
+
export { rbacRules } from "./rules/rbac";
|
|
42
|
+
export { layeringRules } from "./rules/layering";
|
|
43
|
+
export { oneDoorRules } from "./rules/one-door";
|
|
44
|
+
export { structureRules } from "./rules/structure";
|
|
45
|
+
export { designRules } from "./rules/design";
|
|
46
|
+
export { safetyRules } from "./rules/safety";
|
|
47
|
+
export { NOT_APPLICABLE } from "./types";
|
|
48
|
+
export type {
|
|
49
|
+
GuardrailContext,
|
|
50
|
+
GuardrailGroup,
|
|
51
|
+
GuardrailOptions,
|
|
52
|
+
GuardrailResult,
|
|
53
|
+
GuardrailRule,
|
|
54
|
+
NavigationGroupLike,
|
|
55
|
+
PermissionFeatureLike,
|
|
56
|
+
ResolvedGuardrailOptions,
|
|
57
|
+
} from "./types";
|