Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
099f6aa
DX-1205: throw on legacy v1 ably/promises and ably/callbacks imports
umair-ably May 22, 2026
1ff768b
DX-1205: split legacy import-shim throws into message + hint
umair-ably May 26, 2026
ee7cfd6
DX-1209: inline fix-it hints on SDK ErrorInfo throw sites
umair-ably May 26, 2026
75c7dc7
DX-1209: tighten hint language, forecast server walls, add CLI tips
umair-ably May 26, 2026
d4062f8
DX-1209: add scripts/hint-coverage.ts + wire into lint CI
umair-ably May 27, 2026
89ec75a
DX-1209: address Lint + Bundle CI failures
umair-ably May 27, 2026
79f0756
DX-1209: fix push plugin bugs surfaced by PR #2233 review
umair-ably May 28, 2026
b289067
DX-1209: extend ErrorInfo with values-object constructor overload
umair-ably May 28, 2026
25b1569
DX-1209: migrate hint-bearing throw sites to single-call form
umair-ably May 28, 2026
488e1fa
DX-1209: tighten hint and message content per PR #2233 review
umair-ably May 28, 2026
f903ebb
DX-1209: style polish across hint text
umair-ably May 28, 2026
9b70058
DX-1209: rewrite hint-coverage as a TypeScript AST walker
umair-ably May 28, 2026
b5e9963
DX-1209: tighten error-hints test + document bundle threshold bump
umair-ably May 28, 2026
270e7e2
DX-1209: trim message/hint redundancy per D1 audit
umair-ably May 28, 2026
6e49005
DX-1209: forward inner hint when wrapping decode failures
umair-ably May 28, 2026
aa43931
DX-1209: broaden device-token hint to cover unsubscribe path
umair-ably May 28, 2026
22170a7
DX-1209: drop hint-coverage script and hint-pinning tests
umair-ably May 29, 2026
5f896cd
DX-1209: second message/hint redundancy pass
umair-ably Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions callbacks.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @deprecated `'ably/callbacks'` was the v1 callback API entry point and has been removed in ably-js v2.
* v2 is promise-only — import from `'ably'` directly and switch to `await` / `.then()`.
*
* Importing this subpath throws at module load with the migration link.
*
* @see https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md
*/
declare const ablyCallbacksV1EntryPointRemoved: never;
export = ablyCallbacksV1EntryPointRemoved;
6 changes: 6 additions & 0 deletions callbacks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

const err = new Error("'ably/callbacks' was the v1 callback API entry point and is no longer available.");
err.hint =
"ably-js v2 is promise-only — import from 'ably' directly and switch to await / .then(). See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md";
throw err;
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,26 @@
"types": "./liveobjects.d.ts",
"default": "./build/liveobjects.js"
}
},
"./promises": {
"types": "./promises.d.ts",
"default": "./promises.js"
},
"./callbacks": {
"types": "./callbacks.d.ts",
"default": "./callbacks.js"
}
},
"files": [
"build/**",
"ably.d.ts",
"callbacks.d.ts",
"callbacks.js",
"liveobjects.d.ts",
"liveobjects.d.mts",
"modular.d.ts",
"promises.d.ts",
"promises.js",
"push.d.ts",
"resources/**",
"src/**",
Expand Down
10 changes: 10 additions & 0 deletions promises.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @deprecated `'ably/promises'` was the v1 entry point and is no longer available in ably-js v2.
* v2 is promise-only — import from `'ably'` directly.
*
* Importing this subpath throws at module load with the migration link.
*
* @see https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md
*/
declare const ablyPromisesV1EntryPointRemoved: never;
export = ablyPromisesV1EntryPointRemoved;
6 changes: 6 additions & 0 deletions promises.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

const err = new Error("'ably/promises' was the v1 entry point and is no longer available.");
err.hint =
"ably-js v2 is promise-only — import from 'ably' directly. See https://github.com/ably/ably-js/blob/main/docs/migration-guides/v2/lib.md";
throw err;
2 changes: 1 addition & 1 deletion scripts/moduleReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { gzip } from 'zlib';
import Table from 'cli-table';

// The maximum size we allow for a minimal useful Realtime bundle (i.e. one that can subscribe to a channel)
const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 107, gzip: 33 };
const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 118, gzip: 36 };

