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

Viewing files at commit fd993cf6d9f8Commit detailsView latest

packages/persistence/src/hosted.ts 1051 lines · 39.7 KB · typescript
1
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
2
import { send } from '@vercel/queue';
3
import { randomBytes } from 'node:crypto';
4
import {
5
  DEFAULT_EXTENSION_URI,
6
  FederatedInteractionSchema,
7
  PublicAgentCardSchema,
8
  canonicalHash,
9
  type FederatedInteraction,
10
  type AgentPresence,
11
  type PublicAgentCard,
12
  RuntimeDeliverySchema,
13
} from '../../protocol/src/index.js';
14
import type { AgentProfile } from './onboarding.js';
15
import {
16
  decryptGatewayPayload,
17
  encryptGatewayPayload,
18
  issueGatewayGrant,
19
  verifyGatewayGrant,
20
  type GatewayGrant,
21
} from './relay.js';
22
import { postRuntimeJson, runtimeDeliverySignature } from './runtime.js';
23
24
const RUNTIME_DELIVERY_TOPIC = 'openclasp-agent-delivery';
25
26
export type HostedRecordKind =
27
  | 'agent'
28
  | 'delegation'
29
  | 'contract'
30
  | 'interaction'
31
  | 'event'
32
  | 'receipt'
33
  | 'feedback'
34
  | 'conflict'
35
  | 'profile'
36
  | 'consent'
37
  | 'revocation'
38
  | 'project'
39
  | 'agent_profile'
40
  | 'installation'
41
  | 'setup_request'
42
  | 'publication'
43
  | 'presence';
44
45
export type { PublicAgentCard, FederatedInteraction } from '../../protocol/src/index.js';
46
47
function normalizePublicAgentCard(value: unknown): PublicAgentCard {
48
  const current = PublicAgentCardSchema.safeParse(value);
49
  if (current.success) {
50
    const card = current.data;
51
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
52
      /\/$/,
53
      '',
54
    );
55
    return PublicAgentCardSchema.parse({
56
      ...card,
57
      transports: [
58
        {
59
          protocol: 'A2A/1.0',
60
          protocolBinding: 'JSONRPC',
61
          endpoint: `${baseUrl}/a2a/${encodeURIComponent(card.agentId)}`,
62
          managedBy: 'openclasp',
63
        },
64
      ],
65
    });
66
  }
67
  const legacy = value as Record<string, unknown>;
68
  const agentId = String(legacy.agentId ?? '');
69
  const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
70
    /\/$/,
71
    '',
72
  );
73
  return PublicAgentCardSchema.parse({
74
    protocolVersion: '0.1',
75
    agentId,
76
    name: legacy.name,
77
    description: '',
78
    framework: legacy.framework,
79
    agentVersion: '1.0.0',
80
    capabilities: legacy.capabilities,
81
    limitations: legacy.limitations,
82
    assurance: legacy.assurance,
83
    transports: [
84
      {
85
        protocol: 'A2A/1.0',
86
        protocolBinding: 'JSONRPC',
87
        endpoint: `${baseUrl}/a2a/${encodeURIComponent(agentId)}`,
88
        managedBy: 'openclasp',
89
      },
90
    ],
91
    cardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/card.json`,
92
    a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/a2a-agent-card.json`,
93
    extensionUri: DEFAULT_EXTENSION_URI,
94
    publishedAt: legacy.publishedAt,
95
    updatedAt: legacy.updatedAt,
96
  });
97
}
98
99
export function buildPublicAgentCard(
100
  agent: AgentProfile,
101
  baseUrl: string,
102
  previous?: PublicAgentCard,
103
): PublicAgentCard {
104
  const now = new Date().toISOString();
105
  const root = baseUrl.replace(/\/$/, '');
106
  return PublicAgentCardSchema.parse({
107
    protocolVersion: '0.1',
108
    agentId: agent.agentId,
109
    name: agent.name,
110
    description: agent.description ?? '',
111
    framework: agent.framework,
112
    agentVersion: agent.agentVersion ?? '1.0.0',
113
    capabilities: agent.capabilities,
114
    limitations: agent.limitations,
115
    assurance: 'oauth_authenticated',
116
    transports: [
117
      {
118
        protocol: 'A2A/1.0',
119
        protocolBinding: 'JSONRPC',
120
        endpoint: `${root}/a2a/${encodeURIComponent(agent.agentId)}`,
121
        managedBy: 'openclasp',
122
      },
123
    ],
124
    cardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/card.json`,
125
    a2aAgentCardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`,
126
    extensionUri: DEFAULT_EXTENSION_URI,
127
    publishedAt: previous?.publishedAt ?? now,
128
    updatedAt: now,
129
  });
130
}
131
132
const normalized = (value: string) =>
133
  value
134
    .trim()
135
    .toLowerCase()
136
    .replace(/[^a-z0-9]+/g, '_');
137
138
export const AGENT_ONLINE_WINDOW_MS = 2 * 60_000;
139
140
export function resolveAgentPresence(lastSeenAt?: string, now = new Date()): AgentPresence {
141
  const online = Boolean(
142
    lastSeenAt && now.getTime() - Date.parse(lastSeenAt) <= AGENT_ONLINE_WINDOW_MS,
143
  );
144
  return {
145
    status: online ? 'online' : 'offline',
146
    ...(lastSeenAt ? { lastSeenAt } : {}),
147
    checkedAt: now.toISOString(),
148
  };
149
}
150
151
export function canAutoAcceptInteraction(
152
  agent: AgentProfile,
153
  interaction: FederatedInteraction,
154
): boolean {
155
  if (agent.autoAcceptPolicy !== 'safe_matching' || agent.status !== 'active') return false;
156
  const categories = new Set(
157
    [...(agent.autoAcceptTaskCategories ?? []), ...(agent.capabilities ?? [])].map(normalized),
158
  );
159
  const taskCategory = normalized(interaction.contract.taskCategory);
160
  if (!taskCategory || !categories.has(taskCategory)) return false;
161
  if (interaction.contract.allowedData.length > 0) return false;
162
  if (
163
    /\b(password|secret|api[_ -]?key|access[_ -]?token|credential|bank|payment|medical|health|ssn|personal[_ -]?data|customer[_ -]?record)\b/i.test(
164
      `${interaction.contract.purpose} ${interaction.contract.requestedOutcome}`,
165
    )
166
  )
167
    return false;
168
  if (interaction.contract.humanApprovalRequirements.length > 0) return false;
169
  if (interaction.contract.retentionDays > 30) return false;
170
  if (interaction.contract.allowedActions.length > 0) {
171
    const capabilities = new Set((agent.capabilities ?? []).map(normalized));
172
    if (
173
      !interaction.contract.allowedActions.every((action) => capabilities.has(normalized(action)))
174
    )
175
      return false;
176
  }
177
  return true;
178
}
179
180
export type AccountSettings = {
181
  displayName: string;
182
  contributionEnabled: boolean;
183
  retentionDays: number;
184
  evidenceSharing: 'never' | 'ask' | 'contract_only';
185
  rawConversationsStored: true;
186
};
187
188
const defaults: AccountSettings = {
189
  displayName: '',
190
  contributionEnabled: false,
191
  retentionDays: 30,
192
  evidenceSharing: 'ask',
193
  rawConversationsStored: true,
194
};
195
196
export class HostedRepository {
197
  private readonly sql: NeonQueryFunction<false, false>;
198
  private initialized?: Promise<void>;
199
200
  constructor(databaseUrl: string) {
201
    this.sql = neon(databaseUrl);
202
  }
203
204
  private gatewaySecret() {
205
    const value = process.env.OPENCLASP_RELAY_ENCRYPTION_KEY;
206
    if (!value || value.length < 32) throw new Error('Gateway encryption is not configured');
207
    return value;
208
  }
209
210
  ensureSchema(): Promise<void> {
211
    return (this.initialized ??= (async () => {
212
      await this.sql`
213
        CREATE TABLE IF NOT EXISTS openclasp_records (
214
          operator_id TEXT NOT NULL,
215
          kind TEXT NOT NULL,
216
          record_id TEXT NOT NULL,
217
          payload JSONB NOT NULL,
218
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
219
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
220
          PRIMARY KEY (operator_id, kind, record_id)
221
        )
222
      `;
223
      await this.sql`
224
        CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
225
        ON openclasp_records(operator_id, created_at DESC)
226
      `;
227
      await this.sql`
228
        CREATE TABLE IF NOT EXISTS openclasp_account_settings (
229
          operator_id TEXT PRIMARY KEY,
230
          display_name TEXT NOT NULL DEFAULT '',
231
          contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
232
          retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
233
          evidence_sharing TEXT NOT NULL DEFAULT 'ask'
234
            CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
235
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
236
        )
237
      `;
238
      await this.sql`
239
        CREATE TABLE IF NOT EXISTS openclasp_public_agents (
240
          agent_id TEXT PRIMARY KEY,
241
          operator_id TEXT NOT NULL,
242
          card JSONB NOT NULL,
243
          published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
244
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
245
        )
246
      `;
247
      await this.sql`
248
        CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
249
        ON openclasp_public_agents(updated_at DESC)
250
      `;
251
      await this.sql`
252
        CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
253
          interaction_id TEXT PRIMARY KEY,
254
          initiator_operator_id TEXT NOT NULL,
255
          responder_operator_id TEXT NOT NULL,
256
          initiator_agent_id TEXT NOT NULL,
257
          responder_agent_id TEXT NOT NULL,
258
          status TEXT NOT NULL,
259
          payload JSONB NOT NULL,
260
          expires_at TIMESTAMPTZ NOT NULL,
261
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
262
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
263
        )
264
      `;
265
      await this.sql`
266
        CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
267
        ON openclasp_federated_interactions(initiator_operator_id, responder_operator_id, updated_at DESC)
268
      `;
269
      await this.sql`
270
        CREATE TABLE IF NOT EXISTS openclasp_gateway_messages (
271
          message_id TEXT PRIMARY KEY,
272
          idempotency_key TEXT UNIQUE,
273
          interaction_id TEXT NOT NULL,
274
          sender_agent_id TEXT NOT NULL,
275
          recipient_agent_id TEXT NOT NULL,
276
          ciphertext TEXT NOT NULL,
277
          iv TEXT NOT NULL,
278
          auth_tag TEXT NOT NULL,
279
          content_type TEXT NOT NULL,
280
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
281
          expires_at TIMESTAMPTZ NOT NULL,
282
          delivered_at TIMESTAMPTZ
283
        )
284
      `;
285
      await this.sql`
286
        CREATE INDEX IF NOT EXISTS openclasp_gateway_messages_inbox
287
        ON openclasp_gateway_messages(recipient_agent_id, created_at ASC)
288
      `;
289
      await this.sql`
290
        ALTER TABLE openclasp_gateway_messages
291
        ADD COLUMN IF NOT EXISTS runtime_delivered_at TIMESTAMPTZ,
292
        ADD COLUMN IF NOT EXISTS runtime_delivery_attempts INTEGER NOT NULL DEFAULT 0,
293
        ADD COLUMN IF NOT EXISTS runtime_delivery_error TEXT
294
      `;
295
      await this.sql`
296
        CREATE TABLE IF NOT EXISTS openclasp_agent_runtimes (
297
          agent_id TEXT PRIMARY KEY,
298
          operator_id TEXT NOT NULL,
299
          endpoint TEXT NOT NULL,
300
          secret_ciphertext TEXT NOT NULL,
301
          secret_iv TEXT NOT NULL,
302
          secret_auth_tag TEXT NOT NULL,
303
          status TEXT NOT NULL CHECK (status IN ('verified', 'disabled')),
304
          verified_at TIMESTAMPTZ NOT NULL,
305
          last_delivery_at TIMESTAMPTZ,
306
          last_error TEXT,
307
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
308
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
309
        )
310
      `;
311
    })());
312
  }
313
314
  issueGatewayToken(interaction: FederatedInteraction): string {
315
    return issueGatewayGrant(this.gatewaySecret(), {
316
      interactionId: interaction.interactionId,
317
      senderAgentId: interaction.initiatorAgentId,
318
      recipientAgentId: interaction.responderAgentId,
319
      expiresAt: Math.min(Date.parse(interaction.expiresAt), Date.now() + 60 * 60_000),
320
    });
321
  }
322
323
  verifyGatewayToken(token: string): GatewayGrant {
324
    return verifyGatewayGrant(this.gatewaySecret(), token);
325
  }
326
327
  async enqueueGatewayMessage(input: {
328
    interactionId: string;
329
    senderAgentId: string;
330
    recipientAgentId: string;
331
    payload: unknown;
332
    contentType?: string;
333
    idempotencyKey?: string;
334
  }) {
335
    await this.ensureSchema();
336
    const values = await this.sql`
337
      SELECT payload FROM openclasp_federated_interactions
338
      WHERE interaction_id = ${input.interactionId} AND status = 'active'
339
    `;
340
    const interaction = values[0]?.payload
341
      ? FederatedInteractionSchema.parse(values[0].payload)
342
      : undefined;
343
    if (!interaction) throw new Error('An active OpenClasp interaction is required');
344
    const parties = new Set([interaction.initiatorAgentId, interaction.responderAgentId]);
345
    if (
346
      !parties.has(input.senderAgentId) ||
347
      !parties.has(input.recipientAgentId) ||
348
      input.senderAgentId === input.recipientAgentId
349
    )
350
      throw new Error('Gateway participants do not match the interaction');
351
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), input.payload);
352
    const messageId = crypto.randomUUID();
