@things-factory/ai-assistant 10.1.37 → 10.1.38

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 (28) hide show
  1. package/dist-client/tsconfig.tsbuildinfo +1 -1
  2. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +15 -0
  3. package/dist-server/service/chat-message/chat-message-broadcast.js +23 -0
  4. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -1
  5. package/dist-server/service/chat-message/chat-message-room.d.ts +22 -0
  6. package/dist-server/service/chat-message/chat-message-room.js +40 -0
  7. package/dist-server/service/chat-message/chat-message-room.js.map +1 -0
  8. package/dist-server/service/chat-message/chat-message-subscription.js +16 -4
  9. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
  10. package/dist-server/service/chat-session/session-activity-subscription.js +1 -2
  11. package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -1
  12. package/dist-server/service/index.d.ts +0 -1
  13. package/dist-server/service/index.js +0 -1
  14. package/dist-server/service/index.js.map +1 -1
  15. package/dist-server/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +4 -4
  17. package/server/service/chat-message/chat-message-broadcast.ts +41 -0
  18. package/server/service/chat-message/chat-message-room.test.ts +140 -0
  19. package/server/service/chat-message/chat-message-room.ts +45 -0
  20. package/server/service/chat-message/chat-message-subscription.ts +20 -5
  21. package/server/service/chat-session/session-activity-subscription.ts +1 -2
  22. package/server/service/index.ts +0 -1
  23. package/server/tool-registry-boot-report.test.ts +13 -5
  24. package/dist-server/service/subscribe-auth.d.ts +0 -14
  25. package/dist-server/service/subscribe-auth.js +0 -33
  26. package/dist-server/service/subscribe-auth.js.map +0 -1
  27. package/server/service/subscribe-auth.test.ts +0 -57
  28. 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.37",
3
+ "version": "10.1.38",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -30,9 +30,9 @@
30
30
  "@operato/i18n": "^10.0.0",
31
31
  "@operato/styles": "^10.0.0",
32
32
  "@things-factory/ai-client-base": "^10.1.31",
33
- "@things-factory/auth-base": "^10.1.37",
33
+ "@things-factory/auth-base": "^10.1.38",
34
34
  "@things-factory/env": "^10.1.20",
35
- "@things-factory/shell": "^10.1.37",
35
+ "@things-factory/shell": "^10.1.38",
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": "d8a69e5c8803a4b9d3289df0cf38e161a207db61"
47
+ "gitHead": "b947349fe1c27d11038b592ceadaba336f14785b"
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,43 @@ 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
+ * ── 방이 이 사람에게 열려 있나 — ADR-0073 결정 2 ────────────────────────────
32
+ *
33
+ * 공개 대화는 도메인 안 누구나, 비공개 대화는 참여자만. 저장된 길(`chatMessages`)은 그것을 묻고
34
+ * 있었고 방송 길은 묻지 않았다 — 구독 때 도메인 소속만 보고, 필터는 도메인 + 세션만 맞췄다. 그래서
35
+ * 비공개 세션의 id 를 가진 도메인 안 누구나 그 대화를 실시간으로 받았다.
36
+ *
37
+ * 규칙은 한 줄(`mayReadSession`)이고 두 길이 같은 줄을 읽는다. 여기서 다시 적으면 그날부터 두 벌이다.
38
+ */
39
+ /** 방에 대한 두 물음. 저장소는 부르는 쪽이 준다 — 이 파일은 저장소를 모른다. */
40
+ export interface ChatRoomLookup {
41
+ /** 그 세션의 지금 공개 범위. 이 도메인에 그런 세션이 없으면 `undefined`. */
42
+ visibility(): Promise<string | null | undefined>
43
+ /** 부르는 사람이 지금 그 세션의 참여자인가. */
44
+ isParticipant(): Promise<boolean>
45
+ }
46
+
47
+ export async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {
48
+ const visibility = await room.visibility()
49
+
50
+ /* 이 도메인에 없는 세션 — 열림으로 읽지 않는다. */
51
+ if (visibility === undefined) return false
52
+
53
+ /* 공개 방이면 참여 여부를 묻지 않는다. 방송마다 조회가 하나 준다. */
54
+ if (mayReadSession(visibility, false)) return true
55
+
56
+ return mayReadSession(visibility, await room.isParticipant())
57
+ }
58
+
59
+ /**
60
+ * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.
61
+ *
62
+ * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라
63
+ * 메시지가 드물고, 공개 방은 조회 하나로 끝난다.
64
+ */
65
+ export function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {
66
+ const sameSession = chatMessageFilter(domainId, sessionId)
67
+ return async (payload: ChatMessagePayload): Promise<boolean> => sameSession(payload) && (await mayHearRoom(room))
68
+ }
@@ -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,45 @@
1
+ /*
2
+ * 방송 길의 방 — 구독이 돌려주는 스트림과, 방을 묻는 두 조회.
3
+ *
4
+ * 구독 리졸버는 파라미터 데코레이터(@Root · @Arg) 때문에 jest 가 못 읽는다. 그래서 스트림을 짜는 일을
5
+ * 여기로 뺐다 — 시험이 리졸버가 돌려주는 것과 **같은 것**을 돌린다(`chat-message-room.test.ts`).
6
+ *
7
+ * 저장소는 부르는 쪽이 넘긴다. 엔티티를 여기서 import 하면 이 파일도 시험에서 못 읽는다.
8
+ */
9
+ import { filter, pipe } from 'graphql-yoga'
10
+ import { pubsub } from '@things-factory/shell'
11
+
12
+ import { CHAT_MESSAGE_TOPIC, chatMessageRoomFilter, type ChatRoomLookup } from './chat-message-broadcast.js'
13
+
14
+ interface FindOne {
15
+ findOne(options: { where: any; select?: any }): Promise<any>
16
+ }
17
+
18
+ /**
19
+ * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와
20
+ * 같은 조건이다.
21
+ */
22
+ export function chatRoomLookup(
23
+ repos: { sessions: FindOne; participants: FindOne },
24
+ who: { domainId: string; sessionId: string; userId?: string }
25
+ ): ChatRoomLookup {
26
+ return {
27
+ async visibility() {
28
+ const session = await repos.sessions.findOne({ where: { id: who.sessionId, domain: { id: who.domainId } } })
29
+ return session ? ((session as any).visibility ?? null) : undefined
30
+ },
31
+
32
+ async isParticipant() {
33
+ if (!who.userId) return false
34
+ const seat = await repos.participants.findOne({
35
+ where: { session: { id: who.sessionId }, user: { id: who.userId }, domain: { id: who.domainId } }
36
+ })
37
+ return !!seat
38
+ }
39
+ }
40
+ }
41
+
42
+ /** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */
43
+ export function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {
44
+ return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))
45
+ }
@@ -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 { filter, pipe } from 'graphql-yoga'
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 { CHAT_MESSAGE_TOPIC, chatMessageFilter, type ChatMessagePayload } from './chat-message-broadcast.js'
21
- import { assertDomainSubscribeAllowed } from '../subscribe-auth.js'
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,21 @@ export class ChatMessageSubscription {
43
44
 
44
45
  await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
45
46
 
46
- return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageFilter(domain.id, sessionId)))
47
+ /*
48
+ * **방을 묻는다** — ADR-0073 결정 2. 공개 방은 도메인 안 누구나, 비공개 방은 참여자만. 저장된 길
49
+ * (`chatMessages`)은 이것을 물었고 방송 길은 묻지 않아, 비공개 세션의 id 를 가진 도메인 안 누구나
50
+ * 그 대화를 실시간으로 받았다.
51
+ *
52
+ * 못 들어오는 방은 빈 스트림을 주지 않고 거절한다. 없는 세션과 같은 말이다 — 비공개 방이 있다는
53
+ * 사실도 알려 주지 않는다. 구독이 선 뒤에도 방은 메시지마다 다시 묻는다(`chatMessageRoomFilter`).
54
+ */
55
+ const room = chatRoomLookup(
56
+ { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },
57
+ { domainId: domain.id, sessionId, userId: user?.id }
58
+ )
59
+ if (!(await mayHearRoom(room))) throw new Error('chat session not found')
60
+
61
+ return chatMessageStream(domain.id, sessionId, room)
47
62
  }
48
63
  })
49
64
  /*
@@ -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
 
@@ -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). */
@@ -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-privilege-undeclared', params: { category: 'twin-authoring', tool: 'bindTwinEntity', kind: 'write' }, message: '' },
8
- { code: 'tool-privilege-undeclared', params: { category: 'x-ops', tool: 'runThing', kind: 'external' }, message: '' }
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', () => {
@@ -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"]}
@@ -1,57 +0,0 @@
1
- /**
2
- * 구독 도메인 인증 게이트 — 단위 테스트 (보안 핵심: 도메인 격리).
3
- * board-patch / chat-message 양쪽 spine 이 공유하는 게이트.
4
- *
5
- * 회귀 방지: process.superUserGranted 는 async 다. 동기 `!process.superUserGranted(...)` 로
6
- * 평가하면 `!Promise` = 항상 false → membership throw 가 죽는다(fail-open). 게이트는 반드시
7
- * await 로 평가해야 한다. 아래 superUser 분기 테스트가 그 회귀를 박는다.
8
- */
9
- import { assertDomainSubscribeAllowed } from './subscribe-auth'
10
-
11
- const never = async () => false // superUser 아님 (async — 실제 process.superUserGranted 와 동일)
12
- const always = async () => true // superUser (async)
13
-
14
- const domain = { id: 'd1', subdomain: 'acme' }
15
- const userInDomain = { email: 'a@x.io', domains: [{ subdomain: 'acme' }, { subdomain: 'other' }] }
16
- const userOutDomain = { email: 'b@x.io', domains: [{ subdomain: 'other' }] }
17
-
18
- describe('assertDomainSubscribeAllowed (도메인 격리 게이트)', () => {
19
- it('domain 없으면 → reject (domain required)', async () => {
20
- await expect(assertDomainSubscribeAllowed(undefined, userInDomain, never)).rejects.toThrow('domain required')
21
- await expect(assertDomainSubscribeAllowed(null, userInDomain, never)).rejects.toThrow('domain required')
22
- })
23
-
24
- it('user 가 해당 subdomain 소속 → 통과 (superUser 검사 불필요)', async () => {
25
- await expect(assertDomainSubscribeAllowed(domain, userInDomain, never)).resolves.toBeUndefined()
26
- })
27
-
28
- it('user 가 미소속 + superUser 아님 → reject (격리 — 핵심)', async () => {
29
- await expect(assertDomainSubscribeAllowed(domain, userOutDomain, never)).rejects.toThrow(
30
- 'domain(acme) is not working for user(b@x.io).'
31
- )
32
- })
33
-
34
- it('user 가 미소속이어도 superUser(async resolve true) 면 → 통과 (await 회귀 방지)', async () => {
35
- await expect(assertDomainSubscribeAllowed(domain, userOutDomain, always)).resolves.toBeUndefined()
36
- })
37
-
38
- it('user.domains 없음 + superUser 아님 → reject', async () => {
39
- await expect(assertDomainSubscribeAllowed(domain, { email: 'c@x.io' }, never)).rejects.toThrow(
40
- 'domain(acme) is not working for user(c@x.io).'
41
- )
42
- })
43
-
44
- it('user.domains 없음 + superUser → 통과', async () => {
45
- await expect(assertDomainSubscribeAllowed(domain, { email: 'c@x.io' }, always)).resolves.toBeUndefined()
46
- })
47
-
48
- it('isSuperUser 는 domain+user 를 받아 호출되고 await 된다', async () => {
49
- const calls: any[] = []
50
- const spy = async (d: any, u: any) => {
51
- calls.push([d, u])
52
- return true
53
- }
54
- await assertDomainSubscribeAllowed(domain, userOutDomain, spy)
55
- expect(calls).toEqual([[domain, userOutDomain]])
56
- })
57
- })
@@ -1,34 +0,0 @@
1
- /*
2
- * 구독 도메인 인증 게이트 — 순수 로직 (데코레이터·pubsub 없음 → 테스트 가능).
3
- *
4
- * 실시간 구독(@Subscription)은 같은 도메인 구성원만 허용해야 한다(도메인 격리 = 보안 핵심).
5
- * 이 게이트는 board-patch / chat-message 양쪽 spine 의 subscribe 클로저가 공유한다
6
- * (reuse — fork 금지). superUserGranted 는 process 전역 의존이라 주입받아 순수성 유지.
7
- */
8
-
9
- /**
10
- * 구독 허용 여부 단언. 불가 시 throw (graphql-ws 가 구독 거부).
11
- * - domain 없음 → 거부.
12
- * - user 가 해당 subdomain 의 도메인 소속이 아니고 superUser 도 아니면 → 거부.
13
- *
14
- * 주의(버그 수정): process.superUserGranted 는 async(Promise<boolean>) 다. 기존 양쪽 spine 은
15
- * `!process.superUserGranted(...)` 로 동기 평가해 `!Promise` = 항상 false → membership throw 가
16
- * 발동하지 않는 fail-open 상태였다. 여기서는 await 로 제대로 평가한다 → subscribe 는 async.
17
- *
18
- * @param domain context.state.domain
19
- * @param user context.state.user (domains[] / email 사용)
20
- * @param isSuperUser (domain, user) → boolean | Promise<boolean> (process.superUserGranted 주입)
21
- */
22
- export async function assertDomainSubscribeAllowed(
23
- domain: any,
24
- user: any,
25
- isSuperUser: (domain: any, user: any) => boolean | Promise<boolean>
26
- ): Promise<void> {
27
- if (!domain) {
28
- throw new Error('domain required')
29
- }
30
- const subdomain = domain?.subdomain
31
- if (!user?.domains?.find((d: any) => d.subdomain === subdomain) && !(await isSuperUser(domain, user))) {
32
- throw new Error(`domain(${subdomain}) is not working for user(${user?.email}).`)
33
- }
34
- }