Bitskiff

One session per user

On this page

Let each of your users run one session at a time, with a way to end the one that is stuck.

Your users never sign in to Bitskiff, so ending a stuck session happens in your app or from the CLI, by the subject: your own id for the user. This page builds both.

The token

Your backend mints one token per signed-in user, with three permissions:

JSON
{
  "subject": "user_8123",
  "permissions": ["sessions:open", "sessions:read", "sessions:manage"],
  "ttl": "10m"
}
  • sessions:open opens a session.
  • sessions:read lists the sessions this user opened, and no one else's.
  • sessions:manage closes one of them.

It mints on the backend's secret key, which holds tokens:mint and these three permissions too: a token is no wider than the key that mints it.

Mint the token when the page is about to open, and keep it short. The page uses the token to open a session and, if one is stuck, to list and close it. A session runs on its own host token once it is open. 10m is the default, and 1h is the ceiling unless your project raised it.

limits.concurrentSessions defaults to 1, so the token holds one session at a time. You do not need to send it. Credentials has the whole mint call.

The refusal

A second open while the first session is still live is refused. The error is concurrency_limit, and details.scope is subject: this user is at their own limit, not your project.

The refusal names the session in the way. details.sessionIds holds its id, and a subject with a higher limit sees several, comma-separated, oldest first, up to 25. A subject with more live sessions than that lists the rest with GET /v1/sessions. It is there only when the token holds sessions:read, because it tells the page nothing that GET /v1/sessions would not.

End a stuck session in your app

When the open is refused, list the user's sessions, show them, close the one the user picks, and open again.

List and close are REST calls: pass your endpoint (bitskiff whoami) to the page with the token.

import { BitskiffHost, isBitskiffError, type Session } from '@bitskiff/host';

interface Listed {
  sessionId: string;
  label?: string;                       // left out when the session was opened without one
  openedAt: string;                     // Unix ms; a 64-bit number arrives as a string
}

// `pickOne` is your own UI: it shows each session's label and start time, and resolves to the
// session the user chose, or undefined when they cancel.
export async function openForUser(
  token: string,
  endpoint: string,
  pickOne: (sessions: Listed[]) => Promise<Listed | undefined>,
): Promise<Session | undefined> {
  const bitskiff = new BitskiffHost({ key: token });
  try {
    return await bitskiff.open({ label: 'Work laptop' });
  } catch (err) {
    if (!isBitskiffError(err) || err.code !== 'concurrency_limit') throw err;
    if (err.details?.['scope'] !== 'subject') throw err;
    // The ids arrive only when the token holds sessions:read.
    const ids = err.details['sessionIds'];
    if (!ids) throw err;
    const inTheWay = ids.split(',');
    const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };

    // A token reads its own subject's sessions and no one else's.
    const listed = await fetch(`${endpoint}/v1/sessions`, { headers });
    if (!listed.ok) throw new Error(`list refused: ${String(listed.status)}`);
    const { sessions } = (await listed.json()) as { sessions: Listed[] };
    const chosen = await pickOne(sessions.filter((s) => inTheWay.includes(s.sessionId)));
    if (chosen === undefined) return undefined;

    const closed = await fetch(`${endpoint}/v1/sessions/${chosen.sessionId}/close`, {
      method: 'POST',
      headers,
      body: '{}',
    });
    if (!closed.ok) throw new Error(`close refused: ${String(closed.status)}`);
    return bitskiff.open({ label: 'Work laptop' });
  }
}
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

