PlayMesh/Docs
HomeGitHubnpm

Auth & Admission

How PlayMesh handles authentication and decides which instances a session joins.

Authentication

PlayMesh does not implement authentication. It provides a hook where your application authenticates the incoming connection and returns a user ID. If the hook throws, the connection is rejected.
ℹ
Without an onAuthenticate hook, sessions are anonymous - the socket ID is used as userId.

The hook

mesh.onAuthenticate(async request => {
  // request.token    - shortcut for request.auth.token
  // request.auth     - full auth payload from the client
  // request.headers  - HTTP handshake headers
  // request.address  - client IP

  const user = await authService.verifyToken(request.token);

  return {
    userId: user.id,           // required
    roles: user.roles,         // optional - e.g. ['admin'], see Roles below
    data: { plan: user.plan }, // optional - available on session.data
  };
});

JWT example

import jwt from 'jsonwebtoken';

mesh.onAuthenticate(async request => {
  const token = request.token;
  if (!token) throw new Error('Token required');

  const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; role: string };
  return { userId: payload.sub, data: { role: payload.role } };
});

Rejecting with an error code

Throw a PlayMeshError to reject the connection with a machine-readable code the client can branch on - no string matching. A plain Error reaches the client with code AUTH_FAILED and its message; a second login under uniqueUser: 'reject' fails with USER_CONNECTED.
import { PlayMeshError } from '@playmesh/server';

mesh.onAuthenticate(async ({ auth }) => {
  if (auth.version !== GAME_VERSION) throw new PlayMeshError('VERSION_MISMATCH', 'Update the game');
  if (!auth.fromApp) throw new PlayMeshError('APP_ONLY', 'Play from the app');
  return { userId: await verify(auth.token) };
});

// Client
try {
  await client.connect();
} catch (error) {
  if (error.code === 'VERSION_MISMATCH') showUpdatePrompt();
  if (error.code === 'APP_ONLY') showDownloadApp();
}

Sending auth from the client

// Static token
const client = new PlayMeshClient({
  url: 'https://game.example.com',
  auth: { token: 'eyJhbGci...' },
});

// Dynamic - refreshed on each reconnect
const client = new PlayMeshClient({
  url: 'https://game.example.com',
  auth: async () => ({ token: await authService.getToken() }),
});

Admission

After authentication, the admission hook decides which instances the new session joins. The framework then automatically joins the session before the client connection resolves.
mesh.onAdmission(async request => {
  const player = await db.players.findById(request.userId);

  return {
    instances: [
      'world/city-center',
      `guild/${player.guildId}`,
      'social/global-chat',
    ],
  };
});
✦
References can be "domainId/instanceId" paths or bare instance IDs (unique across all domains).

Roles

The authentication hook can grant roles to a session (roles: ['admin']). Inside handlers, session.roles and session.hasRole(role) are available for custom checks, and the requireRole middleware protects events by role - events without a rule stay open to everyone:
import { requireRole } from '@playmesh/server';

mesh.use(
  requireRole({
    'kick-user': ['admin'],
    'mute-user': ['admin', 'moderator'],
  })
);

Role-protected domains and instances

Domains and instances can require roles to join. Every join path enforces it - client join requests, admission, and server-side session.join(). Instance roles apply on top of the domain's.
const moderation = mesh.createDomain('moderation', { roles: ['admin', 'moderator'] });
const vip = world.createInstance('vip-lounge', { roles: ['vip'] });

Changing roles live

Roles can change without reconnecting. session.setRoles() works on a session on this node; mesh.setUserRoles() updates every session of a user on any node. The new roles apply to the next event (requireRole, role-protected instances), the client is told via onRolesChange and an updated client.session.roles, and the session automatically leaves any instance its new roles no longer allow.
// Server
await session.setRoles(['player', 'admin']);             // this node
await mesh.setUserRoles(userId, ['player', 'admin']);    // any node

// Client
client.session.roles;                                    // ['player', 'admin']
client.onRolesChange(roles => showAdminPanel(roles.includes('admin')));

One session per user

