@chaosity/location-client 0.5.1 → 0.7.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 (74) hide show
  1. package/README.md +73 -4
  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 +42 -0
  11. package/dist/cjs/client/GeoPlacesClient.js +92 -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 +12 -0
  19. package/dist/cjs/maps/createTransformRequest.js +87 -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 +104 -0
  32. package/dist/cjs/server/LocationServiceConnector.js +270 -0
  33. package/dist/cjs/server/getClientConfig.d.ts +94 -0
  34. package/dist/cjs/server/getClientConfig.js +174 -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 +32 -0
  40. package/dist/cjs/transport/errors.js +120 -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 +53 -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 +6 -3
  50. package/dist/client/GeoPlacesClient.js +35 -11
  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/createTransformRequest.d.ts +2 -0
  56. package/dist/maps/createTransformRequest.js +45 -1
  57. package/dist/maps/mapLanguage.d.ts +1 -1
  58. package/dist/maps/mapStyle.d.ts +1 -1
  59. package/dist/maps/mapStyle.js +2 -2
  60. package/dist/maps/staticMap.d.ts +1 -1
  61. package/dist/maps/staticMap.js +1 -1
  62. package/dist/server/LocationServiceConnector.d.ts +58 -9
  63. package/dist/server/LocationServiceConnector.js +217 -38
  64. package/dist/server/getClientConfig.d.ts +58 -4
  65. package/dist/server/getClientConfig.js +108 -66
  66. package/dist/server/index.d.ts +6 -6
  67. package/dist/server/index.js +3 -3
  68. package/dist/transport/endpoints.d.ts +1 -1
  69. package/dist/transport/endpoints.js +1 -1
  70. package/dist/transport/errors.d.ts +14 -1
  71. package/dist/transport/errors.js +16 -1
  72. package/dist/transport/http.js +2 -2
  73. package/dist/types/index.d.ts +19 -0
  74. 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
@@ -57,6 +69,10 @@ Set these environment variables:
57
69
  LOCATION_API_URL=https://api.chaosity.cloud
58
70
  LOCATION_CLIENT_ID=your-client-id
59
71
  LOCATION_CLIENT_SECRET=your-client-secret
72
+
73
+ # Only for LocationServiceConnector: the Origin sent on every data request.
74
+ # The API answers 403 without one it recognises — see below.
75
+ LOCATION_ORIGIN=https://your-allowed-domain.example
60
76
  ```
61
77
 
62
78
  Or pass credentials explicitly:
@@ -156,6 +172,8 @@ Available categories: `food_drink`, `entertainment`, `sights`, `transit`, `accom
156
172
 
157
173
  ### MapLibre Geocoder Integration
158
174
 
175
+ Requires the optional peers: `npm install maplibre-gl @maplibre/maplibre-gl-geocoder`
176
+
159
177
  ```typescript
160
178
  import { GeoPlacesClient, GeoPlaces } from '@chaosity/location-client'
161
179
  import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
@@ -193,6 +211,7 @@ const client = new GeoPlacesClient({
193
211
  apiUrl: string,
194
212
  token: string,
195
213
  getToken?: () => string | undefined, // Optional: dynamic token getter
214
+ refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
196
215
  })
197
216
 
198
217
  await client.send(command)
@@ -200,6 +219,16 @@ await client.send(command)
200
219
 
201
220
  When `getToken` is provided, it is called on every request so token updates are reflected without recreating the client.
202
221
 
222
+ `refreshToken` covers the case `getToken` cannot. `getToken` is synchronous —
223
+ MapLibre's `transformRequest` requires that — so it can only ever return the
224
+ token already in hand, and a token the API stops accepting **before** its `exp`
225
+ (revoked, or issued against a since-rotated secret) fails every request until
226
+ the refresh buffer elapses. `refreshToken` is awaited after a 401, and the
227
+ request is retried **once** with what it returns. Return the same token, or
228
+ nothing, and no retry is sent — a request that is going to fail again is not
229
+ worth being billed for twice. A 403 is never retried: a new token cannot fix an
230
+ `Origin` the application does not allow.
231
+
203
232
  #### GeoPlaces Adapter
204
233
 
205
234
  Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
@@ -337,8 +366,19 @@ import { getClientConfig } from '@chaosity/location-client/server'
337
366
 
338
367
  const config = await getClientConfig()
339
368
  // { apiUrl: string, token: string, expiresAt?: number }
369
+
370
+ // The API has rejected a token that has not reached its exp — revoked in the
371
+ // portal, or issued against a client secret that has since been rotated.
372
+ const replacement = await getClientConfig({ forceRefresh: true })
340
373
  ```
