diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index 80ea3fa53..0a1cf6eec 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -64,7 +64,7 @@ If the topic/subtopic doesn't exist yet, you also need an index file at `src/com **Product API commands** (channels, rooms, spaces, presence, pub/sub): ```typescript import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; @@ -139,6 +139,8 @@ static flags = { **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. +**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. + For history commands, also use `timeRangeFlags`: ```typescript import { productApiFlags, timeRangeFlags } from "../../flags.js"; @@ -466,13 +468,13 @@ See the "Keeping Skills Up to Date" section in `CLAUDE.md` for the full list of - [ ] Non-JSON data output uses multi-line labeled blocks (see `patterns.md` "Human-Readable Output Format"), not tables or custom grids - [ ] Non-JSON output exposes all available SDK fields (same data as JSON mode, omitting only null/empty values) - [ ] SDK types imported directly (`import type { CursorUpdate } from "@ably/spaces"`) — no local interface redefinitions of SDK types (display interfaces in `src/utils/` are fine) -- [ ] Field coverage checked against SDK type definitions (`node_modules/ably/ably.d.ts`, `node_modules/@ably/spaces/dist/mjs/types.d.ts`) +- [ ] Field coverage checked against SDK type definitions (`node_modules/@ably/pubsub-core/types.d.ts`, re-exported by `@ably/pubsub-server`; `node_modules/@ably/spaces/dist/mjs/types.d.ts`) - [ ] Subscribe commands do NOT fetch initial state — they only listen for new events (use `get-all` for current state) - [ ] Resource names use `formatResource(name)`, never quoted - [ ] JSON output uses `logJsonResult()` (one-shot) or `logJsonEvent()` (streaming), not direct `formatJsonRecord()` - [ ] JSON data nested under a domain key (singular for events/single items, plural for collections) — not spread at top level (see `patterns.md` "JSON Data Nesting Convention") - [ ] `room.attach()` / `space.enter()` only called when the SDK method requires a realtime connection (subscribe, send, presence) — NOT for REST mutations (update, delete, annotate, history) -- [ ] Subscribe/enter commands use `this.waitAndTrackCleanup(flags, component, flags.duration)` (not `waitUntilInterruptedOrTimeout`) +- [ ] Subscribe/enter commands use `this.waitAndTrackCleanup(flags, component, flags.duration)` (not `waitUntilInterruptedOrTimeout`) and call `this.setupConnectionStateLogging(client, flags)` — together they fail the command (non-zero exit) when the connection fails or keeps dropping, instead of exiting 0 - [ ] Error handling uses `this.fail()` exclusively, not `this.error()` or `this.log(chalk.red(...))` - [ ] Component strings are camelCase: single-word lowercase (`"room"`, `"auth"`), multi-word camelCase (`"channelPublish"`, `"roomPresenceSubscribe"`) - [ ] At least one `--json` example in `static examples` diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index 440e5860c..5f64d5492 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -615,7 +615,7 @@ Attributes: {"priority": "high"} | SDK | Type file | Key types | |-----|-----------|-----------| -| `ably` | `node_modules/ably/ably.d.ts` | `Message`, `PresenceMessage`, `ChannelStateChange`, `ConnectionStateChange` | +| `@ably/pubsub-server` | `node_modules/@ably/pubsub-core/types.d.ts` (re-exported) | `PubSubRealtimeClient`, `PubSubHttpClient`, `Message`, `PresenceMessage`, `ChannelStateChange`, `ConnectionStateChange` | | `@ably/spaces` | `node_modules/@ably/spaces/dist/mjs/types.d.ts` | `SpaceMember`, `CursorUpdate`, `CursorPosition`, `CursorData`, `Lock`, `ProfileData` | | `@ably/chat` | `node_modules/@ably/chat/dist/chat/core/*.d.ts` | `Message` (chat), `PresenceMember`, `OccupancyEvent`, `Reaction` | @@ -623,10 +623,12 @@ Attributes: {"priority": "high"} | SDK | Source directory | Key files | |-----|-----------------|-----------| -| `ably` | `node_modules/ably/` | Realtime, REST, channels, presence | +| `@ably/pubsub-server` | `node_modules/@ably/pubsub-core/build/` (its runtime) | Realtime, HTTP, channels, presence | | `@ably/spaces` | `node_modules/@ably/spaces/dist/mjs/` | `Space.js`, `Members.js`, `Locations.js`, `Cursors.js`, `Locks.js` | | `@ably/chat` | `node_modules/@ably/chat/dist/chat/core/` | Rooms, messages, presence, reactions | +**Never construct Pub/Sub clients directly or import `ably`.** CLI code imports Pub/Sub types with `import type * as Ably from "@ably/pubsub-server"` and gets clients from `createAblyRestClient()` / `createAblyRealtimeClient()`, which build them with `@ably/pubsub-server`'s `createHttpClient` / `createRealtimeClient`, so the CLI always connects as a server. `@ably/chat` (`createChatClient`) and `@ably/spaces` (`createSpacesClient`) wrap that same realtime client; `ably` v2 is not installed at all. + **Import SDK types directly** — never redefine SDK interfaces locally. If `@ably/spaces` exports `CursorPosition`, import it: ```typescript // WRONG — local redefinition duplicates SDK type diff --git a/AGENTS.md b/AGENTS.md index 71b4dcb53..72dc5e676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,7 +282,7 @@ this.error() ← oclif exit (ONLY inside fail, nowhere else) - **Never use `this.error()` directly** — it is an internal implementation detail of `this.fail()`. - **`requireAppId`** returns `Promise` (not nullable) — calls `this.fail()` internally if no app found. - **`runControlCommand`** returns `Promise` (not nullable) — calls `this.fail()` internally on error. -- **Error hints**: `fail()` appends a CLI-specific hint from `src/utils/errors.ts` if one exists for the Ably error code. Hints must only contain actionable CLI advice (e.g., "run `ably login`"), not restate the upstream error message (which is already shown). When adding new error codes, **fetch** https://ably.com/docs/platform/errors/codes using WebFetch to get the official description — do NOT rely on memory or assumptions about what an error code means. +- **Error hints**: `fail()` appends a CLI-specific hint from `src/utils/errors.ts` if one exists for the Ably error code. A hint may be a function of `HintContext` (token auth or not, whether the command declares `--client-id`) so it never suggests a flag the command lacks. Hints must only contain actionable CLI advice (e.g., "run `ably login`"), not restate the upstream error message (which is already shown). When adding new error codes, **fetch** https://ably.com/docs/platform/errors/codes using WebFetch to get the official description — do NOT rely on memory or assumptions about what an error code means. - **`extractErrorInfo(error)`** (`src/utils/errors.ts`) — extracts `{ message, code?, statusCode?, href? }` from an unknown error value. Use in commands that report per-item errors inline (batch publish results, connection test summaries) rather than exiting via `this.fail()`. ### Additional output patterns (direct chalk, not helpers) @@ -326,9 +326,10 @@ When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers, - Spaces: https://ably.com/docs/spaces and API ref at https://sdk.ably.com/builds/ably/spaces/main/typedoc/index.html - Control API: https://ably.com/docs/account/control-api and ref at https://ably.com/docs/api/control-api - Platform: https://ably.com/docs/platform +- **Pub/Sub packages**: the CLI never imports `ably`, and depends on `@ably/pubsub-server` only. Types come from it too (`import type * as Ably from "@ably/pubsub-server"`; clients are `Ably.PubSubRealtimeClient` / `Ably.PubSubHttpClient`). Clients are constructed only by `createAblyRestClient()` / `createAblyRealtimeClient()` in the base command, with `createHttpClient` / `createRealtimeClient` from `@ably/pubsub-server`, so the CLI always connects as a server. Chat and Spaces are built on the same core: create them with `createChatClient(realtime, options)` and `createSpacesClient(realtime)`, passing the CLI's own realtime client. Nothing in the dependency tree uses `ably` v2. - The CLI uses Ably SDKs for all data plane commands. When an API exists in the data plane REST API but has no corresponding SDK method, use the Pub/Sub SDK's request method. - The Control API has no official SDK, so raw HTTP requests are used. -- **SDK packages (`node_modules/ably/`, `node_modules/@ably/spaces/`, `node_modules/@ably/chat/`) are the local source of truth** for types and method behavior. Type definitions (e.g., `ably.d.ts`, `types.d.ts`) tell you what fields exist; source code (e.g., `Space.js`, `Members.js`) tells you how methods behave (side effects, prerequisites like `space.enter()`). When in doubt, read the implementation — not just the types. See `references/patterns.md` "Field display rules" in the `ably-new-command` skill for the full path table and import conventions. +- **SDK packages (`node_modules/@ably/pubsub-core/` (the core behind `@ably/pubsub-server`), `node_modules/@ably/spaces/`, `node_modules/@ably/chat/`) are the local source of truth** for types and method behavior. Type definitions (e.g., `@ably/pubsub-core/types.d.ts`, `types.d.ts`) tell you what fields exist; source code (e.g., `Space.js`, `Members.js`) tells you how methods behave (side effects, prerequisites like `space.enter()`). When in doubt, read the implementation — not just the types. See `references/patterns.md` "Field display rules" in the `ably-new-command` skill for the full path table and import conventions. ## Development Standards diff --git a/docs/Environment-Variables/General-Usage.md b/docs/Environment-Variables/General-Usage.md index 1c37eff0e..58ffeca51 100644 --- a/docs/Environment-Variables/General-Usage.md +++ b/docs/Environment-Variables/General-Usage.md @@ -14,6 +14,7 @@ These environment variables are most commonly used during development as well as | `ABLY_TOKEN` | Authentication | Token/JWT for data plane commands | None | | `ABLY_ACCESS_TOKEN` | Authentication | Access token for Control API commands | None | | `ABLY_APP_ID` | App Selection | Default app for `--app` flag | None | +| `ABLY_CLIENT_ID` | Configuration | Client ID the CLI acts as | Generated once per install | | `ABLY_CLI_CONFIG_DIR` | Configuration | Custom config directory | `~/.ably` | | `ABLY_HISTORY_FILE` | Configuration | Custom history file location | `~/.ably/history` | | `ABLY_CLI_DEFAULT_DURATION` | Behavior | Auto-exit long-running commands (seconds) | None (forever) | diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index 07c5fa8c6..8a83dd029 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -63,6 +63,7 @@ This document outlines the directory structure of the Ably CLI project. │ │ ├── command_not_found/ # Fuzzy-match suggestions for unknown commands │ │ └── init/ # CLI initialization │ ├── services/ # Business logic +│ │ ├── client-identity.ts # Resolves the client ID the CLI acts as │ │ ├── config-manager.ts # CLI configuration (accounts, apps, API keys) │ │ ├── control-api.ts # Ably Control API HTTP client │ │ ├── history-manager.ts # Interactive mode command history persistence @@ -73,8 +74,9 @@ This document outlines the directory structure of the Ably CLI project. │ └── utils/ │ ├── channel-rule-display.ts # Channel rule human-readable display │ ├── chat-constants.ts # Shared Chat SDK constants (REACTION_TYPE_MAP) +│ ├── connection-health.ts # Detects dead connections (failed, reconnect loops) │ ├── server-url.ts # Local server URL parsing (parseServerUrl, formatServerUrl) -│ ├── errors.ts # Error utilities (errorMessage) +│ ├── errors.ts # Error utilities (errorMessage, errorWithReason, hints) │ ├── interrupt-feedback.ts # Ctrl+C feedback messages │ ├── json-formatter.ts # JSON output formatting (formatJson, formatMessageData) │ ├── key-display.ts # Key capability formatting diff --git a/docs/Testing.md b/docs/Testing.md index 3a65eeabf..a6c383aca 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -203,8 +203,8 @@ Call generators at describe-block level. Write `"functionality"` and `"error han | File | Provides | |------|----------| -| `mock-ably-realtime.ts` | Mock `Ably.Realtime` — channels, presence, connection events | -| `mock-ably-rest.ts` | Mock `Ably.Rest` — REST channel operations, request() | +| `mock-ably-realtime.ts` | Mock `PubSubRealtimeClient` — channels, presence, connection events | +| `mock-ably-rest.ts` | Mock `PubSubHttpClient` — REST channel operations, request() | | `mock-ably-chat.ts` | Mock Chat SDK — rooms, messages, typing, reactions | | `mock-ably-spaces.ts` | Mock Spaces SDK — members, cursors, locations, locks | diff --git a/package.json b/package.json index aa615ad66..606dcca1f 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,9 @@ } }, "dependencies": { - "@ably/chat": "^1.3.1", - "@ably/spaces": "^0.5.2", + "@ably/chat": "0.0.0-canary.20261001T1209.783b80e6", + "@ably/pubsub-server": "3.0.0-canary.20261001T0958.2d5f47d6", + "@ably/spaces": "0.0.0-canary.20261001T1221.ca08adce", "@inquirer/prompts": "^8.4.2", "@oclif/core": "^4.11.1", "@oclif/plugin-autocomplete": "^3.2.48", @@ -120,7 +121,6 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", - "ably": "^2.21.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "color-json": "^3.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b798fbe8..b1eef770b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,11 +34,14 @@ importers: .: dependencies: '@ably/chat': - specifier: ^1.3.1 - version: 1.3.1(ably@2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.0-canary.20261001T1209.783b80e6 + version: 0.0.0-canary.20261001T1209.783b80e6(@ably/pubsub-core@3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ably/pubsub-server': + specifier: 3.0.0-canary.20261001T0958.2d5f47d6 + version: 3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@ably/spaces': - specifier: ^0.5.2 - version: 0.5.2(ably@2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.0-canary.20261001T1221.ca08adce + version: 0.0.0-canary.20261001T1221.ca08adce(@ably/pubsub-core@3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@inquirer/prompts': specifier: ^8.4.2 version: 8.4.2(@types/node@25.6.2) @@ -60,9 +63,6 @@ importers: '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 - ably: - specifier: ^2.21.0 - version: 2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) chalk: specifier: ^5.6.2 version: 5.6.2 @@ -374,15 +374,15 @@ importers: version: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) vitest: specifier: ^4.1.6 - version: 4.1.6(@edge-runtime/vm@3.2.0)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.6)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.1.6(@edge-runtime/vm@3.2.0)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(@vitest/ui@4.1.6)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) packages: - '@ably/chat@1.3.1': - resolution: {integrity: sha512-dbhsIz9g2cDzAWv7TvTpEZlKD+xGh+SedZw74QwhmaNjZA/ieB4401ScN/poWLAaGahkXzB2hP2DzeSl4qkDOA==} + '@ably/chat@0.0.0-canary.20261001T1209.783b80e6': + resolution: {integrity: sha512-dOEGuJwQ9834GrE+rOLetxAZ7FlPk6qTiGEsAcMD/E2Ppckv4c/lRCL5DC6X04sb6r7yNCT68cZ0fzuO+atz1Q==} engines: {node: '>=20.0.0'} peerDependencies: - ably: ^2.19.0 + '@ably/pubsub-core': 3.0.0-canary.20261001T0958.2d5f47d6 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: @@ -394,10 +394,26 @@ packages: '@ably/msgpack-js@0.4.0': resolution: {integrity: sha512-IPt/BoiQwCWubqoNik1aw/6M/DleMdrxJOUpSja6xmMRbT2p1TA8oqKWgfZabqzrq8emRNeSl/+4XABPNnW5pQ==} - '@ably/spaces@0.5.2': - resolution: {integrity: sha512-uGCsN6GIDZREh/3IdR5NJchH5jqC7+HKzpB4gpn2zCXnh/71ggxs/Zk6B1z4caHK+eMcEZHotvY87S1+WghI4A==} + '@ably/pubsub-core@3.0.0-canary.20261001T0958.2d5f47d6': + resolution: {integrity: sha512-CI9ZPMS29TU429DQFRWtgFfkj0y6yS2n4arYdbBBr7X/cEZh9/LwupOs9f3Hfekfhid3sb7zF7B8AeaSJNOUzQ==} + engines: {node: '>=16'} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + '@ably/pubsub-server@3.0.0-canary.20261001T0958.2d5f47d6': + resolution: {integrity: sha512-HDTV6oCrRLjlGV5boNTCUyHCM3aYfHSPD1Vo0XtoLwPbTV98Mo9vwPrq43JUVHx3XxvOWqGPKxetJUiwMQNW1w==} + engines: {node: '>=16'} + + '@ably/spaces@0.0.0-canary.20261001T1221.ca08adce': + resolution: {integrity: sha512-EJBLd9ZQUXHEMNUOPbf1IMhzQTiJwAYPkanWaChrTPWeVoFJ5QEARrStWEMXotIPiRJsMyfS+vk33uQ2NZ1QVg==} peerDependencies: - ably: ^2.3.0 + '@ably/pubsub-core': 3.0.0-canary.20261001T0958.2d5f47d6 react: '>=16.8.0' react-dom: '>=16.8.0' peerDependenciesMeta: @@ -1792,11 +1808,6 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.40.2': - resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.59.0': resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] @@ -1883,12 +1894,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.40.2': - resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] @@ -2636,18 +2641,6 @@ packages: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} - ably@2.21.0: - resolution: {integrity: sha512-Zgs3O/hqhnTEoXeV1WxoQeLaww58JbnDVFbVeEDj2kCHeuLPeavTbyf2NQBkxAM3jooN5mILaP0PDm79NOcpKw==} - engines: {node: '>=16'} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -6030,15 +6023,15 @@ packages: snapshots: - '@ably/chat@1.3.1(ably@2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@ably/chat@0.0.0-canary.20261001T1209.783b80e6(@ably/pubsub-core@3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - ably: 2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ably/pubsub-core': 3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) async-mutex: 0.5.0 dequal: 2.0.3 lodash.clonedeep: 4.5.0 optionalDependencies: - '@rollup/rollup-darwin-arm64': 4.40.2 - '@rollup/rollup-linux-x64-gnu': 4.40.2 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -6046,9 +6039,33 @@ snapshots: dependencies: bops: 1.0.1 - '@ably/spaces@0.5.2(ably@2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@ably/pubsub-core@3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@ably/msgpack-js': 0.4.0 + dequal: 2.0.3 + fastestsmallesttextencoderdecoder: 1.0.22 + got: 11.8.6 + ulid: 2.4.0 + ws: 8.21.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@ably/pubsub-server@3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - ably: 2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ably/pubsub-core': 3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - bufferutil + - react + - react-dom + - utf-8-validate + + '@ably/spaces@0.0.0-canary.20261001T1221.ca08adce(@ably/pubsub-core@3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@ably/pubsub-core': 3.0.0-canary.20261001T0958.2d5f47d6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) nanoid: 3.3.11 optionalDependencies: react: 19.2.6 @@ -7585,9 +7602,6 @@ snapshots: '@rollup/rollup-android-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.40.2': - optional: true - '@rollup/rollup-darwin-arm64@4.59.0': optional: true @@ -7633,9 +7647,6 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.40.2': - optional: true - '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true @@ -8567,7 +8578,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.6(@edge-runtime/vm@3.2.0)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.6)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + vitest: 4.1.6(@edge-runtime/vm@3.2.0)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(@vitest/ui@4.1.6)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/utils@4.1.5': dependencies: @@ -8597,21 +8608,6 @@ snapshots: abbrev@3.0.1: {} - ably@2.21.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@ably/msgpack-js': 0.4.0 - dequal: 2.0.3 - fastestsmallesttextencoderdecoder: 1.0.22 - got: 11.8.6 - ulid: 2.4.0 - ws: 8.20.0 - optionalDependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -12043,7 +12039,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@edge-runtime/vm@3.2.0)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.6)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)): + vitest@4.1.6(@edge-runtime/vm@3.2.0)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5(vitest@4.1.5))(@vitest/ui@4.1.6)(jsdom@26.1.0)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) @@ -12201,8 +12197,7 @@ snapshots: ws@8.20.0: {} - ws@8.21.1: - optional: true + ws@8.21.1: {} wsl-utils@0.3.1: dependencies: diff --git a/src/base-command.ts b/src/base-command.ts index f7aae5e8a..5ae70fafe 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -1,8 +1,9 @@ import { InteractiveBaseCommand } from "./interactive-base-command.js"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; +import { createHttpClient, createRealtimeClient } from "@ably/pubsub-server"; import chalk from "chalk"; -import colorJson from "color-json"; import { randomUUID } from "node:crypto"; +import colorJson from "color-json"; import { ConfigManager, @@ -10,6 +11,15 @@ import { type DataPlaneConfig, } from "./services/config-manager.js"; import { ControlApi, controlHostScheme } from "./services/control-api.js"; +import { + CLIENT_ID_CLAIM, + CLIENT_ID_ENV_VAR, + InvalidClientIdError, + type ClientIdentity, + describeClientIdSource, + readTokenIdentity, + resolveClientIdentity, +} from "./services/client-identity.js"; import { extractAppIdFromApiKey, extractKeyNameFromApiKey, @@ -21,7 +31,7 @@ import { type ServerUrl, } from "./utils/server-url.js"; import { errorMessage, getFriendlyAblyErrorHint } from "./utils/errors.js"; -import { coreGlobalFlags } from "./flags.js"; +import { clientIdFlag, coreGlobalFlags } from "./flags.js"; import { InteractiveHelper } from "./services/interactive-helper.js"; import { promptForConfirmation } from "./utils/prompt-confirmation.js"; import { BaseFlags, CommandConfig } from "./types/cli.js"; @@ -36,12 +46,13 @@ import { } from "./utils/output.js"; import stripAnsi from "strip-ansi"; import { getAgentName, getCliVersion } from "./utils/version.js"; -import Spaces from "@ably/spaces"; +import type { SpacesClient } from "@ably/spaces"; import { ChatClient } from "@ably/chat"; import { waitUntilInterruptedOrTimeout, type ExitReason, } from "./utils/long-running.js"; +import { ConnectionHealthMonitor } from "./utils/connection-health.js"; import isTestMode from "./utils/test-mode.js"; import isWebCliMode from "./utils/web-mode.js"; import * as fs from "node:fs"; @@ -142,8 +153,22 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { protected _authInfoShown = false; protected cleanupInProgress = false; protected _suppressSdkErrorLogs = false; - private _cachedRestClient: Ably.Rest | null = null; - private _cachedRealtimeClient: Ably.Realtime | null = null; + private _cachedRestClient: Ably.PubSubHttpClient | null = null; + private _cachedRealtimeClient: Ably.PubSubRealtimeClient | null = null; + + /** + * Suffix the default client ID per process, for commands that run as many + * concurrent processes (bench), so they stay under the per-client-ID + * connection cap. An explicit --client-id is always used as given. + */ + protected suffixClientIdPerProcess = false; + + // The identity this command acts as, suffix included, so the banner and + // every client this command builds agree on it. + private actingIdentity: ClientIdentity | undefined; + + /** Aborted with the cause when a watched connection dies mid-command. */ + private readonly connectionLost = new AbortController(); // Core global flags available to all commands (verbose, json, pretty-json, web-cli-help) static globalFlags = { ...coreGlobalFlags }; @@ -236,24 +261,27 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { * Get test mocks if in test mode * @returns Test mocks object or undefined if not in test mode */ - protected getMockAblyRest(): Ably.Rest | undefined { + protected getMockAblyRest(): Ably.PubSubHttpClient | undefined { if (!isTestMode()) return undefined; // Access global mock if running in test mode - return (globalThis as { __TEST_MOCKS__?: { ablyRestMock: Ably.Rest } }) - .__TEST_MOCKS__?.ablyRestMock; + return ( + globalThis as { __TEST_MOCKS__?: { ablyRestMock: Ably.PubSubHttpClient } } + ).__TEST_MOCKS__?.ablyRestMock; } /** * Get test mocks if in test mode * @returns Test mocks object or undefined if not in test mode */ - protected getMockAblyRealtime(): Ably.Realtime | undefined { + protected getMockAblyRealtime(): Ably.PubSubRealtimeClient | undefined { if (!isTestMode()) return undefined; // Access global mock if running in test mode return ( - globalThis as { __TEST_MOCKS__?: { ablyRealtimeMock: Ably.Realtime } } + globalThis as { + __TEST_MOCKS__?: { ablyRealtimeMock: Ably.PubSubRealtimeClient }; + } ).__TEST_MOCKS__?.ablyRealtimeMock; } @@ -261,11 +289,11 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { * Get test mocks if in test mode * @returns Test mocks object or undefined if not in test mode */ - protected getMockAblySpaces(): Spaces | undefined { + protected getMockAblySpaces(): SpacesClient | undefined { if (!isTestMode()) return undefined; // Access global mock if running in test mode - return (globalThis as { __TEST_MOCKS__?: { ablySpacesMock: Spaces } }) + return (globalThis as { __TEST_MOCKS__?: { ablySpacesMock: SpacesClient } }) .__TEST_MOCKS__?.ablySpacesMock; } @@ -378,7 +406,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { options?: { skipAuthInfo?: boolean; }, - ): Promise { + ): Promise { // Return cached client if it exists if (this._cachedRestClient) { return this._cachedRestClient; @@ -391,10 +419,10 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // Cache the client for reuse if (client) { - this._cachedRestClient = client as Ably.Rest; + this._cachedRestClient = client as Ably.PubSubHttpClient; } - return client as Ably.Rest | null; + return client as Ably.PubSubHttpClient | null; } /** @@ -406,7 +434,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { skipAuthInfo?: boolean; autoConnect?: boolean; }, - ): Promise { + ): Promise { // Return cached client if it exists if (this._cachedRealtimeClient) { return this._cachedRealtimeClient; @@ -420,10 +448,10 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // Cache the client for reuse if (client) { - this._cachedRealtimeClient = client as Ably.Realtime; + this._cachedRealtimeClient = client as Ably.PubSubRealtimeClient; } - return client as Ably.Realtime | null; + return client as Ably.PubSubRealtimeClient | null; } /** @@ -437,7 +465,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { skipAuthInfo?: boolean; autoConnect?: boolean; }, - ): Promise { + ): Promise { const clientType = options?.type || "realtime"; // If in test mode, skip connection and use mock @@ -501,17 +529,12 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } try { - // Create REST client if (clientType === "rest") { - return new Ably.Rest(clientOptions); + return createHttpClient(clientOptions); } - // Create Realtime client const autoConnect = options?.autoConnect !== false; - const client = new Ably.Realtime({ - ...clientOptions, - autoConnect, - }); + const client = createRealtimeClient({ ...clientOptions, autoConnect }); // If autoConnect is disabled, return the client immediately without waiting for connection if (!autoConnect) { @@ -728,6 +751,13 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { ); } } + + const clientId = this.bannerClientId(flags); + if (clientId) { + displayParts.push( + `${chalk.blue("Client=")}${chalk.blue.bold(clientId)}`, + ); + } } } @@ -741,6 +771,49 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } } + /** + * The client ID shown in the banner. Invalid values are reported when the + * client is built, so the banner just omits them. + */ + private bannerClientId(flags: BaseFlags): string | undefined { + if (process.env.ABLY_TOKEN) { + const identity = readTokenIdentity(process.env.ABLY_TOKEN); + if (identity.kind !== "jwt") return undefined; + if (identity.clientId !== "*") return identity.clientId; + } + + try { + return this.resolveActingIdentity(flags).clientId; + } catch { + return undefined; + } + } + + /** + * The identity this command acts as under API-key auth or a wildcard + * token, resolved once. + * + * @throws InvalidClientIdError when the winning value is empty or "*". + */ + private resolveActingIdentity(flags: BaseFlags): ClientIdentity { + if (this.actingIdentity) return this.actingIdentity; + + const identity = resolveClientIdentity( + this.identityFlag(flags), + this.configManager, + ); + this.actingIdentity = + identity.clientId && + this.suffixClientIdPerProcess && + identity.source !== "flag" + ? { + ...identity, + clientId: `${identity.clientId}-${randomUUID().slice(0, 8)}`, + } + : identity; + return this.actingIdentity; + } + /** * Resolve host, port and TLS for the data plane from the first source that * supplies them: `--url`, `ABLY_URL`, `ABLY_ENDPOINT`, then the account @@ -966,6 +1039,28 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { * This hook runs before command execution * It's the oclif standard hook that runs before the run() method */ + /** + * Emit a terminal "completed" line so JSON consumers know the command is + * done. Suppressed when the command is being delegated to from another + * command (the outer command emits its own terminator). + */ + private logCompletedStatus(exitCode: 0 | 1): void { + const isJsonMode = + this.argv.includes("--json") || this.argv.includes("--pretty-json"); + if (!isJsonMode || this.argv.includes("--skip-completed-status")) return; + + const flags: BaseFlags = this.argv.includes("--pretty-json") + ? { "pretty-json": true } + : { json: true }; + this.log( + this.formatJsonRecord( + JsonRecordType.Status, + { status: "completed", exitCode }, + flags, + ), + ); + } + async finally(err: Error | undefined): Promise { // Clean up cached clients try { @@ -998,25 +1093,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { this.debug(`Realtime client cleanup error: ${String(error)}`); } - // Emit a terminal "completed" line so JSON consumers know the command is done. - // Suppressed when the command is being delegated to from another command - // (the outer command emits its own terminator). - const isJsonMode = - this.argv.includes("--json") || this.argv.includes("--pretty-json"); - const suppressCompleted = this.argv.includes("--skip-completed-status"); - if (isJsonMode && !suppressCompleted) { - const flags: BaseFlags = this.argv.includes("--pretty-json") - ? { "pretty-json": true } - : { json: true }; - const exitCode = err ? 1 : 0; - this.log( - this.formatJsonRecord( - JsonRecordType.Status, - { status: "completed", exitCode }, - flags, - ), - ); - } + this.logCompletedStatus(err ? 1 : 0); // Call super to maintain the parent class functionality await super.finally(err); @@ -1158,15 +1235,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // Handle authentication: ABLY_TOKEN env → flags["api-key"] (set by ensureAppAndKey) → ABLY_API_KEY env → config if (process.env.ABLY_TOKEN) { options.token = process.env.ABLY_TOKEN; - - // When using token auth, we don't set the clientId as it may conflict - // with any clientId embedded in the token - if (flags["client-id"] && !this.shouldSuppressOutput(flags)) { - this.logWarning( - "clientId is ignored when using token authentication as the clientId is embedded in the token.", - flags, - ); - } + this.applyTokenIdentity(options, process.env.ABLY_TOKEN, flags); } else if (flags["api-key"]) { this.applyApiKeyAuth(options, flags["api-key"], flags); } else if (process.env.ABLY_API_KEY) { @@ -1175,8 +1244,6 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { const apiKey = this.configManager.getApiKey(); if (apiKey) { options.key = apiKey; - - // Handle client ID for API key auth this.setClientId(options, flags); } } @@ -1550,18 +1617,150 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { this.setClientId(options, flags); } - private setClientId(options: Ably.ClientOptions, flags: BaseFlags): void { - if (flags["client-id"]) { - // Special case: "none" means explicitly no client ID - if (flags["client-id"].toLowerCase() === "none") { - // Don't set clientId at all - } else { - options.clientId = flags["client-id"]; + /** + * The --client-id value, but only when the command declares the shared + * identity flag. Push and token commands declare their own --client-id to + * name a target or filter, which must never become the CLI's own identity. + */ + private identityFlag(flags: BaseFlags): string | undefined { + return this.declaresIdentityFlag() ? flags["client-id"] : undefined; + } + + private declaresIdentityFlag(): boolean { + const declared = (this.constructor as { flags?: Record }) + .flags?.["client-id"]; + return declared === clientIdFlag["client-id"]; + } + + /** + * The client ID this command acts as under API-key auth. Fails the command + * on a value the CLI cannot act as, such as "" or "*". + */ + protected resolveClientId(flags: BaseFlags): string | undefined { + const identity = this.validatedIdentity(flags, () => + this.resolveActingIdentity(flags), + ); + + this.logCliEvent( + flags, + "clientId", + "resolved", + identity.clientId + ? `Acting as client ID ${identity.clientId} (from ${identity.source}).` + : "Acting with no client ID.", + { clientId: identity.clientId, source: identity.source }, + ); + + return identity.clientId; + } + + /** + * The client ID to bind a minted token to: --client-id when given, which + * may be "*" to let the holder assume any identity, otherwise the identity + * the CLI itself acts as. + */ + protected resolveTokenClientId(flags: BaseFlags): string | undefined { + if (flags["client-id"] === undefined) return this.resolveClientId(flags); + + const identity = this.validatedIdentity(flags, () => + resolveClientIdentity(flags["client-id"], this.configManager, { + allowWildcard: true, + }), + ); + + // The client that issues the token acts as the token's identity, so the + // default identity is never resolved. It cannot act as "*", and only + // signs with the key, so it then acts with no client ID. The "none" + // warning has already been shown. + this.actingIdentity = { + ...identity, + clientId: identity.clientId === "*" ? undefined : identity.clientId, + optedOut: false, + }; + + return identity.clientId; + } + + /** + * Resolve an identity, failing the command on an invalid value and warning + * when the deprecated "none" opt-out was used. + */ + private validatedIdentity( + flags: BaseFlags, + resolve: () => ClientIdentity, + ): ClientIdentity { + let identity; + try { + identity = resolve(); + } catch (error) { + if (error instanceof InvalidClientIdError) { + this.fail(error.message, flags, "clientId"); } - } else { - // Generate a default client ID for the CLI - options.clientId = `ably-cli-${randomUUID().slice(0, 8)}`; + + throw error; + } + + if (identity.optedOut) { + this.logWarning( + `Acting with no client ID ("none" from ${describeClientIdSource(identity.source)}) is deprecated: apps that require identified clients reject it. Remove it to use your default client ID, or set ${CLIENT_ID_ENV_VAR}.`, + flags, + ); + } + + return identity; + } + + private setClientId(options: Ably.ClientOptions, flags: BaseFlags): void { + const clientId = this.resolveClientId(flags); + if (clientId !== undefined) { + options.clientId = clientId; + } + } + + /** + * Under token auth the identity is the token's, never overridden. A JWT + * must carry a clientId; a native Ably token is opaque, so its identity is + * only known once Ably accepts it. A wildcard ("*") JWT lets its holder + * pick an identity, so the CLI acts as the one it would under API-key auth. + */ + private applyTokenIdentity( + options: Ably.ClientOptions, + token: string, + flags: BaseFlags, + ): void { + const identity = readTokenIdentity(token); + if (identity.kind === "jwt" && identity.clientId === "*") { + this.setClientId(options, flags); + return; + } + + if (this.identityFlag(flags) && !this.shouldSuppressOutput(flags)) { + this.logWarning( + "--client-id is ignored when using token authentication; the client ID comes from the token.", + flags, + ); } + + if (identity.kind === "jwt" && identity.clientId === undefined) { + this.fail( + `The JWT in ABLY_TOKEN has no ${CLIENT_ID_CLAIM} claim. Tokens used with the CLI must carry a client ID; re-issue it with "ably auth issue-jwt-token --client-id ".`, + flags, + "auth", + ); + } + + this.logCliEvent( + flags, + "clientId", + "resolved", + identity.kind === "jwt" + ? `Acting as client ID ${identity.clientId} (from token).` + : "Acting as the client ID bound to the token.", + { + clientId: identity.kind === "jwt" ? identity.clientId : undefined, + source: "token", + }, + ); } /** @@ -1633,7 +1832,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { * This should be called after creating a Realtime client for long-running commands */ protected setupConnectionStateLogging( - client: Ably.Realtime, + client: Ably.PubSubRealtimeClient, flags: BaseFlags, options?: { component?: string; @@ -1642,10 +1841,15 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { ): () => void { const component = options?.component || "connection"; const showUserMessages = options?.includeUserFriendlyMessages || false; + const health = new ConnectionHealthMonitor((error) => + this.connectionLost.abort(error), + ); const connectionStateHandler = ( stateChange: Ably.ConnectionStateChange, ) => { + health.handle(stateChange); + // Always emit in JSON mode so agents can observe connection lifecycle if (this.shouldOutputJson(flags)) { const eventData: Record = { @@ -1973,6 +2177,10 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { (typeof cmdError.context.errorCode === "number" ? cmdError.context.errorCode : undefined), + { + tokenAuth: Boolean(process.env.ABLY_TOKEN), + hasClientIdFlag: this.declaresIdentityFlag(), + }, ); if (this.shouldOutputJson(flags)) { @@ -2008,27 +2216,29 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { component: string, duration?: number, ): Promise { - const exitReason = await waitUntilInterruptedOrTimeout(duration); + const exitReason = await waitUntilInterruptedOrTimeout( + duration, + this.connectionLost.signal, + ); this.logCliEvent(flags, component, "runComplete", "Exiting wait loop", { exitReason, }); + + // A subscription whose connection died must not report success. + if (exitReason === "aborted") { + this.fail(this.connectionLost.signal.reason, flags, component); + } + this.cleanupInProgress = exitReason === "signal"; // For timeout cases in CLI commands, exit immediately to prevent hanging. // This is especially important for E2E tests and automated scenarios. if (exitReason === "timeout" && !isTestMode()) { - const message = "Duration elapsed – command finished cleanly."; - if (this.shouldOutputJson(flags)) { - this.log( - this.formatJsonRecord( - JsonRecordType.Status, - { status: "complete", message }, - flags, - ), - ); - } else { - this.log(message); + if (!this.shouldOutputJson(flags)) { + this.logToStderr("Duration elapsed – command finished cleanly."); } + // Exiting here skips finally(), so emit its terminal line ourselves. + this.logCompletedStatus(0); // Small delay to ensure output is flushed, then force-exit to prevent // hanging and avoid spurious cleanup messages (e.g. "Detached from channel"). await new Promise((resolve) => setTimeout(resolve, 200)); diff --git a/src/chat-base-command.ts b/src/chat-base-command.ts index 7e416616a..a2310b515 100644 --- a/src/chat-base-command.ts +++ b/src/chat-base-command.ts @@ -1,4 +1,10 @@ -import { ChatClient, LogLevel, Room, RoomStatus } from "@ably/chat"; +import { + type ChatClient, + createChatClient, + LogLevel, + Room, + RoomStatus, +} from "@ably/chat"; import { AblyBaseCommand } from "./base-command.js"; import { productApiFlags } from "./flags.js"; @@ -97,7 +103,7 @@ export abstract class ChatBaseCommand extends AblyBaseCommand { const chatOptions = flags.verbose ? { logLevel: LogLevel.Info } : { logLevel: LogLevel.Silent }; - return (this._chatClient = new ChatClient(realtimeClient, chatOptions)); + return (this._chatClient = createChatClient(realtimeClient, chatOptions)); } protected setupRoomStatusHandler( diff --git a/src/commands/auth/issue-ably-token.ts b/src/commands/auth/issue-ably-token.ts index ac782eb42..36c9941e7 100644 --- a/src/commands/auth/issue-ably-token.ts +++ b/src/commands/auth/issue-ably-token.ts @@ -1,6 +1,5 @@ import { Flags } from "@oclif/core"; -import * as Ably from "ably"; -import { randomUUID } from "node:crypto"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; @@ -38,7 +37,7 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { }), "client-id": Flags.string({ description: - 'Client ID to associate with the token. Use "none" to explicitly issue a token with no client ID, otherwise a default will be generated.', + 'Client ID to bind the token to. Use "*" to let the token holder act as any client ID. Defaults to the client ID the CLI acts as.', }), "token-only": Flags.boolean({ default: false, @@ -82,18 +81,8 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { ttl: flags.ttl * 1000, // Convert to milliseconds for Ably SDK }; - // Handle client ID - use special "none" value to explicitly indicate no clientId - if (flags["client-id"]) { - if (flags["client-id"].toLowerCase() === "none") { - // No client ID - leave clientId undefined in the token params - } else { - // Use the provided client ID - tokenParams.clientId = flags["client-id"]; - } - } else { - // Generate a default client ID - tokenParams.clientId = `ably-cli-${randomUUID().slice(0, 8)}`; - } + const clientId = this.resolveTokenClientId(flags); + if (clientId !== undefined) tokenParams.clientId = clientId; // Create Ably REST client and request token const rest = await this.createAblyRestClient( @@ -127,9 +116,7 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { value: tokenDetails.token, issuedAt: new Date(tokenDetails.issued).toISOString(), expiresAt: new Date(tokenDetails.expires).toISOString(), - ...(tokenDetails.clientId - ? { clientId: tokenDetails.clientId } - : {}), + clientId: tokenDetails.clientId ?? null, capability: tokenDetails.capability, }, }, @@ -146,11 +133,9 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { `${formatLabel("Expires")} ${new Date(tokenDetails.expires).toISOString()}`, ); this.log(`${formatLabel("TTL")} ${flags.ttl} seconds`); - if (tokenDetails.clientId) { - this.log( - `${formatLabel("Client ID")} ${formatClientId(tokenDetails.clientId)}`, - ); - } + this.log( + `${formatLabel("Client ID")} ${tokenDetails.clientId ? formatClientId(tokenDetails.clientId) : "anonymous"}`, + ); this.log( `${formatLabel("Capability")} ${this.formatJsonOutput({ capability: tokenDetails.capability }, flags)}`, ); diff --git a/src/commands/auth/issue-jwt-token.ts b/src/commands/auth/issue-jwt-token.ts index 334ffac01..a9871b325 100644 --- a/src/commands/auth/issue-jwt-token.ts +++ b/src/commands/auth/issue-jwt-token.ts @@ -17,6 +17,7 @@ interface JwtPayload { "x-ably-appId": string; "x-ably-capability": Record; "x-ably-clientId"?: string; + "x-ably-clientType"?: "server"; } export default class IssueJwtTokenCommand extends AblyBaseCommand { @@ -27,6 +28,7 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { '$ ably auth issue-jwt-token --capability \'{"*":["*"]}\'', '$ ably auth issue-jwt-token --capability \'{"chat:*":["publish","subscribe"], "status:*":["subscribe"]}\' --ttl 3600', "$ ably auth issue-jwt-token --client-id client123 --ttl 86400", + "$ ably auth issue-jwt-token --client-type server --client-id backend-worker", "$ ably auth issue-jwt-token --json", "$ ably auth issue-jwt-token --pretty-json", "$ ably auth issue-jwt-token --token-only", @@ -46,7 +48,12 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { }), "client-id": Flags.string({ description: - 'Client ID to associate with the token. Use "none" to explicitly issue a token with no client ID, otherwise a default will be generated.', + 'Client ID to bind the token to. Use "*" to let the token holder act as any client ID. Defaults to the client ID the CLI acts as.', + }), + "client-type": Flags.string({ + description: + "Classify clients using the token as servers, exempt from MAU counting, by adding the signed x-ably-clientType claim", + options: ["server"], }), "token-only": Flags.boolean({ default: false, @@ -102,23 +109,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { "x-ably-capability": capabilities, }; - // Handle client ID - use special "none" value to explicitly indicate no clientId - let clientId: null | string = null; - if (flags["client-id"]) { - if (flags["client-id"].toLowerCase() === "none") { - // No client ID - don't add it to the token - clientId = null; - } else { - // Use the provided client ID - jwtPayload["x-ably-clientId"] = flags["client-id"]; - clientId = flags["client-id"]; - } - } else { - // Generate a default client ID - const defaultClientId = `ably-cli-${randomUUID().slice(0, 8)}`; - jwtPayload["x-ably-clientId"] = defaultClientId; - clientId = defaultClientId; - } + const clientId = this.resolveTokenClientId(flags); + if (clientId !== undefined) jwtPayload["x-ably-clientId"] = clientId; + + const clientType = flags["client-type"] as "server" | undefined; + if (clientType) jwtPayload["x-ably-clientType"] = clientType; // Sign the JWT const token = jwt.sign(jwtPayload, keySecret, { @@ -142,7 +137,8 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { token: { appId, capability: capabilities, - ...(clientId ? { clientId } : {}), + clientId: clientId ?? null, + ...(clientType ? { clientType } : {}), expires: new Date(jwtPayload.exp * 1000).toISOString(), issued: new Date(jwtPayload.iat * 1000).toISOString(), keyId, @@ -166,8 +162,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { this.log(`${formatLabel("TTL")} ${flags.ttl} seconds`); this.log(`${formatLabel("App ID")} ${appId}`); this.log(`${formatLabel("Key ID")} ${keyId}`); - if (clientId) { - this.log(`${formatLabel("Client ID")} ${formatClientId(clientId)}`); + this.log( + `${formatLabel("Client ID")} ${clientId ? formatClientId(clientId) : "anonymous"}`, + ); + if (clientType) { + this.log(`${formatLabel("Client Type")} ${clientType}`); } this.log( `${formatLabel("Capability")} ${this.formatJsonOutput(capabilities, flags)}`, diff --git a/src/commands/auth/revoke-token.ts b/src/commands/auth/revoke-token.ts index 39ff5148e..8beaa90ef 100644 --- a/src/commands/auth/revoke-token.ts +++ b/src/commands/auth/revoke-token.ts @@ -1,5 +1,4 @@ import { Flags } from "@oclif/core"; -import * as https from "node:https"; import stripAnsi from "strip-ansi"; import { AblyBaseCommand } from "../../base-command.js"; @@ -97,128 +96,58 @@ export default class RevokeTokenCommand extends AblyBaseCommand { } } - try { - // Extract the keyName (appId.keyId) from the API key - const keyParts = apiKey.split(":"); - if (keyParts.length !== 2) { - this.fail( - "Invalid API key format. Expected format: appId.keyId:secret", - flags, - "revokeToken", - ); - } + let reauthNote = ""; + if (flags["allow-reauth-margin"]) { + reauthNote = + " Connected clients have a 30s grace period to obtain new tokens before disconnection."; + } - const keyName = keyParts[0]!; - const secret = keyParts[1]!; + try { + const rest = await this.createAblyRestClient({ + ...flags, + "api-key": apiKey, + }); + if (!rest) return; - const requestBody: Record = { - targets: [targetSpecifier], - }; + const response = await rest.auth.revokeTokens( + [{ type: clientId ? "clientId" : "revocationKey", value: targetValue }], + flags["allow-reauth-margin"] ? { allowReauthMargin: true } : undefined, + ); - let reauthNote = ""; - if (flags["allow-reauth-margin"]) { - requestBody.allowReauthMargin = true; - reauthNote = - " Connected clients have a 30s grace period to obtain new tokens before disconnection."; + const failure = response.results.find((result) => "error" in result); + if (failure && "error" in failure) { + this.fail(failure.error, flags, "revokeToken", { + target: targetSpecifier, + }); } - try { - // Make direct HTTPS request to Ably REST API - const response = await this.makeHttpRequest( - keyName, - secret, - requestBody, - ); - const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`; - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - revocation: { - allowReauthMargin: flags["allow-reauth-margin"], - message: stripAnsi(successMessage), - target: targetSpecifier, - response, - }, + const successMessage = `Tokens matching ${targetLabel.toLowerCase()} ${formatResource(targetValue)} have been revoked.${reauthNote}`; + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + revocation: { + allowReauthMargin: flags["allow-reauth-margin"], + message: stripAnsi(successMessage), + target: targetSpecifier, + response, }, - flags, - ); - } else { - this.logSuccessMessage(successMessage, flags); - } - } catch (requestError: unknown) { - const error = requestError as Error & { statusCode?: number }; - if (error.statusCode === 404) { - this.fail( - "No matching tokens found or already revoked", - flags, - "revokeToken", - ); - } - throw requestError; + }, + flags, + ); + } else { + this.logSuccessMessage(successMessage, flags); } } catch (error) { + if ((error as { statusCode?: number }).statusCode === 404) { + this.fail( + "No matching tokens found or already revoked", + flags, + "revokeToken", + ); + } + this.fail(error, flags, "revokeToken"); } } - - // Helper method to make a direct HTTP request to the Ably REST API - private makeHttpRequest( - keyName: string, - secret: string, - requestBody: Record, - ): Promise | string | null> { - return new Promise((resolve, reject) => { - const encodedAuth = Buffer.from(`${keyName}:${secret}`).toString( - "base64", - ); - - const options = { - headers: { - Accept: "application/json", - Authorization: `Basic ${encodedAuth}`, - "Content-Type": "application/json", - }, - hostname: "rest.ably.io", - method: "POST", - path: `/keys/${keyName}/revokeTokens`, - port: 443, - }; - - const req = https.request(options, (res) => { - let data = ""; - - res.on("data", (chunk) => { - data += chunk; - }); - - res.on("end", () => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { - try { - const jsonResponse: Record | null = - data.length > 0 - ? (JSON.parse(data) as Record) - : null; - resolve(jsonResponse); - } catch { - resolve(data); - } - } else { - const err = new Error( - `Request failed with status code ${res.statusCode}: ${data}`, - ) as Error & { statusCode?: number }; - err.statusCode = res.statusCode; - reject(err); - } - }); - }); - - req.on("error", (error) => { - reject(error); - }); - - req.write(JSON.stringify(requestBody)); - req.end(); - }); - } } diff --git a/src/commands/bench/publisher.ts b/src/commands/bench/publisher.ts index b734f15d7..54d2a2721 100644 --- a/src/commands/bench/publisher.ts +++ b/src/commands/bench/publisher.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import chalk from "chalk"; import Table from "cli-table3"; @@ -54,6 +54,8 @@ interface BenchmarkPayload { } export default class BenchPublisher extends AblyBaseCommand { + protected override suffixClientIdPerProcess = true; + static override args = { channelName: Args.string({ description: "The channel name to publish to", @@ -107,7 +109,7 @@ export default class BenchPublisher extends AblyBaseCommand { private readonly MAX_LOG_LINES = 10; // Buffer for the last 10 logs private messageLogBuffer: string[] = []; - private realtime: Ably.Realtime | null = null; + private realtime: Ably.PubSubRealtimeClient | null = null; private presenceCount = 0; // Override finally to ensure resources are cleaned up diff --git a/src/commands/bench/subscriber.ts b/src/commands/bench/subscriber.ts index ff98b09e7..ebb385672 100644 --- a/src/commands/bench/subscriber.ts +++ b/src/commands/bench/subscriber.ts @@ -1,5 +1,5 @@ import { Args } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import chalk from "chalk"; import Table from "cli-table3"; @@ -20,6 +20,8 @@ interface TestMetrics { } export default class BenchSubscriber extends AblyBaseCommand { + protected override suffixClientIdPerProcess = true; + static override args = { channelName: Args.string({ description: "The channel name to subscribe to", @@ -46,7 +48,7 @@ export default class BenchSubscriber extends AblyBaseCommand { private intervalId: NodeJS.Timeout | null = null; private readonly MAX_LOG_LINES = 10; private messageLogBuffer: string[] = []; // Buffer for the last 10 logs - private realtime: Ably.Realtime | null = null; + private realtime: Ably.PubSubRealtimeClient | null = null; // Track whether a test is currently running and retain a reference to the // table that renders live status so we can update/clear it easily. @@ -353,7 +355,7 @@ export default class BenchSubscriber extends AblyBaseCommand { } private handleChannel( - client: Ably.Realtime, + client: Ably.PubSubRealtimeClient, channelName: string, flags: Record, ): Ably.RealtimeChannel { @@ -514,7 +516,7 @@ export default class BenchSubscriber extends AblyBaseCommand { private async setupClient( flags: Record, - ): Promise { + ): Promise { const realtime = await this.createAblyRealtimeClient(flags); if (!realtime) { this.fail( diff --git a/src/commands/channels/annotations/delete.ts b/src/commands/channels/annotations/delete.ts index 0d4c8e216..78399b7eb 100644 --- a/src/commands/channels/annotations/delete.ts +++ b/src/commands/channels/annotations/delete.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../../flags.js"; @@ -51,7 +51,7 @@ export default class ChannelsAnnotationsDelete extends AblyBaseCommand { const serial = args.messageSerial; const type = args.annotationType; - let client: Ably.Realtime | null = null; + let client: Ably.PubSubRealtimeClient | null = null; try { const summarization = extractSummarizationType(type); diff --git a/src/commands/channels/annotations/get.ts b/src/commands/channels/annotations/get.ts index f88e4789a..b8d1ba942 100644 --- a/src/commands/channels/annotations/get.ts +++ b/src/commands/channels/annotations/get.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { productApiFlags } from "../../../flags.js"; diff --git a/src/commands/channels/annotations/publish.ts b/src/commands/channels/annotations/publish.ts index d190e6b3b..138b0bd18 100644 --- a/src/commands/channels/annotations/publish.ts +++ b/src/commands/channels/annotations/publish.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../../flags.js"; diff --git a/src/commands/channels/annotations/subscribe.ts b/src/commands/channels/annotations/subscribe.ts index 508a27bca..2e57886f3 100644 --- a/src/commands/channels/annotations/subscribe.ts +++ b/src/commands/channels/annotations/subscribe.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { @@ -43,7 +43,7 @@ export default class ChannelsAnnotationsSubscribe extends AblyBaseCommand { }), }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; async run(): Promise { const { args, flags } = await this.parse(ChannelsAnnotationsSubscribe); diff --git a/src/commands/channels/append.ts b/src/commands/channels/append.ts index f39ca5767..6806eb299 100644 --- a/src/commands/channels/append.ts +++ b/src/commands/channels/append.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; diff --git a/src/commands/channels/delete.ts b/src/commands/channels/delete.ts index 4f9f95d0c..51b7640d4 100644 --- a/src/commands/channels/delete.ts +++ b/src/commands/channels/delete.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; diff --git a/src/commands/channels/get-message.ts b/src/commands/channels/get-message.ts index fffb04e9f..691764845 100644 --- a/src/commands/channels/get-message.ts +++ b/src/commands/channels/get-message.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { CommandError } from "../../errors/command-error.js"; diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index bee38d77d..1e6356981 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags, timeRangeFlags } from "../../flags.js"; @@ -63,7 +63,7 @@ export default class ChannelsHistory extends AblyBaseCommand { async run(): Promise { const { args, flags } = await this.parse(ChannelsHistory); const channelName = args.channelName; - let client: Ably.Rest | null; + let client: Ably.PubSubHttpClient | null; try { // Create a REST client diff --git a/src/commands/channels/occupancy/subscribe.ts b/src/commands/channels/occupancy/subscribe.ts index d66346479..2aef86566 100644 --- a/src/commands/channels/occupancy/subscribe.ts +++ b/src/commands/channels/occupancy/subscribe.ts @@ -1,5 +1,5 @@ import { Args } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { @@ -34,7 +34,7 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { ...durationFlag, }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; async run(): Promise { const { args, flags } = await this.parse(ChannelsOccupancySubscribe); diff --git a/src/commands/channels/presence/enter.ts b/src/commands/channels/presence/enter.ts index 6a6f5763f..f8a2a37cf 100644 --- a/src/commands/channels/presence/enter.ts +++ b/src/commands/channels/presence/enter.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { isJsonData } from "../../../utils/json-formatter.js"; @@ -52,7 +52,7 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { }), }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; private sequenceCounter = 0; private channel: Ably.RealtimeChannel | null = null; diff --git a/src/commands/channels/presence/subscribe.ts b/src/commands/channels/presence/subscribe.ts index 918d90cab..4d4915828 100644 --- a/src/commands/channels/presence/subscribe.ts +++ b/src/commands/channels/presence/subscribe.ts @@ -1,5 +1,5 @@ import { Args } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { @@ -34,7 +34,7 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { ...durationFlag, }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; async run(): Promise { const { args, flags } = await this.parse(ChannelsPresenceSubscribe); diff --git a/src/commands/channels/publish.ts b/src/commands/channels/publish.ts index 3eea3b98c..24d9e9db8 100644 --- a/src/commands/channels/publish.ts +++ b/src/commands/channels/publish.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; @@ -65,7 +65,7 @@ export default class ChannelsPublish extends AblyBaseCommand { }; private progressIntervalId: NodeJS.Timeout | null = null; - private realtime: Ably.Realtime | null = null; + private realtime: Ably.PubSubRealtimeClient | null = null; // Override finally to ensure resources are cleaned up async finally(err: Error | undefined): Promise { diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index 2c7722b84..962c9d45b 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, @@ -61,7 +61,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand { static override strict = false; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; private sequenceCounter = 0; async run(): Promise { diff --git a/src/commands/channels/update.ts b/src/commands/channels/update.ts index 6d2a0c4ab..78c50ec66 100644 --- a/src/commands/channels/update.ts +++ b/src/commands/channels/update.ts @@ -1,5 +1,5 @@ import { Args, Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; diff --git a/src/commands/connections/test.ts b/src/commands/connections/test.ts index dc81e9774..312a6c2e6 100644 --- a/src/commands/connections/test.ts +++ b/src/commands/connections/test.ts @@ -1,5 +1,6 @@ import { Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; +import { createRealtimeClient } from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { extractErrorInfo } from "../../utils/errors.js"; @@ -27,8 +28,8 @@ export default class ConnectionsTest extends AblyBaseCommand { }), }; - private wsClient: Ably.Realtime | null = null; - private xhrClient: Ably.Realtime | null = null; + private wsClient: Ably.PubSubRealtimeClient | null = null; + private xhrClient: Ably.PubSubRealtimeClient | null = null; // Override finally to ensure resources are cleaned up async finally(err: Error | undefined): Promise { @@ -250,7 +251,7 @@ export default class ConnectionsTest extends AblyBaseCommand { transportParams: Record; }, ): Promise<{ - client: Ably.Realtime | null; + client: Ably.PubSubRealtimeClient | null; error: Error | null; success: boolean; }> { @@ -265,14 +266,14 @@ export default class ConnectionsTest extends AblyBaseCommand { ); this.logProgress(`Testing ${config.displayName} connection to Ably`, flags); - let client: Ably.Realtime | null = null; + let client: Ably.PubSubRealtimeClient | null = null; try { const options: Ably.ClientOptions = { ...baseOptions, transportParams: config.transportParams, }; - client = new Ably.Realtime(options); + client = createRealtimeClient(options); client.connection.on((stateChange: Ably.ConnectionStateChange) => { this.logCliEvent( diff --git a/src/commands/logs/channel-lifecycle/subscribe.ts b/src/commands/logs/channel-lifecycle/subscribe.ts index c40c21abc..9feda56c2 100644 --- a/src/commands/logs/channel-lifecycle/subscribe.ts +++ b/src/commands/logs/channel-lifecycle/subscribe.ts @@ -1,4 +1,4 @@ -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; @@ -33,7 +33,7 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { ...rewindFlag, }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; // Override finally to ensure resources are cleaned up diff --git a/src/commands/logs/connection-lifecycle/subscribe.ts b/src/commands/logs/connection-lifecycle/subscribe.ts index 930de8a14..301da42c5 100644 --- a/src/commands/logs/connection-lifecycle/subscribe.ts +++ b/src/commands/logs/connection-lifecycle/subscribe.ts @@ -1,4 +1,4 @@ -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../../base-command.js"; import { @@ -31,7 +31,7 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { ...rewindFlag, }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; private cleanupChannelStateLogging: (() => void) | null = null; async run(): Promise { diff --git a/src/commands/logs/push/subscribe.ts b/src/commands/logs/push/subscribe.ts index 6db7b45f2..9f2bd8617 100644 --- a/src/commands/logs/push/subscribe.ts +++ b/src/commands/logs/push/subscribe.ts @@ -1,4 +1,4 @@ -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; @@ -32,7 +32,7 @@ export default class LogsPushSubscribe extends AblyBaseCommand { ...rewindFlag, }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; // Override finally to ensure resources are cleaned up diff --git a/src/commands/logs/subscribe.ts b/src/commands/logs/subscribe.ts index a46da9484..826dff789 100644 --- a/src/commands/logs/subscribe.ts +++ b/src/commands/logs/subscribe.ts @@ -1,5 +1,5 @@ import { Flags } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { AblyBaseCommand } from "../../base-command.js"; import { @@ -45,7 +45,7 @@ export default class LogsSubscribe extends AblyBaseCommand { }), }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; async run(): Promise { const { flags } = await this.parse(LogsSubscribe); diff --git a/src/commands/push/channels/list.ts b/src/commands/push/channels/list.ts index 9cbdafb3b..8fe766752 100644 --- a/src/commands/push/channels/list.ts +++ b/src/commands/push/channels/list.ts @@ -38,7 +38,7 @@ export default class PushChannelsList extends AblyBaseCommand { description: "Filter by device ID", }), "client-id": Flags.string({ - description: "Filter by client ID", + description: "Only list subscriptions for this client ID", }), limit: Flags.integer({ description: "Maximum number of results to return", diff --git a/src/commands/push/channels/remove-where.ts b/src/commands/push/channels/remove-where.ts index 170110cc6..37dd3d920 100644 --- a/src/commands/push/channels/remove-where.ts +++ b/src/commands/push/channels/remove-where.ts @@ -28,7 +28,7 @@ export default class PushChannelsRemoveWhere extends AblyBaseCommand { description: "Filter by device ID", }), "client-id": Flags.string({ - description: "Filter by client ID", + description: "Only remove subscriptions for this client ID", }), ...forceFlag, }; diff --git a/src/commands/push/channels/remove.ts b/src/commands/push/channels/remove.ts index fcdf34931..58b1eab9c 100644 --- a/src/commands/push/channels/remove.ts +++ b/src/commands/push/channels/remove.ts @@ -28,7 +28,7 @@ export default class PushChannelsRemove extends AblyBaseCommand { exclusive: ["client-id"], }), "client-id": Flags.string({ - description: "Client ID to unsubscribe", + description: "Client ID whose subscription to remove", exclusive: ["device-id"], }), ...forceFlag, diff --git a/src/commands/push/channels/save.ts b/src/commands/push/channels/save.ts index 94c105404..e49479a50 100644 --- a/src/commands/push/channels/save.ts +++ b/src/commands/push/channels/save.ts @@ -28,7 +28,7 @@ export default class PushChannelsSave extends AblyBaseCommand { exclusive: ["client-id"], }), "client-id": Flags.string({ - description: "Client ID to subscribe", + description: "Client ID to subscribe to the channel", exclusive: ["device-id"], }), }; diff --git a/src/commands/push/devices/list.ts b/src/commands/push/devices/list.ts index fba46e165..fcf79484b 100644 --- a/src/commands/push/devices/list.ts +++ b/src/commands/push/devices/list.ts @@ -31,7 +31,7 @@ export default class PushDevicesList extends AblyBaseCommand { description: "Filter by device ID", }), "client-id": Flags.string({ - description: "Filter by client ID", + description: "Only list devices registered to this client ID", }), state: Flags.string({ description: "Filter by device state", diff --git a/src/commands/push/devices/remove-where.ts b/src/commands/push/devices/remove-where.ts index 1947a08a1..0515f8871 100644 --- a/src/commands/push/devices/remove-where.ts +++ b/src/commands/push/devices/remove-where.ts @@ -20,7 +20,7 @@ export default class PushDevicesRemoveWhere extends AblyBaseCommand { description: "Filter by device ID", }), "client-id": Flags.string({ - description: "Filter by client ID", + description: "Only remove devices registered to this client ID", }), ...forceFlag, }; diff --git a/src/commands/push/devices/save.ts b/src/commands/push/devices/save.ts index ab3200acd..858f24de4 100644 --- a/src/commands/push/devices/save.ts +++ b/src/commands/push/devices/save.ts @@ -54,7 +54,7 @@ export default class PushDevicesSave extends AblyBaseCommand { description: "Web push auth secret (required for web transport)", }), "client-id": Flags.string({ - description: "Client ID to associate with the device", + description: "Client ID to register the device to", }), metadata: Flags.string({ description: "Device metadata as JSON", diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index dda3dd9d0..ddabbd0e2 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -35,7 +35,7 @@ export default class PushPublish extends AblyBaseCommand { exclusive: ["client-id", "recipient"], }), "client-id": Flags.string({ - description: "Target client ID", + description: "Client ID whose devices receive the notification", exclusive: ["device-id", "recipient"], }), recipient: Flags.string({ diff --git a/src/commands/spaces/occupancy/subscribe.ts b/src/commands/spaces/occupancy/subscribe.ts index 5e3839f23..76e9b4be9 100644 --- a/src/commands/spaces/occupancy/subscribe.ts +++ b/src/commands/spaces/occupancy/subscribe.ts @@ -1,5 +1,5 @@ import { Args } from "@oclif/core"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; @@ -35,7 +35,7 @@ export default class SpacesOccupancySubscribe extends SpacesBaseCommand { ...durationFlag, }; - private client: Ably.Realtime | null = null; + private client: Ably.PubSubRealtimeClient | null = null; async run(): Promise { const { args, flags } = await this.parse(SpacesOccupancySubscribe); diff --git a/src/data/env-vars.ts b/src/data/env-vars.ts index 2a21537b6..d0f8772fb 100644 --- a/src/data/env-vars.ts +++ b/src/data/env-vars.ts @@ -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: "`--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. The exception is a JWT whose client ID is `*`, which lets its holder choose: the CLI then acts as `--client-id`, `ABLY_CLIENT_ID`, or its default client ID.", }, ]), new DetailSection("Token expiry", [ @@ -212,6 +212,29 @@ const ABLY_APP_ID = new EnvVarEntry( [], ); +const ABLY_CLIENT_ID = new EnvVarEntry( + "ABLY_CLIENT_ID", + "Configuration", + "Client ID the CLI acts as", + "Any client ID except an empty string or `*`", + "A stable ID generated once per install", + "`--client-id` CLI flag > **`ABLY_CLIENT_ID`** > `client.id` in config > generated default", + ["channels", "rooms", "spaces", "connections", "bench", "logs", "auth"], + "Set the client ID every data plane command acts as under API key auth, so a machine or CI job keeps one identity across runs.", + new Example([ + `export ABLY_CLIENT_ID="deploy-bot"`, + `ably channels publish my-channel hello`, + ]), + [ + new DetailSection("Token authentication", [ + { + kind: "paragraph", + text: "Ignored when `ABLY_TOKEN` is set — the client ID comes from the token — unless the token is a JWT whose client ID is `*`. A wildcard token lets its holder choose, so the CLI acts as `ABLY_CLIENT_ID` (or `--client-id`).", + }, + ]), + ], +); + const ABLY_CLI_CONFIG_DIR = new EnvVarEntry( "ABLY_CLI_CONFIG_DIR", "Configuration", @@ -544,6 +567,7 @@ export const ENV_VARS_DATA: EnvVarsData = new EnvVarsData( ABLY_URL, ABLY_ENDPOINT, ABLY_APP_ID, + ABLY_CLIENT_ID, ABLY_CLI_CONFIG_DIR, ABLY_HISTORY_FILE, ABLY_CLI_DEFAULT_DURATION, diff --git a/src/flags.ts b/src/flags.ts index aec47cbb4..2c2b06dbb 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -71,11 +71,16 @@ 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. + * + * 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 + * this object. A command-local `client-id` flag (a push target or filter, a + * token's subject) never changes who the CLI acts as. */ 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. Defaults to ABLY_CLIENT_ID, or a stable ID generated for this install. Ignored under token authentication unless the token\'s client ID is "*".', }), }; diff --git a/src/services/client-identity.ts b/src/services/client-identity.ts new file mode 100644 index 000000000..c94c001c6 --- /dev/null +++ b/src/services/client-identity.ts @@ -0,0 +1,147 @@ +import jwt from "jsonwebtoken"; + +import type { ConfigManager } from "./config-manager.js"; + +/** Environment variable that sets the CLI's client ID for a whole session. */ +export const CLIENT_ID_ENV_VAR = "ABLY_CLIENT_ID"; + +/** The JWT claim that carries the client ID an Ably JWT is bound to. */ +export const CLIENT_ID_CLAIM = "x-ably-clientId"; + +/** The JWT claim that wraps a native Ably token inside a JWT. */ +const WRAPPED_TOKEN_CLAIM = "x-ably-token"; + +/** Deprecated sentinel that opts out of a client ID entirely. */ +const NO_CLIENT_ID = "none"; + +export type ClientIdSource = "flag" | "env" | "config" | "default"; + +export interface ClientIdentity { + /** The client ID to act as, or undefined when the user opted out with "none". */ + clientId: string | undefined; + source: ClientIdSource; + /** True when the deprecated "none" sentinel was used. */ + optedOut: boolean; +} + +const SOURCE_NAMES: Record = { + flag: "--client-id", + env: CLIENT_ID_ENV_VAR, + config: "the client.id config setting", + default: "the generated default client ID", +}; + +/** How a resolved client ID's source is named in messages to the user. */ +export function describeClientIdSource(source: ClientIdSource): string { + return SOURCE_NAMES[source]; +} + +/** A user-supplied client ID the CLI cannot act as. */ +export class InvalidClientIdError extends Error {} + +/** + * Reject client IDs that would otherwise fail obscurely: an empty value used + * to fall back to a random identity silently, and "*" is rejected by the SDK + * with an error that never names the flag the user typed. A minted token may + * still carry "*", which lets its holder assume any identity. + */ +function validateClientId( + value: string, + source: ClientIdSource, + allowWildcard: boolean, +): void { + const origin = SOURCE_NAMES[source]; + if (value.trim() === "") { + throw new InvalidClientIdError( + `${origin} must not be empty. Set a client ID, or omit ${origin} to use the default.`, + ); + } + + if (value === "*" && !allowWildcard) { + throw new InvalidClientIdError( + `${origin} cannot be "*". The wildcard is only valid in a token's client ID; the CLI must act as one concrete client ID.`, + ); + } +} + +function toIdentity( + value: string, + source: ClientIdSource, + allowWildcard = false, +): ClientIdentity { + validateClientId(value, source, allowWildcard); + const optedOut = value.toLowerCase() === NO_CLIENT_ID; + return { clientId: optedOut ? undefined : value, source, optedOut }; +} + +let processDefaultIdentity: ClientIdentity | undefined; + +/** + * The client ID this process acts as when no --client-id is given: + * ABLY_CLIENT_ID, then the config setting, then a generated install default + * persisted in the config. Resolved once per process so every client, every + * interactive command and every minted token share one identity. + */ +function resolveDefaultClientIdentity( + configManager: ConfigManager, +): ClientIdentity { + if (processDefaultIdentity) return processDefaultIdentity; + + const envClientId = process.env[CLIENT_ID_ENV_VAR]; + const configClientId = configManager.getClientId(); + + processDefaultIdentity = + envClientId === undefined + ? configClientId === undefined + ? toIdentity(configManager.getDefaultClientId(), "default") + : toIdentity(configClientId, "config") + : toIdentity(envClientId, "env"); + + return processDefaultIdentity; +} + +/** + * Resolve the client ID to act as: --client-id > ABLY_CLIENT_ID > config + * setting > generated install default. An explicit flag always wins, so two + * terminals can still act as different clients. + * + * `allowWildcard` accepts "*" from the flag, for commands that mint a token + * rather than act as the client ID. + * + * @throws InvalidClientIdError when the winning value is empty or a + * disallowed "*". + */ +export function resolveClientIdentity( + flagValue: string | undefined, + configManager: ConfigManager, + { allowWildcard = false }: { allowWildcard?: boolean } = {}, +): ClientIdentity { + return flagValue === undefined + ? resolveDefaultClientIdentity(configManager) + : toIdentity(flagValue, "flag", allowWildcard); +} + +/** Forget the memoized process identity. For tests only. */ +export function resetClientIdentityCache(): void { + processDefaultIdentity = undefined; +} + +export type TokenIdentity = + | { kind: "jwt"; clientId: string | undefined } + | { kind: "opaque" }; + +/** + * The client ID a token is bound to. A JWT carries it in a readable claim; a + * native Ably token, alone or wrapped in a JWT, is opaque, so its identity is + * only known once Ably accepts it. + */ +export function readTokenIdentity(token: string): TokenIdentity { + const payload = jwt.decode(token, { json: true }); + if (!payload || WRAPPED_TOKEN_CLAIM in payload) return { kind: "opaque" }; + + const clientId: unknown = payload[CLIENT_ID_CLAIM]; + return { + kind: "jwt", + clientId: typeof clientId === "string" ? clientId : undefined, + }; +} diff --git a/src/services/config-manager.ts b/src/services/config-manager.ts index ec9cc14aa..a397348ed 100644 --- a/src/services/config-manager.ts +++ b/src/services/config-manager.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -65,8 +66,17 @@ export interface OAuthSession { refreshToken: string; } +/** The identity the CLI acts as on the data plane, shared by every account. */ +export interface ClientConfig { + /** Client ID set by the user; wins over the generated default. */ + id?: string; + /** Stable client ID generated on first use, so runs don't mint random IDs. */ + defaultId?: string; +} + export interface AblyConfig { accounts: Record; + client?: ClientConfig; current?: { account?: string; /** @deprecated Legacy field migrated to account.currentAppId on first load */ @@ -184,9 +194,17 @@ export interface ConfigManager { // Config file getConfigPath(): string; saveConfig(): void; + + // Data plane client identity + getClientId(): string | undefined; + getDefaultClientId(): string; reloadConfig(): void; } +export function generateDefaultClientId(): string { + return `ably-cli-${randomUUID().slice(0, 8)}`; +} + // Type declaration for test mocks available on globalThis declare global { var __TEST_MOCKS__: @@ -331,6 +349,27 @@ export class TomlConfigManager implements ConfigManager { return this.configPath; } + public getClientId(): string | undefined { + return this.config.client?.id; + } + + // Get the install's default client ID, generating and persisting it on first use + public getDefaultClientId(): string { + const existing = this.config.client?.defaultId; + if (existing) return existing; + + const defaultId = generateDefaultClientId(); + this.config.client = { ...this.config.client, defaultId }; + try { + this.saveConfig(); + } catch { + // A read-only config (e.g. a locked-down CI container) must not break + // every command: keep the ID in memory for this process instead. + } + + return defaultId; + } + // Get the current account configuration public getCurrentAccount(): AccountConfig | undefined { const currentAlias = this.getCurrentAccountAlias(); diff --git a/src/spaces-base-command.ts b/src/spaces-base-command.ts index 201cb887f..05668536a 100644 --- a/src/spaces-base-command.ts +++ b/src/spaces-base-command.ts @@ -1,35 +1,21 @@ -import * as Ably from "ably"; -import Spaces, { type Space, type SpaceOptions } from "@ably/spaces"; +import type * as Ably from "@ably/pubsub-server"; +import { + createSpacesClient as createSdkSpacesClient, + type Space, + type SpaceOptions, + type SpacesClient, +} from "@ably/spaces"; import { AblyBaseCommand } from "./base-command.js"; import { productApiFlags } from "./flags.js"; import { BaseFlags } from "./types/cli.js"; -import { errorMessage } from "./utils/errors.js"; +import { errorMessage, errorWithReason } from "./utils/errors.js"; import isTestMode from "./utils/test-mode.js"; -// Dynamic import to handle module structure issues -let SpacesConstructor: (new (client: Ably.Realtime) => unknown) | null = null; - -async function getSpacesConstructor(): Promise< - new (client: Ably.Realtime) => unknown -> { - if (!SpacesConstructor) { - const spacesModule = (await import("@ably/spaces")) as unknown; - const moduleAsRecord = spacesModule as Record; - const defaultProperty = moduleAsRecord.default as - | Record - | undefined; - SpacesConstructor = (defaultProperty?.default || - moduleAsRecord.default || - moduleAsRecord) as new (client: Ably.Realtime) => unknown; - } - return SpacesConstructor; -} - export abstract class SpacesBaseCommand extends AblyBaseCommand { static globalFlags = { ...productApiFlags }; protected space: Space | null = null; - protected spaces: Spaces | null = null; - protected realtimeClient: Ably.Realtime | null = null; + protected spaces: SpacesClient | null = null; + protected realtimeClient: Ably.PubSubRealtimeClient | null = null; protected parsedFlags: BaseFlags = {}; protected hasEnteredSpace = false; @@ -171,7 +157,7 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { flags: BaseFlags, spaceName: string, ): Promise<{ - realtimeClient: Ably.Realtime; + realtimeClient: Ably.PubSubRealtimeClient; spacesClient: unknown; space: Space; }> { @@ -194,7 +180,7 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { } // Create a Spaces client using the Ably client - this.spaces = await this.createSpacesClient(this.realtimeClient); + this.spaces = this.createSpacesClient(this.realtimeClient); // We set the offline timeout to 2s otherwise Spaces will hang on to left members for 2 minutes. const options: Partial = { @@ -240,7 +226,10 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { state: connection.state, }); this.fail( - `${errorMsg}. Use --verbose to see detailed connection logs.`, + errorWithReason( + `${errorMsg}. Use --verbose to see detailed connection logs.`, + connection.errorReason, + ), flags, "connection", ); @@ -277,7 +266,7 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { this.logCliEvent(flags, "connection", "failed", errorMsg, { state: "failed", }); - reject(new Error(errorMsg)); + reject(errorWithReason(errorMsg, stateChange.reason)); }; const onClosed = () => { @@ -285,9 +274,9 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { reject(new Error("Connection closed unexpectedly")); }; - const onSuspended = () => { + const onSuspended = (stateChange: Ably.ConnectionStateChange) => { cleanup(); - reject(new Error("Connection suspended")); + reject(errorWithReason("Connection suspended", stateChange.reason)); }; connection.on("connected", onConnected); @@ -395,8 +384,9 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { channel.off("attached", onAttached); channel.off("failed", onFailed); reject( - new Error( + errorWithReason( `Cursors channel failed to attach: ${stateChange.reason?.message || "Unknown error"}`, + stateChange.reason, ), ); }; @@ -419,9 +409,9 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { } } - protected async createSpacesClient( - realtimeClient: Ably.Realtime, - ): Promise { + protected createSpacesClient( + realtimeClient: Ably.PubSubRealtimeClient, + ): SpacesClient { // If in test mode, skip connection and use mock if (isTestMode()) { this.debug(`Running in test mode, using mock Ably Spaces client`); @@ -439,7 +429,6 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { ); } - const Spaces = await getSpacesConstructor(); - return new Spaces(realtimeClient) as Spaces; + return createSdkSpacesClient(realtimeClient); } } diff --git a/src/utils/connection-health.ts b/src/utils/connection-health.ts new file mode 100644 index 000000000..b47f762af --- /dev/null +++ b/src/utils/connection-health.ts @@ -0,0 +1,67 @@ +import type * as Ably from "@ably/pubsub-server"; + +import { errorWithReason } from "./errors.js"; + +/** Consecutive unstable drops after which a connection counts as dead. */ +export const MAX_UNSTABLE_DROPS = 5; + +/** How long a connection must stay up for its next drop to start a new count. */ +export const STABLE_CONNECTION_MS = 10_000; + +/** + * Watches a connection's state changes and reports, once, when it has died: + * either it failed (a terminal state the SDK never leaves), or it keeps + * dropping without staying connected. The second is how a server-side + * rejection such as an eviction can present, as a loop the SDK retries + * forever rather than an error. + */ +export class ConnectionHealthMonitor { + private connectedAt: number | undefined; + private unstableDrops = 0; + private reported = false; + + constructor( + private readonly onDead: (error: Error) => void, + private readonly now: () => number = Date.now, + ) {} + + handle(change: Ably.ConnectionStateChange): void { + switch (change.current) { + case "connected": { + this.connectedAt = this.now(); + break; + } + + case "failed": { + this.report( + `Connection failed: ${change.reason?.message ?? "Unknown error"}`, + change.reason, + ); + break; + } + + case "disconnected": + case "suspended": { + const wasStable = + this.connectedAt !== undefined && + this.now() - this.connectedAt >= STABLE_CONNECTION_MS; + this.unstableDrops = wasStable ? 1 : this.unstableDrops + 1; + this.connectedAt = undefined; + + if (this.unstableDrops >= MAX_UNSTABLE_DROPS) { + this.report( + `Connection lost ${this.unstableDrops} times in a row without staying connected${change.reason ? `: ${change.reason.message}` : ""}`, + change.reason, + ); + } + break; + } + } + } + + private report(message: string, reason?: Ably.ErrorInfo | null): void { + if (this.reported) return; + this.reported = true; + this.onDead(errorWithReason(message, reason)); + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index d5035c5aa..b90771865 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -45,14 +45,59 @@ export function extractErrorInfo(error: unknown): { } /** - * Return a friendly, actionable hint for known Ably error codes. - * Returns undefined for unknown codes. + * Build an Error with a CLI-facing message that keeps the Ably code, status + * code and help URL of the underlying reason, so `fail()` can still attach + * the code and its hint. */ -const clientIdHint = "Use the --client-id flag to set a client identity."; +export function errorWithReason( + message: string, + reason?: { code?: number; statusCode?: number; href?: string } | null, +): Error { + return Object.assign(new Error(message), { + code: reason?.code, + statusCode: reason?.statusCode, + href: reason?.href, + }); +} + +/** What a hint may depend on: how the CLI authenticated and its flags. */ +export interface HintContext { + /** Authenticating with ABLY_TOKEN, so the client ID comes from the token. */ + tokenAuth?: boolean; + /** The command declares the identity --client-id flag. */ + hasClientIdFlag?: boolean; +} + +type Hint = string | ((context: HintContext) => string); + const tokenExpiredHint = "Generate a new token or use an API key instead. See https://ably.com/docs/auth for details."; -const hints: Record = { +const reissueTokenHint = + 'The client ID comes from your token: re-issue it with one, e.g. "ably auth issue-jwt-token --client-id ".'; + +function clientIdSources({ hasClientIdFlag }: HintContext): string { + return hasClientIdFlag + ? "--client-id or the ABLY_CLIENT_ID environment variable" + : "the ABLY_CLIENT_ID environment variable"; +} + +const clientIdHint: Hint = (context) => + context.tokenAuth + ? reissueTokenHint + : `Set a client ID with ${clientIdSources(context)}.`; + +const invalidClientIdHint: Hint = (context) => + context.tokenAuth + ? reissueTokenHint + : `Set a valid client ID (not empty and not "*") with ${clientIdSources(context)}.`; + +/** + * Return a friendly, actionable hint for known Ably error codes. + * Returns undefined for unknown codes. + */ +const hints: Record = { + 40012: invalidClientIdHint, 40101: 'Check your API key or token, or re-authenticate with "ably login".', 40103: "This is unexpected - TLS is enabled by default. Please report this issue at https://ably.com/support", @@ -71,7 +116,11 @@ const hints: Record = { 91000: clientIdHint, }; -export function getFriendlyAblyErrorHint(code?: number): string | undefined { +export function getFriendlyAblyErrorHint( + code?: number, + context: HintContext = {}, +): string | undefined { if (code === undefined) return undefined; - return hints[code]; + const hint = hints[code]; + return typeof hint === "function" ? hint(context) : hint; } diff --git a/src/utils/history.ts b/src/utils/history.ts index 478aedca9..a7682244f 100644 --- a/src/utils/history.ts +++ b/src/utils/history.ts @@ -1,4 +1,4 @@ -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { parseTimestamp } from "./time.js"; diff --git a/src/utils/long-running.ts b/src/utils/long-running.ts index e488f9b60..28e4a8005 100644 --- a/src/utils/long-running.ts +++ b/src/utils/long-running.ts @@ -8,10 +8,15 @@ import isTestMode from "./test-mode.js"; -export type ExitReason = "signal" | "timeout"; +export type ExitReason = "signal" | "timeout" | "aborted"; +/** + * Wait until Ctrl+C, the duration elapses, or `signal` aborts (which a + * command uses to stop waiting on a connection that has died). + */ export async function waitUntilInterruptedOrTimeout( durationSeconds?: number, + signal?: AbortSignal, ): Promise { // In test mode, we may have many instances running concurrently // Increase the max listeners to avoid warnings @@ -26,6 +31,7 @@ export async function waitUntilInterruptedOrTimeout( let sigintHandler: (() => void) | undefined; let sigtermHandler: (() => void) | undefined; let resolved = false; + const abortHandler = (): void => handleExit("aborted"); const handleExit = (reason: ExitReason): void => { if (resolved) { @@ -38,6 +44,7 @@ export async function waitUntilInterruptedOrTimeout( // Remove signal handlers if they were installed if (sigintHandler) process.removeListener("SIGINT", sigintHandler); if (sigtermHandler) process.removeListener("SIGTERM", sigtermHandler); + signal?.removeEventListener("abort", abortHandler); resolve(reason); }; @@ -66,5 +73,11 @@ export async function waitUntilInterruptedOrTimeout( process.once("SIGINT", sigintHandler); process.once("SIGTERM", sigtermHandler); + + if (signal?.aborted) { + handleExit("aborted"); + } else { + signal?.addEventListener("abort", abortHandler); + } }); } diff --git a/src/utils/message.ts b/src/utils/message.ts index 385fe5a84..7423c13f3 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -1,4 +1,4 @@ -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; export function interpolateMessage(template: string, count: number): string { let result = template.replaceAll("{{.Count}}", count.toString()); diff --git a/src/utils/output.ts b/src/utils/output.ts index 9d1a309ac..09062b439 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,4 +1,4 @@ -import type * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import chalk, { type ChalkInstance } from "chalk"; import { formatMessageData, isJsonData } from "./json-formatter.js"; diff --git a/test/e2e/auth/auth-tokens-e2e.test.ts b/test/e2e/auth/auth-tokens-e2e.test.ts index 6daaeb913..3424d00e0 100644 --- a/test/e2e/auth/auth-tokens-e2e.test.ts +++ b/test/e2e/auth/auth-tokens-e2e.test.ts @@ -179,6 +179,9 @@ describe.skipIf(SHOULD_SKIP_E2E)("Auth Tokens E2E Tests", () => { "x-ably-capability": { "*": ["*"] }, "x-ably-clientId": `e2e-revoke-key-client-${randomUUID().slice(0, 8)}`, "x-ably-revocation-key": revocationKey, + // The CLI always connects as a server, which token auth allows + // only with this claim. + "x-ably-clientType": "server", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600, jti: randomUUID(), diff --git a/test/e2e/channels/channels-e2e.test.ts b/test/e2e/channels/channels-e2e.test.ts index f13df50d8..b05eb159a 100644 --- a/test/e2e/channels/channels-e2e.test.ts +++ b/test/e2e/channels/channels-e2e.test.ts @@ -1,5 +1,5 @@ import { describe, it, beforeEach, afterEach, beforeAll, expect } from "vitest"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { E2E_API_KEY, SHOULD_SKIP_E2E, diff --git a/test/e2e/push/channels-e2e.test.ts b/test/e2e/push/channels-e2e.test.ts index 1a4d3778e..b4659f0be 100644 --- a/test/e2e/push/channels-e2e.test.ts +++ b/test/e2e/push/channels-e2e.test.ts @@ -7,7 +7,7 @@ import { afterAll, expect, } from "vitest"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { E2E_API_KEY, SHOULD_SKIP_E2E, @@ -21,7 +21,7 @@ import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Push Channel Subscriptions E2E Tests", () => { let testDeviceIdBase: string; - let client: Ably.Rest; + let client: Ably.PubSubHttpClient; let testDeviceId: string; beforeAll(async () => { diff --git a/test/e2e/push/devices-e2e.test.ts b/test/e2e/push/devices-e2e.test.ts index 2aa52506d..84898202a 100644 --- a/test/e2e/push/devices-e2e.test.ts +++ b/test/e2e/push/devices-e2e.test.ts @@ -7,7 +7,7 @@ import { afterAll, expect, } from "vitest"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { E2E_API_KEY, SHOULD_SKIP_E2E, @@ -21,7 +21,7 @@ import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { let testDeviceIdBase: string; - let client: Ably.Rest; + let client: Ably.PubSubHttpClient; beforeAll(async () => { // Generate unique device ID base for this test run diff --git a/test/e2e/push/publish-e2e.test.ts b/test/e2e/push/publish-e2e.test.ts index 2862a0e66..c83942535 100644 --- a/test/e2e/push/publish-e2e.test.ts +++ b/test/e2e/push/publish-e2e.test.ts @@ -7,7 +7,7 @@ import { afterAll, expect, } from "vitest"; -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; import { E2E_API_KEY, SHOULD_SKIP_E2E, @@ -21,7 +21,7 @@ import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { let testDeviceId: string; - let client: Ably.Rest; + let client: Ably.PubSubHttpClient; beforeAll(async () => { // Generate unique device ID for this test run diff --git a/test/helpers/ably-event-emitter.ts b/test/helpers/ably-event-emitter.ts index 7f563d8d3..c631065e7 100644 --- a/test/helpers/ably-event-emitter.ts +++ b/test/helpers/ably-event-emitter.ts @@ -6,7 +6,7 @@ * the real SDK behavior. */ -import * as Ably from "ably"; +import { createRealtimeClient } from "@ably/pubsub-server"; /** * Type for Ably's EventEmitter instance. @@ -20,8 +20,17 @@ export interface AblyEventEmitter { /** * Ably's internal EventEmitter constructor. - * Access it from the Ably.Realtime class where it's exposed internally. + * Access it from the realtime client class where it's exposed internally. The + * server package exports only factories, so reach the class through an + * unconnected client's constructor. */ +const realtimeClient = createRealtimeClient({ + key: "appId.keyId:keySecret", + autoConnect: false, +}); export const EventEmitter = ( - Ably.Realtime as unknown as { EventEmitter: new () => AblyEventEmitter } + realtimeClient.constructor as unknown as { + EventEmitter: new () => AblyEventEmitter; + } ).EventEmitter; +realtimeClient.close(); diff --git a/test/helpers/e2e-test-helper.ts b/test/helpers/e2e-test-helper.ts index 63c92c61c..01d1df474 100644 --- a/test/helpers/e2e-test-helper.ts +++ b/test/helpers/e2e-test-helper.ts @@ -1,4 +1,5 @@ -import * as Ably from "ably"; +import type * as Ably from "@ably/pubsub-server"; +import { createHttpClient, createRealtimeClient } from "@ably/pubsub-server"; import { randomUUID } from "node:crypto"; import { spawn, ChildProcess, exec } from "node:child_process"; import { promises as fs } from "node:fs"; @@ -67,7 +68,7 @@ export function getUniqueClientId(prefix = "cli-e2e-test"): string { /** * Create an Ably REST client for testing AND TRACK IT */ -export function createAblyClient(): Ably.Rest { +export function createAblyClient(): Ably.PubSubHttpClient { if (!E2E_API_KEY) { throw new Error( "E2E_ABLY_API_KEY environment variable is required for E2E tests", @@ -88,7 +89,7 @@ export function createAblyClient(): Ably.Rest { const _keyId = E2E_API_KEY.split(":")[0]?.split(".")[1]?.slice(0, 4) || "unknown-key"; - const client = new Ably.Rest({ + const client = createHttpClient({ key: E2E_API_KEY, clientId: clientId, }); @@ -101,7 +102,7 @@ export function createAblyClient(): Ably.Rest { /** * Create an Ably Realtime client for testing AND TRACK IT */ -export function createAblyRealtimeClient(): Ably.Realtime { +export function createAblyRealtimeClient(): Ably.PubSubRealtimeClient { if (!E2E_API_KEY) { throw new Error( "E2E_ABLY_API_KEY environment variable is required for E2E tests", @@ -122,7 +123,7 @@ export function createAblyRealtimeClient(): Ably.Realtime { const _keyId = E2E_API_KEY.split(":")[0]?.split(".")[1]?.slice(0, 4) || "unknown-key"; - const client = new Ably.Realtime({ + const client = createRealtimeClient({ key: E2E_API_KEY, clientId: clientId, }); diff --git a/test/helpers/mock-ably-realtime.ts b/test/helpers/mock-ably-realtime.ts index b4af91104..3346715aa 100644 --- a/test/helpers/mock-ably-realtime.ts +++ b/test/helpers/mock-ably-realtime.ts @@ -21,7 +21,11 @@ */ import { vi, type Mock } from "vitest"; -import type { Message, PresenceMessage, ConnectionStateChange } from "ably"; +import type { + Message, + PresenceMessage, + ConnectionStateChange, +} from "@ably/pubsub-server"; import { EventEmitter, type AblyEventEmitter } from "./ably-event-emitter.js"; // We use Ably's EventEmitter to match the SDK's API (on/off/once/emit) diff --git a/test/helpers/mock-ably-rest.ts b/test/helpers/mock-ably-rest.ts index f3e7a0275..19baf91e2 100644 --- a/test/helpers/mock-ably-rest.ts +++ b/test/helpers/mock-ably-rest.ts @@ -68,6 +68,7 @@ export interface MockRestAuth { clientId: string; createTokenRequest: Mock; requestToken: Mock; + revokeTokens: Mock; } /** @@ -232,6 +233,11 @@ function createMockRestAuth(): MockRestAuth { token: "mock-token", expires: Date.now() + 3600000, }), + revokeTokens: vi.fn().mockResolvedValue({ + successCount: 1, + failureCount: 0, + results: [], + }), }; } diff --git a/test/helpers/mock-config-manager.ts b/test/helpers/mock-config-manager.ts index e2d7565cb..0b0d13e0c 100644 --- a/test/helpers/mock-config-manager.ts +++ b/test/helpers/mock-config-manager.ts @@ -34,6 +34,9 @@ import type { // and breaks interception for commands that make HTTPS requests. const DEFAULT_OAUTH_HOST = "ably.com"; +/** The generated default client ID the mock reports when none is configured. */ +export const MOCK_DEFAULT_CLIENT_ID = "ably-cli-mockdflt"; + /** * Type for test configuration values. */ @@ -269,6 +272,18 @@ export class MockConfigManager implements ConfigManager { return "/mock/config/path"; } + public getClientId(): string | undefined { + return this.config.client?.id; + } + + public getDefaultClientId(): string { + this.config.client = { + ...this.config.client, + defaultId: this.config.client?.defaultId ?? MOCK_DEFAULT_CLIENT_ID, + }; + return this.config.client.defaultId!; + } + public getEndpoint(alias?: string): string | undefined { if (alias) { return this.config.accounts[alias]?.endpoint; diff --git a/test/setup.ts b/test/setup.ts index f8db6fc18..7aa3c22b2 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -3,8 +3,8 @@ import { config } from "dotenv"; import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { exec } from "node:child_process"; -import * as Ably from "ably"; -import type Spaces from "@ably/spaces"; +import type * as Ably from "@ably/pubsub-server"; +import type { SpacesClient } from "@ably/spaces"; import type { ChatClient } from "@ably/chat"; // Import types for test mocks @@ -14,10 +14,10 @@ import type { MockConfigManager } from "./helpers/mock-config-manager.js"; declare global { var __TEST_MOCKS__: | { - ablyRestMock: Ably.Rest; + ablyRestMock: Ably.PubSubHttpClient; ablyChatMock?: ChatClient; - ablySpacesMock?: Spaces; - ablyRealtimeMock?: Ably.Realtime; + ablySpacesMock?: SpacesClient; + ablyRealtimeMock?: Ably.PubSubRealtimeClient; configManager?: MockConfigManager; [key: string]: unknown; } @@ -28,7 +28,7 @@ declare global { process.env.ABLY_CLI_TEST_MODE = "true"; // Track active resources for cleanup -const activeClients: (Ably.Rest | Ably.Realtime)[] = []; +const activeClients: (Ably.PubSubHttpClient | Ably.PubSubRealtimeClient)[] = []; const globalProcessRegistry = new Set(); // Global process tracking function @@ -108,7 +108,9 @@ export async function cleanupGlobalProcesses(): Promise { /** * Utility to track an Ably client for cleanup */ -export function trackAblyClient(client: Ably.Rest | Ably.Realtime): void { +export function trackAblyClient( + client: Ably.PubSubHttpClient | Ably.PubSubRealtimeClient, +): void { if (!activeClients.includes(client)) { activeClients.push(client); } @@ -135,7 +137,7 @@ export async function globalCleanup(): Promise { }, 2000); // 2 second timeout per client try { - if (client instanceof Ably.Realtime) { + if ("connection" in client) { if ( client.connection.state === "closed" || client.connection.state === "failed" diff --git a/test/unit/base/base-command-enhanced.test.ts b/test/unit/base/base-command-enhanced.test.ts index 2c0a9b2b6..9243ed2e5 100644 --- a/test/unit/base/base-command-enhanced.test.ts +++ b/test/unit/base/base-command-enhanced.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as _fs from "node:fs"; -import * as _Ably from "ably"; import { AblyBaseCommand } from "../../../src/base-command.js"; import { ConfigManager as _ConfigManager } from "../../../src/services/config-manager.js"; import { InteractiveHelper as _InteractiveHelper } from "../../../src/services/interactive-helper.js"; diff --git a/test/unit/base/base-command.test.ts b/test/unit/base/base-command.test.ts index 29be6d15f..4fe2b3d0c 100644 --- a/test/unit/base/base-command.test.ts +++ b/test/unit/base/base-command.test.ts @@ -142,6 +142,8 @@ describe("AblyBaseCommand", function () { setCurrentApp: vi.fn(), storeAppInfo: vi.fn(), storeAppKey: vi.fn(), + getClientId: vi.fn(), + getDefaultClientId: vi.fn().mockReturnValue("ably-cli-default"), } as MockConfigManager; // Instead of stubbing loadConfig which is private, we'll stub methods that might access the file system diff --git a/test/unit/base/client-id-target-guard.test.ts b/test/unit/base/client-id-target-guard.test.ts new file mode 100644 index 000000000..acaa75467 --- /dev/null +++ b/test/unit/base/client-id-target-guard.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Config } from "@oclif/core"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); + +const TARGET = "target-client-id"; + +type ClientOptionsReader = { + getClientOptions(flags: Record): { clientId?: string }; +}; + +/** + * Commands such as `push devices remove-where --client-id user123` declare + * their own --client-id to name a target or filter. Before the identity flag + * was singled out, that value also became the CLI's own wire identity, which + * billed an end user who never connected. This guard covers every command, + * including ones added later. + */ +describe("command-local --client-id flags", () => { + let config: Config; + + beforeAll(async () => { + process.env.ABLY_API_KEY = "appId.keyId:keySecret"; + config = await Config.load({ root: projectRoot }); + }); + + afterAll(() => { + delete process.env.ABLY_API_KEY; + }); + + it("never become the client ID the CLI acts as", async () => { + // oclif loads commands outside the test's module graph, so take the shared + // identity flag from a command known to declare it rather than importing it. + const publish = await config.findCommand("channels:publish")!.load(); + const identityFlag = publish.flags["client-id"]; + + const targetCommands: string[] = []; + const leaked: string[] = []; + for (const command of config.commands) { + const CommandClass = await command.load(); + // Topic index commands declare no flags at all, despite the static type. + const flags = CommandClass.flags as Record | undefined; + const flag = flags?.["client-id"]; + if (!flag || flag === identityFlag) continue; + + targetCommands.push(command.id); + const instance = new (CommandClass as unknown as new ( + argv: string[], + config: Config, + ) => ClientOptionsReader)([], config); + + const { clientId } = instance.getClientOptions({ "client-id": TARGET }); + if (clientId === TARGET) leaked.push(command.id); + } + + expect(leaked).toEqual([]); + + // Guards the guard: if this drops to zero the loop above checked nothing. + expect(targetCommands).toContain("push:devices:remove-where"); + }); +}); diff --git a/test/unit/base/client-identity.test.ts b/test/unit/base/client-identity.test.ts new file mode 100644 index 000000000..2526d15f8 --- /dev/null +++ b/test/unit/base/client-identity.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Config } from "@oclif/core"; +import jwt from "jsonwebtoken"; + +import { AblyBaseCommand } from "../../../src/base-command.js"; +import { clientIdFlag } from "../../../src/flags.js"; +import { + CLIENT_ID_ENV_VAR, + readTokenIdentity, + resolveClientIdentity, +} from "../../../src/services/client-identity.js"; +import { BaseFlags } from "../../../src/types/cli.js"; +import { + MOCK_DEFAULT_CLIENT_ID, + getMockConfigManager, +} from "../../helpers/mock-config-manager.js"; + +class TestCommand extends AblyBaseCommand { + static override flags = { ...clientIdFlag }; + + async run(): Promise {} + + public testGetClientOptions(flags: BaseFlags) { + return this.getClientOptions(flags); + } + + public testDisplayAuthInfo(flags: BaseFlags) { + return this.displayAuthInfo(flags); + } +} + +class SuffixedCommand extends TestCommand { + protected override suffixClientIdPerProcess = true; +} + +function newCommand(Command = TestCommand): TestCommand { + return new Command([], { runHook: vi.fn() } as unknown as Config); +} + +const API_KEY = "appId.keyId:keySecret"; + +function signJwt(claims: Record): string { + return jwt.sign(claims, "keySecret", { + algorithm: "HS256", + keyid: "appId.keyId", + }); +} + +describe("client identity", () => { + beforeEach(() => { + process.env.ABLY_API_KEY = API_KEY; + }); + + afterEach(() => { + delete process.env.ABLY_API_KEY; + delete process.env.ABLY_TOKEN; + delete process.env[CLIENT_ID_ENV_VAR]; + }); + + describe("resolveClientIdentity", () => { + it("uses the generated install default when nothing is set", () => { + expect(resolveClientIdentity(undefined, getMockConfigManager())).toEqual({ + clientId: MOCK_DEFAULT_CLIENT_ID, + source: "default", + optedOut: false, + }); + }); + + it("prefers the config setting over the generated default", () => { + const config = getMockConfigManager(); + config.getConfig().client = { id: "configured" }; + expect(resolveClientIdentity(undefined, config).clientId).toBe( + "configured", + ); + }); + + it("prefers ABLY_CLIENT_ID over the config setting", () => { + const config = getMockConfigManager(); + config.getConfig().client = { id: "configured" }; + process.env[CLIENT_ID_ENV_VAR] = "from-env"; + expect(resolveClientIdentity(undefined, config)).toMatchObject({ + clientId: "from-env", + source: "env", + }); + }); + + it("prefers an explicit flag over everything else", () => { + process.env[CLIENT_ID_ENV_VAR] = "from-env"; + expect( + resolveClientIdentity("from-flag", getMockConfigManager()), + ).toMatchObject({ clientId: "from-flag", source: "flag" }); + }); + + it("resolves the default once per process", () => { + const config = getMockConfigManager(); + const first = resolveClientIdentity(undefined, config); + process.env[CLIENT_ID_ENV_VAR] = "changed-later"; + expect(resolveClientIdentity(undefined, config)).toBe(first); + }); + + it("treats 'none' as an opt-out", () => { + expect( + resolveClientIdentity("none", getMockConfigManager()), + ).toMatchObject({ clientId: undefined, optedOut: true }); + }); + + it.each(["", " ", "*"])("rejects %j", (value) => { + expect(() => + resolveClientIdentity(value, getMockConfigManager()), + ).toThrow(/--client-id/); + }); + + it("accepts '*' from the flag only when the wildcard is allowed", () => { + expect( + resolveClientIdentity("*", getMockConfigManager(), { + allowWildcard: true, + }), + ).toMatchObject({ clientId: "*", source: "flag" }); + }); + + it("names the client.id config setting when it is invalid", () => { + const config = getMockConfigManager(); + config.getConfig().client = { id: "*" }; + expect(() => resolveClientIdentity(undefined, config)).toThrow( + /client\.id config setting/, + ); + }); + + it("names ABLY_CLIENT_ID when the env var is invalid", () => { + process.env[CLIENT_ID_ENV_VAR] = "*"; + expect(() => + resolveClientIdentity(undefined, getMockConfigManager()), + ).toThrow(/ABLY_CLIENT_ID/); + }); + }); + + describe("readTokenIdentity", () => { + it("reads the client ID claim from a JWT", () => { + expect( + readTokenIdentity(signJwt({ "x-ably-clientId": "alice" })), + ).toEqual({ kind: "jwt", clientId: "alice" }); + }); + + it("reports a JWT without a client ID", () => { + expect(readTokenIdentity(signJwt({}))).toEqual({ + kind: "jwt", + clientId: undefined, + }); + }); + + it("treats a native Ably token as opaque", () => { + expect(readTokenIdentity("xVLyHw.opaque")).toEqual({ kind: "opaque" }); + }); + + it("treats a JWT wrapping a native Ably token as opaque", () => { + expect( + readTokenIdentity(signJwt({ "x-ably-token": "xVLyHw.opaque" })), + ).toEqual({ kind: "opaque" }); + }); + }); + + describe("getClientOptions", () => { + it("acts as the stable default client ID", () => { + const first = newCommand().testGetClientOptions({}); + const second = newCommand().testGetClientOptions({}); + expect(first.clientId).toBe(MOCK_DEFAULT_CLIENT_ID); + expect(second.clientId).toBe(MOCK_DEFAULT_CLIENT_ID); + }); + + it("acts as ABLY_CLIENT_ID when set", () => { + process.env[CLIENT_ID_ENV_VAR] = "deploy-bot"; + expect(newCommand().testGetClientOptions({}).clientId).toBe("deploy-bot"); + }); + + it("acts as an explicit --client-id", () => { + expect( + newCommand().testGetClientOptions({ "client-id": "alice" }).clientId, + ).toBe("alice"); + }); + + it("omits the client ID and warns for the deprecated 'none'", () => { + const command = newCommand(); + const stderr = vi + .spyOn(command as unknown as { logToStderr: () => void }, "logToStderr") + .mockImplementation(() => {}); + + const options = command.testGetClientOptions({ "client-id": "none" }); + + expect(options.clientId).toBeUndefined(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining("deprecated"), + ); + }); + + it("names where a deprecated 'none' came from", () => { + process.env[CLIENT_ID_ENV_VAR] = "none"; + const command = newCommand(); + const stderr = vi + .spyOn(command as unknown as { logToStderr: () => void }, "logToStderr") + .mockImplementation(() => {}); + + command.testGetClientOptions({}); + + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining(`"none" from ${CLIENT_ID_ENV_VAR}`), + ); + }); + + it.each(["", "*"])("fails for --client-id %j", (value) => { + expect(() => + newCommand().testGetClientOptions({ "client-id": value }), + ).toThrow(/--client-id/); + }); + + it("suffixes the default per process when the command asks", () => { + const options = newCommand(SuffixedCommand).testGetClientOptions({}); + expect(options.clientId).toMatch( + new RegExp(`^${MOCK_DEFAULT_CLIENT_ID}-[0-9a-f]{8}$`), + ); + }); + + it("shows the suffixed client ID it acts as in the banner", async () => { + const command = newCommand(SuffixedCommand); + const log = vi + .spyOn(command as unknown as { log: (msg: string) => void }, "log") + .mockImplementation(() => {}); + + const { clientId } = command.testGetClientOptions({}); + await command.testDisplayAuthInfo({}); + + expect(clientId).toMatch(/-[0-9a-f]{8}$/); + expect(log).toHaveBeenCalledWith(expect.stringContaining(clientId!)); + }); + + it("never suffixes an explicit --client-id", () => { + expect( + newCommand(SuffixedCommand).testGetClientOptions({ + "client-id": "alice", + }).clientId, + ).toBe("alice"); + }); + + it("never sets a client ID over a token's", () => { + process.env.ABLY_TOKEN = signJwt({ "x-ably-clientId": "alice" }); + expect(newCommand().testGetClientOptions({}).clientId).toBeUndefined(); + }); + + it("acts as --client-id under a wildcard JWT", () => { + process.env.ABLY_TOKEN = signJwt({ "x-ably-clientId": "*" }); + expect( + newCommand().testGetClientOptions({ "client-id": "alice" }).clientId, + ).toBe("alice"); + }); + + it("acts as the default client ID under a wildcard JWT", () => { + process.env.ABLY_TOKEN = signJwt({ "x-ably-clientId": "*" }); + expect(newCommand().testGetClientOptions({}).clientId).toBe( + MOCK_DEFAULT_CLIENT_ID, + ); + }); + + it("fails for a JWT without a client ID", () => { + process.env.ABLY_TOKEN = signJwt({}); + expect(() => newCommand().testGetClientOptions({})).toThrow( + /x-ably-clientId/, + ); + }); + + it("accepts an opaque native token", () => { + process.env.ABLY_TOKEN = "xVLyHw.opaque"; + expect(newCommand().testGetClientOptions({}).token).toBe("xVLyHw.opaque"); + }); + }); +}); diff --git a/test/unit/commands/auth/issue-ably-token.test.ts b/test/unit/commands/auth/issue-ably-token.test.ts index 4390dac61..e26228312 100644 --- a/test/unit/commands/auth/issue-ably-token.test.ts +++ b/test/unit/commands/auth/issue-ably-token.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { + MOCK_DEFAULT_CLIENT_ID, + getMockConfigManager, +} from "../../../helpers/mock-config-manager.js"; import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, @@ -136,18 +139,96 @@ describe("auth:issue-ably-token command", () => { restMock.auth.createTokenRequest.mockResolvedValue({}); restMock.auth.requestToken.mockResolvedValue(mockTokenDetails); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["auth:issue-ably-token", "--client-id", "none"], import.meta.url, ); expect(stdout).toContain("Ably token generated."); - expect(stdout).not.toContain("Client ID:"); + expect(stdout).toContain("Client ID: anonymous"); + expect(stderr).toContain("Acting with no client ID"); expect(restMock.auth.createTokenRequest).toHaveBeenCalled(); const tokenParams = restMock.auth.createTokenRequest.mock.calls[0][0]; expect(tokenParams.clientId).toBeUndefined(); }); + it("should output a null client ID in JSON when 'none' is specified", async () => { + const restMock = getMockAblyRest(); + restMock.auth.createTokenRequest.mockResolvedValue({}); + restMock.auth.requestToken.mockResolvedValue({ + token: "mock-ably-token-no-client", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: undefined, + }); + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--client-id", "none", "--json"], + import.meta.url, + ); + + const result = parseJsonOutput(stdout); + expect(result.token).toHaveProperty("clientId", null); + }); + + it("should default to the client ID the CLI acts as", async () => { + const restMock = getMockAblyRest(); + restMock.auth.createTokenRequest.mockResolvedValue({}); + + await runCommand(["auth:issue-ably-token"], import.meta.url); + + const tokenParams = restMock.auth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.clientId).toBe(MOCK_DEFAULT_CLIENT_ID); + }); + + it("should issue a wildcard token for --client-id '*'", async () => { + const restMock = getMockAblyRest(); + restMock.auth.createTokenRequest.mockResolvedValue({}); + restMock.auth.requestToken.mockResolvedValue({ + token: "mock-ably-token-wildcard", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: "*", + }); + + const { error } = await runCommand( + ["auth:issue-ably-token", "--client-id", "*"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + const tokenParams = restMock.auth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.clientId).toBe("*"); + }); + + it("should not resolve the default identity for an explicit --client-id", async () => { + const restMock = getMockAblyRest(); + restMock.auth.createTokenRequest.mockResolvedValue({}); + restMock.auth.requestToken.mockResolvedValue({ + token: "mock-ably-token-explicit", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: "alice", + }); + process.env.ABLY_CLIENT_ID = "*"; + + try { + const { error } = await runCommand( + ["auth:issue-ably-token", "--client-id", "alice"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + const tokenParams = restMock.auth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.clientId).toBe("alice"); + } finally { + delete process.env.ABLY_CLIENT_ID; + } + }); + it("should output only token string with --token-only flag", async () => { const restMock = getMockAblyRest(); const mockTokenString = "mock-ably-token-only"; diff --git a/test/unit/commands/auth/issue-jwt-token.test.ts b/test/unit/commands/auth/issue-jwt-token.test.ts index 5790ca336..fad0cdee2 100644 --- a/test/unit/commands/auth/issue-jwt-token.test.ts +++ b/test/unit/commands/auth/issue-jwt-token.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from "vitest"; import { runCommand } from "@oclif/test"; import jwt from "jsonwebtoken"; -import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { + MOCK_DEFAULT_CLIENT_ID, + getMockConfigManager, +} from "../../../helpers/mock-config-manager.js"; import { standardHelpTests, standardArgValidationTests, @@ -128,6 +131,60 @@ describe("auth:issue-jwt-token command", () => { expect(decoded["x-ably-clientId"]).toBeUndefined(); }); + it("should default to the client ID the CLI acts as", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + expect(jwt.decode(stdout.trim(), { json: true })).toHaveProperty( + "x-ably-clientId", + MOCK_DEFAULT_CLIENT_ID, + ); + }); + + it("should issue a wildcard token for --client-id '*'", async () => { + const apiKey = getMockConfigManager().getApiKey()!; + const mockKeySecret = apiKey.split(":")[1]; + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--client-id", "*", "--token-only"], + import.meta.url, + ); + + const decoded = jwt.verify(stdout.trim(), mockKeySecret, { + algorithms: ["HS256"], + }) as jwt.JwtPayload; + + expect(decoded["x-ably-clientId"]).toBe("*"); + }); + + it("should add the server claim with --client-type server", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--client-type", "server", "--json"], + import.meta.url, + ); + + const result = parseJsonOutput(stdout); + const token = (result.token as { value: string; clientType: string }) + .value; + expect(result.token).toHaveProperty("clientType", "server"); + expect(jwt.decode(token, { json: true })).toHaveProperty( + "x-ably-clientType", + "server", + ); + }); + + it("should omit the server claim by default", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + expect(jwt.decode(stdout.trim(), { json: true })).not.toHaveProperty( + "x-ably-clientType", + ); + }); + it("should output only token string with --token-only flag", async () => { const { stdout } = await runCommand( ["auth:issue-jwt-token", "--token-only"], @@ -247,13 +304,23 @@ describe("auth:issue-jwt-token command", () => { expect(stdout).toContain("Client ID: my-client"); }); - it("should omit client ID line when not specified with none", async () => { + it("should show the token as anonymous with none", async () => { const { stdout } = await runCommand( ["auth:issue-jwt-token", "--client-id", "none"], import.meta.url, ); - expect(stdout).not.toContain("Client ID:"); + expect(stdout).toContain("Client ID: anonymous"); + }); + + it("should output a null client ID in JSON with none", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--client-id", "none", "--json"], + import.meta.url, + ); + + const result = parseJsonOutput(stdout); + expect(result.token).toHaveProperty("clientId", null); }); }); }); diff --git a/test/unit/commands/auth/revoke-token.test.ts b/test/unit/commands/auth/revoke-token.test.ts index 9708860c7..41598cae8 100644 --- a/test/unit/commands/auth/revoke-token.test.ts +++ b/test/unit/commands/auth/revoke-token.test.ts @@ -1,9 +1,8 @@ import { Readable } from "node:stream"; -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; -import nock from "nock"; -import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardFlagTests, @@ -14,14 +13,6 @@ describe("auth:revoke-token command", () => { const mockClientId = "test-client-id"; const mockRevocationKey = "group1"; - beforeEach(() => { - nock.cleanAll(); - }); - - afterEach(() => { - nock.cleanAll(); - }); - standardHelpTests("auth:revoke-token", import.meta.url); describe("argument validation", () => { @@ -60,31 +51,22 @@ describe("auth:revoke-token command", () => { describe("functionality", () => { describe("token revocation by client ID", () => { it("should successfully revoke tokens for a client ID", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - }) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); + expect(auth.revokeTokens).toHaveBeenCalledWith( + [{ type: "clientId", value: mockClientId }], + undefined, + ); expect(stderr).toContain("have been revoked"); }); it("should include allowReauthMargin when --allow-reauth-margin is provided", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - allowReauthMargin: true, - }) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( [ @@ -97,18 +79,14 @@ describe("auth:revoke-token command", () => { import.meta.url, ); + expect(auth.revokeTokens).toHaveBeenCalledWith( + [{ type: "clientId", value: mockClientId }], + { allowReauthMargin: true }, + ); expect(stderr).toContain("have been revoked"); }); it("should output JSON format when --json flag is used", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`clientId:${mockClientId}`], - }) - .reply(200, { issuedBefore: 1234567890 }); - const { stdout } = await runCommand( [ "auth:revoke-token", @@ -139,13 +117,7 @@ describe("auth:revoke-token command", () => { describe("token revocation by revocation key", () => { it("should successfully revoke tokens for a revocation key", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`, { - targets: [`revocationKey:${mockRevocationKey}`], - }) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( [ @@ -157,6 +129,10 @@ describe("auth:revoke-token command", () => { import.meta.url, ); + expect(auth.revokeTokens).toHaveBeenCalledWith( + [{ type: "revocationKey", value: mockRevocationKey }], + undefined, + ); expect(stderr).toContain("have been revoked"); }); }); @@ -201,10 +177,7 @@ describe("auth:revoke-token command", () => { it("should cancel when user declines confirmation", async () => { mockStdinAnswer("n"); - - const revokeNock = nock("https://rest.ably.io") - .post(/\/keys\/.*\/revokeTokens/) - .reply(200, {}); + const { auth } = getMockAblyRest(); const { stderr } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId], @@ -212,18 +185,16 @@ describe("auth:revoke-token command", () => { ); expect(stderr).toContain("Revocation cancelled"); - expect(revokeNock.isDone()).toBe(false); + expect(auth.revokeTokens).not.toHaveBeenCalled(); }); }); }); describe("error handling", () => { it("should handle token not found error", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`) - .reply(404, "token_not_found"); + getMockAblyRest().auth.revokeTokens.mockRejectedValueOnce( + Object.assign(new Error("token_not_found"), { statusCode: 404 }), + ); const { error } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], @@ -236,35 +207,42 @@ describe("auth:revoke-token command", () => { }); it("should handle authentication error (invalid API key)", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`) - .reply(401, { error: { message: "Unauthorized" } }); + getMockAblyRest().auth.revokeTokens.mockRejectedValueOnce( + Object.assign(new Error("Unauthorized"), { + code: 40100, + statusCode: 401, + }), + ); const { error } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); - expect(error).toBeDefined(); - expect(error?.message).toMatch(/401|error|revoking/i); + expect(error?.message).toContain("Unauthorized"); }); - it("should handle server error", async () => { - const mockConfig = getMockConfigManager(); - const keyId = mockConfig.getKeyId()!; - nock("https://rest.ably.io") - .post(`/keys/${keyId}/revokeTokens`) - .reply(500, { error: "Internal Server Error" }); + it("should report a per-target failure", async () => { + getMockAblyRest().auth.revokeTokens.mockResolvedValueOnce({ + successCount: 0, + failureCount: 1, + results: [ + { + target: `clientId:${mockClientId}`, + error: Object.assign(new Error("Target rejected"), { + code: 40000, + statusCode: 400, + }), + }, + ], + }); const { error } = await runCommand( ["auth:revoke-token", "--client-id", mockClientId, "--force"], import.meta.url, ); - expect(error).toBeDefined(); - expect(error?.message).toMatch(/500|error|revoking/i); + expect(error?.message).toContain("Target rejected"); }); }); diff --git a/test/unit/commands/channels/subscribe.test.ts b/test/unit/commands/channels/subscribe.test.ts index 83c14c67b..463811e00 100644 --- a/test/unit/commands/channels/subscribe.test.ts +++ b/test/unit/commands/channels/subscribe.test.ts @@ -72,6 +72,28 @@ describe("channels:subscribe command", () => { ); }); + it("should exit non-zero when the connection fails mid-subscription", async () => { + const mock = getMockAblyRealtime(); + const commandPromise = runCommand( + ["channels:subscribe", "test-channel"], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockSubscribeCallback).not.toBeNull(); + }); + mock.connection._emit({ + current: "failed", + previous: "connected", + reason: { message: "Evicted", code: 40012, statusCode: 400 }, + } as Parameters[0]); + + const { error } = await commandPromise; + expect(error?.message).toContain("Connection failed: Evicted"); + expect(error?.message).toContain("Ably error code: 40012"); + expect(error?.oclif?.exit).toBeGreaterThan(0); + }); + it("should receive and display messages with event name and data", async () => { // Run command in background-like manner const commandPromise = runCommand( diff --git a/test/unit/commands/env.test.ts b/test/unit/commands/env.test.ts index 69d0e1bf3..b64b20a28 100644 --- a/test/unit/commands/env.test.ts +++ b/test/unit/commands/env.test.ts @@ -13,6 +13,7 @@ const CANONICAL_NAMES = [ "ABLY_ACCESS_TOKEN", "ABLY_ENDPOINT", "ABLY_APP_ID", + "ABLY_CLIENT_ID", "ABLY_CLI_CONFIG_DIR", "ABLY_HISTORY_FILE", "ABLY_CLI_DEFAULT_DURATION", @@ -81,7 +82,7 @@ describe("env command", () => { expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "env"); expect(result).toHaveProperty("success", true); - expect(result.envVars).toHaveLength(10); + expect(result.envVars).toHaveLength(11); expect(result.envVars[0]).toMatchObject({ name: "ABLY_API_KEY", category: "Authentication", @@ -89,7 +90,7 @@ describe("env command", () => { }); expect(result.envVars[3].name).toBe("ABLY_URL"); expect(result.envVars[4].name).toBe("ABLY_ENDPOINT"); - expect(result.envVars[9].name).toBe("ABLY_CLI_NON_INTERACTIVE"); + expect(result.envVars[10].name).toBe("ABLY_CLI_NON_INTERACTIVE"); expect(result.crossCutting).toBeDefined(); expect(result.crossCutting.authResolutionOrder.heading).toBe( "Authentication Resolution Order", diff --git a/test/unit/data/env-vars.test.ts b/test/unit/data/env-vars.test.ts index 42548a142..ac8a4f76d 100644 --- a/test/unit/data/env-vars.test.ts +++ b/test/unit/data/env-vars.test.ts @@ -17,6 +17,7 @@ const CANONICAL_NAMES = [ "ABLY_URL", "ABLY_ENDPOINT", "ABLY_APP_ID", + "ABLY_CLIENT_ID", "ABLY_CLI_CONFIG_DIR", "ABLY_HISTORY_FILE", "ABLY_CLI_DEFAULT_DURATION", @@ -28,8 +29,8 @@ describe("ENV_VARS_DATA", () => { expect(ENV_VARS_DATA).toBeInstanceOf(EnvVarsData); }); - it("lists exactly 10 variables in canonical order", () => { - expect(ENV_VARS_DATA.variables).toHaveLength(10); + it("lists exactly 11 variables in canonical order", () => { + expect(ENV_VARS_DATA.variables).toHaveLength(11); expect(ENV_VARS_DATA.variables.map((v) => v.name)).toEqual(CANONICAL_NAMES); }); @@ -73,6 +74,7 @@ describe("ENV_VARS_DATA", () => { expect(byName.ABLY_TOKEN.details).toHaveLength(4); expect(byName.ABLY_ACCESS_TOKEN.details).toHaveLength(1); expect(byName.ABLY_APP_ID.details).toHaveLength(0); + expect(byName.ABLY_CLIENT_ID.details).toHaveLength(1); expect(byName.ABLY_CLI_CONFIG_DIR.details).toHaveLength(0); expect(byName.ABLY_HISTORY_FILE.details).toHaveLength(1); expect(byName.ABLY_CLI_DEFAULT_DURATION.details).toHaveLength(0); diff --git a/test/unit/services/config-manager.test.ts b/test/unit/services/config-manager.test.ts index fdac1f3cd..809836213 100644 --- a/test/unit/services/config-manager.test.ts +++ b/test/unit/services/config-manager.test.ts @@ -501,4 +501,29 @@ accessToken = "testaccesstoken" }); }); }); + + describe("#getDefaultClientId", () => { + it("generates and persists a default on first use", () => { + const id = configManager.getDefaultClientId(); + + expect(id).toMatch(/^ably-cli-[0-9a-f]{8}$/); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining(`defaultId = "${id}"`), + expect.anything(), + ); + expect(configManager.getDefaultClientId()).toBe(id); + }); + + it("keeps the default in memory when the config cannot be written", () => { + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error("EROFS: read-only file system"); + }); + + const id = configManager.getDefaultClientId(); + + expect(id).toMatch(/^ably-cli-[0-9a-f]{8}$/); + expect(configManager.getDefaultClientId()).toBe(id); + }); + }); }); diff --git a/test/unit/setup.ts b/test/unit/setup.ts index 5fd9415e6..3ae8287d8 100644 --- a/test/unit/setup.ts +++ b/test/unit/setup.ts @@ -9,6 +9,7 @@ */ import { beforeAll, beforeEach, vi } from "vitest"; +import { resetClientIdentityCache } from "../../src/services/client-identity.js"; import { initializeMockConfigManager, resetMockConfig, @@ -46,6 +47,7 @@ beforeEach(() => { // Reset each mock's internal state resetMockConfig(); + resetClientIdentityCache(); resetMockAblyRealtime(); resetMockAblyRest(); resetMockAblySpaces(); diff --git a/test/unit/utils/connection-health.test.ts b/test/unit/utils/connection-health.test.ts new file mode 100644 index 000000000..8b160fd67 --- /dev/null +++ b/test/unit/utils/connection-health.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; + +import { + ConnectionHealthMonitor, + MAX_UNSTABLE_DROPS, + STABLE_CONNECTION_MS, +} from "../../../src/utils/connection-health.js"; + +type Change = Parameters[0]; + +function change( + current: Change["current"], + reason?: { message: string; code: number; statusCode: number }, +): Change { + return { current, previous: "connected", reason } as Change; +} + +describe("ConnectionHealthMonitor", () => { + it("reports a failed connection with its reason's code", () => { + const onDead = vi.fn(); + const monitor = new ConnectionHealthMonitor(onDead); + + monitor.handle( + change("failed", { message: "Evicted", code: 40012, statusCode: 400 }), + ); + + expect(onDead).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Connection failed: Evicted", + code: 40012, + statusCode: 400, + }), + ); + }); + + it("reports a reconnect loop that never stays connected", () => { + const onDead = vi.fn(); + const monitor = new ConnectionHealthMonitor(onDead, () => 0); + + for (let i = 0; i < MAX_UNSTABLE_DROPS - 1; i++) { + monitor.handle(change("connected")); + monitor.handle(change("disconnected")); + } + expect(onDead).not.toHaveBeenCalled(); + + monitor.handle(change("connected")); + monitor.handle(change("disconnected")); + expect(onDead).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining(`${MAX_UNSTABLE_DROPS} times`), + }), + ); + }); + + it("counts drops while never reaching connected", () => { + const onDead = vi.fn(); + const monitor = new ConnectionHealthMonitor(onDead); + + for (let i = 0; i < MAX_UNSTABLE_DROPS; i++) { + monitor.handle(change("connecting")); + monitor.handle(change("disconnected")); + } + + expect(onDead).toHaveBeenCalledOnce(); + }); + + it("restarts the count after a stable connection", () => { + const onDead = vi.fn(); + let now = 0; + const monitor = new ConnectionHealthMonitor(onDead, () => now); + + for (let i = 0; i < MAX_UNSTABLE_DROPS - 1; i++) { + monitor.handle(change("connected")); + monitor.handle(change("disconnected")); + } + + monitor.handle(change("connected")); + now += STABLE_CONNECTION_MS; + monitor.handle(change("disconnected")); + + expect(onDead).not.toHaveBeenCalled(); + }); + + it("reports only once", () => { + const onDead = vi.fn(); + const monitor = new ConnectionHealthMonitor(onDead); + + monitor.handle(change("failed")); + monitor.handle(change("failed")); + + expect(onDead).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/unit/utils/errors.test.ts b/test/unit/utils/errors.test.ts index 48ae9201b..af32d0b05 100644 --- a/test/unit/utils/errors.test.ts +++ b/test/unit/utils/errors.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { errorMessage } from "../../../src/utils/errors.js"; +import { + errorMessage, + errorWithReason, + getFriendlyAblyErrorHint, +} from "../../../src/utils/errors.js"; describe("errorMessage", () => { it("should extract message from Error instances", () => { @@ -11,3 +15,49 @@ describe("errorMessage", () => { expect(errorMessage(42)).toBe("42"); }); }); + +describe("errorWithReason", () => { + it("keeps the reason's code, status code and help URL", () => { + const error = errorWithReason("Connection failed: nope", { + code: 40012, + statusCode: 400, + href: "https://help.ably.io/error/40012", + }); + + expect(error).toMatchObject({ + message: "Connection failed: nope", + code: 40012, + statusCode: 400, + href: "https://help.ably.io/error/40012", + }); + }); +}); + +describe("getFriendlyAblyErrorHint", () => { + it("returns undefined for unknown codes", () => { + expect(getFriendlyAblyErrorHint(12345)).toBeUndefined(); + }); + + it.each([40012, 40161, 91000])( + "points %i at --client-id only when the command has it", + (code) => { + expect( + getFriendlyAblyErrorHint(code, { hasClientIdFlag: true }), + ).toContain("--client-id or the ABLY_CLIENT_ID"); + expect(getFriendlyAblyErrorHint(code, {})).not.toContain("--client-id"); + expect(getFriendlyAblyErrorHint(code, {})).toContain("ABLY_CLIENT_ID"); + }, + ); + + it.each([40012, 40161, 91000])( + "points %i at re-issuing the token under token auth", + (code) => { + expect( + getFriendlyAblyErrorHint(code, { + tokenAuth: true, + hasClientIdFlag: true, + }), + ).toContain("re-issue it"); + }, + ); +}); diff --git a/test/unit/utils/long-running.test.ts b/test/unit/utils/long-running.test.ts index ed79cff3d..e72c42afc 100644 --- a/test/unit/utils/long-running.test.ts +++ b/test/unit/utils/long-running.test.ts @@ -377,4 +377,37 @@ describe("waitUntilInterruptedOrTimeout", function () { await promise; }); }); + + describe("abort behavior", function () { + it("should resolve with 'aborted' when the signal aborts", async function () { + const controller = new AbortController(); + const promise = waitUntilInterruptedOrTimeout(10, controller.signal); + + controller.abort(new Error("connection died")); + + expect(await promise).toBe("aborted"); + }); + + it("should resolve immediately for an already-aborted signal", async function () { + const controller = new AbortController(); + controller.abort(); + + expect(await waitUntilInterruptedOrTimeout(10, controller.signal)).toBe( + "aborted", + ); + }); + + it("should remove signal handlers when aborted", async function () { + const controller = new AbortController(); + const promise = waitUntilInterruptedOrTimeout(10, controller.signal); + + controller.abort(); + await promise; + + expect(processRemoveListenerStub).toHaveBeenCalledWith( + "SIGINT", + expect.any(Function), + ); + }); + }); });