@siduri-x/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.
- package/LICENSE +190 -0
- package/dist/app.d.ts +9 -0
- package/dist/app.js +436 -0
- package/dist/auth.d.ts +8 -0
- package/dist/auth.js +40 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +48 -0
- package/dist/b0-b6.test.d.ts +1 -0
- package/dist/b0-b6.test.js +121 -0
- package/dist/context-mapper.d.ts +15 -0
- package/dist/context-mapper.js +287 -0
- package/dist/context-mapper.test.d.ts +1 -0
- package/dist/context-mapper.test.js +233 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +167 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +115 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +17 -0
- package/dist/runtime.test.d.ts +1 -0
- package/dist/runtime.test.js +240 -0
- package/dist/smoke.test.d.ts +0 -0
- package/dist/smoke.test.js +6 -0
- package/dist/t4-gating.test.d.ts +1 -0
- package/dist/t4-gating.test.js +193 -0
- package/dist/t5-experience.test.d.ts +1 -0
- package/dist/t5-experience.test.js +156 -0
- package/dist/t6-security.test.d.ts +1 -0
- package/dist/t6-security.test.js +234 -0
- package/dist/t7-release.test.d.ts +1 -0
- package/dist/t7-release.test.js +119 -0
- package/jest.config.json +5 -0
- package/package.json +37 -0
- package/src/app.ts +459 -0
- package/src/auth.test.ts +57 -0
- package/src/auth.ts +49 -0
- package/src/b0-b6.test.ts +137 -0
- package/src/context-mapper.test.ts +258 -0
- package/src/context-mapper.ts +331 -0
- package/src/index.test.ts +129 -0
- package/src/index.ts +161 -0
- package/src/runtime.test.ts +284 -0
- package/src/runtime.ts +1 -0
- package/src/smoke.test.ts +5 -0
- package/src/t4-gating.test.ts +219 -0
- package/src/t5-experience.test.ts +175 -0
- package/src/t6-security.test.ts +257 -0
- package/src/t7-release.test.ts +131 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RequestContext,
|
|
3
|
+
AuthorizationRole,
|
|
4
|
+
Channel,
|
|
5
|
+
DiagnosticCode,
|
|
6
|
+
ContextError,
|
|
7
|
+
validateRequestContext,
|
|
8
|
+
} from '@siduri-x/core';
|
|
9
|
+
|
|
10
|
+
export interface ContextMapperOptions {
|
|
11
|
+
endpointPolicy?: 'public' | 'private' | 'operator' | 'direct';
|
|
12
|
+
defaultPublicAudience?: string;
|
|
13
|
+
defaultPrivateAudience?: string;
|
|
14
|
+
defaultOperatorAudience?: string;
|
|
15
|
+
allowAnonymousPublicChat?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MapRequestContextResult {
|
|
19
|
+
accepted: boolean;
|
|
20
|
+
context?: RequestContext;
|
|
21
|
+
diagnostics?: DiagnosticCode[];
|
|
22
|
+
error?: ContextError;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mapRequestContext(
|
|
26
|
+
input: any,
|
|
27
|
+
options: ContextMapperOptions = {}
|
|
28
|
+
): MapRequestContextResult {
|
|
29
|
+
const diagnostics: DiagnosticCode[] = [];
|
|
30
|
+
const endpointPolicy = options.endpointPolicy || 'public';
|
|
31
|
+
const defaultPublicAudience = options.defaultPublicAudience || 'audience-public';
|
|
32
|
+
|
|
33
|
+
if (!input || typeof input !== 'object') {
|
|
34
|
+
return {
|
|
35
|
+
accepted: false,
|
|
36
|
+
error: {
|
|
37
|
+
code: 'MISSING_CONTEXT',
|
|
38
|
+
fields: ['request'],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check for legacy MASTER_PRIVATE in any audience field or request
|
|
44
|
+
const rawAudience =
|
|
45
|
+
input?.context?.conversation?.audienceId ??
|
|
46
|
+
input?.conversation?.audienceId ??
|
|
47
|
+
input?.audienceId ??
|
|
48
|
+
input?.audience;
|
|
49
|
+
|
|
50
|
+
if (rawAudience === 'MASTER_PRIVATE' || input?.scope === 'MASTER_PRIVATE') {
|
|
51
|
+
if (endpointPolicy === 'public' || input?.channel === 'public' || input?.context?.conversation?.channel === 'public') {
|
|
52
|
+
return {
|
|
53
|
+
accepted: false,
|
|
54
|
+
error: {
|
|
55
|
+
code: 'LEGACY_PERSONAL_AUDIENCE',
|
|
56
|
+
field: 'audienceId',
|
|
57
|
+
correlationId: input?.context?.conversation?.correlationId || input?.correlationId,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 1. If incoming input already has a full neutral context structure
|
|
64
|
+
if (input.context && typeof input.context === 'object') {
|
|
65
|
+
const rawCtx = input.context;
|
|
66
|
+
const companionId = input.companionId || rawCtx.companionId || input.id;
|
|
67
|
+
const correlationId = rawCtx.conversation?.correlationId || input.correlationId;
|
|
68
|
+
|
|
69
|
+
if (!companionId) {
|
|
70
|
+
return {
|
|
71
|
+
accepted: false,
|
|
72
|
+
error: {
|
|
73
|
+
code: 'MISSING_CONTEXT',
|
|
74
|
+
fields: ['companionId'],
|
|
75
|
+
correlationId,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Role cannot select audience or subject
|
|
81
|
+
const rawRole = input.role || rawCtx.actor?.authorizationRole;
|
|
82
|
+
if (input.role && !rawCtx.conversation?.channel && !rawCtx.conversation?.audienceId) {
|
|
83
|
+
return {
|
|
84
|
+
accepted: false,
|
|
85
|
+
error: {
|
|
86
|
+
code: 'AMBIGUOUS_CONTEXT',
|
|
87
|
+
conflicts: ['role_does_not_select_audience', 'role_does_not_select_subject'],
|
|
88
|
+
correlationId,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const channel: Channel = rawCtx.conversation?.channel || (endpointPolicy as Channel);
|
|
94
|
+
let audienceId: string | undefined = rawCtx.conversation?.audienceId;
|
|
95
|
+
|
|
96
|
+
if (!audienceId) {
|
|
97
|
+
if (channel === 'public' || endpointPolicy === 'public') {
|
|
98
|
+
audienceId = defaultPublicAudience;
|
|
99
|
+
diagnostics.push('audience_defaulted_by_public_policy');
|
|
100
|
+
} else {
|
|
101
|
+
return {
|
|
102
|
+
accepted: false,
|
|
103
|
+
error: {
|
|
104
|
+
code: 'MISSING_CONTEXT',
|
|
105
|
+
fields: ['conversation.audienceId'],
|
|
106
|
+
correlationId,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!correlationId) {
|
|
113
|
+
return {
|
|
114
|
+
accepted: false,
|
|
115
|
+
error: {
|
|
116
|
+
code: 'MISSING_CONTEXT',
|
|
117
|
+
fields: ['conversation.correlationId'],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const actor = rawCtx.actor;
|
|
123
|
+
if (!actor || typeof actor !== 'object') {
|
|
124
|
+
return {
|
|
125
|
+
accepted: false,
|
|
126
|
+
error: {
|
|
127
|
+
code: 'MISSING_CONTEXT',
|
|
128
|
+
fields: ['actor'],
|
|
129
|
+
correlationId,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check required capabilities for private/operator/direct channels
|
|
135
|
+
const capabilities: string[] = Array.isArray(actor.capabilities) ? actor.capabilities : [];
|
|
136
|
+
if (channel === 'private' && !capabilities.includes('chat:private')) {
|
|
137
|
+
return {
|
|
138
|
+
accepted: false,
|
|
139
|
+
error: {
|
|
140
|
+
code: 'UNAUTHORIZED_CHANNEL_OR_CAPABILITY',
|
|
141
|
+
message: 'Private channel requires explicit chat:private capability',
|
|
142
|
+
fields: ['actor.capabilities'],
|
|
143
|
+
correlationId,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (channel === 'operator' && !capabilities.includes('memory:inspect') && !capabilities.includes('operator:access')) {
|
|
148
|
+
return {
|
|
149
|
+
accepted: false,
|
|
150
|
+
error: {
|
|
151
|
+
code: 'UNAUTHORIZED_CHANNEL_OR_CAPABILITY',
|
|
152
|
+
message: 'Operator channel requires explicit operator capability',
|
|
153
|
+
fields: ['actor.capabilities'],
|
|
154
|
+
correlationId,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check subject policy
|
|
160
|
+
let subject = rawCtx.subject;
|
|
161
|
+
if (subject) {
|
|
162
|
+
if (subject.subjectId === 'primary_user' || subject === 'primary_user') {
|
|
163
|
+
// Global primary_user is rejected or quarantined
|
|
164
|
+
return {
|
|
165
|
+
accepted: false,
|
|
166
|
+
error: {
|
|
167
|
+
code: 'FORBIDDEN_CONTEXT',
|
|
168
|
+
message: 'Global primary_user subject is forbidden',
|
|
169
|
+
field: 'subject.subjectId',
|
|
170
|
+
correlationId,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const constructed: RequestContext = {
|
|
177
|
+
companionId,
|
|
178
|
+
actor: {
|
|
179
|
+
actorId: actor.actorId,
|
|
180
|
+
sessionId: actor.sessionId,
|
|
181
|
+
authorizationRole: actor.authorizationRole,
|
|
182
|
+
capabilities,
|
|
183
|
+
authenticated: Boolean(actor.authenticated),
|
|
184
|
+
},
|
|
185
|
+
conversation: {
|
|
186
|
+
channel,
|
|
187
|
+
audienceId,
|
|
188
|
+
isLive: rawCtx.conversation?.isLive,
|
|
189
|
+
correlationId,
|
|
190
|
+
},
|
|
191
|
+
subject,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const validated = validateRequestContext(constructed);
|
|
195
|
+
if (!validated.accepted) {
|
|
196
|
+
return validated;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
accepted: true,
|
|
201
|
+
context: validated.context,
|
|
202
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2. Legacy compatibility envelope mapping
|
|
207
|
+
const companionId = input.companionId || input.id;
|
|
208
|
+
const correlationId = input.correlationId || input.conversation?.correlationId;
|
|
209
|
+
|
|
210
|
+
if (!companionId) {
|
|
211
|
+
return {
|
|
212
|
+
accepted: false,
|
|
213
|
+
error: {
|
|
214
|
+
code: 'MISSING_CONTEXT',
|
|
215
|
+
fields: ['companionId'],
|
|
216
|
+
correlationId,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (companionId === 'default') {
|
|
222
|
+
diagnostics.push('companion_default_mapped_for_bootstrap');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Map legacy role to authorizationRole
|
|
226
|
+
const legacyRole = input.role?.toString().toUpperCase();
|
|
227
|
+
let authRole: AuthorizationRole = 'viewer';
|
|
228
|
+
if (legacyRole === 'OWNER') {
|
|
229
|
+
authRole = 'administrator';
|
|
230
|
+
diagnostics.push('legacy_role_mapped_to_authorization');
|
|
231
|
+
} else if (legacyRole === 'OPERATOR') {
|
|
232
|
+
authRole = 'operator';
|
|
233
|
+
diagnostics.push('legacy_role_mapped_to_authorization');
|
|
234
|
+
} else if (legacyRole === 'VIEWER') {
|
|
235
|
+
authRole = 'viewer';
|
|
236
|
+
diagnostics.push('legacy_role_mapped_to_authorization');
|
|
237
|
+
} else if (input.role) {
|
|
238
|
+
return {
|
|
239
|
+
accepted: false,
|
|
240
|
+
error: {
|
|
241
|
+
code: 'INVALID_CONTEXT',
|
|
242
|
+
field: 'role',
|
|
243
|
+
correlationId,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check endpoint policy vs legacy request
|
|
249
|
+
if (endpointPolicy === 'private' || endpointPolicy === 'operator' || endpointPolicy === 'direct') {
|
|
250
|
+
// Missing explicit channel, audience, or capability on private/operator endpoint is an error
|
|
251
|
+
const fields: string[] = [];
|
|
252
|
+
if (!input.channel) fields.push('conversation.channel');
|
|
253
|
+
if (!input.audienceId) fields.push('conversation.audienceId');
|
|
254
|
+
if (!input.capabilities && !input.actor?.capabilities) fields.push('actor.capabilities');
|
|
255
|
+
if (!correlationId) fields.push('conversation.correlationId');
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
accepted: false,
|
|
259
|
+
error: {
|
|
260
|
+
code: 'MISSING_CONTEXT',
|
|
261
|
+
fields: fields.length > 0 ? fields : ['conversation.audienceId', 'actor.capabilities'],
|
|
262
|
+
correlationId,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Ambiguity check: if legacy input specifies role without correlationId or endpoint context for stateful op
|
|
268
|
+
if (input.subject === 'primary_user' || input.subjectId === 'primary_user') {
|
|
269
|
+
return {
|
|
270
|
+
accepted: false,
|
|
271
|
+
error: {
|
|
272
|
+
code: 'FORBIDDEN_CONTEXT',
|
|
273
|
+
message: 'Global primary_user subject is forbidden',
|
|
274
|
+
field: 'subject',
|
|
275
|
+
correlationId,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Missing correlationId for stateful requests
|
|
281
|
+
const finalCorrelationId = correlationId || (input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
|
|
282
|
+
if (!finalCorrelationId) {
|
|
283
|
+
return {
|
|
284
|
+
accepted: false,
|
|
285
|
+
error: {
|
|
286
|
+
code: 'MISSING_CONTEXT',
|
|
287
|
+
fields: ['conversation.correlationId'],
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Build anonymous public context
|
|
293
|
+
const actorId = input.actorId || input.actor?.actorId || 'anonymous-session-a';
|
|
294
|
+
const sessionId = input.sessionId || input.actor?.sessionId || 'session-a';
|
|
295
|
+
if (!input.actorId && !input.actor?.actorId) {
|
|
296
|
+
diagnostics.push('anonymous_session_generated');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const channel: Channel = 'public';
|
|
300
|
+
const audienceId = defaultPublicAudience;
|
|
301
|
+
diagnostics.push('audience_defaulted_by_public_policy');
|
|
302
|
+
|
|
303
|
+
const capabilities = authRole === 'administrator'
|
|
304
|
+
? ['chat:public', 'admin:access']
|
|
305
|
+
: authRole === 'operator'
|
|
306
|
+
? ['chat:public', 'operator:access']
|
|
307
|
+
: ['chat:public'];
|
|
308
|
+
|
|
309
|
+
const mappedContext: RequestContext = {
|
|
310
|
+
companionId,
|
|
311
|
+
actor: {
|
|
312
|
+
actorId,
|
|
313
|
+
sessionId,
|
|
314
|
+
authorizationRole: authRole,
|
|
315
|
+
capabilities,
|
|
316
|
+
authenticated: Boolean(input.authenticated),
|
|
317
|
+
},
|
|
318
|
+
conversation: {
|
|
319
|
+
channel,
|
|
320
|
+
audienceId,
|
|
321
|
+
correlationId: finalCorrelationId,
|
|
322
|
+
},
|
|
323
|
+
subject: undefined, // Anonymous public chat has no subject
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
accepted: true,
|
|
328
|
+
context: mappedContext,
|
|
329
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
'VIEWER',
|
|
35
|
+
[]
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('accepts neutral context chat envelope at /chat route', async () => {
|
|
40
|
+
const res = await request(app)
|
|
41
|
+
.post('/chat')
|
|
42
|
+
.send({
|
|
43
|
+
companionId: 'companion-a',
|
|
44
|
+
context: {
|
|
45
|
+
actor: {
|
|
46
|
+
actorId: 'actor-a',
|
|
47
|
+
sessionId: 'session-a',
|
|
48
|
+
authorizationRole: 'viewer',
|
|
49
|
+
capabilities: ['chat:public'],
|
|
50
|
+
authenticated: false,
|
|
51
|
+
},
|
|
52
|
+
conversation: {
|
|
53
|
+
channel: 'public',
|
|
54
|
+
audienceId: 'audience-public',
|
|
55
|
+
correlationId: 'corr-route-1',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
message: 'Hello structured context',
|
|
59
|
+
history: [],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(res.status).toBe(200);
|
|
63
|
+
expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
|
|
64
|
+
'Hello structured context',
|
|
65
|
+
'VIEWER',
|
|
66
|
+
[]
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('rejects MASTER_PRIVATE in public request with 400 and structured error', async () => {
|
|
71
|
+
const res = await request(app)
|
|
72
|
+
.post('/chat')
|
|
73
|
+
.send({
|
|
74
|
+
companionId: 'companion-a',
|
|
75
|
+
context: {
|
|
76
|
+
actor: {
|
|
77
|
+
actorId: 'actor-a',
|
|
78
|
+
sessionId: 'session-a',
|
|
79
|
+
authorizationRole: 'viewer',
|
|
80
|
+
capabilities: ['chat:public'],
|
|
81
|
+
authenticated: false,
|
|
82
|
+
},
|
|
83
|
+
conversation: {
|
|
84
|
+
channel: 'public',
|
|
85
|
+
audienceId: 'MASTER_PRIVATE',
|
|
86
|
+
correlationId: 'corr-err-1',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
message: 'Forbidden audience test',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(res.status).toBe(400);
|
|
93
|
+
expect(res.body.accepted).toBe(false);
|
|
94
|
+
expect(res.body.error.code).toBe('LEGACY_PERSONAL_AUDIENCE');
|
|
95
|
+
expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rejects global primary_user subject with 400 and structured error', async () => {
|
|
99
|
+
const res = await request(app)
|
|
100
|
+
.post('/chat')
|
|
101
|
+
.send({
|
|
102
|
+
companionId: 'companion-a',
|
|
103
|
+
context: {
|
|
104
|
+
actor: {
|
|
105
|
+
actorId: 'actor-a',
|
|
106
|
+
sessionId: 'session-a',
|
|
107
|
+
authorizationRole: 'viewer',
|
|
108
|
+
capabilities: ['chat:public'],
|
|
109
|
+
authenticated: true,
|
|
110
|
+
},
|
|
111
|
+
conversation: {
|
|
112
|
+
channel: 'public',
|
|
113
|
+
audienceId: 'audience-public',
|
|
114
|
+
correlationId: 'corr-err-2',
|
|
115
|
+
},
|
|
116
|
+
subject: {
|
|
117
|
+
subjectId: 'primary_user',
|
|
118
|
+
kind: 'actor',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
message: 'Forbidden primary user test',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(res.status).toBe(400);
|
|
125
|
+
expect(res.body.accepted).toBe(false);
|
|
126
|
+
expect(res.body.error.code).toBe('FORBIDDEN_CONTEXT');
|
|
127
|
+
expect(fakeRuntime.handleUserMessage).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Express } from 'express';
|
|
4
|
+
import { createApp, AppInstance } from './app';
|
|
5
|
+
import { SiduriRuntime } from './runtime';
|
|
6
|
+
import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
|
|
7
|
+
import { PostgresMemoryOrgan } from '@siduri-x/memory';
|
|
8
|
+
import { VoicevoxAdapter } from '@siduri-x/voice';
|
|
9
|
+
import { EKnowledgeAdapter } from '@siduri-x/knowledge';
|
|
10
|
+
import { OpenRouterVisionAdapter } from '@siduri-x/vision';
|
|
11
|
+
import { ActiveSelfCompiler } from '@siduri-x/behavior';
|
|
12
|
+
import { Live2DAdapter } from '@siduri-x/body';
|
|
13
|
+
import { FixtureObservationOrgan } from '@siduri-x/observation';
|
|
14
|
+
|
|
15
|
+
export { createApp, AppInstance };
|
|
16
|
+
export * from './context-mapper';
|
|
17
|
+
|
|
18
|
+
const runtimes = new Map<string, SiduriRuntime>();
|
|
19
|
+
const instance: AppInstance = createApp(runtimes);
|
|
20
|
+
export const app: Express = instance.app;
|
|
21
|
+
export default app;
|
|
22
|
+
|
|
23
|
+
function createBrain(config: any) {
|
|
24
|
+
const provider = config.provider || 'openrouter';
|
|
25
|
+
const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
|
|
26
|
+
const apiKey = config.apiKey || process.env[config.apiKeyEnv || defaultKeyEnv] || '';
|
|
27
|
+
if (provider === 'openai-compatible') {
|
|
28
|
+
return new OpenAICompatibleBrain({
|
|
29
|
+
apiKey,
|
|
30
|
+
model: config.model || 'local-model',
|
|
31
|
+
baseUrl: config.baseUrl || 'http://127.0.0.1:1234/v1',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return new OpenRouterBrain({ apiKey, model: config.model || 'gpt-4o-mini' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isDisabled(config: any): boolean {
|
|
38
|
+
return !config || config.provider === 'none';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createVoice(config: any) {
|
|
42
|
+
return isDisabled(config)
|
|
43
|
+
? undefined
|
|
44
|
+
: new VoicevoxAdapter({ baseUrl: process.env.VOICEVOX_URL || 'http://localhost:50021', speakerId: config.speakerId || 1 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createKnowledge(config: any) {
|
|
48
|
+
if (isDisabled(config)) return undefined;
|
|
49
|
+
if (!config?.packPath && !config?.registryUrl && !config?.baseUrl && !config?.hubUrl) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return new EKnowledgeAdapter(config);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createVision(config: any) {
|
|
56
|
+
return isDisabled(config)
|
|
57
|
+
? undefined
|
|
58
|
+
: new OpenRouterVisionAdapter({ apiKey: process.env.OPENROUTER_API_KEY || '', model: config.model || 'gpt-4-vision' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createBehavior(config: any) {
|
|
62
|
+
return isDisabled(config) ? undefined : new ActiveSelfCompiler();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createBody(config: any) {
|
|
66
|
+
return isDisabled(config)
|
|
67
|
+
? undefined
|
|
68
|
+
: new Live2DAdapter(config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const PORT = process.env.PORT || 3001;
|
|
72
|
+
|
|
73
|
+
const defaultCompanionConfig = {
|
|
74
|
+
id: 'default',
|
|
75
|
+
name: 'Siduri',
|
|
76
|
+
brain: { provider: 'openrouter', model: 'gpt-4o-mini' },
|
|
77
|
+
voice: { provider: 'voicevox', speakerId: 1 },
|
|
78
|
+
memory: { provider: 'postgres' },
|
|
79
|
+
knowledge: {
|
|
80
|
+
provider: (process.env.SIDURI_KNOWLEDGE_PROVIDER as 'e-knowledge' | 'e-remote' | 'e-hub') || 'e-knowledge',
|
|
81
|
+
packPath: process.env.SIDURI_KNOWLEDGE_PACK || '',
|
|
82
|
+
registryUrl: process.env.SIDURI_KNOWLEDGE_REGISTRY_URL || '',
|
|
83
|
+
packId: process.env.SIDURI_KNOWLEDGE_PACK_ID || '',
|
|
84
|
+
timeoutMs: Number(process.env.SIDURI_KNOWLEDGE_TIMEOUT_MS || 5000),
|
|
85
|
+
preferredMode: (process.env.SIDURI_KNOWLEDGE_MODE as 'lexical' | 'semantic' | 'hybrid') || 'lexical',
|
|
86
|
+
},
|
|
87
|
+
behavior: { provider: 'active_self' },
|
|
88
|
+
body: {
|
|
89
|
+
provider: 'live2d',
|
|
90
|
+
},
|
|
91
|
+
vision: { provider: 'openrouter', model: 'gpt-4-vision' }
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
async function loadCompanionConfig() {
|
|
95
|
+
const configPath = process.env.SIDURI_CONFIG || path.resolve(process.cwd(), 'siduri.config.json');
|
|
96
|
+
let fileConfig: Record<string, any> = {};
|
|
97
|
+
try {
|
|
98
|
+
fileConfig = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, any>;
|
|
99
|
+
console.log(`Loaded companion configuration from ${configPath}`);
|
|
100
|
+
} catch (error: any) {
|
|
101
|
+
if (error?.code !== 'ENOENT') throw new Error(`Unable to read ${configPath}: ${error.message}`);
|
|
102
|
+
console.log(`No ${configPath} found; using environment/default configuration.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const config: any = {
|
|
106
|
+
...defaultCompanionConfig,
|
|
107
|
+
...fileConfig,
|
|
108
|
+
id: fileConfig.id || defaultCompanionConfig.id,
|
|
109
|
+
brain: { ...defaultCompanionConfig.brain, ...fileConfig.brain },
|
|
110
|
+
voice: { ...defaultCompanionConfig.voice, ...fileConfig.voice },
|
|
111
|
+
memory: { ...defaultCompanionConfig.memory, ...fileConfig.memory },
|
|
112
|
+
knowledge: { ...defaultCompanionConfig.knowledge, ...fileConfig.knowledge },
|
|
113
|
+
behavior: { ...defaultCompanionConfig.behavior, ...fileConfig.behavior },
|
|
114
|
+
body: { ...defaultCompanionConfig.body, ...fileConfig.body },
|
|
115
|
+
vision: { ...defaultCompanionConfig.vision, ...fileConfig.vision },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (process.env.SIDURI_KNOWLEDGE_PROVIDER) config.knowledge.provider = process.env.SIDURI_KNOWLEDGE_PROVIDER;
|
|
119
|
+
if (process.env.SIDURI_KNOWLEDGE_PACK) config.knowledge.packPath = process.env.SIDURI_KNOWLEDGE_PACK;
|
|
120
|
+
if (process.env.SIDURI_KNOWLEDGE_REGISTRY_URL) config.knowledge.registryUrl = process.env.SIDURI_KNOWLEDGE_REGISTRY_URL;
|
|
121
|
+
if (process.env.SIDURI_KNOWLEDGE_PACK_ID) config.knowledge.packId = process.env.SIDURI_KNOWLEDGE_PACK_ID;
|
|
122
|
+
if (process.env.SIDURI_KNOWLEDGE_MODE) config.knowledge.preferredMode = process.env.SIDURI_KNOWLEDGE_MODE;
|
|
123
|
+
return config;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function bootDefaultCompanion() {
|
|
127
|
+
if (runtimes.has('default')) return;
|
|
128
|
+
console.log("Booting default companion...");
|
|
129
|
+
const config: any = await loadCompanionConfig();
|
|
130
|
+
|
|
131
|
+
const brain = createBrain(config.brain);
|
|
132
|
+
const memory = new PostgresMemoryOrgan({ connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/siduri' });
|
|
133
|
+
const voice = createVoice(config.voice);
|
|
134
|
+
const knowledge = createKnowledge(config.knowledge);
|
|
135
|
+
const vision = createVision(config.vision);
|
|
136
|
+
const observation = new FixtureObservationOrgan(
|
|
137
|
+
vision ?? { analyze: async () => JSON.stringify({ readings: [] }) },
|
|
138
|
+
);
|
|
139
|
+
instance.setObservationOrgan(observation);
|
|
140
|
+
const behavior = createBehavior(config.behavior);
|
|
141
|
+
const body = createBody(config.body);
|
|
142
|
+
|
|
143
|
+
await memory.runMigrations().catch(e => console.warn("Migrations warning:", e.message));
|
|
144
|
+
|
|
145
|
+
const runtime = new SiduriRuntime('default', config as any, { brain, memory, voice, knowledge, vision, behavior, body });
|
|
146
|
+
await runtime.initialize();
|
|
147
|
+
|
|
148
|
+
runtimes.set('default', runtime);
|
|
149
|
+
console.log("Default companion booted successfully.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
153
|
+
bootDefaultCompanion().then(() => {
|
|
154
|
+
app.listen(PORT, () => {
|
|
155
|
+
console.log(`Siduri-Y API running on port ${PORT}`);
|
|
156
|
+
});
|
|
157
|
+
}).catch(e => {
|
|
158
|
+
console.error("Failed to boot default companion:", e);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
}
|