@huaqiu/dsh-auth 0.1.1 → 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.d.mts CHANGED
@@ -28,28 +28,103 @@ interface HuaqiuAuthConfig {
28
28
  hostAuthPath?: string;
29
29
  /** Seconds a host session is reused before re-fetching. Default 300. */
30
30
  hostSessionTtlSeconds?: number;
31
+ /** Seconds remote token-validation results are cached. Default 60. */
32
+ validationTtlSeconds?: number;
31
33
  }
32
34
  //#endregion
35
+ //#region src/validation.d.ts
36
+ /**
37
+ * Token validation for `@huaqiu/dsh-auth`.
38
+ *
39
+ * Single authoritative validation path shared by standalone (auth.eda.cn) and
40
+ * HQ Edge host credentials. Uses the existing Huaqiu endpoint
41
+ *
42
+ * GET https://www.eda.cn/api/token/validate?token=<token>
43
+ * → { code, message, result: boolean }
44
+ *
45
+ * (the same endpoint consumed by `NextChat/app/auth/is_token_valid.ts`; probe:
46
+ * `curl "https://www.eda.cn/api/token/validate?token=__dummy__"` → 200
47
+ * `{"code":200000,"message":"success","result":false}`).
48
+ *
49
+ * Local expiry is a cheap pre-check only (never authoritative). Remote
50
+ * validation is authoritative, short-lived in-memory cached, and never
51
+ * persisted. Network / 5xx failures are reported as `unavailable` — they are
52
+ * NOT converted into "token invalid", so a transient network blip never forces
53
+ * the user to log in again.
54
+ */
55
+ /**
56
+ * Outcome of an authoritative token validation.
57
+ *
58
+ * - `valid` — the Huaqiu API accepted the token.
59
+ * - `invalid` — the token is definitively rejected/expired.
60
+ * - `unavailable` — validation could not be performed (network/5xx); the token
61
+ * is NOT declared invalid (spec §17/§18).
62
+ */
63
+ type AuthValidationResult = {
64
+ status: 'valid';
65
+ userId?: string;
66
+ expiresAt?: number;
67
+ } | {
68
+ status: 'invalid';
69
+ reason: 'expired' | 'unauthorized' | 'forbidden' | 'invalid';
70
+ } | {
71
+ status: 'unavailable';
72
+ error: Error;
73
+ };
74
+ //#endregion
33
75
  //#region src/service.d.ts
34
76
  interface HuaqiuUserInfo {
35
77
  id: string;
36
78
  token: string;
37
79
  nickname?: string;
80
+ /** Unix seconds; known for browser-pushed sessions (auth.eda.cn window). */
81
+ expiresAt?: number;
38
82
  }
39
83
  interface HuaqiuAuthApi {
40
- isAuthenticated(): boolean;
84
+ /**
85
+ * Authoritative async check: a credential exists AND is known to be valid
86
+ * (local expiry + cached remote validation). Never a mere token-presence
87
+ * check — a host token supplied by hq-edge is not assumed valid just because
88
+ * it exists (spec §10). Short-circuits to `false` after `invalidate()` until
89
+ * re-validated or a fresh credential arrives.
90
+ */
91
+ isAuthenticated(): Promise<boolean>;
41
92
  getAccessToken(): Promise<string | null>;
42
93
  getUserInfo(): Promise<HuaqiuUserInfo | null>;
43
94
  /** Node-side no-op: login always happens in the browser. */
44
95
  login(): Promise<void>;
45
96
  logout(): Promise<void>;
97
+ /**
98
+ * Single authoritative validation path (spec §7). Works identically for
99
+ * standalone and host credentials; never depends on hq-edge.
100
+ */
101
+ validate(): Promise<AuthValidationResult>;
102
+ /**
103
+ * Mark the current credential's validation state stale without deleting the
104
+ * credential (kept for recovery). Next validation cannot reuse a previous
105
+ * "valid" result (spec §9/§11). Call this when an API request returns 401.
106
+ */
107
+ invalidate(): void;
46
108
  onAuthStateChanged(listener: (info: HuaqiuUserInfo | null) => void): () => void;
47
109
  }
48
110
  interface HuaqiuAuthService {
49
111
  auth: HuaqiuAuthApi;
50
- /** Node-only setters used by the webServer route handlers. */
112
+ /**
113
+ * Node-only setters used by the webServer route handlers.
114
+ * NOTE: `service.invalidate()` is the FULL reset (logout: drops the pushed
115
+ * credential, persisted file and host cache). The capability-level
116
+ * `auth.invalidate()` is validation-scoped and keeps the credential.
117
+ */
51
118
  setCredentials(info: HuaqiuUserInfo): void;
52
119
  invalidate(): void;
120
+ /**
121
+ * True when running in HQ Edge host mode (a host base URL was configured —
122
+ * overlay `config.hqEdgeBaseUrl` or `HQ_EDGE_BASE_URL`). The browser half
123
+ * reads this over the webServer config route to decide whether the sidebar
124
+ * login entrypoint is needed: in host mode EDA hands the credential to
125
+ * hq-edge, so the auth plugin's own login UI is suppressed.
126
+ */
127
+ readonly hostMode: boolean;
53
128
  }
54
129
  //#endregion
55
130
  //#region src/index.d.ts
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,19 +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
+ });
309
+ this.hostMode = this.host.enabled;
159
310
  }
311
+ /** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
312
+ hostMode;
160
313
  auth = {
161
- 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
+ },
162
318
  getAccessToken: async () => (await this.resolve())?.token ?? null,
163
319
  getUserInfo: async () => this.resolve(),
164
320
  login: async () => {},
165
321
  logout: async () => this.invalidate(),
322
+ validate: () => this.validateInternal(),
323
+ invalidate: () => this.markStale(),
166
324
  onAuthStateChanged: (listener) => this.on(listener)
167
325
  };
168
- /** 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
+ */
169
331
  async resolve() {
170
332
  if (this.host.enabled) {
171
333
  const host = await this.host.resolve();
@@ -174,8 +336,38 @@ var InMemoryHuaqiuAuthService = class {
174
336
  if (this.current) return this.current;
175
337
  return readPersisted();
176
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
+ }
177
367
  setCredentials(info) {
178
368
  this.current = info;
369
+ this.stale = false;
370
+ this.validator.invalidate();
179
371
  writePersisted(info);
180
372
  this.emit();
181
373
  }
@@ -183,6 +375,8 @@ var InMemoryHuaqiuAuthService = class {
183
375
  const was = this.host.enabled || this.current !== null || readPersisted() !== null;
184
376
  this.current = null;
185
377
  this.host.clear();
378
+ this.stale = true;
379
+ this.validator.invalidate();
186
380
  deletePersisted();
187
381
  if (was) this.emit();
188
382
  }
@@ -222,10 +416,12 @@ function normalizeUserInfo(data) {
222
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;
223
417
  if (!token || !id) return null;
224
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;
225
420
  return {
226
421
  id,
227
422
  token,
228
- ...nickname ? { nickname } : {}
423
+ ...nickname ? { nickname } : {},
424
+ ...expiresAt !== void 0 ? { expiresAt } : {}
229
425
  };
230
426
  }
231
427
  function createAuthHandler(service) {
@@ -252,11 +448,15 @@ function createAuthHandler(service) {
252
448
  if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/session`) {
253
449
  const user = await service.auth.getUserInfo();
254
450
  sendJson(res, 200, {
255
- authenticated: service.auth.isAuthenticated(),
451
+ authenticated: await service.auth.isAuthenticated(),
256
452
  user
257
453
  });
258
454
  return;
259
455
  }
456
+ if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/config`) {
457
+ sendJson(res, 200, { hostMode: service.hostMode });
458
+ return;
459
+ }
260
460
  sendJson(res, 404, { error: "not found" });
261
461
  } catch (err) {
262
462
  sendJson(res, 500, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-auth",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -39,6 +39,8 @@ export interface AuthClient {
39
39
  restore(): Promise<void>
40
40
  /** Re-push persisted credentials on demand (heals a reset/absent node half). */
41
41
  syncNow(): Promise<void>
42
+ /** Browser→node transport (used to read host mode before UI registration). */
43
+ transport: AuthTransport
42
44
  dispose(): void
43
45
  }
44
46
 
@@ -144,6 +146,12 @@ export function createAuthClient(deps: AuthClientDeps): AuthClient {
144
146
  auth,
145
147
  handleMessageEvent,
146
148
  restore,
149
+ /**
150
+ * Browser→node transport. Exposed so the client entry can read host mode
151
+ * (whether an HQ Edge host supplies the credential and the login UI should
152
+ * be suppressed) before registering the sidebar entrypoint.
153
+ */
154
+ transport,
147
155
  /**
148
156
  * Re-push the persisted credential to the node half. Healing path: the
149
157
  * node keeps auth in memory, so a `dsh web` restart (or a failed first
@@ -65,6 +65,7 @@ export function apply(ctx: ClientContext): () => void {
65
65
  })
66
66
 
67
67
  const disposers: Array<() => void> = []
68
+ let disposed = false
68
69
  const disposeProvide = ctx.provide?.('huaqiuAuth', { auth: client.auth })
69
70
  registerAuth(client.auth)
70
71
  registerAuthSync(() => { void client.syncNow() })
@@ -85,15 +86,32 @@ export function apply(ctx: ClientContext): () => void {
85
86
  document.removeEventListener('visibilitychange', sync)
86
87
  })
87
88
 
89
+ /**
90
+ * In HQ Edge host mode (config.hqEdgeBaseUrl set on the node half), EDA
91
+ * launches hq-edge WITH the operator credential, so hq-edge — not this
92
+ * plugin — owns authentication for the session. The auth plugin's own login
93
+ * UI (the `sidebar.footer.action` entrypoint and the login toolviews) is
94
+ * therefore suppressed: it would be redundant and confusing next to the
95
+ * host-provided session. In standalone DSH (official integration) the
96
+ * sidebar entrypoint stays — it is the only login surface there.
97
+ *
98
+ * The mode is read from the node half over the plugin-owned webServer route
99
+ * (async), so registration is deferred until it answers; the returned
100
+ * disposer still drains anything registered later.
101
+ */
88
102
  const slots = ctx.slots
89
- if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
90
- for (const toolName of AUTH_TOOL_NAMES) {
91
- disposers.push(slots.inject('tool.call.toolview', () => slots.register({ name: 'tool.call.toolview', key: toolName }, HuaqiuToolView) as () => void))
103
+ void client.transport.fetchHostMode().then((hostMode) => {
104
+ if (disposed || hostMode) return
105
+ if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
106
+ for (const toolName of AUTH_TOOL_NAMES) {
107
+ disposers.push(slots.inject('tool.call.toolview', () => slots.register({ name: 'tool.call.toolview', key: toolName }, HuaqiuToolView) as () => void))
108
+ }
109
+ disposers.push(slots.inject('sidebar.footer.action', () => slots.register({ name: 'sidebar.footer.action', id: 'huaqiu-auth' }, HuaqiuAuthSidebarAction) as () => void))
92
110
  }
93
- disposers.push(slots.inject('sidebar.footer.action', () => slots.register({ name: 'sidebar.footer.action', id: 'huaqiu-auth' }, HuaqiuAuthSidebarAction) as () => void))
94
- }
111
+ })
95
112
 
96
113
  return () => {
114
+ disposed = true
97
115
  for (const dispose of disposers) {
98
116
  try {
99
117
  dispose()
@@ -9,6 +9,12 @@ import type { AuthTokenPayload } from './lib.js'
9
9
  export interface AuthTransport {
10
10
  pushSession(info: AuthTokenPayload): Promise<void>
11
11
  pushLogout(): Promise<void>
12
+ /**
13
+ * Whether the plugin runs under an HQ Edge host. In host mode hq-edge already
14
+ * holds the operator credential (EDA hands it over on launch), so the
15
+ * browser half's own login UI (sidebar entrypoint) is suppressed.
16
+ */
17
+ fetchHostMode(): Promise<boolean>
12
18
  }
13
19
 
14
20
  export function createWebServerAuthTransport(
@@ -24,6 +30,7 @@ export function createWebServerAuthTransport(
24
30
  token: info.token,
25
31
  userId: info.id,
26
32
  ...(info.nickname !== undefined ? { nickname: info.nickname } : {}),
33
+ ...(info.expiresAt !== undefined ? { expiresAt: info.expiresAt } : {}),
27
34
  }),
28
35
  })
29
36
  if (!res.ok) throw new Error(`auth push failed: HTTP ${res.status}`)
@@ -32,5 +39,21 @@ export function createWebServerAuthTransport(
32
39
  const res = await doFetch(`${base}/logout`, { method: 'POST' })
33
40
  if (!res.ok) throw new Error(`auth logout push failed: HTTP ${res.status}`)
34
41
  },
42
+ async fetchHostMode() {
43
+ try {
44
+ const res = await doFetch(`${base}/config`, {
45
+ method: 'GET',
46
+ headers: { accept: 'application/json' },
47
+ })
48
+ if (!res.ok) return false
49
+ const body = await res.json() as { hostMode?: unknown }
50
+ return body.hostMode === true
51
+ } catch {
52
+ // Offline/same-origin failure: fall back to standalone (show the login
53
+ // entrypoint) rather than hiding it — a login UI is never a security
54
+ // regression, but a missing one in standalone would lock the user out.
55
+ return false
56
+ }
57
+ },
35
58
  }
36
59
  }
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,13 +2,17 @@
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
+ * GET /api/v1/huaqiu/auth/config → { hostMode }
8
9
  *
9
- * These routes are the browser→node transport for Phase 0A (start-p0.md §4:
10
- * smallest supported extension point `apiProxy`'s dispatch table is closed,
11
- * so a plugin-owned `webServer` route is the documented channel).
10
+ * `config` tells the browser half whether it is running under an HQ Edge host
11
+ * (hq-edge passes the operator credential to hq-edge itself, so the sidebar
12
+ * login entrypoint is suppressed). These routes are the browser→node transport
13
+ * for Phase 0A (start-p0.md §4: smallest supported extension point —
14
+ * `apiProxy`'s dispatch table is closed, so a plugin-owned `webServer` route is
15
+ * the documented channel).
12
16
  */
13
17
  import type { IncomingMessage, ServerResponse } from 'node:http'
14
18
  import type { HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
@@ -41,7 +45,15 @@ function normalizeUserInfo(data: Record<string, unknown>): HuaqiuUserInfo | null
41
45
  : null
42
46
  if (!token || !id) return null
43
47
  const nickname = typeof data.nickname === 'string' && data.nickname.length > 0 ? data.nickname : undefined
44
- 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
+ }
45
57
  }
46
58
 
47
59
  export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
@@ -71,11 +83,16 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
71
83
 
72
84
  if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/session`) {
73
85
  const user = await service.auth.getUserInfo()
74
- const authenticated = service.auth.isAuthenticated()
86
+ const authenticated = await service.auth.isAuthenticated()
75
87
  sendJson(res, 200, { authenticated, user })
76
88
  return
77
89
  }
78
90
 
91
+ if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/config`) {
92
+ sendJson(res, 200, { hostMode: service.hostMode })
93
+ return
94
+ }
95
+
79
96
  sendJson(res, 404, { error: 'not found' })
80
97
  } catch (err) {
81
98
  sendJson(res, 500, { error: 'internal error', detail: String(err) })