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>
This commit is contained in:
root
2026-06-08 22:45:53 +10:00
parent 26463b5eb6
commit b783c031b0

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.