10 Commits

Author SHA1 Message Date
root
95fa0c1828 chore(release): 2.2.0 — Kutt Links app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:10:50 +10:00
root
318492a078 feat(links): Links Apps view — embed + update-tracker + quick-add 2026-06-09 00:03:48 +10:00
root
cd5ca03d96 feat(links): /api/kutt proxy (version + create + recent) 2026-06-09 00:03:48 +10:00
root
c8b9dddd61 feat(links): Kutt API client + release version-compare 2026-06-09 00:03:48 +10:00
root
8f7331129f docs(kutt): implementation plan for Kutt URL shortener as a Void app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:03:48 +10:00
root
b783c031b0 docs(kutt): spec for Kutt URL shortener as a Void app
Stock Kutt (bare-metal LXC, Postgres on void-db), blackflame via custom CSS (no
fork), private-first via CF Access with a public-later toggle. Hybrid Void
integration: embedded themed Kutt + a native card (update-tracker + quick-add).
Repos: Hynes/URLShortener-void-kutt + void-v2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:03:48 +10:00
root
26463b5eb6 feat(devices): Scan Now + Manual Add (IP option, MAC colon-mask) → 2.1.4
'Scan Now' triggers POST /api/devices/scan from the band header. '+ Add by MAC'
renamed '+ Manual Add' with an optional IP field (addBody/addManual accept ip)
and a MAC input that auto-inserts colons as you type. Frontend test 4/4; DB-backed
api/repo tests written (run with the suite — skipped locally to avoid colliding
with a concurrent test run on void_test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:58:19 +10:00
root
88ef5786ee feat(devices): manually add a device by MAC (offline pre-register) → 2.1.3
'+ Add by MAC' in the band header → POST /api/devices → lan_devices.addManual
(status=known, present=false; enriched on next scan). Repo + API + frontend tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:12:47 +10:00
root
7a5fd88c07 feat(devices): edit known devices (rename/regroup/delete) → 2.1.2
Known device tiles get a ✎ edit affordance using the existing PATCH/DELETE
/api/devices/:mac endpoints. Previously devices could only be named at promote time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:02:06 +10:00
root
2284a88bd2 docs: add awesome-selfhosted as the research starting point (AGENTS.md)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:16:38 +10:00
22 changed files with 1128 additions and 12 deletions

View File

@@ -9,6 +9,10 @@
Capture **verbose-first** — we consolidate/compress later. Losing information is the only failure mode; over-documenting is fine. Do this proactively as part of finishing a task, not only when asked.
## Research convention
When researching tools/projects to recommend or self-host, **start from [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted)** (browsable at awesome-selfhosted.net) — a trusted, comprehensive, category-organised list of open-source self-hostable software. Cite it (and the relevant category) alongside other sources.
### How — wiki
Owner token: `OWNER_TOKEN` in `/opt/void-server/.env` on CT 311 (`void-app`, `192.168.1.216`). API on the LAN at `http://192.168.1.216:3000`:
- Edit a page: `PATCH /api/pages/:id` with `{ "body_md": "…", "title": "…" }`

View File

@@ -3,6 +3,21 @@
All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).
## 2.2.0 — Links: self-hosted URL shortener (Kutt) as a Void app
- **New "Links" Apps view** (`#/links`, `public/views/links.js`) — a Void-native card (Kutt **version / update tracker** + one-field **quick-add shortener**) on top of the blackflame-themed **Kutt** UI embedded via iframe (`link.hynesy.com`). Hybrid model: native convenience + the full Kutt UI in one tab.
- **`/api/kutt` proxy** (`lib/api/routes/kutt.js`, `lib/links/kutt.js`) — owner-gated server-side proxy that holds the Kutt API key (`GET /version` vs latest GitHub release, cached 6h; `POST /` create; `GET /recent`). The key never reaches the browser. *(Mounted at `/api/kutt`, not `/api/links` — the latter is the Void's existing internal cross-entity linking router.)*
- **Infra:** Kutt runs bare-metal in **LXC 113** (`192.168.1.226:3000`), sharing the **void-db** Postgres (own `kutt` DB/role), private-first behind CF Access at `link.hynesy.com`. Theme served as custom CSS; registration locked after admin creation. Env wired in void-app (`KUTT_API_URL`/`KUTT_API_KEY`/`KUTT_VERSION`).
## 2.1.4 — Devices band: Scan Now + richer Manual Add
- **"Scan Now" button** in the Network·Devices header — triggers the scheduled scan on demand (`POST /api/devices/scan`) and refreshes the band.
- **"+ Add by MAC" → "+ Manual Add"**, now with an optional **IP** field (`POST /api/devices` + `lan_devices.addManual` accept `ip`), and the **MAC field auto-inserts the colons** as you type.
## 2.1.3 — Manually add a device by MAC
- **"+ Add by MAC" in the Network·Devices band** (`POST /api/devices`, `lan_devices.addManual`, `devices_band.js`): pre-register an **offline** device by typing its MAC (+ optional name/group). Lands as `status='known'`, `present=false`; it gets enriched (IP/vendor/present) automatically the next time it's seen by the scan. Idempotent.
## 2.1.2 — Edit known network devices
- **Edit devices in the Network·Devices band** (`public/views/devices_band.js`): known tiles get a ✎ edit affordance — rename, re-group, or delete a device (PATCH/DELETE `/api/devices/:mac`, which already existed). Previously a device could only be named when first promoted from Discovered.
## 2.1.1 — OBD2 Apps rail placeholder
- **OBD2 Apps rail item** (`public/views/obd2.js`, router/app/sidebar): a placeholder launchpad under **Apps** for the parked OBD2 Telemetry project — links to the project + tasks and the research/wiki page. Swap to an `embedView` once a records UI (LubeLogger/Tracktor) is deployed.

View File

@@ -0,0 +1,587 @@
# Kutt URL Shortener as a Void App — 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:** Self-host stock Kutt (bare-metal LXC, Postgres on void-db, blackflame via custom CSS) behind `link.hynesy.com` (private via CF Access), surfaced in the Void as a Hybrid Apps item (embedded themed Kutt + a native update-tracker/quick-add card).
**Architecture:** Kutt runs unmodified in CT 113; the Void embeds its themed UI and adds a native card backed by a `/api/links` server proxy that holds the Kutt API key. Theming + deploy live in `Hynes/URLShortener-void-kutt`; the Void integration lives in `Hynes/Void-Homelab` (void-v2).
**Tech Stack:** Kutt (Node/knex/Postgres), systemd, Traefik + Cloudflare Access, void-v2 (Express + vanilla-ESM SPA, vitest/supertest/jsdom).
**Spec:** `docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md`
**Branch:** `feat/kutt-url-shortener` (spec already committed there).
**Conventions to mirror (void-v2):** embed view = `public/views/timelapse.js` + `public/views/embed.js`; route = `lib/api/routes/health.js` (`Router`, `asyncWrap`, `requireOwner`, `validate`); api mount in `lib/api/index.js`; supertest = `tests/server.test.js`; frontend test = `tests/frontend/embed.test.js`.
---
## Phase A — Kutt service (infra + theme repo)
### Task 1: Create the `kutt` database + role on void-db
**Files:** none (live DB on CT 310).
- [ ] **Step 1: Create role + database (least-priv, NOSUPERUSER)**
```bash
ssh root@192.168.1.215 "su - postgres -c \"psql -v ON_ERROR_STOP=1 <<'SQL'
CREATE ROLE kutt LOGIN PASSWORD 'CHANGE_ME_STRONG' NOSUPERUSER NOCREATEDB NOCREATEROLE;
CREATE DATABASE kutt OWNER kutt;
SQL\""
```
(Replace `CHANGE_ME_STRONG` with a generated secret; reuse it in Task 2's `.env`.)
- [ ] **Step 2: Verify**
```bash
ssh root@192.168.1.215 "su - postgres -c \"psql -tAc \\\"SELECT datname FROM pg_database WHERE datname='kutt'\\\"\""
```
Expected: prints `kutt`.
- [ ] **Step 3: Allow LAN access from CT 113** — confirm void-db's `pg_hba.conf` / `listen_addresses` already accept the LAN subnet (the `void` app on CT 311 connects, so it does). Note the host/port for the DSN: `192.168.1.215:5432`.
(No commit — record the password in the homelab secrets store, not git.)
### Task 2: Create CT 113 + install/run Kutt (bare-metal, Postgres)
**Files:** none in-repo yet (the repeatable scripts are committed in Task 9).
- [ ] **Step 1: Create the LXC on Z**
```bash
ssh root@192.168.1.124 "pct create 113 <debian-12-template> \
--hostname kutt --cores 2 --memory 2048 --rootfs localzfs:8 \
--net0 name=eth0,bridge=vmbr0,gw=192.168.1.1,ip=192.168.1.226/24,hwaddr=<gen-mac> \
--onboot 1 --features nesting=1 --unprivileged 1 && pct start 113"
```
Pick the current Debian template (`pveam available | grep debian-12`); if `.226` is taken, use the next free infra IP. Add the MAC→`.226` reservation in the router. HA-tag via `ha-manager add ct:113 --state started` (matches the other guests).
- [ ] **Step 2: Install Node 20 + clone Kutt at a pinned tag**
```bash
ssh root@192.168.1.226 "
apt-get update -qq && apt-get install -y curl git build-essential
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
useradd -r -m -d /opt/kutt -s /bin/bash kutt
sudo -u kutt git clone --depth 1 --branch v3.2.5 https://github.com/thedevs-network/kutt /opt/kutt
cd /opt/kutt && sudo -u kutt npm ci
"
```
- [ ] **Step 3: Write `/opt/kutt/.env`** (mode 600, owned by `kutt`)
```ini
PORT=3000
SITE_NAME=Hynesy Links
DEFAULT_DOMAIN=link.hynesy.com
JWT_SECRET=<generated-32+char-secret>
TRUST_PROXY=true
DB_CLIENT=pg
DB_HOST=192.168.1.215
DB_PORT=5432
DB_NAME=kutt
DB_USER=kutt
DB_PASSWORD=<the password from Task 1>
DB_SSL=false
REDIS_ENABLED=false
DISALLOW_REGISTRATION=true
DISALLOW_ANONYMOUS_LINKS=true
MAIL_ENABLED=false
ENABLE_RATE_LIMIT=true
```
- [ ] **Step 4: Migrate + create the admin (temporarily allow registration)**
```bash
ssh root@192.168.1.226 "cd /opt/kutt && sudo -u kutt env \$(grep -v '^#' .env | xargs) npm run migrate"
# Temporarily set DISALLOW_REGISTRATION=false, start, register your admin in the browser/API, then set it back to true.
```
Confirm against Kutt's README "first user / admin" flow during this step (Kutt makes the first registered user the admin). After creating the admin, set `DISALLOW_REGISTRATION=true`.
- [ ] **Step 5: systemd unit `kutt.service`**
```ini
[Unit]
Description=Kutt URL shortener
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=kutt
WorkingDirectory=/opt/kutt
EnvironmentFile=/opt/kutt/.env
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
`systemctl daemon-reload && systemctl enable --now kutt`.
- [ ] **Step 6: Generate a Kutt API key** — log into the admin account → Settings → API → generate a key. Record it for Task 8 (`KUTT_API_KEY`).
- [ ] **Step 7: Verify Kutt runs + resolves on the LAN**
```bash
curl -fsS -m6 -o /dev/null -w "kutt root: %{http_code}\n" http://192.168.1.226:3000/
# create a test link via the API and resolve it:
curl -s -X POST http://192.168.1.226:3000/api/v2/links -H "X-API-KEY: <key>" -H "Content-Type: application/json" -d '{"target":"https://example.com"}'
curl -sI http://192.168.1.226:3000/<returned-slug> | grep -iE '^HTTP|^location'
```
Expected: root `200`; the slug returns a `302` to `https://example.com`.
### Task 3: Blackflame theme (custom CSS, no fork)
**Files:** Create `theme/css/blackflame.css` in `Hynes/URLShortener-void-kutt` (committed in Task 9); deployed into `/opt/kutt/custom/css/`.
- [ ] **Step 1: Write the blackflame CSS** — override Kutt's palette/typography to the Void tokens:
```css
/* blackflame.css — Void theme for stock Kutt (drop-in; no source changes) */
:root{
--bg:#0a0a0e; --panel:#14141c; --border:#2a2a36; --text:#e8e6ed; --muted:#888094;
--accent:#ff4f2e; --accent-dim:#7a2716;
}
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;600&family=JetBrains+Mono:wght@400;500&display=swap');
body{background:var(--bg);color:var(--text);font-family:'Cormorant Garamond',Georgia,serif}
a,.link{color:var(--accent)}
button,.button,[type=submit]{background:var(--accent-dim);border:1px solid var(--accent);color:var(--text);border-radius:3px}
button:hover,.button:hover{background:var(--accent);color:var(--bg)}
input,select,textarea{background:#1c1c26;border:1px solid var(--border);color:var(--text);border-radius:3px}
h1,h2,h3{font-family:'Cinzel',serif;letter-spacing:.04em}
code,.mono{font-family:'JetBrains Mono',monospace}
/* refine specific Kutt classes during the deploy task by inspecting the rendered DOM */
```
- [ ] **Step 2: Deploy + verify it's served**
```bash
ssh root@192.168.1.226 "mkdir -p /opt/kutt/custom/css && chown -R kutt: /opt/kutt/custom"
rsync theme/css/blackflame.css root@192.168.1.226:/opt/kutt/custom/css/
ssh root@192.168.1.226 "systemctl restart kutt"
curl -fsS -m6 http://192.168.1.226:3000/ | grep -o 'custom/css/blackflame.css' | head -1
```
Expected: the page references `custom/css/blackflame.css` (Kutt auto-includes files under `custom/css/` per its README). Eyeball via webapp-testing and tighten selectors as needed.
### Task 4: Domain + CF Access (private Phase 1)
**Files:** Traefik dynamic config on mediastack (mirror existing routers); CF Access via API.
- [ ] **Step 1: Traefik router `link.hynesy.com` → CT 113:3000**
On mediastack (`192.168.1.230`), in `/docker/proxy/dynamic.yml` (mirror the `aiusage` block added earlier): add a `link` router (`Host(\`link.hynesy.com\`)`, `websecure`, `certResolver: cloudflare`) + service → `http://192.168.1.226:3000`. Back up the file first. File-provider hot-reloads.
- [ ] **Step 2: CF Access app over the whole host (private)**
Clone the `aiusage` Access app (Google IdP + email allowlist) for domain `link.hynesy.com` via the CF API (creds in the `reference_cloudflare_api` memory). This gates **everything** on `link.hynesy.com` for Phase 1.
- [ ] **Step 3: Verify**
```bash
curl -sI -m8 https://link.hynesy.com | grep -iE '^HTTP|location' # expect 302 -> cloudflareaccess (gated)
```
In a browser authed to CF Access: `https://link.hynesy.com` loads the blackflame Kutt; a created slug `https://link.hynesy.com/<slug>` 302s to target.
---
## Phase B — Void integration (void-v2, TDD)
### Task 5: Kutt API client + version-compare (pure-ish, injectable fetch)
**Files:**
- Create: `lib/links/kutt.js`
- Test: `tests/links/kutt.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/links/kutt.test.js
import { describe, it, expect } from 'vitest';
import { compareVersions, fetchLatestKuttRelease, createLink } from '../../lib/links/kutt.js';
describe('kutt helpers', () => {
it('compareVersions flags an available update (tolerates v-prefix)', () => {
expect(compareVersions('v3.2.5', 'v3.2.6')).toEqual({ running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true });
expect(compareVersions('3.2.6', 'v3.2.6')).toMatchObject({ updateAvailable: false });
});
it('fetchLatestKuttRelease returns tag + url from the GitHub API (injected fetch)', async () => {
const fakeFetch = async () => ({ ok: true, json: async () => ({ tag_name: 'v3.2.6', html_url: 'https://x/releases/v3.2.6' }) });
expect(await fetchLatestKuttRelease({ fetch: fakeFetch })).toEqual({ latest: 'v3.2.6', url: 'https://x/releases/v3.2.6' });
});
it('createLink POSTs to the Kutt API with the key and returns the short link', async () => {
let seen;
const fakeFetch = async (url, opts) => { seen = { url, opts }; return { ok: true, json: async () => ({ link: 'https://link.hynesy.com/abc', address: 'abc' }) }; };
const r = await createLink({ target: 'https://example.com' }, { base: 'http://10.0.0.1:3000', key: 'K', fetch: fakeFetch });
expect(seen.url).toBe('http://10.0.0.1:3000/api/v2/links');
expect(seen.opts.headers['X-API-KEY']).toBe('K');
expect(r.link).toBe('https://link.hynesy.com/abc');
});
});
```
- [ ] **Step 2: Run it; verify it fails**
Run: `npm test -- tests/links/kutt.test.js`
Expected: FAIL — module not found.
- [ ] **Step 3: Create `lib/links/kutt.js`**
```js
// Thin client for stock Kutt's REST API + release-version compare. fetch injected
// for tests; defaults to global fetch (Node 22). No Kutt source coupling.
const norm = v => String(v || '').replace(/^v/, '');
export function compareVersions(running, latest) {
return { running, latest, updateAvailable: norm(running) !== '' && norm(latest) !== '' && norm(running) !== norm(latest) };
}
export async function fetchLatestKuttRelease({ fetch = globalThis.fetch } = {}) {
const res = await fetch('https://api.github.com/repos/thedevs-network/kutt/releases/latest',
{ headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'void' } });
if (!res.ok) throw new Error(`github ${res.status}`);
const j = await res.json();
return { latest: j.tag_name, url: j.html_url };
}
export async function createLink(body, { base, key, fetch = globalThis.fetch }) {
const res = await fetch(`${base}/api/v2/links`, {
method: 'POST',
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}
export async function recentLinks({ base, key, fetch = globalThis.fetch, limit = 5 }) {
const res = await fetch(`${base}/api/v2/links?limit=${limit}`, { headers: { 'X-API-KEY': key } });
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}
```
- [ ] **Step 4: Run it; verify it passes**
Run: `npm test -- tests/links/kutt.test.js`
Expected: PASS (3 passed).
- [ ] **Step 5: Commit**
```bash
git add lib/links/kutt.js tests/links/kutt.test.js
git commit -m "feat(links): Kutt API client + release version-compare"
```
### Task 6: `/api/links` proxy route
**Files:**
- Create: `lib/api/routes/links.js`
- Modify: `lib/api/index.js` (import + `api.use('/links', linksRouter)`)
- Test: `tests/api/links.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/api/links.test.js
import { describe, it, expect, beforeAll, vi } from 'vitest';
import request from 'supertest';
vi.mock('../../lib/links/kutt.js', () => ({
compareVersions: (r, l) => ({ running: r, latest: l, updateAvailable: r !== l }),
fetchLatestKuttRelease: async () => ({ latest: 'v9.9.9', url: 'https://x' }),
createLink: async (b) => ({ link: 'https://link.hynesy.com/abc', address: 'abc', target: b.target }),
recentLinks: async () => ({ data: [] })
}));
let app;
const owner = r => r.set('Authorization', 'Bearer test-token');
beforeAll(async () => {
process.env.OWNER_TOKEN = 'test-token';
process.env.KUTT_API_URL = 'http://10.0.0.1:3000';
process.env.KUTT_API_KEY = 'K';
process.env.KUTT_VERSION = 'v3.2.5';
({ createApp } = await import('../../server.js'));
app = createApp();
});
let createApp;
describe('/api/links', () => {
it('GET /version returns running/latest/updateAvailable (owner)', async () => {
expect((await request(app).get('/api/links/version')).status).toBe(401);
const res = await owner(request(app).get('/api/links/version'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ running: 'v3.2.5', latest: 'v9.9.9', updateAvailable: true });
});
it('POST / creates a link via Kutt (owner)', async () => {
const res = await owner(request(app).post('/api/links')).send({ target: 'https://example.com' });
expect(res.status).toBe(201);
expect(res.body.link).toBe('https://link.hynesy.com/abc');
});
it('POST / rejects a non-URL target', async () => {
expect((await owner(request(app).post('/api/links')).send({ target: 'not a url' })).status).toBe(400);
});
});
```
- [ ] **Step 2: Run it; verify it fails**
Run: `npm test -- tests/api/links.test.js`
Expected: FAIL — route not mounted.
- [ ] **Step 3: Create `lib/api/routes/links.js`**
```js
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import { compareVersions, fetchLatestKuttRelease, createLink, recentLinks } from '../../links/kutt.js';
export const router = Router();
const cfg = () => ({ base: process.env.KUTT_API_URL, key: process.env.KUTT_API_KEY });
// GET /links/version — running (pinned env) vs latest GitHub release (cached 6h).
let cache = { at: 0, val: null };
router.get('/version', requireOwner, asyncWrap(async (_req, res) => {
const running = process.env.KUTT_VERSION || 'unknown';
if (Date.now() - cache.at > 6 * 3600e3 || !cache.val) {
try { cache = { at: Date.now(), val: await fetchLatestKuttRelease({}) }; }
catch { return res.json({ running, latest: null, updateAvailable: false, error: 'version check unavailable' }); }
}
res.json({ ...compareVersions(running, cache.val.latest), url: cache.val.url });
}));
const linkBody = z.object({
target: z.string().url(),
customurl: z.string().max(64).optional(),
description: z.string().max(200).optional()
});
// POST /links — create via Kutt (owner). Key stays server-side.
router.post('/', requireOwner, validate({ body: linkBody }), asyncWrap(async (req, res) => {
if (!process.env.KUTT_API_KEY) return res.status(502).json({ error: { code: 'kutt_unconfigured' } });
res.status(201).json(await createLink(req.body, cfg()));
}));
// GET /links/recent — last few links (owner).
router.get('/recent', requireOwner, asyncWrap(async (_req, res) => {
res.json(await recentLinks(cfg()));
}));
```
- [ ] **Step 4: Mount in `lib/api/index.js`**
Add with the other imports + mounts:
```js
import { router as linksRouter } from './routes/links.js';
```
```js
api.use('/links', linksRouter);
```
- [ ] **Step 5: Run it; verify it passes**
Run: `npm test -- tests/api/links.test.js`
Expected: PASS (3 passed).
- [ ] **Step 6: Commit**
```bash
git add lib/api/routes/links.js lib/api/index.js tests/api/links.test.js
git commit -m "feat(links): /api/links proxy (version + create + recent)"
```
### Task 7: Front-end — "Links" Apps view (embed + native card)
**Files:**
- Create: `public/views/links.js`
- Modify: `public/router.js`, `public/app.js`, `public/components/sidebar.js`, `public/style.css`
- Test: `tests/frontend/links_view.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/frontend/links_view.test.js
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { JSDOM } from 'jsdom';
vi.mock('../../public/api.js', () => ({ api: {
get: vi.fn(async (p) => p.endsWith('/version')
? { running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true, url: 'https://x' }
: { data: [] }),
post: vi.fn(async () => ({ link: 'https://link.hynesy.com/abc' }))
} }));
let render;
beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>', { url: 'http://localhost/' });
global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node;
({ render } = await import('../../public/views/links.js'));
});
afterAll(() => { delete global.window; delete global.document; delete global.Node; });
describe('links view', () => {
it('renders the update badge + quick-add + the Kutt iframe', async () => {
const main = document.getElementById('main');
await render(main);
await new Promise(r => setTimeout(r, 0));
expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://link.hynesy.com/');
expect(main.textContent).toMatch(/update available/i);
expect(main.querySelector('.lk-quickadd')).not.toBeNull();
});
});
```
- [ ] **Step 2: Run it; verify it fails**
Run: `npm test -- tests/frontend/links_view.test.js`
Expected: FAIL — module not found.
- [ ] **Step 3: Create `public/views/links.js`**
```js
// #/links — Hybrid Apps view: a Void-native card (update-tracker + quick-add) on
// top of the embedded themed Kutt UI. Reuses the .term-bar/.term-frame embed classes.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
const SRC = 'https://link.hynesy.com/';
export async function render(main) {
const badge = el('span', { class: 'lk-badge muted' }, 'checking…');
const out = el('span', { class: 'lk-out muted' }, '');
const input = el('input', { class: 'lk-url', placeholder: 'https://long-url-to-shorten…' });
const add = el('button', { class: 'primary' }, '◆ Shorten');
add.onclick = async () => {
const target = input.value.trim(); if (!target) return;
out.textContent = 'creating…';
try { const r = await api.post('/api/links', { target }); out.innerHTML = ''; out.appendChild(el('a', { href: r.link, target: '_blank', rel: 'noopener' }, r.link)); input.value = ''; }
catch { out.textContent = 'failed (is Kutt reachable / API key set?)'; }
};
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ Links'),
el('a', { class: 'ghost', style: { marginLeft: 'auto' }, href: SRC, target: '_blank', rel: 'noopener' }, '↗ Open Kutt')
),
el('div', { class: 'card lk-card' },
el('div', { class: 'lk-row' }, el('span', { class: 'muted' }, 'Kutt version'), badge),
el('div', { class: 'lk-quickadd' }, input, add),
el('div', {}, out)
),
el('iframe', { id: 'embed-frame', src: SRC, class: 'term-frame' })
);
try {
const v = await api.get('/api/links/version');
badge.classList.remove('muted');
if (v.updateAvailable) { badge.classList.add('lk-update'); badge.innerHTML = ''; badge.appendChild(el('a', { href: v.url, target: '_blank', rel: 'noopener' }, `${v.running} → ${v.latest} · update available`)); }
else badge.textContent = `${v.running} · up to date`;
} catch { badge.textContent = 'version check unavailable'; }
}
```
- [ ] **Step 4: Wire route/dispatch/sidebar**
- `public/router.js` — after the `obd2` route: `{ name: 'links', re: /^\/links$/, keys: [] },`
- `public/app.js` — in `VIEWS`, after `obd2`: `links: () => import('./views/links.js'),`
- `public/components/sidebar.js` — in the Apps section, after the OBD2 item: `navItem('Links', '/links')`
- [ ] **Step 5: Add styles in `public/style.css`** (after the `.dv-mac` rule)
```css
.lk-card { max-width: 760px; }
.lk-row { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
.lk-update a { color: var(--accent); }
.lk-quickadd { display: flex; gap: 8px; }
.lk-quickadd .lk-url { flex: 1; }
.lk-out { display: block; margin-top: 8px; font-family: var(--font-mono); font-size: 13px; }
```
- [ ] **Step 6: Run it; verify it passes**
Run: `npm test -- tests/frontend/links_view.test.js`
Expected: PASS. Then `node --check public/app.js` (clean).
- [ ] **Step 7: Commit**
```bash
git add public/views/links.js public/router.js public/app.js public/components/sidebar.js public/style.css tests/frontend/links_view.test.js
git commit -m "feat(links): Links Apps view — embed + update-tracker + quick-add"
```
### Task 8: Env wiring + version bump + deploy
**Files:** Modify `package.json`, `server.js`, `CHANGELOG.md`; void-app `.env` (live).
- [ ] **Step 1: Add Kutt env to void-app** — on CT 311, append to `/opt/void-server/.env`:
```
KUTT_API_URL=http://192.168.1.226:3000
KUTT_API_KEY=<the key from Task 2 Step 6>
KUTT_VERSION=v3.2.5
```
- [ ] **Step 2: Bump version + CHANGELOG** — `package.json` + `server.js` → `2.2.0`; prepend:
```markdown
## 2.2.0 — Links (Kutt) Apps item
- **Kutt URL shortener** folded into the Apps rail (`#/links`): embedded blackflame-themed
Kutt (link.hynesy.com) + a Void-native card with a release update-tracker and a quick-add
that proxies Kutt's REST API server-side (`/api/links`, key held in void-app env). Kutt runs
stock (CT 113, Postgres on void-db), private via CF Access.
```
- [ ] **Step 3: Full suite + deploy**
```bash
npm test
ssh root@192.168.1.124 "pct snapshot 311 pre_2_2_0 --description 'before Links/Kutt'"
./deploy/push.sh
curl -s https://void.hynesy.com/health # via LAN: http://192.168.1.216:3000/health → version 2.2.0
```
- [ ] **Step 4: Commit**
```bash
git add package.json server.js CHANGELOG.md
git commit -m "chore(release): 2.2.0 — Links (Kutt) Apps item"
```
---
## Phase C — repo + docs
### Task 9: `Hynes/URLShortener-void-kutt` repo (theme + deploy)
**Files:** new local repo `/project/src/urlshortener-void-kutt` (or your preferred path).
- [ ] **Step 1: Assemble + commit**
Create the repo with: `theme/css/blackflame.css` (Task 3), `deploy/create-ct.sh` + `deploy/bootstrap.sh` + `deploy/kutt.service` + `deploy/.env.example` (the exact steps/files from Task 2), and a `README.md` documenting the CT 113 deploy + how to update Kutt (bump tag → `git fetch && git checkout <tag> && npm ci && npm run migrate && systemctl restart kutt` → bump `KUTT_VERSION` in void-app). Then:
```bash
cd /project/src/urlshortener-void-kutt && git init -b main && git add -A
git commit -m "Kutt-on-Void: blackflame theme + bare-metal CT 113 deploy"
git remote add origin gitea@192.168.1.223:Hynes/URLShortener-void-kutt.git
git push -u origin main
```
### Task 10: Wiki page + push the void-v2 branch
- [ ] **Step 1: Create the wiki page** — `POST /api/spaces/2201a3dd-…/pages` (owner token) under **Hosts & Services** (`ab398d61-…`), slug `kutt-link-shortener-lxc-113`, title "Kutt / Link Shortener LXC (113)". Body: CT 113 as-deployed (Node, Postgres on void-db, link.hynesy.com, CF-Access-private), the blackflame-via-custom-CSS note, the Void Hybrid integration, and the update flow. Avoid contiguous `IP:port` for any non-live host.
- [ ] **Step 2: Push void-v2** — `git push origin feat/kutt-url-shortener`, then finish the branch (merge to `main`, tag `v2.2.0`) per `superpowers:finishing-a-development-branch`.
---
## Self-review notes
- **Spec coverage:** stock Kutt bare-metal LXC (T2) · Postgres on void-db (T1) · blackflame custom CSS no-fork (T3) · link.hynesy.com + CF-Access-private (T4) · `/api/links` proxy holding the key (T6) · update-tracker vs GitHub release (T5,T6,T7) · quick-add (T6,T7) · embedded themed Kutt in Apps rail (T7) · QR/geo deferred (out of scope, noted) · repos + wiki (T9,T10). All spec sections map to a task.
- **Type/name consistency:** `compareVersions/fetchLatestKuttRelease/createLink/recentLinks` defined in T5 are consumed identically in T6; `KUTT_API_URL/KUTT_API_KEY/KUTT_VERSION` env names match across T6/T8; `/api/links/version|/|recent` paths match between route (T6) and view (T7); embed uses the existing `.term-bar/.term-frame` classes.
- **Out of scope (not planned, per spec):** Phase-2 public access + per-link second domain; geo/tags upstream MRs; Redis; SMTP/registration.
- **Infra discovery flagged inline:** exact Debian template + free IP/MAC (T2), Kutt admin-creation flow (T2 S4), and CSS selector refinement (T3) are confirmed against the live system/Kutt docs during those tasks.
```

View File

@@ -0,0 +1,139 @@
# Design: Kutt URL shortener as a Void app
**Date:** 2026-06-08
**Status:** Approved (brainstorm), pending implementation plan
**Repos:** new **`Hynes/URLShortener-void-kutt`** (theme + deploy) + **`Hynes/Void-Homelab`** (void-v2 integration)
## Summary
Self-host **Kutt** (a modern Node URL shortener) **unmodified** in its own bare-metal
LXC behind `link.hynesy.com`, blackflame-themed via Kutt's **custom-CSS** support (no
fork → stays 100% upstream-clean). Surface it in the Void as a **Hybrid Apps item**:
an embedded themed Kutt UI for management, plus a small **Void-native card** for an
**update-tracker** and a **quick-add** box. Start **fully private** (CF Access over the
whole host) with a clean, no-rebuild path to **public-later** (and per-link
public/private via Kutt's multi-domain support).
## Background / constraints
- **Why Kutt** (vs Shlink): liked the UI, MIT, actively released (v3.2.5, 2026-05),
Node/Postgres bare-metal install, and **themeable via custom CSS without forking**
so we ride upstream `npm` updates with zero merge conflicts.
- **User preferences honoured:** bare-metal-in-LXC (no Docker for long-term personal
apps); blackflame styling; static IP + router MAC reservation per guest; HA-tag +
Z→Z3 replication; backup before changes; document everything to the wiki + git.
- **The redirect-vs-auth split:** a shortener's redirect endpoint normally must be
public, but the admin must be protected. We resolve this with a **CF Access toggle**
(private now) rather than baking a split in.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| App | **Kutt, stock** (pinned release) | Liked UI; themeable via CSS so no fork; upstream-clean. |
| Deploy form | **Bare-metal LXC** (Node + systemd) | No-Docker-for-keepers preference. |
| Database | **Shared `void-db`** (CT 310, PG 16) — dedicated `kutt` DB + least-priv role | One Postgres to back up / HA; no new DB service. |
| Redis | **Skipped** | Optional cache; unnecessary single-user. |
| Domain | `link.hynesy.com` → Traefik → Kutt | Clear, readable. |
| Access (now) | **Private** — CF Access over the whole host | Locked down first. |
| Access (later) | Relax CF Access on redirects; add a public 2nd domain for per-link public/private | No rebuild — policy flip + Kutt multi-domain. |
| UI | **Hybrid** — embed themed Kutt + a Void-native card | Keep the UI you liked + Void extras. |
| Feature parity w/ Shlink | Stock + QR Void-side; richer gaps via **upstream MRs** (separate effort) | Never fork Kutt. |
## Architecture
```
Browser ──(CF Access, Phase 1)── link.hynesy.com ── Traefik(mediastack) ── CT 113 kutt (Node)
│ DATABASE_URL
void-db CT 310 (PG 16, db=kutt)
Void (#/links) ── iframe ── themed Kutt UI
└── Void-native card ── /api/links proxy (void-app, holds Kutt API key) ── CT 113 Kutt REST API
└── update-tracker ── GitHub releases API + running version
```
## Components
### 1. Kutt LXC (CT 113 `kutt`)
- New unprivileged LXC on **Z**, static IP + router MAC reservation, **HA-tagged**,
replicated Z→Z3, AppArmor `unconfined` (host requirement). 2 GB / 2 cores / small disk.
- **Node 20+**; Kutt checked out at a **pinned release tag** (e.g. `v3.2.5`) under
`/opt/kutt`, user `kutt`. Install: `npm ci` → (build step if the release needs one)
`npm run migrate``npm start`, managed by **`kutt.service`** (systemd).
- Config via `/opt/kutt/.env` (mode 600): `DEFAULT_DOMAIN=link.hynesy.com`,
`DB_*`/`DATABASE_URL` → void-db, `DISABLE_REGISTRATION=true`, trust-proxy / forwarded
settings so Kutt knows its public origin, **no SMTP** (registration off, admin
pre-seeded), one admin account + an API key.
### 2. Database (shared void-db)
- On CT 310 (`192.168.1.215:5432`, PG 16): create database **`kutt`** owned by a new
**`kutt`** role (NOSUPERUSER, owns its DB + `public` schema so Kutt migrations run).
Kutt manages its own schema via `npm run migrate`. Rides void-db's existing HA +
backups; no impact on the `void` database.
### 3. Domain + CF Access (private → public)
- Traefik (mediastack `/docker/proxy/dynamic.yml`): router `link.hynesy.com` → CT 113
Kutt port. Wildcard `*.hynesy.com` DNS already targets the tunnel.
- **Phase 1 (private):** CF Access app over **all of** `link.hynesy.com` (Google IdP,
email allowlist) — admin *and* redirects private. (Embed then works via
`void.hynesy.com`, not the raw LAN IP — same CF-cookie rule as Timelapse/AI-Usage.)
- **Phase 2 (public, later, separate effort):** remove CF Access from redirects; for
per-link public/private add a public 2nd domain (e.g. `go.hynesy.com`, no CF Access)
and use Kutt's multi-domain to choose a link's domain.
### 4. Theming (blackflame, no fork)
- Kutt's **custom CSS** hook: a blackflame stylesheet (palette `--accent #ff4f2e` etc.,
Cinzel/Cormorant/JetBrains fonts, surfaces) applied to stock Kutt. Lives in
`Hynes/URLShortener-void-kutt` and is dropped into Kutt's custom/override dir at deploy. **No Kutt
source changes** → `npm`/release updates never conflict.
### 5. Void integration (the Hybrid)
- **Apps rail "Links"** (`#/links`, `public/views/links.js`): a Void header bar + a
Void-native card (below) + an `<iframe src="https://link.hynesy.com">` (themed Kutt).
Mirrors the Timelapse/AI-Usage embed views; added to the **Apps** sidebar section.
- **Void server proxy** (`lib/api/routes/links.js`, mount `/api/links`, owner-gated):
forwards to Kutt's REST API over the LAN (CT 113 IP:port) with the **Kutt API key**
held in void-app's `.env` (key never reaches the browser; LAN call works regardless
of CF Access). Endpoints needed: create link (quick-add) + recent links + version.
- **Void-native card:**
- **Update-tracker** — the proxy fetches `api.github.com/repos/thedevs-network/kutt/
releases/latest` (cached ~6 h) and the running Kutt version; the card shows the
version, an **"update available"** badge when they differ, and a changelog link.
(Bare-metal = manual updates, so this is the "what's new / time to update" signal.)
- **Quick-add** — paste a URL → `POST /api/links` → Kutt creates the short link → show
+ copy. Optional **QR** rendered Void-side (client lib) for any link.
### 6. Feature parity with Shlink
Kutt stays **stock**. Two non-forking lanes: (a) **now**, presentation-only wins like
**QR** in the Void card; (b) **later**, richer gaps (geo analytics, tags) as **merge
requests to Kutt upstream** — tracked as a *separate* project, **out of scope here**.
## Data flow
Create: Void quick-add → `/api/links` proxy (+API key) → Kutt → row in `kutt` DB → short
URL returned. Resolve: `link.hynesy.com/<slug>` → Kutt → 302 (CF-gated in Phase 1).
Update check: card → proxy → GitHub releases + running version → badge.
## Error handling
- Kutt down / proxy error → the card shows "Kutt unreachable" + the `↗ Open` fallback;
the embed shows Kutt's own error. Void itself unaffected.
- GitHub API rate-limited/unreachable → update-tracker shows "version check unavailable"
(cached last-known if any); never blocks the card.
- Missing/invalid Kutt API key → proxy returns a clear 502; quick-add disabled with a hint.
## Testing
- **vitest:** the `/api/links` proxy (mock Kutt API — create/list) and the update-tracker
comparison logic (mock GitHub `releases/latest` + running version → badge true/false);
the `links.js` view renders the card + iframe (jsdom).
- **Deploy smoke:** create a link via Kutt's API → `curl` the slug → 302 to target;
confirm `link.hynesy.com` is CF-gated (302 to cloudflareaccess) in Phase 1.
## Out of scope (separate efforts)
- Phase-2 public access + per-link public/private second domain.
- Upstream MRs for geo/tags parity.
- Redis cache; SMTP/registration; multi-user.
## Repos & docs (standing rule)
- **`Hynes/URLShortener-void-kutt`** (created, `gitea@192.168.1.223:Hynes/URLShortener-void-kutt.git`): blackflame theme CSS, the
LXC create/bootstrap + `kutt.service`, `.env.example`, deploy notes.
- **`Hynes/Void-Homelab`** (void-v2): the `links.js` view + `/api/links` proxy + Apps
rail entry + CHANGELOG.
- **Wiki:** new "Kutt / Link Shortener LXC (113)" page under Hosts & Services.

