Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0a5872f
docs: add architecture, decision record, agent handoff notes and GPL-…
claude Oct 8, 2026
67a31d1
build(bridge): scaffold ghostjs8 uv package with ruff, mypy --strict …
claude Oct 8, 2026
8012c6e
build(web): scaffold Vite + Svelte 5 + strict TypeScript app with esl…
claude Oct 8, 2026
1a2ea5b
ci: add make targets, GitHub Actions CI with SHA-pinned actions, and …
claude Oct 8, 2026
c073ab9
feat(decoder): headless JS8Call 3.0.3 image with Pulse null sink, rem…
claude Oct 8, 2026
177ced7
docs,ci: document decoder container and add decoder image build/healt…
claude Oct 8, 2026
2ec8b81
feat(bridge): KiwiSDR receiver adapter with paired SND/W/F sessions a…
claude Oct 8, 2026
9a7a4a4
feat(decoder): decoder-agent and native JS8Call adapter
claude Oct 8, 2026
e09826a
feat(bridge): contract v0, station orchestrator and browser WebSocket
claude Oct 8, 2026
033b405
test: real-recording acceptance test (make acceptance)
claude Oct 8, 2026
bc28f8a
docs: decoder-agent, RX.SPOT behaviour and acceptance status
claude Oct 8, 2026
a173a48
feat(contract): full v1 protocol with generated JSON Schema and TypeS…
claude Oct 8, 2026
a32bbaf
feat(bridge): evidence-based health state machine
claude Oct 8, 2026
4886248
feat(bridge): receiver directory, Maidenhead validation and SQLite store
claude Oct 8, 2026
66fa574
feat(bridge): station orchestration, control, binary fan-out and JSON…
claude Oct 8, 2026
130f0c3
fix(ci): acceptance build secret defaults to an in-repo empty file
claude Oct 8, 2026
59271b0
feat(web): framework-free protocol, audio, waterfall and state libraries
claude Oct 8, 2026
9598088
feat(web): ghost.js8 listening-post interface
claude Oct 8, 2026
1c009e4
test(web): Playwright smoke suite on a simulated stack
claude Oct 8, 2026
245df41
build: web image and production compose
claude Oct 8, 2026
c3a4ba1
feat(web): show how many browsers are watching the station
claude Oct 8, 2026
24e61cb
docs: operations guide, security-update process, README and changelog
claude Oct 8, 2026
16ea045
feat(ghostnet): autopilot that monitors and records GhostNet JS8 windows
claude Oct 8, 2026
567c56f
test: reap the pacat stand-in in the PCM sink test; ignore Playwright…
claude Oct 8, 2026
7867538
feat(ghostnet): web panel, recorded-net viewer and @GSTFLASH alert
claude Oct 8, 2026
d796025
docs(ghostnet): autopilot guide, env example, operations and handoff …
claude Oct 8, 2026
1dd1335
feat(web): "ghost aurora" redesign — frosted glass, new palette, hero…
claude Oct 8, 2026
97809ac
feat(web): ASCII "signal" backdrop on a solid gradient, replacing the…
claude Oct 8, 2026
56772ec
fix(web): status rows shrink instead of clipping at mid widths
claude Oct 8, 2026
cf9d499
feat(web): stencil ghost mark and terminal wordmark
claude Oct 8, 2026
5a973f7
docs: condensed README with banner, dashboard screenshot and brand as…
claude Oct 8, 2026
4814c2d
feat(web): mode-neutral branding ("JS8 ops console") with an RX mode …
claude Oct 8, 2026
e7d02e2
build: linux/arm64 support, multi-arch release workflow, bridge image…
claude Oct 8, 2026
d275f10
fix(security): receiver address policy, same-origin WebSocket, connec…
claude Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.git
**/node_modules
**/.venv
**/__pycache__
**/.mypy_cache
**/.ruff_cache
**/.pytest_cache
**/.hypothesis
web/dist
web/test-results
web/playwright-report
tools/fixtures/cache
15 changes: 15 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

[*.py]
indent_size = 4

