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

Viewing files at commit 3b628facecbcCommit detailsView latest

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