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

Viewing files at commit 4bfaf3cca6a5Commit detailsView latest

apps/api/src/app.ts 353 lines · 14.0 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
} from '../../../packages/protocol/src/index.js';
13
import {
14
  FixtureFactCheckProvider,
15
  MemoryAuditStore,
16
  TrustEngine,
17
} from '../../../packages/core/src/index.js';
18
import type {
19
  AgentProfile,
20
  HostedRepository,
21
  PublicAgentCard,
22
} from '../../../packages/persistence/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
42
function publicCard(agent: AgentProfile, previous?: PublicAgentCard): PublicAgentCard {
43
  const now = new Date().toISOString();
44
  return {
45
    agentId: agent.agentId,
46
    name: agent.name,
47
    framework: agent.framework,
48
    capabilities: agent.capabilities,
49
    limitations: agent.limitations,
50
    assurance: 'oauth_authenticated',
51
    publishedAt: previous?.publishedAt ?? now,
52
    updatedAt: now,
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
    router.get('/health', async () => ({ status: 'ok' }));
113
    router.get('/ready', async () => ({ status: 'ready' }));
114
    router.get('/openapi.json', async () => app.swagger());
115
    router.get('/v0.1/dashboard', async (request) => {
116
      const owner = operatorId(request);
117
      if (repository && owner) return repository.dashboard(owner);
118
      return {
119
        agents: [...engine.agents.values()],
120
        projects: [],
121
        installations: [],
122
        setupRequests: [],
123
        publications: [],
124
        interactions: [],
125
        events: [...engine.events.values()],
126
        conflicts: [...engine.conflicts.values()],
127
        receipts: [...engine.receipts.values()],
128
        profiles: [...engine.profiles.values()],
129
      };
130
    });
131
    router.get('/v0.1/account', async (request) => {
132
      const owner = operatorId(request);
133
      return {
134
        operatorId: owner,
135
        email: decodeURIComponent(String(request.headers['x-openclasp-email'] ?? '')),
136
        name: decodeURIComponent(String(request.headers['x-openclasp-name'] ?? '')),
137
      };
138
    });
139
    router.get('/v0.1/settings', async (request) => {
140
      const owner = operatorId(request);
141
      if (!repository || !owner)
142
        return {
143
          displayName: '',
144
          contributionEnabled: false,
145
          retentionDays: 30,
146
          evidenceSharing: 'ask',
147
          rawConversationsStored: false,
148
        };
149
      return repository.getSettings(owner);
150
    });
151
    router.get('/v0.1/onboarding', async (request) => {
152
      const owner = operatorId(request);
153
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
154
      return getOnboardingState(repository, owner);
155
    });
156
    router.post('/v0.1/onboarding/:id/approve', async (request) => {
157
      const owner = operatorId(request);
158
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
159
      return approveAgentSetup(repository, owner, (request.params as { id: string }).id);
160
    });
161
    router.post('/v0.1/onboarding/:id/reject', async (request) => {
162
      const owner = operatorId(request);
163
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
164
      return rejectAgentSetup(repository, owner, (request.params as { id: string }).id);
165
    });
166
    router.put('/v0.1/settings', async (request) => {
167
      const owner = operatorId(request);
168
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
169
      const value = z
170
        .object({
171
          displayName: z.string().trim().max(100),
172
          contributionEnabled: z.boolean(),
173
          retentionDays: z.number().int().min(0).max(3650),
174
          evidenceSharing: z.enum(['never', 'ask', 'contract_only']),
175
        })
176
        .parse(request.body);
177
      return repository.saveSettings(owner, value);
178
    });
179
    router.post('/v0.1/agents', async (request) => {
180
      const owner = operatorId(request);
181
      const current = await scopedEngine(request);
182
      const identity = AgentIdentitySchema.parse(request.body);
183
      if (owner && identity.operatorRef !== `auth0:${owner}`)
184
        throw new Error('Agent identity is not owned by the authenticated operator');
185
      const registered = current.registerAgent(identity);
186
      await persist(request, 'agent', identity.agentId, registered);
187
      return registered;
188
    });
189
    router.get('/v0.1/agents/:id', async (request) => {
190
      const { id } = request.params as { id: string };
191
      const value = (await scopedEngine(request)).agents.get(id);
192
      if (!value) throw new Error('Agent not found');
193
      return value;
194
    });
195
    router.post('/v0.1/agents/:id/publication', async (request) => {
196
      const owner = operatorId(request);
197
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
198
      const { id } = request.params as { id: string };
199
      const { published } = z.object({ published: z.boolean() }).parse(request.body);
200
      const rows = await repository.list(owner);
201
      const agent = rows.find((row) => row.kind === 'agent_profile' && row.recordId === id)
202
        ?.payload as AgentProfile | undefined;
203
      if (!agent) throw new Error('Owned agent not found');
204
      if (agent.status !== 'active') throw new Error('Revoked agents cannot be published');
205
      if (!published) {
206
        await repository.unpublishAgent(owner, id);
207
        const publication = { agentId: id, published: false, updatedAt: new Date().toISOString() };
208
        await repository.upsert(owner, 'publication', id, publication);
209
        return publication;
210
      }
211
      const previous = await repository.getPublishedAgent(id);
212
      const card = await repository.publishAgent(owner, publicCard(agent, previous));
213
      const publication = { agentId: id, published: true, updatedAt: card.updatedAt };
214
      await repository.upsert(owner, 'publication', id, publication);
215
      return { ...publication, card };
216
    });
217
    router.get('/v0.1/directory', async (request) => {
218
      operatorId(request);
219
      if (!repository) throw new Error('Hosted persistence is not configured');
220
      const query = z
221
        .object({
222
          query: z.string().trim().max(100).optional(),
223
          capability: z.string().trim().max(100).optional(),
224
          limit: z.coerce.number().int().min(1).max(50).optional(),
225
        })
226
        .parse(request.query);
227
      return repository.searchPublishedAgents(query);
228
    });
229
    router.get('/v0.1/directory/:id', async (request) => {
230
      operatorId(request);
231
      if (!repository) throw new Error('Hosted persistence is not configured');
232
      const card = await repository.getPublishedAgent((request.params as { id: string }).id);
233
      if (!card) throw new Error('Published agent not found');
234
      return card;
235
    });
236
    router.post('/v0.1/delegations', async (request) => {
237
      const value = DelegationCredentialSchema.parse(request.body);
238
      const current = await scopedEngine(request);
239
      current.delegations.set(value.delegationId, value);
240
      await persist(request, 'delegation', value.delegationId, value);
241
      return { valid: current.verifyDelegation(value.delegationId) };
242
    });
243
    router.post('/v0.1/delegations/:id/verify', async (request) => ({
244
      valid: (await scopedEngine(request)).verifyDelegation((request.params as { id: string }).id),
245
    }));
246
    router.post('/v0.1/interactions/contracts', async (request) => {
247
      const contract = (await scopedEngine(request)).saveContract(
248
        InteractionContractSchema.parse(request.body),
249
      );
250
      await persist(request, 'contract', contract.interactionId, contract);
251
      return contract;
252
    });
253
    router.post('/v0.1/events', async (request) => {
254
      const event = (await scopedEngine(request)).recordEvent(
255
        InteractionEventSchema.parse(request.body),
256
      );
257
      await persist(request, 'event', event.eventId, event);
258
      return event;
259
    });
260
    router.post('/v0.1/risk/assess', async (request) =>
261
      (await scopedEngine(request)).assess(request.body as any),
262
    );
263
    router.get('/v0.1/profiles/:id', async (request) => {
264
      const { id } = request.params as { id: string };
265
      const query = request.query as { version?: string; taskCategory?: string };
266
      return (await scopedEngine(request)).getRisk(
267
        id,
268
        query.version ?? '1.0.0',
269
        query.taskCategory ?? 'general',
270
      );
271
    });
272
    router.post('/v0.1/claims/check', async (request) =>
273
      ((value) => factChecker.check(value.claim, value.permission))(
274
        z.object({ claim: z.string(), permission: z.boolean().optional() }).parse(request.body),
275
      ),
276
    );
277
    router.post('/v0.1/receipts', async (request) => {
278
      const receipt = (await scopedEngine(request)).submitReceipt(
279
        ReceiptSchema.parse(request.body),
280
      );
281
      await persist(request, 'receipt', receipt.receiptId, receipt);
282
      return receipt;
283
    });
284
    router.post('/v0.1/receipts/verify', async (request) => {
285
      try {
286
        (await scopedEngine(request)).verifyReceipt(ReceiptSchema.parse(request.body));
287
        return { valid: true };
288
      } catch {
289
        return { valid: false };
290
      }
291
    });
292
    router.post('/v0.1/feedback', async (request) => {
293
      const feedback = FeedbackSchema.parse(request.body);
294
      const current = await scopedEngine(request);
295
      const result = current.submitFeedback(feedback);
296
      await persist(request, 'feedback', feedback.feedbackId, feedback);
297
      for (const profile of current.profiles.values())
298
        await persist(
299
          request,
300
          'profile',
301
          `${profile.agentId}|${profile.agentVersion}|${profile.taskCategory}`,
302
          profile,
303
        );
304
      return result;
305
    });
306
    router.post('/v0.1/conflicts', async (request) => {
307
      const conflict = (await scopedEngine(request)).createConflict(request.body as any);
308
      await persist(request, 'conflict', conflict.conflictId, conflict);
309
      return conflict;
310
    });
311
    router.post('/v0.1/conflicts/:id/permit', async (request) => {
312
      const conflict = (await scopedEngine(request)).permitMediation(
313
        (request.params as { id: string }).id,
314
        (request.body as { agentId: string }).agentId,
315
      );
316
      await persist(request, 'conflict', conflict.conflictId, conflict);
317
      return conflict;
318
    });
319
    router.post('/v0.1/conflicts/:id/resolve', async (request) => {
320
      const conflict = (await scopedEngine(request)).resolveConflict(
321
        (request.params as { id: string }).id,
322
        (request.body as { resolution: string }).resolution,
323
      );
324
      await persist(request, 'conflict', conflict.conflictId, conflict);
325
      return conflict;
326
    });
327
    router.post('/v0.1/revocations/agents/:id', async (request) => {
328
      const id = (request.params as { id: string }).id;
329
      const current = await scopedEngine(request);
330
      current.revokeAgent(id);
331
      await persist(request, 'agent', id, current.agents.get(id));
332
      return { revoked: true };
333
    });
334
    router.post('/v0.1/revocations/delegations/:id', async (request) => {
335
      const id = (request.params as { id: string }).id;
336
      const current = await scopedEngine(request);
337
      current.revokeDelegation(id);
338
      await persist(request, 'delegation', id, current.delegations.get(id));
339
      return { revoked: true };
340
    });
341
    router.post('/v0.1/contributions/consent', async (request) => {
342
      const value = z.object({ agentId: z.string(), enabled: z.boolean() }).parse(request.body);
343
      const current = await scopedEngine(request);
344
      current.setContributionConsent(value.agentId, value.enabled);
345
      await persist(request, 'consent', value.agentId, {
346
        agentId: value.agentId,
347
        ...current.contributionConsent.get(value.agentId),
348
      });
349
      return value;
350
    });
351
  });
352
  return app;
353
}