[Makefile]
indent_style = tab
67 changes: 67 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copy to .env and adjust. No secrets are required for a default deployment.

# Where the web container listens on the host. Keep 127.0.0.1 when a reverse
# proxy on the same host fronts it; use 0.0.0.0 only behind your own firewall.
GHOSTJS8_HTTP_BIND=127.0.0.1
GHOSTJS8_HTTP_PORT=8080

# Origins allowed to open the browser WebSocket (comma-separated). Empty = only
# same-origin pages (the Origin must match the Host your proxy forwards).
# Setting your public URL is still recommended.
GHOSTJS8_ALLOWED_ORIGINS=https://ghost.example.net

# Start listening at boot (persistent listening). Leave the host empty to pick a
# receiver from the UI instead.
GHOSTJS8_RECEIVER_HOST=
GHOSTJS8_RECEIVER_PORT=8073
GHOSTJS8_RECEIVER_PASSWORD=
GHOSTJS8_DIAL_HZ=14078000
GHOSTJS8_MODE=usb
GHOSTJS8_LOW_CUT_HZ=100
GHOSTJS8_HIGH_CUT_HZ=3000

# Minutes to keep the receiver with no browser watching. 0 = keep listening forever.
GHOSTJS8_IDLE_DISCONNECT_MINUTES=0

# Decode/station retention in the SQLite store.
GHOSTJS8_RETENTION_DAYS=30

# Limits
GHOSTJS8_MAX_CLIENTS_PER_SESSION=16
GHOSTJS8_WATERFALL_MAX_FPS=10

# Receivers chosen in the UI (or by the GhostNet autopilot) must resolve to public
# addresses. Set true to allow your own LAN KiwiSDR (private, CGNAT/tailnet
# ranges). Loopback, link-local and metadata addresses are always refused. A
# receiver you configure above (GHOSTJS8_RECEIVER_HOST) is trusted as-is.
GHOSTJS8_ALLOW_PRIVATE_RECEIVERS=false

# Use published multi-arch images (amd64/arm64) instead of building locally:
# docker compose pull && docker compose up -d --no-build
# GHOSTJS8_IMAGE_PREFIX=ghcr.io/d3mocide/ghostjs8
# GHOSTJS8_TAG=0.1.0

# Logs: json (default) or text; level DEBUG/INFO/WARNING/ERROR
GHOSTJS8_LOG_FORMAT=json
GHOSTJS8_LOG_LEVEL=INFO

# GhostNet autopilot (docs/ghostnet.md): follow your region's GhostNet JS8 windows on
# the nearest public KiwiSDR and record them (traffic, waterfall, audio). Off by default.
GHOSTJS8_GHOSTNET=off
# Region: na | eu | aus. Home grid: your Maidenhead locator, e.g. EM73.
GHOSTJS8_GHOSTNET_REGION=na
GHOSTJS8_HOME_GRID=
# GHOSTJS8_GHOSTNET_PREROLL_MINUTES=10
# FLAC audio, roughly 35-50 MB per recorded hour:
# GHOSTJS8_GHOSTNET_RECORD_AUDIO=on
# Watch 7.107 MHz between windows (not recorded):
# GHOSTJS8_GHOSTNET_PARK=on
# GHOSTJS8_NET_RETENTION_DAYS=90

# Decoder placeholder identity (never transmitted; JS8Call refuses to start without one).
# Set on the decoder service if you want different values.
# GHOSTJS8_JS8_CALL=N0CALL
# GHOSTJS8_JS8_GRID=AA00

# Building behind a TLS-intercepting proxy: path to a full CA bundle.
# EXTRA_CA=/path/to/ca-bundle.crt
172 changes: 172 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: ci

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
bridge:
name: bridge (ruff, mypy, pytest)
runs-on: ubuntu-24.04
defaults:
run:
working-directory: bridge
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: "0.11.32"
enable-cache: true
- run: uv sync --locked --all-extras
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy
- run: uv run pytest

web:
name: web (eslint, svelte-check, vitest, build)
runs-on: ubuntu-24.04
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run check
- run: npm test
- run: npm run build

e2e:
name: web e2e (Playwright vs simulated stack)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: "0.11.32"
enable-cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
- run: uv --directory bridge sync --locked --all-extras
- run: npm --prefix web ci
- run: npx --prefix web playwright install --with-deps chromium
- run: make e2e
- if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-traces
path: web/test-results/
retention-days: 7

images:
name: ${{ matrix.image }} image (build, ${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
image: [web, bridge]
arch: [amd64, arm64]
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: docker build -t ghostjs8-${{ matrix.image }}:ci ${{ matrix.image }}/
- name: Bridge image can write FLAC (soundfile + bundled libsndfile)
if: matrix.image == 'bridge'
run: >
docker run --rm --read-only --entrypoint python ghostjs8-bridge:ci -c
"import io, numpy, soundfile; b = io.BytesIO();
soundfile.write(b, numpy.zeros(1200, dtype='int16'), 12000, format='FLAC', subtype='PCM_16');
assert b.getvalue().startswith(b'fLaC'); print('flac ok')"

contract:
name: contract (generated schema + TS in sync)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: "0.11.32"
enable-cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
- run: uv --directory bridge sync --locked --all-extras
- run: npm --prefix web ci
- run: make contract-check

decoder-image:
name: decoder image (build + boot + healthy, ${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build (verifies the per-arch JS8Call SHA-256)
run: docker build -f decoder/Dockerfile -t ghostjs8-decoder:ci .
- name: Boot and wait for healthy
run: |
docker run -d --name dec ghostjs8-decoder:ci
for i in $(seq 1 30); do
s=$(docker inspect dec --format '{{.State.Health.Status}}')
echo "health=$s"; [ "$s" = healthy ] && exit 0; sleep 5
done
docker logs dec; exit 1

acceptance:
name: acceptance (real recording -> native JS8Call -> browser WebSocket, ${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs: [bridge, decoder-image]
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: make acceptance
123 changes: 123 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
name: release

# Tag vX.Y.Z -> multi-arch (amd64 + arm64) images on GHCR:
# ghcr.io/<owner>/ghostjs8-{web,bridge,decoder}:X.Y.Z and :latest
# Each architecture builds natively on its own runner (no emulation), is pushed
# by digest, then the digests are merged into one manifest list per image.

on:
push:
tags: ["v[0-9]+.[0-9]+.[0-9]+"]

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

env:
REGISTRY: ghcr.io

jobs:
build:
name: build ${{ matrix.image }} (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: true
matrix:
image: [web, bridge, decoder]
arch: [amd64, arm64]
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
- image: web
context: web
file: web/Dockerfile
- image: bridge
context: bridge
file: bridge/Dockerfile
- image: decoder
context: .
file: decoder/Dockerfile
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Image name
run: echo "IMAGE=${REGISTRY}/${GITHUB_REPOSITORY_OWNER,,}/ghostjs8-${{ matrix.image }}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
labels: |
org.opencontainers.image.title=ghost.js8 ${{ matrix.image }}
org.opencontainers.image.licenses=GPL-3.0-or-later
- id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}
platforms: linux/${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
provenance: mode=max
sbom: true
- name: Export digest
run: |
mkdir -p /tmp/digests
d='${{ steps.build.outputs.digest }}'
touch "/tmp/digests/${d#sha256:}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: digests-${{ matrix.image }}-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1

merge:
name: publish ${{ matrix.image }} (multi-arch)
runs-on: ubuntu-24.04
needs: build
permissions:
contents: read
packages: write
strategy:
matrix:
image: [web, bridge, decoder]
steps:
- name: Image name and version
run: |
echo "IMAGE=${REGISTRY}/${GITHUB_REPOSITORY_OWNER,,}/ghostjs8-${{ matrix.image }}" >> "$GITHUB_ENV"
echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: digests-${{ matrix.image }}-*
path: /tmp/digests
merge-multiple: true
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list
working-directory: /tmp/digests
run: |
test "$(ls | wc -l)" -eq 2
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" \
$(printf "${IMAGE}@sha256:%s " *)
- name: Inspect
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
Loading