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

Viewing files at commit 874239cc591fCommit detailsView latest

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