@chaosity/location-client 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +207 -40
  2. package/dist/adapters/GeoPlaces.d.ts +7 -1
  3. package/dist/adapters/GeoPlaces.js +64 -22
  4. package/dist/auth/TokenProvider.d.ts +24 -1
  5. package/dist/auth/TokenProvider.js +81 -12
  6. package/dist/auth/tokenHold.d.ts +81 -0
  7. package/dist/auth/tokenHold.js +174 -0
  8. package/dist/aws.d.ts +4 -0
  9. package/dist/aws.js +2 -0
  10. package/dist/cjs/adapters/GeoPlaces.d.ts +7 -1
  11. package/dist/cjs/adapters/GeoPlaces.js +63 -21
  12. package/dist/cjs/auth/TokenProvider.d.ts +24 -1
  13. package/dist/cjs/auth/TokenProvider.js +81 -12
  14. package/dist/cjs/auth/tokenHold.d.ts +81 -0
  15. package/dist/cjs/auth/tokenHold.js +181 -0
  16. package/dist/cjs/aws.d.ts +4 -0
  17. package/dist/cjs/aws.js +155 -0
  18. package/dist/cjs/client/GeoPlacesClient.d.ts +23 -3
  19. package/dist/cjs/client/GeoPlacesClient.js +50 -32
  20. package/dist/cjs/client/commands.d.ts +2 -3
  21. package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
  22. package/dist/cjs/errors/LocationServiceException.js +53 -1
  23. package/dist/cjs/index.d.ts +5 -4
  24. package/dist/cjs/index.js +13 -8
  25. package/dist/cjs/maps/createTransformRequest.d.ts +25 -0
  26. package/dist/cjs/maps/createTransformRequest.js +1 -0
  27. package/dist/cjs/maps/mapPoi.d.ts +10 -5
  28. package/dist/cjs/maps/mapPoi.js +14 -5
  29. package/dist/cjs/maps/mapStyle.d.ts +9 -2
  30. package/dist/cjs/maps/mapStyle.js +16 -12
  31. package/dist/cjs/maps/mapToken.d.ts +84 -0
  32. package/dist/cjs/maps/mapToken.js +126 -0
  33. package/dist/cjs/maps/staticMap.d.ts +7 -3
  34. package/dist/cjs/maps/staticMap.js +13 -12
  35. package/dist/cjs/server/LocationServiceConnector.d.ts +20 -3
  36. package/dist/cjs/server/LocationServiceConnector.js +20 -22
  37. package/dist/cjs/server/getClientConfig.d.ts +10 -5
  38. package/dist/cjs/server/getClientConfig.js +43 -19
  39. package/dist/cjs/server/index.d.ts +1 -1
  40. package/dist/cjs/transport/errors.d.ts +14 -8
  41. package/dist/cjs/transport/errors.js +26 -18
  42. package/dist/cjs/transport/http.d.ts +18 -0
  43. package/dist/cjs/transport/http.js +39 -1
  44. package/dist/cjs/types/index.d.ts +26 -0
  45. package/dist/client/GeoPlacesClient.d.ts +23 -3
  46. package/dist/client/GeoPlacesClient.js +52 -34
  47. package/dist/client/commands.d.ts +2 -3
  48. package/dist/errors/LocationServiceException.d.ts +30 -2
  49. package/dist/errors/LocationServiceException.js +52 -0
  50. package/dist/index.d.ts +5 -4
  51. package/dist/index.js +11 -7
  52. package/dist/maps/createTransformRequest.d.ts +25 -0
  53. package/dist/maps/createTransformRequest.js +1 -1
  54. package/dist/maps/mapPoi.d.ts +10 -5
  55. package/dist/maps/mapPoi.js +14 -5
  56. package/dist/maps/mapStyle.d.ts +9 -2
  57. package/dist/maps/mapStyle.js +17 -13
  58. package/dist/maps/mapToken.d.ts +84 -0
  59. package/dist/maps/mapToken.js +122 -0
  60. package/dist/maps/staticMap.d.ts +7 -3
  61. package/dist/maps/staticMap.js +14 -13
  62. package/dist/server/LocationServiceConnector.d.ts +20 -3
  63. package/dist/server/LocationServiceConnector.js +22 -24
  64. package/dist/server/getClientConfig.d.ts +10 -5
  65. package/dist/server/getClientConfig.js +43 -19
  66. package/dist/server/index.d.ts +1 -1
  67. package/dist/transport/errors.d.ts +14 -8
  68. package/dist/transport/errors.js +27 -19
  69. package/dist/transport/http.d.ts +18 -0
  70. package/dist/transport/http.js +37 -1
  71. package/dist/types/index.d.ts +26 -0
  72. package/package.json +3 -3
