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

Viewing files at commit 1da1b7ef4016Commit detailsView latest

packages/persistence/src/hosted.ts 771 lines · 28.4 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 AgentPresence,
9
  type PublicAgentCard,
10
} from '../../protocol/src/index.js';
11
import type { AgentProfile } from './onboarding.js';
12
import {
13
  decryptGatewayPayload,
14
  encryptGatewayPayload,
15
  issueGatewayGrant,
16
  verifyGatewayGrant,
17
  type GatewayGrant,
18
} from './relay.js';
19
20
export type HostedRecordKind =
21
  | 'agent'
22
  | 'delegation'
23
  | 'contract'
24
  | 'interaction'
25
  | 'event'
26
  | 'receipt'
27
  | 'feedback'
28
  | 'conflict'
29
  | 'profile'
30
  | 'consent'
31
  | 'revocation'
32
  | 'project'
33
  | 'agent_profile'
34
  | 'installation'
35
  | 'setup_request'
36
  | 'publication'
37
  | 'presence';
38
39
export type { PublicAgentCard, FederatedInteraction } from '../../protocol/src/index.js';
40
41
function normalizePublicAgentCard(value: unknown): PublicAgentCard {
42
  const current = PublicAgentCardSchema.safeParse(value);
43
  if (current.success) {
44
    const card = current.data;
45
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
46
      /\/$/,
47
      '',
48
    );
49
    return PublicAgentCardSchema.parse({
50
      ...card,
51
      transports: [
52
        {
53
          protocol: 'A2A/1.0',
54
          protocolBinding: 'JSONRPC',
55
          endpoint: `${baseUrl}/a2a/${encodeURIComponent(card.agentId)}`,
56
          managedBy: 'openclasp',
57
        },
58
      ],
59
    });
60
  }
61
  const legacy = value as Record<string, unknown>;
62
  const agentId = String(legacy.agentId ?? '');
63
  const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
64
    /\/$/,
65
    '',
66
  );
67
  return PublicAgentCardSchema.parse({
68
    protocolVersion: '0.1',
69
    agentId,
70
    name: legacy.name,
71
    description: '',
72
    framework: legacy.framework,
73
    agentVersion: '1.0.0',
74
    capabilities: legacy.capabilities,
75
    limitations: legacy.limitations,
76
    assurance: legacy.assurance,
77
    transports: [
78
      {
79
        protocol: 'A2A/1.0',
80
        protocolBinding: 'JSONRPC',
81
        endpoint: `${baseUrl}/a2a/${encodeURIComponent(agentId)}`,
82
        managedBy: 'openclasp',
83
      },
84
    ],
85
    cardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/card.json`,
86
    a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/a2a-agent-card.json`,
87
    extensionUri: DEFAULT_EXTENSION_URI,
88
    publishedAt: legacy.publishedAt,
89
    updatedAt: legacy.updatedAt,
90
  });
91
}
92
93
export function buildPublicAgentCard(
94
  agent: AgentProfile,
95
  baseUrl: string,
96
  previous?: PublicAgentCard,
97
): PublicAgentCard {
98
  const now = new Date().toISOString();
99
  const root = baseUrl.replace(/\/$/, '');
100
  return PublicAgentCardSchema.parse({
101
    protocolVersion: '0.1',
102
    agentId: agent.agentId,
103
    name: agent.name,
104
    description: agent.description ?? '',
105
    framework: agent.framework,
106
    agentVersion: agent.agentVersion ?? '1.0.0',
107
    capabilities: agent.capabilities,
108
    limitations: agent.limitations,
109
    assurance: 'oauth_authenticated',
110
    transports: [
111
      {
112
        protocol: 'A2A/1.0',
113
        protocolBinding: 'JSONRPC',
114
        endpoint: `${root}/a2a/${encodeURIComponent(agent.agentId)}`,
115
        managedBy: 'openclasp',
116
      },
117
    ],
118
    cardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/card.json`,
119
    a2aAgentCardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`,
120
    extensionUri: DEFAULT_EXTENSION_URI,
121
    publishedAt: previous?.publishedAt ?? now,
122
    updatedAt: now,
123
  });
124
}
125
126
const normalized = (value: string) =>
127
  value
