DEV GUIDE
Racketeer v0.3.1 — architecture reference for contributors and maintainers. Covers the data model, state management patterns, licensing system, and build/release pipeline.
Overview
Racketeer is an offline-first rack documentation system distributed as a native service on each platform. There are no external dependencies at runtime — no Docker, no internet connection, no database server, no cloud services. On Windows, the installer bundles Node.js and registers a Windows Service. Linux and macOS use a shell installer and systemd/LaunchDaemon.
[ Browser ] ────────────────────────────────────────────────── │ HTTP on localhost [ Next.js App Router ] (port 3000 in production / 9002 in dev) │ API Routes [ /api/data GET/POST ] │ Node.js fs [ data/db.json ] ── C:\Program Files\Racketeer\data\ (Windows) │ ── /opt/racketeer/data/ (Linux) [ data/license.key ] + [ data/.install ] + [ data/.machine ]
All application state is a single JSON object. The API layer reads it on every GET and writes it atomically on every POST. There is no caching layer, no ORM, no migrations.
Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| Node.js | 20+ | Development server, build tooling, and production runtime |
| npm | 9+ | Package management |
| NSIS | 3.x | Windows installer compilation (makensis) — only needed to produce .exe files |
| Git | any | Source control |
NSIS is only required when building Windows installer packages. Day-to-day development and all application code runs entirely on Node.js with no Docker dependency.
Local Development
Install and run
npm install
npm run dev # Next.js dev server on http://localhost:9002 (Turbopack)
Other development commands
npm run build # Production build (outputs to .next/) npm run start # Serve the production build on port 3000 npm run lint # ESLint — not run during build (ignoreBuildErrors: true) npm run typecheck # tsc --noEmit — use this instead of relying on build
ignoreBuildErrors: true is set in next.config.ts. Always run npm run typecheck explicitly before committing.
npm run dev). The production server and the installed Windows Service both run on port 3000 (npm run start). These are different ports — don't test one expecting the other's data.
License for development
The dev server reads a license from .env.local. Generate one with the license tool (see License Tools) and add it:
LICENSE_KEY=RKS1.xxxxxx.xxxxxx
In production the license is stored in data/license.key and managed via the admin UI. The env var takes precedence over the file.
Resetting data during testing
node tools/reset.js # Wipes data/db.json and removes data/.install, data/.machine
Environment Variables
Only one environment variable is used. Everything else is stored in data/db.json.
| Variable | Where set | Description |
|---|---|---|
LICENSE_KEY | .env.local / Docker Compose environment: | RKS1-prefixed signed license key. Overrides data/license.key if set. |
NODE_ENV | Docker Compose | Set to production in the container. Controls Next.js optimisations. |
Project Structure
src/ app/ # Next.js App Router pages and API routes page.tsx # Dashboard (sites, racks, stats, export) login/page.tsx # PIN authentication + account management rack/[rackId]/ # Rack builder (drag-and-drop U-slot placement) admin/license/ # License activation UI audit/page.tsx # Audit log viewer + PDF export connections/page.tsx # Cable register + PDF/CSV export licenses/page.tsx # Asset license CRUD (Professional+) tools/page.tsx # CCTV, RAID, battery tools (Professional+) api/data/route.ts # Single GET/POST endpoint — reads/writes db.json context/ AuthContext.tsx # Central state: all data + mutations + auth lib/ types.ts # All domain type definitions — read this first license.ts # Ed25519 license validation (offline) plan-features.ts # PRO_FEATURES array + canAccessFeature() device-definitions.ts# Built-in device type catalogue pdf-utils.ts # Shared addPdfLogo() for all PDF exports utils.ts # Tailwind cn() helper and misc utilities components/ ui/ # shadcn/ui primitives (Button, Dialog, etc.) camera-configuration-dialog.tsx pava-device-configuration-dialog.tsx interactive-ports-view.tsx drive-bay-view.tsx drawer-contents-dialog.tsx data/ # Runtime data (volume-mounted in Docker) db.json # All application state license.key # License key string (alternative to env var) .install # HMAC-signed install timestamp .machine # Stable machine ID tools/ # Publisher CLI tools (Node.js, not part of app) generate-keypair.js # One-time Ed25519 key pair generation generate-license.js # Signs and outputs license keys reset.js # Wipes data/ for testing private.pem # NEVER COMMIT — your Ed25519 private key installer/ # Platform-specific distribution packages windows/ # NSIS .exe installer + docker-compose.yml macos/ # .pkg installer linux/ # .deb package
Data Layer
All application state lives in data/db.json. The entire file is loaded into memory on every GET request and written back atomically on every POST. There is no partial update — the full object is always serialised.
db.json shape
interface AppData { sites: Site[] racks: Rack[] accounts: Account[] connections: Connection[] auditLog: AuditEntry[] assetLicenses: AssetLicense[] // Professional+ appSettings: { appTitle: string // defaults to "Racketeer" appLogo: string | null // Data URL (base64 PNG/JPG) plan: 'starter' | 'professional' | 'team' | 'enterprise' } }
All domain entities use string UUIDs (generated with crypto.randomUUID()). Cross-references are stored as ID strings, not nested objects — e.g. Rack.siteId references Site.id.
API route
A single file handles all data I/O: src/app/api/data/route.ts.
- GET /api/data — reads
db.json, validates the license, returns{ data, licenseStatus } - POST /api/data — accepts the full updated
AppDataobject, validates seat/rack/site limits against the license, writes atomically todb.json
Limit enforcement (seats, racks, sites) happens server-side in the POST handler — it is not purely a UI concern. A client cannot bypass limits by calling the API directly unless the limits are satisfied.
db.json.tmp file first, then renames it over the real file. This prevents a corrupt partial write from destroying data if the process is killed mid-write.
State Management
src/context/AuthContext.tsx is the single source of truth for all runtime state. Every page consumes it via useAuth() or useContext(AuthContext).
What AuthContext holds
- All
AppDatafields spread as top-level properties (sites,racks,accounts,connections,auditLog,assetLicenses,appSettings) - Derived convenience fields:
appTitle,appLogo,license(theLicensePayloaddecoded from the key) - Auth state:
currentUser,isAdmin,isAuthenticated - Mutation methods:
updateData(partial),login(),logout()
Mutation pattern
All data changes go through updateData(Partial<AppData>). It merges the partial update into the current state and POSTs the complete updated object to /api/data.
// ✅ Correct — all mutations via context const { sites, updateData } = useAuth(); await updateData({ sites: [...sites, newSite] }); // ❌ Wrong — never call /api/data directly from a component await fetch('/api/data', { method: 'POST', ... });
Polling
AuthContext polls GET /api/data every 30 seconds to pick up license status changes and data changes made by other logged-in users (multi-seat deployments). The poll interval resets on every successful mutation.
Authentication
Authentication is PIN-based. Each Account has a 4-digit PIN stored as a bcrypt hash. There are no passwords, no email addresses, and no session cookies — session state lives entirely in React context (in-memory).
Session lifecycle
- Login: PIN submitted → bcrypt compare → if match, set
currentUserin context - Inactivity timeout: 5 minutes of no user interaction →
logout()called automatically - Page refresh: session is lost (no persistence). User must log in again.
Roles
| Role | isAdmin | Capabilities |
|---|---|---|
| admin | true | Full CRUD on all data, account management, settings, license activation, export |
| viewer | false | Read-only browsing of sites, racks, devices, ports, and cable register |
Admin-only actions are guarded by isAdmin checks both in the UI (buttons hidden) and in the context mutations. The API itself has no auth — it relies on the Docker network being private to localhost.
API Routes
There is only one API route. All application data flows through it.
| Route | Method | Description |
|---|---|---|
/api/data | GET | Returns { data: AppData, licenseStatus: LicenseStatus }. Validates and decodes the license key on each request. |
/api/data | POST | Accepts full AppData body. Enforces seat/rack/site limits. Writes atomically. Returns updated licenseStatus. |
AppData object sent to /api/data.
Page Structure
| Route | File | Notes |
|---|---|---|
/ | src/app/page.tsx | Dashboard — stats cards, site/rack management, search, import/export, PDF/CSV |
/login | src/app/login/page.tsx | PIN authentication + admin account CRUD |
/rack/[rackId] | src/app/rack/[rackId]/page.tsx | Rack builder — drag-and-drop device placement, front/back views, PDF export |
/admin/license | src/app/admin/license/page.tsx | License activation and status display |
/audit | src/app/audit/page.tsx | Audit log viewer with search/filter and PDF export (Professional+) |
/connections | src/app/connections/page.tsx | Cable register with add/edit/delete and PDF/CSV export |
/licenses | src/app/licenses/page.tsx | Asset license CRUD with expiry tracking and CSV export (Professional+) |
/tools | src/app/tools/page.tsx | CCTV/RAID/battery calculator tools (Professional+) |
All pages are client components ('use client') that consume AuthContext. There are no server components fetching data — everything flows through the single API route via context polling and mutations.
Licensing System
License validation is fully offline using Ed25519 asymmetric cryptography. Keys cannot be forged without the private key, which never leaves the publisher's machine.
Key format
License keys are structured as three dot-separated segments:
RKS1.base64url(JSON payload).base64url(Ed25519 signature)
Payload fields
{
id: "A3F9C1D2", // unique license ID
customer: "Acme Corp", // display name
seats: 5, // max user accounts including admin
racks: 20, // max total racks across all sites
sites: 3, // max sites
plan: "professional", // starter | professional | team | enterprise
iss: 1710288000, // issued (Unix timestamp)
exp: 1741824000 // expires (Unix timestamp, baked at generation)
}
Validation flow
- Split key on
.— must have 3 segments starting withRKS1 - Base64url-decode the payload segment → parse JSON → check structure
- Base64url-decode the signature segment
- Verify the signature against the payload bytes using the embedded public key
- If valid, check
expagainst current time → returnLicenseStatus
License status tiers
| Status | Condition | UX Effect |
|---|---|---|
| valid | exp > now + 30 days | Normal operation |
| expiring-soon | exp within 30 days | Yellow banner warning |
| expiring-critical | exp within 7 days | Red banner warning |
| expired | exp < now | Read-only mode — no mutations allowed |
| invalid | Signature mismatch or malformed key | License entry screen shown |
Clock rollback protection
The data/.install file holds an HMAC-signed timestamp written on first run. On each validation, the current time is compared against this stored timestamp. If the current time is earlier than the stored time, the app treats the license as expired and enters read-only mode. Winding back the system clock does not extend a trial.
Trial key tamper-resistance
Trial keys bake the exp Unix timestamp directly into the Ed25519-signed payload at generation time. Deleting data/db.json, data/.install, or reinstalling the app cannot change the expiry — the expiry is in the signed key string itself, not derived from any local file.
Plan-Based Feature Gating
Feature access is controlled by src/lib/plan-features.ts. The plan field from the license payload determines which features are available.
canAccessFeature()
export const PRO_FEATURES = [ 'cameraConfig', 'pavaConfig', 'driveBays', 'rackImages', 'appBranding', 'importJson', 'exportJson', 'customDevices', 'toolsCctv', 'toolsRaid', 'toolsBattery', 'auditLog', 'rackTemplates', 'assetLicenses', ] as const; export function canAccessFeature( plan: Plan, feature: Feature ): boolean { if (!PRO_FEATURES.includes(feature)) return true; // free feature return plan !== 'starter'; }
Gating pattern in components
Two patterns are used depending on context:
Hard gate — page-level: return a lock screen for non-Pro users.
if (!canAccessFeature(license.plan, 'assetLicenses')) { return ( <div className="flex h-screen items-center justify-center flex-col gap-4"> <Lock /> <h1>Professional Feature</h1> <p>Upgrade your license to access Asset License Tracking.</p> </div> ); }
Soft gate — inline: greyed-out control with upgrade tooltip on hover.
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
{/* span needed: disabled elements don't fire hover events */}
<span className="inline-flex cursor-not-allowed"
style={{ pointerEvents: 'auto' }}>
<Button disabled className="opacity-50 pointer-events-none">
Feature Name
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
Upgrade to Professional to unlock this feature
</TooltipContent>
</Tooltip>
</TooltipProvider>
mouseenter or mouseover events. The <span> with inline-flex and explicit pointerEvents: 'auto' acts as the hover target while the inner element is truly disabled. Without inline-flex, the span collapses to zero size and receives no hover events.
PDF Export Pattern
All 7 PDF exports use jsPDF with the jspdf-autotable plugin for tables. A shared utility handles logo branding.
Shared logo helper
import jsPDF from 'jspdf'; export function addPdfLogo(doc: jsPDF, appLogo: string | null): void { if (!appLogo) return; // no-op when branding not configured try { const pageWidth = doc.internal.pageSize.getWidth(); const imgProps = doc.getImageProperties(appLogo); const maxW = 40, maxH = 18; const ratio = Math.min(maxW / imgProps.width, maxH / imgProps.height); doc.addImage(appLogo, 'AUTO', pageWidth - 14 - imgProps.width * ratio, 8, imgProps.width * ratio, imgProps.height * ratio); } catch { // bad image data must never break the export } }
Call pattern per export function
import { addPdfLogo } from '@/lib/pdf-utils'; // Destructure from context: const { appTitle, appLogo } = useAuth(); function handleExportPdf() { const doc = new jsPDF(); addPdfLogo(doc, appLogo); // top-right logo on page 1 doc.text(`${appTitle} — Report`, 14, 20); // branded title // ... doc.addPage(); addPdfLogo(doc, appLogo); // repeat on each new page }
The appLogo field in appSettings is stored as a Data URL string (e.g. data:image/png;base64,...). jsPDF.addImage() accepts Data URLs directly — no conversion needed.
The 7 PDF export functions
| Function | File | Description |
|---|---|---|
handleExportPdf() | src/app/page.tsx | Full infrastructure export — all sites and racks |
handleExportSitePdf() | src/app/page.tsx | Single-site export with cover page |
handleExportPdf() | src/app/rack/[rackId]/page.tsx | Rack report — devices, ports, and bay details |
exportPdf() | src/app/audit/page.tsx | Audit log as paginated table |
exportPdf() | src/app/connections/page.tsx | Cable register as paginated table |
handleExportPDF() | src/components/camera-configuration-dialog.tsx | Camera config for a single NVR/DVR device |
handleExportPdf() | src/components/pava-device-configuration-dialog.tsx | PAVA speaker config for a single device |
Device System
Devices are placed in rack U-slots. Each device occupies a contiguous range of U positions and can be rendered in front-of-rack or rear-of-rack view.
Built-in device types
Defined in src/lib/device-definitions.ts. Each entry specifies the display name, height in U, default port count, and which capability tabs are available (ports, cameras, PAVA, drives, drawer).
Custom devices
Users with Professional+ licenses can create custom device types via the dashboard. Custom devices are stored in AppData alongside built-in types and rendered identically.
Device capabilities
| Capability | Component | Plan |
|---|---|---|
| Ports (network, power, video, data) | interactive-ports-view.tsx | All |
| Camera inputs | camera-configuration-dialog.tsx | Professional+ |
| PAVA speakers | pava-device-configuration-dialog.tsx | Professional+ |
| Drive bays | drive-bay-view.tsx | Professional+ |
| Drawer contents | drawer-contents-dialog.tsx | All |
Type Definitions
All domain types are in src/lib/types.ts. Read this file first when working on any feature — it is the authoritative shape of all data.
Key types to know:
Site— top-level container with name, address, client info fieldsRack— belongs to a Site viasiteId; holdsDevice[]and layout metadataDevice— placed in a Rack; hasuStart,uHeight, ports, cameras, drives, etc.Port— belongs to a Device; can be connected to another port viaconnectedToPortIdAccount— user account withrole: 'admin' | 'viewer'and hashed PINLicensePayload— decoded license key fields (seats, racks, sites, plan, exp)AssetLicense— software/hardware/subscription license record (Professional+)AuditEntry— immutable log entry with timestamp, user, action, and entity referenceConnection— cable register entry linking two port descriptions with cable details
Build Process
The Windows distribution is built by BUILD-STANDALONE.bat at the project root. It produces a self-contained .exe installer that bundles Node.js and the compiled Next.js application — no Docker required on customer machines.
BUILD-STANDALONE.bat
What the build script does
- Validates Node.js 20+ is present on the build machine
- Runs
npm cito install all dependencies - Runs
next buildto compile the application into.next/ - Prunes to production-only deps with
npm ci --omit=dev - Creates
racketeer-app.zipcontaining.next/,public/,package.json, andnode_modules/ - Downloads
node-v22.14.0-x64.msiif not already present ininstaller/windows/ - Compiles
racketeer-standalone.nsiwith NSIS →Racketeer-Setup-0.3.1.exe - Compiles
racketeer-update.nsi→Racketeer-Update-0.3.1.exe - Assembles everything into
customer-delivery-standalone\
Output artifacts
Racketeer-Setup-0.3.1.exe # Fresh install (bundles Node.js + app) Racketeer-Update-0.3.1.exe # In-place update (app only, data-safe) racketeer-update.ps1 # PowerShell updater for admin automation README.txt Linux\ racketeer-app.zip install.sh Mac\ racketeer-app.zip install.sh
Quick update rebuild
If racketeer-app.zip already exists and you only need to rebuild the update .exe:
BUILD-UPDATE-EXE.bat
Pre-baking a license key
To ship an installer with a license pre-installed, place the .key file in installer/windows/ before running NSIS. The installer script copies it to the data\ directory during installation.
Platform Installers
Each platform installer bundles the compiled Next.js app (racketeer-app.zip) and registers a native system service. No Docker is used at runtime on any platform.
| Platform | Format | Service mechanism | Script |
|---|---|---|---|
| Windows | .exe (NSIS) | Windows Service via WinSW | installer/windows/racketeer-standalone.nsi |
| macOS | Shell + .pkg | LaunchDaemon (launchctl) | installer/macos/ |
| Linux | Shell + .deb | systemd unit | installer/linux/ |
Windows installer detail
The NSIS script (racketeer-standalone.nsi) performs these steps at install time:
- Detects and installs Node.js 22.14.0 LTS from the bundled
node-v22.14.0-x64.msiif not already present - Extracts
racketeer-app.ziptoC:\Program Files\Racketeer\ - Generates
RacketeerService.xmlwith the absolutenode.exepath and writes it alongsideWinSW.exe(renamed toRacketeerService.exe) - Runs
RacketeerService.exe installto register the Windows Service with automatic (delayed) startup - Starts the service immediately
- Creates Start Menu and Desktop shortcuts opening
http://localhost:3000
node.exe node_modules\next\dist\bin\next start --hostname 0.0.0.0 -p 3000
Update installer
racketeer-update.nsi handles in-place upgrades safely: stops the service, backs up data\, replaces app files only, restores data if anything went wrong, then restarts the service. The racketeer-update.ps1 script does the same via PowerShell for IT admin automation.
Release Process
The Windows release is built by running BUILD-STANDALONE.bat on a Windows machine with Node.js and NSIS installed. Linux and macOS packages are built by their respective shell scripts in installer/linux/ and installer/macos/.
- Bump the version in
package.jsonand all version references listed below - Run
BUILD-STANDALONE.baton Windows — produces all installer artifacts incustomer-delivery-standalone\ - Run
installer/linux/build-deb.shon Linux to produce the.deb - Run
installer/macos/build-pkg.shon macOS to produce the.pkg - Upload artifacts to the release download server
BUILD-STANDALONE.bat
Version bump checklist
Before running the release build, update these locations:
package.json—versionfieldCHANGELOG.md— new version entry at topREADME.md— "What's New" section and installer filename references- Website
index.html— hero badge, footer, download URLs (4 occurrences) - Website
docs.html— version in lead paragraph and all installer filenames - Website
user-guide.htmlanddev-guide.html— lead paragraph version
License Tools
The tools/ directory contains publisher CLI scripts. These are not part of the application — they run on your dev machine to generate license keys for customers.
One-time setup
node tools/generate-keypair.js
Creates tools/private.pem and prints the public key. Paste the public key into src/lib/license.ts to embed it in the build. Keep private.pem secret — never commit it.
Generating a license key
# 1-year Professional license node tools/generate-license.js \ --customer "Acme Corp" \ --seats 5 \ --racks 20 \ --sites 3 \ --days 365 # 30-day trial (fixed expiry date baked into key) node tools/generate-license.js \ --customer "Trial" \ --seats 1 \ --racks 1 \ --sites 1 \ --duration 30 # Write to file instead of printing node tools/generate-license.js --customer "Acme" --days 365 \ --out customer-delivery/acme.key
tools/private.pem must never be committed to git, included in Docker images, or shared with anyone. The public key in src/lib/license.ts is safe to ship — it can only verify keys, not create them.
License key options
| Option | Description |
|---|---|
--customer | Customer / organisation name (required) |
--seats | Max user accounts including admin (default: 1) |
--racks | Max total racks across all sites (default: 1) |
--sites | Max sites (default: 1) |
--days | Expiry in N days from today (fixed date in key) |
--months | Expiry in N months from today |
--duration | Trial: N days from today (same as --days but labelled as trial) |
--id | Custom license ID (auto-generated if omitted) |
--out | Write key to a file instead of printing to stdout |
For perpetual licenses, use a large --days value (e.g. --days 36500 for 100 years).