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

Viewing files at commit cd4450c423f7Commit detailsView latest

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