128
    .trim()
129
    .toLowerCase()
130
    .replace(/[^a-z0-9]+/g, '_');
131
132
export const AGENT_ONLINE_WINDOW_MS = 2 * 60_000;
133
134
export function resolveAgentPresence(lastSeenAt?: string, now = new Date()): AgentPresence {
135
  const online = Boolean(
136
    lastSeenAt && now.getTime() - Date.parse(lastSeenAt) <= AGENT_ONLINE_WINDOW_MS,
137
  );
138
  return {
139
    status: online ? 'online' : 'offline',
140
    ...(lastSeenAt ? { lastSeenAt } : {}),
141
    checkedAt: now.toISOString(),
142
  };
143
}
144
145
export function canAutoAcceptInteraction(
146
  agent: AgentProfile,
147
  interaction: FederatedInteraction,
148
): boolean {
149
  if (agent.autoAcceptPolicy !== 'safe_matching' || agent.status !== 'active') return false;
150
  const categories = new Set(
151
    [...(agent.autoAcceptTaskCategories ?? []), ...(agent.capabilities ?? [])].map(normalized),
152
  );
153
  const taskCategory = normalized(interaction.contract.taskCategory);
154
  if (!taskCategory || !categories.has(taskCategory)) return false;
155
  if (interaction.contract.allowedData.length > 0) return false;
156
  if (
157
    /\b(password|secret|api[_ -]?key|access[_ -]?token|credential|bank|payment|medical|health|ssn|personal[_ -]?data|customer[_ -]?record)\b/i.test(
158
      `${interaction.contract.purpose} ${interaction.contract.requestedOutcome}`,
159
    )
160
  )
161
    return false;
162
  if (interaction.contract.humanApprovalRequirements.length > 0) return false;
163
  if (interaction.contract.retentionDays > 30) return false;
164
  if (interaction.contract.allowedActions.length > 0) {
165
    const capabilities = new Set((agent.capabilities ?? []).map(normalized));
166
    if (
167
      !interaction.contract.allowedActions.every((action) => capabilities.has(normalized(action)))
168
    )
169
      return false;
170
  }
171
  return true;
172
}
173
174
export type AccountSettings = {
175
  displayName: string;
176
  contributionEnabled: boolean;
177
  retentionDays: number;
178
  evidenceSharing: 'never' | 'ask' | 'contract_only';
179
  rawConversationsStored: true;
180
};
181
182
const defaults: AccountSettings = {
183
  displayName: '',
184
  contributionEnabled: false,
185
  retentionDays: 30,
186
  evidenceSharing: 'ask',
187
  rawConversationsStored: true,
188
};
189
190
export class HostedRepository {
191
  private readonly sql: NeonQueryFunction<false, false>;
192
  private initialized?: Promise<void>;
193
194
  constructor(databaseUrl: string) {
195
    this.sql = neon(databaseUrl);
196
  }
197
198
  private gatewaySecret() {
199
    const value = process.env.OPENCLASP_RELAY_ENCRYPTION_KEY;
200
    if (!value || value.length < 32) throw new Error('Gateway encryption is not configured');
201
    return value;
202
  }
203
204
  ensureSchema(): Promise<void> {
205
    return (this.initialized ??= (async () => {
206
      await this.sql`
207
        CREATE TABLE IF NOT EXISTS openclasp_records (
208
          operator_id TEXT NOT NULL,
209
          kind TEXT NOT NULL,
210
          record_id TEXT NOT NULL,
211
          payload JSONB NOT NULL,
212
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
213
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
214
          PRIMARY KEY (operator_id, kind, record_id)
215
        )
216
      `;
217
      await this.sql`
218
        CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
219
        ON openclasp_records(operator_id, created_at DESC)
220
      `;
221
      await this.sql`
222
        CREATE TABLE IF NOT EXISTS openclasp_account_settings (
223
          operator_id TEXT PRIMARY KEY,
224
          display_name TEXT NOT NULL DEFAULT '',
225
          contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
226
          retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
227
          evidence_sharing TEXT NOT NULL DEFAULT 'ask'
228
            CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
229
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
230
        )
231
      `;
232
      await this.sql`
233
        CREATE TABLE IF NOT EXISTS openclasp_public_agents (
234
          agent_id TEXT PRIMARY KEY,
235
          operator_id TEXT NOT NULL,
236
          card JSONB NOT NULL,
237
          published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
238
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
239
        )
240
      `;
241
      await this.sql`
242
        CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
243
        ON openclasp_public_agents(updated_at DESC)
244
      `;
245
      await this.sql`
246
        CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
247
          interaction_id TEXT PRIMARY KEY,
248
          initiator_operator_id TEXT NOT NULL,
249
          responder_operator_id TEXT NOT NULL,
250
          initiator_agent_id TEXT NOT NULL,
251
          responder_agent_id TEXT NOT NULL,
252
          status TEXT NOT NULL,
253
          payload JSONB NOT NULL,
254
          expires_at TIMESTAMPTZ NOT NULL,
255
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
256
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
257
        )
258
      `;
259
      await this.sql`
260
        CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
261
        ON openclasp_federated_interactions(initiator_operator_id, responder_operator_id, updated_at DESC)
262
      `;
263
      await this.sql`
264
        CREATE TABLE IF NOT EXISTS openclasp_gateway_messages (
265
          message_id TEXT PRIMARY KEY,
266
          idempotency_key TEXT UNIQUE,
267
          interaction_id TEXT NOT NULL,
268
          sender_agent_id TEXT NOT NULL,
269
          recipient_agent_id TEXT NOT NULL,
270
          ciphertext TEXT NOT NULL,
271
          iv TEXT NOT NULL,
272
          auth_tag TEXT NOT NULL,
273
          content_type TEXT NOT NULL,
274
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
275
          expires_at TIMESTAMPTZ NOT NULL,
276
          delivered_at TIMESTAMPTZ
277
        )
278
      `;
279
      await this.sql`
280
        CREATE INDEX IF NOT EXISTS openclasp_gateway_messages_inbox
281
        ON openclasp_gateway_messages(recipient_agent_id, created_at ASC)
282
      `;
283
    })());
284
  }
285
286
  issueGatewayToken(interaction: FederatedInteraction): string {
287
    return issueGatewayGrant(this.gatewaySecret(), {
288
      interactionId: interaction.interactionId,
289
      senderAgentId: interaction.initiatorAgentId,
290
      recipientAgentId: interaction.responderAgentId,
291
      expiresAt: Math.min(Date.parse(interaction.expiresAt), Date.now() + 60 * 60_000),
292
    });
293
  }
294
295
  verifyGatewayToken(token: string): GatewayGrant {
296
    return verifyGatewayGrant(this.gatewaySecret(), token);
297
  }
298
299
  async enqueueGatewayMessage(input: {
300
    interactionId: string;
301
    senderAgentId: string;
302
    recipientAgentId: string;
303
    payload: unknown;
304
    contentType?: string;
305
    idempotencyKey?: string;
306
  }) {
307
    await this.ensureSchema();
308
    const values = await this.sql`
309
      SELECT payload FROM openclasp_federated_interactions
310
      WHERE interaction_id = ${input.interactionId} AND status = 'active'
311
    `;
312
    const interaction = values[0]?.payload
313
      ? FederatedInteractionSchema.parse(values[0].payload)
314
      : undefined;
315
    if (!interaction) throw new Error('An active OpenClasp interaction is required');
316
    const parties = new Set([interaction.initiatorAgentId, interaction.responderAgentId]);
317
    if (
318
      !parties.has(input.senderAgentId) ||
319
      !parties.has(input.recipientAgentId) ||
320
      input.senderAgentId === input.recipientAgentId
321
    )
322
      throw new Error('Gateway participants do not match the interaction');
323
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), input.payload);
324
    const messageId = crypto.randomUUID();
