Skip to content

feat(mau): resolve one stable client identity per process - #459

Open
ttypic wants to merge 2 commits into
integration/maufrom
integration/mau-stable-client-id
Open

ttypic wants to merge 2 commits into
integration/maufrom
integration/mau-stable-client-id

Conversation

@ttypic

@ttypic ttypic commented Oct 1, 2026 •

Copy link
Copy Markdown

Every client the CLI built used to mint a random ably-cli-<uuid8>, so a
30-command session acted as 30 different clients and left random names
in message history and presence.

The CLI now resolves its client ID once per process with the precedence
--client-id > ABLY_CLIENT_ID > client.id in the config > an install
default generated once and persisted in the config. Every client, every
interactive command and every minted token share it.

  • Reject "" (which silently fell back to a random ID) and "*" with
    messages naming where the value came from; deprecate "none".
  • Under token auth the identity is the token's and is never overridden;
    a JWT without an x-ably-clientId claim is rejected up front.
  • Show the resolved client ID in the "Using:" banner.
  • bench suffixes its default identity per process so concurrent bench
    processes stay under the per-client-ID connection cap.

@vercel

vercel Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cli-web-cli Ready Ready Preview Oct 12, 2026 7:37pm UTC

Request Review

@claude-code-ably-assistant

Copy link
Copy Markdown

Walkthrough

This PR fixes an MAU (monthly active user) counting problem where every CLI command minted a fresh random ably-cli-<uuid8> client ID, making a 30-command session appear as 30 distinct clients. The fix resolves one stable identity per process using a clear precedence chain (--client-id > ABLY_CLIENT_ID env var > client.id in config > a generated default persisted on first use), and displays the resolved client ID in the "Using:" banner. Token auth is handled separately: the token's embedded identity is always used as-is, and a JWT without an x-ably-clientId claim is now rejected at startup.

Changes

Area Files Summary
Services src/services/client-identity.ts (new) New module owning all client-ID resolution logic: resolveClientIdentity(), readTokenIdentity(), validation, and a process-scoped cache
Services src/services/config-manager.ts Adds ClientConfig interface and getClientId() / getDefaultClientId() methods to persist the install-level default ID
Commands src/base-command.ts Replaces ad-hoc random-UUID generation with resolveClientId() / applyTokenIdentity(); adds suffixClientIdPerProcess hook; shows client ID in banner
Commands src/commands/auth/issue-ably-token.ts, issue-jwt-token.ts Token-minting commands now default to the stable CLI identity instead of a fresh UUID
Commands src/commands/bench/publisher.ts, subscriber.ts Set suffixClientIdPerProcess = true so concurrent bench processes stay under the per-client-ID connection cap
Config / Data src/data/env-vars.ts Registers ABLY_CLIENT_ID as a documented env var with precedence and token-auth caveat
Docs docs/Environment-Variables/General-Usage.md, docs/Project-Structure.md Documents ABLY_CLIENT_ID and the new client-identity.ts service file
Tests test/unit/base/client-identity.test.ts (new) 204-line suite covering resolution precedence, memoisation, invalid values, JWT reading, and getClientOptions integration
Tests test/unit/base/base-command.test.ts, test/unit/setup.ts Stub new config-manager methods; reset identity cache between tests
Tests test/unit/commands/env.test.ts, test/unit/data/env-vars.test.ts Update env-var count assertions (10 → 11) and add ABLY_CLIENT_ID to canonical lists
Tests test/helpers/mock-config-manager.ts Adds getClientId / getDefaultClientId stubs and MOCK_DEFAULT_CLIENT_ID export

Review Notes

  • Behavioral change — stable identity replaces random UUIDs: Every data-plane command now acts under one consistent client ID. Existing users whose workflows relied on commands being anonymous (no stable identity) will see a different clientId in Ably message history, presence, and MAU counts after upgrading. Worth calling out in release notes.
  • JWT with no x-ably-clientId is now a hard failure: Previously the CLI silently accepted any ABLY_TOKEN. Now a JWT without the claim is rejected with an error. Users with custom JWTs that omit the claim will break.
  • "none" is deprecated, not removed: Passing --client-id none still works but emits a deprecation warning. Consider whether this needs a migration guide or a removal timeline.
  • Process-scope memoisation (processDefaultIdentity): The identity is resolved once per process and cached in a module-level variable. This is intentional for the CLI use case but is a global singleton — reviewers should confirm the resetClientIdentityCache() call in test/unit/setup.ts properly isolates tests, especially under parallel test runs.
  • suffixClientIdPerProcess for bench commands: The suffix is appended only when the source is not "flag", which means an explicit --client-id on a bench run is used verbatim. Reviewers should verify this is the intended UX.
  • No new runtime dependencies: jsonwebtoken was already present; no new packages added.

