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

Viewing files at commit 32cfcb5542e9Commit detailsView latest

apps/api/src/app.ts 645 lines · 27.1 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
} from '../../../packages/protocol/src/index.js';
15
import {
16
  FixtureFactCheckProvider,
17
  MemoryAuditStore,
18
  TrustEngine,
19
} from '../../../packages/core/src/index.js';
20
import { buildPublicAgentCard } from '../../../packages/persistence/src/index.js';
21
import type { AgentProfile, HostedRepository } from '../../../packages/persistence/src/index.js';
22
import { toA2AAgentCard } from '../../../packages/sidecar/src/index.js';
23
import {
24
  approveAgentSetup,
25
  getOnboardingState,
26
  rejectAgentSetup,
27
} from '../../../packages/persistence/src/onboarding.js';
28
29
type DashboardRepository = Pick<
30
  HostedRepository,
31
  | 'dashboard'
32
  | 'getSettings'
33
  | 'saveSettings'
34
  | 'upsert'
35
  | 'list'
36
  | 'publishAgent'
37
  | 'unpublishAgent'
38
  | 'getPublishedAgent'
39
  | 'searchPublishedAgents'
40
> &
41
  Partial<
42
    Pick<
43
      HostedRepository,
44
      | 'createFederatedInteraction'
45
      | 'listFederatedInteractions'
46
      | 'getFederatedInteraction'
47
      | 'respondToFederatedInteraction'
48
      | 'getLiveSession'
49
      | 'recordLiveSessionEvent'
50
      | 'getRuntimeVerificationKey'
51
      | 'registerAgentRuntime'
52
      | 'disableAgentRuntime'
53
      | 'deleteAgent'
54
      | 'receiveTemporaryMessage'
55
      | 'sendTemporaryMessage'
56
      | 'listHostedThreads'
57
      | 'getHostedThread'
58
      | 'markHostedThreadRead'
59
      | 'closeHostedThread'
60
    >
61
  >;
62
63
export function buildApi(
64
  engine = new TrustEngine(),
65
  factChecker = new FixtureFactCheckProvider(),
66
  repository?: DashboardRepository,
67
) {
68
  const app = Fastify({
69
    logger: { redact: ['req.headers.authorization', 'req.body.rawMessage', 'req.body.privateKey'] },
70
  });
71
  const engines = new Map<string, Promise<TrustEngine>>();
72
  void app.register(cors, { origin: true });
73
  void app.register(swagger, { openapi: { info: { title: 'OpenClasp API', version: '0.1.0' } } });
74
75
  app.setErrorHandler((error, _request, reply) =>
76
    reply
77
      .status(
78
        error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number'
79
          ? error.statusCode
80
          : 400,
81
      )
82
      .send({ error: error instanceof Error ? error.message : 'Unknown error' }),
83
  );
84
  void app.register(async (router) => {
85
    const operatorId = (request: { headers: Record<string, unknown> }) => {
86
      const value = request.headers['x-openclasp-operator'];
87
      if (repository && typeof value !== 'string') {
88
        const error = new Error('Authentication required');
89
        Object.assign(error, { statusCode: 401 });
90
        throw error;
91
      }
92
      return typeof value === 'string' ? value : undefined;
93
    };
94
    const scopedEngine = async (request: { headers: Record<string, unknown> }) => {
95
      const owner = operatorId(request);
96
      if (!repository || !owner) return engine;
97
      let pending = engines.get(owner);
98
      if (!pending) {
99
        pending = repository.list(owner).then((rows) => {
100
          const store = new MemoryAuditStore();
101
          for (const row of rows) {
102
            if (row.kind !== 'interaction') store.append(row.kind, row.recordId, row.payload);
103
          }
104
          return new TrustEngine(store);
105
        });
106
        engines.set(owner, pending);
107
      }
108
      return pending;
109
    };
110
    const persist = async (
111
      request: { headers: Record<string, unknown> },
112
      kind: Parameters<HostedRepository['upsert']>[1],
113
      id: string,
114
      value: unknown,
115
    ) => {
116
      const owner = operatorId(request);
117
      if (repository && owner) await repository.upsert(owner, kind, id, value);
118
    };
119
    const publicBaseUrl = (request: { headers: Record<string, unknown> }) => {
120
      const forwardedHost = request.headers['x-forwarded-host'];
121
      const host = typeof forwardedHost === 'string' ? forwardedHost : request.headers.host;
122
      const forwardedProtocol = request.headers['x-forwarded-proto'];
123
      const protocol = typeof forwardedProtocol === 'string' ? forwardedProtocol : 'https';
124
      return process.env.OPENCLASP_PUBLIC_URL ?? `${protocol}://${String(host ?? 'localhost')}`;
125
    };
126
    router.get('/health', async () => ({ status: 'ok' }));
127
    router.get('/ready', async () => ({ status: 'ready' }));
128
    router.get('/openapi.json', async () => app.swagger());
129
    router.get('/.well-known/openclasp-session-key', async () => {
130
      if (!repository?.getRuntimeVerificationKey)
131
        throw new Error('Runtime verification is not configured');
132
      return repository.getRuntimeVerificationKey();
133
    });
134
    router.get('/extensions/trust/v0.1', async () => ({
135
      uri: 'https://openclasp.vercel.app/extensions/trust/v0.1',
136
      name: 'OpenClasp A2A assurance extension',
137
      version: '0.1',
138
      required: false,
139
      transportsMessages: false,
140
      temporaryChatAdapter: true,
141
      documentation: 'https://github.com/aryaamin/OpenClasp/blob/main/docs/A2A_EXTENSION.md',
142
    }));
143
    router.post('/sessions/:id/events', async (request, reply) => {
144
      if (!repository?.recordLiveSessionEvent)
145
        throw new Error('Live-session reporting is not configured');
146
      const authorization = request.headers.authorization;
147
      if (!authorization?.startsWith('Bearer ')) {
148
        reply.status(401);
149
        return { error: 'session_credential_required' };
150
      }
151
      const event = LiveSessionEventSchema.parse(request.body);
152
      if (event.interactionId !== (request.params as { id: string }).id)
153
        throw new Error('Interaction path does not match the event');
154
      return repository.recordLiveSessionEvent(authorization.slice(7), event);
155
    });
156
    router.post('/a2a/temporary/:id', async (request, reply) => {
157
      if (!repository?.receiveTemporaryMessage)
158
        throw new Error('Temporary chat delivery is not configured');
159
      const authorization = request.headers.authorization;
160
      if (!authorization?.startsWith('Bearer '))
161
        return reply.status(401).send({ error: 'session_credential_required' });
162
      const rpc = z
163
        .object({
164
          jsonrpc: z.literal('2.0'),
165
          id: z.union([z.string().min(1).max(200), z.number()]),
166
          method: z.literal('message/send'),
167
          params: z.object({
168
            message: z.object({
169
              parts: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
170
            }),
171
          }),
172
        })
173
        .parse(request.body);
174
      const content = rpc.params.message.parts
175
        .map((part) => (typeof part.text === 'string' ? part.text : ''))
176
        .filter(Boolean)
177
        .join('\n')
178
        .trim();
179
      if (!content) throw new Error('Temporary chat MVP accepts text parts only');
180
      const recipientAgentId = (request.params as { id: string }).id;
181
      const result = await repository.receiveTemporaryMessage(
182
        authorization.slice(7),
183
        recipientAgentId,
184
        String(rpc.id),
185
        content,
186
      );
187
      return {
188
        jsonrpc: '2.0',
189
        id: rpc.id,
190
        result: {
191
          task: { id: result.message.messageId, state: 'submitted' },
192
          privacyMode: 'openclasp_hosted_temporary',
193
          deduplicated: result.deduplicated,
194
        },
195
      };
196
    });
197
    router.get('/agents/:id/card.json', async (request) => {
198
      if (!repository) throw new Error('Hosted persistence is not configured');
199
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
200
      if (!card) throw new Error('Published agent not found');
201
      return card;
202
    });
203
    router.get('/agents/:id/.well-known/openclasp-agent.json', async (request) => {
204
      if (!repository) throw new Error('Hosted persistence is not configured');
205
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
206
      if (!card) throw new Error('Published agent not found');
207
      return card;
208
    });
209
    router.get('/agents/:id/a2a-agent-card.json', async (request) => {
210
      if (!repository) throw new Error('Hosted persistence is not configured');
211
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
212
      if (!card) throw new Error('Published agent not found');
213
      if (!card.transports.length) throw new Error('Agent has not published an A2A endpoint');
214
      return toA2AAgentCard(card);
215
    });
216
    router.get('/v0.1/dashboard', async (request) => {
217
      const owner = operatorId(request);
218
      if (repository && owner) return repository.dashboard(owner);
219
      return {
220
        agents: [...engine.agents.values()],
221
        projects: [],
222
        installations: [],
223
        setupRequests: [],
224
        publications: [],
225
        interactions: [],
226
        federatedInteractions: [],
227
        liveSessions: [],
228
        hostedThreads: [],
229
        events: [...engine.events.values()],
230
        conflicts: [...engine.conflicts.values()],
231
        receipts: [...engine.receipts.values()],
232
        profiles: [...engine.profiles.values()],
233
        runtimes: [],
234
      };
235
    });
236
    router.get('/v0.1/account', async (request) => {
237
      const owner = operatorId(request);
238
      return {
239
        operatorId: owner,
240
        email: decodeURIComponent(String(request.headers['x-openclasp-email'] ?? '')),
241
        name: decodeURIComponent(String(request.headers['x-openclasp-name'] ?? '')),
242
      };
243
    });
244
    router.get('/v0.1/settings', async (request) => {
245
      const owner = operatorId(request);
246
      if (!repository || !owner)
247
        return {
248
          displayName: '',
249
          contributionEnabled: false,
250
          retentionDays: 30,
251
          evidenceSharing: 'ask',
252
          rawConversationsStored: false,
253
        };
254
      return repository.getSettings(owner);
255
    });
256
    router.get('/v0.1/onboarding', async (request) => {
257
      const owner = operatorId(request);
258
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
259
      return getOnboardingState(repository, owner);
260
    });
261
    router.post('/v0.1/onboarding/:id/approve', async (request) => {
262
      const owner = operatorId(request);
263
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
264
      const requestId = (request.params as { id: string }).id;
265
      const state = await getOnboardingState(repository, owner);
266
      const setup = state.setupRequests.find((item) => item.requestId === requestId);
267
      const result = await approveAgentSetup(repository, owner, requestId);
268
      if (result.status === 'connected' && setup?.autoPublish) {
269
        const previous = await repository.getPublishedAgent(result.agent.agentId);
270
        const card = await repository.publishAgent(
271
          owner,
272
          buildPublicAgentCard(result.agent, publicBaseUrl(request), previous),
273
        );
274
        await repository.upsert(owner, 'publication', result.agent.agentId, {
275
          agentId: result.agent.agentId,
276
          published: true,
277
          updatedAt: card.updatedAt,
278
        });
279
      }
280
      return result;
281
    });
282
    router.post('/v0.1/onboarding/:id/reject', async (request) => {
283
      const owner = operatorId(request);
284
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
285
      return rejectAgentSetup(repository, owner, (request.params as { id: string }).id);
286
    });
287
    router.put('/v0.1/settings', async (request) => {
288
      const owner = operatorId(request);
289
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
290
      const value = z
291
        .object({
292
          displayName: z.string().trim().max(100),
293
          contributionEnabled: z.boolean(),
294
          retentionDays: z.number().int().min(0).max(3650),
295
          evidenceSharing: z.enum(['never', 'ask', 'contract_only']),
296
        })
297
        .parse(request.body);
298
      return repository.saveSettings(owner, value);
299
    });
300
    router.post('/v0.1/agents', async (request) => {
301
      const owner = operatorId(request);
302
      const current = await scopedEngine(request);
303
      const identity = AgentIdentitySchema.parse(request.body);
304
      if (owner && identity.operatorRef !== `auth0:${owner}`)
305
        throw new Error('Agent identity is not owned by the authenticated operator');
306
      const registered = current.registerAgent(identity);
307
      await persist(request, 'agent', identity.agentId, registered);
308
      return registered;
309
    });
310
    router.get('/v0.1/agents/:id', async (request) => {
311
      const { id } = request.params as { id: string };
312
      const value = (await scopedEngine(request)).agents.get(id);
313
      if (!value) throw new Error('Agent not found');
314
      return value;
315
    });
316
    router.post('/v0.1/agents/:id/publication', async (request) => {
317
      const owner = operatorId(request);
318
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
319
      const { id } = request.params as { id: string };
320
      const { published } = z.object({ published: z.boolean() }).parse(request.body);
321
      const rows = await repository.list(owner);
322
      const agent = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
323
        ?.payload as AgentProfile | undefined;
324
      if (!agent) throw new Error('Owned agent not found');
325
      if (agent.status !== 'active') throw new Error('Revoked agents cannot be published');
326
      if (!published) {
327
        await repository.unpublishAgent(owner, id);
328
        const publication = { agentId: id, published: false, updatedAt: new Date().toISOString() };
329
        await repository.upsert(owner, 'publication', id, publication);
330
        return publication;
331
      }
332
      const previous = await repository.getPublishedAgent(id);
333
      const card = await repository.publishAgent(
334
        owner,
335
        buildPublicAgentCard(agent, publicBaseUrl(request), previous),
336
      );
337
      const publication = { agentId: id, published: true, updatedAt: card.updatedAt };
338
      await repository.upsert(owner, 'publication', id, publication);
339
      return { ...publication, card };
340
    });
341
    router.put('/v0.1/agents/:id/automation', async (request) => {
342
      const owner = operatorId(request);
343
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
344
      const { id } = request.params as { id: string };
345
      const value = z
346
        .object({
347
          autoPublish: z.boolean(),
348
          autoAcceptPolicy: z.enum(['off', 'safe_matching']),
349
          autoAcceptTaskCategories: z.array(z.string().trim().min(1).max(100)).max(100),
350
        })
351
        .parse(request.body);
352
      const rows = await repository.list(owner);
353
      const current = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
354
        ?.payload as AgentProfile | undefined;
355
      if (!current) throw new Error('Owned agent not found');
356
      const agent: AgentProfile = {
357
        ...current,
358
        transport: 'direct_a2a',
359
        description: current.description ?? '',
360
        agentVersion: current.agentVersion ?? '1.0.0',
361
        autoPublish: value.autoPublish,
362
        autoAcceptPolicy: value.autoAcceptPolicy,
363
        autoAcceptTaskCategories: [...new Set(value.autoAcceptTaskCategories)],
364
        updatedAt: new Date().toISOString(),
365
      };
366
      await repository.upsert(owner, 'agent_profile', id, agent);
367
      if (value.autoPublish) {
368
        const previous = await repository.getPublishedAgent(id);
369
        const card = await repository.publishAgent(
370
          owner,
371
          buildPublicAgentCard(agent, publicBaseUrl(request), previous),
372
        );
373
        await repository.upsert(owner, 'publication', id, {
374
          agentId: id,
375
          published: true,
376
          updatedAt: card.updatedAt,
377
        });
378
      } else {
379
        await repository.unpublishAgent(owner, id);
380
        await repository.upsert(owner, 'publication', id, {
381
          agentId: id,
382
          published: false,
383
          updatedAt: new Date().toISOString(),
384
        });
385
      }
386
      return agent;
387
    });
388
    router.put('/v0.1/agents/:id/runtime', async (request) => {
389
      const owner = operatorId(request);
390
      if (!repository?.registerAgentRuntime || !owner)
391
        throw new Error('Hosted runtime delivery is not configured');
392
      const endpoint = z
393
        .object({ endpoint: z.string().url().max(2048) })
394
        .parse(request.body).endpoint;
395
      return repository.registerAgentRuntime(
396
        owner,
397
        (request.params as { id: string }).id,
398
        endpoint,
399
      );
400
    });
401
    router.delete('/v0.1/agents/:id/runtime', async (request) => {
402
      const owner = operatorId(request);
403
      if (!repository?.disableAgentRuntime || !owner)
404
        throw new Error('Hosted runtime delivery is not configured');
405
      return repository.disableAgentRuntime(owner, (request.params as { id: string }).id);
406
    });
407
    router.delete('/v0.1/agents/:id', async (request) => {
408
      const owner = operatorId(request);
409
      if (!repository?.deleteAgent || !owner)
410
        throw new Error('Hosted agent deletion is not configured');
411
      const result = await repository.deleteAgent(owner, (request.params as { id: string }).id);
412
      engines.delete(owner);
413
      return result;
414
    });
415
    router.get('/v0.1/agents/:id/threads', async (request) => {
416
      const owner = operatorId(request);
417
      if (!repository?.listHostedThreads || !owner)
418
        throw new Error('Temporary chat history is not configured');
419
      return repository.listHostedThreads(owner, (request.params as { id: string }).id);
420
    });
421
    router.get('/v0.1/agents/:id/threads/:threadId', async (request) => {
422
      const owner = operatorId(request);
423
      if (!repository?.getHostedThread || !owner)
424
        throw new Error('Temporary chat history is not configured');
425
      const params = request.params as { id: string; threadId: string };
426
      return repository.getHostedThread(owner, params.id, params.threadId);
427
    });
428
    router.post('/v0.1/agents/:id/messages', async (request) => {
429
      const owner = operatorId(request);
430
      if (!repository?.sendTemporaryMessage || !owner)
431
        throw new Error('Temporary chat delivery is not configured');
432
      const value = z
433
        .object({ interactionId: z.string().uuid(), content: z.string().trim().min(1).max(20_000) })
434
        .parse(request.body);
435
      return repository.sendTemporaryMessage(
436
        owner,
437
        (request.params as { id: string }).id,
438
        value.interactionId,
439
        value.content,
440
      );
441
    });
442
    router.post('/v0.1/agents/:id/threads/:threadId/read', async (request) => {
443
      const owner = operatorId(request);
444
      if (!repository?.markHostedThreadRead || !owner)
445
        throw new Error('Temporary chat history is not configured');
446
      const params = request.params as { id: string; threadId: string };
447
      return repository.markHostedThreadRead(owner, params.id, params.threadId);
448
    });
449
    router.post('/v0.1/agents/:id/threads/:threadId/close', async (request) => {
450
      const owner = operatorId(request);
451
      if (!repository?.closeHostedThread || !owner)
452
        throw new Error('Temporary chat history is not configured');
453
      const params = request.params as { id: string; threadId: string };
454
      return repository.closeHostedThread(owner, params.id, params.threadId);
455
    });
456
    router.get('/v0.1/directory', async (request) => {
457
      operatorId(request);
458
      if (!repository) throw new Error('Hosted persistence is not configured');
459
      const query = z
460
        .object({
461
          query: z.string().trim().max(100).optional(),
462
          capability: z.string().trim().max(100).optional(),
463
          limit: z.coerce.number().int().min(1).max(50).optional(),
464
        })
465
        .parse(request.query);
466
      return repository.searchPublishedAgents(query);
467
    });
468
    router.get('/v0.1/directory/:id', async (request) => {
469
      operatorId(request);
470
      if (!repository) throw new Error('Hosted persistence is not configured');
471
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
472
      if (!card) throw new Error('Published agent not found');
473
      return card;
474
    });
475
    router.get('/v0.1/federated-interactions', async (request) => {
476
      const owner = operatorId(request);
477
      if (!repository?.listFederatedInteractions || !owner)
478
        throw new Error('Federated interactions are not configured');
479
      return repository.listFederatedInteractions(owner);
480
    });
481
    router.get('/v0.1/federated-interactions/:id', async (request) => {
482
      const owner = operatorId(request);
483
      if (!repository?.getFederatedInteraction || !owner)
484
        throw new Error('Federated interactions are not configured');
485
      const interaction = await repository.getFederatedInteraction(
486
        owner,
487
        (request.params as { id: string }).id,
488
      );
489
      if (!interaction) throw new Error('Interaction not found');
490
      return interaction;
491
    });
492
    router.get('/v0.1/federated-interactions/:id/session', async (request) => {
493
      const owner = operatorId(request);
494
      if (!repository?.getLiveSession || !owner)
495
        throw new Error('Live sessions are not configured');
496
      const value = z.object({ agentId: z.string().min(1) }).parse(request.query);
497
      return repository.getLiveSession(owner, (request.params as { id: string }).id, value.agentId);
498
    });
499
    router.post('/v0.1/federated-interactions', async (request) => {
500
      const owner = operatorId(request);
501
      if (!repository?.createFederatedInteraction || !owner)
502
        throw new Error('Federated interactions are not configured');
503
      const interaction = FederatedInteractionSchema.parse(request.body);
504
      const acceptance = interaction.acceptances[interaction.initiatorAgentId];
505
      if (!acceptance) throw new Error('Initiator acceptance is required');
506
      return repository.createFederatedInteraction(owner, {
507
        ...interaction,
508
        acceptances: {
509
          [interaction.initiatorAgentId]: { ...acceptance, method: 'oauth_account' },
510
        },
511
      });
512
    });
513
    router.post('/v0.1/federated-interactions/:id/respond', async (request) => {
514
      const owner = operatorId(request);
515
      if (!repository?.respondToFederatedInteraction || !owner)
516
        throw new Error('Federated interactions are not configured');
517
      const value = z
518
        .object({ agentId: z.string().min(1), decision: z.enum(['accept', 'reject']) })
519
        .parse(request.body);
520
      return repository.respondToFederatedInteraction(
521
        owner,
522
        (request.params as { id: string }).id,
523
        value.agentId,
524
        value.decision,
525
        'oauth_account',
526
      );
527
    });
528
    router.post('/v0.1/delegations', async (request) => {
529
      const value = DelegationCredentialSchema.parse(request.body);
530
      const current = await scopedEngine(request);
531
      current.delegations.set(value.delegationId, value);
532
      await persist(request, 'delegation', value.delegationId, value);
533
      return { valid: current.verifyDelegation(value.delegationId) };
534
    });
535
    router.post('/v0.1/delegations/:id/verify', async (request) => ({
536
      valid: (await scopedEngine(request)).verifyDelegation((request.params as { id: string }).id),
537
    }));
538
    router.post('/v0.1/interactions/contracts', async (request) => {
539
      const contract = (await scopedEngine(request)).saveContract(
540
        InteractionContractSchema.parse(request.body),
541
      );
542
      await persist(request, 'contract', contract.interactionId, contract);
543
      return contract;
544
    });
545
    router.post('/v0.1/events', async (request) => {
546
      const event = (await scopedEngine(request)).recordEvent(
547
        InteractionEventSchema.parse(request.body),
548
      );
549
      await persist(request, 'event', event.eventId, event);
550
      return event;
551
    });
552
    router.post('/v0.1/risk/assess', async (request) =>
553
      (await scopedEngine(request)).assess(request.body as any),
554
    );
555
    router.get('/v0.1/profiles/:id', async (request) => {
556
      const { id } = request.params as { id: string };
557
      const query = request.query as { version?: string; taskCategory?: string };
558
      return (await scopedEngine(request)).getRisk(
559
        id,
560
        query.version ?? '1.0.0',
561
        query.taskCategory ?? 'general',
562
      );
563
    });
564
    router.post('/v0.1/claims/check', async (request) =>
565
      ((value) => factChecker.check(value.claim, value.permission))(
566
        z.object({ claim: z.string(), permission: z.boolean().optional() }).parse(request.body),
567
      ),
568
    );
569
    router.post('/v0.1/receipts', async (request) => {
570
      const receipt = (await scopedEngine(request)).submitReceipt(
571
        ReceiptSchema.parse(request.body),
572
      );
573
      await persist(request, 'receipt', receipt.receiptId, receipt);
574
      return receipt;
575
    });
576
    router.post('/v0.1/receipts/verify', async (request) => {
577
      try {
578
        (await scopedEngine(request)).verifyReceipt(ReceiptSchema.parse(request.body));
579
        return { valid: true };
580
      } catch {
581
        return { valid: false };
582
      }
583
    });
584
    router.post('/v0.1/feedback', async (request) => {
585
      const feedback = FeedbackSchema.parse(request.body);
586
      const current = await scopedEngine(request);
587
      const result = current.submitFeedback(feedback);
588
      await persist(request, 'feedback', feedback.feedbackId, feedback);
589
      for (const profile of current.profiles.values())
590
        await persist(
591
          request,
592
          'profile',
593
          `${profile.agentId}|${profile.agentVersion}|${profile.taskCategory}`,
594
          profile,
595
        );
596
      return result;
597
    });
598
    router.post('/v0.1/conflicts', async (request) => {
599
      const conflict = (await scopedEngine(request)).createConflict(request.body as any);
600
      await persist(request, 'conflict', conflict.conflictId, conflict);
601
      return conflict;
602
    });
603
    router.post('/v0.1/conflicts/:id/permit', async (request) => {
604
      const conflict = (await scopedEngine(request)).permitMediation(
605
        (request.params as { id: string }).id,
606
        (request.body as { agentId: string }).agentId,
607
      );
608
      await persist(request, 'conflict', conflict.conflictId, conflict);
609
      return conflict;
610
    });
611
    router.post('/v0.1/conflicts/:id/resolve', async (request) => {
612
      const conflict = (await scopedEngine(request)).resolveConflict(
613
        (request.params as { id: string }).id,
614
        (request.body as { resolution: string }).resolution,
615
      );
616
      await persist(request, 'conflict', conflict.conflictId, conflict);
617
      return conflict;
618
    });
619
    router.post('/v0.1/revocations/agents/:id', async (request) => {
620
      const id = (request.params as { id: string }).id;
621
      const current = await scopedEngine(request);
622
      current.revokeAgent(id);
623
      await persist(request, 'agent', id, current.agents.get(id));
624
      return { revoked: true };
625
    });
626
    router.post('/v0.1/revocations/delegations/:id', async (request) => {
627
      const id = (request.params as { id: string }).id;
628
      const current = await scopedEngine(request);
629
      current.revokeDelegation(id);
630
      await persist(request, 'delegation', id, current.delegations.get(id));
631
      return { revoked: true };
632
    });
633
    router.post('/v0.1/contributions/consent', async (request) => {
634
      const value = z.object({ agentId: z.string(), enabled: z.boolean() }).parse(request.body);
635
      const current = await scopedEngine(request);
636
      current.setContributionConsent(value.agentId, value.enabled);
637
      await persist(request, 'consent', value.agentId, {
638
        agentId: value.agentId,
639
        ...current.contributionConsent.get(value.agentId),
640
      });
641
      return value;
642
    });
643
  });
644
  return app;
645
}