Protocol 2.0
The next version of Pikoman is ready in code and being tested. It changes what goes over the socket and how joins are signed. Protocol 1.x, which the rest of these docs describe, is deprecated.
2.0 runs now, next to 1.x. Apps you create in the dashboard get
2.0 keys and use wss://next.pikoman.se for websockets (the client's
ws_address option) and https://next-api.pikoman.se for the HTTP API. Existing 1.x
apps keep working on ws.pikoman.se and api.pikoman.se until they move; we contact
every app owner. When every app has moved, ws. and api.pikoman.se switch to 2.0 and
the next names go away.
What changes
-
JSON on the socket. Every frame is a JSON object with an
opfield, in both directions. Payloads and member data are real JSON, not strings encoded twice. - New keys for every app. An API key for HTTP calls, and a signing key for presence and private joins. Our servers keep only a hash of the API key and the public half of the signing key, so nothing stored on our side can be used to act as your app.
- Join tokens are standard JWTs. Presence and private joins are signed with EdDSA (Ed25519) instead of HMAC. A token is tied to one connection and one channel, and is valid for at most ten minutes.
- Private channels. Channels starting with
private-need a token, like presence, but have no member list. - Random connection ids, such as
c_8f3a1c9e2b7d4a6051e0c3b9, instead of 1, 2, 3. @pikoman/ws1.0.0, which speaks the new protocol. Its API stays almost the same.
In your frontend
Upgrade the client when the switch happens. Most code keeps working as it is:
npm install @pikoman/ws@1listen,listenPresence,on,off,send,membersandgetMembersare unchanged.- New:
listenPrivate(name)for private channels, andrefused(fn)when a join is turned down. - Member data arrive as objects. If you call
JSON.parseon them today, remove it. - You can send objects with
senddirectly; there is no need toJSON.stringifythem first. clientMessage(fn)gets(clientId, event, data), andserverMessage(fn)gets(data, event).
Tabs still running the old client when the server switches are closed with code 4400
("protocol outdated"). They connect again after a reload.
In your auth endpoint
The request is the same, GET {auth_url}/{clientId}-{channelName}. The answer is a token instead of
a join string:
{ "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJjb25uZWN0aW9uIjo….3d9e…" }
The token is a standard JWT signed with EdDSA (Ed25519): a header, a payload and a signature,
each base64url and joined by dots. Any JWT library that supports EdDSA can make one. The server only accepts
"alg": "EdDSA", whatever else the header says.
{
"connection": "c_8f3a1c9e2b7d4a6051e0c3b9",
"channel": "presence-room",
"access": 3,
"data": { "name": "Ada" },
"exp": 1761210300
}connectionandchannelmust match the join, so a token is useless anywhere else.accessis the same permission number as today:3for a full member.datais the member data that everyone in a presence channel sees.expis required, and at most ten minutes ahead. Other claims, such asiat, are ignored.
Inside a token
Change the token and see what it says, and whether the signature still holds. Change the payload and it is signed again. The key pair is made in your browser for this page only; nothing is sent anywhere.
Signing on your backend
Node has Ed25519 built in, so no library is needed:
import { createPrivateKey, sign } from "node:crypto";
const signingKey = createPrivateKey(process.env.PIKOMAN_SIGNING_KEY); // PEM
const part = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
export function pikomanToken({ clientId, channel, access = 3, data }) {
const signed = part({ alg: "EdDSA", typ: "JWT" }) + "." + part({
connection: clientId,
channel,
access,
data,
exp: Math.floor(Date.now() / 1000) + 300,
});
const signature = sign(null, Buffer.from(signed), signingKey).toString("base64url");
return `${signed}.${signature}`;
}
// res.json({ token: pikomanToken({ clientId, channel, data: { name: user.name } }) });Or with a JWT library, such as jose:
import { SignJWT, importPKCS8 } from "jose";
const signingKey = await importPKCS8(process.env.PIKOMAN_SIGNING_KEY, "EdDSA");
const token = await new SignJWT({ connection: clientId, channel, access: 3, data: { name: user.name } })
.setProtectedHeader({ alg: "EdDSA" })
.setExpirationTime("5m")
.sign(signingKey);PHP has Ed25519 built in through sodium:
function part(string $s): string {
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}
// The signing key is a PEM file; its last 32 bytes are the Ed25519 seed.
$der = base64_decode(preg_replace('/-----[^-]+-----|\s+/', '', getenv('PIKOMAN_SIGNING_KEY')));
$secretKey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair(substr($der, -32)));
$signed = part(json_encode(['alg' => 'EdDSA', 'typ' => 'JWT'])) . '.' . part(json_encode([
'connection' => $clientId,
'channel' => $channel,
'access' => 3,
'data' => ['name' => $name],
'exp' => time() + 300,
]));
$token = $signed . '.' . part(sodium_crypto_sign_detached($signed, $secretKey));
echo json_encode(['token' => $token]);
The signing key belongs on your server, next to your other secrets. Never put it in frontend code or in a
VITE_ or similar variable that ends up in the bundle.
Publishing over HTTP
Nothing changes in the calls: the same paths and basic auth, with the new API key as the password. JSON bodies are passed on as JSON.
curl -X POST https://next-api.pikoman.se/orders/shipped \
-u your-app-id:your-api-key \
-d '{"id": 1042}'- New:
GET /{channel}/clientslists the members of a channel with their data. DELETE /kick-allalways applies to the app you authenticate as.- A request body may be at most 256 KB, and one
/mass_eventscall reaches at most 1,000 deliveries.
On the wire
Only needed if you write your own client. A presence join, then a client message:
{"op":"join","channel":"presence-room","token":"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJjb25uZWN0aW9uIjo….3d9e…"}
{"op":"members","channel":"presence-room","members":{"c_8f3a…":{"name":"Ada"}}}
{"op":"send","channel":"presence-room","event":"typing","data":{"on":true}}| 1.x | 2.0 op |
|---|---|
0:{id} | connected |
1:{channel}:{event}:{data} | event |
2:{channel} | join / joined |
2:presence-…:{signature}:… | join with token |
3:{channel} | error, code join_refused |
4:{channel} | leave / left / member_left |
8:… | send / client_event |
9:RATE_LIMIT | error, code rate_limited |
16:{channel}:{members} | members |
64:{time} | ping / pong |
To do now
- Find where your backend signs presence joins and where it publishes, so you know what to change.
- Check that no secret is in your frontend bundle.
- if something in your setup does not fit what is described here.