@chaosity/location-client 0.5.1 → 0.6.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 (71) hide show
  1. package/README.md +14 -0
  2. package/dist/adapters/GeoPlaces.d.ts +1 -1
  3. package/dist/auth/TokenProvider.js +3 -3
  4. package/dist/cjs/adapters/GeoPlaces.d.ts +64 -0
  5. package/dist/cjs/adapters/GeoPlaces.js +213 -0
  6. package/dist/cjs/auth/TokenProvider.d.ts +64 -0
  7. package/dist/cjs/auth/TokenProvider.js +144 -0
  8. package/dist/cjs/auth/tokenRefresh.d.ts +37 -0
  9. package/dist/cjs/auth/tokenRefresh.js +58 -0
  10. package/dist/cjs/client/GeoPlacesClient.d.ts +39 -0
  11. package/dist/cjs/client/GeoPlacesClient.js +68 -0
  12. package/dist/cjs/errors/LocationServiceException.d.ts +44 -0
  13. package/dist/cjs/errors/LocationServiceException.js +60 -0
  14. package/dist/cjs/index.d.ts +24 -0
  15. package/dist/cjs/index.js +72 -0
  16. package/dist/cjs/maps/Utils.d.ts +2 -0
  17. package/dist/cjs/maps/Utils.js +8 -0
  18. package/dist/cjs/maps/createTransformRequest.d.ts +10 -0
  19. package/dist/cjs/maps/createTransformRequest.js +43 -0
  20. package/dist/cjs/maps/mapEnums.d.ts +102 -0
  21. package/dist/cjs/maps/mapEnums.js +103 -0
  22. package/dist/cjs/maps/mapLanguage.d.ts +43 -0
  23. package/dist/cjs/maps/mapLanguage.js +75 -0
  24. package/dist/cjs/maps/mapPoi.d.ts +44 -0
  25. package/dist/cjs/maps/mapPoi.js +64 -0
  26. package/dist/cjs/maps/mapStyle.d.ts +77 -0
  27. package/dist/cjs/maps/mapStyle.js +111 -0
  28. package/dist/cjs/maps/staticMap.d.ts +85 -0
  29. package/dist/cjs/maps/staticMap.js +81 -0
  30. package/dist/cjs/package.json +3 -0
  31. package/dist/cjs/server/LocationServiceConnector.d.ts +55 -0
  32. package/dist/cjs/server/LocationServiceConnector.js +91 -0
  33. package/dist/cjs/server/getClientConfig.d.ts +40 -0
  34. package/dist/cjs/server/getClientConfig.js +130 -0
  35. package/dist/cjs/server/index.d.ts +6 -0
  36. package/dist/cjs/server/index.js +9 -0
  37. package/dist/cjs/transport/endpoints.d.ts +2 -0
  38. package/dist/cjs/transport/endpoints.js +34 -0
  39. package/dist/cjs/transport/errors.d.ts +19 -0
  40. package/dist/cjs/transport/errors.js +104 -0
  41. package/dist/cjs/transport/http.d.ts +24 -0
  42. package/dist/cjs/transport/http.js +142 -0
  43. package/dist/cjs/types/index.d.ts +34 -0
  44. package/dist/cjs/types/index.js +3 -0
  45. package/dist/cjs/utils/roundPosition.d.ts +66 -0
  46. package/dist/cjs/utils/roundPosition.js +109 -0
  47. package/dist/cjs/utils/tokenClaims.d.ts +55 -0
  48. package/dist/cjs/utils/tokenClaims.js +61 -0
  49. package/dist/client/GeoPlacesClient.d.ts +3 -3
  50. package/dist/client/GeoPlacesClient.js +4 -4
  51. package/dist/index.d.ts +22 -22
  52. package/dist/index.js +12 -12
  53. package/dist/maps/Utils.d.ts +1 -1
  54. package/dist/maps/Utils.js +1 -1
  55. package/dist/maps/mapLanguage.d.ts +1 -1
  56. package/dist/maps/mapStyle.d.ts +1 -1
  57. package/dist/maps/mapStyle.js +2 -2
  58. package/dist/maps/staticMap.d.ts +1 -1
  59. package/dist/maps/staticMap.js +1 -1
  60. package/dist/server/LocationServiceConnector.d.ts +2 -2
  61. package/dist/server/LocationServiceConnector.js +6 -6
  62. package/dist/server/getClientConfig.d.ts +1 -1
  63. package/dist/server/getClientConfig.js +2 -2
  64. package/dist/server/index.d.ts +6 -6
  65. package/dist/server/index.js +3 -3
  66. package/dist/transport/endpoints.d.ts +1 -1
  67. package/dist/transport/endpoints.js +1 -1
  68. package/dist/transport/errors.d.ts +1 -1
  69. package/dist/transport/errors.js +1 -1
  70. package/dist/transport/http.js +2 -2
  71. package/package.json +33 -11
