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

Viewing files at commit 9a8fdf2387ffCommit detailsView latest

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