353
    const expiresAt = new Date(Date.now() + 24 * 60 * 60_000).toISOString();
354
    const rows = await this.sql`
355
      INSERT INTO openclasp_gateway_messages(
356
        message_id, idempotency_key, interaction_id, sender_agent_id, recipient_agent_id,
357
        ciphertext, iv, auth_tag, content_type, expires_at
358
      ) SELECT
359
        ${messageId}, ${input.idempotencyKey ?? null}, ${input.interactionId},
360
        ${input.senderAgentId}, ${input.recipientAgentId}, ${encrypted.ciphertext},
361
        ${encrypted.iv}, ${encrypted.authTag}, ${input.contentType ?? 'application/json'}, ${expiresAt}
362
      WHERE (
363
        SELECT COUNT(*) FROM openclasp_gateway_messages
364
        WHERE interaction_id = ${input.interactionId} AND created_at >= NOW() - INTERVAL '24 hours'
365
      ) < 100
366
      ON CONFLICT (idempotency_key) DO NOTHING
367
      RETURNING message_id
368
    `;
369
    let storedMessageId = rows.length ? String(rows[0]?.message_id) : undefined;
370
    if (!storedMessageId && input.idempotencyKey) {
371
      const existing = await this.sql`
372
        SELECT message_id FROM openclasp_gateway_messages
373
        WHERE idempotency_key = ${input.idempotencyKey}
374
      `;
375
      storedMessageId = existing[0]?.message_id ? String(existing[0].message_id) : undefined;
376
    }
377
    if (!storedMessageId)
378
      throw new Error('Interaction message limit reached (100 messages per 24 hours)');
379
    const runtimes = await this.sql`
380
      SELECT status FROM openclasp_agent_runtimes
381
      WHERE agent_id = ${input.recipientAgentId} AND status = 'verified'
382
    `;
383
    if (storedMessageId && runtimes.length)
384
      await send(
385
        RUNTIME_DELIVERY_TOPIC,
386
        { messageId: storedMessageId },
387
        { idempotencyKey: `delivery:${storedMessageId}`, retentionSeconds: 86_400 },
388
      );
