@things-factory/board-import 10.1.24 → 10.1.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-import",
3
- "version": "10.1.24",
3
+ "version": "10.1.26",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -23,13 +23,13 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@operato/graphql": "^10.0.0",
26
- "@things-factory/ai-client-base": "^10.1.20",
27
- "@things-factory/attachment-base": "^10.1.24",
28
- "@things-factory/auth-base": "^10.1.24",
29
- "@things-factory/board-service": "^10.1.24",
26
+ "@things-factory/ai-client-base": "^10.1.26",
27
+ "@things-factory/attachment-base": "^10.1.26",
28
+ "@things-factory/auth-base": "^10.1.26",
29
+ "@things-factory/board-service": "^10.1.26",
30
30
  "@things-factory/env": "^10.1.20",
31
- "@things-factory/shell": "^10.1.20",
31
+ "@things-factory/shell": "^10.1.26",
32
32
  "dxf-parser": "^1.1.2"
33
33
  },
34
- "gitHead": "0d88a504e0ffa63cd91279b88c6998d4bf1b3d86"
34
+ "gitHead": "151bda14d899442c6846532ddded1e2bfe23a2f5"
35
35
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The import tools ask for what their GraphQL doors ask for.
3
+ *
4
+ * These tools reach the same service functions as `importBoardAsync` / `importSession` /
5
+ * `materializeImportSession` in import-session-resolver.ts. They used to run with no check, so a
6
+ * caller holding only the conversation privilege could create a Board through a chat.
7
+ */
8
+ import { readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+
11
+ const mockCheckPermission = jest.fn()
12
+ jest.mock('@things-factory/auth-base', () => ({ checkPermission: (...a: any[]) => mockCheckPermission(...a) }), {
13
+ virtual: true
14
+ })
15
+ const mockMaterializeFromSession = jest.fn(async () => ({ id: 'b1', name: 'New', state: 'draft' }))
16
+ jest.mock('./import-session/materialize-from-session', () => ({ materializeFromSession: (...a: any[]) => (mockMaterializeFromSession as any)(...a) }))
17
+ const mockStartImportJobAsync = jest.fn(async () => ({ id: 's1', status: 'pending' }))
18
+ const mockFetchImportSession = jest.fn(async () => ({ id: 's1', status: 'completed' }))
19
+ jest.mock('./import-session/import-actions', () => ({
20
+ startImportJobAsync: (...a: any[]) => (mockStartImportJobAsync as any)(...a),
21
+ fetchImportSession: (...a: any[]) => (mockFetchImportSession as any)(...a),
22
+ describeAdapter: (a: any) => a
23
+ }))
24
+ jest.mock('./import-session/import-session', () => ({ ImportSession: class ImportSession {} }))
25
+ jest.mock('@things-factory/board-service', () => ({ Board: class Board {}, Group: class Group {} }), { virtual: true })
26
+ jest.mock('@things-factory/shell', () => ({ getRepository: () => ({}) }), { virtual: true })
27
+
28
+ import { clearToolRegistry, findToolSpec } from '@things-factory/ai-client-base'
29
+ import { IMPORT_TOOL_PRIVILEGE, registerBoardImportTools } from './import-tools'
30
+
31
+ const state = { domain: { id: 'd1' }, user: { id: 'u1' }, unsafeIP: false, prohibitedPrivileges: [{ category: 'x', privilege: 'y' }] }
32
+
33
+ beforeEach(() => {
34
+ clearToolRegistry()
35
+ registerBoardImportTools()
36
+ jest.clearAllMocks()
37
+ })
38
+ afterEach(() => clearToolRegistry())
39
+
40
+ const ARGS: Record<keyof typeof IMPORT_TOOL_PRIVILEGE, any> = {
41
+ importBoardAsync: { attachmentId: 'a1' },
42
+ getImportSession: { sessionId: 's1' },
43
+ materializeImportSession: { sessionId: 's1', name: 'New' }
44
+ }
45
+ const EFFECT: Record<keyof typeof IMPORT_TOOL_PRIVILEGE, jest.Mock> = {
46
+ importBoardAsync: mockStartImportJobAsync,
47
+ getImportSession: mockFetchImportSession,
48
+ materializeImportSession: mockMaterializeFromSession
49
+ }
50
+
51
+ describe.each(Object.keys(IMPORT_TOOL_PRIVILEGE) as (keyof typeof IMPORT_TOOL_PRIVILEGE)[])('%s', tool => {
52
+ test('refuses, and does nothing, when the caller lacks the privilege', async () => {
53
+ mockCheckPermission.mockResolvedValue(false)
54
+ const spec: any = findToolSpec(tool)
55
+ await expect(spec.builder(ARGS[tool], { state })).rejects.toThrow(
56
+ new RegExp(`${IMPORT_TOOL_PRIVILEGE[tool].category}:${IMPORT_TOOL_PRIVILEGE[tool].privilege}`)
57
+ )
58
+ expect(EFFECT[tool]).not.toHaveBeenCalled()
59
+ if (tool === 'materializeImportSession') expect(mockFetchImportSession).not.toHaveBeenCalled()
60
+ })
61
+
62
+ test('asks auth for exactly the declared privilege, with the request state', async () => {
63
+ mockCheckPermission.mockResolvedValue(true)
64
+ const spec: any = findToolSpec(tool)
65
+ await spec.builder(ARGS[tool], { state })
66
+ expect(mockCheckPermission).toHaveBeenCalledWith(
67
+ IMPORT_TOOL_PRIVILEGE[tool],
68
+ state.user,
69
+ state.domain,
70
+ state.unsafeIP,
71
+ state.prohibitedPrivileges
72
+ )
73
+ expect(EFFECT[tool]).toHaveBeenCalled()
74
+ })
75
+
76
+ test('declares the same privilege on the spec, for doors that decide what to offer', () => {
77
+ const spec: any = findToolSpec(tool)
78
+ expect(spec.privilege).toBe(IMPORT_TOOL_PRIVILEGE[tool])
79
+ })
80
+ })
81
+
82
+ test('the table says what the GraphQL doors say', () => {
83
+ const resolver = readFileSync(join(__dirname, 'import-session', 'import-session-resolver.ts'), 'utf8')
84
+ const DOOR: Record<keyof typeof IMPORT_TOOL_PRIVILEGE, string> = {
85
+ importBoardAsync: 'importBoardAsync',
86
+ getImportSession: 'importSession',
87
+ materializeImportSession: 'materializeImportSession'
88
+ }
89
+ for (const [tool, door] of Object.entries(DOOR) as [keyof typeof IMPORT_TOOL_PRIVILEGE, string][]) {
90
+ const m = resolver.match(
91
+ new RegExp(`@Directive\\('@privilege\\(category: "([^"]+)", privilege: "([^"]+)"\\)'\\)\\s*async ${door}\\(`)
92
+ )
93
+ expect(m).not.toBeNull()
94
+ expect({ category: m![1], privilege: m![2] }).toEqual(IMPORT_TOOL_PRIVILEGE[tool])
95
+ }
96
+ })
97
+
98
+ test('every tool that touches a domain is in the table', () => {
99
+ const source = readFileSync(join(__dirname, 'import-tools.ts'), 'utf8')
100
+ const bare = source.match(/= requireDomainContext\(ctx\?\.state\)/g) ?? []
101
+ expect(bare).toEqual([])
102
+ })
@@ -45,7 +45,10 @@ function getDeps(): any {
45
45
  const actionsMod = require('./import-session/import-actions')
46
46
  // eslint-disable-next-line @typescript-eslint/no-var-requires
47
47
  const materializeMod = require('./import-session/materialize-from-session')
48
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
49
+ const authMod = require('@things-factory/auth-base')
48
50
  _depsCache = {
51
+ checkPermission: authMod.checkPermission,
49
52
  ImportSession: isMod.ImportSession,
50
53
  Board: bsMod.Board,
51
54
  Group: bsMod.Group,
@@ -72,6 +75,40 @@ function requireDomainContext(state: any): { domain: any; user: any } {
72
75
  return { domain: state.domain, user: state.user }
73
76
  }
74
77
 
78
+ /*
79
+ * A tool is a second entrance to a door that already exists, so it asks for what that door asks for.
80
+ *
81
+ * `materializeImportSession` and `importBoardAsync` sit behind `board-import:mutation` as GraphQL
82
+ * mutations, and `importSession` behind `board-import:query` (import-session-resolver.ts). The tools
83
+ * called the same service functions with no check at all, so whoever could open a conversation —
84
+ * `ai-assistant:mutation` — could create a Board through it.
85
+ *
86
+ * The check lives in the builder rather than in whichever chat door dispatches the tool: there is
87
+ * more than one such door, and the effect has to be guarded wherever it is reached from. The same
88
+ * object is also declared on the spec as `privilege`, which is what a door reads to decide whether
89
+ * to offer the tool at all; declaring and enforcing from one constant keeps the two from drifting.
90
+ */
91
+ export const IMPORT_TOOL_PRIVILEGE = {
92
+ importBoardAsync: { category: 'board-import', privilege: 'mutation' },
93
+ getImportSession: { category: 'board-import', privilege: 'query' },
94
+ materializeImportSession: { category: 'board-import', privilege: 'mutation' }
95
+ } as const
96
+
97
+ type GuardedImportTool = keyof typeof IMPORT_TOOL_PRIVILEGE
98
+
99
+ async function requireImportPrivilege(tool: GuardedImportTool, state: any): Promise<{ domain: any; user: any }> {
100
+ const { domain, user } = requireDomainContext(state)
101
+ const required = IMPORT_TOOL_PRIVILEGE[tool]
102
+ const allowed = await getDeps().checkPermission(required, user, domain, state.unsafeIP, state.prohibitedPrivileges)
103
+ if (!allowed) {
104
+ throw new Error(
105
+ `[board-import] '${tool}' needs ${required.category}:${required.privilege}. ` +
106
+ 'Being allowed to talk to the assistant does not open it.'
107
+ )
108
+ }
109
+ return { domain, user }
110
+ }
111
+
75
112
  const importToolsCategory: ToolCategory = {
76
113
  name: 'board-import',
77
114
  description: 'Drawing/CAD/IFC/image → Board 변환 파이프라인 (zero-to-twin).',
@@ -112,6 +149,7 @@ const importToolsCategory: ToolCategory = {
112
149
  {
113
150
  kind: 'external',
114
151
  name: 'importBoardAsync',
152
+ privilege: IMPORT_TOOL_PRIVILEGE.importBoardAsync,
115
153
  description:
116
154
  'Attachment(도면/조감도/사진) 을 비동기로 보드 모델로 변환 시작. 즉시 ImportSession 을 ' +
117
155
  '반환하고 백그라운드에서 진행. status 가 completed 가 될 때까지 getImportSession 으로 ' +
@@ -141,7 +179,7 @@ const importToolsCategory: ToolCategory = {
141
179
  required: ['attachmentId']
142
180
  },
143
181
  builder: async (args, ctx) => {
144
- const { domain, user } = requireDomainContext(ctx?.state)
182
+ const { domain, user } = await requireImportPrivilege('importBoardAsync', ctx?.state)
145
183
  const session = await getDeps().startImportJobAsync(
146
184
  {
147
185
  attachmentId: args.attachmentId,
@@ -163,6 +201,7 @@ const importToolsCategory: ToolCategory = {
163
201
  {
164
202
  kind: 'read',
165
203
  name: 'getImportSession',
204
+ privilege: IMPORT_TOOL_PRIVILEGE.getImportSession,
166
205
  description:
167
206
  'ImportSession 의 진행/결과 조회. status 가 queued/parsing/mapping/assembling/binding 인 ' +
168
207
  '동안은 progress 와 message 를 반환, completed 면 result.boardModel 까지 포함. failed 면 ' +
@@ -175,7 +214,7 @@ const importToolsCategory: ToolCategory = {
175
214
  required: ['sessionId']
176
215
  },
177
216
  builder: async (args, ctx) => {
178
- const { domain, user } = requireDomainContext(ctx?.state)
217
+ const { domain, user } = await requireImportPrivilege('getImportSession', ctx?.state)
179
218
  const session = await getDeps().fetchImportSession(
180
219
  args.sessionId,
181
220
  { domain, user },
@@ -198,6 +237,7 @@ const importToolsCategory: ToolCategory = {
198
237
  {
199
238
  kind: 'external',
200
239
  name: 'materializeImportSession',
240
+ privilege: IMPORT_TOOL_PRIVILEGE.materializeImportSession,
201
241
  description:
202
242
  '완료된 ImportSession 의 결과 boardModel 을 새 Board entity 로 영속화. status 가 ' +
203
243
  'completed 인 세션만 처리. 결과 Board 는 state="draft" 로 생성되므로 사용자가 검수 후 ' +
@@ -221,7 +261,7 @@ const importToolsCategory: ToolCategory = {
221
261
  required: ['sessionId', 'name']
222
262
  },
223
263
  builder: async (args, ctx) => {
224
- const { domain, user } = requireDomainContext(ctx?.state)
264
+ const { domain, user } = await requireImportPrivilege('materializeImportSession', ctx?.state)
225
265
  const session = await getDeps().fetchImportSession(
226
266
  args.sessionId,
227
267
  { domain, user },