screeps-connectivity 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1539f52: Add NotifyPrefs type and notifyPrefs field on UserInfo for email notification preference support.
8
+ - de39cf0: Expose public-profile data through the API: `power` on the `user.find` response, an optional `id` argument on `user.stats`, and a typed `leaderboard.find` response.
9
+ - 4b8f9d9: Add `setFetch()` to `screeps-connectivity` so consumers can supply a custom fetch implementation (e.g. Tauri's HTTP plugin) without patching `window.fetch`. `screeps-client` uses this to enable CORS-free HTTP in the standalone Tauri desktop app without affecting the browser runtime.
10
+
11
+ ### Patch Changes
12
+
13
+ - 882bea5: Fix `TokenAuth` always using its static token — server-issued `X-Token` headers, WebSocket token rotation, and the idle keep-alive timer are now skipped when the auth strategy sets `supportsTokenRefresh: false`.
14
+
3
15
  ## 0.9.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screeps-connectivity",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "repository": {
@@ -18,7 +18,7 @@ import type { ApiRoomDecorationsResponse } from './types/api.js'
18
18
  type WsConstructor = typeof globalThis.WebSocket
19
19
 
20
20
  export interface TokenRefreshOptions {
21
- /** Milliseconds of idleness before issuing a keep-alive request. Default 30_000. */
21
+ /** Interval in milliseconds between world-status polls. Default 30_000. */
22
22
  intervalMs?: number
23
23
  }
24
24
 
@@ -35,9 +35,8 @@ export interface ScreepsClientOptions {
35
35
  maxCacheEntries?: number
36
36
  }
37
37
  /**
38
- * Idle keep-alive that refreshes the auth token via a lightweight `auth/me` call when no
39
- * authenticated traffic has happened for `intervalMs`. Default `{ intervalMs: 30_000 }`.
40
- * Pass `false` to disable.
38
+ * Polls `/api/user/world-status` on a fixed interval to keep world status current.
39
+ * Default `{ intervalMs: 30_000 }`. Pass `false` to disable.
41
40
  */
42
41
  tokenRefresh?: TokenRefreshOptions | false
43
42
  /** Override the /api/game/room-decorations response with static data (useful for dev/testing when the server doesn't support the endpoint). */
@@ -60,7 +59,6 @@ export class ScreepsClient {
60
59
  private readonly tokenRefreshIntervalMs: number | null
61
60
  private readonly tokenSyncSubs: Subscription[] = []
62
61
  private tokenRefreshTimer: ReturnType<typeof setInterval> | null = null
63
- private lastHttpActivity = 0
64
62
  private refreshInFlight = false
65
63
 
66
64
  constructor(opts: ScreepsClientOptions) {
@@ -93,23 +91,21 @@ export class ScreepsClient {
93
91
  ? null
94
92
  : (opts.tokenRefresh?.intervalMs ?? 30_000)
95
93
 
96
- this.wireTokenSync()
94
+ this.wireTokenSync(opts.auth.supportsTokenRefresh ?? true)
97
95
  }
98
96
 
99
- private wireTokenSync(): void {
100
- // HTTP rotates token via X-Token → propagate to WS so a later reconnect uses the fresh one.
101
- this.tokenSyncSubs.push(this.http.on('http:tokenRefresh', ({ token }) => {
102
- this.socket.setToken(token)
103
- }))
104
- // WS issues a new token on auth → keep HTTP side in sync.
105
- this.tokenSyncSubs.push(this.socket.on('socket:tokenRefresh', (data) => {
106
- const detail = data as { token: string }
107
- this.http.setToken(detail.token)
108
- }))
109
- // Any successful HTTP response counts as activity — defers the next idle refresh.
110
- this.tokenSyncSubs.push(this.http.on('http:success', () => {
111
- this.lastHttpActivity = Date.now()
112
- }))
97
+ private wireTokenSync(supportsRefresh: boolean): void {
98
+ if (supportsRefresh) {
99
+ // HTTP rotates token via X-Token → propagate to WS so a later reconnect uses the fresh one.
100
+ this.tokenSyncSubs.push(this.http.on('http:tokenRefresh', ({ token }) => {
101
+ this.socket.setToken(token)
102
+ }))
103
+ // WS issues a new token on auth keep HTTP side in sync.
104
+ this.tokenSyncSubs.push(this.socket.on('socket:tokenRefresh', (data) => {
105
+ const detail = data as { token: string }
106
+ this.http.setToken(detail.token)
107
+ }))
108
+ }
113
109
  }
114
110
 
115
111
  get isConnected(): boolean {
@@ -137,15 +133,9 @@ export class ScreepsClient {
137
133
  private startTokenRefresh(): void {
138
134
  if (this.tokenRefreshIntervalMs === null) return
139
135
  if (this.tokenRefreshTimer !== null) return
140
- const intervalMs = this.tokenRefreshIntervalMs
141
- this.lastHttpActivity = Date.now()
142
- // Tick at half the interval so we react within intervalMs of idleness.
143
- const tickMs = Math.max(1_000, Math.floor(intervalMs / 2))
144
136
  this.tokenRefreshTimer = setInterval(() => {
145
- const idleFor = Date.now() - this.lastHttpActivity
146
- if (idleFor < intervalMs) return
147
137
  void this.refreshTokenNow()
148
- }, tickMs)
138
+ }, this.tokenRefreshIntervalMs)
149
139
  }
150
140
 
151
141
  private stopTokenRefresh(): void {
@@ -159,10 +149,10 @@ export class ScreepsClient {
159
149
  if (this.refreshInFlight) return
160
150
  this.refreshInFlight = true
161
151
  try {
162
- this.logger.log('[screeps:client] token refresh (idle)')
152
+ this.logger.log('[screeps:client] world status refresh')
163
153
  await this.stores.user.refreshWorldStatus()
164
154
  } catch (err) {
165
- this.logger.log('[screeps:client] token refresh failed', err)
155
+ this.logger.log('[screeps:client] world status refresh failed', err)
166
156
  } finally {
167
157
  this.refreshInFlight = false
168
158
  }
@@ -117,7 +117,7 @@ export class HttpClient extends EventTarget {
117
117
  const res = await getFetch()(url.toString(), init)
118
118
 
119
119
  const newToken = res.headers.get('x-token')
120
- if (newToken) {
120
+ if (newToken && (this.authStrategy.supportsTokenRefresh ?? true)) {
121
121
  this.token = newToken
122
122
  this.emit('http:tokenRefresh', { token: newToken })
123
123
  }
@@ -2,4 +2,6 @@ import type { HttpClient } from '../HttpClient.js'
2
2
 
3
3
  export interface AuthStrategy {
4
4
  authenticate(http: HttpClient): Promise<string>
5
+ /** False means the token is static and must never be replaced by server-issued tokens. Default: true. */
6
+ readonly supportsTokenRefresh?: boolean
5
7
  }
@@ -2,6 +2,7 @@ import type { AuthStrategy } from './AuthStrategy.js'
2
2
  import type { HttpClient } from '../HttpClient.js'
3
3
 
4
4
  export class TokenAuth implements AuthStrategy {
5
+ readonly supportsTokenRefresh = false
5
6
  private readonly token: string
6
7
 
7
8
  constructor(opts: { token: string }) {
@@ -1,16 +1,18 @@
1
1
  import type { HttpClient } from '../HttpClient.js'
2
- import type { ApiLeaderboardListResponse, ApiLeaderboardSeasonsResponse } from '../../types/api.js'
2
+ import type { ApiLeaderboardFindResponse, ApiLeaderboardListResponse, ApiLeaderboardSeasonsResponse } from '../../types/api.js'
3
3
 
4
4
  export interface LeaderboardEndpoints {
5
5
  list(limit?: number, mode?: 'world' | 'power', offset?: number, season?: string): Promise<ApiLeaderboardListResponse>
6
- find(username: string, mode?: string, season?: string): Promise<unknown>
6
+ find(username: string, mode?: string, season?: string): Promise<ApiLeaderboardFindResponse>
7
7
  seasons(): Promise<ApiLeaderboardSeasonsResponse>
8
8
  }
9
9
 
10
10
  export function createLeaderboardEndpoints(http: HttpClient): LeaderboardEndpoints {
11
11
  return {
12
12
  list: (limit = 10, mode = 'world', offset = 0, season) => http.request('GET', '/api/leaderboard/list', { limit, mode, offset, season }),
13
- find: (username, mode = 'world', season = '') => http.request('GET', '/api/leaderboard/find', { username, mode, season }),
13
+ // Best-effort: ranks feed the public profile and not every server implements
14
+ // them, so a miss shouldn't raise a user-facing toast.
15
+ find: (username, mode = 'world', season = '') => http.request('GET', '/api/leaderboard/find', { username, mode, season }, { silent: true }),
14
16
  seasons: () => http.request('GET', '/api/leaderboard/seasons'),
15
17
  }
16
18
  }
@@ -5,17 +5,11 @@ import type {
5
5
  ApiUserMoneyHistoryResponse,
6
6
  ApiUserOverviewResponse,
7
7
  ApiUserRoomsResponse,
8
+ ApiUserStatsResponse,
8
9
  } from '../../types/api.js'
10
+ import type { NotifyPrefs } from '../../types/game.js'
9
11
  import { createUserMessagesEndpoints, type UserMessagesEndpoints } from './user-messages.js'
10
12
 
11
- export interface NotifyPrefs {
12
- disabled: boolean
13
- disabledOnMessages: boolean
14
- sendOnline: boolean
15
- interval: number
16
- errorsInterval: number
17
- }
18
-
19
13
  export interface UserEndpoints {
20
14
  branches(): Promise<ApiUserBranchesResponse>
21
15
  code: {
@@ -31,7 +25,7 @@ export interface UserEndpoints {
31
25
  }
32
26
  }
33
27
  console(expression: string, shard?: string | null): Promise<unknown>
34
- stats(interval: number): Promise<unknown>
28
+ stats(interval: number, id?: string): Promise<ApiUserStatsResponse>
35
29
  rooms(id: string): Promise<ApiUserRoomsResponse>
36
30
  overview(interval: number, statName: string): Promise<ApiUserOverviewResponse>
37
31
  worldStatus(): Promise<{ ok: number; status: 'normal' | 'lost' | 'empty' }>
@@ -74,7 +68,7 @@ export function createUserEndpoints(http: HttpClient): UserEndpoints {
74
68
  },
75
69
  },
76
70
  console: (expression, shard) => http.request('POST', '/api/user/console', withShard({ expression }, shard)),
77
- stats: (interval) => http.request('GET', '/api/user/stats', { interval }),
71
+ stats: (interval, id) => http.request('GET', '/api/user/stats', id != null ? { interval, id } : { interval }, { silent: true }),
78
72
  // Best-effort dashboard data: not every server implements these, and the
79
73
  // Overview page degrades gracefully (zeros / no tiles), so a failure here
80
74
  // shouldn't raise a user-facing error toast — mark them silent.
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export type {
25
25
  RoomObjectDiff,
26
26
  RoomMap2Data,
27
27
  UserInfo,
28
+ NotifyPrefs,
28
29
  CpuStats,
29
30
  WorldStatus,
30
31
  ConsoleMessage,
@@ -41,7 +42,7 @@ export type {
41
42
 
42
43
  export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
43
44
  export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
44
- export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive, ApiUserOverviewResponse, ApiUserOverviewTotals, ApiUserRoomsResponse, ApiUserMoneyHistoryResponse, ApiMarketOrder, ApiMarketOrdersIndexResponse, ApiMarketOrdersResponse, ApiMarketMyOrdersResponse, ApiMarketStat, ApiMarketStatsResponse, ApiPowerCreep, ApiPowerCreepsListResponse } from './types/api.js'
45
+ export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive, ApiUserOverviewResponse, ApiUserOverviewTotals, ApiUserRoomsResponse, ApiUserStatsResponse, ApiLeaderboardFindResponse, ApiUserMoneyHistoryResponse, ApiMarketOrder, ApiMarketOrdersIndexResponse, ApiMarketOrdersResponse, ApiMarketMyOrdersResponse, ApiMarketStat, ApiMarketStatsResponse, ApiPowerCreep, ApiPowerCreepsListResponse, ApiUserMessage, ApiUserMessagesListResponse, ApiUserMessagesIndexEntry, ApiUserMessagesIndexResponse, ApiUserMessagesUnreadCountResponse, ApiUserFindResponse } from './types/api.js'
45
46
  export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
46
47
 
47
48
  export { badgeToSvg } from './badge/index.js'
package/src/types/api.ts CHANGED
@@ -239,9 +239,28 @@ export interface ApiUserFindResponse {
239
239
  username: string
240
240
  badge: import('./game.js').Badge
241
241
  gcl: number
242
+ // Lifetime power (GPL) — present in the public lookup alongside gcl.
243
+ power?: number
242
244
  }
243
245
  }
244
246
 
247
+ /** Response of GET /api/user/stats?id=&interval=. `stats` maps a metric to per-tick buckets; consumers sum the bucket values over the interval. */
248
+ export interface ApiUserStatsResponse {
249
+ ok: number
250
+ stats?: Record<string, Array<{ value: number; endTime?: number }>>
251
+ statsMax?: Record<string, number>
252
+ }
253
+
254
+ /** Response of GET /api/leaderboard/find?username=&mode=&season=. Servers return either a flat list of per-season records or a single season-scoped record at the top level. */
255
+ export interface ApiLeaderboardFindResponse {
256
+ ok: number
257
+ list?: Array<{ season: string; rank: number; score: number; user: string }>
258
+ rank?: number
259
+ score?: number
260
+ season?: string
261
+ user?: string
262
+ }
263
+
245
264
  export interface ApiUserMoneyHistoryResponse {
246
265
  ok: number
247
266
  page: number
@@ -318,6 +337,7 @@ export interface ApiUserMessage {
318
337
  respondent: string
319
338
  user: string
320
339
  text: string
340
+ type?: 'in' | 'out'
321
341
  unread: boolean
322
342
  }
323
343
 
@@ -329,12 +349,12 @@ export interface ApiUserMessagesListResponse {
329
349
  export interface ApiUserMessagesIndexEntry {
330
350
  _id: string
331
351
  message: ApiUserMessage
332
- user: { _id: string; username: string; badge: import('./game.js').Badge }
333
352
  }
334
353
 
335
354
  export interface ApiUserMessagesIndexResponse {
336
355
  ok: number
337
- list: ApiUserMessagesIndexEntry[]
356
+ messages: ApiUserMessagesIndexEntry[]
357
+ users: Record<string, { _id: string; username: string; badge: import('./game.js').Badge }>
338
358
  }
339
359
 
340
360
  export interface ApiUserMessagesUnreadCountResponse {
package/src/types/game.ts CHANGED
@@ -60,6 +60,14 @@ export interface RoomObject {
60
60
  export type RoomObjectMap = Record<string, RoomObject>
61
61
  export type RoomObjectDiff = Record<string, Partial<RoomObject> | null>
62
62
 
63
+ export interface NotifyPrefs {
64
+ disabled: boolean
65
+ disabledOnMessages: boolean
66
+ sendOnline: boolean
67
+ interval: number
68
+ errorsInterval: number
69
+ }
70
+
63
71
  export interface UserInfo {
64
72
  _id: string
65
73
  username: string
@@ -72,6 +80,7 @@ export interface UserInfo {
72
80
  badge: Badge
73
81
  /** True when the account has a password set. Absent for password-less accounts (e.g. Steam-only logins). Only present for the authenticated user's own info. */
74
82
  password?: boolean
83
+ notifyPrefs?: Partial<NotifyPrefs>
75
84
  }
76
85
 
77
86
  export interface CpuStats {
@@ -104,6 +104,9 @@ describe('ScreepsClient', () => {
104
104
  })
105
105
  })
106
106
 
107
+ // Dynamic-token auth strategy (supportsTokenRefresh defaults to true) used for token-rotation tests.
108
+ const dynamicAuth = (initial = 'initial') => ({ authenticate: async () => initial })
109
+
107
110
  describe('ScreepsClient — token sync', () => {
108
111
  it('rotates SocketClient token when an HTTP response carries x-token', async () => {
109
112
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
@@ -114,7 +117,7 @@ describe('ScreepsClient — token sync', () => {
114
117
 
115
118
  const client = new ScreepsClient({
116
119
  url: 'http://test.local',
117
- auth: new TokenAuth({ token: 'initial' }),
120
+ auth: dynamicAuth(),
118
121
  storage: null,
119
122
  WebSocket: MockWS as unknown as typeof WebSocket,
120
123
  tokenRefresh: false,
@@ -129,7 +132,7 @@ describe('ScreepsClient — token sync', () => {
129
132
  it('rotates HttpClient token when WS auth response carries a new token', async () => {
130
133
  const client = new ScreepsClient({
131
134
  url: 'http://test.local',
132
- auth: new TokenAuth({ token: 'initial' }),
135
+ auth: dynamicAuth(),
133
136
  storage: null,
134
137
  WebSocket: MockWS as unknown as typeof WebSocket,
135
138
  tokenRefresh: false,
@@ -147,16 +150,36 @@ describe('ScreepsClient — token sync', () => {
147
150
  expect(httpSetToken).toHaveBeenCalledWith('ws-rotated-token')
148
151
  expect(client.http.token).toBe('ws-rotated-token')
149
152
  })
153
+
154
+ it('does NOT sync tokens when using TokenAuth (static token)', async () => {
155
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
156
+ new Response(JSON.stringify({ ok: 1 }), {
157
+ headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
158
+ })
159
+ ))
160
+
161
+ const client = new ScreepsClient({
162
+ url: 'http://test.local',
163
+ auth: new TokenAuth({ token: 'my-static-token' }),
164
+ storage: null,
165
+ WebSocket: MockWS as unknown as typeof WebSocket,
166
+ })
167
+
168
+ const socketSetToken = vi.spyOn(client.socket, 'setToken')
169
+ await client.http.request('GET', '/api/auth/me')
170
+
171
+ expect(socketSetToken).not.toHaveBeenCalled()
172
+ })
150
173
  })
151
174
 
152
- describe('ScreepsClient — idle token refresh', () => {
175
+ describe('ScreepsClient — world status polling', () => {
153
176
  beforeEach(() => { vi.useFakeTimers() })
154
177
  afterEach(() => { vi.useRealTimers() })
155
178
 
156
179
  async function buildConnected(opts: { tokenRefresh?: { intervalMs?: number } | false } = {}) {
157
180
  const client = new ScreepsClient({
158
181
  url: 'http://test.local',
159
- auth: new TokenAuth({ token: 'tok' }),
182
+ auth: dynamicAuth('tok'),
160
183
  storage: null,
161
184
  WebSocket: MockWS as unknown as typeof WebSocket,
162
185
  tokenRefresh: opts.tokenRefresh,
@@ -170,13 +193,12 @@ describe('ScreepsClient — idle token refresh', () => {
170
193
  return client
171
194
  }
172
195
 
173
- it('issues a world-status call after intervalMs of HTTP idleness', async () => {
196
+ it('polls world-status on a fixed interval', async () => {
174
197
  const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
175
198
  const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
176
199
  fetchMock.mockClear()
177
200
 
178
- // Idle for 1.5s — exceeds 1s interval, refresh should fire on next tick (every 500ms).
179
- await vi.advanceTimersByTimeAsync(1_500)
201
+ await vi.advanceTimersByTimeAsync(1_000)
180
202
 
181
203
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
182
204
  expect(paths).toContain('/api/user/world-status')
@@ -184,19 +206,19 @@ describe('ScreepsClient — idle token refresh', () => {
184
206
  client.disconnect()
185
207
  })
186
208
 
187
- it('does NOT issue an auth/me call while HTTP traffic resets the idle clock', async () => {
209
+ it('polls world-status even while other HTTP traffic is ongoing', async () => {
188
210
  const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
189
211
  const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
190
212
  fetchMock.mockClear()
191
213
 
192
- // Make a request every 400ms, well below the 1s threshold.
193
- for (let i = 0; i < 5; i++) {
214
+ // Continuous HTTP traffic every 200ms should not suppress the fixed poll.
215
+ for (let i = 0; i < 6; i++) {
194
216
  await client.http.request('GET', '/api/version')
195
- await vi.advanceTimersByTimeAsync(400)
217
+ await vi.advanceTimersByTimeAsync(200)
196
218
  }
197
219
 
198
220
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
199
- expect(paths).not.toContain('/api/auth/me')
221
+ expect(paths).toContain('/api/user/world-status')
200
222
 
201
223
  client.disconnect()
202
224
  })
@@ -209,11 +231,37 @@ describe('ScreepsClient — idle token refresh', () => {
209
231
  await vi.advanceTimersByTimeAsync(60_000)
210
232
 
211
233
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
212
- expect(paths).not.toContain('/api/auth/me')
234
+ expect(paths).not.toContain('/api/user/world-status')
213
235
 
214
236
  client.disconnect()
215
237
  })
216
238
 
239
+ it('still polls world-status when using TokenAuth (token is never replaced)', async () => {
240
+ const client = new ScreepsClient({
241
+ url: 'http://test.local',
242
+ auth: new TokenAuth({ token: 'tok' }),
243
+ storage: null,
244
+ WebSocket: MockWS as unknown as typeof WebSocket,
245
+ tokenRefresh: { intervalMs: 1_000 },
246
+ })
247
+ const connectPromise = client.connect()
248
+ await vi.advanceTimersByTimeAsync(0)
249
+ const ws = MockWS.instances[MockWS.instances.length - 1]
250
+ ws.simulateOpen()
251
+ ws.simulateMessage('auth ok tok')
252
+ await connectPromise
253
+
254
+ const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
255
+ fetchMock.mockClear()
256
+ await vi.advanceTimersByTimeAsync(1_500)
257
+
258
+ const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
259
+ expect(paths).toContain('/api/user/world-status')
260
+ // Token must not have changed despite the response
261
+ expect(client.http.token).toBe('tok')
262
+ client.disconnect()
263
+ })
264
+
217
265
  it('stops the refresh timer on disconnect()', async () => {
218
266
  const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
219
267
  client.disconnect()
@@ -224,6 +272,6 @@ describe('ScreepsClient — idle token refresh', () => {
224
272
  await vi.advanceTimersByTimeAsync(5_000)
225
273
 
226
274
  const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
227
- expect(paths).not.toContain('/api/auth/me')
275
+ expect(paths).not.toContain('/api/user/world-status')
228
276
  })
229
277
  })
@@ -81,12 +81,23 @@ describe('HttpClient', () => {
81
81
  fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
82
82
  headers: { 'content-type': 'application/json', 'x-token': 'refreshed' },
83
83
  }))
84
- const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'old' }) })
84
+ // Use a plain object (supportsTokenRefresh defaults to true) to verify rotation works for dynamic-token strategies
85
+ const http = new HttpClient({ url: 'http://test.local', auth: { authenticate: async () => 'old' } })
85
86
  http.token = 'old'
86
87
  await http.request('GET', '/api/version')
87
88
  expect(http.token).toBe('refreshed')
88
89
  })
89
90
 
91
+ it('does NOT update token from x-token header when using TokenAuth (static token)', async () => {
92
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
93
+ headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
94
+ }))
95
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'my-static-token' }) })
96
+ http.token = 'my-static-token'
97
+ await http.request('GET', '/api/version')
98
+ expect(http.token).toBe('my-static-token')
99
+ })
100
+
90
101
  it('retries once on 401 after re-authenticating', async () => {
91
102
  let calls = 0
92
103
  fetchMock.mockImplementation(() => {
@@ -136,13 +147,25 @@ describe('HttpClient', () => {
136
147
  fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
137
148
  headers: { 'content-type': 'application/json', 'x-token': 'new-tok' },
138
149
  }))
139
- const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'old' }) })
150
+ // Use a plain object (supportsTokenRefresh defaults to true) to verify the event fires for dynamic-token strategies
151
+ const http = new HttpClient({ url: 'http://test.local', auth: { authenticate: async () => 'old' } })
140
152
  const handler = vi.fn()
141
153
  http.on('http:tokenRefresh', handler)
142
154
  await http.request('GET', '/api/version')
143
155
  expect(handler).toHaveBeenCalledWith({ token: 'new-tok' })
144
156
  })
145
157
 
158
+ it('does NOT emit http:tokenRefresh when using TokenAuth (static token)', async () => {
159
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
160
+ headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
161
+ }))
162
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'my-static-token' }) })
163
+ const handler = vi.fn()
164
+ http.on('http:tokenRefresh', handler)
165
+ await http.request('GET', '/api/version')
166
+ expect(handler).not.toHaveBeenCalled()
167
+ })
168
+
146
169
  it('emits http:success on 200 response', async () => {
147
170
  fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
148
171
  const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })