@goplusvn/core 0.1.75 → 0.1.77
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 +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +301 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +173 -0
- package/src/workspace/pages/workspace-list-page.tsx +802 -0
- package/src/workspace/route-handlers.ts +550 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.76 — Multi-tenant + workspace: một cây tổ chức/chi nhánh/phòng ban, uỷ quyền cấp tài khoản trong phạm vi
|
|
4
|
+
|
|
5
|
+
Nguồn: `docs/WORKSPACE-MULTITENANT-PLAN.md` + `docs/WORKSPACE-RESEARCH-IAM.md`
|
|
6
|
+
(khảo sát Keycloak / AWS IAM / Azure AD / NetSuite / Odoo trước khi chọn hình
|
|
7
|
+
dạng). Bài toán: một dự án có nhóm VẬN HÀNH toàn quyền và các nhóm KHÁCH HÀNG
|
|
8
|
+
chỉ được tạo/phân quyền người dùng trong không gian của mình — trục không gian
|
|
9
|
+
tuỳ dự án là tổ chức, chi nhánh, hay phòng ban.
|
|
10
|
+
|
|
11
|
+
**Toàn bộ đợt này là ADDITIVE.** Không đổi, không xoá API cũ; app đang chạy
|
|
12
|
+
không phải sửa dòng nào.
|
|
13
|
+
|
|
14
|
+
**Core — feature `workspaces` (mới):**
|
|
15
|
+
- `features/workspaces/` — `schema.prisma` + 2 migration SQL idempotent
|
|
16
|
+
(`0001_init`, `0002_delegation-columns`). Bảng `workspaces` (materialized
|
|
17
|
+
path) + `user_workspaces` (`is_admin`, `is_default`).
|
|
18
|
+
- **MỘT cây, hai độ sâu** thay vì ba cơ chế theo ba trục: nhãn trục là `kind`
|
|
19
|
+
do app khai, độ sâu là `depth`. Spartronics dừng ở đơn vị, thingtodo đi
|
|
20
|
+
thêm một tầng phòng ban, vinhhoa nếu dùng thì là chi nhánh — cùng một engine.
|
|
21
|
+
- `path`/`depth` CHỈ service được ghi (`createWorkspace`/`moveWorkspace`).
|
|
22
|
+
Ghi tay bằng `prisma.workspace.create` là cây vẫn hiện đúng trên UI trong
|
|
23
|
+
khi phạm vi đã hỏng — im lặng, không exception.
|
|
24
|
+
- `WorkspaceScope` = `canViewAll` + `rootIds` + `allowedIds?` + `adminIds` +
|
|
25
|
+
`defaultId?`. Không có quyền nào → sentinel `NO_WORKSPACE_ACCESS`, KHÔNG
|
|
26
|
+
phải `in: []` (Prisma coi `in: []` là "không lọc" ở vài dạng lồng nhau).
|
|
27
|
+
- `delegation.ts` — 7 bất biến D1–D7 chống leo thang: không tự nâng mình, không
|
|
28
|
+
cấp quyền mình không có, không đẩy nhánh sang tổ chức khác, không sửa người ở
|
|
29
|
+
ngoài phạm vi… có test cho từng cái.
|
|
30
|
+
- `route-handlers.ts` — GET/POST/PATCH/DELETE + `move`, tự cắt `path`/`parentId`
|
|
31
|
+
khỏi payload. App bọc bằng cổng RBAC của mình: hai tầng gác chồng lên nhau
|
|
32
|
+
(cổng gác VAI, handler gác PHẠM VI) là CỐ Ý, bỏ tầng nào cũng hở.
|
|
33
|
+
- UI: `WorkspaceListPage`, `WorkspaceTreeView`, `WorkspaceSwitcher`,
|
|
34
|
+
`ScopeLevelSelect`, `buildTree`.
|
|
35
|
+
|
|
36
|
+
**Core — vá rò rỉ có thật ở danh sách người dùng:**
|
|
37
|
+
- `user-service.ts` nhận `memberScopeWhere(scope)`. Trước đây trang Người dùng
|
|
38
|
+
trả về TOÀN BỘ user của hệ thống cho mọi vai có `user:view` — với mô hình
|
|
39
|
+
nhiều khách hàng dùng chung một app thì đó là danh bạ của khách hàng khác.
|
|
40
|
+
- `memberScopeWhere` CỐ Ý **không có nhánh `null`**: tài khoản chưa gán không
|
|
41
|
+
gian nào là tài khoản hệ thống/cấp trên, lọt ra là lọt đúng thứ không được lọt.
|
|
42
|
+
|
|
43
|
+
**Core — branch-scope dùng chung một AsyncLocalStorage với workspace:**
|
|
44
|
+
- `branch-scope/context.ts` giữ NGUYÊN tên và hành vi 4 hàm cũ, nhưng thân dời
|
|
45
|
+
sang `workspace/context.ts`. Hai store riêng thì `runWithoutBranchScope()`
|
|
46
|
+
chỉ thoát được một store, store kia vẫn lọc, `MAX(số phiếu)` lại bị cắt theo
|
|
47
|
+
phạm vi ⇒ **trùng số phiếu** (sự cố RS-260727-0003).
|
|
48
|
+
|
|
49
|
+
**Core — guardrails:**
|
|
50
|
+
- Luật `one-door/workspace`: chỉ file cửa được `import "@goerp/core/workspace"`.
|
|
51
|
+
- Luật `auth/server-action-bare-session`: cấm `getSession()` trần trong file
|
|
52
|
+
`"use server"` — phạm vi phải đi qua helper dùng chung, không chép ba dòng ở
|
|
53
|
+
mỗi action.
|
|
54
|
+
- `primitives.ts`/`scanner.ts`/`types.ts` nhận luật theo thư mục cửa.
|
|
55
|
+
|
|
56
|
+
**Core — đóng gói:**
|
|
57
|
+
- `exports` thêm `./workspace/route-handlers`. Wildcard `./*` map sang
|
|
58
|
+
`./src/*/index.ts` nên subpath là FILE (không phải thư mục có index) buộc
|
|
59
|
+
phải khai tường minh — y như `./rbac/route-handlers` đã phải khai.
|
|
60
|
+
|
|
3
61
|
## 0.1.71 — Bịt gap từ pilot init spartronics: cron giờ thật, đăng nhập username, proxy publicPrefixes, template hết vấp
|
|
4
62
|
|
|
5
63
|
Nguồn: docs/INIT-PILOT-SPARTRONICS-GAPS.md (dựng app thật từ goerp-init 0.1.70
|
package/bin/goerp-features.mjs
CHANGED
|
@@ -37,16 +37,20 @@ if (command === "status") {
|
|
|
37
37
|
for (const s of f.steps.filter((s) => !s.materialized)) {
|
|
38
38
|
console.log(` ↳ thiếu bước ${s.num}_${s.slug}`);
|
|
39
39
|
}
|
|
40
|
+
for (const file of f.ignored ?? []) {
|
|
41
|
+
console.log(` ⚠ BỎ QUA ${file} — tên sai quy ước NNN_slug.sql (slug chỉ a-z0-9-)`);
|
|
42
|
+
}
|
|
40
43
|
}
|
|
41
44
|
process.exit(0);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
if (command === "sync") {
|
|
45
48
|
const actions = syncFeatures({ featuresRoot, appRoot, only });
|
|
46
|
-
if (actions.length === 0) {
|
|
49
|
+
if (actions.filter((a) => a.type !== "ignored").length === 0) {
|
|
47
50
|
console.log("✓ Không có gì để sync — app đã đủ schema + migrations.");
|
|
48
51
|
} else {
|
|
49
52
|
for (const a of actions) {
|
|
53
|
+
if (a.type === "ignored") continue;
|
|
50
54
|
if (a.type === "schema") console.log(`✓ schema ${a.feature} → ${a.target}`);
|
|
51
55
|
else console.log(`✓ migrate ${a.feature} + ${a.step} → prisma/migrations/${a.dir}`);
|
|
52
56
|
}
|
|
@@ -54,6 +58,12 @@ if (command === "sync") {
|
|
|
54
58
|
"\nTiếp theo: pnpm prisma migrate deploy && pnpm prisma generate"
|
|
55
59
|
);
|
|
56
60
|
}
|
|
61
|
+
// In sau cùng để không bị cuộn mất giữa danh sách "✓".
|
|
62
|
+
for (const a of actions.filter((a) => a.type === "ignored")) {
|
|
63
|
+
console.error(
|
|
64
|
+
`⚠ ${a.feature}: BỎ QUA ${a.file} — tên sai quy ước NNN_slug.sql (slug chỉ a-z0-9-). Bước này SẼ KHÔNG chạy.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
57
67
|
process.exit(0);
|
|
58
68
|
}
|
|
59
69
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# feature `workspaces`
|
|
2
|
+
|
|
3
|
+
Cây không gian làm việc + membership + cột uỷ quyền. **Opt-in** — app nào chưa
|
|
4
|
+
cần thì không sync, không có bảng, không ảnh hưởng gì.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pnpm goerp-features sync --only workspaces
|
|
8
|
+
pnpm prisma migrate deploy && pnpm prisma generate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Bước thủ công (bắt buộc, sync không làm hộ)
|
|
12
|
+
|
|
13
|
+
`sync` copy được `schema.prisma` fragment vì `Workspace`/`UserWorkspace` là model
|
|
14
|
+
MỚI. Ba bảng còn lại (`users`, `roles`, `user_roles`) do **app sở hữu** — Prisma
|
|
15
|
+
cấm khai trùng model, nên bước 0002 chỉ chạy SQL, còn model thì app tự thêm:
|
|
16
|
+
|
|
17
|
+
```prisma
|
|
18
|
+
model User {
|
|
19
|
+
// … giữ nguyên
|
|
20
|
+
isProtected Boolean @default(false) @map("is_protected")
|
|
21
|
+
permissionCeilingRoleId String? @map("permission_ceiling_role_id")
|
|
22
|
+
userWorkspaces UserWorkspace[] // ← back-relation BẮT BUỘC
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
model Role {
|
|
26
|
+
// … giữ nguyên
|
|
27
|
+
workspaceId String? @map("workspace_id")
|
|
28
|
+
rank Int @default(100)
|
|
29
|
+
isSystem Boolean @default(false)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
model UserRole {
|
|
33
|
+
// … giữ nguyên
|
|
34
|
+
workspaceId String? @map("workspace_id") // W7 — khai từ đầu, enforce sau
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Thiếu back-relation `userWorkspaces` thì `prisma generate` báo lỗi quan hệ.
|
|
39
|
+
|
|
40
|
+
## Cấu hình runtime
|
|
41
|
+
|
|
42
|
+
Composition root của app (cùng chỗ gọi `configureBranchScope`):
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { configureWorkspaces } from "@goerp/core/workspace"
|
|
46
|
+
|
|
47
|
+
configureWorkspaces<Session>({
|
|
48
|
+
kinds: [
|
|
49
|
+
{ key: "unit", label: "Đơn vị", childKinds: ["department"] },
|
|
50
|
+
{ key: "department", label: "Bộ phận", canHaveChildren: false },
|
|
51
|
+
],
|
|
52
|
+
db, // đọc user_workspaces + workspaces
|
|
53
|
+
scopeField: "workspaceId", // cột mang phạm vi trên chứng từ
|
|
54
|
+
getUserId: (s) => s.user?.id,
|
|
55
|
+
canViewAll: (s) => checkPermission(s, "workspace", "view-all-workspaces"),
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
App **chưa** có bảng này (mức 0) thì bỏ `db`, cắm `getMemberships` vào nguồn sẵn
|
|
60
|
+
có (`user_branches`): cây phẳng, hành vi giống hệt branch-scope hôm nay.
|
|
61
|
+
|
|
62
|
+
## Seed
|
|
63
|
+
|
|
64
|
+
Feature không seed dữ liệu — cây tổ chức là dữ liệu nghiệp vụ của từng app. Tạo
|
|
65
|
+
nút bằng `createWorkspace()` của `@goerp/core/workspace` (nó tự ghi `path`/`depth`
|
|
66
|
+
và cưỡng chế trần độ sâu), **đừng INSERT tay** rồi tự đặt `path`.
|
|
67
|
+
|
|
68
|
+
## Quyền
|
|
69
|
+
|
|
70
|
+
Feature không tự cấp quyền. App khai trong Permission Registry của mình rồi chạy
|
|
71
|
+
`rbac-sync` — tối thiểu `workspace:view|create|update|delete` và action
|
|
72
|
+
`view-all-workspaces`. **Chủ tự tick quyền**, sync không cấp.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- goerp feature: workspaces — bước 0001 (idempotent).
|
|
2
|
+
--
|
|
3
|
+
-- Cây không gian làm việc + membership. YÊU CẦU: bảng "users" tồn tại (schema
|
|
4
|
+
-- auth/RBAC chung của goerp) và model User của app khai back-relation
|
|
5
|
+
-- `userWorkspaces UserWorkspace[]` — xem features/workspaces/README.md.
|
|
6
|
+
--
|
|
7
|
+
-- Không có FK nào trỏ tới bảng chứng từ: chứng từ giữ nguyên cột phạm vi sẵn có
|
|
8
|
+
-- (`branch_id` / `department_id` / `workspace_id`), engine đọc cột đó qua
|
|
9
|
+
-- `scopeField`. Nhờ vậy app lên feature này KHÔNG phải migrate một dòng dữ liệu
|
|
10
|
+
-- chứng từ nào.
|
|
11
|
+
CREATE TABLE IF NOT EXISTS "workspaces" (
|
|
12
|
+
"id" TEXT NOT NULL,
|
|
13
|
+
"code" TEXT NOT NULL,
|
|
14
|
+
"name" TEXT NOT NULL,
|
|
15
|
+
"kind" TEXT NOT NULL DEFAULT 'unit',
|
|
16
|
+
"parent_id" TEXT,
|
|
17
|
+
"path" TEXT NOT NULL,
|
|
18
|
+
"depth" INTEGER NOT NULL DEFAULT 1,
|
|
19
|
+
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
|
20
|
+
"settings" JSONB,
|
|
21
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
22
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
23
|
+
|
|
24
|
+
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "workspaces_code_key" ON "workspaces"("code");
|
|
28
|
+
-- Index chính của cả cơ chế: bung con cháu = `path LIKE '/x/%'`.
|
|
29
|
+
CREATE INDEX IF NOT EXISTS "workspaces_path_idx" ON "workspaces"("path");
|
|
30
|
+
CREATE INDEX IF NOT EXISTS "workspaces_parent_id_idx" ON "workspaces"("parent_id");
|
|
31
|
+
CREATE INDEX IF NOT EXISTS "workspaces_kind_idx" ON "workspaces"("kind");
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS "user_workspaces" (
|
|
34
|
+
"id" TEXT NOT NULL,
|
|
35
|
+
"user_id" TEXT NOT NULL,
|
|
36
|
+
"workspace_id" TEXT NOT NULL,
|
|
37
|
+
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
|
38
|
+
"is_admin" BOOLEAN NOT NULL DEFAULT false,
|
|
39
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
40
|
+
|
|
41
|
+
CONSTRAINT "user_workspaces_pkey" PRIMARY KEY ("id")
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "user_workspaces_user_id_workspace_id_key" ON "user_workspaces"("user_id", "workspace_id");
|
|
45
|
+
CREATE INDEX IF NOT EXISTS "user_workspaces_user_id_idx" ON "user_workspaces"("user_id");
|
|
46
|
+
CREATE INDEX IF NOT EXISTS "user_workspaces_workspace_id_idx" ON "user_workspaces"("workspace_id");
|
|
47
|
+
|
|
48
|
+
-- FK thêm rời để chạy lại vô hại (Postgres không có ADD CONSTRAINT IF NOT EXISTS).
|
|
49
|
+
DO $$
|
|
50
|
+
BEGIN
|
|
51
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_parent_id_fkey') THEN
|
|
52
|
+
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_parent_id_fkey"
|
|
53
|
+
FOREIGN KEY ("parent_id") REFERENCES "workspaces"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
54
|
+
END IF;
|
|
55
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_workspaces_user_id_fkey') THEN
|
|
56
|
+
ALTER TABLE "user_workspaces" ADD CONSTRAINT "user_workspaces_user_id_fkey"
|
|
57
|
+
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
58
|
+
END IF;
|
|
59
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_workspaces_workspace_id_fkey') THEN
|
|
60
|
+
ALTER TABLE "user_workspaces" ADD CONSTRAINT "user_workspaces_workspace_id_fkey"
|
|
61
|
+
FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
62
|
+
END IF;
|
|
63
|
+
END $$;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- goerp feature: workspaces — bước 0002 (idempotent). Cột cho phần UỶ QUYỀN
|
|
2
|
+
-- (bất biến D1–D7). Tất cả đều nullable hoặc có DEFAULT ⇒ app đang chạy không vỡ,
|
|
3
|
+
-- và khi chưa có dòng nào dùng thì hành vi y hệt hôm nay.
|
|
4
|
+
--
|
|
5
|
+
-- CHÚ Ý: ba bảng dưới đây do APP sở hữu (roles / user_roles / users), nên
|
|
6
|
+
-- `schema.prisma` fragment của feature KHÔNG khai lại chúng — Prisma cấm trùng
|
|
7
|
+
-- model. App phải tự thêm các trường tương ứng vào model của mình; xem
|
|
8
|
+
-- features/workspaces/README.md mục "Bước thủ công".
|
|
9
|
+
|
|
10
|
+
-- D2 — trần quyền kiểu AWS permissions boundary. Quyền hiệu lực = GIAO của
|
|
11
|
+
-- quyền vai trò và trần này, đánh giá MỖI request. Người tạo bị thu hồi quyền
|
|
12
|
+
-- sau đó thì user họ tạo cũng hẹp lại theo, chứ không giữ nguyên quyền cũ.
|
|
13
|
+
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "permission_ceiling_role_id" TEXT;
|
|
14
|
+
|
|
15
|
+
-- D6 — tài khoản được bảo vệ: admin nhánh KHÔNG đổi mật khẩu / vô hiệu hoá /
|
|
16
|
+
-- gỡ vai trò của tài khoản này, dù cùng workspace và dù D5 thoả.
|
|
17
|
+
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "is_protected" BOOLEAN NOT NULL DEFAULT false;
|
|
18
|
+
|
|
19
|
+
-- Vai trò riêng của một workspace (null = vai trò hệ thống, dùng chung).
|
|
20
|
+
ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
|
|
21
|
+
-- Cấp bậc: càng NHỎ càng cao. SUPER_ADMIN = 0. Admin nhánh chỉ tạo/gán được
|
|
22
|
+
-- vai trò có rank LỚN HƠN rank của chính mình.
|
|
23
|
+
ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "rank" INTEGER NOT NULL DEFAULT 100;
|
|
24
|
+
-- Vai trò hệ thống: chỉ quản trị toàn cục sửa được.
|
|
25
|
+
ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "is_system" BOOLEAN NOT NULL DEFAULT false;
|
|
26
|
+
|
|
27
|
+
-- W7 — vai trò có hiệu lực ở đúng một workspace. KHAI CỘT TỪ ĐẦU, ENFORCE SAU:
|
|
28
|
+
-- thêm cột vào bảng gán quyền khi đã có dữ liệu thật là việc đau. null = hành vi
|
|
29
|
+
-- y hệt hôm nay (hiệu lực ở MỌI membership).
|
|
30
|
+
ALTER TABLE "user_roles" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS "roles_workspace_id_idx" ON "roles"("workspace_id");
|
|
33
|
+
CREATE INDEX IF NOT EXISTS "user_roles_workspace_id_idx" ON "user_roles"("workspace_id");
|
|
34
|
+
CREATE INDEX IF NOT EXISTS "users_permission_ceiling_role_id_idx" ON "users"("permission_ceiling_role_id");
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/// Cây KHÔNG GIAN LÀM VIỆC — đơn vị / tổ chức / chi nhánh / phòng ban là MỘT
|
|
2
|
+
/// cây, khác nhau ở nhãn `kind` và độ sâu, không phải bốn cơ chế song song.
|
|
3
|
+
///
|
|
4
|
+
/// `path` là materialized path `/{ancestor}/…/{self}/` — bung con cháu bằng một
|
|
5
|
+
/// câu `path LIKE '/x/%'` đi được index btree, không đệ quy runtime, không CTE.
|
|
6
|
+
/// **Service ghi cột này, KHÔNG cho sửa tay**: đổi cha mà quên ghi lại path cho
|
|
7
|
+
/// cả nhánh con thì cây trông đúng trên UI mà phạm vi thì sai.
|
|
8
|
+
model Workspace {
|
|
9
|
+
id String @id @default(cuid()) @map("id")
|
|
10
|
+
code String @unique @map("code")
|
|
11
|
+
name String @map("name")
|
|
12
|
+
/// "org" | "unit" | "branch" | "department" | app tự thêm. Chỉ là NHÃN hiển
|
|
13
|
+
/// thị + luật cấu hình, không đổi logic phạm vi.
|
|
14
|
+
kind String @default("unit") @map("kind")
|
|
15
|
+
parentId String? @map("parent_id")
|
|
16
|
+
path String @map("path")
|
|
17
|
+
depth Int @default(1) @map("depth")
|
|
18
|
+
isActive Boolean @default(true) @map("is_active")
|
|
19
|
+
/// Cấu hình riêng của đơn vị (logo, ca làm, chính sách…).
|
|
20
|
+
settings Json? @map("settings")
|
|
21
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
22
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
23
|
+
|
|
24
|
+
parent Workspace? @relation("WorkspaceTree", fields: [parentId], references: [id])
|
|
25
|
+
children Workspace[] @relation("WorkspaceTree")
|
|
26
|
+
members UserWorkspace[]
|
|
27
|
+
|
|
28
|
+
@@index([path])
|
|
29
|
+
@@index([parentId])
|
|
30
|
+
@@index([kind])
|
|
31
|
+
@@map("workspaces")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Membership `user ↔ workspace`.
|
|
35
|
+
///
|
|
36
|
+
/// `isAdmin` trả lời "nhánh NÀO", quyền RBAC trả lời "được LÀM GÌ" — hai trục
|
|
37
|
+
/// vuông góc, cố ý không gộp. Có `isAdmin` mà không có quyền `user:manage` thì
|
|
38
|
+
/// vẫn không tạo được tài khoản, và ngược lại.
|
|
39
|
+
model UserWorkspace {
|
|
40
|
+
id String @id @default(cuid()) @map("id")
|
|
41
|
+
userId String @map("user_id")
|
|
42
|
+
workspaceId String @map("workspace_id")
|
|
43
|
+
/// Không gian mở sẵn khi đăng nhập / gán cho bản ghi mới tạo.
|
|
44
|
+
isDefault Boolean @default(false) @map("is_default")
|
|
45
|
+
/// Quản trị viên của nhánh này (uỷ quyền cấp tài khoản trong phạm vi).
|
|
46
|
+
isAdmin Boolean @default(false) @map("is_admin")
|
|
47
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
48
|
+
|
|
49
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
50
|
+
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
51
|
+
|
|
52
|
+
@@unique([userId, workspaceId])
|
|
53
|
+
@@index([userId])
|
|
54
|
+
@@index([workspaceId])
|
|
55
|
+
@@map("user_workspaces")
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.77",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"./import": "./src/import/index.ts",
|
|
55
55
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
56
56
|
"./rbac/route-handlers": "./src/rbac/route-handlers.ts",
|
|
57
|
+
"./workspace/route-handlers": "./src/workspace/route-handlers.ts",
|
|
57
58
|
"./rbac/permissions-version": "./src/rbac/permissions-version.ts",
|
|
58
59
|
"./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
|
|
59
60
|
"./errors/app-error": "./src/errors/app-error.ts",
|
package/scripts/feature-sync.mjs
CHANGED
|
@@ -23,19 +23,38 @@
|
|
|
23
23
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const STEP_NAME = /^(\d{3,})_([a-z0-9-]+)\.sql$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* features/<name>/migrations/NNN_slug.sql → [{num, slug, file}] theo thứ tự.
|
|
30
|
+
*
|
|
31
|
+
* Slug chỉ nhận `a-z0-9-` (KHÔNG gạch dưới) vì tên bước đi thẳng vào tên thư mục
|
|
32
|
+
* migration của app. File `.sql` đặt sai tên bị BỎ QUA — và bỏ qua im lặng là
|
|
33
|
+
* bẫy thật sự: bước tạo bảng chạy, bước thêm cột thì không, app deploy xanh mà
|
|
34
|
+
* cột không tồn tại. Đã dính đúng ca này với `0002_delegation_columns.sql`.
|
|
35
|
+
* Vì vậy trả kèm `ignored` để CLI kêu to.
|
|
36
|
+
*/
|
|
27
37
|
export function listFeatureSteps(featureDir) {
|
|
28
38
|
const dir = join(featureDir, "migrations");
|
|
29
39
|
if (!existsSync(dir)) return [];
|
|
30
40
|
return readdirSync(dir)
|
|
31
|
-
.filter((f) =>
|
|
41
|
+
.filter((f) => STEP_NAME.test(f))
|
|
32
42
|
.sort()
|
|
33
43
|
.map((f) => {
|
|
34
|
-
const m = f.match(
|
|
44
|
+
const m = f.match(STEP_NAME);
|
|
35
45
|
return { num: m[1], slug: m[2], file: join(dir, f) };
|
|
36
46
|
});
|
|
37
47
|
}
|
|
38
48
|
|
|
49
|
+
/** File `.sql` nằm trong migrations/ nhưng sai quy ước tên — không bao giờ chạy. */
|
|
50
|
+
export function listIgnoredStepFiles(featureDir) {
|
|
51
|
+
const dir = join(featureDir, "migrations");
|
|
52
|
+
if (!existsSync(dir)) return [];
|
|
53
|
+
return readdirSync(dir)
|
|
54
|
+
.filter((f) => f.endsWith(".sql") && !STEP_NAME.test(f))
|
|
55
|
+
.sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
/** Tên mọi feature trong thư mục features/ của core. */
|
|
40
59
|
export function listFeatures(featuresRoot) {
|
|
41
60
|
if (!existsSync(featuresRoot)) return [];
|
|
@@ -93,6 +112,7 @@ export function featureStatus({ featuresRoot, appRoot }) {
|
|
|
93
112
|
...step,
|
|
94
113
|
materialized: isStepMaterialized(migrationsDir, feature, step),
|
|
95
114
|
})),
|
|
115
|
+
ignored: listIgnoredStepFiles(featureDir),
|
|
96
116
|
};
|
|
97
117
|
});
|
|
98
118
|
}
|
|
@@ -110,6 +130,14 @@ const GENERATED_HEADER = (feature) =>
|
|
|
110
130
|
*/
|
|
111
131
|
export function syncFeatures({ featuresRoot, appRoot, now = new Date(), only = [] }) {
|
|
112
132
|
const actions = [];
|
|
133
|
+
// Kêu trước khi làm gì: file sai tên sẽ không bao giờ chạy, mà app vẫn deploy
|
|
134
|
+
// xanh — người dùng chỉ phát hiện khi một cột không tồn tại lúc runtime.
|
|
135
|
+
for (const feature of listFeatures(featuresRoot)) {
|
|
136
|
+
if (only.length > 0 && !only.includes(feature)) continue;
|
|
137
|
+
for (const file of listIgnoredStepFiles(join(featuresRoot, feature))) {
|
|
138
|
+
actions.push({ type: "ignored", feature, file });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
113
141
|
const migrationsDir = join(appRoot, "prisma", "migrations");
|
|
114
142
|
let offset = 0;
|
|
115
143
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getScopeContext,
|
|
3
|
+
resolveAmbientScope,
|
|
4
|
+
runWithScope,
|
|
5
|
+
runWithoutScope,
|
|
6
|
+
} from "../workspace/context";
|
|
7
|
+
import type { ScopeContext } from "../workspace/context";
|
|
2
8
|
|
|
3
9
|
import type { BranchScope } from "./types";
|
|
4
10
|
|
|
@@ -6,61 +12,38 @@ import type { BranchScope } from "./types";
|
|
|
6
12
|
* Context của lưới an toàn lớp 2. `apiHandler` mở context cho MỌI request đã
|
|
7
13
|
* xác thực; extension branch-guard đọc ở đây.
|
|
8
14
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* Thân của module này đã dời sang `workspace/context.ts` để `branch-scope` và
|
|
16
|
+
* `workspace` dùng **chung đúng một AsyncLocalStorage**. Đây không phải dọn dẹp
|
|
17
|
+
* cho đẹp: hai store riêng thì `runWithoutBranchScope()` chỉ thoát được một
|
|
18
|
+
* store, store kia vẫn lọc, và `MAX(số phiếu)` lại bị cắt theo phạm vi ⇒ **trùng
|
|
19
|
+
* số phiếu** (sự cố có thật RS-260727-0003).
|
|
11
20
|
*
|
|
12
|
-
*
|
|
13
|
-
* `resolve` vào lúc mở context, nhờ vậy context không kéo theo prisma và không
|
|
14
|
-
* tạo vòng import.
|
|
21
|
+
* API dưới đây giữ nguyên tên và hành vi cũ — app không phải sửa một dòng nào.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
|
-
export
|
|
18
|
-
resolve: () => Promise<BranchScope>;
|
|
19
|
-
/** Cache trong phạm vi 1 request — extension gán ở lần resolve đầu. */
|
|
20
|
-
cached?: Promise<BranchScope>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const store = new AsyncLocalStorage<BranchScopeContext>();
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* PrismaPromise là LƯỜI: truy vấn chỉ thật sự chạy ở lần `.then()` đầu tiên.
|
|
27
|
-
* `run(ctx, () => db.receipt.count())` trả promise chưa chạy ra ngoài, người gọi
|
|
28
|
-
* `await` bên ngoài → truy vấn chạy NGOÀI context → guard im lặng không lọc
|
|
29
|
-
* (và với `exit` thì ngược lại: truy vấn cần toàn cục lại bị lọc → trùng số
|
|
30
|
-
* phiếu). Gọi `.then` ngay tại đây, khi còn ở trong/ngoài context đúng như ý,
|
|
31
|
-
* để việc chạy được ghim vào đúng phạm vi — bất kể người gọi await ở đâu.
|
|
32
|
-
*/
|
|
33
|
-
function pinToCurrentContext<T>(result: T): T {
|
|
34
|
-
const thenable = result as { then?: unknown };
|
|
35
|
-
if (typeof thenable?.then !== "function") return result;
|
|
36
|
-
return (result as unknown as Promise<unknown>).then((value) => value) as T;
|
|
37
|
-
}
|
|
24
|
+
export type BranchScopeContext = ScopeContext<BranchScope>;
|
|
38
25
|
|
|
39
26
|
export function runWithBranchScope<T>(
|
|
40
27
|
context: BranchScopeContext,
|
|
41
28
|
fn: () => T | Promise<T>,
|
|
42
29
|
): T | Promise<T> {
|
|
43
|
-
return
|
|
30
|
+
return runWithScope<T, BranchScope>(context, fn);
|
|
44
31
|
}
|
|
45
32
|
|
|
46
33
|
export function getBranchScopeContext(): BranchScopeContext | undefined {
|
|
47
|
-
return
|
|
34
|
+
return getScopeContext() as BranchScopeContext | undefined;
|
|
48
35
|
}
|
|
49
36
|
|
|
50
37
|
/** Phạm vi của request hiện tại (resolve + cache); undefined khi ở ngoài context. */
|
|
51
38
|
export function resolveAmbientBranchScope(): Promise<BranchScope> | undefined {
|
|
52
|
-
|
|
53
|
-
if (!ctx) return undefined;
|
|
54
|
-
ctx.cached ??= ctx.resolve();
|
|
55
|
-
return ctx.cached;
|
|
39
|
+
return resolveAmbientScope<BranchScope>();
|
|
56
40
|
}
|
|
57
41
|
|
|
58
42
|
/**
|
|
59
43
|
* Chạy `fn` NGOÀI lưới guard — cho truy vấn hạ tầng mà kết quả phải toàn cục
|
|
60
|
-
* bất kể phạm vi user. Ca kinh điển: đánh số phiếu
|
|
61
|
-
*
|
|
62
|
-
* guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
|
|
44
|
+
* bất kể phạm vi user. Ca kinh điển: đánh số phiếu. Mọi `await` bên trong đều
|
|
45
|
+
* thoát guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
|
|
63
46
|
*/
|
|
64
47
|
export function runWithoutBranchScope<T>(fn: () => T): T {
|
|
65
|
-
return
|
|
48
|
+
return runWithoutScope(fn);
|
|
66
49
|
}
|
|
@@ -126,6 +126,47 @@ describe("featureStatus", () => {
|
|
|
126
126
|
})
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
describe("file .sql sai quy ước tên", () => {
|
|
130
|
+
// Ca có thật: `0002_delegation_columns.sql` (gạch DƯỚI) bị bỏ qua im lặng, nên
|
|
131
|
+
// bảng thì được tạo mà cột uỷ quyền thì không — app deploy xanh, lỗi chỉ lộ ra
|
|
132
|
+
// lúc runtime. Bỏ qua thì vẫn bỏ qua, nhưng phải KÊU.
|
|
133
|
+
it("không materialize, nhưng có mặt trong `ignored` và trong log sync", () => {
|
|
134
|
+
writeFeature(
|
|
135
|
+
"f-a",
|
|
136
|
+
{ "0001_init.sql": "SELECT 1;", "0002_bad_name.sql": "SELECT 2;" },
|
|
137
|
+
"model A { id String @id }"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const status = featureStatus({ featuresRoot, appRoot })
|
|
141
|
+
const feature = status.find((f: { feature: string }) => f.feature === "f-a")!
|
|
142
|
+
expect(feature.steps.map((s: { slug: string }) => s.slug)).toEqual(["init"])
|
|
143
|
+
expect(feature.ignored).toEqual(["0002_bad_name.sql"])
|
|
144
|
+
|
|
145
|
+
const actions = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
|
|
146
|
+
expect(
|
|
147
|
+
actions.filter((a: { type: string }) => a.type === "migration")
|
|
148
|
+
).toHaveLength(1)
|
|
149
|
+
expect(
|
|
150
|
+
actions.find((a: { type: string }) => a.type === "ignored")
|
|
151
|
+
).toMatchObject({ feature: "f-a", file: "0002_bad_name.sql" })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("tên đúng quy ước (gạch ngang) thì chạy bình thường", () => {
|
|
155
|
+
writeFeature(
|
|
156
|
+
"f-b",
|
|
157
|
+
{ "0001_init.sql": "SELECT 1;", "0002_delegation-columns.sql": "SELECT 2;" },
|
|
158
|
+
"model B { id String @id }"
|
|
159
|
+
)
|
|
160
|
+
const status = featureStatus({ featuresRoot, appRoot })
|
|
161
|
+
const feature = status.find((f: { feature: string }) => f.feature === "f-b")!
|
|
162
|
+
expect(feature.steps.map((s: { slug: string }) => s.slug)).toEqual([
|
|
163
|
+
"init",
|
|
164
|
+
"delegation-columns",
|
|
165
|
+
])
|
|
166
|
+
expect(feature.ignored).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
129
170
|
describe("migrationTimestamp", () => {
|
|
130
171
|
it("dạng YYYYMMDDHHMMSS UTC + offset giây", () => {
|
|
131
172
|
const now = new Date("2026-07-31T01:02:03Z")
|
|
@@ -121,6 +121,53 @@ describe("rule bắt được vi phạm thật", () => {
|
|
|
121
121
|
);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it("one-door/workspace — import thẳng bị bắt; cửa branch-scope cũng là cửa hợp lệ", () => {
|
|
125
|
+
const leaky = fixture({
|
|
126
|
+
"lib/branch-scope.ts":
|
|
127
|
+
'import { configureWorkspaces } from "@goerp/core/workspace"\nconfigureWorkspaces({ getUserId: (s) => s.id, canViewAll: () => false })',
|
|
128
|
+
"app/(main)/don-hang/page.tsx":
|
|
129
|
+
'import { getWorkspaceScope } from "@goerp/core/workspace"\nexport default function Page() { return null }',
|
|
130
|
+
});
|
|
131
|
+
expect(resultOf(run(leaky), "one-door/workspace").violations).toEqual([
|
|
132
|
+
"app/(main)/don-hang/page.tsx",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// Dùng lớp 1 (getWorkspaceScope) mà cửa quên cấu hình singleton → bắt.
|
|
136
|
+
const unconfigured = fixture({
|
|
137
|
+
"lib/branch-scope.ts":
|
|
138
|
+
'export { getWorkspaceScope } from "@goerp/core/workspace"',
|
|
139
|
+
"app/api/x/route.ts": "const s = await getWorkspaceScope(session)",
|
|
140
|
+
});
|
|
141
|
+
expect(
|
|
142
|
+
resultOf(run(unconfigured), "one-door/workspace").violations.join(),
|
|
143
|
+
).toContain("KHÔNG gọi configureWorkspaces()");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("one-door/workspace — chỉ dùng lớp 2 / hàm cây thì không đòi configureWorkspaces", () => {
|
|
147
|
+
const root = fixture({
|
|
148
|
+
"lib/branch-scope.ts":
|
|
149
|
+
'export { createScopeGuardExtension } from "@goerp/core/workspace"',
|
|
150
|
+
});
|
|
151
|
+
expect(resultOf(run(root), "one-door/workspace").violations).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("one-door/* — `import type` không tính là đi cửa sau, nhưng `import { type X }` thì có", () => {
|
|
155
|
+
const root = fixture({
|
|
156
|
+
"lib/branch-scope.ts":
|
|
157
|
+
'import { configureWorkspaces } from "@goerp/core/workspace"\nconfigureWorkspaces({ getUserId: (s) => s.id, canViewAll: () => false })',
|
|
158
|
+
// Chỉ mượn `interface` để chú kiểu — biên dịch xong là biến mất, không
|
|
159
|
+
// module nào bị nạp nên không thể bỏ lỡ configureWorkspaces().
|
|
160
|
+
"configs/workspace.config.ts":
|
|
161
|
+
'import type { WorkspaceKindConfig } from "@goerp/core/workspace"\nexport const KINDS: WorkspaceKindConfig[] = []',
|
|
162
|
+
// Vẫn nạp module thật, chỉ là gắn `type` bên trong ngoặc → vẫn bắt.
|
|
163
|
+
"app/(main)/x/page.tsx":
|
|
164
|
+
'import { buildTree, type WorkspaceNode } from "@goerp/core/workspace"\nexport default function Page() { return buildTree([]) }',
|
|
165
|
+
});
|
|
166
|
+
expect(resultOf(run(root), "one-door/workspace").violations).toEqual([
|
|
167
|
+
"app/(main)/x/page.tsx",
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
124
171
|
it("auth/api-route-gated — không gác thì phải được KHAI công khai ở proxy.ts", () => {
|
|
125
172
|
const root = fixture({
|
|
126
173
|
"proxy.ts":
|
|
@@ -141,8 +141,21 @@ interface SingleDoorSpec {
|
|
|
141
141
|
* theo lời gọi cấu hình — code chạy tới lúc thật mới nổ, và với branch-scope
|
|
142
142
|
* thì "nổ" nghĩa là người chi nhánh này nhìn thấy dữ liệu chi nhánh khác.
|
|
143
143
|
*/
|
|
144
|
+
/**
|
|
145
|
+
* Bỏ các câu `import type { … } from "…"` trước khi soi.
|
|
146
|
+
*
|
|
147
|
+
* Import chỉ-kiểu bị xoá sạch lúc biên dịch: nó không kéo được module nào vào
|
|
148
|
+
* bundle, nên cũng không thể bỏ lỡ `configureX()` — cái mà rule này canh. Đếm
|
|
149
|
+
* nó thành vi phạm chỉ ép người ta hoặc bỏ chú kiểu, hoặc dựng một cửa vòng
|
|
150
|
+
* vèo chỉ để mượn một `interface`. Import THƯỜNG có kèm `type` bên trong ngoặc
|
|
151
|
+
* (`import { type X, y }`) vẫn tính — nó vẫn nạp module thật.
|
|
152
|
+
*/
|
|
153
|
+
const TYPE_ONLY_IMPORT = /^\s*import\s+type\s[^\n]*$/gm;
|
|
154
|
+
|
|
144
155
|
export function singleDoorImport(spec: SingleDoorSpec): GuardrailRule {
|
|
145
156
|
const test = stateless(spec.pattern);
|
|
157
|
+
const readValueImports = (ctx: GuardrailContext, file: string) =>
|
|
158
|
+
ctx.readCode(file).replace(TYPE_ONLY_IMPORT, "");
|
|
146
159
|
return {
|
|
147
160
|
id: spec.id,
|
|
148
161
|
title: spec.title,
|
|
@@ -157,7 +170,7 @@ export function singleDoorImport(spec: SingleDoorSpec): GuardrailRule {
|
|
|
157
170
|
for (const file of ctx.files) {
|
|
158
171
|
const rel = ctx.rel(file);
|
|
159
172
|
if (doors.includes(rel)) continue;
|
|
160
|
-
if (test.test(ctx
|
|
173
|
+
if (test.test(readValueImports(ctx, file))) offenders.push(rel);
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
if (spec.mustConfigure && (spec.mustConfigureWhen?.(ctx) ?? true)) {
|