325
    const expiresAt = new Date(Date.now() + 24 * 60 * 60_000).toISOString();
326
    const rows = await this.sql`
327
      INSERT INTO openclasp_gateway_messages(
328
        message_id, idempotency_key, interaction_id, sender_agent_id, recipient_agent_id,
329
        ciphertext, iv, auth_tag, content_type, expires_at
330
      ) VALUES (
331
        ${messageId}, ${input.idempotencyKey ?? null}, ${input.interactionId},
332
        ${input.senderAgentId}, ${input.recipientAgentId}, ${encrypted.ciphertext},
333
        ${encrypted.iv}, ${encrypted.authTag}, ${input.contentType ?? 'application/json'}, ${expiresAt}
334
      )
335
      ON CONFLICT (idempotency_key) DO NOTHING
336
      RETURNING message_id
337
    `;
338
    return {
339
      accepted: true,
340
      deduplicated: rows.length === 0,
341
      messageId: rows.length ? String(rows[0]?.message_id) : undefined,
342
      expiresAt,
343
    };
344
  }
345
346
  async listGatewayMessages(operatorId: string, agentId: string, limit = 20) {
347
    await this.ensureSchema();
348
    const owned = await this.sql`
349
      SELECT 1 FROM openclasp_records
350
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
351
    `;
352
    if (!owned.length) throw new Error('Agent is not owned by this account');
353
    await this.sql`DELETE FROM openclasp_gateway_messages WHERE expires_at <= NOW()`;
354
    const rows = await this.sql`
355
      UPDATE openclasp_gateway_messages SET delivered_at = COALESCE(delivered_at, NOW())
356
      WHERE message_id IN (
357
        SELECT message_id FROM openclasp_gateway_messages
358
        WHERE recipient_agent_id = ${agentId} AND expires_at > NOW()
359
        ORDER BY created_at ASC LIMIT ${Math.min(Math.max(limit, 1), 50)}
360
      )
361
      RETURNING message_id, interaction_id, sender_agent_id, recipient_agent_id,
362
        ciphertext, iv, auth_tag, content_type, created_at, expires_at
363
    `;
364
    return rows.map((row) => ({
365
      messageId: String(row.message_id),
366
      interactionId: String(row.interaction_id),
367
      senderAgentId: String(row.sender_agent_id),
368
      recipientAgentId: String(row.recipient_agent_id),
369
      contentType: String(row.content_type),
370
      createdAt: new Date(String(row.created_at)).toISOString(),
371
      expiresAt: new Date(String(row.expires_at)).toISOString(),
372
      payload: decryptGatewayPayload(this.gatewaySecret(), {
373
        ciphertext: String(row.ciphertext),
374
        iv: String(row.iv),
375
        authTag: String(row.auth_tag),
376
      }),
377
    }));
378
  }
