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

Viewing files at commit 18afdc33bab8Commit detailsView latest

packages/persistence/src/hosted.ts 481 lines · 17.7 KB · typescript
1
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
2
import {
3
  DEFAULT_EXTENSION_URI,
4
  FederatedInteractionSchema,
5
  PublicAgentCardSchema,
6
  canonicalHash,
7
  type FederatedInteraction,
8
  type PublicAgentCard,
9
} from '../../protocol/src/index.js';
10
import type { AgentProfile } from './onboarding.js';
11
12
export type HostedRecordKind =
13
  | 'agent'
14
  | 'delegation'
15
  | 'contract'
16
  | 'interaction'
17
  | 'event'
18
  | 'receipt'
19
  | 'feedback'
20
  | 'conflict'
21
  | 'profile'
22
  | 'consent'
23
  | 'revocation'
24
  | 'project'
25
  | 'agent_profile'
26
  | 'installation'
27
  | 'setup_request'
28
  | 'publication';
29
30
export type { PublicAgentCard, FederatedInteraction } from '../../protocol/src/index.js';
31
32
function normalizePublicAgentCard(value: unknown): PublicAgentCard {
33
  const current = PublicAgentCardSchema.safeParse(value);
34
  if (current.success) return current.data;
35
  const legacy = value as Record<string, unknown>;
36
  const agentId = String(legacy.agentId ?? '');
37
  const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
38
    /\/$/,
39
    '',
40
  );
41
  return PublicAgentCardSchema.parse({
42
    protocolVersion: '0.1',
43
    agentId,
44
    name: legacy.name,
45
    description: '',
46
    framework: legacy.framework,
47
    agentVersion: '1.0.0',
48
    capabilities: legacy.capabilities,
49
    limitations: legacy.limitations,
50
    assurance: legacy.assurance,
51
    transports: [],
52
    cardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/card.json`,
53
    a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/a2a-agent-card.json`,
54
    extensionUri: DEFAULT_EXTENSION_URI,
55
    publishedAt: legacy.publishedAt,
56
    updatedAt: legacy.updatedAt,
57
  });
