@chaosity/location-client 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +107 -4
  2. package/dist/cjs/client/GeoPlacesClient.d.ts +13 -2
  3. package/dist/cjs/client/GeoPlacesClient.js +59 -9
  4. package/dist/cjs/index.d.ts +1 -1
  5. package/dist/cjs/index.js +3 -2
  6. package/dist/cjs/maps/createTransformRequest.d.ts +2 -0
  7. package/dist/cjs/maps/createTransformRequest.js +45 -1
  8. package/dist/cjs/maps/mapStyle.d.ts +3 -1
  9. package/dist/cjs/maps/mapStyle.js +24 -19
  10. package/dist/cjs/maps/staticMap.d.ts +3 -1
  11. package/dist/cjs/maps/staticMap.js +18 -12
  12. package/dist/cjs/server/LocationServiceConnector.d.ts +56 -7
  13. package/dist/cjs/server/LocationServiceConnector.js +210 -32
  14. package/dist/cjs/server/getClientConfig.d.ts +76 -3
  15. package/dist/cjs/server/getClientConfig.js +152 -74
  16. package/dist/cjs/transport/errors.d.ts +30 -0
  17. package/dist/cjs/transport/errors.js +40 -0
  18. package/dist/cjs/transport/http.d.ts +36 -0
  19. package/dist/cjs/transport/http.js +118 -15
  20. package/dist/cjs/types/index.d.ts +37 -1
  21. package/dist/client/GeoPlacesClient.d.ts +13 -2
  22. package/dist/client/GeoPlacesClient.js +59 -9
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +2 -2
  25. package/dist/maps/createTransformRequest.d.ts +2 -0
  26. package/dist/maps/createTransformRequest.js +45 -1
  27. package/dist/maps/mapStyle.d.ts +3 -1
  28. package/dist/maps/mapStyle.js +25 -20
  29. package/dist/maps/staticMap.d.ts +3 -1
  30. package/dist/maps/staticMap.js +19 -13
  31. package/dist/server/LocationServiceConnector.d.ts +56 -7
  32. package/dist/server/LocationServiceConnector.js +211 -33
  33. package/dist/server/getClientConfig.d.ts +76 -3
  34. package/dist/server/getClientConfig.js +149 -74
  35. package/dist/transport/errors.d.ts +30 -0
  36. package/dist/transport/errors.js +38 -0
  37. package/dist/transport/http.d.ts +36 -0
  38. package/dist/transport/http.js +116 -14
  39. package/dist/types/index.d.ts +37 -1
  40. package/package.json +1 -1
@@ -7,11 +7,74 @@ 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
+ /**
37
+ * What to check when the token source comes up empty. The guard itself is
38
+ * shared with the map fetches (`noTokenAvailable`); only the advice differs,
39
+ * and on this path the answer is always the credentials.
40
+ */
41
+ const NO_TOKEN_ADVICE = 'check clientId/clientSecret configuration';
42
+ /** The caller's headers minus `names`, however they capitalised them. */
43
+ function withoutHeaders(headers, names) {
44
+ if (!headers)
45
+ return {};
46
+ const wanted = new Set(names);
47
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => !wanted.has(key.toLowerCase())));
48
+ }
49
+ /**
50
+ * Say why the 403 happened, when we know.
51
+ *
52
+ * `Origin not allowed` with no Origin sent is not an ambiguous failure — it is
53
+ * the documented backend path missing one piece of configuration (#45), and the
54
+ * API's own message cannot say so because from its side the header is simply
55
+ * absent. A new integrator following the README hit a bare "Origin not allowed"
56
+ * that named neither the cause nor the fix.
57
+ */
58
+ function explainMissingOrigin(err, sentOrigin) {
59
+ if (sentOrigin)
60
+ return err;
61
+ if (!(err instanceof LocationServiceException_js_1.LocationServiceException))
62
+ return err;
63
+ if (err.code !== 'OriginNotAllowedException')
64
+ return err;
65
+ return new LocationServiceException_js_1.LocationServiceException({
66
+ code: err.code,
67
+ message: `${err.message} — this request carried no Origin header, and the API requires ` +
68
+ `one it recognises on every data request. Set \`origin\` on the ` +
69
+ `LocationServiceConnector, set LOCATION_ORIGIN, or pass an Origin in the ` +
70
+ `per-call headers; if the application has no allowed domain configured in ` +
71
+ `the developer portal yet, set that first.`,
72
+ statusCode: err.statusCode,
73
+ requestId: err.requestId,
74
+ details: err.details,
75
+ cause: err,
76
+ });
77
+ }
15
78
  /**
16
79
  * LocationServiceConnector — server-side connector for the Location Service API.
17
80
  *
@@ -19,28 +82,79 @@ const log = (0, debug_1.default)('location-client:connector');
19
82
  * token management, and the same transport (timeout, cancellation, retry) as the
20
83
  * browser client.
21
84
  *
85
+ * Configuration is COMPLETED from the environment rather than replaced by it.
86
+ * The constructor used to be all-or-nothing — any argument at all took the
87
+ * "caller supplies everything" branch — so `new LocationServiceConnector()` had
88
+ * credentials but could never send an Origin (403 on every data request) and
89
+ * `new LocationServiceConnector({ origin })` had an Origin but no credentials
90
+ * (#45). Now an explicit `token`/`getToken` still wins outright, and anything
91
+ * short of that is filled in from `LOCATION_API_URL` / `LOCATION_CLIENT_ID` /
92
+ * `LOCATION_CLIENT_SECRET` / `LOCATION_ORIGIN`.
93
+ *
22
94
  * @example
23
95
  * ```typescript
96
+ * // Credentials and apiUrl from the environment, Origin supplied here
24
97
  * const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
25
98
  * const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
26
99
  * ```
27
100
  */