389
    return {
390
      accepted: true,
391
      deduplicated: rows.length === 0,
392
      messageId: storedMessageId,
393
      runtimeDispatchQueued: Boolean(storedMessageId && runtimes.length),
394
      expiresAt,
395
    };
396
  }
397
398
  async listGatewayMessages(operatorId: string, agentId: string, limit = 20) {
399
    await this.ensureSchema();
400
    const owned = await this.sql`
401
      SELECT 1 FROM openclasp_records
402
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
403
    `;
404
    if (!owned.length) throw new Error('Agent is not owned by this account');
405
    await this.sql`DELETE FROM openclasp_gateway_messages WHERE expires_at <= NOW()`;
406
    const rows = await this.sql`
407
      UPDATE openclasp_gateway_messages SET delivered_at = COALESCE(delivered_at, NOW())
408
      WHERE message_id IN (
409
        SELECT message_id FROM openclasp_gateway_messages
410
        WHERE recipient_agent_id = ${agentId} AND expires_at > NOW()
411
        ORDER BY created_at ASC LIMIT ${Math.min(Math.max(limit, 1), 50)}
412
      )
413
      RETURNING message_id, interaction_id, sender_agent_id, recipient_agent_id,
414
        ciphertext, iv, auth_tag, content_type, created_at, expires_at
415
    `;
416
    return rows.map((row) => ({
417
      messageId: String(row.message_id),
418
      interactionId: String(row.interaction_id),
419
      senderAgentId: String(row.sender_agent_id),
420
      recipientAgentId: String(row.recipient_agent_id),
421
      contentType: String(row.content_type),
422
      createdAt: new Date(String(row.created_at)).toISOString(),
423
      expiresAt: new Date(String(row.expires_at)).toISOString(),
424
      payload: decryptGatewayPayload(this.gatewaySecret(), {
425
        ciphertext: String(row.ciphertext),
426
        iv: String(row.iv),
427
        authTag: String(row.auth_tag),
428
      }),
429
    }));
430
  }
431
432
  async acknowledgeGatewayMessage(operatorId: string, agentId: string, messageId: string) {
433
    await this.ensureSchema();
434
    const rows = await this.sql`
435
      DELETE FROM openclasp_gateway_messages message
436
      USING openclasp_records agent
437
      WHERE message.message_id = ${messageId}
438
        AND message.recipient_agent_id = ${agentId}
439
        AND agent.operator_id = ${operatorId}
440
        AND agent.kind = 'agent_profile'
441
        AND agent.record_id = ${agentId}
442
      RETURNING message.message_id
443
    `;
444
    if (!rows.length) throw new Error('Gateway message not found');
445
    return { acknowledged: true, messageId };
446
  }
447
448
  async upsert(
449
    operatorId: string,
450
    kind: HostedRecordKind,
451
    recordId: string,
452
    payload: unknown,
453
  ): Promise<void> {
454
    await this.ensureSchema();
455
    const encoded = JSON.stringify(payload);
456
    await this.sql`
457
      INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
458
      VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
459
      ON CONFLICT (operator_id, kind, record_id)
460
      DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
461
    `;
462
  }
463
464
  async list(
465
    operatorId: string,
466
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
467
    await this.ensureSchema();
468
    const rows = await this.sql`
469
      SELECT kind, record_id, payload
470
      FROM openclasp_records
471
      WHERE operator_id = ${operatorId}
472
      ORDER BY created_at ASC
473
    `;
474
    return rows.map((row) => ({
475
      kind: row.kind as HostedRecordKind,
476
      recordId: String(row.record_id),
477
      payload: row.payload,
478
    }));
479
  }
480
481
  async dashboard(operatorId: string) {
482
    const rows = await this.list(operatorId);
483
    const [federatedInteractions, runtimes] = await Promise.all([
484
      this.listFederatedInteractions(operatorId),
485
      this.listAgentRuntimes(operatorId),
486
    ]);
487
    const ofKind = (kind: HostedRecordKind) =>
488
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
489
    const presence = new Map(
490
      rows
491
        .filter((row) => row.kind === 'presence')
492
        .map((row) => [row.recordId, String(row.payload.lastSeenAt)]),
493
    );
494
    return {
495
      agents: [...ofKind('agent_profile'), ...ofKind('agent')].map((agent) => ({
496
        ...agent,
497
        presence: resolveAgentPresence(presence.get(String(agent.agentId))),
498
      })),
499
      projects: ofKind('project'),
500
      installations: ofKind('installation'),
501
      setupRequests: ofKind('setup_request'),
502
      publications: ofKind('publication'),
503
      interactions: ofKind('interaction'),
504
      events: ofKind('event'),
505
      conflicts: ofKind('conflict'),
506
      receipts: ofKind('receipt'),
507
      profiles: ofKind('profile'),
508
      federatedInteractions,
509
      runtimes,
510
    };
511
  }
512
513
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
514
    await this.ensureSchema();
515
    card = PublicAgentCardSchema.parse(card);
516
    const encoded = JSON.stringify(card);