58
}
59
60
export function buildPublicAgentCard(
61
  agent: AgentProfile,
62
  baseUrl: string,
63
  previous?: PublicAgentCard,
64
): PublicAgentCard {
65
  const now = new Date().toISOString();
66
  const root = baseUrl.replace(/\/$/, '');
67
  return PublicAgentCardSchema.parse({
68
    protocolVersion: '0.1',
69
    agentId: agent.agentId,
70
    name: agent.name,
71
    description: agent.description ?? '',
72
    framework: agent.framework,
73
    agentVersion: agent.agentVersion ?? '1.0.0',
74
    capabilities: agent.capabilities,
75
    limitations: agent.limitations,
76
    assurance: 'oauth_authenticated',
77
    transports: agent.a2aEndpoint
78
      ? [{ protocol: 'A2A/1.0', protocolBinding: 'JSONRPC', endpoint: agent.a2aEndpoint }]
79
      : [],
80
    cardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/card.json`,
81
    a2aAgentCardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`,
82
    extensionUri: DEFAULT_EXTENSION_URI,
83
    publishedAt: previous?.publishedAt ?? now,
84
    updatedAt: now,
85
  });
86
}
87
88
export type AccountSettings = {
89
  displayName: string;
90
  contributionEnabled: boolean;
91
  retentionDays: number;
92
  evidenceSharing: 'never' | 'ask' | 'contract_only';
93
  rawConversationsStored: false;
94
};
95
96
const defaults: AccountSettings = {
97
  displayName: '',
98
  contributionEnabled: false,
99
  retentionDays: 30,
100
  evidenceSharing: 'ask',
101
  rawConversationsStored: false,
102
};
103
104
export class HostedRepository {
105
  private readonly sql: NeonQueryFunction<false, false>;
106
  private initialized?: Promise<void>;
107
108
  constructor(databaseUrl: string) {
109
    this.sql = neon(databaseUrl);
110
  }
111
112
  ensureSchema(): Promise<void> {
113
    return (this.initialized ??= (async () => {
114
      await this.sql`
115
        CREATE TABLE IF NOT EXISTS openclasp_records (
116
          operator_id TEXT NOT NULL,
117
          kind TEXT NOT NULL,
118
          record_id TEXT NOT NULL,
119
          payload JSONB NOT NULL,
120
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
121
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
122
          PRIMARY KEY (operator_id, kind, record_id)
123
        )
124
      `;
125
      await this.sql`
126
        CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
127
        ON openclasp_records(operator_id, created_at DESC)
128
      `;
129
      await this.sql`
130
        CREATE TABLE IF NOT EXISTS openclasp_account_settings (
131
          operator_id TEXT PRIMARY KEY,
132
          display_name TEXT NOT NULL DEFAULT '',
133
          contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
134
          retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
135
          evidence_sharing TEXT NOT NULL DEFAULT 'ask'
136
            CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
137
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
138
        )
139
      `;
140
      await this.sql`
141
        CREATE TABLE IF NOT EXISTS openclasp_public_agents (
142
          agent_id TEXT PRIMARY KEY,
143
          operator_id TEXT NOT NULL,
144
          card JSONB NOT NULL,
145
          published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
146
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
147
        )
148
      `;
149
      await this.sql`
150
        CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
151
        ON openclasp_public_agents(updated_at DESC)
152
      `;
153
      await this.sql`
154
        CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
155
          interaction_id TEXT PRIMARY KEY,
156
          initiator_operator_id TEXT NOT NULL,
157
          responder_operator_id TEXT NOT NULL,
158
          initiator_agent_id TEXT NOT NULL,
159
          responder_agent_id TEXT NOT NULL,
160
          status TEXT NOT NULL,
161
          payload JSONB NOT NULL,
162
          expires_at TIMESTAMPTZ NOT NULL,
163
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
164
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
165
        )
166
      `;
167
      await this.sql`
168
        CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
169
        ON openclasp_federated_interactions(initiator_operator_id, responder_operator_id, updated_at DESC)
170
      `;
171
    })());
172
  }
173
174
  async upsert(
175
    operatorId: string,
176
    kind: HostedRecordKind,
177
    recordId: string,
178
    payload: unknown,
179
  ): Promise<void> {
180
    await this.ensureSchema();
181
    const encoded = JSON.stringify(payload);
182
    await this.sql`
183
      INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
184
      VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
185
      ON CONFLICT (operator_id, kind, record_id)
186
      DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
187
    `;
188
  }
189
190
  async list(
191
    operatorId: string,
192
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
193
    await this.ensureSchema();
194
    const rows = await this.sql`
195
      SELECT kind, record_id, payload
196
      FROM openclasp_records
197
      WHERE operator_id = ${operatorId}
198
      ORDER BY created_at ASC
199
    `;
200
    return rows.map((row) => ({
201
      kind: row.kind as HostedRecordKind,
202
      recordId: String(row.record_id),
203
      payload: row.payload,
204
    }));
205
  }
206
207
  async dashboard(operatorId: string) {
208
    const rows = await this.list(operatorId);
209
    const federatedInteractions = await this.listFederatedInteractions(operatorId);
210
    const ofKind = (kind: HostedRecordKind) =>
211
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
212
    return {
213
      agents: [...ofKind('agent_profile'), ...ofKind('agent')],
214
      projects: ofKind('project'),
215
      installations: ofKind('installation'),
216
      setupRequests: ofKind('setup_request'),
217
      publications: ofKind('publication'),
218
      interactions: ofKind('interaction'),
219
      events: ofKind('event'),
220
      conflicts: ofKind('conflict'),
221
      receipts: ofKind('receipt'),
222
      profiles: ofKind('profile'),
223
      federatedInteractions,
224
    };
225
  }
226
227
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
228
    await this.ensureSchema();
229
    card = PublicAgentCardSchema.parse(card);
230
    const encoded = JSON.stringify(card);
231
    const rows = await this.sql`
232
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
233
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
234
      ON CONFLICT (agent_id) DO UPDATE SET
235
        card = EXCLUDED.card,
236
        updated_at = NOW()
237
      WHERE openclasp_public_agents.operator_id = ${operatorId}
238
      RETURNING card
239
    `;
240
    const published = rows[0]?.card as PublicAgentCard | undefined;
241
    if (!published) throw new Error('Agent ID is already owned by another operator');
242
    return published;
243
  }
