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

Viewing files at commit 8a15fe87b720Commit detailsView latest

apps/api/src/app.ts 1252 lines · 55.4 KB · typescript
1
import Fastify from 'fastify';
2
import cors from '@fastify/cors';
3
import swagger from '@fastify/swagger';
4
import { z } from 'zod';
5
import {
6
  AgentIdentitySchema,
7
  DelegationCredentialSchema,
8
  FeedbackSchema,
9
  InteractionContractSchema,
10
  InteractionEventSchema,
11
  ReceiptSchema,
12
  FederatedInteractionSchema,
13
  LiveSessionEventSchema,
14
  InteractionCompletionReportSchema,
15
  InteractionFeedbackSchema,
16
} from '../../../packages/protocol/src/index.js';
17
import {
18
  MemoryAuditStore,
19
  TrustEngine,
20
  UnavailableFactCheckProvider,
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
  createDashboardAgent,
28
  createHostedProviderAgent,
29
  getOnboardingState,
30
  rejectAgentSetup,
31
} from '../../../packages/persistence/src/onboarding.js';
32
import { FixedWindowRateLimiter } from './security.js';
33
import {
34
  buildOwnerCompletionReport,
35
  buildOwnerFeedback,
36
  buildQuickstartInteraction,
37
} from './quickstart.js';
38
39
type DashboardRepository = Pick<
40
  HostedRepository,
41
  | 'getSettings'
42
  | 'saveSettings'
43
  | 'upsert'
44
  | 'list'
45
  | 'publishAgent'
46
  | 'unpublishAgent'
47
  | 'getPublishedAgent'
48
  | 'resolveAgentReference'
49
  | 'searchPublishedAgents'
50
> & {
51
  dashboard(operatorId: string): Promise<Record<string, unknown>>;
52
} & Partial<
53
    Pick<
54
      HostedRepository,
55
      | 'createFederatedInteraction'
56
      | 'listFederatedInteractions'
57
      | 'getFederatedInteraction'
58
      | 'respondToFederatedInteraction'
59
      | 'proposeContractRevision'
60
      | 'respondToContractRevision'
61
      | 'getLiveSession'
62
      | 'getCounterpartyBrief'
63
      | 'submitCompletionReport'
64
      | 'recordSessionCompletionReport'
65
      | 'submitInteractionFeedback'
66
      | 'recordSessionFeedback'
67
      | 'listFeedbackRequests'
68
      | 'recordLiveSessionEvent'
69
      | 'touchAgentPresence'
70
      | 'getRuntimeVerificationKey'
71
      | 'registerAgentRuntime'
72
      | 'disableAgentRuntime'
73
      | 'deleteAgent'
74
      | 'issueAgentAccessToken'
75
      | 'listAgentAccessTokens'
76
      | 'revokeAgentAccessToken'
77
      | 'receiveTemporaryMessage'
78
      | 'sendTemporaryMessage'
79
      | 'listHostedThreads'
80
      | 'getHostedThread'
81
      | 'markHostedThreadRead'
82
      | 'closeHostedThread'
83
    >
84
  > & {
85
    listContextualIntelligence?: (
86
      operatorId: string,
87
      input?: { agentId?: string; taskCategory?: string },
88
    ) => Promise<any[]>;
89
    searchPersonalizedMarketplace?: (
90
      operatorId: string,
91
      input?: { agentId?: string; taskCategory?: string; query?: string; limit?: number },
92
    ) => Promise<any[]>;
93
  };
