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

Viewing files at commit 296bb55574a2Commit detailsView latest

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