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

Viewing files at commit ce192002f08aCommit detailsView latest

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