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

Viewing files at commit 9a8fdf2387ffCommit detailsView latest

packages/persistence/src/hosted.ts 533 lines · 19.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
const normalized = (value: string) =>
89
  value
90
    .trim()
91
    .toLowerCase()
92
    .replace(/[^a-z0-9]+/g, '_');
93
94
export function canAutoAcceptInteraction(
95
  agent: AgentProfile,
96
  interaction: FederatedInteraction,
97
): boolean {
98
  if (agent.autoAcceptPolicy !== 'safe_matching' || agent.status !== 'active') return false;
99
  const categories = new Set(
100
    [...(agent.autoAcceptTaskCategories ?? []), ...(agent.capabilities ?? [])].map(normalized),
101
  );
102
  const taskCategory = normalized(interaction.contract.taskCategory);
103
  if (!taskCategory || !categories.has(taskCategory)) return false;
104
  if (interaction.contract.allowedData.length > 0) return false;
105
  if (
106
    /\b(password|secret|api[_ -]?key|access[_ -]?token|credential|bank|payment|medical|health|ssn|personal[_ -]?data|customer[_ -]?record)\b/i.test(
107
      `${interaction.contract.purpose} ${interaction.contract.requestedOutcome}`,
108
    )
109
  )
110
    return false;
111
  if (interaction.contract.humanApprovalRequirements.length > 0) return false;
112
  if (interaction.contract.retentionDays > 30) return false;
113
  if (interaction.contract.allowedActions.length > 0) {
114
    const capabilities = new Set((agent.capabilities ?? []).map(normalized));
115
    if (
116
      !interaction.contract.allowedActions.every((action) => capabilities.has(normalized(action)))
117
    )
118
      return false;
119
  }
120
  return true;
121
}
122
123
export type AccountSettings = {
124
  displayName: string;
125
  contributionEnabled: boolean;
126
  retentionDays: number;
127
  evidenceSharing: 'never' | 'ask' | 'contract_only';
128
  rawConversationsStored: false;
129
};
130
131
const defaults: AccountSettings = {
132
  displayName: '',
133
  contributionEnabled: false,
134
  retentionDays: 30,
135
  evidenceSharing: 'ask',
136
  rawConversationsStored: false,
137
};
138
139
export class HostedRepository {
140
  private readonly sql: NeonQueryFunction<false, false>;
141
  private initialized?: Promise<void>;
142
143
  constructor(databaseUrl: string) {
144
    this.sql = neon(databaseUrl);
145
  }
146
147
  ensureSchema(): Promise<void> {
148
    return (this.initialized ??= (async () => {
149
      await this.sql`
150
        CREATE TABLE IF NOT EXISTS openclasp_records (
151
          operator_id TEXT NOT NULL,
152
          kind TEXT NOT NULL,
153
          record_id TEXT NOT NULL,
154
          payload JSONB NOT NULL,
155
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
156
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
157
          PRIMARY KEY (operator_id, kind, record_id)
158
        )
159
      `;
160
      await this.sql`
161
        CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
162
        ON openclasp_records(operator_id, created_at DESC)
163
      `;
164
      await this.sql`
165
        CREATE TABLE IF NOT EXISTS openclasp_account_settings (
166
          operator_id TEXT PRIMARY KEY,
167
          display_name TEXT NOT NULL DEFAULT '',
168
          contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
169
          retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
170
          evidence_sharing TEXT NOT NULL DEFAULT 'ask'
171
            CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
172
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
173
        )
174
      `;
175
      await this.sql`
176
        CREATE TABLE IF NOT EXISTS openclasp_public_agents (
177
          agent_id TEXT PRIMARY KEY,
178
          operator_id TEXT NOT NULL,
179
          card JSONB NOT NULL,
180
          published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
181
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
182
        )
183
      `;
184
      await this.sql`
185
        CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
186
        ON openclasp_public_agents(updated_at DESC)
187
      `;
188
      await this.sql`
189
        CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
190
          interaction_id TEXT PRIMARY KEY,
191
          initiator_operator_id TEXT NOT NULL,
192
          responder_operator_id TEXT NOT NULL,
193
          initiator_agent_id TEXT NOT NULL,
194
          responder_agent_id TEXT NOT NULL,
195
          status TEXT NOT NULL,
196
          payload JSONB NOT NULL,
197
          expires_at TIMESTAMPTZ NOT NULL,
198
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
199
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
200
        )
201
      `;
202
      await this.sql`
203
        CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
204
        ON openclasp_federated_interactions(initiator_operator_id, responder_operator_id, updated_at DESC)
205
      `;
206
    })());
207
  }
208
209
  async upsert(
210
    operatorId: string,
211
    kind: HostedRecordKind,
212
    recordId: string,
213
    payload: unknown,
214
  ): Promise<void> {
215
    await this.ensureSchema();
216
    const encoded = JSON.stringify(payload);
217
    await this.sql`
218
      INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
219
      VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
220
      ON CONFLICT (operator_id, kind, record_id)
221
      DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
222
    `;
223
  }
224
225
  async list(
226
    operatorId: string,
227
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
228
    await this.ensureSchema();
229
    const rows = await this.sql`
230
      SELECT kind, record_id, payload
231
      FROM openclasp_records
232
      WHERE operator_id = ${operatorId}
233
      ORDER BY created_at ASC
234
    `;
235
    return rows.map((row) => ({
236
      kind: row.kind as HostedRecordKind,
237
      recordId: String(row.record_id),
238
      payload: row.payload,
239
    }));
240
  }
241
242
  async dashboard(operatorId: string) {
243
    const rows = await this.list(operatorId);
244
    const federatedInteractions = await this.listFederatedInteractions(operatorId);
245
    const ofKind = (kind: HostedRecordKind) =>
246
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
247
    return {
248
      agents: [...ofKind('agent_profile'), ...ofKind('agent')],
249
      projects: ofKind('project'),
250
      installations: ofKind('installation'),
251
      setupRequests: ofKind('setup_request'),
252
      publications: ofKind('publication'),
253
      interactions: ofKind('interaction'),
254
      events: ofKind('event'),
255
      conflicts: ofKind('conflict'),
256
      receipts: ofKind('receipt'),
257
      profiles: ofKind('profile'),
258
      federatedInteractions,
259
    };
260
  }
261
262
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
263
    await this.ensureSchema();
264
    card = PublicAgentCardSchema.parse(card);
265
    const encoded = JSON.stringify(card);
266
    const rows = await this.sql`
267
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
268
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
269
      ON CONFLICT (agent_id) DO UPDATE SET
270
        card = EXCLUDED.card,
271
        updated_at = NOW()
272
      WHERE openclasp_public_agents.operator_id = ${operatorId}
273
      RETURNING card
274
    `;
275
    const published = rows[0]?.card as PublicAgentCard | undefined;
276
    if (!published) throw new Error('Agent ID is already owned by another operator');
277
    return published;
278
  }
