@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,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
+ }