@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,11 +1,68 @@
1
1
  import debug from 'debug';
2
2
  import { LocationServiceException } from '../errors/LocationServiceException.js';
3
3
  import { resolveEndpoint } from '../transport/endpoints.js';
4
+ import { isTokenRejected } from '../transport/errors.js';
4
5
  import { requestJson } from '../transport/http.js';
5
6
  import { roundPositionFields } from '../utils/roundPosition.js';
6
7
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
7
- import { getClientConfig } from './getClientConfig.js';
8
+ import { resolveApiUrl, serverTokenSource } from './getClientConfig.js';
8
9
  const log = debug('location-client:connector');
10
+ /** Header lookup that does not care how the caller capitalised the name. */
11
+ function headerValue(headers, name) {
12
+ if (!headers)
13
+ return undefined;
14
+ const wanted = name.toLowerCase();
15
+ for (const [key, value] of Object.entries(headers)) {
16
+ if (key.toLowerCase() === wanted)
17
+ return value;
18
+ }
19
+ return undefined;
20
+ }
21
+ /**
22
+ * The headers `dispatch` sets itself, and which a caller therefore cannot
23
+ * supply. Lower-case, because that is how they are compared.
24
+ *
25
+ * Kept beside `withoutHeaders` so the list and the record in `dispatch` cannot
26
+ * drift apart: a header added there must be added here too, or the caller's own
27
+ * spelling of it survives the merge.
28
+ */
29
+ const SYSTEM_HEADERS = ['origin', 'content-type', 'authorization'];
30
+ /** The caller's headers minus `names`, however they capitalised them. */
31
+ function withoutHeaders(headers, names) {
32
+ if (!headers)
33
+ return {};
34
+ const wanted = new Set(names);
35
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => !wanted.has(key.toLowerCase())));
36
+ }
37
+ /**
38
+ * Say why the 403 happened, when we know.
39
+ *
40
+ * `Origin not allowed` with no Origin sent is not an ambiguous failure — it is
41
+ * the documented backend path missing one piece of configuration (#45), and the
42
+ * API's own message cannot say so because from its side the header is simply
43
+ * absent. A new integrator following the README hit a bare "Origin not allowed"
44
+ * that named neither the cause nor the fix.
45
+ */
46
+ function explainMissingOrigin(err, sentOrigin) {
47
+ if (sentOrigin)
48
+ return err;
49
+ if (!(err instanceof LocationServiceException))
50
+ return err;
51
+ if (err.code !== 'OriginNotAllowedException')
52
+ return err;
53
+ return new LocationServiceException({
54
+ code: err.code,
55
+ message: `${err.message} — this request carried no Origin header, and the API requires ` +
56
+ `one it recognises on every data request. Set \`origin\` on the ` +
57
+ `LocationServiceConnector, set LOCATION_ORIGIN, or pass an Origin in the ` +
58
+ `per-call headers; if the application has no allowed domain configured in ` +
59
+ `the developer portal yet, set that first.`,
60
+ statusCode: err.statusCode,
61
+ requestId: err.requestId,
62
+ details: err.details,
63
+ cause: err,
64
+ });
65
+ }
9
66
  /**
10
67
  * LocationServiceConnector — server-side connector for the Location Service API.
11
68
  *
@@ -13,28 +70,79 @@ const log = debug('location-client:connector');
13
70
  * token management, and the same transport (timeout, cancellation, retry) as the
14
71
  * browser client.
15
72
  *
73
+ * Configuration is COMPLETED from the environment rather than replaced by it.
74
+ * The constructor used to be all-or-nothing — any argument at all took the
75
+ * "caller supplies everything" branch — so `new LocationServiceConnector()` had
76
+ * credentials but could never send an Origin (403 on every data request) and
77
+ * `new LocationServiceConnector({ origin })` had an Origin but no credentials
78
+ * (#45). Now an explicit `token`/`getToken` still wins outright, and anything
79
+ * short of that is filled in from `LOCATION_API_URL` / `LOCATION_CLIENT_ID` /
80
+ * `LOCATION_CLIENT_SECRET` / `LOCATION_ORIGIN`.
81
+ *
16
82
  * @example
17
83
  * ```typescript
84
+ * // Credentials and apiUrl from the environment, Origin supplied here
18
85
  * const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
19
86
  * const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
20
87
  * ```
21
88
  */