94
95
export function buildApi(
96
  engine = new TrustEngine(),
97
  factChecker = new UnavailableFactCheckProvider(),
98
  repository?: DashboardRepository,
99
  security: { internalAuthSecret?: string } = {},
100
) {
101
  const app = Fastify({
102
    bodyLimit: 256 * 1024,
103
    requestTimeout: 30_000,
104
    logger: {
105
      redact: [
106
        'req.headers.authorization',
107
        'req.headers.cookie',
108
        'req.headers.x-openclasp-internal-auth',
109
        'req.body.rawMessage',
110
        'req.body.privateKey',
111
      ],
112
    },
113
  });
114
  const engines = new Map<string, Promise<TrustEngine>>();
115
  const limiter = new FixedWindowRateLimiter();
116
  const allowedOrigin = process.env.OPENCLASP_PUBLIC_URL
117
    ? new URL(process.env.OPENCLASP_PUBLIC_URL).origin
118
    : undefined;
119
  void app.register(cors, {
120
    credentials: Boolean(repository),
121
    origin(origin, callback) {
122
      callback(null, !origin || !allowedOrigin || origin === allowedOrigin);
123
    },
124
  });
125
  void app.register(swagger, { openapi: { info: { title: 'OpenClasp API', version: '0.1.0' } } });
126
127
  app.addHook('onRequest', async (request, reply) => {
128
    const method = request.method.toUpperCase();
129
    if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return;
130
    const operator = request.headers['x-openclasp-operator'];
131
    const bucket = typeof operator === 'string' ? `operator:${operator}` : `ip:${request.ip}`;
132
    const externalSession = request.url.startsWith('/sessions/') || request.url.startsWith('/a2a/');
133
    const result = limiter.consume(bucket, externalSession ? 300 : 120, 60_000);
134
    reply.header('x-ratelimit-remaining', result.remaining);
135
    if (!result.allowed)
136
      return reply
137
        .status(429)
138
        .header('retry-after', result.retryAfterSeconds)
139
        .send({ error: 'rate_limit_exceeded' });
140
  });
141
142
  app.addHook('onSend', async (_request, reply, payload) => {
143
    reply.header('x-content-type-options', 'nosniff');
144
    reply.header('referrer-policy', 'no-referrer');
145
    reply.header('permissions-policy', 'camera=(), microphone=(), geolocation=()');
146
    return payload;
147
  });
148
149
  app.setErrorHandler((error, _request, reply) =>
150
    reply
151
      .status(
152
        error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number'
153
          ? error.statusCode
154
          : 400,
155
      )
156
      .send({ error: error instanceof Error ? error.message : 'Unknown error' }),
157
  );
158
  void app.register(async (router) => {
159
    const operatorId = (request: { headers: Record<string, unknown> }) => {
160
      const value = request.headers['x-openclasp-operator'];
161
      if (
162
        repository &&
163
        security.internalAuthSecret &&
164
        request.headers['x-openclasp-internal-auth'] !== security.internalAuthSecret
165
      ) {
166
        const error = new Error('Trusted authentication boundary required');
167
        Object.assign(error, { statusCode: 401 });
168
        throw error;
169
      }
170
      if (repository && typeof value !== 'string') {
171
        const error = new Error('Authentication required');
172
        Object.assign(error, { statusCode: 401 });
173
        throw error;
174
      }
175
      return typeof value === 'string' ? value : undefined;
176
    };
177
    const boundAgentId = (request: { headers: Record<string, unknown> }) => {
178
      const value = request.headers['x-openclasp-bound-agent'];
179
      if (typeof value !== 'string' || !value) {
180
        const error = new Error('Agent-bound authentication required');
181
        Object.assign(error, { statusCode: 401 });
182
        throw error;
183
      }
184
      return value;
185
    };
186
    const enforceBoundAgent = (
187
      request: { headers: Record<string, unknown> },
188
      requestedAgentId: string,
189
    ) => {
190
      const value = request.headers['x-openclasp-bound-agent'];
191
      if (typeof value === 'string' && value !== requestedAgentId) {
192
        const error = new Error('Agent credential cannot access another agent');
193
        Object.assign(error, { statusCode: 403 });
194
        throw error;
195
      }
196
    };
197
    const scopedEngine = async (request: { headers: Record<string, unknown> }) => {
198
      const owner = operatorId(request);
199
      if (!repository || !owner) return engine;
200
      let pending = engines.get(owner);
201
      if (!pending) {
202
        pending = repository.list(owner).then((rows) => {
203
          const store = new MemoryAuditStore();
204
          for (const row of rows) {
205
            if (row.kind !== 'interaction') store.append(row.kind, row.recordId, row.payload);
206
          }
207
          return new TrustEngine(store);
208
        });
209
        engines.set(owner, pending);
210
      }
211
      return pending;
212
    };
213
    const persist = async (
214
      request: { headers: Record<string, unknown> },
215
      kind: Parameters<HostedRepository['upsert']>[1],
216
      id: string,
217
      value: unknown,
218
    ) => {
219
      const owner = operatorId(request);
220
      if (repository && owner) await repository.upsert(owner, kind, id, value);
221
    };
222
    const publicBaseUrl = (request: { headers: Record<string, unknown> }) => {
223
      const forwardedHost = request.headers['x-forwarded-host'];
224
      const host = typeof forwardedHost === 'string' ? forwardedHost : request.headers.host;
225
      const forwardedProtocol = request.headers['x-forwarded-proto'];
226
      const protocol = typeof forwardedProtocol === 'string' ? forwardedProtocol : 'https';
227
      return process.env.OPENCLASP_PUBLIC_URL ?? `${protocol}://${String(host ?? 'localhost')}`;
228
    };
229
    const escapeHtml = (value: string) =>
230
      value.replace(/[&<>"']/g, (character) => {
231
        const entities: Record<string, string> = {
232
          '&': '&amp;',
233
          '<': '&lt;',
234
          '>': '&gt;',
235
          '"': '&quot;',
236
          "'": '&#39;',
237
        };
238
        return entities[character]!;
239
      });
240
    router.get('/health', async () => ({ status: 'ok' }));
241
    router.get('/ready', async () => ({ status: 'ready' }));
242
    router.get('/openapi.json', async () => app.swagger());
243
    router.get('/.well-known/openclasp-session-key', async () => {
244
      if (!repository?.getRuntimeVerificationKey)
245
        throw new Error('Runtime verification is not configured');
246
      return repository.getRuntimeVerificationKey();
247
    });
248
    router.get('/extensions/trust/v0.1', async () => ({
249
      uri: 'https://openclasp.vercel.app/extensions/trust/v0.1',
250
      name: 'OpenClasp A2A assurance extension',
251
      version: '0.1',
252
      required: false,
253
      transportsMessages: false,
254
      temporaryChatAdapter: true,
255
      documentation: 'https://github.com/aryaamin/OpenClasp/blob/main/docs/A2A_EXTENSION.md',
256
    }));
257
    router.post('/sessions/:id/events', async (request, reply) => {
258
      if (!repository?.recordLiveSessionEvent)
259
        throw new Error('Live-session reporting is not configured');
260
      const authorization = request.headers.authorization;
261
      if (!authorization?.startsWith('Bearer ')) {
262
        reply.status(401);
263
        return { error: 'session_credential_required' };
264
      }
265
      const event = LiveSessionEventSchema.parse(request.body);
266
      if (event.interactionId !== (request.params as { id: string }).id)
267
        throw new Error('Interaction path does not match the event');
268
      return repository.recordLiveSessionEvent(authorization.slice(7), event);
269
    });
270
    router.post('/sessions/:id/completion-reports', async (request, reply) => {
271
      if (!repository?.recordSessionCompletionReport)
272
        throw new Error('Live-session completion reporting is not configured');
273
      const authorization = request.headers.authorization;
274
      if (!authorization?.startsWith('Bearer ')) {
275
        reply.status(401);
276
        return { error: 'session_credential_required' };
277
      }
278
      const report = InteractionCompletionReportSchema.parse(request.body);
279
      if (report.interactionId !== (request.params as { id: string }).id)
280
        throw new Error('Interaction path does not match the completion report');
281
      return repository.recordSessionCompletionReport(authorization.slice(7), report);
282
    });
283
    router.post('/sessions/:id/feedback', async (request, reply) => {
284
      if (!repository?.recordSessionFeedback)
285
        throw new Error('Live-session feedback is not configured');
286
      const authorization = request.headers.authorization;
287
      if (!authorization?.startsWith('Bearer ')) {
288
        reply.status(401);
289
        return { error: 'session_credential_required' };
290
      }
291
      const feedback = InteractionFeedbackSchema.parse(request.body);
292
      if (feedback.interactionId !== (request.params as { id: string }).id)
293
        throw new Error('Interaction path does not match the feedback');
294
      return repository.recordSessionFeedback(authorization.slice(7), feedback);
295
    });
296
    router.post('/a2a/temporary/:id', async (request, reply) => {
297
      if (!repository?.receiveTemporaryMessage)
298
        throw new Error('Temporary chat delivery is not configured');
299
      const authorization = request.headers.authorization;
300
      if (!authorization?.startsWith('Bearer '))
301
        return reply.status(401).send({ error: 'session_credential_required' });
302
      const rpc = z
303
        .object({
304
          jsonrpc: z.literal('2.0'),
305
          id: z.union([z.string().min(1).max(200), z.number()]),
306
          method: z.literal('message/send'),
307
          params: z.object({
308
            message: z.object({
309
              parts: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
310
            }),
311
          }),
312
        })
313
        .parse(request.body);
314
      const content = rpc.params.message.parts
315
        .map((part) => (typeof part.text === 'string' ? part.text : ''))
316
        .filter(Boolean)
317
        .join('\n')
318
        .trim();
319
      if (!content) throw new Error('Temporary chat MVP accepts text parts only');
320
      const recipientAgentId = (request.params as { id: string }).id;
321
      const result = await repository.receiveTemporaryMessage(
322
        authorization.slice(7),
323
        recipientAgentId,
324
        String(rpc.id),
325
        content,
326
      );
327
      return {
328
        jsonrpc: '2.0',
329
        id: rpc.id,
330
        result: {
331
          task: { id: result.message.messageId, state: 'submitted' },
332
          privacyMode: 'openclasp_hosted_temporary',
333
          deduplicated: result.deduplicated,
334
        },
335
      };
336
    });
337
    router.get('/agents/:id/card.json', async (request) => {
338
      if (!repository) throw new Error('Hosted persistence is not configured');
339
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
340
      if (!card) throw new Error('Published agent not found');
341
      return card;
342
    });
343
    router.get('/agents/:id/.well-known/openclasp-agent.json', async (request) => {
344
      if (!repository) throw new Error('Hosted persistence is not configured');
345
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
346
      if (!card) throw new Error('Published agent not found');
347
      return card;
348
    });
349
    router.get('/agents/:id/a2a-agent-card.json', async (request) => {
350
      if (!repository) throw new Error('Hosted persistence is not configured');
351
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
352
      if (!card) throw new Error('Published agent not found');
353
      if (!card.transports.length) throw new Error('Agent has not published an A2A endpoint');
354
      return toA2AAgentCard(card);
355
    });
356
    router.get('/directory/resolve', async (request) => {
357
      if (!repository) throw new Error('Hosted persistence is not configured');
358
      const { reference } = z
359
        .object({ reference: z.string().min(1).max(2048) })
360
        .parse(request.query);
361
      const result = await repository.resolveAgentReference(reference);
362
      if (!result) throw new Error('Published agent not found');
363
      return result;
364
    });
365
    router.get('/directory/search', async (request) => {
366
      if (!repository) throw new Error('Hosted persistence is not configured');
367
      const input = z
368
        .object({
369
          query: z.string().trim().max(100).optional(),
370
          capability: z.string().trim().max(100).optional(),
371
          limit: z.coerce.number().int().min(1).max(50).optional(),
372
        })
373
        .parse(request.query);
374
      return repository.searchPublishedAgents(input);
375
    });
376
    router.get('/a/:reference', async (request, reply) => {
377
      if (!repository) throw new Error('Hosted persistence is not configured');
378
      const result = await repository.resolveAgentReference(
379
        (request.params as { reference: string }).reference,
380
      );
381
      if (!result) throw new Error('Published agent not found');
382
      const { card } = result;
383
      const capabilities = card.capabilities
384
        .map((capability) => `<li>${escapeHtml(capability)}</li>`)
385
        .join('');
386
      const limitations = card.limitations
387
        .map((limitation) => `<li>${escapeHtml(limitation)}</li>`)
388
        .join('');
389
      const structured = JSON.stringify({
390
        '@context': 'https://schema.org',
391
        '@type': 'SoftwareApplication',
392
        name: card.name,
393
        description: card.description,
394
        url: card.profileUrl,
395
      }).replace(/</g, '\\u003c');
396
      return reply.type('text/html; charset=utf-8').send(`<!doctype html>
397
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
398
<title>${escapeHtml(card.name)} · OpenClasp</title><meta name="description" content="${escapeHtml(card.description)}">
399
<meta property="og:title" content="${escapeHtml(card.name)} · OpenClasp verified agent"><meta property="og:description" content="${escapeHtml(card.description)}">
400
<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>
401
<script type="application/ld+json">${structured}</script></head><body><main><div class="brand">OpenClasp</div><div class="verified">✓ Account and agent ownership verified</div>
402
<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>
403
<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>
404
<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>`);
405
    });
406
    router.get('/v0.1/dashboard', async (request) => {
407
      const owner = operatorId(request);
408
      if (repository && owner) return repository.dashboard(owner);
409
      return {
410
        agents: [...engine.agents.values()],
411
        projects: [],
412
        installations: [],
413
        setupRequests: [],
414
        publications: [],
415
        interactions: [],
416
        federatedInteractions: [],
417
        liveSessions: [],
418
        hostedThreads: [],
419
        events: [...engine.events.values()],
420
        conflicts: [...engine.conflicts.values()],
421
        receipts: [...engine.receipts.values()],
422
        profiles: [...engine.profiles.values()],
423
        counterpartyBriefs: [],
424
        completionReports: [],
425
        feedbackRequests: [],
426
        interactionFeedback: [],
427
        interactionConclusions: [],
428
        learningEligibility: [],
429
        profileDeltas: [],
430
        intelligenceSummaries: [],
431
        runtimes: [],
432
      };
433
    });
434
    router.get('/v0.1/intelligence', async (request) => {
435
      const owner = operatorId(request);
436
      if (!repository?.listContextualIntelligence || !owner)
437
        throw new Error('Contextual intelligence is not configured');
438
      const input = z
439
        .object({
440
          agentId: z.string().min(1).optional(),
441
          taskCategory: z.string().trim().min(1).max(100).optional(),
442
        })
443
        .parse(request.query);
444
      return repository.listContextualIntelligence(owner, {
445
        ...(input.agentId ? { agentId: input.agentId } : {}),
446
        ...(input.taskCategory ? { taskCategory: input.taskCategory } : {}),
447
      });
448
    });
449
    router.get('/v0.1/marketplace', async (request) => {
450
      const owner = operatorId(request);
451
      if (!repository?.searchPersonalizedMarketplace || !owner)
452
        throw new Error('Personalized marketplace is not configured');
453
      const input = z
454
        .object({
455
          agentId: z.string().min(1).optional(),
456
          taskCategory: z.string().trim().min(1).max(100).optional(),
457
          query: z.string().trim().max(100).optional(),
458
          limit: z.coerce.number().int().min(1).max(50).default(30),
459
        })
460
        .parse(request.query);
461
      return repository.searchPersonalizedMarketplace(owner, {
462
        ...(input.agentId ? { agentId: input.agentId } : {}),
463
        ...(input.taskCategory ? { taskCategory: input.taskCategory } : {}),
464
        ...(input.query ? { query: input.query } : {}),
465
        limit: input.limit,
466
      });
467
    });
468
    router.get('/v0.1/account', async (request) => {
469
      const owner = operatorId(request);
470
      return {
471
        operatorId: owner,
472
        email: decodeURIComponent(String(request.headers['x-openclasp-email'] ?? '')),
473
        name: decodeURIComponent(String(request.headers['x-openclasp-name'] ?? '')),
474
      };
475
    });
476
    router.get('/v0.1/settings', async (request) => {
477
      const owner = operatorId(request);
478
      if (!repository || !owner)
479
        return {
480
          displayName: '',
481
          contributionEnabled: false,
482
          retentionDays: 30,
483
          evidenceSharing: 'ask',
484
          rawConversationsStored: false,
485
        };
486
      return repository.getSettings(owner);
487
    });
488
    router.get('/v0.1/onboarding', async (request) => {
489
      const owner = operatorId(request);
490
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
491
      return getOnboardingState(repository, owner);
492
    });
493
    router.get('/v0.1/runtime/bootstrap', async (request) => {
494
      const owner = operatorId(request);
495
      const agentId = boundAgentId(request);
496
      const rows = owner && repository ? await repository.list(owner) : [];
497
      const profile = rows.find((row) => row.kind === 'agent_profile' && row.recordId === agentId)
498
        ?.payload as AgentProfile | undefined;
499
      return {
500
        agentId,
501
        agentVersion: profile?.agentVersion ?? '1.0.0',
502
        capabilities: profile?.capabilities ?? [],
503
        limitations: profile?.limitations ?? [],
504
        openClaspUrl: publicBaseUrl(request),
505
        runtimeRegistrationEndpoint: `${publicBaseUrl(request)}/v0.1/runtime`,
506
        protocol: 'A2A/1.0',
507
        controlProtocol: 'OpenClasp/0.1',
508
      };
509
    });
510
    router.put('/v0.1/runtime', async (request) => {
511
      const owner = operatorId(request);
512
      if (!repository?.registerAgentRuntime || !owner)
513
        throw new Error('Hosted runtime delivery is not configured');
514
      const endpoint = z
515
        .object({ endpoint: z.string().url().max(2048) })
516
        .parse(request.body).endpoint;
517
      return repository.registerAgentRuntime(owner, boundAgentId(request), endpoint);
518
    });
519
    router.delete('/v0.1/runtime', async (request) => {
520
      const owner = operatorId(request);
521
      if (!repository?.disableAgentRuntime || !owner)
522
        throw new Error('Hosted runtime delivery is not configured');
523
      return repository.disableAgentRuntime(owner, boundAgentId(request));
524
    });
525
    router.post('/v0.1/runtime/heartbeat', async (request) => {
526
      const owner = operatorId(request);
527
      if (!repository?.touchAgentPresence || !owner)
528
        throw new Error('Agent presence is not configured');
529
      return repository.touchAgentPresence(owner, boundAgentId(request));
530
    });
531
    router.put('/v0.1/runtime/profile', async (request) => {
532
      const owner = operatorId(request);
533
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
534
      const agentId = boundAgentId(request);
535
      const value = z
536
        .object({
537
          description: z.string().trim().max(500).optional(),
538
          capabilities: z.array(z.string().trim().min(1).max(100)).min(1).max(100),
539
          limitations: z.array(z.string().trim().min(1).max(300)).max(100).default([]),
540
        })
541
        .strict()
542
        .parse(request.body);
543
      const rows = await repository.list(owner);
544
      const current = rows.find((row) => row.kind === 'agent_profile' && row.recordId === agentId)
545
        ?.payload as AgentProfile | undefined;
546
      if (!current || current.status !== 'active') throw new Error('Active owned agent not found');
547
      const profile: AgentProfile = {
548
        ...current,
549
        ...(value.description !== undefined ? { description: value.description } : {}),
550
        capabilities: [...new Set(value.capabilities)],
551
        limitations: [...new Set(value.limitations)],
552
        updatedAt: new Date().toISOString(),
553
      };
554
      await repository.upsert(owner, 'agent_profile', agentId, profile);
555
      const publication = rows.find((row) => row.kind === 'publication' && row.recordId === agentId)
556
        ?.payload as { published?: boolean } | undefined;
557
      if (profile.autoPublish || publication?.published) {
558
        const previous = await repository.getPublishedAgent(agentId);
559
        const card = await repository.publishAgent(
560
          owner,
561
          buildPublicAgentCard(profile, publicBaseUrl(request), previous),
562
        );
563
        await repository.upsert(owner, 'publication', agentId, {
564
          agentId,
565
          published: true,
566
          updatedAt: card.updatedAt,
567
        });
568
        return { profile, card };
569
      }
570
      return { profile };
571
    });
572
    router.post('/v0.1/onboarding/:id/approve', async (request) => {
573
      const owner = operatorId(request);
574
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
575
      const requestId = (request.params as { id: string }).id;
576
      const state = await getOnboardingState(repository, owner);
577
      const setup = state.setupRequests.find((item) => item.requestId === requestId);
578
      const result = await approveAgentSetup(repository, owner, requestId);
579
      if (result.status === 'connected' && setup?.autoPublish) {
580
        const previous = await repository.getPublishedAgent(result.agent.agentId);
581
        const card = await repository.publishAgent(
582
          owner,
583
          buildPublicAgentCard(result.agent, publicBaseUrl(request), previous),
584
        );
585
        await repository.upsert(owner, 'publication', result.agent.agentId, {
586
          agentId: result.agent.agentId,
587
          published: true,
588
          updatedAt: card.updatedAt,
589
        });
590
      }
591
      return result;
592
    });
593
    router.post('/v0.1/onboarding/:id/reject', async (request) => {
594
      const owner = operatorId(request);
595
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
596
      return rejectAgentSetup(repository, owner, (request.params as { id: string }).id);
597
    });
598
    router.put('/v0.1/settings', async (request) => {
599
      const owner = operatorId(request);
600
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
601
      const value = z
602
        .object({
603
          displayName: z.string().trim().max(100),
604
          contributionEnabled: z.boolean(),
605
          retentionDays: z.number().int().min(0).max(3650),
606
          evidenceSharing: z.enum(['never', 'ask', 'contract_only']),
607
        })
608
        .parse(request.body);
609
      return repository.saveSettings(owner, value);
610
    });
611
    router.post('/v0.1/provider-connections', async (request) => {
612
      const owner = operatorId(request);
613
      if (!repository?.issueAgentAccessToken || !owner)
614
        throw new Error('Hosted provider connections are not configured');
615
      const value = z
616
        .object({
617
          provider: z.enum(['botpress', 'custom']),
618
          agentName: z.string().trim().min(1).max(100),
619
          projectName: z.string().trim().min(1).max(100),
620
          description: z.string().trim().min(1).max(500),
621
          capabilities: z.array(z.string().trim().min(1).max(100)).min(1).max(100),
622
          limitations: z.array(z.string().trim().min(1).max(300)).max(100).default([]),
623
          expiresInDays: z.number().int().min(1).max(365).default(365),
624
        })
625
        .parse(request.body);
626
      const created = await createHostedProviderAgent(repository, owner, value);
627
      try {
628
        const accessToken = await repository.issueAgentAccessToken(owner, created.agent.agentId, {
629
          name: 'Botpress',
630
          expiresInDays: value.expiresInDays,
631
        });
632
        return { ...created, provider: value.provider, accessToken };
633
      } catch (error) {
634
        if (repository.deleteAgent)
635
          await repository.deleteAgent(owner, created.agent.agentId).catch(() => undefined);
636
        throw error;
637
      }
638
    });
639
    router.post('/v0.1/quickstart/agent', async (request) => {
640
      const owner = operatorId(request);
641
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
642
      const value = z
643
        .object({
644
          agentName: z.string().trim().min(1).max(100),
645
          projectName: z.string().trim().min(1).max(100).default('My agents'),
646
          description: z.string().trim().min(1).max(500),
647
          capabilities: z.array(z.string().trim().min(1).max(100)).min(1).max(20),
648
          limitations: z.array(z.string().trim().min(1).max(300)).max(20).default([]),
649
        })
650
        .strict()
651
        .parse(request.body);
652
      const created = await createDashboardAgent(repository, owner, value);
653
      const card = await repository.publishAgent(
654
        owner,
655
        buildPublicAgentCard(created.agent, publicBaseUrl(request)),
656
      );
657
      await repository.upsert(owner, 'publication', created.agent.agentId, {
658
        agentId: created.agent.agentId,
659
        published: true,
660
        updatedAt: card.updatedAt,
661
      });
662
      return { ...created, card };
663
    });
664
    router.post('/v0.1/agents', async (request) => {
665
      const owner = operatorId(request);
666
      const current = await scopedEngine(request);
667
      const identity = AgentIdentitySchema.parse(request.body);
668
      if (owner && identity.operatorRef !== `auth0:${owner}`)
669
        throw new Error('Agent identity is not owned by the authenticated operator');
670
      const registered = current.registerAgent(identity);
671
      await persist(request, 'agent', identity.agentId, registered);
672
      return registered;
673
    });
674
    router.get('/v0.1/agents/:id', async (request) => {
675
      const { id } = request.params as { id: string };
676
      const value = (await scopedEngine(request)).agents.get(id);
677
      if (!value) throw new Error('Agent not found');
678
      return value;
679
    });
680
    router.post('/v0.1/agents/:id/publication', async (request) => {
681
      const owner = operatorId(request);
682
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
683
      const { id } = request.params as { id: string };
684
      const { published } = z.object({ published: z.boolean() }).parse(request.body);
685
      const rows = await repository.list(owner);
686
      const agent = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
687
        ?.payload as AgentProfile | undefined;
688
      if (!agent) throw new Error('Owned agent not found');
689
      if (agent.status !== 'active') throw new Error('Revoked agents cannot be published');
690
      if (!published) {
691
        await repository.unpublishAgent(owner, id);
692
        const publication = { agentId: id, published: false, updatedAt: new Date().toISOString() };
693
        await repository.upsert(owner, 'publication', id, publication);
694
        return publication;
695
      }
696
      const previous = await repository.getPublishedAgent(id);
697
      const card = await repository.publishAgent(
698
        owner,
699
        buildPublicAgentCard(agent, publicBaseUrl(request), previous),
700
      );
701
      const publication = { agentId: id, published: true, updatedAt: card.updatedAt };
702
      await repository.upsert(owner, 'publication', id, publication);
703
      return { ...publication, card };
704
    });
705
    router.put('/v0.1/agents/:id/automation', async (request) => {
706
      const owner = operatorId(request);
707
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
708
      const { id } = request.params as { id: string };
709
      const value = z
710
        .object({
711
          autoPublish: z.boolean(),
712
          autoAcceptPolicy: z.enum(['off', 'safe_matching']),
713
          autoAcceptTaskCategories: z.array(z.string().trim().min(1).max(100)).max(100),
714
        })
715
        .parse(request.body);
716
      const rows = await repository.list(owner);
717
      const current = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
718
        ?.payload as AgentProfile | undefined;
719
      if (!current) throw new Error('Owned agent not found');
720
      const agent: AgentProfile = {
721
        ...current,
722
        transport: 'direct_a2a',
723
        description: current.description ?? '',
724
        agentVersion: current.agentVersion ?? '1.0.0',
725
        autoPublish: value.autoPublish,
726
        autoAcceptPolicy: value.autoAcceptPolicy,
727
        autoAcceptTaskCategories: [...new Set(value.autoAcceptTaskCategories)],
728
        updatedAt: new Date().toISOString(),
729
      };
730
      await repository.upsert(owner, 'agent_profile', id, agent);
731
      if (value.autoPublish) {
732
        const previous = await repository.getPublishedAgent(id);
733
        const card = await repository.publishAgent(
734
          owner,
735
          buildPublicAgentCard(agent, publicBaseUrl(request), previous),
736
        );
737
        await repository.upsert(owner, 'publication', id, {
738
          agentId: id,
739
          published: true,
740
          updatedAt: card.updatedAt,
741
        });
742
      } else {
743
        await repository.unpublishAgent(owner, id);
744
        await repository.upsert(owner, 'publication', id, {
745
          agentId: id,
746
          published: false,
747
          updatedAt: new Date().toISOString(),
748
        });
749
      }
750
      return agent;
751
    });
752
    router.put('/v0.1/agents/:id/runtime', async (request) => {
753
      const owner = operatorId(request);
754
      if (!repository?.registerAgentRuntime || !owner)
755
        throw new Error('Hosted runtime delivery is not configured');
756
      const endpoint = z
757
        .object({ endpoint: z.string().url().max(2048) })
758
        .parse(request.body).endpoint;
759
      return repository.registerAgentRuntime(
760
        owner,
761
        (request.params as { id: string }).id,
762
        endpoint,
763
      );
764
    });
765
    router.delete('/v0.1/agents/:id/runtime', async (request) => {
766
      const owner = operatorId(request);
767
      if (!repository?.disableAgentRuntime || !owner)
768
        throw new Error('Hosted runtime delivery is not configured');
769
      return repository.disableAgentRuntime(owner, (request.params as { id: string }).id);
770
    });
771
    router.get('/v0.1/agents/:id/access-tokens', async (request) => {
772
      const owner = operatorId(request);
773
      if (!repository?.listAgentAccessTokens || !owner)
774
        throw new Error('Agent access tokens are not configured');
775
      return repository.listAgentAccessTokens(owner, (request.params as { id: string }).id);
776
    });
777
    router.post('/v0.1/agents/:id/access-tokens', async (request) => {
778
      const owner = operatorId(request);
779
      if (!repository?.issueAgentAccessToken || !owner)
780
        throw new Error('Agent access tokens are not configured');
781
      const value = z
782
        .object({
783
          name: z.string().trim().min(1).max(100),
784
          expiresInDays: z.number().int().min(1).max(365).default(365),
785
        })
786
        .parse(request.body);
787
      return repository.issueAgentAccessToken(owner, (request.params as { id: string }).id, value);
788
    });
789
    router.delete('/v0.1/agents/:id/access-tokens/:tokenId', async (request) => {
790
      const owner = operatorId(request);
791
      if (!repository?.revokeAgentAccessToken || !owner)
792
        throw new Error('Agent access tokens are not configured');
793
      const params = request.params as { id: string; tokenId: string };
794
      return repository.revokeAgentAccessToken(owner, params.id, params.tokenId);
795
    });
796
    router.delete('/v0.1/agents/:id', async (request) => {
797
      const owner = operatorId(request);
798
      if (!repository?.deleteAgent || !owner)
799
        throw new Error('Hosted agent deletion is not configured');
800
      const result = await repository.deleteAgent(owner, (request.params as { id: string }).id);
801
      engines.delete(owner);
802
      return result;
803
    });
804
    router.get('/v0.1/agents/:id/threads', async (request) => {
805
      const owner = operatorId(request);
806
      if (!repository?.listHostedThreads || !owner)
807
        throw new Error('Temporary chat history is not configured');
808
      return repository.listHostedThreads(owner, (request.params as { id: string }).id);
809
    });
810
    router.get('/v0.1/agents/:id/threads/:threadId', async (request) => {
811
      const owner = operatorId(request);
812
      if (!repository?.getHostedThread || !owner)
813
        throw new Error('Temporary chat history is not configured');
814
      const params = request.params as { id: string; threadId: string };
815
      return repository.getHostedThread(owner, params.id, params.threadId);
816
    });
817
    router.post('/v0.1/agents/:id/messages', async (request) => {
818
      const owner = operatorId(request);
819
      if (!repository?.sendTemporaryMessage || !owner)
820
        throw new Error('Temporary chat delivery is not configured');
821
      const value = z
822
        .object({ interactionId: z.string().uuid(), content: z.string().trim().min(1).max(20_000) })
823
        .parse(request.body);
824
      return repository.sendTemporaryMessage(
825
        owner,
826
        (request.params as { id: string }).id,
827
        value.interactionId,
828
        value.content,
829
      );
830
    });
831
    router.post('/v0.1/agents/:id/threads/:threadId/read', async (request) => {
832
      const owner = operatorId(request);
833
      if (!repository?.markHostedThreadRead || !owner)
834
        throw new Error('Temporary chat history is not configured');
835
      const params = request.params as { id: string; threadId: string };
836
      return repository.markHostedThreadRead(owner, params.id, params.threadId);
837
    });
838
    router.post('/v0.1/agents/:id/threads/:threadId/close', async (request) => {
839
      const owner = operatorId(request);
840
      if (!repository?.closeHostedThread || !owner)
841
        throw new Error('Temporary chat history is not configured');
842
      const params = request.params as { id: string; threadId: string };
843
      return repository.closeHostedThread(owner, params.id, params.threadId);
844
    });
845
    router.get('/v0.1/directory', async (request) => {
846
      operatorId(request);
847
      if (!repository) throw new Error('Hosted persistence is not configured');
848
      const query = z
849
        .object({
850
          query: z.string().trim().max(100).optional(),
851
          capability: z.string().trim().max(100).optional(),
852
          limit: z.coerce.number().int().min(1).max(50).optional(),
853
        })
854
        .parse(request.query);
855
      return repository.searchPublishedAgents(query);
856
    });
857
    router.get('/v0.1/directory/:id', async (request) => {
858
      operatorId(request);
859
      if (!repository) throw new Error('Hosted persistence is not configured');
860
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
861
      if (!card) throw new Error('Published agent not found');
862
      return card;
863
    });
864
    router.get('/v0.1/federated-interactions', async (request) => {
865
      const owner = operatorId(request);
866
      if (!repository?.listFederatedInteractions || !owner)
867
        throw new Error('Federated interactions are not configured');
868
      return repository.listFederatedInteractions(owner);
869
    });
870
    router.post('/v0.1/federated-interactions/start', async (request) => {
871
      const owner = operatorId(request);
872
      if (!repository?.createFederatedInteraction || !owner)
873
        throw new Error('Federated interactions are not configured');
874
      const value = z
875
        .object({
876
          initiatorAgentId: z.string().min(1),
877
          responderAgentId: z.string().min(1),
878
          task: z.string().trim().min(10).max(2000),
879
          requestedOutcome: z.string().trim().min(3).max(1000),
880
          successCriterion: z.string().trim().min(3).max(1000),
881
          taskCategory: z.string().trim().min(1).max(100).optional(),
882
          deadline: z.string().datetime().optional(),
883
        })
884
        .strict()
885
        .refine((input) => !input.deadline || Date.parse(input.deadline) > Date.now(), {
886
          message: 'Deadline must be in the future',
887
          path: ['deadline'],
888
        })
889
        .parse(request.body);
890
      const [initiator, responder] = await Promise.all([
891
        repository.getPublishedAgent(value.initiatorAgentId),
892
        repository.getPublishedAgent(value.responderAgentId),
893
      ]);
894
      if (!initiator) throw new Error('Publish the initiating agent before starting an agreement');
895
      if (!responder) throw new Error('The selected counterparty is no longer available');
896
      if (repository.listFederatedInteractions) {
897
        const existing = (await repository.listFederatedInteractions(owner)).find(
898
          (candidate) =>
899
            ['pending', 'active'].includes(candidate.status) &&
900
            candidate.initiatorAgentId === value.initiatorAgentId &&
901
            candidate.responderAgentId === value.responderAgentId &&
902
            candidate.contract.purpose === value.task.slice(0, 500) &&
903
            candidate.contract.requestedOutcome === value.requestedOutcome &&
904
            candidate.contract.successCriteria[0] === value.successCriterion,
905
        );
906
        if (existing) return existing;
907
      }
908
      return repository.createFederatedInteraction(
909
        owner,
910
        buildQuickstartInteraction(initiator, responder, value),
911
      );
912
    });
913
    router.get('/v0.1/federated-interactions/:id', async (request) => {
914
      const owner = operatorId(request);
915
      if (!repository?.getFederatedInteraction || !owner)
916
        throw new Error('Federated interactions are not configured');
917
      const interaction = await repository.getFederatedInteraction(
918
        owner,
919
        (request.params as { id: string }).id,
920
      );
921
      if (!interaction) throw new Error('Interaction not found');
922
      return interaction;
923
    });
924
    router.get('/v0.1/federated-interactions/:id/session', async (request) => {
925
      const owner = operatorId(request);
926
      if (!repository?.getLiveSession || !owner)
927
        throw new Error('Live sessions are not configured');
928
      const value = z.object({ agentId: z.string().min(1) }).parse(request.query);
929
      enforceBoundAgent(request, value.agentId);
930
      return repository.getLiveSession(owner, (request.params as { id: string }).id, value.agentId);
931
    });
932
    router.get('/v0.1/federated-interactions/:id/brief', async (request) => {
933
      const owner = operatorId(request);
934
      if (!repository?.getCounterpartyBrief || !owner)
935
        throw new Error('Counterparty briefs are not configured');
936
      const value = z.object({ agentId: z.string().min(1) }).parse(request.query);
937
      enforceBoundAgent(request, value.agentId);
938
      return repository.getCounterpartyBrief(
939
        owner,
940
        (request.params as { id: string }).id,
941
        value.agentId,
942
      );
943
    });
944
    router.post('/v0.1/federated-interactions/:id/complete', async (request) => {
945
      const owner = operatorId(request);
946
      if (!repository?.getFederatedInteraction || !repository.submitCompletionReport || !owner)
947
        throw new Error('Interaction completion is not configured');
948
      const interactionId = (request.params as { id: string }).id;
949
      const value = z
950
        .object({
951
          agentId: z.string().min(1),
952
          outcome: z.enum(['success', 'partial', 'failure', 'cancelled']),
953
          summary: z.string().trim().min(3).max(2000),
954
          evidenceReferences: z.array(z.string().url().max(2048)).max(20).default([]),
955
        })
956
        .strict()
957
        .parse(request.body);
958
      const [interaction, rows] = await Promise.all([
959
        repository.getFederatedInteraction(owner, interactionId),
960
        repository.list(owner),
961
      ]);
962
      if (!interaction) throw new Error('Interaction not found');
963
      if (!['active', 'completed'].includes(interaction.status))
964
        throw new Error('The agreement must be active before reporting its outcome');
965
      const duplicate = rows.find(
966
        (row) =>
967
          row.kind === 'completion_report' &&
968
          row.payload?.interactionId === interactionId &&
969
          row.payload?.reportingAgentId === value.agentId,
970
      );
971
      if (duplicate) return { report: duplicate.payload, deduplicated: true };
972
      const agent = rows.find(
973
        (row) => row.kind === 'agent_profile' && row.recordId === value.agentId,
974
      )?.payload as AgentProfile | undefined;
975
      if (!agent || agent.status !== 'active') throw new Error('Active owned agent not found');
976
      const session = repository.getLiveSession
977
        ? await repository.getLiveSession(owner, interactionId, value.agentId)
978
        : undefined;
979
      const report = buildOwnerCompletionReport(interaction, agent, value, {
980
        ...(session?.activatedAt ? { startedAt: session.activatedAt } : {}),
981
      });
982
      return repository.submitCompletionReport(owner, value.agentId, report, 'oauth_account');
983
    });
984
    router.post('/v0.1/federated-interactions/:id/completion-reports', async (request) => {
985
      const owner = operatorId(request);
986
      if (!repository?.submitCompletionReport || !owner)
987
        throw new Error('Completion reports are not configured');
988
      const report = InteractionCompletionReportSchema.parse(request.body);
989
      if (report.interactionId !== (request.params as { id: string }).id)
990
        throw new Error('Interaction path does not match the completion report');
991
      return repository.submitCompletionReport(
992
        owner,
993
        boundAgentId(request),
994
        report,
995
        request.headers['x-openclasp-credential-type'] === 'agent_access_token'
996
          ? 'agent_access_token'
997
          : 'oauth_installation',
998
      );
999
    });
1000
    router.post('/v0.1/federated-interactions/:id/feedback', async (request) => {
1001
      const owner = operatorId(request);
1002
      if (!repository?.submitInteractionFeedback || !owner)
1003
        throw new Error('Interaction feedback is not configured');
1004
      const feedback = InteractionFeedbackSchema.parse(request.body);
1005
      if (feedback.interactionId !== (request.params as { id: string }).id)
1006
        throw new Error('Interaction path does not match the feedback');
1007
      return repository.submitInteractionFeedback(
1008
        owner,
1009
        boundAgentId(request),
1010
        feedback,
1011
        request.headers['x-openclasp-credential-type'] === 'agent_access_token'
1012
          ? 'agent_access_token'
1013
          : 'oauth_installation',
1014
      );
1015
    });
1016
    router.post('/v0.1/feedback-requests/:id/respond', async (request) => {
1017
      const owner = operatorId(request);
1018
      if (!repository?.listFeedbackRequests || !repository.submitInteractionFeedback || !owner)
1019
        throw new Error('Interaction feedback is not configured');
1020
      const value = z
1021
        .object({
1022
          agentId: z.string().min(1),
1023
          rating: z.number().int().min(1).max(5),
1024
          wouldWorkAgain: z.enum(['yes', 'no', 'unsure']),
1025
          privateComment: z.string().trim().max(1000).optional(),
1026
        })
1027
        .strict()
1028
        .parse(request.body);
1029
      const [requests, rows] = await Promise.all([
1030
        repository.listFeedbackRequests(owner, value.agentId),
1031
        repository.list(owner),
1032
      ]);
1033
      const duplicate = rows.find(
1034
        (row) =>
1035
          row.kind === 'interaction_feedback' &&
1036
          row.payload?.requestId === (request.params as { id: string }).id &&
1037
          row.payload?.reviewerAgentId === value.agentId,
1038
      );
1039
      if (duplicate) return { feedback: duplicate.payload, deduplicated: true };
1040
      const feedbackRequest = requests.find(
1041
        (candidate) => candidate.requestId === (request.params as { id: string }).id,
1042
      );
1043
      if (!feedbackRequest || feedbackRequest.status !== 'pending')
1044
        throw new Error('Pending feedback request not found');
1045
      const agent = rows.find(
1046
        (row) => row.kind === 'agent_profile' && row.recordId === value.agentId,
1047
      )?.payload as AgentProfile | undefined;
1048
      if (!agent || agent.status !== 'active') throw new Error('Active owned agent not found');
1049
      const feedback = buildOwnerFeedback(feedbackRequest, agent, value);
1050
      return repository.submitInteractionFeedback(owner, value.agentId, feedback, 'oauth_account');
1051
    });
1052
    router.get('/v0.1/feedback-requests', async (request) => {
1053
      const owner = operatorId(request);
1054
      if (!repository?.listFeedbackRequests || !owner)
1055
        throw new Error('Interaction feedback is not configured');
1056
      const value = z.object({ agentId: z.string().min(1) }).parse(request.query);
1057
      enforceBoundAgent(request, value.agentId);
1058
      return repository.listFeedbackRequests(owner, value.agentId);
1059
    });
1060
    router.post('/v0.1/federated-interactions', async (request) => {
1061
      const owner = operatorId(request);
1062
      if (!repository?.createFederatedInteraction || !owner)
1063
        throw new Error('Federated interactions are not configured');
1064
      const interaction = FederatedInteractionSchema.parse(request.body);
1065
      const acceptance = interaction.acceptances[interaction.initiatorAgentId];
1066
      if (!acceptance) throw new Error('Initiator acceptance is required');
1067
      return repository.createFederatedInteraction(owner, {
1068
        ...interaction,
1069
        acceptances: {
1070
          [interaction.initiatorAgentId]: { ...acceptance, method: 'oauth_account' },
1071
        },
1072
      });
1073
    });
1074
    router.post('/v0.1/federated-interactions/:id/respond', async (request) => {
1075
      const owner = operatorId(request);
1076
      if (!repository?.respondToFederatedInteraction || !owner)
1077
        throw new Error('Federated interactions are not configured');
1078
      const value = z
1079
        .object({ agentId: z.string().min(1), decision: z.enum(['accept', 'reject']) })
1080
        .parse(request.body);
1081
      return repository.respondToFederatedInteraction(
1082
        owner,
1083
        (request.params as { id: string }).id,
1084
        value.agentId,
1085
        value.decision,
1086
        'oauth_account',
1087
      );
1088
    });
1089
    router.post('/v0.1/federated-interactions/:id/contract-proposals', async (request) => {
1090
      const owner = operatorId(request);
1091
      if (!repository?.proposeContractRevision || !owner)
1092
        throw new Error('Contract negotiation is not configured');
1093
      const value = z
1094
        .object({
1095
          agentId: z.string().min(1),
1096
          expectedTermsHash: z.string().min(1).optional(),
1097
          contract: InteractionContractSchema,
1098
        })
1099
        .parse(request.body);
1100
      enforceBoundAgent(request, value.agentId);
1101
      return repository.proposeContractRevision(
1102
        owner,
1103
        (request.params as { id: string }).id,
1104
        value.agentId,
1105
        value.contract,
1106
        value.expectedTermsHash,
1107
        request.headers['x-openclasp-credential-type'] === 'agent_access_token'
1108
          ? 'oauth_installation'
1109
          : 'oauth_account',
1110
      );
1111
    });
1112
    router.post(
1113
      '/v0.1/federated-interactions/:id/contract-proposals/:revisionId/respond',
1114
      async (request) => {
1115
        const owner = operatorId(request);
1116
        if (!repository?.respondToContractRevision || !owner)
1117
          throw new Error('Contract negotiation is not configured');
1118
        const value = z
1119
          .object({ agentId: z.string().min(1), decision: z.enum(['accept', 'reject']) })
1120
          .parse(request.body);
1121
        enforceBoundAgent(request, value.agentId);
1122
        const params = request.params as { id: string; revisionId: string };
1123
        return repository.respondToContractRevision(
1124
          owner,
1125
          params.id,
1126
          value.agentId,
1127
          params.revisionId,
1128
          value.decision,
1129
          request.headers['x-openclasp-credential-type'] === 'agent_access_token'
1130
            ? 'oauth_installation'
1131
            : 'oauth_account',
1132
        );
1133
      },
1134
    );
1135
    router.post('/v0.1/delegations', async (request) => {
1136
      const value = DelegationCredentialSchema.parse(request.body);
1137
      const current = await scopedEngine(request);
1138
      current.delegations.set(value.delegationId, value);
1139
      await persist(request, 'delegation', value.delegationId, value);
1140
      return { valid: current.verifyDelegation(value.delegationId) };
1141
    });
1142
    router.post('/v0.1/delegations/:id/verify', async (request) => ({
1143
      valid: (await scopedEngine(request)).verifyDelegation((request.params as { id: string }).id),
1144
    }));
1145
    router.post('/v0.1/interactions/contracts', async (request) => {
1146
      const contract = (await scopedEngine(request)).saveContract(
1147
        InteractionContractSchema.parse(request.body),
1148
      );
1149
      await persist(request, 'contract', contract.interactionId, contract);
1150
      return contract;
1151
    });
1152
    router.post('/v0.1/events', async (request) => {
1153
      const event = (await scopedEngine(request)).recordEvent(
1154
        InteractionEventSchema.parse(request.body),
1155
      );
1156
      await persist(request, 'event', event.eventId, event);
1157
      return event;
1158
    });
1159
    router.post('/v0.1/risk/assess', async (request) =>
1160
      (await scopedEngine(request)).assess(request.body as any),
1161
    );
1162
    router.get('/v0.1/profiles/:id', async (request) => {
1163
      const { id } = request.params as { id: string };
1164
      const query = request.query as { version?: string; taskCategory?: string };
1165
      return (await scopedEngine(request)).getRisk(
1166
        id,
1167
        query.version ?? '1.0.0',
1168
        query.taskCategory ?? 'general',
1169
      );
1170
    });
1171
    router.post('/v0.1/claims/check', async (request) =>
1172
      ((value) => factChecker.check(value.claim, value.permission))(
1173
        z.object({ claim: z.string(), permission: z.boolean().optional() }).parse(request.body),
1174
      ),
1175
    );
1176
    router.post('/v0.1/receipts', async (request) => {
1177
      const receipt = (await scopedEngine(request)).submitReceipt(
1178
        ReceiptSchema.parse(request.body),
1179
      );
1180
      await persist(request, 'receipt', receipt.receiptId, receipt);
1181
      return receipt;
1182
    });
1183
    router.post('/v0.1/receipts/verify', async (request) => {
1184
      try {
1185
        (await scopedEngine(request)).verifyReceipt(ReceiptSchema.parse(request.body));
1186
        return { valid: true };
1187
      } catch {
1188
        return { valid: false };
1189
      }
1190
    });
1191
    router.post('/v0.1/feedback', async (request) => {
1192
      const feedback = FeedbackSchema.parse(request.body);
1193
      const current = await scopedEngine(request);
1194
      const result = current.submitFeedback(feedback);
1195
      await persist(request, 'feedback', feedback.feedbackId, feedback);
1196
      for (const profile of current.profiles.values())
1197
        await persist(
1198
          request,
1199
          'profile',
1200
          `${profile.agentId}|${profile.agentVersion}|${profile.taskCategory}`,
1201
          profile,
1202
        );
1203
      return result;
1204
    });
1205
    router.post('/v0.1/conflicts', async (request) => {
1206
      const conflict = (await scopedEngine(request)).createConflict(request.body as any);
1207
      await persist(request, 'conflict', conflict.conflictId, conflict);
1208
      return conflict;
1209
    });
1210
    router.post('/v0.1/conflicts/:id/permit', async (request) => {
1211
      const conflict = (await scopedEngine(request)).permitMediation(
1212
        (request.params as { id: string }).id,
1213
        (request.body as { agentId: string }).agentId,
1214
      );
1215
      await persist(request, 'conflict', conflict.conflictId, conflict);
1216
      return conflict;
1217
    });
1218
    router.post('/v0.1/conflicts/:id/resolve', async (request) => {
1219
      const conflict = (await scopedEngine(request)).resolveConflict(
1220
        (request.params as { id: string }).id,
1221
        (request.body as { resolution: string }).resolution,
1222
      );
1223
      await persist(request, 'conflict', conflict.conflictId, conflict);
1224
      return conflict;
1225
    });
1226
    router.post('/v0.1/revocations/agents/:id', async (request) => {
1227
      const id = (request.params as { id: string }).id;
1228
      const current = await scopedEngine(request);
1229
      current.revokeAgent(id);
1230
      await persist(request, 'agent', id, current.agents.get(id));
1231
      return { revoked: true };
1232
    });
1233
    router.post('/v0.1/revocations/delegations/:id', async (request) => {
1234
      const id = (request.params as { id: string }).id;
1235
      const current = await scopedEngine(request);
1236
      current.revokeDelegation(id);
1237
      await persist(request, 'delegation', id, current.delegations.get(id));
1238
      return { revoked: true };
1239
    });
1240
    router.post('/v0.1/contributions/consent', async (request) => {
1241
      const value = z.object({ agentId: z.string(), enabled: z.boolean() }).parse(request.body);
1242
      const current = await scopedEngine(request);
1243
      current.setContributionConsent(value.agentId, value.enabled);
1244
      await persist(request, 'consent', value.agentId, {
1245
        agentId: value.agentId,
1246
        ...current.contributionConsent.get(value.agentId),
1247
      });
1248
      return value;
1249
    });
1250
  });
1251
  return app;
1252
}