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

Viewing files at commit fd993cf6d9f8Commit detailsView latest

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