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

Viewing files at commit f1f8fc237d6aCommit detailsView latest

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