28
101
  class LocationServiceConnector {
29
- constructor(config) {
102
+ constructor(config = {}) {
30
103
  this.serviceId = 'Geo Places';
31
- this.configPromise = config ? Promise.resolve(config) : (0, getClientConfig_js_1.getClientConfig)();
32
- this.origin = config?.origin;
104
+ this.config = config;
105
+ // `||`, not `??`, to match the other four env-completed fields: an empty
106
+ // string is not a choice to send no Origin, it is a missing value, and the
107
+ // asymmetry meant `origin: ''` suppressed the environment and then produced
108
+ // an error telling the caller to set a variable they may already have set.
109
+ this.origin =
110
+ config.origin ||
111
+ process.env.LOCATION_ORIGIN ||
112
+ process.env.LOCATION_SERVICE_ORIGIN;
113
+ }
114
+ /**
115
+ * One place that knows how a token is obtained, so nothing can drift.
116
+ *
117
+ * Built on first use rather than in the constructor: resolving the
118
+ * environment path used to start a `/auth/token` round trip from `new`, whose
119
+ * rejection nothing was awaiting yet — an unhandled rejection for a
120
+ * misconfigured process, before it had made a single request.
121
+ */
122
+ source() {
123
+ return (this.tokenSource ?? (this.tokenSource = this.buildSource()));
33
124
  }
34
- /** One place that knows how a token is obtained, so nothing can drift. */
35
- async resolveToken() {
36
- const config = await this.configPromise;
37
- if ('getToken' in config && typeof config.getToken === 'function') {
38
- const result = await config.getToken();
39
- if (!result)
40
- return undefined;
41
- return typeof result === 'string' ? result : result.token;
125
+ buildSource() {
126
+ const { apiUrl, token, getToken, clientId, clientSecret } = this.config;
127
+ // An explicit token source wins outright. A caller that supplied one is
128
+ // managing credentials itself, and quietly reading the environment
129
+ // underneath it could send another application's token.
130
+ if (getToken) {
131
+ return {
132
+ apiUrl: () => requireApiUrl(apiUrl),
133
+ get: async (forceRefresh) => {
134
+ const result = await getToken(forceRefresh);
135
+ if (!result)
136
+ return undefined;
137
+ return typeof result === 'string' ? result : result.token;
138
+ },
139
+ };
140
+ }
141
+ if (token) {
142
+ return {
143
+ apiUrl: () => requireApiUrl(apiUrl),
144
+ // A fixed string. Asking again returns the same one, which is how the
145
+ // retry guard knows there is nothing to retry with.
146
+ get: async () => token,
147
+ };
42
148
  }
43
- return config.token;
149
+ // Nothing but (at most) an apiUrl and an origin — complete it from the
150
+ // environment. This is the branch every sample and doc actually takes.
151
+ const env = (0, getClientConfig_js_1.serverTokenSource)({ apiUrl, clientId, clientSecret });
152
+ return {
153
+ // Already validated by serverTokenSource, which cannot resolve
154
+ // credentials without it.
155
+ apiUrl: () => env.apiUrl,
156
+ get: async (forceRefresh) => (await env.getToken(forceRefresh)).token,
157
+ };
44
158
  }
45
159
  /**
46
160
  * This application's own configuration, as carried on the access token
@@ -57,35 +171,99 @@ class LocationServiceConnector {
57
171
  * case until one is configured in the portal.
58
172
  */
59
173
  async getAppConfig() {
60
- return (0, tokenClaims_js_1.readAppConfigClaims)(await this.resolveToken());
174
+ return (0, tokenClaims_js_1.readAppConfigClaims)(await this.source().get());
175
+ }
176
+ /**
177
+ * The Origin this request will actually carry.
178
+ *
179
+ * ONE definition, for the two readers that must never disagree about it: the
180
+ * header merge in `dispatch`, and the 403 explanation in `send`. A per-call
181
+ * header beats the connector default, whatever the caller capitalised.
182
+ */
183
+ effectiveOrigin(options) {
184
+ // `||` for the same reason as the constructor: an empty per-call header is
185
+ // a missing value, not a decision to send no Origin.
186
+ return headerValue(options?.headers, 'origin') || this.origin;
61
187
  }
62
188
  async send(command, options) {
63
- const token = await this.resolveToken();
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
- }
189
+ const source = this.source();
71
190
  const cmd = command;
72
- const endpoint = (0, endpoints_js_1.resolveEndpoint)(cmd);
73
- // The token is already resolved above, so the precision this application
74
- // is entitled to is available before the request is shaped (api#65).
75
- // Absent claim -> the 3 dp floor, which is what every application gets
76
- // until one is configured otherwise.
191
+ const url = `${source.apiUrl()}${(0, endpoints_js_1.resolveEndpoint)(cmd)}`;
192
+ try {
193
+ return await this.dispatchWithRetry(source, url, cmd, options);
194
+ }
195
+ catch (err) {
196
+ throw explainMissingOrigin(err, this.effectiveOrigin(options));
197
+ }
198
+ }
199
+ async dispatchWithRetry(source, url, cmd, options) {
200
+ const token = await source.get();
201
+ if (!token)
202
+ throw (0, errors_js_1.noTokenAvailable)(NO_TOKEN_ADVICE);
203
+ try {
204
+ return await this.dispatch(url, token, cmd, options);
205
+ }
206
+ catch (err) {
207
+ if (!(0, errors_js_1.isTokenRejected)(err))
208
+ throw err;
209
+ // One retry, and only when the replacement is genuinely a different
210
+ // token. That single comparison covers every source: a fixed `token`
211
+ // string, a caller `getToken` that ignores `forceRefresh`, and a cached
212
+ // token the API has revoked before its `exp` all hand back what we
213
+ // already sent — and re-sending it would be a second doomed request for
214
+ // the same answer.
215
+ const fresh = await source.get(true);
216
+ if (!fresh || fresh === token)
217
+ throw err;
218
+ log('401 on a token the API no longer accepts — retrying once, refreshed');
219
+ return await this.dispatch(url, fresh, cmd, options);
220
+ }
221
+ }
222
+ dispatch(url, token, cmd, options) {
223
+ // The token is resolved before the request is shaped, so the precision this
224
+ // application is entitled to is available (api#65). Absent claim -> the
225
+ // 3 dp floor, which is what every application gets until one is configured
226
+ // otherwise. Recomputed per attempt because a refreshed token may carry
227
+ // different claims.
77
228
  const { biasDecimals } = (0, tokenClaims_js_1.readAppConfigClaims)(token);
78
229
  const input = (0, roundPosition_js_1.roundPositionFields)(cmd.input, biasDecimals);
79
- // Caller headers first so the system ones below cannot be overridden, but an
80
- // explicit per-call Origin still beats the connector-wide default.
230
+ // Every system header is set exactly ONCE, and the caller's own spelling of
231
+ // each is dropped first.
232
+ //
233
+ // Spreading them over the caller's record is not enough, because fetch's
234
+ // Headers fill APPENDS rather than replaces: a caller's lowercase key
235
+ // survives beside the canonical one and both go on the wire. For Origin
236
+ // that produced `Origin: default, per-call`, which the API's exact-match
237
+ // domain check rejects (observed: 403 OriginNotAllowedException) — and two
238
+ // keys with the SAME value fared no better, `Origin: x, x`. For
239
+ // Authorization it is worse than a failed override: `{ authorization:
240
+ // 'Bearer not-yours' }` went out as `Bearer not-yours, Bearer <real>`,
241
+ // corrupting the token rather than being ignored by it.
242
+ //
243
+ // Origin's value comes from `effectiveOrigin`, so a per-call header still
244
+ // beats the connector default — it is the DUPLICATE that is removed, not
245
+ // the caller's intent.
246
+ const origin = this.effectiveOrigin(options);
81
247
  const headers = {
82
- ...(this.origin ? { Origin: this.origin } : {}),
83
- ...(options?.headers ?? {}),
248
+ ...withoutHeaders(options?.headers, SYSTEM_HEADERS),
249
+ ...(origin ? { Origin: origin } : {}),
84
250
  'Content-Type': 'application/json',
85
251
  Authorization: `Bearer ${token}`,
86
252
  };
87
- log('Sending %s request to %s', cmd.constructor?.name, endpoint);
88
- return (0, http_js_1.requestJson)(`${(await this.configPromise).apiUrl}${endpoint}`, { method: 'POST', headers, body: JSON.stringify(input) }, options);
253
+ log('Sending %s request to %s', cmd.constructor?.name, url);
254
+ return (0, http_js_1.requestJson)(url, { method: 'POST', headers, body: JSON.stringify(input) }, options);
89
255
  }
90
256
  }
91
257
  exports.LocationServiceConnector = LocationServiceConnector;
258
+ function requireApiUrl(explicit) {
259
+ const apiUrl = (0, getClientConfig_js_1.resolveApiUrl)(explicit);
260
+ if (!apiUrl) {
261
+ throw new LocationServiceException_js_1.LocationServiceException({
262
+ code: 'ValidationException',
263
+ message: 'No API URL. Pass `apiUrl` to the LocationServiceConnector constructor ' +
264
+ 'or set LOCATION_API_URL.',
265
+ details: { source: 'client' },
266
+ });
267
+ }
268
+ return apiUrl;
269
+ }
@@ -1,12 +1,71 @@
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
  }
17
+ /**
18
+ * How many applications one process keeps token providers for.
19
+ *
20
+ * A provider holds a URL, a client id, a secret and one cached JWT, so the
21
+ * ceiling is about memory containment rather than a tuned working set — 64 is
22
+ * far above what a single-tenant service needs and far below anything worth
23
+ * worrying about. An agency past it pays a re-mint for the least recently used
24
+ * application, which is exactly the behaviour this replaced, but only for the
25
+ * coldest one instead of for every alternation.
26
+ */
27
+ export declare const MAX_CACHED_PROVIDERS = 64;
7
28
  export interface ServerClientConfig extends ClientConfig {
29
+ /**
30
+ * Narrowed back to required. `ClientConfig.token` is optional because a
31
+ * client can be driven by `getToken`/`refreshToken` alone, but this type is
32
+ * what `getClientConfig` RESOLVES — it either has a token or it threw — and
33
+ * widening it would push a needless `string | undefined` onto every consumer
34
+ * that reads `config.token`.
35
+ */
36
+ token: string;
8
37
  expiresAt?: number;
9
38
  }
39
+ /**
40
+ * Where the API lives, from the argument or the environment.
41
+ *
42
+ * Separate from the credentials because the two are independently overridable:
43
+ * a caller can hand `LocationServiceConnector` its own `token` and still expect
44
+ * `LOCATION_API_URL` to say where to send it.
45
+ */
46
+ export declare function resolveApiUrl(explicit?: string): string | undefined;
47
+ /**
48
+ * A LIVE token source: resolved credentials plus a `getToken` that re-mints
49
+ * when the cached token is spent.
50
+ *
51
+ * This is the seam `getClientConfig` and `LocationServiceConnector` share, and
52
+ * it exists because the two need different SHAPES of the same thing.
53
+ * `getClientConfig` has to return plain data — see the warning on its return
54
+ * value — so it can only ever hand back a snapshot. The connector is long-lived
55
+ * and needs the source itself, or it dies at the first `exp` (#36).
56
+ *
57
+ * Deliberately not exported from `./server`: it hands out a callable bound to
58
+ * the process-wide provider, and the public surface stays the two functions
59
+ * that were already there.
60
+ */
61
+ export interface ServerTokenSource {
62
+ apiUrl: string;
63
+ /** Resolves with a token or rejects; it never resolves tokenless. */
64
+ getToken(forceRefresh?: boolean): Promise<TokenResponse & {
65
+ token: string;
66
+ }>;
67
+ }
68
+ export declare function serverTokenSource(config?: ServerAuthConfig): ServerTokenSource;
10
69
  /**
11
70
  * Get client configuration with OAuth2 authentication.
12
71
  *
@@ -26,6 +85,21 @@ export interface ServerClientConfig extends ClientConfig {
26
85
  * NEVER call from browser/client code as it exposes credentials.
27
86
  * For SPA projects, create your own backend endpoint that calls this.
28
87
  *
88
+ * ## The return value is PLAIN DATA, and has to stay that way
89
+ *
90
+ * `{ apiUrl, token, expiresAt }` — no methods, no closures. The reason is not
91
+ * style: the shape every sample uses is a Next.js Server Action that returns
92
+ * this straight to a Client Component (every `src/lib/actions/location.ts`
93
+ * under `location-service-samples/web`), and the RSC boundary
94
+ * serialises it. A function on this object is not serialisable and throws at
95
+ * the boundary, so "make getClientConfig return getToken" — which #36 proposed
96
+ * and this JSDoc used to promise two lines below — would break every Next.js
97
+ * consumer of the library.
98
+ *
99
+ * A caller that needs a token which REFRESHES wants one of:
100
+ * - `LocationServiceConnector`, which holds a live source internally (#36), or
101
+ * - `TokenProvider` directly, if it is managing its own lifecycle.
102
+ *
29
103
  * @example
30
104
  * // Auto-detect from environment
31
105
  * const config = await getClientConfig()
@@ -33,8 +107,7 @@ export interface ServerClientConfig extends ClientConfig {
33
107
  * // Or override specific values
34
108
  * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
35
109
  *
36
- * // Use getToken() for automatic caching and refresh
37
- * const { token } = await config.getToken()
38
- * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
110
+ * // The API rejected the token before its exp — revoked, or secret rotated
111
+ * const fresh = await getClientConfig({ forceRefresh: true })
39
112
  */
40
113
  export declare function getClientConfig(config?: ServerAuthConfig): Promise<ServerClientConfig>;
@@ -3,15 +3,41 @@ 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.MAX_CACHED_PROVIDERS = void 0;
7
+ exports.resolveApiUrl = resolveApiUrl;
8
+ exports.serverTokenSource = serverTokenSource;
6
9
  exports.getClientConfig = getClientConfig;
7
10
  const debug_1 = __importDefault(require("debug"));
8
11
  const node_crypto_1 = require("node:crypto");
9
12
  const TokenProvider_js_1 = require("../auth/TokenProvider.js");
10
13
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
11
14
  const log = (0, debug_1.default)('location-client:clientConfig');
12
- // Singleton instance to prevent race conditions
13
- let tokenProviderInstance = null;
14
- let currentConfig = null;
15
+ /**
16
+ * How many applications one process keeps token providers for.
17
+ *
18
+ * A provider holds a URL, a client id, a secret and one cached JWT, so the
19
+ * ceiling is about memory containment rather than a tuned working set — 64 is
20
+ * far above what a single-tenant service needs and far below anything worth
21
+ * worrying about. An agency past it pays a re-mint for the least recently used
22
+ * application, which is exactly the behaviour this replaced, but only for the
23
+ * coldest one instead of for every alternation.
24
+ */
25
+ exports.MAX_CACHED_PROVIDERS = 64;
26
+ /**
27
+ * One provider per configuration, most-recently-used last.
28
+ *
29
+ * This used to be TWO module-level variables holding a single provider, and a
30
+ * process serving more than one application therefore evicted the cache on
31
+ * every alternation: A→B→A→B took a full `/auth/token` round trip per call,
32
+ * each writing a jti row, all against the one shared token-endpoint throttle.
33
+ * The hit rate under alternating load was 0% (#39). Nothing leaked between
34
+ * tenants — each caller closes over the provider it asked for — so what this
35
+ * fixes is availability and cost, not confidentiality.
36
+ *
37
+ * A `Map` iterates in insertion order, so re-inserting on a hit makes the first
38
+ * key the least recently used, and an LRU needs no other bookkeeping.
39
+ */
40
+ const tokenProviders = new Map();
15
41
  function getTokenProvider(apiUrl, clientId, clientSecret) {
16
42
  // The SECRET is part of the key. Without it, rotating a client secret while
17
43
  // keeping the same clientId left this process reusing a provider built on the
@@ -19,16 +45,112 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
19
45
  // rather than concatenated so the key is never a secret in its own right, and
20
46
  // never ends up in a log line (#5).
21
47
  const configKey = `${apiUrl}:${clientId}:${(0, node_crypto_1.createHash)('sha256').update(clientSecret).digest('hex').slice(0, 16)}`;
22
- // Reuse existing instance if config matches
23
- if (tokenProviderInstance && currentConfig === configKey) {
48
+ const cached = tokenProviders.get(configKey);
49
+ if (cached) {
24
50
  log('[getTokenProvider] Reusing existing TokenProvider instance');
25
- return tokenProviderInstance;
51
+ // Re-insert to mark it most recently used.
52
+ tokenProviders.delete(configKey);
53
+ tokenProviders.set(configKey, cached);
54
+ return cached;
26
55
  }
27
- // Create new instance if config changed
28
56
  log('[getTokenProvider] Creating new TokenProvider instance');
29
- tokenProviderInstance = new TokenProvider_js_1.TokenProvider({ apiUrl, clientId, clientSecret });
30
- currentConfig = configKey;
31
- return tokenProviderInstance;
57
+ const provider = new TokenProvider_js_1.TokenProvider({ apiUrl, clientId, clientSecret });
58
+ tokenProviders.set(configKey, provider);
59
+ if (tokenProviders.size > exports.MAX_CACHED_PROVIDERS) {
60
+ const leastRecentlyUsed = tokenProviders.keys().next().value;
61
+ /* c8 ignore next — size > 0 here, so the iterator always yields */
62
+ if (leastRecentlyUsed !== undefined) {
63
+ log('[getTokenProvider] Evicting the least recently used TokenProvider');
64
+ tokenProviders.delete(leastRecentlyUsed);
65
+ }
66
+ }
67
+ return provider;
68
+ }
69
+ /**
70
+ * Where the API lives, from the argument or the environment.
71
+ *
72
+ * Separate from the credentials because the two are independently overridable:
73
+ * a caller can hand `LocationServiceConnector` its own `token` and still expect
74
+ * `LOCATION_API_URL` to say where to send it.
75
+ */
76
+ function resolveApiUrl(explicit) {
77
+ return (explicit ||
78
+ process.env.LOCATION_API_URL ||
79
+ process.env.LOCATION_SERVICE_API_URL);
80
+ }
81
+ function serverTokenSource(config = {}) {
82
+ log('[serverTokenSource] Starting with config:', {
83
+ hasApiUrl: !!config.apiUrl,
84
+ hasClientId: !!config.clientId,
85
+ });
86
+ // Auto-detect from environment with fallbacks
87
+ const apiUrl = resolveApiUrl(config.apiUrl);
88
+ const clientId = config.clientId ||
89
+ process.env.LOCATION_CLIENT_ID ||
90
+ process.env.LOCATION_SERVICE_CLIENT_ID;
91
+ const clientSecret = config.clientSecret ||
92
+ process.env.LOCATION_CLIENT_SECRET ||
93
+ process.env.LOCATION_SERVICE_CLIENT_SECRET;
94
+ log('[serverTokenSource] Resolved config:', {
95
+ apiUrl,
96
+ clientId: clientId?.substring(0, 10) + '...',
97
+ });
98
+ // Validate required values. A LocationServiceException like everything else
99
+ // this package throws — it used to be a bare `Error`, which was survivable
100
+ // while only `getClientConfig` could raise it and is not now that the
101
+ // connector reaches this path too.
102
+ if (!apiUrl || !clientId || !clientSecret) {
103
+ console.error('[location-client] Missing required configuration');
104
+ throw new LocationServiceException_js_1.LocationServiceException({
105
+ code: 'ValidationException',
106
+ message: 'Missing required configuration. Set environment variables: ' +
107
+ 'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET',
108
+ details: { source: 'client' },
109
+ });
110
+ }
111
+ log('[serverTokenSource] Getting TokenProvider instance');
112
+ const provider = getTokenProvider(apiUrl, clientId, clientSecret);
113
+ return {
114
+ apiUrl,
115
+ async getToken(forceRefresh = false) {
116
+ log('[serverTokenSource] Fetching token (forceRefresh=%s)', forceRefresh);
117
+ let result;
118
+ try {
119
+ result = await provider.getToken(forceRefresh);
120
+ }
121
+ catch (error) {
122
+ // The provider now rejects rather than resolving with success:false, and
123
+ // the rejection is typed — so a store outage (503) can be reported as a
124
+ // store outage instead of as bad credentials.
125
+ if (error instanceof LocationServiceException_js_1.LocationServiceException) {
126
+ if (error.isAuth) {
127
+ throw new LocationServiceException_js_1.LocationServiceException({
128
+ code: 'InvalidCredentialsException',
129
+ message: `Authentication failed for client ID "${clientId}". ` +
130
+ `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
131
+ statusCode: error.statusCode,
132
+ requestId: error.requestId,
133
+ cause: error,
134
+ });
135
+ }
136
+ throw error;
137
+ }
138
+ throw error;
139
+ }
140
+ // TokenProvider rejects rather than returning a tokenless success, so this
141
+ // is unreachable in practice — it is here to keep the contract explicit at
142
+ // the type level rather than asserting non-null.
143
+ const token = result.token;
144
+ if (!token) {
145
+ throw new LocationServiceException_js_1.LocationServiceException({
146
+ code: 'InvalidCredentialsException',
147
+ message: 'Token provider returned no token',
148
+ details: { source: 'client' },
149
+ });
150
+ }
151
+ return { ...result, token };
152
+ },
153
+ };
32
154
  }
33
155
  /**
34
156
  * Get client configuration with OAuth2 authentication.
@@ -49,6 +171,21 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
49
171
  * NEVER call from browser/client code as it exposes credentials.
50
172
  * For SPA projects, create your own backend endpoint that calls this.
51
173
  *
174
+ * ## The return value is PLAIN DATA, and has to stay that way
175
+ *
176
+ * `{ apiUrl, token, expiresAt }` — no methods, no closures. The reason is not
177
+ * style: the shape every sample uses is a Next.js Server Action that returns
178
+ * this straight to a Client Component (every `src/lib/actions/location.ts`
179
+ * under `location-service-samples/web`), and the RSC boundary
180
+ * serialises it. A function on this object is not serialisable and throws at
181
+ * the boundary, so "make getClientConfig return getToken" — which #36 proposed
182
+ * and this JSDoc used to promise two lines below — would break every Next.js
183
+ * consumer of the library.
184
+ *
185
+ * A caller that needs a token which REFRESHES wants one of:
186
+ * - `LocationServiceConnector`, which holds a live source internally (#36), or
187
+ * - `TokenProvider` directly, if it is managing its own lifecycle.
188
+ *
52
189
  * @example
53
190
  * // Auto-detect from environment
54
191
  * const config = await getClientConfig()
@@ -56,74 +193,15 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
56
193
  * // Or override specific values
57
194
  * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
58
195
  *
59
- * // Use getToken() for automatic caching and refresh
60
- * const { token } = await config.getToken()
61
- * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
196
+ * // The API rejected the token before its exp — revoked, or secret rotated
197
+ * const fresh = await getClientConfig({ forceRefresh: true })
62
198
  */
63
199
  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
- }
200
+ const source = serverTokenSource(config);
201
+ const result = await source.getToken(config.forceRefresh);
124
202
  log('[getClientConfig] Token fetched successfully, length:', result.token.length);
125
203
  return {
126
- apiUrl,
204
+ apiUrl: source.apiUrl,
127
205
  token: result.token,
128
206
  expiresAt: result.expiresAt,
129
207
  };