Files
Void-Homelab/docs/superpowers/plans/2026-06-08-kutt-url-shortener.md
2026-06-09 00:03:48 +10:00

25 KiB

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)
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
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
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
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)
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)
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
[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

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:
/* 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
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
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

// 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
// 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
git add lib/links/kutt.js tests/links/kutt.test.js
git commit -m "feat(links): Kutt API client + release version-compare"

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

// 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
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:

import { router as linksRouter } from './routes/links.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
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)"

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

// 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
// #/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)

.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
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 + CHANGELOGpackage.json + server.js2.2.0; prepend:
## 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
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
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:

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 pagePOST /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-v2git 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.