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

ToolVersionPurpose
Node.js20+Development server, build tooling, and production runtime
npm9+Package management
NSIS3.xWindows installer compilation (makensis) — only needed to produce .exe files
GitanySource 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

bash
npm install
npm run dev          # Next.js dev server on http://localhost:9002 (Turbopack)

Other development commands

bash / cmd
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
TypeScript errors do not fail the build. ignoreBuildErrors: true is set in next.config.ts. Always run npm run typecheck explicitly before committing.
Port note: The dev server runs on port 9002 (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:

.env.local
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

bash
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.

VariableWhere setDescription
LICENSE_KEY.env.local / Docker Compose environment:RKS1-prefixed signed license key. Overrides data/license.key if set.
NODE_ENVDocker ComposeSet to production in the container. Controls Next.js optimisations.

Project Structure

repository layout
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

TypeScript (AppData interface)
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.

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.

Atomic writes: The route writes to a 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

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.

usage pattern
// ✅ 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

Roles

RoleisAdminCapabilities
admintrueFull CRUD on all data, account management, settings, license activation, export
viewerfalseRead-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.

RouteMethodDescription
/api/dataGETReturns { data: AppData, licenseStatus: LicenseStatus }. Validates and decodes the license key on each request.
/api/dataPOSTAccepts full AppData body. Enforces seat/rack/site limits. Writes atomically. Returns updated licenseStatus.
No other API routes exist. Features like audit logging, asset licenses, connections, and app settings are all just fields within the single AppData object sent to /api/data.

Page Structure

RouteFileNotes
/src/app/page.tsxDashboard — stats cards, site/rack management, search, import/export, PDF/CSV
/loginsrc/app/login/page.tsxPIN authentication + admin account CRUD
/rack/[rackId]src/app/rack/[rackId]/page.tsxRack builder — drag-and-drop device placement, front/back views, PDF export
/admin/licensesrc/app/admin/license/page.tsxLicense activation and status display
/auditsrc/app/audit/page.tsxAudit log viewer with search/filter and PDF export (Professional+)
/connectionssrc/app/connections/page.tsxCable register with add/edit/delete and PDF/CSV export
/licensessrc/app/licenses/page.tsxAsset license CRUD with expiry tracking and CSV export (Professional+)
/toolssrc/app/tools/page.tsxCCTV/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:

key structure
RKS1.base64url(JSON payload).base64url(Ed25519 signature)

Payload fields

LicensePayload (decoded)
{
  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

  1. Split key on . — must have 3 segments starting with RKS1
  2. Base64url-decode the payload segment → parse JSON → check structure
  3. Base64url-decode the signature segment
  4. Verify the signature against the payload bytes using the embedded public key
  5. If valid, check exp against current time → return LicenseStatus

License status tiers

StatusConditionUX Effect
validexp > now + 30 daysNormal operation
expiring-soonexp within 30 daysYellow banner warning
expiring-criticalexp within 7 daysRed banner warning
expiredexp < nowRead-only mode — no mutations allowed
invalidSignature mismatch or malformed keyLicense 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()

src/lib/plan-features.ts
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.

page-level gate (src/app/licenses/page.tsx)
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.

tooltip gate pattern (dashboard / tools page)
<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>
Why the wrapper span? Disabled HTML elements do not fire 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

src/lib/pdf-utils.ts
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

usage in any PDF 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

FunctionFileDescription
handleExportPdf()src/app/page.tsxFull infrastructure export — all sites and racks
handleExportSitePdf()src/app/page.tsxSingle-site export with cover page
handleExportPdf()src/app/rack/[rackId]/page.tsxRack report — devices, ports, and bay details
exportPdf()src/app/audit/page.tsxAudit log as paginated table
exportPdf()src/app/connections/page.tsxCable register as paginated table
handleExportPDF()src/components/camera-configuration-dialog.tsxCamera config for a single NVR/DVR device
handleExportPdf()src/components/pava-device-configuration-dialog.tsxPAVA 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

CapabilityComponentPlan
Ports (network, power, video, data)interactive-ports-view.tsxAll
Camera inputscamera-configuration-dialog.tsxProfessional+
PAVA speakerspava-device-configuration-dialog.tsxProfessional+
Drive baysdrive-bay-view.tsxProfessional+
Drawer contentsdrawer-contents-dialog.tsxAll

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:

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.

cmd (run on Windows build machine)
BUILD-STANDALONE.bat

What the build script does

  1. Validates Node.js 20+ is present on the build machine
  2. Runs npm ci to install all dependencies
  3. Runs next build to compile the application into .next/
  4. Prunes to production-only deps with npm ci --omit=dev
  5. Creates racketeer-app.zip containing .next/, public/, package.json, and node_modules/
  6. Downloads node-v22.14.0-x64.msi if not already present in installer/windows/
  7. Compiles racketeer-standalone.nsi with NSIS → Racketeer-Setup-0.3.1.exe
  8. Compiles racketeer-update.nsiRacketeer-Update-0.3.1.exe
  9. Assembles everything into customer-delivery-standalone\

Output artifacts

customer-delivery-standalone\
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:

cmd
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.

PlatformFormatService mechanismScript
Windows.exe (NSIS)Windows Service via WinSWinstaller/windows/racketeer-standalone.nsi
macOSShell + .pkgLaunchDaemon (launchctl)installer/macos/
LinuxShell + .debsystemd unitinstaller/linux/

Windows installer detail

The NSIS script (racketeer-standalone.nsi) performs these steps at install time:

  1. Detects and installs Node.js 22.14.0 LTS from the bundled node-v22.14.0-x64.msi if not already present
  2. Extracts racketeer-app.zip to C:\Program Files\Racketeer\
  3. Generates RacketeerService.xml with the absolute node.exe path and writes it alongside WinSW.exe (renamed to RacketeerService.exe)
  4. Runs RacketeerService.exe install to register the Windows Service with automatic (delayed) startup
  5. Starts the service immediately
  6. Creates Start Menu and Desktop shortcuts opening http://localhost:3000
service command (written into RacketeerService.xml)
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/.

  1. Bump the version in package.json and all version references listed below
  2. Run BUILD-STANDALONE.bat on Windows — produces all installer artifacts in customer-delivery-standalone\
  3. Run installer/linux/build-deb.sh on Linux to produce the .deb
  4. Run installer/macos/build-pkg.sh on macOS to produce the .pkg
  5. Upload artifacts to the release download server
cmd (Windows)
BUILD-STANDALONE.bat

Version bump checklist

Before running the release build, update these locations:

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

bash
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

bash
# 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
Security: 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

OptionDescription
--customerCustomer / organisation name (required)
--seatsMax user accounts including admin (default: 1)
--racksMax total racks across all sites (default: 1)
--sitesMax sites (default: 1)
--daysExpiry in N days from today (fixed date in key)
--monthsExpiry in N months from today
--durationTrial: N days from today (same as --days but labelled as trial)
--idCustom license ID (auto-generated if omitted)
--outWrite key to a file instead of printing to stdout

For perpetual licenses, use a large --days value (e.g. --days 36500 for 100 years).