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

Viewing files at commit 5b7999fcbb0cCommit detailsView latest

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