279
280
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
281
    await this.ensureSchema();
282
    const rows = await this.sql`
283
      DELETE FROM openclasp_public_agents
284
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
285
      RETURNING agent_id
286
    `;
287
    return rows.length > 0;
288
  }
289
290
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
291
    await this.ensureSchema();
292
    const rows = await this.sql`
293
      SELECT card FROM openclasp_public_agents WHERE agent_id = ${agentId}
294
    `;
295
    return rows[0]?.card ? normalizePublicAgentCard(rows[0].card) : undefined;
296
  }
297
298
  async searchPublishedAgents(input: {
299
    query?: string | undefined;
300
    capability?: string | undefined;
301
    limit?: number | undefined;
302
  }): Promise<PublicAgentCard[]> {
303
    await this.ensureSchema();
304
    const rows = await this.sql`
305
      SELECT card FROM openclasp_public_agents ORDER BY updated_at DESC LIMIT 100
306
    `;
307
    const query = input.query?.trim().toLowerCase();
308
    const capability = input.capability?.trim().toLowerCase();
309
    return rows
310
      .map((row) => normalizePublicAgentCard(row.card))
311
      .filter(
312
        (card) =>
313
          (!query ||
314
            card.name.toLowerCase().includes(query) ||
315
            card.framework.toLowerCase().includes(query)) &&
316
          (!capability ||
317
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
318
      )
319
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
320
  }
321
322
  async createFederatedInteraction(
323
    operatorId: string,
324
    value: FederatedInteraction,
325
  ): Promise<FederatedInteraction> {
326
    await this.ensureSchema();
327
    const interaction = FederatedInteractionSchema.parse(value);
328
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
329
    if (interaction.contract.interactionId !== interaction.interactionId)
330
      throw new Error('Contract interaction ID does not match');
331
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
332
      throw new Error('Contract hash does not match the immutable terms');
333
    if (
334
      interaction.contract.parties.length !== 2 ||
335
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
336
      interaction.contract.parties[1] !== interaction.responderAgentId
337
    )
338
      throw new Error('Contract parties do not match the interaction participants');
339
    const acceptanceEntries = Object.entries(interaction.acceptances);
340
    if (
341
      acceptanceEntries.length !== 1 ||
342
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
343
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
344
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
345
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
346
    )
347
      throw new Error('A pending interaction requires only the initiator acceptance');
348
    if (Date.parse(interaction.expiresAt) <= Date.now())
349
      throw new Error('Interaction expiry must be in the future');
350
    const owners = await this.sql`
351
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
352
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
353
    `;
354
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
355
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
356
    if (!initiator || initiator.operator_id !== operatorId)
357
      throw new Error('The initiating agent must be published and owned by this account');
358
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
359
    if (responder.operator_id === operatorId)
360
      throw new Error('Federated interactions require agents from different accounts');
361
    const responderCard = normalizePublicAgentCard(responder.card);
362
    if (
363
      !responderCard.transports.some(
364
        (transport) =>
365
          transport.endpoint === interaction.responderTransport.endpoint &&
366
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
367
      )
368
    )
369
      throw new Error('Responder transport does not match its published Agent Card');
370
    const encoded = JSON.stringify(interaction);
371
    const rows = await this.sql`
372
      INSERT INTO openclasp_federated_interactions(
373
        interaction_id, initiator_operator_id, responder_operator_id,
374
        initiator_agent_id, responder_agent_id, status, payload, expires_at
375
      ) VALUES (
376
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
377
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
378
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
379
      )
380
      RETURNING payload
381
    `;
382
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
383
    const profiles = await this.sql`
384
      SELECT payload FROM openclasp_records
385
      WHERE operator_id = ${responder.operator_id}
386
        AND kind = 'agent_profile'
387
        AND record_id = ${interaction.responderAgentId}
388
      LIMIT 1
389
    `;
390
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
391
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
392
      return this.respondToFederatedInteraction(
393
        String(responder.operator_id),
394
        stored.interactionId,
395
        stored.responderAgentId,
396
        'accept',
397
        'policy_auto_accept',
398
      );
399
    return stored;
400
  }
401
402
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
403
    await this.ensureSchema();
404
    await this.sql`
405
      UPDATE openclasp_federated_interactions
406
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
407
      WHERE status = 'pending' AND expires_at <= NOW()
408
    `;
409
    const rows = await this.sql`
410
      SELECT payload FROM openclasp_federated_interactions
411
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
412
      ORDER BY updated_at DESC
413
    `;
414
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
415
  }
