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 } };
});

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'] });

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

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). 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 application events. When both sides enable signing, every application event in both directions is signed with ECDSA P-256 / SHA-256 (WebCrypto, no extra dependencies). It must be enabled on both sides - a signing server rejects unsigned clients and vice versa, with no silent downgrade to unsigned mode.
// 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 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) {
  console.error('Auth failed:', error.message);
}

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