@huaqiu/dsh-auth 0.3.10 → 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/index.d.mts CHANGED
@@ -144,6 +144,10 @@ declare class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
144
144
  * short-circuit to false and forces a fresh remote validation next time.
145
145
  */
146
146
  private stale;
147
+ /** Last host credential version seen; see `observeHostVersion`. */
148
+ private lastHostVersion;
149
+ private _log;
150
+ private get log();
147
151
  constructor(config?: Partial<HuaqiuAuthConfig> | null, opts?: {
148
152
  fetchImpl?: typeof fetch;
149
153
  });
@@ -159,11 +163,45 @@ declare class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
159
163
  * standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
160
164
  * would let the DSH plugins keep calling the backend while hq-edge itself has
161
165
  * no auth info. The standalone fallbacks only apply when no host is configured.
166
+ *
167
+ * A host credential the host itself reports as `authenticated: false` counts
168
+ * as "no credential" — otherwise `/session` would hand the browser half a user
169
+ * object its own `authenticated` flag contradicts, and the browser half (which
170
+ * adopts the session only when `authenticated` is true) would silently drop
171
+ * it. That mismatch is exactly how "the token reached HQ Edge but never
172
+ * reached the plugin" presents.
162
173
  */
163
174
  private resolve;
164
- /** Spec §7: resolve → local expiry → remote validation → update state. */
175
+ /**
176
+ * Track the host's credential version so a replaced credential re-arms the
177
+ * session after a 401.
178
+ *
179
+ * `invalidate()` (called when a backend answers 401) must keep the session
180
+ * down — but only for *that* credential. HQ Edge bumps `version` whenever EDA
181
+ * replaces or clears its credential, so a new version means the operator has
182
+ * a new token and the previous rejection no longer applies.
183
+ */
184
+ private observeHostVersion;
185
+ /**
186
+ * Spec §7: resolve → local expiry → remote validation → update state.
187
+ *
188
+ * In HOST mode there is no remote validation: the host is the authority. It
189
+ * received the credential from EDA and publishes its own `authenticated`
190
+ * verdict, so probing `www.eda.cn/api/token/validate` here is not just
191
+ * redundant — it is wrong. That endpoint does not know the credential class
192
+ * EDA hands to HQ Edge (it answers `result: false`) and is unreachable in
193
+ * offline deployments, so gating on it made every host-mode session read as
194
+ * unauthenticated even while hq-edge reported `authenticated: true`.
195
+ */
165
196
  private validateInternal;
166
- /** Validation-scoped invalidation: keep the credential, drop cached validity. */
197
+ /** Validation-scoped invalidation: keep the credential, drop cached validity.
198
+ *
199
+ * In host mode the host cache *is* the cached validity — the validator only
200
+ * confirmed what the host already said — so the next call must re-fetch:
201
+ * otherwise a 401-driven `auth.invalidate()` would keep stale=true latched
202
+ * for the full `hostSessionTtlSeconds` (default 300 s), and an HQ-Edge
203
+ * credential replace in between would be invisible until the cache expired.
204
+ */
167
205
  private markStale;
168
206
  setCredentials(info: HuaqiuUserInfo): void;
169
207
  invalidate(): void;
package/lib/index.mjs CHANGED
@@ -1,6 +1,34 @@
1
+ import { getLogger } from "@huaqiu/dsh-plugin-log";
1
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
5
+ //#region src/host.ts
6
+ /**
7
+ * HQ Edge host mode for `dsh-auth`.
8
+ *
9
+ * In standalone DSH, credentials arrive only when the user logs in through the
10
+ * browser (auth.eda.cn iframe → postMessage → `webServer` route →
11
+ * `setCredentials`). That means the node half is empty until a browser tab
12
+ * focuses the card, and every tool returns `needs_auth` in the gap — including
13
+ * across a process restart (the in-memory cache is gone).
14
+ *
15
+ * Host mode removes that gap. When HQ Edge is the host, it already holds the
16
+ * operator-supplied token + user id, and exposes them on a loopback route. The
17
+ * node half fetches them on boot and is authoritative immediately, without
18
+ * waiting for a browser. The browser half is untouched — host mode is *mode*,
19
+ * not API (spec §5/§14).
20
+ *
21
+ * Resolution order inside `getUserInfo()` (spec §6.2):
22
+ * 1. host session — HQ Edge configured → fetch + cache (TTL)
23
+ * 2. pushed session — what the browser half sent (today's behaviour)
24
+ * 3. persisted file — node-side `~/.dsh/auth/session.json`, written on every set
25
+ * 4. null → tools return needs_auth
26
+ */
27
+ let _log = null;
28
+ function log$1() {
29
+ if (_log === null) _log = getLogger("dsh-auth");
30
+ return _log;
31
+ }
4
32
  /**
5
33
  * Resolve the effective config: overlay `config` (highest) > env (safety net
6
34
  * for non-supervisor installs) > defaults. Centralised here so only `dsh-auth`
@@ -33,8 +61,15 @@ function resolveHostConfig(config, env = process.env) {
33
61
  };
34
62
  }
35
63
  /**
36
- * Fetches and caches the host (HQ Edge) session. The cache is memory-only with a
37
- * TTL; the loopback GET is cheap and the token is static, so we never persist it.
64
+ * Fetches and caches the host (HQ Edge) session. The cache is memory-only with
65
+ * a TTL; the loopback GET is cheap, so we never persist the credential.
66
+ *
67
+ * Caching rule — **only a usable session is cached**. A host that is up but has
68
+ * no credential yet (EDA not logged in, or the login dialog still open) must
69
+ * NOT be cached: the operator may complete the login a second later, and a
70
+ * cached negative would pin every tool to `needs_auth` for the whole TTL. The
71
+ * old code cached whatever it got, so a DSH that booted before EDA
72
+ * authenticated stayed "logged out" for up to `hostSessionTtlSeconds` (300s).
38
73
  */