// `pickOne` is your own UI: it shows each session's label and start time, and resolves to the
// session the user chose, or undefined when they cancel.
export async function openForUser(token, endpoint, pickOne) {
  const bitskiff = new BitskiffHost({ key: token });
  try {
    return await bitskiff.open({ label: 'Work laptop' });
  } catch (err) {
    if (!isBitskiffError(err) || err.code !== 'concurrency_limit') throw err;
    if (err.details?.scope !== 'subject') throw err;
    // The ids arrive only when the token holds sessions:read.
    if (!err.details.sessionIds) throw err;
    const inTheWay = err.details.sessionIds.split(',');
    const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };

    // A token reads its own subject's sessions and no one else's.
    const listed = await fetch(`${endpoint}/v1/sessions`, { headers });
    if (!listed.ok) throw new Error(`list refused: ${String(listed.status)}`);
    const { sessions } = await listed.json();
    const chosen = await pickOne(sessions.filter((s) => inTheWay.includes(s.sessionId)));
    if (chosen === undefined) return undefined;

    const closed = await fetch(`${endpoint}/v1/sessions/${chosen.sessionId}/close`, {
      method: 'POST',
      headers,
      body: '{}',
    });
    if (!closed.ok) throw new Error(`close refused: ${String(closed.status)}`);
    return bitskiff.open({ label: 'Work laptop' });
  }
}

A label is how the user tells two sessions apart, so open with one that names the device.

The session in the way may be one that somebody opened with a copy of this user's token. It lists like any other, with a label your app did not write, and the close ends it. So render each label as text, never as HTML, and show openedAt beside it. A stolen token is the rest.

End a stuck session from the CLI

Your support team can do the same from the command line, with the user's subject:

Shell
bitskiff sessions list --subject user_8123
bitskiff sessions close --subject user_8123

close --subject ends every live session that user opened. Each client gets close code 4000, and each host sees the session end.

A stolen token

A token read off a page or a log works as that user until it expires. A copy holds every permission the token holds, so it can open sessions, list them, and close them, the real person's too. It reaches nothing outside the subject, and its ttl is what bounds it. When you know it is out:

  1. Delete it. Find its id with GET /v1/tokens?subject=user_8123, or use the tokenId you kept at mint, then call DELETE /v1/tokens/{tokenId} from your backend.
  2. Close the user's sessions: bitskiff sessions close --subject user_8123, or the same list and close from your backend with a secret key that holds sessions:read and sessions:manage.

Deleting a token frees nothing by itself. A session it opened keeps running on its own host token. After step 1 the copy opens nothing: an open with it is refused invalid_token, with details.reason of revoked. After step 2 what it opened is gone. Mint the user a new token.

Bounds on a stuck session

A session whose host goes away ends when the host grace (hostResumeWindow) runs out, 10s by default, or 60 s after open if it never attached. A session whose host is still running, on a device the user left behind, holds its place until something ends it. Two settings bound that:

SettingWhat it doesA value for this shape
idleTimeout, the idle limitends the session when no client has been joined for this long30m
maxSessionTime, the maximum session timeends the session at this age, whatever is happening4h

The idle limit counts only while no client is joined. A device left behind with a client still joined never goes idle, so maxSessionTime is the bound for that case. Both default to 0s, no limit. Set them once, on the project, and every open that does not name them takes them:

Shell
bitskiff settings set idleTimeout 30m
bitskiff settings set maxSessionTime 4h

The console's Settings panel shows and sets the same session defaults. A value above the project's ceiling is held to the ceiling, and the answer says so.

A value set at open still wins, for the one device that needs a different bound:

import { BitskiffHost, type Session } from '@bitskiff/host';

export function openLobbyDisplay(token: string): Promise<Session> {
  const bitskiff = new BitskiffHost({ key: token });
  return bitskiff.open({ label: 'Lobby display', idleTimeout: '2h', maxSessionTime: '12h' });
}
import { BitskiffHost } from '@bitskiff/host';

export function openLobbyDisplay(token) {
  const bitskiff = new BitskiffHost({ key: token });
  return bitskiff.open({ label: 'Lobby display', idleTimeout: '2h', maxSessionTime: '12h' });
}

A long host grace works against you here. If you raise hostResumeWindow, the host grace, so a reload keeps its session, a device that never comes back holds the user's place for that long. Limits has every setting and its ceiling.

Next steps