@@ -0,0 +1,84 @@
1
+ import type { CallOptions } from '../transport/http.js';
2
+ /**
3
+ * Where a map helper gets its token, and how it gets a new one (#72).
4
+ *
5
+ * `getToken` is synchronous by contract: MapLibre's `transformRequest` calls
6
+ * it for every tile and cannot wait. `refreshToken` is how a map recovers when
7
+ * the API refuses the token in hand before its `exp` — a rotated secret, for
8
+ * one — which nothing else in the map path can do. It is the escape hatch
9
+ * `GeoPlacesClient` takes, under the same name.
10
+ */
11
+ export interface MapTokens {
12
+ /** The token in hand, now. */
13
+ getToken: () => string | undefined;
14
+ /** Obtain a new token after the API refused the one in hand. */
15
+ refreshToken?: () => Promise<string | undefined>;
16
+ }
17
+ /** A bare `getToken`, which behaves as it always has, or `MapTokens`. */
18
+ export type MapTokenSource = (() => string | undefined) | MapTokens;
19
+ /**
20
+ * Send with the token in hand, and after a 401 once more with a different one
21
+ * from `refreshToken`, all within the call's signal and deadline (#62). A 403
22
+ * is never retried, and neither is the token the API refused.
23
+ */
24
+ export declare function sendWithTokenRefresh<T>(source: MapTokenSource, noTokenAdvice: string, call: CallOptions, send: (token: string) => Promise<T>): Promise<T>;
25
+ /** A tile's coordinates, as `map.refreshTiles` takes them. */
26
+ interface TileCoordinates {
27
+ x: number;
28
+ y: number;
29
+ z: number;
30
+ }
31
+ /** What MapLibre's `error` event carries for a refused request. */
32
+ interface MapErrorEvent {
33
+ error?: {
34
+ status?: number;
35
+ url?: string;
36
+ };
37
+ sourceId?: string;
38
+ tile?: {
39
+ tileID: {
40
+ canonical: TileCoordinates;
41
+ };
42
+ };
43
+ }
44
+ /** The part of a MapLibre `Map` that `refreshTokenOnUnauthorized` uses. */
45
+ export interface TokenRefreshMap {
46
+ on(type: 'error', listener: (event: MapErrorEvent) => void): unknown;
47
+ off(type: 'error', listener: (event: MapErrorEvent) => void): unknown;
48
+ refreshTiles(sourceId: string, tileIds?: TileCoordinates[]): void;
49
+ }
50
+ /**
51
+ * Recover the tiles MapLibre fetches itself when the API refuses the token
52
+ * (#72).
53
+ *
54
+ * `createTransformRequest` builds each request synchronously and never sees
55
+ * the answer, so a refused tile used to leave a hole until the page reloaded.
56
+ * This listens for MapLibre's `error` events. On a 401 from a URL of our API it
57
+ * asks `refreshToken` once for the whole burst. When `getToken` then returns a
58
+ * different token, it reloads each refused tile with `map.refreshTiles`, whose
59
+ * requests carry that token. So `refreshToken` must make `getToken` return
60
+ * what it obtained: the tiles have no other way to receive it. A refused
61
+ * request that is not a tile (a glyph, a sprite) has the token replaced for
62
+ * the next one, and nothing reloaded.
63
+ *
64
+ * Tiles are reloaded by id, never a whole source: MapLibre 6 reloads a source's
65
+ * errored tiles as still loading, and they wait for a load that never comes.
66
+ *
67
+ * What it learns is held as the fetch helpers hold it, and shared with them
68
+ * when they are handed the same `tokens` object (#38): a token `refreshToken`
69
+ * could not replace, and a failure that says when to ask again, are not asked
70
+ * about again until they lapse or the token in hand changes.
71
+ *
72
+ * Returns a function that stops listening.
73
+ *
74
+ * @example
75
+ * const tokens = { getToken, refreshToken }
76
+ * const map = new Map({
77
+ * container: 'map',
78
+ * style: await fetchMapStyle(API_URL, 'Standard', tokens),
79
+ * transformRequest: createTransformRequest(API_URL, getToken),
80
+ * })
81
+ * refreshTokenOnUnauthorized(map, API_URL, tokens)
82
+ */
83
+ export declare function refreshTokenOnUnauthorized(map: TokenRefreshMap, apiUrl: string, tokens: Required<MapTokens>): () => void;
84
+ export {};
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sendWithTokenRefresh = sendWithTokenRefresh;
4
+ exports.refreshTokenOnUnauthorized = refreshTokenOnUnauthorized;
5
+ const tokenHold_js_1 = require("../auth/tokenHold.js");
6
+ const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
7
+ const errors_js_1 = require("../transport/errors.js");
8
+ const http_js_1 = require("../transport/http.js");
9
+ const createTransformRequest_js_1 = require("./createTransformRequest.js");
10
+ /**
11
+ * One hold per `MapTokens` object, so the helpers handed the same one share
12
+ * what the API and `refreshToken` said (#38): a refusal one of them met is not
13
+ * asked again by the next. A bare `getToken` never asks again, and needs none.
14
+ */
15
+ const holds = new WeakMap();
16
+ function holdOf(tokens) {
17
+ let hold = holds.get(tokens);
18
+ if (!hold)
19
+ holds.set(tokens, (hold = new tokenHold_js_1.TokenHold()));
20
+ return hold;
21
+ }
22
+ /**
23
+ * Send with the token in hand, and after a 401 once more with a different one
24
+ * from `refreshToken`, all within the call's signal and deadline (#62). A 403
25
+ * is never retried, and neither is the token the API refused.
26
+ */
27
+ async function sendWithTokenRefresh(source, noTokenAdvice, call, send) {
28
+ const tokens = typeof source === 'function' ? { getToken: source } : source;
29
+ const token = tokens.getToken();
30
+ if (!token)
31
+ throw (0, errors_js_1.noTokenAvailable)(noTokenAdvice);
32
+ const { refreshToken } = tokens;
33
+ if (!refreshToken)
34
+ return send(token);
35
+ return (0, tokenHold_js_1.sendRetryingOnce)(holdOf(tokens), token, send, async () => (await (0, http_js_1.withinCall)(refreshToken(), call)) ?? tokens.getToken());
36
+ }
37
+ /**
38
+ * Recover the tiles MapLibre fetches itself when the API refuses the token
39
+ * (#72).
40
+ *
41
+ * `createTransformRequest` builds each request synchronously and never sees
42
+ * the answer, so a refused tile used to leave a hole until the page reloaded.
43
+ * This listens for MapLibre's `error` events. On a 401 from a URL of our API it
44
+ * asks `refreshToken` once for the whole burst. When `getToken` then returns a
45
+ * different token, it reloads each refused tile with `map.refreshTiles`, whose
46
+ * requests carry that token. So `refreshToken` must make `getToken` return
47
+ * what it obtained: the tiles have no other way to receive it. A refused
48
+ * request that is not a tile (a glyph, a sprite) has the token replaced for
49
+ * the next one, and nothing reloaded.
50
+ *
51
+ * Tiles are reloaded by id, never a whole source: MapLibre 6 reloads a source's
52
+ * errored tiles as still loading, and they wait for a load that never comes.
53
+ *
54
+ * What it learns is held as the fetch helpers hold it, and shared with them
55
+ * when they are handed the same `tokens` object (#38): a token `refreshToken`
56
+ * could not replace, and a failure that says when to ask again, are not asked
57
+ * about again until they lapse or the token in hand changes.
58
+ *
59
+ * Returns a function that stops listening.
60
+ *
61
+ * @example
62
+ * const tokens = { getToken, refreshToken }
63
+ * const map = new Map({
64
+ * container: 'map',
65
+ * style: await fetchMapStyle(API_URL, 'Standard', tokens),
66
+ * transformRequest: createTransformRequest(API_URL, getToken),
67
+ * })
68
+ * refreshTokenOnUnauthorized(map, API_URL, tokens)
69
+ */
70
+ function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
71
+ const hold = holdOf(tokens);
72
+ // Each source's refused tiles, keyed `z/x/y` so a tile refused twice is
73
+ // reloaded once.
74
+ const refused = new Map();
75
+ let asking = false;
76
+ const onError = ({ error, sourceId, tile }) => {
77
+ if (error?.status !== 401 || !error.url || !(0, createTransformRequest_js_1.isOurApi)(error.url, apiUrl))
78
+ return;
79
+ if (sourceId && tile) {
80
+ const { x, y, z } = tile.tileID.canonical;
81
+ const tiles = refused.get(sourceId) ?? new Map();
82
+ refused.set(sourceId, tiles.set(`${z}/${x}/${y}`, { x, y, z }));
83
+ }
84
+ if (asking)
85
+ return;
86
+ const inHand = tokens.getToken();
87
+ const held = hold.check(inHand);
88
+ if (held && !held.askAgain)
89
+ return;
90
+ asking = true;
91
+ // Asked inside an executor, so a `refreshToken` that throws instead of
92
+ // rejecting lands in the catch below rather than in MapLibre's emitter.
93
+ new Promise((resolve) => resolve(tokens.refreshToken()))
94
+ .then(() => {
95
+ // A reloaded tile takes its token from `getToken`, as every tile does,
96
+ // so that is the token that must have changed. Reloading on what
97
+ // `refreshToken` returned alone would send the refused one again, and
98
+ // its 401 would ask again: a loop of refreshes and reloads.
99
+ const now = tokens.getToken();
100
+ if (!now || now === inHand) {
101
+ // MapLibre's error carries none of the API's fields, so the refusal
102
+ // is remembered as the 401 it was.
103
+ hold.remember(new LocationServiceException_js_1.LocationServiceException({
104
+ code: 'UnauthorizedException',
105
+ message: 'The API refused the token, and getToken has no other since refreshToken settled.',
106
+ statusCode: 401,
107
+ }), inHand);
108
+ return;
109
+ }
110
+ for (const [id, tiles] of refused)
111
+ map.refreshTiles(id, [...tiles.values()]);
112
+ })
113
+ .catch((refusal) => {
114
+ // Only a failure that says when to ask again is remembered; any other
115
+ // leaves the next refused tile to ask, as the send paths do.
116
+ if ((0, tokenHold_js_1.holdFor)(refusal) > 0)
117
+ hold.remember(refusal, inHand);
118
+ })
119
+ .finally(() => {
120
+ refused.clear();
121
+ asking = false;
122
+ });
123
+ };
124
+ map.on('error', onError);
125
+ return () => map.off('error', onError);
126
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RequestOptions } from '../transport/http.js';
2
2
  import type { ColorScheme, LabelSize, MapFeatureMode, ScaleBarUnit, StaticMapStyle } from './mapEnums.js';
