screeps-connectivity 0.11.0 → 0.13.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 +24 -0
- package/package.json +1 -1
- package/src/ScreepsClient.ts +6 -1
- package/src/http/auth/TokenAuth.ts +10 -2
- package/src/http/fetchServerVersion.ts +66 -10
- package/src/index.ts +4 -2
- package/src/types/game.ts +24 -0
- package/tests/ScreepsClient.test.ts +52 -0
- package/tests/http/HttpClient.test.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 94a6658: Add Discord OAuth login: getDiscordFeature() helper in screeps-connectivity, and a "Login with Discord" button in the web and desktop login forms.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 46b5e2d: Fix Steam/password logins silently expiring after ~5 minutes on screepsmod-auth servers. These logins reconnect via a rotating, TTL-limited session token, but `TokenAuth` was hard-coded to ignore the server-issued `X-Token`, so the client kept replaying the original token until the server expired it — surfacing as a sudden `401` on `/api/user/world-status` (and every other authed request) even while actively using the client.
|
|
12
|
+
|
|
13
|
+
`TokenAuth` now accepts `supportsTokenRefresh` (default `false`, preserving durable personal-API-token behavior). The client enables it for Steam/password-derived session tokens so the rotated `X-Token` is adopted on every response, keeping the session alive.
|
|
14
|
+
|
|
15
|
+
## 0.12.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 8fd1a08: Fix Steam login failing with "auth failed" on brand-new accounts (e.g. xxscreeps). Some servers hand back a provisional token for a first-time OAuth signup that can't authenticate the websocket until a username is chosen; the login flow now detects this via `/api/auth/me` and prompts for a username before connecting. Adds `fetchAuthMeWithToken` and `completeProviderRegistration` to `screeps-connectivity`.
|
|
20
|
+
- b26940a: xxscreeps-mod-client now publishes an `xxscreeps-mod-client` server feature at `/api/version` reflecting `.screepsrc.yaml`'s `backend.allowGuestAccess`, `backend.allowEmailRegistration`, and `backend.steamApiKey`. screeps-client reads this via the new `getXxscreepsModClientFeature` helper (screeps-connectivity) to show or hide the Guest, "Create account", and "Login with Steam" options to match what the server actually allows, instead of guessing.
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- 594073a: Fix cache/storage namespace collision when two distinct game worlds are hosted under the same domain via a path (e.g. Screeps World vs Screeps Season on screeps.com) — terrain and other cached data no longer bleed between them.
|
|
25
|
+
- 15d0c1f: Show a dismissable popup (reload/logout) instead of silently bouncing to the login screen when an already-connected session hits a fatal socket error or disconnect. Add a similar popup for 429 rate-limit responses from official servers, with a button to open the server's "disable rate limiting" link (opens the OS browser in the desktop app). Fix the map view not reloading terrain when switching shards — room names collide across shards, so the renderer's terrain cache was serving stale terrain from the previously viewed shard. Also removes the 5-minute sessionStorage cache on `fetchServerVersion` so the pre-login welcome screen always reflects the server's current `/api/version`.
|
|
26
|
+
|
|
3
27
|
## 0.11.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/package.json
CHANGED
package/src/ScreepsClient.ts
CHANGED
|
@@ -64,7 +64,12 @@ export class ScreepsClient {
|
|
|
64
64
|
constructor(opts: ScreepsClientOptions) {
|
|
65
65
|
let namespace: string
|
|
66
66
|
try {
|
|
67
|
-
|
|
67
|
+
const parsed = new URL(opts.url)
|
|
68
|
+
const path = parsed.pathname.replace(/\/+$/, '')
|
|
69
|
+
// Hostname alone isn't enough: some servers host multiple distinct worlds under one
|
|
70
|
+
// domain via a path (e.g. Screeps World at screeps.com vs Screeps Season at
|
|
71
|
+
// screeps.com/season) — including the path keeps their caches from colliding.
|
|
72
|
+
namespace = path ? `${parsed.hostname}${path}` : parsed.hostname
|
|
68
73
|
} catch {
|
|
69
74
|
throw new TypeError(`ScreepsClient: invalid url "${opts.url}"`)
|
|
70
75
|
}
|
|
@@ -2,11 +2,19 @@ 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
|
|
5
|
+
readonly supportsTokenRefresh: boolean
|
|
6
6
|
private readonly token: string
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* @param opts.supportsTokenRefresh Set true when the token is a rotating,
|
|
10
|
+
* TTL-limited session token (e.g. obtained from a screepsmod-auth steam/password
|
|
11
|
+
* exchange) so the client adopts the server-issued `X-Token` and the session
|
|
12
|
+
* stays alive. Leave false (default) for a durable personal API token, which
|
|
13
|
+
* must never be replaced by a server-issued token.
|
|
14
|
+
*/
|
|
15
|
+
constructor(opts: { token: string; supportsTokenRefresh?: boolean }) {
|
|
9
16
|
this.token = opts.token
|
|
17
|
+
this.supportsTokenRefresh = opts.supportsTokenRefresh ?? false
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
async authenticate(_http: HttpClient): Promise<string> {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServerVersion } from '../types/game.js'
|
|
2
|
-
import type { ScreepsmodAuthFeature, ServerFeature } from '../types/game.js'
|
|
3
|
-
import type { ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
|
|
2
|
+
import type { DiscordFeature, ScreepsmodAuthFeature, ServerFeature, XxscreepsModClientFeature } from '../types/game.js'
|
|
3
|
+
import type { ApiAuthMeResponse, ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
|
|
4
4
|
import { getFetch } from './fetchFn.js'
|
|
5
5
|
|
|
6
6
|
export type { ApiAuthModInfoResponse }
|
|
@@ -49,19 +49,12 @@ function baseUrl(url: string): string {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Fetch `/api/version` from a Screeps server without authentication.
|
|
52
|
-
* The result is cached in `sessionStorage` for 5 minutes (per server hostname).
|
|
53
52
|
* Useful for pre-login UI: showing the welcome text and detecting installed mods.
|
|
54
53
|
*/
|
|
55
54
|
export async function fetchServerVersion(url: string): Promise<ServerVersion> {
|
|
56
|
-
const key = sessionKey('version', url)
|
|
57
|
-
const cached = readFromSession<ServerVersion>(key)
|
|
58
|
-
if (cached) return cached
|
|
59
|
-
|
|
60
55
|
const res = await getFetch()(`${baseUrl(url)}api/version`)
|
|
61
56
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
62
|
-
|
|
63
|
-
writeToSession(key, data)
|
|
64
|
-
return data
|
|
57
|
+
return res.json() as Promise<ServerVersion>
|
|
65
58
|
}
|
|
66
59
|
|
|
67
60
|
/**
|
|
@@ -123,6 +116,42 @@ export async function registerUser(
|
|
|
123
116
|
return res.json() as Promise<{ ok: number; error?: string }>
|
|
124
117
|
}
|
|
125
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Fetch `/api/auth/me` using a bare token, without going through a full `ScreepsClient`.
|
|
121
|
+
* Used right after an OAuth popup (Steam, GitHub, ...) hands back a token, to check whether
|
|
122
|
+
* the account still needs to finish registration — servers like xxscreeps issue a provisional
|
|
123
|
+
* token for brand-new provider signups that has no `username` yet until one is chosen.
|
|
124
|
+
*/
|
|
125
|
+
export async function fetchAuthMeWithToken(url: string, token: string): Promise<ApiAuthMeResponse | null> {
|
|
126
|
+
const res = await getFetch()(`${baseUrl(url)}api/auth/me`, {
|
|
127
|
+
headers: { 'X-Token': token, 'X-Username': token },
|
|
128
|
+
})
|
|
129
|
+
if (!res.ok) return null
|
|
130
|
+
return res.json() as Promise<ApiAuthMeResponse>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Finish an OAuth provider signup (Steam, GitHub, ...) by choosing a username for the
|
|
135
|
+
* provisional account. Requires the provisional token handed back from the OAuth popup.
|
|
136
|
+
* Returns the upgraded token (a real, non-expiring-in-2-minutes user token) taken from the
|
|
137
|
+
* `X-Token` response header — the provisional token cannot authenticate anything else.
|
|
138
|
+
*/
|
|
139
|
+
export async function completeProviderRegistration(
|
|
140
|
+
url: string,
|
|
141
|
+
token: string,
|
|
142
|
+
username: string,
|
|
143
|
+
email?: string,
|
|
144
|
+
): Promise<{ token: string }> {
|
|
145
|
+
const res = await getFetch()(`${baseUrl(url)}api/register/set-username`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'Content-Type': 'application/json', 'X-Token': token, 'X-Username': token },
|
|
148
|
+
body: JSON.stringify({ username, ...(email ? { email } : {}) }),
|
|
149
|
+
})
|
|
150
|
+
const data = await res.json() as { ok?: number; error?: string }
|
|
151
|
+
if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`)
|
|
152
|
+
return { token: res.headers.get('x-token') ?? token }
|
|
153
|
+
}
|
|
154
|
+
|
|
126
155
|
/**
|
|
127
156
|
* Extract a specific feature entry from a `ServerVersion` response by name.
|
|
128
157
|
* Returns `undefined` if the feature is not present.
|
|
@@ -150,3 +179,30 @@ export function getServerFeature<T extends ServerFeature = ServerFeature>(
|
|
|
150
179
|
export function getScreepsmodAuth(version: ServerVersion): ScreepsmodAuthFeature | undefined {
|
|
151
180
|
return getServerFeature<ScreepsmodAuthFeature>(version, 'screepsmod-auth')
|
|
152
181
|
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns the `xxscreeps-mod-client` feature entry if present, otherwise `undefined`.
|
|
185
|
+
* This feature reflects the server's `.screepsrc.yaml` `backend` settings
|
|
186
|
+
* (`allowGuestAccess`, `allowEmailRegistration`, `steamApiKey`), letting the client
|
|
187
|
+
* gate guest/registration/Steam UI without an extra request.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* const caps = getXxscreepsModClientFeature(version)
|
|
191
|
+
* if (caps && !caps.allowGuestAccess) hideGuestButton()
|
|
192
|
+
*/
|
|
193
|
+
export function getXxscreepsModClientFeature(version: ServerVersion): XxscreepsModClientFeature | undefined {
|
|
194
|
+
return getServerFeature<XxscreepsModClientFeature>(version, 'xxscreeps-mod-client')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Returns the `discord` feature entry (published by `xxscreeps-mod-discord`) if
|
|
199
|
+
* present, otherwise `undefined`. Use `discordLogin` to gate the "Login with
|
|
200
|
+
* Discord" button before showing it.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* const discord = getDiscordFeature(version)
|
|
204
|
+
* if (discord?.discordLogin) showDiscordButton()
|
|
205
|
+
*/
|
|
206
|
+
export function getDiscordFeature(version: ServerVersion): DiscordFeature | undefined {
|
|
207
|
+
return getServerFeature<DiscordFeature>(version, 'discord')
|
|
208
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type {
|
|
|
32
32
|
ServerVersion,
|
|
33
33
|
ServerFeature,
|
|
34
34
|
ScreepsmodAuthFeature,
|
|
35
|
+
XxscreepsModClientFeature,
|
|
36
|
+
DiscordFeature,
|
|
35
37
|
ShardInfo,
|
|
36
38
|
WorldInfo,
|
|
37
39
|
Badge,
|
|
@@ -40,9 +42,9 @@ export type {
|
|
|
40
42
|
MapVisualEntry,
|
|
41
43
|
} from './types/game.js'
|
|
42
44
|
|
|
43
|
-
export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
|
|
45
|
+
export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, fetchAuthMeWithToken, completeProviderRegistration, getServerFeature, getScreepsmodAuth, getXxscreepsModClientFeature, getDiscordFeature } from './http/fetchServerVersion.js'
|
|
44
46
|
export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.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'
|
|
47
|
+
export type { ApiAuthMeResponse, 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'
|
|
46
48
|
export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
|
|
47
49
|
|
|
48
50
|
export { badgeToSvg } from './badge/index.js'
|
package/src/types/game.ts
CHANGED
|
@@ -113,6 +113,30 @@ export interface ScreepsmodAuthFeature extends ServerFeature {
|
|
|
113
113
|
}>
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Published by `xxscreeps-mod-client` at `/api/version`, reflecting the
|
|
118
|
+
* `backend` settings from the server's `.screepsrc.yaml`.
|
|
119
|
+
*/
|
|
120
|
+
export interface XxscreepsModClientFeature extends ServerFeature {
|
|
121
|
+
name: 'xxscreeps-mod-client'
|
|
122
|
+
version: number
|
|
123
|
+
allowGuestAccess: boolean
|
|
124
|
+
allowEmailRegistration: boolean
|
|
125
|
+
steamLogin: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Published by `xxscreeps-mod-discord` at `/api/version` when Discord login is
|
|
130
|
+
* configured. `discordLogin` gates the UI; `loginUrl` is the OAuth entry point
|
|
131
|
+
* (a popup navigates there to start the flow).
|
|
132
|
+
*/
|
|
133
|
+
export interface DiscordFeature extends ServerFeature {
|
|
134
|
+
name: 'discord'
|
|
135
|
+
version: number
|
|
136
|
+
discordLogin: boolean
|
|
137
|
+
loginUrl: string
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
export interface ServerVersion {
|
|
117
141
|
ok: number
|
|
118
142
|
package: number
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { ScreepsClient } from '../src/ScreepsClient.js'
|
|
3
3
|
import { TokenAuth } from '../src/http/auth/TokenAuth.js'
|
|
4
|
+
import type { StorageAdapter } from '../src/storage/StorageAdapter.js'
|
|
5
|
+
|
|
6
|
+
class MemoryStorage implements StorageAdapter {
|
|
7
|
+
readonly data = new Map<string, Uint8Array>()
|
|
8
|
+
async get(key: string): Promise<Uint8Array | null> { return this.data.get(key) ?? null }
|
|
9
|
+
async set(key: string, value: Uint8Array): Promise<void> { this.data.set(key, value) }
|
|
10
|
+
async delete(key: string): Promise<void> { this.data.delete(key) }
|
|
11
|
+
async clear(): Promise<void> { this.data.clear() }
|
|
12
|
+
}
|
|
4
13
|
|
|
5
14
|
class MockWS {
|
|
6
15
|
static instances: MockWS[] = []
|
|
@@ -38,6 +47,49 @@ beforeEach(() => {
|
|
|
38
47
|
})
|
|
39
48
|
afterEach(() => { vi.unstubAllGlobals() })
|
|
40
49
|
|
|
50
|
+
describe('ScreepsClient — cache namespace', () => {
|
|
51
|
+
it('does not collide between two worlds hosted under the same hostname (e.g. Screeps World vs Season)', async () => {
|
|
52
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
|
|
53
|
+
const { pathname } = new URL(url)
|
|
54
|
+
const digit = pathname.startsWith('/season/') ? '1' : '0'
|
|
55
|
+
return Promise.resolve(
|
|
56
|
+
new Response(JSON.stringify({ ok: 1, terrain: [{ _id: 'id', room: 'W5N5', terrain: digit.repeat(2500), type: 'terrain' }] }), {
|
|
57
|
+
headers: { 'content-type': 'application/json' },
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
const storage = new MemoryStorage()
|
|
63
|
+
const worldClient = new ScreepsClient({
|
|
64
|
+
url: 'https://test.local',
|
|
65
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
66
|
+
storage,
|
|
67
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
68
|
+
})
|
|
69
|
+
const seasonClient = new ScreepsClient({
|
|
70
|
+
url: 'https://test.local/season',
|
|
71
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
72
|
+
storage,
|
|
73
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const worldTerrain = await worldClient.stores.room.terrain('W5N5', 'shard0')
|
|
77
|
+
const seasonTerrain = await seasonClient.stores.room.terrain('W5N5', 'shard0')
|
|
78
|
+
|
|
79
|
+
// Same room name + shard on both worlds must resolve to distinct terrain and
|
|
80
|
+
// distinct persisted cache keys — the path (/season) has to disambiguate them
|
|
81
|
+
// since hostname alone is identical.
|
|
82
|
+
expect(worldTerrain.get(0, 0)).toBe(0)
|
|
83
|
+
expect(seasonTerrain.get(0, 0)).toBe(1)
|
|
84
|
+
expect([...storage.data.keys()]).toEqual([
|
|
85
|
+
'test.local/terrain/shard0/W5N5',
|
|
86
|
+
'test.local/season/terrain/shard0/W5N5',
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
vi.unstubAllGlobals()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
41
93
|
describe('ScreepsClient', () => {
|
|
42
94
|
it('exposes http, socket, and stores properties', () => {
|
|
43
95
|
const client = new ScreepsClient({
|
|
@@ -98,6 +98,16 @@ describe('HttpClient', () => {
|
|
|
98
98
|
expect(http.token).toBe('my-static-token')
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
it('DOES update token from x-token header when TokenAuth opts into refresh (session token)', async () => {
|
|
102
|
+
fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
|
|
103
|
+
headers: { 'content-type': 'application/json', 'x-token': 'rotated' },
|
|
104
|
+
}))
|
|
105
|
+
const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'session-token', supportsTokenRefresh: true }) })
|
|
106
|
+
http.token = 'session-token'
|
|
107
|
+
await http.request('GET', '/api/version')
|
|
108
|
+
expect(http.token).toBe('rotated')
|
|
109
|
+
})
|
|
110
|
+
|
|
101
111
|
it('retries once on 401 after re-authenticating', async () => {
|
|
102
112
|
let calls = 0
|
|
103
113
|
fetchMock.mockImplementation(() => {
|