@goplusvn/core 0.1.57 → 0.1.59

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/PLATFORM.md CHANGED
@@ -193,6 +193,133 @@ await casUpdateById(
193
193
  )
194
194
  ```
195
195
 
196
+ ## File storage
197
+
198
+ One engine, two backends: a private local directory (default) or S3/MinIO. The
199
+ app configures it once in a dedicated `src/lib/storage.ts` and imports storage
200
+ *only through that file* — the engine is a singleton, so importing core directly
201
+ gives you the functions without the `configureStorage` call that arms them.
202
+
203
+ ```ts
204
+ // src/lib/storage.ts — the one door
205
+ import { configureStorage, type StorageDb } from "@goerp/core/storage"
206
+ import { db } from "@/lib/prisma"
207
+
208
+ configureStorage({ db: db as unknown as StorageDb }) // local disk: storage/files
209
+ export * from "@goerp/core/storage"
210
+ ```
211
+
212
+ Both routes are factories — the app supplies only its authorization rule:
213
+
214
+ ```ts
215
+ // app/api/files/[...key]/route.ts
216
+ export const GET = apiHandler<{ key: string[] }>(
217
+ createFileProxyHandler<Session>({
218
+ authorize: ({ key, session }) =>
219
+ key.startsWith("hop-dong/") ? checkPermission(session, "contract", "view") : true,
220
+ })
221
+ )
222
+
223
+ // app/api/upload/route.ts
224
+ export const POST = apiHandler(createUploadHandler()) // 25MB, extension whitelist
225
+ ```
226
+
227
+ `authorize` returning `false` is 403; returning `{ status: 404 }` hides the
228
+ object's very existence. It runs *after* the key is validated, so a traversal
229
+ attempt never reaches app code. Uploads always return `/api/files/<key>` no
230
+ matter which backend is live, so switching to S3 later doesn't invalidate URLs
231
+ already stored in the DB — and the local directory is private, not
232
+ `public/uploads`: attachments are financial documents and ID scans, and a web
233
+ root serves them to anyone with the path.
234
+
235
+ S3/MinIO is opt-in because bundlers statically resolve dynamic imports — a
236
+ lazily-imported driver would still make `aws-sdk` a hard build dependency for
237
+ every app. It lives at its own subpath with the SDK as an *optional* peer:
238
+
239
+ ```ts
240
+ import { createS3Driver } from "@goerp/core/storage/s3"
241
+
242
+ configureStorage({
243
+ db: db as unknown as StorageDb,
244
+ driver: createS3Driver(),
245
+ requireRemote: process.env.NODE_ENV === "production",
246
+ })
247
+ ```
248
+
249
+ `requireRemote` makes a missing/broken S3 config *throw* instead of quietly
250
+ falling back to container-local disk — that fallback "succeeds" and then loses
251
+ every file on the next deploy. Credentials come from `system_configs`
252
+ (`STORAGE_TYPE=s3`, `S3_ENDPOINT`, `S3_PUBLIC_ENDPOINT`, `S3_REGION`,
253
+ `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REJECT_UNAUTHORIZED`), cached
254
+ until `clearCache()`. The public endpoint is used for presigning only —
255
+ a presigned signature is bound to the host that signed it, so signing with the
256
+ internal endpoint yields URLs the browser cannot use.
257
+
258
+ ## Branch scope
259
+
260
+ Multi-branch data visibility (`@goerp/core/branch-scope`). Two layers, on
261
+ purpose — the second exists because the first is something a new route can
262
+ simply forget.
263
+
264
+ **Layer 1 — explicit filtering.** Every page/route/service that lists records
265
+ does `const scope = await getBranchScope(session)` then spreads
266
+ `scopedBranchWhere(scope)` into its `where`. `scopedBranchWhere` returns `{}`
267
+ for view-all users, so there is no branching at the call site. Related helpers:
268
+ `canAccessBranch(scope, row.branchId)` for detail pages, `clampBranchFilter`
269
+ for a branch filter the client sent (out-of-scope selections collapse to the
270
+ sentinel — 0 rows, never "no filter"), `clampIdFilter` for branch-owned things
271
+ like warehouses. Records with `branchId: null` are treated as shared and stay
272
+ visible to everyone.
273
+
274
+ **Layer 2 — the safety net.** `createBranchGuardExtension({ models })` is a
275
+ Prisma extension that ANDs the branch condition onto read operations
276
+ (`findMany`, `findFirst`, `findFirstOrThrow`, `count`, `aggregate`, `groupBy`)
277
+ for the declared models. It only acts inside a request context opened by the
278
+ app's api-handler, so RSC pages, cron, webhooks and scripts see unfiltered data
279
+ and must use layer 1. `findUnique*` is deliberately not guarded (its `where`
280
+ only takes unique fields) — guard detail pages with `canAccessBranch`.
281
+
282
+ Wiring, all in the app's composition root:
283
+
284
+ ```ts
285
+ // src/lib/branch-scope.ts — the ONE door; nothing else imports the core module
286
+ configureBranchScope<Session>({
287
+ getUserId: (session) => session.user?.id,
288
+ canViewAll: (session) => session.user.roles.includes("admin"),
289
+ getAllowedBranchIds: (session) => session.user.branches, // or pass `db` instead
290
+ })
291
+
292
+ // src/lib/prisma.ts
293
+ db = rawClient.$extends(createBranchGuardExtension({ models: ["Invoice"] }))
294
+
295
+ // src/lib/api-handler.ts (middleware) — resolve is lazy, so a request that
296
+ // never touches a guarded model costs nothing
297
+ runWithBranchScope({ resolve: () => getBranchScope(session) }, () => next())
298
+ ```
299
+
300
+ Either give `configureBranchScope` a `db` (it reads `user_branches`) or
301
+ `getAllowedBranchIds` when the session already carries the list.
302
+
303
+ Three traps, each of which has cost a real incident:
304
+
305
+ - **A guarded model MUST have a `branchId` column**, or you must route it
306
+ through a relation: `buildWhere: (model, scope) => model === "GoodsReceipt" ?
307
+ scopedBranchWhere(scope, "warehouse") : null`. Declare a model without the
308
+ column and Prisma throws a validation error on *every* read a scoped user
309
+ makes — a blank app, not a few missing rows.
310
+ - **Document numbering must escape the guard.** `MAX(number)` filtered by
311
+ branch produces duplicate numbers and a unique violation. Wrap those queries
312
+ in `runWithoutBranchScope(...)`.
313
+ - **"No branches assigned" means zero rows, not everything.** That is what
314
+ `NO_BRANCH_ACCESS` encodes; an empty `in: []` is ambiguous in Prisma and one
315
+ mistake there exposes the whole table.
316
+
317
+ `runWithBranchScope` / `runWithoutBranchScope` pin a returned thenable to the
318
+ context before handing it back, so `runWithoutBranchScope(() => db.doc.aggregate(…))`
319
+ works even though a PrismaPromise does not run until it is awaited — otherwise
320
+ the query would execute wherever the caller happened to await it, i.e. in the
321
+ wrong scope.
322
+
196
323
  ## Utils
197
324
 
198
325
  `@goerp/core/utils` (formatCurrency, formatDate, cn, …) plus the granular:
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.57",
4
+ "version": "0.1.59",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -93,17 +93,25 @@
93
93
  "./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
94
94
  "./auth/auth-service": "./src/auth/auth-service.ts",
95
95
  "./package.json": "./package.json",
96
- "./providers/brand-theme": "./src/providers/brand-theme.ts"
96
+ "./providers/brand-theme": "./src/providers/brand-theme.ts",
97
+ "./storage": "./src/storage/index.ts",
98
+ "./storage/s3": "./src/storage/s3/index.ts"
97
99
  },
98
100
  "peerDependencies": {
101
+ "@aws-sdk/client-s3": "^3.0.0",
102
+ "@aws-sdk/s3-request-presigner": "^3.0.0",
103
+ "@smithy/node-http-handler": "^4.0.0",
99
104
  "next": ">=14.0.0",
100
105
  "react": "^18.0.0 || ^19.0.0",
101
106
  "react-dom": "^18.0.0 || ^19.0.0"
102
107
  },
103
108
  "devDependencies": {
109
+ "@aws-sdk/client-s3": "^3.1101.0",
110
+ "@aws-sdk/s3-request-presigner": "^3.1101.0",
104
111
  "@eslint/compat": "1.2.7",
105
112
  "@eslint/js": "9.18.0",
106
113
  "@next/eslint-plugin-next": "16.0.3",
114
+ "@smithy/node-http-handler": "^4.9.13",
107
115
  "@testing-library/jest-dom": "^6.9.1",
108
116
  "@testing-library/react": "^16.3.0",
109
117
  "@types/bcryptjs": "^2.4.6",
@@ -198,6 +206,17 @@
198
206
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
199
207
  "zod": "3.23.8"
200
208
  },
209
+ "peerDependenciesMeta": {
210
+ "@aws-sdk/client-s3": {
211
+ "optional": true
212
+ },
213
+ "@aws-sdk/s3-request-presigner": {
214
+ "optional": true
215
+ },
216
+ "@smithy/node-http-handler": {
217
+ "optional": true
218
+ }
219
+ },
201
220
  "scripts": {
202
221
  "build": "NODE_OPTIONS='--max-old-space-size=10240' tsup",
203
222
  "dev": "tsup --watch",
@@ -0,0 +1,288 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ getBranchScopeContext,
5
+ resolveAmbientBranchScope,
6
+ runWithBranchScope,
7
+ runWithoutBranchScope,
8
+ type BranchScopeContext,
9
+ } from "../context";
10
+ import { createBranchGuardExtension } from "../guard";
11
+ import {
12
+ canAccessBranch,
13
+ clampBranchFilter,
14
+ clampIdFilter,
15
+ configureBranchScope,
16
+ getBranchScope,
17
+ scopedBranchWhere,
18
+ } from "../scope";
19
+ import { NO_BRANCH_ACCESS, type BranchScope } from "../types";
20
+
21
+ const scoped: BranchScope = { canViewAll: false, allowedBranchIds: ["b1", "b2"] };
22
+ const viewAll: BranchScope = { canViewAll: true };
23
+
24
+ interface FakeSession {
25
+ id?: string;
26
+ admin?: boolean;
27
+ }
28
+
29
+ function configure(rows: { branchId: string | null }[]) {
30
+ const findMany = vi.fn(async () => rows);
31
+ configureBranchScope<FakeSession>({
32
+ db: { userBranch: { findMany } },
33
+ getUserId: (s) => s.id,
34
+ canViewAll: (s) => Boolean(s.admin),
35
+ });
36
+ return findMany;
37
+ }
38
+
39
+ describe("getBranchScope", () => {
40
+ it("người xem-tất không tốn truy vấn user_branches", async () => {
41
+ const findMany = configure([]);
42
+ expect(await getBranchScope({ id: "u1", admin: true })).toEqual({
43
+ canViewAll: true,
44
+ });
45
+ expect(findMany).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it("lấy đúng chi nhánh được gán", async () => {
49
+ configure([{ branchId: "b1" }, { branchId: "b2" }]);
50
+ expect(await getBranchScope({ id: "u1" })).toEqual({
51
+ canViewAll: false,
52
+ allowedBranchIds: ["b1", "b2"],
53
+ });
54
+ });
55
+
56
+ it("chưa gán chi nhánh nào → sentinel = thấy 0 dòng, KHÔNG phải thấy tất", async () => {
57
+ configure([]);
58
+ expect(await getBranchScope({ id: "u1" })).toEqual({
59
+ canViewAll: false,
60
+ allowedBranchIds: [NO_BRANCH_ACCESS],
61
+ });
62
+ });
63
+
64
+ it("session không có userId → sentinel (không tra DB bằng undefined)", async () => {
65
+ const findMany = configure([{ branchId: "b1" }]);
66
+ expect(await getBranchScope({})).toEqual({
67
+ canViewAll: false,
68
+ allowedBranchIds: [NO_BRANCH_ACCESS],
69
+ });
70
+ expect(findMany).not.toHaveBeenCalled();
71
+ });
72
+ });
73
+
74
+ describe("scopedBranchWhere", () => {
75
+ it("xem tất → không thêm điều kiện", () => {
76
+ expect(scopedBranchWhere(viewAll)).toEqual({});
77
+ });
78
+
79
+ it("có phạm vi → CN được gán + bản ghi chưa gắn CN (dùng chung)", () => {
80
+ expect(scopedBranchWhere(scoped)).toEqual({
81
+ OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }],
82
+ });
83
+ });
84
+
85
+ it("scope qua quan hệ cho model không có cột branchId", () => {
86
+ expect(scopedBranchWhere(scoped, "warehouse")).toEqual({
87
+ OR: [
88
+ { warehouse: { branchId: { in: ["b1", "b2"] } } },
89
+ { warehouse: { branchId: null } },
90
+ ],
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("canAccessBranch", () => {
96
+ it("xem tất → qua hết", () => {
97
+ expect(canAccessBranch(viewAll, "x")).toBe(true);
98
+ });
99
+ it("bản ghi không gắn CN = dùng chung", () => {
100
+ expect(canAccessBranch(scoped, null)).toBe(true);
101
+ });
102
+ it("đúng/sai theo danh sách được gán", () => {
103
+ expect(canAccessBranch(scoped, "b1")).toBe(true);
104
+ expect(canAccessBranch(scoped, "x")).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("kẹp bộ lọc client gửi lên", () => {
109
+ it("clampBranchFilter: giữ nguyên khi xem tất, lọc bỏ CN ngoài phạm vi", () => {
110
+ expect(clampBranchFilter(["x"], viewAll)).toEqual(["x"]);
111
+ expect(clampBranchFilter(["b1", "x"], scoped)).toEqual(["b1"]);
112
+ });
113
+
114
+ it("clampBranchFilter: toàn bộ ngoài phạm vi → sentinel, không rơi về không-lọc", () => {
115
+ expect(clampBranchFilter(["x", "y"], scoped)).toEqual([NO_BRANCH_ACCESS]);
116
+ });
117
+
118
+ it("clampBranchFilter: không lọc gì → undefined (phạm vi đã lo)", () => {
119
+ expect(clampBranchFilter(undefined, scoped)).toBeUndefined();
120
+ expect(clampBranchFilter("", scoped)).toBeUndefined();
121
+ });
122
+
123
+ it("clampIdFilter: ngoài danh sách cho phép → sentinel", () => {
124
+ expect(clampIdFilter(["w1", "x"], ["w1", "w2"])).toEqual(["w1"]);
125
+ expect(clampIdFilter(["x"], ["w1"])).toEqual([NO_BRANCH_ACCESS]);
126
+ });
127
+ });
128
+
129
+ describe("context", () => {
130
+ it("ngoài context → undefined (extension bất động cho cron/script)", () => {
131
+ expect(getBranchScopeContext()).toBeUndefined();
132
+ expect(resolveAmbientBranchScope()).toBeUndefined();
133
+ });
134
+
135
+ it("resolve lười và cache trong 1 request", async () => {
136
+ const resolve = vi.fn(async () => scoped);
137
+ await runWithBranchScope({ resolve }, async () => {
138
+ expect(resolve).not.toHaveBeenCalled();
139
+ expect(await resolveAmbientBranchScope()).toBe(scoped);
140
+ expect(await resolveAmbientBranchScope()).toBe(scoped);
141
+ expect(resolve).toHaveBeenCalledTimes(1);
142
+ });
143
+ });
144
+
145
+ it("context sống qua await bên trong (PrismaPromise phải await TRONG context)", async () => {
146
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
147
+ await Promise.resolve();
148
+ expect(getBranchScopeContext()).toBeDefined();
149
+ });
150
+ });
151
+
152
+ it("runWithoutBranchScope thoát guard rồi trả lại context", async () => {
153
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
154
+ await runWithoutBranchScope(async () => {
155
+ await Promise.resolve();
156
+ expect(getBranchScopeContext()).toBeUndefined();
157
+ expect(resolveAmbientBranchScope()).toBeUndefined();
158
+ });
159
+ expect(getBranchScopeContext()).toBeDefined();
160
+ });
161
+ });
162
+
163
+ /**
164
+ * PrismaPromise lười: truy vấn chạy ở lần `.then()` đầu tiên, không phải lúc
165
+ * gọi `db.receipt.count()`. Giả lập đúng như vậy để chốt rằng callback KHÔNG
166
+ * async cũng chạy trong đúng phạm vi.
167
+ */
168
+ function lazyQuery(): Promise<BranchScopeContext | undefined> {
169
+ let started: Promise<BranchScopeContext | undefined> | undefined;
170
+ const thenable: PromiseLike<BranchScopeContext | undefined> = {
171
+ then(onFulfilled, onRejected) {
172
+ // Ngữ cảnh được chốt tại đây — chỗ đầu tiên ai đó await.
173
+ started ??= Promise.resolve(getBranchScopeContext());
174
+ return started.then(onFulfilled, onRejected);
175
+ },
176
+ };
177
+ // PrismaPromise cũng khai kiểu Promise nhưng chỉ chạy ở `.then` đầu tiên.
178
+ return thenable as Promise<BranchScopeContext | undefined>;
179
+ }
180
+
181
+ it("callback KHÔNG async trả PrismaPromise vẫn chạy TRONG context", async () => {
182
+ const ctx = await runWithBranchScope({ resolve: async () => scoped }, () =>
183
+ lazyQuery(),
184
+ );
185
+ expect(ctx).toBeDefined();
186
+ });
187
+
188
+ it("runWithoutBranchScope: callback KHÔNG async vẫn thoát guard", async () => {
189
+ const ctx = await runWithBranchScope(
190
+ { resolve: async () => scoped },
191
+ async () => runWithoutBranchScope(() => lazyQuery()),
192
+ );
193
+ expect(ctx).toBeUndefined();
194
+ });
195
+
196
+ it("giá trị đồng bộ đi qua nguyên vẹn (không bị bọc thành promise)", () => {
197
+ expect(runWithoutBranchScope(() => 42)).toBe(42);
198
+ });
199
+ });
200
+
201
+ describe("createBranchGuardExtension", () => {
202
+ const ext = createBranchGuardExtension({
203
+ models: ["Receipt", "GoodsReceipt"],
204
+ buildWhere: (model, scope) =>
205
+ model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
206
+ });
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ const run = ext.query.$allModels.$allOperations as (a: any) => Promise<any>;
209
+
210
+ let seen: unknown;
211
+ const query = vi.fn(async (args: unknown) => {
212
+ seen = args;
213
+ return "ok";
214
+ });
215
+
216
+ beforeEach(() => {
217
+ seen = undefined;
218
+ query.mockClear();
219
+ });
220
+
221
+ const call = (model: string, operation: string, args: unknown) =>
222
+ run({ model, operation, args, query });
223
+
224
+ it("model ngoài danh sách → đi thẳng", async () => {
225
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
226
+ call("Customer", "findMany", { where: { name: "a" } }),
227
+ );
228
+ expect(seen).toEqual({ where: { name: "a" } });
229
+ });
230
+
231
+ it("thao tác ghi và findUnique → không đụng vào", async () => {
232
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
233
+ await call("Receipt", "findUnique", { where: { id: "r1" } });
234
+ expect(seen).toEqual({ where: { id: "r1" } });
235
+ await call("Receipt", "update", { where: { id: "r1" }, data: {} });
236
+ expect(seen).toEqual({ where: { id: "r1" }, data: {} });
237
+ });
238
+ });
239
+
240
+ it("ngoài context (cron/script) → không cắt dữ liệu", async () => {
241
+ await call("Receipt", "findMany", { where: { a: 1 } });
242
+ expect(seen).toEqual({ where: { a: 1 } });
243
+ });
244
+
245
+ it("xem tất → không thêm điều kiện", async () => {
246
+ await runWithBranchScope({ resolve: async () => viewAll }, () =>
247
+ call("Receipt", "findMany", { where: { a: 1 } }),
248
+ );
249
+ expect(seen).toEqual({ where: { a: 1 } });
250
+ });
251
+
252
+ it("AND thêm điều kiện chi nhánh, GIỮ nguyên where cũ", async () => {
253
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
254
+ call("Receipt", "findMany", { where: { status: "paid" } }),
255
+ );
256
+ expect(seen).toEqual({
257
+ where: {
258
+ AND: [
259
+ { status: "paid" },
260
+ { OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
261
+ ],
262
+ },
263
+ });
264
+ });
265
+
266
+ it("không có where sẵn → guard trở thành where", async () => {
267
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
268
+ call("Receipt", "count", {}),
269
+ );
270
+ expect(seen).toEqual({
271
+ where: { OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
272
+ });
273
+ });
274
+
275
+ it("buildWhere riêng cho model không có cột branchId (chèn cột không tồn tại = Prisma ném lỗi cho MỌI truy vấn)", async () => {
276
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
277
+ call("GoodsReceipt", "findMany", {}),
278
+ );
279
+ expect(seen).toEqual({
280
+ where: {
281
+ OR: [
282
+ { warehouse: { branchId: { in: ["b1", "b2"] } } },
283
+ { warehouse: { branchId: null } },
284
+ ],
285
+ },
286
+ });
287
+ });
288
+ });
@@ -0,0 +1,66 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ import type { BranchScope } from "./types";
4
+
5
+ /**
6
+ * Context của lưới an toàn lớp 2. `apiHandler` mở context cho MỌI request đã
7
+ * xác thực; extension branch-guard đọc ở đây.
8
+ *
9
+ * Phạm vi được resolve LƯỜI — lần đầu một model bị guard thực sự được đọc.
10
+ * Request không đụng model nào bị guard thì không tốn thêm truy vấn nào.
11
+ *
12
+ * File này cố ý không import phần scope: composition root của app tiêm hàm
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.
15
+ */
16
+
17
+ export interface BranchScopeContext {
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
+ }
38
+
39
+ export function runWithBranchScope<T>(
40
+ context: BranchScopeContext,
41
+ fn: () => T | Promise<T>,
42
+ ): T | Promise<T> {
43
+ return store.run(context, () => pinToCurrentContext(fn()));
44
+ }
45
+
46
+ export function getBranchScopeContext(): BranchScopeContext | undefined {
47
+ return store.getStore();
48
+ }
49
+
50
+ /** Phạm vi của request hiện tại (resolve + cache); undefined khi ở ngoài context. */
51
+ export function resolveAmbientBranchScope(): Promise<BranchScope> | undefined {
52
+ const ctx = store.getStore();
53
+ if (!ctx) return undefined;
54
+ ctx.cached ??= ctx.resolve();
55
+ return ctx.cached;
56
+ }
57
+
58
+ /**
59
+ * 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 (`MAX(number)` bị lọc theo
61
+ * chi nhánh là sinh trùng số → lỗi unique). Mọi `await` bên trong đều thoát
62
+ * guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
63
+ */
64
+ export function runWithoutBranchScope<T>(fn: () => T): T {
65
+ return store.exit(() => pinToCurrentContext(fn()));
66
+ }
@@ -0,0 +1,100 @@
1
+ import { resolveAmbientBranchScope } from "./context";
2
+ import { scopedBranchWhere } from "./scope";
3
+
4
+ import type { BranchScope } from "./types";
5
+
6
+ /**
7
+ * LỚP 2 — lưới an toàn ở tầng ORM (mô hình Odoo/NocoBase): route mới quên lọc
8
+ * chi nhánh thì vẫn không lộ dữ liệu.
9
+ *
10
+ * Chỉ chạm thao tác ĐỌC, chỉ trên model được khai, và chỉ khi có
11
+ * BranchScopeContext — ngoài context (RSC page, cron, webhook, script) extension
12
+ * bất động, nên tác vụ nền không bị cắt dữ liệu bất ngờ.
13
+ *
14
+ * db.$extends(createBranchGuardExtension({
15
+ * models: ["Receipt", "Expense", "CashCount"],
16
+ * // model không có cột branchId thì khai đường đi riêng:
17
+ * buildWhere: (model, scope) =>
18
+ * model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
19
+ * }))
20
+ *
21
+ * CẢNH BÁO: model đưa vào `models` PHẢI có cột branchId, hoặc phải có nhánh
22
+ * riêng trong `buildWhere`. Chèn điều kiện lên cột không tồn tại là Prisma ném
23
+ * validation error cho MỌI thao tác đọc của user bị scope — nghĩa là hỏng cả
24
+ * nghiệp vụ, không chỉ hỏng bộ lọc.
25
+ */
26
+
27
+ export interface BranchGuardOptions {
28
+ /** Tên model Prisma (đúng chữ hoa/thường như trong schema). */
29
+ models: string[];
30
+ /**
31
+ * Điều kiện guard riêng theo model; trả `null` để dùng mặc định (cột
32
+ * branchId trực tiếp + cho phép null = dùng chung).
33
+ */
34
+ buildWhere?: (
35
+ model: string,
36
+ scope: BranchScope,
37
+ ) => Record<string, unknown> | null;
38
+ /** Mặc định: các thao tác đọc nhiều dòng. */
39
+ operations?: string[];
40
+ }
41
+
42
+ /**
43
+ * findUnique* cố ý không guard: `where` của nó chỉ nhận unique field, chèn thêm
44
+ * điều kiện là Prisma từ chối. Trang chi tiết guard bằng `canAccessBranch`.
45
+ * Mutation cũng không guard — create ghi vào chi nhánh của user, update/delete
46
+ * đi qua kiểm tra quyền riêng.
47
+ */
48
+ const DEFAULT_OPERATIONS = [
49
+ "findMany",
50
+ "findFirst",
51
+ "findFirstOrThrow",
52
+ "count",
53
+ "aggregate",
54
+ "groupBy",
55
+ ];
56
+
57
+ export function createBranchGuardExtension(options: BranchGuardOptions) {
58
+ const models = new Set(options.models);
59
+ const operations = new Set(options.operations ?? DEFAULT_OPERATIONS);
60
+ const buildWhere = options.buildWhere;
61
+
62
+ return {
63
+ name: "branch-guard",
64
+ query: {
65
+ $allModels: {
66
+ // Chữ ký thật do Prisma sinh theo schema của từng app; khai chặt ở core
67
+ // sẽ không assignable ở phía app (kiểu hẹp hơn, contravariance).
68
+ /* eslint-disable @typescript-eslint/no-explicit-any */
69
+ async $allOperations({
70
+ model,
71
+ operation,
72
+ args,
73
+ query,
74
+ }: {
75
+ model?: string;
76
+ operation: string;
77
+ args: any;
78
+ query: (args: any) => Promise<any>;
79
+ }): Promise<any> {
80
+ if (!model || !models.has(model)) return query(args);
81
+ if (!operations.has(operation)) return query(args);
82
+
83
+ const scopePromise = resolveAmbientBranchScope();
84
+ if (!scopePromise) return query(args);
85
+
86
+ const scope = await scopePromise;
87
+ if (scope.canViewAll) return query(args);
88
+
89
+ const guard = buildWhere?.(model, scope) ?? scopedBranchWhere(scope);
90
+ const prevWhere = args?.where;
91
+ return query({
92
+ ...args,
93
+ where: prevWhere ? { AND: [prevWhere, guard] } : guard,
94
+ });
95
+ },
96
+ /* eslint-enable @typescript-eslint/no-explicit-any */
97
+ },
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Phạm vi dữ liệu theo chi nhánh — hai lớp:
3
+ *
4
+ * Lớp 1 (tường minh): `getBranchScope(session)` + `scopedBranchWhere(scope)`
5
+ * trong page/route/service. Đây mới là lớp làm việc chính.
6
+ *
7
+ * Lớp 2 (lưới an toàn): `createBranchGuardExtension` cắm vào Prisma, tự AND
8
+ * điều kiện chi nhánh cho thao tác đọc trên model được khai — cứu chỗ quên.
9
+ *
10
+ * App cấu hình một lần ở composition root và import phạm vi qua đúng file đó
11
+ * (`src/lib/branch-scope.ts`), giống kho tập tin: engine là singleton, import
12
+ * thẳng từ core sẽ lấy được hàm nhưng bỏ lỡ lời gọi cấu hình.
13
+ */
14
+ export {
15
+ configureBranchScope,
16
+ canViewAllBranches,
17
+ getBranchScope,
18
+ canAccessBranch,
19
+ scopedBranchWhere,
20
+ clampBranchFilter,
21
+ clampIdFilter,
22
+ } from "./scope";
23
+
24
+ export {
25
+ runWithBranchScope,
26
+ getBranchScopeContext,
27
+ resolveAmbientBranchScope,
28
+ runWithoutBranchScope,
29
+ type BranchScopeContext,
30
+ } from "./context";
31
+
32
+ export {
33
+ createBranchGuardExtension,
34
+ type BranchGuardOptions,
35
+ } from "./guard";
36
+
37
+ export {
38
+ NO_BRANCH_ACCESS,
39
+ type BranchScope,
40
+ type BranchScopeConfig,
41
+ type BranchScopeDb,
42
+ } from "./types";