379
380
  async acknowledgeGatewayMessage(operatorId: string, agentId: string, messageId: string) {
381
    await this.ensureSchema();
382
    const rows = await this.sql`
383
      DELETE FROM openclasp_gateway_messages message
384
      USING openclasp_records agent
385
      WHERE message.message_id = ${messageId}
386
        AND message.recipient_agent_id = ${agentId}
387
        AND agent.operator_id = ${operatorId}
388
        AND agent.kind = 'agent_profile'
389
        AND agent.record_id = ${agentId}
390
      RETURNING message.message_id
391
    `;
392
    if (!rows.length) throw new Error('Gateway message not found');
393
    return { acknowledged: true, messageId };
394
  }
395
396
  async upsert(
397
    operatorId: string,
398
    kind: HostedRecordKind,
399
    recordId: string,
400
    payload: unknown,
401
  ): Promise<void> {
402
    await this.ensureSchema();
403
    const encoded = JSON.stringify(payload);
404
    await this.sql`
405
      INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
406
      VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
407
      ON CONFLICT (operator_id, kind, record_id)
408
      DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
409
    `;
410
  }
411
412
  async list(
413
    operatorId: string,
414
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
415
    await this.ensureSchema();
416
    const rows = await this.sql`
417
      SELECT kind, record_id, payload
418
      FROM openclasp_records
419
      WHERE operator_id = ${operatorId}
420
      ORDER BY created_at ASC
421
    `;
422
    return rows.map((row) => ({
423
      kind: row.kind as HostedRecordKind,
424
      recordId: String(row.record_id),
425
      payload: row.payload,
426
    }));
427
  }
428
429
  async dashboard(operatorId: string) {
430
    const rows = await this.list(operatorId);
431
    const federatedInteractions = await this.listFederatedInteractions(operatorId);
432
    const ofKind = (kind: HostedRecordKind) =>
433
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
434
    const presence = new Map(
435
      rows
436
        .filter((row) => row.kind === 'presence')
437
        .map((row) => [row.recordId, String(row.payload.lastSeenAt)]),
438
    );
439
    return {
440
      agents: [...ofKind('agent_profile'), ...ofKind('agent')].map((agent) => ({
441
        ...agent,
442
        presence: resolveAgentPresence(presence.get(String(agent.agentId))),
443
      })),
444
      projects: ofKind('project'),
445
      installations: ofKind('installation'),
446
      setupRequests: ofKind('setup_request'),
447
      publications: ofKind('publication'),
448
      interactions: ofKind('interaction'),
449
      events: ofKind('event'),
450
      conflicts: ofKind('conflict'),
451
      receipts: ofKind('receipt'),
452
      profiles: ofKind('profile'),
453
      federatedInteractions,
454
    };
455
  }
456
457
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
458
    await this.ensureSchema();
459
    card = PublicAgentCardSchema.parse(card);
460
    const encoded = JSON.stringify(card);
461
    const rows = await this.sql`
462
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
463
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
464
      ON CONFLICT (agent_id) DO UPDATE SET
465
        card = EXCLUDED.card,
466
        updated_at = NOW()
467
      WHERE openclasp_public_agents.operator_id = ${operatorId}
468
      RETURNING card
469
    `;
470
    const published = rows[0]?.card as PublicAgentCard | undefined;
471
    if (!published) throw new Error('Agent ID is already owned by another operator');
472
    return published;
473
  }
474
475
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
476
    await this.ensureSchema();
477
    const rows = await this.sql`
478
      DELETE FROM openclasp_public_agents
479
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
480
      RETURNING agent_id
481
    `;
482
    return rows.length > 0;
483
  }
484
485
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
486
    await this.ensureSchema();
487
    const rows = await this.sql`
488
      SELECT card FROM openclasp_public_agents WHERE agent_id = ${agentId}
489
    `;
490
    if (!rows[0]?.card) return undefined;
491
    const card = normalizePublicAgentCard(rows[0].card);
492
    return { ...card, presence: await this.getAgentPresence(agentId) };
493
  }