@claude-code-ably-assistant

Copy link
Copy Markdown

Review: feat(mau): resolve one stable client identity per process

This PR replaces the per-run random ably-cli-{uuid} client ID with a stable per-install identity resolved through a clear priority chain: --client-id flag > ABLY_CLIENT_ID env > config setting > generated default persisted on first use. The design is clean, the test coverage is good, and the cache invalidation for tests is properly handled. Two things worth calling out before merging.

Breaking change: JWT tokens without x-ably-clientId now hard-fail

File: src/base-command.ts - applyTokenIdentity

Previously, ABLY_TOKEN with any valid JWT was silently accepted. Now readTokenIdentity calls jwt.decode(), which returns non-null for any structurally valid JWT. If the decoded payload has no x-ably-clientId claim, every command that calls getClientOptions() fails immediately.

Users who will be surprised by this:

  • Tokens issued before this PR (e.g. via ably auth issue-jwt-token without --client-id)
  • JWTs from external issuers that do not include x-ably-clientId
  • Anyone who previously used --client-id none when issuing a token

The error message is actionable, but this is a user-visible breaking change and should be called out in the changelog/PR description so it does not land silently.

ABLY_CLIENT_ID is silently ignored under token auth without a warning

The warning in applyTokenIdentity only fires when flags["client-id"] (the flag) is set. It does not fire when ABLY_CLIENT_ID is set in the environment. Since ABLY_CLIENT_ID is a newly documented feature, a user who sets it and also has ABLY_TOKEN active will see their configured identity silently bypassed. Extending the guard to also check process.env[CLIENT_ID_ENV_VAR] would cover this gap.

Everything else looks solid

  • Priority chain (flag > env > config > default) is correct and well-tested.
  • Module-level processDefaultIdentity memoization is safe; resetClientIdentityCache() in beforeEach handles test isolation correctly.
  • Per-process suffix for bench commands is correctly gated on source !== "flag" so an explicit --client-id is never mutated.
  • getDefaultClientId() writing to disk on first use is intentional and fine.
  • Mock config manager correctly implements the new interface.

@ttypic
ttypic requested a review from umair-ably October 1, 2026 12:59
@umair-ably
umair-ably added this pull request to stack #465 October 1, 2026 14:14
@sacOO7
sacOO7 requested a lite review from Copilot October 5, 2026 09:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved moderate issues remain in token validation, auth flag handling, benchmark identity consistency, and concurrent default persistence.

Review effort: Lite
Findings: 4 Medium severity · 1 Low severity

Open (5)
What changed in this PR

This PR gives the CLI a stable client identity across commands, authentication, configuration, and benchmark processes.

Changes:

  • Adds identity precedence, validation, and persisted defaults.
  • Applies identities to clients, banners, JWTs, and Ably tokens.
  • Adds benchmark suffixing, tests, and documentation updates.
File Summary
test/​unit/​setup.ts Resets identity state between tests.
test/​unit/​data/​env-vars.test.ts Updates environment metadata tests.
test/​unit/​commands/​env.test.ts Updates environment command expectations.
test/​unit/​base/​client-identity.test.ts Tests identity resolution behavior.
test/​unit/​base/​base-command.test.ts Updates base-command mocks.
test/​helpers/​mock-config-manager.ts Adds mock identity support.
src/​services/​config-manager.ts Persists generated default identities.
src/​services/​client-identity.ts Resolves and validates client identities.
src/​data/​env-vars.ts Documents ABLY_CLIENT_ID.
src/​commands/​bench/​subscriber.ts Enables benchmark identity suffixing.
src/​commands/​bench/​publisher.ts Enables benchmark identity suffixing.
src/​commands/​auth/​issue-jwt-token.ts Propagates resolved identity into JWTs.
src/​commands/​auth/​issue-ably-token.ts Propagates resolved identity into Ably tokens.
src/​base-command.ts Integrates identity resolution, token handling, and banners.
docs/​Project-Structure.md Documents the new service.
docs/​Environment-Variables/​General-Usage.md Documents the new environment variable.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/base-command.ts Outdated
Comment on lines +1633 to +1637
const clientId =
identity.clientId &&
this.suffixClientIdPerProcess &&
identity.source !== "flag"
? `${identity.clientId}-${randomUUID().slice(0, 8)}`
Comment thread src/commands/auth/issue-ably-token.ts Outdated
Comment on lines 90 to 95
tokenParams.clientId = flags["client-id"];
}
} else {
// Generate a default client ID
tokenParams.clientId = `ably-cli-${randomUUID().slice(0, 8)}`;
// Default to the identity the CLI itself acts as
tokenParams.clientId = this.resolveClientId(flags);
}
Comment thread src/commands/auth/issue-jwt-token.ts Outdated
Comment on lines +114 to +118
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;
// Default to the identity the CLI itself acts as
clientId = this.resolveClientId(flags) ?? null;
Comment on lines +358 to +362
const existing = this.config.client?.defaultId;
if (existing) return existing;

