All Void 2.0 superpowers specs and implementation plans now live at
docs/superpowers/{specs,plans}/ inside the repo. Previously they were
at /project/docs/superpowers/ which was not under git.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
100 KiB
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
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
# 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
# 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
# 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
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-dbLXC on PVE host
# 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-appLXC
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
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
# 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 <target>`
3. `pct migrate 311 <target>`
4. Service maintenance on source host
5. Migrate back when done
- Step 6: Commit
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 310on PVE host)
pct enter 310
- Step 2: Install Postgres 16 + pgvector
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
# 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
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
# From void2-app
apt install -y postgresql-client
PGPASSWORD=<the-password> 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):
## 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 <db-ip> -U void -d void -c 'SELECT 1;'`
- Step 7: Commit
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
node --version
# Expect: v22.x.x — if not, install via nvm or nodesource
- Step 2: Init package.json
cd /project/src/void-v2
npm init -y
- Step 3: Edit package.json — scripts + type=module
{
"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
npm install express pg zod dotenv bcrypt pino pino-pretty
- Step 5: Install dev deps
npm install --save-dev vitest @vitest/coverage-v8
- Step 6: Write
vitest.config.js
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
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
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:
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:
import 'dotenv/config';
tests/helpers/db.js:
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
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
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
mkdir -p lib/db/migrations
- Step 6: Run tests — expect PASS
DATABASE_URL=postgres://void:PWD@192.168.1.X:5432/void npm test -- tests/db/migrate.test.js
- Step 7: Commit
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:
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
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
npm test -- tests/db/migration_001.test.js
- Step 4: Commit
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:
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
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:
// 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
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.
// 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');
});
});
// 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
// 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);
});
});
// 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
npm test -- tests/repos/
- Step 8: Commit
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
// 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
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
npm test -- tests/db/migration_002.test.js
- Step 4: Commit
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
// 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
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
// 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
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
npm test -- tests/repos/pages.test.js tests/repos/refs.test.js
- Step 6: Commit
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
// 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
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
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
// 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
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)
// 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);
});
});
// 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
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)
// 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
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
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
// 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
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
// 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
});
});
// 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;
}
// 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
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)
// 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
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
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
// 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);
});
});
// 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
// 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);
});
});
// 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
// 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');
});
});
// 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
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 realaudit.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
// 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);
}
});
});
});
-- 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
// 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
// lib/db/repos/audit_stub.js
export { recordAudit } from './audit.js';
- Step 4: Audit + Pending tests
// 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]');
});
});
// 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;
}
// 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
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
// 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
// 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
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 <OWNER_TOKEN> 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
// 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
// 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
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
npm install --save-dev supertest
- Step 2: Write test using supertest
// 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
// 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
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
.envon dev box points atvoid2-db
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
cd /project/src/void-v2
npm test
Expect ALL tests pass.
- Step 3: Run migrations against real DB
npm run migrate
Expect: log lines applying 001 → 006, exit code 0.
- Step 4: Start server
OWNER_TOKEN=$(openssl rand -base64 24) npm start &
echo $! > /tmp/void-server.pid
sleep 1
- Step 5: Curl smoke
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
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]:
### 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
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
# 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
#!/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."
chmod +x deploy/push.sh
- Step 3: Append deploy notes
## 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
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
# 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
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 (
resetDbthenmigrateUp) /healthreturnsdb_ok: trueagainst the real DB/api/spacesreturns 401 without token, 200 with token- Audit log records create + update + delete on a space (manual
curl+psqlcheck) git log --onelineshows ~20 commits, each scoped and meaningful- No
console.logs left inlib/orserver.js .envis gitignored- CHANGELOG.md has the Plan 1 entry
docs/plan-1-complete.mddescribes 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+.