@things-factory/ai-assistant 10.1.38 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.38",
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.31",
33
- "@things-factory/auth-base": "^10.1.38",
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.38",
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": "b947349fe1c27d11038b592ceadaba336f14785b"
47
+ "gitHead": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
48
48
  }
@@ -28,39 +28,41 @@ export function chatMessageFilter(domainId: string, sessionId: string) {
28
28
  }
29
29
 
30
30
  /*
31
- * ── 방이 이 사람에게 열려 있나 — ADR-0073 결정 2 ────────────────────────────
31
+ * ── Is the room open to this person — ADR-0073 decision 2 ─────────────────────
32
32
  *
33
- * 공개 대화는 도메인 안 누구나, 비공개 대화는 참여자만. 저장된 길(`chatMessages`)은 그것을 묻고
34
- * 있었고 방송 길은 묻지 않았다 — 구독 때 도메인 소속만 보고, 필터는 도메인 + 세션만 맞췄다. 그래서
35
- * 비공개 세션의 id 를 가진 도메인 안 누구나 그 대화를 실시간으로 받았다.
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.
36
37
  *
37
- * 규칙은 한 줄(`mayReadSession`)이고 두 길이 같은 줄을 읽는다. 여기서 다시 적으면 그날부터 두 벌이다.
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.
38
40
  */
39
- /** 방에 대한 두 물음. 저장소는 부르는 쪽이 준다 — 이 파일은 저장소를 모른다. */
41
+ /** The two questions about a room. The caller supplies the storage — this file does not know it. */
40
42
  export interface ChatRoomLookup {
41
- /** 그 세션의 지금 공개 범위. 이 도메인에 그런 세션이 없으면 `undefined`. */
43
+ /** The session's visibility now; `undefined` when this domain has no such session. */
42
44
  visibility(): Promise<string | null | undefined>
43
- /** 부르는 사람이 지금 그 세션의 참여자인가. */
45
+ /** Whether the caller sits in that session now. */
44
46
  isParticipant(): Promise<boolean>
45
47
  }
46
48
 
47
49
  export async function mayHearRoom(room: ChatRoomLookup): Promise<boolean> {
48
50
  const visibility = await room.visibility()
49
51
 
50
- /* 이 도메인에 없는 세션 — 열림으로 읽지 않는다. */
52
+ /* A session this domain does not have — never read as open. */
51
53
  if (visibility === undefined) return false
52
54
 
53
- /* 공개 방이면 참여 여부를 묻지 않는다. 방송마다 조회가 하나 준다. */
55
+ /* An open room does not ask about membership — one query fewer per message. */
54
56
  if (mayReadSession(visibility, false)) return true
55
57
 
56
58
  return mayReadSession(visibility, await room.isParticipant())
57
59
  }
58
60
 
59
61
  /**
60
- * 같은 도메인 + 세션이고 **그 방이 지금 이 사람에게 열려 있을 때만** 통과한다.
62
+ * Passes only the same domain + session, and only while **that room is open to this person**.
61
63
  *
62
- * 방은 메시지마다 다시 묻는다 — 누가 듣고 있는 동안 세션이 비공개로 바뀔 수 있다. 사람의 대화라
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.
64
66
  */
65
67
  export function chatMessageRoomFilter(domainId: string, sessionId: string, room: ChatRoomLookup) {
66
68
  const sameSession = chatMessageFilter(domainId, sessionId)
@@ -1,10 +1,13 @@
1
1
  /*
2
- * 방송 길의 방 — 구독이 돌려주는 스트림과, 방을 묻는 두 조회.
2
+ * The room on the broadcast path — the stream the subscription returns, and the two lookups that
3
+ * ask about the room.
3
4
  *
4
- * 구독 리졸버는 파라미터 데코레이터(@Root · @Arg) 때문에 jest 가 못 읽는다. 그래서 스트림을 짜는 일을
5
- * 여기로 뺐다 — 시험이 리졸버가 돌려주는 것과 **같은 것**을 돌린다(`chat-message-room.test.ts`).
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`).
6
8
  *
7
- * 저장소는 부르는 쪽이 넘긴다. 엔티티를 여기서 import 하면 이 파일도 시험에서 못 읽는다.
9
+ * The caller passes the storage in. Importing the entities here would make this file unreadable to
10
+ * the tests too.
8
11
  */
9
12
  import { filter, pipe } from 'graphql-yoga'
10
13
  import { pubsub } from '@things-factory/shell'
@@ -16,8 +19,8 @@ interface FindOne {
16
19
  }
17
20
 
18
21
  /**
19
- * 방을 묻는 두 조회. **둘 다 부르는 사람의 도메인 안에서** 찾는다 — 저장된 길(`chatMessages`)의 질의와
20
- * 같은 조건이다.
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`).
21
24
  */
22
25
  export function chatRoomLookup(
23
26
  repos: { sessions: FindOne; participants: FindOne },
@@ -39,7 +42,7 @@ export function chatRoomLookup(
39
42
  }
40
43
  }
41
44
 
42
- /** 구독이 돌려주는 스트림 — 같은 도메인 + 세션이고, 그 방이 메시지마다 이 사람에게 열려 있을 때만. */
45
+ /** The stream the subscription returns — same domain + session, and the room open to this person at every message. */
43
46
  export function chatMessageStream(domainId: string, sessionId: string, room: ChatRoomLookup) {
44
47
  return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageRoomFilter(domainId, sessionId, room)))
45
48
  }
@@ -45,12 +45,13 @@ export class ChatMessageSubscription {
45
45
  await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
46
46
 
47
47
  /*
48
- * **방을 묻는다** — ADR-0073 결정 2. 공개 방은 도메인 안 누구나, 비공개 방은 참여자만. 저장된 길
49
- * (`chatMessages`)은 이것을 물었고 방송 길은 묻지 않아, 비공개 세션의 id 를 가진 도메인 안 누구나
50
- * 그 대화를 실시간으로 받았다.
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
51
  *
52
- * 못 들어오는 방은 빈 스트림을 주지 않고 거절한다. 없는 세션과 같은 말이다 — 비공개 방이 있다는
53
- * 사실도 알려 주지 않는다. 구독이 선 뒤에도 방은 메시지마다 다시 묻는다(`chatMessageRoomFilter`).
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`).
54
55
  */
55
56
  const room = chatRoomLookup(
56
57
  { sessions: getRepository(ChatSession), participants: getRepository(ChatSessionParticipant) },
@@ -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
- * 아니다. 관문이 `doors` 로 옮겨간 뒤(ADR-0071 결정 2) 검사를 안 넘기면 문을 적은 도구가 전부
58
- * 닫히고, 그러면 앱의 적합성 시험에서 **실제 자료를 읽는 도구만** 조용히 사라진다. 그래서 기본은
59
- * 모든 문을 지나는 사람이고, 다른 쪽을 보고 싶은 앱은 `toolAccess` 를 적어 그 경우를 쓴다.
60
- * 권한 축은 자기 시험이 따로 본다(`tool-access.test.ts` · `tool-scope.test.ts`).
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' } }
@@ -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
+ })