@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.
@@ -1,12 +1,52 @@
1
+ import type { TokenResponse } from '../auth/TokenProvider.js';
1
2
  import type { ClientConfig } from '../types/index.js';
2
3
  export interface ServerAuthConfig {
3
4
  apiUrl?: string;
4
5
  clientId?: string;
5
6
  clientSecret?: string;
7
+ /**
8
+ * Mint a new token instead of returning the cached one.
9
+ *
10
+ * For the case the cache cannot see: a token the API has stopped accepting
11
+ * before its `exp` — revoked in the portal, or issued against a secret that
12
+ * has since been rotated. `TokenProvider` judges freshness from `exp` alone,
13
+ * so without this a caller holding a dead token waits out its whole lifetime.
14
+ */
15
+ forceRefresh?: boolean;
6
16
  }
7
17
  export interface ServerClientConfig extends ClientConfig {
8
18
  expiresAt?: number;
9
19
  }
20
+ /**
21
+ * Where the API lives, from the argument or the environment.
22
+ *
23
+ * Separate from the credentials because the two are independently overridable:
24
+ * a caller can hand `LocationServiceConnector` its own `token` and still expect
25
+ * `LOCATION_API_URL` to say where to send it.
26
+ */
27
+ export declare function resolveApiUrl(explicit?: string): string | undefined;
28
+ /**
29
+ * A LIVE token source: resolved credentials plus a `getToken` that re-mints
30
+ * when the cached token is spent.
31
+ *
32
+ * This is the seam `getClientConfig` and `LocationServiceConnector` share, and
33
+ * it exists because the two need different SHAPES of the same thing.
34
+ * `getClientConfig` has to return plain data — see the warning on its return
35
+ * value — so it can only ever hand back a snapshot. The connector is long-lived
36
+ * and needs the source itself, or it dies at the first `exp` (#36).
37
+ *
38
+ * Deliberately not exported from `./server`: it hands out a callable bound to
39
+ * the process-wide provider, and the public surface stays the two functions
40
+ * that were already there.
41
+ */
42
+ export interface ServerTokenSource {
43
+ apiUrl: string;
44
+ /** Resolves with a token or rejects; it never resolves tokenless. */
45
+ getToken(forceRefresh?: boolean): Promise<TokenResponse & {
46
+ token: string;
47
+ }>;
48
+ }
49
+ export declare function serverTokenSource(config?: ServerAuthConfig): ServerTokenSource;
10
50
  /**
11
51
  * Get client configuration with OAuth2 authentication.
12
52
  *
@@ -26,6 +66,21 @@ export interface ServerClientConfig extends ClientConfig {
26
66
  * NEVER call from browser/client code as it exposes credentials.
27
67
  * For SPA projects, create your own backend endpoint that calls this.
28
68
  *
69
+ * ## The return value is PLAIN DATA, and has to stay that way
70
+ *
71
+ * `{ apiUrl, token, expiresAt }` — no methods, no closures. The reason is not
72
+ * style: the shape every sample uses is a Next.js Server Action that returns
73
+ * this straight to a Client Component (every `src/lib/actions/location.ts`
74
+ * under `location-service-samples/web`), and the RSC boundary
75
+ * serialises it. A function on this object is not serialisable and throws at
76
+ * the boundary, so "make getClientConfig return getToken" — which #36 proposed
77
+ * and this JSDoc used to promise two lines below — would break every Next.js
78
+ * consumer of the library.
79
+ *
80
+ * A caller that needs a token which REFRESHES wants one of:
81
+ * - `LocationServiceConnector`, which holds a live source internally (#36), or
82
+ * - `TokenProvider` directly, if it is managing its own lifecycle.
83
+ *
29
84
  * @example
30
85
  * // Auto-detect from environment
31
86
  * const config = await getClientConfig()
@@ -33,8 +88,7 @@ export interface ServerClientConfig extends ClientConfig {
33
88
  * // Or override specific values
34
89
  * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
35
90
  *
36
- * // Use getToken() for automatic caching and refresh
37
- * const { token } = await config.getToken()
38
- * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
91
+ * // The API rejected the token before its exp — revoked, or secret rotated
92
+ * const fresh = await getClientConfig({ forceRefresh: true })
39
93
  */
40
94
  export declare function getClientConfig(config?: ServerAuthConfig): Promise<ServerClientConfig>;
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveApiUrl = resolveApiUrl;
7
+ exports.serverTokenSource = serverTokenSource;
6
8
  exports.getClientConfig = getClientConfig;
7
9
  const debug_1 = __importDefault(require("debug"));
8
10
  const node_crypto_1 = require("node:crypto");