244
245
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
246
    await this.ensureSchema();
247
    const rows = await this.sql`
248
      DELETE FROM openclasp_public_agents
249
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
250
      RETURNING agent_id
251
    `;
252
    return rows.length > 0;
253
  }
254
255
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
256
    await this.ensureSchema();
257
    const rows = await this.sql`
258
      SELECT card FROM openclasp_public_agents WHERE agent_id = ${agentId}
259
    `;
260
    return rows[0]?.card ? normalizePublicAgentCard(rows[0].card) : undefined;
261
  }
262
263
  async searchPublishedAgents(input: {
264
    query?: string | undefined;
265
    capability?: string | undefined;
266
    limit?: number | undefined;
267
  }): Promise<PublicAgentCard[]> {
268
    await this.ensureSchema();
269
    const rows = await this.sql`
270
      SELECT card FROM openclasp_public_agents ORDER BY updated_at DESC LIMIT 100
271
    `;
272
    const query = input.query?.trim().toLowerCase();
273
    const capability = input.capability?.trim().toLowerCase();
274
    return rows
275
      .map((row) => normalizePublicAgentCard(row.card))
276
      .filter(
277
        (card) =>
278
          (!query ||
279
            card.name.toLowerCase().includes(query) ||
280
            card.framework.toLowerCase().includes(query)) &&
281
          (!capability ||
282
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
283
      )
284
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
285
  }
286
287
  async createFederatedInteraction(
288
    operatorId: string,
289
    value: FederatedInteraction,
290
  ): Promise<FederatedInteraction> {
291
    await this.ensureSchema();
292
    const interaction = FederatedInteractionSchema.parse(value);
293
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
294
    if (interaction.contract.interactionId !== interaction.interactionId)
295
      throw new Error('Contract interaction ID does not match');
296
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
297
      throw new Error('Contract hash does not match the immutable terms');
298
    if (
299
      interaction.contract.parties.length !== 2 ||
300
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
301
      interaction.contract.parties[1] !== interaction.responderAgentId
302
    )
303
      throw new Error('Contract parties do not match the interaction participants');
304
    const acceptanceEntries = Object.entries(interaction.acceptances);
305
    if (
306
      acceptanceEntries.length !== 1 ||
307
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
308
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
309
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
310
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
311
    )
312
      throw new Error('A pending interaction requires only the initiator acceptance');
313
    if (Date.parse(interaction.expiresAt) <= Date.now())
314
      throw new Error('Interaction expiry must be in the future');
315
    const owners = await this.sql`
316
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
317
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
318
    `;
319
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
320
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
321
    if (!initiator || initiator.operator_id !== operatorId)
322
      throw new Error('The initiating agent must be published and owned by this account');
323
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
324
    if (responder.operator_id === operatorId)
325
      throw new Error('Federated interactions require agents from different accounts');
326
    const responderCard = normalizePublicAgentCard(responder.card);
327
    if (
328
      !responderCard.transports.some(
329
        (transport) =>
330
          transport.endpoint === interaction.responderTransport.endpoint &&
331
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
332
      )
333
    )
334
      throw new Error('Responder transport does not match its published Agent Card');
335
    const encoded = JSON.stringify(interaction);
336
    const rows = await this.sql`
337
      INSERT INTO openclasp_federated_interactions(
338
        interaction_id, initiator_operator_id, responder_operator_id,
339
        initiator_agent_id, responder_agent_id, status, payload, expires_at
340
      ) VALUES (
341
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
342
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
343
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
344
      )
345
      RETURNING payload
346
    `;
347
    return FederatedInteractionSchema.parse(rows[0]?.payload);
348
  }
349
350
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
351
    await this.ensureSchema();
352
    await this.sql`
353
      UPDATE openclasp_federated_interactions
354
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
355
      WHERE status = 'pending' AND expires_at <= NOW()
356
    `;
357
    const rows = await this.sql`
358
      SELECT payload FROM openclasp_federated_interactions
359
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
360
      ORDER BY updated_at DESC
361
    `;
362
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
363
  }
