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

Viewing files at commit 3dc3c2c6da63Commit detailsView latest

apps/api/src/app.ts 282 lines · 11.2 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 { HostedRepository } from '../../../packages/persistence/src/index.js';
19
import {
20
  approveAgentSetup,
21
  getOnboardingState,
22
  rejectAgentSetup,
23
} from '../../../packages/persistence/src/onboarding.js';
24
25
type DashboardRepository = Pick<
26
  HostedRepository,
27
  'dashboard' | 'getSettings' | 'saveSettings' | 'upsert' | 'list'
28
>;
29
30
export function buildApi(
31
  engine = new TrustEngine(),
32
  factChecker = new FixtureFactCheckProvider(),
33
  repository?: DashboardRepository,
34
) {
35
  const app = Fastify({
36
    logger: { redact: ['req.headers.authorization', 'req.body.rawMessage', 'req.body.privateKey'] },
37
  });
38
  const engines = new Map<string, Promise<TrustEngine>>();
39
  void app.register(cors, { origin: true });
40
  void app.register(swagger, { openapi: { info: { title: 'OpenClasp API', version: '0.1.0' } } });
41
42
  app.setErrorHandler((error, _request, reply) =>
43
    reply
44
      .status(
45
        error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number'
46
          ? error.statusCode
47
          : 400,
48
      )
49
      .send({ error: error instanceof Error ? error.message : 'Unknown error' }),
50
  );
51
  void app.register(async (router) => {
52
    const operatorId = (request: { headers: Record<string, unknown> }) => {
53
      const value = request.headers['x-openclasp-operator'];
54
      if (repository && typeof value !== 'string') {
55
        const error = new Error('Authentication required');
56
        Object.assign(error, { statusCode: 401 });
57
        throw error;
58
      }
59
      return typeof value === 'string' ? value : undefined;
60
    };
61
    const scopedEngine = async (request: { headers: Record<string, unknown> }) => {
62
      const owner = operatorId(request);
63
      if (!repository || !owner) return engine;
64
      let pending = engines.get(owner);
65
      if (!pending) {
66
        pending = repository.list(owner).then((rows) => {
67
          const store = new MemoryAuditStore();
68
          for (const row of rows) {
69
            if (row.kind !== 'interaction') store.append(row.kind, row.recordId, row.payload);
70
          }
71
          return new TrustEngine(store);
72
        });
73
        engines.set(owner, pending);
74
      }
75
      return pending;
76
    };
77
    const persist = async (
78
      request: { headers: Record<string, unknown> },
79
      kind: Parameters<HostedRepository['upsert']>[1],
80
      id: string,
81
      value: unknown,
82
    ) => {
83
      const owner = operatorId(request);
84
      if (repository && owner) await repository.upsert(owner, kind, id, value);
85
    };
86
    router.get('/health', async () => ({ status: 'ok' }));
87
    router.get('/ready', async () => ({ status: 'ready' }));
88
    router.get('/openapi.json', async () => app.swagger());
89
    router.get('/v0.1/dashboard', async (request) => {
90
      const owner = operatorId(request);
91
      if (repository && owner) return repository.dashboard(owner);
92
      return {
93
        agents: [...engine.agents.values()],
94
        interactions: [],
95
        events: [...engine.events.values()],
96
        conflicts: [...engine.conflicts.values()],
97
        receipts: [...engine.receipts.values()],
98
        profiles: [...engine.profiles.values()],
99
      };
100
    });
101
    router.get('/v0.1/account', async (request) => {
102
      const owner = operatorId(request);
103
      return {
104
        operatorId: owner,
105
        email: decodeURIComponent(String(request.headers['x-openclasp-email'] ?? '')),
106
        name: decodeURIComponent(String(request.headers['x-openclasp-name'] ?? '')),
107
      };
108
    });
109
    router.get('/v0.1/settings', async (request) => {
110
      const owner = operatorId(request);
111
      if (!repository || !owner)
112
        return {
113
          displayName: '',
114
          contributionEnabled: false,
115
          retentionDays: 30,
116
          evidenceSharing: 'ask',
117
          rawConversationsStored: false,
118
        };
119
      return repository.getSettings(owner);
120
    });
121
    router.get('/v0.1/onboarding', async (request) => {
122
      const owner = operatorId(request);
123
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
124
      return getOnboardingState(repository, owner);
125
    });
126
    router.post('/v0.1/onboarding/:id/approve', async (request) => {
127
      const owner = operatorId(request);
128
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
129
      return approveAgentSetup(repository, owner, (request.params as { id: string }).id);
130
    });
131
    router.post('/v0.1/onboarding/:id/reject', async (request) => {
132
      const owner = operatorId(request);
133
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
134
      return rejectAgentSetup(repository, owner, (request.params as { id: string }).id);
135
    });
136
    router.put('/v0.1/settings', async (request) => {
137
      const owner = operatorId(request);
138
      if (!repository || !owner) throw new Error('Hosted persistence is not configured');
139
      const value = z
140
        .object({
141
          displayName: z.string().trim().max(100),
142
          contributionEnabled: z.boolean(),
143
          retentionDays: z.number().int().min(0).max(3650),
144
          evidenceSharing: z.enum(['never', 'ask', 'contract_only']),
145
        })
146
        .parse(request.body);
147
      return repository.saveSettings(owner, value);
148
    });
149
    router.post('/v0.1/agents', async (request) => {
150
      const owner = operatorId(request);
151
      const current = await scopedEngine(request);
152
      const identity = AgentIdentitySchema.parse(request.body);
153
      if (owner && identity.operatorRef !== `auth0:${owner}`)
154
        throw new Error('Agent identity is not owned by the authenticated operator');
155
      const registered = current.registerAgent(identity);
156
      await persist(request, 'agent', identity.agentId, registered);
157
      return registered;
158
    });
159
    router.get('/v0.1/agents/:id', async (request) => {
160
      const { id } = request.params as { id: string };
161
      const value = (await scopedEngine(request)).agents.get(id);
162
      if (!value) throw new Error('Agent not found');
163
      return value;
164
    });
165
    router.post('/v0.1/delegations', async (request) => {
166
      const value = DelegationCredentialSchema.parse(request.body);
167
      const current = await scopedEngine(request);
168
      current.delegations.set(value.delegationId, value);
169
      await persist(request, 'delegation', value.delegationId, value);
170
      return { valid: current.verifyDelegation(value.delegationId) };
171
    });
172
    router.post('/v0.1/delegations/:id/verify', async (request) => ({
173
      valid: (await scopedEngine(request)).verifyDelegation((request.params as { id: string }).id),
174
    }));
175
    router.post('/v0.1/interactions/contracts', async (request) => {
176
      const contract = (await scopedEngine(request)).saveContract(
177
        InteractionContractSchema.parse(request.body),
178
      );
179
      await persist(request, 'contract', contract.interactionId, contract);
180
      return contract;
181
    });
182
    router.post('/v0.1/events', async (request) => {
183
      const event = (await scopedEngine(request)).recordEvent(
184
        InteractionEventSchema.parse(request.body),
185
      );
186
      await persist(request, 'event', event.eventId, event);
187
      return event;
188
    });
189
    router.post('/v0.1/risk/assess', async (request) =>
190
      (await scopedEngine(request)).assess(request.body as any),
191
    );
192
    router.get('/v0.1/profiles/:id', async (request) => {
193
      const { id } = request.params as { id: string };
194
      const query = request.query as { version?: string; taskCategory?: string };
195
      return (await scopedEngine(request)).getRisk(
196
        id,
197
        query.version ?? '1.0.0',
198
        query.taskCategory ?? 'general',
199
      );
200
    });
201
    router.post('/v0.1/claims/check', async (request) =>
202
      ((value) => factChecker.check(value.claim, value.permission))(
203
        z.object({ claim: z.string(), permission: z.boolean().optional() }).parse(request.body),
204
      ),
205
    );
206
    router.post('/v0.1/receipts', async (request) => {
207
      const receipt = (await scopedEngine(request)).submitReceipt(
208
        ReceiptSchema.parse(request.body),
209
      );
210
      await persist(request, 'receipt', receipt.receiptId, receipt);
211
      return receipt;
212
    });
213
    router.post('/v0.1/receipts/verify', async (request) => {
214
      try {
215
        (await scopedEngine(request)).submitReceipt(ReceiptSchema.parse(request.body));
216
        return { valid: true };
217
      } catch {
218
        return { valid: false };
219
      }
220
    });
221
    router.post('/v0.1/feedback', async (request) => {
222
      const feedback = FeedbackSchema.parse(request.body);
223
      const current = await scopedEngine(request);
224
      const result = current.submitFeedback(feedback);
225
      await persist(request, 'feedback', feedback.feedbackId, feedback);
226
      for (const profile of current.profiles.values())
227
        await persist(
228
          request,
229
          'profile',
230
          `${profile.agentId}|${profile.agentVersion}|${profile.taskCategory}`,
231
          profile,
232
        );
233
      return result;
234
    });
235
    router.post('/v0.1/conflicts', async (request) => {
236
      const conflict = (await scopedEngine(request)).createConflict(request.body as any);
237
      await persist(request, 'conflict', conflict.conflictId, conflict);
238
      return conflict;
239
    });
240
    router.post('/v0.1/conflicts/:id/permit', async (request) => {
241
      const conflict = (await scopedEngine(request)).permitMediation(
242
        (request.params as { id: string }).id,
243
        (request.body as { agentId: string }).agentId,
244
      );
245
      await persist(request, 'conflict', conflict.conflictId, conflict);
246
      return conflict;
247
    });
248
    router.post('/v0.1/conflicts/:id/resolve', async (request) => {
249
      const conflict = (await scopedEngine(request)).resolveConflict(
250
        (request.params as { id: string }).id,
251
        (request.body as { resolution: string }).resolution,
252
      );
253
      await persist(request, 'conflict', conflict.conflictId, conflict);
254
      return conflict;
255
    });
256
    router.post('/v0.1/revocations/agents/:id', async (request) => {
257
      const id = (request.params as { id: string }).id;
258
      const current = await scopedEngine(request);
259
      current.revokeAgent(id);
260
      await persist(request, 'agent', id, current.agents.get(id));
261
      return { revoked: true };
262
    });
263
    router.post('/v0.1/revocations/delegations/:id', async (request) => {
264
      const id = (request.params as { id: string }).id;
265
      const current = await scopedEngine(request);
266
      current.revokeDelegation(id);
267
      await persist(request, 'delegation', id, current.delegations.get(id));
268
      return { revoked: true };
269
    });
270
    router.post('/v0.1/contributions/consent', async (request) => {
271
      const value = z.object({ agentId: z.string(), enabled: z.boolean() }).parse(request.body);
272
      const current = await scopedEngine(request);
273
      current.setContributionConsent(value.agentId, value.enabled);
274
      await persist(request, 'consent', value.agentId, {
275
        agentId: value.agentId,
276
        ...current.contributionConsent.get(value.agentId),
277
      });
278
      return value;
279
    });
280
  });
281
  return app;
282
}