@@ -30,6 +32,92 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
30
32
  currentConfig = configKey;
31
33
  return tokenProviderInstance;
32
34
  }
35
+ /**
36
+ * Where the API lives, from the argument or the environment.
37
+ *
38
+ * Separate from the credentials because the two are independently overridable:
39
+ * a caller can hand `LocationServiceConnector` its own `token` and still expect
40
+ * `LOCATION_API_URL` to say where to send it.
41
+ */
42
+ function resolveApiUrl(explicit) {
43
+ return (explicit ||
44
+ process.env.LOCATION_API_URL ||
45
+ process.env.LOCATION_SERVICE_API_URL);
46
+ }
47
+ function serverTokenSource(config = {}) {
48
+ log('[serverTokenSource] Starting with config:', {
49
+ hasApiUrl: !!config.apiUrl,
50
+ hasClientId: !!config.clientId,
51
+ });
52
+ // Auto-detect from environment with fallbacks
53
+ const apiUrl = resolveApiUrl(config.apiUrl);
54
+ const clientId = config.clientId ||
55
+ process.env.LOCATION_CLIENT_ID ||
56
+ process.env.LOCATION_SERVICE_CLIENT_ID;
57
+ const clientSecret = config.clientSecret ||
58
+ process.env.LOCATION_CLIENT_SECRET ||
59
+ process.env.LOCATION_SERVICE_CLIENT_SECRET;
60
+ log('[serverTokenSource] Resolved config:', {
61
+ apiUrl,
62
+ clientId: clientId?.substring(0, 10) + '...',
63
+ });
64
+ // Validate required values. A LocationServiceException like everything else
65
+ // this package throws — it used to be a bare `Error`, which was survivable
66
+ // while only `getClientConfig` could raise it and is not now that the
67
+ // connector reaches this path too.
68
+ if (!apiUrl || !clientId || !clientSecret) {
69
+ console.error('[location-client] Missing required configuration');
70
+ throw new LocationServiceException_js_1.LocationServiceException({
71
+ code: 'ValidationException',
72
+ message: 'Missing required configuration. Set environment variables: ' +
73
+ 'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET',
74
+ details: { source: 'client' },
75
+ });
76
+ }
77
+ log('[serverTokenSource] Getting TokenProvider instance');
78
+ const provider = getTokenProvider(apiUrl, clientId, clientSecret);
79
+ return {
80
+ apiUrl,
81
+ async getToken(forceRefresh = false) {
82
+ log('[serverTokenSource] Fetching token (forceRefresh=%s)', forceRefresh);
83
+ let result;
84
+ try {
85
+ result = await provider.getToken(forceRefresh);
86
+ }
87
+ catch (error) {
88
+ // The provider now rejects rather than resolving with success:false, and
89
+ // the rejection is typed — so a store outage (503) can be reported as a
90
+ // store outage instead of as bad credentials.
91
+ if (error instanceof LocationServiceException_js_1.LocationServiceException) {
92
+ if (error.isAuth) {
93
+ throw new LocationServiceException_js_1.LocationServiceException({
94
+ code: 'InvalidCredentialsException',
95
+ message: `Authentication failed for client ID "${clientId}". ` +
96
+ `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
97
+ statusCode: error.statusCode,
98
+ requestId: error.requestId,
99
+ cause: error,
100
+ });
101
+ }
102
+ throw error;
103
+ }
104
+ throw error;
105
+ }
106
+ // TokenProvider rejects rather than returning a tokenless success, so this
107
+ // is unreachable in practice — it is here to keep the contract explicit at
108
+ // the type level rather than asserting non-null.
109
+ const token = result.token;
110
+ if (!token) {
111
+ throw new LocationServiceException_js_1.LocationServiceException({
112
+ code: 'InvalidCredentialsException',
113
+ message: 'Token provider returned no token',
114
+ details: { source: 'client' },
115
+ });
116
+ }
117
+ return { ...result, token };
118
+ },
119
+ };
120
+ }
33
121
  /**
34
122
  * Get client configuration with OAuth2 authentication.
35
123
  *
@@ -49,6 +137,21 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
49
137
  * NEVER call from browser/client code as it exposes credentials.
50
138
  * For SPA projects, create your own backend endpoint that calls this.
51
139
  *
140
+ * ## The return value is PLAIN DATA, and has to stay that way
141
+ *
142
+ * `{ apiUrl, token, expiresAt }` — no methods, no closures. The reason is not
143
+ * style: the shape every sample uses is a Next.js Server Action that returns
144
+ * this straight to a Client Component (every `src/lib/actions/location.ts`
145
+ * under `location-service-samples/web`), and the RSC boundary
146
+ * serialises it. A function on this object is not serialisable and throws at
147
+ * the boundary, so "make getClientConfig return getToken" — which #36 proposed
148
+ * and this JSDoc used to promise two lines below — would break every Next.js
149
+ * consumer of the library.
150
+ *
151
+ * A caller that needs a token which REFRESHES wants one of:
152
+ * - `LocationServiceConnector`, which holds a live source internally (#36), or
153
+ * - `TokenProvider` directly, if it is managing its own lifecycle.
154
+ *
52
155
  * @example
53
156
  * // Auto-detect from environment
54
157
  * const config = await getClientConfig()
@@ -56,74 +159,15 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
56
159
  * // Or override specific values
57
160
  * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
58
161
  *
59
- * // Use getToken() for automatic caching and refresh
60
- * const { token } = await config.getToken()
61
- * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
162
+ * // The API rejected the token before its exp — revoked, or secret rotated
163
+ * const fresh = await getClientConfig({ forceRefresh: true })
62
164
  */
63
165
  async function getClientConfig(config = {}) {
64
- log('[getClientConfig] Starting with config:', {
65
- hasApiUrl: !!config.apiUrl,
66
- hasClientId: !!config.clientId,
67
- });
68
- // Auto-detect from environment with fallbacks
69
- const apiUrl = config.apiUrl ||
70
- process.env.LOCATION_API_URL ||
71
- process.env.LOCATION_SERVICE_API_URL;
72
- const clientId = config.clientId ||
73
- process.env.LOCATION_CLIENT_ID ||
74
- process.env.LOCATION_SERVICE_CLIENT_ID;
75
- const clientSecret = config.clientSecret ||
76
- process.env.LOCATION_CLIENT_SECRET ||
77
- process.env.LOCATION_SERVICE_CLIENT_SECRET;
78
- log('[getClientConfig] Resolved config:', {
79
- apiUrl,
80
- clientId: clientId?.substring(0, 10) + '...',
81
- });
82
- // Validate required values
83
- if (!apiUrl || !clientId || !clientSecret) {
84
- console.error('[getClientConfig] Missing required configuration');
85
- throw new Error('Missing required configuration. Set environment variables: ' +
86
- 'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET');
87
- }
88
- log('[getClientConfig] Getting TokenProvider instance');
89
- const provider = getTokenProvider(apiUrl, clientId, clientSecret);
90
- log('[getClientConfig] Fetching token');
91
- let result;
92
- try {
93
- result = await provider.getToken();
94
- }
95
- catch (error) {
96
- // The provider now rejects rather than resolving with success:false, and the
97
- // rejection is typed — so a store outage (503) can be reported as a store
98
- // outage instead of as bad credentials.
99
- if (error instanceof LocationServiceException_js_1.LocationServiceException) {
100
- if (error.isAuth) {
101
- throw new LocationServiceException_js_1.LocationServiceException({
102
- code: 'InvalidCredentialsException',
103
- message: `Authentication failed for client ID "${clientId}". ` +
104
- `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
105
- statusCode: error.statusCode,
106
- requestId: error.requestId,
107
- cause: error,
108
- });
109
- }
110
- throw error;
111
- }
112
- throw error;
113
- }
114
- // TokenProvider rejects rather than returning a tokenless success, so this is
115
- // unreachable in practice — it is here to keep the contract explicit at the
116
- // type level rather than asserting non-null.
117
- if (!result.token) {
118
- throw new LocationServiceException_js_1.LocationServiceException({
119
- code: 'InvalidCredentialsException',
120
- message: 'Token provider returned no token',
121
- details: { source: 'client' },
122
- });
123
- }
166
+ const source = serverTokenSource(config);
167
+ const result = await source.getToken(config.forceRefresh);
124
168
  log('[getClientConfig] Token fetched successfully, length:', result.token.length);
125
169
  return {
126
- apiUrl,
170
+ apiUrl: source.apiUrl,
127
171
  token: result.token,
128
172
  expiresAt: result.expiresAt,
129
173
  };
@@ -12,6 +12,19 @@ import { LocationServiceException } from '../errors/LocationServiceException.js'
12
12
  * { message: "Unauthorized" } API Gateway's own responses
13
13
  */
14
14
  export declare function parseErrorResponse(status: number, statusText: string, body: string, headers?: Headers): LocationServiceException;
15
+ /**
16
+ * The API has rejected this token: a 401, and only a 401.
17
+ *
18
+ * The authorizer throws `Unauthorized` for a token it cannot verify or that has
19
+ * expired, and API Gateway turns that into a 401. Its other refusals — no
20
+ * domain configured for the application, an Origin the application does not
21
+ * allow — are a Deny policy or a service 403, and a fresh token changes
22
+ * neither. Retrying those would send, and bill, the same doomed request twice.
23
+ *
24
+ * Shared by both send paths so the browser client and the server connector
25
+ * cannot come to different conclusions about the same response.
26
+ */
27
+ export declare function isTokenRejected(err: unknown): boolean;
15
28
  /**
16
29
  * `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
17
30
  * API sends the first; a date is handled so a proxy or gateway cannot surprise us.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseErrorResponse = parseErrorResponse;
4
+ exports.isTokenRejected = isTokenRejected;
4
5
  exports.parseRetryAfter = parseRetryAfter;
5
6
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
6
7
  /**
@@ -87,6 +88,21 @@ function statusCode(status) {
87
88
  return status >= 500 ? 'InternalException' : 'ServiceException';
88
89
  }
89
90
  }
91
+ /**
92
+ * The API has rejected this token: a 401, and only a 401.
93
+ *
94
+ * The authorizer throws `Unauthorized` for a token it cannot verify or that has
95
+ * expired, and API Gateway turns that into a 401. Its other refusals — no
96
+ * domain configured for the application, an Origin the application does not
97
+ * allow — are a Deny policy or a service 403, and a fresh token changes
98
+ * neither. Retrying those would send, and bill, the same doomed request twice.
99
+ *
100
+ * Shared by both send paths so the browser client and the server connector
101
+ * cannot come to different conclusions about the same response.
102
+ */
103
+ function isTokenRejected(err) {
104
+ return err instanceof LocationServiceException_js_1.LocationServiceException && err.statusCode === 401;
105
+ }
90
106
  /**
91
107
  * `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
92
108
  * API sends the first; a date is handled so a proxy or gateway cannot surprise us.
@@ -4,6 +4,25 @@ export interface ClientConfig {
4
4
  /** Optional callback to get the current token dynamically. When provided,
5
5
  * called on every request so token updates are reflected without recreating the client. */
6
6
  getToken?: () => string | undefined;
7
+ /**
8
+ * Asked for a replacement AFTER the API has rejected the current token with a
9
+ * 401, so the request can be retried once instead of failing.
10
+ *
11
+ * Separate from `getToken` because that one is synchronous by contract — it
12
+ * is read while a request is being built and cannot await anything, so it can
13
+ * only ever return the token already in hand. Nothing in this library could
14
+ * therefore recover from a token revoked, or a secret rotated, before its
15
+ * `exp`: every request failed until the refresh buffer elapsed on its own
16
+ * (#36).
17
+ *
18
+ * Returning `undefined` is not "no replacement": the client then re-reads
19
+ * `getToken`, because a provider refreshing in the background may have landed
20
+ * a new token while the failed request was in flight. What actually decides
21
+ * is the token that comes out of those two — if it is the one just rejected,
22
+ * or there is none, the retry is skipped rather than repeating a request that
23
+ * is going to fail again.
24
+ */
25
+ refreshToken?: () => Promise<string | undefined>;
7
26
  }
8
27
  /**
9
28
  * Minimal interface for AWS SDK command objects.
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import debug from 'debug';
2
2
  import { resolveEndpoint } from '../transport/endpoints.js';
3
+ import { isTokenRejected } from '../transport/errors.js';
3
4
  import { requestJson } from '../transport/http.js';
4
5
  import { roundPositionFields } from '../utils/roundPosition.js';
5
6
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
@@ -32,8 +33,11 @@ export class GeoPlacesClient {
32
33
  * case until one is configured in the portal.
33
34
  */
34
35
  getAppConfig() {
35
- const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
36
- return readAppConfigClaims(token);
36
+ return readAppConfigClaims(this.currentToken());
37
+ }
38
+ /** Prefer the getToken callback (live ref) over a static token string. */
39
+ currentToken() {
40
+ return this.clientConfig.getToken?.() ?? this.clientConfig.token;
37
41
  }
38
42
  /**
39
43
  * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
@@ -41,15 +45,35 @@ export class GeoPlacesClient {
41
45
  */
42
46
  async send(command, options) {
43
47
  const cmd = command;
44
- const endpoint = resolveEndpoint(cmd);
45
- // Prefer the getToken callback (live ref) over a static token string.
46
- const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
48
+ const url = `${this.clientConfig.apiUrl}${resolveEndpoint(cmd)}`;
49
+ const token = this.currentToken();
50
+ try {
51
+ return await this.dispatch(url, token, cmd, options);
52
+ }
53
+ catch (err) {
54
+ if (!isTokenRejected(err))
55
+ throw err;
56
+ // One shot. `refreshToken` is the only way to actually obtain a new
57
+ // token here — `getToken` is synchronous and returns the one already in
58
+ // hand — but it is re-read as a fallback because a provider that
59
+ // refreshes in the background may have landed a new one while this
60
+ // request was in flight.
61
+ const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
62
+ // Nothing new to send. Repeating the request would fail identically, and
63
+ // be billed identically.
64
+ if (!fresh || fresh === token)
65
+ throw err;
66
+ log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
67
+ return await this.dispatch(url, fresh, cmd, options);
68
+ }
69
+ }
70
+ dispatch(url, token, cmd, options) {
47
71
  // Resolve the token BEFORE rounding: the precision this application is
48
72
  // entitled to is a claim on it (api#65). Absent claim -> the 3 dp floor.
49
73
  const { biasDecimals } = readAppConfigClaims(token);
50
74
  const input = roundPositionFields(cmd.input, biasDecimals);
51
- log('Sending %s to %s', cmd.constructor?.name, endpoint);
52
- return requestJson(`${this.clientConfig.apiUrl}${endpoint}`, {
75
+ log('Sending %s to %s', cmd.constructor?.name, url);
76
+ return requestJson(url, {
53
77
  method: 'POST',
54
78
  headers: {
55
79
  '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,14 +1,58 @@
1
+ /**
2
+ * Does this URL belong to our API?
3
+ *
4
+ * This decides who receives the customer's bearer token, and it used to be
5
+ * `url.startsWith(apiUrl)` — a string test standing in for a URL test. For
6
+ * `apiUrl = "https://api.example.com"`, the host `api.example.com.evil.test`
7
+ * is a prefix match, so a style referencing
8
+ * `https://api.example.com.evil.test/tiles/1/2/3` was handed
9
+ * `Authorization: Bearer <token>` and the token left the building (#34).
10
+ *
11
+ * That is reachable because a style descriptor is DATA: its `sprite`, `glyphs`
12
+ * and `sources` entries are URLs the style author chose, and MapLibre asks
13
+ * transformRequest about every one of them. Any style not wholly ours — a
14
+ * customer's own, or one edited through a tool — can name a host it likes.
15
+ *
16
+ * Compared as URLs instead, which also gets host case-folding, default ports
17
+ * (`https://api.test:443` === `https://api.test`) and userinfo right for free,
18
+ * and adds a path check so a shared host serving another tenant under a
19
+ * different base path is not "ours" either.
20
+ *
21
+ * Fails CLOSED: anything that will not parse gets no token. The only way to
22
+ * reach that is a relative `apiUrl` in a runtime with no `location` to resolve
23
+ * it against — i.e. not a browser, which is the only place MapLibre runs.
24
+ */
25
+ function isOurApi(url, apiUrl) {
26
+ const base = typeof location === 'undefined' ? undefined : location.href;
27
+ let ours;
28
+ let theirs;
29
+ try {
30
+ ours = new URL(apiUrl, base);
31
+ theirs = new URL(url, base);
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ if (theirs.origin !== ours.origin)
37
+ return false;
38
+ // Trailing slashes normalised so `/v1` and `/v1/` behave the same; the `/`
39
+ // boundary is what stops `/v1` from matching `/v1-internal`.
40
+ const basePath = ours.pathname.replace(/\/+$/, '');
41
+ return (theirs.pathname === basePath || theirs.pathname.startsWith(`${basePath}/`));
42
+ }
1
43
  /**
2
44
  * Creates a transformRequest function for MapLibre that adds authentication
3
45
  * and proper Accept headers for AWS Location Service API requests.
4
46
  *
47
+ * The token is attached to our own API and nowhere else — see isOurApi.
48
+ *
5
49
  * @param apiUrl - Base URL of the Location Service API
6
50
  * @param getToken - Callback function that returns the current auth token
7
51
  * @returns MapLibre transformRequest function
8
52
  */
9
53
  export function createTransformRequest(apiUrl, getToken) {
10
54
  return (url, _resourceType) => {
11
- if (url.startsWith(apiUrl)) {
55
+ if (isOurApi(url, apiUrl)) {
12
56
  const token = getToken();
13
57
  if (!token) {
14
58
  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
- getToken?: () => Promise<{
7
- token: string;
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 configPromise;
34
- private origin?;
63
+ private readonly config;
64
+ private tokenSource?;
65
+ private readonly origin?;
35
66
  readonly serviceId: string;
36
67
  constructor(config?: ConnectorConfig);
37
- /** One place that knows how a token is obtained, so nothing can drift. */
38
- private resolveToken;
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
  }