package/README.md CHANGED
@@ -22,6 +22,18 @@ AWS Location Service compatible client with custom Bearer token authentication.
22
22
  npm install @chaosity/location-client
23
23
  ```
24
24
 
25
+ The Places commands, the transport and the static-map and style-URL helpers need
26
+ nothing else. The **map** features are optional peer dependencies, so install
27
+ them only if you use them:
28
+
29
+ ```bash
30
+ # interactive map helpers (createTransformRequest, applyMapLanguage, POI toggles)
31
+ npm install maplibre-gl
32
+
33
+ # the MapLibre geocoder control (the GeoPlaces adapter)
34
+ npm install maplibre-gl @maplibre/maplibre-gl-geocoder
35
+ ```
36
+
25
37
  ## Key Features
26
38
 
27
39
  - **Custom Authentication**: Uses Bearer tokens instead of AWS SigV4
@@ -156,6 +168,8 @@ Available categories: `food_drink`, `entertainment`, `sights`, `transit`, `accom
156
168
 
157
169
  ### MapLibre Geocoder Integration
158
170
 
171
+ Requires the optional peers: `npm install maplibre-gl @maplibre/maplibre-gl-geocoder`
172
+
159
173
  ```typescript
160
174
  import { GeoPlacesClient, GeoPlaces } from '@chaosity/location-client'
161
175
  import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
@@ -1,6 +1,6 @@
1
1
  import type { MaplibreGeocoderApi, MaplibreGeocoderApiConfig, MaplibreGeocoderFeatureResults, MaplibreGeocoderPlaceResults, MaplibreGeocoderSuggestionResults } from '@maplibre/maplibre-gl-geocoder';
2
2
  import type { Map } from 'maplibre-gl';
3
- import type { GeoPlacesClient } from '../client/GeoPlacesClient';
3
+ import type { GeoPlacesClient } from '../client/GeoPlacesClient.js';
4
4
  /**
5
5
  * GeoPlaces - MapLibre adapter for AWS Location Service
6
6
  *
@@ -1,7 +1,7 @@
1
1
  import debug from 'debug';
2
- import { LocationServiceException } from '../errors/LocationServiceException';
3
- import { requestJson } from '../transport/http';
4
- import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry } from './tokenRefresh';
2
+ import { LocationServiceException } from '../errors/LocationServiceException.js';
3
+ import { requestJson } from '../transport/http.js';
4
+ import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './tokenRefresh.js';
5
5
  const log = debug('location-client:auth');
6
6
  /**
7
7
  * TokenProvider - SERVER-SIDE ONLY
@@ -0,0 +1,64 @@
1
+ import type { MaplibreGeocoderApi, MaplibreGeocoderApiConfig, MaplibreGeocoderFeatureResults, MaplibreGeocoderPlaceResults, MaplibreGeocoderSuggestionResults } from '@maplibre/maplibre-gl-geocoder';
2
+ import type { Map } from 'maplibre-gl';
3
+ import type { GeoPlacesClient } from '../client/GeoPlacesClient.js';
4
+ /**
5
+ * GeoPlaces - MapLibre adapter for AWS Location Service
6
+ *
7
+ * Implements MaplibreGeocoderApi interface for compatibility with @maplibre/maplibre-gl-geocoder
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
+ }
46
+ export declare class GeoPlaces implements MaplibreGeocoderApi {
47
+ private client;
48
+ private 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;
58
+ private normalizeLanguage;
59
+ forwardGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
60
+ reverseGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
61
+ getSuggestions(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderSuggestionResults>;
62
+ searchByPlaceId(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderPlaceResults>;
63
+ localGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
64
+ }
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GeoPlaces = void 0;
7
+ const client_geo_places_1 = require("@aws-sdk/client-geo-places");
8
+ const amazon_location_utilities_datatypes_1 = require("@aws/amazon-location-utilities-datatypes");
9
+ const debug_1 = __importDefault(require("debug"));
10
+ const log = (0, debug_1.default)('location-client:geocoder');
11
+ /**
12
+ * Give the control the fields it renders, not just the ones GeoJSON needs.
13
+ *
14
+ * `@maplibre/maplibre-gl-geocoder` is a Carmen-shaped consumer: its result list
15
+ * calls `item.place_name.split(',')`, the input takes `result.place_name` on
16
+ * select, and fly-to reads `center` / `bbox`. The AWS converters produce plain
17
+ * GeoJSON — `properties` and `geometry` only — so a forward geocode used to
18
+ * hand the control features with no `place_name`. The list renderer threw, the
19
+ * control caught it and emitted `error`, and with no listener that surfaced as
20
+ * `Unhandled error. (undefined)` in the console: the Enter key silently did
21
+ * nothing. Suggestions were unaffected because that path renders `text`.
22
+ *
23
+ * The `as MaplibreGeocoderFeatureResults` cast this replaces is what hid it —
24
+ * `place_name` and `text` are required on the type.
25
+ */
26
+ function toCarmenFeatures(features) {
27
+ return features.map((feature) => {
28
+ const p = (feature.properties ?? {});
29
+ const title = typeof p.Title === 'string' ? p.Title : '';
30
+ const label = typeof p['Address.Label'] === 'string' ? p['Address.Label'] : '';
31
+ // `flattenProperties` flattens nested OBJECTS (Address.Label) but leaves an
32
+ // array of numbers intact, so MapView is still [minx, miny, maxx, maxy].
33
+ const view = p.MapView;
34
+ const bbox = Array.isArray(view) &&
35
+ view.length === 4 &&
36
+ view.every((v) => typeof v === 'number')
37
+ ? view
38
+ : undefined;
39
+ const center = feature.geometry?.type === 'Point'
40
+ ? feature.geometry.coordinates
41
+ : undefined;
42
+ return {
43
+ ...feature,
44
+ text: title || label,
45
+ place_name: label || title,
46
+ place_type: typeof p.PlaceType === 'string' ? [p.PlaceType] : [],
47
+ ...(bbox ? { bbox } : {}),
48
+ ...(center ? { center } : {}),
49
+ };
50
+ });
51
+ }
52
+ class GeoPlaces {
53
+ constructor(client, map, options = {}) {
54
+ this.client = client;
55
+ this.map = map;
56
+ this.details = options.details ?? {};
57
+ }
58
+ /**
59
+ * Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
60
+ *
61
+ * Returning `undefined` rather than `[]` matters: an empty array is still a
62
+ * field on the request, and the point is to send nothing.
63
+ */
64
+ detailFeatures() {
65
+ const features = [];
66
+ if (this.details.access)
67
+ features.push(client_geo_places_1.GetPlaceAdditionalFeature.ACCESS);
68
+ if (this.details.secondaryAddresses)
69
+ features.push(client_geo_places_1.GetPlaceAdditionalFeature.SECONDARY_ADDRESSES);
70
+ if (this.details.contact)
71
+ features.push(client_geo_places_1.GetPlaceAdditionalFeature.CONTACT);
72
+ if (this.details.timeZone)
73
+ features.push(client_geo_places_1.GetPlaceAdditionalFeature.TIME_ZONE);
74
+ return features.length ? features : undefined;
75
+ }
76
+ normalizeLanguage(language) {
77
+ if (Array.isArray(language))
78
+ return language[0] || 'en';
79
+ if (typeof language === 'string')
80
+ return language;
81
+ return 'en';
82
+ }
83
+ async forwardGeocode(config) {
84
+ log('forwardGeocode query=%s', config.query);
85
+ const center = this.map.getCenter();
86
+ const biasPosition = config.proximity && config.proximity.length >= 2
87
+ ? [config.proximity[0], config.proximity[1]]
88
+ : [center.lng, center.lat];
89
+ const commandInput = {
90
+ QueryText: config.query,
91
+ BiasPosition: biasPosition,
92
+ MaxResults: config.limit || 5,
93
+ Language: this.normalizeLanguage(config.language),
94
+ };
95
+ if (config.countries) {
96
+ commandInput.Filter = {
97
+ IncludeCountries: Array.isArray(config.countries)
98
+ ? config.countries
99
+ : config.countries.split(','),
100
+ };
101
+ }
102
+ const response = (await this.client.send(new client_geo_places_1.GeocodeCommand(commandInput)));
103
+ const converted = (0, amazon_location_utilities_datatypes_1.geocodeResponseToFeatureCollection)(response, {
104
+ flattenProperties: true,
105
+ });
106
+ const result = {
107
+ type: 'FeatureCollection',
108
+ features: toCarmenFeatures(converted.features),
109
+ };
110
+ log('forwardGeocode returned %d results', result.features.length);
111
+ return result;
112
+ }
113
+ async reverseGeocode(config) {
114
+ log('reverseGeocode query=%o', config.query);
115
+ const queryPosition = Array.isArray(config.query) && config.query.length >= 2
116
+ ? [config.query[0], config.query[1]]
117
+ : [0, 0];
118
+ const commandInput = {
119
+ QueryPosition: queryPosition,
120
+ MaxResults: config.limit || 1,
121
+ Language: this.normalizeLanguage(config.language),
122
+ };
123
+ const response = (await this.client.send(new client_geo_places_1.ReverseGeocodeCommand(commandInput)));
124
+ const converted = (0, amazon_location_utilities_datatypes_1.reverseGeocodeResponseToFeatureCollection)(response, {
125
+ flattenProperties: true,
126
+ });
127
+ const result = {
128
+ type: 'FeatureCollection',
129
+ features: toCarmenFeatures(converted.features),
130
+ };
131
+ log('reverseGeocode returned %d results', result.features.length);
132
+ return result;
133
+ }
134
+ async getSuggestions(config) {
135
+ log('getSuggestions query=%s', config.query);
136
+ const center = this.map.getCenter();
137
+ const biasPosition = config.proximity && config.proximity.length >= 2
138
+ ? [config.proximity[0], config.proximity[1]]
139
+ : [center.lng, center.lat];
140
+ const commandInput = {
141
+ QueryText: config.query,
142
+ BiasPosition: biasPosition,
143
+ MaxResults: config.limit || 5,
144
+ Language: this.normalizeLanguage(config.language),
145
+ // No AdditionalFeatures (#3 / T19).
146
+ //
147
+ // This used to send `[Core]`, which put every keystroke in the Core
148
+ // bucket at $0.50/1k. The only thing Core adds to a Suggest response is
149
+ // `Highlights`, and this adapter reads `Title` and `Place.PlaceId` —
150
+ // nothing else. Verified against Amazon Location on 2026-08-25:
151
+ //
152
+ // with [Core] -> bucket Core keys: Title, ..., Place, Highlights
153
+ // without -> bucket Label keys: Title, ..., Place
154
+ //
155
+ // Same two fields, $0.20/1k instead of $0.50. Suggest fires per
156
+ // keystroke, so it is the highest-volume call the library makes.
157
+ ...(config.countries || config.bbox
158
+ ? {
159
+ Filter: {
160
+ ...(config.countries
161
+ ? {
162
+ IncludeCountries: Array.isArray(config.countries)
163
+ ? config.countries
164
+ : config.countries.split(','),
165
+ }
166
+ : {}),
167
+ ...(config.bbox ? { BoundingBox: config.bbox } : {}),
168
+ },
169
+ }
170
+ : {}),
171
+ };
172
+ const response = (await this.client.send(new client_geo_places_1.SuggestCommand(commandInput)));
173
+ const suggestions = { suggestions: [] };
174
+ for (const item of response.ResultItems ?? []) {
175
+ const text = item.Title;
176
+ if (!text)
177
+ continue;
178
+ const placeId = item.Place?.PlaceId;
179
+ suggestions.suggestions.push({ text, placeId });
180
+ }
181
+ log('getSuggestions returned %d suggestions', suggestions.suggestions.length);
182
+ return suggestions;
183
+ }
184
+ async searchByPlaceId(config) {
185
+ log('searchByPlaceId placeId=%s', config.query);
186
+ // Opt-in rather than always-on (#3 / T19). Requesting all four put every
187
+ // lookup in the Advanced bucket at $1.50/1k; the default now sends none
188
+ // and stays in Core at $0.50. Callers that want the detail ask for it.
189
+ const additionalFeatures = this.detailFeatures();
190
+ const command = new client_geo_places_1.GetPlaceCommand({
191
+ PlaceId: config.query,
192
+ Language: this.normalizeLanguage(config.language),
193
+ ...(additionalFeatures ? { AdditionalFeatures: additionalFeatures } : {}),
194
+ });
195
+ const response = (await this.client.send(command));
196
+ const result = (0, amazon_location_utilities_datatypes_1.getPlaceResponseToFeatureCollection)(response, {
197
+ flattenProperties: true,
198
+ });
199
+ const carmenGeojsonFeatures = result.features.map((feature) => ({
200
+ ...feature,
201
+ text: response.Title || '',
202
+ place_name: response.Address?.Label || '',
203
+ place_type: response.PlaceType ? [response.PlaceType] : [],
204
+ bbox: response.MapView,
205
+ }));
206
+ log('searchByPlaceId returned %d features', carmenGeojsonFeatures.length);
207
+ return { place: carmenGeojsonFeatures };
208
+ }
209
+ async localGeocode(config) {
210
+ return this.forwardGeocode(config);
211
+ }
212
+ }
213
+ exports.GeoPlaces = GeoPlaces;
@@ -0,0 +1,64 @@
1
+ export interface TokenResponse {
2
+ success: boolean;
3
+ token?: string;
4
+ expiresAt?: number;
5
+ error?: string;
6
+ }
7
+ export interface TokenProviderConfig {
8
+ apiUrl: string;
9
+ clientId: string;
10
+ clientSecret: string;
11
+ }
12
+ /**
13
+ * TokenProvider - SERVER-SIDE ONLY
14
+ *
15
+ * ⚠️ WARNING: This class requires client credentials (clientId and clientSecret)
16
+ * and must NEVER be used in browser/client-side code.
17
+ *
18
+ * Use this only in:
19
+ * - Node.js server environments
20
+ * - Next.js Server Actions (marked with 'use server')
21
+ * - Next.js API routes
22
+ * - Backend services
23
+ *
24
+ * For browser usage, use the React provider which receives tokens from server-side code.
25
+ *
26
+ * @example
27
+ * // ✓ Correct: Server-side usage
28
+ * import { TokenProvider } from '@chaosity/location-client/server'
29
+ *
30
+ * const provider = new TokenProvider({
31
+ * apiUrl: process.env.API_URL!,
32
+ * clientId: process.env.CLIENT_ID!,
33
+ * clientSecret: process.env.CLIENT_SECRET!,
34
+ * })
35
+ *
36
+ * @example
37
+ * // ✗ Wrong: Never use in browser code
38
+ * // This would expose your credentials!
39
+ */
40
+ export declare class TokenProvider {
41
+ private config;
42
+ private cachedToken?;
43
+ private cachedExpiresAt?;
44
+ private tokenPromise?;
45
+ constructor(config: TokenProviderConfig);
46
+ getToken(forceRefresh?: boolean): Promise<TokenResponse>;
47
+ /**
48
+ * Fetch a token, distinguishing transient failure from terminal (#9).
49
+ *
50
+ * This used to collapse every non-200 into `new Error(message)`: no status,
51
+ * no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
52
+ * the API returns when its config store is unreachable — was indistinguishable
53
+ * from `401 unauthorized`, and a customer whose credentials were perfectly
54
+ * good was told to go and check them.
55
+ *
56
+ * The shared transport now does the classifying: it retries 503/429/network
57
+ * honouring `Retry-After`, never retries 401/400, and throws a typed
58
+ * LocationServiceException either way. A failure REJECTS rather than resolving
59
+ * with `success: false`, so a stale token can never be used by accident.
60
+ */
61
+ private fetchToken;
62
+ private isExpired;
63
+ clearCache(): void;
64
+ }
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TokenProvider = void 0;
7
+ const debug_1 = __importDefault(require("debug"));
8
+ const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
9
+ const http_js_1 = require("../transport/http.js");
10
+ const tokenRefresh_js_1 = require("./tokenRefresh.js");
11
+ const log = (0, debug_1.default)('location-client:auth');
12
+ /**
13
+ * TokenProvider - SERVER-SIDE ONLY
14
+ *
15
+ * ⚠️ WARNING: This class requires client credentials (clientId and clientSecret)
16
+ * and must NEVER be used in browser/client-side code.
17
+ *
18
+ * Use this only in:
19
+ * - Node.js server environments
20
+ * - Next.js Server Actions (marked with 'use server')
21
+ * - Next.js API routes
22
+ * - Backend services
23
+ *
24
+ * For browser usage, use the React provider which receives tokens from server-side code.
25
+ *
26
+ * @example
27
+ * // ✓ Correct: Server-side usage
28
+ * import { TokenProvider } from '@chaosity/location-client/server'
29
+ *
30
+ * const provider = new TokenProvider({
31
+ * apiUrl: process.env.API_URL!,
32
+ * clientId: process.env.CLIENT_ID!,
33
+ * clientSecret: process.env.CLIENT_SECRET!,
34
+ * })
35
+ *
36
+ * @example
37
+ * // ✗ Wrong: Never use in browser code
38
+ * // This would expose your credentials!
39
+ */
40
+ class TokenProvider {
41
+ constructor(config) {
42
+ // Runtime check: prevent usage in browser
43
+ if (typeof window !== 'undefined') {
44
+ throw new Error('TokenProvider cannot be used in browser environments. ' +
45
+ 'It requires client credentials that must never be exposed to browsers. ' +
46
+ 'Use @chaosity/location-client-react for browser usage.');
47
+ }
48
+ log('Initializing TokenProvider for %s', config.apiUrl);
49
+ this.config = config;
50
+ }
51
+ async getToken(forceRefresh = false) {
52
+ if (!forceRefresh && this.cachedToken && !this.isExpired()) {
53
+ const expiresIn = this.cachedExpiresAt
54
+ ? Math.floor((this.cachedExpiresAt - Date.now()) / 1000)
55
+ : 'unknown';
56
+ log('Using cached token (expires in %ds)', expiresIn);
57
+ return {
58
+ success: true,
59
+ token: this.cachedToken,
60
+ expiresAt: this.cachedExpiresAt,
61
+ };
62
+ }
63
+ // If token fetch is already in progress, wait for it
64
+ if (this.tokenPromise) {
65
+ log('Token fetch in progress, waiting for existing request...');
66
+ return this.tokenPromise;
67
+ }
68
+ // Start new token fetch
69
+ const reason = forceRefresh
70
+ ? 'forced refresh'
71
+ : this.cachedToken
72
+ ? 'token expired'
73
+ : 'no cached token';
74
+ log('Refreshing token (%s) from %s', reason, this.config.apiUrl);
75
+ this.tokenPromise = this.fetchToken();
76
+ try {
77
+ const result = await this.tokenPromise;
78
+ return result;
79
+ }
80
+ finally {
81
+ // Clear promise after completion (success or failure)
82
+ this.tokenPromise = undefined;
83
+ }
84
+ }
85
+ /**
86
+ * Fetch a token, distinguishing transient failure from terminal (#9).
87
+ *
88
+ * This used to collapse every non-200 into `new Error(message)`: no status,
89
+ * no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
90
+ * the API returns when its config store is unreachable — was indistinguishable
91
+ * from `401 unauthorized`, and a customer whose credentials were perfectly
92
+ * good was told to go and check them.
93
+ *
94
+ * The shared transport now does the classifying: it retries 503/429/network
95
+ * honouring `Retry-After`, never retries 401/400, and throws a typed
96
+ * LocationServiceException either way. A failure REJECTS rather than resolving
97
+ * with `success: false`, so a stale token can never be used by accident.
98
+ */
99
+ async fetchToken() {
100
+ const { clientId, clientSecret, apiUrl } = this.config;
101
+ const credentials = btoa(`${clientId}:${clientSecret}`);
102
+ const data = await (0, http_js_1.requestJson)(`${apiUrl}/auth/token`, {
103
+ method: 'POST',
104
+ headers: {
105
+ Authorization: `Basic ${credentials}`,
106
+ 'Content-Type': 'application/x-www-form-urlencoded',
107
+ },
108
+ body: new URLSearchParams({
109
+ grant_type: 'client_credentials',
110
+ }).toString(),
111
+ }, { retry: { maxAttempts: 3 } });
112
+ if (!data.access_token) {
113
+ throw new LocationServiceException_js_1.LocationServiceException({
114
+ code: 'InvalidCredentialsException',
115
+ message: 'Token endpoint returned no access_token',
116
+ details: { source: 'client' },
117
+ });
118
+ }
119
+ this.cachedToken = data.access_token;
120
+ // The token's own `exp` claim first — it is the only value that cannot
121
+ // disagree with what the API will actually accept. `expires_at` and
122
+ // `expires_in` are what the response CLAIMS, and are kept as fallbacks.
123
+ this.cachedExpiresAt =
124
+ (0, tokenRefresh_js_1.readTokenExpiry)(data.access_token) ??
125
+ data.expires_at ??
126
+ Date.now() + (data.expires_in ?? 900) * 1000;
127
+ log('Token acquired successfully (expires in %ds)', Math.floor((this.cachedExpiresAt - Date.now()) / 1000));
128
+ return {
129
+ success: true,
130
+ token: this.cachedToken,
131
+ expiresAt: this.cachedExpiresAt,
132
+ };
133
+ }
134
+ isExpired(bufferSeconds = tokenRefresh_js_1.TOKEN_REFRESH_BUFFER_SECONDS) {
135
+ if (!this.cachedExpiresAt)
136
+ return true;
137
+ return Date.now() >= this.cachedExpiresAt - bufferSeconds * 1000;
138
+ }
139
+ clearCache() {
140
+ this.cachedToken = undefined;
141
+ this.cachedExpiresAt = undefined;
142
+ }
143
+ }
144
+ exports.TokenProvider = TokenProvider;
@@ -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,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TOKEN_REFRESH_BUFFER_SECONDS = void 0;
4
+ exports.readTokenExpiry = readTokenExpiry;
5
+ /**
6
+ * How long before expiry a token is treated as needing replacement.
7
+ *
8
+ * ONE number, used by both sides: the server-side `TokenProvider` deciding
9
+ * whether its cached token is still good, and the React provider deciding
10
+ * whether to ask for a new one. Both apply it to the same `exp` claim, so they
11
+ * cannot reach different answers about the same token.
12
+ *
13
+ * It used to be two separate `60`s — a private literal in `isExpired()` and a
14
+ * public `refreshBuffer` prop default — with nothing tying them together. On
15
+ * 2026-08-23 a consumer passed `refreshBuffer={800}` against a 900 s token: the
16
+ * client judged it stale after 100 s, the server still considered it fresh for
17
+ * another 840 s and returned the same one, and the client asked again
18
+ * immediately — roughly 110 requests per second from an idle page.
19
+ *
20
+ * Not configurable per consumer, deliberately. A settable client-side buffer is
21
+ * precisely what allowed that disagreement, and no amount of capping or backing
22
+ * off fixes it as cleanly as the two sides simply sharing the number.
23
+ */
24
+ exports.TOKEN_REFRESH_BUFFER_SECONDS = 60;
25
+ /**
26
+ * Read the `exp` claim out of a JWT, in milliseconds.
27
+ *
28
+ * The expiry is IN THE TOKEN, so neither side needs to be told it. Both used to
29
+ * take it on trust from elsewhere — the server from the response body's
30
+ * `expires_at`, the React provider from whatever `getConfig` returned, falling
31
+ * back to inventing `Date.now() + 900_000` when that was absent. An invented
32
+ * expiry is how the two ended up disagreeing about the same token.
33
+ *
34
+ * Deliberately NOT verified. This is only used to decide *when to refresh*;
35
+ * nothing is authorised on the strength of it, and the API verifies the
36
+ * signature on every request regardless. Trusting `exp` for scheduling is safe
37
+ * in a way that trusting it for access would not be.
38
+ *
39
+ * Returns undefined for anything unparseable, so callers keep their fallback.
40
+ */
41
+ function readTokenExpiry(token) {
42
+ if (!token)
43
+ return undefined;
44
+ const payload = token.split('.')[1];
45
+ if (!payload)
46
+ return undefined;
47
+ try {
48
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
49
+ const json = typeof atob === 'function'
50
+ ? atob(base64)
51
+ : Buffer.from(base64, 'base64').toString('utf8');
52
+ const exp = JSON.parse(json)?.exp;
53
+ return typeof exp === 'number' ? exp * 1000 : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
@@ -0,0 +1,39 @@
1
+ import type { RequestOptions } from '../transport/http.js';
2
+ import type { ClientConfig } from '../types/index.js';
3
+ import type { AppConfigClaims } from '../utils/tokenClaims.js';
4
+ export type SendOptions = RequestOptions;
5
+ /**
6
+ * GeoPlacesClient — AWS Location Service compatible client with custom auth.
7
+ *
8
+ * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
9
+ * request and response types are identical to AWS Location Service.
10
+ *
11
+ * Pass `getToken` in config for live refresh without recreating the client.
12
+ */
13
+ export declare class GeoPlacesClient {
14
+ private clientConfig;
15
+ readonly config: {
16
+ serviceId: string;
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;
34
+ /**
35
+ * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
36
+ * to disable the retry loop. Every failure throws LocationServiceException.
37
+ */
38
+ send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
39
+ }