diff --git a/lib/db/repos/agents.js b/lib/db/repos/agents.js new file mode 100644 index 0000000..28dd5c2 --- /dev/null +++ b/lib/db/repos/agents.js @@ -0,0 +1,74 @@ +import crypto from 'node:crypto'; +import bcrypt from 'bcrypt'; +import { pool } from '../pool.js'; +import { recordAudit } from './audit_stub.js'; + +const FIELDS = ['slug','name','kind','model','persona_path','capabilities','scopes']; + +export async function create(input, actor) { + const cols = [], vals = [], ph = []; + let i = 1; + for (const f of FIELDS) { + if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); ph.push(`$${i++}`); } + } + const { rows: [r] } = await pool.query( + `INSERT INTO agents(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`, + vals + ); + await recordAudit(actor, 'create', 'agent', r.id, null, r); + return r; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE id=$1`, [id]); + return r; +} + +export async function getBySlug(slug) { + const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE slug=$1`, [slug]); + return r; +} + +export async function list() { + const { rows } = await pool.query(`SELECT * FROM agents ORDER BY name`); + return rows; +} + +export async function setCapabilities(id, capabilities, scopes) { + const { rows: [r] } = await pool.query( + `UPDATE agents SET capabilities=$1, scopes=$2 WHERE id=$3 RETURNING *`, + [capabilities, scopes || {}, id] + ); + return r; +} + +export async function createToken(agent_id, label) { + const plaintext = 'vk_' + crypto.randomBytes(32).toString('base64url'); + const token_hash = await bcrypt.hash(plaintext, 12); + const { rows: [t] } = await pool.query( + `INSERT INTO agent_tokens(agent_id, label, token_hash) VALUES($1,$2,$3) RETURNING id`, + [agent_id, label || null, token_hash] + ); + return { token: plaintext, id: t.id }; +} + +export async function verifyToken(plaintext) { + if (!plaintext?.startsWith('vk_')) return null; + const { rows } = await pool.query( + `SELECT t.id, t.token_hash, t.agent_id, a.* + FROM agent_tokens t JOIN agents a ON a.id = t.agent_id + WHERE t.revoked_at IS NULL` + ); + for (const row of rows) { + if (await bcrypt.compare(plaintext, row.token_hash)) { + await pool.query(`UPDATE agent_tokens SET last_used=now() WHERE id=$1`, [row.id]); + const { token_hash, ...agent } = row; + return agent; + } + } + return null; +} + +export async function revokeToken(token_id) { + await pool.query(`UPDATE agent_tokens SET revoked_at=now() WHERE id=$1`, [token_id]); +} diff --git a/lib/db/repos/conversations.js b/lib/db/repos/conversations.js new file mode 100644 index 0000000..2ad1d39 --- /dev/null +++ b/lib/db/repos/conversations.js @@ -0,0 +1,44 @@ +import { pool } from '../pool.js'; +import { recordAudit } from './audit_stub.js'; + +export async function create({ title, agent_id, participants, metadata }, actor) { + const { rows: [r] } = await pool.query( + `INSERT INTO conversations(title, agent_id, participants, metadata) + VALUES($1,$2,$3,$4) RETURNING *`, + [title || null, agent_id || null, participants || [], metadata || {}] + ); + await recordAudit(actor, 'create', 'conversation', r.id, null, r); + return r; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM conversations WHERE id=$1`, [id]); + return r; +} + +export async function list({ limit = 50, offset = 0 } = {}) { + const { rows } = await pool.query( + `SELECT * FROM conversations ORDER BY started_at DESC LIMIT $1 OFFSET $2`, + [limit, offset] + ); + return rows; +} + +export async function setStatus(id, status, actor) { + const before = await getById(id); + const { rows: [r] } = await pool.query( + `UPDATE conversations SET status=$1, ended_at = CASE WHEN $1='archived' THEN now() ELSE ended_at END + WHERE id=$2 RETURNING *`, + [status, id] + ); + await recordAudit(actor, 'update', 'conversation', id, before, r); + return r; +} + +export async function setSummary(id, summary) { + const { rows: [r] } = await pool.query( + `UPDATE conversations SET summary=$1, status='summarized' WHERE id=$2 RETURNING *`, + [summary, id] + ); + return r; +} diff --git a/lib/db/repos/messages.js b/lib/db/repos/messages.js new file mode 100644 index 0000000..86476f0 --- /dev/null +++ b/lib/db/repos/messages.js @@ -0,0 +1,18 @@ +import { pool } from '../pool.js'; + +export async function append(conversation_id, { role, body, agent_id, metadata }) { + const { rows: [r] } = await pool.query( + `INSERT INTO messages(conversation_id, role, body, agent_id, metadata) + VALUES($1,$2,$3,$4,$5) RETURNING *`, + [conversation_id, role, body, agent_id || null, metadata || {}] + ); + return r; +} + +export async function listByConversation(conversation_id, { limit = 1000 } = {}) { + const { rows } = await pool.query( + `SELECT * FROM messages WHERE conversation_id=$1 ORDER BY created_at LIMIT $2`, + [conversation_id, limit] + ); + return rows; +} diff --git a/tests/repos/agents.test.js b/tests/repos/agents.test.js new file mode 100644 index 0000000..cb7a43e --- /dev/null +++ b/tests/repos/agents.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as agents from '../../lib/db/repos/agents.js'; + +const owner = { kind: 'user', id: null }; +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('agents repo', () => { + it('creates agent with default capabilities', async () => { + const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); + expect(a.capabilities.read).toBe(true); + expect(a.capabilities.write).toBe(false); + }); + + it('createToken returns plaintext, verifyToken finds the agent', async () => { + const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); + const { token, id } = await agents.createToken(a.id, 'default'); + expect(token).toMatch(/^vk_/); + const found = await agents.verifyToken(token); + expect(found?.id).toBe(a.id); + }); + + it('revoked token does not verify', async () => { + const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); + const { token, id } = await agents.createToken(a.id, 'default'); + await agents.revokeToken(id); + expect(await agents.verifyToken(token)).toBeNull(); + }); + + it('setCapabilities updates the jsonb', async () => { + const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); + const u = await agents.setCapabilities(a.id, { read: true, suggest: true, write: true }, { pages: true }); + expect(u.capabilities.write).toBe(true); + expect(u.scopes.pages).toBe(true); + }); +}); diff --git a/tests/repos/conversations.test.js b/tests/repos/conversations.test.js new file mode 100644 index 0000000..75168f0 --- /dev/null +++ b/tests/repos/conversations.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as agents from '../../lib/db/repos/agents.js'; +import * as conversations from '../../lib/db/repos/conversations.js'; +import * as messages from '../../lib/db/repos/messages.js'; + +const owner = { kind: 'user', id: null }; +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('conversations + messages', () => { + it('creates a conversation and appends messages', async () => { + const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); + const c = await conversations.create({ title: 'hi', agent_id: a.id }, owner); + const m1 = await messages.append(c.id, { role: 'user', body: 'hello' }); + const m2 = await messages.append(c.id, { role: 'assistant', body: 'hi', agent_id: a.id }); + const list = await messages.listByConversation(c.id); + expect(list).toHaveLength(2); + expect(list[0].id).toBe(m1.id); + }); +});