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:
139
docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md
Normal file
139
docs/superpowers/specs/2026-06-08-kutt-url-shortener-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user