The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 535fc73a59b4Commit detailsView latest

apps/api/src/app.ts 849 lines · 36.3 KB · typescript
1
import Fastify from 'fastify';
2
import cors from '@fastify/cors';
3
import swagger from '@fastify/swagger';
4
import { z } from 'zod';
5
import {
6
  AgentIdentitySchema,
7
  DelegationCredentialSchema,
8
  FeedbackSchema,
9
  InteractionContractSchema,
10
  InteractionEventSchema,
11
  ReceiptSchema,
12
  FederatedInteractionSchema,
13
  LiveSessionEventSchema,
14
  InteractionCompletionReportSchema,
15
  InteractionFeedbackSchema,
16
} from '../../../packages/protocol/src/index.js';
17
import {
18
  FixtureFactCheckProvider,
19
  MemoryAuditStore,
20
  TrustEngine,
21
} from '../../../packages/core/src/index.js';
22
import { buildPublicAgentCard } from '../../../packages/persistence/src/index.js';
23
import type { AgentProfile, HostedRepository } from '../../../packages/persistence/src/index.js';
24
import { toA2AAgentCard } from '../../../packages/sidecar/src/index.js';
25
import {
26
  approveAgentSetup,
27
  createHostedProviderAgent,
28
  getOnboardingState,
29
  rejectAgentSetup,
30
} from '../../../packages/persistence/src/onboarding.js';
31
32
type DashboardRepository = Pick<
33
  HostedRepository,
34
  | 'dashboard'
35
  | 'getSettings'
36
  | 'saveSettings'
37
  | 'upsert'
38
  | 'list'
39
  | 'publishAgent'
40
  | 'unpublishAgent'
41
  | 'getPublishedAgent'
42
  | 'searchPublishedAgents'
43
> &
44
  Partial<
45
    Pick<
46
      HostedRepository,
47
      | 'createFederatedInteraction'
48
      | 'listFederatedInteractions'
49
      | 'getFederatedInteraction'
50
      | 'respondToFederatedInteraction'
51
      | 'getLiveSession'
52
      | 'getCounterpartyBrief'
53
      | 'submitCompletionReport'
54
      | 'recordSessionCompletionReport'
55
      | 'submitInteractionFeedback'
56
      | 'recordSessionFeedback'
57
      | 'listFeedbackRequests'
58
      | 'recordLiveSessionEvent'
59
      | 'touchAgentPresence'
60
      | 'getRuntimeVerificationKey'
61
      | 'registerAgentRuntime'
62
      | 'disableAgentRuntime'
63
      | 'deleteAgent'
64
      | 'issueAgentAccessToken'
65
      | 'listAgentAccessTokens'
66
      | 'revokeAgentAccessToken'
67
      | 'receiveTemporaryMessage'
68
      | 'sendTemporaryMessage'
69
      | 'listHostedThreads'
70
      | 'getHostedThread'
71
      | 'markHostedThreadRead'
72
      | 'closeHostedThread'
73
    >
74
  >;
75
76
export function buildApi(
77
  engine = new TrustEngine(),
78
  factChecker = new FixtureFactCheckProvider(),
79
  repository?: DashboardRepository,
80
) {
81
  const app = Fastify({
82
    logger: { redact: ['req.headers.authorization', 'req.body.rawMessage', 'req.body.privateKey'] },
83
  });
84
  const engines = new Map<string, Promise<TrustEngine>>();
85
  void app.register(cors, { origin: true });
86
  void app.register(swagger, { openapi: { info: { title: 'OpenClasp API', version: '0.1.0' } } });
87
88
  app.setErrorHandler((error, _request, reply) =>
89
    reply
90
      .status(
91
        error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number'
92
          ? error.statusCode
93
          : 400,
94
      )
95
      .send({ error: error instanceof Error ? error.message : 'Unknown error' }),
96
  );
97
  void app.register(async (router) => {
98
    const operatorId = (request: { headers: Record<string, unknown> }) => {
99
      const value = request.headers['x-openclasp-operator'];
100
      if (repository && typeof value !== 'string') {
101
        const error = new Error('Authentication required');
102
        Object.assign(error, { statusCode: 401 });
103
        throw error;
104
      }
105
      return typeof value === 'string' ? value : undefined;
106
    };
107
    const boundAgentId = (request: { headers: Record<string, unknown> }) => {
108
      const value = request.headers['x-openclasp-bound-agent'];
109
      if (typeof value !== 'string' || !value) {
110
        const error = new Error('Agent-bound authentication required');
111
        Object.assign(error, { statusCode: 401 });
112
        throw error;
113
      }
114
      return value;
115
    };
116
    const enforceBoundAgent = (
117
      request: { headers: Record<string, unknown> },
118
      requestedAgentId: string,
119
    ) => {
120
      const value = request.headers['x-openclasp-bound-agent'];
121
      if (typeof value === 'string' && value !== requestedAgentId) {
122
        const error = new Error('Agent credential cannot access another agent');
123
        Object.assign(error, { statusCode: 403 });
124
        throw error;
125
      }
126
    };
127
    const scopedEngine = async (request: { headers: Record<string, unknown> }) => {
128
      const owner = operatorId(request);
129
      if (!repository || !owner) return engine;
130
      let pending = engines.get(owner);
131
      if (!pending) {
132
        pending = repository.list(owner).then((rows) => {
133
          const store = new MemoryAuditStore();
134
          for (const row of rows) {
135
            if (row.kind !== 'interaction') store.append(row.kind, row.recordId, row.payload);
136
          }
137
          return new TrustEngine(store);
138
        });
139
        engines.set(owner, pending);
140
      }
141
      return pending;
142
    };
143
    const persist = async (
144
      request: { headers: Record<string, unknown> },
145
      kind: Parameters<HostedRepository['upsert']>[1],
146
      id: string,
147
      value: unknown,
148
    ) => {
149
      const owner = operatorId(request);
150
      if (repository && owner) await repository.upsert(owner, kind, id, value);
151
    };
152
    const publicBaseUrl = (request: { headers: Record<string, unknown> }) => {
153
      const forwardedHost = request.headers['x-forwarded-host'];
154
      const host = typeof forwardedHost === 'string' ? forwardedHost : request.headers.host;
155
      const forwardedProtocol = request.headers['x-forwarded-proto'];
156
      const protocol = typeof forwardedProtocol === 'string' ? forwardedProtocol : 'https';
157
      return process.env.OPENCLASP_PUBLIC_URL ?? `${protocol}://${String(host ?? 'localhost')}`;
158
    };
159
    router.get('/health', async () => ({ status: 'ok' }));
160
    router.get('/ready', async () => ({ status: 'ready' }));
161
    router.get('/openapi.json', async () => app.swagger());
162
    router.get('/.well-known/openclasp-session-key', async () => {
163
      if (!repository?.getRuntimeVerificationKey)
164
        throw new Error('Runtime verification is not configured');
165
      return repository.getRuntimeVerificationKey();
166
    });
167
    router.get('/extensions/trust/v0.1', async () => ({
168
      uri: 'https://openclasp.vercel.app/extensions/trust/v0.1',
169
      name: 'OpenClasp A2A assurance extension',
170
      version: '0.1',
171
      required: false,
172
      transportsMessages: false,
173
      temporaryChatAdapter: true,
174
      documentation: 'https://github.com/aryaamin/OpenClasp/blob/main/docs/A2A_EXTENSION.md',
175
    }));
176
    router.post('/sessions/:id/events', async (request, reply) => {
177
      if (!repository?.recordLiveSessionEvent)
178
        throw new Error('Live-session reporting is not configured');
179
      const authorization = request.headers.authorization;
180
      if (!authorization?.startsWith('Bearer ')) {
181
        reply.status(401);
182
        return { error: 'session_credential_required' };
183
      }
184
      const event = LiveSessionEventSchema.parse(request.body);
185
      if (event.interactionId !== (request.params as { id: string }).id)
186
        throw new Error('Interaction path does not match the event');
187
      return repository.recordLiveSessionEvent(authorization.slice(7), event);
188
    });
189
    router.post('/sessions/:id/completion-reports', async (request, reply) => {
190
      if (!repository?.recordSessionCompletionReport)
191
        throw new Error('Live-session completion reporting is not configured');
192
      const authorization = request.headers.authorization;
193
      if (!authorization?.startsWith('Bearer ')) {
194
        reply.status(401);
195
        return { error: 'session_credential_required' };
196
      }
197
      const report = InteractionCompletionReportSchema.parse(request.body);
198
      if (report.interactionId !== (request.params as { id: string }).id)
199
        throw new Error('Interaction path does not match the completion report');
200
      return repository.recordSessionCompletionReport(authorization.slice(7), report);
201
    });
202
    router.post('/sessions/:id/feedback', async (request, reply) => {
203
      if (!repository?.recordSessionFeedback)
204
        throw new Error('Live-session feedback is not configured');
205
      const authorization = request.headers.authorization;
206
      if (!authorization?.startsWith('Bearer ')) {
207
        reply.status(401);
208
        return { error: 'session_credential_required' };
209
      }
210
      const feedback = InteractionFeedbackSchema.parse(request.body);
211
      if (feedback.interactionId !== (request.params as { id: string }).id)
212
        throw new Error('Interaction path does not match the feedback');
213
      return repository.recordSessionFeedback(authorization.slice(7), feedback);
214
    });
215
    router.post('/a2a/temporary/:id', async (request, reply) => {
216
      if (!repository?.receiveTemporaryMessage)
217
        throw new Error('Temporary chat delivery is not configured');
218
      const authorization = request.headers.authorization;
219
      if (!authorization?.startsWith('Bearer '))
220
        return reply.status(401).send({ error: 'session_credential_required' });
221
      const rpc = z
222
        .object({
223
          jsonrpc: z.literal('2.0'),
224
          id: z.union([z.string().min(1).max(200), z.number()]),
225
          method: z.literal('message/send'),
226
          params: z.object({
227
            message: z.object({
228
              parts: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
229
            }),
230
          }),
231
        })
232
        .parse(request.body);
233
      const content = rpc.params.message.parts
234
        .map((part) => (typeof part.text === 'string' ? part.text : ''))
235
        .filter(Boolean)
236
        .join('\n')
237
        .trim();
238
      if (!content) throw new Error('Temporary chat MVP accepts text parts only');
239
      const recipientAgentId = (request.params as { id: string }).id;
240
      const result = await repository.receiveTemporaryMessage(
241
        authorization.slice(7),
242
        recipientAgentId,
243
        String(rpc.id),
244
        content,
245
      );
246
      return {
247
        jsonrpc: '2.0',
248
        id: rpc.id,
249
        result: {
250
          task: { id: result.message.messageId, state: 'submitted' },
251
          privacyMode: 'openclasp_hosted_temporary',
252
          deduplicated: result.deduplicated,
253
        },
254
      };
255
    });
256
    router.get('/agents/:id/card.json', async (request) => {
257
      if (!repository) throw new Error('Hosted persistence is not configured');
258
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
259
      if (!card) throw new Error('Published agent not found');
260
      return card;
261
    });
262
    router.get('/agents/:id/.well-known/openclasp-agent.json', async (request) => {
263
      if (!repository) throw new Error('Hosted persistence is not configured');
264
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
265
      if (!card) throw new Error('Published agent not found');
266
      return card;
267
    });
268
    router.get('/agents/:id/a2a-agent-card.json', async (request) => {
269
      if (!repository) throw new Error('Hosted persistence is not configured');
270
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
271
      if (!card) throw new Error('Published agent not found');
272
      if (!card.transports.length) throw new Error('Agent has not published an A2A endpoint');
273
      return toA2AAgentCard(card);
274
    });
275
    router.get('/v0.1/dashboard', async (request) => {
276
      const owner = operatorId(request);
277
      if (repository && owner) return repository.dashboard(owner);
278
      return {
279
        agents: [...engine.agents.values()],
280
        projects: [],
281
        installations: [],
282
        setupRequests: [],
283
        publications: [],
284
        interactions: [],
285
        federatedInteractions: [],
286
        liveSessions: [],
287
        hostedThreads: [],
288
        events: [...engine.events.values()],
289
        conflicts: [...engine.conflicts.values()],
290
        receipts: [...engine.receipts.values()],
291
        profiles: [...engine.profiles.values()],
292
        counterpartyBriefs: [],
293
        completionReports: [],
294
        feedbackRequests: [],
295
        interactionFeedback: [],
296
        interactionConclusions: [],
297
        learningEligibility: [],
298
        profileDeltas: [],
299
        runtimes: [],
300
      };
301
    });
302
    router.get('/v0.1/account', async (request) => {
303
      const owner = operatorId(request);
304
      return {
305
        operatorId: owner,
306
        email: decodeURIComponent(String(request.headers['x-openclasp-email'] ?? '')),
307
        name: decodeURIComponent(String(request.headers['x-openclasp-name'] ?? '')),
308
      };
309
    });
310
    router.get('/v0.1/settings', async (request) => {
311
      const owner = operatorId(request);
312
      if (!repository || !owner)
313
        return {
314
          displayName: '',
315
          contributionEnabled: false,
316
          retentionDays: 30,
317
          evidenceSharing: 'ask',
318
          rawConversationsStored: false,
319
        };
320
      return repository.getSettings(owner);
321
    });
322
    router.get('/v0.1/onboarding', async (request) => {
323
      const owner = operatorId(request);
324
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
325
      return getOnboardingState(repository, owner);
326
    });
327
    router.get('/v0.1/runtime/bootstrap', async (request) => {
328
      operatorId(request);
329
      const agentId = boundAgentId(request);
330
      return {
331
        agentId,
332
        openClaspUrl: publicBaseUrl(request),
333
        runtimeRegistrationEndpoint: `${publicBaseUrl(request)}/v0.1/runtime`,
334
        protocol: 'A2A/1.0',
335
        controlProtocol: 'OpenClasp/0.1',
336
      };
337
    });
338
    router.put('/v0.1/runtime', async (request) => {
339
      const owner = operatorId(request);
340
      if (!repository?.registerAgentRuntime || !owner)
341
        throw new Error('Hosted runtime delivery is not configured');
342
      const endpoint = z
343
        .object({ endpoint: z.string().url().max(2048) })
344
        .parse(request.body).endpoint;
345
      return repository.registerAgentRuntime(owner, boundAgentId(request), endpoint);
346
    });
347
    router.delete('/v0.1/runtime', async (request) => {
348
      const owner = operatorId(request);
349
      if (!repository?.disableAgentRuntime || !owner)
350
        throw new Error('Hosted runtime delivery is not configured');
351
      return repository.disableAgentRuntime(owner, boundAgentId(request));
352
    });
353
    router.post('/v0.1/runtime/heartbeat', async (request) => {
354
      const owner = operatorId(request);
355
      if (!repository?.touchAgentPresence || !owner)
356
        throw new Error('Agent presence is not configured');
357
      return repository.touchAgentPresence(owner, boundAgentId(request));
358
    });
359
    router.post('/v0.1/onboarding/:id/approve', async (request) => {
360
      const owner = operatorId(request);
361
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
362
      const requestId = (request.params as { id: string }).id;
363
      const state = await getOnboardingState(repository, owner);
364
      const setup = state.setupRequests.find((item) => item.requestId === requestId);
365
      const result = await approveAgentSetup(repository, owner, requestId);
366
      if (result.status === 'connected' && setup?.autoPublish) {
367
        const previous = await repository.getPublishedAgent(result.agent.agentId);
368
        const card = await repository.publishAgent(
369
          owner,
370
          buildPublicAgentCard(result.agent, publicBaseUrl(request), previous),
371
        );
372
        await repository.upsert(owner, 'publication', result.agent.agentId, {
373
          agentId: result.agent.agentId,
374
          published: true,
375
          updatedAt: card.updatedAt,
376
        });
377
      }
378
      return result;
379
    });
380
    router.post('/v0.1/onboarding/:id/reject', async (request) => {
381
      const owner = operatorId(request);
382
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
383
      return rejectAgentSetup(repository, owner, (request.params as { id: string }).id);
384
    });
385
    router.put('/v0.1/settings', async (request) => {
386
      const owner = operatorId(request);
387
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
388
      const value = z
389
        .object({
390
          displayName: z.string().trim().max(100),
391
          contributionEnabled: z.boolean(),
392
          retentionDays: z.number().int().min(0).max(3650),
393
          evidenceSharing: z.enum(['never', 'ask', 'contract_only']),
394
        })
395
        .parse(request.body);
396
      return repository.saveSettings(owner, value);
397
    });
398
    router.post('/v0.1/provider-connections', async (request) => {
399
      const owner = operatorId(request);
400
      if (!repository?.issueAgentAccessToken || !owner)
401
        throw new Error('Hosted provider connections are not configured');
402
      const value = z
403
        .object({
404
          provider: z.enum(['botpress', 'custom']),
405
          agentName: z.string().trim().min(1).max(100),
406
          projectName: z.string().trim().min(1).max(100),
407
          description: z.string().trim().max(500).optional(),
408
          capabilities: z.array(z.string().trim().min(1).max(100)).max(100).default([]),
409
          limitations: z.array(z.string().trim().min(1).max(300)).max(100).default([]),
410
          expiresInDays: z.number().int().min(1).max(365).default(365),
411
        })
412
        .parse(request.body);
413
      const created = await createHostedProviderAgent(repository, owner, value);
414
      try {
415
        const accessToken = await repository.issueAgentAccessToken(owner, created.agent.agentId, {
416
          name: 'Botpress',
417
          expiresInDays: value.expiresInDays,
418
        });
419
        return { ...created, provider: value.provider, accessToken };
420
      } catch (error) {
421
        if (repository.deleteAgent)
422
          await repository.deleteAgent(owner, created.agent.agentId).catch(() => undefined);
423
        throw error;
424
      }
425
    });
426
    router.post('/v0.1/agents', async (request) => {
427
      const owner = operatorId(request);
428
      const current = await scopedEngine(request);
429
      const identity = AgentIdentitySchema.parse(request.body);
430
      if (owner && identity.operatorRef !== `auth0:${owner}`)
431
        throw new Error('Agent identity is not owned by the authenticated operator');
432
      const registered = current.registerAgent(identity);
433
      await persist(request, 'agent', identity.agentId, registered);
434
      return registered;
435
    });
436
    router.get('/v0.1/agents/:id', async (request) => {
437
      const { id } = request.params as { id: string };
438
      const value = (await scopedEngine(request)).agents.get(id);
439
      if (!value) throw new Error('Agent not found');
440
      return value;
441
    });
442
    router.post('/v0.1/agents/:id/publication', async (request) => {
443
      const owner = operatorId(request);
444
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
445
      const { id } = request.params as { id: string };
446
      const { published } = z.object({ published: z.boolean() }).parse(request.body);
447
      const rows = await repository.list(owner);
448
      const agent = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
449
        ?.payload as AgentProfile | undefined;
450
      if (!agent) throw new Error('Owned agent not found');
451
      if (agent.status !== 'active') throw new Error('Revoked agents cannot be published');
452
      if (!published) {
453
        await repository.unpublishAgent(owner, id);
454
        const publication = { agentId: id, published: false, updatedAt: new Date().toISOString() };
455
        await repository.upsert(owner, 'publication', id, publication);
456
        return publication;
457
      }
458
      const previous = await repository.getPublishedAgent(id);
459
      const card = await repository.publishAgent(
460
        owner,
461
        buildPublicAgentCard(agent, publicBaseUrl(request), previous),
462
      );
463
      const publication = { agentId: id, published: true, updatedAt: card.updatedAt };
464
      await repository.upsert(owner, 'publication', id, publication);
465
      return { ...publication, card };
466
    });
467
    router.put('/v0.1/agents/:id/automation', async (request) => {
468
      const owner = operatorId(request);
469
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
470
      const { id } = request.params as { id: string };
471
      const value = z
472
        .object({
473
          autoPublish: z.boolean(),
474
          autoAcceptPolicy: z.enum(['off', 'safe_matching']),
475
          autoAcceptTaskCategories: z.array(z.string().trim().min(1).max(100)).max(100),
476
        })
477
        .parse(request.body);
478
      const rows = await repository.list(owner);
479
      const current = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
480
        ?.payload as AgentProfile | undefined;
481
      if (!current) throw new Error('Owned agent not found');
482
      const agent: AgentProfile = {
483
        ...current,
484
        transport: 'direct_a2a',
485
        description: current.description ?? '',
486
        agentVersion: current.agentVersion ?? '1.0.0',
487
        autoPublish: value.autoPublish,
488
        autoAcceptPolicy: value.autoAcceptPolicy,
489
        autoAcceptTaskCategories: [...new Set(value.autoAcceptTaskCategories)],
490
        updatedAt: new Date().toISOString(),
491
      };
492
      await repository.upsert(owner, 'agent_profile', id, agent);
493
      if (value.autoPublish) {
494
        const previous = await repository.getPublishedAgent(id);
495
        const card = await repository.publishAgent(
496
          owner,
497
          buildPublicAgentCard(agent, publicBaseUrl(request), previous),
498
        );
499
        await repository.upsert(owner, 'publication', id, {
500
          agentId: id,
501
          published: true,
502
          updatedAt: card.updatedAt,
503
        });
504
      } else {
505
        await repository.unpublishAgent(owner, id);
506
        await repository.upsert(owner, 'publication', id, {
507
          agentId: id,
508
          published: false,
509
          updatedAt: new Date().toISOString(),
510
        });
511
      }
512
      return agent;
513
    });
514
    router.put('/v0.1/agents/:id/runtime', async (request) => {
515
      const owner = operatorId(request);
516
      if (!repository?.registerAgentRuntime || !owner)
517
        throw new Error('Hosted runtime delivery is not configured');
518
      const endpoint = z
519
        .object({ endpoint: z.string().url().max(2048) })
520
        .parse(request.body).endpoint;
521
      return repository.registerAgentRuntime(
522
        owner,
523
        (request.params as { id: string }).id,
524
        endpoint,
525
      );
526
    });
527
    router.delete('/v0.1/agents/:id/runtime', async (request) => {
528
      const owner = operatorId(request);
529
      if (!repository?.disableAgentRuntime || !owner)
530
        throw new Error('Hosted runtime delivery is not configured');
531
      return repository.disableAgentRuntime(owner, (request.params as { id: string }).id);
532
    });
533
    router.get('/v0.1/agents/:id/access-tokens', async (request) => {
534
      const owner = operatorId(request);
535
      if (!repository?.listAgentAccessTokens || !owner)
536
        throw new Error('Agent access tokens are not configured');
537
      return repository.listAgentAccessTokens(owner, (request.params as { id: string }).id);
538
    });
539
    router.post('/v0.1/agents/:id/access-tokens', async (request) => {
540
      const owner = operatorId(request);
541
      if (!repository?.issueAgentAccessToken || !owner)
542
        throw new Error('Agent access tokens are not configured');
543
      const value = z
544
        .object({
545
          name: z.string().trim().min(1).max(100),
546
          expiresInDays: z.number().int().min(1).max(365).default(365),
547
        })
548
        .parse(request.body);
549
      return repository.issueAgentAccessToken(owner, (request.params as { id: string }).id, value);
550
    });
551
    router.delete('/v0.1/agents/:id/access-tokens/:tokenId', async (request) => {
552
      const owner = operatorId(request);
553
      if (!repository?.revokeAgentAccessToken || !owner)
554
        throw new Error('Agent access tokens are not configured');
555
      const params = request.params as { id: string; tokenId: string };
556
      return repository.revokeAgentAccessToken(owner, params.id, params.tokenId);
557
    });
558
    router.delete('/v0.1/agents/:id', async (request) => {
559
      const owner = operatorId(request);
560
      if (!repository?.deleteAgent || !owner)
561
        throw new Error('Hosted agent deletion is not configured');
562
      const result = await repository.deleteAgent(owner, (request.params as { id: string }).id);
563
      engines.delete(owner);
564
      return result;
565
    });
566
    router.get('/v0.1/agents/:id/threads', async (request) => {
567
      const owner = operatorId(request);
568
      if (!repository?.listHostedThreads || !owner)
569
        throw new Error('Temporary chat history is not configured');
570
      return repository.listHostedThreads(owner, (request.params as { id: string }).id);
571
    });
572
    router.get('/v0.1/agents/:id/threads/:threadId', async (request) => {
573
      const owner = operatorId(request);
574
      if (!repository?.getHostedThread || !owner)
575
        throw new Error('Temporary chat history is not configured');
576
      const params = request.params as { id: string; threadId: string };
577
      return repository.getHostedThread(owner, params.id, params.threadId);
578
    });
579
    router.post('/v0.1/agents/:id/messages', async (request) => {
580
      const owner = operatorId(request);
581
      if (!repository?.sendTemporaryMessage || !owner)
582
        throw new Error('Temporary chat delivery is not configured');
583
      const value = z
584
        .object({ interactionId: z.string().uuid(), content: z.string().trim().min(1).max(20_000) })
585
        .parse(request.body);
586
      return repository.sendTemporaryMessage(
587
        owner,
588
        (request.params as { id: string }).id,
589
        value.interactionId,
590
        value.content,
591
      );
592
    });
593
    router.post('/v0.1/agents/:id/threads/:threadId/read', async (request) => {
594
      const owner = operatorId(request);
595
      if (!repository?.markHostedThreadRead || !owner)
596
        throw new Error('Temporary chat history is not configured');
597
      const params = request.params as { id: string; threadId: string };
598
      return repository.markHostedThreadRead(owner, params.id, params.threadId);
599
    });
600
    router.post('/v0.1/agents/:id/threads/:threadId/close', async (request) => {
601
      const owner = operatorId(request);
602
      if (!repository?.closeHostedThread || !owner)
603
        throw new Error('Temporary chat history is not configured');
604
      const params = request.params as { id: string; threadId: string };
605
      return repository.closeHostedThread(owner, params.id, params.threadId);
606
    });
607
    router.get('/v0.1/directory', async (request) => {
608
      operatorId(request);
609
      if (!repository) throw new Error('Hosted persistence is not configured');
610
      const query = z
611
        .object({
612
          query: z.string().trim().max(100).optional(),
613
          capability: z.string().trim().max(100).optional(),
614
          limit: z.coerce.number().int().min(1).max(50).optional(),
615
        })
616
        .parse(request.query);
617
      return repository.searchPublishedAgents(query);
618
    });
619
    router.get('/v0.1/directory/:id', async (request) => {
620
      operatorId(request);
621
      if (!repository) throw new Error('Hosted persistence is not configured');
622
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
623
      if (!card) throw new Error('Published agent not found');
624
      return card;
625
    });
626
    router.get('/v0.1/federated-interactions', async (request) => {
627
      const owner = operatorId(request);
628
      if (!repository?.listFederatedInteractions || !owner)
629
        throw new Error('Federated interactions are not configured');
630
      return repository.listFederatedInteractions(owner);
631
    });
632
    router.get('/v0.1/federated-interactions/:id', async (request) => {
633
      const owner = operatorId(request);
634
      if (!repository?.getFederatedInteraction || !owner)
635
        throw new Error('Federated interactions are not configured');
636
      const interaction = await repository.getFederatedInteraction(
637
        owner,
638
        (request.params as { id: string }).id,
639
      );
640
      if (!interaction) throw new Error('Interaction not found');
641
      return interaction;
642
    });
643
    router.get('/v0.1/federated-interactions/:id/session', async (request) => {
644
      const owner = operatorId(request);
645
      if (!repository?.getLiveSession || !owner)
646
        throw new Error('Live sessions are not configured');
647
      const value = z.object({ agentId: z.string().min(1) }).parse(request.query);
648
      enforceBoundAgent(request, value.agentId);
649
      return repository.getLiveSession(owner, (request.params as { id: string }).id, value.agentId);
650
    });
651
    router.get('/v0.1/federated-interactions/:id/brief', async (request) => {
652
      const owner = operatorId(request);
653
      if (!repository?.getCounterpartyBrief || !owner)
654
        throw new Error('Counterparty briefs are not configured');
655
      const value = z.object({ agentId: z.string().min(1) }).parse(request.query);
656
      enforceBoundAgent(request, value.agentId);
657
      return repository.getCounterpartyBrief(
658
        owner,
659
        (request.params as { id: string }).id,
660
        value.agentId,
661
      );
662
    });
663
    router.post('/v0.1/federated-interactions/:id/completion-reports', async (request) => {
664
      const owner = operatorId(request);
665
      if (!repository?.submitCompletionReport || !owner)
666
        throw new Error('Completion reports are not configured');
667
      const report = InteractionCompletionReportSchema.parse(request.body);
668
      if (report.interactionId !== (request.params as { id: string }).id)
669
        throw new Error('Interaction path does not match the completion report');
670
      return repository.submitCompletionReport(
671
        owner,
672
        boundAgentId(request),
673
        report,
674
        request.headers['x-openclasp-credential-type'] === 'agent_access_token'
675
          ? 'agent_access_token'
676
          : 'oauth_installation',
677
      );
678
    });
679
    router.post('/v0.1/federated-interactions/:id/feedback', async (request) => {
680
      const owner = operatorId(request);
681
      if (!repository?.submitInteractionFeedback || !owner)
682
        throw new Error('Interaction feedback is not configured');
683
      const feedback = InteractionFeedbackSchema.parse(request.body);
684
      if (feedback.interactionId !== (request.params as { id: string }).id)
685
        throw new Error('Interaction path does not match the feedback');
686
      return repository.submitInteractionFeedback(
687
        owner,
688
        boundAgentId(request),
689
        feedback,
690
        request.headers['x-openclasp-credential-type'] === 'agent_access_token'
691
          ? 'agent_access_token'
692
          : 'oauth_installation',
693
      );
694
    });
695
    router.get('/v0.1/feedback-requests', async (request) => {
696
      const owner = operatorId(request);
697
      if (!repository?.listFeedbackRequests || !owner)
698
        throw new Error('Interaction feedback is not configured');
699
      const value = z.object({ agentId: z.string().min(1) }).parse(request.query);
700
      enforceBoundAgent(request, value.agentId);
701
      return repository.listFeedbackRequests(owner, value.agentId);
702
    });
703
    router.post('/v0.1/federated-interactions', async (request) => {
704
      const owner = operatorId(request);
705
      if (!repository?.createFederatedInteraction || !owner)
706
        throw new Error('Federated interactions are not configured');
707
      const interaction = FederatedInteractionSchema.parse(request.body);
708
      const acceptance = interaction.acceptances[interaction.initiatorAgentId];
709
      if (!acceptance) throw new Error('Initiator acceptance is required');
710
      return repository.createFederatedInteraction(owner, {
711
        ...interaction,
712
        acceptances: {
713
          [interaction.initiatorAgentId]: { ...acceptance, method: 'oauth_account' },
714
        },
715
      });
716
    });
717
    router.post('/v0.1/federated-interactions/:id/respond', async (request) => {
718
      const owner = operatorId(request);
719
      if (!repository?.respondToFederatedInteraction || !owner)
720
        throw new Error('Federated interactions are not configured');
721
      const value = z
722
        .object({ agentId: z.string().min(1), decision: z.enum(['accept', 'reject']) })
723
        .parse(request.body);
724
      return repository.respondToFederatedInteraction(
725
        owner,
726
        (request.params as { id: string }).id,
727
        value.agentId,
728
        value.decision,
729
        'oauth_account',
730
      );
731
    });
732
    router.post('/v0.1/delegations', async (request) => {
733
      const value = DelegationCredentialSchema.parse(request.body);
734
      const current = await scopedEngine(request);
735
      current.delegations.set(value.delegationId, value);
736
      await persist(request, 'delegation', value.delegationId, value);
737
      return { valid: current.verifyDelegation(value.delegationId) };
738
    });
739
    router.post('/v0.1/delegations/:id/verify', async (request) => ({
740
      valid: (await scopedEngine(request)).verifyDelegation((request.params as { id: string }).id),
741
    }));
742
    router.post('/v0.1/interactions/contracts', async (request) => {
743
      const contract = (await scopedEngine(request)).saveContract(
744
        InteractionContractSchema.parse(request.body),
745
      );
746
      await persist(request, 'contract', contract.interactionId, contract);
747
      return contract;
748
    });
749
    router.post('/v0.1/events', async (request) => {
750
      const event = (await scopedEngine(request)).recordEvent(
751
        InteractionEventSchema.parse(request.body),
752
      );
753
      await persist(request, 'event', event.eventId, event);
754
      return event;
755
    });
756
    router.post('/v0.1/risk/assess', async (request) =>
757
      (await scopedEngine(request)).assess(request.body as any),
758
    );
759
    router.get('/v0.1/profiles/:id', async (request) => {
760
      const { id } = request.params as { id: string };
761
      const query = request.query as { version?: string; taskCategory?: string };
762
      return (await scopedEngine(request)).getRisk(
763
        id,
764
        query.version ?? '1.0.0',
765
        query.taskCategory ?? 'general',
766
      );
767
    });
768
    router.post('/v0.1/claims/check', async (request) =>
769
      ((value) => factChecker.check(value.claim, value.permission))(
770
        z.object({ claim: z.string(), permission: z.boolean().optional() }).parse(request.body),
771
      ),
772
    );
773
    router.post('/v0.1/receipts', async (request) => {
774
      const receipt = (await scopedEngine(request)).submitReceipt(
775
        ReceiptSchema.parse(request.body),
776
      );
777
      await persist(request, 'receipt', receipt.receiptId, receipt);
778
      return receipt;
779
    });
780
    router.post('/v0.1/receipts/verify', async (request) => {
781
      try {
782
        (await scopedEngine(request)).verifyReceipt(ReceiptSchema.parse(request.body));
783
        return { valid: true };
784
      } catch {
785
        return { valid: false };
786
      }
787
    });
788
    router.post('/v0.1/feedback', async (request) => {
789
      const feedback = FeedbackSchema.parse(request.body);
790
      const current = await scopedEngine(request);
791
      const result = current.submitFeedback(feedback);
792
      await persist(request, 'feedback', feedback.feedbackId, feedback);
793
      for (const profile of current.profiles.values())
794
        await persist(
795
          request,
796
          'profile',
797
          `${profile.agentId}|${profile.agentVersion}|${profile.taskCategory}`,
798
          profile,
799
        );
800
      return result;
801
    });
802
    router.post('/v0.1/conflicts', async (request) => {
803
      const conflict = (await scopedEngine(request)).createConflict(request.body as any);
804
      await persist(request, 'conflict', conflict.conflictId, conflict);
805
      return conflict;
806
    });
807
    router.post('/v0.1/conflicts/:id/permit', async (request) => {
808
      const conflict = (await scopedEngine(request)).permitMediation(
809
        (request.params as { id: string }).id,
810
        (request.body as { agentId: string }).agentId,
811
      );
812
      await persist(request, 'conflict', conflict.conflictId, conflict);
813
      return conflict;
814
    });
815
    router.post('/v0.1/conflicts/:id/resolve', async (request) => {
816
      const conflict = (await scopedEngine(request)).resolveConflict(
817
        (request.params as { id: string }).id,
818
        (request.body as { resolution: string }).resolution,
819
      );
820
      await persist(request, 'conflict', conflict.conflictId, conflict);
821
      return conflict;
822
    });
823
    router.post('/v0.1/revocations/agents/:id', async (request) => {
824
      const id = (request.params as { id: string }).id;
825
      const current = await scopedEngine(request);
826
      current.revokeAgent(id);
827
      await persist(request, 'agent', id, current.agents.get(id));
828
      return { revoked: true };
829
    });
830
    router.post('/v0.1/revocations/delegations/:id', async (request) => {
831
      const id = (request.params as { id: string }).id;
832
      const current = await scopedEngine(request);
833
      current.revokeDelegation(id);
834
      await persist(request, 'delegation', id, current.delegations.get(id));
835
      return { revoked: true };
836
    });
837
    router.post('/v0.1/contributions/consent', async (request) => {
838
      const value = z.object({ agentId: z.string(), enabled: z.boolean() }).parse(request.body);
839
      const current = await scopedEngine(request);
840
      current.setContributionConsent(value.agentId, value.enabled);
841
      await persist(request, 'consent', value.agentId, {
842
        agentId: value.agentId,
843
        ...current.contributionConsent.get(value.agentId),
844
      });
845
      return value;
846
    });
847
  });
848
  return app;
849
}