@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.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +78 -0
- package/dist/app.d.ts +11 -0
- package/dist/app.js +1190 -0
- package/dist/auth.d.ts +34 -0
- package/dist/auth.js +84 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +74 -0
- package/dist/b0-b6.test.d.ts +1 -0
- package/dist/b0-b6.test.js +121 -0
- package/dist/boot.d.ts +91 -0
- package/dist/boot.js +154 -0
- package/dist/boot.test.d.ts +1 -0
- package/dist/boot.test.js +91 -0
- package/dist/context-mapper.d.ts +20 -0
- package/dist/context-mapper.js +233 -0
- package/dist/context-mapper.test.d.ts +1 -0
- package/dist/context-mapper.test.js +287 -0
- package/dist/cors.d.ts +3 -0
- package/dist/cors.js +42 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +116 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +151 -0
- package/dist/knowledge.test.d.ts +1 -0
- package/dist/knowledge.test.js +236 -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 +282 -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 +155 -0
- package/dist/t6-security.test.d.ts +1 -0
- package/dist/t6-security.test.js +535 -0
- package/dist/t7-release.test.d.ts +1 -0
- package/dist/t7-release.test.js +119 -0
- package/dist/teach-mode.test.d.ts +1 -0
- package/dist/teach-mode.test.js +277 -0
- package/jest.config.json +5 -0
- package/package.json +44 -0
- package/siduri.sqlite +0 -0
- package/src/app.ts +1209 -0
- package/src/auth.test.ts +90 -0
- package/src/auth.ts +101 -0
- package/src/b0-b6.test.ts +137 -0
- package/src/boot.test.ts +112 -0
- package/src/boot.ts +197 -0
- package/src/context-mapper.test.ts +319 -0
- package/src/context-mapper.ts +279 -0
- package/src/cors.ts +44 -0
- package/src/index.test.ts +178 -0
- package/src/index.ts +101 -0
- package/src/knowledge.test.ts +267 -0
- package/src/runtime.test.ts +336 -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 +174 -0
- package/src/t6-security.test.ts +589 -0
- package/src/t7-release.test.ts +131 -0
- package/src/teach-mode.test.ts +312 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { mapRequestContext } from './context-mapper';
|
|
2
|
+
|
|
3
|
+
describe('API Request Context Mapper (Single-Owner, Single-Machine)', () => {
|
|
4
|
+
test('Local chat request maps to valid RequestContext with local user defaults', () => {
|
|
5
|
+
const input = {
|
|
6
|
+
companionId: 'companion-a',
|
|
7
|
+
message: 'Hello world',
|
|
8
|
+
correlationId: 'corr-local-1',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const result = mapRequestContext(input);
|
|
12
|
+
|
|
13
|
+
expect(result.accepted).toBe(true);
|
|
14
|
+
expect(result.context?.companionId).toBe('companion-a');
|
|
15
|
+
expect(result.context?.actor.actorId).toBe('local-user');
|
|
16
|
+
expect(result.context?.actor.authenticated).toBe(true);
|
|
17
|
+
expect(result.context?.conversation.correlationId).toBe('corr-local-1');
|
|
18
|
+
expect(result.context?.source).toBe('local');
|
|
19
|
+
expect(result.context?.subject).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('Accepts structured context envelope', () => {
|
|
23
|
+
const input = {
|
|
24
|
+
companionId: 'companion-a',
|
|
25
|
+
context: {
|
|
26
|
+
actor: {
|
|
27
|
+
actorId: 'user-main',
|
|
28
|
+
sessionId: 'session-main',
|
|
29
|
+
authenticated: true,
|
|
30
|
+
capabilities: ['chat', 'system'],
|
|
31
|
+
},
|
|
32
|
+
conversation: {
|
|
33
|
+
correlationId: 'corr-structured-1',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
message: 'Hello structured context',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const result = mapRequestContext(input);
|
|
40
|
+
expect(result.accepted).toBe(true);
|
|
41
|
+
expect(result.context?.actor.actorId).toBe('user-main');
|
|
42
|
+
expect(result.context?.conversation.correlationId).toBe('corr-structured-1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Rejects global primary_user subject', () => {
|
|
46
|
+
const input = {
|
|
47
|
+
companionId: 'companion-a',
|
|
48
|
+
context: {
|
|
49
|
+
actor: {
|
|
50
|
+
actorId: 'user-a',
|
|
51
|
+
sessionId: 'session-a',
|
|
52
|
+
},
|
|
53
|
+
conversation: {
|
|
54
|
+
correlationId: 'corr-primary-user',
|
|
55
|
+
},
|
|
56
|
+
subject: {
|
|
57
|
+
subjectId: 'primary_user',
|
|
58
|
+
kind: 'actor',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const result = mapRequestContext(input);
|
|
64
|
+
expect(result.accepted).toBe(false);
|
|
65
|
+
expect(result.error?.code).toBe('FORBIDDEN_CONTEXT');
|
|
66
|
+
expect(result.error?.field).toBe('subject.subjectId');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('Rejects missing actor on structured context', () => {
|
|
70
|
+
const input = {
|
|
71
|
+
companionId: 'companion-a',
|
|
72
|
+
context: {
|
|
73
|
+
actor: {
|
|
74
|
+
actorId: '',
|
|
75
|
+
sessionId: 'session-a',
|
|
76
|
+
},
|
|
77
|
+
conversation: {
|
|
78
|
+
correlationId: 'corr-teach-no-actor',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = mapRequestContext(input);
|
|
84
|
+
expect(result.accepted).toBe(false);
|
|
85
|
+
expect(result.error?.code).toBe('MISSING_CONTEXT');
|
|
86
|
+
expect(result.error?.fields).toContain('actor.actorId');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('Two companion IDs with same actor remain isolated under context mapper', () => {
|
|
90
|
+
const actor = {
|
|
91
|
+
actorId: 'user-a',
|
|
92
|
+
sessionId: 'session-a',
|
|
93
|
+
authenticated: true,
|
|
94
|
+
};
|
|
95
|
+
const conv = {
|
|
96
|
+
correlationId: 'corr-iso-1',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const reqA = { companionId: 'companion-a', context: { actor, conversation: conv } };
|
|
100
|
+
const reqB = { companionId: 'companion-b', context: { actor, conversation: conv } };
|
|
101
|
+
|
|
102
|
+
const resA = mapRequestContext(reqA);
|
|
103
|
+
const resB = mapRequestContext(reqB);
|
|
104
|
+
|
|
105
|
+
expect(resA.accepted).toBe(true);
|
|
106
|
+
expect(resB.accepted).toBe(true);
|
|
107
|
+
expect(resA.context?.companionId).toBe('companion-a');
|
|
108
|
+
expect(resB.context?.companionId).toBe('companion-b');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('Rejects missing companionId', () => {
|
|
112
|
+
const input = {
|
|
113
|
+
message: 'No companion id',
|
|
114
|
+
correlationId: 'corr-no-comp',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const result = mapRequestContext(input);
|
|
118
|
+
expect(result.accepted).toBe(false);
|
|
119
|
+
expect(result.error?.code).toBe('MISSING_CONTEXT');
|
|
120
|
+
expect(result.error?.fields).toContain('companionId');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('Rejects missing correlation ID when generateCorrelationId is not set', () => {
|
|
124
|
+
const input = {
|
|
125
|
+
companionId: 'companion-a',
|
|
126
|
+
context: {
|
|
127
|
+
actor: {
|
|
128
|
+
actorId: 'user-a',
|
|
129
|
+
sessionId: 'session-a',
|
|
130
|
+
},
|
|
131
|
+
conversation: {
|
|
132
|
+
correlationId: '',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = mapRequestContext(input);
|
|
138
|
+
expect(result.accepted).toBe(false);
|
|
139
|
+
expect(result.error?.code).toBe('MISSING_CONTEXT');
|
|
140
|
+
expect(result.error?.fields).toContain('conversation.correlationId');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Generates correlationId when generateCorrelationId option is true', () => {
|
|
144
|
+
const input = {
|
|
145
|
+
companionId: 'companion-a',
|
|
146
|
+
generateCorrelationId: true,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const result = mapRequestContext(input);
|
|
150
|
+
expect(result.accepted).toBe(true);
|
|
151
|
+
expect(result.context?.conversation.correlationId).toMatch(/^corr-/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('Overrides forged client actor authentication and caps capabilities on unauthenticated requests', () => {
|
|
155
|
+
const forgedInput = {
|
|
156
|
+
companionId: 'companion-a',
|
|
157
|
+
authenticated: false, // Server detected unauthenticated request
|
|
158
|
+
source: 'external',
|
|
159
|
+
context: {
|
|
160
|
+
actor: {
|
|
161
|
+
actorId: 'attacker',
|
|
162
|
+
sessionId: 'session-att',
|
|
163
|
+
authenticated: true, // Forged
|
|
164
|
+
capabilities: ['system:exec', 'bash', 'chat'], // Forged elevated capabilities
|
|
165
|
+
authorizationRole: 'OWNER', // Forged role
|
|
166
|
+
},
|
|
167
|
+
conversation: {
|
|
168
|
+
correlationId: 'corr-forged-1',
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const result = mapRequestContext(forgedInput);
|
|
174
|
+
expect(result.accepted).toBe(true);
|
|
175
|
+
expect(result.context?.actor.authenticated).toBe(false);
|
|
176
|
+
expect(result.context?.actor.capabilities).toEqual(['chat']);
|
|
177
|
+
expect(result.context?.actor.authorizationRole).toBe('viewer');
|
|
178
|
+
expect(result.context?.source).toBe('external');
|
|
179
|
+
expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
|
|
180
|
+
expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('Prevents attenuated caller from escalating to administrator role or forging elevated capabilities', () => {
|
|
184
|
+
const attenuatedPayload = {
|
|
185
|
+
companionId: 'companion-a',
|
|
186
|
+
authenticated: true,
|
|
187
|
+
role: 'VIEWER',
|
|
188
|
+
source: 'external',
|
|
189
|
+
context: {
|
|
190
|
+
actor: {
|
|
191
|
+
actorId: 'visitor-bob',
|
|
192
|
+
sessionId: 'session-vis-1',
|
|
193
|
+
authorizationRole: 'administrator', // Forged owner/admin role
|
|
194
|
+
capabilities: ['chat', 'system', 'root:manage', 'action:execute'], // Forged system/root capabilities
|
|
195
|
+
},
|
|
196
|
+
conversation: {
|
|
197
|
+
correlationId: 'corr-att-escalate',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const result = mapRequestContext(attenuatedPayload);
|
|
203
|
+
expect(result.accepted).toBe(true);
|
|
204
|
+
expect(result.context?.actor.authenticated).toBe(true);
|
|
205
|
+
expect(result.context?.actor.authorizationRole).toBe('viewer');
|
|
206
|
+
expect(result.context?.actor.capabilities).toEqual(['chat']);
|
|
207
|
+
expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
|
|
208
|
+
expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('Strips unknown capabilities for authenticated owner while preserving canonical companion capabilities', () => {
|
|
212
|
+
const ownerPayload = {
|
|
213
|
+
companionId: 'companion-a',
|
|
214
|
+
authenticated: true,
|
|
215
|
+
source: 'local',
|
|
216
|
+
context: {
|
|
217
|
+
actor: {
|
|
218
|
+
actorId: 'owner-user',
|
|
219
|
+
sessionId: 'session-owner-1',
|
|
220
|
+
capabilities: ['chat', 'system', 'root:unauthorized', 'arbitrary:hack'],
|
|
221
|
+
},
|
|
222
|
+
conversation: {
|
|
223
|
+
correlationId: 'corr-owner-caps',
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const result = mapRequestContext(ownerPayload);
|
|
229
|
+
expect(result.accepted).toBe(true);
|
|
230
|
+
expect(result.context?.actor.authorizationRole).toBe('administrator');
|
|
231
|
+
expect(result.context?.actor.capabilities).toEqual(['chat', 'system']);
|
|
232
|
+
expect(result.diagnostics).toContain('capability_escalation_attempt_suppressed');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('Flat envelope: suppresses role and capability escalation when serverRole is VIEWER', () => {
|
|
236
|
+
const viewerPayload = {
|
|
237
|
+
companionId: 'companion-a',
|
|
238
|
+
authenticated: false,
|
|
239
|
+
serverRole: 'VIEWER',
|
|
240
|
+
role: 'OWNER', // Forged in flat envelope
|
|
241
|
+
capabilities: ['chat', 'system', 'action:execute'],
|
|
242
|
+
generateCorrelationId: true,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const result = mapRequestContext(viewerPayload);
|
|
246
|
+
expect(result.accepted).toBe(true);
|
|
247
|
+
expect(result.context?.actor.authorizationRole).toBe('viewer');
|
|
248
|
+
expect(result.context?.actor.capabilities).toEqual(['chat']);
|
|
249
|
+
expect(result.diagnostics).toContain('role_escalation_attempt_suppressed');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('Authenticated owner receives canonical administrator role and capabilities, and can safely attenuate to viewer', () => {
|
|
253
|
+
const ownerPayload = {
|
|
254
|
+
companionId: 'companion-a',
|
|
255
|
+
authenticated: true,
|
|
256
|
+
serverRole: 'OWNER',
|
|
257
|
+
generateCorrelationId: true,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const ownerResult = mapRequestContext(ownerPayload);
|
|
261
|
+
expect(ownerResult.accepted).toBe(true);
|
|
262
|
+
expect(ownerResult.context?.actor.authorizationRole).toBe('administrator');
|
|
263
|
+
expect(ownerResult.context?.actor.capabilities).toEqual([
|
|
264
|
+
'chat',
|
|
265
|
+
'memory:approve',
|
|
266
|
+
'action:execute',
|
|
267
|
+
'system',
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
// Attenuation to viewer
|
|
271
|
+
const attenuatedPayload = {
|
|
272
|
+
...ownerPayload,
|
|
273
|
+
role: 'VIEWER',
|
|
274
|
+
};
|
|
275
|
+
const attenuatedResult = mapRequestContext(attenuatedPayload);
|
|
276
|
+
expect(attenuatedResult.accepted).toBe(true);
|
|
277
|
+
expect(attenuatedResult.context?.actor.authorizationRole).toBe('viewer');
|
|
278
|
+
expect(attenuatedResult.diagnostics).not.toContain('role_escalation_attempt_suppressed');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('Mode Mapping (Casual, Teach, Hybrid)', () => {
|
|
282
|
+
test('maps explicit mode parameter to RequestContext', () => {
|
|
283
|
+
const casualResult = mapRequestContext({
|
|
284
|
+
companionId: 'comp-1',
|
|
285
|
+
mode: 'casual',
|
|
286
|
+
generateCorrelationId: true,
|
|
287
|
+
});
|
|
288
|
+
expect(casualResult.accepted).toBe(true);
|
|
289
|
+
expect(casualResult.context?.mode).toBe('casual');
|
|
290
|
+
|
|
291
|
+
const teachResult = mapRequestContext({
|
|
292
|
+
companionId: 'comp-1',
|
|
293
|
+
mode: 'teach',
|
|
294
|
+
generateCorrelationId: true,
|
|
295
|
+
});
|
|
296
|
+
expect(teachResult.accepted).toBe(true);
|
|
297
|
+
expect(teachResult.context?.mode).toBe('teach');
|
|
298
|
+
|
|
299
|
+
const hybridResult = mapRequestContext({
|
|
300
|
+
companionId: 'comp-1',
|
|
301
|
+
mode: 'hybrid',
|
|
302
|
+
generateCorrelationId: true,
|
|
303
|
+
});
|
|
304
|
+
expect(hybridResult.accepted).toBe(true);
|
|
305
|
+
expect(hybridResult.context?.mode).toBe('hybrid');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('rejects invalid interaction mode', () => {
|
|
309
|
+
const invalidResult = mapRequestContext({
|
|
310
|
+
companionId: 'comp-1',
|
|
311
|
+
mode: 'super_turbo',
|
|
312
|
+
generateCorrelationId: true,
|
|
313
|
+
});
|
|
314
|
+
expect(invalidResult.accepted).toBe(false);
|
|
315
|
+
expect(invalidResult.error?.code).toBe('INVALID_CONTEXT');
|
|
316
|
+
expect(invalidResult.error?.field).toBe('mode');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RequestContext,
|
|
3
|
+
DiagnosticCode,
|
|
4
|
+
ContextError,
|
|
5
|
+
validateRequestContext,
|
|
6
|
+
} from '@sidurijs/core';
|
|
7
|
+
|
|
8
|
+
export interface ContextMapperOptions {
|
|
9
|
+
endpointPolicy?: 'public' | 'private' | 'operator' | 'direct' | string;
|
|
10
|
+
allowAnonymousPublicChat?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MapRequestContextResult {
|
|
14
|
+
accepted: boolean;
|
|
15
|
+
context?: RequestContext;
|
|
16
|
+
diagnostics?: DiagnosticCode[];
|
|
17
|
+
error?: ContextError;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Maps incoming HTTP requests to a canonical RequestContext.
|
|
22
|
+
* In a single-owner, single-machine model:
|
|
23
|
+
* - Security is enforced at the external machine boundary, NOT internally between roles.
|
|
24
|
+
* - No internal audience or viewer/operator/owner role hierarchies.
|
|
25
|
+
* - Authenticated callers are the single owner with full companion access.
|
|
26
|
+
* - Unauthenticated callers are bounded to public chat.
|
|
27
|
+
*/
|
|
28
|
+
export function mapRequestContext(
|
|
29
|
+
input: any,
|
|
30
|
+
options: ContextMapperOptions = {}
|
|
31
|
+
): MapRequestContextResult {
|
|
32
|
+
const diagnostics: DiagnosticCode[] = [];
|
|
33
|
+
|
|
34
|
+
if (!input || typeof input !== 'object') {
|
|
35
|
+
return {
|
|
36
|
+
accepted: false,
|
|
37
|
+
error: {
|
|
38
|
+
code: 'MISSING_CONTEXT',
|
|
39
|
+
fields: ['request'],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 1. If incoming input already has a context structure
|
|
45
|
+
if (input.context && typeof input.context === 'object') {
|
|
46
|
+
const rawCtx = input.context;
|
|
47
|
+
const companionId = input.companionId || rawCtx.companionId || input.id;
|
|
48
|
+
const correlationId =
|
|
49
|
+
rawCtx.conversation?.correlationId ||
|
|
50
|
+
input.correlationId ||
|
|
51
|
+
(input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
|
|
52
|
+
|
|
53
|
+
if (!companionId) {
|
|
54
|
+
return {
|
|
55
|
+
accepted: false,
|
|
56
|
+
error: {
|
|
57
|
+
code: 'MISSING_CONTEXT',
|
|
58
|
+
fields: ['companionId'],
|
|
59
|
+
correlationId,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const actor = rawCtx.actor;
|
|
65
|
+
if (!actor || typeof actor !== 'object') {
|
|
66
|
+
return {
|
|
67
|
+
accepted: false,
|
|
68
|
+
error: {
|
|
69
|
+
code: 'MISSING_CONTEXT',
|
|
70
|
+
fields: ['actor'],
|
|
71
|
+
correlationId,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Reject invalid primary_user subject
|
|
77
|
+
if (rawCtx.subject && (rawCtx.subject.subjectId === 'primary_user' || rawCtx.subject === 'primary_user')) {
|
|
78
|
+
return {
|
|
79
|
+
accepted: false,
|
|
80
|
+
error: {
|
|
81
|
+
code: 'FORBIDDEN_CONTEXT',
|
|
82
|
+
message: 'Global primary_user subject is forbidden',
|
|
83
|
+
field: 'subject.subjectId',
|
|
84
|
+
correlationId,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isViewerRequested =
|
|
90
|
+
(typeof input.role === 'string' && input.role.toLowerCase() === 'viewer') ||
|
|
91
|
+
(typeof input.serverRole === 'string' && input.serverRole.toLowerCase() === 'viewer') ||
|
|
92
|
+
(typeof actor.authorizationRole === 'string' && actor.authorizationRole.toLowerCase() === 'viewer') ||
|
|
93
|
+
(typeof (actor as any).role === 'string' && (actor as any).role.toLowerCase() === 'viewer');
|
|
94
|
+
|
|
95
|
+
// Determine server-enforced authentication status at the machine boundary
|
|
96
|
+
const isAuthenticated = input.authenticated !== undefined
|
|
97
|
+
? Boolean(input.authenticated)
|
|
98
|
+
: (actor.authenticated !== undefined ? Boolean(actor.authenticated) : true);
|
|
99
|
+
|
|
100
|
+
const isViewer = !isAuthenticated || isViewerRequested;
|
|
101
|
+
|
|
102
|
+
const CANONICAL_CAPABILITIES = new Set(['chat', 'memory:approve', 'action:execute', 'system']);
|
|
103
|
+
const DEFAULT_OWNER_CAPABILITIES = ['chat', 'memory:approve', 'action:execute', 'system'];
|
|
104
|
+
|
|
105
|
+
let safeCapabilities: string[];
|
|
106
|
+
let authorizationRole: string;
|
|
107
|
+
|
|
108
|
+
if (isViewer) {
|
|
109
|
+
safeCapabilities = ['chat'];
|
|
110
|
+
authorizationRole = 'viewer';
|
|
111
|
+
if (Array.isArray(actor.capabilities) && actor.capabilities.some((c: string) => c !== 'chat' && c !== 'chat:public')) {
|
|
112
|
+
diagnostics.push('capability_escalation_attempt_suppressed');
|
|
113
|
+
}
|
|
114
|
+
if (actor.authorizationRole && actor.authorizationRole.toLowerCase() !== 'viewer') {
|
|
115
|
+
diagnostics.push('role_escalation_attempt_suppressed');
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
authorizationRole = 'administrator';
|
|
119
|
+
if (Array.isArray(actor.capabilities) && actor.capabilities.length > 0) {
|
|
120
|
+
const filtered = actor.capabilities.filter((c: string) => CANONICAL_CAPABILITIES.has(c));
|
|
121
|
+
if (filtered.length < actor.capabilities.length) {
|
|
122
|
+
diagnostics.push('capability_escalation_attempt_suppressed');
|
|
123
|
+
}
|
|
124
|
+
safeCapabilities = filtered.length > 0 ? filtered : [...DEFAULT_OWNER_CAPABILITIES];
|
|
125
|
+
} else {
|
|
126
|
+
safeCapabilities = [...DEFAULT_OWNER_CAPABILITIES];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const constructed: RequestContext = {
|
|
131
|
+
companionId,
|
|
132
|
+
actor: {
|
|
133
|
+
...actor,
|
|
134
|
+
actorId: actor.actorId,
|
|
135
|
+
sessionId: actor.sessionId,
|
|
136
|
+
capabilities: safeCapabilities,
|
|
137
|
+
authenticated: isAuthenticated,
|
|
138
|
+
authorizationRole,
|
|
139
|
+
},
|
|
140
|
+
conversation: {
|
|
141
|
+
correlationId,
|
|
142
|
+
channel: rawCtx.conversation?.channel || input.channel || 'direct',
|
|
143
|
+
isLive: rawCtx.conversation?.isLive,
|
|
144
|
+
...rawCtx.conversation,
|
|
145
|
+
},
|
|
146
|
+
source: input.source || rawCtx.source || (isAuthenticated ? 'local' : 'external'),
|
|
147
|
+
subject: rawCtx.subject,
|
|
148
|
+
mode: rawCtx.mode || input.mode,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const validated = validateRequestContext(constructed);
|
|
152
|
+
if (!validated.accepted) {
|
|
153
|
+
return validated;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
accepted: true,
|
|
158
|
+
context: validated.context,
|
|
159
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Synthesize clean RequestContext from request envelope
|
|
164
|
+
const companionId = input.companionId || input.id;
|
|
165
|
+
const correlationId = input.correlationId || input.conversation?.correlationId || (input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
|
|
166
|
+
|
|
167
|
+
if (!companionId) {
|
|
168
|
+
return {
|
|
169
|
+
accepted: false,
|
|
170
|
+
error: {
|
|
171
|
+
code: 'MISSING_CONTEXT',
|
|
172
|
+
fields: ['companionId'],
|
|
173
|
+
correlationId,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (companionId === 'default') {
|
|
179
|
+
diagnostics.push('companion_default_mapped_for_bootstrap');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!correlationId) {
|
|
183
|
+
return {
|
|
184
|
+
accepted: false,
|
|
185
|
+
error: {
|
|
186
|
+
code: 'MISSING_CONTEXT',
|
|
187
|
+
fields: ['conversation.correlationId'],
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (input.subject === 'primary_user' || input.subjectId === 'primary_user') {
|
|
193
|
+
return {
|
|
194
|
+
accepted: false,
|
|
195
|
+
error: {
|
|
196
|
+
code: 'FORBIDDEN_CONTEXT',
|
|
197
|
+
message: 'Global primary_user subject is forbidden',
|
|
198
|
+
field: 'subject',
|
|
199
|
+
correlationId,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const isViewerRequested =
|
|
205
|
+
(typeof input.role === 'string' && input.role.toLowerCase() === 'viewer') ||
|
|
206
|
+
(typeof input.serverRole === 'string' && input.serverRole.toLowerCase() === 'viewer');
|
|
207
|
+
|
|
208
|
+
const isAuthenticated = input.authenticated !== undefined ? Boolean(input.authenticated) : true;
|
|
209
|
+
const isViewer = !isAuthenticated || isViewerRequested;
|
|
210
|
+
|
|
211
|
+
const actorId = input.actorId || input.actor?.actorId || (isAuthenticated && !isViewer ? 'local-user' : 'anonymous-session');
|
|
212
|
+
const sessionId = input.sessionId || input.actor?.sessionId || `session-${Date.now()}`;
|
|
213
|
+
if (!input.actorId && !input.actor?.actorId) {
|
|
214
|
+
diagnostics.push('anonymous_session_generated');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const CANONICAL_CAPABILITIES = new Set(['chat', 'memory:approve', 'action:execute', 'system']);
|
|
218
|
+
const DEFAULT_OWNER_CAPABILITIES = ['chat', 'memory:approve', 'action:execute', 'system'];
|
|
219
|
+
|
|
220
|
+
const rawCaps = Array.isArray(input.capabilities)
|
|
221
|
+
? input.capabilities
|
|
222
|
+
: Array.isArray(input.actor?.capabilities)
|
|
223
|
+
? input.actor.capabilities
|
|
224
|
+
: undefined;
|
|
225
|
+
|
|
226
|
+
let capabilities: string[];
|
|
227
|
+
let authorizationRole: string;
|
|
228
|
+
|
|
229
|
+
if (isViewer) {
|
|
230
|
+
capabilities = ['chat'];
|
|
231
|
+
authorizationRole = 'viewer';
|
|
232
|
+
if (rawCaps && rawCaps.some((c: string) => c !== 'chat' && c !== 'chat:public')) {
|
|
233
|
+
diagnostics.push('capability_escalation_attempt_suppressed');
|
|
234
|
+
}
|
|
235
|
+
if (input.role && input.role.toLowerCase() !== 'viewer') {
|
|
236
|
+
diagnostics.push('role_escalation_attempt_suppressed');
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
authorizationRole = 'administrator';
|
|
240
|
+
if (rawCaps && rawCaps.length > 0) {
|
|
241
|
+
const filtered = rawCaps.filter((c: string) => CANONICAL_CAPABILITIES.has(c));
|
|
242
|
+
if (filtered.length < rawCaps.length) {
|
|
243
|
+
diagnostics.push('capability_escalation_attempt_suppressed');
|
|
244
|
+
}
|
|
245
|
+
capabilities = filtered.length > 0 ? filtered : [...DEFAULT_OWNER_CAPABILITIES];
|
|
246
|
+
} else {
|
|
247
|
+
capabilities = [...DEFAULT_OWNER_CAPABILITIES];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const mappedContext: RequestContext = {
|
|
252
|
+
companionId,
|
|
253
|
+
actor: {
|
|
254
|
+
actorId,
|
|
255
|
+
sessionId,
|
|
256
|
+
capabilities,
|
|
257
|
+
authenticated: isAuthenticated,
|
|
258
|
+
authorizationRole,
|
|
259
|
+
},
|
|
260
|
+
conversation: {
|
|
261
|
+
channel: input.channel || input.conversation?.channel || 'direct',
|
|
262
|
+
correlationId,
|
|
263
|
+
},
|
|
264
|
+
source: input.source || (isAuthenticated ? 'local' : 'external'),
|
|
265
|
+
subject: input.subject,
|
|
266
|
+
mode: input.mode || input.conversation?.mode,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const validated = validateRequestContext(mappedContext);
|
|
270
|
+
if (!validated.accepted) {
|
|
271
|
+
return validated;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
accepted: true,
|
|
276
|
+
context: validated.context,
|
|
277
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
278
|
+
};
|
|
279
|
+
}
|
package/src/cors.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import cors from 'cors';
|
|
2
|
+
|
|
3
|
+
export function getAllowedOrigins(): Set<string> {
|
|
4
|
+
const allowed = new Set<string>([
|
|
5
|
+
'http://localhost:3000',
|
|
6
|
+
'http://127.0.0.1:3000',
|
|
7
|
+
'http://localhost:3001',
|
|
8
|
+
'http://127.0.0.1:3001',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
if (process.env.PORT) {
|
|
12
|
+
allowed.add(`http://localhost:${process.env.PORT}`);
|
|
13
|
+
allowed.add(`http://127.0.0.1:${process.env.PORT}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const envOrigins = process.env.ALLOWED_ORIGINS;
|
|
17
|
+
if (envOrigins) {
|
|
18
|
+
for (const origin of envOrigins.split(',')) {
|
|
19
|
+
const trimmed = origin.trim();
|
|
20
|
+
if (trimmed) {
|
|
21
|
+
allowed.add(trimmed);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return allowed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createCorsOptions(): cors.CorsOptions {
|
|
30
|
+
return {
|
|
31
|
+
origin: (origin, callback) => {
|
|
32
|
+
// Allow non-browser requests with no origin header (e.g., native tools, curl)
|
|
33
|
+
if (!origin) {
|
|
34
|
+
return callback(null, true);
|
|
35
|
+
}
|
|
36
|
+
const allowedOrigins = getAllowedOrigins();
|
|
37
|
+
if (allowedOrigins.has(origin)) {
|
|
38
|
+
return callback(null, true);
|
|
39
|
+
}
|
|
40
|
+
return callback(null, false);
|
|
41
|
+
},
|
|
42
|
+
credentials: true,
|
|
43
|
+
};
|
|
44
|
+
}
|