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

Viewing files at commit 18afdc33bab8Commit detailsView latest

apps/api/src/app.ts 429 lines · 17.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
    router.get('/health', async () => ({ status: 'ok' }));
107
    router.get('/ready', async () => ({ status: 'ready' }));
108
    router.get('/openapi.json', async () => app.swagger());
109
    router.get('/extensions/trust/v0.1', async () => ({
110
      uri: 'https://openclasp.vercel.app/extensions/trust/v0.1',
111
      name: 'OpenClasp A2A assurance extension',
112
      version: '0.1',
113
      required: false,
114
      transportsMessages: false,
115
      documentation: 'https://github.com/aryaamin/OpenClasp/blob/main/docs/A2A_EXTENSION.md',
116
    }));
117
    router.get('/agents/:id/card.json', async (request) => {
118
      if (!repository) throw new Error('Hosted persistence is not configured');
119
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
120
      if (!card) throw new Error('Published agent not found');
121
      return card;
122
    });
123
    router.get('/agents/:id/.well-known/openclasp-agent.json', async (request) => {
124
      if (!repository) throw new Error('Hosted persistence is not configured');
125
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
126
      if (!card) throw new Error('Published agent not found');
127
      return card;
128
    });
129
    router.get('/agents/:id/a2a-agent-card.json', async (request) => {
130
      if (!repository) throw new Error('Hosted persistence is not configured');
131
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
132
      if (!card) throw new Error('Published agent not found');
133
      if (!card.transports.length) throw new Error('Agent has not published an A2A endpoint');
134
      return toA2AAgentCard(card);
135
    });
136
    router.get('/v0.1/dashboard', async (request) => {
137
      const owner = operatorId(request);
138
      if (repository && owner) return repository.dashboard(owner);
139
      return {
140
        agents: [...engine.agents.values()],
141
        projects: [],
142
        installations: [],
143
        setupRequests: [],
144
        publications: [],
145
        interactions: [],
146
        federatedInteractions: [],
147
        events: [...engine.events.values()],
148
        conflicts: [...engine.conflicts.values()],
149
        receipts: [...engine.receipts.values()],
150
        profiles: [...engine.profiles.values()],
151
      };
152
    });
153
    router.get('/v0.1/account', async (request) => {
154
      const owner = operatorId(request);
155
      return {
156
        operatorId: owner,
157
        email: decodeURIComponent(String(request.headers['x-openclasp-email'] ?? '')),
158
        name: decodeURIComponent(String(request.headers['x-openclasp-name'] ?? '')),
159
      };
160
    });
161
    router.get('/v0.1/settings', async (request) => {
162
      const owner = operatorId(request);
163
      if (!repository || !owner)
164
        return {
165
          displayName: '',
166
          contributionEnabled: false,
167
          retentionDays: 30,
168
          evidenceSharing: 'ask',
169
          rawConversationsStored: false,
170
        };
171
      return repository.getSettings(owner);
172
    });
173
    router.get('/v0.1/onboarding', async (request) => {
174
      const owner = operatorId(request);
175
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
176
      return getOnboardingState(repository, owner);
177
    });
178
    router.post('/v0.1/onboarding/:id/approve', async (request) => {
179
      const owner = operatorId(request);
180
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
181
      return approveAgentSetup(repository, owner, (request.params as { id: string }).id);
182
    });
183
    router.post('/v0.1/onboarding/:id/reject', async (request) => {
184
      const owner = operatorId(request);
185
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
186
      return rejectAgentSetup(repository, owner, (request.params as { id: string }).id);
187
    });
188
    router.put('/v0.1/settings', async (request) => {
189
      const owner = operatorId(request);
190
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
191
      const value = z
192
        .object({
193
          displayName: z.string().trim().max(100),
194
          contributionEnabled: z.boolean(),
195
          retentionDays: z.number().int().min(0).max(3650),
196
          evidenceSharing: z.enum(['never', 'ask', 'contract_only']),
197
        })
198
        .parse(request.body);
199
      return repository.saveSettings(owner, value);
200
    });
201
    router.post('/v0.1/agents', async (request) => {
202
      const owner = operatorId(request);
203
      const current = await scopedEngine(request);
204
      const identity = AgentIdentitySchema.parse(request.body);
205
      if (owner && identity.operatorRef !== `auth0:${owner}`)
206
        throw new Error('Agent identity is not owned by the authenticated operator');
207
      const registered = current.registerAgent(identity);
208
      await persist(request, 'agent', identity.agentId, registered);
209
      return registered;
210
    });
211
    router.get('/v0.1/agents/:id', async (request) => {
212
      const { id } = request.params as { id: string };
213
      const value = (await scopedEngine(request)).agents.get(id);
214
      if (!value) throw new Error('Agent not found');
215
      return value;
216
    });
