@siduri-x/api 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/dist/app.d.ts +9 -0
- package/dist/app.js +436 -0
- package/dist/auth.d.ts +8 -0
- package/dist/auth.js +40 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +48 -0
- package/dist/b0-b6.test.d.ts +1 -0
- package/dist/b0-b6.test.js +121 -0
- package/dist/context-mapper.d.ts +15 -0
- package/dist/context-mapper.js +287 -0
- package/dist/context-mapper.test.d.ts +1 -0
- package/dist/context-mapper.test.js +233 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +167 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +115 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +17 -0
- package/dist/runtime.test.d.ts +1 -0
- package/dist/runtime.test.js +240 -0
- package/dist/smoke.test.d.ts +0 -0
- package/dist/smoke.test.js +6 -0
- package/dist/t4-gating.test.d.ts +1 -0
- package/dist/t4-gating.test.js +193 -0
- package/dist/t5-experience.test.d.ts +1 -0
- package/dist/t5-experience.test.js +156 -0
- package/dist/t6-security.test.d.ts +1 -0
- package/dist/t6-security.test.js +234 -0
- package/dist/t7-release.test.d.ts +1 -0
- package/dist/t7-release.test.js +119 -0
- package/jest.config.json +5 -0
- package/package.json +37 -0
- package/src/app.ts +459 -0
- package/src/auth.test.ts +57 -0
- package/src/auth.ts +49 -0
- package/src/b0-b6.test.ts +137 -0
- package/src/context-mapper.test.ts +258 -0
- package/src/context-mapper.ts +331 -0
- package/src/index.test.ts +129 -0
- package/src/index.ts +161 -0
- package/src/runtime.test.ts +284 -0
- package/src/runtime.ts +1 -0
- package/src/smoke.test.ts +5 -0
- package/src/t4-gating.test.ts +219 -0
- package/src/t5-experience.test.ts +175 -0
- package/src/t6-security.test.ts +257 -0
- package/src/t7-release.test.ts +131 -0
- package/tsconfig.json +16 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import express, { Express } from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import { SiduriRuntime } from './runtime';
|
|
4
|
+
import { OpenAICompatibleBrain, OpenRouterBrain } from '@siduri-x/brain';
|
|
5
|
+
import { PostgresMemoryOrgan } from '@siduri-x/memory';
|
|
6
|
+
import { VoicevoxAdapter } from '@siduri-x/voice';
|
|
7
|
+
import { EKnowledgeAdapter } from '@siduri-x/knowledge';
|
|
8
|
+
import { OpenRouterVisionAdapter } from '@siduri-x/vision';
|
|
9
|
+
import { ActiveSelfCompiler } from '@siduri-x/behavior';
|
|
10
|
+
import { Live2DAdapter } from '@siduri-x/body';
|
|
11
|
+
import { FixtureObservationOrgan } from '@siduri-x/observation';
|
|
12
|
+
import { DefaultHandsOrgan } from '@siduri-x/hands';
|
|
13
|
+
import { DefaultEarOrgan } from '@siduri-x/ear';
|
|
14
|
+
import { attachIdentity, requireRole, Identity } from './auth';
|
|
15
|
+
import { mapRequestContext } from './context-mapper';
|
|
16
|
+
|
|
17
|
+
export interface AppInstance {
|
|
18
|
+
app: Express;
|
|
19
|
+
runtimes: Map<string, SiduriRuntime>;
|
|
20
|
+
setObservationOrgan: (org: FixtureObservationOrgan) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createApp(runtimes: Map<string, SiduriRuntime> = new Map()): AppInstance {
|
|
24
|
+
const app: Express = express();
|
|
25
|
+
app.use(cors());
|
|
26
|
+
app.use(express.json());
|
|
27
|
+
|
|
28
|
+
let observationOrgan: FixtureObservationOrgan | undefined;
|
|
29
|
+
|
|
30
|
+
function createBrain(config: any) {
|
|
31
|
+
const provider = config.provider || 'openrouter';
|
|
32
|
+
const defaultKeyEnv = provider === 'openai-compatible' ? 'OPENAI_COMPATIBLE_API_KEY' : 'OPENROUTER_API_KEY';
|
|
33
|
+
const apiKey = config.apiKey || process.env[config.apiKeyEnv || defaultKeyEnv] || '';
|
|
34
|
+
if (provider === 'openai-compatible') {
|
|
35
|
+
return new OpenAICompatibleBrain({
|
|
36
|
+
apiKey,
|
|
37
|
+
model: config.model || 'local-model',
|
|
38
|
+
baseUrl: config.baseUrl || 'http://127.0.0.1:1234/v1',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return new OpenRouterBrain({ apiKey, model: config.model || 'gpt-4o-mini' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isDisabled(config: any): boolean {
|
|
45
|
+
return !config || config.provider === 'none';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createVoice(config: any) {
|
|
49
|
+
return isDisabled(config)
|
|
50
|
+
? undefined
|
|
51
|
+
: new VoicevoxAdapter({ baseUrl: process.env.VOICEVOX_URL || 'http://localhost:50021', speakerId: config.speakerId || 1 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createKnowledge(config: any) {
|
|
55
|
+
return isDisabled(config) ? undefined : new EKnowledgeAdapter(config);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createVision(config: any) {
|
|
59
|
+
return isDisabled(config)
|
|
60
|
+
? undefined
|
|
61
|
+
: new OpenRouterVisionAdapter({ apiKey: process.env.OPENROUTER_API_KEY || '', model: config.model || 'gpt-4-vision' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createBehavior(config: any) {
|
|
65
|
+
return isDisabled(config) ? undefined : new ActiveSelfCompiler();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createBody(config: any) {
|
|
69
|
+
return isDisabled(config)
|
|
70
|
+
? undefined
|
|
71
|
+
: new Live2DAdapter(config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createHands(config: any) {
|
|
75
|
+
return isDisabled(config)
|
|
76
|
+
? new DefaultHandsOrgan()
|
|
77
|
+
: new DefaultHandsOrgan(config);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createEar(config: any) {
|
|
81
|
+
return isDisabled(config)
|
|
82
|
+
? new DefaultEarOrgan()
|
|
83
|
+
: new DefaultEarOrgan(config);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
app.post('/boot', requireRole(['OWNER']), async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const { id, config } = req.body;
|
|
89
|
+
if (runtimes.has(id)) {
|
|
90
|
+
return res.status(400).json({ error: "Already booted" });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const brain = createBrain(config.brain);
|
|
94
|
+
const memory = new PostgresMemoryOrgan({ connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/siduri' });
|
|
95
|
+
const voice = createVoice(config.voice);
|
|
96
|
+
const knowledge = createKnowledge(config.knowledge);
|
|
97
|
+
const vision = createVision(config.vision);
|
|
98
|
+
const behavior = createBehavior(config.behavior);
|
|
99
|
+
const body = createBody(config.body);
|
|
100
|
+
const hands = createHands(config.hands);
|
|
101
|
+
const ear = createEar(config.ear);
|
|
102
|
+
|
|
103
|
+
const runtime = new SiduriRuntime(id, config, { brain, memory, voice, knowledge, vision, behavior, body, hands, ear });
|
|
104
|
+
await runtime.initialize();
|
|
105
|
+
|
|
106
|
+
runtimes.set(id, runtime);
|
|
107
|
+
|
|
108
|
+
res.json({ success: true, id });
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
res.status(500).json({ error: e.message });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// STATUS / HEALTH ENDPOINTS
|
|
115
|
+
app.get('/health', (req, res) => res.json({ status: "ok" }));
|
|
116
|
+
app.get('/version', (req, res) => res.json({ name: "siduri-y-api", version: "0.2.0-y" }));
|
|
117
|
+
app.get('/ready', (req, res) => res.json({ status: "ready", dependencies: {} }));
|
|
118
|
+
app.get('/voice/health', (req, res) => res.json({ provider: "voicevox", healthy: true }));
|
|
119
|
+
app.get('/obs/health', (req, res) => res.json({ connected: true }));
|
|
120
|
+
app.get('/platforms/status', (req, res) => res.json({ platforms: {} }));
|
|
121
|
+
app.get('/me', attachIdentity, (req, res) => {
|
|
122
|
+
const identity = (req as any).identity as Identity;
|
|
123
|
+
res.json({
|
|
124
|
+
actorId: identity.role === 'OWNER' ? 'owner-user' : 'anonymous-session',
|
|
125
|
+
role: identity.role,
|
|
126
|
+
authenticated: identity.role === 'OWNER',
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
app.put('/me', requireRole(['OWNER']), (req, res) => res.json({ success: true }));
|
|
130
|
+
|
|
131
|
+
// CHAT (API context boundary validation)
|
|
132
|
+
app.post('/chat', attachIdentity, async (req, res) => {
|
|
133
|
+
const { id, message, history } = req.body;
|
|
134
|
+
const identity = (req as any).identity as Identity;
|
|
135
|
+
|
|
136
|
+
// Call context mapper at the API boundary
|
|
137
|
+
const mappingResult = mapRequestContext(
|
|
138
|
+
{
|
|
139
|
+
...req.body,
|
|
140
|
+
id: id || req.body.companionId,
|
|
141
|
+
role: req.body.role || identity?.role,
|
|
142
|
+
generateCorrelationId: true,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
endpointPolicy: 'public',
|
|
146
|
+
defaultPublicAudience: 'audience-public',
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (!mappingResult.accepted) {
|
|
151
|
+
return res.status(400).json({
|
|
152
|
+
accepted: false,
|
|
153
|
+
error: mappingResult.error,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const companionId = mappingResult.context!.companionId;
|
|
158
|
+
const runtime = runtimes.get(companionId);
|
|
159
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// Map authorization role to legacy memory scope for backwards-compatible runtime call
|
|
163
|
+
const legacyScope =
|
|
164
|
+
mappingResult.context!.actor.authorizationRole === 'administrator'
|
|
165
|
+
? 'OWNER'
|
|
166
|
+
: mappingResult.context!.actor.authorizationRole === 'operator'
|
|
167
|
+
? 'OPERATOR'
|
|
168
|
+
: 'VIEWER';
|
|
169
|
+
|
|
170
|
+
const response = await runtime.handleUserMessage(message, legacyScope, history);
|
|
171
|
+
res.json(response);
|
|
172
|
+
} catch (e: any) {
|
|
173
|
+
res.status(500).json({ error: e.message });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// MEMORY GETTERS
|
|
178
|
+
app.get('/memory/proposals', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
|
|
179
|
+
const id = req.query.id as string || Array.from(runtimes.keys())[0];
|
|
180
|
+
const runtime = runtimes.get(id);
|
|
181
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
182
|
+
if (!runtime.memory) return res.json({ proposals: [] });
|
|
183
|
+
try {
|
|
184
|
+
const proposals = await runtime.memory.getPendingClaims();
|
|
185
|
+
res.json({ proposals });
|
|
186
|
+
} catch (e: any) {
|
|
187
|
+
res.status(500).json({ error: e.message });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
app.get('/memory', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
|
|
192
|
+
const id = req.query.id as string || Array.from(runtimes.keys())[0];
|
|
193
|
+
const runtime = runtimes.get(id);
|
|
194
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
195
|
+
if (!runtime.memory) return res.json({ items: [] });
|
|
196
|
+
try {
|
|
197
|
+
const items = await runtime.memory.getClaims();
|
|
198
|
+
res.json({ items });
|
|
199
|
+
} catch (e: any) {
|
|
200
|
+
res.status(500).json({ error: e.message });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.get('/memory/claims', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
|
|
205
|
+
const id = req.query.id as string || Array.from(runtimes.keys())[0];
|
|
206
|
+
const runtime = runtimes.get(id);
|
|
207
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
208
|
+
if (!runtime.memory) return res.json({ claims: [] });
|
|
209
|
+
try {
|
|
210
|
+
const claims = await runtime.memory.getClaims();
|
|
211
|
+
res.json({ claims });
|
|
212
|
+
} catch (e: any) {
|
|
213
|
+
res.status(500).json({ error: e.message });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
app.get('/memory/behavioral', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
|
|
218
|
+
const id = req.query.id as string || Array.from(runtimes.keys())[0];
|
|
219
|
+
const runtime = runtimes.get(id);
|
|
220
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
221
|
+
if (!runtime.memory) return res.json({ directives: [] });
|
|
222
|
+
try {
|
|
223
|
+
const directives = await runtime.memory.getDirectives();
|
|
224
|
+
res.json({ directives });
|
|
225
|
+
} catch (e: any) {
|
|
226
|
+
res.status(500).json({ error: e.message });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// MEMORY MUTATIONS - PROPOSALS
|
|
231
|
+
app.post('/memory/proposals/update', requireRole(['OWNER', 'OPERATOR']), async (req, res) => res.json({ success: true }));
|
|
232
|
+
|
|
233
|
+
app.post('/memory/proposals/approve', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
|
|
234
|
+
const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
|
|
235
|
+
const runtime = runtimes.get(id);
|
|
236
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
237
|
+
if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
|
|
238
|
+
try {
|
|
239
|
+
await runtime.memory.approveClaim(req.body.id);
|
|
240
|
+
res.json({ approved: true });
|
|
241
|
+
} catch (e: any) {
|
|
242
|
+
res.status(500).json({ error: e.message });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
app.post('/memory/proposals/reject', requireRole(['OWNER', 'OPERATOR']), async (req, res) => {
|
|
247
|
+
const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
|
|
248
|
+
const runtime = runtimes.get(id);
|
|
249
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
250
|
+
if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
|
|
251
|
+
try {
|
|
252
|
+
await runtime.memory.rejectClaim(req.body.id);
|
|
253
|
+
res.json({ rejected: true });
|
|
254
|
+
} catch (e: any) {
|
|
255
|
+
res.status(500).json({ error: e.message });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// MEMORY MUTATIONS - BEHAVIORAL
|
|
260
|
+
app.post('/memory/behavioral/approve', requireRole(['OWNER']), async (req, res) => {
|
|
261
|
+
const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
|
|
262
|
+
const runtime = runtimes.get(id);
|
|
263
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
264
|
+
if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
|
|
265
|
+
try {
|
|
266
|
+
await runtime.memory.approveDirective(req.body.id);
|
|
267
|
+
res.json({ approved: true });
|
|
268
|
+
} catch (e: any) {
|
|
269
|
+
res.status(500).json({ error: e.message });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
app.post('/memory/behavioral/reject', requireRole(['OWNER']), async (req, res) => {
|
|
274
|
+
const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
|
|
275
|
+
const runtime = runtimes.get(id);
|
|
276
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
277
|
+
if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
|
|
278
|
+
try {
|
|
279
|
+
await runtime.memory.rejectDirective(req.body.id);
|
|
280
|
+
res.json({ rejected: true });
|
|
281
|
+
} catch (e: any) {
|
|
282
|
+
res.status(500).json({ error: e.message });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
app.post('/memory/behavioral/revoke', requireRole(['OWNER']), async (req, res) => {
|
|
287
|
+
const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
|
|
288
|
+
const runtime = runtimes.get(id);
|
|
289
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
290
|
+
if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
|
|
291
|
+
try {
|
|
292
|
+
await runtime.memory.revokeDirective(req.body.id);
|
|
293
|
+
res.json({ revoked: true });
|
|
294
|
+
} catch (e: any) {
|
|
295
|
+
res.status(500).json({ error: e.message });
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
app.post('/memory/behavioral/disable', requireRole(['OWNER']), async (req, res) => {
|
|
300
|
+
const id = req.body.companionId as string || Array.from(runtimes.keys())[0];
|
|
301
|
+
const runtime = runtimes.get(id);
|
|
302
|
+
if (!runtime) return res.status(404).json({ error: "Companion not found" });
|
|
303
|
+
if (!runtime.memory) return res.status(400).json({ error: "Memory organ not configured" });
|
|
304
|
+
try {
|
|
305
|
+
await runtime.memory.disableDirective(req.body.id);
|
|
306
|
+
res.json({ disabled: true });
|
|
307
|
+
} catch (e: any) {
|
|
308
|
+
res.status(500).json({ error: e.message });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
app.post('/dev/memory/reset', requireRole(['OWNER']), async (req, res) => res.json({ reset: true }));
|
|
313
|
+
|
|
314
|
+
// MOCKS / DEV / EVIDENCE / PLATFORMS
|
|
315
|
+
app.get('/platforms/events', (req, res) => res.json({ events: [] }));
|
|
316
|
+
app.get('/platforms/actions', (req, res) => res.json({ actions: [] }));
|
|
317
|
+
app.get('/evidence', (req, res) => res.json({ results: [] }));
|
|
318
|
+
app.get('/observations', (req, res) => res.json({ observations: observationOrgan?.current() ?? [] }));
|
|
319
|
+
|
|
320
|
+
app.post('/dev/mock-response', async (req, res) => {
|
|
321
|
+
const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
|
|
322
|
+
const runtime = runtimes.get(companionId);
|
|
323
|
+
if (!runtime) return res.status(404).json({ accepted: false, error: 'Companion not found' });
|
|
324
|
+
const staged = runtime.gating.stageResponse({
|
|
325
|
+
requestContext: {
|
|
326
|
+
companionId,
|
|
327
|
+
actor: {
|
|
328
|
+
actorId: 'operator-a',
|
|
329
|
+
sessionId: 'sess-op',
|
|
330
|
+
authorizationRole: 'operator',
|
|
331
|
+
capabilities: ['chat:public', 'memory:approve'],
|
|
332
|
+
authenticated: true,
|
|
333
|
+
},
|
|
334
|
+
conversation: {
|
|
335
|
+
channel: 'public',
|
|
336
|
+
audienceId: 'audience-public',
|
|
337
|
+
correlationId: req.body?.correlation_id || `corr-${Date.now()}`,
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
candidateSpeech: req.body?.speech || 'Mocked staged response for review',
|
|
341
|
+
candidateLanguage: req.body?.language || 'en',
|
|
342
|
+
requiresApproval: req.body?.requiresApproval ?? true,
|
|
343
|
+
});
|
|
344
|
+
res.json({
|
|
345
|
+
accepted: true,
|
|
346
|
+
staged: true,
|
|
347
|
+
status: staged.status,
|
|
348
|
+
response_id: staged.responseId,
|
|
349
|
+
correlation_id: staged.correlationId,
|
|
350
|
+
speech: staged.speech,
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
app.post('/dev/approve-response', async (req, res) => {
|
|
355
|
+
const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
|
|
356
|
+
const runtime = runtimes.get(companionId);
|
|
357
|
+
if (!runtime) return res.status(404).json({ approved: false, error: 'Companion not found' });
|
|
358
|
+
|
|
359
|
+
let responseId = req.body?.responseId;
|
|
360
|
+
let correlationId = req.body?.correlation_id;
|
|
361
|
+
|
|
362
|
+
if (!responseId && correlationId) {
|
|
363
|
+
const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
|
|
364
|
+
if (found) {
|
|
365
|
+
responseId = found.responseId;
|
|
366
|
+
}
|
|
367
|
+
} else if (responseId && !correlationId) {
|
|
368
|
+
const found = runtime.gating.getStagedPlan(responseId);
|
|
369
|
+
if (found) {
|
|
370
|
+
correlationId = found.correlationId;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!responseId) {
|
|
375
|
+
return res.status(400).json({ approved: false, error: 'UNKNOWN_APPROVAL_ID' });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const result = runtime.gating.approveResponse({
|
|
379
|
+
responseId,
|
|
380
|
+
companionId,
|
|
381
|
+
correlationId: correlationId || '',
|
|
382
|
+
audienceId: req.body?.audienceId,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (!result.success) {
|
|
386
|
+
return res.status(400).json({ approved: false, error: result.reason });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Now evaluated as approved
|
|
390
|
+
const evaluation = runtime.gating.evaluateGate(result.plan!);
|
|
391
|
+
res.json({
|
|
392
|
+
approved: true,
|
|
393
|
+
status: evaluation.disposition,
|
|
394
|
+
response_id: result.plan!.responseId,
|
|
395
|
+
speech: result.plan!.speech,
|
|
396
|
+
language: result.plan!.language,
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
app.post('/dev/reject-response', async (req, res) => {
|
|
401
|
+
const companionId = req.body?.companionId || Array.from(runtimes.keys())[0] || 'default';
|
|
402
|
+
const runtime = runtimes.get(companionId);
|
|
403
|
+
if (!runtime) return res.status(404).json({ rejected: false, error: 'Companion not found' });
|
|
404
|
+
|
|
405
|
+
let responseId = req.body?.responseId;
|
|
406
|
+
let correlationId = req.body?.correlation_id;
|
|
407
|
+
|
|
408
|
+
if (!responseId && correlationId) {
|
|
409
|
+
const found = runtime.gating.findStagedPlanByCorrelation(companionId, correlationId);
|
|
410
|
+
if (found) {
|
|
411
|
+
responseId = found.responseId;
|
|
412
|
+
}
|
|
413
|
+
} else if (responseId && !correlationId) {
|
|
414
|
+
const found = runtime.gating.getStagedPlan(responseId);
|
|
415
|
+
if (found) {
|
|
416
|
+
correlationId = found.correlationId;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!responseId) {
|
|
421
|
+
return res.status(400).json({ rejected: false, error: 'UNKNOWN_APPROVAL_ID' });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const result = runtime.gating.rejectResponse({
|
|
425
|
+
responseId,
|
|
426
|
+
companionId,
|
|
427
|
+
correlationId: correlationId || '',
|
|
428
|
+
reason: req.body?.reason,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (!result.success) {
|
|
432
|
+
return res.status(400).json({ rejected: false, error: result.reason });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
res.json({
|
|
436
|
+
rejected: true,
|
|
437
|
+
status: result.plan!.status,
|
|
438
|
+
response_id: result.plan!.responseId,
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
app.post('/dev/mock-observation', async (req, res) => {
|
|
443
|
+
if (!observationOrgan) return res.status(503).json({ accepted: false, reason: 'observation_unavailable' });
|
|
444
|
+
const result = await observationOrgan.ingest(
|
|
445
|
+
new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]),
|
|
446
|
+
'fixture-observation',
|
|
447
|
+
'configured-vision',
|
|
448
|
+
);
|
|
449
|
+
if (!result.observation) return res.status(result.duplicate ? 200 : 409).json({ accepted: false, ...result });
|
|
450
|
+
res.status(202).json({ accepted: true, observation: result.observation });
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
app.post('/platforms/actions/suggest', (req, res) => res.json({ suggested: true }));
|
|
454
|
+
app.post('/platforms/actions/approve', (req, res) => res.json({ approved: true }));
|
|
455
|
+
app.post('/platforms/actions/reject', (req, res) => res.json({ rejected: true }));
|
|
456
|
+
app.post('/platforms/actions/send', (req, res) => res.json({ sent: true }));
|
|
457
|
+
|
|
458
|
+
return { app, runtimes, setObservationOrgan: (org: FixtureObservationOrgan) => { observationOrgan = org; } };
|
|
459
|
+
}
|
package/src/auth.test.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { resolveIdentity } from './auth';
|
|
2
|
+
|
|
3
|
+
describe('Auth Identity Resolution', () => {
|
|
4
|
+
const originalEnv = process.env;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
jest.resetModules();
|
|
8
|
+
process.env = { ...originalEnv };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterAll(() => {
|
|
12
|
+
process.env = originalEnv;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const mockReq = (token?: string) => ({
|
|
16
|
+
headers: {
|
|
17
|
+
authorization: token ? `Bearer ${token}` : undefined
|
|
18
|
+
}
|
|
19
|
+
} as any);
|
|
20
|
+
|
|
21
|
+
test('resolves explicit owner token', () => {
|
|
22
|
+
process.env.OWNER_TOKEN = 'owner-secret';
|
|
23
|
+
process.env.OPERATOR_TOKEN = 'operator-secret';
|
|
24
|
+
process.env.NODE_ENV = 'production';
|
|
25
|
+
|
|
26
|
+
const ownerId = resolveIdentity(mockReq('owner-secret'));
|
|
27
|
+
expect(ownerId.role).toBe('OWNER');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('resolves explicit operator token', () => {
|
|
31
|
+
process.env.OWNER_TOKEN = 'owner-secret';
|
|
32
|
+
process.env.OPERATOR_TOKEN = 'operator-secret';
|
|
33
|
+
process.env.NODE_ENV = 'production';
|
|
34
|
+
|
|
35
|
+
const opId = resolveIdentity(mockReq('operator-secret'));
|
|
36
|
+
expect(opId.role).toBe('OPERATOR');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('defaults to viewer when no token is present in production', () => {
|
|
40
|
+
process.env.NODE_ENV = 'production';
|
|
41
|
+
const viewerId = resolveIdentity(mockReq());
|
|
42
|
+
expect(viewerId.role).toBe('VIEWER');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('defaults to viewer for invalid token in production', () => {
|
|
46
|
+
process.env.NODE_ENV = 'production';
|
|
47
|
+
const invalidId = resolveIdentity(mockReq('invalid-token'));
|
|
48
|
+
expect(invalidId.role).toBe('VIEWER');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('resolves development fallback role', () => {
|
|
52
|
+
process.env.NODE_ENV = 'development';
|
|
53
|
+
process.env.DEV_LOCAL_AUTH_ROLE = 'OWNER';
|
|
54
|
+
const devId = resolveIdentity(mockReq());
|
|
55
|
+
expect(devId.role).toBe('OWNER');
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
|
|
3
|
+
export type Role = 'OWNER' | 'OPERATOR' | 'VIEWER';
|
|
4
|
+
|
|
5
|
+
export interface Identity {
|
|
6
|
+
role: Role;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveIdentity(req: Request): Identity {
|
|
10
|
+
const authHeader = req.headers.authorization;
|
|
11
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : undefined;
|
|
12
|
+
|
|
13
|
+
// 1. Explicit token matches
|
|
14
|
+
if (process.env.OWNER_TOKEN && token === process.env.OWNER_TOKEN) {
|
|
15
|
+
return { role: 'OWNER' };
|
|
16
|
+
}
|
|
17
|
+
if (process.env.OPERATOR_TOKEN && token === process.env.OPERATOR_TOKEN) {
|
|
18
|
+
return { role: 'OPERATOR' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 2. Development fallback
|
|
22
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
23
|
+
if (isDev && process.env.DEV_LOCAL_AUTH_ROLE) {
|
|
24
|
+
const fallbackRole = process.env.DEV_LOCAL_AUTH_ROLE.toUpperCase() as Role;
|
|
25
|
+
if (['OWNER', 'OPERATOR', 'VIEWER'].includes(fallbackRole)) {
|
|
26
|
+
return { role: fallbackRole };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Default
|
|
31
|
+
return { role: 'VIEWER' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function requireRole(allowedRoles: Role[]) {
|
|
35
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
36
|
+
const identity = resolveIdentity(req);
|
|
37
|
+
(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(', ')}` });
|
|
41
|
+
}
|
|
42
|
+
next();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function attachIdentity(req: Request, res: Response, next: NextFunction) {
|
|
47
|
+
(req as any).identity = resolveIdentity(req);
|
|
48
|
+
next();
|
|
49
|
+
}
|