| 1 | import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
|
| 2 |
|
| 3 | export type HostedRecordKind =
|
| 4 | | 'agent'
|
| 5 | | 'delegation'
|
| 6 | | 'contract'
|
| 7 | | 'interaction'
|
| 8 | | 'event'
|
| 9 | | 'receipt'
|
| 10 | | 'feedback'
|
| 11 | | 'conflict'
|
| 12 | | 'profile'
|
| 13 | | 'consent'
|
| 14 | | 'revocation'
|
| 15 | | 'project'
|
| 16 | | 'agent_profile'
|
| 17 | | 'installation'
|
| 18 | | 'setup_request'
|
| 19 | | 'publication';
|
| 20 |
|
| 21 | export type PublicAgentCard = {
|
| 22 | agentId: string;
|
| 23 | name: string;
|
| 24 | framework: string;
|
| 25 | capabilities: string[];
|
| 26 | limitations: string[];
|
| 27 | assurance: 'oauth_authenticated' | 'cryptographically_verified';
|
| 28 | publishedAt: string;
|
| 29 | updatedAt: string;
|
| 30 | };
|
| 31 |
|
| 32 | export type AccountSettings = {
|
| 33 | displayName: string;
|
| 34 | contributionEnabled: boolean;
|
| 35 | retentionDays: number;
|
| 36 | evidenceSharing: 'never' | 'ask' | 'contract_only';
|
| 37 | rawConversationsStored: false;
|
| 38 | };
|
| 39 |
|
| 40 | const defaults: AccountSettings = {
|
| 41 | displayName: '',
|
| 42 | contributionEnabled: false,
|
| 43 | retentionDays: 30,
|
| 44 | evidenceSharing: 'ask',
|
| 45 | rawConversationsStored: false,
|
| 46 | };
|
| 47 |
|
| 48 | export class HostedRepository {
|
| 49 | private readonly sql: NeonQueryFunction<false, false>;
|
| 50 | private initialized?: Promise<void>;
|
| 51 |
|
| 52 | constructor(databaseUrl: string) {
|
| 53 | this.sql = neon(databaseUrl);
|
| 54 | }
|
| 55 |
|
| 56 | ensureSchema(): Promise<void> {
|
| 57 | return (this.initialized ??= (async () => {
|
| 58 | await this.sql`
|
| 59 | CREATE TABLE IF NOT EXISTS openclasp_records (
|
| 60 | operator_id TEXT NOT NULL,
|
| 61 | kind TEXT NOT NULL,
|
| 62 | record_id TEXT NOT NULL,
|
| 63 | payload JSONB NOT NULL,
|
| 64 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 65 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 66 | PRIMARY KEY (operator_id, kind, record_id)
|
| 67 | )
|
| 68 | `;
|
| 69 | await this.sql`
|
| 70 | CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
|
| 71 | ON openclasp_records(operator_id, created_at DESC)
|
| 72 | `;
|
| 73 | await this.sql`
|
| 74 | CREATE TABLE IF NOT EXISTS openclasp_account_settings (
|
| 75 | operator_id TEXT PRIMARY KEY,
|
| 76 | display_name TEXT NOT NULL DEFAULT '',
|
| 77 | contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
| 78 | retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
|
| 79 | evidence_sharing TEXT NOT NULL DEFAULT 'ask'
|
| 80 | CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
|
| 81 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 82 | )
|
| 83 | `;
|
| 84 | await this.sql`
|
| 85 | CREATE TABLE IF NOT EXISTS openclasp_public_agents (
|
| 86 | agent_id TEXT PRIMARY KEY,
|
| 87 | operator_id TEXT NOT NULL,
|
| 88 | card JSONB NOT NULL,
|
| 89 | published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 90 | updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 91 | )
|
| 92 | `;
|
| 93 | await this.sql`
|
| 94 | CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
|
| 95 | ON openclasp_public_agents(updated_at DESC)
|
| 96 | `;
|
| 97 | })());
|
| 98 | }
|
| 99 |
|
| 100 | async upsert(
|
| 101 | operatorId: string,
|
| 102 | kind: HostedRecordKind,
|
| 103 | recordId: string,
|
| 104 | payload: unknown,
|
| 105 | ): Promise<void> {
|
| 106 | await this.ensureSchema();
|
| 107 | const encoded = JSON.stringify(payload);
|
| 108 | await this.sql`
|
| 109 | INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
|
| 110 | VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
|
| 111 | ON CONFLICT (operator_id, kind, record_id)
|
| 112 | DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
|
| 113 | `;
|
| 114 | }
|
| 115 |
|
| 116 | async list(
|
| 117 | operatorId: string,
|
| 118 | ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
|
| 119 | await this.ensureSchema();
|
| 120 | const rows = await this.sql`
|
| 121 | SELECT kind, record_id, payload
|
| 122 | FROM openclasp_records
|
| 123 | WHERE operator_id = ${operatorId}
|
| 124 | ORDER BY created_at ASC
|
| 125 | `;
|
| 126 | return rows.map((row) => ({
|
| 127 | kind: row.kind as HostedRecordKind,
|
| 128 | recordId: String(row.record_id),
|
| 129 | payload: row.payload,
|
| 130 | }));
|
| 131 | }
|
| 132 |
|
| 133 | async dashboard(operatorId: string) {
|
| 134 | const rows = await this.list(operatorId);
|
| 135 | const ofKind = (kind: HostedRecordKind) =>
|
| 136 | rows.filter((row) => row.kind === kind).map((row) => row.payload);
|
| 137 | return {
|
| 138 | agents: [...ofKind('agent_profile'), ...ofKind('agent')],
|
| 139 | projects: ofKind('project'),
|
| 140 | installations: ofKind('installation'),
|
| 141 | setupRequests: ofKind('setup_request'),
|
| 142 | publications: ofKind('publication'),
|
| 143 | interactions: ofKind('interaction'),
|
| 144 | events: ofKind('event'),
|
| 145 | conflicts: ofKind('conflict'),
|
| 146 | receipts: ofKind('receipt'),
|
| 147 | profiles: ofKind('profile'),
|
| 148 | };
|
| 149 | }
|
| 150 |
|
| 151 | async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
|
| 152 | await this.ensureSchema();
|
| 153 | const encoded = JSON.stringify(card);
|
| 154 | const rows = await this.sql`
|
| 155 | INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
|
| 156 | VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
|
| 157 | ON CONFLICT (agent_id) DO UPDATE SET
|
| 158 | card = EXCLUDED.card,
|
| 159 | updated_at = NOW()
|
| 160 | WHERE openclasp_public_agents.operator_id = ${operatorId}
|
| 161 | RETURNING card
|
| 162 | `;
|
| 163 | const published = rows[0]?.card as PublicAgentCard | undefined;
|
| 164 | if (!published) throw new Error('Agent ID is already owned by another operator');
|
| 165 | return published;
|
| 166 | }
|
| 167 |
|
| 168 | async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
|
| 169 | await this.ensureSchema();
|
| 170 | const rows = await this.sql`
|
| 171 | DELETE FROM openclasp_public_agents
|
| 172 | WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
|
| 173 | RETURNING agent_id
|
| 174 | `;
|
| 175 | return rows.length > 0;
|
| 176 | }
|
| 177 |
|
| 178 | async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
|
| 179 | await this.ensureSchema();
|
| 180 | const rows = await this.sql`
|
| 181 | SELECT card FROM openclasp_public_agents WHERE agent_id = ${agentId}
|
| 182 | `;
|
| 183 | return rows[0]?.card as PublicAgentCard | undefined;
|
| 184 | }
|
| 185 |
|
| 186 | async searchPublishedAgents(input: {
|
| 187 | query?: string | undefined;
|
| 188 | capability?: string | undefined;
|
| 189 | limit?: number | undefined;
|
| 190 | }): Promise<PublicAgentCard[]> {
|
| 191 | await this.ensureSchema();
|
| 192 | const rows = await this.sql`
|
| 193 | SELECT card FROM openclasp_public_agents ORDER BY updated_at DESC LIMIT 100
|
| 194 | `;
|
| 195 | const query = input.query?.trim().toLowerCase();
|
| 196 | const capability = input.capability?.trim().toLowerCase();
|
| 197 | return rows
|
| 198 | .map((row) => row.card as PublicAgentCard)
|
| 199 | .filter(
|
| 200 | (card) =>
|
| 201 | (!query ||
|
| 202 | card.name.toLowerCase().includes(query) ||
|
| 203 | card.framework.toLowerCase().includes(query)) &&
|
| 204 | (!capability ||
|
| 205 | card.capabilities.some((value) => value.toLowerCase().includes(capability))),
|
| 206 | )
|
| 207 | .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
|
| 208 | }
|
| 209 |
|
| 210 | async getSettings(operatorId: string): Promise<AccountSettings> {
|
| 211 | await this.ensureSchema();
|
| 212 | const rows = await this.sql`
|
| 213 | SELECT display_name, contribution_enabled, retention_days, evidence_sharing
|
| 214 | FROM openclasp_account_settings
|
| 215 | WHERE operator_id = ${operatorId}
|
| 216 | `;
|
| 217 | const row = rows[0] as
|
| 218 | | {
|
| 219 | display_name: string;
|
| 220 | contribution_enabled: boolean;
|
| 221 | retention_days: number;
|
| 222 | evidence_sharing: AccountSettings['evidenceSharing'];
|
| 223 | }
|
| 224 | | undefined;
|
| 225 | return row
|
| 226 | ? {
|
| 227 | displayName: row.display_name,
|
| 228 | contributionEnabled: row.contribution_enabled,
|
| 229 | retentionDays: row.retention_days,
|
| 230 | evidenceSharing: row.evidence_sharing,
|
| 231 | rawConversationsStored: false,
|
| 232 | }
|
| 233 | : defaults;
|
| 234 | }
|
| 235 |
|
| 236 | async saveSettings(
|
| 237 | operatorId: string,
|
| 238 | settings: Omit<AccountSettings, 'rawConversationsStored'>,
|
| 239 | ): Promise<AccountSettings> {
|
| 240 | await this.ensureSchema();
|
| 241 | await this.sql`
|
| 242 | INSERT INTO openclasp_account_settings(
|
| 243 | operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
|
| 244 | ) VALUES (
|
| 245 | ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
|
| 246 | ${settings.retentionDays}, ${settings.evidenceSharing}
|
| 247 | )
|
| 248 | ON CONFLICT (operator_id) DO UPDATE SET
|
| 249 | display_name = EXCLUDED.display_name,
|
| 250 | contribution_enabled = EXCLUDED.contribution_enabled,
|
| 251 | retention_days = EXCLUDED.retention_days,
|
| 252 | evidence_sharing = EXCLUDED.evidence_sharing,
|
| 253 | updated_at = NOW()
|
| 254 | `;
|
| 255 | return { ...settings, rawConversationsStored: false };
|
| 256 | }
|
| 257 | }
|