@huaqiu/dsh-auth 0.3.9 → 0.3.11
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/lib/client.js +15 -1
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +46 -2
- package/lib/index.mjs +211 -19
- package/package.json +3 -2
- package/src/client/client.ts +12 -2
- package/src/client/transport.ts +15 -0
- package/src/host.ts +105 -12
- package/src/index.ts +15 -0
- package/src/routes.ts +19 -0
- package/src/service.ts +118 -8
package/src/host.ts
CHANGED
|
@@ -20,11 +20,25 @@
|
|
|
20
20
|
* 4. null → tools return needs_auth
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
|
|
24
|
+
|
|
25
|
+
// Lazy so test suites that `vi.mock('@deepseek-ai/dsh-home-paths', …)` before
|
|
26
|
+
// the test's TMP constant is initialised don't trigger `dshHomePath('logs')` at
|
|
27
|
+
// module-load time. Every site goes through `log()` instead of holding a module
|
|
28
|
+
// reference to the logger.
|
|
29
|
+
let _log: PluginLogger | null = null
|
|
30
|
+
function log(): PluginLogger {
|
|
31
|
+
if (_log === null) _log = getLogger('dsh-auth')
|
|
32
|
+
return _log
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
export interface HuaqiuAuthConfig {
|
|
24
36
|
/** HQ Edge base URL, e.g. "http://localhost:18080". Absent → standalone. */
|
|
25
37
|
hqEdgeBaseUrl?: string
|
|
26
38
|
/** Path on the host; default "/api/v1/auth/token". */
|
|
27
39
|
hostAuthPath?: string
|
|
40
|
+
/** Host endpoint that triggers the EDA login dialog; default "/api/v1/auth/login". */
|
|
41
|
+
hostLoginPath?: string
|
|
28
42
|
/** Seconds a host session is reused before re-fetching. Default 300. */
|
|
29
43
|
hostSessionTtlSeconds?: number
|
|
30
44
|
/** Seconds remote token-validation results are cached. Default 60. */
|
|
@@ -32,6 +46,7 @@ export interface HuaqiuAuthConfig {
|
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
export const DEFAULT_HOST_AUTH_PATH = '/api/v1/auth/token'
|
|
49
|
+
export const DEFAULT_HOST_LOGIN_PATH = '/api/v1/auth/login'
|
|
35
50
|
export const DEFAULT_HOST_TTL_SECONDS = 300
|
|
36
51
|
export const DEFAULT_VALIDATION_TTL_SECONDS = 60
|
|
37
52
|
|
|
@@ -50,6 +65,9 @@ export function resolveHostConfig(
|
|
|
50
65
|
const hostAuthPath = config?.hostAuthPath
|
|
51
66
|
?? env.HQ_EDGE_AUTH_PATH
|
|
52
67
|
?? DEFAULT_HOST_AUTH_PATH
|
|
68
|
+
const hostLoginPath = config?.hostLoginPath
|
|
69
|
+
?? env.HQ_EDGE_LOGIN_PATH
|
|
70
|
+
?? DEFAULT_HOST_LOGIN_PATH
|
|
53
71
|
const ttlRaw = config?.hostSessionTtlSeconds ?? env.HQ_EDGE_HOST_TTL_SECONDS
|
|
54
72
|
let ttl = DEFAULT_HOST_TTL_SECONDS
|
|
55
73
|
if (typeof ttlRaw === 'number' && Number.isFinite(ttlRaw) && ttlRaw > 0) {
|
|
@@ -69,6 +87,7 @@ export function resolveHostConfig(
|
|
|
69
87
|
return {
|
|
70
88
|
hqEdgeBaseUrl: baseUrl,
|
|
71
89
|
hostAuthPath,
|
|
90
|
+
hostLoginPath,
|
|
72
91
|
hostSessionTtlSeconds: ttl,
|
|
73
92
|
validationTtlSeconds,
|
|
74
93
|
}
|
|
@@ -83,11 +102,37 @@ export interface ResolvedHostUser {
|
|
|
83
102
|
id: string
|
|
84
103
|
token: string
|
|
85
104
|
nickname?: string
|
|
105
|
+
/**
|
|
106
|
+
* The HOST's own verdict on the credential.
|
|
107
|
+
*
|
|
108
|
+
* HQ Edge receives its credential from EDA (`AuthStateChanged`) and reports
|
|
109
|
+
* `authenticated` alongside the token; that verdict is authoritative for host
|
|
110
|
+
* mode. This plugin must not substitute its own guess: the token class EDA
|
|
111
|
+
* hands to HQ Edge is not necessarily known to the public `www.eda.cn`
|
|
112
|
+
* token-validation endpoint, so a remote probe there can answer "not valid"
|
|
113
|
+
* for a credential the operator's own editor considers live.
|
|
114
|
+
*/
|
|
115
|
+
authenticated: boolean
|
|
116
|
+
/**
|
|
117
|
+
* Host-side state version (HQ Edge bumps it on every credential replace and
|
|
118
|
+
* clear). A change means "this is a different credential than the one you
|
|
119
|
+
* looked at", which is how a 401-driven invalidation gets undone.
|
|
120
|
+
*/
|
|
121
|
+
version?: number
|
|
122
|
+
/** Unix seconds, when the host knows the expiry. */
|
|
123
|
+
expiresAt?: number
|
|
86
124
|
}
|
|
87
125
|
|
|
88
126
|
/**
|
|
89
|
-
* Fetches and caches the host (HQ Edge) session. The cache is memory-only with
|
|
90
|
-
* TTL; the loopback GET is cheap
|
|
127
|
+
* Fetches and caches the host (HQ Edge) session. The cache is memory-only with
|
|
128
|
+
* a TTL; the loopback GET is cheap, so we never persist the credential.
|
|
129
|
+
*
|
|
130
|
+
* Caching rule — **only a usable session is cached**. A host that is up but has
|
|
131
|
+
* no credential yet (EDA not logged in, or the login dialog still open) must
|
|
132
|
+
* NOT be cached: the operator may complete the login a second later, and a
|
|
133
|
+
* cached negative would pin every tool to `needs_auth` for the whole TTL. The
|
|
134
|
+
* old code cached whatever it got, so a DSH that booted before EDA
|
|
135
|
+
* authenticated stayed "logged out" for up to `hostSessionTtlSeconds` (300s).
|
|
91
136
|
*/
|
|
92
137
|
export class HostSessionResolver {
|
|
93
138
|
private cache: HostSession | null = null
|
|
@@ -110,21 +155,47 @@ export class HostSessionResolver {
|
|
|
110
155
|
if (this.cache !== null && now - this.cache.fetchedAt < this.ttlMs) {
|
|
111
156
|
return this.cache.info
|
|
112
157
|
}
|
|
158
|
+
const url = `${this.baseUrl}${this.path}`
|
|
159
|
+
let info: ResolvedHostUser | null = null
|
|
160
|
+
let status: number | null = null
|
|
113
161
|
try {
|
|
114
|
-
const res = await this.doFetch(
|
|
162
|
+
const res = await this.doFetch(url, {
|
|
115
163
|
method: 'GET',
|
|
116
164
|
headers: { accept: 'application/json' },
|
|
117
165
|
})
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
166
|
+
status = res.status
|
|
167
|
+
if (res.ok) {
|
|
168
|
+
const data = await res.json() as Record<string, unknown>
|
|
169
|
+
info = normalizeHostUser(data)
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// Network error: nothing usable right now.
|
|
173
|
+
log().warn('host session fetch failed', { url, error: String(err) })
|
|
174
|
+
info = null
|
|
175
|
+
}
|
|
176
|
+
if (info !== null && info.authenticated) {
|
|
122
177
|
this.cache = { info, fetchedAt: now }
|
|
178
|
+
// Never log the credential itself — id/version are enough to correlate
|
|
179
|
+
// this plugin with the HQ Edge log line that served it.
|
|
180
|
+
log().debug('host session resolved', {
|
|
181
|
+
url,
|
|
182
|
+
status,
|
|
183
|
+
userId: info.id,
|
|
184
|
+
version: info.version ?? null,
|
|
185
|
+
authenticated: info.authenticated,
|
|
186
|
+
})
|
|
123
187
|
return info
|
|
124
|
-
} catch {
|
|
125
|
-
// Network error: fall back to a previously cached value if we have one.
|
|
126
|
-
return this.cache?.info ?? null
|
|
127
188
|
}
|
|
189
|
+
// Unusable (unreachable, unparseable, or the host says it has no
|
|
190
|
+
// credential). Drop the cache so the very next call re-asks the host.
|
|
191
|
+
this.cache = null
|
|
192
|
+
log().info('no usable host session', {
|
|
193
|
+
url,
|
|
194
|
+
status,
|
|
195
|
+
parsed: info !== null,
|
|
196
|
+
hostAuthenticated: info?.authenticated ?? null,
|
|
197
|
+
})
|
|
198
|
+
return null
|
|
128
199
|
}
|
|
129
200
|
|
|
130
201
|
/** Drop the cached value so the next `resolve()` re-fetches (reactive invalidation). */
|
|
@@ -139,7 +210,15 @@ function asId(raw: unknown): string | null {
|
|
|
139
210
|
return null
|
|
140
211
|
}
|
|
141
212
|
|
|
142
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Parse the host route payload into a credential, tolerating key-name drift.
|
|
215
|
+
*
|
|
216
|
+
* HQ Edge answers `GET /api/v1/auth/token` with
|
|
217
|
+
* `{ authenticated, token, userId, version }`. The `authenticated` flag is the
|
|
218
|
+
* host's own verdict and is carried through verbatim; hosts that predate it
|
|
219
|
+
* (or a minimal `{ token, userId }` stub) omit the key, in which case the
|
|
220
|
+
* presence of a usable token+id is taken as the old contract was: authenticated.
|
|
221
|
+
*/
|
|
143
222
|
export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUser | null {
|
|
144
223
|
const token = typeof data.token === 'string' && data.token.length > 0
|
|
145
224
|
? data.token
|
|
@@ -151,5 +230,19 @@ export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUs
|
|
|
151
230
|
const nickname = typeof data.nickname === 'string' && data.nickname.length > 0
|
|
152
231
|
? data.nickname
|
|
153
232
|
: undefined
|
|
154
|
-
|
|
233
|
+
const authenticated = data.authenticated === undefined ? true : data.authenticated === true
|
|
234
|
+
const version = typeof data.version === 'number' && Number.isFinite(data.version)
|
|
235
|
+
? data.version
|
|
236
|
+
: undefined
|
|
237
|
+
const expiresAt = typeof data.expiresAt === 'number' && Number.isFinite(data.expiresAt)
|
|
238
|
+
? data.expiresAt
|
|
239
|
+
: undefined
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
token,
|
|
243
|
+
authenticated,
|
|
244
|
+
...(nickname ? { nickname } : {}),
|
|
245
|
+
...(version !== undefined ? { version } : {}),
|
|
246
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
247
|
+
}
|
|
155
248
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Context } from '@deepseek-ai/cordis'
|
|
8
8
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
9
|
+
import { getLogger } from '@huaqiu/dsh-plugin-log'
|
|
9
10
|
import { InMemoryHuaqiuAuthService, type HuaqiuAuthService } from './service.js'
|
|
10
11
|
import { AUTH_ROUTE_PREFIX, createAuthHandler } from './routes.js'
|
|
11
12
|
import type { HuaqiuAuthConfig } from './host.js'
|
|
12
13
|
|
|
14
|
+
const log = getLogger('dsh-auth')
|
|
15
|
+
|
|
13
16
|
export type { HuaqiuAuthApi, HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
|
|
14
17
|
export { InMemoryHuaqiuAuthService } from './service.js'
|
|
15
18
|
export { AUTH_ROUTE_PREFIX, createAuthHandler, type AuthHandler } from './routes.js'
|
|
@@ -33,6 +36,16 @@ declare module '@deepseek-ai/cordis' {
|
|
|
33
36
|
* without a supervisor (spec §6.4).
|
|
34
37
|
*/
|
|
35
38
|
export function apply(ctx: Context, config?: Partial<HuaqiuAuthConfig>): void {
|
|
39
|
+
// The single most useful line when a harness upgrade "loses" the credential:
|
|
40
|
+
// it proves whether the overlay `config` still reached `apply(ctx, config)`.
|
|
41
|
+
// When `hostMode` is false under HQ Edge, the endpoint was not delivered and
|
|
42
|
+
// every tool will report `needs_auth` no matter what the browser does.
|
|
43
|
+
log.info('applying dsh-auth node half', {
|
|
44
|
+
hasConfig: config != null,
|
|
45
|
+
configKeys: config != null ? Object.keys(config) : [],
|
|
46
|
+
hqEdgeBaseUrl: config?.hqEdgeBaseUrl ?? null,
|
|
47
|
+
})
|
|
48
|
+
|
|
36
49
|
const service = new InMemoryHuaqiuAuthService(config)
|
|
37
50
|
ctx.effect(() => ctx.provide('huaqiuAuth', service))
|
|
38
51
|
|
|
@@ -41,4 +54,6 @@ export function apply(ctx: Context, config?: Partial<HuaqiuAuthConfig>): void {
|
|
|
41
54
|
path: AUTH_ROUTE_PREFIX,
|
|
42
55
|
handler: createAuthHandler(service),
|
|
43
56
|
}))
|
|
57
|
+
|
|
58
|
+
log.info('dsh-auth node half ready', { routes: AUTH_ROUTE_PREFIX, hostMode: service.hostMode })
|
|
44
59
|
}
|
package/src/routes.ts
CHANGED
|
@@ -15,8 +15,18 @@
|
|
|
15
15
|
* the documented channel).
|
|
16
16
|
*/
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
|
+
import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
|
|
18
19
|
import type { HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
|
|
19
20
|
|
|
21
|
+
// Lazy so test suites that `vi.mock('@deepseek-ai/dsh-home-paths', …)` before
|
|
22
|
+
// their TMP constant is initialised don't trigger `dshHomePath('logs')` at
|
|
23
|
+
// module-load time.
|
|
24
|
+
let _log: PluginLogger | null = null
|
|
25
|
+
function log(): PluginLogger {
|
|
26
|
+
if (_log === null) _log = getLogger('dsh-auth')
|
|
27
|
+
return _log
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
export const AUTH_ROUTE_PREFIX = '/api/v1/huaqiu/auth'
|
|
21
31
|
|
|
22
32
|
export type AuthHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void> | void
|
|
@@ -93,6 +103,15 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
|
|
|
93
103
|
return
|
|
94
104
|
}
|
|
95
105
|
|
|
106
|
+
if (req.method === 'POST' && pathname === `${AUTH_ROUTE_PREFIX}/login`) {
|
|
107
|
+
// Host-mode login: forward to HQ Edge, which asks EDA (KiCad) to open
|
|
108
|
+
// its TriggerLoginDialog. Blocks until the dialog is completed.
|
|
109
|
+
await service.auth.login()
|
|
110
|
+
const user = await service.auth.getUserInfo()
|
|
111
|
+
sendJson(res, 200, { ok: true, authenticated: user !== null, user })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
sendJson(res, 404, { error: 'not found' })
|
|
97
116
|
} catch (err) {
|
|
98
117
|
sendJson(res, 500, { error: 'internal error', detail: String(err) })
|
package/src/service.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
24
24
|
import { join } from 'node:path'
|
|
25
25
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
26
|
+
import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
|
|
26
27
|
import {
|
|
27
28
|
HostSessionResolver,
|
|
28
29
|
resolveHostConfig,
|
|
@@ -146,29 +147,48 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
146
147
|
private listeners = new Set<(info: HuaqiuUserInfo | null) => void>()
|
|
147
148
|
private readonly host: HostSessionResolver
|
|
148
149
|
private readonly validator: TokenValidator
|
|
150
|
+
private readonly doFetch: typeof fetch
|
|
151
|
+
private readonly hostLoginPath: string
|
|
152
|
+
/** Bound on the host login RPC (user must complete the EDA dialog). */
|
|
153
|
+
private readonly loginWaitMs: number
|
|
149
154
|
/**
|
|
150
155
|
* True once the current credential has been rejected (API 401) or explicitly
|
|
151
156
|
* invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
|
|
152
157
|
* short-circuit to false and forces a fresh remote validation next time.
|
|
153
158
|
*/
|
|
154
159
|
private stale = false
|
|
160
|
+
/** Last host credential version seen; see `observeHostVersion`. */
|
|
161
|
+
private lastHostVersion: number | null = null
|
|
162
|
+
private _log: PluginLogger | null = null
|
|
163
|
+
private get log(): PluginLogger {
|
|
164
|
+
if (this._log === null) this._log = getLogger('dsh-auth')
|
|
165
|
+
return this._log
|
|
166
|
+
}
|
|
155
167
|
|
|
156
168
|
constructor(
|
|
157
169
|
config?: Partial<HuaqiuAuthConfig> | null,
|
|
158
170
|
opts?: { fetchImpl?: typeof fetch },
|
|
159
171
|
) {
|
|
160
172
|
const resolved = resolveHostConfig(config)
|
|
173
|
+
this.doFetch = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis)
|
|
161
174
|
this.host = new HostSessionResolver(
|
|
162
175
|
resolved.hqEdgeBaseUrl ?? '',
|
|
163
176
|
resolved.hostAuthPath ?? '/api/v1/auth/token',
|
|
164
177
|
(resolved.hostSessionTtlSeconds ?? 300) * 1000,
|
|
165
|
-
|
|
178
|
+
this.doFetch,
|
|
166
179
|
)
|
|
180
|
+
this.hostLoginPath = resolved.hostLoginPath ?? '/api/v1/auth/login'
|
|
181
|
+
this.loginWaitMs = 5 * 60_000
|
|
167
182
|
this.validator = new TokenValidator({
|
|
168
183
|
ttlMs: (resolved.validationTtlSeconds ?? 60) * 1000,
|
|
169
|
-
fetchImpl:
|
|
184
|
+
fetchImpl: this.doFetch,
|
|
170
185
|
})
|
|
171
186
|
this.hostMode = this.host.enabled
|
|
187
|
+
this.log.info('auth service started', {
|
|
188
|
+
hostMode: this.hostMode,
|
|
189
|
+
hostAuthPath: resolved.hostAuthPath,
|
|
190
|
+
hostSessionTtlSeconds: resolved.hostSessionTtlSeconds,
|
|
191
|
+
})
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
/** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
|
|
@@ -176,13 +196,40 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
176
196
|
|
|
177
197
|
readonly auth: HuaqiuAuthApi = {
|
|
178
198
|
isAuthenticated: async () => {
|
|
199
|
+
// `stale` is "an upstream 401 was reported against a previous credential".
|
|
200
|
+
// Run resolve() *first* so the host's version comparator can run — HQ Edge
|
|
201
|
+
// bumps its credential version when the operator re-authes, and a newer
|
|
202
|
+
// version means the previous 401 no longer applies. Only after that
|
|
203
|
+
// observation do we honour the stale latch (if the credential is
|
|
204
|
+
// unchanged). Without this ordering, a 401-driven invalidation would
|
|
205
|
+
// keep the operator logged out even after HQ Edge swapped credentials.
|
|
206
|
+
const info = await this.resolve()
|
|
207
|
+
if (info === null) return false
|
|
179
208
|
if (this.stale) return false
|
|
180
209
|
return (await this.validateInternal()).status === 'valid'
|
|
181
210
|
},
|
|
182
211
|
getAccessToken: async () => (await this.resolve())?.token ?? null,
|
|
183
212
|
getUserInfo: async () => this.resolve(),
|
|
184
213
|
login: async () => {
|
|
185
|
-
|
|
214
|
+
// Standalone: login is a browser action (auth.eda.cn iframe) — no-op.
|
|
215
|
+
if (!this.host.enabled) return
|
|
216
|
+
// Host mode: the browser-side iframe is suppressed, so clicking login
|
|
217
|
+
// must ask the host (hq-edge → EDA TriggerLoginDialog) to open the EDA
|
|
218
|
+
// login dialog. The host route blocks until AuthStateChanged flips the
|
|
219
|
+
// credential (bounded), then we drop the cached host session so the next
|
|
220
|
+
// resolve() re-fetches the fresh token.
|
|
221
|
+
const res = await this.doFetch(`${this.host.baseUrl}${this.hostLoginPath}`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { accept: 'application/json' },
|
|
224
|
+
signal: AbortSignal.timeout(this.loginWaitMs),
|
|
225
|
+
})
|
|
226
|
+
if (!res.ok) {
|
|
227
|
+
throw new Error(`host login not completed: HTTP ${res.status}`)
|
|
228
|
+
}
|
|
229
|
+
this.host.clear()
|
|
230
|
+
this.stale = false
|
|
231
|
+
this.validator.invalidate()
|
|
232
|
+
this.emit()
|
|
186
233
|
},
|
|
187
234
|
logout: async () => this.invalidate(),
|
|
188
235
|
validate: () => this.validateInternal(),
|
|
@@ -199,24 +246,78 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
199
246
|
* standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
|
|
200
247
|
* would let the DSH plugins keep calling the backend while hq-edge itself has
|
|
201
248
|
* no auth info. The standalone fallbacks only apply when no host is configured.
|
|
249
|
+
*
|
|
250
|
+
* A host credential the host itself reports as `authenticated: false` counts
|
|
251
|
+
* as "no credential" — otherwise `/session` would hand the browser half a user
|
|
252
|
+
* object its own `authenticated` flag contradicts, and the browser half (which
|
|
253
|
+
* adopts the session only when `authenticated` is true) would silently drop
|
|
254
|
+
* it. That mismatch is exactly how "the token reached HQ Edge but never
|
|
255
|
+
* reached the plugin" presents.
|
|
202
256
|
*/
|
|
203
257
|
private async resolve(): Promise<HuaqiuUserInfo | null> {
|
|
204
258
|
if (this.host.enabled) {
|
|
205
259
|
const host = await this.host.resolve()
|
|
206
|
-
if (host
|
|
207
|
-
|
|
260
|
+
if (!host || !host.authenticated) {
|
|
261
|
+
this.log.debug('host session unusable', {
|
|
262
|
+
hostReachable: host !== null,
|
|
263
|
+
hostAuthenticated: host?.authenticated ?? null,
|
|
264
|
+
})
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
this.observeHostVersion(host.version)
|
|
268
|
+
return toUserInfo(host)
|
|
208
269
|
}
|
|
209
270
|
if (this.current) return this.current
|
|
210
271
|
return readPersisted()
|
|
211
272
|
}
|
|
212
273
|
|
|
213
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* Track the host's credential version so a replaced credential re-arms the
|
|
276
|
+
* session after a 401.
|
|
277
|
+
*
|
|
278
|
+
* `invalidate()` (called when a backend answers 401) must keep the session
|
|
279
|
+
* down — but only for *that* credential. HQ Edge bumps `version` whenever EDA
|
|
280
|
+
* replaces or clears its credential, so a new version means the operator has
|
|
281
|
+
* a new token and the previous rejection no longer applies.
|
|
282
|
+
*/
|
|
283
|
+
private observeHostVersion(version?: number): void {
|
|
284
|
+
if (typeof version !== 'number' || !Number.isFinite(version)) return
|
|
285
|
+
if (this.lastHostVersion !== null && version !== this.lastHostVersion) {
|
|
286
|
+
this.stale = false
|
|
287
|
+
this.validator.invalidate()
|
|
288
|
+
this.log.info('host credential replaced — re-arming session', { version })
|
|
289
|
+
}
|
|
290
|
+
this.lastHostVersion = version
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Spec §7: resolve → local expiry → remote validation → update state.
|
|
295
|
+
*
|
|
296
|
+
* In HOST mode there is no remote validation: the host is the authority. It
|
|
297
|
+
* received the credential from EDA and publishes its own `authenticated`
|
|
298
|
+
* verdict, so probing `www.eda.cn/api/token/validate` here is not just
|
|
299
|
+
* redundant — it is wrong. That endpoint does not know the credential class
|
|
300
|
+
* EDA hands to HQ Edge (it answers `result: false`) and is unreachable in
|
|
301
|
+
* offline deployments, so gating on it made every host-mode session read as
|
|
302
|
+
* unauthenticated even while hq-edge reported `authenticated: true`.
|
|
303
|
+
*/
|
|
214
304
|
private async validateInternal(): Promise<AuthValidationResult> {
|
|
215
305
|
const info = await this.resolve()
|
|
216
306
|
if (!info) {
|
|
217
|
-
|
|
307
|
+
// Host mode deliberately does NOT latch `stale` here: an unreachable or
|
|
308
|
+
// not-yet-logged-in host is a transient condition, and latching would
|
|
309
|
+
// freeze the session even after the host recovers.
|
|
310
|
+
if (!this.host.enabled) this.stale = true
|
|
218
311
|
return { status: 'invalid', reason: 'invalid' }
|
|
219
312
|
}
|
|
313
|
+
if (this.host.enabled) {
|
|
314
|
+
if (this.validator.isLocallyExpired(info.expiresAt)) {
|
|
315
|
+
this.validator.invalidate(info.token)
|
|
316
|
+
this.stale = true
|
|
317
|
+
return { status: 'invalid', reason: 'expired' }
|
|
318
|
+
}
|
|
319
|
+
return { status: 'valid', userId: info.id, ...(info.expiresAt !== undefined ? { expiresAt: info.expiresAt } : {}) }
|
|
320
|
+
}
|
|
220
321
|
// Local expiry is an optimization; remote validation stays authoritative.
|
|
221
322
|
if (this.validator.isLocallyExpired(info.expiresAt)) {
|
|
222
323
|
this.validator.invalidate(info.token)
|
|
@@ -231,10 +332,18 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
231
332
|
return result
|
|
232
333
|
}
|
|
233
334
|
|
|
234
|
-
/** Validation-scoped invalidation: keep the credential, drop cached validity.
|
|
335
|
+
/** Validation-scoped invalidation: keep the credential, drop cached validity.
|
|
336
|
+
*
|
|
337
|
+
* In host mode the host cache *is* the cached validity — the validator only
|
|
338
|
+
* confirmed what the host already said — so the next call must re-fetch:
|
|
339
|
+
* otherwise a 401-driven `auth.invalidate()` would keep stale=true latched
|
|
340
|
+
* for the full `hostSessionTtlSeconds` (default 300 s), and an HQ-Edge
|
|
341
|
+
* credential replace in between would be invisible until the cache expired.
|
|
342
|
+
*/
|
|
235
343
|
private markStale(): void {
|
|
236
344
|
this.stale = true
|
|
237
345
|
this.validator.invalidate()
|
|
346
|
+
if (this.host.enabled) this.host.clear()
|
|
238
347
|
}
|
|
239
348
|
|
|
240
349
|
setCredentials(info: HuaqiuUserInfo): void {
|
|
@@ -275,5 +384,6 @@ function toUserInfo(host: ResolvedHostUser): HuaqiuUserInfo {
|
|
|
275
384
|
id: host.id,
|
|
276
385
|
token: host.token,
|
|
277
386
|
...(host.nickname !== undefined ? { nickname: host.nickname } : {}),
|
|
387
|
+
...(host.expiresAt !== undefined ? { expiresAt: host.expiresAt } : {}),
|
|
278
388
|
}
|
|
279
389
|
}
|