kczx-user-management 1.0.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/src/index.js ADDED
@@ -0,0 +1,1358 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * dsh-plugin-user-management — Host half
5
+ *
6
+ * An HTTPS remote-access gateway + user administration for the dsh web
7
+ * surface. The plugin spins up its OWN node:https listener (TLS + self-signed
8
+ * certs + Host allow-list) that reverse-proxies to the loopback dsh
9
+ * webserver, with user-management's own user store as the auth:
10
+ * - Standalone /login page (login + register tabs; first registrant becomes
11
+ * admin). Unauthenticated page navigations redirect to /login, API/WS
12
+ * traffic is answered 401.
13
+ * - Optional TOTP two-step verification (RFC 6238): self-service enrollment
14
+ * with QR + manual entry, OTP demanded after a correct password at login,
15
+ * brute-force lockout, single-use replay guard, and an admin recovery
16
+ * reset. See src/totp.js and the /me/totp/* + /users/:id/reset-totp APIs.
17
+ * - JSON API under /user-management/api: sessions, self service, admin user
18
+ * management (list / delete / reset password / role / disable), IP bans,
19
+ * and the activity + audit ledgers.
20
+ * - Everything else (the SPA, dsh /api, /plugins, static) is proxied to the
21
+ * loopback dsh web — but only past the auth gate, so the loopback dsh web
22
+ * stays unreachable directly and the auth cannot be bypassed.
23
+ *
24
+ * Role model: the first account registered into an empty system becomes
25
+ * admin; later registrations are plain users. Admins manage everyone,
26
+ * plain users see/change only themselves.
27
+ *
28
+ * Data lives in `$DSH_HOME/user-management/` (0600, atomic writes) — see
29
+ * src/store.js. Self-signed certs live under `$DSH_HOME/user-management/certs/`.
30
+ *
31
+ * IDENTITY FOR SIBLING PLUGINS: this plugin provides the cordis service
32
+ * `user-management` ({ resolveRequest(req), resolveToken(token) }) so any
33
+ * host-plane plugin can map an incoming request's `um_session` cookie to
34
+ * `{ id, username, role, ... }` — e.g. to attribute scheduled items, shares
35
+ * or edits to a user. Consume it with RUNTIME `ctx.inject(['user-management'],
36
+ * cb)` only (statically injecting an optional service hangs activation);
37
+ * full examples in the README section 「给其他插件:解析请求的用户身份」 and
38
+ * on createIdentityService below.
39
+ *
40
+ * The network-access layer (gateway-core/proxy/certs + hot-reload + self-heal)
41
+ * is adapted from dsh-gateway (clarknu/dsh-gateway); the auth backend is
42
+ * user-management's store (um_session), NOT dsh-gateway's flat HMAC users.
43
+ */
44
+
45
+ import { readFileSync } from 'node:fs'
46
+ import { join } from 'node:path'
47
+ import { networkInterfaces } from 'node:os'
48
+ import { request as httpRequest } from 'node:http'
49
+ import { request as httpsRequest } from 'node:https'
50
+ import z from '@deepseek-ai/schemastery'
51
+ import {
52
+ createStore,
53
+ dshHome,
54
+ normalizeIp,
55
+ tempPassword,
56
+ StoreError,
57
+ ACTIVITY_LIMIT_DEFAULT,
58
+ normalizePermissions,
59
+ } from './store.js'
60
+ import { createTrustedIdentity } from './trusted-identity.js'
61
+ import { otpauthUri } from './totp.js'
62
+ import qrCodeFactory from './vendor/qrcode-generator.js'
63
+ import {
64
+ SESSION_COOKIE,
65
+ API_PREFIX,
66
+ createDecider,
67
+ parseCookies,
68
+ isAuditableRequest,
69
+ } from './gate.js'
70
+ import { renderLoginPage } from './login-page.js'
71
+ import { createGateway } from './gateway-core.js'
72
+
73
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
74
+
75
+ const MAX_BODY_BYTES = 64 * 1024
76
+ const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
77
+ /**
78
+ * Compact "what changed" text for a permission edit. The ledger is read by
79
+ * humans scanning for who loosened or tightened what — a full JSON blob would
80
+ * bury that, and folder lists can be long.
81
+ */
82
+ function describePermissionDelta(before, after) {
83
+ const parts = []
84
+ for (const key of Object.keys(after)) {
85
+ const toText = (value) => (Array.isArray(value) ? value.join('|') : value === null ? '' : String(value))
86
+ const a = toText(after[key])
87
+ const b = toText(before ? before[key] : undefined)
88
+ if (a !== b) parts.push(`${key}=${a === '' ? '(none)' : a}`)
89
+ }
90
+ return parts.length > 0 ? parts.join(' ') : 'no change'
91
+ }
92
+
93
+ /** Ledger types a plain user may read about themselves. */
94
+ const SELF_ACTIVITY_TYPES = ['login', 'login_failed', 'logout', 'password_change', 'totp_enabled', 'totp_disabled']
95
+ const ADMIN_ACTIVITY_TYPES = ['login', 'login_failed', 'logout', 'password_change', 'register', 'reset_password', 'role_change', 'delete_user', 'access', 'totp_enabled', 'totp_disabled', 'totp_reset', 'user_created', 'user_disabled', 'user_enabled', 'permission_change', 'audit_clear', 'ban_ip', 'unban_ip']
96
+ const AUDIT_LIMIT_DEFAULT = 200
97
+ /** TOTP brute-force guard: lock an account's OTP check after this many
98
+ * consecutive bad codes (the code space is only 10^6, so without this an
99
+ * attacker holding the password could grind the 3-valid-codes-per-30s
100
+ * window indefinitely). In-memory only — a restart clears it. */
101
+ const OTP_FAIL_LIMIT = 5
102
+ const OTP_LOCKOUT_MS = 60 * 1000
103
+
104
+ /**
105
+ * Per-username lockout after consecutive TOTP failures. Deliberately simple:
106
+ * `fail()` counts, and reaching the limit locks for `lockoutMs` (during which
107
+ * even a correct code is rejected — pacing attacks defeat count-only guards).
108
+ */
109
+ function createOtpGuard({ limit = OTP_FAIL_LIMIT, lockoutMs = OTP_LOCKOUT_MS, now = () => Date.now() } = {}) {
110
+ const state = new Map() // username(lowercased) → { fails, lockedUntil }
111
+ const keyOf = (username) => String(username || '').toLowerCase()
112
+ return {
113
+ fail(username) {
114
+ const key = keyOf(username)
115
+ const rec = state.get(key) || { fails: 0, lockedUntil: 0 }
116
+ rec.fails += 1
117
+ if (rec.fails >= limit) {
118
+ rec.lockedUntil = now() + lockoutMs
119
+ rec.fails = 0
120
+ }
121
+ state.set(key, rec)
122
+ },
123
+ /** Remaining lockout in ms; 0 = not locked. */
124
+ locked(username) {
125
+ const rec = state.get(keyOf(username))
126
+ if (!rec) return 0
127
+ return Math.max(0, rec.lockedUntil - now())
128
+ },
129
+ reset(username) {
130
+ state.delete(keyOf(username))
131
+ },
132
+ }
133
+ }
134
+
135
+ /** Self-contained QR SVG for the enrollment URI (white ground, scannable). */
136
+ function totpQrSvg(uri) {
137
+ const qr = qrCodeFactory(0, 'M')
138
+ qr.addData(uri)
139
+ qr.make()
140
+ return qr.createSvgTag({ cellSize: 5, margin: 2 })
141
+ }
142
+
143
+ function sendJson(res, status, payload, extraHeaders) {
144
+ res.writeHead(status, Object.assign({ 'content-type': 'application/json; charset=utf-8' }, extraHeaders || {}))
145
+ res.end(JSON.stringify(payload))
146
+ }
147
+
148
+ function readJsonBody(req) {
149
+ return new Promise((fulfil, reject) => {
150
+ let size = 0
151
+ const chunks = []
152
+ req.on('data', (chunk) => {
153
+ size += chunk.length
154
+ if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
155
+ chunks.push(chunk)
156
+ })
157
+ req.on('end', () => {
158
+ try { fulfil(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
159
+ catch (error) { reject(new StoreError('bad_json', `invalid JSON body: ${error && error.message}`)) }
160
+ })
161
+ req.on('error', reject)
162
+ })
163
+ }
164
+
165
+ // Secure because the gateway is HTTPS-only now (the shared-server HTTP gate is gone).
166
+ function sessionCookie(token) {
167
+ return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=${SESSION_TTL_SECONDS}`
168
+ }
169
+
170
+ function clearedCookie() {
171
+ return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`
172
+ }
173
+
174
+ /** Normalize an API path: strip trailing slashes (keep the root). */
175
+ function normalizeApiPath(pathname) {
176
+ const stripped = pathname.replace(/\/+$/, '')
177
+ return stripped === '' ? pathname : stripped
178
+ }
179
+
180
+ function statusForStoreError(error) {
181
+ switch (error && error.code) {
182
+ case 'last_admin': return 409
183
+ case 'not_found': return 404
184
+ default: return 400
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The full API request handler, factored out for tests. `deps`:
190
+ * { store, clientIp(req) }.
191
+ * Anonymous (no session) → 401 on every non-public endpoint; an authenticated
192
+ * non-admin hitting an admin endpoint → 403. The gateway also 401s anonymous
193
+ * non-public paths before reaching here (defense in depth), but handleApi is
194
+ * self-consistent without it so it can be tested on a bare http server.
195
+ */
196
+ async function handleApi(req, res, deps) {
197
+ const url = new URL(req.url || '/', 'http://dsh.local')
198
+ const path = normalizeApiPath(url.pathname)
199
+ const method = (req.method || 'GET').toUpperCase()
200
+ const { store } = deps
201
+ const apiPath = path.startsWith(`${API_PREFIX}/`) ? path.slice(API_PREFIX.length) : path
202
+
203
+ const authed = async () => {
204
+ // A signature-bearing request from the co-located login gateway has no
205
+ // um_session cookie (it never logged in here) — the trusted identity IS
206
+ // the session, so the ledgers, the user table and the admin APIs all keep
207
+ // working behind the single door.
208
+ if (typeof deps.resolveTrusted === 'function') {
209
+ const trusted = deps.resolveTrusted(req)
210
+ if (trusted) return trusted
211
+ }
212
+ const cookies = parseCookies(req.headers && req.headers.cookie)
213
+ return store.resolveSession(cookies[SESSION_COOKIE])
214
+ }
215
+
216
+ // requireAdmin: 401 anonymous, 403 authenticated non-admin, else the admin session.
217
+ const requireAdmin = async () => {
218
+ const session = await authed()
219
+ if (!session) return { ok: false, status: 401, message: '未登录' }
220
+ if (session.user.role !== 'admin') return { ok: false, status: 403, message: '需要管理员权限' }
221
+ return { ok: true, session }
222
+ }
223
+
224
+ // ── anonymous endpoints ──────────────────────────────────────────────────
225
+
226
+ if (apiPath === '/login' && method === 'POST') {
227
+ const body = await readJsonBody(req)
228
+ const username = typeof body.username === 'string' ? body.username.trim() : ''
229
+ const outcome = await store.checkLogin(username, body.password)
230
+ if (outcome.result === 'invalid') {
231
+ await store.appendActivity({ type: 'login_failed', username: username || null, ip: deps.clientIp(req), detail: 'wrong credentials' })
232
+ return sendJson(res, 401, { error: '用户名或密码错误' })
233
+ }
234
+ if (outcome.result === 'disabled') {
235
+ await store.appendActivity({ type: 'login_failed', username: username, userId: outcome.user.id, ip: deps.clientIp(req), detail: 'account disabled' })
236
+ return sendJson(res, 403, { error: '账号已被禁用,请联系管理员' })
237
+ }
238
+ const user = outcome.user
239
+ // TOTP step — only revealed AFTER the password is correct (otpRequired in
240
+ // the response tells the login page to show the code field; a missing or
241
+ // wrong code never discloses whether the account has TOTP to a caller who
242
+ // doesn't hold valid credentials).
243
+ if (user.totpSecret) {
244
+ const guard = deps.otpGuard || (deps.otpGuard = createOtpGuard())
245
+ const lockedFor = guard.locked(username)
246
+ if (lockedFor > 0) {
247
+ await store.appendActivity({ type: 'login_failed', username, userId: user.id, ip: deps.clientIp(req), detail: 'totp attempts locked' })
248
+ return sendJson(res, 429, { error: `两步验证失败次数过多,请 ${Math.ceil(lockedFor / 1000)} 秒后再试`, otpRequired: true })
249
+ }
250
+ const code = typeof body.otp === 'string' ? body.otp.trim() : ''
251
+ if (code === '') {
252
+ return sendJson(res, 401, { error: '请输入两步验证码', otpRequired: true })
253
+ }
254
+ const verdict = await store.verifyLoginOtp(user, code)
255
+ if (!verdict.ok) {
256
+ guard.fail(username)
257
+ await store.appendActivity({ type: 'login_failed', username, userId: user.id, ip: deps.clientIp(req), detail: 'invalid totp' })
258
+ return sendJson(res, 401, { error: '两步验证码错误', otpRequired: true })
259
+ }
260
+ guard.reset(username)
261
+ }
262
+ const { token } = await store.createSession(user)
263
+ await store.touchLogin(user)
264
+ await store.appendActivity({ type: 'login', username: user.username, userId: user.id, ip: deps.clientIp(req) })
265
+ return sendJson(res, 200, { user: store.publicUser(user) }, { 'set-cookie': sessionCookie(token) })
266
+ }
267
+
268
+ if (apiPath === '/register' && method === 'POST') {
269
+ const body = await readJsonBody(req)
270
+ const username = typeof body.username === 'string' ? body.username.trim() : ''
271
+ const role = store.roleForNextRegistration()
272
+ // Approval mode (autoActivate off, the default): every self-registration
273
+ // except the first admin starts DISABLED until an admin enables the
274
+ // account — the users-table enable button is the approval action. The
275
+ // bare-handler test harness (no deps.autoActivate) keeps classic
276
+ // auto-activation so old flows read unchanged.
277
+ const autoActivate = deps.autoActivate ? deps.autoActivate() === true : true
278
+ const pending = role !== 'admin' && !autoActivate
279
+ try {
280
+ const created = await store.createUser({ username, password: body.password, role, disabled: pending })
281
+ if (pending) {
282
+ await store.appendActivity({ type: 'register', username: created.username, userId: created.id, ip: deps.clientIp(req), detail: `role=${role} pending` })
283
+ return sendJson(res, 200, { user: created, pending: true })
284
+ }
285
+ const user = store.findUserByUsername(created.username)
286
+ const { token } = await store.createSession(user)
287
+ await store.touchLogin(user)
288
+ await store.appendActivity({ type: 'register', username: created.username, userId: created.id, ip: deps.clientIp(req), detail: `role=${role}` })
289
+ return sendJson(res, 200, { user: created }, { 'set-cookie': sessionCookie(token) })
290
+ } catch (error) {
291
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
292
+ throw error
293
+ }
294
+ }
295
+
296
+ if (apiPath === '/session' && method === 'GET') {
297
+ const session = await authed()
298
+ return sendJson(res, 200, {
299
+ user: session ? store.publicUser(session.user) : null,
300
+ // Observer mode (merged deployment): this layer sits behind the single
301
+ // login door, so the client hides everything that only makes sense for a
302
+ // door of its own — accounts, bans, certificates, 2FA, sign-out — and
303
+ // shows the ledgers it exists for.
304
+ observer: typeof deps.observerMode === 'function' ? deps.observerMode() === true : false,
305
+ })
306
+ }
307
+
308
+ if (apiPath === '/logout' && method === 'POST') {
309
+ const session = await authed()
310
+ if (session) {
311
+ await store.dropSession(session.token)
312
+ await store.appendActivity({ type: 'logout', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
313
+ }
314
+ return sendJson(res, 200, { ok: true }, { 'set-cookie': clearedCookie() })
315
+ }
316
+
317
+ // ── authenticated self service ───────────────────────────────────────────
318
+
319
+ if (apiPath === '/me/password' && method === 'POST') {
320
+ const session = await authed()
321
+ if (!session) return sendJson(res, 401, { error: '未登录' })
322
+ const body = await readJsonBody(req)
323
+ const ok = await store.verifyLogin(session.user.username, body.oldPassword)
324
+ if (!ok) return sendJson(res, 400, { error: '当前密码不正确' })
325
+ try {
326
+ await store.setPassword(session.user, body.newPassword)
327
+ } catch (error) {
328
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
329
+ throw error
330
+ }
331
+ await store.dropUserSessions(session.user.id, session.token)
332
+ await store.appendActivity({ type: 'password_change', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
333
+ return sendJson(res, 200, { ok: true })
334
+ }
335
+
336
+ // ── TOTP self-service enrollment ─────────────────────────────────────────
337
+
338
+ // Step 1: mint the (pending) secret. The QR + otpauth URI are generated
339
+ // here so the browser half stays bundle-light.
340
+ if (apiPath === '/me/totp/setup' && method === 'POST') {
341
+ const session = await authed()
342
+ if (!session) return sendJson(res, 401, { error: '未登录' })
343
+ try {
344
+ const { secret } = await store.startTotpSetup(session.user)
345
+ const uri = otpauthUri({ username: session.user.username, secret })
346
+ return sendJson(res, 200, { secret, otpauth: uri, qrSvg: totpQrSvg(uri) })
347
+ } catch (error) {
348
+ if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
349
+ throw error
350
+ }
351
+ }
352
+
353
+ // Step 2: prove possession of the secret with a live code.
354
+ if (apiPath === '/me/totp/activate' && method === 'POST') {
355
+ const session = await authed()
356
+ if (!session) return sendJson(res, 401, { error: '未登录' })
357
+ const body = await readJsonBody(req)
358
+ let activated = false
359
+ try {
360
+ activated = await store.activateTotp(session.user, body.code)
361
+ } catch (error) {
362
+ if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
363
+ throw error
364
+ }
365
+ if (!activated) return sendJson(res, 400, { error: '动态码不正确,请确认验证器时间已同步后重试' })
366
+ await store.appendActivity({ type: 'totp_enabled', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
367
+ return sendJson(res, 200, { ok: true })
368
+ }
369
+
370
+ // Disable: identity re-confirmed by password (a stolen session alone must
371
+ // not be able to weaken the account). Existing sessions are kept — they
372
+ // already passed the full login.
373
+ if (apiPath === '/me/totp/disable' && method === 'POST') {
374
+ const session = await authed()
375
+ if (!session) return sendJson(res, 401, { error: '未登录' })
376
+ const body = await readJsonBody(req)
377
+ const ok = await store.verifyLogin(session.user.username, body.password)
378
+ if (!ok) return sendJson(res, 400, { error: '登录密码不正确' })
379
+ try {
380
+ await store.disableTotp(session.user)
381
+ } catch (error) {
382
+ if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
383
+ throw error
384
+ }
385
+ await store.appendActivity({ type: 'totp_disabled', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
386
+ return sendJson(res, 200, { ok: true })
387
+ }
388
+
389
+ if (apiPath === '/users' && method === 'GET') {
390
+ const session = await authed()
391
+ if (!session) return sendJson(res, 401, { error: '未登录' })
392
+ // Each row carries today's consumption: an administrator setting a limit is
393
+ // looking at the account it applies to, and a plain user wants to see their
394
+ // own allowance. Null means "no activity recorded today", not zero.
395
+ const withUsage = (row) => ({ ...row, usage: store.getUsage(row.username) })
396
+ if (session.user.role === 'admin') return sendJson(res, 200, { users: store.listUsers().map(withUsage) })
397
+ return sendJson(res, 200, { users: [withUsage(store.publicUser(session.user))] })
398
+ }
399
+
400
+ // ── 内部:接收登录门(dsh-passwords)的会话事件 ──────────────────────────
401
+ // 合并部署下登录发生在唯一入口,这本「登录记录」只能靠它推过来。凭据就是
402
+ // 签名身份头本身:只有回环 + 有效 HMAC 才进得来(resolveTrusted 已校验),
403
+ // 路径因此列在 PUBLIC_PATHS 里(浏览器路径的会话检查对它不适用)。
404
+ if (apiPath === '/internal/session-event' && method === 'POST') {
405
+ const trusted = typeof deps.resolveTrusted === 'function' ? deps.resolveTrusted(req) : null
406
+ if (!trusted) return sendJson(res, 401, { error: 'untrusted' })
407
+ const body = await readJsonBody(req)
408
+ const type = typeof body.type === 'string' ? body.type : ''
409
+ if (!INTERNAL_SESSION_TYPES.has(type)) return sendJson(res, 400, { error: 'unknown event' })
410
+ await store.appendActivity({
411
+ type,
412
+ username: trusted.user.username,
413
+ userId: trusted.user.id,
414
+ ip: deps.clientIp(req),
415
+ detail: typeof body.detail === 'string' && body.detail !== '' ? body.detail : undefined,
416
+ })
417
+ return sendJson(res, 200, { ok: true })
418
+ }
419
+
420
+ // ── token usage reporting (any signed-in account) ────────────────────────
421
+ // dsh's own client projection is the only reliable source of provider
422
+ // accounting (the gateway cannot see it, and reconstructing it from response
423
+ // bodies was abandoned as unreliable), so the browser reports DELTAS of that
424
+ // projection and this side accumulates them into the hourly window the quota
425
+ // is measured against. Deltas are idempotent from the client's point of view
426
+ // — it tracks the last cumulative value — so a duplicate report cannot inflate
427
+ // a quota.
428
+ if (apiPath === '/usage/report' && method === 'POST') {
429
+ const session = await authed()
430
+ if (!session) return sendJson(res, 401, { error: '未登录' })
431
+ const body = await readJsonBody(req)
432
+ const tokens = Number(body.tokens)
433
+ if (!Number.isFinite(tokens) || tokens < 0 || tokens > 100_000_000) {
434
+ return sendJson(res, 400, { error: 'tokens 无效' })
435
+ }
436
+ await store.addTokens(session.user.username, Math.round(tokens))
437
+ return sendJson(res, 200, { ok: true })
438
+ }
439
+
440
+ if (apiPath === '/activity' && method === 'GET') {
441
+ const session = await authed()
442
+ if (!session) return sendJson(res, 401, { error: '未登录' })
443
+ const isAdmin = session.user.role === 'admin'
444
+ const type = url.searchParams.get('type') || undefined
445
+ const limit = Number(url.searchParams.get('limit')) || ACTIVITY_LIMIT_DEFAULT
446
+ if (!isAdmin) {
447
+ const entries = await store.listActivity({ types: SELF_ACTIVITY_TYPES, userId: session.user.id, limit })
448
+ return sendJson(res, 200, { entries })
449
+ }
450
+ const username = url.searchParams.get('username')
451
+ const target = username ? store.findUserByUsername(username) : null
452
+ const entries = await store.listActivity({
453
+ type,
454
+ userId: username ? (target ? target.id : 'NoSuchUser') : undefined,
455
+ limit,
456
+ })
457
+ return sendJson(res, 200, { entries })
458
+ }
459
+
460
+ if (apiPath === '/audit' && method === 'GET') {
461
+ const session = await authed()
462
+ if (!session) return sendJson(res, 401, { error: '未登录' })
463
+ if (session.user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限' })
464
+ const entries = await store.listAudit({
465
+ username: url.searchParams.get('username') || undefined,
466
+ method: url.searchParams.get('method') || undefined,
467
+ path: url.searchParams.get('path') || undefined,
468
+ statusClass: url.searchParams.get('statusClass') || undefined,
469
+ limit: Number(url.searchParams.get('limit')) || AUDIT_LIMIT_DEFAULT,
470
+ })
471
+ return sendJson(res, 200, { entries })
472
+ }
473
+
474
+ if (apiPath === '/audit' && method === 'DELETE') {
475
+ const admin = await requireAdmin()
476
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
477
+ await store.clearAudit()
478
+ await store.appendActivity({ type: 'audit_clear', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req) })
479
+ return sendJson(res, 200, { ok: true })
480
+ }
481
+
482
+ const auditDeleteMatch = /^\/audit\/([A-Za-z0-9_-]+)$/.exec(apiPath)
483
+ if (auditDeleteMatch && method === 'DELETE') {
484
+ const admin = await requireAdmin()
485
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
486
+ const removed = await store.removeAuditEntry(auditDeleteMatch[1])
487
+ if (!removed) return sendJson(res, 404, { error: '记录不存在' })
488
+ return sendJson(res, 200, { ok: true })
489
+ }
490
+
491
+ // ── admin-only IP bans ───────────────────────────────────────────────────
492
+
493
+ if (apiPath === '/bans' && method === 'GET') {
494
+ const session = await authed()
495
+ if (!session) return sendJson(res, 401, { error: '未登录' })
496
+ if (session.user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限' })
497
+ return sendJson(res, 200, { bans: store.listBans(), selfIp: deps.clientIp(req) })
498
+ }
499
+
500
+ if (apiPath === '/bans' && method === 'POST') {
501
+ const admin = await requireAdmin()
502
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
503
+ const body = await readJsonBody(req)
504
+ const ip = typeof body.ip === 'string' ? body.ip.trim() : ''
505
+ if (ip === deps.clientIp(req)) return sendJson(res, 400, { error: '不能封禁当前正在使用的 IP' })
506
+ try {
507
+ await store.banIp(ip, { note: body.note, by: admin.session.user.username })
508
+ } catch (error) {
509
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
510
+ throw error
511
+ }
512
+ await store.appendActivity({ type: 'ban_ip', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `ip=${ip}${body.note ? ` note=${body.note}` : ''}` })
513
+ return sendJson(res, 200, { ok: true })
514
+ }
515
+
516
+ const unbanMatch = /^\/bans\/(.+)$/.exec(apiPath)
517
+ if (unbanMatch && method === 'DELETE') {
518
+ const admin = await requireAdmin()
519
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
520
+ const ip = decodeURIComponent(unbanMatch[1])
521
+ try {
522
+ await store.unbanIp(ip)
523
+ } catch (error) {
524
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
525
+ throw error
526
+ }
527
+ await store.appendActivity({ type: 'unban_ip', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `ip=${ip}` })
528
+ return sendJson(res, 200, { ok: true })
529
+ }
530
+
531
+ // ── admin-only user administration ───────────────────────────────────────
532
+
533
+ if (apiPath === '/users' && method === 'POST') {
534
+ const admin = await requireAdmin()
535
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
536
+ const body = await readJsonBody(req)
537
+ try {
538
+ const created = await store.createUser({ username: body.username, password: body.password, role: body.role || 'user' })
539
+ await store.appendActivity({ type: 'user_created', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `created=${created.username} role=${created.role}` })
540
+ return sendJson(res, 200, { user: created })
541
+ } catch (error) {
542
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
543
+ throw error
544
+ }
545
+ }
546
+
547
+ const disableMatch = /^\/users\/([A-Za-z0-9_-]+)\/disabled$/.exec(apiPath)
548
+ if (disableMatch && method === 'POST') {
549
+ const admin = await requireAdmin()
550
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
551
+ const target = store.findUser(disableMatch[1])
552
+ if (!target) return sendJson(res, 404, { error: '用户不存在' })
553
+ if (target.id === admin.session.user.id) return sendJson(res, 403, { error: '不能禁用自己的账号' })
554
+ const body = await readJsonBody(req)
555
+ const disabled = !!body.disabled
556
+ try {
557
+ await store.setDisabled(target, disabled)
558
+ } catch (error) {
559
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
560
+ throw error
561
+ }
562
+ if (disabled) await store.dropUserSessions(target.id)
563
+ await store.appendActivity({
564
+ type: disabled ? 'user_disabled' : 'user_enabled',
565
+ username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req),
566
+ detail: `target=${target.username}`,
567
+ })
568
+ return sendJson(res, 200, { user: store.publicUser(target) })
569
+ }
570
+
571
+ // ── per-user permissions & quotas (admin) ────────────────────────────────
572
+ // The account model dsh-passwords used to own: workspace scope, hourly token
573
+ // cap, daily minutes, sandbox floor, upload/git switches and the policy ban.
574
+ // GET reads, PUT/PATCH merges a partial patch (absent keys keep their value).
575
+ const permissionsMatch = /^\/users\/([A-Za-z0-9_-]+)\/permissions$/.exec(apiPath)
576
+ if (permissionsMatch) {
577
+ const admin = await requireAdmin()
578
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
579
+ const target = store.findUser(permissionsMatch[1])
580
+ if (!target) return sendJson(res, 404, { error: '用户不存在' })
581
+ if (method === 'GET') return sendJson(res, 200, { user: store.publicUser(target) })
582
+ if (method === 'PUT' || method === 'PATCH') {
583
+ const body = await readJsonBody(req)
584
+ const before = store.publicUser(target).permissions
585
+ let updated
586
+ try {
587
+ updated = await store.setPermissions(target, body)
588
+ } catch (error) {
589
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
590
+ throw error
591
+ }
592
+ await store.appendActivity({
593
+ type: 'permission_change',
594
+ username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req),
595
+ detail: `target=${target.username} ${describePermissionDelta(before, updated.permissions)}`,
596
+ })
597
+ // Banning is a policy action: it takes effect on live sessions at once.
598
+ if (updated.permissions.banned && !before.banned) await store.dropUserSessions(target.id)
599
+ return sendJson(res, 200, { user: updated })
600
+ }
601
+ return sendJson(res, 405, { error: 'method not allowed' })
602
+ }
603
+
604
+ const adminMatch = /^\/users\/([A-Za-z0-9_-]+)(?:\/(reset-password|reset-totp|role))?$/.exec(apiPath)
605
+
606
+ if (adminMatch && method === 'DELETE' && !adminMatch[2]) {
607
+ const admin = await requireAdmin()
608
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
609
+ const target = store.findUser(adminMatch[1])
610
+ if (!target) return sendJson(res, 404, { error: '用户不存在' })
611
+ if (target.id === admin.session.user.id) return sendJson(res, 403, { error: '不能删除自己的账号' })
612
+ try {
613
+ await store.removeUser(target)
614
+ } catch (error) {
615
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
616
+ throw error
617
+ }
618
+ await store.appendActivity({ type: 'delete_user', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `deleted=${target.username}` })
619
+ return sendJson(res, 200, { ok: true })
620
+ }
621
+
622
+ if (adminMatch && adminMatch[2] === 'reset-password' && method === 'POST') {
623
+ const admin = await requireAdmin()
624
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
625
+ const target = store.findUser(adminMatch[1])
626
+ if (!target) return sendJson(res, 404, { error: '用户不存在' })
627
+ const generated = tempPassword()
628
+ await store.setPassword(target, generated)
629
+ await store.dropUserSessions(target.id)
630
+ await store.appendActivity({ type: 'reset_password', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `target=${target.username}` })
631
+ return sendJson(res, 200, { tempPassword: generated })
632
+ }
633
+
634
+ // Admin recovery path for a lost authenticator device: strip the target's
635
+ // TOTP so they can sign in with the password alone (and re-enroll). Does
636
+ // not touch their live sessions — this weakens FUTURE logins only, and the
637
+ // action itself lands in the activity ledger.
638
+ if (adminMatch && adminMatch[2] === 'reset-totp' && method === 'POST') {
639
+ const admin = await requireAdmin()
640
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
641
+ const target = store.findUser(adminMatch[1])
642
+ if (!target) return sendJson(res, 404, { error: '用户不存在' })
643
+ try {
644
+ await store.disableTotp(target)
645
+ } catch (error) {
646
+ if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
647
+ throw error
648
+ }
649
+ await store.appendActivity({ type: 'totp_reset', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `target=${target.username}` })
650
+ return sendJson(res, 200, { user: store.publicUser(target) })
651
+ }
652
+
653
+ if (adminMatch && adminMatch[2] === 'role' && method === 'POST') {
654
+ const admin = await requireAdmin()
655
+ if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
656
+ const target = store.findUser(adminMatch[1])
657
+ if (!target) return sendJson(res, 404, { error: '用户不存在' })
658
+ if (target.id === admin.session.user.id) return sendJson(res, 403, { error: '不能修改自己的角色' })
659
+ const body = await readJsonBody(req)
660
+ try {
661
+ await store.setRole(target, body.role)
662
+ } catch (error) {
663
+ if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
664
+ throw error
665
+ }
666
+ await store.dropUserSessions(target.id)
667
+ await store.appendActivity({ type: 'role_change', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `target=${target.username} role=${target.role}` })
668
+ return sendJson(res, 200, { user: store.publicUser(target) })
669
+ }
670
+
671
+ return sendJson(res, 404, { error: 'not found' })
672
+ }
673
+
674
+ /** How often an account's activity counter is allowed to be written. */
675
+ const USAGE_TOUCH_INTERVAL_MS = 60_000
676
+
677
+ /** Static assets never count as activity (a page load is not "using" the agent). */
678
+ const STATIC_PATH_RE = /\.(?:js|mjs|css|map|woff2?|ttf|otf|png|jpe?g|gif|svg|ico|webp|avif|mp4|webmanifest|txt)$/i
679
+
680
+ /** In-memory per-account throttle for the usage counter (not persisted). */
681
+ const lastUsageTouch = new Map()
682
+
683
+ /** Session events the signing gateway may push into the login ledger. */
684
+ const INTERNAL_SESSION_TYPES = new Set(['login', 'login_failed', 'logout'])
685
+
686
+ // ── schemastery config (the `user-management:` settings namespace) ──────────
687
+ const Config = z.object({
688
+ enabled: z.boolean().default(true),
689
+ // Registration approval switch: false (default) puts every self-registered
690
+ // account (first admin excepted) into the disabled state until an admin
691
+ // enables it from the users table; true activates registrations immediately.
692
+ autoActivate: z.boolean().default(false),
693
+ listenHost: z.string().default('0.0.0.0'),
694
+ port: z.natural().min(1).max(65535).default(19843),
695
+ sites: z
696
+ .array(
697
+ z.object({
698
+ hosts: z.array(z.string()).default([]),
699
+ cert: z.string().default(''),
700
+ key: z.string().default(''),
701
+ }),
702
+ )
703
+ .default([]), // empty = auto-enumerate all local IPs (see allLocalIPs)
704
+ // ── merged deployment (dsh-passwords in front) ──────────────────────────
705
+ // Shared HMAC key (dsh-passwords MCP_INTERNAL_SECRET). When set, a request
706
+ // arriving from a loopback peer carrying a valid signature is trusted as an
707
+ // authenticated user: this plugin stops being a login door and becomes the
708
+ // ledger observer behind the single door. Empty (default) = inert, i.e. the
709
+ // standalone deployment where this plugin owns the login. See
710
+ // src/trusted-identity.js for the exact trust conditions.
711
+ trustedSecret: z.string().default(''),
712
+ // Accepted signing peers; empty = loopback only (127.0.0.1 / ::1).
713
+ trustedPeers: z.array(z.string()).default([]),
714
+ // Serve the gateway listener over plain HTTP. ONLY legal together with a
715
+ // loopback listenHost: the listener is then reachable by the co-located
716
+ // dsh-passwords gateway alone, which tunnels WebSocket upgrades as raw TCP
717
+ // and therefore cannot speak TLS to this hop. Never enable with 0.0.0.0.
718
+ plaintext: z.boolean().default(false),
719
+ title: z.string().default('DSH 控制台'),
720
+ // Reserved (accepted, not wired): the store owns session lifetime, login
721
+ // failure is not auto-locked (admins set IP bans instead), and handleApi
722
+ // owns its own body-size cap. Kept for the settings card + forward use.
723
+ sessionDays: z.natural().min(1).default(7),
724
+ loginFailLimit: z.natural().min(1).default(5),
725
+ lockoutSeconds: z.natural().min(1).default(60),
726
+ maxBodyBytes: z.natural().min(1024).default(16384),
727
+ })
728
+
729
+ /**
730
+ * Enumerate every non-loopback local IP (IPv4 + IPv6, including Tailscale):
731
+ * skips lo/Loopback + vEthernet/virtual/hyper-v interface names, internal
732
+ * addresses, IPv4 APIPA (169.254.), IPv6 loopback (::1) and link-local
733
+ * (fe80::). Accepts an optional `interfaces` arg (defaults to
734
+ * node:os.networkInterfaces()) so the filter is unit-testable with a stub.
735
+ */
736
+ /** Loopback listener hosts — the only ones a plaintext gateway may bind. */
737
+ function isLoopbackHost(host) {
738
+ const value = String(host || '').trim().toLowerCase()
739
+ return value === '127.0.0.1' || value === 'localhost' || value === '::1'
740
+ }
741
+
742
+ function allLocalIPs(interfaces) {
743
+ const ifaces = interfaces || (() => { try { return networkInterfaces() } catch { return {} } })()
744
+ const out = []
745
+ for (const [name, addrs] of Object.entries(ifaces)) {
746
+ if (/^(lo|Loopback)/i.test(name)) continue
747
+ if (/vEthernet|virtual|hyper-v/i.test(name)) continue
748
+ for (const a of addrs || []) {
749
+ if (a.internal) continue
750
+ const addr = a.address || ''
751
+ if (!addr) continue
752
+ if (a.family === 'IPv4') {
753
+ if (addr.startsWith('169.254.')) continue
754
+ out.push(addr)
755
+ } else if (a.family === 'IPv6') {
756
+ if (addr === '::1' || addr.startsWith('fe80:')) continue
757
+ out.push(addr)
758
+ }
759
+ }
760
+ }
761
+ return out
762
+ }
763
+
764
+ /**
765
+ * Auto-site host list: every local IP plus its sslip.io / nip.io wildcard-DNS
766
+ * aliases. Purely additive SAN coverage — trusting the self-signed cert once
767
+ * then makes IP, pretty-name and wildcard-DNS access all warning-free. (It
768
+ * does NOT remove the untrusted-cert warning by itself; for public IPs a real
769
+ * Let's Encrypt cert via these names is the zero-warning path, and on
770
+ * Tailscale `tailscale cert` + ts.net names remain the better choice.)
771
+ */
772
+ function autoSiteHosts(ips) {
773
+ const list = (ips || []).map(String)
774
+ return ['localhost', ...list, ...list.flatMap((ip) => [`${ip}.sslip.io`, `${ip}.nip.io`])]
775
+ }
776
+
777
+ /**
778
+ * Resolve the effective site list from config + auto-enumerated local IPs.
779
+ *
780
+ * MERGE semantics (v0.5.4): the auto-enumerated host list — localhost, every
781
+ * non-loopback local IP and each IP's sslip.io / nip.io wildcard-DNS alias —
782
+ * ALWAYS forms the self-signed certificate site, so the gateway stays
783
+ * reachable on the LAN / Tailscale / loopback out of the box. Configured
784
+ * sites WITHOUT cert/key are folded into that self-signed site: their hosts
785
+ * are merged in (de-duplicated), covering names the local NICs can't see —
786
+ * e.g. a NAT'd public IP routed to this host that no local interface owns.
787
+ * Configured sites WITH cert/key are kept as independent SNI sites (real
788
+ * domain certs). Empty cfg.sites is the zero-config default (pure auto).
789
+ *
790
+ * Before v0.5.4 a configured sites array REPLACED the auto list entirely,
791
+ * dropping every local IP + alias and breaking local / LAN / Tailscale
792
+ * access with 421 (unknown host) the moment a user added any site.
793
+ */
794
+ function resolveSites(cfg, autoIps) {
795
+ const configured = (cfg && cfg.sites && cfg.sites.length) ? cfg.sites : []
796
+ const extraHosts = configured
797
+ .filter((s) => !s.cert && !s.key)
798
+ .flatMap((s) => s.hosts || [])
799
+ const mergedHosts = [...new Set([...autoSiteHosts(autoIps), ...extraHosts])]
800
+ const domainSites = configured.filter((s) => s.cert || s.key)
801
+ return [{ hosts: mergedHosts, cert: '', key: '' }, ...domainSites]
802
+ }
803
+
804
+ /**
805
+ * The empty-user-store guard. Previously REFUSED to start a non-loopback
806
+ * listener with no registered users (forcing the first admin registration
807
+ * onto loopback). Relaxed to a no-op under the zero-config default (Option B):
808
+ * a non-loopback listener with no users STARTS and the first visitor
809
+ * registers as admin (the user-management model). The safety message moved
810
+ * to bootWarnings, which warns (does not refuse) in that case. Always returns
811
+ * null (safe to apply); kept + exported for tests + forward use.
812
+ */
813
+ function emptyUsersGuard() {
814
+ return null
815
+ }
816
+
817
+ /**
818
+ * Cordis service `user-management` — lets sibling host-plane plugins resolve
819
+ * who is behind an incoming request. Extracted as a factory so tests can
820
+ * exercise it without running the full apply() (which owns the gateway
821
+ * lifecycle).
822
+ *
823
+ * @typedef {Object} UmUser
824
+ * @property {string} id stable user id (e.g. `u_3_a1b2c3d4`)
825
+ * @property {string} username
826
+ * @property {'admin'|'user'} role
827
+ * @property {boolean} disabled always false — disabled users never resolve
828
+ * @property {number} createdAt epoch ms
829
+ * @property {number|null} lastLoginAt
830
+ *
831
+ * @example // consumer (any host-plane plugin):
832
+ * module.exports = {
833
+ * name: 'my-plugin',
834
+ * inject: ['webServer'], // ← do NOT list 'user-management' here;
835
+ * // statically injecting an optional
836
+ * // service hangs your activation
837
+ * apply(ctx) {
838
+ * ctx.inject(['user-management'], (scope) => {
839
+ * const um = scope['user-management']
840
+ * ctx.effect(() => ctx.webServer.register({
841
+ * kind: 'prefix', path: '/my-plugin/api',
842
+ * handler: async (req, res) => {
843
+ * const user = await um.resolveRequest(req)
844
+ * if (!user) return sendJson(res, 401, { error: '未登录' })
845
+ * if (user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员' })
846
+ * // user.username / user.id attribute the action
847
+ * },
848
+ * }), 'my-plugin: api')
849
+ * })
850
+ * },
851
+ * }
852
+ *
853
+ * Both resolvers await the store's async load internally, so the service is
854
+ * safe to call as soon as it is provided. Unauthenticated / expired sessions
855
+ * resolve to null; disabled users never resolve (the store drops their
856
+ * sessions on disable). Browser halves of sibling plugins don't need this
857
+ * service — they can simply `fetch('/user-management/api/session')`, which
858
+ * the gateway answers locally with `{ user: {...} | null }`.
859
+ *
860
+ * @param {{ store: object, ready: Promise<object> }} opts
861
+ * @returns {{ resolveRequest(req: object): Promise<UmUser|null>, resolveToken(token: string): Promise<UmUser|null> }}
862
+ */
863
+ function createIdentityService({ store, ready }) {
864
+ const withSession = async (token) => {
865
+ try { await ready } catch { return null } // broken store → nobody resolves
866
+ const session = await store.resolveSession(token)
867
+ return session ? store.publicUser(session.user) : null
868
+ }
869
+ return {
870
+ /** Resolve the browser request's `um_session` cookie to its user. */
871
+ async resolveRequest(req) {
872
+ const cookies = parseCookies(req.headers && req.headers.cookie)
873
+ return withSession(cookies[SESSION_COOKIE])
874
+ },
875
+ /** Lower-level variant for callers that already extracted the token. */
876
+ async resolveToken(token) {
877
+ return withSession(token)
878
+ },
879
+ }
880
+ }
881
+
882
+ const plugin = {
883
+ name: 'user-management',
884
+ inject: ['webServer'],
885
+ __internals: {
886
+ handleApi,
887
+ sendJson,
888
+ readJsonBody,
889
+ normalizeApiPath,
890
+ statusForStoreError,
891
+ sessionCookie,
892
+ clearedCookie,
893
+ SELF_ACTIVITY_TYPES,
894
+ ADMIN_ACTIVITY_TYPES,
895
+ AUDIT_LIMIT_DEFAULT,
896
+ Config,
897
+ emptyUsersGuard,
898
+ allLocalIPs,
899
+ autoSiteHosts,
900
+ createIdentityService,
901
+ resolveSites,
902
+ createOtpGuard,
903
+ totpQrSvg,
904
+ },
905
+ apply(ctx, config = {}) {
906
+ const pluginName = 'user-management'
907
+ const store = createStore({ home: dshHome() })
908
+ const ready = store.load().catch((error) => {
909
+ console.error(`[${pluginName}] store load failed:`, error && error.message)
910
+ throw error
911
+ })
912
+ const socketIp = (req) => normalizeIp(req.socket && req.socket.remoteAddress)
913
+ // Merged deployment: the co-located dsh-passwords gateway signs the user —
914
+ // and the real client IP it saw — onto every request it proxies here.
915
+ // Memoized per request: the decider, both ledger hooks and the API all ask,
916
+ // and each ask would otherwise re-run the HMAC.
917
+ // Created inert and (re)built inside every rebuild: `config` here is only
918
+ // the composition base — the real values live in the settings namespace,
919
+ // which registers *after* apply() (see refreshTrustedIdentity below). The
920
+ // closure stays stable so deps/decider can hold it while the secret moves.
921
+ let trustedBase = createTrustedIdentity({ secret: '' })
922
+ let trustedCache = new WeakMap()
923
+ const resolveTrusted = (req) => {
924
+ if (!req || typeof req !== 'object') return null
925
+ if (trustedCache.has(req)) return trustedCache.get(req)
926
+ const session = trustedBase(req)
927
+ trustedCache.set(req, session)
928
+ return session
929
+ }
930
+ /** Rebuild the verifier from the live settings (secret/peers are hot). */
931
+ const refreshTrustedIdentity = (cfg) => {
932
+ trustedBase = createTrustedIdentity({ secret: cfg.trustedSecret, peers: cfg.trustedPeers })
933
+ trustedCache = new WeakMap()
934
+ }
935
+ /** True client IP: the signed upstream value when present, else the socket
936
+ * peer. Behind the single door every socket peer is 127.0.0.1, so without
937
+ * this the ledgers' IP column — and IP banning — would be meaningless. */
938
+ const clientIp = (req) => {
939
+ const trusted = resolveTrusted(req)
940
+ return (trusted && trusted.clientIp) || socketIp(req)
941
+ }
942
+ const deps = { store, clientIp, resolveTrusted }
943
+
944
+ // Identity service for sibling plugins (see createIdentityService).
945
+ ctx.provide('user-management', createIdentityService({ store, ready }))
946
+
947
+
948
+ const dataDir = join(dshHome(), 'user-management')
949
+ const certsDir = join(dataDir, 'certs')
950
+
951
+ const log = (msg) => console.log(`[${pluginName}] ${msg}`)
952
+ const warn = (msg) => console.warn(`[${pluginName}] ${msg}`)
953
+ log(`gateway plugin v${pkg.version} starting (pid ${process.pid})`)
954
+
955
+ /** The injected webServer service carries the real bound dsh port. */
956
+ const resolveUpstream = (cfg) => {
957
+ try {
958
+ const ws = ctx.webServer
959
+ if (ws && typeof ws.port === 'number') return `http://127.0.0.1:${ws.port}`
960
+ } catch { /* fall through */ }
961
+ warn('user-management: webServer service unavailable — assuming upstream http://127.0.0.1:3080')
962
+ return 'http://127.0.0.1:3080'
963
+ }
964
+
965
+ // dsh 0.1.2-rc.1+ gates `/` index.html behind a launchToken + signed
966
+ // browser cookie (dsh-client-connection BrowserAuth). The injected
967
+ // `connection` service exposes the PUBLIC authenticatedUrl(baseUrl)
968
+ // builder — the sanctioned way to obtain a token-carrying URL (reading
969
+ // BrowserAuth.launchToken directly would couple us to a TS-private
970
+ // field). We hand the gateway a builder that binds the token URL to the
971
+ // upstream origin at call time, so hot-reloaded upstreams stay in sync.
972
+ // On 0.1.1-rc.2 (no BrowserAuth) the builder stays null and the proxy
973
+ // falls through to a plain passthrough — backward compatible.
974
+ let authenticatedUrlBuilder = null
975
+ if (typeof ctx.inject === 'function') {
976
+ ctx.inject(['connection'], (fiber) => {
977
+ try {
978
+ // ctx.inject delivers a fiber whose `.connection` is the registered
979
+ // HostConnectionService (cf. the existing settings inject: scope.settings).
980
+ const conn = (fiber && fiber.connection) || fiber
981
+ authenticatedUrlBuilder = conn && typeof conn.authenticatedUrl === 'function'
982
+ ? (origin) => {
983
+ try { return conn.authenticatedUrl(origin) } catch { return null }
984
+ }
985
+ : null
986
+ } catch { authenticatedUrlBuilder = null }
987
+ })
988
+ }
989
+ // Binds at call time: proxy.js invokes getAuthenticatedUrl(origin) and
990
+ // needs the token URL string, not the builder function itself. Returning
991
+ // the bare builder (v0.8.0) made new URL(fn) throw inside the mint and
992
+ // silently skip cookie minting — masked on 0.1.1-rc.2 where the builder
993
+ // is null and the plain-proxy fallback was used regardless.
994
+ const getAuthenticatedUrl = (origin) => (authenticatedUrlBuilder ? authenticatedUrlBuilder(origin) : null)
995
+
996
+ // The decider runs on every gateway request: IP-ban check (403) → session
997
+ // resolve → gate decision (allow/redirect/401). onAccess feeds the activity
998
+ // ledger; the gateway wires onApiRequest/onWsOpen to the audit ledger.
999
+ const decider = createDecider({
1000
+ resolveSession: async (token) => { await ready; return store.resolveSession(token) },
1001
+ resolveTrusted,
1002
+ getClientIp: clientIp,
1003
+ isBanned: (ip) => store.isBanned(ip),
1004
+ onAccess: (req, resolved, path) => {
1005
+ store.appendActivity({
1006
+ type: 'access',
1007
+ username: resolved ? resolved.user.username : null,
1008
+ userId: resolved ? resolved.user.id : null,
1009
+ ip: clientIp(req),
1010
+ detail: path,
1011
+ }).catch(() => {})
1012
+ },
1013
+ })
1014
+ const auditEntry = (session, method, path, ip, status, type) => ({
1015
+ type,
1016
+ username: session ? session.user.username : null,
1017
+ userId: session ? session.user.id : null,
1018
+ ip,
1019
+ method,
1020
+ path,
1021
+ status: status === undefined ? null : status,
1022
+ })
1023
+ const auditHooks = {
1024
+ onApiRequest: (req, session, path, status) => {
1025
+ store.appendAudit(auditEntry(session, (req.method || 'GET').toUpperCase(), path, clientIp(req), status, 'api')).catch(() => {})
1026
+ },
1027
+ onWsOpen: (req, session, path) => {
1028
+ store.appendAudit(auditEntry(session, 'WS', path, clientIp(req), null, 'ws')).catch(() => {})
1029
+ },
1030
+ }
1031
+
1032
+ // ── gateway lifecycle: hot-reload (bind-then-swap) + self-heal ────────
1033
+ let settingsScope = null
1034
+ let current = null
1035
+ let currentOptions = null
1036
+ let startedAt = null
1037
+ let lastError = ''
1038
+ let lastOnErrorAt = 0
1039
+ let restarting = false
1040
+ let rebuildChain = Promise.resolve()
1041
+ const resolvedConfig = () => (settingsScope ? settingsScope.get() : config)
1042
+ // Registration approval switch, read per request so settings hot-reload applies.
1043
+ deps.autoActivate = () => resolvedConfig().autoActivate === true
1044
+ // Observer mode = a signing secret is configured, i.e. dsh-passwords owns
1045
+ // the login. Read per request so a settings change flips the UI live.
1046
+ deps.observerMode = () => String(resolvedConfig().trustedSecret || '') !== ''
1047
+ /**
1048
+ * Permissions for a session — the enforcement hooks' one question for the
1049
+ * host half.
1050
+ *
1051
+ * A trusted (signed) session carries no permissions of its own, so the
1052
+ * account record here is the source of truth, matched by username. An
1053
+ * unknown username yields null, which the gateway reads as "unrestricted":
1054
+ * enforcement is a property of the accounts this plugin owns, and inventing
1055
+ * restrictions for a name it has never seen would lock people out mid-migration.
1056
+ */
1057
+ /**
1058
+ * Quota gate for the request path: accrue activity, then refuse once a
1059
+ * daily-minute or hourly-token allowance is spent.
1060
+ *
1061
+ * Only accounts that carry a limit are tracked at all — an account with no
1062
+ * limits must not pay a usage write for every request it makes. Accrual is
1063
+ * throttled by wall clock, and the elapsed time since the previous touch is
1064
+ * what gets added, so the counter tracks real activity rather than request
1065
+ * count.
1066
+ *
1067
+ * @returns {string|null} a refusal reason, or null to proceed.
1068
+ */
1069
+ deps.quotaRefusal = (session, path) => {
1070
+ const username = session && session.user && session.user.username
1071
+ if (typeof username !== 'string' || username === '') return null
1072
+ const user = store.findUserByUsername(username)
1073
+ if (!user) return null
1074
+ const perms = normalizePermissions(user.permissions)
1075
+ const daily = perms.dailyMinuteLimit
1076
+ const hourly = perms.hourlyTokenLimit
1077
+ if (daily === null && hourly === null) return null
1078
+ if (typeof path === 'string' && STATIC_PATH_RE.test(path)) return null
1079
+
1080
+ const ts = Date.now()
1081
+ const previous = lastUsageTouch.get(username) || 0
1082
+ if (ts - previous >= USAGE_TOUCH_INTERVAL_MS) {
1083
+ lastUsageTouch.set(username, ts)
1084
+ // Fire and forget: a quota counter must never add latency to a request.
1085
+ void store.touchUsage(username, previous === 0 ? 0 : (ts - previous) / 1000).catch(() => {})
1086
+ }
1087
+ const usage = store.getUsage(username)
1088
+ if (!usage) return null
1089
+ if (daily !== null && usage.activeSeconds >= daily * 60) return '每日时长配额已用完'
1090
+ if (hourly !== null && usage.hourlyTokens >= hourly) return '每小时 token 配额已用完'
1091
+ return null
1092
+ }
1093
+
1094
+ /**
1095
+ * Workspace ownership, exposed to the gateway's enforcement hooks.
1096
+ *
1097
+ * The gateway owns no storage: it asks these four questions and applies the
1098
+ * answers, which keeps the ownership policy testable at the store level and
1099
+ * the HTTP mechanics testable at the proxy level.
1100
+ */
1101
+ // Error → status mapping lives with the store's own error types.
1102
+ deps.statusForError = (error) => statusForStoreError(error)
1103
+ deps.workspaceOwners = () => store.workspaceOwners()
1104
+ deps.claimWorkspace = (path, username) => store.claimWorkspace(path, username)
1105
+ deps.releaseWorkspace = (path) => store.releaseWorkspace(path)
1106
+ deps.renameWorkspace = (oldPath, newPath) => store.renameWorkspace(oldPath, newPath)
1107
+
1108
+ deps.permissionsFor = (session) => {
1109
+ const username = session && session.user && session.user.username
1110
+ if (typeof username !== 'string' || username === '') return null
1111
+ const user = store.findUserByUsername(username)
1112
+ if (!user) return null
1113
+ return normalizePermissions(user.permissions)
1114
+ }
1115
+
1116
+ const queueRebuild = (force = false) => {
1117
+ rebuildChain = rebuildChain
1118
+ .then(async () => {
1119
+ await ready
1120
+ const cfg = resolvedConfig()
1121
+ refreshTrustedIdentity(cfg)
1122
+ if (cfg.enabled === false) {
1123
+ if (current) log('user-management: disabled — listener stopped')
1124
+ restarting = false
1125
+ stopHealthCheck()
1126
+ try { current && current.stop() } catch (e) { warn(`user-management: error stopping listener — ${e.message || e}`) }
1127
+ current = null
1128
+ currentOptions = null
1129
+ startedAt = null
1130
+ return
1131
+ }
1132
+ // Site resolution (MERGE, v0.5.4): auto-enumerated hosts (localhost
1133
+ // + every non-loopback local IP + their sslip.io / nip.io aliases)
1134
+ // always form the self-signed cert site, so the gateway stays
1135
+ // reachable on the LAN/Tailscale/loopback. Configured sites WITHOUT
1136
+ // cert/key fold their hosts into that self-signed site (covers names
1137
+ // the local NICs can't see, e.g. a NAT'd public IP); configured
1138
+ // sites WITH cert/key stay independent SNI sites (real domain
1139
+ // certs). Empty cfg.sites = pure auto (zero-config default).
1140
+ const sites = resolveSites(cfg, allLocalIPs())
1141
+ // Plaintext is legal on loopback only: it exists so the co-located
1142
+ // dsh-passwords gateway can reach this observer through the raw TCP
1143
+ // tunnel it uses for WebSocket upgrades. Any other bind would expose
1144
+ // the observer unencrypted, so it is refused rather than honoured.
1145
+ if (cfg.plaintext && !isLoopbackHost(cfg.listenHost)) {
1146
+ warn(`user-management: plaintext listener pinned to 127.0.0.1 (configured listenHost "${cfg.listenHost}" would serve it unencrypted)`)
1147
+ }
1148
+ const options = {
1149
+ listenHost: cfg.plaintext ? '127.0.0.1' : cfg.listenHost,
1150
+ plaintext: cfg.plaintext === true,
1151
+ port: cfg.port,
1152
+ upstream: resolveUpstream(cfg),
1153
+ getAuthenticatedUrl,
1154
+ sites,
1155
+ certsDir,
1156
+ title: cfg.title,
1157
+ decider,
1158
+ handleApi,
1159
+ renderLoginPage,
1160
+ deps,
1161
+ clearedCookie,
1162
+ auditHooks,
1163
+ log,
1164
+ warn,
1165
+ onError: (error) => {
1166
+ const now = Date.now()
1167
+ if (now - lastOnErrorAt < 30000) {
1168
+ warn('user-management: listener error recurring — suppressing auto-restart')
1169
+ return
1170
+ }
1171
+ lastOnErrorAt = now
1172
+ void queueRebuild(true)
1173
+ },
1174
+ }
1175
+ if (current && !force) {
1176
+ // Two-tier hot reload: request-time fields mutate in place (no gap);
1177
+ // listener-affecting fields restart the server.
1178
+ const restartNeeded =
1179
+ currentOptions.listenHost !== options.listenHost ||
1180
+ currentOptions.plaintext !== options.plaintext ||
1181
+ currentOptions.port !== options.port ||
1182
+ currentOptions.upstream !== options.upstream ||
1183
+ JSON.stringify(currentOptions.sites) !== JSON.stringify(options.sites)
1184
+ if (!restartNeeded) {
1185
+ restarting = false
1186
+ Object.assign(currentOptions, options)
1187
+ currentOptions.sites = options.sites
1188
+ return
1189
+ }
1190
+ log('user-management: listener settings changed — restarting')
1191
+ }
1192
+ // A listener swap tears down the very connection that requested it.
1193
+ // Give the in-flight response a beat to flush before closing the old server.
1194
+ if (current) await new Promise((resolve) => setTimeout(resolve, 120))
1195
+ restarting = true
1196
+ stopHealthCheck()
1197
+ try { current && current.stop() } catch (e) { warn(`user-management: error stopping previous listener — ${e.message || e}`) }
1198
+ current = null
1199
+ currentOptions = null
1200
+ startedAt = null
1201
+ lastError = ''
1202
+ try {
1203
+ const next = createGateway(options)
1204
+ const port = await next.start() // bind-then-swap: only publish `current` after a successful bind
1205
+ current = next
1206
+ currentOptions = options
1207
+ startedAt = new Date().toISOString()
1208
+ restarting = false
1209
+ bootWarnings(cfg, sites, port)
1210
+ startHealthCheck()
1211
+ } catch (error) {
1212
+ restarting = false
1213
+ stopHealthCheck()
1214
+ lastError = error.message || String(error)
1215
+ warn(`user-management: failed to apply configuration, gateway is down — ${lastError}`)
1216
+ }
1217
+ })
1218
+ .catch(() => {}) // a contained rebuild never poisons the chain
1219
+ return rebuildChain
1220
+ }
1221
+
1222
+ function bootWarnings(cfg, sites, port) {
1223
+ const listenHost = String((cfg && cfg.listenHost) || '')
1224
+ const loopbackOnly = /^(127\.0\.0\.1|::1|localhost)$/i.test(listenHost)
1225
+ if (!loopbackOnly && store.listUsers().length === 0) {
1226
+ warn(`user-management: non-loopback listener with no registered users — the first visitor will register as admin; ensure the network is trusted (or register the first admin on loopback 127.0.0.1:${port} first)`)
1227
+ }
1228
+ const hosts = (sites || []).flatMap((s) => s.hosts || [])
1229
+ // After MERGE the self-signed site always carries localhost + every
1230
+ // local IP, so this warning only fires when there genuinely is no
1231
+ // non-loopback host anywhere (no local NICs + nothing configured) —
1232
+ // i.e. the gateway is about to be exposed with no reachable name.
1233
+ const hasNonLoopbackHost = hosts.some((h) => h && h !== 'localhost' && !/^(127\.|::1$)/.test(h))
1234
+ if (!hasNonLoopbackHost) {
1235
+ warn(`user-management: listening on port ${port} but no public hostname is configured — add sites[].hosts before exposing it`)
1236
+ }
1237
+ }
1238
+
1239
+ // ── self-heal: 60s HTTPS probe, rebuild only after 3 consecutive failures ─
1240
+ let healthTimer = null
1241
+ let checking = false
1242
+ let healthFails = 0
1243
+ const HEALTH_FAIL_LIMIT = 3
1244
+
1245
+ // Self-heal probe picks the first non-loopback IPv4 (falls back to loopback).
1246
+ const primaryIPv4 = () => allLocalIPs().find((ip) => ip.includes('.')) || null
1247
+
1248
+ // Plaintext twin of probeHttps — see the call site: probing a plaintext
1249
+ // listener with TLS would fail 3× and rebuild it in a loop forever.
1250
+ const probeHttp = (host, port, timeoutMs = 4000) =>
1251
+ new Promise((resolve) => {
1252
+ let settled = false
1253
+ const done = (ok) => { if (settled) return; settled = true; req.destroy(); resolve(ok) }
1254
+ const req = httpRequest(
1255
+ { host, port, path: '/', method: 'GET', timeout: timeoutMs, headers: { Host: host } },
1256
+ (res) => { res.resume(); done(true) },
1257
+ )
1258
+ req.on('timeout', () => done(false))
1259
+ req.on('error', () => done(false))
1260
+ req.end()
1261
+ })
1262
+
1263
+ const probeHttps = (host, port, timeoutMs = 4000) =>
1264
+ new Promise((resolve) => {
1265
+ let settled = false
1266
+ const done = (ok) => { if (settled) return; settled = true; req.destroy(); resolve(ok) }
1267
+ const req = httpsRequest(
1268
+ { host, port, path: '/', method: 'GET', rejectUnauthorized: false, timeout: timeoutMs, headers: { Host: host } },
1269
+ (res) => { res.resume(); done(true) },
1270
+ )
1271
+ req.on('timeout', () => done(false))
1272
+ req.on('error', () => done(false))
1273
+ req.end()
1274
+ })
1275
+
1276
+ const startHealthCheck = () => { stopHealthCheck(); healthTimer = setInterval(() => { void checkHealth() }, 60000); healthTimer.unref && healthTimer.unref() }
1277
+ const stopHealthCheck = () => { if (healthTimer) { clearInterval(healthTimer); healthTimer = null } }
1278
+ const checkHealth = async () => {
1279
+ const gw = current
1280
+ if (!gw || typeof gw.port !== 'number' || checking) return
1281
+ checking = true
1282
+ try {
1283
+ const host =
1284
+ currentOptions && currentOptions.listenHost === '0.0.0.0'
1285
+ ? (primaryIPv4() || '127.0.0.1')
1286
+ : ((currentOptions && currentOptions.listenHost) || '127.0.0.1')
1287
+ const probe = (currentOptions && currentOptions.plaintext === true) ? probeHttp : probeHttps
1288
+ const ok = await probe(host, gw.port)
1289
+ if (current !== gw) return
1290
+ if (ok) { if (healthFails > 0) healthFails = 0; return }
1291
+ healthFails += 1
1292
+ if (healthFails >= HEALTH_FAIL_LIMIT) {
1293
+ healthFails = 0
1294
+ warn(`user-management: listener health check failed ${HEALTH_FAIL_LIMIT}x consecutively — not serving, restarting`)
1295
+ await queueRebuild(true)
1296
+ } else {
1297
+ warn(`user-management: HTTPS health check failed (${healthFails}/${HEALTH_FAIL_LIMIT}) — will retry`)
1298
+ }
1299
+ } finally {
1300
+ checking = false
1301
+ }
1302
+ }
1303
+
1304
+ // ── /user-management/panel — admin-only status (reached via the gateway proxy) ─
1305
+ ctx.effect(() => ctx.webServer.register({
1306
+ kind: 'prefix',
1307
+ path: '/user-management/panel',
1308
+ handler: async (req, res) => {
1309
+ const method = (req.method || 'GET').toUpperCase()
1310
+ if (method !== 'GET' && method !== 'HEAD') return sendJson(res, 405, { error: 'method not allowed' })
1311
+ await ready
1312
+ const cookies = parseCookies(req.headers && req.headers.cookie)
1313
+ const session = await store.resolveSession(cookies[SESSION_COOKIE])
1314
+ if (!session || session.user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限' })
1315
+ const cfg = resolvedConfig()
1316
+ const phase = cfg.enabled === false ? 'disabled' : restarting ? 'restarting' : current ? 'running' : lastError ? 'error' : 'stopped'
1317
+ return sendJson(res, 200, {
1318
+ version: pkg.version,
1319
+ enabled: cfg.enabled !== false,
1320
+ phase,
1321
+ startedAt,
1322
+ lastError,
1323
+ listenHost: cfg.listenHost,
1324
+ port: (current && current.port) || cfg.port,
1325
+ sites: (cfg.sites || []).map((s) => ({ hosts: s.hosts || [], cert: s.cert ? 'file' : 'auto' })),
1326
+ users: store.listUsers().length,
1327
+ })
1328
+ },
1329
+ }), `${pluginName}: panel route`)
1330
+
1331
+ // ── settings namespace: hot-reload on every committed change ────────────
1332
+ if (typeof ctx.inject === 'function') {
1333
+ ctx.inject(['settings'], (scope) => {
1334
+ try {
1335
+ const registration = scope.settings.register('user-management', Config, { base: config })
1336
+ settingsScope = registration
1337
+ registration.watch(() => { void queueRebuild() })
1338
+ void queueRebuild()
1339
+ } catch (error) {
1340
+ warn(`user-management: settings namespace unavailable, using the composition config only — ${error.message || error}`)
1341
+ void queueRebuild()
1342
+ }
1343
+ })
1344
+ }
1345
+
1346
+ // Profiles without a settings service still get the gateway from composition config.
1347
+ void queueRebuild()
1348
+
1349
+ ctx.on('dispose', () => {
1350
+ stopHealthCheck()
1351
+ if (current) log('user-management: plugin disposed — stopping listener')
1352
+ try { current && current.stop() } catch (e) { warn(`user-management: error stopping listener on dispose — ${e.message || e}`) }
1353
+ current = null
1354
+ })
1355
+ },
1356
+ }
1357
+
1358
+ export default plugin