22
89
  export class LocationServiceConnector {
23
- constructor(config) {
90
+ constructor(config = {}) {
24
91
  this.serviceId = 'Geo Places';
25
- this.configPromise = config ? Promise.resolve(config) : getClientConfig();
26
- this.origin = config?.origin;
92
+ this.config = config;
93
+ // `||`, not `??`, to match the other four env-completed fields: an empty
94
+ // string is not a choice to send no Origin, it is a missing value, and the
95
+ // asymmetry meant `origin: ''` suppressed the environment and then produced
96
+ // an error telling the caller to set a variable they may already have set.
97
+ this.origin =
98
+ config.origin ||
99
+ process.env.LOCATION_ORIGIN ||
100
+ process.env.LOCATION_SERVICE_ORIGIN;
101
+ }
102
+ /**
103
+ * One place that knows how a token is obtained, so nothing can drift.
104
+ *
105
+ * Built on first use rather than in the constructor: resolving the
106
+ * environment path used to start a `/auth/token` round trip from `new`, whose
107
+ * rejection nothing was awaiting yet — an unhandled rejection for a
108
+ * misconfigured process, before it had made a single request.
109
+ */
110
+ source() {
111
+ return (this.tokenSource ?? (this.tokenSource = this.buildSource()));
27
112
  }
28
- /** One place that knows how a token is obtained, so nothing can drift. */
29
- async resolveToken() {
30
- const config = await this.configPromise;
31
- if ('getToken' in config && typeof config.getToken === 'function') {
32
- const result = await config.getToken();
33
- if (!result)
34
- return undefined;
35
- return typeof result === 'string' ? result : result.token;
113
+ buildSource() {
114
+ const { apiUrl, token, getToken, clientId, clientSecret } = this.config;
115
+ // An explicit token source wins outright. A caller that supplied one is
116
+ // managing credentials itself, and quietly reading the environment
117
+ // underneath it could send another application's token.
118
+ if (getToken) {
119
+ return {
120
+ apiUrl: () => requireApiUrl(apiUrl),
121
+ get: async (forceRefresh) => {
122
+ const result = await getToken(forceRefresh);
123
+ if (!result)
124
+ return undefined;
125
+ return typeof result === 'string' ? result : result.token;
126
+ },
127
+ };
128
+ }
129
+ if (token) {
130
+ return {
131
+ apiUrl: () => requireApiUrl(apiUrl),
132
+ // A fixed string. Asking again returns the same one, which is how the
133
+ // retry guard knows there is nothing to retry with.
134
+ get: async () => token,
135
+ };
36
136
  }
37
- return config.token;
137
+ // Nothing but (at most) an apiUrl and an origin — complete it from the
138
+ // environment. This is the branch every sample and doc actually takes.
139
+ const env = serverTokenSource({ apiUrl, clientId, clientSecret });
140
+ return {
141
+ // Already validated by serverTokenSource, which cannot resolve
142
+ // credentials without it.
143
+ apiUrl: () => env.apiUrl,
144
+ get: async (forceRefresh) => (await env.getToken(forceRefresh)).token,
145
+ };
38
146
  }
39
147
  /**
40
148
  * This application's own configuration, as carried on the access token
@@ -51,34 +159,105 @@ export class LocationServiceConnector {
51
159
  * case until one is configured in the portal.
52
160
  */
53
161
  async getAppConfig() {
54
- return readAppConfigClaims(await this.resolveToken());
162
+ return readAppConfigClaims(await this.source().get());
163
+ }
164
+ /**
165
+ * The Origin this request will actually carry.
166
+ *
167
+ * ONE definition, for the two readers that must never disagree about it: the
168
+ * header merge in `dispatch`, and the 403 explanation in `send`. A per-call
169
+ * header beats the connector default, whatever the caller capitalised.
170
+ */
171
+ effectiveOrigin(options) {
172
+ // `||` for the same reason as the constructor: an empty per-call header is
173
+ // a missing value, not a decision to send no Origin.
174
+ return headerValue(options?.headers, 'origin') || this.origin;
55
175
  }
56
176
  async send(command, options) {
57
- const token = await this.resolveToken();
58
- if (!token) {
59
- throw new LocationServiceException({
60
- code: 'InvalidCredentialsException',
61
- message: 'No token available — check clientId/clientSecret configuration',
62
- details: { source: 'client' },
63
- });
64
- }
177
+ const source = this.source();
65
178
  const cmd = command;
66
- const endpoint = resolveEndpoint(cmd);
67
- // The token is already resolved above, so the precision this application
68
- // is entitled to is available before the request is shaped (api#65).
69
- // Absent claim -> the 3 dp floor, which is what every application gets
70
- // until one is configured otherwise.
179
+ const url = `${source.apiUrl()}${resolveEndpoint(cmd)}`;
180
+ try {
181
+ return await this.dispatchWithRetry(source, url, cmd, options);
182
+ }
183
+ catch (err) {
184
+ throw explainMissingOrigin(err, this.effectiveOrigin(options));
185
+ }
186
+ }
187
+ async dispatchWithRetry(source, url, cmd, options) {
188
+ const token = await source.get();
189
+ if (!token)
190
+ throw noTokenAvailable();
191
+ try {
192
+ return await this.dispatch(url, token, cmd, options);
193
+ }
194
+ catch (err) {
195
+ if (!isTokenRejected(err))
196
+ throw err;
197
+ // One retry, and only when the replacement is genuinely a different
198
+ // token. That single comparison covers every source: a fixed `token`
199
+ // string, a caller `getToken` that ignores `forceRefresh`, and a cached
200
+ // token the API has revoked before its `exp` all hand back what we
201
+ // already sent — and re-sending it would be a second doomed request, and
202
+ // a second billed one.
203
+ const fresh = await source.get(true);
204
+ if (!fresh || fresh === token)
205
+ throw err;
206
+ log('401 on a token the API no longer accepts — retrying once, refreshed');
207
+ return await this.dispatch(url, fresh, cmd, options);
208
+ }
209
+ }
210
+ dispatch(url, token, cmd, options) {
211
+ // The token is resolved before the request is shaped, so the precision this
212
+ // application is entitled to is available (api#65). Absent claim -> the
213
+ // 3 dp floor, which is what every application gets until one is configured
214
+ // otherwise. Recomputed per attempt because a refreshed token may carry
215
+ // different claims.
71
216
  const { biasDecimals } = readAppConfigClaims(token);
72
217
  const input = roundPositionFields(cmd.input, biasDecimals);
73
- // Caller headers first so the system ones below cannot be overridden, but an
74
- // explicit per-call Origin still beats the connector-wide default.
218
+ // Every system header is set exactly ONCE, and the caller's own spelling of
219
+ // each is dropped first.
220
+ //
221
+ // Spreading them over the caller's record is not enough, because fetch's
222
+ // Headers fill APPENDS rather than replaces: a caller's lowercase key
223
+ // survives beside the canonical one and both go on the wire. For Origin
224
+ // that produced `Origin: default, per-call`, which the API's exact-match
225
+ // domain check rejects (observed: 403 OriginNotAllowedException) — and two
226
+ // keys with the SAME value fared no better, `Origin: x, x`. For
227
+ // Authorization it is worse than a failed override: `{ authorization:
228
+ // 'Bearer not-yours' }` went out as `Bearer not-yours, Bearer <real>`,
229
+ // corrupting the token rather than being ignored by it.
230
+ //
231
+ // Origin's value comes from `effectiveOrigin`, so a per-call header still
232
+ // beats the connector default — it is the DUPLICATE that is removed, not
233
+ // the caller's intent.
234
+ const origin = this.effectiveOrigin(options);
75
235
  const headers = {
76
- ...(this.origin ? { Origin: this.origin } : {}),
77
- ...(options?.headers ?? {}),
236
+ ...withoutHeaders(options?.headers, SYSTEM_HEADERS),
237
+ ...(origin ? { Origin: origin } : {}),
78
238
  'Content-Type': 'application/json',
79
239
  Authorization: `Bearer ${token}`,
80
240
  };
81
- log('Sending %s request to %s', cmd.constructor?.name, endpoint);
82
- return requestJson(`${(await this.configPromise).apiUrl}${endpoint}`, { method: 'POST', headers, body: JSON.stringify(input) }, options);
241
+ log('Sending %s request to %s', cmd.constructor?.name, url);
242
+ return requestJson(url, { method: 'POST', headers, body: JSON.stringify(input) }, options);
83
243
  }
84
244
  }
245
+ function requireApiUrl(explicit) {
246
+ const apiUrl = resolveApiUrl(explicit);
247
+ if (!apiUrl) {
248
+ throw new LocationServiceException({
249
+ code: 'ValidationException',
250
+ message: 'No API URL. Pass `apiUrl` to the LocationServiceConnector constructor ' +
251
+ 'or set LOCATION_API_URL.',
252
+ details: { source: 'client' },
253
+ });
254
+ }
255
+ return apiUrl;
256
+ }
257
+ function noTokenAvailable() {
258
+ return new LocationServiceException({
259
+ code: 'InvalidCredentialsException',
260
+ message: 'No token available — check clientId/clientSecret configuration',
261
+ details: { source: 'client' },
262
+ });
263
+ }
@@ -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>;
@@ -24,6 +24,92 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
24
24
  currentConfig = configKey;
25
25
  return tokenProviderInstance;
26
26
  }
27
+ /**
28
+ * Where the API lives, from the argument or the environment.
29
+ *
30
+ * Separate from the credentials because the two are independently overridable:
31
+ * a caller can hand `LocationServiceConnector` its own `token` and still expect
32
+ * `LOCATION_API_URL` to say where to send it.
33
+ */
34
+ export function resolveApiUrl(explicit) {
35
+ return (explicit ||
36
+ process.env.LOCATION_API_URL ||
37
+ process.env.LOCATION_SERVICE_API_URL);
38
+ }
39
+ export function serverTokenSource(config = {}) {
40
+ log('[serverTokenSource] Starting with config:', {
41
+ hasApiUrl: !!config.apiUrl,
42
+ hasClientId: !!config.clientId,
43
+ });
44
+ // Auto-detect from environment with fallbacks
45
+ const apiUrl = resolveApiUrl(config.apiUrl);
46
+ const clientId = config.clientId ||
47
+ process.env.LOCATION_CLIENT_ID ||
48
+ process.env.LOCATION_SERVICE_CLIENT_ID;
49
+ const clientSecret = config.clientSecret ||
50
+ process.env.LOCATION_CLIENT_SECRET ||
51
+ process.env.LOCATION_SERVICE_CLIENT_SECRET;
52
+ log('[serverTokenSource] Resolved config:', {
53
+ apiUrl,
54
+ clientId: clientId?.substring(0, 10) + '...',
55
+ });
56
+ // Validate required values. A LocationServiceException like everything else
57
+ // this package throws — it used to be a bare `Error`, which was survivable
58
+ // while only `getClientConfig` could raise it and is not now that the
59
+ // connector reaches this path too.
60
+ if (!apiUrl || !clientId || !clientSecret) {
61
+ console.error('[location-client] Missing required configuration');
62
+ throw new LocationServiceException({
63
+ code: 'ValidationException',
64
+ message: 'Missing required configuration. Set environment variables: ' +
65
+ 'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET',
66
+ details: { source: 'client' },
67
+ });
68
+ }
69
+ log('[serverTokenSource] Getting TokenProvider instance');
70
+ const provider = getTokenProvider(apiUrl, clientId, clientSecret);
71
+ return {
72
+ apiUrl,
73
+ async getToken(forceRefresh = false) {
74
+ log('[serverTokenSource] Fetching token (forceRefresh=%s)', forceRefresh);
75
+ let result;
76
+ try {
77
+ result = await provider.getToken(forceRefresh);
78
+ }
79
+ catch (error) {
80
+ // The provider now rejects rather than resolving with success:false, and
81
+ // the rejection is typed — so a store outage (503) can be reported as a
82
+ // store outage instead of as bad credentials.
83
+ if (error instanceof LocationServiceException) {
84
+ if (error.isAuth) {
85
+ throw new LocationServiceException({
86
+ code: 'InvalidCredentialsException',
87
+ message: `Authentication failed for client ID "${clientId}". ` +
88
+ `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
89
+ statusCode: error.statusCode,
90
+ requestId: error.requestId,
91
+ cause: error,
92
+ });
93
+ }
94
+ throw error;
95
+ }
96
+ throw error;
97
+ }
98
+ // TokenProvider rejects rather than returning a tokenless success, so this
99
+ // is unreachable in practice — it is here to keep the contract explicit at
100
+ // the type level rather than asserting non-null.
101
+ const token = result.token;
102
+ if (!token) {
103
+ throw new LocationServiceException({
104
+ code: 'InvalidCredentialsException',
105
+ message: 'Token provider returned no token',
106
+ details: { source: 'client' },
107
+ });
108
+ }
109
+ return { ...result, token };
110
+ },
111
+ };
112
+ }
27
113
  /**
28
114
  * Get client configuration with OAuth2 authentication.
29
115
  *
@@ -43,6 +129,21 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
43
129
  * NEVER call from browser/client code as it exposes credentials.
44
130
  * For SPA projects, create your own backend endpoint that calls this.
45
131
  *
132
+ * ## The return value is PLAIN DATA, and has to stay that way
133
+ *
134
+ * `{ apiUrl, token, expiresAt }` — no methods, no closures. The reason is not
135
+ * style: the shape every sample uses is a Next.js Server Action that returns
136
+ * this straight to a Client Component (every `src/lib/actions/location.ts`
137
+ * under `location-service-samples/web`), and the RSC boundary
138
+ * serialises it. A function on this object is not serialisable and throws at
139
+ * the boundary, so "make getClientConfig return getToken" — which #36 proposed
140
+ * and this JSDoc used to promise two lines below — would break every Next.js
141
+ * consumer of the library.
142
+ *
143
+ * A caller that needs a token which REFRESHES wants one of:
144
+ * - `LocationServiceConnector`, which holds a live source internally (#36), or
145
+ * - `TokenProvider` directly, if it is managing its own lifecycle.
146
+ *
46
147
  * @example
47
148
  * // Auto-detect from environment
48
149
  * const config = await getClientConfig()
@@ -50,74 +151,15 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
50
151
  * // Or override specific values
51
152
  * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
52
153
  *
53
- * // Use getToken() for automatic caching and refresh
54
- * const { token } = await config.getToken()
55
- * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
154
+ * // The API rejected the token before its exp — revoked, or secret rotated
155
+ * const fresh = await getClientConfig({ forceRefresh: true })
56
156
  */
57
157
  export async function getClientConfig(config = {}) {
58
- log('[getClientConfig] Starting with config:', {
59
- hasApiUrl: !!config.apiUrl,
60
- hasClientId: !!config.clientId,
61
- });
62
- // Auto-detect from environment with fallbacks
63
- const apiUrl = config.apiUrl ||
64
- process.env.LOCATION_API_URL ||
65
- process.env.LOCATION_SERVICE_API_URL;
66
- const clientId = config.clientId ||
67
- process.env.LOCATION_CLIENT_ID ||
68
- process.env.LOCATION_SERVICE_CLIENT_ID;
69
- const clientSecret = config.clientSecret ||
70
- process.env.LOCATION_CLIENT_SECRET ||
71
- process.env.LOCATION_SERVICE_CLIENT_SECRET;
72
- log('[getClientConfig] Resolved config:', {
73
- apiUrl,
74
- clientId: clientId?.substring(0, 10) + '...',
75
- });
76
- // Validate required values
77
- if (!apiUrl || !clientId || !clientSecret) {
78
- console.error('[getClientConfig] Missing required configuration');
79
- throw new Error('Missing required configuration. Set environment variables: ' +
80
- 'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET');
81
- }
82
- log('[getClientConfig] Getting TokenProvider instance');
83
- const provider = getTokenProvider(apiUrl, clientId, clientSecret);
84
- log('[getClientConfig] Fetching token');
85
- let result;
86
- try {
87
- result = await provider.getToken();
88
- }
89
- catch (error) {
90
- // The provider now rejects rather than resolving with success:false, and the
91
- // rejection is typed — so a store outage (503) can be reported as a store
92
- // outage instead of as bad credentials.
93
- if (error instanceof LocationServiceException) {
94
- if (error.isAuth) {
95
- throw new LocationServiceException({
96
- code: 'InvalidCredentialsException',
97
- message: `Authentication failed for client ID "${clientId}". ` +
98
- `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
99
- statusCode: error.statusCode,
100
- requestId: error.requestId,
101
- cause: error,
102
- });
103
- }
104
- throw error;
105
- }
106
- throw error;
107
- }
108
- // TokenProvider rejects rather than returning a tokenless success, so this is
109
- // unreachable in practice — it is here to keep the contract explicit at the
110
- // type level rather than asserting non-null.
111
- if (!result.token) {
112
- throw new LocationServiceException({
113
- code: 'InvalidCredentialsException',
114
- message: 'Token provider returned no token',
115
- details: { source: 'client' },
116
- });
117
- }
158
+ const source = serverTokenSource(config);
159
+ const result = await source.getToken(config.forceRefresh);
118
160
  log('[getClientConfig] Token fetched successfully, length:', result.token.length);
119
161
  return {
120
- apiUrl,
162
+ apiUrl: source.apiUrl,
121
163
  token: result.token,
122
164
  expiresAt: result.expiresAt,
123
165
  };
@@ -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.
@@ -83,6 +83,21 @@ function statusCode(status) {
83
83
  return status >= 500 ? 'InternalException' : 'ServiceException';
84
84
  }
85
85
  }
86
+ /**
87
+ * The API has rejected this token: a 401, and only a 401.
88
+ *
89
+ * The authorizer throws `Unauthorized` for a token it cannot verify or that has
90
+ * expired, and API Gateway turns that into a 401. Its other refusals — no
91
+ * domain configured for the application, an Origin the application does not
92
+ * allow — are a Deny policy or a service 403, and a fresh token changes
93
+ * neither. Retrying those would send, and bill, the same doomed request twice.
94
+ *
95
+ * Shared by both send paths so the browser client and the server connector
96
+ * cannot come to different conclusions about the same response.
97
+ */
98
+ export function isTokenRejected(err) {
99
+ return err instanceof LocationServiceException && err.statusCode === 401;
100
+ }
86
101
  /**
87
102
  * `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
88
103
  * 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",