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

Viewing files at commit 3f140fdd2001Commit detailsView latest

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