const defaultId = generateDefaultClientId();
this.config.client = { ...this.config.client, defaultId };
Comment thread src/services/client-identity.ts Outdated

@umair-ably umair-ably left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some claude assisted comments for this one:

  1. A config file that can't be written now breaks every data-plane command — config-manager.ts getDefaultClientId
  • On first use, getDefaultClientId calls saveConfig(), which throws Failed to save Ably config if the write fails.
  • resolveClientId only catches InvalidClientIdError and re-throws everything else.
  • So a CI container or Docker image with a read-only home directory or ABLY_CLI_CONFIG_DIR, and only ABLY_API_KEY set, worked before this PR and now fails on every command. The banner swallows the error; getClientOptions doesn't.
  • Fix: if the write fails, keep the generated ID in memory for this process and optionally logWarning once.
  1. Race on the first write — same method
  • When two CLI processes start on a fresh config (parallel CI steps, several bench processes), each generates its own default, and the last write wins. The other process runs under an ID that never gets saved.
  • saveConfig also writes the whole in-memory snapshot, so it can overwrite a token refresh another process just saved. That's the exact problem reloadConfig was added to prevent.
  • Fix: call reloadConfig(), re-check client.defaultId, then write.
  1. The bench banner shows a different client ID from the one used
  • bannerClientId calls resolveClientIdentity directly, so it ignores suffixClientIdPerProcess.
  • bench publisher and bench subscriber print Client=ably-cli-xxxx but actually connect as ably-cli-xxxx-<8 hex>.
  • Fix: skip the banner field when the command suffixes the ID, or note that it's suffixed per process.
  1. The --client-id description is stale — src/flags.ts:78 (not in the diff)
  • It still says Use "none" to explicitly set no client ID, which this PR deprecates.
  • Fix: drop the "none" sentence and mention that the default is a stable ID per install (or ABLY_CLIENT_ID). After pnpm prepare this also updates the generated docs and README.
  1. The deprecation message assumes "none" came from the flag
  • "Omit the value to use your default client ID" reads oddly when "none" was set via ABLY_CLIENT_ID or the config.
  • Fix: use SOURCE_NAMES[identity.source] in the message, as the validation errors already do.

Base automatically changed from integration/mau-new-packages to integration/mau October 12, 2026 11:38
Every client the CLI built used to mint a random `ably-cli-<uuid8>`, so a
30-command session acted as 30 different clients and left random names
in message history and presence.

The CLI now resolves its client ID once per process with the precedence
--client-id > ABLY_CLIENT_ID > `client.id` in the config > an install
default generated once and persisted in the config. Every client, every
interactive command and every minted token share it.

- Reject "" (which silently fell back to a random ID) and "*" with
  messages naming where the value came from; deprecate "none".
- Under token auth the identity is the token's and is never overridden;
  a JWT without an `x-ably-clientId` claim is rejected up front.
- Show the resolved client ID in the "Using:" banner.
- bench suffixes its default identity per process so concurrent bench
  processes stay under the per-client-ID connection cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Resolve the acting identity once per command, bench suffix
  included, so the banner shows the ID the clients connect as
- Route explicit --client-id on the token commands through the
  shared resolver: "none" now warns as deprecated, and "*" is
  allowed so wildcard tokens can still be minted. With an explicit
  --client-id the issuing client acts as the token's identity, so
  the default identity is never resolved
- Under a wildcard ("*") JWT, act as --client-id, ABLY_CLIENT_ID or
  the default client ID, since the token lets its holder choose
- Keep the generated default ID in memory if the config can't be
  written, instead of failing every command
- Name the client.id config setting correctly in errors, say which
  source a deprecated "none" came from, and update the --client-id
  descriptions

Tests cover the banner matching the suffixed ID, wildcard tokens
when minting and connecting, source names in messages, and the
default ID's first-write and read-only-config cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ttypic

ttypic commented Oct 12, 2026

Copy link
Copy Markdown
Author

@umair-ably fixed 1,3,4,5. For 2 I think race is something not very harmful and expected. We can come up with very sophisticated locking mechanism, but I think it will be over engineering

This branch was successfully deployed

1 active deployment
Preview — f432c0a0 Deployed Oct 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants