@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 ADDED
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ var __importDefault = (this && this.__importDefault) || function (mod) {
17
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
+ };
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.app = exports.createApp = void 0;
21
+ const promises_1 = require("node:fs/promises");
22
+ const node_path_1 = __importDefault(require("node:path"));
23
+ const app_1 = require("./app");
24
+ Object.defineProperty(exports, "createApp", { enumerable: true, get: function () { return app_1.createApp; } });
25
+ const runtime_1 = require("./runtime");
26
+ const brain_1 = require("@siduri-x/brain");
27
+ const memory_1 = require("@siduri-x/memory");
28
+ const voice_1 = require("@siduri-x/voice");
29
+ const knowledge_1 = require("@siduri-x/knowledge");
30
+ const vision_1 = require("@siduri-x/vision");
31
+ const behavior_1 = require("@siduri-x/behavior");
32
+ const body_1 = require("@siduri-x/body");
33
+ const observation_1 = require("@siduri-x/observation");
34
+ __exportStar(require("./context-mapper"), exports);
35
+ const runtimes = new Map();
36
+ const instance = (0, app_1.createApp)(runtimes);
37
+ exports.app = instance.app;
38
+ exports.default = exports.app;
39
+ function createBrain(config) {
40
+ const provider = config?.provider || 'openrouter';
41
+ const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
42
+ const apiKey = config?.apiKey || process.env[config?.apiKeyEnv || defaultKeyEnv] || '';
43
+ if (provider === 'openai-compatible') {
44
+ return new brain_1.OpenAICompatibleBrain({
45
+ apiKey,
46
+ model: config?.model || 'local-model',
47
+ baseUrl: config?.baseUrl || 'http://127.0.0.1:1234/v1',
48
+ });
49
+ }
50
+ return new brain_1.OpenRouterBrain({ apiKey, model: config?.model || 'gpt-4o-mini' });
51
+ }
52
+ function isDisabled(config) {
53
+ return !config || config.provider === 'none';
54
+ }
55
+ function createVoice(config) {
56
+ return isDisabled(config)
57
+ ? undefined
58
+ : new voice_1.VoiceAdapter({
59
+ provider: config?.provider || 'voicevox',
60
+ baseUrl: config?.baseUrl || process.env.VOICEVOX_URL || 'http://localhost:50021',
61
+ speakerId: config?.speakerId || 1,
62
+ ...config,
63
+ });
64
+ }
65
+ function createKnowledge(config) {
66
+ if (isDisabled(config))
67
+ return undefined;
68
+ if (!config?.packPath && !config?.registryUrl && !config?.baseUrl) {
69
+ return undefined;
70
+ }
71
+ return new knowledge_1.EKnowledgeAdapter(config || {});
72
+ }
73
+ function createVision(config) {
74
+ return isDisabled(config)
75
+ ? undefined
76
+ : new vision_1.OpenRouterVisionAdapter({
77
+ apiKey: config?.apiKey || process.env.OPENROUTER_API_KEY || '',
78
+ model: config?.model || 'gpt-4-vision',
79
+ ...config,
80
+ });
81
+ }
82
+ function createBehavior(config) {
83
+ return isDisabled(config) ? undefined : new behavior_1.ActiveSelfCompiler();
84
+ }
85
+ function createBody(config) {
86
+ return isDisabled(config)
87
+ ? undefined
88
+ : new body_1.Live2DAdapter(config);
89
+ }
90
+ const PORT = process.env.PORT || 3001;
91
+ const defaultCompanionConfig = {
92
+ id: 'default',
93
+ name: 'Siduri',
94
+ brain: { provider: 'openrouter', model: 'gpt-4o-mini' },
95
+ voice: { provider: 'voicevox', speakerId: 1 },
96
+ memory: { provider: 'postgres' },
97
+ knowledge: {
98
+ provider: process.env.SIDURI_KNOWLEDGE_PROVIDER || 'e-knowledge',
99
+ packPath: process.env.SIDURI_KNOWLEDGE_PACK || '',
100
+ registryUrl: process.env.SIDURI_KNOWLEDGE_REGISTRY_URL || '',
101
+ packId: process.env.SIDURI_KNOWLEDGE_PACK_ID || '',
102
+ timeoutMs: Number(process.env.SIDURI_KNOWLEDGE_TIMEOUT_MS || 5000),
103
+ preferredMode: process.env.SIDURI_KNOWLEDGE_MODE || 'lexical',
104
+ },
105
+ behavior: { provider: 'active_self' },
106
+ body: {
107
+ provider: 'live2d',
108
+ },
109
+ vision: { provider: 'openrouter', model: 'gpt-4-vision' }
110
+ };
111
+ async function loadCompanionConfig() {
112
+ const configPath = process.env.SIDURI_CONFIG || node_path_1.default.resolve(process.cwd(), 'siduri.config.json');
113
+ let fileConfig = {};
114
+ try {
115
+ fileConfig = JSON.parse(await (0, promises_1.readFile)(configPath, 'utf8'));
116
+ console.log(`Loaded companion configuration from ${configPath}`);
117
+ }
118
+ catch (error) {
119
+ if (error?.code !== 'ENOENT')
120
+ throw new Error(`Unable to read ${configPath}: ${error.message}`);
121
+ console.log(`No ${configPath} found; using environment/default configuration.`);
122
+ }
123
+ const config = {
124
+ ...defaultCompanionConfig,
125
+ ...fileConfig,
126
+ id: fileConfig.id || defaultCompanionConfig.id,
127
+ brain: { ...defaultCompanionConfig.brain, ...fileConfig.brain },
128
+ voice: { ...defaultCompanionConfig.voice, ...fileConfig.voice },
129
+ memory: { ...defaultCompanionConfig.memory, ...fileConfig.memory },
130
+ knowledge: { ...defaultCompanionConfig.knowledge, ...fileConfig.knowledge },
131
+ behavior: { ...defaultCompanionConfig.behavior, ...fileConfig.behavior },
132
+ body: { ...defaultCompanionConfig.body, ...fileConfig.body },
133
+ vision: { ...defaultCompanionConfig.vision, ...fileConfig.vision },
134
+ };
135
+ if (process.env.SIDURI_KNOWLEDGE_PROVIDER)
136
+ config.knowledge.provider = process.env.SIDURI_KNOWLEDGE_PROVIDER;
137
+ if (process.env.SIDURI_KNOWLEDGE_PACK)
138
+ config.knowledge.packPath = process.env.SIDURI_KNOWLEDGE_PACK;
139
+ if (process.env.SIDURI_KNOWLEDGE_REGISTRY_URL)
140
+ config.knowledge.registryUrl = process.env.SIDURI_KNOWLEDGE_REGISTRY_URL;
141
+ if (process.env.SIDURI_KNOWLEDGE_PACK_ID)
142
+ config.knowledge.packId = process.env.SIDURI_KNOWLEDGE_PACK_ID;
143
+ if (process.env.SIDURI_KNOWLEDGE_MODE)
144
+ config.knowledge.preferredMode = process.env.SIDURI_KNOWLEDGE_MODE;
145
+ return config;
146
+ }
147
+ async function bootDefaultCompanion() {
148
+ if (runtimes.has('default'))
149
+ return;
150
+ console.log("Booting default companion...");
151
+ const config = await loadCompanionConfig();
152
+ const brain = createBrain(config.brain);
153
+ const memory = new memory_1.PostgresMemoryOrgan({ connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/siduri' });
154
+ const voice = createVoice(config.voice);
155
+ const knowledge = createKnowledge(config.knowledge);
156
+ const vision = createVision(config.vision);
157
+ const observation = new observation_1.FixtureObservationOrgan(vision ?? { analyze: async () => JSON.stringify({ readings: [] }) });
158
+ instance.setObservationOrgan(observation);
159
+ const behavior = createBehavior(config.behavior);
160
+ const body = createBody(config.body);
161
+ await memory.runMigrations().catch(e => console.warn("Migrations warning:", e.message));
162
+ const runtime = new runtime_1.SiduriRuntime('default', config, { brain, memory, voice, knowledge, vision, behavior, body });
163
+ await runtime.initialize();
164
+ runtimes.set('default', runtime);
165
+ console.log("Default companion booted successfully.");
166
+ }
167
+ if (process.env.NODE_ENV !== 'test') {
168
+ bootDefaultCompanion().then(() => {
169
+ exports.app.listen(Number(PORT), '127.0.0.1', () => {
170
+ console.log(`Siduri-X API running on port ${PORT} (127.0.0.1)`);
171
+ });
172
+ }).catch(e => {
173
+ console.error("Failed to boot default companion:", e);
174
+ process.exit(1);
175
+ });
176
+ }
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "@siduri-x/api",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
4
7
  "main": "dist/index.js",
5
8
  "dependencies": {
6
- "@siduri-x/behavior": "1.0.5",
9
+ "@siduri-x/behavior": "1.0.6",
7
10
  "@siduri-x/body": "1.0.4",
8
- "@siduri-x/brain": "1.0.4",
9
- "@siduri-x/core": "1.0.5",
11
+ "@siduri-x/brain": "1.0.5",
12
+ "@siduri-x/core": "1.0.8",
10
13
  "@siduri-x/ear": "1.0.3",
11
14
  "@siduri-x/hands": "1.0.3",
12
15
  "@siduri-x/knowledge": "1.0.2",
13
- "@siduri-x/memory": "1.0.4",
14
- "@siduri-x/observation": "1.0.2",
16
+ "@siduri-x/memory": "1.0.6",
17
+ "@siduri-x/observation": "1.0.3",
15
18
  "@siduri-x/vision": "1.0.2",
16
19
  "@siduri-x/voice": "1.0.6",
17
20
  "@siduri-x/mouth": "1.0.0",
package/src/app.ts CHANGED
@@ -13,7 +13,7 @@ import { FixtureObservationOrgan } from '@siduri-x/observation';
13
13
  import { DefaultHandsOrgan, DefaultHandsOrganConfig } from '@siduri-x/hands';
14
14
  import { DefaultEarOrgan, EarOrganConfig } from '@siduri-x/ear';
15
15
  import { DefaultMouthOrgan, DefaultMouthOrganConfig } from '@siduri-x/mouth';
16
- import { attachIdentity, requireRole, Identity } from './auth';
16
+ import { attachIdentity, requireAuth, Identity } from './auth';
17
17
  import { mapRequestContext } from './context-mapper';
18
18
 
19
19
  export interface AppBrainConfig {
@@ -130,7 +130,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
130
130
  : new DefaultMouthOrgan({ ...config, voice });
131
131
  }
132
132
 
133
- app.post('/boot', requireRole(['OWNER']), async (req, res) => {
133
+ app.post('/boot', requireAuth, async (req, res) => {
134
134
  try {
135
135
  const { id, config } = req.body;
136
136
  if (runtimes.has(id)) {
@@ -204,39 +204,26 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
204
204
  app.get('/me', attachIdentity, (req, res) => {
205
205
  const identity = (req as any).identity as Identity;
206
206
  res.json({
207
- actorId: identity.role === 'OWNER' ? 'owner-user' : 'anonymous-session',
208
- role: identity.role,
209
- authenticated: identity.role === 'OWNER',
207
+ actorId: identity.actorId || (identity.authenticated ? 'owner-user' : 'anonymous-session'),
208
+ role: identity.role || (identity.authenticated ? 'OWNER' : 'VIEWER'),
209
+ authenticated: Boolean(identity.authenticated),
210
210
  });
211
211
  });
212
- app.put('/me', requireRole(['OWNER']), (req, res) => res.json({ success: true }));
212
+ app.put('/me', requireAuth, (req, res) => res.json({ success: true }));
213
213
 
214
214
  // CHAT (API context boundary validation)
215
215
  app.post('/chat', attachIdentity, async (req, res) => {
216
216
  const { id, message, history } = req.body;
217
217
  const identity = (req as any).identity as Identity;
218
218
 
219
- // Single-owner companion model: /chat defaults to OWNER identity.
220
- // Explicit non-owner role in request body or context (e.g. adversarial test suites) is respected.
221
- const effectiveRole = req.body?.role === 'VIEWER' || req.body?.context?.actor?.authorizationRole === 'viewer'
222
- ? 'VIEWER'
223
- : (identity?.role === 'OPERATOR' ? 'OPERATOR' : 'OWNER');
224
- const isOwner = effectiveRole === 'OWNER';
225
-
226
- // Call context mapper at the API boundary
227
- const mappingResult = mapRequestContext(
228
- {
229
- ...req.body,
230
- id: id || req.body.companionId,
231
- role: effectiveRole,
232
- authenticated: isOwner,
233
- generateCorrelationId: true,
234
- },
235
- {
236
- endpointPolicy: 'public',
237
- defaultPublicAudience: 'audience-public',
238
- }
239
- );
219
+ // Single-owner companion model: map request context directly
220
+ const mappingResult = mapRequestContext({
221
+ ...req.body,
222
+ id: id || req.body.companionId,
223
+ authenticated: identity?.authenticated ?? true,
224
+ source: identity?.source ?? 'local',
225
+ generateCorrelationId: true,
226
+ });
240
227
 
241
228
  if (!mappingResult.accepted) {
242
229
  return res.status(400).json({
@@ -269,24 +256,13 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
269
256
  const { id, message, history } = req.body;
270
257
  const identity = (req as any).identity as Identity;
271
258
 
272
- const effectiveRole = req.body?.role === 'VIEWER' || req.body?.context?.actor?.authorizationRole === 'viewer'
273
- ? 'VIEWER'
274
- : (identity?.role === 'OPERATOR' ? 'OPERATOR' : 'OWNER');
275
- const isOwner = effectiveRole === 'OWNER';
276
-
277
- const mappingResult = mapRequestContext(
278
- {
279
- ...req.body,
280
- id: id || req.body.companionId,
281
- role: effectiveRole,
282
- authenticated: isOwner,
283
- generateCorrelationId: true,
284
- },
285
- {
286
- endpointPolicy: 'public',
287
- defaultPublicAudience: 'audience-public',
288
- }
289
- );
259
+ const mappingResult = mapRequestContext({
260
+ ...req.body,
261
+ id: id || req.body.companionId,
262
+ authenticated: identity?.authenticated ?? true,
263
+ source: identity?.source ?? 'local',
264
+ generateCorrelationId: true,
265
+ });
290
266
 
291
267
  if (!mappingResult.accepted) {
292
268
  return res.status(400).json({
@@ -406,7 +382,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
406
382
  });
407
383
 
408
384
  // MEMORY GETTERS
409
- app.get('/memory/proposals', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
385
+ app.get('/memory/proposals', requireAuth, async (req, res) => {
410
386
  const id = req.query.id as string || Array.from(runtimes.keys())[0];
411
387
  const runtime = runtimes.get(id);
412
388
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -419,7 +395,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
419
395
  }
420
396
  });
421
397
 
422
- app.get('/memory', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
398
+ app.get('/memory', requireAuth, async (req, res) => {
423
399
  const id = req.query.id as string || Array.from(runtimes.keys())[0];
424
400
  const runtime = runtimes.get(id);
425
401
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -432,7 +408,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
432
408
  }
433
409
  });
434
410
 
435
- app.get('/memory/claims', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
411
+ app.get('/memory/claims', requireAuth, async (req, res) => {
436
412
  const id = req.query.id as string || Array.from(runtimes.keys())[0];
437
413
  const runtime = runtimes.get(id);
438
414
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -445,7 +421,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
445
421
  }
446
422
  });
447
423
 
448
- app.get('/memory/behavioral', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
424
+ app.get('/memory/behavioral', requireAuth, async (req, res) => {
449
425
  const id = req.query.id as string || Array.from(runtimes.keys())[0];
450
426
  const runtime = runtimes.get(id);
451
427
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -459,7 +435,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
459
435
  });
460
436
 
461
437
  // MEMORY MUTATIONS - PROPOSALS
462
- app.post('/memory/proposals/update', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
438
+ app.post('/memory/proposals/update', requireAuth, async (req, res) => {
463
439
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
464
440
  const runtime = runtimes.get(id);
465
441
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -478,7 +454,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
478
454
  }
479
455
  });
480
456
 
481
- app.post('/memory/proposals/approve', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
457
+ app.post('/memory/proposals/approve', requireAuth, async (req, res) => {
482
458
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
483
459
  const runtime = runtimes.get(id);
484
460
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -491,7 +467,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
491
467
  }
492
468
  });
493
469
 
494
- app.post('/memory/proposals/reject', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
470
+ app.post('/memory/proposals/reject', requireAuth, async (req, res) => {
495
471
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
496
472
  const runtime = runtimes.get(id);
497
473
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -505,7 +481,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
505
481
  });
506
482
 
507
483
  // MEMORY MUTATIONS - BEHAVIORAL
508
- app.post('/memory/behavioral/approve', requireRole(['OWNER']), async (req, res) => {
484
+ app.post('/memory/behavioral/approve', requireAuth, async (req, res) => {
509
485
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
510
486
  const runtime = runtimes.get(id);
511
487
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -518,7 +494,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
518
494
  }
519
495
  });
520
496
 
521
- app.post('/memory/behavioral/reject', requireRole(['OWNER']), async (req, res) => {
497
+ app.post('/memory/behavioral/reject', requireAuth, async (req, res) => {
522
498
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
523
499
  const runtime = runtimes.get(id);
524
500
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -531,7 +507,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
531
507
  }
532
508
  });
533
509
 
534
- app.post('/memory/behavioral/revoke', requireRole(['OWNER']), async (req, res) => {
510
+ app.post('/memory/behavioral/revoke', requireAuth, async (req, res) => {
535
511
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
536
512
  const runtime = runtimes.get(id);
537
513
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -544,7 +520,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
544
520
  }
545
521
  });
546
522
 
547
- app.post('/memory/behavioral/disable', requireRole(['OWNER']), async (req, res) => {
523
+ app.post('/memory/behavioral/disable', requireAuth, async (req, res) => {
548
524
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
549
525
  const runtime = runtimes.get(id);
550
526
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -559,7 +535,7 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
559
535
 
560
536
  const isDevMode = process.env.NODE_ENV !== 'production' || process.env.SIDURI_DEV_MODE === 'true';
561
537
  if (isDevMode) {
562
- app.post('/dev/memory/reset', requireRole(['OWNER']), async (req, res) => {
538
+ app.post('/dev/memory/reset', requireAuth, async (req, res) => {
563
539
  const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
564
540
  const runtime = runtimes.get(id);
565
541
  if (!runtime) return res.status(404).json({ error: "Companion not found" });
@@ -582,15 +558,12 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
582
558
  requestContext: {
583
559
  companionId,
584
560
  actor: {
585
- actorId: 'operator-a',
586
- sessionId: 'sess-op',
587
- authorizationRole: 'operator',
588
- capabilities: ['chat:public', 'memory:approve'],
561
+ actorId: 'local-user',
562
+ sessionId: 'sess-local',
563
+ capabilities: ['chat', 'memory:approve'],
589
564
  authenticated: true,
590
565
  },
591
566
  conversation: {
592
- channel: 'public',
593
- audienceId: 'audience-public',
594
567
  correlationId: req.body?.correlation_id || `corr-${Date.now()}`,
595
568
  },
596
569
  },
@@ -636,7 +609,6 @@ export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): App
636
609
  responseId,
637
610
  companionId,
638
611
  correlationId: correlationId || '',
639
- audienceId: req.body?.audienceId,
640
612
  });
641
613
 
642
614
  if (!result.success) {
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
- }