diff --git a/docs/superpowers/plans/2026-06-08-kutt-url-shortener.md b/docs/superpowers/plans/2026-06-08-kutt-url-shortener.md new file mode 100644 index 0000000..b464835 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-kutt-url-shortener.md @@ -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 \ + --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= \ + --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= +TRUST_PROXY=true +DB_CLIENT=pg +DB_HOST=192.168.1.215 +DB_PORT=5432 +DB_NAME=kutt +DB_USER=kutt +DB_PASSWORD= +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: " -H "Content-Type: application/json" -d '{"target":"https://example.com"}' +curl -sI http://192.168.1.226:3000/ | 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/` 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('
', { 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= +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 && 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. +```