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

Viewing files at commit 0fc0266ffa14Commit detailsView latest

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