@things-factory/ai-assistant 10.1.114 → 10.1.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/client/components/assistant-dock.ts +26 -3
  2. package/client/setup-assistant-dock.ts +3 -2
  3. package/client/utils/entry-proposal.ts +14 -1
  4. package/dist-client/components/assistant-dock.d.ts +5 -0
  5. package/dist-client/components/assistant-dock.js +25 -3
  6. package/dist-client/components/assistant-dock.js.map +1 -1
  7. package/dist-client/setup-assistant-dock.d.ts +1 -0
  8. package/dist-client/setup-assistant-dock.js +1 -0
  9. package/dist-client/setup-assistant-dock.js.map +1 -1
  10. package/dist-client/tsconfig.tsbuildinfo +1 -1
  11. package/dist-client/utils/entry-proposal.d.ts +10 -1
  12. package/dist-client/utils/entry-proposal.js +13 -1
  13. package/dist-client/utils/entry-proposal.js.map +1 -1
  14. package/dist-server/service/data-entry/entry-apply.d.ts +8 -0
  15. package/dist-server/service/data-entry/entry-apply.js +19 -2
  16. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  17. package/dist-server/service/data-entry/entry-refusal.d.ts +6 -0
  18. package/dist-server/service/data-entry/entry-refusal.js +25 -0
  19. package/dist-server/service/data-entry/entry-refusal.js.map +1 -0
  20. package/dist-server/service/data-entry/entry-resolver.js +2 -0
  21. package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
  22. package/dist-server/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +6 -6
  24. package/server/service/data-entry/data-entry-update.test.ts +10 -1
  25. package/server/service/data-entry/data-entry.test.ts +29 -1
  26. package/server/service/data-entry/entry-apply.ts +27 -2
  27. package/server/service/data-entry/entry-refusal.test.ts +73 -0
  28. package/server/service/data-entry/entry-refusal.ts +20 -0
  29. package/server/service/data-entry/entry-resolver.ts +2 -0
  30. package/test/entry-proposal.test.ts +31 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.114",
3
+ "version": "10.1.121",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -34,11 +34,11 @@
34
34
  "@operato/shell": "^10.0.0",
35
35
  "@operato/styles": "^10.0.0",
36
36
  "@things-factory/ai-client-base": "^10.1.114",
37
- "@things-factory/apptool-base": "^10.1.106",
38
- "@things-factory/attachment-base": "^10.1.113",
39
- "@things-factory/auth-base": "^10.1.113",
37
+ "@things-factory/apptool-base": "^10.1.121",
38
+ "@things-factory/attachment-base": "^10.1.121",
39
+ "@things-factory/auth-base": "^10.1.121",
40
40
  "@things-factory/env": "^10.1.20",
41
- "@things-factory/shell": "^10.1.106",
41
+ "@things-factory/shell": "^10.1.121",
42
42
  "dompurify": "^3.0.0",
43
43
  "exceljs": "4.2.0",
44
44
  "graphql-scalars": "^1.22.0",
@@ -52,5 +52,5 @@
52
52
  "copyfiles": "^2.4.1",
53
53
  "rimraf": "^5.0.0"
54
54
  },
55
- "gitHead": "0d8f9bc0ff0cb50431f2c76cb166aaf3809d3cf7"
55
+ "gitHead": "23536a73b10f103048a2c893d3960b2a4e0f03dd"
56
56
  }
@@ -167,6 +167,16 @@ describe('changing what the person pressed', () => {
167
167
  expect(records.l1.amount).toBe('12400.00')
168
168
  })
169
169
 
170
+ it.each(['Mutation.changeExpenseLine', 'Query.myExpenseLines'])('enriches only the failed update door: %s', async door => {
171
+ const proposal = await card([{ ref: 'l1', values: { amount: 13000 } }])
172
+ const refusal = { message: 'required', extensions: { operation: door.startsWith('Query.') ? 'query' : 'mutation' }, path: [door.split('.')[1]] }
173
+ const refusalFor = jest.fn().mockResolvedValue(refusal)
174
+ const outcome = await applyEntryProposal('u1', deps(proposal, { passesDoor: async asked => asked !== door, refusalFor }))
175
+ expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, refusal })
176
+ expect(refusalFor.mock.calls).toEqual([[door]])
177
+ expect(records.l1.amount).toBe('12400.00')
178
+ })
179
+
170
180
  it('★ a newer 수정안 replaces an older one - an 입력안 of the same record does not', async () => {
171
181
  const older = await card([{ ref: 'l1', values: { amount: 13000 } }])
172
182
  const entry = { key: 'e1', kind: 'entry', target: 'office.expense-line', rows: [] }
@@ -347,4 +357,3 @@ describe('a check that cannot run', () => {
347
357
  expect(proposal.rows[0].issues).toEqual([{ code: 'check-failed', blocking: true }])
348
358
  })
349
359
  })
350
-
@@ -157,6 +157,35 @@ describe('entering what the person pressed', () => {
157
157
  expect(asked[0].variables).toEqual({ line: { spentOn: '2026-09-12', amount: 1 } })
158
158
  })
159
159
 
160
+ it('a closed mutation carries its authoritative refusal without entering any rows', async () => {
161
+ const refusal = { message: 'requires warehouse:mutation', extensions: { code: 'FORBIDDEN', because: 'role', operation: 'mutation', requires: { category: 'warehouse', privilege: 'mutation' } }, path: ['addExpenseLine'] }
162
+ const refusalFor = jest.fn().mockResolvedValue(refusal)
163
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
164
+ expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false, refusalFor }))).toEqual({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, rows: [], refusal })
165
+ expect(refusalFor).toHaveBeenCalledWith('Mutation.addExpenseLine')
166
+ expect(asked).toEqual([])
167
+ })
168
+
169
+ it('failed refusal enrichment keeps the original refusal and no writes', async () => {
170
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
171
+ expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false, refusalFor: async () => { throw new Error('lookup unavailable') } }))).toEqual({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, rows: [] })
172
+ expect(asked).toEqual([])
173
+ })
174
+
175
+ it('audits the closed door before best-effort enrichment and tolerates a broken diagnostic sink', async () => {
176
+ const order: string[] = []
177
+ const target = { ...expense, record: async () => { order.push('audit') } }
178
+ const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
179
+ const outcome = await applyEntryProposal('k1', deps(messages, {
180
+ passesDoor: async () => false,
181
+ refusalFor: async () => { order.push('explain'); throw new Error('unavailable') },
182
+ report: () => { throw new Error('diagnostic unavailable') }
183
+ }), () => target)
184
+ expect(order).toEqual(['audit', 'explain'])
185
+ expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0 })
186
+ expect(asked).toEqual([])
187
+ })
188
+
160
189
  it('★ asks the checks again - a record entered since the card was made keeps its row out', async () => {
161
190
  const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1, memo: 'dup' })])] }]
162
191
  const outcome = await applyEntryProposal('k1', deps(messages))
@@ -381,4 +410,3 @@ describe("a moment is the domain's wall time", () => {
381
410
  expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
382
411
  })
383
412
  })
384
-
@@ -29,12 +29,20 @@ export interface EntryRowResult {
29
29
  error?: string
30
30
  }
31
31
 
32
+ export interface EntryRefusal {
33
+ message: string
34
+ extensions: Record<string, unknown>
35
+ path: string[]
36
+ }
37
+
32
38
  export interface EntryApplyOutcome {
33
39
  verdict: 'accepted' | 'refused' | 'unavailable'
34
40
  errorCode?: string
35
41
  applied: number
36
42
  missed: number
37
43
  rows: EntryRowResult[]
44
+ /** AUTH's serialized refusal, only when the closed door could be identified. */
45
+ refusal?: EntryRefusal
38
46
  }
39
47
 
40
48
  export interface EntryApplyDeps {
@@ -43,6 +51,7 @@ export interface EntryApplyDeps {
43
51
  /** Whether an outcome line already answers this key. */
44
52
  answered: boolean
45
53
  passesDoor(door: string): Promise<boolean>
54
+ refusalFor?(door: string): Promise<EntryRefusal | undefined>
46
55
  /**
47
56
  * Runs one of the application's queries or mutations with the person's request state - its API, its directives
48
57
  * and its own checks. Answers what GraphQL answers.
@@ -56,6 +65,22 @@ export interface EntryApplyDeps {
56
65
 
57
66
  const nothing = (verdict: EntryApplyOutcome['verdict'], errorCode: string): EntryApplyOutcome => ({ verdict, errorCode, applied: 0, missed: 0, rows: [] })
58
67
 
68
+ /** Called after the existing audit. An unavailable explanation must not change the refusal. */
69
+ async function closedDoor(door: string, deps: EntryApplyDeps): Promise<EntryApplyOutcome> {
70
+ const outcome = nothing('refused', 'door-closed')
71
+ try {
72
+ const refusal = await deps.refusalFor?.(door)
73
+ return refusal ? { ...outcome, refusal } : outcome
74
+ } catch (error) {
75
+ try {
76
+ deps.report?.('[ai-assistant] entry refusal explanation unavailable', error)
77
+ } catch {
78
+ /* A diagnostic sink cannot turn a closed door into a failed proposal request. */
79
+ }
80
+ return outcome
81
+ }
82
+ }
83
+
59
84
  export function findEntryProposal(messages: StoredProposalMessage[], key: string): { proposal?: any; superseded: boolean } {
60
85
  let at = -1
61
86
  let proposal: any
@@ -85,7 +110,7 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
85
110
  const door = entryDoorOf(target.mutation)
86
111
  if (!(await deps.passesDoor(door))) {
87
112
  await recordEntryEvent(target, { phase: 'apply', tool: entryToolName(target.key), rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
88
- return nothing('refused', 'door-closed')
113
+ return closedDoor(door, deps)
89
114
  }
90
115
 
91
116
  /* Read again from the stored values - the same rules, and today's records for the target's checks. */
@@ -176,7 +201,7 @@ async function applyChanges(key: string, proposal: any, target: EntryTarget, dep
176
201
  for (const door of [rootFieldOf(update.mutation, 'mutation'), rootFieldOf(update.read.query, 'query')]) {
177
202
  if (!(await deps.passesDoor(door))) {
178
203
  await recordEntryEvent(target, { phase: 'apply', tool, rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
179
- return nothing('refused', 'door-closed')
204
+ return closedDoor(door, deps)
180
205
  }
181
206
  }
182
207
 
@@ -0,0 +1,73 @@
1
+ const mockJudge = jest.fn()
2
+ const mockLookup = jest.fn()
3
+ jest.mock('@things-factory/auth-base', () => ({
4
+ judgePermission: (...args: any[]) => mockJudge(...args),
5
+ privilegeOfField: (...args: any[]) => mockLookup(...args),
6
+ privilegeRejection: jest.requireActual('../../../../auth-base/server/service/privilege/privilege-rejection').privilegeRejection
7
+ }))
8
+
9
+ import { entryRefusalFor } from './entry-refusal'
10
+ import { requestableFrom } from '../../../../auth-ui/client/utils/access-request'
11
+
12
+ const gate = { category: 'warehouse', privilege: 'mutation', owner: true, super: true }
13
+ const state = { domain: { id: 'd' }, user: { id: 'u' }, unsafeIP: true, prohibitedPrivileges: [{ category: 'warehouse', privilege: 'mutation', by: 'license' }] }
14
+
15
+ beforeEach(() => {
16
+ mockJudge.mockReset().mockResolvedValue({ permitted: false, because: 'role' })
17
+ mockLookup.mockReset().mockReturnValue({ known: true, gate })
18
+ })
19
+
20
+ it('uses AUTH\'s full judgement and declaration, not a guess from a closed boolean', async () => {
21
+ const refusal = await entryRefusalFor({ state })('Mutation.updateInboundPlan')
22
+ expect(mockLookup).toHaveBeenCalledWith('Mutation', 'updateInboundPlan')
23
+ expect(mockJudge).toHaveBeenCalledWith(gate, state.user, state.domain, true, state.prohibitedPrivileges)
24
+ expect(refusal).toEqual({
25
+ message: 'unauthorized! warehouse:mutation privilege required',
26
+ extensions: { code: 'FORBIDDEN', requires: { category: 'warehouse', privilege: 'mutation' }, because: 'role', operation: 'mutation' },
27
+ path: ['updateInboundPlan']
28
+ })
29
+ expect(requestableFrom(refusal?.extensions, refusal?.path[0])).toEqual({ category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan' })
30
+ })
31
+
32
+ it.each(['address', 'license', 'ownership', 'unknown'])('carries %s without offering a role request', async because => {
33
+ mockJudge.mockResolvedValue({ permitted: false, because })
34
+ const refusal = await entryRefusalFor({ state })('Mutation.updateInboundPlan')
35
+ expect(refusal?.extensions.because).toBe(because)
36
+ expect(requestableFrom(refusal?.extensions)).toBeNull()
37
+ })
38
+
39
+ it('a failed read stays a query even when applyEntryProposal is a mutation', async () => {
40
+ const refusal = await entryRefusalFor({ state })('Query.inboundPlans')
41
+ expect(refusal?.extensions.operation).toBe('query')
42
+ expect(refusal?.path).toEqual(['inboundPlans'])
43
+ expect(requestableFrom(refusal?.extensions)).toBeNull()
44
+ })
45
+
46
+ it('ownership-only carries no named requirement', async () => {
47
+ mockLookup.mockReturnValue({ known: true, gate: { owner: true } })
48
+ mockJudge.mockResolvedValue({ permitted: false, because: 'ownership' })
49
+ expect((await entryRefusalFor({ state })('Mutation.reset'))?.extensions.requires).toBe('ownership')
50
+ })
51
+
52
+ it('unknown, malformed and open doors supply no invented refusal', async () => {
53
+ expect(await entryRefusalFor({ state })('not-a-door')).toBeUndefined()
54
+ expect(mockLookup).not.toHaveBeenCalled()
55
+ for (const lookup of [{ known: false }, { known: true, gate: null }]) {
56
+ mockLookup.mockReturnValue(lookup)
57
+ expect(await entryRefusalFor({ state })('Mutation.updateInboundPlan')).toBeUndefined()
58
+ }
59
+ expect(mockJudge).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('missing caller and a newly permitted judgement supply no refusal', async () => {
63
+ for (const context of [undefined, { state: { user: state.user } }, { state: { domain: state.domain } }])
64
+ expect(await entryRefusalFor(context)('Mutation.updateInboundPlan')).toBeUndefined()
65
+ expect(mockJudge).not.toHaveBeenCalled()
66
+ mockJudge.mockResolvedValue({ permitted: true })
67
+ expect(await entryRefusalFor({ state })('Mutation.updateInboundPlan')).toBeUndefined()
68
+ })
69
+
70
+ it('uses the request translator for the refusal sentence', async () => {
71
+ const refusal = await entryRefusalFor({ state, t: () => '권한이 필요합니다.' })('Mutation.updateInboundPlan')
72
+ expect(refusal?.message).toBe('권한이 필요합니다.')
73
+ })
@@ -0,0 +1,20 @@
1
+ import { judgePermission, privilegeOfField, privilegeRejection } from '@things-factory/auth-base'
2
+ import { doorField } from '../chat-message/door-access.js'
3
+ import type { EntryRefusal } from './entry-apply.js'
4
+
5
+ /** Presentation only: AUTH still guards the application's mutation when it is executed. */
6
+ export function entryRefusalFor(context: { state?: any; t?: any } | undefined): (door: string) => Promise<EntryRefusal | undefined> {
7
+ return async door => {
8
+ const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
9
+ const field = doorField(door)
10
+ if (!domain || !user || !field) return undefined
11
+ const asked = privilegeOfField(field.typeName, field.fieldName)
12
+ if (!asked.known || !asked.gate) return undefined
13
+ const judgement = await judgePermission(asked.gate as any, user, domain, unsafeIP, prohibitedPrivileges)
14
+ /* The gate may have opened since the preflight: never invent a refusal to explain that race. */
15
+ if (judgement.permitted !== false) return undefined
16
+ const operation = field.typeName === 'Mutation' ? 'mutation' : field.typeName === 'Query' ? 'query' : 'subscription'
17
+ const error = privilegeRejection(context, asked.gate.category, asked.gate.privilege, judgement.because, operation)
18
+ return { message: error.message, extensions: error.extensions, path: [field.fieldName] }
19
+ }
20
+ }
@@ -13,6 +13,7 @@ import { PROPOSAL_OUTCOME_PREFIX } from '../chat-message/proposal-outcome.js'
13
13
  import { toolAccessFor } from '../tool-access.js'
14
14
  import { applyEntryProposal, entryProposalAnswered, type EntryApplyOutcome } from './entry-apply.js'
15
15
  import { runAsCaller } from './entry-tools.js'
16
+ import { entryRefusalFor } from './entry-refusal.js'
16
17
 
17
18
  @Resolver()
18
19
  export class EntryProposalResolver {
@@ -57,6 +58,7 @@ export class EntryProposalResolver {
57
58
  messages: carrying.map((message: any) => ({ id: message.id, proposals: message.proposalsJson ?? [] })),
58
59
  answered: entryProposalAnswered(key, lines.map((line: any) => line.content)),
59
60
  passesDoor: door => passes(door),
61
+ refusalFor: entryRefusalFor(context),
60
62
  state: context.state,
61
63
  /*
62
64
  * The application's API, as the person - the same request state the door would have seen had they typed
@@ -5,6 +5,9 @@
5
5
  import { readFileSync } from 'fs'
6
6
  import { join } from 'path'
7
7
 
8
+ const mockNotify = jest.fn()
9
+ jest.mock('@operato/layout', () => ({ notify: (...args: any[]) => mockNotify(...args) }))
10
+
8
11
  import { runProposal, setEntryProposalHandler } from '../client/utils/assistant-proposals'
9
12
  import {
10
13
  entryGroupOf,
@@ -78,6 +81,34 @@ describe('the entry card', () => {
78
81
  expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1, missed: 0 })
79
82
  })
80
83
 
84
+ it('a closed door emits one generic notification and keeps its refused verdict', async () => {
85
+ const refusal = { message: 'required', extensions: { code: 'FORBIDDEN', because: 'role', operation: 'mutation', requires: { category: 'warehouse', privilege: 'mutation' } }, path: ['addExpenseLine'] }
86
+ const report = jest.fn()
87
+ const handler = entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, refusal }), t, () => undefined, report)
88
+ const context = { sessionId: 's', confirm: async () => true, notify: () => {} }
89
+ expect(await handler(card('k'), context)).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0 })
90
+ expect(report.mock.calls).toEqual([[{ level: 'error', message: 'required', ex: [refusal] }]])
91
+ const broken = entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', refusal }), t, () => undefined, () => { throw new Error('toast unavailable') })
92
+ expect(await broken(card('k'), context)).toMatchObject({ verdict: 'refused', errorCode: 'door-closed' })
93
+ const rejected = entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', refusal }), t, () => undefined, async () => { throw new Error('async delivery unavailable') })
94
+ expect(await rejected(card('k'), context)).toMatchObject({ verdict: 'refused', errorCode: 'door-closed' })
95
+ })
96
+
97
+ it('successful and stale-review answers emit no refusal notification', async () => {
98
+ const report = jest.fn()
99
+ for (const answer of [{ verdict: 'accepted', applied: 1 }, { verdict: 'refused', errorCode: 'review-stale' }] as EntryApplyAnswer[]) {
100
+ await entryProposalHandler(async () => answer, t, () => undefined, report)(card('k'), { sessionId: 's', confirm: async () => true, notify: () => {} })
101
+ }
102
+ expect(report).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('the default transport passes the serialized refusal to layout, with no explicit action', async () => {
106
+ mockNotify.mockClear()
107
+ const refusal = { message: 'required', extensions: { code: 'FORBIDDEN' }, path: ['addExpenseLine'] }
108
+ await entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', refusal }), t)(card('k'), { sessionId: 's', confirm: async () => true, notify: () => {} })
109
+ expect(mockNotify.mock.calls).toEqual([[{ level: 'error', message: 'required', ex: [refusal] }]])
110
+ })
111
+
81
112
  it('the chat draws the review and names the button by the rows it enters', () => {
82
113
  const chat = readFileSync(join(__dirname, '..', 'client', 'components', 'assistant-chat.ts'), 'utf8')
83
114
  expect(chat).toContain('<ox-assistant-entry-review .proposal=${p}>')
@@ -107,4 +138,3 @@ describe('the entry card', () => {
107
138
  expect(entryReadyCount(p)).toBe(2)
108
139
  })
109
140
  })
110
-