@siduri-x/api 1.0.1 → 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.
Files changed (43) hide show
  1. package/dist/index.js +18 -9
  2. package/package.json +16 -12
  3. package/src/app.ts +294 -95
  4. package/src/auth.test.ts +56 -23
  5. package/src/auth.ts +74 -22
  6. package/src/context-mapper.test.ts +42 -147
  7. package/src/context-mapper.ts +44 -179
  8. package/src/index.test.ts +61 -32
  9. package/src/index.ts +30 -21
  10. package/src/runtime.test.ts +57 -5
  11. package/src/t5-experience.test.ts +0 -1
  12. package/src/t6-security.test.ts +0 -1
  13. package/dist/app.d.ts +0 -9
  14. package/dist/app.js +0 -480
  15. package/dist/auth.d.ts +0 -8
  16. package/dist/auth.js +0 -40
  17. package/dist/auth.test.d.ts +0 -1
  18. package/dist/auth.test.js +0 -48
  19. package/dist/b0-b6.test.d.ts +0 -1
  20. package/dist/b0-b6.test.js +0 -121
  21. package/dist/context-mapper.d.ts +0 -15
  22. package/dist/context-mapper.js +0 -287
  23. package/dist/context-mapper.test.d.ts +0 -1
  24. package/dist/context-mapper.test.js +0 -233
  25. package/dist/cors.d.ts +0 -3
  26. package/dist/cors.js +0 -42
  27. package/dist/index.d.ts +0 -6
  28. package/dist/index.test.d.ts +0 -1
  29. package/dist/index.test.js +0 -115
  30. package/dist/runtime.d.ts +0 -1
  31. package/dist/runtime.js +0 -17
  32. package/dist/runtime.test.d.ts +0 -1
  33. package/dist/runtime.test.js +0 -240
  34. package/dist/smoke.test.d.ts +0 -0
  35. package/dist/smoke.test.js +0 -6
  36. package/dist/t4-gating.test.d.ts +0 -1
  37. package/dist/t4-gating.test.js +0 -193
  38. package/dist/t5-experience.test.d.ts +0 -1
  39. package/dist/t5-experience.test.js +0 -156
  40. package/dist/t6-security.test.d.ts +0 -1
  41. package/dist/t6-security.test.js +0 -424
  42. package/dist/t7-release.test.d.ts +0 -1
  43. package/dist/t7-release.test.js +0 -119
package/src/auth.test.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { resolveIdentity } from './auth';
1
+ import { resolveIdentity, requireAuth } from './auth';
2
2
 
3
- describe('Auth Identity Resolution', () => {
3
+ describe('Auth Identity Resolution (Single-Owner External Boundary)', () => {
4
4
  const originalEnv = process.env;
5
5
 
6
6
  beforeEach(() => {
@@ -12,46 +12,79 @@ describe('Auth Identity Resolution', () => {
12
12
  process.env = originalEnv;
13
13
  });
14
14
 
15
- const mockReq = (token?: string) => ({
15
+ const mockReq = (token?: string, ip: string = '192.168.1.50') => ({
16
16
  headers: {
17
- authorization: token ? `Bearer ${token}` : undefined
18
- }
17
+ authorization: token ? `Bearer ${token}` : undefined,
18
+ },
19
+ ip,
20
+ socket: { remoteAddress: ip },
19
21
  } as any);
20
22
 
21
- test('resolves explicit owner token', () => {
22
- process.env.OWNER_TOKEN = 'owner-secret';
23
- process.env.OPERATOR_TOKEN = 'operator-secret';
23
+ test('authenticates external request when token matches configured AUTH_TOKEN', () => {
24
+ process.env.AUTH_TOKEN = 'secret-auth-key';
24
25
  process.env.NODE_ENV = 'production';
25
26
 
26
- const ownerId = resolveIdentity(mockReq('owner-secret'));
27
- expect(ownerId.role).toBe('OWNER');
27
+ const identity = resolveIdentity(mockReq('secret-auth-key'));
28
+ expect(identity.authenticated).toBe(true);
29
+ expect(identity.source).toBe('external');
28
30
  });
29
31
 
30
- test('resolves explicit operator token', () => {
32
+ test('authenticates external request when token matches legacy OWNER_TOKEN', () => {
31
33
  process.env.OWNER_TOKEN = 'owner-secret';
32
- process.env.OPERATOR_TOKEN = 'operator-secret';
33
34
  process.env.NODE_ENV = 'production';
34
35
 
35
- const opId = resolveIdentity(mockReq('operator-secret'));
36
- expect(opId.role).toBe('OPERATOR');
36
+ const identity = resolveIdentity(mockReq('owner-secret'));
37
+ expect(identity.authenticated).toBe(true);
38
+ expect(identity.source).toBe('external');
37
39
  });
38
40
 
39
- test('defaults to viewer when no token is present in production', () => {
41
+ test('rejects external request when token is missing in production with configured token', () => {
42
+ process.env.AUTH_TOKEN = 'secret-auth-key';
40
43
  process.env.NODE_ENV = 'production';
41
- const viewerId = resolveIdentity(mockReq());
42
- expect(viewerId.role).toBe('VIEWER');
44
+
45
+ const identity = resolveIdentity(mockReq());
46
+ expect(identity.authenticated).toBe(false);
43
47
  });
44
48
 
45
- test('defaults to viewer for invalid token in production', () => {
49
+ test('rejects external request for invalid token in production', () => {
50
+ process.env.AUTH_TOKEN = 'secret-auth-key';
46
51
  process.env.NODE_ENV = 'production';
47
- const invalidId = resolveIdentity(mockReq('invalid-token'));
48
- expect(invalidId.role).toBe('VIEWER');
52
+
53
+ const identity = resolveIdentity(mockReq('invalid-token'));
54
+ expect(identity.authenticated).toBe(false);
49
55
  });
50
56
 
51
- test('resolves development fallback role', () => {
57
+ test('authenticates local loopback request in development mode', () => {
52
58
  process.env.NODE_ENV = 'development';
53
59
  process.env.DEV_LOCAL_AUTH_ROLE = 'OWNER';
54
- const devId = resolveIdentity(mockReq());
55
- expect(devId.role).toBe('OWNER');
60
+ process.env.AUTH_TOKEN = 'secret-key';
61
+
62
+ const devIdentity = resolveIdentity(mockReq(undefined, '127.0.0.1'));
63
+ expect(devIdentity.authenticated).toBe(true);
64
+ expect(devIdentity.source).toBe('local');
65
+ });
66
+
67
+ test('requireAuth middleware accepts authenticated request and rejects unauthenticated with 401', () => {
68
+ process.env.AUTH_TOKEN = 'secret-key';
69
+ process.env.NODE_ENV = 'production';
70
+
71
+ const nextFn = jest.fn();
72
+ const resUnauthorized = {
73
+ status: jest.fn().mockReturnThis(),
74
+ json: jest.fn().mockReturnThis(),
75
+ } as any;
76
+
77
+ // Unauthorized call
78
+ requireAuth(mockReq('wrong-token'), resUnauthorized, nextFn);
79
+ expect(resUnauthorized.status).toHaveBeenCalledWith(401);
80
+ expect(nextFn).not.toHaveBeenCalled();
81
+
82
+ // Authorized call
83
+ const resAuthorized = {
84
+ status: jest.fn().mockReturnThis(),
85
+ json: jest.fn().mockReturnThis(),
86
+ } as any;
87
+ requireAuth(mockReq('secret-key'), resAuthorized, nextFn);
88
+ expect(nextFn).toHaveBeenCalled();
56
89
  });
57
90
  });
package/src/auth.ts CHANGED
@@ -1,49 +1,101 @@
1
1
  import { Request, Response, NextFunction } from 'express';
2
2
 
3
- export type Role = 'OWNER' | 'OPERATOR' | 'VIEWER';
3
+ export type Role = 'OWNER' | 'OPERATOR' | 'VIEWER' | 'user' | string;
4
4
 
5
5
  export interface Identity {
6
- role: Role;
6
+ authenticated: boolean;
7
+ source: 'local' | 'external';
8
+ actorId?: string;
9
+ role?: Role;
10
+ [key: string]: unknown;
7
11
  }
8
12
 
13
+ /**
14
+ * Checks whether an incoming request originates from the local machine loopback interface.
15
+ */
16
+ export function isLocalRequest(req: Request): boolean {
17
+ const ip = req.ip || req.socket?.remoteAddress || '';
18
+ return (
19
+ ip === '127.0.0.1' ||
20
+ ip === '::1' ||
21
+ ip === '::ffff:127.0.0.1' ||
22
+ ip.endsWith('127.0.0.1') ||
23
+ ip === 'localhost'
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Resolves request identity according to the single-owner external machine boundary security model.
29
+ *
30
+ * - Security is enforced at the external machine boundary, NOT internally.
31
+ * - External network requests require a matching token (AUTH_TOKEN / OWNER_TOKEN).
32
+ * - There are no internal viewer/operator/owner role privileges inside the machine.
33
+ */
9
34
  export function resolveIdentity(req: Request): Identity {
10
- const authHeader = req.headers.authorization;
35
+ const authHeader = req.headers?.authorization;
11
36
  const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : undefined;
37
+ const ownerToken = process.env.AUTH_TOKEN || process.env.API_TOKEN || process.env.OWNER_TOKEN;
38
+ const operatorToken = process.env.OPERATOR_TOKEN;
39
+ const configuredToken = ownerToken || operatorToken;
40
+
41
+ const local = isLocalRequest(req);
12
42
 
13
43
  // 1. Explicit token matches
14
- if (process.env.OWNER_TOKEN && token === process.env.OWNER_TOKEN) {
15
- return { role: 'OWNER' };
44
+ if (ownerToken && token === ownerToken) {
45
+ return { authenticated: true, source: local ? 'local' : 'external', role: 'OWNER' };
16
46
  }
17
- if (process.env.OPERATOR_TOKEN && token === process.env.OPERATOR_TOKEN) {
18
- return { role: 'OPERATOR' };
47
+ if (operatorToken && token === operatorToken) {
48
+ return { authenticated: true, source: local ? 'local' : 'external', role: 'OPERATOR' };
19
49
  }
20
50
 
21
- // 2. Development fallback
51
+ // 2. Dev local auth role fallback
22
52
  const isDev = process.env.NODE_ENV !== 'production';
23
53
  if (isDev && process.env.DEV_LOCAL_AUTH_ROLE) {
24
54
  const fallbackRole = process.env.DEV_LOCAL_AUTH_ROLE.toUpperCase() as Role;
25
- if (['OWNER', 'OPERATOR', 'VIEWER'].includes(fallbackRole)) {
26
- return { role: fallbackRole };
27
- }
55
+ return {
56
+ authenticated: fallbackRole === 'OWNER' || fallbackRole === 'OPERATOR',
57
+ source: 'local',
58
+ role: fallbackRole,
59
+ };
60
+ }
61
+
62
+ // 3. Default unauthenticated / visitor
63
+ return { authenticated: false, source: local ? 'local' : 'external', role: 'VIEWER' };
64
+ }
65
+
66
+ /**
67
+ * External boundary authentication middleware.
68
+ * Verifies that requests crossing the external boundary are authorized.
69
+ */
70
+ export function requireAuth(req: Request, res: Response, next: NextFunction) {
71
+ const identity = resolveIdentity(req);
72
+ (req as any).identity = identity;
73
+
74
+ if (!identity.authenticated) {
75
+ return res.status(401).json({ error: 'Unauthorized: missing or invalid authentication token' });
28
76
  }
77
+ next();
78
+ }
29
79
 
30
- // Default
31
- return { role: 'VIEWER' };
80
+ /**
81
+ * Attaches the resolved machine identity to the request.
82
+ */
83
+ export function attachIdentity(req: Request, res: Response, next: NextFunction) {
84
+ (req as any).identity = resolveIdentity(req);
85
+ next();
32
86
  }
33
87
 
34
- export function requireRole(allowedRoles: Role[]) {
88
+ /**
89
+ * Compatibility alias: in single-owner model, all authenticated callers have full access.
90
+ */
91
+ export function requireRole(allowedRoles?: Role[]) {
35
92
  return (req: Request, res: Response, next: NextFunction) => {
36
93
  const identity = resolveIdentity(req);
37
94
  (req as any).identity = identity;
38
-
39
- if (!allowedRoles.includes(identity.role)) {
40
- return res.status(403).json({ error: `Forbidden: requires one of ${allowedRoles.join(', ')}` });
95
+
96
+ if (!identity.authenticated) {
97
+ return res.status(403).json({ error: `Forbidden: requires one of ${(allowedRoles || []).join(', ')}` });
41
98
  }
42
99
  next();
43
100
  };
44
101
  }
45
-
46
- export function attachIdentity(req: Request, res: Response, next: NextFunction) {
47
- (req as any).identity = resolveIdentity(req);
48
- next();
49
- }
@@ -1,116 +1,56 @@
1
1
  import { mapRequestContext } from './context-mapper';
2
2
 
3
- describe('API Request Context Mapper (P2 & T1-01 to T1-10)', () => {
4
- // T1-01: Anonymous public chat, no audience
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
- context: {
9
- actor: {
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?.conversation.channel).toBe('public');
33
- expect(result.context?.conversation.audienceId).toBe('audience-public');
34
- expect(result.context?.subject).toBeUndefined();
35
- expect(result.diagnostics).toContain('audience_defaulted_by_public_policy');
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
- // T1-03: Private chat without capability
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: 'actor-a',
63
- sessionId: 'session-a',
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
- channel: 'private',
70
- audienceId: 'audience-private-a',
71
- correlationId: 'corr-private-a',
33
+ correlationId: 'corr-structured-1',
72
34
  },
73
35
  },
74
- message: 'Show private data',
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, { endpointPolicy: 'operator' });
92
- expect(result.accepted).toBe(false);
93
- expect(result.error?.code).toBe('MISSING_CONTEXT');
94
- expect(result.error?.fields).toEqual(
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
- // T1-05: Global primary_user input
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: 'actor-a',
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
- // T1-06: MASTER_PRIVATE in public mode
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
- // T1-08: Two companion IDs, same actor remain isolated
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: 'actor-a',
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
- // T1-09: Missing correlation ID
209
- test('T1-09: Missing correlation ID is rejected before stateful operation', () => {
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: 'actor-a',
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
- // T1-10: Ambiguous legacy request
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
- role: 'VIEWER',
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(false);
253
- expect(result.error?.code).toBe('AMBIGUOUS_CONTEXT');
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
  });