View File

@@ -34,6 +34,7 @@ import { router as littleblueRouter } from './routes/littleblue.js';
import { router as aiUsageRouter } from './routes/ai_usage.js';
import { router as infraRouter } from './routes/infra.js';
import { router as clusterRouter } from './routes/cluster.js';
import { router as kuttRouter } from './routes/kutt.js';
export function mountApi(app) {
const api = Router();
@@ -65,6 +66,7 @@ export function mountApi(app) {
api.use('/conversations/:conversation_id/messages', messagesByConvRouter);
api.use('/tags', tagsRouter);
api.use('/links', linksRouter);
api.use('/kutt', kuttRouter);
api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter);
api.use('/search', searchRouter);

View File

@@ -4,6 +4,7 @@ import { asyncWrap, errorMiddleware } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import * as devices from '../../db/repos/lan_devices.js';
import { isRandomizedMac } from '../../infra/scan.js';
import * as agents from '../../db/repos/agents.js';
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
import { accessOwnerEmail } from '../../auth/cf_access.js';
@@ -67,6 +68,20 @@ router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody
res.json(updated);
}));
const addBody = z.object({
mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i),
ip: z.string().regex(/^\d{1,3}(\.\d{1,3}){3}$/).optional(),
name: z.string().max(120).optional(),
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
vendor: z.string().max(120).optional()
});
// POST /devices — manually add a device by MAC (e.g. an offline device) (owner).
router.post('/', requireOwner, validate({ body: addBody }), asyncWrap(async (req, res) => {
const mac = req.body.mac.toLowerCase();
res.status(201).json(await devices.addManual({ ...req.body, mac, randomized: isRandomizedMac(mac) }));
}));
// DELETE /devices/:mac (owner).
router.delete('/:mac', requireOwner, validate({ params: macParam }), asyncWrap(async (req, res) => {
if (!(await devices.remove(req.params.mac.toLowerCase()))) return res.status(404).json({ error: { code: 'not_found' } });

37
lib/api/routes/kutt.js Normal file
View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import { compareVersions, fetchLatestKuttRelease, createLink, recentLinks } from '../../links/kutt.js';
export const router = Router();
const cfg = () => ({ base: process.env.KUTT_API_URL, key: process.env.KUTT_API_KEY });
// GET /kutt/version — running (pinned env) vs latest GitHub release (cached 6h).
let cache = { at: 0, val: null };
router.get('/version', requireOwner, asyncWrap(async (_req, res) => {
const running = process.env.KUTT_VERSION || 'unknown';
if (Date.now() - cache.at > 6 * 3600e3 || !cache.val) {
try { cache = { at: Date.now(), val: await fetchLatestKuttRelease({}) }; }
catch { return res.json({ running, latest: null, updateAvailable: false, error: 'version check unavailable' }); }
}
res.json({ ...compareVersions(running, cache.val.latest), url: cache.val.url });
}));
const linkBody = z.object({
target: z.string().url(),
customurl: z.string().max(64).optional(),
description: z.string().max(200).optional()
});
// POST /kutt — create via Kutt (owner). Key stays server-side.
router.post('/', requireOwner, validate({ body: linkBody }), asyncWrap(async (req, res) => {
if (!process.env.KUTT_API_KEY) return res.status(502).json({ error: { code: 'kutt_unconfigured' } });
res.status(201).json(await createLink(req.body, cfg()));
}));
// GET /kutt/recent — last few links (owner).
router.get('/recent', requireOwner, asyncWrap(async (_req, res) => {
res.json(await recentLinks(cfg()));
}));

View File

@@ -19,6 +19,22 @@ export async function get(mac) {
return r || null;
}
// Manually add a device by MAC (e.g. an offline device whose MAC you know). Lands
// as status='known', present=false. Idempotent — re-adding updates name/grp/vendor.
export async function addManual({ mac, ip = null, name = null, grp = 'Flagged', vendor = null, randomized = false }) {
const { rows: [r] } = await pool.query(
`INSERT INTO lan_devices (mac, ip, name, grp, vendor, randomized, status, present, first_seen, last_seen)
VALUES ($1,$2,$3,$4,$5,$6,'known',false,now(),now())
ON CONFLICT (mac) DO UPDATE SET
ip = COALESCE(NULLIF(EXCLUDED.ip,''), lan_devices.ip),
name = EXCLUDED.name, grp = EXCLUDED.grp,
vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor),
status = 'known'
RETURNING ${COLS}`,
[mac, ip, name, grp, vendor, !!randomized]);
return r;
}
// Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present
// WITHOUT touching owner-curated name/grp/status/flagged.
export async function upsertScan(rows) {

31
lib/links/kutt.js Normal file
View File

@@ -0,0 +1,31 @@
// Thin client for stock Kutt's REST API + release-version compare. fetch injected
// for tests; defaults to global fetch (Node 22). No Kutt source coupling.
const norm = v => String(v || '').replace(/^v/, '');
export function compareVersions(running, latest) {
return { running, latest, updateAvailable: norm(running) !== '' && norm(latest) !== '' && norm(running) !== norm(latest) };
}
export async function fetchLatestKuttRelease({ fetch = globalThis.fetch } = {}) {
const res = await fetch('https://api.github.com/repos/thedevs-network/kutt/releases/latest',
{ headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'void' } });
if (!res.ok) throw new Error(`github ${res.status}`);
const j = await res.json();
return { latest: j.tag_name, url: j.html_url };
}
export async function createLink(body, { base, key, fetch = globalThis.fetch }) {
const res = await fetch(`${base}/api/v2/links`, {
method: 'POST',
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}
export async function recentLinks({ base, key, fetch = globalThis.fetch, limit = 5 }) {
const res = await fetch(`${base}/api/v2/links?limit=${limit}`, { headers: { 'X-API-KEY': key } });
if (!res.ok) throw new Error(`kutt ${res.status}`);
return res.json();
}

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.1.1",
"version": "2.2.0",
"type": "module",
"private": true,
"scripts": {

View File

@@ -28,6 +28,7 @@ const VIEWS = {
timelapse: () => import('./views/timelapse.js'),
'ai-usage': () => import('./views/aiusage.js'),
obd2: () => import('./views/obd2.js'),
links: () => import('./views/links.js'),
settings: () => import('./views/settings.js'),
jobs: () => import('./views/jobs.js')
};

View File

@@ -132,7 +132,8 @@ export function renderSidebar(root) {
el('div', { class: 'sb-title' }, 'Apps'),
navItem('Timelapse', '/timelapse'),
navItem('AI Usage', '/ai-usage'),
navItem('OBD2', '/obd2')
navItem('OBD2', '/obd2'),
navItem('Links', '/links')
)
);

View File

@@ -29,6 +29,7 @@ const ROUTES = [
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] },
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
{ name: 'obd2', re: /^\/obd2$/, keys: [] },
{ name: 'links', re: /^\/links$/, keys: [] },
{ name: 'settings', re: /^\/settings$/, keys: [] },
{ name: 'jobs', re: /^\/jobs$/, keys: [] },
{ name: 'home', re: /^\/?$/, keys: [] }

View File

@@ -563,6 +563,23 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); }
.dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; }
.lk-card { max-width: 760px; }
.lk-row { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
.lk-update a { color: var(--accent); }
.lk-quickadd { display: flex; gap: 8px; }
.lk-quickadd .lk-url { flex: 1; }
.lk-out { display: block; margin-top: 8px; font-family: var(--font-mono); font-size: 13px; }
.dv-tile { position: relative; }
.dv-edit-btn { position: absolute; top: 5px; right: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
.dv-tile:hover .dv-edit-btn { opacity: 1; }
.dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
.dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; }
.dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; }
.dv-addtoggle { margin-left: auto; font-size: 11px; padding: 2px 8px; white-space: nowrap; }
.dv-scanbtn { font-size: 11px; padding: 2px 8px; white-space: nowrap; margin-left: 6px; }
.dv-scanbtn:disabled { opacity: .6; cursor: default; }
.dv-addform { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--accent-dim); border-radius: 6px; background: var(--accent-soft); }
.dv-addform .dv-edit-name { flex: 1 1 9rem; }
.dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; }
.dv-tile.flag { border-color: var(--bad); background: #1a1012; }
.dv-tile.flag .dv-nm { color: var(--bad); }

View File

@@ -8,12 +8,34 @@ let host;
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
function tile(d) {
return el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') },
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
el('span', { class: 'dv-ip' }, d.ip || ''),
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
el('span', { class: 'dv-vendor' },
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')));
const t = el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') });
function view() {
clear(t);
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
edit.onclick = editMode;
mount(t,
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
el('span', { class: 'dv-ip' }, d.ip || ''),
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
el('span', { class: 'dv-vendor' },
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')),
d.mac ? edit : null);
}
function editMode() {
clear(t);
const nameI = el('input', { class: 'dv-edit-name', value: d.name || '' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
grpS.value = d.grp || 'Flagged';
const save = el('button', { class: 'dv-add' }, 'Save');
save.onclick = async () => { await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value }); load(); };
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
const cancel = el('button', { class: 'ghost' }, 'Cancel');
cancel.onclick = view;
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, save, del, cancel);
}
view();
return t;
}
function discoveredRow(d, onDone) {
@@ -33,6 +55,30 @@ function discoveredRow(d, onDone) {
nameI, grpS, add, ignore);
}
// Manual add form — for offline devices (MAC required; IP optional). The MAC
// field auto-inserts the colons as you type.
function manualAddForm() {
const macI = el('input', { class: 'dv-edit-name', placeholder: 'aa:bb:cc:dd:ee:ff' });
macI.oninput = () => {
const v = macI.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 12).toLowerCase();
macI.value = v.match(/.{1,2}/g)?.join(':') ?? v;
};
const ipI = el('input', { class: 'dv-edit-name', placeholder: 'IP (optional)' });
const nameI = el('input', { class: 'dv-edit-name', placeholder: 'name (optional)' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
const add = el('button', { class: 'dv-add' }, 'Add');
add.onclick = async () => {
const mac = macI.value.trim().toLowerCase();
if (!/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(mac)) { err.textContent = 'MAC must look like aa:bb:cc:dd:ee:ff'; return; }
const ip = ipI.value.trim();
if (ip && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { err.textContent = 'IP must look like 192.168.1.x'; return; }
try { await api.post('/api/devices', { mac, ip: ip || undefined, name: nameI.value.trim() || undefined, grp: grpS.value }); load(); }
catch { err.textContent = 'add failed'; }
};
return el('div', { class: 'dv-addform' }, macI, ipI, nameI, grpS, add, err);
}
async function load() {
if (!host) return;
let data, discovered = [];
@@ -54,11 +100,24 @@ async function load() {
...discovered.map(d => discoveredRow(d, load)))
: null;
const addForm = manualAddForm();
addForm.style.display = 'none';
const addToggle = el('button', { class: 'ghost dv-addtoggle' }, '+ Manual Add');
addToggle.onclick = () => { addForm.style.display = addForm.style.display === 'none' ? 'flex' : 'none'; };
const scanBtn = el('button', { class: 'ghost dv-scanbtn' }, 'Scan Now');
scanBtn.onclick = async () => {
scanBtn.textContent = 'Scanning…'; scanBtn.disabled = true;
try { await api.post('/api/devices/scan'); } catch { /* ignore */ }
load();
};
clear(host);
mount(host,
el('div', { class: 'dv-hd' },
el('div', { class: 'dv-title' }, 'Network · Devices'),
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`)),
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`),
addToggle, scanBtn),
addForm,
...sections,
discPanel);
}

39
public/views/links.js Normal file
View File

@@ -0,0 +1,39 @@
// #/links — Hybrid Apps view: a Void-native card (update-tracker + quick-add) on
// top of the embedded themed Kutt UI. Reuses the .term-bar/.term-frame embed classes.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
const SRC = 'https://link.hynesy.com/';
export async function render(main) {
const badge = el('span', { class: 'lk-badge muted' }, 'checking…');
const out = el('span', { class: 'lk-out muted' }, '');
const input = el('input', { class: 'lk-url', placeholder: 'https://long-url-to-shorten…' });
const add = el('button', { class: 'primary' }, '◆ Shorten');
add.onclick = async () => {
const target = input.value.trim(); if (!target) return;
out.textContent = 'creating…';
try { const r = await api.post('/api/kutt', { target }); out.innerHTML = ''; out.appendChild(el('a', { href: r.link, target: '_blank', rel: 'noopener' }, r.link)); input.value = ''; }
catch { out.textContent = 'failed (is Kutt reachable / API key set?)'; }
};
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ Links'),
el('a', { class: 'ghost', style: { marginLeft: 'auto' }, href: SRC, target: '_blank', rel: 'noopener' }, '↗ Open Kutt')
),
el('div', { class: 'card lk-card' },
el('div', { class: 'lk-row' }, el('span', { class: 'muted' }, 'Kutt version'), badge),
el('div', { class: 'lk-quickadd' }, input, add),
el('div', {}, out)
),
el('iframe', { id: 'embed-frame', src: SRC, class: 'term-frame' })
);
try {
const v = await api.get('/api/kutt/version');
badge.classList.remove('muted');
if (v.updateAvailable) { badge.classList.add('lk-update'); badge.innerHTML = ''; badge.appendChild(el('a', { href: v.url, target: '_blank', rel: 'noopener' }, `${v.running}${v.latest} · update available`)); }
else badge.textContent = `${v.running} · up to date`;
} catch { badge.textContent = 'version check unavailable'; }
}

View File

@@ -14,7 +14,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js';
import httpProxy from 'http-proxy';
const VERSION = '2.1.1';
const VERSION = '2.2.0';
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the

View File

@@ -38,4 +38,21 @@ describe('/api/devices', () => {
it('PATCH rejects a bad MAC', async () => {
expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400);
});
it('POST / manually adds an offline device by MAC (owner, lowercased, status=known, absent)', async () => {
expect((await request(app).post('/api/devices').send({ mac: 'aa:bb:cc:dd:ee:ff' })).status).toBe(401);
const res = await owner(request(app).post('/api/devices')).send({ mac: 'AA:BB:CC:DD:EE:FF', ip: '192.168.1.77', name: 'Garage door', grp: 'Smart Home' });
expect(res.status).toBe(201);
expect(res.body.mac).toBe('aa:bb:cc:dd:ee:ff');
expect(res.body.ip).toBe('192.168.1.77');
expect(res.body.status).toBe('known');
expect(res.body.present).toBe(false);
const band = await request(app).get('/api/devices');
expect(band.body.groups.find(g => g.name === 'Smart Home').devices.some(d => d.name === 'Garage door')).toBe(true);
});
it('POST / rejects a bad MAC and a bad IP', async () => {
expect((await owner(request(app).post('/api/devices')).send({ mac: 'nope' })).status).toBe(400);
expect((await owner(request(app).post('/api/devices')).send({ mac: 'aa:bb:cc:dd:ee:ff', ip: 'not-an-ip' })).status).toBe(400);
});
});

