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

Viewing files at commit c2c9d5ba27afCommit detailsView latest

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