3
+ import type { MapTokenSource } from './mapToken.js';
3
4
  /**
4
5
  * Static maps: build the URL, send the right headers, get a Blob.
5
6
  *
@@ -110,11 +111,14 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
110
111
  *
111
112
  * A refused plan feature — a Satellite `style`, the default when none is
112
113
  * given, or a `politicalView` — rejects with a `LocationServiceException`
113
- * whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does.
114
+ * whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does. Given
115
+ * `{ getToken, refreshToken }`, a refused token is replaced once, as
116
+ * `fetchMapStyle` does it (#72).
114
117
  *
115
118
  * @param apiUrl Base URL of the Location Service API
116
119
  * @param options Render options; exactly one of center / boundingBox / boundedPositions
117
- * @param getToken Callback returning the current auth token
120
+ * @param tokens Callback returning the current auth token, or
121
+ * `{ getToken, refreshToken }` to recover from a refused one
118
122
  * @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
119
123
  *
120
124
  * @example
@@ -124,4 +128,4 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
124
128
  * }, getToken)
125
129
  * const url = URL.createObjectURL(blob) // remember to revokeObjectURL
126
130
  */
127
- export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, getToken: () => string | undefined, request?: RequestOptions): Promise<Blob>;
131
+ export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, tokens: MapTokenSource, request?: RequestOptions): Promise<Blob>;
@@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.staticMapAccept = staticMapAccept;
4
4
  exports.buildStaticMapUrl = buildStaticMapUrl;