40
tests/api/kutt.test.js Normal file
View File

@@ -0,0 +1,40 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import request from 'supertest';
vi.mock('../../lib/links/kutt.js', () => ({
compareVersions: (r, l) => ({ running: r, latest: l, updateAvailable: r !== l }),
fetchLatestKuttRelease: async () => ({ latest: 'v9.9.9', url: 'https://x' }),
createLink: async (b) => ({ link: 'https://link.hynesy.com/abc', address: 'abc', target: b.target }),
recentLinks: async () => ({ data: [] })
}));
let app;
const owner = r => r.set('Authorization', 'Bearer test-token');
beforeAll(async () => {
process.env.OWNER_TOKEN = 'test-token';
process.env.KUTT_API_URL = 'http://10.0.0.1:3000';
process.env.KUTT_API_KEY = 'K';
process.env.KUTT_VERSION = 'v3.2.5';
({ createApp } = await import('../../server.js'));
app = createApp();
});
let createApp;
describe('/api/kutt', () => {
it('GET /version returns running/latest/updateAvailable (owner)', async () => {
expect((await request(app).get('/api/kutt/version')).status).toBe(401);
const res = await owner(request(app).get('/api/kutt/version'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ running: 'v3.2.5', latest: 'v9.9.9', updateAvailable: true });
});
it('POST / creates a link via Kutt (owner)', async () => {
const res = await owner(request(app).post('/api/kutt')).send({ target: 'https://example.com' });
expect(res.status).toBe(201);
expect(res.body.link).toBe('https://link.hynesy.com/abc');
});
it('POST / rejects a non-URL target', async () => {
expect((await owner(request(app).post('/api/kutt')).send({ target: 'not a url' })).status).toBe(400);
});
});

View File

@@ -6,15 +6,19 @@ vi.mock('../../public/api.js', () => ({
api: {
get: vi.fn(async (p) => {
if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', vendor: 'Netgear', randomized: false, present: true } ] } ] };
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', grp: 'Network', vendor: 'Netgear', randomized: false, present: true } ] } ] };
if (p === '/api/devices/discovered') return [
{ mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ];
return {};
}),
patch: vi.fn(async () => ({}))
patch: vi.fn(async () => ({})),
post: vi.fn(async () => ({})),
del: vi.fn(async () => ({}))
}
}));
import { api } from '../../public/api.js';
let renderDevicesBand;
beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' });
@@ -33,4 +37,44 @@ describe('devices band', () => {
expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present
expect(host.textContent).toMatch(/Discovered/i);
});
it('lets you edit a known device (✎ → name/group → Save patches)', async () => {
const host = document.getElementById('h');
await renderDevicesBand(host);
await new Promise(r => setTimeout(r, 0));
const t = host.querySelector('.dv-tile');
t.querySelector('.dv-edit-btn').click();
const nameI = t.querySelector('.dv-edit-name');
expect(nameI.value).toBe('Orbi Satellite');
nameI.value = 'Orbi RBS50';
t.querySelector('.dv-add').click(); // Save
await new Promise(r => setTimeout(r, 0));
expect(api.patch).toHaveBeenCalledWith('/api/devices/bc:a5:11:3e:06:88',
expect.objectContaining({ name: 'Orbi RBS50', grp: 'Network' }));
});
it('Manual Add reveals a form (with IP) and POSTs the new device; MAC field auto-inserts colons', async () => {
const host = document.getElementById('h');
await renderDevicesBand(host);
await new Promise(r => setTimeout(r, 0));
expect(host.querySelector('.dv-addtoggle').textContent).toBe('+ Manual Add');
host.querySelector('.dv-addtoggle').click(); // reveal the form
const [macI, ipI] = host.querySelectorAll('.dv-addform .dv-edit-name');
macI.value = 'aabbccddeeff';
macI.dispatchEvent(new window.Event('input')); // colon-mask
expect(macI.value).toBe('aa:bb:cc:dd:ee:ff');
ipI.value = '192.168.1.50';
host.querySelector('.dv-addform .dv-add').click();
await new Promise(r => setTimeout(r, 0));
expect(api.post).toHaveBeenCalledWith('/api/devices', expect.objectContaining({ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.50' }));
});
it('Scan Now triggers the scheduled scan', async () => {
const host = document.getElementById('h');
await renderDevicesBand(host);
await new Promise(r => setTimeout(r, 0));
host.querySelector('.dv-scanbtn').click();
await new Promise(r => setTimeout(r, 0));
expect(api.post).toHaveBeenCalledWith('/api/devices/scan');
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { JSDOM } from 'jsdom';
vi.mock('../../public/api.js', () => ({ api: {
get: vi.fn(async (p) => p.endsWith('/version')
? { running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true, url: 'https://x' }
: { data: [] }),
post: vi.fn(async () => ({ link: 'https://link.hynesy.com/abc' }))
} }));
let render;
beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>', { url: 'http://localhost/' });
global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node;
({ render } = await import('../../public/views/links.js'));
});
afterAll(() => { delete global.window; delete global.document; delete global.Node; });
describe('links view', () => {
it('renders the update badge + quick-add + the Kutt iframe', async () => {
const main = document.getElementById('main');
await render(main);
await new Promise(r => setTimeout(r, 0));
expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://link.hynesy.com/');
expect(main.textContent).toMatch(/update available/i);
expect(main.querySelector('.lk-quickadd')).not.toBeNull();
});
});

23
tests/links/kutt.test.js Normal file
View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, fetchLatestKuttRelease, createLink } from '../../lib/links/kutt.js';
describe('kutt helpers', () => {
it('compareVersions flags an available update (tolerates v-prefix)', () => {
expect(compareVersions('v3.2.5', 'v3.2.6')).toEqual({ running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true });
expect(compareVersions('3.2.6', 'v3.2.6')).toMatchObject({ updateAvailable: false });
});
it('fetchLatestKuttRelease returns tag + url from the GitHub API (injected fetch)', async () => {
const fakeFetch = async () => ({ ok: true, json: async () => ({ tag_name: 'v3.2.6', html_url: 'https://x/releases/v3.2.6' }) });
expect(await fetchLatestKuttRelease({ fetch: fakeFetch })).toEqual({ latest: 'v3.2.6', url: 'https://x/releases/v3.2.6' });
});
it('createLink POSTs to the Kutt API with the key and returns the short link', async () => {
let seen;
const fakeFetch = async (url, opts) => { seen = { url, opts }; return { ok: true, json: async () => ({ link: 'https://link.hynesy.com/abc', address: 'abc' }) }; };
const r = await createLink({ target: 'https://example.com' }, { base: 'http://10.0.0.1:3000', key: 'K', fetch: fakeFetch });
expect(seen.url).toBe('http://10.0.0.1:3000/api/v2/links');
expect(seen.opts.headers['X-API-KEY']).toBe('K');
expect(r.link).toBe('https://link.hynesy.com/abc');
});
});