diff --git a/docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md b/docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md new file mode 100644 index 0000000..026bc9c --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md @@ -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 `