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

Viewing files at commit 1da1b7ef4016Commit detailsView latest

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