494
495
  async searchPublishedAgents(input: {
496
    query?: string | undefined;
497
    capability?: string | undefined;
498
    limit?: number | undefined;
499
  }): Promise<PublicAgentCard[]> {
500
    await this.ensureSchema();
501
    const rows = await this.sql`
502
      SELECT card FROM openclasp_public_agents ORDER BY updated_at DESC LIMIT 100
503
    `;
504
    const query = input.query?.trim().toLowerCase();
505
    const capability = input.capability?.trim().toLowerCase();
506
    const presenceRows = await this.sql`
507
      SELECT records.record_id, records.payload
508
      FROM openclasp_records records
509
      INNER JOIN openclasp_public_agents agents ON agents.agent_id = records.record_id
510
      WHERE records.kind = 'presence'
511
    `;
512
    const presence = new Map(
513
      presenceRows.map((row) => [String(row.record_id), String(row.payload.lastSeenAt)]),
514
    );
515
    return rows
516
      .map((row) => {
517
        const card = normalizePublicAgentCard(row.card);
518
        return { ...card, presence: resolveAgentPresence(presence.get(card.agentId)) };
519
      })
520
      .filter(
521
        (card) =>
522
          (!query ||
523
            card.name.toLowerCase().includes(query) ||
524
            card.framework.toLowerCase().includes(query)) &&
525
          (!capability ||
526
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
527
      )
528
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
529
  }
530
531
  async touchAgentPresence(operatorId: string, agentId: string): Promise<AgentPresence> {
532
    await this.ensureSchema();
533
    const owned = await this.sql`
534
      SELECT 1 FROM openclasp_records
535
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
536
    `;
537
    if (!owned.length) throw new Error('Agent is not owned by this account');
538
    const lastSeenAt = new Date().toISOString();
539
    await this.upsert(operatorId, 'presence', agentId, { lastSeenAt });
540
    return resolveAgentPresence(lastSeenAt);
541
  }
542
543
  async getAgentPresence(agentId: string): Promise<AgentPresence> {
544
    await this.ensureSchema();
545
    const rows = await this.sql`
546
      SELECT records.payload
547
      FROM openclasp_public_agents agents
548
      LEFT JOIN openclasp_records records
549
        ON records.operator_id = agents.operator_id
550
        AND records.kind = 'presence'
551
        AND records.record_id = agents.agent_id
552
      WHERE agents.agent_id = ${agentId}
553
      LIMIT 1
554
    `;
555
    if (!rows.length) throw new Error('Published agent not found');
556
    const lastSeenAt = rows[0]?.payload?.lastSeenAt;
557
    return resolveAgentPresence(typeof lastSeenAt === 'string' ? lastSeenAt : undefined);
558
  }
559
560
  async createFederatedInteraction(
561
    operatorId: string,
562
    value: FederatedInteraction,
563
  ): Promise<FederatedInteraction> {
564
    await this.ensureSchema();
565
    const interaction = FederatedInteractionSchema.parse(value);
566
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
567
    if (interaction.contract.interactionId !== interaction.interactionId)
568
      throw new Error('Contract interaction ID does not match');
569
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
570
      throw new Error('Contract hash does not match the immutable terms');
571
    if (
572
      interaction.contract.parties.length !== 2 ||
573
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
574
      interaction.contract.parties[1] !== interaction.responderAgentId
575
    )
576
      throw new Error('Contract parties do not match the interaction participants');
577
    const acceptanceEntries = Object.entries(interaction.acceptances);
578
    if (
579
      acceptanceEntries.length !== 1 ||
580
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
581
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
582
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
583
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
584
    )
585
      throw new Error('A pending interaction requires only the initiator acceptance');
586
    if (Date.parse(interaction.expiresAt) <= Date.now())
587
      throw new Error('Interaction expiry must be in the future');
588
    const owners = await this.sql`
589
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
590
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
591
    `;
592
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
593
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
594
    if (!initiator || initiator.operator_id !== operatorId)
595
      throw new Error('The initiating agent must be published and owned by this account');
596
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
597
    if (responder.operator_id === operatorId)
598
      throw new Error('Federated interactions require agents from different accounts');
599
    const responderCard = normalizePublicAgentCard(responder.card);
600
    if (
601
      !responderCard.transports.some(
602
        (transport) =>
603
          transport.endpoint === interaction.responderTransport.endpoint &&
604
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
605
      )
606
    )
607
      throw new Error('Responder transport does not match its published Agent Card');
608
    const encoded = JSON.stringify(interaction);
609
    const rows = await this.sql`
610
      INSERT INTO openclasp_federated_interactions(
611
        interaction_id, initiator_operator_id, responder_operator_id,
612
        initiator_agent_id, responder_agent_id, status, payload, expires_at
613
      ) VALUES (
614
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
615
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
616
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
617
      )
618
      RETURNING payload
619
    `;
620
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
621
    const profiles = await this.sql`
622
      SELECT payload FROM openclasp_records
623
      WHERE operator_id = ${responder.operator_id}
624
        AND kind = 'agent_profile'
625
        AND record_id = ${interaction.responderAgentId}
626
      LIMIT 1
627
    `;
628
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
629
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
630
      return this.respondToFederatedInteraction(
631
        String(responder.operator_id),
632
        stored.interactionId,
633
        stored.responderAgentId,
634
        'accept',
635
        'policy_auto_accept',
636
      );
637
    return stored;
638
  }
639
640
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
641
    await this.ensureSchema();
642
    await this.sql`
643
      UPDATE openclasp_federated_interactions
644
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
645
      WHERE status = 'pending' AND expires_at <= NOW()
646
    `;
647
    const rows = await this.sql`
648
      SELECT payload FROM openclasp_federated_interactions
649
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
650
      ORDER BY updated_at DESC
651
    `;
652
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
653
  }
