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

Viewing files at commit 5239911501a5Commit detailsView latest

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