364
365
  async getFederatedInteraction(
366
    operatorId: string,
367
    interactionId: string,
368
  ): Promise<FederatedInteraction | undefined> {
369
    const values = await this.listFederatedInteractions(operatorId);
370
    return values.find((value) => value.interactionId === interactionId);
371
  }
372
373
  async respondToFederatedInteraction(
374
    operatorId: string,
375
    interactionId: string,
376
    agentId: string,
377
    decision: 'accept' | 'reject',
378
    method: 'oauth_installation' | 'oauth_account' = 'oauth_account',
379
  ): Promise<FederatedInteraction> {
380
    await this.ensureSchema();
381
    const rows = await this.sql`
382
      SELECT payload, responder_operator_id, responder_agent_id
383
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
384
    `;
385
    const row = rows[0];
386
    if (!row) throw new Error('Interaction invitation not found');
387
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
388
      throw new Error('Only the invited agent may respond');
389
    const current = FederatedInteractionSchema.parse(row.payload);
390
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
391
    const now = new Date().toISOString();
392
    if (Date.parse(current.expiresAt) <= Date.now()) {
393
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
394
      await this.updateFederatedInteraction(interactionId, current.status, expired);
395
      throw new Error('Invitation has expired');
396
    }
397
    const next: FederatedInteraction =
398
      decision === 'reject'
399
        ? { ...current, status: 'rejected', updatedAt: now }
400
        : {
401
            ...current,
402
            status: 'active',
403
            updatedAt: now,
404
            acceptances: {
405
              ...current.acceptances,
406
              [agentId]: {
407
                agentId,
408
                method,
409
                termsHash: current.termsHash,
410
                acceptedAt: now,
411
              },
412
            },
413
          };
414
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
415
    if (!updated) throw new Error('Invitation was already handled');
416
    return next;
417
  }
418
419
  private async updateFederatedInteraction(
420
    interactionId: string,
421
    expectedStatus: string,
422
    value: FederatedInteraction,
423
  ): Promise<boolean> {
424
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
425
    const rows = await this.sql`
426
      UPDATE openclasp_federated_interactions
427
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
428
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
429
      RETURNING interaction_id
430
    `;
431
    return rows.length > 0;
432
  }
433
434
  async getSettings(operatorId: string): Promise<AccountSettings> {
435
    await this.ensureSchema();
436
    const rows = await this.sql`
437
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
438
      FROM openclasp_account_settings
439
      WHERE operator_id = ${operatorId}
440
    `;
441
    const row = rows[0] as
442
      | {
443
          display_name: string;
444
          contribution_enabled: boolean;
445
          retention_days: number;
446
          evidence_sharing: AccountSettings['evidenceSharing'];
447
        }
448
      | undefined;
449
    return row
450
      ? {
451
          displayName: row.display_name,
452
          contributionEnabled: row.contribution_enabled,
453
          retentionDays: row.retention_days,
454
          evidenceSharing: row.evidence_sharing,
455
          rawConversationsStored: false,
456
        }
457
      : defaults;
458
  }
459
460
  async saveSettings(
461
    operatorId: string,
462
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
463
  ): Promise<AccountSettings> {
464
    await this.ensureSchema();
465
    await this.sql`
466
      INSERT INTO openclasp_account_settings(
467
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
468
      ) VALUES (
469
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
470
        ${settings.retentionDays}, ${settings.evidenceSharing}
471
      )
472
      ON CONFLICT (operator_id) DO UPDATE SET
473
        display_name = EXCLUDED.display_name,
474
        contribution_enabled = EXCLUDED.contribution_enabled,
475
        retention_days = EXCLUDED.retention_days,
476
        evidence_sharing = EXCLUDED.evidence_sharing,
477
        updated_at = NOW()
478
    `;
479
    return { ...settings, rawConversationsStored: false };
480
  }
481
}