# One session per user

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](/docs/guides/credentials/#tokens) 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.

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

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

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](#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:

```sh
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:

| Setting | What it does | A value for this shape |
|---|---|---|
| `idleTimeout`, the idle limit | ends the session when no client has been joined for this long | `30m` |
| `maxSessionTime`, the maximum session time | ends the session at this age, whatever is happening | `4h` |

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:

```sh
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:

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

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

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](/docs/guides/limits/#other-session-settings) has every setting and its ceiling.

## Next steps

- [Credentials](/docs/guides/credentials/)
- [Limits](/docs/guides/limits/)
- [Session lifecycle](/docs/guides/lifecycle/)