654
655
  async getFederatedInteraction(
656
    operatorId: string,
657
    interactionId: string,
658
  ): Promise<FederatedInteraction | undefined> {
659
    const values = await this.listFederatedInteractions(operatorId);
660
    return values.find((value) => value.interactionId === interactionId);
661
  }
662
663
  async respondToFederatedInteraction(
664
    operatorId: string,
665
    interactionId: string,
666
    agentId: string,
667
    decision: 'accept' | 'reject',
668
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
669
  ): Promise<FederatedInteraction> {
670
    await this.ensureSchema();
671
    const rows = await this.sql`
672
      SELECT payload, responder_operator_id, responder_agent_id
673
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
674
    `;
675
    const row = rows[0];
676
    if (!row) throw new Error('Interaction invitation not found');
677
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
678
      throw new Error('Only the invited agent may respond');
679
    const current = FederatedInteractionSchema.parse(row.payload);
680
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
681
    const now = new Date().toISOString();
682
    if (Date.parse(current.expiresAt) <= Date.now()) {
683
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
684
      await this.updateFederatedInteraction(interactionId, current.status, expired);
685
      throw new Error('Invitation has expired');
686
    }
687
    const next: FederatedInteraction =
688
      decision === 'reject'
689
        ? { ...current, status: 'rejected', updatedAt: now }
690
        : {
691
            ...current,
692
            status: 'active',
693
            updatedAt: now,
694
            acceptances: {
695
              ...current.acceptances,
696
              [agentId]: {
697
                agentId,
698
                method,
699
                termsHash: current.termsHash,
700
                acceptedAt: now,
701
              },
702
            },
703
          };
704
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
705
    if (!updated) throw new Error('Invitation was already handled');
706
    return next;
707
  }
708
709
  private async updateFederatedInteraction(
710
    interactionId: string,
711
    expectedStatus: string,
712
    value: FederatedInteraction,
713
  ): Promise<boolean> {
714
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
715
    const rows = await this.sql`
716
      UPDATE openclasp_federated_interactions
717
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
718
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
719
      RETURNING interaction_id
720
    `;
721
    return rows.length > 0;
722
  }
723
724
  async getSettings(operatorId: string): Promise<AccountSettings> {
725
    await this.ensureSchema();
726
    const rows = await this.sql`
727
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
728
      FROM openclasp_account_settings
729
      WHERE operator_id = ${operatorId}
730
    `;
731
    const row = rows[0] as
732
      | {
733
          display_name: string;
734
          contribution_enabled: boolean;
735
          retention_days: number;
736
          evidence_sharing: AccountSettings['evidenceSharing'];
737
        }
738
      | undefined;
739
    return row
740
      ? {
741
          displayName: row.display_name,
742
          contributionEnabled: row.contribution_enabled,
743
          retentionDays: row.retention_days,
744
          evidenceSharing: row.evidence_sharing,
745
          rawConversationsStored: true,
746
        }
747
      : defaults;
748
  }
749
750
  async saveSettings(
751
    operatorId: string,
752
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
753
  ): Promise<AccountSettings> {
754
    await this.ensureSchema();
755
    await this.sql`
756
      INSERT INTO openclasp_account_settings(
757
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
758
      ) VALUES (
759
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
760
        ${settings.retentionDays}, ${settings.evidenceSharing}
761
      )
762
      ON CONFLICT (operator_id) DO UPDATE SET
763
        display_name = EXCLUDED.display_name,
764
        contribution_enabled = EXCLUDED.contribution_enabled,
765
        retention_days = EXCLUDED.retention_days,
766
        evidence_sharing = EXCLUDED.evidence_sharing,
767
        updated_at = NOW()
768
    `;
769
    return { ...settings, rawConversationsStored: true };
770
  }
771
}