217
    router.post('/v0.1/agents/:id/publication', async (request) => {
218
      const owner = operatorId(request);
219
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
220
      const { id } = request.params as { id: string };
221
      const { published } = z.object({ published: z.boolean() }).parse(request.body);
222
      const rows = await repository.list(owner);
223
      const agent = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
224
        ?.payload as AgentProfile | undefined;
225
      if (!agent) throw new Error('Owned agent not found');
226
      if (agent.status !== 'active') throw new Error('Revoked agents cannot be published');
227
      if (!published) {
228
        await repository.unpublishAgent(owner, id);
229
        const publication = { agentId: id, published: false, updatedAt: new Date().toISOString() };
230
        await repository.upsert(owner, 'publication', id, publication);
231
        return publication;
232
      }
233
      const previous = await repository.getPublishedAgent(id);
234
      const forwardedHost = request.headers['x-forwarded-host'];
235
      const host = typeof forwardedHost === 'string' ? forwardedHost : request.headers.host;
236
      const forwardedProtocol = request.headers['x-forwarded-proto'];
237
      const protocol = typeof forwardedProtocol === 'string' ? forwardedProtocol : 'https';
238
      const baseUrl = process.env.OPENCLASP_PUBLIC_URL ?? `${protocol}://${host}`;
239
      const card = await repository.publishAgent(
240
        owner,
241
        buildPublicAgentCard(agent, baseUrl, previous),
242
      );
243
      const publication = { agentId: id, published: true, updatedAt: card.updatedAt };
244
      await repository.upsert(owner, 'publication', id, publication);
245
      return { ...publication, card };
246
    });
247
    router.get('/v0.1/directory', async (request) => {
248
      operatorId(request);
249
      if (!repository) throw new Error('Hosted persistence is not configured');
250
      const query = z
251
        .object({
252
          query: z.string().trim().max(100).optional(),
253
          capability: z.string().trim().max(100).optional(),
254
          limit: z.coerce.number().int().min(1).max(50).optional(),
255
        })
256
        .parse(request.query);
257
      return repository.searchPublishedAgents(query);
258
    });
259
    router.get('/v0.1/directory/:id', async (request) => {
260
      operatorId(request);
261
      if (!repository) throw new Error('Hosted persistence is not configured');
262
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
263
      if (!card) throw new Error('Published agent not found');
264
      return card;
265
    });
266
    router.get('/v0.1/federated-interactions', async (request) => {
267
      const owner = operatorId(request);
268
      if (!repository?.listFederatedInteractions || !owner)
269
        throw new Error('Federated interactions are not configured');
270
      return repository.listFederatedInteractions(owner);
271
    });
272
    router.get('/v0.1/federated-interactions/:id', async (request) => {
273
      const owner = operatorId(request);
274
      if (!repository?.getFederatedInteraction || !owner)
275
        throw new Error('Federated interactions are not configured');
276
      const interaction = await repository.getFederatedInteraction(
277
        owner,
278
        (request.params as { id: string }).id,
279
      );
280
      if (!interaction) throw new Error('Interaction not found');
281
      return interaction;
282
    });
283
    router.post('/v0.1/federated-interactions', async (request) => {
284
      const owner = operatorId(request);
285
      if (!repository?.createFederatedInteraction || !owner)
286
        throw new Error('Federated interactions are not configured');
287
      const interaction = FederatedInteractionSchema.parse(request.body);
288
      const acceptance = interaction.acceptances[interaction.initiatorAgentId];
289
      if (!acceptance) throw new Error('Initiator acceptance is required');
290
      return repository.createFederatedInteraction(owner, {
291
        ...interaction,
292
        acceptances: {
293
          [interaction.initiatorAgentId]: { ...acceptance, method: 'oauth_account' },
294
        },
295
      });
296
    });
297
    router.post('/v0.1/federated-interactions/:id/respond', async (request) => {
298
      const owner = operatorId(request);
299
      if (!repository?.respondToFederatedInteraction || !owner)
300
        throw new Error('Federated interactions are not configured');
301
      const value = z
302
        .object({ agentId: z.string().min(1), decision: z.enum(['accept', 'reject']) })
303
        .parse(request.body);
304
      return repository.respondToFederatedInteraction(
305
        owner,
306
        (request.params as { id: string }).id,
307
        value.agentId,
308
        value.decision,
309
        'oauth_account',
310
      );
311
    });
312
    router.post('/v0.1/delegations', async (request) => {
313
      const value = DelegationCredentialSchema.parse(request.body);
314
      const current = await scopedEngine(request);
315
      current.delegations.set(value.delegationId, value);
316
      await persist(request, 'delegation', value.delegationId, value);
317
      return { valid: current.verifyDelegation(value.delegationId) };
318
    });