const baseClientNames = ['BaseRest', 'BaseRealtime'];

Expand Down
199 changes: 142 additions & 57 deletions src/common/lib/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,14 @@ class Auth {
} else {
/* Basic auth */
if (!options.key) {
const msg =
'No authentication options provided; need one of: key, authUrl, or authCallback (or for testing only, token or tokenDetails)';
const msg = 'No authentication options provided';
Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth()', msg);
throw new ErrorInfo(msg, 40160, 401);
throw new ErrorInfo({
message: msg,
code: 40160,
statusCode: 401,
hint: 'Pass one of ClientOptions.{ key, authUrl, authCallback, token, tokenDetails }. For production, prefer authUrl or authCallback so the API key stays on your server.',
Comment thread
umair-ably marked this conversation as resolved.
});
}
Logger.logAction(this.logger, Logger.LOG_MINOR, 'Auth()', 'anonymous, using basic auth');
this._saveBasicOptions(options);
Expand Down Expand Up @@ -269,7 +273,12 @@ class Auth {
/* RSA10a: authorize() call implies token auth. If a key is passed it, we
* just check if it doesn't clash and assume we're generating a token from it */
if (authOptions && authOptions.key && this.authOptions.key !== authOptions.key) {
throw new ErrorInfo('Unable to update auth options with incompatible key', 40102, 401);
throw new ErrorInfo({
message: 'Unable to update auth options with incompatible key',
code: 40102,
statusCode: 401,
hint: 'auth.authorize() cannot change the API key - the new authOptions.key differs from the one the client was constructed with. To use a different key, construct a new Ably client.',
Comment thread
umair-ably marked this conversation as resolved.
});
}

try {
Expand Down Expand Up @@ -486,27 +495,39 @@ class Auth {
}
if (Platform.BufferUtils.isBuffer(body)) body = body.toString();
if (!contentType) {
cb(new ErrorInfo('authUrl response is missing a content-type header', 40170, 401), null);
const err = new ErrorInfo({
message: 'authUrl response is missing a content-type header',
code: 40170,
statusCode: 401,
hint: 'Auth endpoints may return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt.',
});
cb(err, null);
return;
}
const json = contentType.indexOf('application/json') > -1,
text = contentType.indexOf('text/plain') > -1 || contentType.indexOf('application/jwt') > -1;
if (!json && !text) {
cb(
new ErrorInfo(
'authUrl responded with unacceptable content-type ' +
contentType +
', should be either text/plain, application/jwt or application/json',
40170,
401,
),
null,
);
const err = new ErrorInfo({
message:
'authUrl responded with unacceptable Content-Type ' +
contentType +
'. Expected one of: text/plain, application/jwt or application/json',
code: 40170,
statusCode: 401,
hint: 'Auth endpoints may return a Content-Type of application/json (TokenDetails/TokenRequest), text/plain (token string) or application/jwt; the SDK cannot parse other content types.',
});
cb(err, null);
return;
}
if (json) {
if ((body as string).length > MAX_TOKEN_LENGTH) {
cb(new ErrorInfo('authUrl response exceeded max permitted length', 40170, 401), null);
const err = new ErrorInfo({
message: 'authUrl response exceeded max permitted length',
code: 40170,
statusCode: 401,
hint: 'authUrl payloads must be under 128 KB. If your TokenDetails legitimately contains a large capability, trim unused fields or set authOptions.suppressMaxLengthCheck. Otherwise check the endpoint is returning only the token shape, not wrapped in extra JSON.',
});
cb(err, null);
return;
}
try {
Expand Down Expand Up @@ -585,7 +606,12 @@ class Auth {
'Auth()',
'library initialized with a token literal without any way to renew the token when it expires (no authUrl, authCallback, or key). See https://help.ably.io/error/40171 for help',
);
throw new ErrorInfo(msg, 40171, 403);
throw new ErrorInfo({
message: msg,
code: 40171,
statusCode: 403,
hint: 'Initialise the client with one of ClientOptions.{ key, authUrl, authCallback } so the SDK can refresh tokens. A bare token/tokenDetails alone cannot be renewed once expired.',
});
}

/* normalise token params */
Expand Down Expand Up @@ -628,7 +654,13 @@ class Auth {
tokenRequestCallbackTimeoutExpired = true;
const msg = 'Token request callback timed out after ' + timeoutLength / 1000 + ' seconds';
Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg);
reject(new ErrorInfo(msg, 40170, 401));
const err = new ErrorInfo({
message: msg,
code: 40170,
statusCode: 401,
hint: 'authCallback did not invoke its callback within the timeout, or authUrl did not respond. Check that the callback runs to completion on every code path and that authUrl is reachable.',
});
reject(err);
}, timeoutLength);

tokenRequestCallback!(resolvedTokenParams, (err, tokenRequestOrDetails, contentType) => {
Expand All @@ -648,29 +680,41 @@ class Auth {
/* the response from the callback might be a token string, a signed request or a token details */
if (typeof tokenRequestOrDetails === 'string') {
if (tokenRequestOrDetails.length === 0) {
reject(new ErrorInfo('Token string is empty', 40170, 401));
const err = new ErrorInfo({
message: 'Token string is empty',
code: 40170,
statusCode: 401,
hint: 'Return a non-empty token string, or a TokenDetails/TokenRequest object.',
});
reject(err);
} else if (tokenRequestOrDetails.length > MAX_TOKEN_LENGTH) {
reject(
new ErrorInfo(
'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)',
40170,
401,
),
);
const err = new ErrorInfo({
message: 'Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)',
code: 40170,
statusCode: 401,
hint: 'Tokens must be under 128 KB. If the TokenDetails legitimately contains a large capability, trim unused fields. Otherwise check the endpoint is returning only the token, not wrapped in extra data.',
});
reject(err);
} else if (tokenRequestOrDetails === 'undefined' || tokenRequestOrDetails === 'null') {
/* common failure mode with poorly-implemented authCallbacks */
reject(new ErrorInfo('Token string was literal null/undefined', 40170, 401));
const err = new ErrorInfo({
message: 'Token string was literal null/undefined',
code: 40170,
statusCode: 401,
hint: 'Return the token itself, not "undefined"/"null"; callbacks that have no value to return should pass an error instead.',
});
reject(err);
} else if (
tokenRequestOrDetails[0] === '{' &&
!(contentType && contentType.indexOf('application/jwt') > -1)
) {
reject(
new ErrorInfo(
"Token was double-encoded; make sure you're not JSON-encoding an already encoded token request or details",
40170,
401,
),
);
const err = new ErrorInfo({
message: 'Token was double-encoded',
code: 40170,
statusCode: 401,
hint: 'Return TokenDetails/TokenRequest as a parsed object, or set Content-Type: application/jwt for JWT tokens.',
});
reject(err);
} else {
resolve({ token: tokenRequestOrDetails } as API.TokenDetails);
}
Expand All @@ -681,18 +725,25 @@ class Auth {
'Expected token request callback to call back with a token string or token request/details object, but got a ' +
typeof tokenRequestOrDetails;
Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg);
reject(new ErrorInfo(msg, 40170, 401));
const err = new ErrorInfo({
message: msg,
code: 40170,
statusCode: 401,
hint: 'authCallback must invoke its callback with (err, tokenStringOrTokenDetailsOrTokenRequest). authUrl must respond with a token string or TokenDetails/TokenRequest JSON.',
});
reject(err);
return;
}
const objectSize = JSON.stringify(tokenRequestOrDetails).length;
if (objectSize > MAX_TOKEN_LENGTH && !resolvedAuthOptions.suppressMaxLengthCheck) {
reject(
new ErrorInfo(
const err = new ErrorInfo({
message:
'Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)',
40170,
401,
),
);
code: 40170,
statusCode: 401,
hint: 'Token objects must serialise to under 128 KB. Trim unused fields from your TokenDetails/TokenRequest, or set authOptions.suppressMaxLengthCheck if you understand the risk.',
});
reject(err);
return;
}
if ('issued' in tokenRequestOrDetails) {
Expand All @@ -704,7 +755,13 @@ class Auth {
const msg =
'Expected token request callback to call back with a token string, token request object, or token details object';
Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth.requestToken()', msg);
reject(new ErrorInfo(msg, 40170, 401));
const err = new ErrorInfo({
message: msg,
code: 40170,
statusCode: 401,
hint: 'Your authCallback/authUrl returned an object without a `keyName` (so it was treated as a TokenDetails) and that shape was also rejected. Return either a token string, a TokenRequest (with keyName), or a TokenDetails (with token).',
});
reject(err);
return;
}
/* it's a token request, so make the request */
Expand Down Expand Up @@ -775,18 +832,33 @@ class Auth {

const key = authOptions.key;
if (!key) {
throw new ErrorInfo('No key specified', 40101, 403);
throw new ErrorInfo({
message: 'No key specified',
code: 40101,
statusCode: 403,
hint: 'createTokenRequest needs an API key. Pass ClientOptions.key on the client or { key } in the authOptions argument. Token-auth clients cannot construct token requests themselves.',
});
}
const keyParts = key.split(':'),
keyName = keyParts[0],
keySecret = keyParts[1];

if (!keySecret) {
throw new ErrorInfo('Invalid key specified', 40101, 403);
throw new ErrorInfo({
message: 'Invalid key specified',
code: 40101,
statusCode: 403,
hint: 'API keys are "appId.keyId:secret". Copy the full key including the colon from the Ably dashboard. If you have the Ably CLI installed, `ably auth keys list` shows the keys configured on the current app.',
});
}

if (tokenParams.clientId === '') {
throw new ErrorInfo('clientId can’t be an empty string', 40012, 400);
throw new ErrorInfo({
message: 'clientId can’t be an empty string',
code: 40012,
statusCode: 400,
hint: 'Pass a non-empty clientId, or omit the field entirely for an anonymous token.',
});
}

if ('capability' in tokenParams) {
Expand Down Expand Up @@ -906,11 +978,13 @@ class Auth {
if (token) {
if (this._tokenClientIdMismatch(token.clientId)) {
/* 403 to trigger a permanently failed client - RSA15c */
throw new ErrorInfo(
'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')',
40102,
403,
);
throw new ErrorInfo({
message:
'Mismatch between clientId in token (' + token.clientId + ') and current clientId (' + this.clientId + ')',
code: 40102,
statusCode: 403,
hint: 'Issue the token with the same clientId as ClientOptions.clientId, or omit ClientOptions.clientId and let the token define it. The two cannot diverge.',
});
}
/* RSA4b1 -- if we have a server time offset set already, we can
* automatically remove expired tokens. Else just use the cached token. If it is
Expand Down Expand Up @@ -972,13 +1046,19 @@ class Auth {
/* User-set: check types, '*' is disallowed, throw any errors */
_userSetClientId(clientId: string | undefined) {
if (!(typeof clientId === 'string' || clientId === null)) {
throw new ErrorInfo('clientId must be either a string or null', 40012, 400);
throw new ErrorInfo({
message: 'clientId must be either a string or null',
code: 40012,
statusCode: 400,
hint: 'Pass a string (e.g. a user id) or null for an anonymous client. Numbers and objects are not accepted.',
});
} else if (clientId === '*') {
throw new ErrorInfo(
'Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, instantiate the library with {defaultTokenParams: {clientId: "*"}}), or if calling authorize(), pass it in as a tokenParam: authorize({clientId: "*"}, authOptions)',
40012,
400,
);
throw new ErrorInfo({
message: 'Can’t use "*" as a clientId as that string is reserved',
code: 40012,
statusCode: 400,
hint: 'Move "*" out of ClientOptions.clientId. For a wildcard token, set defaultTokenParams: { clientId: "*" } on the client, or pass it to authorize() as a tokenParam. The API key must have wildcard-clientId capability in the Ably dashboard, otherwise the server rejects the token request. If you have the Ably CLI installed, `ably auth keys list` shows your key\'s capabilities.',
});
} else {
const err = this._uncheckedSetClientId(clientId);
if (err) throw err;
Expand All @@ -991,7 +1071,12 @@ class Auth {
/* Should never happen in normal circumstances as realtime should
* recognise mismatch and return an error */
const msg = 'Unexpected clientId mismatch: client has ' + this.clientId + ', requested ' + clientId;
const err = new ErrorInfo(msg, 40102, 401);
const err = new ErrorInfo({
message: msg,
code: 40102,
statusCode: 401,
hint: 'A clientId from the token does not match ClientOptions.clientId. Issue the token with the matching clientId, or omit ClientOptions.clientId and let the token define it.',
});
Logger.logAction(this.logger, Logger.LOG_ERROR, 'Auth._uncheckedSetClientId()', msg);
return err;
} else {
Expand Down
Loading
Loading