@things-factory/ai-assistant 10.1.40 → 10.1.42

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.40",
3
+ "version": "10.1.42",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -44,5 +44,5 @@
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
46
  },
47
- "gitHead": "51c5d804c14ad8fcc6d4707e6ef80c9deede2766"
47
+ "gitHead": "982aa01403b979c4e348fd207a3fb4d1a7b5f521"
48
48
  }
@@ -52,7 +52,7 @@ export class AssistantCapabilityResolver {
52
52
  @Query(() => AiCapabilityView, {
53
53
  description: 'Whether this deployment has an AI configured, and which registrations are incomplete. Read this before offering to ask the assistant — do not find out by failing.'
54
54
  })
55
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
55
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
56
56
  async aiCapability(@Ctx() _context: any): Promise<AiCapabilityView> {
57
57
  const capability = aiCapability()
58
58
  const warnings = [...getToolRegistryWarnings(), ...getAssistantModeWarnings()].map(warning => ({
@@ -200,7 +200,7 @@ export class AssistantChatResolver {
200
200
  * 2026-09-10. No `legacy` alias: this operation is new, so no one was ever passing under an old
201
201
  * name, and declaring an alias would open a second name that nothing needs.
202
202
  */
203
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
203
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
204
204
  async assistantChat(@Arg('input') input: AssistantChatInput, @Ctx() context: any): Promise<AssistantChatOutput> {
205
205
  const client = resolveAIClient()
206
206
  if (!client) {
@@ -83,7 +83,7 @@ export class AssistantModeResolver {
83
83
  description:
84
84
  'Conversations that stand for this kind of subject, the default first. A screen showing one subject opens the head of this list and offers the rest; a single entry means there is nothing to choose.'
85
85
  })
86
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
86
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
87
87
  async assistantModes(@Arg('subject') subject: string, @Ctx() context: any): Promise<AssistantModeChoice[]> {
88
88
  const t = requestTranslator(context)
89
89
 
@@ -101,7 +101,7 @@ export class AssistantModeResolver {
101
101
  description: 'What this conversation offers on screen — `/` sentences, the `@` catalog and the notices, translated.'
102
102
  })
103
103
  /* Reading what a conversation offers is reading, and the same door the session list uses. */
104
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
104
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
105
105
  async assistantMode(@Arg('kind') kind: string, @Ctx() context: any): Promise<AssistantModeView> {
106
106
  return assistantModeView(kind, requestTranslator(context)) as AssistantModeView
107
107
  }
@@ -50,3 +50,50 @@ describe("'none' is not an anchor", () => {
50
50
  expect(initialVisibility({ anchorType: 'space' })).toBe('open')
51
51
  })
52
52
  })
53
+
54
+ /*
55
+ * Calling a door is not reading a conversation (ruled 2026-09-24).
56
+ *
57
+ * Every ai-assistant door now admits the domain owner (`test/open-doors.test.ts`), so the owner may
58
+ * open the dock anywhere in the domain. That says who may *call* — not whose private conversation they
59
+ * may read. The room rule is asked with two things only, the visibility and whether the caller sits in
60
+ * the room, and neither place that reads a room asks anything about ownership. If one ever did, a domain
61
+ * owner would read every private conversation in the domain, and nothing on the screen would say so.
62
+ */
63
+ describe('the domain owner still reads a private conversation only as a participant', () => {
64
+ const { readFileSync } = require('fs')
65
+ const { join } = require('path')
66
+ const source = (path: string) => readFileSync(join(__dirname, '..', path), 'utf8') as string
67
+
68
+ /** The body of `chatMessages` — the stored path. The stream's path is the room lookup and `mayHearRoom`. */
69
+ const storedPath = () => {
70
+ const resolver = source('chat-session-resolver.ts')
71
+ const start = resolver.indexOf('async chatMessages(')
72
+ expect(start).toBeGreaterThan(0)
73
+ const end = resolver.indexOf('\n @', start)
74
+ return resolver.slice(start, end > start ? end : undefined)
75
+ }
76
+ const streamPath = () => {
77
+ const broadcast = source('chat-message/chat-message-broadcast.ts')
78
+ return source('chat-message/chat-message-room.ts') + broadcast.slice(broadcast.indexOf('export interface ChatRoomLookup'))
79
+ }
80
+
81
+ const OWNERSHIP = /owner|superUser|checkPermission|hasPrivilege|privilege/i
82
+
83
+ it('★ the rule has no input for ownership — visibility and participation, nothing else', () => {
84
+ expect(mayReadSession.length).toBe(2)
85
+ expect(mayReadSession('private', false)).toBe(false)
86
+ })
87
+
88
+ it('★ the stored path asks the rule, and asks nothing about ownership', () => {
89
+ const body = storedPath()
90
+ expect(body).toMatch(/mayReadSession\(/)
91
+ expect(body).not.toMatch(OWNERSHIP)
92
+ })
93
+
94
+ it('★ the stream asks the same rule, and asks nothing about ownership', () => {
95
+ const body = streamPath()
96
+ expect(body).toMatch(/mayReadSession\(/)
97
+ expect(body).not.toMatch(OWNERSHIP)
98
+ })
99
+ })
@@ -100,7 +100,7 @@ class ChatSessionInboxEntry {
100
100
  @Resolver()
101
101
  export class ChatSessionResolver {
102
102
  @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by id.' })
103
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
103
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
104
104
  async chatSession(
105
105
  @Arg('id') id: string,
106
106
  @Ctx() context: ResolverContext
@@ -113,7 +113,7 @@ export class ChatSessionResolver {
113
113
  description:
114
114
  'List AI chat sessions anchored to a target (oldest first). Anchor decides the nature and the history boundary of the conversation: board = authoring, space = the site and the twins running there. Board anchor also matches legacy rows that only have boardId.'
115
115
  })
116
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
116
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
117
117
  async chatSessionsByAnchor(
118
118
  @Arg('anchorType') anchorType: string,
119
119
  @Arg('anchorId') anchorId: string,
@@ -135,7 +135,7 @@ export class ChatSessionResolver {
135
135
  description:
136
136
  "List sessions anchored to a target with this viewer's unread state, most recent activity first. Supports free-text search over name and last preview, a mine-only filter, hidden inclusion, and paging. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
137
137
  })
138
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
138
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
139
139
  async chatSessionInbox(
140
140
  @Arg('anchorType') anchorType: string,
141
141
  @Arg('anchorId') anchorId: string,
@@ -164,7 +164,7 @@ export class ChatSessionResolver {
164
164
  description:
165
165
  'Total number of sessions matching the same filters as chatSessionInbox (before paging). Request it alongside the list so the UI can tell "end of page" from "end of data".'
166
166
  })
167
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
167
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
168
168
  async chatSessionInboxTotal(
169
169
  @Arg('anchorType') anchorType: string,
170
170
  @Arg('anchorId') anchorId: string,
@@ -199,7 +199,7 @@ export class ChatSessionResolver {
199
199
  description:
200
200
  "Number of sessions on this anchor that the caller has hidden and that have had no activity since. Meant to be requested alongside chatSessionInbox in the same operation so the filter can say how many are folded away."
201
201
  })
202
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
202
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
203
203
  async hiddenSessionCount(
204
204
  @Arg('anchorType') anchorType: string,
205
205
  @Arg('anchorId') anchorId: string,
@@ -270,7 +270,7 @@ export class ChatSessionResolver {
270
270
  description:
271
271
  'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
272
272
  })
273
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
273
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
274
274
  async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
275
275
  const { domain, user } = context.state
276
276
  const repo = getRepository(ChatSession)
@@ -302,7 +302,7 @@ export class ChatSessionResolver {
302
302
  description:
303
303
  'Hide (or unhide) a session from the calling user\'s own list. Per-user and reversible — the session and other participants are unaffected. Activity newer than the hide makes it reappear; the inbox filter (showHidden) reveals hidden ones.'
304
304
  })
305
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
305
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
306
306
  async hideAISession(
307
307
  @Arg('sessionId') sessionId: string,
308
308
  @Ctx() context: ResolverContext,
@@ -357,7 +357,7 @@ export class ChatSessionResolver {
357
357
  description:
358
358
  "Open this conversation to everyone in the domain, or close it to its participants. Only the owner may change it. Opening exposes what is already written — the screen says so before asking. Door checks on assistant answers are unaffected (ADR-0073)."
359
359
  })
360
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
360
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
361
361
  async setSessionVisibility(
362
362
  @Arg('sessionId') sessionId: string,
363
363
  @Arg('visibility') visibility: string,
@@ -394,7 +394,7 @@ export class ChatSessionResolver {
394
394
  description:
395
395
  'Rename a chat session. Any participant may rename it — the name is the topic label of a shared deliberation and the change is reversible. Broadcast as meta activity so every participant sees it without raising unread badges.'
396
396
  })
397
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
397
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
398
398
  async renameAISession(
399
399
  @Arg('sessionId') sessionId: string,
400
400
  @Arg('name') name: string,
@@ -429,7 +429,7 @@ export class ChatSessionResolver {
429
429
  description:
430
430
  "Mark a session as read up to now for the current user. Idempotent. Distinct from presence (lastSeenAt) — a window can be open without being read."
431
431
  })
432
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
432
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
433
433
  async markSessionRead(
434
434
  @Arg('sessionId') sessionId: string,
435
435
  @Ctx() context: ResolverContext
@@ -449,7 +449,7 @@ export class ChatSessionResolver {
449
449
  description:
450
450
  'List users in the current domain for the `@` mention popup. Anyone with the chat privilege may search here; this is separate from the admin-only users().'
451
451
  })
452
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
452
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
453
453
  async domainUsersForMention(
454
454
  @Arg('query', { nullable: true, description: 'Substring to match against name/email (case-insensitive). Empty → all.' }) query: string | undefined,
455
455
  @Arg('limit', () => Int, { nullable: true, defaultValue: 50 }) limit: number,
@@ -468,7 +468,7 @@ export class ChatSessionResolver {
468
468
  @Query(() => [ChatSessionParticipant], {
469
469
  description: 'List participants of a ChatSession (members / owner).'
470
470
  })
471
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
471
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
472
472
  async chatSessionParticipants(
473
473
  @Arg('sessionId') sessionId: string,
474
474
  @Ctx() context: ResolverContext
@@ -488,7 +488,7 @@ export class ChatSessionResolver {
488
488
  @Mutation(() => Boolean, {
489
489
  description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
490
490
  })
491
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
491
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
492
492
  async touchPresence(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
493
493
  const { domain, user } = context.state
494
494
  const repo = getRepository(ChatSessionParticipant)
@@ -501,7 +501,7 @@ export class ChatSessionResolver {
501
501
  }
502
502
 
503
503
  @Query(() => [ChatMessage], { description: 'List chat messages of a session, oldest first.' })
504
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
504
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
505
505
  async chatMessages(
506
506
  @Arg('sessionId') sessionId: string,
507
507
  @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
@@ -547,7 +547,7 @@ export class ChatSessionResolver {
547
547
  description:
548
548
  'Start (or get existing) AI chat session anchored to a target. Idempotent — returns first existing or creates one. Use anchorType board for board authoring, space for a site (its co-located twins).'
549
549
  })
550
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
550
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
551
551
  async startAISession(
552
552
  @Arg('anchorType') anchorType: string,
553
553
  @Arg('anchorId') anchorId: string,
@@ -584,7 +584,7 @@ export class ChatSessionResolver {
584
584
  description:
585
585
  'Always create a new AI chat session anchored to a target (no idempotent reuse). For multi-session UX — opening another tab.'
586
586
  })
587
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
587
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
588
588
  async createAISession(
589
589
  @Arg('anchorType') anchorType: string,
590
590
  @Arg('anchorId') anchorId: string,
@@ -626,7 +626,7 @@ export class ChatSessionResolver {
626
626
  @Mutation(() => Boolean, {
627
627
  description: 'Rename a ChatSession (tab label).'
628
628
  })
629
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
629
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
630
630
  async renameChatSession(
631
631
  @Arg('sessionId') sessionId: string,
632
632
  @Arg('name') name: string,
@@ -645,7 +645,7 @@ export class ChatSessionResolver {
645
645
  description:
646
646
  "What the host did with a proposal the assistant made. The host calls this once per card it was asked to run; the server records it as a system row, so the panel shows it, a refreshed panel still shows it, and the model reads on its next turn that its proposal was refused instead of assuming it landed (ADR-0070)."
647
647
  })
648
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
648
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
649
649
  async reportProposalOutcome(
650
650
  @Arg('sessionId') sessionId: string,
651
651
  @Arg('proposalKey') proposalKey: string,
@@ -695,7 +695,7 @@ export class ChatSessionResolver {
695
695
  description:
696
696
  "Say something to the people in this session. The turn is stored as the speaker's, addressed to the room, and the assistant is not called — it reads the line as context the next time someone asks it something. Returns the stored message id, or null when there was nothing to say (ADR-0070)."
697
697
  })
698
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
698
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
699
699
  async sayToRoom(
700
700
  @Arg('sessionId') sessionId: string,
701
701
  @Arg('content') content: string,
@@ -763,7 +763,7 @@ export class ChatSessionResolver {
763
763
  description:
764
764
  'Append a system note to a session — context that did not come from a participant typing (e.g. a conversation carried over from another surface, an external state change). The AI sees it as part of the shared history.'
765
765
  })
766
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
766
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
767
767
  async appendSessionNote(
768
768
  @Arg('sessionId') sessionId: string,
769
769
  @Arg('content') content: string,
@@ -813,7 +813,7 @@ export class ChatSessionResolver {
813
813
  description:
814
814
  "Carry a conversation from another surface into this session as its own turns, preserving order. A person's turns are stored as theirs, addressed to the room. An assistant turn is stored as the assistant's only when it names its original (sourceSessionId, sourceMessageId) and the server can read it; otherwise it is kept as a quotation on a system row — roles are the server's to write (ADR-0070)."
815
815
  })
816
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
816
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
817
817
  async appendSessionTurns(
818
818
  @Arg('sessionId') sessionId: string,
819
819
  @Arg('turns', () => GraphQLJSON) turns: { role?: string; content?: string }[],
@@ -108,6 +108,22 @@ describe('every open door has a written reason', () => {
108
108
  })
109
109
  })
110
110
 
111
+ describe('the domain owner may call every door (ruled 2026-09-24)', () => {
112
+ /*
113
+ * The conversation doors did not admit domain owners, and warehouse has no role that grants
114
+ * ai-assistant — so on :3500 nobody could open the AI dock, the owner included. Every business door
115
+ * of the apps admits the owner; these now do too. Calling a door is not reading a conversation:
116
+ * private ones stay with their participants (`session-visibility.test.ts`).
117
+ */
118
+ it.each(['Query', 'Mutation'] as const)('%s — every declared door admits the owner', typeName => {
119
+ const notForOwners = fieldsOf(typeName).filter(field => {
120
+ const gate: any = privilegeOfField(typeName, field)
121
+ return gate.known && gate.gate && gate.gate.owner !== true
122
+ })
123
+ expect(notForOwners).toEqual([])
124
+ })
125
+ })
126
+
111
127
  describe('what the walk itself has to be true for', () => {
112
128
  it('reaches the whole schema, not a handful of fields', () => {
113
129
  /* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */