Web-based amateur radio logging platform. A Cloudlog-parity logbook with native FT4/FT8 support and FlexRadio control.
Digital modes run one of three ways, chosen by the digital.source setting:
flex— fully self-contained. DigiShack pulls DAX audio from a FlexRadio over VITA-49 and decodes FT8/FT4 in-process. No external decoder.icom— the same, over the RS-BA1 network protocol to an IC-7300, IC-705, IC-9700 or IC-7610 (verified on air with an IC-7300 MK2; the others share the CI-V tables but are untested). No virtual audio cable, no virtual COM port.wsjtx— takes decodes from an external decoder (WSJT-X, JTDX, wsjtx-omega) over its UDP protocol, for setups that prefer one.
A complete station in one application — logging, digital modes, rig control and the QSL side — rather than a logbook that talks to other programs.
Logging. DXCC-aware logbook with ADIF import and export, duplicate checking against band and mode, a triage view for contacts missing data, and award progress for DXCC, WAS, WAZ, WAC, grid squares and IOTA, with POTA hunting and activation progress on its own page. Grid-square and worked-coverage maps drawn from vendored coastlines, so they work with the uplink down.
Digital modes. FT8, FT4 and FT2 decoded and transmitted in-process — no external decoder, no virtual audio cable, no virtual COM port. Live waterfall, per-decode award scoring, and click-to-call sequencing that runs the whole exchange and logs it. WSPR (reported to wsprnet), JS8, RTTY, CW, PSK31, Olivia, SSTV, Feld Hell and JTTY are decoded too, and all of them except WSPR can send; each has its own page in docs/.
Automatic operating. Call CQ, hunt callable CQs, hunt POTA activators, or chase them across bands from the spot feed. An operating schedule decides what runs when, with sleeping hours and a PA duty-cycle rest. Every transmission passes safety brakes: duplicate suppression, a runaway stop, a dead-receiver guard, SWR and PA-temperature cutouts, and a wall-clock session limit.
Transmit control on every page. The header carries a TX control beside FT-0. An admin arms transmit for the radio in use (with a confirmation that names the radio and its RF power); any operator disarms it in one click, which halts the contact in progress, pauses the automatic mode and cancels a transmission still waiting for its window. Arming from the header again resumes the automatic mode that a header disarm paused. FT-0 stops everything and releases the radio.
Rig control. One control surface that follows whichever radio is selected: dial and
modulation, RF panadapter (FlexRadio) or audio waterfall (Icom) with click-to-tune,
receiver and transmitter cards, receiver audio in the browser, and a voice mode that
closes the digital path and hands the radio back. The Rig page is off the menu until
Show the experimental Rig page (ui.experimental.rig) is switched on.
Spectrum. Waterfall HD (a WebGL high-resolution waterfall), a 3D waterfall, and analyzer views (Spectrum, Ridgeline, Persistence, Delta, Threshold) of the audio passband or the FlexRadio's RF panadapter, receive-only, with selectable colormaps that recolour the whole history at once.
DX cluster. Live spots from a DX Spider / AR-Cluster / CC-Cluster node, read by the bridge, with a per-band band map and flags for the entities, band slots and mode slots the log still needs. Off by default; clicking a spot only tunes the receiver.
Ham toolkit. Grid locator, "where can I operate?" band privileges, antenna and feedline lengths, power and dB, SWR, wavelength, time zones and reference tables — all computed in the browser, no internet needed.
QSL. Rendered cards from your own artwork, an email queue you review before
anything sends (tabs per status, bulk approve/skip), printable Avery address-label sheets
for paper cards, and handling for addresses that are not ordinary mailboxes — Winlink's
accept-list key, arrl.net's forwarder, and the mycall@ placeholder convention.
Logbook services. LoTW upload and download, QRZ, eQSL, Club Log, Cloudlog/Wavelog and N3FJP. PSKReporter spot upload and band-condition figures. Email alerts when the station faults.
The header holds Dashboard, four menus — Operate, Log, Analyze, Tools — then Settings (admins) and Help. On the right: the UTC clock, the TX control, FT-0, the Night toggle and your account. When the bar is short of room it narrows (Dashboard drops, Settings and Help become icons) and finally collapses into one menu button; on a phone the same groups open in a drawer from the button at the top left.
Ctrl+K (⌘K on a Mac) opens a go-to palette over every page your role can open, including the ones with no menu entry (Account, QSL cards, Users, Backup …).
Night switches this device to a red-on-black theme for a dark shack; it is remembered per browser. The waterfall, spectrum and SSTV canvases keep their own palettes.
| Menu | Page | Route | Who | What it is |
|---|---|---|---|---|
| — | Dashboard | / |
everyone | Live station card, today's figures, recent contacts, all-time totals |
| Operate | Digital | /decodes |
everyone (operating needs OPERATOR) | Decode list, waterfall, click-to-call, the automatic modes, HALT TX |
| Operate | Grid map | /gridmap |
everyone | Live decodes and worked squares on a world map, with the greyline |
| Operate | Spectrum | /spectrum |
everyone | Waterfall HD, 3D, analyzer views |
| Operate | DX cluster | /cluster |
everyone | Cluster spots, filters and a band map |
| Operate | Rig control | /rig |
OPERATOR, once ui.experimental.rig is on |
The radio's control surface |
| Log | Logbook | /qsos |
everyone | Filterable log; filters, sort and page live in the URL |
| Log | New QSO | /qsos/new |
OPERATOR | Log a contact by hand |
| Log | QSL | /qsl |
OPERATOR | The QSL email queue (paper cards at /qsl/cards) |
| Log | POTA | /pota |
everyone | Activations, hunting progress, the POTA log import |
| Analyze | Statistics | /stats |
everyone | Totals with a per-year trend, highlights, most worked, by band, mode and continent |
| Analyze | Awards | /awards |
everyone | DXCC, WAS, WAZ, WAC, grids and IOTA on one screen |
| Analyze | Coverage | /map |
everyone | Every contact by bearing and distance from home |
| Tools | Ham toolkit | /tools |
everyone | The calculators and references above, each deep-linkable (/tools#swr) |
| — | Settings | /settings |
ADMIN | Every setting, plus the admin tool pages in one sidebar |
| — | Help | /help |
everyone | Searchable answers to "why did it do that?" |
Stations (/stations, OPERATOR) and ADIF (/adif) appear under Tools for operators and
viewers; admins reach them, with Users, Incomplete, DXCC data, API keys, Integrations,
Backup and Updates, from the settings sidebar.
Settings has one sidebar of categories — Station, Radio, Modes, Automation, Logbook
sync, Spotting & broadcast, QSL & email, System — each expanding to its groups, a search
over every setting (/ focuses it), and a save bar that appears only when something is
unsaved. Each group shows its essential settings and folds the rest under Advanced:
86 of the 222 settings are on show by default; the other 136 are tagged advanced. Links can deep-link a category, group or
setting (/settings?tab=…, ?group=…, ?setting=…).
Live station, mid-session — not mock-ups. Taken at 1.116.0, before the 1.224–1.239 page rebuilds and the grouped menus: the features shown are still there, the layout around them has changed.
Digital — FT8 running unattended. Auto Hunt is working a station while the next caller waits in the call-back queue; the band strip along the top is what the whole PSKReporter network is hearing per band, against what this receiver is actually decoding.
Rig control — the RF panadapter, with the band plan under the ruler, spotted callsigns marked on frequency, the receive passband shaded, and click-to-tune.
Dashboard — today against yesterday and the week, what was worked that had never been worked before, and the all-time totals.
Full documentation is in docs/, indexed by docs/README.md, which also lists a page per digital mode. The main ones:
| Getting started | Install, first run, first contact |
| Operating | Automatic modes, the safety brakes, FT-0, band conditions |
| Digital modes | FT8/FT4/FT2, the DAX path, transmitting |
| POTA | Chasing, n-fers, importing your hunter log |
| QSL | Cards, templates, the emailer |
| Backup and moving | Bundles, restore, the two traps that break a migration |
| Settings reference | Every setting, generated from the code |
| Architecture | What runs where, and why |
| Troubleshooting | Symptoms in the order you meet them |
| Development | Conventions, the test suite, adding things |
| Layer | Choice |
|---|---|
| Framework | Next.js 16, Pages Router |
| DB / ORM | MySQL + Prisma |
| Background work | Timers in the two processes, listed in lib/schedule/jobs.ts. No job queue and no Redis |
| Process manager | PM2 (no Docker) |
| Reverse proxy | NGINX |
| Realtime | WebSocket (ws) for the live decode feed and receiver audio, sign-in required |
| Theme | Dark, with a per-device Night (red-on-black) theme; red accent #c21807, Oswald |
There is no app/ directory and no App Router. Don't add one.
Requires Node 20+ and MySQL 8+ (the Proxmox installer below uses MariaDB). Redis is not needed: nothing queues work.
Create the database first:
CREATE DATABASE digishack CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'digishack'@'localhost' IDENTIFIED BY 'a-real-password';
GRANT ALL PRIVILEGES ON digishack.* TO 'digishack'@'localhost';
FLUSH PRIVILEGES;Then run the installer. It creates .env, generates SETTINGS_KEY, installs
dependencies, migrates, builds and starts PM2 — and is safe to re-run:
./scripts/install.shIt stops and tells you to edit DATABASE_URL if .env is still the example.
Manual equivalent
cp .env.example .env
# set DATABASE_URL, and SETTINGS_KEY=$(openssl rand -hex 32)
npm install # runs `prisma generate` via postinstall
npm run db:migrate # dev: creates the initial migration
# or against an already-migrated database:
npm run db:deployTo upgrade later, either from the /update page in the app (see Updating) or from a shell:
./scripts/update.sh # pull, migrate, build, reload
./scripts/update.sh --backup # mysqldump first
./scripts/update.sh --no-pull # already updated by other meansupdate.sh refuses to run with a dirty working tree, skips migrate deploy when
the schema is already current, and reloads PM2 rather than restarting it so
in-flight requests aren't dropped.
Optionally load sample data so the dashboard and log filters have something to show (a station, three operators and ~60 QSOs across bands, modes and QSL states):
npm run db:seednpm run dev # http://localhost:3000First run redirects to /setup, which creates the initial ADMIN account and then closes itself permanently. After that, create a station on /stations before logging — every QSO is attributed to one (the seed script creates one for you).
The seed deliberately creates no user account. Logins come only from
/setup, because seeding an admin with a known password would commit a working credential to version control.
| Script | Does |
|---|---|
npm run dev |
Next dev server |
npm run build / npm start |
Production build / serve |
npm run typecheck |
tsc --noEmit |
npm run check |
typecheck + all assertion suites |
npm run check:adif |
ADIF writer/parser assertions — run after touching lib/adif/ |
npm run check:dxcc |
cty.xml parser + callsign resolution assertions |
npm run check:wsjtx |
WSJT-X protocol assertions (byte-level) |
npm run bridge |
Run the digital bridge in the foreground (either source) |
npm run db:migrate |
prisma migrate dev |
npm run db:deploy |
prisma migrate deploy |
npm run db:seed |
Dev sample data — station, operators, ~60 QSOs |
npm run db:studio |
Prisma Studio |
npm run db:validate |
Validate the schema |
npm ci && npm run build
pm2 start ecosystem.config.js
pm2 save && pm2 startupNGINX config lives in deploy/nginx/digishack.conf.
The live sockets need a sign-in (1.222.0). /ws/decodes and /ws/audio are served by
the bridge on :3101, and every upgrade is checked by
lib/bridge/ws-auth.ts before a socket exists: an Origin
header, when present, must name the request's host, and the request must carry either the
browser's digishack_session cookie or — from a process on the box itself, over a direct
loopback connection with no proxy headers — the bridge token. Through a proxy the token is
refused even when correct. If you run your own nginx (or another proxy) instead of the
shipped file:
- route both
/ws/decodesand/ws/audioto the bridge, with the WebSocket upgrade headers, or the Digital page sits at "connecting" with no waterfall; - pass
Hostthrough (proxy_set_header Host $host, or setX-Forwarded-Host), or the Origin check answers 403; - serve the sockets on the same host as the pages, so the browser sends the session cookie;
- strip any client-sent
X-Bridge-Token(proxy_set_header X-Bridge-Token "";), as the shipped file does.
Local scripts that open the bridge's socket — a recorder, a probe — must now
authenticate. new WebSocket("ws://127.0.0.1:3101/ws/audio") with no credential gets
401. Use localBridgeSocket("/ws/audio") from lib/bridge/ws-auth.ts (run from inside
the project tree, as the scripts/*.ts are), or send the bridge.token value as an
X-Bridge-Token header.
PM2 only checks that a process exists. scripts/healthcheck.sh
checks that each tier still answers, and restarts one that has stopped (three misses in a
row, at most four restarts an hour). It assumes the container layout — the app in
/opt/digishack, PM2 at /usr/bin/pm2 — and runs every minute from root's crontab:
* * * * * /opt/digishack/scripts/healthcheck.sh
Neither installer adds that line; it has to be added by hand.
deploy/proxmox/digishack-lxc.sh builds a container
and installs into it. Run it on the Proxmox host:
bash deploy/proxmox/digishack-lxc.sh
bash deploy/proxmox/digishack-lxc.sh --ctid 141 --hostname shack --cores 4It fetches a Debian 12 template if needed, creates an unprivileged LXC with nesting on,
installs Node 20, MariaDB and PM2, clones the repo, creates the database, and then hands
over to scripts/install.sh — the same installer a bare-metal install uses, so there is
only one install path to keep working. It refuses to touch an existing CTID.
Two things are specific to running in a container, and the script says both when it finishes:
- The clock is the host's clock. A container cannot set its own time, and FT8 tolerates about a second of error before decoding degrades and nobody decodes you — which reads exactly like a dead band. Fix NTP on the host.
- FlexRadio discovery uses multicast, which containers are bad at. Set
flex.hostexplicitly (a blankflex.hostis what turns discovery on). A networked Icom is unaffected: plain unicast UDP to an address you configure.
ecosystem.config.js defines two processes: digishack-web and
digishack-bridge. The bridge is separate on purpose — it binds a UDP socket, and
a bound UDP socket can't be shared across cluster workers. Keeping it out of the
web tier is what allows the web tier to scale.
Both processes must stay at
instances: 1. The bridge because of the UDP socket; the web tier until realtime fan-out moves to Redis pub/sub.
pages/ routes (Pages Router)
api/ the app's own API; api/v1/ is the stable public one
components/ React components by area: ui primitives, layout shell (menus, palette,
TX control), decodes, rig, spectrum, cluster, maps, tools, log, qso,
qsl, dashboard, settings, help, auth
services/radio/ the bridge process (PM2: digishack-bridge) and its operating logic
services/winlink/ Winlink: supervisor for a pinned Pat engine (in progress)
lib/
radio/ shared by both radios: decode pipeline and worker threads, timing,
transmit gate, station gate (the header's TX view), schedules,
receiver controls
bridge/ WebSocket auth for the bridge (ws-auth.ts, localBridgeSocket) and the
browser's socket URL (ws-url.ts)
flex/ icom/ the two radio drivers (SmartSDR API + DAX; RS-BA1 + CI-V)
digital/ dsp/ modems and message codecs: FT8/FT4/FT2, WSPR, JS8, RTTY, CW, PSK31, ...
wsjtx/ the WSJT-X UDP protocol, for an external decoder
cluster/ DX cluster telnet client, spot parser, dedupe and "needed" flags
spectrum/ the Spectrum page's history, colormaps, camera and wire decoding
maps/ geo/ canvas map drawing, projection, greyline; distance and land outlines
ham/ bands, band plan, modes, grid geometry; ham/tools/ is the toolkit maths
adif/ dxcc/ awards/ pota/ qsl/ qrz/ integrations/ the logbook side
settings/ the settings registry, categories (tabs.ts), search and encrypted storage
auth/ api/ validation/ db/ client/ sessions and keys, route helpers, zod schemas,
Prisma and backups, browser-side fetch and socket hooks
time.ts UTC display and datetime-local conversion (lib/time/ is clock correction)
prisma/ schema, migrations, sample-data seed
scripts/ check-*.ts suites (npm run check), install/update/health scripts,
hardware probes
deploy/ nginx config, Proxmox container installer
docs/ documentation
Only three values live in .env:
| Why it can't move | |
|---|---|
DATABASE_URL |
Settings are stored in the database, so its own credentials can't be |
SETTINGS_KEY |
The key that decrypts those settings can't itself be encrypted |
PORT |
Needed before the app can query anything |
Everything else — QRZ, LoTW, eQSL, ClubLog, SMTP, PSKReporter, the
bridge — is managed at /settings (ADMIN only) and stored in the Setting
table, with secrets encrypted using AES-256-GCM. GCM rather than CBC because
it authenticates as well as encrypts: a tampered ciphertext fails to decrypt
instead of yielding plausible garbage that then gets sent to a remote service as
a password.
Add a new setting by adding an entry to
lib/settings/registry.ts — that file is the
definitive list, and the UI and API are both generated from it. Read one with
getSetting() / getSecret() / getNumberSetting() / getBooleanSetting().
Resolution order is database → envFallback → registry default → null. The
envFallback exists purely as a migration path for installs that predate the
Settings UI; the Settings page flags anything still coming from .env so it's
visible what's left to move.
SETTINGS_KEYmust never change on a live install. Every encrypted row is tied to it.install.shwill not overwrite an existing one. Back it up alongside the database — a database backup without it is undecryptable.- Plaintext secrets are never sent to the browser. The API returns
••••••1234and a source label, nothing more. - A blank secret field means "unchanged", not "empty". The browser never has
the current value, so blank cannot mean clear. Clearing is explicit, via the
Clear button (which sends
null). - A row that won't decrypt is treated as unset, not as a reason to fall back to
.env. Quietly using a different credential than the one the UI displays would be worse than having none. - Without a valid
SETTINGS_KEYthe app still runs; secrets simply can't be stored, and /settings says so prominently.
/update (ADMIN) shows the running version, how many commits behind the branch
is, and what would be deployed. One button runs
git merge --ff-only → npm ci → a backup bundle → prisma migrate deploy →
npm run build → npm run check → pm2 reload, streaming each step's output. A backup
that fails stops the update before the schema is touched; a failing npm run check is
reported but does not block the reload of code that built.
This is remote code execution, so update.allowFromUi defaults to false.
Upgrading DigiShack never silently adds a deploy path to an existing install — an
operator has to enable it, and the toggle is on the /update page with the risk
stated beside it.
The safeguards are deliberate, and worth not removing:
- ADMIN only on every endpoint; a VIEWER cannot read the status.
- Refuses a dirty working tree, so local edits are never clobbered.
--ff-only— no merge commits, no history rewriting, and a diverged branch is refused rather than reconciled.- A failed install, migration or build aborts before the reload, so a broken build never becomes the running version.
- The token never leaks. Encrypted at rest; written to a
0600temp file only for the duration of a fetch and removed in afinally. It is deliberately not placed in the remote URL (that persists it into.git/config) nor passed via-c(visible in the process list to every user on the machine). It is also redacted from captured output before that output can reach a log or a browser.
Run state persists to logs/update-state.json because the reload kills the process
performing the update — the UI polls through the restart and picks the outcome back
up. scripts/update.sh remains available and additionally supports --backup.
Qso.dxcc holds an ADIF entity code. Resolving a callsign to one needs reference
data, loaded from Club Log's cty.xml at /dxcc (ADMIN) — either fetched with
a cty API key or uploaded by hand for a shack with no outbound internet.
Nothing is committed to the repo. The file is maintained upstream, changes regularly, and is not ours to redistribute. Refresh it every month or two.
Club Log's file is used rather than AD1C's cty.dat because cty.dat carries a
country name and primary prefix but not the numeric ADIF entity code, which is
the one value Qso.dxcc needs.
lib/dxcc/resolve.ts handles all of these, and a naive
lookup gets every one of them wrong:
| Input | Resolves to | Why |
|---|---|---|
KH6XYZ |
Hawaii (110) | not the USA, despite the K |
KL7AA |
Alaska (6) | ditto |
VP2E/K9XYZ |
Anguilla (12) | portable prefix beats the home call |
K9XYZ/KH6 |
Hawaii (110) | suffix form of the same rule |
F/K9XYZ |
France (227) | tie broken toward the shorter token |
K9XYZ/P |
USA (291) | /P, /M, /QRP, bare digits are not locations |
K9XYZ/MM |
no entity | maritime mobile is outside every entity |
YU1ABC in 1995 |
Yugoslavia (296) | deleted entity, valid at the time |
YU1ABC in 2026 |
Serbia (501) | same prefix, different era |
Precedence is whole-callsign exception, then longest matching prefix, each filtered by validity at the QSO date. Always pass the QSO's own date — resolving a 1995 contact against today's table credits the wrong entity.
Import safety: dxcc.importedAt is deleted before an import starts and written
only on success, and the resolver treats its absence as "no data". A failed or
interrupted import therefore yields "not loaded" rather than answers drawn from
half a table.
services/radio is DigiShack's own radio engine — the
self-contained decode/transmit paths for a FlexRadio (digital.source = flex) and
a networked Icom (digital.source = icom). It can also take decodes from an
external decoder over the WSJT-X UDP protocol (digital.source = wsjtx) for
setups that prefer one. Run it with npm run bridge, or under PM2 as
digishack-bridge.
It was called the "omega bridge" until 1.5.0, after wsjtx-omega. That is one
program it can take decodes from; it has nothing to do with the two radios the
bridge drives itself, and the name made every setting read as though an external
program were always involved. The bridge's own settings are bridge.* now and the
external-decoder ones wsjtx.*; the old keys are still read when the new ones are
unset.
It listens for Heartbeat/Status/Decode/QSOLogged, persists decodes to
DigitalDecode, optionally auto-logs QSOs, broadcasts everything to browsers on
ws://…/ws/decodes (signed-in browsers only — see Production), and exposes
a control API so the web app can send Reply / HaltTx / HighlightCallsign back to the
decoder.
Point the decoder's UDP server at this host on wsjtx.udpPort (WSJT-X defaults
to 2237).
- The bridge must stay at
instances: 1. It binds a UDP socket, and a bound UDP socket cannot be shared across cluster workers — a second instance either fails to bind or silently steals half the datagrams. This is also why it is a separate process rather than living inside Next.js. - The control API is never proxied by NGINX.
/reply,/halt,/free-text,/transmit-gateand friends make the radio transmit or change whether it may. The web app reaches them over localhost withbridge.token(through/api/bridge/control, which checks the user's role first — arming needs ADMIN); without that token the control API refuses to serve and says so. wsjtx.autoLogdefaults to off. Writing to the log because a radio said so is opt-in.- Decodes are batched before insert — one FT8 cycle yields 30+ in the same instant.
- A decode whose band can't be resolved reaches the live feed but is not
persisted.
DigitalDecode.bandis required and a guessed band is worse than none. - Every decode can also go to a CSV per UTC day — set
digital.decodeCsvDir. Separate from the database copy, which is pruned afterdigital.decodeRetentionDays: the table is what the application queries, the files are the raw feed kept in a format that outlives the schema. Includes decodes with no resolvable band, which never reach the database at all. - A contact made by the native path keeps its whole exchange in
Qso.transcript— every message sent and received, with times, reports, offsets and any transmission the radio refused. Seelib/digital/transcript.ts. Null on manual entries, ADIF imports and anything logged through an external decoder: those never saw the messages, so null means "not recorded" rather than "nothing was said".
lib/wsjtx/protocol.ts is Qt QDataStream, big-endian:
magic 0xadbccbda, schema, type, then an id string and typed fields. Strings are a
quint32 byte length (0xffffffff = null) then UTF-8.
Three things that are easy to get wrong, all asserted in npm run check:wsjtx:
- QDateTime carries a trailing offset. After the Julian day, ms and timespec
byte, a timespec of
2(OffsetFromUTC) adds aqint32. Skipping it shifts every subsequent field in a QSOLogged packet — the original decoder did exactly that. - Delta Frequency is not optional. It sits between delta time and mode in a
Decode, and it is what
DigitalDecode.freqOffsetstores. - QColor is six 16-bit values after a spec byte, with 8-bit components scaled
by
0x101— needed for HighlightCallsign.
WSJT-X reports FT4 as MODE=MFSK; the bridge normalises that to FT4 before
logging, because that is what the operator calls it.
/api/v1 is the stable surface for third-party clients. Everything outside it
is app internals and may change without notice — that separation is the point of
versioning it.
GET /api/v1 returns a machine-readable list of every endpoint, its required
role, and the conventions. Start there rather than in this README.
curl -H 'Authorization: Bearer dsk_…' https://host/api/v1
# Ingest ADIF — the endpoint WSJT-X-adjacent tools should post to
curl -X POST -H 'Authorization: Bearer dsk_…' -H 'Content-Type: text/plain' \
--data-binary @log.adi 'https://host/api/v1/adif?stationId=…'Keys are managed at /api-keys (ADMIN). The token is shown once — only its SHA-256 is stored.
- A key can never hold ADMIN. The API rejects it. A leaked key that reaches user management or the updater is categorically worse than one that logs a QSO.
- Bearer tokens work only on
/api/v1. Every other route is cookie-only; a route opts in explicitly withallowApiKey: trueon itsMethodSpec. Don't add that flag to anything under/api/users,/api/settings,/api/updateor/api/api-keys. - Keys cannot mint keys —
/api-keysis cookie-only, so a leaked VIEWER token can't be escalated. - Revoking is immediate:
activeandexpiresAtare checked on every request. - Session auth is tried before key auth, so a browser request is never attributed to a key that happens to be in the headers.
New public endpoints should share their implementation with the app's own route
(see lib/adif/import-service.ts and
lib/stats/summary.ts) rather than reimplementing it — a
second copy drifts.
GET /api/integrations/status runs read-only credential checks. Nothing there
writes to a remote logbook, which is what makes it safe against live accounts.
| Service | Implemented | Notes |
|---|---|---|
| QRZ Logbook | status, differential download, upload | qrz.logbookApiKey is per-logbook and separate from the XML login |
| LoTW | status, confirmation download, upload | uploads are signed here from the callsign certificate — no TQSL; see docs/lotw-upload.md |
| eQSL | inbox sync, upload | the status card reports the inbox sync's last run |
| Club Log | upload, one contact at a time | needs an API key from Club Log's helpdesk as well as the login |
| Cloudlog / Wavelog, N3FJP | status, upload |
HRDLOG is not supported: nothing ever uploaded there, and its settings were removed.
What gets uploaded, when, and what Sent and Rcvd mean is in docs/logbook-sync.md.
ClubLog authenticates by registered email address, not callsign, and prefers an Application Password over the account password.
- Nothing bulk-uploads the back catalogue on its own. These are live public logs;
pushing local QSOs outward en masse is very hard to unpick, especially on LoTW.
Automatic upload skips contacts older than
uploads.since; the back catalogue goes through a separate, explicit request that shows the count first. - Sync endpoints default to
dryRun. The first thing an operator should see is what would change. - LoTW sync only ever sets
lotwRcvdtrue. A confirmation missing from an incremental window is not evidence it was withdrawn. - Matching is to the minute, not the second — LoTW and ADIF both record
TIME_ONthat coarsely.
curl -X POST -b cookies \
'http://localhost:3000/api/integrations/qrz-import?stationId=…&dryRun=0&maxPages=12'Paging uses AFTERLOGID, which is undocumented. Determined empirically: it
returns ascending logid order and is inclusive of the id given, so each
page repeats the previous page's last record — one alreadyInLog per page, which
dedupe absorbs. MAX:5000 is accepted.
Never use
z.coerce.boolean()for a query parameter. It applies JavaScriptBoolean()semantics, so?dryRun=0means true. UseboolQuery()fromlib/validation/query.ts. This silently turned a real import into a no-op that reported success.
/awards tracks DXCC, WAS, WAZ, WAC, grid squares and IOTA on one screen — a confirmed / worked / not-yet bar for each, and on demand the codes worked, the ones missing, and a by-band breakdown — filterable by station, band and mode.
For awards, "confirmed" means a paper card or LoTW — not eQSL
(lib/awards/compute.ts). The ARRL does not accept eQSL for DXCC,
WAS or VUCC, nor CQ for WAZ, so counting it would inflate every total against what could
actually be submitted. The log's own "confirmed" filter
(lib/db/qso.ts) is broader and does include eQSL.
Each award keys off one ADIF field, which is why they are stored:
| Award | Field | Denominator |
|---|---|---|
| DXCC | dxcc |
current (non-deleted) entities from cty.xml |
| WAS | state |
50 US states |
| WAZ | cqZone |
40 CQ zones |
| WAC | continent |
7 continents |
| Grid squares | gridSquare |
none |
| IOTA | iota |
none |
Grid and IOTA have no denominator on purpose. The IOTA reference is ~1,200 groups maintained by the RSGB programme and isn't bundled, so progress reads "groups worked" rather than "N of M" — inventing a denominator would be worse than omitting one.
If more has been worked than the reference data contains — a partial or stale
cty.xml — the award sets totalUnreliable, hides the fraction and says so. The
count stays correct; only the denominator is untrustworthy.
cqZone and continent are filled from the DXCC lookup on the entry form and by
the /dxcc backfill, because WAZ and WAC are otherwise unreachable.
Every page and every API route requires a session, with these exceptions:
/api/auth/{setup,login,logout,me,forgot,reset}, /api/health (liveness, no log data),
and the two links a QSL recipient follows, /api/qsl/card/[id] and /api/qsl/unsubscribe,
which need an HMAC token instead. docs/exposure.md records how each was
checked. The bridge's WebSockets need the same session (or, from the box itself, the
bridge token) — see Production.
User is the login identity; Operator is the QSO-attribution record. They
are separate on purpose — an Operator is scoped to exactly one Station, so
credentials on Operator would force anyone operating two stations to hold two
logins. Operator.userId optionally links them; guest ops can be logged without
having an account at all.
User.role is authoritative for authorization. Operator.role is not — it
describes that person's function at that station and is never consulted for
access control.
| Role | Can |
|---|---|
VIEWER |
Read everything. No mutations at all. |
OPERATOR |
Log and edit QSOs, stations, operators. |
ADMIN |
Everything, plus user management and destructive deletes. |
Roles are a ladder, so a VIEWER-marked GET is reachable by all three. Read
methods are VIEWER and mutations OPERATOR, which is what makes VIEWER a
genuinely read-only account rather than one enforced by convention.
Guard helpers:
- API routes —
authedRoute({ GET: { role: "VIEWER", handler }, ... })fromlib/auth/guard.ts. Use this, notroute(), for anything other than the four public auth endpoints. - Pages —
export const getServerSideProps = withPageAuth({ role: "OPERATOR" }). Server-side, so a protected page never renders without a valid session. - UI role-gating —
useCan("OPERATOR")fromlib/client/session.tsx. This only hides controls; it is not a security boundary.
Passwords use scrypt from node:crypto, not argon2 or bcrypt. Both
alternatives are native modules needing per-platform prebuilt binaries, and this
repo is authored on Windows and deployed on Linux — a failed native rebuild on
deploy takes down login. scrypt is memory-hard, on OWASP's accepted list, and
ships in Node core. Parameters are stored inside each hash, so raising them later
keeps old hashes verifiable (see needsRehash) and logins upgrade opportunistically.
Sessions are server-side rows, not JWTs. That is the whole reason for the choice: disabling an account or changing a password revokes existing sessions immediately. Only the SHA-256 of the token is stored, so a database leak yields no usable sessions.
Login is throttled in-process (lib/auth/throttle.ts).
Per-process is correct while the web tier runs at instances: 1; if it is ever
clustered this must move to Redis, or the effective limit multiplies by the worker
count.
Wrong email, wrong password and disabled account all return the same 401, and a missing account is verified against a dummy hash so it costs the same ~100ms. Neither the message nor the timing reveals which addresses are registered.
The last active admin cannot be demoted, disabled or deleted — that would lock everyone out with no recovery short of editing the database by hand.
Everything is UTC. <input type="datetime-local"> works in local time, so
every conversion goes through lib/time.ts. Getting this wrong
puts the log hours off and breaks LoTW matching outright.
Never res.json() a payload containing a QSO. freqHz is a Prisma BigInt
and res.json() throws on it. Use sendJson() from
lib/api/respond.ts, which serializes BigInt as a JSON
number (amateur frequencies top out around 2.5e11 Hz, well inside
Number.MAX_SAFE_INTEGER).
Frequency is authoritative; band is derived. POST /api/qsos accepts an
optional band and fills it from freqHz when absent. If both are supplied and
disagree, the request is rejected rather than guessed at — a band that doesn't
match its frequency silently corrupts per-band award tracking.
Band and mode names are ADIF 3.x values, not a contest-only set.
This matters for ADIF import/export, LoTW, and award tracking. FT4 is stored as
FT4 even though ADIF encodes it as MODE=MFSK / SUBMODE=FT4; the ADIF writer
makes that split on export (lib/adif/fields.ts). See
lib/ham/.
Stations can't be deleted while they hold QSOs (409). Operators can be —
Qso.operator is onDelete: SetNull, so deleting an operator detaches them from
their contacts instead of destroying log rows.
Phases 1 to 5 are implemented. The last open Phase 2 item, the DX cluster, landed in 1.233.0.
- Phase 1 — schema, QSO/station/operator/rig CRUD, dashboard, log view
- Authentication — users, roles, sessions, first-run setup, password reset by email (not a numbered spec phase; added because DigiShack is web-facing)
- Phase 2 — Cloudlog parity
- Manual QSO entry
- ADIF import/export
- DXCC entity resolution (Club Log cty.xml)
- DXCC / WAS / WAZ / WAC / grid / IOTA award tracking
- Public REST API (
/api/v1) with API keys - QRZ logbook sync (status, full import, differential download) and LoTW confirmation download
- Uploads to QRZ, LoTW (signed here, no TQSL), eQSL, Club Log, Cloudlog/Wavelog and N3FJP
- QSO maps —
/map(bearing and distance from home) and/gridmap - DX cluster (
/cluster, 1.233.0) — receive-only; never yet connected to a real node - CAT control panel (
/rig) - QSL card management (
/qsl/cards)
- Phase 4a — WSJT-X UDP bridge (protocol, persistence, auto-log, WebSocket, control API)
- Phase 4c — self-contained FT8/FT4 decode from FlexRadio DAX audio (receive)
- Phase 4d — self-contained FT8/FT4 transmit over DAX TX (verified on air: 242 PSKReporter reception reports across NA/EU/SA from one CQ)
- Phase 4e — automated operating modes on top of the native TX/RX chain:
- Auto CQ — call CQ on a clear frequency, work whoever answers, log, repeat
- Auto Call — click any decode row and the sequencer runs the full QSO
- Auto Hunt — work callable CQs one at a time (dupe/cooldown guarded); live-verified: W4PSK and SP1ABC (DX) logged back-to-back autonomously
- Auto Hunt POTA — hunt restricted to stations calling CQ POTA
- Auto Call POTA — click a POTA activator's decode
- Award-aware hunting: ranks by new DXCC / band slot / CQ zone / continent / grid, with signal strength only as a tiebreaker
- Chase POTA — retunes to spotted activators from the POTA spot feed
- FT2 — modulator, 77-bit packing, CRC-13, LDPC(128,90) and the decoder, ported
from wsjt-x_improved
lib/ft2(finished in 0.41.0). Experimental: no FT2 signal from another station has been decoded here yet — see docs/digital-modes.md. Note that the FT2 description circulating publicly is substantially wrong — see the 0.29.0 changelog entry for the real parameters. - More modes — WSPR (receive, reported to wsprnet), JS8, RTTY, CW, PSK31, Olivia, SSTV, Feld Hell and JTTY, each with a page in docs/
- YouTube Live streaming of the waterfall and receiver audio (docs/streaming.md)
- Auto band-hop (
auto.bandHop) — on a guard pause, retune to the next band on the list, listen two cycles, settle or hop on. - ATU tune button and
flex.atuOnBandChange. - Live band-activity page (
/decodes) - Spectrum page (
/spectrum): WebGL spectrogram and 3D waterfall (1.229.0) - Ham toolkit (
/tools) (1.230.0) - Arm/disarm transmit from the header (1.220.0); grouped menus and the Ctrl/⌘+K palette (1.234.0); the Night theme (1.235.0)
- Phase 4f — a second radio: the networked Icom (RS-BA1), decoding, transmitting and running the automatic modes from the same operating layer as the FlexRadio. Verified on air with an IC-7300MK2.
Phase 4b — confirm Flex control via the decoder's Hamlib link— dropped. It assumed the external decoder would hold the CAT link and DigiShack would borrow it. DigiShack talks to both radios directly now (SmartSDR API, and CI-V over RS-BA1), and rig control deliberately goes through the bridge so it follows whichever radio is selected. A third path through somebody else's Hamlib link would re-introduce exactly the confusion that removingpages/api/flex/tune.tsgot rid of.- Phase 5 — PSKReporter: spot lookup, and reporting our own decodes
(
pskreporter.upload) so DigiShack appears as a receiver - Phase 3 — QSL emailer: SMTP transport, templates, QRZ address lookup and a
queue/review/send workflow at
/qsl
- There is no job queue. BullMQ and ioredis were installed and never used, and have been removed. PSKReporter reporting batches in-process on a timer instead, which is sufficient for one station.
- QRZ and eQSL are verified against the live services. QRZ uploads are round-trip checked; the eQSL inbox sync has been applied to the real log.
- Club Log needs an API key, and its absence looks like a block. Without the
apifield both write endpoints answer a bare 403 from nginx, which was misread for a month as Club Log refusing this installation. With the key, measured 2026-08-30:getadif.php,putlogs.phpandrealtime.phpall answer 200. Uploads go one contact at a time throughrealtime.php(1.156.0). - LoTW upload is measured: 26 contacts signed here were accepted and then listed back by LoTW's own report (1.94.2). LoTW only says a file is queued, so a daily reconciliation clears the Sent flag on anything LoTW does not hold (1.102.0).
- LoTW's chunked full sync is unverified: ARRL throttles with HTTP 503 and testing it properly means hammering them.
- DXCC runs on a fixture.
cty.xmlneeds a Club Log API key (or a manual upload from the DXCC page). Until then the awards page correctly reports its reference data as stale rather than pretending otherwise.
GPL-3.0-or-later. See LICENSE.
This is not an arbitrary choice. The native FT8/FT4 path depends on
@e04/ft8ts, a TypeScript port of the WSJT-X
2.7.0 codec, which is GPL-3.0 and is imported directly into application code:
lib/radio/decode-worker.mjs—decodeFT8,decodeFT4, on a worker thread (the receive path, both radios)lib/radio/waveform.ts—encodeFT8,encodeFT4(the transmit path, both radios)
That makes DigiShack a combined work, so GPL-3.0 is what the dependency already
requires. It is the only copyleft dependency in the tree; everything else is
permissive. Every working implementation of these modes descends from WSJT-X
(WSJT-X itself, WSJT-X Improved by DG2YCB, DECODIUM, ft8ts), so there is no
permissive alternative to adopt instead — and the licence choice matches the norm
for amateur radio software.
Practical consequences worth knowing:
- Anyone you give a copy or a binary to is entitled to the source, including your local modifications.
- Running it yourself, or for your club on your own server, imposes nothing extra — GPL obligations attach to distribution, not use.
- FT2 may therefore be ported from DECODIUM or wsjt-x_improved at no additional licensing cost, keeping their copyright notices intact.
- FT8/FT4 encode and decode:
@e04/ft8ts(GPL-3.0), a port of WSJT-X by Joe Taylor K1JT, Steve Franke K9AN, Bill Somerville G4WJS and contributors. - DXCC reference data: Club Log
cty.xml, by permission of its maintainers. - POTA spot data: the Parks on the Air API.
- Reception reports: PSKReporter.
- The FT-0 name and its specifications — 0 baud, 0 Hz bandwidth, −∞ dB minimum SNR, 0 W, a 100% success rate and "when all else fails: nothing" — are the work of the FT-0 Working Group at ft-0.com. The joke is theirs; the kill switch named after it is not, and any disappointment at discovering the button actually does something is entirely this project's fault.