By default a user can hold multiple concurrent sessions (several devices or tabs). The uniqueUser option enforces one live session per userId:
new PlayMesh({ uniqueUser: 'replace' }); // new login kicks the existing sessions
new PlayMesh({ uniqueUser: 'reject' });  // new login is refused while a session is live
Both policies are cluster-safe: they are backed by atomic per-user Redis claims (TTL-bound, renewed by presence heartbeats, released with owner validation). Two simultaneous logins are deterministic - reject admits exactly one across all nodes, and replacekeeps the newest accepted connection and kicks exactly the previous one. A crashed node's claim expires on its own, so users are never permanently blocked. The kick reason is Signed in from another connection with code REPLACED:
client.onKick((reason, code) => {
  if (code === 'REPLACED') showMessage('You signed in on another device');
});

Client join requests

Clients can ask to join an instance with client.join(path). These requests are denied by default - the server stays authoritative. Opt an instance in with a veto hook:
vipLounge.onJoinRequest(session => session.data.level >= 10);
Return true to admit; return falseor throw to veto (a thrown error's message is sent to the client; the code is JOIN_DENIED, or yours when you throw a PlayMeshError). Server-side session.join(...) and admission are unaffected. client.leave(path) is always honored.

Manual assignment

mesh.onSessionCreate(async session => {
  const player = await db.players.findById(session.userId);
  const zone = mesh.domain('world').instance(player.currentZone);
  await session.join(zone);

  if (player.guildId) {
    const guild = mesh.domain('guild').instance(player.guildId);
    await session.join(guild);
  }
});

Connection flow

1. Client connects with auth payload
2. onAuthenticate hook - verify token, return userId
3. Session created
4. onSessionCreate hooks run
5. onConnect hooks run
6. onAdmission hook - return instance list
7. Session joins each instance (join hooks fire)
8. playmesh:session event sent to client
9. client.connect() resolves with SessionInfo

Message signing

Opt-in integrity for the wire. When both sides enable signing, every application event in both directions and every data-bearing protocol message (scoped events, requests and responses, chat, state changes and snapshots, join snapshots, presence) is signed with ECDSA P-256 / SHA-256 (WebCrypto, no extra dependencies). Routing information - instance paths, event names, request ids - rides inside the signed payload, so it cannot be altered without breaking the signature. It must be enabled on both sides - a signing server rejects unsigned clients and vice versa, with no silent downgrade to unsigned mode.
Three protocol messages stay deliberately unsigned: playmesh:session (it bootstraps the key exchange itself - its integrity rests on TLS), playmesh:error (must be deliverable when signing is what failed), and playmesh:kicked (immediately followed by a forced disconnect).
// Server
const mesh = new PlayMesh({ signing: true });

// Client - generates an ephemeral, non-extractable keypair per connect()
const client = new PlayMeshClient({
  url: 'https://game.example.com',
  signing: true,
});

Replay protection

Every signed envelope is bound to a random session nonce the server generates per connection (a reconnect gets a new one), the direction and event name, and a strictly increasing sequence number. Together these prevent replay within a session, across reconnects, and against other server nodes. Messages that fail verification are dropped - the wire error is always a generic Signature verification failed, sessions accumulating failures are disconnected, and on the client failures are reported via onError.

Payload restrictions

Signed payloads must be JSON-compatible (objects, arrays, strings, booleans, null, finite numbers). Date values arrive as ISO strings. Binary values, BigInt, functions, symbols, non-finite numbers and circular structures make emit() throw instead of silently corrupting the message.
⚠
Signing does not replace TLS - keys and nonces are exchanged over the transport at connect time, so production must use https/wss. And it does not prevent cheating: a malicious client signs its own payloads with a valid key, so the server must still validate and authorize everything.
ℹ
In multi-node deployments the server's signing keypair is shared between nodes through Redis (stored unencrypted at <namespace>:signing:keypair, default playmesh:signing:keypair), so treat Redis as part of the trust boundary: require auth, keep it on a private network, and never log or dump that key. There is no automatic key rotation - to rotate, delete the key and restart the cluster.

Error handling

try {
  await client.connect();
} catch (error) {
  // error.code: AUTH_FAILED, USER_CONNECTED, VERSION_MISMATCH, or your own code
  console.error('Auth failed:', error.code, error.message);
}

client.onError(error => {
  if (error.scope === 'connection') showAuthError(error.code, error.message);
});