517
    const rows = await this.sql`
518
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
519
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
520
      ON CONFLICT (agent_id) DO UPDATE SET
521
        card = EXCLUDED.card,
522
        updated_at = NOW()
523
      WHERE openclasp_public_agents.operator_id = ${operatorId}
524
      RETURNING card
525
    `;
526
    const published = rows[0]?.card as PublicAgentCard | undefined;
527
    if (!published) throw new Error('Agent ID is already owned by another operator');
528
    return published;
529
  }
530
531
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
532
    await this.ensureSchema();
533
    const rows = await this.sql`
534
      DELETE FROM openclasp_public_agents
535
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
536
      RETURNING agent_id
537
    `;
538
    return rows.length > 0;
539
  }
540
541
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
542
    await this.ensureSchema();
543
    const rows = await this.sql`
544
      SELECT card FROM openclasp_public_agents WHERE agent_id = ${agentId}
545
    `;
546
    if (!rows[0]?.card) return undefined;
547
    const card = normalizePublicAgentCard(rows[0].card);
548
    return { ...card, presence: await this.getAgentPresence(agentId) };
549
  }
550
551
  async searchPublishedAgents(input: {
552
    query?: string | undefined;
553
    capability?: string | undefined;
554
    limit?: number | undefined;
555
  }): Promise<PublicAgentCard[]> {
556
    await this.ensureSchema();
557
    const rows = await this.sql`
558
      SELECT card FROM openclasp_public_agents ORDER BY updated_at DESC LIMIT 100
559
    `;
560
    const query = input.query?.trim().toLowerCase();
561
    const capability = input.capability?.trim().toLowerCase();
562
    const presenceRows = await this.sql`
563
      SELECT records.record_id, records.payload
564
      FROM openclasp_records records
565
      INNER JOIN openclasp_public_agents agents ON agents.agent_id = records.record_id
566
      WHERE records.kind = 'presence'
567
    `;
568
    const presence = new Map(
569
      presenceRows.map((row) => [String(row.record_id), String(row.payload.lastSeenAt)]),
570
    );
571
    return rows
572
      .map((row) => {
573
        const card = normalizePublicAgentCard(row.card);
574
        return { ...card, presence: resolveAgentPresence(presence.get(card.agentId)) };
575
      })
576
      .filter(
577
        (card) =>
578
          (!query ||
579
            card.name.toLowerCase().includes(query) ||
580
            card.framework.toLowerCase().includes(query)) &&
581
          (!capability ||
582
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
583
      )
584
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
585
  }
586
587
  async touchAgentPresence(operatorId: string, agentId: string): Promise<AgentPresence> {
588
    await this.ensureSchema();
589
    const owned = await this.sql`
590
      SELECT 1 FROM openclasp_records
591
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
592
    `;
593
    if (!owned.length) throw new Error('Agent is not owned by this account');
594
    const lastSeenAt = new Date().toISOString();
595
    await this.upsert(operatorId, 'presence', agentId, { lastSeenAt });
596
    return resolveAgentPresence(lastSeenAt);
597
  }
598
599
  async getAgentPresence(agentId: string): Promise<AgentPresence> {
600
    await this.ensureSchema();
601
    const rows = await this.sql`
602
      SELECT records.payload
603
      FROM openclasp_public_agents agents
604
      LEFT JOIN openclasp_records records
605
        ON records.operator_id = agents.operator_id
606
        AND records.kind = 'presence'
607
        AND records.record_id = agents.agent_id
608
      WHERE agents.agent_id = ${agentId}
609
      LIMIT 1
610
    `;
611
    if (!rows.length) throw new Error('Published agent not found');
612
    const lastSeenAt = rows[0]?.payload?.lastSeenAt;
613
    return resolveAgentPresence(typeof lastSeenAt === 'string' ? lastSeenAt : undefined);
614
  }
615
616
  async listAgentRuntimes(operatorId: string) {
617
    await this.ensureSchema();
618
    const rows = await this.sql`
619
      SELECT agent_id, endpoint, status, verified_at, last_delivery_at, last_error, updated_at
620
      FROM openclasp_agent_runtimes
621
      WHERE operator_id = ${operatorId}
622
      ORDER BY updated_at DESC
623
    `;
624
    return rows.map((row) => ({
625
      agentId: String(row.agent_id),
626
      endpoint: String(row.endpoint),
627
      status: row.status as 'verified' | 'disabled',
628
      verifiedAt: new Date(String(row.verified_at)).toISOString(),
629
      ...(row.last_delivery_at
630
        ? { lastDeliveryAt: new Date(String(row.last_delivery_at)).toISOString() }
631
        : {}),
632
      ...(row.last_error ? { lastError: String(row.last_error) } : {}),
633
      updatedAt: new Date(String(row.updated_at)).toISOString(),
634
    }));
635
  }
636
637
  async registerAgentRuntime(operatorId: string, agentId: string, endpoint: string) {
638
    await this.ensureSchema();
639
    const owned = await this.sql`
640
      SELECT 1 FROM openclasp_records
641
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
642
    `;
643
    if (!owned.length) throw new Error('Agent is not owned by this account');
644
    const challenge = crypto.randomUUID();
645
    const verification = await postRuntimeJson(endpoint, {
646
      type: 'openclasp.runtime.verify',
647
      version: '1',
648
      agentId,
649
      challenge,
650
    });
651
    if (
652
      verification.status < 200 ||
653
      verification.status >= 300 ||
654
      !verification.body ||
655
      typeof verification.body !== 'object' ||
656
      (verification.body as { type?: unknown }).type !== 'openclasp.runtime.verified' ||
657
      (verification.body as { version?: unknown }).version !== '1' ||
658
      (verification.body as { agentId?: unknown }).agentId !== agentId ||
659
      (verification.body as { challenge?: unknown }).challenge !== challenge
660
    )
661
      throw new Error('Runtime endpoint did not return the verification challenge');
662
    const runtimeSecret = randomBytes(32).toString('base64url');
663
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), runtimeSecret);
664
    const verifiedAt = new Date().toISOString();
