Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/ably-new-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ static flags = {
- `await this.requireAppId(flags)` — resolves and validates the app ID, returns `Promise<string>` (non-nullable). Calls `this.fail()` internally if no app found — no manual null check needed.
- `await this.runControlCommand(flags, api => api.method(appId))` — creates the Control API client, executes the call, and handles errors in one step. Returns `Promise<T>` (non-nullable). Useful for single API calls; for multi-step flows, use `this.createControlApi(flags)` directly.

**When to include `clientIdFlag`:** Add `...clientIdFlag` to commands where client identity affects the operation: subscribe, publish, enter, set, acquire, update, delete, append, annotate. The reason is that users may want to test auth scenarios — e.g., "can client B update client A's message?" — so they need the ability to set their client ID. Do NOT add to read-only queries (get, get-all, history, occupancy get) — Ably capabilities are operation-based, not clientId-based, so client identity is irrelevant for pure reads.
**When to include `clientIdFlag`:** Add `...clientIdFlag` to commands where client identity affects the operation: subscribe, publish, enter, set, acquire, update, delete, append, annotate. The reason is that users may want to test auth scenarios — e.g., "can client B update client A's message?" — so they need the ability to set their client ID. Do NOT add to read-only queries (get, get-all, history, occupancy get). Reads still carry identity — every command acts as the session's client ID (`ABLY_CLIENT_ID` > config > generated default) and Ably counts it under MAU billing — but a read has no reason to act as someone else, so the session identity is enough.

**Target client IDs are not identity:** the base command treats `--client-id` as the client ID the CLI acts as only when the command declares it via the shared `clientIdFlag` object. When a command's `--client-id` names a *target* or *filter* (a push recipient, a token's subject), declare a command-local `Flags.string()` instead — never spread `clientIdFlag` for that — so the value can't become the CLI's own identity. `test/unit/base/client-id-target-guard.test.ts` enforces this for every command.

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/ably-new-command/references/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ async run(): Promise<void> {

## Get Pattern

Get commands perform one-shot read-only queries for current state. They don't need `clientIdFlag` (Ably capabilities are operation-based, not clientId-based — client identity is irrelevant for reads), `durationFlag`, or `rewindFlag`.
Get commands perform one-shot read-only queries for current state. They don't need `clientIdFlag` (they act as the session's client ID, which is enough for a read), `durationFlag`, or `rewindFlag`.

```typescript
static override flags = {
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ This is the Ably CLI npm package (`@ably/cli`), built with the [oclif framework]
│ ├── integration/ # Multi-component, mocked external services
│ ├── e2e/ # Full scenarios against real Ably
│ └── helpers/ # runCommand(), MockConfigManager, etc.
├── docs/ # Project docs (Testing.md, Project-Structure.md, etc.)
├── docs/ # Project docs (Testing.md, Project-Structure.md, Client-Identity.md, etc.)
└── package.json # Scripts defined here
```

Expand Down Expand Up @@ -100,7 +100,7 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v
- **`coreGlobalFlags`** — `--verbose`, `--json`, `--pretty-json`, `--web-cli-help` (hidden) (on every command via `AblyBaseCommand.globalFlags`)
- **`productApiFlags`** — core + hidden product API flags (`port`, `tlsPort`, `tls`). Use for commands that talk to the Ably product API.
- **`controlApiFlags`** — core + hidden control API flags (`control-host`, `dashboard-host`). Use for commands that talk to the Control API.
- **`clientIdFlag`** — `--client-id`. Add to commands where client identity affects the operation: subscribe, publish, enter, set, acquire, update, delete, append. Do NOT add to read-only queries (get, get-all, occupancy get) — Ably capabilities are operation-based, not clientId-based, so client identity is irrelevant for pure reads. Do NOT add globally.
- **`clientIdFlag`** — `--client-id`. Add to commands where acting as a particular client is part of the operation: subscribe, publish, enter, set, acquire, update, delete, append. Do NOT add to read-only queries (get, get-all, occupancy get): every command, reads included, already acts as the session's client ID (`ABLY_CLIENT_ID` > config > generated default, see `docs/Client-Identity.md`) and Ably counts it under MAU billing, but a read has no reason to act as someone else. Do NOT add globally. A `--client-id` that names a *target* (push recipient, token subject) must be a command-local flag, never `clientIdFlag` — the base command treats only `clientIdFlag` as identity.
- **`durationFlag`** — `--duration` / `-D`. Use for long-running subscribe/stream commands that auto-exit after N seconds.
- **`rewindFlag`** — `--rewind`. Use for subscribe commands that support message replay (default: 0).
- **`timeRangeFlags`** — `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`).
Expand Down
68 changes: 68 additions & 0 deletions docs/Client-Identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Client Identity and MAU Classification

Ably bills monthly active users (MAU) per distinct client ID on **device** traffic; **server** traffic is exempt from MAU counting, from the per-client-ID connection cap, and from the requirement to carry a client ID. This page describes how the CLI declares its own traffic and which client ID it acts as. Design rationale: DXRFC-029 (CLI Classification and Identity for MAU Pricing).

## Always a server

The CLI always connects as a server. Every Pub/Sub client is built with `@ably/pubsub-server`, whatever the CLI authenticates with, and Chat and Spaces wrap that same client, so rooms and spaces traffic is server traffic too.

With an API key (`ably login`, `ABLY_API_KEY`) that is all Ably needs. Under token auth (`ABLY_TOKEN`) Ably grants the server side only through a signed `x-ably-clientType=server` claim on the token, and rejects a client that declares itself a server without it. Native Ably tokens cannot carry the claim, so use a JWT issued with:

```bash
ably auth issue-jwt-token --client-type server --client-id backend-worker
```

## Which client ID the CLI acts as

Under API key auth, the client ID is resolved once per process, from the first of:

1. `--client-id <id>`, on commands that offer it
2. `ABLY_CLIENT_ID`
3. `client.id` in the config file (`~/.ably/config`)
4. A default generated once per install (`ably-cli-<8 hex chars>`) and saved as `client.defaultId` in the config

Every client the command builds, every command in an interactive session, and every token minted by `ably auth issue-ably-token` / `issue-jwt-token` without `--client-id` uses that one ID. Commands without a `--client-id` flag still act as it. `ably bench` commands append a per-process suffix to the default, so that many concurrent bench processes stay under the per-client-ID connection cap.
Comment on lines +17 to +24

To give a machine or CI job a fixed identity, set it once:

```bash
export ABLY_CLIENT_ID="deploy-bot"
```

or in the config file:

```toml
[client]
id = "deploy-bot"
```

Values the CLI refuses:

- `""` — an empty client ID. It used to fall back silently to a random ID.
- `"*"` — the wildcard. The CLI acts as, and issues tokens to, one concrete client ID.
- `"none"` — deprecated. It still acts with no client ID, with a warning, but apps that require identified clients reject that traffic.
Comment on lines +39 to +43

Under token auth the client ID is the token's and is never overridden; `--client-id` is ignored with a warning. A JWT without an `x-ably-clientId` claim is rejected before connecting.

### Target client IDs are not identity

On push and token commands, `--client-id` names a *target* rather than the CLI's identity: `ably push devices list --client-id user123` lists that user's devices, and `ably auth issue-jwt-token --client-id alice` issues a token to alice. These never change who the CLI acts as.

## Simulating multiple users

An explicit `--client-id` always wins, so two terminals can act as two different clients:

```bash
# Terminal 1
ably channels presence enter my-channel --client-id alice

# Terminal 2
ably channels presence enter my-channel --client-id bob
```

Both connect as servers, so they are not counted or capped as devices. Ably counts client IDs wherever they appear, including in message payloads, so each simulated name may still register as an MAU. The CLI cannot reproduce device behaviour (MAU counting, the per-client-ID connection cap, identified-client enforcement); use an Ably SDK on the device side for that.

## When something goes wrong

- Rejections about the client ID (`40012`, `40161`, `91000`) carry a hint for how you authenticated: set `--client-id` / `ABLY_CLIENT_ID` under API key auth, or re-issue the token with a client ID under token auth.
- A long-running command whose connection fails, or keeps dropping without staying connected, exits non-zero instead of reporting success.
2 changes: 2 additions & 0 deletions docs/Environment-Variables/General-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ These environment variables are most commonly used during development as well as
| `ABLY_ENDPOINT` | Host Override | Override Realtime/REST API endpoint (host only) | SDK default |

> For development, testing, debugging, and internal variables, see [Development Stage Usage](Development-Usage.md).
>
> For how `ABLY_CLIENT_ID`, `--client-id` and tokens decide which client ID the CLI acts as, and why the CLI always connects as a server for MAU billing, see [Client Identity](../Client-Identity.md).
3 changes: 1 addition & 2 deletions src/commands/auth/issue-ably-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand {
'$ ably auth issue-ably-token --capability \'{"*":["*"]}\'',
'$ ably auth issue-ably-token --capability \'{"chat:*":["publish","subscribe"], "status:*":["subscribe"]}\' --ttl 3600',
"$ ably auth issue-ably-token --client-id client123 --ttl 86400",
'$ ably auth issue-ably-token --client-id "none" --ttl 3600',
"$ ably auth issue-ably-token --json",
"$ ably auth issue-ably-token --pretty-json",
"$ ably auth issue-ably-token --token-only",
Expand All @@ -37,7 +36,7 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand {
}),
"client-id": Flags.string({
description:
'Client ID to issue the token to (defaults to the client ID the CLI acts as). Use "none" to issue a token with no client ID.',
'Client ID to issue the token to (defaults to the client ID the CLI acts as). "none" issues an anonymous token, which apps requiring identified clients reject.',
}),
"token-only": Flags.boolean({
default: false,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/auth/issue-jwt-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand {
}),
"client-id": Flags.string({
description:
'Client ID to issue the token to (defaults to the client ID the CLI acts as). Use "none" to issue a token with no client ID.',
'Client ID to issue the token to (defaults to the client ID the CLI acts as). "none" issues an anonymous token, which apps requiring identified clients reject.',
}),
"client-type": Flags.string({
description:
Expand Down
4 changes: 2 additions & 2 deletions src/data/env-vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const ABLY_API_KEY = new EnvVarEntry(
new DetailSection("Client ID", [
{
kind: "paragraph",
text: "Auto-generates a default client ID in the format `ably-cli-{uuid}`. Override with `--client-id <value>`, or pass `--client-id none` to send no client ID.",
text: "Acts as one client ID per install, generated once and saved in the config. Set a fixed one with `ABLY_CLIENT_ID`, or override it per command with `--client-id <value>`.",
},
]),
],
Expand Down Expand Up @@ -158,7 +158,7 @@ const ABLY_TOKEN = new EnvVarEntry(
new DetailSection("Client ID", [
{
kind: "paragraph",
text: "`--client-id` is ignored when `ABLY_TOKEN` is set — the client ID is embedded in the token. A warning is logged if `--client-id` is passed.",
text: "Comes from the token, which must carry one (`--client-id` and `ABLY_CLIENT_ID` are ignored). The CLI always connects as a server, which Ably accepts under token auth only from a JWT with the `x-ably-clientType: server` claim (`ably auth issue-jwt-token --client-type server`).",
},
]),
new DetailSection("Token expiry", [
Expand Down
8 changes: 5 additions & 3 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ export const hiddenControlApiFlags = {
};

/**
* client-id flag for commands where client identity matters (e.g., subscribe, publish, enter, update, delete).
* Not needed for read-only queries (get, get-all, occupancy get) — Ably capabilities are operation-based, not clientId-based.
* client-id flag for commands where acting as a particular client is part of the operation
* (e.g., subscribe, publish, enter, update, delete). Read-only queries (get, get-all, occupancy get)
* don't take it: they still carry the session's client ID (ABLY_CLIENT_ID, config, or the generated
* default), and Ably counts that ID, but there's no per-command reason to act as someone else.
*
* This exact definition is the identity flag: the base command honours
* `client-id` as the CLI's own identity only when a command declares it via
Expand All @@ -80,7 +82,7 @@ export const hiddenControlApiFlags = {
export const clientIdFlag = {
"client-id": Flags.string({
description:
'Overrides any default client ID when using API authentication. Use "none" to explicitly set no client ID. Not applicable when using token authentication.',
"Client ID to act as, overriding ABLY_CLIENT_ID and the configured default. Ignored under token authentication, where the token sets it.",
}),
};

Expand Down