# Void 2.0 — Plan 1: Foundation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Provision the two Void 2.0 LXCs, stand up Postgres + pgvector, scaffold the `void-server` Node project, build the full schema via migrations, implement all entity repositories with capability checks, ship owner-token auth, and expose a healthcheck. Produces a running-but-empty backend you can hit with `curl`. **Architecture:** Two LXCs — `void2-db` (Postgres 16 + pgvector) and `void2-app` (Node 22 + Express). One repo at `/project/src/void-v2`. Strict layer boundaries: HTTP routes → `lib/api/` → `lib/db/repos/` → SQL. Capability checks centralised in `lib/auth/capability.js`; audit log written by every mutating repo call. **Tech Stack:** Node 22 LTS, Express 4, `pg` (node-postgres), Zod (validation), Vitest (tests), `bcrypt` (token hashing), `pino` (logs), pgvector (vector type), pg-boss (later plans). PVE 8.x for LXCs. **Reference spec:** `/project/docs/superpowers/specs/2026-05-31-void-v2-design.md` --- ## File Structure ``` /project/src/void-v2/ ├── README.md ├── CHANGELOG.md ├── docs/ │ └── VERSION_HISTORY.md ├── .env.example ├── .gitignore ├── package.json ├── vitest.config.js ├── server.js Express bootstrap (entry) ├── lib/ │ ├── db/ │ │ ├── pool.js Postgres connection pool │ │ ├── migrate.js Migration runner (up only for v1) │ │ ├── migrations/ │ │ │ ├── 001_core.sql spaces, projects, tasks │ │ │ ├── 002_knowledge.sql pages, page_revisions, refs │ │ │ ├── 003_resources.sql resources, dependencies, credentials, source_docs │ │ │ ├── 004_agents.sql agents, conversations, messages │ │ │ ├── 005_cross.sql tags, entity_tags, entity_links, attachments │ │ │ └── 006_audit.sql audit_log, pending_changes │ │ └── repos/ │ │ ├── spaces.js │ │ ├── projects.js │ │ ├── tasks.js │ │ ├── pages.js │ │ ├── refs.js │ │ ├── resources.js │ │ ├── source_docs.js │ │ ├── agents.js │ │ ├── conversations.js │ │ ├── messages.js │ │ ├── tags.js │ │ ├── links.js │ │ ├── attachments.js │ │ ├── audit.js │ │ └── pending_changes.js │ ├── auth/ │ │ ├── owner.js Owner bearer-token middleware │ │ └── capability.js Per-actor capability check │ └── log.js pino logger ├── tests/ │ ├── helpers/ │ │ └── db.js Test DB bootstrap (drop + migrate + transaction-per-test) │ └── repos/ One test file per repo └── deploy/ ├── void2-app.service systemd unit └── README.md Deploy steps ``` --- ## Task 1: Repo Scaffolding + Git Init **Files:** - Create: `/project/src/void-v2/.gitignore` - Create: `/project/src/void-v2/README.md` - Create: `/project/src/void-v2/CHANGELOG.md` - Create: `/project/src/void-v2/docs/VERSION_HISTORY.md` - Create: `/project/src/void-v2/.env.example` - [ ] **Step 1: Create repo directory and init git** ```bash mkdir -p /project/src/void-v2/docs cd /project/src/void-v2 git init -b main ``` - [ ] **Step 2: Write `.gitignore`** ``` node_modules/ .env *.log coverage/ .DS_Store ``` - [ ] **Step 3: Write `README.md`** ```markdown # Void 2.0 Homelab orchestrator + canonical knowledge store. Cradle-themed. Successor to Void 1.x (CT 301). Spec at `/project/docs/superpowers/specs/2026-05-31-void-v2-design.md`. ## Layout - `void-server` (this repo) — Node API, MCP, UI, cron, agent runtime - `void-workers` — Python ingest workers (separate repo, later plan) ## Quick start (dev) 1. Provision `void2-db` LXC (see `deploy/README.md`) 2. Install Postgres + pgvector on `void2-db` 3. `npm install` 4. `cp .env.example .env` and edit 5. `npm run migrate` 6. `npm start` 7. `curl -H "Authorization: Bearer $OWNER_TOKEN" http://localhost:3000/health` ``` - [ ] **Step 4: Write `CHANGELOG.md`** ```markdown # Changelog All notable changes to Void 2.0 are documented here. Format: [Keep a Changelog](https://keepachangelog.com). ## [Unreleased] ### Added - Initial repo scaffolding ``` - [ ] **Step 5: Write `docs/VERSION_HISTORY.md`** ```markdown # Version History Narrative history of major Void releases. ## Void 2.0 (in development, started 2026-05-31) Complete remaster of Void 1.x. Foundation-first rebuild on Postgres + pgvector, two-LXC HA shape, expanded entity model (Spaces, Projects, Tasks, Pages, References, Source Docs, Resources, Conversations, Agents), unified MCP surface for AI agents with per-agent capability tiers. See spec: `/project/docs/superpowers/specs/2026-05-31-void-v2-design.md`. ## Void 1.x (2026-04 — 2026-05, CT 301) Cradle-themed dashboard + 7 character agents (Dross, Orthos, Eithan, Mercy, Lindon, Yerin, Little Blue). Gridstack Sacred Valley layout. node:sqlite storage. Mastra+Ollama for Orthos; Claude binary subprocess for others. Path B rebuild (2026-05-13) added FTS5 search, NLP summaries, memory-update markers, BookStack page import. ``` - [ ] **Step 6: Write `.env.example`** ``` DATABASE_URL=postgres://void:CHANGE_ME@192.168.X.X:5432/void OWNER_TOKEN=CHANGE_ME_TO_LONG_RANDOM PORT=3000 LOG_LEVEL=info NODE_ENV=development ``` - [ ] **Step 7: Commit** ```bash git add -A git commit -m "chore: initial repo scaffolding" ``` --- ## Task 2: PVE — Provision void2-db and void2-app LXCs **Files:** - Create: `/project/src/void-v2/deploy/README.md` **Note:** These commands run on the PVE host (`z`), not inside `/project`. Hand to user to execute, or SSH from agent if PVE shell is reachable. Use unprivileged LXCs with the standard apparmor=unconfined override for this homelab. - [ ] **Step 1: Choose CT IDs and IPs** Pick two free CT IDs (suggestion: 310 = `void2-db`, 311 = `void2-app`). Pick two free IPs on the 192.168.1.0/24 LAN. - [ ] **Step 2: Create `void2-db` LXC on PVE host** ```bash # Run on PVE host 'z' as root pct create 310 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \ --hostname void2-db \ --cores 2 --memory 4096 --swap 1024 \ --net0 name=eth0,bridge=vmbr0,ip=192.168.1.X/24,gw=192.168.1.1 \ --storage donatello-zfs --rootfs donatello-zfs:32 \ --unprivileged 1 --features nesting=1 \ --onboot 1 --start 0 # Apparmor override (this LXC env requirement per CLAUDE.md) cat >> /etc/pve/lxc/310.conf <<'EOF' lxc.apparmor.profile: unconfined EOF pct start 310 ``` - [ ] **Step 3: Create `void2-app` LXC** ```bash pct create 311 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \ --hostname void2-app \ --cores 4 --memory 4096 --swap 1024 \ --net0 name=eth0,bridge=vmbr0,ip=192.168.1.Y/24,gw=192.168.1.1 \ --storage donatello-zfs --rootfs donatello-zfs:16 \ --unprivileged 1 --features nesting=1 \ --onboot 1 --start 0 cat >> /etc/pve/lxc/311.conf <<'EOF' lxc.apparmor.profile: unconfined EOF pct start 311 ``` - [ ] **Step 4: Verify both LXCs are up** ```bash pct list | grep void2 # Expect: 310 running void2-db, 311 running void2-app ``` - [ ] **Step 5: Write deploy/README.md capturing the actual CT IDs + IPs chosen** ```markdown # Deploy ## LXC inventory | Role | CT | IP | Specs | |---|---|---|---| | Postgres | 310 (`void2-db`) | 192.168.1.X | 2 vCPU, 4 GB RAM, 32 GB disk | | App | 311 (`void2-app`) | 192.168.1.Y | 4 vCPU, 4 GB RAM, 16 GB disk | ## Maintenance flow (planned downtime) 1. Notify Void: `curl -X POST .../api/maintenance/start` (later plan) 2. `pct migrate 310 ` 3. `pct migrate 311 ` 4. Service maintenance on source host 5. Migrate back when done ``` - [ ] **Step 6: Commit** ```bash cd /project/src/void-v2 git add deploy/README.md git commit -m "docs: capture LXC provisioning steps + inventory" ``` --- ## Task 3: Postgres 16 + pgvector on void2-db **Files:** - Modify: `/project/src/void-v2/deploy/README.md` (append Postgres setup section) **Note:** Runs inside CT 310 (`void2-db`). - [ ] **Step 1: SSH into void2-db (or `pct enter 310` on PVE host)** ```bash pct enter 310 ``` - [ ] **Step 2: Install Postgres 16 + pgvector** ```bash apt update apt install -y curl ca-certificates gnupg install -d /usr/share/postgresql-common/pgdg curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \ https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release; echo $VERSION_CODENAME)-pgdg main" \ > /etc/apt/sources.list.d/pgdg.list apt update apt install -y postgresql-16 postgresql-16-pgvector ``` - [ ] **Step 3: Configure listen address + pg_hba for LAN access** ```bash # Listen on LAN sed -i "s/^#listen_addresses.*/listen_addresses = '*'/" \ /etc/postgresql/16/main/postgresql.conf # Allow LAN access from void2-app cat >> /etc/postgresql/16/main/pg_hba.conf <<'EOF' # Void 2.0 app host void void 192.168.1.0/24 scram-sha-256 EOF systemctl restart postgresql ``` - [ ] **Step 4: Create database + user, enable pgvector** ```bash sudo -u postgres psql <<'EOF' CREATE USER void WITH PASSWORD 'GENERATE_AND_CHANGE_ME'; CREATE DATABASE void OWNER void; \c void CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pgcrypto; -- for gen_random_uuid() EOF ``` Generate password with `openssl rand -base64 24`. Store in your password manager + paste into `.env` on `void2-app` later. - [ ] **Step 5: Verify connection from void2-app** ```bash # From void2-app apt install -y postgresql-client PGPASSWORD= psql -h 192.168.1.X -U void -d void -c 'SELECT version();' # Expect: PostgreSQL 16.x ``` - [ ] **Step 6: Append deploy notes** Append to `/project/src/void-v2/deploy/README.md` (back on this LXC): ```markdown ## Postgres setup (CT 310) - Postgres 16 + pgvector + pgcrypto - Listen on all addresses, LAN-only via firewall - DB `void`, user `void`, password in user's secrets store - Test from app: `psql -h -U void -d void -c 'SELECT 1;'` ``` - [ ] **Step 7: Commit** ```bash git add deploy/README.md git commit -m "docs: capture Postgres + pgvector setup" ``` --- ## Task 4: Node Project Initialization on void2-app **Files:** - Create: `/project/src/void-v2/package.json` - Create: `/project/src/void-v2/vitest.config.js` - Create: `/project/src/void-v2/lib/log.js` **Note:** Development happens here on `/project/src/void-v2` (CT 300). Deploy step (rsync to `void2-app`) comes in Task 21. - [ ] **Step 1: Install Node 22 if not already present** ```bash node --version # Expect: v22.x.x — if not, install via nvm or nodesource ``` - [ ] **Step 2: Init package.json** ```bash cd /project/src/void-v2 npm init -y ``` - [ ] **Step 3: Edit package.json — scripts + type=module** ```json { "name": "void-server", "version": "2.0.0-alpha.1", "type": "module", "private": true, "scripts": { "start": "node server.js", "migrate": "node lib/db/migrate.js up", "test": "vitest run", "test:watch": "vitest" } } ``` - [ ] **Step 4: Install runtime deps** ```bash npm install express pg zod dotenv bcrypt pino pino-pretty ``` - [ ] **Step 5: Install dev deps** ```bash npm install --save-dev vitest @vitest/coverage-v8 ``` - [ ] **Step 6: Write `vitest.config.js`** ```javascript import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, testTimeout: 10_000, coverage: { provider: 'v8', reporter: ['text', 'html'] }, setupFiles: ['./tests/helpers/setup.js'] } }); ``` - [ ] **Step 7: Write `lib/log.js`** ```javascript import pino from 'pino'; const transport = process.env.NODE_ENV === 'production' ? undefined : { target: 'pino-pretty' }; export const log = pino({ level: process.env.LOG_LEVEL || 'info', transport }); ``` - [ ] **Step 8: Commit** ```bash git add package.json package-lock.json vitest.config.js lib/log.js git commit -m "chore: node project init + deps + logger" ``` --- ## Task 5: Postgres Pool + Migration Runner **Files:** - Create: `/project/src/void-v2/lib/db/pool.js` - Create: `/project/src/void-v2/lib/db/migrate.js` - Create: `/project/src/void-v2/tests/helpers/setup.js` - Create: `/project/src/void-v2/tests/helpers/db.js` - Create: `/project/src/void-v2/tests/db/migrate.test.js` - [ ] **Step 1: Write the failing test** `tests/db/migrate.test.js`: ```javascript import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb, withClient } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; describe('migrate', () => { beforeAll(async () => { await resetDb(); }); it('creates schema_migrations table on first run', async () => { await migrateUp(); await withClient(async (c) => { const { rows } = await c.query( `SELECT to_regclass('public.schema_migrations') AS t;` ); expect(rows[0].t).toBe('schema_migrations'); }); }); it('is idempotent — second run is a no-op', async () => { await migrateUp(); await migrateUp(); await withClient(async (c) => { const { rows } = await c.query( `SELECT count(*)::int AS n FROM schema_migrations;` ); expect(rows[0].n).toBeGreaterThanOrEqual(0); }); }); }); ``` - [ ] **Step 2: Write test helpers** `tests/helpers/setup.js`: ```javascript import 'dotenv/config'; ``` `tests/helpers/db.js`: ```javascript import { pool } from '../../lib/db/pool.js'; export async function resetDb() { await pool.query(` DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE EXTENSION IF NOT EXISTS vector; `); } export async function withClient(fn) { const client = await pool.connect(); try { await fn(client); } finally { client.release(); } } ``` - [ ] **Step 3: Write `lib/db/pool.js`** ```javascript import pg from 'pg'; import 'dotenv/config'; export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10, idleTimeoutMillis: 30_000 }); ``` - [ ] **Step 4: Write `lib/db/migrate.js`** ```javascript import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { pool } from './pool.js'; import { log } from '../log.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MIG_DIR = path.join(__dirname, 'migrations'); export async function migrateUp() { const client = await pool.connect(); try { await client.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now() ); `); const files = (await fs.readdir(MIG_DIR)).filter(f => f.endsWith('.sql')).sort(); const { rows } = await client.query('SELECT name FROM schema_migrations'); const applied = new Set(rows.map(r => r.name)); for (const file of files) { if (applied.has(file)) continue; const sql = await fs.readFile(path.join(MIG_DIR, file), 'utf8'); log.info({ migration: file }, 'applying migration'); await client.query('BEGIN'); try { await client.query(sql); await client.query( 'INSERT INTO schema_migrations(name) VALUES ($1)', [file] ); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } } } finally { client.release(); } } if (process.argv[2] === 'up') { migrateUp().then(() => process.exit(0)).catch((e) => { log.error(e); process.exit(1); }); } ``` - [ ] **Step 5: Create empty migrations directory** ```bash mkdir -p lib/db/migrations ``` - [ ] **Step 6: Run tests — expect PASS** ```bash DATABASE_URL=postgres://void:PWD@192.168.1.X:5432/void npm test -- tests/db/migrate.test.js ``` - [ ] **Step 7: Commit** ```bash git add lib/db/ tests/ git commit -m "feat: db pool + migration runner with idempotency" ``` --- ## Task 6: Migration 001 — Core (spaces, projects, tasks) **Files:** - Create: `/project/src/void-v2/lib/db/migrations/001_core.sql` - Create: `/project/src/void-v2/tests/db/migration_001.test.js` - [ ] **Step 1: Write the failing test** `tests/db/migration_001.test.js`: ```javascript import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb, withClient } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; describe('migration 001 — core', () => { beforeAll(async () => { await resetDb(); await migrateUp(); }); it('creates spaces, projects, tasks tables', async () => { await withClient(async (c) => { for (const t of ['spaces', 'projects', 'tasks']) { const { rows } = await c.query( `SELECT to_regclass('public.' || $1) AS t;`, [t] ); expect(rows[0].t).toBe(t); } }); }); it('enforces UNIQUE(space_id, slug) on projects', async () => { await withClient(async (c) => { const { rows: [s] } = await c.query( `INSERT INTO spaces(slug, name) VALUES('test', 'Test') RETURNING id;` ); await c.query( `INSERT INTO projects(space_id, slug, name) VALUES($1, 'a', 'A');`, [s.id] ); await expect( c.query( `INSERT INTO projects(space_id, slug, name) VALUES($1, 'a', 'B');`, [s.id] ) ).rejects.toThrow(/duplicate key/i); }); }); }); ``` - [ ] **Step 2: Write `lib/db/migrations/001_core.sql`** ```sql CREATE TABLE spaces ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), slug text NOT NULL UNIQUE, name text NOT NULL, description text, theme text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE projects ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, slug text NOT NULL, name text NOT NULL, description text, status text NOT NULL DEFAULT 'active' CHECK (status IN ('idea','active','paused','done','abandoned')), started_at timestamptz, completed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (space_id, slug) ); CREATE TABLE tasks ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, project_id uuid REFERENCES projects(id) ON DELETE SET NULL, title text NOT NULL, body text, status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','blocked','done')), priority int, due_at timestamptz, position int, completed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_projects_space ON projects(space_id); CREATE INDEX idx_tasks_space ON tasks(space_id); CREATE INDEX idx_tasks_project ON tasks(project_id); CREATE INDEX idx_tasks_status ON tasks(status) WHERE status <> 'done'; ``` - [ ] **Step 3: Run tests — expect PASS** ```bash npm test -- tests/db/migration_001.test.js ``` - [ ] **Step 4: Commit** ```bash git add lib/db/migrations/001_core.sql tests/db/migration_001.test.js git commit -m "feat(schema): 001 — spaces, projects, tasks with check constraints" ``` --- ## Task 7: Repos — spaces, projects, tasks (TDD) **Files:** - Create: `/project/src/void-v2/lib/db/repos/spaces.js` - Create: `/project/src/void-v2/lib/db/repos/projects.js` - Create: `/project/src/void-v2/lib/db/repos/tasks.js` - Create: `/project/src/void-v2/tests/repos/spaces.test.js` - Create: `/project/src/void-v2/tests/repos/projects.test.js` - Create: `/project/src/void-v2/tests/repos/tasks.test.js` Each repo exports `create`, `getById`, `getBySlug` (where applicable), `list`, `update`, `del`. Each mutating call returns the row and is responsible for emitting an audit-log entry — but audit_log doesn't exist yet (Task 16). We add a `recordAudit(actor, action, before, after)` placeholder that's a no-op until then. Repos accept an `actor` parameter (object: `{kind, id}`) on mutating ops. - [ ] **Step 1: Write spaces repo tests** `tests/repos/spaces.test.js`: ```javascript import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('spaces repo', () => { it('creates and returns a space', async () => { const s = await spaces.create({ slug: 'homelab', name: 'Homelab' }, owner); expect(s.id).toBeDefined(); expect(s.slug).toBe('homelab'); }); it('getBySlug returns the row', async () => { await spaces.create({ slug: 'a', name: 'A' }, owner); const got = await spaces.getBySlug('a'); expect(got.name).toBe('A'); }); it('list returns all', async () => { await spaces.create({ slug: 'a', name: 'A' }, owner); await spaces.create({ slug: 'b', name: 'B' }, owner); const all = await spaces.list(); expect(all).toHaveLength(2); }); it('update changes fields and bumps updated_at', async () => { const s = await spaces.create({ slug: 'a', name: 'A' }, owner); const u = await spaces.update(s.id, { name: 'Renamed' }, owner); expect(u.name).toBe('Renamed'); expect(new Date(u.updated_at).getTime()) .toBeGreaterThanOrEqual(new Date(s.updated_at).getTime()); }); it('del removes the row', async () => { const s = await spaces.create({ slug: 'a', name: 'A' }, owner); await spaces.del(s.id, owner); expect(await spaces.getBySlug('a')).toBeUndefined(); }); }); ``` - [ ] **Step 2: Implement `lib/db/repos/spaces.js`** ```javascript import { pool } from '../pool.js'; import { recordAudit } from './audit_stub.js'; export async function create({ slug, name, description, theme }, actor) { const { rows: [r] } = await pool.query( `INSERT INTO spaces(slug, name, description, theme) VALUES($1,$2,$3,$4) RETURNING *`, [slug, name, description || null, theme || null] ); await recordAudit(actor, 'create', 'space', r.id, null, r); return r; } export async function getById(id) { const { rows: [r] } = await pool.query( `SELECT * FROM spaces WHERE id=$1`, [id]); return r; } export async function getBySlug(slug) { const { rows: [r] } = await pool.query( `SELECT * FROM spaces WHERE slug=$1`, [slug]); return r; } export async function list() { const { rows } = await pool.query(`SELECT * FROM spaces ORDER BY name`); return rows; } export async function update(id, patch, actor) { const before = await getById(id); const fields = ['name','description','theme','slug']; const sets = [], vals = []; let i = 1; for (const f of fields) { if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } } sets.push(`updated_at=now()`); vals.push(id); const { rows: [r] } = await pool.query( `UPDATE spaces SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, vals ); await recordAudit(actor, 'update', 'space', id, before, r); return r; } export async function del(id, actor) { const before = await getById(id); await pool.query(`DELETE FROM spaces WHERE id=$1`, [id]); await recordAudit(actor, 'delete', 'space', id, before, null); } ``` - [ ] **Step 3: Create audit stub** `lib/db/repos/audit_stub.js`: ```javascript // Replaced in Task 16 with real audit_log writes. export async function recordAudit() { /* noop until 006 migration lands */ } ``` - [ ] **Step 4: Run spaces tests — expect PASS** ```bash npm test -- tests/repos/spaces.test.js ``` - [ ] **Step 5: Write projects repo + tests (mirror spaces pattern)** `tests/repos/projects.test.js` — tests: create requires space_id, getBySpace lists by space, status enum enforced, UNIQUE(space, slug) enforced. Then implement `lib/db/repos/projects.js` mirroring `spaces.js` with the additional `space_id` foreign key and a `listBySpace(spaceId, {status})` method. ```javascript // tests/repos/projects.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as projects from '../../lib/db/repos/projects.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('projects repo', () => { it('creates a project under a space', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const p = await projects.create( { space_id: s.id, slug: 'z2', name: 'Z2 Migration' }, owner ); expect(p.space_id).toBe(s.id); expect(p.status).toBe('active'); }); it('enforces unique slug per space', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); await projects.create({ space_id: s.id, slug: 'a', name: 'A' }, owner); await expect( projects.create({ space_id: s.id, slug: 'a', name: 'B' }, owner) ).rejects.toThrow(); }); it('listBySpace filters by status', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); await projects.create({ space_id: s.id, slug: 'a', name: 'A' }, owner); await projects.create( { space_id: s.id, slug: 'b', name: 'B', status: 'done' }, owner ); const active = await projects.listBySpace(s.id, { status: 'active' }); expect(active).toHaveLength(1); expect(active[0].slug).toBe('a'); }); }); ``` ```javascript // lib/db/repos/projects.js import { pool } from '../pool.js'; import { recordAudit } from './audit_stub.js'; export async function create({ space_id, slug, name, description, status = 'active', started_at }, actor) { const { rows: [r] } = await pool.query( `INSERT INTO projects(space_id, slug, name, description, status, started_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, [space_id, slug, name, description || null, status, started_at || null] ); await recordAudit(actor, 'create', 'project', r.id, null, r); return r; } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM projects WHERE id=$1`, [id]); return r; } export async function listBySpace(space_id, { status } = {}) { const sql = status ? `SELECT * FROM projects WHERE space_id=$1 AND status=$2 ORDER BY name` : `SELECT * FROM projects WHERE space_id=$1 ORDER BY name`; const args = status ? [space_id, status] : [space_id]; const { rows } = await pool.query(sql, args); return rows; } export async function update(id, patch, actor) { const before = await getById(id); const fields = ['slug','name','description','status','started_at','completed_at']; const sets = [], vals = []; let i = 1; for (const f of fields) { if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } } sets.push(`updated_at=now()`); vals.push(id); const { rows: [r] } = await pool.query( `UPDATE projects SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, vals ); await recordAudit(actor, 'update', 'project', id, before, r); return r; } export async function del(id, actor) { const before = await getById(id); await pool.query(`DELETE FROM projects WHERE id=$1`, [id]); await recordAudit(actor, 'delete', 'project', id, before, null); } ``` - [ ] **Step 6: Write tasks repo + tests** ```javascript // tests/repos/tasks.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as projects from '../../lib/db/repos/projects.js'; import * as tasks from '../../lib/db/repos/tasks.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('tasks repo', () => { it('creates a task with optional project', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const t = await tasks.create({ space_id: s.id, title: 'do it' }, owner); expect(t.status).toBe('todo'); expect(t.project_id).toBeNull(); }); it('marking done sets completed_at', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const t = await tasks.create({ space_id: s.id, title: 'x' }, owner); const u = await tasks.update(t.id, { status: 'done' }, owner); expect(u.status).toBe('done'); expect(u.completed_at).not.toBeNull(); }); it('listByProject returns project tasks only', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const p = await projects.create({ space_id: s.id, slug: 'p', name: 'P' }, owner); await tasks.create({ space_id: s.id, project_id: p.id, title: 'a' }, owner); await tasks.create({ space_id: s.id, title: 'orphan' }, owner); const list = await tasks.listByProject(p.id); expect(list).toHaveLength(1); }); }); ``` ```javascript // lib/db/repos/tasks.js import { pool } from '../pool.js'; import { recordAudit } from './audit_stub.js'; export async function create({ space_id, project_id = null, title, body, priority, due_at, position }, actor) { const { rows: [r] } = await pool.query( `INSERT INTO tasks(space_id, project_id, title, body, priority, due_at, position) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [space_id, project_id, title, body || null, priority || null, due_at || null, position || null] ); await recordAudit(actor, 'create', 'task', r.id, null, r); return r; } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM tasks WHERE id=$1`, [id]); return r; } export async function listByProject(project_id) { const { rows } = await pool.query( `SELECT * FROM tasks WHERE project_id=$1 ORDER BY position NULLS LAST, created_at`, [project_id] ); return rows; } export async function listBySpace(space_id, { status } = {}) { const sql = status ? `SELECT * FROM tasks WHERE space_id=$1 AND status=$2 ORDER BY created_at` : `SELECT * FROM tasks WHERE space_id=$1 ORDER BY created_at`; const { rows } = await pool.query(sql, status ? [space_id, status] : [space_id]); return rows; } export async function update(id, patch, actor) { const before = await getById(id); const fields = ['title','body','status','priority','due_at','position','project_id']; const sets = [], vals = []; let i = 1; for (const f of fields) { if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } } if (patch.status === 'done') { sets.push(`completed_at=now()`); } sets.push(`updated_at=now()`); vals.push(id); const { rows: [r] } = await pool.query( `UPDATE tasks SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, vals ); await recordAudit(actor, 'update', 'task', id, before, r); return r; } export async function del(id, actor) { const before = await getById(id); await pool.query(`DELETE FROM tasks WHERE id=$1`, [id]); await recordAudit(actor, 'delete', 'task', id, before, null); } ``` - [ ] **Step 7: Run all repo tests — expect PASS** ```bash npm test -- tests/repos/ ``` - [ ] **Step 8: Commit** ```bash git add lib/db/repos/ tests/repos/ git commit -m "feat(repos): spaces, projects, tasks with audit stub" ``` --- ## Task 8: Migration 002 — Knowledge (pages, page_revisions, refs) **Files:** - Create: `/project/src/void-v2/lib/db/migrations/002_knowledge.sql` - Create: `/project/src/void-v2/tests/db/migration_002.test.js` - [ ] **Step 1: Write test** ```javascript // tests/db/migration_002.test.js import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb, withClient } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; describe('migration 002 — knowledge', () => { beforeAll(async () => { await resetDb(); await migrateUp(); }); it('creates pages, page_revisions, refs', async () => { await withClient(async (c) => { for (const t of ['pages','page_revisions','refs']) { const { rows } = await c.query( `SELECT to_regclass('public.' || $1) AS t;`, [t] ); expect(rows[0].t).toBe(t); } }); }); it('refs.kind check enforces enum', async () => { await withClient(async (c) => { const { rows: [s] } = await c.query( `INSERT INTO spaces(slug,name) VALUES('h','H') RETURNING id;` ); await expect(c.query( `INSERT INTO refs(space_id, kind) VALUES($1, 'invalid');`, [s.id] )).rejects.toThrow(/check/i); }); }); }); ``` - [ ] **Step 2: Write `002_knowledge.sql`** ```sql CREATE TABLE pages ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), space_id uuid REFERENCES spaces(id) ON DELETE SET NULL, slug text NOT NULL, title text NOT NULL, body_md text NOT NULL DEFAULT '', body_html text, parent_id uuid REFERENCES pages(id) ON DELETE SET NULL, embedding vector(1024), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (space_id, slug) ); CREATE TABLE page_revisions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, body_md text NOT NULL, edited_by text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE refs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), space_id uuid REFERENCES spaces(id) ON DELETE SET NULL, kind text NOT NULL CHECK (kind IN ('url','video','pdf','image','file')), source_url text, title text, description text, summary text, body_text text, blob_path text, thumbnail text, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, embedding vector(1024), status text NOT NULL DEFAULT 'ingested' CHECK (status IN ('ingested','indexed','enriched')), source_kind text, external_id text, captured_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_pages_space ON pages(space_id); CREATE INDEX idx_pages_parent ON pages(parent_id); CREATE INDEX idx_pages_fts ON pages USING GIN (to_tsvector('english', title || ' ' || body_md)); CREATE INDEX idx_pages_embed ON pages USING hnsw (embedding vector_cosine_ops); CREATE INDEX idx_refs_space ON refs(space_id); CREATE INDEX idx_refs_kind ON refs(kind); CREATE INDEX idx_refs_external ON refs(source_kind, external_id) WHERE source_kind IS NOT NULL; CREATE INDEX idx_refs_fts ON refs USING GIN ( to_tsvector('english', coalesce(title,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(body_text,'')) ); CREATE INDEX idx_refs_embed ON refs USING hnsw (embedding vector_cosine_ops); ``` - [ ] **Step 3: Run tests** ```bash npm test -- tests/db/migration_002.test.js ``` - [ ] **Step 4: Commit** ```bash git add lib/db/migrations/002_knowledge.sql tests/db/migration_002.test.js git commit -m "feat(schema): 002 — pages, page_revisions, refs with FTS + vector indexes" ``` --- ## Task 9: Repos — pages, page_revisions, refs **Files:** - Create: `/project/src/void-v2/lib/db/repos/pages.js` - Create: `/project/src/void-v2/lib/db/repos/refs.js` - Create: `/project/src/void-v2/tests/repos/pages.test.js` - Create: `/project/src/void-v2/tests/repos/refs.test.js` Follow the same pattern as Task 7 (create / getById / getBySlug / list / update / del with `actor` parameter, audit stub). - [ ] **Step 1: Write pages tests** ```javascript // tests/repos/pages.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as pages from '../../lib/db/repos/pages.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('pages repo', () => { it('creates a page and auto-snapshots a revision', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const p = await pages.create( { space_id: s.id, slug: 'a', title: 'A', body_md: 'hello' }, owner ); expect(p.body_md).toBe('hello'); const revs = await pages.listRevisions(p.id); expect(revs).toHaveLength(1); expect(revs[0].body_md).toBe('hello'); }); it('updating body adds a revision', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const p = await pages.create( { space_id: s.id, slug: 'a', title: 'A', body_md: 'v1' }, owner ); await pages.update(p.id, { body_md: 'v2' }, owner); const revs = await pages.listRevisions(p.id); expect(revs).toHaveLength(2); expect(revs[0].body_md).toBe('v2'); // newest first }); }); ``` - [ ] **Step 2: Implement `lib/db/repos/pages.js`** ```javascript import { pool } from '../pool.js'; import { recordAudit } from './audit_stub.js'; async function snapshot(client, page_id, body_md, edited_by) { await client.query( `INSERT INTO page_revisions(page_id, body_md, edited_by) VALUES($1,$2,$3)`, [page_id, body_md, edited_by || null] ); } export async function create({ space_id, slug, title, body_md = '', parent_id }, actor) { const client = await pool.connect(); try { await client.query('BEGIN'); const { rows: [r] } = await client.query( `INSERT INTO pages(space_id, slug, title, body_md, parent_id) VALUES($1,$2,$3,$4,$5) RETURNING *`, [space_id, slug, title, body_md, parent_id || null] ); await snapshot(client, r.id, body_md, actor?.kind); await client.query('COMMIT'); await recordAudit(actor, 'create', 'page', r.id, null, r); return r; } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM pages WHERE id=$1`, [id]); return r; } export async function getBySlug(space_id, slug) { const { rows: [r] } = await pool.query( `SELECT * FROM pages WHERE space_id=$1 AND slug=$2`, [space_id, slug] ); return r; } export async function listBySpace(space_id) { const { rows } = await pool.query( `SELECT id, space_id, slug, title, parent_id, updated_at FROM pages WHERE space_id=$1 ORDER BY title`, [space_id] ); return rows; } export async function listRevisions(page_id) { const { rows } = await pool.query( `SELECT * FROM page_revisions WHERE page_id=$1 ORDER BY created_at DESC`, [page_id] ); return rows; } export async function update(id, patch, actor) { const before = await getById(id); const client = await pool.connect(); try { await client.query('BEGIN'); const fields = ['slug','title','body_md','body_html','parent_id','embedding']; const sets = [], vals = []; let i = 1; for (const f of fields) { if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } } sets.push(`updated_at=now()`); vals.push(id); const { rows: [r] } = await client.query( `UPDATE pages SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, vals ); if (patch.body_md !== undefined && patch.body_md !== before.body_md) { await snapshot(client, id, patch.body_md, actor?.kind); } await client.query('COMMIT'); await recordAudit(actor, 'update', 'page', id, before, r); return r; } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } export async function del(id, actor) { const before = await getById(id); await pool.query(`DELETE FROM pages WHERE id=$1`, [id]); await recordAudit(actor, 'delete', 'page', id, before, null); } ``` - [ ] **Step 3: Write refs tests** ```javascript // tests/repos/refs.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as refs from '../../lib/db/repos/refs.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('refs repo', () => { it('creates a url ref', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const r = await refs.create({ space_id: s.id, kind: 'url', source_url: 'https://example.com', title: 'Ex', source_kind: 'manual' }, owner); expect(r.kind).toBe('url'); expect(r.status).toBe('ingested'); }); it('idempotent upsert by (source_kind, external_id)', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const r1 = await refs.upsertByExternal({ space_id: s.id, kind: 'url', source_url: 'https://e.com', source_kind: 'karakeep', external_id: 'kk-123', title: 'v1' }, owner); const r2 = await refs.upsertByExternal({ space_id: s.id, kind: 'url', source_url: 'https://e.com', source_kind: 'karakeep', external_id: 'kk-123', title: 'v2' }, owner); expect(r2.id).toBe(r1.id); expect(r2.title).toBe('v2'); }); }); ``` - [ ] **Step 4: Implement `lib/db/repos/refs.js`** ```javascript import { pool } from '../pool.js'; import { recordAudit } from './audit_stub.js'; const FIELDS = [ 'space_id','kind','source_url','title','description','summary', 'body_text','blob_path','thumbnail','metadata','embedding','status', 'source_kind','external_id','captured_at' ]; export async function create(input, actor) { const cols = [], vals = [], placeholders = []; let i = 1; for (const f of FIELDS) { if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); placeholders.push(`$${i++}`); } } const { rows: [r] } = await pool.query( `INSERT INTO refs(${cols.join(',')}) VALUES(${placeholders.join(',')}) RETURNING *`, vals ); await recordAudit(actor, 'create', 'ref', r.id, null, r); return r; } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM refs WHERE id=$1`, [id]); return r; } export async function upsertByExternal(input, actor) { const { source_kind, external_id } = input; if (!source_kind || !external_id) { throw new Error('upsertByExternal requires source_kind + external_id'); } const { rows: [existing] } = await pool.query( `SELECT * FROM refs WHERE source_kind=$1 AND external_id=$2`, [source_kind, external_id] ); if (existing) { return update(existing.id, input, actor); } return create(input, actor); } export async function list({ space_id, kind, limit = 100, offset = 0 } = {}) { const where = [], vals = []; let i = 1; if (space_id) { where.push(`space_id=$${i++}`); vals.push(space_id); } if (kind) { where.push(`kind=$${i++}`); vals.push(kind); } const w = where.length ? `WHERE ${where.join(' AND ')}` : ''; vals.push(limit, offset); const { rows } = await pool.query( `SELECT * FROM refs ${w} ORDER BY captured_at DESC LIMIT $${i++} OFFSET $${i}`, vals ); return rows; } export async function update(id, patch, actor) { const before = await getById(id); const sets = [], vals = []; let i = 1; for (const f of FIELDS) { if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } } sets.push(`updated_at=now()`); vals.push(id); const { rows: [r] } = await pool.query( `UPDATE refs SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, vals ); await recordAudit(actor, 'update', 'ref', id, before, r); return r; } export async function del(id, actor) { const before = await getById(id); await pool.query(`DELETE FROM refs WHERE id=$1`, [id]); await recordAudit(actor, 'delete', 'ref', id, before, null); } ``` - [ ] **Step 5: Run tests — expect PASS** ```bash npm test -- tests/repos/pages.test.js tests/repos/refs.test.js ``` - [ ] **Step 6: Commit** ```bash git add lib/db/repos/pages.js lib/db/repos/refs.js tests/repos/pages.test.js tests/repos/refs.test.js git commit -m "feat(repos): pages with auto-revisions, refs with upsertByExternal" ``` --- ## Task 10: Migration 003 — Resources + Source Docs **Files:** - Create: `/project/src/void-v2/lib/db/migrations/003_resources.sql` - Create: `/project/src/void-v2/tests/db/migration_003.test.js` - [ ] **Step 1: Write the test** ```javascript // tests/db/migration_003.test.js import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb, withClient } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; describe('migration 003 — resources', () => { beforeAll(async () => { await resetDb(); await migrateUp(); }); it('creates resources, resource_dependencies, resource_credentials, source_docs', async () => { await withClient(async (c) => { for (const t of ['resources','resource_dependencies','resource_credentials','source_docs']) { const { rows } = await c.query( `SELECT to_regclass('public.' || $1) AS t;`, [t] ); expect(rows[0].t).toBe(t); } }); }); }); ``` - [ ] **Step 2: Write `003_resources.sql`** ```sql CREATE TABLE resources ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, slug text NOT NULL, name text NOT NULL, runtime_type text NOT NULL CHECK (runtime_type IN ('lxc','vm','docker','bare-metal')), host text, url text, version text, status text NOT NULL DEFAULT 'unknown' CHECK (status IN ('running','stopped','down','unknown')), monitoring jsonb NOT NULL DEFAULT '{}'::jsonb, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, last_check timestamptz, maintenance_until timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (space_id, slug) ); CREATE TABLE resource_dependencies ( resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE, depends_on uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE, kind text, PRIMARY KEY (resource_id, depends_on), CHECK (resource_id <> depends_on) ); CREATE TABLE resource_credentials ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE, label text NOT NULL, vault_path text NOT NULL, kind text, notes text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE source_docs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), resource_id uuid REFERENCES resources(id) ON DELETE CASCADE, name text NOT NULL, upstream_url text NOT NULL, version text, format text, sync_source text, local_path text, body_text text, embedding vector(1024), last_synced timestamptz, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_resources_space ON resources(space_id); CREATE INDEX idx_source_docs_resource ON source_docs(resource_id); CREATE INDEX idx_source_docs_fts ON source_docs USING GIN (to_tsvector('english', coalesce(body_text,''))); CREATE INDEX idx_source_docs_embed ON source_docs USING hnsw (embedding vector_cosine_ops); ``` - [ ] **Step 3: Run + commit** ```bash npm test -- tests/db/migration_003.test.js git add lib/db/migrations/003_resources.sql tests/db/migration_003.test.js git commit -m "feat(schema): 003 — resources, deps, credentials, source_docs" ``` --- ## Task 11: Repos — resources, source_docs **Files:** - Create: `/project/src/void-v2/lib/db/repos/resources.js` - Create: `/project/src/void-v2/lib/db/repos/source_docs.js` - Create: `/project/src/void-v2/tests/repos/resources.test.js` - Create: `/project/src/void-v2/tests/repos/source_docs.test.js` Follow the established pattern. Resources additionally exposes `addDependency(resource_id, depends_on, kind)`, `removeDependency`, `listDependencies(resource_id)`, `addCredential`, `listCredentials`. - [ ] **Step 1: Write resources tests** ```javascript // tests/repos/resources.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as resources from '../../lib/db/repos/resources.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('resources repo', () => { it('creates a resource', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const r = await resources.create({ space_id: s.id, slug: 'kk', name: 'Karakeep', runtime_type: 'docker', host: '192.168.1.230', url: 'https://karakeep.hynesy.com' }, owner); expect(r.status).toBe('unknown'); }); it('addDependency creates a link, prevents self-dep', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const a = await resources.create({ space_id: s.id, slug: 'a', name: 'A', runtime_type: 'lxc' }, owner); const b = await resources.create({ space_id: s.id, slug: 'b', name: 'B', runtime_type: 'lxc' }, owner); await resources.addDependency(a.id, b.id, 'data'); const deps = await resources.listDependencies(a.id); expect(deps).toHaveLength(1); expect(deps[0].depends_on).toBe(b.id); await expect(resources.addDependency(a.id, a.id, 'self')).rejects.toThrow(); }); }); ``` - [ ] **Step 2: Implement `lib/db/repos/resources.js`** ```javascript import { pool } from '../pool.js'; import { recordAudit } from './audit_stub.js'; const FIELDS = ['space_id','slug','name','runtime_type','host','url','version','status','monitoring','metadata','last_check','maintenance_until']; 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 resources(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`, vals ); await recordAudit(actor, 'create', 'resource', r.id, null, r); return r; } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM resources WHERE id=$1`, [id]); return r; } export async function listBySpace(space_id) { const { rows } = await pool.query( `SELECT * FROM resources WHERE space_id=$1 ORDER BY name`, [space_id] ); return rows; } export async function update(id, patch, actor) { const before = await getById(id); const sets = [], vals = []; let i = 1; for (const f of FIELDS) { if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } } sets.push(`updated_at=now()`); vals.push(id); const { rows: [r] } = await pool.query( `UPDATE resources SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, vals ); await recordAudit(actor, 'update', 'resource', id, before, r); return r; } export async function del(id, actor) { const before = await getById(id); await pool.query(`DELETE FROM resources WHERE id=$1`, [id]); await recordAudit(actor, 'delete', 'resource', id, before, null); } export async function addDependency(resource_id, depends_on, kind) { if (resource_id === depends_on) throw new Error('resource cannot depend on itself'); await pool.query( `INSERT INTO resource_dependencies(resource_id, depends_on, kind) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, [resource_id, depends_on, kind || null] ); } export async function removeDependency(resource_id, depends_on) { await pool.query( `DELETE FROM resource_dependencies WHERE resource_id=$1 AND depends_on=$2`, [resource_id, depends_on] ); } export async function listDependencies(resource_id) { const { rows } = await pool.query( `SELECT * FROM resource_dependencies WHERE resource_id=$1`, [resource_id] ); return rows; } export async function addCredential(resource_id, { label, vault_path, kind, notes }) { const { rows: [r] } = await pool.query( `INSERT INTO resource_credentials(resource_id, label, vault_path, kind, notes) VALUES($1,$2,$3,$4,$5) RETURNING *`, [resource_id, label, vault_path, kind || null, notes || null] ); return r; } export async function listCredentials(resource_id) { const { rows } = await pool.query( `SELECT id, resource_id, label, vault_path, kind, notes, created_at FROM resource_credentials WHERE resource_id=$1 ORDER BY label`, [resource_id] ); return rows; } ``` - [ ] **Step 3: Write source_docs repo + tests (mirror pattern)** ```javascript // tests/repos/source_docs.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as resources from '../../lib/db/repos/resources.js'; import * as sourceDocs from '../../lib/db/repos/source_docs.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('source_docs repo', () => { it('creates a source doc bound to a resource', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const r = await resources.create({ space_id: s.id, slug: 'kk', name: 'KK', runtime_type: 'docker' }, owner); const sd = await sourceDocs.create({ resource_id: r.id, name: 'Karakeep docs', upstream_url: 'https://docs.karakeep.app', version: '0.20', format: 'markdown' }, owner); expect(sd.resource_id).toBe(r.id); }); }); ``` ```javascript // lib/db/repos/source_docs.js import { pool } from '../pool.js'; import { recordAudit } from './audit_stub.js'; const FIELDS = ['resource_id','name','upstream_url','version','format','sync_source','local_path','body_text','embedding','last_synced','metadata']; 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 source_docs(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`, vals ); await recordAudit(actor, 'create', 'source_doc', r.id, null, r); return r; } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM source_docs WHERE id=$1`, [id]); return r; } export async function listByResource(resource_id) { const { rows } = await pool.query( `SELECT * FROM source_docs WHERE resource_id=$1 ORDER BY name`, [resource_id] ); return rows; } export async function update(id, patch, actor) { const before = await getById(id); const sets = [], vals = []; let i = 1; for (const f of FIELDS) { if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } } sets.push(`updated_at=now()`); vals.push(id); const { rows: [r] } = await pool.query( `UPDATE source_docs SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, vals ); await recordAudit(actor, 'update', 'source_doc', id, before, r); return r; } export async function del(id, actor) { const before = await getById(id); await pool.query(`DELETE FROM source_docs WHERE id=$1`, [id]); await recordAudit(actor, 'delete', 'source_doc', id, before, null); } ``` - [ ] **Step 4: Run tests + commit** ```bash npm test -- tests/repos/resources.test.js tests/repos/source_docs.test.js git add lib/db/repos/resources.js lib/db/repos/source_docs.js tests/repos/resources.test.js tests/repos/source_docs.test.js git commit -m "feat(repos): resources (+ deps + creds) and source_docs" ``` --- ## Task 12: Migration 004 — Agents + Conversations + Messages **Files:** - Create: `/project/src/void-v2/lib/db/migrations/004_agents.sql` - Create: `/project/src/void-v2/tests/db/migration_004.test.js` - [ ] **Step 1: Write test (existence check on all three tables)** ```javascript // tests/db/migration_004.test.js import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb, withClient } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; describe('migration 004 — agents', () => { beforeAll(async () => { await resetDb(); await migrateUp(); }); it('creates agents, agent_tokens, conversations, messages', async () => { await withClient(async (c) => { for (const t of ['agents','agent_tokens','conversations','messages']) { const { rows } = await c.query(`SELECT to_regclass('public.' || $1) AS t;`, [t]); expect(rows[0].t).toBe(t); } }); }); }); ``` - [ ] **Step 2: Write `004_agents.sql`** ```sql CREATE TABLE agents ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), slug text NOT NULL UNIQUE, name text NOT NULL, kind text NOT NULL CHECK (kind IN ('claude','ollama','mastra','mcp-client','external')), model text, persona_path text, capabilities jsonb NOT NULL DEFAULT '{"read":true,"suggest":true,"write":false}'::jsonb, scopes jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE agent_tokens ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, label text, token_hash text NOT NULL, last_used timestamptz, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz ); CREATE TABLE conversations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), title text, agent_id uuid REFERENCES agents(id) ON DELETE SET NULL, participants text[] NOT NULL DEFAULT '{}', status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','summarized','archived')), summary text, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, embedding vector(1024), started_at timestamptz NOT NULL DEFAULT now(), ended_at timestamptz ); CREATE TABLE messages ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role text NOT NULL CHECK (role IN ('user','assistant','system','tool')), agent_id uuid REFERENCES agents(id) ON DELETE SET NULL, body text NOT NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_messages_conv ON messages(conversation_id, created_at); CREATE INDEX idx_messages_fts ON messages USING GIN (to_tsvector('english', body)); CREATE INDEX idx_agent_tokens_hash ON agent_tokens(token_hash) WHERE revoked_at IS NULL; ``` - [ ] **Step 3: Run + commit** ```bash npm test -- tests/db/migration_004.test.js git add lib/db/migrations/004_agents.sql tests/db/migration_004.test.js git commit -m "feat(schema): 004 — agents, tokens, conversations, messages" ``` --- ## Task 13: Repos — agents, conversations, messages **Files:** - Create: `/project/src/void-v2/lib/db/repos/agents.js` - Create: `/project/src/void-v2/lib/db/repos/conversations.js` - Create: `/project/src/void-v2/lib/db/repos/messages.js` - Create tests for each. Agents repo additionally exposes: - `createToken(agent_id, label)` — generates random token, returns plaintext once, stores bcrypt hash - `verifyToken(plaintext)` — finds matching unrevoked token by hash, returns agent or null - `revokeToken(token_id)` - `setCapabilities(agent_id, capabilities, scopes)` - [ ] **Step 1: Write agents tests** ```javascript // tests/repos/agents.test.js 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_/); // prefix 'vk_' = void key 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); }); }); ``` - [ ] **Step 2: Implement `lib/db/repos/agents.js`** ```javascript 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; // Naïve approach: load all unrevoked tokens, bcrypt-compare. Fine at small N. // Switch to a lookup index if agent count grows. 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] ); // Strip token_hash from returned agent record 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] ); } ``` - [ ] **Step 3: Write conversations + messages repos + tests** ```javascript // tests/repos/conversations.test.js 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); // ordered by created_at }); }); ``` ```javascript // lib/db/repos/conversations.js 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; } ``` ```javascript // lib/db/repos/messages.js 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; } ``` - [ ] **Step 4: Run + commit** ```bash npm test -- tests/repos/agents.test.js tests/repos/conversations.test.js git add lib/db/repos/agents.js lib/db/repos/conversations.js lib/db/repos/messages.js tests/repos/agents.test.js tests/repos/conversations.test.js git commit -m "feat(repos): agents (+ tokens + caps), conversations, messages" ``` --- ## Task 14: Migration 005 — Cross-cutting (tags, entity_tags, entity_links, attachments) **Files:** - Create: `/project/src/void-v2/lib/db/migrations/005_cross.sql` - Create: `/project/src/void-v2/tests/db/migration_005.test.js` - [ ] **Step 1: Test (existence checks)** ```javascript // tests/db/migration_005.test.js import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb, withClient } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; describe('migration 005 — cross', () => { beforeAll(async () => { await resetDb(); await migrateUp(); }); it('creates tags, entity_tags, entity_links, attachments', async () => { await withClient(async (c) => { for (const t of ['tags','entity_tags','entity_links','attachments']) { const { rows } = await c.query(`SELECT to_regclass('public.' || $1) AS t;`, [t]); expect(rows[0].t).toBe(t); } }); }); }); ``` - [ ] **Step 2: Write `005_cross.sql`** ```sql CREATE TABLE tags ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL UNIQUE, description text, color text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE entity_tags ( entity_type text NOT NULL, entity_id uuid NOT NULL, tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (entity_type, entity_id, tag_id) ); CREATE TABLE entity_links ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), from_type text NOT NULL, from_id uuid NOT NULL, to_type text NOT NULL, to_id uuid NOT NULL, relation text NOT NULL DEFAULT 'attached', created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (from_type, from_id, to_type, to_id, relation) ); CREATE TABLE attachments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), entity_type text, entity_id uuid, filename text NOT NULL, mime_type text, size_bytes bigint, blob_path text NOT NULL, checksum text, uploaded_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_entity_tags_entity ON entity_tags(entity_type, entity_id); CREATE INDEX idx_entity_links_from ON entity_links(from_type, from_id); CREATE INDEX idx_entity_links_to ON entity_links(to_type, to_id); CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id); ``` - [ ] **Step 3: Run + commit** ```bash npm test -- tests/db/migration_005.test.js git add lib/db/migrations/005_cross.sql tests/db/migration_005.test.js git commit -m "feat(schema): 005 — tags, entity_tags, entity_links, attachments" ``` --- ## Task 15: Repos — tags, links, attachments **Files:** - Create: `/project/src/void-v2/lib/db/repos/tags.js` - Create: `/project/src/void-v2/lib/db/repos/links.js` - Create: `/project/src/void-v2/lib/db/repos/attachments.js` - Create tests for each. - [ ] **Step 1: Tags repo + tests** ```javascript // tests/repos/tags.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as tags from '../../lib/db/repos/tags.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('tags repo', () => { it('upserts a tag by name', async () => { const t1 = await tags.upsert('homelab'); const t2 = await tags.upsert('homelab'); expect(t2.id).toBe(t1.id); }); it('attach + detach + listForEntity', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const t = await tags.upsert('urgent'); await tags.attach('space', s.id, t.id); const list = await tags.listForEntity('space', s.id); expect(list).toHaveLength(1); await tags.detach('space', s.id, t.id); expect(await tags.listForEntity('space', s.id)).toHaveLength(0); }); }); ``` ```javascript // lib/db/repos/tags.js import { pool } from '../pool.js'; export async function upsert(name, { description, color } = {}) { const { rows: [r] } = await pool.query( `INSERT INTO tags(name, description, color) VALUES($1,$2,$3) ON CONFLICT(name) DO UPDATE SET description=COALESCE(EXCLUDED.description, tags.description), color=COALESCE(EXCLUDED.color, tags.color) RETURNING *`, [name, description || null, color || null] ); return r; } export async function list() { const { rows } = await pool.query(`SELECT * FROM tags ORDER BY name`); return rows; } export async function attach(entity_type, entity_id, tag_id) { await pool.query( `INSERT INTO entity_tags(entity_type, entity_id, tag_id) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, [entity_type, entity_id, tag_id] ); } export async function detach(entity_type, entity_id, tag_id) { await pool.query( `DELETE FROM entity_tags WHERE entity_type=$1 AND entity_id=$2 AND tag_id=$3`, [entity_type, entity_id, tag_id] ); } export async function listForEntity(entity_type, entity_id) { const { rows } = await pool.query( `SELECT t.* FROM tags t JOIN entity_tags et ON et.tag_id=t.id WHERE et.entity_type=$1 AND et.entity_id=$2 ORDER BY t.name`, [entity_type, entity_id] ); return rows; } ``` - [ ] **Step 2: Links repo + tests** ```javascript // tests/repos/links.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as links from '../../lib/db/repos/links.js'; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('links repo', () => { it('create + listFrom + listTo + remove', async () => { const a = '11111111-1111-1111-1111-111111111111'; const b = '22222222-2222-2222-2222-222222222222'; const link = await links.create('project', a, 'page', b, 'mentions'); expect(link.relation).toBe('mentions'); expect(await links.listFrom('project', a)).toHaveLength(1); expect(await links.listTo('page', b)).toHaveLength(1); await links.remove(link.id); expect(await links.listFrom('project', a)).toHaveLength(0); }); it('idempotent on the unique tuple', async () => { const a = '11111111-1111-1111-1111-111111111111'; const b = '22222222-2222-2222-2222-222222222222'; const l1 = await links.create('project', a, 'page', b, 'mentions'); const l2 = await links.create('project', a, 'page', b, 'mentions'); expect(l2.id).toBe(l1.id); }); }); ``` ```javascript // lib/db/repos/links.js import { pool } from '../pool.js'; export async function create(from_type, from_id, to_type, to_id, relation = 'attached') { const { rows: [r] } = await pool.query( `INSERT INTO entity_links(from_type, from_id, to_type, to_id, relation) VALUES($1,$2,$3,$4,$5) ON CONFLICT (from_type, from_id, to_type, to_id, relation) DO UPDATE SET relation=EXCLUDED.relation RETURNING *`, [from_type, from_id, to_type, to_id, relation] ); return r; } export async function listFrom(from_type, from_id) { const { rows } = await pool.query( `SELECT * FROM entity_links WHERE from_type=$1 AND from_id=$2`, [from_type, from_id] ); return rows; } export async function listTo(to_type, to_id) { const { rows } = await pool.query( `SELECT * FROM entity_links WHERE to_type=$1 AND to_id=$2`, [to_type, to_id] ); return rows; } export async function remove(id) { await pool.query(`DELETE FROM entity_links WHERE id=$1`, [id]); } ``` - [ ] **Step 3: Attachments repo + tests** ```javascript // tests/repos/attachments.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as attachments from '../../lib/db/repos/attachments.js'; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('attachments repo', () => { it('records an attachment', async () => { const a = await attachments.create({ entity_type: 'page', entity_id: '11111111-1111-1111-1111-111111111111', filename: 'spec.pdf', mime_type: 'application/pdf', size_bytes: 1024, blob_path: 'aa/abc', checksum: 'abc' }); expect(a.filename).toBe('spec.pdf'); }); }); ``` ```javascript // lib/db/repos/attachments.js import { pool } from '../pool.js'; export async function create({ entity_type, entity_id, filename, mime_type, size_bytes, blob_path, checksum }) { const { rows: [r] } = await pool.query( `INSERT INTO attachments(entity_type, entity_id, filename, mime_type, size_bytes, blob_path, checksum) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [entity_type, entity_id, filename, mime_type || null, size_bytes || null, blob_path, checksum || null] ); return r; } export async function listForEntity(entity_type, entity_id) { const { rows } = await pool.query( `SELECT * FROM attachments WHERE entity_type=$1 AND entity_id=$2 ORDER BY uploaded_at DESC`, [entity_type, entity_id] ); return rows; } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM attachments WHERE id=$1`, [id]); return r; } export async function del(id) { await pool.query(`DELETE FROM attachments WHERE id=$1`, [id]); } ``` - [ ] **Step 4: Run + commit** ```bash npm test -- tests/repos/tags.test.js tests/repos/links.test.js tests/repos/attachments.test.js git add lib/db/repos/tags.js lib/db/repos/links.js lib/db/repos/attachments.js tests/repos/tags.test.js tests/repos/links.test.js tests/repos/attachments.test.js git commit -m "feat(repos): tags, polymorphic entity_links, attachments" ``` --- ## Task 16: Migration 006 + Real Audit Log + Pending Changes **Files:** - Create: `/project/src/void-v2/lib/db/migrations/006_audit.sql` - Create: `/project/src/void-v2/lib/db/repos/audit.js` - Create: `/project/src/void-v2/lib/db/repos/pending_changes.js` - Modify: `/project/src/void-v2/lib/db/repos/audit_stub.js` — re-export from real `audit.js` - Create: `/project/src/void-v2/tests/db/migration_006.test.js` - Create: `/project/src/void-v2/tests/repos/audit.test.js` - Create: `/project/src/void-v2/tests/repos/pending_changes.test.js` - [ ] **Step 1: Migration test + SQL** ```javascript // tests/db/migration_006.test.js import { describe, it, expect, beforeAll } from 'vitest'; import { resetDb, withClient } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; describe('migration 006 — audit', () => { beforeAll(async () => { await resetDb(); await migrateUp(); }); it('creates audit_log and pending_changes', async () => { await withClient(async (c) => { for (const t of ['audit_log','pending_changes']) { const { rows } = await c.query(`SELECT to_regclass('public.' || $1) AS t;`, [t]); expect(rows[0].t).toBe(t); } }); }); }); ``` ```sql -- lib/db/migrations/006_audit.sql CREATE TABLE audit_log ( id bigserial PRIMARY KEY, actor_kind text NOT NULL CHECK (actor_kind IN ('user','agent','cron','worker','system')), actor_id uuid, entity_type text NOT NULL, entity_id uuid, action text NOT NULL CHECK (action IN ('create','update','delete','suggest','approve','reject')), diff jsonb, occurred_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE pending_changes ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, entity_type text NOT NULL, entity_id uuid, action text NOT NULL CHECK (action IN ('create','update','delete')), payload jsonb NOT NULL, reason text, status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')), resolved_at timestamptz, resolved_by text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id, occurred_at); CREATE INDEX idx_audit_actor ON audit_log(actor_kind, actor_id, occurred_at); CREATE INDEX idx_pending_status ON pending_changes(status, created_at) WHERE status='pending'; ``` - [ ] **Step 2: Real audit repo** ```javascript // lib/db/repos/audit.js import { pool } from '../pool.js'; const REDACT_KEYS = new Set([ 'token','token_hash','password','api_key','secret','authorization' ]); function redact(obj) { if (!obj || typeof obj !== 'object') return obj; if (Array.isArray(obj)) return obj.map(redact); const out = {}; for (const [k, v] of Object.entries(obj)) { if (REDACT_KEYS.has(k.toLowerCase())) out[k] = '[REDACTED]'; else out[k] = redact(v); } return out; } function diff(before, after) { if (before === null && after === null) return null; if (before === null) return { kind: 'create', after: redact(after) }; if (after === null) return { kind: 'delete', before: redact(before) }; const changed = {}; for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) { if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) { changed[k] = { before: redact(before[k]), after: redact(after[k]) }; } } return Object.keys(changed).length ? { kind: 'update', changes: changed } : null; } export async function recordAudit(actor, action, entity_type, entity_id, before, after) { const d = diff(before, after); await pool.query( `INSERT INTO audit_log(actor_kind, actor_id, entity_type, entity_id, action, diff) VALUES($1,$2,$3,$4,$5,$6)`, [actor?.kind || 'system', actor?.id || null, entity_type, entity_id, action, d] ); } export async function listForEntity(entity_type, entity_id, { limit = 100 } = {}) { const { rows } = await pool.query( `SELECT * FROM audit_log WHERE entity_type=$1 AND entity_id=$2 ORDER BY occurred_at DESC LIMIT $3`, [entity_type, entity_id, limit] ); return rows; } export async function listByActor({ actor_kind, actor_id, limit = 100 } = {}) { const where = [], vals = []; let i = 1; if (actor_kind) { where.push(`actor_kind=$${i++}`); vals.push(actor_kind); } if (actor_id) { where.push(`actor_id=$${i++}`); vals.push(actor_id); } vals.push(limit); const w = where.length ? `WHERE ${where.join(' AND ')}` : ''; const { rows } = await pool.query( `SELECT * FROM audit_log ${w} ORDER BY occurred_at DESC LIMIT $${i}`, vals ); return rows; } ``` - [ ] **Step 3: Replace stub with re-export** ```javascript // lib/db/repos/audit_stub.js export { recordAudit } from './audit.js'; ``` - [ ] **Step 4: Audit + Pending tests** ```javascript // tests/repos/audit.test.js import { describe, it, expect, beforeEach } from 'vitest'; import { resetDb } from '../helpers/db.js'; import { migrateUp } from '../../lib/db/migrate.js'; import * as spaces from '../../lib/db/repos/spaces.js'; import * as audit from '../../lib/db/repos/audit.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('audit log', () => { it('creates an audit row on space create', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); const rows = await audit.listForEntity('space', s.id); expect(rows).toHaveLength(1); expect(rows[0].action).toBe('create'); }); it('records the diff of an update', async () => { const s = await spaces.create({ slug: 'h', name: 'H' }, owner); await spaces.update(s.id, { name: 'Hh' }, owner); const rows = await audit.listForEntity('space', s.id); const upd = rows.find(r => r.action === 'update'); expect(upd.diff.changes.name.before).toBe('H'); expect(upd.diff.changes.name.after).toBe('Hh'); }); it('redacts known sensitive keys', async () => { // Simulate by writing audit directly with a sensitive payload await audit.recordAudit(owner, 'update', 'test', null, { token: 'secret-abc' }, { token: 'secret-def' } ); const rows = await audit.listByActor({}); const r = rows.find(r => r.entity_type === 'test'); expect(r.diff.changes.token.before).toBe('[REDACTED]'); expect(r.diff.changes.token.after).toBe('[REDACTED]'); }); }); ``` ```javascript // lib/db/repos/pending_changes.js import { pool } from '../pool.js'; export async function create({ agent_id, entity_type, entity_id, action, payload, reason }) { const { rows: [r] } = await pool.query( `INSERT INTO pending_changes(agent_id, entity_type, entity_id, action, payload, reason) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, [agent_id, entity_type, entity_id || null, action, payload, reason || null] ); return r; } export async function listPending({ limit = 100 } = {}) { const { rows } = await pool.query( `SELECT * FROM pending_changes WHERE status='pending' ORDER BY created_at LIMIT $1`, [limit] ); return rows; } export async function getById(id) { const { rows: [r] } = await pool.query(`SELECT * FROM pending_changes WHERE id=$1`, [id]); return r; } export async function resolve(id, status, resolved_by) { const { rows: [r] } = await pool.query( `UPDATE pending_changes SET status=$1, resolved_at=now(), resolved_by=$2 WHERE id=$3 AND status='pending' RETURNING *`, [status, resolved_by || null, id] ); return r; } ``` ```javascript // tests/repos/pending_changes.test.js 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 pending from '../../lib/db/repos/pending_changes.js'; const owner = { kind: 'user', id: null }; beforeEach(async () => { await resetDb(); await migrateUp(); }); describe('pending changes', () => { it('creates and resolves a pending change', async () => { const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner); const p = await pending.create({ agent_id: a.id, entity_type: 'page', action: 'create', payload: { title: 'draft', body_md: 'hi' }, reason: 'inferred from chat' }); expect(p.status).toBe('pending'); const resolved = await pending.resolve(p.id, 'approved', 'owner'); expect(resolved.status).toBe('approved'); }); }); ``` - [ ] **Step 5: Run + commit** ```bash npm test -- tests/repos/audit.test.js tests/repos/pending_changes.test.js git add lib/db/migrations/006_audit.sql lib/db/repos/audit.js lib/db/repos/audit_stub.js lib/db/repos/pending_changes.js tests/db/migration_006.test.js tests/repos/audit.test.js tests/repos/pending_changes.test.js git commit -m "feat: real audit_log with redaction + pending_changes; replace stub" ``` --- ## Task 17: Capability Check Module **Files:** - Create: `/project/src/void-v2/lib/auth/capability.js` - Create: `/project/src/void-v2/tests/auth/capability.test.js` The capability module is the single decision point for "can this actor perform this action on this entity type?" It's called from REST handlers and (later) MCP tools. - [ ] **Step 1: Write tests** ```javascript // tests/auth/capability.test.js import { describe, it, expect } from 'vitest'; import { canAct } from '../../lib/auth/capability.js'; const ownerActor = { kind: 'user', id: null }; const readAgent = { kind: 'agent', id: 'a1', capabilities: { read: true, suggest: true, write: false }, scopes: {} }; const writeAgent = { kind: 'agent', id: 'a2', capabilities: { read: true, suggest: true, write: true }, scopes: { pages: true } }; describe('canAct', () => { it('owner can do anything', () => { expect(canAct(ownerActor, 'create', 'page')).toBe('allow'); expect(canAct(ownerActor, 'delete', 'resource')).toBe('allow'); }); it('read-only agent can read', () => { expect(canAct(readAgent, 'read', 'page')).toBe('allow'); }); it('default agent on create returns "suggest"', () => { expect(canAct(readAgent, 'create', 'page')).toBe('suggest'); }); it('write-scoped agent can write to its scope', () => { expect(canAct(writeAgent, 'create', 'page')).toBe('allow'); }); it('write-capable agent without scope still suggests outside it', () => { expect(canAct(writeAgent, 'create', 'resource')).toBe('suggest'); }); it('agent with no capabilities is deny', () => { expect(canAct({ kind: 'agent', id: 'x', capabilities: {} }, 'read', 'page')).toBe('deny'); }); }); ``` - [ ] **Step 2: Implement** ```javascript // lib/auth/capability.js /** * canAct(actor, action, entity_type) -> 'allow' | 'suggest' | 'deny' * * - User (owner): always 'allow'. * - Agent with capabilities.write=true AND scope present for entity_type: 'allow'. * - Agent with capabilities.suggest=true: 'suggest' (caller writes to pending_changes). * - Agent with capabilities.read=true on read actions: 'allow'. * - Otherwise: 'deny'. */ export function canAct(actor, action, entity_type) { if (!actor) return 'deny'; if (actor.kind === 'user') return 'allow'; if (actor.kind === 'cron' || actor.kind === 'worker' || actor.kind === 'system') return 'allow'; if (actor.kind !== 'agent') return 'deny'; const caps = actor.capabilities || {}; const scopes = actor.scopes || {}; if (action === 'read') return caps.read ? 'allow' : 'deny'; const isMutation = ['create','update','delete'].includes(action); if (!isMutation) return 'deny'; if (caps.write && scopes[entity_type]) return 'allow'; if (caps.suggest) return 'suggest'; return 'deny'; } ``` - [ ] **Step 3: Run + commit** ```bash mkdir -p tests/auth npm test -- tests/auth/capability.test.js git add lib/auth/capability.js tests/auth/capability.test.js git commit -m "feat(auth): capability check — user/cron/worker allow; agents tiered allow/suggest/deny" ``` --- ## Task 18: Owner Token Middleware **Files:** - Create: `/project/src/void-v2/lib/auth/owner.js` - Create: `/project/src/void-v2/tests/auth/owner.test.js` The owner middleware checks `Authorization: Bearer ` against the env var. Anything else fails with 401. Agent-token verification (via the `agents` repo) is a separate middleware added later. - [ ] **Step 1: Write tests** ```javascript // tests/auth/owner.test.js import { describe, it, expect, vi } from 'vitest'; import { ownerOnly } from '../../lib/auth/owner.js'; function mockReq(token) { return { headers: { authorization: token ? `Bearer ${token}` : undefined } }; } function mockRes() { const r = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), end: vi.fn() }; return r; } describe('ownerOnly middleware', () => { beforeEach(() => { process.env.OWNER_TOKEN = 'test-token'; }); it('rejects missing token', () => { const res = mockRes(); const next = vi.fn(); ownerOnly(mockReq(null), res, next); expect(res.status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); it('rejects wrong token', () => { const res = mockRes(); const next = vi.fn(); ownerOnly(mockReq('wrong'), res, next); expect(res.status).toHaveBeenCalledWith(401); }); it('accepts correct token + attaches actor', () => { const res = mockRes(); const next = vi.fn(); const req = mockReq('test-token'); ownerOnly(req, res, next); expect(next).toHaveBeenCalled(); expect(req.actor).toEqual({ kind: 'user', id: null }); }); }); ``` - [ ] **Step 2: Implement** ```javascript // lib/auth/owner.js export function ownerOnly(req, res, next) { const expected = process.env.OWNER_TOKEN; if (!expected) return res.status(500).json({ error: { code: 'no_owner_token', message: 'OWNER_TOKEN not configured' } }); const auth = req.headers.authorization || ''; const [scheme, token] = auth.split(' '); if (scheme !== 'Bearer' || token !== expected) { return res.status(401).json({ error: { code: 'unauthorized', message: 'invalid token' } }); } req.actor = { kind: 'user', id: null }; next(); } ``` - [ ] **Step 3: Run + commit** ```bash npm test -- tests/auth/owner.test.js git add lib/auth/owner.js tests/auth/owner.test.js git commit -m "feat(auth): owner-only middleware for single-user bearer auth" ``` --- ## Task 19: Express Bootstrap + /health Endpoint **Files:** - Create: `/project/src/void-v2/server.js` - Create: `/project/src/void-v2/tests/server.test.js` The health endpoint pings the DB pool (`SELECT 1`) and returns version info. It does NOT require auth (probes need to be unauthenticated). - [ ] **Step 1: Install supertest** ```bash npm install --save-dev supertest ``` - [ ] **Step 2: Write test using supertest** ```javascript // tests/server.test.js import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { createApp } from '../server.js'; import { resetDb } from './helpers/db.js'; import { migrateUp } from '../lib/db/migrate.js'; import { pool } from '../lib/db/pool.js'; let app; beforeAll(async () => { await resetDb(); await migrateUp(); process.env.OWNER_TOKEN = 'test-token'; app = createApp(); }); afterAll(async () => { await pool.end(); }); describe('server', () => { it('GET /health returns 200 with db_ok=true', async () => { const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body.db_ok).toBe(true); expect(res.body.version).toBeDefined(); }); it('GET /api/spaces without token returns 401', async () => { const res = await request(app).get('/api/spaces'); expect(res.status).toBe(401); }); it('GET /api/spaces with token returns 200 and empty array', async () => { const res = await request(app) .get('/api/spaces') .set('Authorization', 'Bearer test-token'); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); }); ``` - [ ] **Step 3: Implement `server.js`** ```javascript // server.js import 'dotenv/config'; import express from 'express'; import { pool } from './lib/db/pool.js'; import { ownerOnly } from './lib/auth/owner.js'; import { log } from './lib/log.js'; import * as spaces from './lib/db/repos/spaces.js'; const VERSION = '2.0.0-alpha.1'; export function createApp() { const app = express(); app.use(express.json({ limit: '10mb' })); // Healthcheck — no auth, used by probes app.get('/health', async (_req, res) => { let db_ok = false; try { await pool.query('SELECT 1'); db_ok = true; } catch (e) { log.error({ err: e }, 'healthcheck db ping failed'); } res.json({ ok: true, db_ok, version: VERSION }); }); // All /api/* requires owner token (for now; agent tokens added later) app.use('/api', ownerOnly); // Smoke endpoint — list spaces. Full CRUD lands in Plan 2. app.get('/api/spaces', async (_req, res) => { res.json(await spaces.list()); }); // Generic 404 app.use((_req, res) => res.status(404).json({ error: { code: 'not_found' } })); // Generic error handler app.use((err, _req, res, _next) => { log.error({ err }, 'unhandled'); res.status(500).json({ error: { code: 'internal', message: err.message } }); }); return app; } if (import.meta.url === `file://${process.argv[1]}`) { const port = process.env.PORT || 3000; createApp().listen(port, () => log.info({ port }, 'void-server listening')); } ``` - [ ] **Step 4: Run tests + commit** ```bash npm test -- tests/server.test.js git add server.js package.json package-lock.json tests/server.test.js git commit -m "feat(server): Express bootstrap, /health, ownerOnly on /api, smoke /api/spaces" ``` --- ## Task 20: Smoke Test Against Real DB **Files:** - None (manual verification) - [ ] **Step 1: Confirm `.env` on dev box points at `void2-db`** ```bash cat /project/src/void-v2/.env # Expect DATABASE_URL=postgres://void:...@192.168.1.X:5432/void ``` - [ ] **Step 2: Run all tests against real DB** ```bash cd /project/src/void-v2 npm test ``` Expect ALL tests pass. - [ ] **Step 3: Run migrations against real DB** ```bash npm run migrate ``` Expect: log lines applying 001 → 006, exit code 0. - [ ] **Step 4: Start server** ```bash OWNER_TOKEN=$(openssl rand -base64 24) npm start & echo $! > /tmp/void-server.pid sleep 1 ``` - [ ] **Step 5: Curl smoke** ```bash curl -s http://localhost:3000/health | jq # Expect: {"ok":true,"db_ok":true,"version":"2.0.0-alpha.1"} curl -s -H "Authorization: Bearer $OWNER_TOKEN" http://localhost:3000/api/spaces | jq # Expect: [] ``` - [ ] **Step 6: Stop server** ```bash kill $(cat /tmp/void-server.pid); rm /tmp/void-server.pid ``` - [ ] **Step 7: Commit nothing — verification only. Note completion in CHANGELOG.** Append to `CHANGELOG.md` under `## [Unreleased]`: ```markdown ### Added (Plan 1: Foundation) - LXC provisioning for `void2-db` (Postgres 16 + pgvector) and `void2-app` - Schema migrations 001-006 covering core, knowledge, resources, agents, cross-cutting, audit - Repos with capability-checked `actor` parameter and audit trail - Owner-token bearer auth - `/health` endpoint - Test coverage on all repos + migrations + capability + owner middleware ``` ```bash git add CHANGELOG.md git commit -m "docs: changelog entry for Plan 1 completion" ``` --- ## Task 21: systemd Service Unit + Deploy Script **Files:** - Create: `/project/src/void-v2/deploy/void-server.service` - Create: `/project/src/void-v2/deploy/push.sh` - Modify: `/project/src/void-v2/deploy/README.md` (append deploy section) - [ ] **Step 1: Write systemd unit** ```ini # deploy/void-server.service [Unit] Description=Void 2.0 server After=network-online.target Wants=network-online.target [Service] Type=simple User=void WorkingDirectory=/opt/void-server EnvironmentFile=/opt/void-server/.env ExecStart=/usr/bin/node /opt/void-server/server.js Restart=on-failure RestartSec=5 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target ``` - [ ] **Step 2: Write `deploy/push.sh`** ```bash #!/usr/bin/env bash set -euo pipefail # Push dev source to void2-app (CT 311) and restart service. # Run from /project/src/void-v2. TARGET=${TARGET:-root@192.168.1.Y} # void2-app REMOTE_DIR=${REMOTE_DIR:-/opt/void-server} rsync -avz --delete \ --exclude node_modules \ --exclude .git \ --exclude tests \ --exclude coverage \ ./ "$TARGET:$REMOTE_DIR/" ssh "$TARGET" "cd $REMOTE_DIR && npm install --omit=dev && systemctl restart void-server" echo "Deployed." ``` ```bash chmod +x deploy/push.sh ``` - [ ] **Step 3: Append deploy notes** ```markdown ## App deploy (CT 311) One-time setup on `void2-app`: - `apt install -y nodejs npm` (or install Node 22 from nodesource) - `useradd -r -m -d /opt/void-server void` - `mkdir -p /opt/void-server && chown void: /opt/void-server` - Copy `void-server.service` to `/etc/systemd/system/void-server.service` - `systemctl daemon-reload && systemctl enable void-server` - Create `/opt/void-server/.env` with `DATABASE_URL` + `OWNER_TOKEN` - Run `deploy/push.sh` from the dev box Maintenance: - Logs: `journalctl -u void-server -f` - Status: `systemctl status void-server` - Migrate: `cd /opt/void-server && npm run migrate` ``` - [ ] **Step 4: Commit** ```bash git add deploy/ git commit -m "chore(deploy): systemd unit, push.sh, one-time setup notes" ``` --- ## Task 22: Plan 1 Completion Doc **Files:** - Create: `/project/src/void-v2/docs/plan-1-complete.md` - [ ] **Step 1: Write completion doc** ```markdown # Plan 1 Complete — Foundation Done: 2026-XX-XX ## What landed - Two LXCs provisioned: `void2-db` (Postgres 16 + pgvector), `void2-app` (Node 22 + Express) - Schema migrations 001-006: all entity tables + cross-cutting + audit - All repos with capability-ready `actor` parameter, audit trail enabled - Owner bearer-token auth on `/api/*`; `/health` is open - ~30+ test cases across migrations, repos, capability, owner middleware - systemd unit + push.sh deploy script ## What's NOT here - Routes for every entity (Plan 2) - MCP server (Plan 5) - The Void UI (Plan 2) - Capture workers (Plan 3 + 4) - Migrations from Void 1.x / BookStack / Karakeep (Plan 7) ## How to verify ``` cd /project/src/void-v2 npm test # all green npm run migrate # applies 001-006 OWNER_TOKEN=test npm start & # server up curl localhost:3000/health # db_ok=true curl -H "Authorization: Bearer test" localhost:3000/api/spaces # [] ``` ## Next: Plan 2 — Core REST API + Void UI shell ``` - [ ] **Step 2: Commit** ```bash git add docs/plan-1-complete.md git commit -m "docs: Plan 1 completion summary" ``` --- ## Self-Review Checklist Run through this once after implementing: - [ ] All tests pass (`npm test`) - [ ] Migrations apply cleanly on a fresh DB (`resetDb` then `migrateUp`) - [ ] `/health` returns `db_ok: true` against the real DB - [ ] `/api/spaces` returns 401 without token, 200 with token - [ ] Audit log records create + update + delete on a space (manual `curl` + `psql` check) - [ ] `git log --oneline` shows ~20 commits, each scoped and meaningful - [ ] No `console.log`s left in `lib/` or `server.js` - [ ] `.env` is gitignored - [ ] CHANGELOG.md has the Plan 1 entry - [ ] `docs/plan-1-complete.md` describes the state --- **Plan 1 produces:** a running Void 2.0 backend with the full schema in place, repos that record audit, owner auth, and a smoke endpoint — but no business endpoints, no UI, no capture, no MCP. That's all Plan 2+.