@chaosity/location-client 0.6.0 → 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.
- package/README.md +59 -4
- package/dist/cjs/client/GeoPlacesClient.d.ts +3 -0
- package/dist/cjs/client/GeoPlacesClient.js +31 -7
- package/dist/cjs/maps/createTransformRequest.d.ts +2 -0
- package/dist/cjs/maps/createTransformRequest.js +45 -1
- package/dist/cjs/server/LocationServiceConnector.d.ts +56 -7
- package/dist/cjs/server/LocationServiceConnector.js +211 -32
- package/dist/cjs/server/getClientConfig.d.ts +57 -3
- package/dist/cjs/server/getClientConfig.js +108 -64
- package/dist/cjs/transport/errors.d.ts +13 -0
- package/dist/cjs/transport/errors.js +16 -0
- package/dist/cjs/types/index.d.ts +19 -0
- package/dist/client/GeoPlacesClient.d.ts +3 -0
- package/dist/client/GeoPlacesClient.js +31 -7
- package/dist/maps/createTransformRequest.d.ts +2 -0
- package/dist/maps/createTransformRequest.js +45 -1
- package/dist/server/LocationServiceConnector.d.ts +56 -7
- package/dist/server/LocationServiceConnector.js +212 -33
- package/dist/server/getClientConfig.d.ts +57 -3
- package/dist/server/getClientConfig.js +106 -64
- package/dist/transport/errors.d.ts +13 -0
- package/dist/transport/errors.js +15 -0
- package/dist/types/index.d.ts +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,10 @@ Set these environment variables:
|
|
|
69
69
|
LOCATION_API_URL=https://api.chaosity.cloud
|
|
70
70
|
LOCATION_CLIENT_ID=your-client-id
|
|
71
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
|
|
72
76
|
```
|
|
73
77
|
|
|
74
78
|
Or pass credentials explicitly:
|
|
@@ -207,6 +211,7 @@ const client = new GeoPlacesClient({
|
|
|
207
211
|
apiUrl: string,
|
|
208
212
|
token: string,
|
|
209
213
|
getToken?: () => string | undefined, // Optional: dynamic token getter
|
|
214
|
+
refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
|
|
210
215
|
})
|
|
211
216
|
|
|
212
217
|
await client.send(command)
|
|
@@ -214,6 +219,16 @@ await client.send(command)
|
|
|
214
219
|
|
|
215
220
|
When `getToken` is provided, it is called on every request so token updates are reflected without recreating the client.
|
|
216
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
|
+
|
|
217
232
|
#### GeoPlaces Adapter
|
|
218
233
|
|
|
219
234
|
Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
|
|
@@ -351,8 +366,19 @@ import { getClientConfig } from '@chaosity/location-client/server'
|
|
|
351
366
|
|
|
352
367
|
const config = await getClientConfig()
|
|
353
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 })
|
|
354
373
|
```
|
|
355
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
|
+
|
|
356
382
|
#### TokenProvider
|
|
357
383
|
|
|
358
384
|
Lower-level token management with caching and deduplication.
|
|
@@ -371,14 +397,18 @@ const { success, token, expiresAt } = await provider.getToken()
|
|
|
371
397
|
|
|
372
398
|
#### LocationServiceConnector
|
|
373
399
|
|
|
374
|
-
Server-side connector for backend-to-backend API calls.
|
|
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:
|
|
375
403
|
|
|
376
404
|
```typescript
|
|
377
405
|
import { LocationServiceConnector } from '@chaosity/location-client/server'
|
|
378
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.
|
|
379
410
|
const connector = new LocationServiceConnector({
|
|
380
|
-
|
|
381
|
-
token: config.token,
|
|
411
|
+
origin: 'https://your-allowed-domain.example',
|
|
382
412
|
})
|
|
383
413
|
|
|
384
414
|
const result = await connector.send(
|
|
@@ -386,9 +416,34 @@ const result = await connector.send(
|
|
|
386
416
|
)
|
|
387
417
|
```
|
|
388
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
|
+
|
|
389
440
|
## Cache-Friendly Position Rounding
|
|
390
441
|
|
|
391
|
-
`BiasPosition` coordinates are automatically rounded
|
|
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.
|
|
392
447
|
|
|
393
448
|
`QueryPosition` (reverse geocode) retains full precision since it represents an exact point the user selected.
|
|
394
449
|
|
|
@@ -31,9 +31,12 @@ export declare class GeoPlacesClient {
|
|
|
31
31
|
* case until one is configured in the portal.
|
|
32
32
|
*/
|
|
33
33
|
getAppConfig(): AppConfigClaims;
|
|
34
|
+
/** Prefer the getToken callback (live ref) over a static token string. */
|
|
35
|
+
private currentToken;
|
|
34
36
|
/**
|
|
35
37
|
* @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
|
|
36
38
|
* to disable the retry loop. Every failure throws LocationServiceException.
|
|
37
39
|
*/
|
|
38
40
|
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
41
|
+
private dispatch;
|
|
39
42
|
}
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.GeoPlacesClient = void 0;
|
|
7
7
|
const debug_1 = __importDefault(require("debug"));
|
|
8
8
|
const endpoints_js_1 = require("../transport/endpoints.js");
|
|
9
|
+
const errors_js_1 = require("../transport/errors.js");
|
|
9
10
|
const http_js_1 = require("../transport/http.js");
|
|
10
11
|
const roundPosition_js_1 = require("../utils/roundPosition.js");
|
|
11
12
|
const tokenClaims_js_1 = require("../utils/tokenClaims.js");
|
|
@@ -38,8 +39,11 @@ class GeoPlacesClient {
|
|
|
38
39
|
* case until one is configured in the portal.
|
|
39
40
|
*/
|
|
40
41
|
getAppConfig() {
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
return (0, tokenClaims_js_1.readAppConfigClaims)(this.currentToken());
|
|
43
|
+
}
|
|
44
|
+
/** Prefer the getToken callback (live ref) over a static token string. */
|
|
45
|
+
currentToken() {
|
|
46
|
+
return this.clientConfig.getToken?.() ?? this.clientConfig.token;
|
|
43
47
|
}
|
|
44
48
|
/**
|
|
45
49
|
* @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
|
|
@@ -47,15 +51,35 @@ class GeoPlacesClient {
|
|
|
47
51
|
*/
|
|
48
52
|
async send(command, options) {
|
|
49
53
|
const cmd = command;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
const url = `${this.clientConfig.apiUrl}${(0, endpoints_js_1.resolveEndpoint)(cmd)}`;
|
|
55
|
+
const token = this.currentToken();
|
|
56
|
+
try {
|
|
57
|
+
return await this.dispatch(url, token, cmd, options);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
if (!(0, errors_js_1.isTokenRejected)(err))
|
|
61
|
+
throw err;
|
|
62
|
+
// One shot. `refreshToken` is the only way to actually obtain a new
|
|
63
|
+
// token here — `getToken` is synchronous and returns the one already in
|
|
64
|
+
// hand — but it is re-read as a fallback because a provider that
|
|
65
|
+
// refreshes in the background may have landed a new one while this
|
|
66
|
+
// request was in flight.
|
|
67
|
+
const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
|
|
68
|
+
// Nothing new to send. Repeating the request would fail identically, and
|
|
69
|
+
// be billed identically.
|
|
70
|
+
if (!fresh || fresh === token)
|
|
71
|
+
throw err;
|
|
72
|
+
log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
|
|
73
|
+
return await this.dispatch(url, fresh, cmd, options);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
dispatch(url, token, cmd, options) {
|
|
53
77
|
// Resolve the token BEFORE rounding: the precision this application is
|
|
54
78
|
// entitled to is a claim on it (api#65). Absent claim -> the 3 dp floor.
|
|
55
79
|
const { biasDecimals } = (0, tokenClaims_js_1.readAppConfigClaims)(token);
|
|
56
80
|
const input = (0, roundPosition_js_1.roundPositionFields)(cmd.input, biasDecimals);
|
|
57
|
-
log('Sending %s to %s', cmd.constructor?.name,
|
|
58
|
-
return (0, http_js_1.requestJson)(
|
|
81
|
+
log('Sending %s to %s', cmd.constructor?.name, url);
|
|
82
|
+
return (0, http_js_1.requestJson)(url, {
|
|
59
83
|
method: 'POST',
|
|
60
84
|
headers: {
|
|
61
85
|
'Content-Type': 'application/json',
|
|
@@ -3,6 +3,8 @@ import type { RequestTransformFunction } from 'maplibre-gl';
|
|
|
3
3
|
* Creates a transformRequest function for MapLibre that adds authentication
|
|
4
4
|
* and proper Accept headers for AWS Location Service API requests.
|
|
5
5
|
*
|
|
6
|
+
* The token is attached to our own API and nowhere else — see isOurApi.
|
|
7
|
+
*
|
|
6
8
|
* @param apiUrl - Base URL of the Location Service API
|
|
7
9
|
* @param getToken - Callback function that returns the current auth token
|
|
8
10
|
* @returns MapLibre transformRequest function
|
|
@@ -1,17 +1,61 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createTransformRequest = createTransformRequest;
|
|
4
|
+
/**
|
|
5
|
+
* Does this URL belong to our API?
|
|
6
|
+
*
|
|
7
|
+
* This decides who receives the customer's bearer token, and it used to be
|
|
8
|
+
* `url.startsWith(apiUrl)` — a string test standing in for a URL test. For
|
|
9
|
+
* `apiUrl = "https://api.example.com"`, the host `api.example.com.evil.test`
|
|
10
|
+
* is a prefix match, so a style referencing
|
|
11
|
+
* `https://api.example.com.evil.test/tiles/1/2/3` was handed
|
|
12
|
+
* `Authorization: Bearer <token>` and the token left the building (#34).
|
|
13
|
+
*
|
|
14
|
+
* That is reachable because a style descriptor is DATA: its `sprite`, `glyphs`
|
|
15
|
+
* and `sources` entries are URLs the style author chose, and MapLibre asks
|
|
16
|
+
* transformRequest about every one of them. Any style not wholly ours — a
|
|
17
|
+
* customer's own, or one edited through a tool — can name a host it likes.
|
|
18
|
+
*
|
|
19
|
+
* Compared as URLs instead, which also gets host case-folding, default ports
|
|
20
|
+
* (`https://api.test:443` === `https://api.test`) and userinfo right for free,
|
|
21
|
+
* and adds a path check so a shared host serving another tenant under a
|
|
22
|
+
* different base path is not "ours" either.
|
|
23
|
+
*
|
|
24
|
+
* Fails CLOSED: anything that will not parse gets no token. The only way to
|
|
25
|
+
* reach that is a relative `apiUrl` in a runtime with no `location` to resolve
|
|
26
|
+
* it against — i.e. not a browser, which is the only place MapLibre runs.
|
|
27
|
+
*/
|
|
28
|
+
function isOurApi(url, apiUrl) {
|
|
29
|
+
const base = typeof location === 'undefined' ? undefined : location.href;
|
|
30
|
+
let ours;
|
|
31
|
+
let theirs;
|
|
32
|
+
try {
|
|
33
|
+
ours = new URL(apiUrl, base);
|
|
34
|
+
theirs = new URL(url, base);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (theirs.origin !== ours.origin)
|
|
40
|
+
return false;
|
|
41
|
+
// Trailing slashes normalised so `/v1` and `/v1/` behave the same; the `/`
|
|
42
|
+
// boundary is what stops `/v1` from matching `/v1-internal`.
|
|
43
|
+
const basePath = ours.pathname.replace(/\/+$/, '');
|
|
44
|
+
return (theirs.pathname === basePath || theirs.pathname.startsWith(`${basePath}/`));
|
|
45
|
+
}
|
|
4
46
|
/**
|
|
5
47
|
* Creates a transformRequest function for MapLibre that adds authentication
|
|
6
48
|
* and proper Accept headers for AWS Location Service API requests.
|
|
7
49
|
*
|
|
50
|
+
* The token is attached to our own API and nowhere else — see isOurApi.
|
|
51
|
+
*
|
|
8
52
|
* @param apiUrl - Base URL of the Location Service API
|
|
9
53
|
* @param getToken - Callback function that returns the current auth token
|
|
10
54
|
* @returns MapLibre transformRequest function
|
|
11
55
|
*/
|
|
12
56
|
function createTransformRequest(apiUrl, getToken) {
|
|
13
57
|
return (url, _resourceType) => {
|
|
14
|
-
if (url
|
|
58
|
+
if (isOurApi(url, apiUrl)) {
|
|
15
59
|
const token = getToken();
|
|
16
60
|
if (!token) {
|
|
17
61
|
console.warn('[createTransformRequest] No token available');
|
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
import type { RequestOptions } from '../transport/http.js';
|
|
2
2
|
import type { AppConfigClaims } from '../utils/tokenClaims.js';
|
|
3
3
|
export interface ConnectorConfig {
|
|
4
|
+
/** Falls back to `LOCATION_API_URL` / `LOCATION_SERVICE_API_URL`. */
|
|
4
5
|
apiUrl?: string;
|
|
6
|
+
/**
|
|
7
|
+
* A fixed token. Nothing can refresh it, so it dies at its own `exp` — pass
|
|
8
|
+
* `getToken` instead, or nothing at all, for a connector that keeps working.
|
|
9
|
+
*/
|
|
5
10
|
token?: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Where a token comes from. Called for every request, so this is what makes
|
|
13
|
+
* a long-lived connector survive expiry.
|
|
14
|
+
*
|
|
15
|
+
* `forceRefresh` is passed as `true` when the API has just rejected the token
|
|
16
|
+
* this returned — the signature is `TokenProvider.getToken`'s exactly, so
|
|
17
|
+
* `getToken: (f) => provider.getToken(f)` is a complete implementation.
|
|
18
|
+
*/
|
|
19
|
+
getToken?: (forceRefresh?: boolean) => Promise<string | {
|
|
20
|
+
token?: string;
|
|
21
|
+
} | undefined>;
|
|
22
|
+
/** Falls back to `LOCATION_CLIENT_ID` / `LOCATION_SERVICE_CLIENT_ID`. */
|
|
23
|
+
clientId?: string;
|
|
24
|
+
/** Falls back to `LOCATION_CLIENT_SECRET` / `LOCATION_SERVICE_CLIENT_SECRET`. */
|
|
25
|
+
clientSecret?: string;
|
|
9
26
|
/**
|
|
10
27
|
* Sent as the `Origin` header on every request. The API requires an Origin it
|
|
11
28
|
* recognises on every data request and answers 403 without one, so every
|
|
12
29
|
* caller was setting it by hand on each `send`; this does it once.
|
|
30
|
+
*
|
|
31
|
+
* Falls back to `LOCATION_ORIGIN` / `LOCATION_SERVICE_ORIGIN`, so the
|
|
32
|
+
* zero-argument connector can be made to work from the environment alone.
|
|
13
33
|
*/
|
|
14
34
|
origin?: string;
|
|
15
35
|
}
|
|
@@ -23,19 +43,38 @@ export interface SendOptions extends RequestOptions {
|
|
|
23
43
|
* token management, and the same transport (timeout, cancellation, retry) as the
|
|
24
44
|
* browser client.
|
|
25
45
|
*
|
|
46
|
+
* Configuration is COMPLETED from the environment rather than replaced by it.
|
|
47
|
+
* The constructor used to be all-or-nothing — any argument at all took the
|
|
48
|
+
* "caller supplies everything" branch — so `new LocationServiceConnector()` had
|
|
49
|
+
* credentials but could never send an Origin (403 on every data request) and
|
|
50
|
+
* `new LocationServiceConnector({ origin })` had an Origin but no credentials
|
|
51
|
+
* (#45). Now an explicit `token`/`getToken` still wins outright, and anything
|
|
52
|
+
* short of that is filled in from `LOCATION_API_URL` / `LOCATION_CLIENT_ID` /
|
|
53
|
+
* `LOCATION_CLIENT_SECRET` / `LOCATION_ORIGIN`.
|
|
54
|
+
*
|
|
26
55
|
* @example
|
|
27
56
|
* ```typescript
|
|
57
|
+
* // Credentials and apiUrl from the environment, Origin supplied here
|
|
28
58
|
* const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
|
|
29
59
|
* const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
|
|
30
60
|
* ```
|
|
31
61
|
*/
|
|
32
62
|
export declare class LocationServiceConnector {
|
|
33
|
-
private
|
|
34
|
-
private
|
|
63
|
+
private readonly config;
|
|
64
|
+
private tokenSource?;
|
|
65
|
+
private readonly origin?;
|
|
35
66
|
readonly serviceId: string;
|
|
36
67
|
constructor(config?: ConnectorConfig);
|
|
37
|
-
/**
|
|
38
|
-
|
|
68
|
+
/**
|
|
69
|
+
* One place that knows how a token is obtained, so nothing can drift.
|
|
70
|
+
*
|
|
71
|
+
* Built on first use rather than in the constructor: resolving the
|
|
72
|
+
* environment path used to start a `/auth/token` round trip from `new`, whose
|
|
73
|
+
* rejection nothing was awaiting yet — an unhandled rejection for a
|
|
74
|
+
* misconfigured process, before it had made a single request.
|
|
75
|
+
*/
|
|
76
|
+
private source;
|
|
77
|
+
private buildSource;
|
|
39
78
|
/**
|
|
40
79
|
* This application's own configuration, as carried on the access token
|
|
41
80
|
* (api#65) — bias precision, and the countries it is scoped to.
|
|
@@ -51,5 +90,15 @@ export declare class LocationServiceConnector {
|
|
|
51
90
|
* case until one is configured in the portal.
|
|
52
91
|
*/
|
|
53
92
|
getAppConfig(): Promise<AppConfigClaims>;
|
|
93
|
+
/**
|
|
94
|
+
* The Origin this request will actually carry.
|
|
95
|
+
*
|
|
96
|
+
* ONE definition, for the two readers that must never disagree about it: the
|
|
97
|
+
* header merge in `dispatch`, and the 403 explanation in `send`. A per-call
|
|
98
|
+
* header beats the connector default, whatever the caller capitalised.
|
|
99
|
+
*/
|
|
100
|
+
private effectiveOrigin;
|
|
54
101
|
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
102
|
+
private dispatchWithRetry;
|
|
103
|
+
private dispatch;
|
|
55
104
|
}
|
|
@@ -7,11 +7,68 @@ exports.LocationServiceConnector = void 0;
|
|
|
7
7
|
const debug_1 = __importDefault(require("debug"));
|
|
8
8
|
const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
|
|
9
9
|
const endpoints_js_1 = require("../transport/endpoints.js");
|
|
10
|
+
const errors_js_1 = require("../transport/errors.js");
|
|
10
11
|
const http_js_1 = require("../transport/http.js");
|
|
11
12
|
const roundPosition_js_1 = require("../utils/roundPosition.js");
|
|
12
13
|
const tokenClaims_js_1 = require("../utils/tokenClaims.js");
|
|
13
14
|
const getClientConfig_js_1 = require("./getClientConfig.js");
|
|
14
15
|
const log = (0, debug_1.default)('location-client:connector');
|
|
16
|
+
/** Header lookup that does not care how the caller capitalised the name. */
|
|
17
|
+
function headerValue(headers, name) {
|
|
18
|
+
if (!headers)
|
|
19
|
+
return undefined;
|
|
20
|
+
const wanted = name.toLowerCase();
|
|
21
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
22
|
+
if (key.toLowerCase() === wanted)
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The headers `dispatch` sets itself, and which a caller therefore cannot
|
|
29
|
+
* supply. Lower-case, because that is how they are compared.
|
|
30
|
+
*
|
|
31
|
+
* Kept beside `withoutHeaders` so the list and the record in `dispatch` cannot
|
|
32
|
+
* drift apart: a header added there must be added here too, or the caller's own
|
|
33
|
+
* spelling of it survives the merge.
|
|
34
|
+
*/
|
|
35
|
+
const SYSTEM_HEADERS = ['origin', 'content-type', 'authorization'];
|
|
36
|
+
/** The caller's headers minus `names`, however they capitalised them. */
|
|
37
|
+
function withoutHeaders(headers, names) {
|
|
38
|
+
if (!headers)
|
|
39
|
+
return {};
|
|
40
|
+
const wanted = new Set(names);
|
|
41
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) => !wanted.has(key.toLowerCase())));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Say why the 403 happened, when we know.
|
|
45
|
+
*
|
|
46
|
+
* `Origin not allowed` with no Origin sent is not an ambiguous failure — it is
|
|
47
|
+
* the documented backend path missing one piece of configuration (#45), and the
|
|
48
|
+
* API's own message cannot say so because from its side the header is simply
|
|
49
|
+
* absent. A new integrator following the README hit a bare "Origin not allowed"
|
|
50
|
+
* that named neither the cause nor the fix.
|
|
51
|
+
*/
|
|
52
|
+
function explainMissingOrigin(err, sentOrigin) {
|
|
53
|
+
if (sentOrigin)
|
|
54
|
+
return err;
|
|
55
|
+
if (!(err instanceof LocationServiceException_js_1.LocationServiceException))
|
|
56
|
+
return err;
|
|
57
|
+
if (err.code !== 'OriginNotAllowedException')
|
|
58
|
+
return err;
|
|
59
|
+
return new LocationServiceException_js_1.LocationServiceException({
|
|
60
|
+
code: err.code,
|
|
61
|
+
message: `${err.message} — this request carried no Origin header, and the API requires ` +
|
|
62
|
+
`one it recognises on every data request. Set \`origin\` on the ` +
|
|
63
|
+
`LocationServiceConnector, set LOCATION_ORIGIN, or pass an Origin in the ` +
|
|
64
|
+
`per-call headers; if the application has no allowed domain configured in ` +
|
|
65
|
+
`the developer portal yet, set that first.`,
|
|
66
|
+
statusCode: err.statusCode,
|
|
67
|
+
requestId: err.requestId,
|
|
68
|
+
details: err.details,
|
|
69
|
+
cause: err,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
15
72
|
/**
|
|
16
73
|
* LocationServiceConnector — server-side connector for the Location Service API.
|
|
17
74
|
*
|
|
@@ -19,28 +76,79 @@ const log = (0, debug_1.default)('location-client:connector');
|
|
|
19
76
|
* token management, and the same transport (timeout, cancellation, retry) as the
|
|
20
77
|
* browser client.
|
|
21
78
|
*
|
|
79
|
+
* Configuration is COMPLETED from the environment rather than replaced by it.
|
|
80
|
+
* The constructor used to be all-or-nothing — any argument at all took the
|
|
81
|
+
* "caller supplies everything" branch — so `new LocationServiceConnector()` had
|
|
82
|
+
* credentials but could never send an Origin (403 on every data request) and
|
|
83
|
+
* `new LocationServiceConnector({ origin })` had an Origin but no credentials
|
|
84
|
+
* (#45). Now an explicit `token`/`getToken` still wins outright, and anything
|
|
85
|
+
* short of that is filled in from `LOCATION_API_URL` / `LOCATION_CLIENT_ID` /
|
|
86
|
+
* `LOCATION_CLIENT_SECRET` / `LOCATION_ORIGIN`.
|
|
87
|
+
*
|
|
22
88
|
* @example
|
|
23
89
|
* ```typescript
|
|
90
|
+
* // Credentials and apiUrl from the environment, Origin supplied here
|
|
24
91
|
* const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
|
|
25
92
|
* const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
|
|
26
93
|
* ```
|
|
27
94
|
*/
|
|
28
95
|
class LocationServiceConnector {
|
|
29
|
-
constructor(config) {
|
|
96
|
+
constructor(config = {}) {
|
|
30
97
|
this.serviceId = 'Geo Places';
|
|
31
|
-
this.
|
|
32
|
-
|
|
98
|
+
this.config = config;
|
|
99
|
+
// `||`, not `??`, to match the other four env-completed fields: an empty
|
|
100
|
+
// string is not a choice to send no Origin, it is a missing value, and the
|
|
101
|
+
// asymmetry meant `origin: ''` suppressed the environment and then produced
|
|
102
|
+
// an error telling the caller to set a variable they may already have set.
|
|
103
|
+
this.origin =
|
|
104
|
+
config.origin ||
|
|
105
|
+
process.env.LOCATION_ORIGIN ||
|
|
106
|
+
process.env.LOCATION_SERVICE_ORIGIN;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One place that knows how a token is obtained, so nothing can drift.
|
|
110
|
+
*
|
|
111
|
+
* Built on first use rather than in the constructor: resolving the
|
|
112
|
+
* environment path used to start a `/auth/token` round trip from `new`, whose
|
|
113
|
+
* rejection nothing was awaiting yet — an unhandled rejection for a
|
|
114
|
+
* misconfigured process, before it had made a single request.
|
|
115
|
+
*/
|
|
116
|
+
source() {
|
|
117
|
+
return (this.tokenSource ?? (this.tokenSource = this.buildSource()));
|
|
33
118
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
119
|
+
buildSource() {
|
|
120
|
+
const { apiUrl, token, getToken, clientId, clientSecret } = this.config;
|
|
121
|
+
// An explicit token source wins outright. A caller that supplied one is
|
|
122
|
+
// managing credentials itself, and quietly reading the environment
|
|
123
|
+
// underneath it could send another application's token.
|
|
124
|
+
if (getToken) {
|
|
125
|
+
return {
|
|
126
|
+
apiUrl: () => requireApiUrl(apiUrl),
|
|
127
|
+
get: async (forceRefresh) => {
|
|
128
|
+
const result = await getToken(forceRefresh);
|
|
129
|
+
if (!result)
|
|
130
|
+
return undefined;
|
|
131
|
+
return typeof result === 'string' ? result : result.token;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (token) {
|
|
136
|
+
return {
|
|
137
|
+
apiUrl: () => requireApiUrl(apiUrl),
|
|
138
|
+
// A fixed string. Asking again returns the same one, which is how the
|
|
139
|
+
// retry guard knows there is nothing to retry with.
|
|
140
|
+
get: async () => token,
|
|
141
|
+
};
|
|
42
142
|
}
|
|
43
|
-
|
|
143
|
+
// Nothing but (at most) an apiUrl and an origin — complete it from the
|
|
144
|
+
// environment. This is the branch every sample and doc actually takes.
|
|
145
|
+
const env = (0, getClientConfig_js_1.serverTokenSource)({ apiUrl, clientId, clientSecret });
|
|
146
|
+
return {
|
|
147
|
+
// Already validated by serverTokenSource, which cannot resolve
|
|
148
|
+
// credentials without it.
|
|
149
|
+
apiUrl: () => env.apiUrl,
|
|
150
|
+
get: async (forceRefresh) => (await env.getToken(forceRefresh)).token,
|
|
151
|
+
};
|
|
44
152
|
}
|
|
45
153
|
/**
|
|
46
154
|
* This application's own configuration, as carried on the access token
|
|
@@ -57,35 +165,106 @@ class LocationServiceConnector {
|
|
|
57
165
|
* case until one is configured in the portal.
|
|
58
166
|
*/
|
|
59
167
|
async getAppConfig() {
|
|
60
|
-
return (0, tokenClaims_js_1.readAppConfigClaims)(await this.
|
|
168
|
+
return (0, tokenClaims_js_1.readAppConfigClaims)(await this.source().get());
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The Origin this request will actually carry.
|
|
172
|
+
*
|
|
173
|
+
* ONE definition, for the two readers that must never disagree about it: the
|
|
174
|
+
* header merge in `dispatch`, and the 403 explanation in `send`. A per-call
|
|
175
|
+
* header beats the connector default, whatever the caller capitalised.
|
|
176
|
+
*/
|
|
177
|
+
effectiveOrigin(options) {
|
|
178
|
+
// `||` for the same reason as the constructor: an empty per-call header is
|
|
179
|
+
// a missing value, not a decision to send no Origin.
|
|
180
|
+
return headerValue(options?.headers, 'origin') || this.origin;
|
|
61
181
|
}
|
|
62
182
|
async send(command, options) {
|
|
63
|
-
const
|
|
64
|
-
if (!token) {
|
|
65
|
-
throw new LocationServiceException_js_1.LocationServiceException({
|
|
66
|
-
code: 'InvalidCredentialsException',
|
|
67
|
-
message: 'No token available — check clientId/clientSecret configuration',
|
|
68
|
-
details: { source: 'client' },
|
|
69
|
-
});
|
|
70
|
-
}
|
|
183
|
+
const source = this.source();
|
|
71
184
|
const cmd = command;
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
185
|
+
const url = `${source.apiUrl()}${(0, endpoints_js_1.resolveEndpoint)(cmd)}`;
|
|
186
|
+
try {
|
|
187
|
+
return await this.dispatchWithRetry(source, url, cmd, options);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
throw explainMissingOrigin(err, this.effectiveOrigin(options));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async dispatchWithRetry(source, url, cmd, options) {
|
|
194
|
+
const token = await source.get();
|
|
195
|
+
if (!token)
|
|
196
|
+
throw noTokenAvailable();
|
|
197
|
+
try {
|
|
198
|
+
return await this.dispatch(url, token, cmd, options);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (!(0, errors_js_1.isTokenRejected)(err))
|
|
202
|
+
throw err;
|
|
203
|
+
// One retry, and only when the replacement is genuinely a different
|
|
204
|
+
// token. That single comparison covers every source: a fixed `token`
|
|
205
|
+
// string, a caller `getToken` that ignores `forceRefresh`, and a cached
|
|
206
|
+
// token the API has revoked before its `exp` all hand back what we
|
|
207
|
+
// already sent — and re-sending it would be a second doomed request, and
|
|
208
|
+
// a second billed one.
|
|
209
|
+
const fresh = await source.get(true);
|
|
210
|
+
if (!fresh || fresh === token)
|
|
211
|
+
throw err;
|
|
212
|
+
log('401 on a token the API no longer accepts — retrying once, refreshed');
|
|
213
|
+
return await this.dispatch(url, fresh, cmd, options);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
dispatch(url, token, cmd, options) {
|
|
217
|
+
// The token is resolved before the request is shaped, so the precision this
|
|
218
|
+
// application is entitled to is available (api#65). Absent claim -> the
|
|
219
|
+
// 3 dp floor, which is what every application gets until one is configured
|
|
220
|
+
// otherwise. Recomputed per attempt because a refreshed token may carry
|
|
221
|
+
// different claims.
|
|
77
222
|
const { biasDecimals } = (0, tokenClaims_js_1.readAppConfigClaims)(token);
|
|
78
223
|
const input = (0, roundPosition_js_1.roundPositionFields)(cmd.input, biasDecimals);
|
|
79
|
-
//
|
|
80
|
-
//
|
|
224
|
+
// Every system header is set exactly ONCE, and the caller's own spelling of
|
|
225
|
+
// each is dropped first.
|
|
226
|
+
//
|
|
227
|
+
// Spreading them over the caller's record is not enough, because fetch's
|
|
228
|
+
// Headers fill APPENDS rather than replaces: a caller's lowercase key
|
|
229
|
+
// survives beside the canonical one and both go on the wire. For Origin
|
|
230
|
+
// that produced `Origin: default, per-call`, which the API's exact-match
|
|
231
|
+
// domain check rejects (observed: 403 OriginNotAllowedException) — and two
|
|
232
|
+
// keys with the SAME value fared no better, `Origin: x, x`. For
|
|
233
|
+
// Authorization it is worse than a failed override: `{ authorization:
|
|
234
|
+
// 'Bearer not-yours' }` went out as `Bearer not-yours, Bearer <real>`,
|
|
235
|
+
// corrupting the token rather than being ignored by it.
|
|
236
|
+
//
|
|
237
|
+
// Origin's value comes from `effectiveOrigin`, so a per-call header still
|
|
238
|
+
// beats the connector default — it is the DUPLICATE that is removed, not
|
|
239
|
+
// the caller's intent.
|
|
240
|
+
const origin = this.effectiveOrigin(options);
|
|
81
241
|
const headers = {
|
|
82
|
-
...(
|
|
83
|
-
...(
|
|
242
|
+
...withoutHeaders(options?.headers, SYSTEM_HEADERS),
|
|
243
|
+
...(origin ? { Origin: origin } : {}),
|
|
84
244
|
'Content-Type': 'application/json',
|
|
85
245
|
Authorization: `Bearer ${token}`,
|
|
86
246
|
};
|
|
87
|
-
log('Sending %s request to %s', cmd.constructor?.name,
|
|
88
|
-
return (0, http_js_1.requestJson)(
|
|
247
|
+
log('Sending %s request to %s', cmd.constructor?.name, url);
|
|
248
|
+
return (0, http_js_1.requestJson)(url, { method: 'POST', headers, body: JSON.stringify(input) }, options);
|
|
89
249
|
}
|
|
90
250
|
}
|
|
91
251
|
exports.LocationServiceConnector = LocationServiceConnector;
|
|
252
|
+
function requireApiUrl(explicit) {
|
|
253
|
+
const apiUrl = (0, getClientConfig_js_1.resolveApiUrl)(explicit);
|
|
254
|
+
if (!apiUrl) {
|
|
255
|
+
throw new LocationServiceException_js_1.LocationServiceException({
|
|
256
|
+
code: 'ValidationException',
|
|
257
|
+
message: 'No API URL. Pass `apiUrl` to the LocationServiceConnector constructor ' +
|
|
258
|
+
'or set LOCATION_API_URL.',
|
|
259
|
+
details: { source: 'client' },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return apiUrl;
|
|
263
|
+
}
|
|
264
|
+
function noTokenAvailable() {
|
|
265
|
+
return new LocationServiceException_js_1.LocationServiceException({
|
|
266
|
+
code: 'InvalidCredentialsException',
|
|
267
|
+
message: 'No token available — check clientId/clientSecret configuration',
|
|
268
|
+
details: { source: 'client' },
|
|
269
|
+
});
|
|
270
|
+
}
|