39
74
  var HostSessionResolver = class {
40
75
  baseUrl;
@@ -56,22 +91,45 @@ var HostSessionResolver = class {
56
91
  if (!this.enabled) return null;
57
92
  const now = Date.now();
58
93
  if (this.cache !== null && now - this.cache.fetchedAt < this.ttlMs) return this.cache.info;
94
+ const url = `${this.baseUrl}${this.path}`;
95
+ let info = null;
96
+ let status = null;
59
97
  try {
60
- const res = await this.doFetch(`${this.baseUrl}${this.path}`, {
98
+ const res = await this.doFetch(url, {
61
99
  method: "GET",
62
100
  headers: { accept: "application/json" }
63
101
  });
64
- if (!res.ok) return this.cache?.info ?? null;
65
- const info = normalizeHostUser(await res.json());
66
- if (!info) return this.cache?.info ?? null;
102
+ status = res.status;
103
+ if (res.ok) info = normalizeHostUser(await res.json());
104
+ } catch (err) {
105
+ log$1().warn("host session fetch failed", {
106
+ url,
107
+ error: String(err)
108
+ });
109
+ info = null;
110
+ }
111
+ if (info !== null && info.authenticated) {
67
112
  this.cache = {
68
113
  info,
69
114
  fetchedAt: now
70
115
  };
116
+ log$1().debug("host session resolved", {
117
+ url,
118
+ status,
119
+ userId: info.id,
120
+ version: info.version ?? null,
121
+ authenticated: info.authenticated
122
+ });
71
123
  return info;
72
- } catch {
73
- return this.cache?.info ?? null;
74
124
  }
125
+ this.cache = null;
126
+ log$1().info("no usable host session", {
127
+ url,
128
+ status,
129
+ parsed: info !== null,
130
+ hostAuthenticated: info?.authenticated ?? null
131
+ });
132
+ return null;
75
133
  }
76
134
  /** Drop the cached value so the next `resolve()` re-fetches (reactive invalidation). */
77
135
  clear() {
@@ -83,16 +141,30 @@ function asId(raw) {
83
141
  if (typeof raw === "number" && Number.isFinite(raw)) return String(raw);
84
142
  return null;
85
143
  }
86
- /** Parse the host route payload into a credential, tolerating key-name drift. */
144
+ /**
145
+ * Parse the host route payload into a credential, tolerating key-name drift.
146
+ *
147
+ * HQ Edge answers `GET /api/v1/auth/token` with
148
+ * `{ authenticated, token, userId, version }`. The `authenticated` flag is the
149
+ * host's own verdict and is carried through verbatim; hosts that predate it
150
+ * (or a minimal `{ token, userId }` stub) omit the key, in which case the
151
+ * presence of a usable token+id is taken as the old contract was: authenticated.
152
+ */
87
153
  function normalizeHostUser(data) {
88
154
  const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
89
155
  const id = asId(data.userId) ?? asId(data.id) ?? asId(data.user_id);
90
156
  if (!token || !id) return null;
91
157
  const nickname = typeof data.nickname === "string" && data.nickname.length > 0 ? data.nickname : void 0;
158
+ const authenticated = data.authenticated === void 0 ? true : data.authenticated === true;
159
+ const version = typeof data.version === "number" && Number.isFinite(data.version) ? data.version : void 0;
160
+ const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt : void 0;
92
161
  return {
93
162
  id,
94
163
  token,
95
- ...nickname ? { nickname } : {}
164
+ authenticated,
165
+ ...nickname ? { nickname } : {},
166
+ ...version !== void 0 ? { version } : {},
167
+ ...expiresAt !== void 0 ? { expiresAt } : {}
96
168
  };
97
169
  }
98
170
  /**
@@ -305,6 +377,13 @@ var InMemoryHuaqiuAuthService = class {
305
377
  * short-circuit to false and forces a fresh remote validation next time.
306
378
  */
307
379
  stale = false;
380
+ /** Last host credential version seen; see `observeHostVersion`. */
381
+ lastHostVersion = null;
382
+ _log = null;
383
+ get log() {
384
+ if (this._log === null) this._log = getLogger("dsh-auth");
385
+ return this._log;
386
+ }
308
387
  constructor(config, opts) {
309
388
  const resolved = resolveHostConfig(config);
310
389
  this.doFetch = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis);
@@ -316,11 +395,17 @@ var InMemoryHuaqiuAuthService = class {
316
395
  fetchImpl: this.doFetch
317
396
  });
318
397
  this.hostMode = this.host.enabled;
398
+ this.log.info("auth service started", {
399
+ hostMode: this.hostMode,
400
+ hostAuthPath: resolved.hostAuthPath,
401
+ hostSessionTtlSeconds: resolved.hostSessionTtlSeconds
402
+ });
319
403
  }
320
404
  /** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
321
405
  hostMode;
322
406
  auth = {
323
407
  isAuthenticated: async () => {
408
+ if (await this.resolve() === null) return false;
324
409
  if (this.stale) return false;
325
410
  return (await this.validateInternal()).status === "valid";
326
411
  },
@@ -353,26 +438,83 @@ var InMemoryHuaqiuAuthService = class {
353
438
  * standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
354
439
  * would let the DSH plugins keep calling the backend while hq-edge itself has
355
440
  * no auth info. The standalone fallbacks only apply when no host is configured.
441
+ *
442
+ * A host credential the host itself reports as `authenticated: false` counts
443
+ * as "no credential" — otherwise `/session` would hand the browser half a user
444
+ * object its own `authenticated` flag contradicts, and the browser half (which
445
+ * adopts the session only when `authenticated` is true) would silently drop
446
+ * it. That mismatch is exactly how "the token reached HQ Edge but never
447
+ * reached the plugin" presents.
356
448
  */
357
449
  async resolve() {
358
450
  if (this.host.enabled) {
359
451
  const host = await this.host.resolve();
360
- if (host) return toUserInfo(host);
361
- return null;
452
+ if (!host || !host.authenticated) {
453
+ this.log.debug("host session unusable", {
454
+ hostReachable: host !== null,
455
+ hostAuthenticated: host?.authenticated ?? null
456
+ });
457
+ return null;
458
+ }
459
+ this.observeHostVersion(host.version);
460
+ return toUserInfo(host);
362
461
  }
363
462
  if (this.current) return this.current;
364
463
  return readPersisted();
365
464
  }
366
- /** Spec §7: resolve → local expiry → remote validation → update state. */
465
+ /**
466
+ * Track the host's credential version so a replaced credential re-arms the
467
+ * session after a 401.
468
+ *
469
+ * `invalidate()` (called when a backend answers 401) must keep the session
470
+ * down — but only for *that* credential. HQ Edge bumps `version` whenever EDA
471
+ * replaces or clears its credential, so a new version means the operator has
472
+ * a new token and the previous rejection no longer applies.
473
+ */
474
+ observeHostVersion(version) {
475
+ if (typeof version !== "number" || !Number.isFinite(version)) return;
476
+ if (this.lastHostVersion !== null && version !== this.lastHostVersion) {
477
+ this.stale = false;
478
+ this.validator.invalidate();
479
+ this.log.info("host credential replaced — re-arming session", { version });
480
+ }
481
+ this.lastHostVersion = version;
482
+ }
483
+ /**
484
+ * Spec §7: resolve → local expiry → remote validation → update state.
485
+ *
486
+ * In HOST mode there is no remote validation: the host is the authority. It
487
+ * received the credential from EDA and publishes its own `authenticated`
488
+ * verdict, so probing `www.eda.cn/api/token/validate` here is not just
489
+ * redundant — it is wrong. That endpoint does not know the credential class
490
+ * EDA hands to HQ Edge (it answers `result: false`) and is unreachable in
491
+ * offline deployments, so gating on it made every host-mode session read as
492
+ * unauthenticated even while hq-edge reported `authenticated: true`.
493
+ */
367
494
  async validateInternal() {
368
495
  const info = await this.resolve();
369
496
  if (!info) {
370
- this.stale = true;
497
+ if (!this.host.enabled) this.stale = true;
371
498
  return {
372
499
  status: "invalid",
373
500
  reason: "invalid"
374
501
  };
375
502
  }
503
+ if (this.host.enabled) {
504
+ if (this.validator.isLocallyExpired(info.expiresAt)) {
505
+ this.validator.invalidate(info.token);
506
+ this.stale = true;
507
+ return {
508
+ status: "invalid",
509
+ reason: "expired"
510
+ };
511
+ }
512
+ return {
513
+ status: "valid",
514
+ userId: info.id,
515
+ ...info.expiresAt !== void 0 ? { expiresAt: info.expiresAt } : {}
516
+ };
517
+ }
376
518
  if (this.validator.isLocallyExpired(info.expiresAt)) {
377
519
  this.validator.invalidate(info.token);
378
520
  this.stale = true;
@@ -386,10 +528,18 @@ var InMemoryHuaqiuAuthService = class {
386
528
  else if (result.status === "invalid") this.stale = true;
387
529
  return result;
388
530
  }
389
- /** Validation-scoped invalidation: keep the credential, drop cached validity. */
531
+ /** Validation-scoped invalidation: keep the credential, drop cached validity.
532
+ *
533
+ * In host mode the host cache *is* the cached validity — the validator only
534
+ * confirmed what the host already said — so the next call must re-fetch:
535
+ * otherwise a 401-driven `auth.invalidate()` would keep stale=true latched
536
+ * for the full `hostSessionTtlSeconds` (default 300 s), and an HQ-Edge
537
+ * credential replace in between would be invisible until the cache expired.
538
+ */
390
539
  markStale() {
391
540
  this.stale = true;
392
541
  this.validator.invalidate();
542
+ if (this.host.enabled) this.host.clear();
393
543
  }
394
544
  setCredentials(info) {
395
545
  this.current = info;
@@ -420,7 +570,8 @@ function toUserInfo(host) {
420
570
  return {
421
571
  id: host.id,
422
572
  token: host.token,
423
- ...host.nickname !== void 0 ? { nickname: host.nickname } : {}
573
+ ...host.nickname !== void 0 ? { nickname: host.nickname } : {},
574
+ ...host.expiresAt !== void 0 ? { expiresAt: host.expiresAt } : {}
424
575
  };
425
576
  }
426
577
  //#endregion
@@ -505,6 +656,7 @@ function createAuthHandler(service) {
505
656
  }
506
657
  //#endregion
507
658
  //#region src/index.ts
659
+ const log = getLogger("dsh-auth");
508
660
  const name = "@huaqiu/dsh-auth";
509
661
  const inject = ["webServer"];
510
662
  /**
@@ -516,6 +668,11 @@ const inject = ["webServer"];
516
668
  * without a supervisor (spec §6.4).
517
669
  */
518
670
  function apply(ctx, config) {
671
+ log.info("applying dsh-auth node half", {
672
+ hasConfig: config != null,
673
+ configKeys: config != null ? Object.keys(config) : [],
674
+ hqEdgeBaseUrl: config?.hqEdgeBaseUrl ?? null
675
+ });
519
676
  const service = new InMemoryHuaqiuAuthService(config);
520
677
  ctx.effect(() => ctx.provide("huaqiuAuth", service));
521
678
  ctx.effect(() => ctx.webServer.register({
@@ -523,6 +680,10 @@ function apply(ctx, config) {
523
680
  path: AUTH_ROUTE_PREFIX,
524
681
  handler: createAuthHandler(service)
525
682
  }));
683
+ log.info("dsh-auth node half ready", {
684
+ routes: AUTH_ROUTE_PREFIX,
685
+ hostMode: service.hostMode
686
+ });
526
687
  }
527
688
  //#endregion
528
689
  export { AUTH_ROUTE_PREFIX, InMemoryHuaqiuAuthService, apply, createAuthHandler, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-auth",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -31,7 +31,8 @@
31
31
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.0"
32
32
  },
33
33
  "dependencies": {
34
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.0"
34
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.0",
35
+ "@huaqiu/dsh-plugin-log": "0.3.11"
35
36
  },
36
37
  "files": [
37
38
  "lib",
package/src/host.ts CHANGED
@@ -20,6 +20,18 @@
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
@@ -90,11 +102,37 @@ export interface ResolvedHostUser {
90
102
  id: string
91
103
  token: string
92
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
93
124
  }
94
125
 
95
126
  /**
96
- * Fetches and caches the host (HQ Edge) session. The cache is memory-only with a
97
- * TTL; the loopback GET is cheap and the token is static, so we never persist it.
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).
98
136
  */
99
137
  export class HostSessionResolver {
100
138
  private cache: HostSession | null = null
@@ -117,21 +155,47 @@ export class HostSessionResolver {
117
155
  if (this.cache !== null && now - this.cache.fetchedAt < this.ttlMs) {
118
156
  return this.cache.info
119
157
  }
158
+ const url = `${this.baseUrl}${this.path}`
159
+ let info: ResolvedHostUser | null = null
160
+ let status: number | null = null
120
161
  try {
121
- const res = await this.doFetch(`${this.baseUrl}${this.path}`, {
162
+ const res = await this.doFetch(url, {
122
163
  method: 'GET',
123
164
  headers: { accept: 'application/json' },
124
165
  })
125
- if (!res.ok) return this.cache?.info ?? null
126
- const data = await res.json() as Record<string, unknown>
127
- const info = normalizeHostUser(data)
128
- if (!info) return this.cache?.info ?? null
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) {
129
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
+ })
130
187
  return info
131
- } catch {
132
- // Network error: fall back to a previously cached value if we have one.
133
- return this.cache?.info ?? null
134
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
135
199
  }
136
200
 
137
201
  /** Drop the cached value so the next `resolve()` re-fetches (reactive invalidation). */
@@ -146,7 +210,15 @@ function asId(raw: unknown): string | null {
146
210
  return null
147
211
  }
148
212
 
149
- /** Parse the host route payload into a credential, tolerating key-name drift. */
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
+ */
150
222
  export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUser | null {
151
223
  const token = typeof data.token === 'string' && data.token.length > 0
152
224
  ? data.token
@@ -158,5 +230,19 @@ export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUs
158
230
  const nickname = typeof data.nickname === 'string' && data.nickname.length > 0
159
231
  ? data.nickname
160
232
  : undefined
161
- return { id, token, ...(nickname ? { nickname } : {}) }
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
+ }
162
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
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,
@@ -156,6 +157,13 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
156
157
  * short-circuit to false and forces a fresh remote validation next time.
157
158
  */
158
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
+ }
159
167
 
160
168
  constructor(
161
169
  config?: Partial<HuaqiuAuthConfig> | null,
@@ -176,6 +184,11 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
176
184
  fetchImpl: this.doFetch,
177
185
  })
178
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
+ })
179
192
  }
180
193
 
181
194
  /** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
@@ -183,6 +196,15 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
183
196
 
184
197
  readonly auth: HuaqiuAuthApi = {
185
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
186
208
  if (this.stale) return false
187
209
  return (await this.validateInternal()).status === 'valid'
188
210
  },
@@ -224,24 +246,78 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
224
246
  * standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
225
247
  * would let the DSH plugins keep calling the backend while hq-edge itself has
226
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.
227
256
  */
228
257
  private async resolve(): Promise<HuaqiuUserInfo | null> {
229
258
  if (this.host.enabled) {
230
259
  const host = await this.host.resolve()
231
- if (host) return toUserInfo(host)
232
- return null
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)
233
269
  }
234
270
  if (this.current) return this.current
235
271
  return readPersisted()
236
272
  }
237
273
 
238
- /** Spec §7: resolve → local expiry → remote validation → update state. */
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
+ */
239
304
  private async validateInternal(): Promise<AuthValidationResult> {
240
305
  const info = await this.resolve()
241
306
  if (!info) {
242
- this.stale = true
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
243
311
  return { status: 'invalid', reason: 'invalid' }
244
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
+ }
245
321
  // Local expiry is an optimization; remote validation stays authoritative.
246
322
  if (this.validator.isLocallyExpired(info.expiresAt)) {
247
323
  this.validator.invalidate(info.token)
@@ -256,10 +332,18 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
256
332
  return result
257
333
  }
258
334
 
259
- /** 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
+ */
260
343
  private markStale(): void {
261
344
  this.stale = true
262
345
  this.validator.invalidate()
346
+ if (this.host.enabled) this.host.clear()
263
347
  }
264
348
 
265
349
  setCredentials(info: HuaqiuUserInfo): void {
@@ -300,5 +384,6 @@ function toUserInfo(host: ResolvedHostUser): HuaqiuUserInfo {
300
384
  id: host.id,
301
385
  token: host.token,
302
386
  ...(host.nickname !== undefined ? { nickname: host.nickname } : {}),
387
+ ...(host.expiresAt !== undefined ? { expiresAt: host.expiresAt } : {}),
303
388
  }
304
389
  }