416
417
  async getFederatedInteraction(
418
    operatorId: string,
419
    interactionId: string,
420
  ): Promise<FederatedInteraction | undefined> {
421
    const values = await this.listFederatedInteractions(operatorId);
422
    return values.find((value) => value.interactionId === interactionId);
423
  }
424
425
  async respondToFederatedInteraction(
426
    operatorId: string,
427
    interactionId: string,
428
    agentId: string,
429
    decision: 'accept' | 'reject',
430
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
431
  ): Promise<FederatedInteraction> {
432
    await this.ensureSchema();
433
    const rows = await this.sql`
434
      SELECT payload, responder_operator_id, responder_agent_id
435
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
436
    `;
437
    const row = rows[0];
438
    if (!row) throw new Error('Interaction invitation not found');
439
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
440
      throw new Error('Only the invited agent may respond');
441
    const current = FederatedInteractionSchema.parse(row.payload);
442
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
443
    const now = new Date().toISOString();
444
    if (Date.parse(current.expiresAt) <= Date.now()) {
445
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
446
      await this.updateFederatedInteraction(interactionId, current.status, expired);
447
      throw new Error('Invitation has expired');
448
    }
449
    const next: FederatedInteraction =
450
      decision === 'reject'
451
        ? { ...current, status: 'rejected', updatedAt: now }
452
        : {
453
            ...current,
454
            status: 'active',
455
            updatedAt: now,
456
            acceptances: {
457
              ...current.acceptances,
458
              [agentId]: {
459
                agentId,
460
                method,
461
                termsHash: current.termsHash,
462
                acceptedAt: now,
463
              },
464
            },
465
          };
466
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
467
    if (!updated) throw new Error('Invitation was already handled');
468
    return next;
469
  }
470
471
  private async updateFederatedInteraction(
472
    interactionId: string,
473
    expectedStatus: string,
474
    value: FederatedInteraction,
475
  ): Promise<boolean> {
476
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
477
    const rows = await this.sql`
478
      UPDATE openclasp_federated_interactions
479
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
480
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
481
      RETURNING interaction_id
482
    `;
483
    return rows.length > 0;
484
  }
485
486
  async getSettings(operatorId: string): Promise<AccountSettings> {
487
    await this.ensureSchema();
488
    const rows = await this.sql`
489
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
490
      FROM openclasp_account_settings
491
      WHERE operator_id = ${operatorId}
492
    `;
493
    const row = rows[0] as
494
      | {
495
          display_name: string;
496
          contribution_enabled: boolean;
497
          retention_days: number;
498
          evidence_sharing: AccountSettings['evidenceSharing'];
499
        }
500
      | undefined;
501
    return row
502
      ? {
503
          displayName: row.display_name,
504
          contributionEnabled: row.contribution_enabled,
505
          retentionDays: row.retention_days,
506
          evidenceSharing: row.evidence_sharing,
507
          rawConversationsStored: false,
508
        }
509
      : defaults;
510
  }
511
512
  async saveSettings(
513
    operatorId: string,
514
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
515
  ): Promise<AccountSettings> {
516
    await this.ensureSchema();
517
    await this.sql`
518
      INSERT INTO openclasp_account_settings(
519
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
520
      ) VALUES (
521
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
522
        ${settings.retentionDays}, ${settings.evidenceSharing}
523
      )
524
      ON CONFLICT (operator_id) DO UPDATE SET
525
        display_name = EXCLUDED.display_name,
526
        contribution_enabled = EXCLUDED.contribution_enabled,
527
        retention_days = EXCLUDED.retention_days,
528
        evidence_sharing = EXCLUDED.evidence_sharing,
529
        updated_at = NOW()
530
    `;
531
    return { ...settings, rawConversationsStored: false };
532
  }
533
}