@sidurijs/api 1.0.0

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 (66) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +78 -0
  3. package/dist/app.d.ts +11 -0
  4. package/dist/app.js +1190 -0
  5. package/dist/auth.d.ts +34 -0
  6. package/dist/auth.js +84 -0
  7. package/dist/auth.test.d.ts +1 -0
  8. package/dist/auth.test.js +74 -0
  9. package/dist/b0-b6.test.d.ts +1 -0
  10. package/dist/b0-b6.test.js +121 -0
  11. package/dist/boot.d.ts +91 -0
  12. package/dist/boot.js +154 -0
  13. package/dist/boot.test.d.ts +1 -0
  14. package/dist/boot.test.js +91 -0
  15. package/dist/context-mapper.d.ts +20 -0
  16. package/dist/context-mapper.js +233 -0
  17. package/dist/context-mapper.test.d.ts +1 -0
  18. package/dist/context-mapper.test.js +287 -0
  19. package/dist/cors.d.ts +3 -0
  20. package/dist/cors.js +42 -0
  21. package/dist/index.d.ts +7 -0
  22. package/dist/index.js +116 -0
  23. package/dist/index.test.d.ts +1 -0
  24. package/dist/index.test.js +151 -0
  25. package/dist/knowledge.test.d.ts +1 -0
  26. package/dist/knowledge.test.js +236 -0
  27. package/dist/runtime.d.ts +1 -0
  28. package/dist/runtime.js +17 -0
  29. package/dist/runtime.test.d.ts +1 -0
  30. package/dist/runtime.test.js +282 -0
  31. package/dist/smoke.test.d.ts +0 -0
  32. package/dist/smoke.test.js +6 -0
  33. package/dist/t4-gating.test.d.ts +1 -0
  34. package/dist/t4-gating.test.js +193 -0
  35. package/dist/t5-experience.test.d.ts +1 -0
  36. package/dist/t5-experience.test.js +155 -0
  37. package/dist/t6-security.test.d.ts +1 -0
  38. package/dist/t6-security.test.js +535 -0
  39. package/dist/t7-release.test.d.ts +1 -0
  40. package/dist/t7-release.test.js +119 -0
  41. package/dist/teach-mode.test.d.ts +1 -0
  42. package/dist/teach-mode.test.js +277 -0
  43. package/jest.config.json +5 -0
  44. package/package.json +44 -0
  45. package/siduri.sqlite +0 -0
  46. package/src/app.ts +1209 -0
  47. package/src/auth.test.ts +90 -0
  48. package/src/auth.ts +101 -0
  49. package/src/b0-b6.test.ts +137 -0
  50. package/src/boot.test.ts +112 -0
  51. package/src/boot.ts +197 -0
  52. package/src/context-mapper.test.ts +319 -0
  53. package/src/context-mapper.ts +279 -0
  54. package/src/cors.ts +44 -0
  55. package/src/index.test.ts +178 -0
  56. package/src/index.ts +101 -0
  57. package/src/knowledge.test.ts +267 -0
  58. package/src/runtime.test.ts +336 -0
  59. package/src/runtime.ts +1 -0
  60. package/src/smoke.test.ts +5 -0
  61. package/src/t4-gating.test.ts +219 -0
  62. package/src/t5-experience.test.ts +174 -0
  63. package/src/t6-security.test.ts +589 -0
  64. package/src/t7-release.test.ts +131 -0
  65. package/src/teach-mode.test.ts +312 -0
  66. package/tsconfig.json +16 -0