665
    const saved = await this.sql`
666
      INSERT INTO openclasp_agent_runtimes(
667
        agent_id, operator_id, endpoint, secret_ciphertext, secret_iv, secret_auth_tag,
668
        status, verified_at, updated_at
669
      ) VALUES (
670
        ${agentId}, ${operatorId}, ${endpoint}, ${encrypted.ciphertext}, ${encrypted.iv},
671
        ${encrypted.authTag}, 'verified', ${verifiedAt}, NOW()
672
      )
673
      ON CONFLICT (agent_id) DO UPDATE SET
674
        operator_id = EXCLUDED.operator_id,
675
        endpoint = EXCLUDED.endpoint,
676
        secret_ciphertext = EXCLUDED.secret_ciphertext,
677
        secret_iv = EXCLUDED.secret_iv,
678
        secret_auth_tag = EXCLUDED.secret_auth_tag,
679
        status = 'verified',
680
        verified_at = EXCLUDED.verified_at,
681
        last_error = NULL,
682
        updated_at = NOW()
683
      WHERE openclasp_agent_runtimes.operator_id = ${operatorId}
684
      RETURNING agent_id
685
    `;
686
    if (!saved.length) throw new Error('Agent runtime is owned by another account');
687
    const pending = await this.sql`
688
      SELECT message_id FROM openclasp_gateway_messages
689
      WHERE recipient_agent_id = ${agentId}
690
        AND runtime_delivered_at IS NULL
691
        AND expires_at > NOW()
692
      ORDER BY created_at ASC
693
      LIMIT 100
694
    `;
695
    const queued = await Promise.allSettled(
696
      pending.map((row) =>
697
        send(
698
          RUNTIME_DELIVERY_TOPIC,
699
          { messageId: String(row.message_id) },
700
          {
701
            idempotencyKey: `registration:${verifiedAt}:${row.message_id}`,
702
            retentionSeconds: 86_400,
703
          },
704
        ),
705
      ),
706
    );
707
    return {
708
      agentId,
709
      endpoint,
710
      status: 'verified' as const,
711
      verifiedAt,
712
      signingSecret: runtimeSecret,
713
      secretShownOnce: true,
714
      queuedMessages: pending.length,
715
      queueFailures: queued.filter((result) => result.status === 'rejected').length,
716
    };
717
  }
718
719
  async disableAgentRuntime(operatorId: string, agentId: string) {
720
    await this.ensureSchema();
721
    const rows = await this.sql`
722
      UPDATE openclasp_agent_runtimes
723
      SET status = 'disabled', updated_at = NOW()
724
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
725
      RETURNING agent_id
726
    `;
727
    if (!rows.length) throw new Error('Agent runtime not found');
728
    return { agentId, status: 'disabled' as const };
729
  }
730
731
  async deliverGatewayMessage(messageId: string) {
732
    await this.ensureSchema();
733
    const rows = await this.sql`
734
      SELECT message.*, runtime.endpoint,
735
        runtime.secret_ciphertext, runtime.secret_iv, runtime.secret_auth_tag,
736
        interaction.payload AS interaction
737
      FROM openclasp_gateway_messages message
738
      INNER JOIN openclasp_agent_runtimes runtime
739
        ON runtime.agent_id = message.recipient_agent_id AND runtime.status = 'verified'
740
      INNER JOIN openclasp_federated_interactions interaction
741
        ON interaction.interaction_id = message.interaction_id
742
      WHERE message.message_id = ${messageId}
743
        AND message.runtime_delivered_at IS NULL
744
        AND message.expires_at > NOW()
745
      LIMIT 1
746
    `;
747
    const row = rows[0];
748
    if (!row) return { delivered: false, terminal: true };
749
    const runtimeSecret = decryptGatewayPayload(this.gatewaySecret(), {
750
      ciphertext: String(row.secret_ciphertext),
751
      iv: String(row.secret_iv),
752
      authTag: String(row.secret_auth_tag),
753
    });
754
    if (typeof runtimeSecret !== 'string') throw new Error('Invalid runtime signing secret');
755
    const interaction = FederatedInteractionSchema.parse(row.interaction);
756
    const expiresAt = new Date(String(row.expires_at)).toISOString();
757
    const delivery = RuntimeDeliverySchema.parse({
758
      type: 'openclasp.a2a.delivery',
759
      version: '1',
760
      deliveryId: String(row.message_id),
761
      agentId: String(row.recipient_agent_id),
762
      interactionId: String(row.interaction_id),
763
      receivedAt: new Date().toISOString(),
764
      message: {
765
        messageId: String(row.message_id),
766
        senderAgentId: String(row.sender_agent_id),
767
        contentType: String(row.content_type),
768
        createdAt: new Date(String(row.created_at)).toISOString(),
769
        expiresAt,
770
        payload: decryptGatewayPayload(this.gatewaySecret(), {
771
          ciphertext: String(row.ciphertext),
772
          iv: String(row.iv),
773
          authTag: String(row.auth_tag),
774
        }),
775
      },
776
      reply: {
777
        endpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/a2a/${encodeURIComponent(String(row.sender_agent_id))}`,
778
        bearerToken: issueGatewayGrant(this.gatewaySecret(), {
779
          interactionId: interaction.interactionId,
780
          senderAgentId: String(row.recipient_agent_id),
781
          recipientAgentId: String(row.sender_agent_id),
782
          expiresAt: Math.min(Date.parse(expiresAt), Date.now() + 60 * 60_000),
783
        }),
784
      },
785
    });
786
    const body = JSON.stringify(delivery);
787
    const timestamp = new Date().toISOString();
788
    try {
789
      const response = await postRuntimeJson(String(row.endpoint), delivery, {
790
        'openclasp-delivery-id': delivery.deliveryId,
791
        'openclasp-timestamp': timestamp,
792
        'openclasp-signature': `v1=${runtimeDeliverySignature(runtimeSecret, delivery.deliveryId, timestamp, body)}`,
793
      });
794
      if (response.status < 200 || response.status >= 300)
795
        throw new Error(`Runtime endpoint returned HTTP ${response.status}`);
796
      await this.sql`
797
        UPDATE openclasp_gateway_messages
798
        SET runtime_delivered_at = NOW(), runtime_delivery_attempts = runtime_delivery_attempts + 1,
799
          runtime_delivery_error = NULL
800
        WHERE message_id = ${messageId} AND runtime_delivered_at IS NULL
801
      `;
802
      await this.sql`
803
        UPDATE openclasp_agent_runtimes
804
        SET last_delivery_at = NOW(), last_error = NULL, updated_at = NOW()
805
        WHERE agent_id = ${delivery.agentId}
806
      `;
807
      const runtimeRows = await this.sql`
808
        SELECT operator_id FROM openclasp_agent_runtimes WHERE agent_id = ${delivery.agentId}
809
      `;
810
      if (runtimeRows[0]?.operator_id)
811
        await this.touchAgentPresence(String(runtimeRows[0].operator_id), delivery.agentId);
812
      return { delivered: true, messageId };
813
    } catch (error) {
814
      const reason =
815
        error instanceof Error ? error.message.slice(0, 500) : 'Runtime delivery failed';
816
      await this.sql`
817
        UPDATE openclasp_gateway_messages
818
        SET runtime_delivery_attempts = runtime_delivery_attempts + 1,
819
          runtime_delivery_error = ${reason}
820
        WHERE message_id = ${messageId}
821
      `;
822
      await this.sql`
823
        UPDATE openclasp_agent_runtimes
824
        SET last_error = ${reason}, updated_at = NOW()
825
        WHERE agent_id = ${delivery.agentId}
826
      `;
827
      throw error;
828
    }
829
  }
830
831
  async abandonGatewayDelivery(messageId: string, reason: string) {
832
    await this.ensureSchema();
833
    await this.sql`
834
      UPDATE openclasp_gateway_messages
835
      SET runtime_delivery_error = ${reason.slice(0, 500)}
836
      WHERE message_id = ${messageId} AND runtime_delivered_at IS NULL
837
    `;
838
  }
839
840
  async createFederatedInteraction(
841
    operatorId: string,
842
    value: FederatedInteraction,
843
  ): Promise<FederatedInteraction> {
844
    await this.ensureSchema();
845
    const interaction = FederatedInteractionSchema.parse(value);
846
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
847
    if (interaction.contract.interactionId !== interaction.interactionId)
848
      throw new Error('Contract interaction ID does not match');
849
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
850
      throw new Error('Contract hash does not match the immutable terms');
851
    if (
852
      interaction.contract.parties.length !== 2 ||
853
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
854
      interaction.contract.parties[1] !== interaction.responderAgentId
855
    )
856
      throw new Error('Contract parties do not match the interaction participants');
857
    const acceptanceEntries = Object.entries(interaction.acceptances);
858
    if (
859
      acceptanceEntries.length !== 1 ||
860
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
861
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
862
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
863
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
864
    )
865
      throw new Error('A pending interaction requires only the initiator acceptance');
866
    if (Date.parse(interaction.expiresAt) <= Date.now())
867
      throw new Error('Interaction expiry must be in the future');
868
    const owners = await this.sql`
869
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
870
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
871
    `;
872
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
873
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
874
    if (!initiator || initiator.operator_id !== operatorId)
875
      throw new Error('The initiating agent must be published and owned by this account');
876
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
877
    if (responder.operator_id === operatorId)
878
      throw new Error('Federated interactions require agents from different accounts');
879
    const responderCard = normalizePublicAgentCard(responder.card);
880
    if (
881
      !responderCard.transports.some(
882
        (transport) =>
883
          transport.endpoint === interaction.responderTransport.endpoint &&
884
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
885
      )
886
    )
887
      throw new Error('Responder transport does not match its published Agent Card');
888
    const encoded = JSON.stringify(interaction);
889
    const rows = await this.sql`
890
      INSERT INTO openclasp_federated_interactions(
891
        interaction_id, initiator_operator_id, responder_operator_id,
892
        initiator_agent_id, responder_agent_id, status, payload, expires_at
893
      ) VALUES (
894
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
895
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
896
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
897
      )
898
      RETURNING payload
899
    `;
900
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
901
    const profiles = await this.sql`
902
      SELECT payload FROM openclasp_records
903
      WHERE operator_id = ${responder.operator_id}
904
        AND kind = 'agent_profile'
905
        AND record_id = ${interaction.responderAgentId}
906
      LIMIT 1
907
    `;
908
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
909
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
910
      return this.respondToFederatedInteraction(
911
        String(responder.operator_id),
912
        stored.interactionId,
913
        stored.responderAgentId,
914
        'accept',
915
        'policy_auto_accept',
916
      );
917
    return stored;
918
  }
919
920
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
921
    await this.ensureSchema();
922
    await this.sql`
923
      UPDATE openclasp_federated_interactions
924
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
925
      WHERE status = 'pending' AND expires_at <= NOW()
926
    `;
927
    const rows = await this.sql`
928
      SELECT payload FROM openclasp_federated_interactions
929
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
930
      ORDER BY updated_at DESC
931
    `;
932
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
933
  }
934
935
  async getFederatedInteraction(
936
    operatorId: string,
937
    interactionId: string,
938
  ): Promise<FederatedInteraction | undefined> {
939
    const values = await this.listFederatedInteractions(operatorId);
940
    return values.find((value) => value.interactionId === interactionId);
941
  }
942
943
  async respondToFederatedInteraction(
944
    operatorId: string,
945
    interactionId: string,
946
    agentId: string,
947
    decision: 'accept' | 'reject',
948
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
949
  ): Promise<FederatedInteraction> {
950
    await this.ensureSchema();
951
    const rows = await this.sql`
952
      SELECT payload, responder_operator_id, responder_agent_id
953
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
954
    `;
955
    const row = rows[0];
956
    if (!row) throw new Error('Interaction invitation not found');
957
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
958
      throw new Error('Only the invited agent may respond');
959
    const current = FederatedInteractionSchema.parse(row.payload);
960
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
961
    const now = new Date().toISOString();
962
    if (Date.parse(current.expiresAt) <= Date.now()) {
963
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
964
      await this.updateFederatedInteraction(interactionId, current.status, expired);
965
      throw new Error('Invitation has expired');
966
    }
967
    const next: FederatedInteraction =
968
      decision === 'reject'
969
        ? { ...current, status: 'rejected', updatedAt: now }
970
        : {
971
            ...current,
972
            status: 'active',
973
            updatedAt: now,
974
            acceptances: {
975
              ...current.acceptances,
976
              [agentId]: {
977
                agentId,
978
                method,
979
                termsHash: current.termsHash,
980
                acceptedAt: now,
981
              },
982
            },
983
          };
984
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
985
    if (!updated) throw new Error('Invitation was already handled');
986
    return next;
987
  }
988
989
  private async updateFederatedInteraction(
990
    interactionId: string,
991
    expectedStatus: string,
992
    value: FederatedInteraction,
993
  ): Promise<boolean> {
994
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
995
    const rows = await this.sql`
996
      UPDATE openclasp_federated_interactions
997
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
998
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
999
      RETURNING interaction_id
1000
    `;
1001
    return rows.length > 0;
1002
  }
1003
1004
  async getSettings(operatorId: string): Promise<AccountSettings> {
1005
    await this.ensureSchema();
1006
    const rows = await this.sql`
1007
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
1008
      FROM openclasp_account_settings
1009
      WHERE operator_id = ${operatorId}
1010
    `;
1011
    const row = rows[0] as
1012
      | {
1013
          display_name: string;
1014
          contribution_enabled: boolean;
1015
          retention_days: number;
1016
          evidence_sharing: AccountSettings['evidenceSharing'];
1017
        }
1018
      | undefined;
1019
    return row
1020
      ? {
1021
          displayName: row.display_name,
1022
          contributionEnabled: row.contribution_enabled,
1023
          retentionDays: row.retention_days,
1024
          evidenceSharing: row.evidence_sharing,
1025
          rawConversationsStored: true,
1026
        }
1027
      : defaults;
1028
  }
1029
1030
  async saveSettings(
1031
    operatorId: string,
1032
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
1033
  ): Promise<AccountSettings> {
1034
    await this.ensureSchema();
1035
    await this.sql`
1036
      INSERT INTO openclasp_account_settings(
1037
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
1038
      ) VALUES (
1039
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
1040
        ${settings.retentionDays}, ${settings.evidenceSharing}
1041
      )
1042
      ON CONFLICT (operator_id) DO UPDATE SET
1043
        display_name = EXCLUDED.display_name,
1044
        contribution_enabled = EXCLUDED.contribution_enabled,
1045
        retention_days = EXCLUDED.retention_days,
1046
        evidence_sharing = EXCLUDED.evidence_sharing,
1047
        updated_at = NOW()
1048
    `;
1049
    return { ...settings, rawConversationsStored: true };
1050
  }
1051
}