No description
  • TypeScript 86.3%
  • CSS 9.5%
  • Dockerfile 2.2%
  • HTML 2%
Find a file
2026-10-14 16:39:52 +01:00
client First commit 2026-10-14 16:39:52 +01:00
server First commit 2026-10-14 16:39:52 +01:00
.dockerignore First commit 2026-10-14 16:39:52 +01:00
.env.example First commit 2026-10-14 16:39:52 +01:00
.gitignore First commit 2026-10-14 16:39:52 +01:00
Caddyfile.example First commit 2026-10-14 16:39:52 +01:00
compose.yaml First commit 2026-10-14 16:39:52 +01:00
Dockerfile First commit 2026-10-14 16:39:52 +01:00
README.md First commit 2026-10-14 16:39:52 +01:00

Squawk Audio Widget — third-party integration template

A self-contained, client-agnostic example of embedding Newsquawk Audio live streaming into a third-party site. This directory is a template: copy it per client, fill in .env, and deploy. The worked examples throughout use the example.com deployment (client api-customer-example, origins example.com).

The widget itself is a TypeScript/Vite project (client/) with no runtime dependencies; the token endpoint is a small zero-dependency Node script (server/src/token-server.ts).

squawk_audio_widget/
├── client/                 Vite + TypeScript frontend
│   ├── index.html          demo page (Vite entry)
│   ├── src/
│   │   ├── widget.ts       the widget: typed SquawkClient + mount()
│   │   ├── main.ts         demo bootstrap
│   │   └── widget.css / demo.css
│   └── dist/               build output (served by token-server)
├── server/                 Bun + TypeScript token endpoint
│   └── src/token-server.ts
├── Dockerfile              multi-stage: bun build client → bun runtime
└── compose.yaml            optional Caddy TLS profile

1. Architecture — and why server-side minting is required

Browser (client site)
│
│  1. GET /token                      ──►  Token endpoint (same origin)
│     ◄─ {"access_token", ...}              holds the Keycloak CLIENT SECRET
│                                           (client_credentials grant,
│                                            24 h token, cached server-side)
│  2. WSS audio-v2.newsquawk.com/api/ws?token=<JWT>
│     subscribe → stream_start → binary WebM/Opus → stream_end
│
audio-v2.newsquawk.com  validates the JWT (RS256, issuer, audience=audio-client)
and maps the Keycloak role `subscriber` → app role `listen`.

The Keycloak client secret must never reach the browser. Anyone who has it can mint unlimited tokens. So the token is always minted server-side:

  • Option A (recommended): run the included token server next to the widget. It mints a token via the service account, caches it, and serves it to the widget from the same origin.
  • Option B: if the client already has a backend, port getToken() from server/src/token-server.ts (≈20 lines) into it and expose e.g. GET /api/audio/token authenticated however they already authenticate their users.

Per-client checklist (once per new client)

Step Where Notes
1. Keycloak confidential client api-customer-<client> Keycloak realm service-account only, no login flows
2. Audience mapper → audio-client the client required by the API's audience check
3. Assign subscriber client role (on react-frontend-client) to the service account Keycloak maps to app role listen
4. Add the client's origins to SQUAWK_ALLOWED_ORIGINS audio-v2:~/audio/conf/env_file takes effect at restart
5. Copy this template, fill .env deployment box client id/secret, shared key, frame-ancestors
6. Smoke test — /token, /api/auth/me, WS subscribe

What is already provisioned for the current client:

Item Value
Allowed browser origins (CORS, staged) example.com, www.example.com, localhost dev
Allowed framing hosts (CSP frame-ancestors) same as CORS origins — set via .env
Keycloak client api-customer-example (confidential, service-account only)
Audience mapper adds audio-client to tokens (required by the API)
Role subscriber client role on react-frontend-client → listen
Verified live token mint, /api/auth/me, /api/auth/verify?roles=listen, WS subscribe

2. Quick start (local)

A ready-to-use .env (chmod 600) with the provisioned service account already exists in this directory; the server loads it automatically (Bun reads .env).

Container demo (what the tunnel forwards to):

cd ~/projects/squawk_audio_widget
docker compose up -d --build
# → http://127.0.0.1:5173  (PORT is pinned in .env for the tunnel demo)

Widget development (HMR): stop the container first (it owns port 5173), then run Vite directly with the token server alongside:

docker compose down
bun run server/src/token-server.ts &  # token endpoint on 8787
cd client && bun install && bun run dev
# → http://localhost:5173  (Vite proxies /token to :8787)

Open the page, press ▶. Pressing play is the user gesture that unlocks browser autoplay; the widget then plays the live channel as soon as a broadcast starts.

Testing without the token server: open http://127.0.0.1:8787/?token=<JWT> — the widget uses the literal token instead of fetching /token. Handy for debugging; never for production.

Widget configuration

Via mount options (SquawkWidget.mount('#el', {...})) or URL parameters:

Mount option URL param Meaning
api_url ?apiUrl= Audio API origin (default https://audio-v2.newsquawk.com)
token_url ?tokenUrl= Token endpoint (default ./token, same origin)
token ?token= Literal token (testing only)
shared_key ?key= Sent as X-Widget-Key to the token endpoint (demo convenience — a shared key is not real authentication)
default_channel ?channel= Preselected keyword, e.g. asset:forex
max_buffer_ms — Target buffer-ahead in ms (default 1200)

The channel dropdown resolves in priority order:

  1. SQUAWK_CHANNELS from the server's .env (curated list served at /channels) — use this to offer only the channels a client is licensed for
  2. the full public channel list from the audio API
  3. built-in defaults (works even if CORS is not yet active for your origin)

While connected, the widget records each completed broadcast and shows a ⟲ Last squawk button to replay it (capped at 32 MB — hours of Opus speech; older recordings are replaced by newer ones).


3. Deploying the widget host

The widget host serves static files and the token endpoint on one origin (e.g. audio.<client>.com, or a Newsquawk-controlled domain like web.virtualfunction.net).

cp .env.example .env    # fill in CLIENT_SECRET, SHARED_KEY, CHANNELS and
chmod 600 .env          # FRAME_ANCESTORS for this client
docker compose up -d --build

That's the whole deployment: the compose file runs the token server as an unprivileged container, publishes ${PORT:-8787}, applies restart: unless-stopped, and healthchecks the server. Configuration comes from a read-only mount of .env — secrets are never baked into the image (.dockerignore enforces that). Real environment variables still override .env, and PORT in .env changes both the container and published port.

Widget tweaks under client/src need a rebuild: docker compose up -d --build (instant — no dependencies to install).

By default the widget port is published on loopback only — handy for local testing and for keeping TLS the single public entry point.

Production TLS — built-in Caddy profile

The compose file ships an optional Caddy service (compose profile tls) that terminates TLS with automatic ACME certificates:

cp Caddyfile.example Caddyfile   # set the widget hostname
docker compose --profile tls up -d --build
# Caddyfile.example — set your widget hostname; certs are issued and renewed
# automatically (DNS must point at this host, ports 80/443 reachable).
audio.<client>.com {
	reverse_proxy squawk-audio:8787
}

Caddy proxies to the widget service over the internal compose network, so the only publicly published ports are 80/443.

Option B — bare systemd + node

# /etc/systemd/system/squawk-audio.service
[Unit]
Description=Squawk Audio widget + token server
After=network-online.target

[Service]
User=www-data
WorkingDirectory=/opt/squawk_audio_widget
ExecStart=/usr/bin/bun run server/src/token-server.ts
Restart=always

[Install]
WantedBy=multi-user.target

Option C — your own static host + backend

Serve client/dist with any static server and implement /token (or ?tokenUrl= any other path) in your existing backend using the getToken() logic from server/src/token-server.ts.


4. Embedding on the client site

Two supported patterns. Examples use client-example.com as the client site and audio.virtualfunction.net as the widget host; substitute per client.

Pattern 1 — iframe (no changes to the client's app code)

The widget page sends Content-Security-Policy: frame-ancestors … from SQUAWK_FRAME_ANCESTORS (see .env) — set it to the client's site origins — so they may embed it:

<iframe
  src="https://audio.virtualfunction.net/?channel=asset:forex"
  style="border:0; width:420px; height:190px"
  title="Live squawk audio"
  allow="autoplay"></iframe>

The client's own CSP must allow it:

frame-src https://audio.virtualfunction.net;

The iframe is cross-origin, so all browser calls to the audio API happen from the widget host — that origin is what needs CORS (section 5).

Pattern 2 — module embed (inside the client's own Vite/TS app)

Copy client/src/widget.ts (+ widget.css) into the client's codebase and mount it from their app:

import { SquawkWidget } from './widget';
import './widget.css';

SquawkWidget.mount('#squawk-audio', {
  token_url: '/api/audio/token', // their backend's token endpoint
});

The widget is a dependency-free typed module, so this works in any bundler. Everything is same-origin here; the client's CSP only needs

connect-src 'self' wss://audio-v2.newsquawk.com;

(and no changes at all if connect-src is not restricted).


5. CORS on the audio API

Browser fetches to audio-v2.newsquawk.com (the channels list) require an Access-Control-Allow-Origin match. WebSocket connections are not CORS-gated. Add every origin the widget will be served/embedded from to SQUAWK_ALLOWED_ORIGINS in audio-v2:~/audio/conf/env_file — it takes effect at restart, so batch new clients together.

Current staged value:

SQUAWK_ALLOWED_ORIGINS='https://audio-v2.newsquawk.com,*.newsquawk.com,http://localhost:3000,http://localhost:3001,https://example.com,https://www.example.com,http://localhost:8787'

Restart in a quiet window (drops live listeners and any in-progress broadcast; clients reconnect automatically):

ssh system@audio-v2.newsquawk.com 'cd ~/audio && docker compose restart audio-server'

Verify afterwards:

curl -s -D - -o /dev/null -H 'Origin: https://example.com' \
  https://audio-v2.newsquawk.com/api/settings/channels | grep -i access-control

6. CSP cheat sheet

Site Directive Value
Widget host frame-ancestors 'self' + the client's site origins — via SQUAWK_FRAME_ANCESTORS in .env
Widget host connect-src 'self' wss://audio-v2.newsquawk.com
Widget host script-src / style-src 'self' — the widget needs no inline script/style
Client site (iframe pattern) frame-src the widget host origin
Client site (script pattern) connect-src wss://audio-v2.newsquawk.com (plus 'self' for the token endpoint)

7. Troubleshooting

Symptom Cause Fix
WS closes with code 4003 token missing/invalid/expired or no listen role widget auto-refetches the token and reconnects; if it persists, check the Keycloak role assignment
Token fetch failed: HTTP 401 X-Widget-Key mismatch align SQUAWK_SHARED_KEY and the page's fetch header
Channels dropdown falls back to defaults CORS not active for this origin yet (restart pending) or API down section 5; playback still works
"Press play to start audio" browser blocked autoplay (no user gesture yet) expected — the first ▶ click unlocks audio
Audio plays but late/skips network jitter or long backgrounding handled internally (fast-drain + reset seeks); if persistent, raise maxBufferMs
Silence, LIVE never lights nobody is broadcasting on that channel subscribe to a busier channel; check https://audio-v2.newsquawk.com/api/health
Iframe refuses to load frame-ancestors missing the client origin update SQUAWK_FRAME_ANCESTORS and restart the token server

8. Operational notes

  • Token lifetime is 24 h (realm default). server/src/token-server.ts caches and re-mints 2 min before expiry; the widget re-fetches a token on every reconnect, so server restarts/deployments never strand a listener.
  • Secret rotation: set a new secret on the Keycloak client (PUT /auth/admin/realms/Newsquawk/clients/{id} with {"secret": "..."}), update .env, restart the token server. Old tokens stay valid until their 24 h expiry.
  • Kill switch: disabling the client's Keycloak client stops all new tokens within a minute (after the cached one lapses).
  • Rate limits on the audio API apply per client IP (auth endpoints 60/min) — the widget's design (token cached server-side, one WS connection per listener) stays far below them.