5
5
  exports.fetchStaticMap = fetchStaticMap;
6
- const errors_js_1 = require("../transport/errors.js");
7
6
  const http_js_1 = require("../transport/http.js");
7
+ const mapToken_js_1 = require("./mapToken.js");
8
8
  /**
9
9
  * The Accept header this request must send.
10
10
  *
@@ -55,11 +55,14 @@ function buildStaticMapUrl(apiUrl, options) {
55
55
  *
56
56
  * A refused plan feature — a Satellite `style`, the default when none is
57
57
  * given, or a `politicalView` — rejects with a `LocationServiceException`
58
- * whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does.
58
+ * whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does. Given
59
+ * `{ getToken, refreshToken }`, a refused token is replaced once, as
60
+ * `fetchMapStyle` does it (#72).
59
61
  *
60
62
  * @param apiUrl Base URL of the Location Service API
61
63
  * @param options Render options; exactly one of center / boundingBox / boundedPositions
62
- * @param getToken Callback returning the current auth token
64
+ * @param tokens Callback returning the current auth token, or
65
+ * `{ getToken, refreshToken }` to recover from a refused one
63
66
  * @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
64
67
  *
65
68
  * @example
@@ -69,23 +72,21 @@ function buildStaticMapUrl(apiUrl, options) {
69
72
  * }, getToken)
70
73
  * const url = URL.createObjectURL(blob) // remember to revokeObjectURL
71
74
  */
