Crypto Ticker
Build a streaming ticker demo with API-key authentication and WebSocket delivery. The example covers token-based security, middleware configuration, and process-based connection handling.
Classification: Runnable local tutorial. It includes the registry, Lua sources, browser client, ordered startup commands, and browser verification. Its permissive policies and in-memory token store are deliberately limited to a loopback demo.
Overview
- API-key exchange — Submit an API key and receive an HMAC-signed bearer token
- Token middleware — Validate a bearer token and restore its security context; the endpoint rejects requests without an actor
- WebSocket fan-out — Broadcast from one ticker process to multiple connection handlers
- Static assets — Serve the browser client with
http.static - Storage — Keep API keys in SQLite and token data in memory
Prerequisites
-
Wippy runtime
v0.3.32a. -
A browser with WebSocket support.
-
An empty working directory. Create the project directories before adding the files below:
mkdir auth-ticker cd auth-ticker mkdir -p src/public dataIn PowerShell:
New-Item -ItemType Directory -Path auth-ticker\src\public -Force New-Item -ItemType Directory -Path auth-ticker\data -Force Set-Location auth-ticker
Project Structure
auth-ticker/
├── data/
├── wippy.lock
└── src/
├── _index.yaml
├── auth_token.lua
├── ws_ticker.lua
├── ws_handler.lua
├── ticker.lua
├── migrate.lua
└── public/
└── index.html
Architecture
flowchart TB
subgraph Clients
Browser[Browser Client]
API[API Client]
end
subgraph "HTTP Layer"
Server[http.service
gateway :8081]
Static[http.static
public/]
subgraph "Public Router"
CORS1[cors middleware]
AuthEndpoint[auth_token
POST /auth/token]
end
subgraph "WS Router /ws"
CORS2[cors middleware]
TokenAuth[token_auth middleware]
WSEndpoint[ws_ticker
GET /ws/ticker]
WSRelay[websocket_relay]
end
end
subgraph "Security Layer"
TokenStore[security.token_store
tokens]
Policy[security.policy
user_policy]
SysPolicy[security.policy
system_policy]
MemStore[store.memory
token_data]
end
subgraph "Storage"
DB[db.sql.sqlite
auth.db]
end
subgraph "Process Layer"
Supervisor[process.host
processes]
WSHandler[ws_handler
per-connection]
Ticker[ticker
singleton]
end
%% Client connections
Browser -->|"GET /"| Static
API -->|"POST /auth/token"| CORS1
Browser -->|"WS /ws/ticker"| CORS2
%% API flow
CORS1 --> AuthEndpoint
AuthEndpoint -->|validate| TokenStore
AuthEndpoint -->|"issue token"| API
%% WS flow
CORS2 --> TokenAuth
TokenAuth -->|validate| TokenStore
TokenAuth --> WSEndpoint
WSEndpoint -->|spawn| Supervisor
Supervisor --> WSHandler
WSEndpoint --> WSRelay
WSRelay <-->|"messages"| WSHandler
%% Token store deps
MemStore --> TokenStore
Policy -->|attached to token| TokenStore
SysPolicy -->|"actor + scope"| AuthEndpoint
SysPolicy -->|"actor + scope"| Ticker
%% Auth uses DB for API keys
AuthEndpoint -->|lookup API key| DB
%% Process communication
WSHandler -->|subscribe| Ticker
Ticker -->|broadcast| WSHandler
WSRelay <-->|"ws frames"| Browser
Security Flow
-
API-key exchange: The client posts an API key to
/auth/token. The handler validates it against the database, creates an actor withuser_policy, and issues an HMAC-signed token. -
Token authentication: WebSocket connections pass through
token_auth, which validates the bearer token and restores its actor and policies. -
Process spawning: The WebSocket endpoint spawns a handler process. The token's
user_policyauthorizes the spawn. -
Message routing: The
websocket_relaymiddleware routes WebSocket frames to the handler process as messages.
Configuration
Create src/_index.yaml:
version: "1.0"
namespace: app
entries:
# Database for API keys
- name: db
kind: db.sql.sqlite
file: "./data/auth.db"
lifecycle:
auto_start: true
# Token backing store
- name: token_data
kind: store.memory
lifecycle:
auto_start: true
# Token store with HMAC signing
- name: tokens
kind: security.token_store
store: app:token_data
token_length: 32
default_expiration: "1h"
token_key: "local-demo-signing-key-do-not-deploy"
# Security policy for authenticated users
- name: user_policy
kind: security.policy
policy:
actions: "*"
resources: "*"
effect: allow
groups:
- user
# Policy for internal code that runs without an end-user actor
- name: system_policy
kind: security.policy
policy:
actions:
- db.get
- security.actor.create
- security.policy.get
- security.scope.create
- security.token_store.get
- security.token.create
- process.registry.register
- process.send
- process.monitor
resources: "*"
effect: allow
# Process host
- name: processes
kind: process.host
lifecycle:
auto_start: true
# Terminal host used by `wippy run -x app:migrate`
- name: terminal
kind: terminal.host
lifecycle:
auto_start: true
# Database migration
- name: migrate
kind: process.lua
source: file://migrate.lua
method: main
modules: [sql, logger, crypto]
security:
actor:
id: "service:migrate"
policies:
- app:system_policy
- name: migrate-service
kind: process.service
process: app:migrate
host: app:processes
lifecycle:
auto_start: true
# Ticker broadcaster
- name: ticker
kind: process.lua
source: file://ticker.lua
method: main
modules: [logger, time, crypto]
security:
actor:
id: "service:ticker"
policies:
- app:system_policy
- name: ticker-service
kind: process.service
process: app:ticker
host: app:processes
lifecycle:
auto_start: true
# WebSocket handler (spawned per connection)
- name: ws_handler
kind: process.lua
source: file://ws_handler.lua
method: main
modules: [logger, json]
# HTTP server
- name: gateway
kind: http.service
addr: "127.0.0.1:8081"
lifecycle:
auto_start: true
requires:
- app:ticker-service
# Public router (no auth)
- name: public_router
kind: http.router
meta:
server: app:gateway
middleware:
- cors
options:
cors.allow.origins: "http://127.0.0.1:8081"
# WebSocket router (with auth)
- name: ws_router
kind: http.router
meta:
server: app:gateway
prefix: /ws
middleware:
- cors
- token_auth
options:
cors.allow.origins: "http://127.0.0.1:8081"
token_auth.store: "app:tokens"
post_middleware:
- websocket_relay
post_options:
wsrelay.allowed.origins: "http://127.0.0.1:8081"
# Static files
- name: public_fs
kind: fs.directory
directory: ./src/public
- name: static
kind: http.static
meta:
server: app:gateway
path: /
fs: app:public_fs
static_options:
spa: true
index: index.html
# Auth token exchange
- name: auth_token
kind: function.lua
source: file://auth_token.lua
method: handler
modules: [http, sql, crypto, security, json]
security:
actor:
id: "service:auth"
policies:
- app:system_policy
- name: auth_token.endpoint
kind: http.endpoint
meta:
router: app:public_router
method: POST
path: /auth/token
func: app:auth_token
# WebSocket ticker endpoint
- name: ws_ticker
kind: function.lua
source: file://ws_ticker.lua
method: handler
modules: [http, json, security, logger]
- name: ws_ticker.endpoint
kind: http.endpoint
meta:
router: app:ws_router
method: GET
path: /ticker
func: app:ws_ticker
user_policy rides inside every issued token and covers what an authenticated connection does. system_policy covers the code that runs before any token exists — the migration, the ticker, and the token exchange itself — because a gated call made without an actor and a scope is denied.
For production, read the HMAC key from an environment variable with a placeholder (token_key: ${env:TOKEN_KEY}) instead of hardcoding it. See Environment System.
Token Exchange
auth_token.lua validates API keys and issues HMAC-signed tokens:
local http = require("http")
local sql = require("sql")
local security = require("security")
local function handler()
local req = http.request()
local res = http.response()
local body, parse_err = req:body_json()
if parse_err then
res:set_status(http.STATUS.BAD_REQUEST)
res:write_json({error = "invalid JSON"})
return
end
local api_key = body.api_key
if not api_key or #api_key == 0 then
res:set_status(http.STATUS.BAD_REQUEST)
res:write_json({error = "api_key required"})
return
end
local db, db_err = sql.get("app:db")
if db_err then
res:set_status(http.STATUS.INTERNAL_ERROR)
res:write_json({error = "database unavailable"})
return
end
local rows, query_err = db:query(
"SELECT user_id, role FROM api_keys WHERE api_key = ?",
{api_key}
)
db:release()
if query_err then
res:set_status(http.STATUS.INTERNAL_ERROR)
res:write_json({error = "lookup failed"})
return
end
if #rows == 0 then
res:set_status(http.STATUS.UNAUTHORIZED)
res:write_json({error = "invalid API key"})
return
end
local user = rows[1]
-- Create actor with user identity
local actor = security.new_actor("user:" .. user.user_id, {
role = user.role,
user_id = user.user_id
})
-- Attach user_policy to the scope
local policy, _ = security.policy("app:user_policy")
local scope = policy and security.new_scope({policy}) or security.new_scope()
-- Issue HMAC-signed token
local store, store_err = security.token_store("app:tokens")
if store_err then
res:set_status(http.STATUS.INTERNAL_ERROR)
res:write_json({error = "token store unavailable"})
return
end
local token, token_err = store:create(actor, scope, {
expiration = "1h",
meta = {ip = req:remote_addr()}
})
store:close()
if token_err then
res:set_status(http.STATUS.INTERNAL_ERROR)
res:write_json({error = "token creation failed"})
return
end
res:write_json({
token = token,
user_id = user.user_id,
role = user.role,
expires_in = 3600
})
end
return { handler = handler }
WebSocket Endpoint
ws_ticker.lua spawns a handler process for each authenticated connection:
local http = require("http")
local json = require("json")
local security = require("security")
local logger = require("logger")
local function handler()
local req = http.request()
local res = http.response()
if req:method() ~= http.METHOD.GET then
res:set_status(http.STATUS.METHOD_NOT_ALLOWED)
res:write_json({error = "method not allowed"})
return
end
-- Actor is set by token_auth middleware
local actor = security.actor()
if not actor then
res:set_status(http.STATUS.UNAUTHORIZED)
res:write_json({error = "authentication required"})
return
end
local user_id = actor:id()
-- Spawn handler process (authorized by user_policy in token)
local pid, err = process.spawn("app:ws_handler", "app:processes", user_id)
if err then
logger:error("spawn failed", {error = tostring(err)})
res:set_status(http.STATUS.INTERNAL_ERROR)
res:write_json({error = "failed to create handler"})
return
end
-- Configure websocket_relay to route messages to handler
res:set_header("X-WS-Relay", json.encode({
target_pid = tostring(pid),
metadata = {user_id = user_id, auth_time = os.time()}
}))
end
return { handler = handler }
Connection Handler
The websocket_relay middleware automatically sends lifecycle messages to the handler process:
ws.join- Connection established, includesclient_pidfor sending responsesws.message- Client sent a message; the payload is the raw frame (a string for text frames)ws.leave- Connection closed (sent automatically on disconnect)
Messages sent the other way, to the client PID, reach the browser as a single JSON text frame shaped {topic, data}. The topic is yours to pick and the payload arrives as data.
ws_handler.lua - handles these lifecycle messages:
local logger = require("logger")
local json = require("json")
local function main(user_id)
local inbox = process.inbox()
local client_pid = nil
local subscribed = false
logger:info("handler started", {user_id = user_id})
while true do
local msg, ok = inbox:receive()
if not ok then break end
local topic = msg:topic()
local data = msg:payload():data()
if topic == "ws.join" then
client_pid = data.client_pid
-- Subscribe with our PID for crash monitoring
local _, subscribe_err = process.send("ticker", "subscribe", {
client_pid = client_pid,
handler_pid = process.pid()
})
if subscribe_err then
error("failed to subscribe to ticker: " .. tostring(subscribe_err))
end
subscribed = true
-- Send welcome
process.send(client_pid, "welcome", {user_id = user_id})
logger:info("client joined", {user_id = user_id, client_pid = client_pid})
elseif topic == "ws.message" then
local content = json.decode(data)
if content and content.type == "ping" then
process.send(client_pid, "pong", {})
end
elseif topic == "ws.leave" then
-- Relay sends this automatically on disconnect
logger:info("client left", {user_id = user_id, client_pid = data.client_pid})
if subscribed then
process.send("ticker", "unsubscribe", {handler_pid = process.pid()})
end
break
end
end
return 0
end
return { main = main }
Broadcasting
ticker.lua maintains subscriptions and broadcasts locally simulated price updates;
the tutorial does not call an external market-data service:
local logger = require("logger")
local time = require("time")
local crypto = require("crypto")
-- handler_pid -> client_pid mapping
local subscriptions = {}
local prices = {
["BTC-USD"] = 42000.00,
["ETH-USD"] = 2500.00,
["SOL-USD"] = 95.00
}
local function broadcast(updates)
for _, client_pid in pairs(subscriptions) do
process.send(client_pid, "ticker", updates)
end
end
local function update_prices()
for symbol, price in pairs(prices) do
local bytes, random_err = crypto.random.bytes(2)
if random_err then
error("failed to generate price movement: " .. tostring(random_err))
end
local rand = (bytes:byte(1) * 256 + bytes:byte(2)) / 65535.0
local factor = (rand - 0.5) * 0.002
prices[symbol] = price * (1 + factor)
prices[symbol] = tonumber(string.format("%.2f", prices[symbol]))
end
end
local function get_updates()
local updates = {}
for symbol, price in pairs(prices) do
table.insert(updates, {symbol = symbol, price = price, timestamp = os.time()})
end
return updates
end
local function main()
local inbox = process.inbox()
local events = process.events()
local ticker, ticker_err = time.ticker("1s")
if ticker_err then
logger:error("failed to create ticker", {error = tostring(ticker_err)})
error("failed to create ticker: " .. tostring(ticker_err))
end
local tick_ch = ticker:response()
local _, register_err = process.registry.register("ticker")
if register_err then
error("failed to register ticker: " .. tostring(register_err))
end
logger:info("ticker started", {pid = process.pid()})
while true do
local r = channel.select {
inbox:case_receive(),
events:case_receive(),
tick_ch:case_receive()
}
if r.channel == tick_ch then
update_prices()
if next(subscriptions) then
broadcast(get_updates())
end
elseif r.channel == events then
local event = r.value
if event.kind == process.event.CANCEL then
ticker:stop()
logger:info("ticker stopping")
return 0
elseif event.kind == process.event.EXIT then
-- Handler exited, remove subscription
if subscriptions[event.from] then
logger:info("handler exited", {handler_pid = event.from})
subscriptions[event.from] = nil
end
end
else
local msg = r.value
local topic = msg:topic()
local data = msg:payload():data()
if topic == "subscribe" then
local handler_pid = data.handler_pid
local client_pid = data.client_pid
subscriptions[handler_pid] = client_pid
process.monitor(handler_pid)
logger:info("subscribed", {handler_pid = handler_pid, client_pid = client_pid})
process.send(client_pid, "ticker", get_updates())
elseif topic == "unsubscribe" then
subscriptions[data.handler_pid] = nil
logger:info("unsubscribed", {handler_pid = data.handler_pid})
end
end
end
end
return { main = main }
Database Migration
migrate.lua creates the API-keys table and generates a demo key:
local sql = require("sql")
local logger = require("logger")
local crypto = require("crypto")
local function main()
local db, err = sql.get("app:db")
if err then
logger:error("failed to connect", {error = tostring(err)})
error("failed to connect: " .. tostring(err))
end
local _, exec_err = db:execute([[
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at INTEGER NOT NULL
)
]])
if exec_err then
db:release()
logger:error("migration failed", {error = tostring(exec_err)})
error("migration failed: " .. tostring(exec_err))
end
-- Create one random local-demo key. It is printed only on first creation.
local rows, query_err = db:query(
"SELECT api_key FROM api_keys WHERE user_id = ?",
{"demo"}
)
if query_err then
db:release()
error("failed to query demo API key: " .. tostring(query_err))
end
if #rows == 0 then
local demo_key, key_err = crypto.random.string(32)
if key_err then
db:release()
error("failed to generate demo API key: " .. tostring(key_err))
end
local _, insert_err = db:execute(
"INSERT INTO api_keys (api_key, user_id, role, created_at) VALUES (?, ?, ?, ?)",
{demo_key, "demo", "user", os.time()}
)
if insert_err then
db:release()
error("failed to store demo API key: " .. tostring(insert_err))
end
logger:info("demo API key created", {api_key = demo_key})
else
logger:info("demo API key already exists; use the value saved from its first creation")
end
db:release()
return 0
end
return { main = main }
Browser Client
public/index.html - exchanges the API key for a token, then streams prices. A browser cannot set headers on a WebSocket handshake, so the token travels in the x-auth-token query parameter that token_auth also reads:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Crypto Ticker</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #ddd; }
#status { color: #666; }
</style>
</head>
<body>
<h1>Crypto Ticker</h1>
<input id="key" placeholder="demo API key" size="40">
<button id="connect">Connect</button>
<p id="status">disconnected</p>
<table><thead><tr><th>Symbol</th><th>Price</th></tr></thead><tbody id="rows"></tbody></table>
<script>
const status = document.getElementById("status");
const rows = document.getElementById("rows");
document.getElementById("connect").onclick = async () => {
const res = await fetch("/auth/token", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({api_key: document.getElementById("key").value})
});
if (!res.ok) { status.textContent = "auth failed"; return; }
const {token} = await res.json();
const url = `ws://${location.host}/ws/ticker?x-auth-token=${encodeURIComponent(token)}`;
const ws = new WebSocket(url);
ws.onopen = () => {
status.textContent = "connected";
ws.send(JSON.stringify({type: "ping"}));
};
ws.onclose = () => { status.textContent = "disconnected"; };
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
if (msg.topic !== "ticker") return;
rows.innerHTML = "";
for (const quote of msg.data) {
rows.insertAdjacentHTML("beforeend",
`<tr><td>${quote.symbol}</td><td>${quote.price.toFixed(2)}</td></tr>`);
}
};
};
</script>
</body>
</html>
Running
Initialize the lock, run the migration to completion, then start the long-running services. Running the migration as a separate command prevents the token endpoint from racing the table creation.
mkdir -p data
wippy init
wippy run -x app:migrate
wippy run
Open http://127.0.0.1:8081 and enter the demo API key from the migration log. The
page should show Connected as demo, followed by BTC, ETH, and SOL prices that update
once per second.
You can also verify the exchange before opening the browser:
curl -X POST http://127.0.0.1:8081/auth/token \
-H "Content-Type: application/json" \
-d '{"api_key":"<demo-key-from-migration>"}'
In PowerShell:
Invoke-RestMethod -Method Post `
-Uri http://127.0.0.1:8081/auth/token `
-ContentType 'application/json' `
-Body '{"api_key":"<demo-key-from-migration>"}'
A successful response contains token, user_id: "demo", role: "user", and
expires_in: 3600. An invalid key returns HTTP 401.
Troubleshooting and Cleanup
no such table: api_keysmeans the migration command was skipped or failed. Stop the runtime and rerunwippy run -x app:migratebefore starting it again.- A 401 from
/auth/tokenmeans the API key does not match the row indata/auth.db. Reset the database if the one-time log value was lost. - A 401 or immediate close on the WebSocket usually means the query parameter was removed or the in-memory token store was reset by a runtime restart. Exchange the API key again after every restart.
- An origin rejection means the browser URL does not exactly match
http://127.0.0.1:8081; use that URL or update both origin options together. - Stop the runtime with Ctrl+C. Delete
data/auth.dbto remove the demo API key.
Next Steps
- WebSocket Relay — Middleware configuration
- Security Module — Actors, policies, and token stores
- Process Management — Process spawning and messaging