341
374
 
375
+ The return value is **plain data** — no methods, no closures — so it can be
376
+ returned straight out of a Next.js Server Action to a Client Component. It is
377
+ therefore a snapshot: the token in it stops working at its `expiresAt`, and the
378
+ caller asks for another. For a long-lived server process that should just keep
379
+ working, use `LocationServiceConnector`, which holds a live token source and
380
+ refreshes for you.
381
+
342
382
  #### TokenProvider
343
383
 
344
384
  Lower-level token management with caching and deduplication.
@@ -357,14 +397,18 @@ const { success, token, expiresAt } = await provider.getToken()
357
397
 
358
398
  #### LocationServiceConnector
359
399
 
360
- Server-side connector for backend-to-backend API calls. Can auto-configure from environment variables when no config is passed.
400
+ Server-side connector for backend-to-backend API calls. It **completes** its
401
+ configuration from the environment rather than replacing it, so you supply only
402
+ the parts the environment does not have:
361
403
 
362
404
  ```typescript
363
405
  import { LocationServiceConnector } from '@chaosity/location-client/server'
364
406
 
407
+ // apiUrl + credentials from LOCATION_API_URL / LOCATION_CLIENT_ID /
408
+ // LOCATION_CLIENT_SECRET; Origin from here. Set LOCATION_ORIGIN as well and
409
+ // `new LocationServiceConnector()` needs no arguments at all.
365
410
  const connector = new LocationServiceConnector({
366
- apiUrl: config.apiUrl,
367
- token: config.token,
411
+ origin: 'https://your-allowed-domain.example',
368
412
  })
369
413
 
370
414
  const result = await connector.send(
@@ -372,9 +416,34 @@ const result = await connector.send(
372
416
  )
373
417
  ```
374
418
 
419
+ **Every data request needs an `Origin` the service recognises, and the API
420
+ answers 403 without one** — server-to-server calls included, not just browsers.
421
+ In a browser the browser sets it; here you do, with `origin` above,
422
+ `LOCATION_ORIGIN`, or a per-call header (`send(cmd, { headers: { Origin } })`,
423
+ which wins over both). `/auth/token` is the one endpoint exempt.
424
+
425
+ A connector configured this way keeps working indefinitely: it holds a live
426
+ token source, refreshes before expiry, and retries once with a new token if the
427
+ API rejects the one it sent. Pass an explicit `token` instead and you opt out of
428
+ all of that — it is a fixed string, and it dies at its own `exp`:
429
+
430
+ ```typescript
431
+ // Managing credentials yourself: an explicit token source wins outright, and
432
+ // the environment is not consulted.
433
+ const connector = new LocationServiceConnector({
434
+ apiUrl,
435
+ origin: 'https://your-allowed-domain.example',
436
+ getToken: (forceRefresh) => provider.getToken(forceRefresh),
437
+ })
438
+ ```
439
+
375
440
  ## Cache-Friendly Position Rounding
376
441
 
377
- `BiasPosition` coordinates are automatically rounded to 2 decimal places (~1.1 km grid) before each API request. This maximizes cache hits across nearby users without affecting result quality — bias is approximate by nature.
442
+ `BiasPosition` coordinates are automatically rounded before each API request, to
443
+ whatever precision your application is entitled to — a `biasDecimals` claim on
444
+ the access token, defaulting to **3 decimal places** (~110 m) when the token
445
+ carries none. This maximizes cache hits across nearby users without affecting
446
+ result quality — bias is approximate by nature.
378
447
 
379
448
  `QueryPosition` (reverse geocode) retains full precision since it represents an exact point the user selected.
380
449
 
@@ -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;