@chaosity/location-client 0.2.0 → 0.3.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.
@@ -6,10 +6,55 @@ import type { GeoPlacesClient } from '../client/GeoPlacesClient';
6
6
  *
7
7
  * Implements MaplibreGeocoderApi interface for compatibility with @maplibre/maplibre-gl-geocoder
8
8
  */
9
+ /**
10
+ * Extra GetPlace detail, and what each one costs (#3 / T19).
11
+ *
12
+ * Measured against Amazon Location on 2026-08-25 by requesting each feature
13
+ * alone and reading the pricing bucket back:
14
+ *
15
+ * (none) -> Core $0.50/1k
16
+ * SecondaryAddresses -> Core $0.50/1k
17
+ * Access -> Advanced $1.50/1k
18
+ * TimeZone -> Advanced $1.50/1k
19
+ * Contact -> Advanced $1.50/1k
20
+ *
21
+ * `secondaryAddresses` is therefore free in bucket terms and the others triple
22
+ * the price of every lookup. They are opt-in for that reason, not because the
23
+ * data is unwelcome.
24
+ *
25
+ * Worth knowing before enabling `contact`: for a street address it costs
26
+ * Advanced and returns no contact field at all — only points of interest carry
27
+ * one. On an address-completion flow that is 3x the price for nothing.
28
+ */
29
+ export interface GeoPlacesDetailOptions {
30
+ /** Entrance/exit points. Moves the request to the Advanced bucket. */
31
+ access?: boolean;
32
+ /** Unit and sub-address detail. Stays in the Core bucket — free to enable. */
33
+ secondaryAddresses?: boolean;
34
+ /** Phone/website, POIs only. Moves the request to the Advanced bucket. */
35
+ contact?: boolean;
36
+ /** IANA zone and offset. Moves the request to the Advanced bucket. */
37
+ timeZone?: boolean;
38
+ }
39
+ export interface GeoPlacesOptions {
40
+ /**
41
+ * Extra detail on `searchByPlaceId`. Default: none, which keeps every
42
+ * lookup in the Core bucket.
43
+ */
44
+ details?: GeoPlacesDetailOptions;
45
+ }
9
46
  export declare class GeoPlaces implements MaplibreGeocoderApi {
10
47
  private client;
11
48
  private map;
12
- constructor(client: GeoPlacesClient, map: Map);
49
+ private details;
50
+ constructor(client: GeoPlacesClient, map: Map, options?: GeoPlacesOptions);
51
+ /**
52
+ * Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
53
+ *
54
+ * Returning `undefined` rather than `[]` matters: an empty array is still a
55
+ * field on the request, and the point is to send nothing.
56
+ */
57
+ private detailFeatures;
13
58
  private normalizeLanguage;
14
59
  forwardGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
15
60
  reverseGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
@@ -1,16 +1,30 @@
1
- import { GeocodeCommand, GetPlaceAdditionalFeature, GetPlaceCommand, ReverseGeocodeCommand, SuggestAdditionalFeature, SuggestCommand, } from '@aws-sdk/client-geo-places';
1
+ import { GeocodeCommand, GetPlaceAdditionalFeature, GetPlaceCommand, ReverseGeocodeCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
2
  import { geocodeResponseToFeatureCollection, getPlaceResponseToFeatureCollection, reverseGeocodeResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
3
3
  import debug from 'debug';
4
4
  const log = debug('location-client:geocoder');
5
- /**
6
- * GeoPlaces - MapLibre adapter for AWS Location Service
7
- *
8
- * Implements MaplibreGeocoderApi interface for compatibility with @maplibre/maplibre-gl-geocoder
9
- */
10
5
  export class GeoPlaces {
11
- constructor(client, map) {
6
+ constructor(client, map, options = {}) {
12
7
  this.client = client;
13
8
  this.map = map;
9
+ this.details = options.details ?? {};
10
+ }
11
+ /**
12
+ * Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
13
+ *
14
+ * Returning `undefined` rather than `[]` matters: an empty array is still a
15
+ * field on the request, and the point is to send nothing.
16
+ */
17
+ detailFeatures() {
18
+ const features = [];
19
+ if (this.details.access)
20
+ features.push(GetPlaceAdditionalFeature.ACCESS);
21
+ if (this.details.secondaryAddresses)
22
+ features.push(GetPlaceAdditionalFeature.SECONDARY_ADDRESSES);
23
+ if (this.details.contact)
24
+ features.push(GetPlaceAdditionalFeature.CONTACT);
25
+ if (this.details.timeZone)
26
+ features.push(GetPlaceAdditionalFeature.TIME_ZONE);
27
+ return features.length ? features : undefined;
14
28
  }
15
29
  normalizeLanguage(language) {
16
30
  if (Array.isArray(language))
@@ -73,7 +87,18 @@ export class GeoPlaces {
73
87
  BiasPosition: biasPosition,
74
88
  MaxResults: config.limit || 5,
75
89
  Language: this.normalizeLanguage(config.language),
76
- AdditionalFeatures: [SuggestAdditionalFeature.CORE],
90
+ // No AdditionalFeatures (#3 / T19).
91
+ //
92
+ // This used to send `[Core]`, which put every keystroke in the Core
93
+ // bucket at $0.50/1k. The only thing Core adds to a Suggest response is
94
+ // `Highlights`, and this adapter reads `Title` and `Place.PlaceId` —
95
+ // nothing else. Verified against Amazon Location on 2026-08-25:
96
+ //
97
+ // with [Core] -> bucket Core keys: Title, ..., Place, Highlights
98
+ // without -> bucket Label keys: Title, ..., Place
99
+ //
100
+ // Same two fields, $0.20/1k instead of $0.50. Suggest fires per
101
+ // keystroke, so it is the highest-volume call the library makes.
77
102
  ...(config.countries || config.bbox
78
103
  ? {
79
104
  Filter: {
@@ -103,15 +128,14 @@ export class GeoPlaces {
103
128
  }
104
129
  async searchByPlaceId(config) {
105
130
  log('searchByPlaceId placeId=%s', config.query);
131
+ // Opt-in rather than always-on (#3 / T19). Requesting all four put every
132
+ // lookup in the Advanced bucket at $1.50/1k; the default now sends none
133
+ // and stays in Core at $0.50. Callers that want the detail ask for it.
134
+ const additionalFeatures = this.detailFeatures();
106
135
  const command = new GetPlaceCommand({
107
136
  PlaceId: config.query,
108
137
  Language: this.normalizeLanguage(config.language),
109
- AdditionalFeatures: [
110
- GetPlaceAdditionalFeature.ACCESS,
111
- GetPlaceAdditionalFeature.SECONDARY_ADDRESSES,
112
- GetPlaceAdditionalFeature.CONTACT,
113
- GetPlaceAdditionalFeature.TIME_ZONE,
114
- ],
138
+ ...(additionalFeatures ? { AdditionalFeatures: additionalFeatures } : {}),
115
139
  });
116
140
  const response = (await this.client.send(command));
117
141
  const result = getPlaceResponseToFeatureCollection(response, {
@@ -1,6 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { LocationServiceException } from '../errors/LocationServiceException';
3
3
  import { requestJson } from '../transport/http';
4
+ import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry } from './tokenRefresh';
4
5
  const log = debug('location-client:auth');
5
6
  /**
6
7
  * TokenProvider - SERVER-SIDE ONLY
@@ -110,9 +111,13 @@ export class TokenProvider {
110
111
  });
111
112
  }
112
113
  this.cachedToken = data.access_token;
113
- // Prefer the absolute expires_at (ms); fall back to expires_in (seconds).
114
+ // The token's own `exp` claim first — it is the only value that cannot
115
+ // disagree with what the API will actually accept. `expires_at` and
116
+ // `expires_in` are what the response CLAIMS, and are kept as fallbacks.
114
117
  this.cachedExpiresAt =
115
- data.expires_at ?? Date.now() + (data.expires_in ?? 900) * 1000;
118
+ readTokenExpiry(data.access_token) ??
119
+ data.expires_at ??
120
+ Date.now() + (data.expires_in ?? 900) * 1000;
116
121
  log('Token acquired successfully (expires in %ds)', Math.floor((this.cachedExpiresAt - Date.now()) / 1000));
117
122
  return {
118
123
  success: true,
@@ -120,7 +125,7 @@ export class TokenProvider {
120
125
  expiresAt: this.cachedExpiresAt,
121
126
  };
122
127
  }
123
- isExpired(bufferSeconds = 60) {
128
+ isExpired(bufferSeconds = TOKEN_REFRESH_BUFFER_SECONDS) {
124
129
  if (!this.cachedExpiresAt)
125
130
  return true;
126
131
  return Date.now() >= this.cachedExpiresAt - bufferSeconds * 1000;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * How long before expiry a token is treated as needing replacement.
3
+ *
4
+ * ONE number, used by both sides: the server-side `TokenProvider` deciding
5
+ * whether its cached token is still good, and the React provider deciding
6
+ * whether to ask for a new one. Both apply it to the same `exp` claim, so they
7
+ * cannot reach different answers about the same token.
8
+ *
9
+ * It used to be two separate `60`s — a private literal in `isExpired()` and a
10
+ * public `refreshBuffer` prop default — with nothing tying them together. On
11
+ * 2026-08-23 a consumer passed `refreshBuffer={800}` against a 900 s token: the
12
+ * client judged it stale after 100 s, the server still considered it fresh for
13
+ * another 840 s and returned the same one, and the client asked again
14
+ * immediately — roughly 110 requests per second from an idle page.
15
+ *
16
+ * Not configurable per consumer, deliberately. A settable client-side buffer is
17
+ * precisely what allowed that disagreement, and no amount of capping or backing
18
+ * off fixes it as cleanly as the two sides simply sharing the number.
19
+ */
20
+ export declare const TOKEN_REFRESH_BUFFER_SECONDS = 60;
21
+ /**
22
+ * Read the `exp` claim out of a JWT, in milliseconds.
23
+ *
24
+ * The expiry is IN THE TOKEN, so neither side needs to be told it. Both used to
25
+ * take it on trust from elsewhere — the server from the response body's
26
+ * `expires_at`, the React provider from whatever `getConfig` returned, falling
27
+ * back to inventing `Date.now() + 900_000` when that was absent. An invented
28
+ * expiry is how the two ended up disagreeing about the same token.
29
+ *
30
+ * Deliberately NOT verified. This is only used to decide *when to refresh*;
31
+ * nothing is authorised on the strength of it, and the API verifies the
32
+ * signature on every request regardless. Trusting `exp` for scheduling is safe
33
+ * in a way that trusting it for access would not be.
34
+ *
35
+ * Returns undefined for anything unparseable, so callers keep their fallback.
36
+ */
37
+ export declare function readTokenExpiry(token: string | undefined): number | undefined;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * How long before expiry a token is treated as needing replacement.
3
+ *
4
+ * ONE number, used by both sides: the server-side `TokenProvider` deciding
5
+ * whether its cached token is still good, and the React provider deciding
6
+ * whether to ask for a new one. Both apply it to the same `exp` claim, so they
7
+ * cannot reach different answers about the same token.
8
+ *
9
+ * It used to be two separate `60`s — a private literal in `isExpired()` and a
10
+ * public `refreshBuffer` prop default — with nothing tying them together. On
11
+ * 2026-08-23 a consumer passed `refreshBuffer={800}` against a 900 s token: the
12
+ * client judged it stale after 100 s, the server still considered it fresh for
13
+ * another 840 s and returned the same one, and the client asked again
14
+ * immediately — roughly 110 requests per second from an idle page.
15
+ *
16
+ * Not configurable per consumer, deliberately. A settable client-side buffer is
17
+ * precisely what allowed that disagreement, and no amount of capping or backing
18
+ * off fixes it as cleanly as the two sides simply sharing the number.
19
+ */
20
+ export const TOKEN_REFRESH_BUFFER_SECONDS = 60;
21
+ /**
22
+ * Read the `exp` claim out of a JWT, in milliseconds.
23
+ *
24
+ * The expiry is IN THE TOKEN, so neither side needs to be told it. Both used to
25
+ * take it on trust from elsewhere — the server from the response body's
26
+ * `expires_at`, the React provider from whatever `getConfig` returned, falling
27
+ * back to inventing `Date.now() + 900_000` when that was absent. An invented
28
+ * expiry is how the two ended up disagreeing about the same token.
29
+ *
30
+ * Deliberately NOT verified. This is only used to decide *when to refresh*;
31
+ * nothing is authorised on the strength of it, and the API verifies the
32
+ * signature on every request regardless. Trusting `exp` for scheduling is safe
33
+ * in a way that trusting it for access would not be.
34
+ *
35
+ * Returns undefined for anything unparseable, so callers keep their fallback.
36
+ */
37
+ export function readTokenExpiry(token) {
38
+ if (!token)
39
+ return undefined;
40
+ const payload = token.split('.')[1];
41
+ if (!payload)
42
+ return undefined;
43
+ try {
44
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
45
+ const json = typeof atob === 'function'
46
+ ? atob(base64)
47
+ : Buffer.from(base64, 'base64').toString('utf8');
48
+ const exp = JSON.parse(json)?.exp;
49
+ return typeof exp === 'number' ? exp * 1000 : undefined;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RequestOptions } from '../transport/http';
2
2
  import type { ClientConfig } from '../types';
3
+ import type { AppConfigClaims } from '../utils/tokenClaims';
3
4
  export type SendOptions = RequestOptions;
4
5
  /**
5
6
  * GeoPlacesClient — AWS Location Service compatible client with custom auth.
@@ -15,6 +16,21 @@ export declare class GeoPlacesClient {
15
16
  serviceId: string;
16
17
  };
17
18
  constructor(config: ClientConfig);
19
+ /**
20
+ * This application's own configuration, as carried on the access token
21
+ * (api#65) — bias precision, and the countries it is scoped to.
22
+ *
23
+ * Provided so an application can SHOW its own settings: populate a country
24
+ * selector with the markets it actually serves, label a settings screen,
25
+ * and so on. Being a few minutes stale is cosmetic for that.
26
+ *
27
+ * It is not an entitlement check. See AppConfigClaims for why acting on
28
+ * `countries` client-side makes requests fail that would otherwise succeed.
29
+ *
30
+ * Returns `{}` when the token carries no application config, which is the
31
+ * case until one is configured in the portal.
32
+ */
33
+ getAppConfig(): AppConfigClaims;
18
34
  /**
19
35
  * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
20
36
  * to disable the retry loop. Every failure throws LocationServiceException.
@@ -2,6 +2,7 @@ import debug from 'debug';
2
2
  import { resolveEndpoint } from '../transport/endpoints';
3
3
  import { requestJson } from '../transport/http';
4
4
  import { roundPositionFields } from '../utils/roundPosition';
5
+ import { readAppConfigClaims } from '../utils/tokenClaims';
5
6
  const log = debug('location-client:api');
6
7
  /**
7
8
  * GeoPlacesClient — AWS Location Service compatible client with custom auth.
@@ -16,6 +17,24 @@ export class GeoPlacesClient {
16
17
  this.clientConfig = config;
17
18
  this.config = { serviceId: 'Geo Places' };
18
19
  }
20
+ /**
21
+ * This application's own configuration, as carried on the access token
22
+ * (api#65) — bias precision, and the countries it is scoped to.
23
+ *
24
+ * Provided so an application can SHOW its own settings: populate a country
25
+ * selector with the markets it actually serves, label a settings screen,
26
+ * and so on. Being a few minutes stale is cosmetic for that.
27
+ *
28
+ * It is not an entitlement check. See AppConfigClaims for why acting on
29
+ * `countries` client-side makes requests fail that would otherwise succeed.
30
+ *
31
+ * Returns `{}` when the token carries no application config, which is the
32
+ * case until one is configured in the portal.
33
+ */
34
+ getAppConfig() {
35
+ const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
36
+ return readAppConfigClaims(token);
37
+ }
19
38
  /**
20
39
  * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
21
40
  * to disable the retry loop. Every failure throws LocationServiceException.
@@ -23,9 +42,12 @@ export class GeoPlacesClient {
23
42
  async send(command, options) {
24
43
  const cmd = command;
25
44
  const endpoint = resolveEndpoint(cmd);
26
- const input = roundPositionFields(cmd.input);
27
45
  // Prefer the getToken callback (live ref) over a static token string.
28
46
  const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
47
+ // Resolve the token BEFORE rounding: the precision this application is
48
+ // entitled to is a claim on it (api#65). Absent claim -> the 3 dp floor.
49
+ const { biasDecimals } = readAppConfigClaims(token);
50
+ const input = roundPositionFields(cmd.input, biasDecimals);
29
51
  log('Sending %s to %s', cmd.constructor?.name, endpoint);
30
52
  return requestJson(`${this.clientConfig.apiUrl}${endpoint}`, {
31
53
  method: 'POST',
package/dist/index.d.ts CHANGED
@@ -2,11 +2,13 @@ export { GeoPlacesClient } from './client/GeoPlacesClient';
2
2
  export type { SendOptions } from './client/GeoPlacesClient';
3
3
  export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http';
4
4
  export type { RequestOptions } from './transport/http';
5
+ export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh';
5
6
  export { LocationServiceException } from './errors/LocationServiceException';
6
7
  export type { LocationServiceExceptionOptions } from './errors/LocationServiceException';
7
8
  export * from '@aws-sdk/client-geo-places';
8
9
  export * from '@aws/amazon-location-utilities-datatypes';
9
10
  export { GeoPlaces } from './adapters/GeoPlaces';
11
+ export type { GeoPlacesDetailOptions, GeoPlacesOptions, } from './adapters/GeoPlaces';
10
12
  export { createTransformRequest } from './maps/createTransformRequest';
11
13
  export { applyMapLanguage } from './maps/mapLanguage';
12
14
  export { POI_CATEGORIES, setAllPoiVisibility, setPoiVisibility, } from './maps/mapPoi';
@@ -15,3 +17,4 @@ export { buildMapStyleUrl, fetchMapStyle } from './maps/mapStyle';
15
17
  export type { MapStyleOptions } from './maps/mapStyle';
16
18
  export { transformRequest } from './maps/Utils';
17
19
  export type { ClientConfig, GeoPlacesCommand, MapLike } from './types';
20
+ export type { AppConfigClaims } from './utils/tokenClaims';
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  export { GeoPlacesClient } from './client/GeoPlacesClient';
3
3
  // Transport options — cancellation, per-attempt timeout, retry policy
4
4
  export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http';
5
+ // Token refresh policy — shared by the server provider and the React provider
6
+ export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh';
5
7
  // Errors
6
8
  export { LocationServiceException } from './errors/LocationServiceException';
7
9
  // Re-export AWS SDK commands and types
@@ -1,4 +1,5 @@
1
1
  import type { RequestOptions } from '../transport/http';
2
+ import type { AppConfigClaims } from '../utils/tokenClaims';
2
3
  export interface ConnectorConfig {
3
4
  apiUrl?: string;
4
5
  token?: string;
@@ -33,5 +34,22 @@ export declare class LocationServiceConnector {
33
34
  private origin?;
34
35
  readonly serviceId: string;
35
36
  constructor(config?: ConnectorConfig);
37
+ /** One place that knows how a token is obtained, so nothing can drift. */
38
+ private resolveToken;
39
+ /**
40
+ * This application's own configuration, as carried on the access token
41
+ * (api#65) — bias precision, and the countries it is scoped to.
42
+ *
43
+ * Provided so an application can SHOW its own settings: populate a country
44
+ * selector with the markets it actually serves, label a settings screen, and
45
+ * so on. Being a few minutes stale is cosmetic for that.
46
+ *
47
+ * It is not an entitlement check. See AppConfigClaims for why acting on
48
+ * `countries` client-side makes requests fail that would otherwise succeed.
49
+ *
50
+ * Returns `{}` when the token carries no application config, which is the
51
+ * case until one is configured in the portal.
52
+ */
53
+ getAppConfig(): Promise<AppConfigClaims>;
36
54
  send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
37
55
  }
@@ -3,6 +3,7 @@ import { LocationServiceException } from '../errors/LocationServiceException';
3
3
  import { resolveEndpoint } from '../transport/endpoints';
4
4
  import { requestJson } from '../transport/http';
5
5
  import { roundPositionFields } from '../utils/roundPosition';
6
+ import { readAppConfigClaims } from '../utils/tokenClaims';
6
7
  import { getClientConfig } from './getClientConfig';
7
8
  const log = debug('location-client:connector');
8
9
  /**
@@ -24,20 +25,36 @@ export class LocationServiceConnector {
24
25
  this.configPromise = config ? Promise.resolve(config) : getClientConfig();
25
26
  this.origin = config?.origin;
26
27
  }
27
- async send(command, options) {
28
+ /** One place that knows how a token is obtained, so nothing can drift. */
29
+ async resolveToken() {
28
30
  const config = await this.configPromise;
29
- let token;
30
31
  if ('getToken' in config && typeof config.getToken === 'function') {
31
32
  const result = await config.getToken();
32
- token = result
33
- ? typeof result === 'string'
34
- ? result
35
- : result.token
36
- : undefined;
37
- }
38
- else {
39
- token = config.token;
33
+ if (!result)
34
+ return undefined;
35
+ return typeof result === 'string' ? result : result.token;
40
36
  }
37
+ return config.token;
38
+ }
39
+ /**
40
+ * This application's own configuration, as carried on the access token
41
+ * (api#65) — bias precision, and the countries it is scoped to.
42
+ *
43
+ * Provided so an application can SHOW its own settings: populate a country
44
+ * selector with the markets it actually serves, label a settings screen, and
45
+ * so on. Being a few minutes stale is cosmetic for that.
46
+ *
47
+ * It is not an entitlement check. See AppConfigClaims for why acting on
48
+ * `countries` client-side makes requests fail that would otherwise succeed.
49
+ *
50
+ * Returns `{}` when the token carries no application config, which is the
51
+ * case until one is configured in the portal.
52
+ */
53
+ async getAppConfig() {
54
+ return readAppConfigClaims(await this.resolveToken());
55
+ }
56
+ async send(command, options) {
57
+ const token = await this.resolveToken();
41
58
  if (!token) {
42
59
  throw new LocationServiceException({
43
60
  code: 'InvalidCredentialsException',
@@ -47,7 +64,12 @@ export class LocationServiceConnector {
47
64
  }
48
65
  const cmd = command;
49
66
  const endpoint = resolveEndpoint(cmd);
50
- const input = roundPositionFields(cmd.input);
67
+ // The token is already resolved above, so the precision this application
68
+ // is entitled to is available before the request is shaped (api#65).
69
+ // Absent claim -> the 3 dp floor, which is what every application gets
70
+ // until one is configured otherwise.
71
+ const { biasDecimals } = readAppConfigClaims(token);
72
+ const input = roundPositionFields(cmd.input, biasDecimals);
51
73
  // Caller headers first so the system ones below cannot be overridden, but an
52
74
  // explicit per-call Origin still beats the connector-wide default.
53
75
  const headers = {
@@ -57,6 +79,6 @@ export class LocationServiceConnector {
57
79
  Authorization: `Bearer ${token}`,
58
80
  };
59
81
  log('Sending %s request to %s', cmd.constructor?.name, endpoint);
60
- return requestJson(`${config.apiUrl}${endpoint}`, { method: 'POST', headers, body: JSON.stringify(input) }, options);
82
+ return requestJson(`${(await this.configPromise).apiUrl}${endpoint}`, { method: 'POST', headers, body: JSON.stringify(input) }, options);
61
83
  }
62
84
  }
@@ -1,15 +1,66 @@
1
1
  /**
2
- * Round coordinate arrays in command inputs for better API cache hits.
2
+ * Round coordinate arrays in command inputs so nearby callers share a server
3
+ * cache entry.
3
4
  *
4
- * BiasPosition is rounded to 2 decimal places (~1.1 km precision) —
5
- * sufficient for spatial biasing while maximizing cache reuse across
6
- * nearby users.
5
+ * `BiasPosition` is a hint about where to look, so collapsing it onto a grid
6
+ * lets two users in the same area reuse one upstream answer. `QueryPosition`
7
+ * is not a hint — reverse-geocode resolves an exact point a user clicked or a
8
+ * device reported, and rounding it returns a neighbour's address. It is left
9
+ * alone (RFC-0001 §2).
7
10
  *
8
- * QueryPosition (reverse geocode) keeps full precision since it
9
- * represents an exact point the user clicked or their device reported.
11
+ * WHY THE DEFAULT MOVED FROM 2 dp TO 3 dp
12
+ *
13
+ * 2 dp is a ~1.1 km grid, and that is not a coarser answer — it is a wrong
14
+ * one. Measured against Amazon Location directly, `QueryText: "cafe"` from
15
+ * Sydney CBD returns a completely different set of places when the bias moves
16
+ * 111 m:
17
+ *
18
+ * base -> Crepe de Paris | Deli Ziosa | CBD Patisserie
19
+ * +111 m -> Cafe Chocolat | Paradiso Cafe | Incanto Coffee
20
+ *
21
+ * So two users a kilometre apart both received whichever places were nearest
22
+ * the FIRST of them, with nothing in the response saying so. The server moved
23
+ * to 3 dp in api#21 — but this library rounds BEFORE sending, on both the
24
+ * browser and server paths, so the precision was already gone by the time the
25
+ * request arrived and that fix never reached anyone using the SDK. This is
26
+ * what makes it reach them.
27
+ *
28
+ * The cost is hit rate: cells shrink 100x in area. RFC-0001 §5 expected only
29
+ * "single-digit to low-double-digit percent" in mixed traffic, so there was
30
+ * little to protect, and a miss costs one upstream call while a wrong answer
31
+ * costs trust.
10
32
  */
33
+ /** The server's floor (api#65). A request for less is clamped up to it. */
34
+ export declare const DEFAULT_BIAS_DECIMALS = 3;
35
+ /**
36
+ * The band the server enforces (api#65). Mirrored here so the value this
37
+ * library rounds to is the value the server will actually key its cache by —
38
+ * rounding to something outside the band just means being wrong about what
39
+ * was sent.
40
+ *
41
+ * The floor is correctness: below 3 dp the nearest places are not the ones
42
+ * returned. The ceiling is cost: cells shrink 100x in area per decimal, so
43
+ * finer precision collapses the cache hit rate and every miss is a billable
44
+ * upstream call.
45
+ */
46
+ export declare const MIN_BIAS_DECIMALS = 3;
47
+ export declare const MAX_BIAS_DECIMALS = 5;
48
+ /**
49
+ * Clamp a requested precision into the allowed band.
50
+ *
51
+ * Anything absent or malformed becomes the default rather than throwing: this
52
+ * runs on every request, and a surprising token claim must not break geocoding
53
+ * for an application that is otherwise working.
54
+ */
55
+ export declare function clampBiasDecimals(requested?: unknown): number;
11
56
  /**
12
57
  * Shallow-clone the input and round position arrays that benefit from caching.
13
58
  * Returns the original object if no position fields are present.
59
+ *
60
+ * @param input the command input
61
+ * @param decimals precision for this application; defaults to the floor.
62
+ * Comes from the `biasDecimals` JWT claim where the token
63
+ * carries one (api#65) — an application entitled to finer
64
+ * bias gets it without the caller configuring anything.
14
65
  */
15
- export declare function roundPositionFields<T extends object>(input: T): T;
66
+ export declare function roundPositionFields<T extends object>(input: T, decimals?: number): T;
@@ -1,38 +1,104 @@
1
1
  /**
2
- * Round coordinate arrays in command inputs for better API cache hits.
2
+ * Round coordinate arrays in command inputs so nearby callers share a server
3
+ * cache entry.
3
4
  *
4
- * BiasPosition is rounded to 2 decimal places (~1.1 km precision) —
5
- * sufficient for spatial biasing while maximizing cache reuse across
6
- * nearby users.
5
+ * `BiasPosition` is a hint about where to look, so collapsing it onto a grid
6
+ * lets two users in the same area reuse one upstream answer. `QueryPosition`
7
+ * is not a hint — reverse-geocode resolves an exact point a user clicked or a
8
+ * device reported, and rounding it returns a neighbour's address. It is left
9
+ * alone (RFC-0001 §2).
7
10
  *
8
- * QueryPosition (reverse geocode) keeps full precision since it
9
- * represents an exact point the user clicked or their device reported.
11
+ * WHY THE DEFAULT MOVED FROM 2 dp TO 3 dp
12
+ *
13
+ * 2 dp is a ~1.1 km grid, and that is not a coarser answer — it is a wrong
14
+ * one. Measured against Amazon Location directly, `QueryText: "cafe"` from
15
+ * Sydney CBD returns a completely different set of places when the bias moves
16
+ * 111 m:
17
+ *
18
+ * base -> Crepe de Paris | Deli Ziosa | CBD Patisserie
19
+ * +111 m -> Cafe Chocolat | Paradiso Cafe | Incanto Coffee
20
+ *
21
+ * So two users a kilometre apart both received whichever places were nearest
22
+ * the FIRST of them, with nothing in the response saying so. The server moved
23
+ * to 3 dp in api#21 — but this library rounds BEFORE sending, on both the
24
+ * browser and server paths, so the precision was already gone by the time the
25
+ * request arrived and that fix never reached anyone using the SDK. This is
26
+ * what makes it reach them.
27
+ *
28
+ * The cost is hit rate: cells shrink 100x in area. RFC-0001 §5 expected only
29
+ * "single-digit to low-double-digit percent" in mixed traffic, so there was
30
+ * little to protect, and a miss costs one upstream call while a wrong answer
31
+ * costs trust.
32
+ */
33
+ /** The server's floor (api#65). A request for less is clamped up to it. */
34
+ export const DEFAULT_BIAS_DECIMALS = 3;
35
+ /**
36
+ * The band the server enforces (api#65). Mirrored here so the value this
37
+ * library rounds to is the value the server will actually key its cache by —
38
+ * rounding to something outside the band just means being wrong about what
39
+ * was sent.
40
+ *
41
+ * The floor is correctness: below 3 dp the nearest places are not the ones
42
+ * returned. The ceiling is cost: cells shrink 100x in area per decimal, so
43
+ * finer precision collapses the cache hit rate and every miss is a billable
44
+ * upstream call.
10
45
  */
11
- const BIAS_DECIMALS = 2; // ~1.1 km grid
12
- const BIAS_FACTOR = 10 ** BIAS_DECIMALS;
46
+ export const MIN_BIAS_DECIMALS = 3;
47
+ export const MAX_BIAS_DECIMALS = 5;
48
+ /**
49
+ * Clamp a requested precision into the allowed band.
50
+ *
51
+ * Anything absent or malformed becomes the default rather than throwing: this
52
+ * runs on every request, and a surprising token claim must not break geocoding
53
+ * for an application that is otherwise working.
54
+ */
55
+ export function clampBiasDecimals(requested) {
56
+ const n = Number(requested);
57
+ if ((typeof requested !== 'number' &&
58
+ !(typeof requested === 'string' && requested.trim() !== '')) ||
59
+ !Number.isFinite(n)) {
60
+ return DEFAULT_BIAS_DECIMALS;
61
+ }
62
+ const floored = Math.floor(n);
63
+ if (floored < MIN_BIAS_DECIMALS)
64
+ return MIN_BIAS_DECIMALS;
65
+ if (floored > MAX_BIAS_DECIMALS)
66
+ return MAX_BIAS_DECIMALS;
67
+ return floored;
68
+ }
13
69
  /** Known position field names and whether they should be rounded */
14
70
  const POSITION_FIELDS = {
15
71
  BiasPosition: true, // geocode, autocomplete, search — round for cache
16
72
  QueryPosition: false, // reverse geocode — keep full precision
17
73
  };
18
- function roundCoord(value) {
19
- return Math.round(value * BIAS_FACTOR) / BIAS_FACTOR;
74
+ function roundCoord(value, decimals) {
75
+ const factor = 10 ** decimals;
76
+ return Math.round(value * factor) / factor;
20
77
  }
21
78
  /**
22
79
  * Shallow-clone the input and round position arrays that benefit from caching.
23
80
  * Returns the original object if no position fields are present.
81
+ *
82
+ * @param input the command input
83
+ * @param decimals precision for this application; defaults to the floor.
84
+ * Comes from the `biasDecimals` JWT claim where the token
85
+ * carries one (api#65) — an application entitled to finer
86
+ * bias gets it without the caller configuring anything.
24
87
  */
25
- export function roundPositionFields(input) {
88
+ export function roundPositionFields(input, decimals = DEFAULT_BIAS_DECIMALS) {
26
89
  if (!input || typeof input !== 'object')
27
90
  return input;
28
- let modified = false;
29
- const result = { ...input };
91
+ const dp = clampBiasDecimals(decimals);
92
+ let cloned = null;
30
93
  for (const [field, shouldRound] of Object.entries(POSITION_FIELDS)) {
31
- const value = result[field];
32
- if (!shouldRound || !Array.isArray(value) || value.length < 2)
94
+ if (!shouldRound)
95
+ continue;
96
+ const value = input[field];
97
+ if (!Array.isArray(value))
33
98
  continue;
34
- result[field] = value.map(roundCoord);
35
- modified = true;
99
+ if (!cloned)
100
+ cloned = { ...input };
101
+ cloned[field] = value.map((v) => typeof v === 'number' && Number.isFinite(v) ? roundCoord(v, dp) : v);
36
102
  }
37
- return modified ? result : input;
103
+ return cloned ?? input;
38
104
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Read advisory application config out of the access token (api#65).
3
+ *
4
+ * The API puts an application's own settings — `biasDecimals`, `countries` —
5
+ * into the JWT alongside `allowedDomain` and `allowedResources`, so this
6
+ * library can stop hard-coding values it has no other way of knowing.
7
+ *
8
+ * DELIBERATELY UNVERIFIED, and that is safe. This library has no signing key
9
+ * and does not need one: every claim here is re-read from the application row
10
+ * by the API on each request, and the API's answer is the one that counts. A
11
+ * forged token would fail at the authorizer long before any of this mattered.
12
+ * What is read here only decides how the request is SHAPED — a hint, never a
13
+ * permission.
14
+ *
15
+ * A JWT is signed, not encrypted, so the payload is plain base64url. Nothing
16
+ * secret is in it; these are the caller's own settings.
17
+ */
18
+ export interface AppConfigClaims {
19
+ /**
20
+ * Bias precision this application is entitled to.
21
+ *
22
+ * Safe to act on: it only changes how a coordinate is rounded before
23
+ * sending, and the server re-rounds to its own configured value anyway. A
24
+ * stale value here costs precision, never correctness.
25
+ */
26
+ biasDecimals?: number;
27
+ /**
28
+ * Countries this application may search, ISO 3166-1 alpha-2.
29
+ *
30
+ * READ THIS, DO NOT ACT ON IT. It is here to be displayed — a country
31
+ * selector, a settings screen, a "this application serves AU and NZ" label.
32
+ *
33
+ * Do not inject it into requests and do not reject requests with it. The
34
+ * token is a snapshot up to fifteen minutes old; the API reads the scope
35
+ * fresh from the application row on every request. Acting on a stale value
36
+ * makes things WORSE, in both directions:
37
+ *
38
+ * app is now scoped to NZ, token still says AU
39
+ * send nothing -> API injects [NZ] -> 200
40
+ * inject stale [AU] -> outside scope -> 400
41
+ *
42
+ * So a request that would have succeeded fails instead. Rejecting locally
43
+ * has the mirror-image bug: refusing something the API would now allow.
44
+ * Sending nothing and letting the API scope the request is always correct.
45
+ */
46
+ countries?: string[];
47
+ }
48
+ /**
49
+ * Decode a JWT payload without verifying it.
50
+ *
51
+ * Returns `{}` for anything unparseable. This runs on every request, so a
52
+ * surprising token must degrade to "no claims" rather than break geocoding
53
+ * for an application that is otherwise working.
54
+ */
55
+ export declare function readAppConfigClaims(token?: string | null): AppConfigClaims;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Read advisory application config out of the access token (api#65).
3
+ *
4
+ * The API puts an application's own settings — `biasDecimals`, `countries` —
5
+ * into the JWT alongside `allowedDomain` and `allowedResources`, so this
6
+ * library can stop hard-coding values it has no other way of knowing.
7
+ *
8
+ * DELIBERATELY UNVERIFIED, and that is safe. This library has no signing key
9
+ * and does not need one: every claim here is re-read from the application row
10
+ * by the API on each request, and the API's answer is the one that counts. A
11
+ * forged token would fail at the authorizer long before any of this mattered.
12
+ * What is read here only decides how the request is SHAPED — a hint, never a
13
+ * permission.
14
+ *
15
+ * A JWT is signed, not encrypted, so the payload is plain base64url. Nothing
16
+ * secret is in it; these are the caller's own settings.
17
+ */
18
+ /**
19
+ * Decode a JWT payload without verifying it.
20
+ *
21
+ * Returns `{}` for anything unparseable. This runs on every request, so a
22
+ * surprising token must degrade to "no claims" rather than break geocoding
23
+ * for an application that is otherwise working.
24
+ */
25
+ export function readAppConfigClaims(token) {
26
+ if (typeof token !== 'string')
27
+ return {};
28
+ const parts = token.split('.');
29
+ if (parts.length !== 3)
30
+ return {};
31
+ try {
32
+ // base64url -> base64: JWT omits padding and swaps two characters.
33
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
34
+ const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=');
35
+ // `atob` exists in browsers and in Node 16+, so one path serves both.
36
+ const json = decodeURIComponent(Array.from(atob(padded), (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''));
37
+ const payload = JSON.parse(json);
38
+ const claims = {};
39
+ if (typeof payload.biasDecimals === 'number') {
40
+ claims.biasDecimals = payload.biasDecimals;
41
+ }
42
+ else if (typeof payload.biasDecimals === 'string' &&
43
+ payload.biasDecimals.trim() !== '') {
44
+ const n = Number(payload.biasDecimals);
45
+ if (Number.isFinite(n))
46
+ claims.biasDecimals = n;
47
+ }
48
+ if (Array.isArray(payload.countries)) {
49
+ const list = payload.countries.filter((c) => typeof c === 'string');
50
+ if (list.length)
51
+ claims.countries = list;
52
+ }
53
+ return claims;
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",