@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/index.d.mts CHANGED
@@ -27,6 +27,8 @@ interface HuaqiuAuthConfig {
27
27
  hqEdgeBaseUrl?: string;
28
28
  /** Path on the host; default "/api/v1/auth/token". */
29
29
  hostAuthPath?: string;
30
+ /** Host endpoint that triggers the EDA login dialog; default "/api/v1/auth/login". */
31
+ hostLoginPath?: string;
30
32
  /** Seconds a host session is reused before re-fetching. Default 300. */
31
33
  hostSessionTtlSeconds?: number;
32
34
  /** Seconds remote token-validation results are cached. Default 60. */
@@ -132,12 +134,20 @@ declare class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
132
134
  private listeners;
133
135
  private readonly host;
134
136
  private readonly validator;
137
+ private readonly doFetch;
138
+ private readonly hostLoginPath;
139
+ /** Bound on the host login RPC (user must complete the EDA dialog). */
140
+ private readonly loginWaitMs;
135
141
  /**
136
142
  * True once the current credential has been rejected (API 401) or explicitly
137
143
  * invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
138
144
  * short-circuit to false and forces a fresh remote validation next time.
139
145
  */
140
146
  private stale;
147
+ /** Last host credential version seen; see `observeHostVersion`. */
148
+ private lastHostVersion;
149
+ private _log;
150
+ private get log();
141
151
  constructor(config?: Partial<HuaqiuAuthConfig> | null, opts?: {
142
152
  fetchImpl?: typeof fetch;
143
153
  });
@@ -153,11 +163,45 @@ declare class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
153
163
  * standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
154
164
  * would let the DSH plugins keep calling the backend while hq-edge itself has
155
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.
156
173
  */
157
174
  private resolve;
158
- /** 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
+ */
159
196
  private validateInternal;
160
- /** 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
+ */
161
205
  private markStale;
162
206
  setCredentials(info: HuaqiuUserInfo): void;
163
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`
@@ -9,6 +37,7 @@ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
9
37
  function resolveHostConfig(config, env = process.env) {
10
38
  const baseUrl = config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? "";
11
39
  const hostAuthPath = config?.hostAuthPath ?? env.HQ_EDGE_AUTH_PATH ?? "/api/v1/auth/token";
40
+ const hostLoginPath = config?.hostLoginPath ?? env.HQ_EDGE_LOGIN_PATH ?? "/api/v1/auth/login";
12
41
  const ttlRaw = config?.hostSessionTtlSeconds ?? env.HQ_EDGE_HOST_TTL_SECONDS;
13
42
  let ttl = 300;
14
43
  if (typeof ttlRaw === "number" && Number.isFinite(ttlRaw) && ttlRaw > 0) ttl = ttlRaw;
@@ -26,13 +55,21 @@ function resolveHostConfig(config, env = process.env) {
26
55
  return {
27
56
  hqEdgeBaseUrl: baseUrl,
28
57
  hostAuthPath,
58
+ hostLoginPath,
29
59
  hostSessionTtlSeconds: ttl,
30
60
  validationTtlSeconds
31
61
  };
32
62
  }
33
63
  /**
34
- * Fetches and caches the host (HQ Edge) session. The cache is memory-only with a
35
- * 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).
36
73
  */
37
74
  var HostSessionResolver = class {
38
75
  baseUrl;
@@ -54,22 +91,45 @@ var HostSessionResolver = class {
54
91
  if (!this.enabled) return null;
55
92
  const now = Date.now();
56
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;
57
97
  try {
58
- const res = await this.doFetch(`${this.baseUrl}${this.path}`, {
98
+ const res = await this.doFetch(url, {
59
99
  method: "GET",
60
100
  headers: { accept: "application/json" }
61
101
  });
62
- if (!res.ok) return this.cache?.info ?? null;
63
- const info = normalizeHostUser(await res.json());
64
- 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) {
65
112
  this.cache = {
66
113
  info,
67
114
  fetchedAt: now
68
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
+ });
69
123
  return info;
70
- } catch {
71
- return this.cache?.info ?? null;
72
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;
73
133
  }
74
134
  /** Drop the cached value so the next `resolve()` re-fetches (reactive invalidation). */
75
135
  clear() {
@@ -81,16 +141,30 @@ function asId(raw) {
81
141
  if (typeof raw === "number" && Number.isFinite(raw)) return String(raw);
82
142
  return null;
83
143
  }
84
- /** 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
+ */
85
153
  function normalizeHostUser(data) {
86
154
  const token = typeof data.token === "string" && data.token.length > 0 ? data.token : null;
87
155
  const id = asId(data.userId) ?? asId(data.id) ?? asId(data.user_id);
88
156
  if (!token || !id) return null;
89
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;
90
161
  return {
91
162
  id,
92
163
  token,
93
- ...nickname ? { nickname } : {}
164
+ authenticated,
165
+ ...nickname ? { nickname } : {},
166
+ ...version !== void 0 ? { version } : {},
167
+ ...expiresAt !== void 0 ? { expiresAt } : {}
94
168
  };
95
169
  }
96
170
  /**
@@ -293,31 +367,63 @@ var InMemoryHuaqiuAuthService = class {
293
367
  listeners = /* @__PURE__ */ new Set();
294
368
  host;
295
369
  validator;
370
+ doFetch;
371
+ hostLoginPath;
372
+ /** Bound on the host login RPC (user must complete the EDA dialog). */
373
+ loginWaitMs;
296
374
  /**
297
375
  * True once the current credential has been rejected (API 401) or explicitly
298
376
  * invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
299
377
  * short-circuit to false and forces a fresh remote validation next time.
300
378
  */
301
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
+ }
302
387
  constructor(config, opts) {
303
388
  const resolved = resolveHostConfig(config);
304
- this.host = new HostSessionResolver(resolved.hqEdgeBaseUrl ?? "", resolved.hostAuthPath ?? "/api/v1/auth/token", (resolved.hostSessionTtlSeconds ?? 300) * 1e3, opts?.fetchImpl);
389
+ this.doFetch = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis);
390
+ this.host = new HostSessionResolver(resolved.hqEdgeBaseUrl ?? "", resolved.hostAuthPath ?? "/api/v1/auth/token", (resolved.hostSessionTtlSeconds ?? 300) * 1e3, this.doFetch);
391
+ this.hostLoginPath = resolved.hostLoginPath ?? "/api/v1/auth/login";
392
+ this.loginWaitMs = 3e5;
305
393
  this.validator = new TokenValidator({
306
394
  ttlMs: (resolved.validationTtlSeconds ?? 60) * 1e3,
307
- fetchImpl: opts?.fetchImpl
395
+ fetchImpl: this.doFetch
308
396
  });
309
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
+ });
310
403
  }
311
404
  /** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
312
405
  hostMode;
313
406
  auth = {
314
407
  isAuthenticated: async () => {
408
+ if (await this.resolve() === null) return false;
315
409
  if (this.stale) return false;
316
410
  return (await this.validateInternal()).status === "valid";
317
411
  },
318
412
  getAccessToken: async () => (await this.resolve())?.token ?? null,
319
413
  getUserInfo: async () => this.resolve(),
320
- login: async () => {},
414
+ login: async () => {
415
+ if (!this.host.enabled) return;
416
+ const res = await this.doFetch(`${this.host.baseUrl}${this.hostLoginPath}`, {
417
+ method: "POST",
418
+ headers: { accept: "application/json" },
419
+ signal: AbortSignal.timeout(this.loginWaitMs)
420
+ });
421
+ if (!res.ok) throw new Error(`host login not completed: HTTP ${res.status}`);
422
+ this.host.clear();
423
+ this.stale = false;
424
+ this.validator.invalidate();
425
+ this.emit();
426
+ },
321
427
  logout: async () => this.invalidate(),
322
428
  validate: () => this.validateInternal(),
323
429
  invalidate: () => this.markStale(),
@@ -332,26 +438,83 @@ var InMemoryHuaqiuAuthService = class {
332
438
  * standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
333
439
  * would let the DSH plugins keep calling the backend while hq-edge itself has
334
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.
335
448
  */
336
449
  async resolve() {
337
450
  if (this.host.enabled) {
338
451
  const host = await this.host.resolve();
339
- if (host) return toUserInfo(host);
340
- 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);
341
461
  }
342
462
  if (this.current) return this.current;
343
463
  return readPersisted();
344
464
  }
345
- /** 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
+ */
346
494
  async validateInternal() {
347
495
  const info = await this.resolve();
348
496
  if (!info) {
349
- this.stale = true;
497
+ if (!this.host.enabled) this.stale = true;
350
498
  return {
351
499
  status: "invalid",
352
500
  reason: "invalid"
353
501
  };
354
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
+ }
355
518
  if (this.validator.isLocallyExpired(info.expiresAt)) {
356
519
  this.validator.invalidate(info.token);
357
520
  this.stale = true;
@@ -365,10 +528,18 @@ var InMemoryHuaqiuAuthService = class {
365
528
  else if (result.status === "invalid") this.stale = true;
366
529
  return result;
367
530
  }
368
- /** 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
+ */
369
539
  markStale() {
370
540
  this.stale = true;
371
541
  this.validator.invalidate();
542
+ if (this.host.enabled) this.host.clear();
372
543
  }
373
544
  setCredentials(info) {
374
545
  this.current = info;
@@ -399,7 +570,8 @@ function toUserInfo(host) {
399
570
  return {
400
571
  id: host.id,
401
572
  token: host.token,
402
- ...host.nickname !== void 0 ? { nickname: host.nickname } : {}
573
+ ...host.nickname !== void 0 ? { nickname: host.nickname } : {},
574
+ ...host.expiresAt !== void 0 ? { expiresAt: host.expiresAt } : {}
403
575
  };
404
576
  }
405
577
  //#endregion
@@ -463,6 +635,16 @@ function createAuthHandler(service) {
463
635
  sendJson(res, 200, { hostMode: service.hostMode });
464
636
  return;
465
637
  }
638
+ if (req.method === "POST" && pathname === `/api/v1/huaqiu/auth/login`) {
639
+ await service.auth.login();
640
+ const user = await service.auth.getUserInfo();
641
+ sendJson(res, 200, {
642
+ ok: true,
643
+ authenticated: user !== null,
644
+ user
645
+ });
646
+ return;
647
+ }
466
648
  sendJson(res, 404, { error: "not found" });
467
649
  } catch (err) {
468
650
  sendJson(res, 500, {
@@ -474,6 +656,7 @@ function createAuthHandler(service) {
474
656
  }
475
657
  //#endregion
476
658
  //#region src/index.ts
659
+ const log = getLogger("dsh-auth");
477
660
  const name = "@huaqiu/dsh-auth";
478
661
  const inject = ["webServer"];
479
662
  /**
@@ -485,6 +668,11 @@ const inject = ["webServer"];
485
668
  * without a supervisor (spec §6.4).
486
669
  */
487
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
+ });
488
676
  const service = new InMemoryHuaqiuAuthService(config);
489
677
  ctx.effect(() => ctx.provide("huaqiuAuth", service));
490
678
  ctx.effect(() => ctx.webServer.register({
@@ -492,6 +680,10 @@ function apply(ctx, config) {
492
680
  path: AUTH_ROUTE_PREFIX,
493
681
  handler: createAuthHandler(service)
494
682
  }));
683
+ log.info("dsh-auth node half ready", {
684
+ routes: AUTH_ROUTE_PREFIX,
685
+ hostMode: service.hostMode
686
+ });
495
687
  }
496
688
  //#endregion
497
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.9",
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",
@@ -131,8 +131,18 @@ export function createAuthClient(deps: AuthClientDeps): AuthClient {
131
131
  return hostSession ?? storage.get()
132
132
  },
133
133
  login: async (options?: LoginOptions): Promise<void> => {
134
- // Host mode: hq-edge owns the session — never open the auth iframe.
135
- if (hostMode) return
134
+ // Host mode: hq-edge owns the session — ask the host (EDA) to open its
135
+ // own login dialog instead of the auth.eda.cn iframe. The node route
136
+ // blocks until the dialog completes; then re-resolve and emit (only on
137
+ // a real change, so a redundant trigger never re-renders every card).
138
+ if (hostMode) {
139
+ const prevToken = hostSession?.token
140
+ await transport.triggerLogin()
141
+ hostSessionLoaded = false
142
+ await resolveHost()
143
+ if (hostSession?.token !== prevToken) emit(hostSession ?? storage.get())
144
+ return
145
+ }
136
146
  openIframe(options ?? {})
137
147
  },
138
148
  logout: async (): Promise<void> => {
@@ -22,6 +22,14 @@ export interface AuthTransport {
22
22
  * credential without ever opening the auth.eda.cn iframe.
23
23
  */
24
24
  fetchSession(): Promise<{ authenticated: boolean; user: HostSessionUser | null }>
25
+ /**
26
+ * Ask the node half to trigger the EDA login dialog through the host.
27
+ * Standalone (no host) must NOT call this — login is the browser iframe
28
+ * there. In host mode the node blocks until the dialog is completed (the
29
+ * host route waits for AuthStateChanged), then the caller re-fetches the
30
+ * session to pick up the fresh credential.
31
+ */
32
+ triggerLogin(): Promise<void>
25
33
  }
26
34
 
27
35
  /** Minimal session user shape returned by the node `/session` route. */
@@ -80,5 +88,12 @@ export function createWebServerAuthTransport(
80
88
  const user = body.user && typeof body.user === 'object' ? body.user as HostSessionUser : null
81
89
  return { authenticated: body.authenticated === true, user }
82
90
  },
91
+ async triggerLogin() {
92
+ const res = await doFetch(`${base}/login`, {
93
+ method: 'POST',
94
+ headers: { accept: 'application/json' },
95
+ })
96
+ if (!res.ok) throw new Error(`auth login trigger failed: HTTP ${res.status}`)
97
+ },
83
98
  }
84
99
  }