319
    router.post('/v0.1/delegations/:id/verify', async (request) => ({
320
      valid: (await scopedEngine(request)).verifyDelegation((request.params as { id: string }).id),
321
    }));
322
    router.post('/v0.1/interactions/contracts', async (request) => {
323
      const contract = (await scopedEngine(request)).saveContract(
324
        InteractionContractSchema.parse(request.body),
325
      );
326
      await persist(request, 'contract', contract.interactionId, contract);
327
      return contract;
328
    });
329
    router.post('/v0.1/events', async (request) => {
330
      const event = (await scopedEngine(request)).recordEvent(
331
        InteractionEventSchema.parse(request.body),
332
      );
333
      await persist(request, 'event', event.eventId, event);
334
      return event;
335
    });
336
    router.post('/v0.1/risk/assess', async (request) =>
337
      (await scopedEngine(request)).assess(request.body as any),
338
    );
339
    router.get('/v0.1/profiles/:id', async (request) => {
340
      const { id } = request.params as { id: string };
341
      const query = request.query as { version?: string; taskCategory?: string };
342
      return (await scopedEngine(request)).getRisk(
343
        id,
344
        query.version ?? '1.0.0',
345
        query.taskCategory ?? 'general',
346
      );
347
    });
348
    router.post('/v0.1/claims/check', async (request) =>
349
      ((value) => factChecker.check(value.claim, value.permission))(
350
        z.object({ claim: z.string(), permission: z.boolean().optional() }).parse(request.body),
351
      ),
352
    );
353
    router.post('/v0.1/receipts', async (request) => {
354
      const receipt = (await scopedEngine(request)).submitReceipt(
355
        ReceiptSchema.parse(request.body),
356
      );
357
      await persist(request, 'receipt', receipt.receiptId, receipt);
358
      return receipt;
359
    });
360
    router.post('/v0.1/receipts/verify', async (request) => {
361
      try {
362
        (await scopedEngine(request)).verifyReceipt(ReceiptSchema.parse(request.body));
363
        return { valid: true };
364
      } catch {
365
        return { valid: false };
366
      }
367
    });
368
    router.post('/v0.1/feedback', async (request) => {
369
      const feedback = FeedbackSchema.parse(request.body);
370
      const current = await scopedEngine(request);
371
      const result = current.submitFeedback(feedback);
372
      await persist(request, 'feedback', feedback.feedbackId, feedback);
373
      for (const profile of current.profiles.values())
374
        await persist(
375
          request,
376
          'profile',
377
          `${profile.agentId}|${profile.agentVersion}|${profile.taskCategory}`,
378
          profile,
379
        );
380
      return result;
381
    });
382
    router.post('/v0.1/conflicts', async (request) => {
383
      const conflict = (await scopedEngine(request)).createConflict(request.body as any);
384
      await persist(request, 'conflict', conflict.conflictId, conflict);
385
      return conflict;
386
    });
387
    router.post('/v0.1/conflicts/:id/permit', async (request) => {
388
      const conflict = (await scopedEngine(request)).permitMediation(
389
        (request.params as { id: string }).id,
390
        (request.body as { agentId: string }).agentId,
391
      );
392
      await persist(request, 'conflict', conflict.conflictId, conflict);
393
      return conflict;
394
    });
395
    router.post('/v0.1/conflicts/:id/resolve', async (request) => {
396
      const conflict = (await scopedEngine(request)).resolveConflict(
397
        (request.params as { id: string }).id,
398
        (request.body as { resolution: string }).resolution,
399
      );
400
      await persist(request, 'conflict', conflict.conflictId, conflict);
401
      return conflict;
402
    });
403
    router.post('/v0.1/revocations/agents/:id', async (request) => {
404
      const id = (request.params as { id: string }).id;
405
      const current = await scopedEngine(request);
406
      current.revokeAgent(id);
407
      await persist(request, 'agent', id, current.agents.get(id));
408
      return { revoked: true };
409
    });
410
    router.post('/v0.1/revocations/delegations/:id', async (request) => {
411
      const id = (request.params as { id: string }).id;
412
      const current = await scopedEngine(request);
413
      current.revokeDelegation(id);
414
      await persist(request, 'delegation', id, current.delegations.get(id));
415
      return { revoked: true };
416
    });
417
    router.post('/v0.1/contributions/consent', async (request) => {
418
      const value = z.object({ agentId: z.string(), enabled: z.boolean() }).parse(request.body);
419
      const current = await scopedEngine(request);
420
      current.setContributionConsent(value.agentId, value.enabled);
421
      await persist(request, 'consent', value.agentId, {
422
        agentId: value.agentId,
423
        ...current.contributionConsent.get(value.agentId),
424
      });
425
      return value;
426
    });
427
  });
428
  return app;
429
}