72
- async function fetchStaticMap(apiUrl, options, getToken, request = {}) {
73
- const token = getToken();
74
- // The same guard as fetchMapStyle and the server connector: a render is not
75
- // worth requesting without a token to send (#37).
76
- if (!token) {
77
- throw (0, errors_js_1.noTokenAvailable)('getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.');
78
- }
75
+ async function fetchStaticMap(apiUrl, options, tokens, request = {}) {
76
+ const call = (0, http_js_1.startCall)(request);
79
77
  // Through the shared transport, so a static map gets the timeout, budget,
80
78
  // cancellation and retry every other call has -- and its failures arrive as
81
79
  // LocationServiceException rather than as a raw TypeError. The API's own
82
80
  // {message, code, requestId} survives, which matters here: the messages are
83
81
  // specific and actionable -- "'width' and 'height' are required", "Only one
84
82
  // of center, bounding-box or bounded-positions may be set".
85
- return (0, http_js_1.requestBlob)(buildStaticMapUrl(apiUrl, options), {
83
+ //
84
+ // The same guard as fetchMapStyle and the server connector: a render is not
85
+ // worth requesting without a token to send (#37).
86
+ return (0, mapToken_js_1.sendWithTokenRefresh)(tokens, 'getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.', call, (token) => (0, http_js_1.requestBlob)(buildStaticMapUrl(apiUrl, options), {
86
87
  headers: {
87
88
  Authorization: `Bearer ${token}`,
88
89
  Accept: staticMapAccept(options.style),
89
90
  },
90
- }, request);
91
+ }, call));
91
92
  }
@@ -1,5 +1,7 @@
1
+ import type { GetTokenOptions } from '../auth/TokenProvider.js';
1
2
  import type { VerifyAddressResponse } from '../client/commands.js';
2
3
  import type { RequestOptions } from '../transport/http.js';
4
+ import type { CommandOutput, CommandWithOutput } from '../types/index.js';
3
5
  import type { AppConfigClaims } from '../utils/tokenClaims.js';
4
6
  export interface ConnectorConfig {
5
7
  /** Falls back to `LOCATION_API_URL` / `LOCATION_SERVICE_API_URL`. */
@@ -14,10 +16,13 @@ export interface ConnectorConfig {
14
16
  * a long-lived connector survive expiry.
15
17
  *
16
18
  * `forceRefresh` is passed as `true` when the API has just rejected the token
17
- * this returned — the signature is `TokenProvider.getToken`'s exactly, so
18
- * `getToken: (f) => provider.getToken(f)` is a complete implementation.
19
+ * this returned, and `options` always asks for `cachedUntilExpiry` (#63): a
20
+ * source that can keep sending its cached token through a throttled refresh
21
+ * should, since a connector's token never reaches a browser. The signature
22
+ * is `TokenProvider.getToken`'s, so
23
+ * `getToken: (f, o) => provider.getToken(f, o)` is a complete implementation.
19
24
  */
20
- getToken?: (forceRefresh?: boolean) => Promise<string | {
25
+ getToken?: (forceRefresh?: boolean, options?: GetTokenOptions) => Promise<string | {
21
26
  token?: string;
22
27
  } | undefined>;
23
28
  /** Falls back to `LOCATION_CLIENT_ID` / `LOCATION_SERVICE_CLIENT_ID`. */
@@ -70,6 +75,8 @@ export declare class LocationServiceConnector {
70
75
  private readonly config;
71
76
  private tokenSource?;
72
77
  private readonly origin?;
78
+ /** A token the API refused, and why, until the hold lapses (#38). */
79
+ private readonly refused;
73
80
  readonly serviceId: string;
74
81
  constructor(config?: ConnectorConfig);
75
82
  /**
@@ -107,6 +114,16 @@ export declare class LocationServiceConnector {
107
114
  * header beats the connector default, whatever the caller capitalised.
108
115
  */
109
116
  private effectiveOrigin;
117
+ /**
118
+ * Send a command, and resolve with its output:
119
+ * `await connector.send(new SearchTextCommand(…))` is a
120
+ * `SearchTextCommandOutput`, with nothing to annotate (#68).
121
+ */
122
+ send<C extends CommandWithOutput>(command: C, options?: SendOptions): Promise<CommandOutput<C>>;
123
+ /**
124
+ * The signature `send` had before it inferred its output (#68), kept for
125
+ * calls naming both type arguments, as `GeoPlacesClient.send` keeps it.
126
+ */
110
127
  send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
111
128
  /**
112
129
  * Verify a PlaceId: `send(new VerifyAddressCommand({ PlaceId }))`, typed
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.LocationServiceConnector = void 0;
7
7
  const debug_1 = __importDefault(require("debug"));
8
+ const tokenHold_js_1 = require("../auth/tokenHold.js");
8
9
  const commands_js_1 = require("../client/commands.js");
9
10
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
10
11
  const endpoints_js_1 = require("../transport/endpoints.js");
@@ -106,6 +107,8 @@ function explainMissingOrigin(err, sentOrigin) {
106
107
  */
107
108
  class LocationServiceConnector {
108
109
  constructor(config = {}) {
110
+ /** A token the API refused, and why, until the hold lapses (#38). */
111
+ this.refused = new tokenHold_js_1.TokenHold();
109
112
  this.serviceId = 'Geo Places';
110
113
  this.config = config;
111
114
  // `||`, not `??`, to match the other four env-completed fields: an empty
@@ -137,7 +140,10 @@ class LocationServiceConnector {
137
140
  return {
138
141
  apiUrl: () => requireApiUrl(apiUrl),
139
142
  get: async (forceRefresh) => {
140
- const result = await getToken(forceRefresh);
143
+ // As the environment source below asks its provider (#63).
144
+ const result = await getToken(forceRefresh, {
145
+ cachedUntilExpiry: true,
146
+ });
141
147
  if (!result)
142
148
  return undefined;
143
149
  return typeof result === 'string' ? result : result.token;
@@ -159,7 +165,9 @@ class LocationServiceConnector {
159
165
  // Already validated by serverTokenSource, which cannot resolve
160
166
  // credentials without it.
161
167
  apiUrl: () => env.apiUrl,
162
- get: async (forceRefresh) => (await env.getToken(forceRefresh)).token,
168
+ // A throttled refresh keeps the cached token in use until its own exp
169
+ // (#63): this is a server dispatch, never a token handed to a browser.
170
+ get: async (forceRefresh) => (await env.getToken(forceRefresh, { cachedUntilExpiry: true })).token,
163
171
  };
164
172
  }
165
173
  /**
@@ -197,8 +205,11 @@ class LocationServiceConnector {
197
205
  const source = this.source();
198
206
  const cmd = command;
199
207
  const url = `${source.apiUrl()}${(0, endpoints_js_1.resolveEndpoint)(cmd)}`;
208
+ // The call's deadline starts here, before the token is waited for, so the
209
+ // caller's `overallTimeoutMs` and `signal` bound the whole call (#62).
210
+ const call = (0, http_js_1.startCall)(options);
200
211
  try {
201
- return await this.dispatchWithRetry(source, url, cmd, options);
212
+ return await this.dispatchWithRetry(source, url, cmd, call);
202
213
  }
203
214
  catch (err) {
204
215
  throw explainMissingOrigin(err, this.effectiveOrigin(options));
@@ -216,27 +227,14 @@ class LocationServiceConnector {
216
227
  return this.send(new commands_js_1.VerifyAddressCommand({ PlaceId: placeId }), options);
217
228
  }
218
229
  async dispatchWithRetry(source, url, cmd, options) {
219
- const token = await source.get();
230
+ // Raced against the caller's signal and deadline, not given them: the
231
+ // token fetch may be shared with other calls (#62).
232
+ const token = await (0, http_js_1.withinCall)(source.get(), options);
220
233
  if (!token)
221
234
  throw (0, errors_js_1.noTokenAvailable)(NO_TOKEN_ADVICE);
222
- try {
223
- return await this.dispatch(url, token, cmd, options);
224
- }
225
- catch (err) {
226
- if (!(0, errors_js_1.isTokenRejected)(err))
227
- throw err;
228
- // One retry, and only when the replacement is genuinely a different
229
- // token. That single comparison covers every source: a fixed `token`
230
- // string, a caller `getToken` that ignores `forceRefresh`, and a cached
231
- // token the API has revoked before its `exp` all hand back what we
232
- // already sent — and re-sending it would be a second doomed request for
233
- // the same answer.
234
- const fresh = await source.get(true);
235
- if (!fresh || fresh === token)
236
- throw err;
237
- log('401 on a token the API no longer accepts — retrying once, refreshed');
238
- return await this.dispatch(url, fresh, cmd, options);
239
- }
235
+ // Through `sendRetryingOnce`, like every 401 retry here (#38). The forced
236
+ // refresh is raced against the caller as the first ask was (#62).
237
+ return (0, tokenHold_js_1.sendRetryingOnce)(this.refused, token, (t) => this.dispatch(url, t, cmd, options), () => (0, http_js_1.withinCall)(source.get(true), options), () => log('401 on a token the API no longer accepts — retrying once, refreshed'));
240
238
  }
241
239
  dispatch(url, token, cmd, options) {
242
240
  // The caller's input goes out as the caller wrote it — nothing in the body
@@ -1,4 +1,4 @@
1
- import type { TokenResponse } from '../auth/TokenProvider.js';
1
+ import type { GetTokenOptions, TokenResponse } from '../auth/TokenProvider.js';
2
2
  import type { ClientConfig } from '../types/index.js';
3
3
  export interface ServerAuthConfig {
4
4
  apiUrl?: string;
@@ -60,8 +60,12 @@ export declare function resolveApiUrl(explicit?: string): string | undefined;
60
60
  */
61
61
  export interface ServerTokenSource {
62
62
  apiUrl: string;
63
- /** Resolves with a token or rejects; it never resolves tokenless. */
64
- getToken(forceRefresh?: boolean): Promise<TokenResponse & {
63
+ /**
64
+ * Resolves with a token or rejects; it never resolves tokenless. The options
65
+ * are `TokenProvider.getToken`'s: the connector asks for `cachedUntilExpiry`
66
+ * (#63), and `getClientConfig` never does.
67
+ */
68
+ getToken(forceRefresh?: boolean, options?: GetTokenOptions): Promise<TokenResponse & {
65
69
  token: string;
66
70
  }>;
67
71
  }
@@ -104,8 +108,9 @@ export declare function serverTokenSource(config?: ServerAuthConfig): ServerToke
104
108
  * // Auto-detect from environment
105
109
  * const config = await getClientConfig()
106
110
  *
107
- * // Or override specific values
108
- * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
111
+ * // Or override specific values — the API URL is the one on the
112
+ * // application's page in the developer portal
113
+ * const config = await getClientConfig({ apiUrl: 'https://your-api-url.example' })
109
114
  *
110
115
  * // The API rejected the token before its exp — revoked, or secret rotated
111
116
  * const fresh = await getClientConfig({ forceRefresh: true })
@@ -78,6 +78,30 @@ function resolveApiUrl(explicit) {
78
78
  process.env.LOCATION_API_URL ||
79
79
  process.env.LOCATION_SERVICE_API_URL);
80
80
  }
81
+ /**
82
+ * A refusal of the client credentials themselves, as opposed to a refusal
83
+ * whose sentence already names its cause.
84
+ *
85
+ * Two answers qualify. `/auth/token`'s own `Invalid credentials` is a secret
86
+ * that matched nothing. The gateway's 401 (`UnauthorizedException`) is the
87
+ * authorizer refusing the Basic pair before `/auth/token` runs. An application
88
+ * that is not active is a 403 (`ApplicationNotActiveException`) on either
89
+ * path, whose sentence names its cause, and it is left as the API wrote it,
90
+ * like every 403.
91
+ */
92
+ function isCredentialsRefusal(error) {
93
+ if (!(error instanceof LocationServiceException_js_1.LocationServiceException))
94
+ return false;
95
+ if (error.statusCode !== 401)
96
+ return false;
97
+ return (error.code === 'UnauthorizedException' ||
98
+ error.message === 'Invalid credentials');
99
+ }
100
+ /** The API's words as a sentence, so the advice can follow them. */
101
+ const sentence = (text) => (/[.!?]$/.test(text) ? text : `${text}.`);
102
+ function credentialsAdvice(clientId) {
103
+ return `Check that LOCATION_CLIENT_ID ("${clientId}") and LOCATION_CLIENT_SECRET match your application in the developer portal.`;
104
+ }
81
105
  function serverTokenSource(config = {}) {
82
106
  log('[serverTokenSource] Starting with config:', {
83
107
  hasApiUrl: !!config.apiUrl,
@@ -112,28 +136,27 @@ function serverTokenSource(config = {}) {
112
136
  const provider = getTokenProvider(apiUrl, clientId, clientSecret);
113
137
  return {
114
138
  apiUrl,
115
- async getToken(forceRefresh = false) {
139
+ async getToken(forceRefresh = false, options) {
116
140
  log('[serverTokenSource] Fetching token (forceRefresh=%s)', forceRefresh);
117
141
  let result;
118
142
  try {
119
- result = await provider.getToken(forceRefresh);
143
+ result = await provider.getToken(forceRefresh, options);
120
144
  }
121
145
  catch (error) {
122
- // The provider now rejects rather than resolving with success:false, and
123
- // the rejection is typed — so a store outage (503) can be reported as a
124
- // store outage instead of as bad credentials.
125
- if (error instanceof LocationServiceException_js_1.LocationServiceException) {
126
- if (error.isAuth) {
127
- throw new LocationServiceException_js_1.LocationServiceException({
128
- code: 'InvalidCredentialsException',
129
- message: `Authentication failed for client ID "${clientId}". ` +
130
- `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
131
- statusCode: error.statusCode,
132
- requestId: error.requestId,
133
- cause: error,
134
- });
135
- }
136
- throw error;
146
+ // The API's code and sentence, passed through (#38). Every 401 and 403
147
+ // used to become "Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET",
148
+ // which sent a suspended application to check a secret that was fine.
149
+ // The advice is added only where the credentials are what was refused.
150
+ if (isCredentialsRefusal(error)) {
151
+ throw new LocationServiceException_js_1.LocationServiceException({
152
+ code: error.code,
153
+ message: `${sentence(error.message)} ${credentialsAdvice(clientId)}`,
154
+ statusCode: error.statusCode,
155
+ requestId: error.requestId,
156
+ details: error.details,
157
+ retryAfterMs: error.retryAfterMs,
158
+ cause: error,
159
+ });
137
160
  }
138
161
  throw error;
139
162
  }
@@ -190,8 +213,9 @@ function serverTokenSource(config = {}) {
190
213
  * // Auto-detect from environment
191
214
  * const config = await getClientConfig()
192
215
  *
193
- * // Or override specific values
194
- * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
216
+ * // Or override specific values — the API URL is the one on the
217
+ * // application's page in the developer portal
218
+ * const config = await getClientConfig({ apiUrl: 'https://your-api-url.example' })
195
219
  *
196
220
  * // The API rejected the token before its exp — revoked, or secret rotated
197
221
  * const fresh = await getClientConfig({ forceRefresh: true })
@@ -1,5 +1,5 @@
1
1
  export { TokenProvider } from '../auth/TokenProvider.js';
2
- export type { TokenProviderConfig, TokenResponse, } from '../auth/TokenProvider.js';
2
+ export type { GetTokenOptions, TokenProviderConfig, TokenResponse, } from '../auth/TokenProvider.js';
3
3
  export { getClientConfig } from './getClientConfig.js';
4
4
  export type { ServerAuthConfig, ServerClientConfig } from './getClientConfig.js';
5
5
  export { LocationServiceConnector } from './LocationServiceConnector.js';
@@ -2,14 +2,20 @@ import { LocationServiceException } from '../errors/LocationServiceException.js'
2
2
  /**
3
3
  * Turn a non-2xx response into a LocationServiceException.
4
4
  *
5
- * The API does not yet speak one error shape — that is api#29 (T23) — so this
6
- * tolerates the three it currently emits and synthesises a `code` for each.
7
- * RFC-0002 calls this legacy tolerance, and it is what lets the client ship
8
- * before the API contract lands. Delete the fallbacks once T23 is deployed.
9
- *
10
- * { message, code, requestId } service Lambdas — already correct
11
- * { error, error_description } /auth/token, OAuth shape
12
- * { message: "Unauthorized" } API Gateway's own responses
5
+ * The API answers every failure with a `code`, in one of three envelopes:
6
+ *
7
+ * { message, code, requestId } data routes; the gateway
8
+ * { error, error_description, code, requestId } /auth/token (OAuth 2.0)
9
+ * { code, message } the per-address limit
10
+ *
11
+ * The sentence is in `message`, or on /auth/token in `error_description`, and
12
+ * both are read whatever else the body carries (#38). The description used to
13
+ * be read only from a body with no `code`, and /auth/token has sent one since,
14
+ * so every refusal from it arrived as "Request failed: Unauthorized": a
15
+ * suspended application read exactly like a wrong secret.
16
+ *
17
+ * A body with no `code` — a proxy's, or an older deployment's — still gets
18
+ * one: from its OAuth `error`, else from the status.
13
19
  */
14
20
  export declare function parseErrorResponse(status: number, statusText: string, body: string, headers?: Headers): LocationServiceException;
15
21
  /**