@things-factory/ai-assistant 10.1.37 → 10.1.39
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/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/chat-message/chat-message-broadcast.d.ts +15 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js +23 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -1
- package/dist-server/service/chat-message/chat-message-room.d.ts +22 -0
- package/dist-server/service/chat-message/chat-message-room.js +43 -0
- package/dist-server/service/chat-message/chat-message-room.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-subscription.js +17 -4
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
- package/dist-server/service/chat-session/session-activity-subscription.js +1 -2
- package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -1
- package/dist-server/service/index.d.ts +0 -1
- package/dist-server/service/index.js +0 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.js +6 -5
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/chat-message/chat-message-broadcast.ts +43 -0
- package/server/service/chat-message/chat-message-room.test.ts +140 -0
- package/server/service/chat-message/chat-message-room.ts +48 -0
- package/server/service/chat-message/chat-message-subscription.ts +21 -5
- package/server/service/chat-session/session-activity-subscription.ts +1 -2
- package/server/service/index.ts +0 -1
- package/server/service/testing/conversation-script.test.ts +1 -1
- package/server/service/testing/mode-conformance.test.ts +2 -2
- package/server/service/testing/mode-conformance.ts +6 -5
- package/server/tool-registry-boot-report.test.ts +13 -5
- package/test/open-doors.test.ts +132 -0
- package/dist-server/service/subscribe-auth.d.ts +0 -14
- package/dist-server/service/subscribe-auth.js +0 -33
- package/dist-server/service/subscribe-auth.js.map +0 -1
- package/server/service/subscribe-auth.test.ts +0 -57
- package/server/service/subscribe-auth.ts +0 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.39",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"@operato/graphql": "^10.0.0",
|
|
30
30
|
"@operato/i18n": "^10.0.0",
|
|
31
31
|
"@operato/styles": "^10.0.0",
|
|
32
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
32
|
+
"@things-factory/ai-client-base": "^10.1.39",
|
|
33
|
+
"@things-factory/auth-base": "^10.1.39",
|
|
34
34
|
"@things-factory/env": "^10.1.20",
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
35
|
+
"@things-factory/shell": "^10.1.39",
|
|
36
36
|
"dompurify": "^3.0.0",
|
|
37
37
|
"graphql-scalars": "^1.22.0",
|
|
38
38
|
"graphql-tag": "^2.12.6",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"copyfiles": "^2.4.1",
|
|
45
45
|
"rimraf": "^5.0.0"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
|
|
48
48
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* **ChatSession 단위**로 묶이므로 필터 키가 sessionId (+ domain 격리).
|
|
8
8
|
*/
|
|
9
9
|
import type { ChatMessage } from './chat-message.js' // type-only — 런타임 import 아님
|
|
10
|
+
import { mayReadSession } from '../chat-session/session-visibility.js'
|
|
10
11
|
|
|
11
12
|
/** chat-message 브로드캐스트 토픽. */
|
|
12
13
|
export const CHAT_MESSAGE_TOPIC = 'board-ai-chat-message'
|
|
@@ -25,3 +26,45 @@ export function chatMessageFilter(domainId: string, sessionId: string) {
|
|
|
25
26
|
return (payload: ChatMessagePayload): boolean =>
|
|
26
27
|
!!payload && payload.domainId === domainId && payload.sessionId === sessionId
|
|
27
28
|
}
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* ── Is the room open to this person — ADR-0073 decision 2 ─────────────────────
|
|
32
|
+
*
|
|
33
|
+
* An open conversation is readable by anyone in the domain, a private one by its participants. The
|
|
34
|
+
* stored path (`chatMessages`) asked that and the broadcast path did not — subscribing checked only
|
|
35
|
+
* domain membership, and the filter matched only domain + session. So anyone in the domain holding a
|
|
36
|
+
* private session's id received that conversation as it was written.
|
|
37
|
+
*
|
|
38
|
+
* The rule is one line (`mayReadSession`) and both paths read that line. Writing it again here would
|
|
39
|
+
* make two copies from that day on.
|
|
40
|
+
*/
|
|
41
|
+
/** The two questions about a room. The caller supplies the storage — this file does not know it. */
|
|
42
|
+
export interface ChatRoomLookup {
|
|
43
|
+
/** The session's visibility now; `undefined` when this domain has no such session. */
|
|
44
|
+
visibility(): Promise<string | null | undefined>
|
|
45
|
+
/** Whether the caller sits in that session now. */
|
|
46
|
+
isParticipant(): Promise<boolean>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {
|
|
50
|
+
const visibility = await room.visibility()
|
|
51
|
+
|
|
52
|
+
/* A session this domain does not have — never read as open. */
|
|
53
|
+
if (visibility === undefined) return false
|
|
54
|
+
|
|
55
|
+
/* An open room does not ask about membership — one query fewer per message. */
|
|
56
|
+
if (mayReadSession(visibility, false)) return true
|
|
57
|
+
|
|
58
|
+
return mayReadSession(visibility, await room.isParticipant())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Passes only the same domain + session, and only while **that room is open to this person**.
|
|
63
|
+
*
|
|
64
|
+
* The room is asked again for every message — a session can be made private while someone is
|
|
65
|
+
* listening. People write these messages, so they are infrequent, and an open room costs one query.
|
|
66
|
+
*/
|
|
67
|
+
export function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {
|
|
68
|
+
const sameSession = chatMessageFilter(domainId, sessionId)
|
|
69
|
+
return async (payload: ChatMessagePayload): Promise<boolean> => sameSession(payload) && (await mayHearRoom(room))
|
|
70
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: a private conversation does not stream to people outside it.
|
|
3
|
+
*
|
|
4
|
+
* ADR-0073 decision 2 — an open session is readable by anyone in the domain, a private one by its
|
|
5
|
+
* participants. The stored path (`chatMessages`) asked that; the live path did not. Subscribing
|
|
6
|
+
* checked domain membership, the filter matched domain + session, and the resolver redacted by doors
|
|
7
|
+
* — so anyone in the domain holding a private session's id received its messages as they were
|
|
8
|
+
* written. Same value, two paths, one of them asking about the room.
|
|
9
|
+
*
|
|
10
|
+
* The room is asked on every message, not only when subscribing: a session can be made private
|
|
11
|
+
* while someone is already listening.
|
|
12
|
+
*/
|
|
13
|
+
import { publishChatMessage } from './chat-message-publish'
|
|
14
|
+
import { chatMessageRoomFilter, mayHearRoom, type ChatRoomLookup } from './chat-message-broadcast'
|
|
15
|
+
import { chatMessageStream, chatRoomLookup } from './chat-message-room'
|
|
16
|
+
|
|
17
|
+
/** A room whose visibility and membership the test sets — and counts how often it is asked. */
|
|
18
|
+
function room(visibility: string | null | undefined, participant: boolean) {
|
|
19
|
+
const asked = { visibility: 0, participant: 0 }
|
|
20
|
+
const lookup: ChatRoomLookup & { set(v: string | null | undefined): void } = {
|
|
21
|
+
async visibility() {
|
|
22
|
+
asked.visibility++
|
|
23
|
+
return visibility
|
|
24
|
+
},
|
|
25
|
+
async isParticipant() {
|
|
26
|
+
asked.participant++
|
|
27
|
+
return participant
|
|
28
|
+
},
|
|
29
|
+
set(v) {
|
|
30
|
+
visibility = v
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { lookup, asked }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('mayHearRoom — the same rule the stored path reads', () => {
|
|
37
|
+
it('★ a private room is closed to someone who does not sit in it', async () => {
|
|
38
|
+
expect(await mayHearRoom(room('private', false).lookup)).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('a private room is open to its participants', async () => {
|
|
42
|
+
expect(await mayHearRoom(room('private', true).lookup)).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('an open room is open to the domain, and membership is not asked', async () => {
|
|
46
|
+
const { lookup, asked } = room('open', false)
|
|
47
|
+
expect(await mayHearRoom(lookup)).toBe(true)
|
|
48
|
+
expect(asked.participant).toBe(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('a session that predates the visibility column reads as open — the stored path reads it so too', async () => {
|
|
52
|
+
expect(await mayHearRoom(room(null, false).lookup)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('★ a session this domain does not have is closed, not open', async () => {
|
|
56
|
+
expect(await mayHearRoom(room(undefined, true).lookup)).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('chatMessageRoomFilter', () => {
|
|
61
|
+
const payload = (over: any = {}) => ({ domainId: 'd1', sessionId: 's1', message: { id: 'm1' } as any, ...over })
|
|
62
|
+
|
|
63
|
+
it('another domain or another session never passes, and the room is not asked', async () => {
|
|
64
|
+
const { lookup, asked } = room('open', true)
|
|
65
|
+
const pass = chatMessageRoomFilter('d1', 's1', lookup)
|
|
66
|
+
|
|
67
|
+
expect(await pass(payload({ domainId: 'd2' }))).toBe(false)
|
|
68
|
+
expect(await pass(payload({ sessionId: 's2' }))).toBe(false)
|
|
69
|
+
expect(asked.visibility).toBe(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('★ the room is asked again for every message — a session made private mid-stream stops streaming', async () => {
|
|
73
|
+
const { lookup } = room('open', false)
|
|
74
|
+
const pass = chatMessageRoomFilter('d1', 's1', lookup)
|
|
75
|
+
|
|
76
|
+
expect(await pass(payload())).toBe(true)
|
|
77
|
+
lookup.set('private')
|
|
78
|
+
expect(await pass(payload())).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('chatRoomLookup — both questions stay inside the caller\'s domain', () => {
|
|
83
|
+
function repos(session: any, participant: any) {
|
|
84
|
+
const seen: any[] = []
|
|
85
|
+
const make = (row: any) => ({
|
|
86
|
+
findOne: async (options: any) => {
|
|
87
|
+
seen.push(options.where)
|
|
88
|
+
return row
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
return { deps: { sessions: make(session), participants: make(participant) }, seen }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
it('★ the session and the seat are both looked up with the domain', async () => {
|
|
95
|
+
const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1' })
|
|
96
|
+
const lookup = chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' })
|
|
97
|
+
|
|
98
|
+
expect(await lookup.visibility()).toBe('private')
|
|
99
|
+
expect(await lookup.isParticipant()).toBe(true)
|
|
100
|
+
expect(seen[0]).toEqual({ id: 's1', domain: { id: 'd1' } })
|
|
101
|
+
expect(seen[1]).toEqual({ session: { id: 's1' }, user: { id: 'u1' }, domain: { id: 'd1' } })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('no session in this domain answers undefined — the rule reads that as closed', async () => {
|
|
105
|
+
const { deps } = repos(null, null)
|
|
106
|
+
expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: 'u1' }).visibility()).toBeUndefined()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('a caller with no user sits nowhere', async () => {
|
|
110
|
+
const { deps, seen } = repos({ id: 's1', visibility: 'private' }, { id: 'p1' })
|
|
111
|
+
expect(await chatRoomLookup(deps, { domainId: 'd1', sessionId: 's1', userId: undefined }).isParticipant()).toBe(false)
|
|
112
|
+
expect(seen).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('the live stream the subscription returns', () => {
|
|
117
|
+
function nextOrTimeout(it: AsyncIterator<any>, ms = 300): Promise<any> {
|
|
118
|
+
return Promise.race([it.next().then(r => r.value), new Promise(resolve => setTimeout(() => resolve('TIMEOUT'), ms))])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
it('★ someone outside a private room does not receive what is said in it', async () => {
|
|
122
|
+
const it = chatMessageStream('d1', 's-private', room('private', false).lookup)[Symbol.asyncIterator]()
|
|
123
|
+
const next = nextOrTimeout(it)
|
|
124
|
+
|
|
125
|
+
publishChatMessage({ domainId: 'd1', sessionId: 's-private', message: { id: 'm1', role: 'user', content: 'not for you' } as any })
|
|
126
|
+
|
|
127
|
+
expect(await next).toBe('TIMEOUT')
|
|
128
|
+
await it.return?.()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('a participant does', async () => {
|
|
132
|
+
const it = chatMessageStream('d1', 's-private', room('private', true).lookup)[Symbol.asyncIterator]()
|
|
133
|
+
const next = nextOrTimeout(it)
|
|
134
|
+
|
|
135
|
+
publishChatMessage({ domainId: 'd1', sessionId: 's-private', message: { id: 'm2', role: 'user', content: 'for you' } as any })
|
|
136
|
+
|
|
137
|
+
expect((await next)?.message?.id).toBe('m2')
|
|
138
|
+
await it.return?.()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The room on the broadcast path — the stream the subscription returns, and the two lookups that
|
|
3
|
+
* ask about the room.
|
|
4
|
+
*
|
|
5
|
+
* jest cannot read the subscription resolver (parameter decorators: @Root · @Arg), so composing the
|
|
6
|
+
* stream lives here — the test runs **the same thing** the resolver returns
|
|
7
|
+
* (`chat-message-room.test.ts`).
|
|
8
|
+
*
|
|
9
|
+
* The caller passes the storage in. Importing the entities here would make this file unreadable to
|
|
10
|
+
* the tests too.
|
|
11
|
+
*/
|
|
12
|
+
import { filter, pipe } from 'graphql-yoga'
|
|
13
|
+
import { pubsub } from '@things-factory/shell'
|
|
14
|
+
|
|
15
|
+
import { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'
|
|
16
|
+
|
|
17
|
+
interface FindOne {
|
|
18
|
+
findOne(options: { where: any; select?: any }): Promise<any>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The two lookups about a room. **Both search within the caller's domain** — the same condition as
|
|
23
|
+
* the stored path's query (`chatMessages`).
|
|
24
|
+
*/
|
|
25
|
+
export function chatRoomLookup(
|
|
26
|
+
repos: { sessions: FindOne; participants: FindOne },
|
|
27
|
+
who: { domainId: string; sessionId: string; userId?: string }
|
|
28
|
+
): ChatRoomLookup {
|
|
29
|
+
return {
|
|
30
|
+
async visibility() {
|
|
31
|
+
const session = await repos.sessions.findOne({ where: { id: who.sessionId, domain: { id: who.domainId } } })
|
|
32
|
+
return session ? ((session as any).visibility ?? null) : undefined
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async isParticipant() {
|
|
36
|
+
if (!who.userId) return false
|
|
37
|
+
const seat = await repos.participants.findOne({
|
|
38
|
+
where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }
|
|
39
|
+
})
|
|
40
|
+
return !!seat
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The stream the subscription returns — same domain + session, and the room open to this person at every message. */
|
|
46
|
+
export function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {
|
|
47
|
+
return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))
|
|
48
|
+
}
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
* 순수 로직(토픽/필터)은 chat-message-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.
|
|
15
15
|
*/
|
|
16
16
|
import { Resolver, Subscription, Root, Arg, Ctx } from 'type-graphql'
|
|
17
|
-
import {
|
|
18
|
-
import { pubsub } from '@things-factory/shell'
|
|
17
|
+
import { assertDomainSubscribeAllowed, getRepository } from '@things-factory/shell'
|
|
19
18
|
import { ChatMessage } from './chat-message.js'
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
19
|
+
import { ChatSession } from '../chat-session/chat-session.js'
|
|
20
|
+
import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
|
|
21
|
+
import { mayHearRoom, type ChatMessagePayload } from './chat-message-broadcast.js'
|
|
22
|
+
import { chatMessageStream, chatRoomLookup } from './chat-message-room.js'
|
|
22
23
|
import { readerGate } from './reader-gate.js'
|
|
23
24
|
|
|
24
25
|
// publishChatMessage 는 데코레이터 없는 chat-message-publish.ts 에 분리 (테스트 import 가능).
|
|
@@ -43,7 +44,22 @@ export class ChatMessageSubscription {
|
|
|
43
44
|
|
|
44
45
|
await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
/*
|
|
48
|
+
* **Ask about the room** — ADR-0073 decision 2. An open room is for anyone in the domain, a private
|
|
49
|
+
* one for its participants. The stored path (`chatMessages`) asked this and the broadcast path did
|
|
50
|
+
* not, so anyone in the domain holding a private session's id received it live.
|
|
51
|
+
*
|
|
52
|
+
* A room the caller cannot enter is refused rather than handed an empty stream, in the same words
|
|
53
|
+
* as a missing session — so a private room's existence is not confirmed either. Once subscribed,
|
|
54
|
+
* the room is asked again for every message (`chatMessageRoomFilter`).
|
|
55
|
+
*/
|
|
56
|
+
const room = chatRoomLookup(
|
|
57
|
+
{ sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },
|
|
58
|
+
{ domainId: domain.id, sessionId, userId: user?.id }
|
|
59
|
+
)
|
|
60
|
+
if (!(await mayHearRoom(room))) throw new Error('chat session not found')
|
|
61
|
+
|
|
62
|
+
return chatMessageStream(domain.id, sessionId, room)
|
|
47
63
|
}
|
|
48
64
|
})
|
|
49
65
|
/*
|
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Resolver, Subscription, Root, ObjectType, Field } from 'type-graphql'
|
|
10
10
|
import { filter, pipe } from 'graphql-yoga'
|
|
11
|
-
import { pubsub } from '@things-factory/shell'
|
|
11
|
+
import { assertDomainSubscribeAllowed, pubsub } from '@things-factory/shell'
|
|
12
12
|
|
|
13
13
|
import { SESSION_ACTIVITY_TOPIC, sessionActivityFilter, type SessionActivityPayload } from './session-activity-broadcast.js'
|
|
14
|
-
import { assertDomainSubscribeAllowed } from '../subscribe-auth.js'
|
|
15
14
|
|
|
16
15
|
export { publishSessionActivity } from './session-activity-publish.js'
|
|
17
16
|
|
package/server/service/index.ts
CHANGED
|
@@ -21,7 +21,6 @@ export * from './assistant-capability-resolver.js'
|
|
|
21
21
|
export * from './tool-access.js'
|
|
22
22
|
/* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
|
|
23
23
|
export * from './grounding-sources.js'
|
|
24
|
-
export * from './subscribe-auth.js'
|
|
25
24
|
/* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
|
|
26
25
|
export * from './chat-session/session-anchor.js'
|
|
27
26
|
/* Who may read a conversation — one rule, also asked by doors outside this package (board-ai's revertPatch). */
|
|
@@ -101,7 +101,7 @@ it('★ a proposal belongs to the turn that asked for it', async () => {
|
|
|
101
101
|
{
|
|
102
102
|
kind: 'read',
|
|
103
103
|
name: 'proposeSceneEdit',
|
|
104
|
-
/*
|
|
104
|
+
/* An authoring tool reads only the open scene — it reaches no storage, so it has no doors. */
|
|
105
105
|
doors: [],
|
|
106
106
|
description: 'propose placing a component',
|
|
107
107
|
schema: {},
|
|
@@ -26,9 +26,9 @@ beforeEach(() => {
|
|
|
26
26
|
name: 'twin-authoring',
|
|
27
27
|
guidance: 'read the scene before proposing an edit',
|
|
28
28
|
specs: [
|
|
29
|
-
/*
|
|
29
|
+
/* Hands back what the surface sent — reaches no storage. */
|
|
30
30
|
{ kind: 'read', name: 'getOpenScene', description: 'the open scene', schema: {}, doors: [], builder: () => ({}) },
|
|
31
|
-
/*
|
|
31
|
+
/* Reads storage — names its door. This is where the gate splits. */
|
|
32
32
|
{ kind: 'read', name: 'getTwinStructure', description: 'the twin structure', schema: {}, doors: ['Query.twinInstances'], builder: () => ({}) }
|
|
33
33
|
]
|
|
34
34
|
} as any)
|
|
@@ -53,11 +53,12 @@ export async function runRegisteredMode(
|
|
|
53
53
|
maxTokens: settings.maxTokens,
|
|
54
54
|
hostContext: options.hostContext,
|
|
55
55
|
/*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* This harness asks about **the mode** — "what does this mode open", not "does this person pass the
|
|
57
|
+
* doors". Since the gate moved onto `doors` (ADR-0071 decision 2), passing no check closes every tool
|
|
58
|
+
* that names a door, and an app's conformance test would quietly lose **exactly the tools that read
|
|
59
|
+
* real data**. So the default is someone who passes every door; an app that wants the other case
|
|
60
|
+
* writes `toolAccess`. The permission axis has its own tests (`tool-access.test.ts` ·
|
|
61
|
+
* `tool-scope.test.ts`).
|
|
61
62
|
*/
|
|
62
63
|
toolAccess: options.toolAccess ?? (async () => true),
|
|
63
64
|
state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } }
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The boot report prints what registered but is not yet what the contracts require.
|
|
3
|
+
*
|
|
4
|
+
* It also asks the schema, through AUTH's field table, whether a door a tool names exists. That
|
|
5
|
+
* import is why this suite did not load from 2026-09-18 (`0c3a8ac939`) until 2026-09-24: the
|
|
6
|
+
* package root of auth-base brings shell and its ORM configuration with it, and jest has no module
|
|
7
|
+
* list to give that configuration. The table is a function of one field name, so it is stood in
|
|
8
|
+
* here — the door report has its own tests (`door-declaration-report.test.ts`).
|
|
9
|
+
*/
|
|
1
10
|
jest.mock('@things-factory/env', () => ({ logger: { warn: jest.fn() } }))
|
|
11
|
+
jest.mock('@things-factory/auth-base', () => ({ privilegeOfField: () => ({ known: false }) }))
|
|
2
12
|
|
|
3
13
|
import { formatToolRegistryWarnings } from './tool-registry-boot-report'
|
|
4
14
|
|
|
5
15
|
it('★ groups the recorded omissions by code and names every tool', () => {
|
|
6
16
|
const lines = formatToolRegistryWarnings([
|
|
7
|
-
{ code: 'tool-
|
|
8
|
-
{ code: 'tool-
|
|
17
|
+
{ code: 'tool-doors-undeclared', params: { category: 'twin-authoring', tool: 'getOpenScene', kind: 'read' }, message: '' },
|
|
18
|
+
{ code: 'tool-doors-undeclared', params: { category: 'x-ops', tool: 'runThing', kind: 'external' }, message: '' }
|
|
9
19
|
])
|
|
10
20
|
|
|
11
|
-
expect(lines).toEqual([
|
|
12
|
-
'[ai-assistant] tool-privilege-undeclared ×2: twin-authoring/bindTwinEntity(write), x-ops/runThing(external)'
|
|
13
|
-
])
|
|
21
|
+
expect(lines).toEqual(['[ai-assistant] tool-doors-undeclared ×2: twin-authoring/getOpenScene(read), x-ops/runThing(external)'])
|
|
14
22
|
})
|
|
15
23
|
|
|
16
24
|
it('says nothing when every tool is declared', () => {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which of ai-assistant's doors are open, and why each one is allowed to be.
|
|
3
|
+
*
|
|
4
|
+
* ── The thing this catches ──────────────────────────────────────────────────
|
|
5
|
+
* `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is
|
|
6
|
+
* indistinguishable from a field somebody decided to open. The list below turns the absence into a
|
|
7
|
+
* statement: anything open that is not on it fails, and the reason has to be written first. Same
|
|
8
|
+
* measure as `worklist/tests/open-doors.test.ts` and `headless-twin/test/open-doors.test.ts`.
|
|
9
|
+
*
|
|
10
|
+
* ── What the census found (2026-09-24) ──────────────────────────────────────
|
|
11
|
+
* 27 root fields, 25 declare `ai-assistant:query` or `:mutation`. The two open ones are the
|
|
12
|
+
* subscriptions — a stream has no `@privilege` to carry, so what keeps it inside the tenant is its
|
|
13
|
+
* subscribe and its filter. Writing their reasons found one false: `chatMessageActivity` asked the
|
|
14
|
+
* domain and the session but not the room, so a private conversation streamed to anyone in the
|
|
15
|
+
* domain holding its id (ADR-0073 decision 2). Fixed in `8264dc46e7`; the reason below is true
|
|
16
|
+
* from then on, and `chat-message-room.test.ts` keeps it true.
|
|
17
|
+
*
|
|
18
|
+
* ── Why the compiled output ──────────────────────────────────────────────────
|
|
19
|
+
* type-graphql needs `emitDecoratorMetadata` to build the schema; jest's babel transform does not
|
|
20
|
+
* emit it. So this reads `dist-server` — and a build older than its sources would miss a door added
|
|
21
|
+
* since and pass, hence the freshness check before anything else.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
24
|
+
import { join, relative } from 'path'
|
|
25
|
+
|
|
26
|
+
import { buildSchema } from 'type-graphql'
|
|
27
|
+
import type { GraphQLSchema } from 'graphql'
|
|
28
|
+
|
|
29
|
+
const PKG = join(__dirname, '..')
|
|
30
|
+
const DIST = join(PKG, 'dist-server')
|
|
31
|
+
const AUTH_BASE_DIST = join(PKG, '..', 'auth-base', 'dist-server')
|
|
32
|
+
|
|
33
|
+
/** Resolver sources newer than their build: the schema below would not be the one served. */
|
|
34
|
+
function staleResolvers(): string[] {
|
|
35
|
+
const out: string[] = []
|
|
36
|
+
const walk = (dir: string) => {
|
|
37
|
+
for (const name of readdirSync(dir)) {
|
|
38
|
+
const path = join(dir, name)
|
|
39
|
+
if (statSync(path).isDirectory()) walk(path)
|
|
40
|
+
else if (path.endsWith('.ts') && !path.endsWith('.test.ts') && /@(Query|Mutation|Subscription)\(/.test(readFileSync(path, 'utf8'))) {
|
|
41
|
+
const built = join(DIST, relative(join(PKG, 'server'), path)).replace(/\.ts$/, '.js')
|
|
42
|
+
if (!existsSync(built) || statSync(built).mtimeMs < statSync(path).mtimeMs) out.push(relative(PKG, path))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
walk(join(PKG, 'server'))
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Root = 'Query' | 'Mutation' | 'Subscription'
|
|
51
|
+
|
|
52
|
+
/** Open on purpose, each with what keeps it inside the caller's tenant — and, for a room, inside the room. */
|
|
53
|
+
const OPEN_BY_DECISION: Record<Root, Record<string, string>> = {
|
|
54
|
+
Query: {},
|
|
55
|
+
Mutation: {},
|
|
56
|
+
Subscription: {
|
|
57
|
+
chatMessageActivity:
|
|
58
|
+
"subscribe: a member of the domain, and the room open to the caller — open to the domain, private to its participants " +
|
|
59
|
+
'(ADR-0073 decision 2), refused with the words of a missing session otherwise. Every message: same domain + session, ' +
|
|
60
|
+
'and the room asked again. The body is redacted by doors for each receiver (ADR-0073 ruling 1).',
|
|
61
|
+
mySessionActivity:
|
|
62
|
+
"subscribe: a member of the domain. Every message: same domain, and the caller among the payload's participants. " +
|
|
63
|
+
'Badge fields and a one-line preview only, no body.'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let schema: GraphQLSchema
|
|
68
|
+
let privilegeOfField: (typeName: string, field: string) => { known: boolean; gate?: unknown }
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
const stale = staleResolvers()
|
|
72
|
+
if (stale.length) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`dist-server is older than ${stale.length} resolver source(s), so the schema below is not the one served:\n ` +
|
|
75
|
+
stale.join('\n ') +
|
|
76
|
+
'\nBuild first: yarn workspace @things-factory/ai-assistant build:server'
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { schema: declared } = require(join(DIST, 'service', 'index.js'))
|
|
81
|
+
const directive = require(join(AUTH_BASE_DIST, 'service', 'privilege', 'privilege-directive.js'))
|
|
82
|
+
privilegeOfField = directive.privilegeOfField
|
|
83
|
+
|
|
84
|
+
schema = await buildSchema({
|
|
85
|
+
resolvers: declared.resolverClasses,
|
|
86
|
+
validate: false,
|
|
87
|
+
/* The subscriptions name their own subscribe; the walk only needs a pubSub to accept them. */
|
|
88
|
+
pubSub: { publish() {}, subscribe() {} } as any
|
|
89
|
+
})
|
|
90
|
+
directive.privilegeDirectiveResolver(schema)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
function fieldsOf(typeName: Root): string[] {
|
|
94
|
+
const type = typeName === 'Query' ? schema.getQueryType() : typeName === 'Mutation' ? schema.getMutationType() : schema.getSubscriptionType()
|
|
95
|
+
return Object.keys(type?.getFields() ?? {})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openFields(typeName: Root): string[] {
|
|
99
|
+
return fieldsOf(typeName).filter(field => {
|
|
100
|
+
const gate = privilegeOfField(typeName, field)
|
|
101
|
+
return gate.known && gate.gate === null
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('every open door has a written reason', () => {
|
|
106
|
+
it.each(['Query', 'Mutation', 'Subscription'] as const)('%s', typeName => {
|
|
107
|
+
expect(openFields(typeName).sort()).toEqual(Object.keys(OPEN_BY_DECISION[typeName]).sort())
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('what the walk itself has to be true for', () => {
|
|
112
|
+
it('reaches the whole schema, not a handful of fields', () => {
|
|
113
|
+
/* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */
|
|
114
|
+
expect(fieldsOf('Query').length).toBeGreaterThanOrEqual(10)
|
|
115
|
+
expect(fieldsOf('Mutation').length).toBeGreaterThanOrEqual(13)
|
|
116
|
+
expect(fieldsOf('Subscription')).toHaveLength(2)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('tells an undeclared field from one the schema does not have', () => {
|
|
120
|
+
/* `known: false` must never read as open — that is how a typo becomes a gate that is not there. */
|
|
121
|
+
expect(privilegeOfField('Query', 'noSuchField')).toEqual({ known: false })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('the census, in plain numbers', () => {
|
|
125
|
+
/* 2026-09-24: 27 root fields, 25 declared, 2 open — both streams. */
|
|
126
|
+
const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
|
|
127
|
+
const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
|
|
128
|
+
|
|
129
|
+
expect(all).toBe(27)
|
|
130
|
+
expect(open).toBe(2)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 구독 허용 여부 단언. 불가 시 throw (graphql-ws 가 구독 거부).
|
|
3
|
-
* - domain 없음 → 거부.
|
|
4
|
-
* - user 가 해당 subdomain 의 도메인 소속이 아니고 superUser 도 아니면 → 거부.
|
|
5
|
-
*
|
|
6
|
-
* 주의(버그 수정): process.superUserGranted 는 async(Promise<boolean>) 다. 기존 양쪽 spine 은
|
|
7
|
-
* `!process.superUserGranted(...)` 로 동기 평가해 `!Promise` = 항상 false → membership throw 가
|
|
8
|
-
* 발동하지 않는 fail-open 상태였다. 여기서는 await 로 제대로 평가한다 → subscribe 는 async.
|
|
9
|
-
*
|
|
10
|
-
* @param domain context.state.domain
|
|
11
|
-
* @param user context.state.user (domains[] / email 사용)
|
|
12
|
-
* @param isSuperUser (domain, user) → boolean | Promise<boolean> (process.superUserGranted 주입)
|
|
13
|
-
*/
|
|
14
|
-
export declare function assertDomainSubscribeAllowed(domain: any, user: any, isSuperUser: (domain: any, user: any) => boolean | Promise<boolean>): Promise<void>;
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/*
|
|
3
|
-
* 구독 도메인 인증 게이트 — 순수 로직 (데코레이터·pubsub 없음 → 테스트 가능).
|
|
4
|
-
*
|
|
5
|
-
* 실시간 구독(@Subscription)은 같은 도메인 구성원만 허용해야 한다(도메인 격리 = 보안 핵심).
|
|
6
|
-
* 이 게이트는 board-patch / chat-message 양쪽 spine 의 subscribe 클로저가 공유한다
|
|
7
|
-
* (reuse — fork 금지). superUserGranted 는 process 전역 의존이라 주입받아 순수성 유지.
|
|
8
|
-
*/
|
|
9
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.assertDomainSubscribeAllowed = assertDomainSubscribeAllowed;
|
|
11
|
-
/**
|
|
12
|
-
* 구독 허용 여부 단언. 불가 시 throw (graphql-ws 가 구독 거부).
|
|
13
|
-
* - domain 없음 → 거부.
|
|
14
|
-
* - user 가 해당 subdomain 의 도메인 소속이 아니고 superUser 도 아니면 → 거부.
|
|
15
|
-
*
|
|
16
|
-
* 주의(버그 수정): process.superUserGranted 는 async(Promise<boolean>) 다. 기존 양쪽 spine 은
|
|
17
|
-
* `!process.superUserGranted(...)` 로 동기 평가해 `!Promise` = 항상 false → membership throw 가
|
|
18
|
-
* 발동하지 않는 fail-open 상태였다. 여기서는 await 로 제대로 평가한다 → subscribe 는 async.
|
|
19
|
-
*
|
|
20
|
-
* @param domain context.state.domain
|
|
21
|
-
* @param user context.state.user (domains[] / email 사용)
|
|
22
|
-
* @param isSuperUser (domain, user) → boolean | Promise<boolean> (process.superUserGranted 주입)
|
|
23
|
-
*/
|
|
24
|
-
async function assertDomainSubscribeAllowed(domain, user, isSuperUser) {
|
|
25
|
-
if (!domain) {
|
|
26
|
-
throw new Error('domain required');
|
|
27
|
-
}
|
|
28
|
-
const subdomain = domain?.subdomain;
|
|
29
|
-
if (!user?.domains?.find((d) => d.subdomain === subdomain) && !(await isSuperUser(domain, user))) {
|
|
30
|
-
throw new Error(`domain(${subdomain}) is not working for user(${user?.email}).`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
//# sourceMappingURL=subscribe-auth.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"subscribe-auth.js","sourceRoot":"","sources":["../../server/service/subscribe-auth.ts"],"names":[],"mappings":";AAAA;;;;;;GAMG;;AAeH,oEAYC;AAzBD;;;;;;;;;;;;GAYG;AACI,KAAK,UAAU,4BAA4B,CAChD,MAAW,EACX,IAAS,EACT,WAAmE;IAEnE,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAA;IACpC,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,EAAE,SAAS,CAAA;IACnC,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,IAAI,CAAC,CAAC,MAAM,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC;QACtG,MAAM,IAAI,KAAK,CAAC,UAAU,SAAS,6BAA6B,IAAI,EAAE,KAAK,IAAI,CAAC,CAAA;IAClF,CAAC;AACH,CAAC","sourcesContent":["/*\n * 구독 도메인 인증 게이트 — 순수 로직 (데코레이터·pubsub 없음 → 테스트 가능).\n *\n * 실시간 구독(@Subscription)은 같은 도메인 구성원만 허용해야 한다(도메인 격리 = 보안 핵심).\n * 이 게이트는 board-patch / chat-message 양쪽 spine 의 subscribe 클로저가 공유한다\n * (reuse — fork 금지). superUserGranted 는 process 전역 의존이라 주입받아 순수성 유지.\n */\n\n/**\n * 구독 허용 여부 단언. 불가 시 throw (graphql-ws 가 구독 거부).\n * - domain 없음 → 거부.\n * - user 가 해당 subdomain 의 도메인 소속이 아니고 superUser 도 아니면 → 거부.\n *\n * 주의(버그 수정): process.superUserGranted 는 async(Promise<boolean>) 다. 기존 양쪽 spine 은\n * `!process.superUserGranted(...)` 로 동기 평가해 `!Promise` = 항상 false → membership throw 가\n * 발동하지 않는 fail-open 상태였다. 여기서는 await 로 제대로 평가한다 → subscribe 는 async.\n *\n * @param domain context.state.domain\n * @param user context.state.user (domains[] / email 사용)\n * @param isSuperUser (domain, user) → boolean | Promise<boolean> (process.superUserGranted 주입)\n */\nexport async function assertDomainSubscribeAllowed(\n domain: any,\n user: any,\n isSuperUser: (domain: any, user: any) => boolean | Promise<boolean>\n): Promise<void> {\n if (!domain) {\n throw new Error('domain required')\n }\n const subdomain = domain?.subdomain\n if (!user?.domains?.find((d: any) => d.subdomain === subdomain) && !(await isSuperUser(domain, user))) {\n throw new Error(`domain(${subdomain}) is not working for user(${user?.email}).`)\n }\n}\n"]}
|