@siduri-x/api 1.0.2 → 1.0.4
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/dist/index.js +176 -0
- package/package.json +9 -6
- package/src/app.ts +36 -64
- package/src/auth.test.ts +56 -23
- package/src/auth.ts +74 -22
- package/src/context-mapper.test.ts +42 -147
- package/src/context-mapper.ts +44 -179
- package/src/index.test.ts +8 -32
- package/src/runtime.test.ts +3 -5
- package/src/t5-experience.test.ts +0 -1
- package/src/t6-security.test.ts +0 -1
|
@@ -1,116 +1,56 @@
|
|
|
1
1
|
import { mapRequestContext } from './context-mapper';
|
|
2
2
|
|
|
3
|
-
describe('API Request Context Mapper (
|
|
4
|
-
|
|
5
|
-
test('T1-01: Anonymous public chat with no audience resolves to configured public audience and no subject', () => {
|
|
3
|
+
describe('API Request Context Mapper (Single-Owner, Single-Machine)', () => {
|
|
4
|
+
test('Local chat request maps to valid RequestContext with local user defaults', () => {
|
|
6
5
|
const input = {
|
|
7
6
|
companionId: 'companion-a',
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
actorId: 'anonymous-session-a',
|
|
11
|
-
sessionId: 'session-a',
|
|
12
|
-
authorizationRole: 'viewer',
|
|
13
|
-
capabilities: ['chat:public'],
|
|
14
|
-
authenticated: false,
|
|
15
|
-
},
|
|
16
|
-
conversation: {
|
|
17
|
-
channel: 'public',
|
|
18
|
-
correlationId: 'corr-public-a',
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
message: 'Hello.',
|
|
22
|
-
history: [],
|
|
7
|
+
message: 'Hello world',
|
|
8
|
+
correlationId: 'corr-local-1',
|
|
23
9
|
};
|
|
24
10
|
|
|
25
|
-
const result = mapRequestContext(input
|
|
26
|
-
endpointPolicy: 'public',
|
|
27
|
-
defaultPublicAudience: 'audience-public',
|
|
28
|
-
});
|
|
11
|
+
const result = mapRequestContext(input);
|
|
29
12
|
|
|
30
13
|
expect(result.accepted).toBe(true);
|
|
31
14
|
expect(result.context?.companionId).toBe('companion-a');
|
|
32
|
-
expect(result.context?.
|
|
33
|
-
expect(result.context?.
|
|
34
|
-
expect(result.context?.
|
|
35
|
-
expect(result.
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// T1-02: OWNER public chat
|
|
39
|
-
test('T1-02: OWNER public chat maps authorization metadata only with no relationship or private routing', () => {
|
|
40
|
-
const input = {
|
|
41
|
-
id: 'companion-a',
|
|
42
|
-
role: 'OWNER',
|
|
43
|
-
correlationId: 'corr-owner-public',
|
|
44
|
-
message: 'Public broadcast message',
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const result = mapRequestContext(input, { endpointPolicy: 'public' });
|
|
48
|
-
expect(result.accepted).toBe(true);
|
|
49
|
-
expect(result.context?.actor.authorizationRole).toBe('administrator');
|
|
50
|
-
expect(result.context?.conversation.channel).toBe('public');
|
|
51
|
-
expect(result.context?.conversation.audienceId).toBe('audience-public');
|
|
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');
|
|
52
19
|
expect(result.context?.subject).toBeUndefined();
|
|
53
|
-
expect(result.diagnostics).toContain('legacy_role_mapped_to_authorization');
|
|
54
20
|
});
|
|
55
21
|
|
|
56
|
-
|
|
57
|
-
test('T1-03: Private chat without capability is rejected before organ invocation', () => {
|
|
22
|
+
test('Accepts structured context envelope', () => {
|
|
58
23
|
const input = {
|
|
59
24
|
companionId: 'companion-a',
|
|
60
25
|
context: {
|
|
61
26
|
actor: {
|
|
62
|
-
actorId: '
|
|
63
|
-
sessionId: 'session-
|
|
64
|
-
authorizationRole: 'viewer',
|
|
65
|
-
capabilities: ['chat:public'], // Missing 'chat:private'
|
|
27
|
+
actorId: 'user-main',
|
|
28
|
+
sessionId: 'session-main',
|
|
66
29
|
authenticated: true,
|
|
30
|
+
capabilities: ['chat', 'system'],
|
|
67
31
|
},
|
|
68
32
|
conversation: {
|
|
69
|
-
|
|
70
|
-
audienceId: 'audience-private-a',
|
|
71
|
-
correlationId: 'corr-private-a',
|
|
33
|
+
correlationId: 'corr-structured-1',
|
|
72
34
|
},
|
|
73
35
|
},
|
|
74
|
-
message: '
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const result = mapRequestContext(input, { endpointPolicy: 'private' });
|
|
78
|
-
expect(result.accepted).toBe(false);
|
|
79
|
-
expect(result.error?.code).toBe('UNAUTHORIZED_CHANNEL_OR_CAPABILITY');
|
|
80
|
-
expect(result.error?.fields).toContain('actor.capabilities');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// T1-04: Operator request without operator context
|
|
84
|
-
test('T1-04: Operator request without operator context is rejected with diagnostic', () => {
|
|
85
|
-
const input = {
|
|
86
|
-
id: 'companion-a',
|
|
87
|
-
role: 'OPERATOR',
|
|
88
|
-
correlationId: 'corr-op-missing',
|
|
36
|
+
message: 'Hello structured context',
|
|
89
37
|
};
|
|
90
38
|
|
|
91
|
-
const result = mapRequestContext(input
|
|
92
|
-
expect(result.accepted).toBe(
|
|
93
|
-
expect(result.
|
|
94
|
-
expect(result.
|
|
95
|
-
expect.arrayContaining(['conversation.channel', 'conversation.audienceId', 'actor.capabilities'])
|
|
96
|
-
);
|
|
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');
|
|
97
43
|
});
|
|
98
44
|
|
|
99
|
-
|
|
100
|
-
test('T1-05: Global primary_user input is rejected/quarantined at mapper', () => {
|
|
45
|
+
test('Rejects global primary_user subject', () => {
|
|
101
46
|
const input = {
|
|
102
47
|
companionId: 'companion-a',
|
|
103
48
|
context: {
|
|
104
49
|
actor: {
|
|
105
|
-
actorId: '
|
|
50
|
+
actorId: 'user-a',
|
|
106
51
|
sessionId: 'session-a',
|
|
107
|
-
authorizationRole: 'viewer',
|
|
108
|
-
capabilities: ['chat:public'],
|
|
109
|
-
authenticated: true,
|
|
110
52
|
},
|
|
111
53
|
conversation: {
|
|
112
|
-
channel: 'public',
|
|
113
|
-
audienceId: 'audience-public',
|
|
114
54
|
correlationId: 'corr-primary-user',
|
|
115
55
|
},
|
|
116
56
|
subject: {
|
|
@@ -126,47 +66,15 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
126
66
|
expect(result.error?.field).toBe('subject.subjectId');
|
|
127
67
|
});
|
|
128
68
|
|
|
129
|
-
|
|
130
|
-
test('T1-06: MASTER_PRIVATE in public mode is rejected with no fallback', () => {
|
|
131
|
-
const input = {
|
|
132
|
-
companionId: 'companion-a',
|
|
133
|
-
context: {
|
|
134
|
-
actor: {
|
|
135
|
-
actorId: 'actor-a',
|
|
136
|
-
sessionId: 'session-a',
|
|
137
|
-
authorizationRole: 'viewer',
|
|
138
|
-
capabilities: ['chat:public'],
|
|
139
|
-
authenticated: false,
|
|
140
|
-
},
|
|
141
|
-
conversation: {
|
|
142
|
-
channel: 'public',
|
|
143
|
-
audienceId: 'MASTER_PRIVATE',
|
|
144
|
-
correlationId: 'corr-master-private',
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const result = mapRequestContext(input, { endpointPolicy: 'public' });
|
|
150
|
-
expect(result.accepted).toBe(false);
|
|
151
|
-
expect(result.error?.code).toBe('LEGACY_PERSONAL_AUDIENCE');
|
|
152
|
-
expect(result.error?.field).toBe('audienceId');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// T1-07: Missing actor on explicit teaching
|
|
156
|
-
test('T1-07: Missing actor on explicit teaching produces no durable profile and pending/anonymous context only', () => {
|
|
69
|
+
test('Rejects missing actor on structured context', () => {
|
|
157
70
|
const input = {
|
|
158
71
|
companionId: 'companion-a',
|
|
159
72
|
context: {
|
|
160
73
|
actor: {
|
|
161
74
|
actorId: '',
|
|
162
75
|
sessionId: 'session-a',
|
|
163
|
-
authorizationRole: 'viewer',
|
|
164
|
-
capabilities: ['chat:public'],
|
|
165
|
-
authenticated: false,
|
|
166
76
|
},
|
|
167
77
|
conversation: {
|
|
168
|
-
channel: 'public',
|
|
169
|
-
audienceId: 'audience-public',
|
|
170
78
|
correlationId: 'corr-teach-no-actor',
|
|
171
79
|
},
|
|
172
80
|
},
|
|
@@ -178,18 +86,13 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
178
86
|
expect(result.error?.fields).toContain('actor.actorId');
|
|
179
87
|
});
|
|
180
88
|
|
|
181
|
-
|
|
182
|
-
test('T1-08: Two companion IDs with same actor remain isolated under context mapper', () => {
|
|
89
|
+
test('Two companion IDs with same actor remain isolated under context mapper', () => {
|
|
183
90
|
const actor = {
|
|
184
|
-
actorId: '
|
|
91
|
+
actorId: 'user-a',
|
|
185
92
|
sessionId: 'session-a',
|
|
186
|
-
authorizationRole: 'viewer' as const,
|
|
187
|
-
capabilities: ['chat:public'],
|
|
188
93
|
authenticated: true,
|
|
189
94
|
};
|
|
190
95
|
const conv = {
|
|
191
|
-
channel: 'public' as const,
|
|
192
|
-
audienceId: 'audience-public',
|
|
193
96
|
correlationId: 'corr-iso-1',
|
|
194
97
|
};
|
|
195
98
|
|
|
@@ -205,21 +108,27 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
205
108
|
expect(resB.context?.companionId).toBe('companion-b');
|
|
206
109
|
});
|
|
207
110
|
|
|
208
|
-
|
|
209
|
-
|
|
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', () => {
|
|
210
124
|
const input = {
|
|
211
125
|
companionId: 'companion-a',
|
|
212
126
|
context: {
|
|
213
127
|
actor: {
|
|
214
|
-
actorId: '
|
|
128
|
+
actorId: 'user-a',
|
|
215
129
|
sessionId: 'session-a',
|
|
216
|
-
authorizationRole: 'viewer',
|
|
217
|
-
capabilities: ['chat:public'],
|
|
218
|
-
authenticated: true,
|
|
219
130
|
},
|
|
220
131
|
conversation: {
|
|
221
|
-
channel: 'public',
|
|
222
|
-
audienceId: 'audience-public',
|
|
223
132
|
correlationId: '',
|
|
224
133
|
},
|
|
225
134
|
},
|
|
@@ -231,28 +140,14 @@ describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
|
|
|
231
140
|
expect(result.error?.fields).toContain('conversation.correlationId');
|
|
232
141
|
});
|
|
233
142
|
|
|
234
|
-
|
|
235
|
-
test('T1-10: Ambiguous legacy request emits structured diagnostic and rejects ambiguous mapping', () => {
|
|
143
|
+
test('Generates correlationId when generateCorrelationId option is true', () => {
|
|
236
144
|
const input = {
|
|
237
145
|
companionId: 'companion-a',
|
|
238
|
-
|
|
239
|
-
context: {
|
|
240
|
-
actor: {
|
|
241
|
-
actorId: 'actor-a',
|
|
242
|
-
sessionId: 'session-a',
|
|
243
|
-
authorizationRole: 'viewer',
|
|
244
|
-
capabilities: ['chat:public'],
|
|
245
|
-
authenticated: true,
|
|
246
|
-
},
|
|
247
|
-
// Role supplied at top level without channel or audience
|
|
248
|
-
},
|
|
146
|
+
generateCorrelationId: true,
|
|
249
147
|
};
|
|
250
148
|
|
|
251
149
|
const result = mapRequestContext(input);
|
|
252
|
-
expect(result.accepted).toBe(
|
|
253
|
-
expect(result.
|
|
254
|
-
expect(result.error?.conflicts).toEqual(
|
|
255
|
-
expect.arrayContaining(['role_does_not_select_audience', 'role_does_not_select_subject'])
|
|
256
|
-
);
|
|
150
|
+
expect(result.accepted).toBe(true);
|
|
151
|
+
expect(result.context?.conversation.correlationId).toMatch(/^corr-/);
|
|
257
152
|
});
|
|
258
153
|
});
|
package/src/context-mapper.ts
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RequestContext,
|
|
3
|
-
AuthorizationRole,
|
|
4
|
-
Channel,
|
|
5
3
|
DiagnosticCode,
|
|
6
4
|
ContextError,
|
|
7
5
|
validateRequestContext,
|
|
8
6
|
} from '@siduri-x/core';
|
|
9
7
|
|
|
10
8
|
export interface ContextMapperOptions {
|
|
11
|
-
endpointPolicy?: 'public' | 'private' | 'operator' | 'direct';
|
|
12
|
-
defaultPublicAudience?: string;
|
|
13
|
-
defaultPrivateAudience?: string;
|
|
14
|
-
defaultOperatorAudience?: string;
|
|
9
|
+
endpointPolicy?: 'public' | 'private' | 'operator' | 'direct' | string;
|
|
15
10
|
allowAnonymousPublicChat?: boolean;
|
|
16
11
|
}
|
|
17
12
|
|
|
@@ -22,13 +17,17 @@ export interface MapRequestContextResult {
|
|
|
22
17
|
error?: ContextError;
|
|
23
18
|
}
|
|
24
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 boundary, not internally between roles.
|
|
24
|
+
* - No internal audience or viewer/operator/owner role hierarchies.
|
|
25
|
+
*/
|
|
25
26
|
export function mapRequestContext(
|
|
26
27
|
input: any,
|
|
27
28
|
options: ContextMapperOptions = {}
|
|
28
29
|
): MapRequestContextResult {
|
|
29
30
|
const diagnostics: DiagnosticCode[] = [];
|
|
30
|
-
const endpointPolicy = options.endpointPolicy || 'public';
|
|
31
|
-
const defaultPublicAudience = options.defaultPublicAudience || 'audience-public';
|
|
32
31
|
|
|
33
32
|
if (!input || typeof input !== 'object') {
|
|
34
33
|
return {
|
|
@@ -40,27 +39,7 @@ export function mapRequestContext(
|
|
|
40
39
|
};
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
//
|
|
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
|
|
42
|
+
// 1. If incoming input already has a context structure
|
|
64
43
|
if (input.context && typeof input.context === 'object') {
|
|
65
44
|
const rawCtx = input.context;
|
|
66
45
|
const companionId = input.companionId || rawCtx.companionId || input.id;
|
|
@@ -77,48 +56,6 @@ export function mapRequestContext(
|
|
|
77
56
|
};
|
|
78
57
|
}
|
|
79
58
|
|
|
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
59
|
const actor = rawCtx.actor;
|
|
123
60
|
if (!actor || typeof actor !== 'object') {
|
|
124
61
|
return {
|
|
@@ -131,64 +68,37 @@ export function mapRequestContext(
|
|
|
131
68
|
};
|
|
132
69
|
}
|
|
133
70
|
|
|
134
|
-
//
|
|
135
|
-
|
|
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')) {
|
|
71
|
+
// Reject invalid primary_user subject
|
|
72
|
+
if (rawCtx.subject && (rawCtx.subject.subjectId === 'primary_user' || rawCtx.subject === 'primary_user')) {
|
|
148
73
|
return {
|
|
149
74
|
accepted: false,
|
|
150
75
|
error: {
|
|
151
|
-
code: '
|
|
152
|
-
message: '
|
|
153
|
-
|
|
76
|
+
code: 'FORBIDDEN_CONTEXT',
|
|
77
|
+
message: 'Global primary_user subject is forbidden',
|
|
78
|
+
field: 'subject.subjectId',
|
|
154
79
|
correlationId,
|
|
155
80
|
},
|
|
156
81
|
};
|
|
157
82
|
}
|
|
158
83
|
|
|
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
84
|
const constructed: RequestContext = {
|
|
177
85
|
companionId,
|
|
178
86
|
actor: {
|
|
179
87
|
actorId: actor.actorId,
|
|
180
88
|
sessionId: actor.sessionId,
|
|
89
|
+
capabilities: Array.isArray(actor.capabilities) ? actor.capabilities : ['chat'],
|
|
90
|
+
authenticated: actor.authenticated !== undefined ? Boolean(actor.authenticated) : true,
|
|
181
91
|
authorizationRole: actor.authorizationRole,
|
|
182
|
-
|
|
183
|
-
authenticated: Boolean(actor.authenticated),
|
|
92
|
+
...actor,
|
|
184
93
|
},
|
|
185
94
|
conversation: {
|
|
186
|
-
channel,
|
|
187
|
-
audienceId,
|
|
188
|
-
isLive: rawCtx.conversation?.isLive,
|
|
189
95
|
correlationId,
|
|
96
|
+
channel: rawCtx.conversation?.channel || input.channel || 'direct',
|
|
97
|
+
isLive: rawCtx.conversation?.isLive,
|
|
98
|
+
...rawCtx.conversation,
|
|
190
99
|
},
|
|
191
|
-
|
|
100
|
+
source: input.source || rawCtx.source || 'local',
|
|
101
|
+
subject: rawCtx.subject,
|
|
192
102
|
};
|
|
193
103
|
|
|
194
104
|
const validated = validateRequestContext(constructed);
|
|
@@ -203,9 +113,9 @@ export function mapRequestContext(
|
|
|
203
113
|
};
|
|
204
114
|
}
|
|
205
115
|
|
|
206
|
-
// 2.
|
|
116
|
+
// 2. Synthesize clean RequestContext from request envelope
|
|
207
117
|
const companionId = input.companionId || input.id;
|
|
208
|
-
const correlationId = input.correlationId || input.conversation?.correlationId;
|
|
118
|
+
const correlationId = input.correlationId || input.conversation?.correlationId || (input.generateCorrelationId ? `corr-${Date.now()}` : undefined);
|
|
209
119
|
|
|
210
120
|
if (!companionId) {
|
|
211
121
|
return {
|
|
@@ -222,49 +132,16 @@ export function mapRequestContext(
|
|
|
222
132
|
diagnostics.push('companion_default_mapped_for_bootstrap');
|
|
223
133
|
}
|
|
224
134
|
|
|
225
|
-
|
|
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
|
-
|
|
135
|
+
if (!correlationId) {
|
|
257
136
|
return {
|
|
258
137
|
accepted: false,
|
|
259
138
|
error: {
|
|
260
139
|
code: 'MISSING_CONTEXT',
|
|
261
|
-
fields:
|
|
262
|
-
correlationId,
|
|
140
|
+
fields: ['conversation.correlationId'],
|
|
263
141
|
},
|
|
264
142
|
};
|
|
265
143
|
}
|
|
266
144
|
|
|
267
|
-
// Ambiguity check: if legacy input specifies role without correlationId or endpoint context for stateful op
|
|
268
145
|
if (input.subject === 'primary_user' || input.subjectId === 'primary_user') {
|
|
269
146
|
return {
|
|
270
147
|
accepted: false,
|
|
@@ -277,55 +154,43 @@ export function mapRequestContext(
|
|
|
277
154
|
};
|
|
278
155
|
}
|
|
279
156
|
|
|
280
|
-
|
|
281
|
-
const
|
|
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';
|
|
157
|
+
const actorId = input.actorId || input.actor?.actorId || 'local-user';
|
|
158
|
+
const sessionId = input.sessionId || input.actor?.sessionId || `session-${Date.now()}`;
|
|
295
159
|
if (!input.actorId && !input.actor?.actorId) {
|
|
296
160
|
diagnostics.push('anonymous_session_generated');
|
|
297
161
|
}
|
|
298
162
|
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
? ['chat:public', 'admin:access']
|
|
305
|
-
: authRole === 'operator'
|
|
306
|
-
? ['chat:public', 'operator:access']
|
|
307
|
-
: ['chat:public'];
|
|
163
|
+
const capabilities = Array.isArray(input.capabilities)
|
|
164
|
+
? input.capabilities
|
|
165
|
+
: Array.isArray(input.actor?.capabilities)
|
|
166
|
+
? input.actor.capabilities
|
|
167
|
+
: ['chat', 'system'];
|
|
308
168
|
|
|
309
169
|
const mappedContext: RequestContext = {
|
|
310
170
|
companionId,
|
|
311
171
|
actor: {
|
|
312
172
|
actorId,
|
|
313
173
|
sessionId,
|
|
314
|
-
authorizationRole: authRole,
|
|
315
174
|
capabilities,
|
|
316
|
-
authenticated: Boolean(input.authenticated),
|
|
175
|
+
authenticated: input.authenticated !== undefined ? Boolean(input.authenticated) : true,
|
|
176
|
+
authorizationRole: input.role ? (input.role.toLowerCase() === 'viewer' ? 'viewer' : 'administrator') : undefined,
|
|
317
177
|
},
|
|
318
178
|
conversation: {
|
|
319
|
-
channel,
|
|
320
|
-
|
|
321
|
-
correlationId: finalCorrelationId,
|
|
179
|
+
channel: input.channel || input.conversation?.channel || 'direct',
|
|
180
|
+
correlationId,
|
|
322
181
|
},
|
|
323
|
-
|
|
182
|
+
source: input.source || 'local',
|
|
183
|
+
subject: input.subject,
|
|
324
184
|
};
|
|
325
185
|
|
|
186
|
+
const validated = validateRequestContext(mappedContext);
|
|
187
|
+
if (!validated.accepted) {
|
|
188
|
+
return validated;
|
|
189
|
+
}
|
|
190
|
+
|
|
326
191
|
return {
|
|
327
192
|
accepted: true,
|
|
328
|
-
context:
|
|
193
|
+
context: validated.context,
|
|
329
194
|
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
330
195
|
};
|
|
331
196
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -31,7 +31,10 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
31
31
|
expect(res.status).toBe(200);
|
|
32
32
|
expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
|
|
33
33
|
'Hello neutral world',
|
|
34
|
-
|
|
34
|
+
expect.objectContaining({
|
|
35
|
+
companionId: 'companion-a',
|
|
36
|
+
conversation: expect.objectContaining({ channel: 'direct' }),
|
|
37
|
+
}),
|
|
35
38
|
[]
|
|
36
39
|
);
|
|
37
40
|
});
|
|
@@ -51,7 +54,6 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
51
54
|
},
|
|
52
55
|
conversation: {
|
|
53
56
|
channel: 'public',
|
|
54
|
-
audienceId: 'audience-public',
|
|
55
57
|
correlationId: 'corr-route-1',
|
|
56
58
|
},
|
|
57
59
|
},
|
|
@@ -62,39 +64,14 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
62
64
|
expect(res.status).toBe(200);
|
|
63
65
|
expect(fakeRuntime.handleUserMessage).toHaveBeenCalledWith(
|
|
64
66
|
'Hello structured context',
|
|
65
|
-
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
companionId: 'companion-a',
|
|
69
|
+
conversation: expect.objectContaining({ channel: 'public' }),
|
|
70
|
+
}),
|
|
66
71
|
[]
|
|
67
72
|
);
|
|
68
73
|
});
|
|
69
74
|
|
|
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
75
|
test('rejects global primary_user subject with 400 and structured error', async () => {
|
|
99
76
|
const res = await request(app)
|
|
100
77
|
.post('/chat')
|
|
@@ -110,7 +87,6 @@ describe('API Boundary Context Validation (P2 Route Integration)', () => {
|
|
|
110
87
|
},
|
|
111
88
|
conversation: {
|
|
112
89
|
channel: 'public',
|
|
113
|
-
audienceId: 'audience-public',
|
|
114
90
|
correlationId: 'corr-err-2',
|
|
115
91
|
},
|
|
116
92
|
subject: {
|