@@ -0,0 +1,178 @@
1
+ import request from 'supertest';
2
+ import { createApp } from './app';
3
+
4
+ describe('API Boundary Context Validation (P2 Route Integration)', () => {
5
+ const fakeRuntime: any = {
6
+ handleUserMessage: jest.fn().mockResolvedValue({
7
+ response: { subtitle_en: 'Hello there' },
8
+ metadata: {},
9
+ }),
10
+ };
11
+
12
+ let app: any;
13
+ let runtimes: Map<string, any>;
14
+
15
+ beforeEach(() => {
16
+ runtimes = new Map([['companion-a', fakeRuntime]]);
17
+ const created = createApp(runtimes);
18
+ app = created.app;
19
+ fakeRuntime.handleUserMessage.mockClear();
20
+ });
21
+
22
+ test('accepts valid anonymous chat request and maps through API boundary', async () => {
23
+ const res = await request(app)
24
+ .post('/chat')
25
+ .send({
26
+ id: 'companion-a',
27
+ message: 'Hello neutral world',
28
+ history: [],
29
+ });
30
+
31
+ expect(res.status).toBe(200);
32
+ expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
33
+ 'Hello neutral world',
34
+ expect.objectContaining({
35
+ companionId: 'companion-a',
36
+ conversation: expect.objectContaining({ channel: 'direct' }),
37
+ }),
38
+ []
39
+ );
40
+ });
41
+
42
+ test('accepts neutral context chat envelope at /chat route', async () => {
43
+ const res = await request(app)
44
+ .post('/chat')
45
+ .send({
46
+ companionId: 'companion-a',
47
+ context: {
48
+ actor: {
49
+ actorId: 'actor-a',
50
+ sessionId: 'session-a',
51
+ authorizationRole: 'viewer',
52
+ capabilities: ['chat:public'],
53
+ authenticated: false,
54
+ },
55
+ conversation: {
56
+ channel: 'public',
57
+ correlationId: 'corr-route-1',
58
+ },
59
+ },
60
+ message: 'Hello structured context',
61
+ history: [],
62
+ });
63
+
64
+ expect(res.status).toBe(200);
65
+ expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
66
+ 'Hello structured context',
67
+ expect.objectContaining({
68
+ companionId: 'companion-a',
69
+ conversation: expect.objectContaining({ channel: 'public' }),
70
+ }),
71
+ []
72
+ );
73
+ });
74
+
75
+ test('rejects global primary_user subject with 400 and structured error', async () => {
76
+ const res = await request(app)
77
+ .post('/chat')
78
+ .send({
79
+ companionId: 'companion-a',
80
+ context: {
81
+ actor: {
82
+ actorId: 'actor-a',
83
+ sessionId: 'session-a',
84
+ authorizationRole: 'viewer',
85
+ capabilities: ['chat:public'],
86
+ authenticated: true,
87
+ },
88
+ conversation: {
89
+ channel: 'public',
90
+ correlationId: 'corr-err-2',
91
+ },
92
+ subject: {
93
+ subjectId: 'primary_user',
94
+ kind: 'actor',
95
+ },
96
+ },
97
+ message: 'Forbidden primary user test',
98
+ });
99
+
100
+ expect(res.status).toBe(400);
101
+ expect(res.body.accepted).toBe(false);
102
+ expect(res.body.error.code).toBe('FORBIDDEN_CONTEXT');
103
+ expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test('streams response chunks via POST /chat/stream', async () => {
107
+ fakeRuntime.mouth = {
108
+ stream: async function* () {
109
+ yield { utteranceId: 'utt-1', index: 1, deltaText: 'Hello', isComplete: false, medium: 'web' };
110
+ yield { utteranceId: 'utt-1', index: 2, deltaText: ' world', isComplete: false, medium: 'web' };
111
+ yield { utteranceId: 'utt-1', index: 3, deltaText: '', isComplete: true, medium: 'web' };
112
+ },
113
+ };
114
+
115
+ const res = await request(app)
116
+ .post('/chat/stream')
117
+ .send({
118
+ id: 'companion-a',
119
+ message: 'Stream me',
120
+ });
121
+
122
+ expect(res.status).toBe(200);
123
+ expect(res.headers['content-type']).toContain('text/event-stream');
124
+ expect(res.text).toContain('event: staged');
125
+ expect(res.text).toContain('event: chunk');
126
+ expect(res.text).toContain('event: done');
127
+ });
128
+
129
+ test('handles barge-in interruption via POST /chat/interrupt', async () => {
130
+ fakeRuntime.mouth = { interrupt: jest.fn() };
131
+
132
+ const res = await request(app)
133
+ .post('/chat/interrupt')
134
+ .send({
135
+ companionId: 'companion-a',
136
+ reason: 'user_stop',
137
+ });
138
+
139
+ expect(res.status).toBe(200);
140
+ expect(res.body.interrupted).toBe(true);
141
+ expect(fakeRuntime.mouth.interrupt).toHaveBeenCalledWith('user_stop');
142
+ });
143
+
144
+ test('handles mouth interruption via POST /mouth/interrupt', async () => {
145
+ fakeRuntime.mouth = { interrupt: jest.fn() };
146
+
147
+ const res = await request(app)
148
+ .post('/mouth/interrupt')
149
+ .send({
150
+ companionId: 'companion-a',
151
+ reason: 'user_barge_in',
152
+ });
153
+
154
+ expect(res.status).toBe(200);
155
+ expect(res.body.interrupted).toBe(true);
156
+ expect(fakeRuntime.mouth.interrupt).toHaveBeenCalledWith('user_barge_in');
157
+ });
158
+
159
+ test('POST /chat passes explicit interaction mode (casual, teach, hybrid) to runtime', async () => {
160
+ const res = await request(app)
161
+ .post('/chat')
162
+ .send({
163
+ id: 'companion-a',
164
+ message: 'Casual banter',
165
+ mode: 'casual',
166
+ });
167
+
168
+ expect(res.status).toBe(200);
169
+ expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
170
+ 'Casual banter',
171
+ expect.objectContaining({
172
+ companionId: 'companion-a',
173
+ mode: 'casual',
174
+ }),
175
+ []
176
+ );
177
+ });
178
+ });
package/src/index.ts ADDED
@@ -0,0 +1,101 @@
1
+ import dotenv from 'dotenv';
2
+ dotenv.config({ override: true });
3
+
4
+ import { readFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { Express } from 'express';
7
+ import { createApp, AppInstance } from './app';
8
+ import { SiduriRuntime } from './runtime';
9
+ import { bootCompanion, createVision, createObservation } from './boot';
10
+
11
+ export { createApp, AppInstance };
12
+ export * from './context-mapper';
13
+ export * from './boot';
14
+
15
+ const runtimes = new Map<string, SiduriRuntime>();
16
+ const instance: AppInstance = createApp(runtimes);
17
+ export const app: Express = instance.app;
18
+ export default app;
19
+
20
+ const PORT = process.env.PORT || 3001;
21
+
22
+ const defaultCompanionConfig = {
23
+ id: 'default',
24
+ name: 'Siduri',
25
+ brain: { provider: 'openrouter', model: 'gpt-4o-mini' },
26
+ voice: { provider: 'voicevox', speakerId: 1 },
27
+ memory: { provider: 'sqlite' },
28
+ knowledge: {
29
+ provider: (process.env.SIDURI_KNOWLEDGE_PROVIDER as any) || 'unified',
30
+ lifeDatabase: true,
31
+ dbPath: process.env.STORAGE_PATH || process.env.SQLITE_DB_PATH || 'siduri.sqlite',
32
+ packPath: process.env.SIDURI_KNOWLEDGE_PACK || '',
33
+ registryUrl: process.env.SIDURI_KNOWLEDGE_REGISTRY_URL || '',
34
+ packId: process.env.SIDURI_KNOWLEDGE_PACK_ID || '',
35
+ timeoutMs: Number(process.env.SIDURI_KNOWLEDGE_TIMEOUT_MS || 5000),
36
+ preferredMode: (process.env.SIDURI_KNOWLEDGE_MODE as 'lexical' | 'semantic' | 'hybrid') || 'lexical',
37
+ },
38
+ behavior: { provider: 'active_self' },
39
+ body: {
40
+ provider: 'live2d',
41
+ },
42
+ vision: { provider: 'openrouter', model: 'gpt-4-vision' }
43
+ };
44
+
45
+ async function loadCompanionConfig() {
46
+ const configPath = process.env.SIDURI_CONFIG || path.resolve(process.cwd(), 'siduri.config.json');
47
+ let fileConfig: Record<string, any> = {};
48
+ try {
49
+ fileConfig = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, any>;
50
+ console.log(`Loaded companion configuration from ${configPath}`);
51
+ } catch (error: any) {
52
+ if (error?.code !== 'ENOENT') throw new Error(`Unable to read ${configPath}: ${error.message}`);
53
+ console.log(`No ${configPath} found; using environment/default configuration.`);
54
+ }
55
+
56
+ const config: any = {
57
+ ...defaultCompanionConfig,
58
+ ...fileConfig,
59
+ id: fileConfig.id || defaultCompanionConfig.id,
60
+ brain: { ...defaultCompanionConfig.brain, ...fileConfig.brain },
61
+ voice: { ...defaultCompanionConfig.voice, ...fileConfig.voice },
62
+ memory: { ...defaultCompanionConfig.memory, ...fileConfig.memory },
63
+ knowledge: { ...defaultCompanionConfig.knowledge, ...fileConfig.knowledge },
64
+ behavior: { ...defaultCompanionConfig.behavior, ...fileConfig.behavior },
65
+ body: { ...defaultCompanionConfig.body, ...fileConfig.body },
66
+ vision: { ...defaultCompanionConfig.vision, ...fileConfig.vision },
67
+ };
68
+
69
+ if (process.env.SIDURI_KNOWLEDGE_PROVIDER) config.knowledge.provider = process.env.SIDURI_KNOWLEDGE_PROVIDER;
70
+ if (process.env.SIDURI_KNOWLEDGE_PACK) config.knowledge.packPath = process.env.SIDURI_KNOWLEDGE_PACK;
71
+ if (process.env.SIDURI_KNOWLEDGE_REGISTRY_URL) config.knowledge.registryUrl = process.env.SIDURI_KNOWLEDGE_REGISTRY_URL;
72
+ if (process.env.SIDURI_KNOWLEDGE_PACK_ID) config.knowledge.packId = process.env.SIDURI_KNOWLEDGE_PACK_ID;
73
+ if (process.env.SIDURI_KNOWLEDGE_MODE) config.knowledge.preferredMode = process.env.SIDURI_KNOWLEDGE_MODE;
74
+ if (process.env.SIDURI_KNOWLEDGE_DB_PATH) config.knowledge.dbPath = process.env.SIDURI_KNOWLEDGE_DB_PATH;
75
+ return config;
76
+ }
77
+
78
+ async function bootDefaultCompanion() {
79
+ if (runtimes.has('default')) return;
80
+ console.log("Booting default companion...");
81
+ const config: any = await loadCompanionConfig();
82
+
83
+ const vision = createVision(config.vision);
84
+ const observation = createObservation(vision);
85
+ instance.setObservationOrgan(observation);
86
+
87
+ const runtime = await bootCompanion('default', config, { observationOrgan: observation });
88
+ runtimes.set('default', runtime);
89
+ console.log("Default companion booted successfully.");
90
+ }
91
+
92
+ if (process.env.NODE_ENV !== 'test') {
93
+ bootDefaultCompanion().then(() => {
94
+ app.listen(Number(PORT), '127.0.0.1', () => {
95
+ console.log(`Siduri-X API running on port ${PORT} (127.0.0.1)`);
96
+ });
97
+ }).catch(e => {
98
+ console.error("Failed to boot default companion:", e);
99
+ process.exit(1);
100
+ });
101
+ }
@@ -0,0 +1,267 @@
1
+ import request from 'supertest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createApp } from './app';
5
+ import { SiduriRuntime } from './runtime';
6
+ import { UnifiedKnowledgeOrgan } from '@sidurijs/knowledge';
7
+
8
+ describe('Life Database & UnifiedKnowledgeOrgan API Integration', () => {
9
+ const testDbPath = path.resolve(__dirname, '../test-api-knowledge.sqlite');
10
+ let app: any;
11
+ let runtime: SiduriRuntime;
12
+ let knowledge: UnifiedKnowledgeOrgan;
13
+ const mockAuthHeader = { 'Authorization': 'Bearer test-token' };
14
+
15
+ const cleanDb = () => {
16
+ for (const file of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
17
+ if (fs.existsSync(file)) {
18
+ try { fs.unlinkSync(file); } catch {}
19
+ }
20
+ }
21
+ };
22
+
23
+ beforeAll(async () => {
24
+ process.env.AUTH_TOKEN = 'test-token';
25
+ cleanDb();
26
+
27
+ knowledge = new UnifiedKnowledgeOrgan({
28
+ lifeDatabase: true,
29
+ dbPath: testDbPath,
30
+ });
31
+
32
+ const mockBrain: any = {
33
+ generatePlan: jest.fn().mockImplementation(async (ctx: any) => {
34
+ return {
35
+ speech: `I see context: ${ctx.contextPrompt || 'none'}`,
36
+ language: 'en',
37
+ };
38
+ }),
39
+ };
40
+
41
+ const memory: any = {
42
+ initialize: async () => {},
43
+ proposeClaim: async (claim: any) => (knowledge.lifeDb as any).db.proposeClaim(claim),
44
+ getClaims: async (limit?: number) => (knowledge.lifeDb as any).db.getAllClaims(undefined, limit || 500),
45
+ getPendingClaims: async (limit?: number) =>
46
+ (knowledge.lifeDb as any).db.getAllClaims(undefined, limit || 500).filter((c: any) => c.status === 'pending'),
47
+ approveClaim: async (id: string) => (knowledge.lifeDb as any).db.approveClaim(id),
48
+ rejectClaim: async (id: string) => (knowledge.lifeDb as any).db.rejectClaim(id),
49
+ searchClaims: async () => [],
50
+ getApprovedClaims: async () => [],
51
+ getDirectives: async () => [],
52
+ };
53
+
54
+ runtime = new SiduriRuntime(
55
+ 'test-comp',
56
+ { name: 'Test Companion', organs: { knowledge: { provider: 'unified', dbPath: testDbPath } } } as any,
57
+ {
58
+ brain: mockBrain,
59
+ knowledge,
60
+ externalKnowledge: knowledge.eAdapter ?? knowledge,
61
+ memory,
62
+ }
63
+ );
64
+ await runtime.initialize();
65
+
66
+ const runtimes = new Map([['test-comp', runtime]]);
67
+ const instance = createApp(runtimes);
68
+ app = instance.app;
69
+ });
70
+
71
+ afterAll(async () => {
72
+ knowledge.close();
73
+ delete process.env.AUTH_TOKEN;
74
+ cleanDb();
75
+ });
76
+
77
+ test('seeds inventory item and queries via GET /knowledge/inventory', async () => {
78
+ await knowledge.inventory.saveItem({
79
+ id: 'inv-item-1',
80
+ companionId: 'test-comp',
81
+ entityName: 'Hydro Visor',
82
+ domain: 'hardware',
83
+ properties: { model: 'V1', resolution: '4K' },
84
+ updatedAt: new Date().toISOString(),
85
+ });
86
+
87
+ const res = await request(app)
88
+ .get('/knowledge/inventory?id=test-comp')
89
+ .set(mockAuthHeader);
90
+
91
+ expect(res.status).toBe(200);
92
+ expect(res.body.items).toHaveLength(1);
93
+ expect(res.body.items[0].entityName).toBe('Hydro Visor');
94
+ expect(res.body.items[0].domain).toBe('hardware');
95
+ });
96
+
97
+ test('seeds finance entry and queries via GET /knowledge/finance', async () => {
98
+ await knowledge.finance.addEntry({
99
+ id: 'fin-1',
100
+ companionId: 'test-comp',
101
+ category: 'subscription',
102
+ amount: -15.99,
103
+ currency: 'USD',
104
+ timestamp: new Date().toISOString(),
105
+ });
106
+
107
+ const res = await request(app)
108
+ .get('/knowledge/finance?id=test-comp')
109
+ .set(mockAuthHeader);
110
+
111
+ expect(res.status).toBe(200);
112
+ expect(res.body.entries).toHaveLength(1);
113
+ expect(res.body.entries[0].category).toBe('subscription');
114
+ expect(res.body.summary).toBeDefined();
115
+ expect(res.body.summary.totalExpenses).toBe(15.99);
116
+ });
117
+
118
+ test('queries life snapshot via GET /knowledge/life', async () => {
119
+ const res = await request(app)
120
+ .get('/knowledge/life?id=test-comp&q=Hydro')
121
+ .set(mockAuthHeader);
122
+
123
+ expect(res.status).toBe(200);
124
+ expect(res.body.matchedInventory).toHaveLength(1);
125
+ expect(res.body.matchedInventory[0].entityName).toBe('Hydro Visor');
126
+ expect(res.body.formattedContext).toContain('<life_context>');
127
+ });
128
+
129
+ test('chat request triggers Stream D and injects Life DB context into cognition prompt', async () => {
130
+ const res = await request(app)
131
+ .post('/chat')
132
+ .send({
133
+ id: 'test-comp',
134
+ message: 'Tell me about the Hydro Visor specs',
135
+ history: [],
136
+ });
137
+
138
+ expect(res.status).toBe(200);
139
+ expect(res.body.response.subtitle_en).toContain('Hydro Visor');
140
+ });
141
+
142
+ test('boot endpoint instantiates UnifiedKnowledgeOrgan with Life DB enabled', async () => {
143
+ const bootRes = await request(app)
144
+ .post('/boot')
145
+ .set(mockAuthHeader)
146
+ .send({
147
+ id: 'booted-comp',
148
+ config: {
149
+ name: 'Booted Companion',
150
+ organs: {
151
+ knowledge: {
152
+ provider: 'unified',
153
+ dbPath: testDbPath,
154
+ },
155
+ },
156
+ },
157
+ });
158
+
159
+ expect(bootRes.status).toBe(200);
160
+ expect(bootRes.body.success).toBe(true);
161
+
162
+ // Verify the booted companion's knowledge organ is UnifiedKnowledgeOrgan with working Life DB
163
+ const lifeRes = await request(app)
164
+ .get('/knowledge/life?id=booted-comp')
165
+ .set(mockAuthHeader);
166
+
167
+ expect(lifeRes.status).toBe(200);
168
+ expect(lifeRes.body.matchedInventory).toEqual([]);
169
+ });
170
+
171
+ test('seeds and queries generic entities, events, and tasks', async () => {
172
+ // 1. Entities
173
+ await knowledge.entities.saveEntity({
174
+ id: 'ent-1',
175
+ companionId: 'test-comp',
176
+ name: 'Coffee Grinder',
177
+ entityType: 'item',
178
+ domain: 'kitchen',
179
+ properties: { burr: 'conical', setting: 14 },
180
+ });
181
+
182
+ const entRes = await request(app)
183
+ .get('/knowledge/entities?id=test-comp&domain=kitchen')
184
+ .set(mockAuthHeader);
185
+ expect(entRes.status).toBe(200);
186
+ expect(entRes.body.entities).toHaveLength(1);
187
+ expect(entRes.body.entities[0].name).toBe('Coffee Grinder');
188
+ expect(entRes.body.entities[0].properties.setting).toBe(14);
189
+
190
+ // 2. Events
191
+ await knowledge.events.addEvent({
192
+ id: 'evt-1',
193
+ companionId: 'test-comp',
194
+ stream: 'health:heartrate',
195
+ timestamp: new Date().toISOString(),
196
+ metricValue: 72,
197
+ metadata: { unit: 'bpm' },
198
+ });
199
+
200
+ const evtRes = await request(app)
201
+ .get('/knowledge/events?id=test-comp&stream=health:heartrate')
202
+ .set(mockAuthHeader);
203
+ expect(evtRes.status).toBe(200);
204
+ expect(evtRes.body.events).toHaveLength(1);
205
+ expect(evtRes.body.events[0].metricValue).toBe(72);
206
+
207
+ // 3. Tasks
208
+ await knowledge.tasks.saveTask({
209
+ id: 'task-1',
210
+ companionId: 'test-comp',
211
+ title: 'Order fresh espresso beans',
212
+ status: 'pending',
213
+ priority: 2,
214
+ });
215
+
216
+ const taskRes = await request(app)
217
+ .get('/knowledge/tasks?id=test-comp&status=pending')
218
+ .set(mockAuthHeader);
219
+ expect(taskRes.status).toBe(200);
220
+ expect(taskRes.body.tasks).toHaveLength(1);
221
+ expect(taskRes.body.tasks[0].title).toBe('Order fresh espresso beans');
222
+ });
223
+
224
+ test('Truth Gate candidate proposal approval commits Life DB mutations', async () => {
225
+ // Stage a candidate proposal targeting Life DB
226
+ const proposal = await (knowledge.lifeDb as any).db.proposeClaim({
227
+ id: 'prop-task-gate',
228
+ companionId: 'test-comp',
229
+ subject: 'task:buy-filter',
230
+ predicate: 'is',
231
+ value: 'Buy paper filters',
232
+ status: 'pending',
233
+ evidence: {
234
+ type: 'task',
235
+ title: 'Buy paper filters',
236
+ status: 'todo',
237
+ priority: 1,
238
+ },
239
+ });
240
+ expect(proposal.status).toBe('pending');
241
+
242
+ // Confirm it is not yet in tasks
243
+ const beforeRes = await request(app)
244
+ .get('/knowledge/tasks?id=test-comp')
245
+ .set(mockAuthHeader);
246
+ expect(beforeRes.body.tasks.some((t: any) => t.title === 'Buy paper filters')).toBe(false);
247
+
248
+ // Approve via Truth Gate endpoint
249
+ const approveRes = await request(app)
250
+ .post('/knowledge/proposals/approve')
251
+ .set(mockAuthHeader)
252
+ .send({ companionId: 'test-comp', id: 'prop-task-gate' });
253
+
254
+ expect(approveRes.status).toBe(200);
255
+ expect(approveRes.body.approved).toBe(true);
256
+ expect(approveRes.body.target).toBe('knowledge');
257
+
258
+ // Confirm it is committed to Life DB tasks
259
+ const afterRes = await request(app)
260
+ .get('/knowledge/tasks?id=test-comp')
261
+ .set(mockAuthHeader);
262
+ expect(afterRes.status).toBe(200);
263
+ const approvedTask = afterRes.body.tasks.find((t: any) => t.title === 'Buy paper filters');
264
+ expect(approvedTask).toBeDefined();
265
+ expect(approvedTask.status).toBe('todo');
266
+ });
267
+ });