Files
Void-Homelab/docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md
root a166d525f4 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-08 22:45:53 +10:00

8.3 KiB

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 migratenpm 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 changesnpm/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.

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.