@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 +40 -2
- package/lib/index.mjs +177 -16
- package/package.json +3 -2
- package/src/host.ts +98 -12
- package/src/index.ts +15 -0
- package/src/routes.ts +10 -0
- package/src/service.ts +90 -5
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
|
-
/**
|
|
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
|
|
37
|
-
* TTL; the loopback GET is cheap
|
|
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(
|
|
98
|
+
const res = await this.doFetch(url, {
|
|
61
99
|
method: "GET",
|
|
62
100
|
headers: { accept: "application/json" }
|
|
63
101
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
361
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
97
|
-
* 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).
|
|
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(
|
|
162
|
+
const res = await this.doFetch(url, {
|
|
122
163
|
method: 'GET',
|
|
123
164
|
headers: { accept: 'application/json' },
|
|
124
165
|
})
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|