@huaqiu/dsh-auth 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -16,10 +16,18 @@ function resolveHostConfig(config, env = process.env) {
16
16
  const parsed = Number.parseInt(ttlRaw, 10);
17
17
  if (Number.isFinite(parsed) && parsed > 0) ttl = parsed;
18
18
  }
19
+ const vTtlRaw = config?.validationTtlSeconds ?? env.HQ_EDGE_VALIDATION_TTL_SECONDS;
20
+ let validationTtlSeconds = 60;
21
+ if (typeof vTtlRaw === "number" && Number.isFinite(vTtlRaw) && vTtlRaw > 0) validationTtlSeconds = vTtlRaw;
22
+ else if (typeof vTtlRaw === "string" && vTtlRaw.trim().length > 0) {
23
+ const parsed = Number.parseInt(vTtlRaw, 10);
24
+ if (Number.isFinite(parsed) && parsed > 0) validationTtlSeconds = parsed;
25
+ }
19
26
  return {
20
27
  hqEdgeBaseUrl: baseUrl,
21
28
  hostAuthPath,
22
- hostSessionTtlSeconds: ttl
29
+ hostSessionTtlSeconds: ttl,
30
+ validationTtlSeconds
23
31
  };
24
32
  }
25
33
  /**
@@ -85,6 +93,135 @@ function normalizeHostUser(data) {
85
93
  ...nickname ? { nickname } : {}
86
94
  };
87
95
  }
96
+ /**
97
+ * Classifies the HTTP status of the validation request.
98
+ *
99
+ * 401 → unauthorized; 403 → forbidden; any other non-ok status (incl. 5xx) →
100
+ * unavailable. A 2xx body of `{ result: false }` → unauthorized (the endpoint
101
+ * does not distinguish expired vs revoked, so we use the generic
102
+ * `unauthorized` reason; local expiry is the only source of `expired`).
103
+ */
104
+ function classifyStatus(status) {
105
+ if (status === 401) return {
106
+ status: "invalid",
107
+ reason: "unauthorized"
108
+ };
109
+ if (status === 403) return {
110
+ status: "invalid",
111
+ reason: "forbidden"
112
+ };
113
+ return {
114
+ status: "unavailable",
115
+ error: /* @__PURE__ */ new Error(`token validation HTTP ${status}`)
116
+ };
117
+ }
118
+ var TokenValidator = class {
119
+ ttlMs;
120
+ validateUrl;
121
+ fetchImpl;
122
+ now;
123
+ /** token → { result, at }; in-memory only, never persisted (spec §8). */
124
+ cache = /* @__PURE__ */ new Map();
125
+ /** token → in-flight promise; coalesces concurrent validate() calls (§16). */
126
+ inFlight = /* @__PURE__ */ new Map();
127
+ constructor(options = {}) {
128
+ this.ttlMs = options.ttlMs ?? 6e4;
129
+ this.validateUrl = options.validateUrl ?? "https://www.eda.cn/api/token/validate";
130
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
131
+ this.now = options.now ?? (() => Date.now());
132
+ }
133
+ /**
134
+ * Cheap local expiry check — an optimization, not authoritative validation.
135
+ * Unknown expiry ⇒ not assumed invalid (spec §6).
136
+ */
137
+ isLocallyExpired(expiresAt) {
138
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return false;
139
+ return expiresAt * 1e3 <= this.now();
140
+ }
141
+ /**
142
+ * Validate a token. Cached within TTL; a single in-flight request is shared
143
+ * by concurrent callers. Never treats a network failure as "invalid".
144
+ */
145
+ async validate(token, session) {
146
+ if (!token) return {
147
+ status: "invalid",
148
+ reason: "invalid"
149
+ };
150
+ if (this.isLocallyExpired(session?.expiresAt)) {
151
+ this.cache.delete(token);
152
+ return {
153
+ status: "invalid",
154
+ reason: "expired"
155
+ };
156
+ }
157
+ const cached = this.cache.get(token);
158
+ if (cached && this.now() - cached.at < this.ttlMs) return cached.result;
159
+ const inFlight = this.inFlight.get(token);
160
+ if (inFlight) return inFlight;
161
+ const promise = this.validateRemotely(token);
162
+ this.inFlight.set(token, promise);
163
+ try {
164
+ const result = await promise;
165
+ this.cache.set(token, {
166
+ result,
167
+ at: this.now()
168
+ });
169
+ return result;
170
+ } finally {
171
+ this.inFlight.delete(token);
172
+ }
173
+ }
174
+ /** Drop cached/in-flight validation for a token (or all when omitted). */
175
+ invalidate(token) {
176
+ if (token === void 0) {
177
+ this.cache.clear();
178
+ this.inFlight.clear();
179
+ return;
180
+ }
181
+ this.cache.delete(token);
182
+ this.inFlight.delete(token);
183
+ }
184
+ /** Resolve a currently-cached result (no remote call), or null. */
185
+ peek(token) {
186
+ const cached = this.cache.get(token);
187
+ if (!cached) return null;
188
+ if (this.now() - cached.at >= this.ttlMs) {
189
+ this.cache.delete(token);
190
+ return null;
191
+ }
192
+ return cached.result;
193
+ }
194
+ async validateRemotely(token) {
195
+ const url = `${this.validateUrl}?token=${encodeURIComponent(token)}`;
196
+ let res;
197
+ try {
198
+ res = await this.fetchImpl(url, {
199
+ method: "GET",
200
+ headers: { accept: "application/json" }
201
+ });
202
+ } catch (err) {
203
+ return {
204
+ status: "unavailable",
205
+ error: err
206
+ };
207
+ }
208
+ if (!res.ok) return classifyStatus(res.status);
209
+ let body;
210
+ try {
211
+ body = await res.json();
212
+ } catch (err) {
213
+ return {
214
+ status: "unavailable",
215
+ error: /* @__PURE__ */ new Error(`token validation: unparseable response (${String(err)})`)
216
+ };
217
+ }
218
+ if ((typeof body === "object" && body !== null ? body.result : void 0) === true) return { status: "valid" };
219
+ return {
220
+ status: "invalid",
221
+ reason: "unauthorized"
222
+ };
223
+ }
224
+ };
88
225
  //#endregion
89
226
  //#region src/service.ts
90
227
  /**
@@ -124,10 +261,12 @@ function readPersisted() {
124
261
  const token = typeof raw.token === "string" && raw.token.length > 0 ? raw.token : null;
125
262
  if (!id || !token) return null;
126
263
  const nickname = typeof raw.nickname === "string" && raw.nickname.length > 0 ? raw.nickname : void 0;
264
+ const expiresAt = typeof raw.expiresAt === "number" && Number.isFinite(raw.expiresAt) ? raw.expiresAt : void 0;
127
265
  return {
128
266
  id,
129
267
  token,
130
- ...nickname ? { nickname } : {}
268
+ ...nickname ? { nickname } : {},
269
+ ...expiresAt !== void 0 ? { expiresAt } : {}
131
270
  };
132
271
  } catch {
133
272
  return null;
@@ -153,22 +292,42 @@ var InMemoryHuaqiuAuthService = class {
153
292
  current = null;
154
293
  listeners = /* @__PURE__ */ new Set();
155
294
  host;
295
+ validator;
296
+ /**
297
+ * True once the current credential has been rejected (API 401) or explicitly
298
+ * invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
299
+ * short-circuit to false and forces a fresh remote validation next time.
300
+ */
301
+ stale = false;
156
302
  constructor(config, opts) {
157
303
  const resolved = resolveHostConfig(config);
158
304
  this.host = new HostSessionResolver(resolved.hqEdgeBaseUrl ?? "", resolved.hostAuthPath ?? "/api/v1/auth/token", (resolved.hostSessionTtlSeconds ?? 300) * 1e3, opts?.fetchImpl);
305
+ this.validator = new TokenValidator({
306
+ ttlMs: (resolved.validationTtlSeconds ?? 60) * 1e3,
307
+ fetchImpl: opts?.fetchImpl
308
+ });
159
309
  this.hostMode = this.host.enabled;
160
310
  }
161
311
  /** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
162
312
  hostMode;
163
313
  auth = {
164
- isAuthenticated: () => this.host.enabled || this.current !== null || readPersisted() !== null,
314
+ isAuthenticated: async () => {
315
+ if (this.stale) return false;
316
+ return (await this.validateInternal()).status === "valid";
317
+ },
165
318
  getAccessToken: async () => (await this.resolve())?.token ?? null,
166
319
  getUserInfo: async () => this.resolve(),
167
320
  login: async () => {},
168
321
  logout: async () => this.invalidate(),
322
+ validate: () => this.validateInternal(),
323
+ invalidate: () => this.markStale(),
169
324
  onAuthStateChanged: (listener) => this.on(listener)
170
325
  };
171
- /** Spec §6.2 resolution order: host → pushed → persisted → null. */
326
+ /**
327
+ * Spec §6.2 resolution order: host → pushed → persisted → null.
328
+ * Returns the credential regardless of validation state (recovery keeps the
329
+ * value; `validate()`/`isAuthenticated()` decide whether it is usable).
330
+ */
172
331
  async resolve() {
173
332
  if (this.host.enabled) {
174
333
  const host = await this.host.resolve();
@@ -177,8 +336,38 @@ var InMemoryHuaqiuAuthService = class {
177
336
  if (this.current) return this.current;
178
337
  return readPersisted();
179
338
  }
339
+ /** Spec §7: resolve → local expiry → remote validation → update state. */
340
+ async validateInternal() {
341
+ const info = await this.resolve();
342
+ if (!info) {
343
+ this.stale = true;
344
+ return {
345
+ status: "invalid",
346
+ reason: "invalid"
347
+ };
348
+ }
349
+ if (this.validator.isLocallyExpired(info.expiresAt)) {
350
+ this.validator.invalidate(info.token);
351
+ this.stale = true;
352
+ return {
353
+ status: "invalid",
354
+ reason: "expired"
355
+ };
356
+ }
357
+ const result = await this.validator.validate(info.token, { expiresAt: info.expiresAt });
358
+ if (result.status === "valid") this.stale = false;
359
+ else if (result.status === "invalid") this.stale = true;
360
+ return result;
361
+ }
362
+ /** Validation-scoped invalidation: keep the credential, drop cached validity. */
363
+ markStale() {
364
+ this.stale = true;
365
+ this.validator.invalidate();
366
+ }
180
367
  setCredentials(info) {
181
368
  this.current = info;
369
+ this.stale = false;
370
+ this.validator.invalidate();
182
371
  writePersisted(info);
183
372
  this.emit();
184
373
  }
@@ -186,6 +375,8 @@ var InMemoryHuaqiuAuthService = class {
186
375
  const was = this.host.enabled || this.current !== null || readPersisted() !== null;
187
376
  this.current = null;
188
377
  this.host.clear();
378
+ this.stale = true;
379
+ this.validator.invalidate();
189
380
  deletePersisted();
190
381
  if (was) this.emit();
191
382
  }
@@ -225,10 +416,12 @@ function normalizeUserInfo(data) {
225
416
  const id = typeof data.userId === "string" && data.userId.length > 0 ? data.userId : typeof data.userId === "number" && Number.isFinite(data.userId) ? String(data.userId) : typeof data.id === "string" && data.id.length > 0 ? data.id : typeof data.id === "number" && Number.isFinite(data.id) ? String(data.id) : null;
226
417
  if (!token || !id) return null;
227
418
  const nickname = typeof data.nickname === "string" && data.nickname.length > 0 ? data.nickname : void 0;
419
+ const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt : void 0;
228
420
  return {
229
421
  id,
230
422
  token,
231
- ...nickname ? { nickname } : {}
423
+ ...nickname ? { nickname } : {},
424
+ ...expiresAt !== void 0 ? { expiresAt } : {}
232
425
  };
233
426
  }
234
427
  function createAuthHandler(service) {
@@ -255,7 +448,7 @@ function createAuthHandler(service) {
255
448
  if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/session`) {
256
449
  const user = await service.auth.getUserInfo();
257
450
  sendJson(res, 200, {
258
- authenticated: service.auth.isAuthenticated(),
451
+ authenticated: await service.auth.isAuthenticated(),
259
452
  user
260
453
  });
261
454
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-auth",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -30,6 +30,7 @@ export function createWebServerAuthTransport(
30
30
  token: info.token,
31
31
  userId: info.id,
32
32
  ...(info.nickname !== undefined ? { nickname: info.nickname } : {}),
33
+ ...(info.expiresAt !== undefined ? { expiresAt: info.expiresAt } : {}),
33
34
  }),
34
35
  })
35
36
  if (!res.ok) throw new Error(`auth push failed: HTTP ${res.status}`)
package/src/host.ts CHANGED
@@ -27,10 +27,13 @@ export interface HuaqiuAuthConfig {
27
27
  hostAuthPath?: string
28
28
  /** Seconds a host session is reused before re-fetching. Default 300. */
29
29
  hostSessionTtlSeconds?: number
30
+ /** Seconds remote token-validation results are cached. Default 60. */
31
+ validationTtlSeconds?: number
30
32
  }
31
33
 
32
34
  export const DEFAULT_HOST_AUTH_PATH = '/api/v1/auth/token'
33
35
  export const DEFAULT_HOST_TTL_SECONDS = 300
36
+ export const DEFAULT_VALIDATION_TTL_SECONDS = 60
34
37
 
35
38
  /**
36
39
  * Resolve the effective config: overlay `config` (highest) > env (safety net
@@ -55,7 +58,20 @@ export function resolveHostConfig(
55
58
  const parsed = Number.parseInt(ttlRaw, 10)
56
59
  if (Number.isFinite(parsed) && parsed > 0) ttl = parsed
57
60
  }
58
- return { hqEdgeBaseUrl: baseUrl, hostAuthPath, hostSessionTtlSeconds: ttl }
61
+ const vTtlRaw = config?.validationTtlSeconds ?? env.HQ_EDGE_VALIDATION_TTL_SECONDS
62
+ let validationTtlSeconds = DEFAULT_VALIDATION_TTL_SECONDS
63
+ if (typeof vTtlRaw === 'number' && Number.isFinite(vTtlRaw) && vTtlRaw > 0) {
64
+ validationTtlSeconds = vTtlRaw
65
+ } else if (typeof vTtlRaw === 'string' && vTtlRaw.trim().length > 0) {
66
+ const parsed = Number.parseInt(vTtlRaw, 10)
67
+ if (Number.isFinite(parsed) && parsed > 0) validationTtlSeconds = parsed
68
+ }
69
+ return {
70
+ hqEdgeBaseUrl: baseUrl,
71
+ hostAuthPath,
72
+ hostSessionTtlSeconds: ttl,
73
+ validationTtlSeconds,
74
+ }
59
75
  }
60
76
 
61
77
  export interface HostSession {
package/src/routes.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * HTTP adapter: receives the browser-pushed credentials and serves the node
3
3
  * auth state (probe / boot restore). Same-origin through `ctx.webServer`:
4
4
  *
5
- * POST /api/v1/huaqiu/auth/session body { token, userInfo? } → cache set
5
+ * POST /api/v1/huaqiu/auth/session body { token, userId, nickname?, expiresAt? } → cache set
6
6
  * POST /api/v1/huaqiu/auth/logout → cache cleared
7
7
  * GET /api/v1/huaqiu/auth/session → { authenticated, user }
8
8
  * GET /api/v1/huaqiu/auth/config → { hostMode }
@@ -45,7 +45,15 @@ function normalizeUserInfo(data: Record<string, unknown>): HuaqiuUserInfo | null
45
45
  : null
46
46
  if (!token || !id) return null
47
47
  const nickname = typeof data.nickname === 'string' && data.nickname.length > 0 ? data.nickname : undefined
48
- return { id, token, ...(nickname ? { nickname } : {}) }
48
+ const expiresAt = typeof data.expiresAt === 'number' && Number.isFinite(data.expiresAt)
49
+ ? data.expiresAt
50
+ : undefined
51
+ return {
52
+ id,
53
+ token,
54
+ ...(nickname ? { nickname } : {}),
55
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
56
+ }
49
57
  }
50
58
 
51
59
  export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
@@ -75,7 +83,7 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
75
83
 
76
84
  if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/session`) {
77
85
  const user = await service.auth.getUserInfo()
78
- const authenticated = service.auth.isAuthenticated()
86
+ const authenticated = await service.auth.isAuthenticated()
79
87
  sendJson(res, 200, { authenticated, user })
80
88
  return
81
89
  }
package/src/service.ts CHANGED
@@ -29,26 +29,52 @@ import {
29
29
  type HuaqiuAuthConfig,
30
30
  type ResolvedHostUser,
31
31
  } from './host.js'
32
+ import { TokenValidator, type AuthValidationResult } from './validation.js'
32
33
 
33
34
  export interface HuaqiuUserInfo {
34
35
  id: string
35
36
  token: string
36
37
  nickname?: string
38
+ /** Unix seconds; known for browser-pushed sessions (auth.eda.cn window). */
39
+ expiresAt?: number
37
40
  }
38
41
 
39
42
  export interface HuaqiuAuthApi {
40
- isAuthenticated(): boolean
43
+ /**
44
+ * Authoritative async check: a credential exists AND is known to be valid
45
+ * (local expiry + cached remote validation). Never a mere token-presence
46
+ * check — a host token supplied by hq-edge is not assumed valid just because
47
+ * it exists (spec §10). Short-circuits to `false` after `invalidate()` until
48
+ * re-validated or a fresh credential arrives.
49
+ */
50
+ isAuthenticated(): Promise<boolean>
41
51
  getAccessToken(): Promise<string | null>
42
52
  getUserInfo(): Promise<HuaqiuUserInfo | null>
43
53
  /** Node-side no-op: login always happens in the browser. */
44
54
  login(): Promise<void>
45
55
  logout(): Promise<void>
56
+ /**
57
+ * Single authoritative validation path (spec §7). Works identically for
58
+ * standalone and host credentials; never depends on hq-edge.
59
+ */
60
+ validate(): Promise<AuthValidationResult>
61
+ /**
62
+ * Mark the current credential's validation state stale without deleting the
63
+ * credential (kept for recovery). Next validation cannot reuse a previous
64
+ * "valid" result (spec §9/§11). Call this when an API request returns 401.
65
+ */
66
+ invalidate(): void
46
67
  onAuthStateChanged(listener: (info: HuaqiuUserInfo | null) => void): () => void
47
68
  }
48
69
 
49
70
  export interface HuaqiuAuthService {
50
71
  auth: HuaqiuAuthApi
51
- /** Node-only setters used by the webServer route handlers. */
72
+ /**
73
+ * Node-only setters used by the webServer route handlers.
74
+ * NOTE: `service.invalidate()` is the FULL reset (logout: drops the pushed
75
+ * credential, persisted file and host cache). The capability-level
76
+ * `auth.invalidate()` is validation-scoped and keeps the credential.
77
+ */
52
78
  setCredentials(info: HuaqiuUserInfo): void
53
79
  invalidate(): void
54
80
  /**
@@ -79,7 +105,15 @@ function readPersisted(): HuaqiuUserInfo | null {
79
105
  const nickname = typeof raw.nickname === 'string' && raw.nickname.length > 0
80
106
  ? raw.nickname
81
107
  : undefined
82
- return { id, token, ...(nickname ? { nickname } : {}) }
108
+ const expiresAt = typeof raw.expiresAt === 'number' && Number.isFinite(raw.expiresAt)
109
+ ? raw.expiresAt
110
+ : undefined
111
+ return {
112
+ id,
113
+ token,
114
+ ...(nickname ? { nickname } : {}),
115
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
116
+ }
83
117
  } catch {
84
118
  return null
85
119
  }
@@ -111,6 +145,13 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
111
145
  private current: HuaqiuUserInfo | null = null
112
146
  private listeners = new Set<(info: HuaqiuUserInfo | null) => void>()
113
147
  private readonly host: HostSessionResolver
148
+ private readonly validator: TokenValidator
149
+ /**
150
+ * True once the current credential has been rejected (API 401) or explicitly
151
+ * invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
152
+ * short-circuit to false and forces a fresh remote validation next time.
153
+ */
154
+ private stale = false
114
155
 
115
156
  constructor(
116
157
  config?: Partial<HuaqiuAuthConfig> | null,
@@ -123,6 +164,10 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
123
164
  (resolved.hostSessionTtlSeconds ?? 300) * 1000,
124
165
  opts?.fetchImpl,
125
166
  )
167
+ this.validator = new TokenValidator({
168
+ ttlMs: (resolved.validationTtlSeconds ?? 60) * 1000,
169
+ fetchImpl: opts?.fetchImpl,
170
+ })
126
171
  this.hostMode = this.host.enabled
127
172
  }
128
173
 
@@ -130,17 +175,26 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
130
175
  readonly hostMode: boolean
131
176
 
132
177
  readonly auth: HuaqiuAuthApi = {
133
- isAuthenticated: () => this.host.enabled || this.current !== null || readPersisted() !== null,
178
+ isAuthenticated: async () => {
179
+ if (this.stale) return false
180
+ return (await this.validateInternal()).status === 'valid'
181
+ },
134
182
  getAccessToken: async () => (await this.resolve())?.token ?? null,
135
183
  getUserInfo: async () => this.resolve(),
136
184
  login: async () => {
137
185
  /* login is a browser action */
138
186
  },
139
187
  logout: async () => this.invalidate(),
188
+ validate: () => this.validateInternal(),
189
+ invalidate: () => this.markStale(),
140
190
  onAuthStateChanged: (listener) => this.on(listener),
141
191
  }
142
192
 
143
- /** Spec §6.2 resolution order: host → pushed → persisted → null. */
193
+ /**
194
+ * Spec §6.2 resolution order: host → pushed → persisted → null.
195
+ * Returns the credential regardless of validation state (recovery keeps the
196
+ * value; `validate()`/`isAuthenticated()` decide whether it is usable).
197
+ */
144
198
  private async resolve(): Promise<HuaqiuUserInfo | null> {
145
199
  if (this.host.enabled) {
146
200
  const host = await this.host.resolve()
@@ -150,8 +204,37 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
150
204
  return readPersisted()
151
205
  }
152
206
 
207
+ /** Spec §7: resolve → local expiry → remote validation → update state. */
208
+ private async validateInternal(): Promise<AuthValidationResult> {
209
+ const info = await this.resolve()
210
+ if (!info) {
211
+ this.stale = true
212
+ return { status: 'invalid', reason: 'invalid' }
213
+ }
214
+ // Local expiry is an optimization; remote validation stays authoritative.
215
+ if (this.validator.isLocallyExpired(info.expiresAt)) {
216
+ this.validator.invalidate(info.token)
217
+ this.stale = true
218
+ return { status: 'invalid', reason: 'expired' }
219
+ }
220
+ const result = await this.validator.validate(info.token, { expiresAt: info.expiresAt })
221
+ if (result.status === 'valid') this.stale = false
222
+ else if (result.status === 'invalid') this.stale = true
223
+ // 'unavailable' leaves the stale flag untouched — a network blip never
224
+ // declares the credential invalid (spec §17).
225
+ return result
226
+ }
227
+
228
+ /** Validation-scoped invalidation: keep the credential, drop cached validity. */
229
+ private markStale(): void {
230
+ this.stale = true
231
+ this.validator.invalidate()
232
+ }
233
+
153
234
  setCredentials(info: HuaqiuUserInfo): void {
154
235
  this.current = info
236
+ this.stale = false
237
+ this.validator.invalidate()
155
238
  void writePersisted(info)
156
239
  this.emit()
157
240
  }
@@ -160,6 +243,8 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
160
243
  const was = this.host.enabled || this.current !== null || readPersisted() !== null
161
244
  this.current = null
162
245
  this.host.clear()
246
+ this.stale = true
247
+ this.validator.invalidate()
163
248
  deletePersisted()
164
249
  if (was) this.emit()
165
250
  }