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/store.js ADDED
@@ -0,0 +1,1072 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * user-management store — users / sessions / activity ledger.
5
+ *
6
+ * Everything lives under `$DSH_HOME/user-management/`:
7
+ * - users.json accounts (scrypt password hashes), mode 0600, atomic writes
8
+ * - sessions.json issued session tokens (survive restarts), same protection
9
+ * - activity.jsonl append-only event ledger (logins, admin actions, page
10
+ * accesses), rolled over to the tail MAX_LEDGER_LINES
11
+ *
12
+ * No third-party dependencies: hashing is crypto.scrypt, tokens are
13
+ * crypto.randomBytes. All mutation paths are async and serialize through
14
+ * per-file write chains so concurrent requests cannot interleave writes.
15
+ */
16
+
17
+ import fsP from 'node:fs/promises'
18
+ import net from 'node:net'
19
+ import { randomBytes, scrypt as scryptCb, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
20
+ import { promisify } from 'node:util'
21
+ import { join, resolve } from 'node:path'
22
+ import { homedir } from 'node:os'
23
+ import { generateSecret, verifyTotp } from './totp.js'
24
+ import { DENY_ALL_WORKSPACES, isWorkspaceRestricted, normalizePath } from './permissions.js'
25
+ // Only used to verify hashes imported from dsh-passwords: its accounts are
26
+ // bcrypt and a hash cannot be converted, so the record keeps its algorithm
27
+ // marker and is transparently re-hashed to scrypt on the next sign-in.
28
+ import bcrypt from 'bcryptjs'
29
+
30
+ const scrypt = promisify(scryptCb)
31
+
32
+ const USERS_FILE = 'users.json'
33
+ const SESSIONS_FILE = 'sessions.json'
34
+ const ACTIVITY_FILE = 'activity.jsonl'
35
+ const AUDIT_FILE = 'audit.jsonl'
36
+ const BANS_FILE = 'bans.json'
37
+ const USAGE_FILE = 'usage.json'
38
+ const WORKSPACES_FILE = 'workspaces.json'
39
+
40
+ /** scrypt key length / cost — modest defaults, login is not a hot path. */
41
+ const KEY_LEN = 32
42
+ const SCRYPT_COST = 16384
43
+ /** Token quota window: tokens are counted per rolling hour. */
44
+ const HOURLY_WINDOW_MS = 60 * 60 * 1000
45
+
46
+ /** Session lifetime; sliding — each authenticated check past half-life renews. */
47
+ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
48
+ const SESSION_RENEW_MS = SESSION_TTL_MS / 2
49
+ /** Ledger rollover: trim to the tail once the file grows past 2× the cap. */
50
+ const MAX_LEDGER_LINES = 2000
51
+
52
+ // ── per-user permissions & quotas ───────────────────────────────────────────
53
+ //
54
+ // Ported from dsh-passwords' user_permissions row so this plugin can own
55
+ // accounts, workspace scope and usage limits on its own. Defaults are
56
+ // permissive: an empty folder list means "every workspace" (the same reading
57
+ // dsh-passwords gives it), a null limit means "no cap". A record written
58
+ // before this model existed has no permissions block and reads exactly as
59
+ // these defaults, so migration is a no-op for existing users.
60
+
61
+ /** Sandbox floors the gateway may pin an account to (dsh-passwords' ranking). */
62
+ const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
63
+
64
+ /** Marker for a hash imported from dsh-passwords (bcrypt, not convertible). */
65
+ const BCRYPT_ALGO = 'bcrypt'
66
+
67
+ /** A broken bcrypt dependency is reported once, not once per login attempt. */
68
+ let bcryptFailureReported = false
69
+
70
+ const DEFAULT_PERMISSIONS = Object.freeze({
71
+ /** Absolute folders this account may touch; empty = unrestricted. */
72
+ allowedFolders: [],
73
+ /** Tokens per rolling hour; null = unlimited. */
74
+ hourlyTokenLimit: null,
75
+ /** Minutes of agent time per day; null = unlimited. */
76
+ dailyMinuteLimit: null,
77
+ /** Floor this account's sandbox to a mode; null = leave the choice to it. */
78
+ sandboxMode: null,
79
+ /** Allow uploads / git operations through the gateway. dsh-passwords ships
80
+ * these asymmetric (upload on, git download off) and that asymmetry is kept:
81
+ * a git clone can pull an entire repository out of the host. */
82
+ allowUpload: true,
83
+ allowGit: false,
84
+ /** Allow creating new workspaces, not just using existing ones (dp default:
85
+ * off — a workspace is a place an agent may write to). */
86
+ allowWorkspaceCreate: false,
87
+ /** Gate-level deny (sessions dropped, sign-in refused): unlike `disabled`
88
+ * this is a policy state the enforcement layer reads on every request. */
89
+ banned: false,
90
+ /** Free-form admin note. */
91
+ note: '',
92
+ })
93
+
94
+ function normalizeFolders(raw) {
95
+ if (!Array.isArray(raw)) return []
96
+ const out = []
97
+ for (const entry of raw) {
98
+ const value = String(entry ?? '').trim()
99
+ if (value !== '' && !out.includes(value)) out.push(value)
100
+ }
101
+ return out
102
+ }
103
+
104
+ /** Accept only a non-negative finite number; anything else means "no cap". */
105
+ function normalizeLimit(raw) {
106
+ if (raw === null || raw === undefined || raw === '') return null
107
+ const value = Number(raw)
108
+ if (!Number.isFinite(value) || value < 0) return null
109
+ return Math.floor(value)
110
+ }
111
+
112
+ /**
113
+ * Coerce anything (a fresh record, an old record, an API patch) into a
114
+ * complete, valid permissions block. Every field is validated here rather than
115
+ * at each call site: an invalid sandbox mode or a negative limit must never
116
+ * reach the enforcement layer.
117
+ */
118
+ function normalizePermissions(raw) {
119
+ const source = raw && typeof raw === 'object' ? raw : {}
120
+ return {
121
+ allowedFolders: normalizeFolders(source.allowedFolders),
122
+ hourlyTokenLimit: normalizeLimit(source.hourlyTokenLimit),
123
+ dailyMinuteLimit: normalizeLimit(source.dailyMinuteLimit),
124
+ sandboxMode: SANDBOX_MODES.includes(source.sandboxMode) ? source.sandboxMode : null,
125
+ // Only an explicit true enables these: absent means the safe default, both
126
+ // for a fresh record and for one written before a field existed.
127
+ allowUpload: source.allowUpload !== false,
128
+ allowGit: source.allowGit === true,
129
+ allowWorkspaceCreate: source.allowWorkspaceCreate === true,
130
+ banned: source.banned === true,
131
+ note: typeof source.note === 'string' ? source.note.slice(0, 200) : '',
132
+ }
133
+ }
134
+
135
+ // Workspace-scope semantics live in ./permissions.js and are re-exported here:
136
+ // one implementation for the store, the API and the enforcement layer.
137
+ /** Operation audit ledger (separate file — must not be rolled away by
138
+ * security events or vice versa). Same 2× trim rule. */
139
+ const MAX_AUDIT_LINES = 5000
140
+
141
+ const USERNAME_RE = /^[a-zA-Z0-9_-]{2,32}$/
142
+ const MIN_PASSWORD_LEN = 6
143
+ const ACTIVITY_LIMIT_DEFAULT = 200
144
+
145
+ class StoreError extends Error {
146
+ constructor(code, message) {
147
+ super(message)
148
+ this.code = code
149
+ }
150
+ }
151
+
152
+ /** dsh data root — mirrors the host ($DSH_HOME, default ~/.dsh). */
153
+ function dshHome() {
154
+ return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), '.dsh')
155
+ }
156
+
157
+ function isValidUsername(name) {
158
+ return typeof name === 'string' && USERNAME_RE.test(name)
159
+ }
160
+
161
+ function isValidPassword(pw) {
162
+ return typeof pw === 'string' && pw.length >= MIN_PASSWORD_LEN && pw.length <= 256
163
+ }
164
+
165
+ async function hashPassword(password, salt) {
166
+ const useSalt = salt || randomBytes(16).toString('hex')
167
+ const derived = await scrypt(password, useSalt, KEY_LEN, { N: SCRYPT_COST, r: 8, p: 1 })
168
+ return { salt: useSalt, passHash: derived.toString('hex') }
169
+ }
170
+
171
+ /**
172
+ * Constant-time password check against a stored record.
173
+ *
174
+ * Native records are scrypt ({ salt, passHash }). Records imported from
175
+ * dsh-passwords carry { algo: 'bcrypt', passHash } instead: a bcrypt hash
176
+ * cannot be converted, so it is verified as-is and upgraded on the next
177
+ * successful sign-in (see checkLogin).
178
+ */
179
+ function verifyPassword(record, password) {
180
+ if (!record || !record.passHash || typeof password !== 'string') return false
181
+ if (record.algo === BCRYPT_ALGO) {
182
+ if (typeof record.passHash !== 'string' || record.passHash === '') return false
183
+ try {
184
+ return bcrypt.compareSync(password, record.passHash)
185
+ } catch (error) {
186
+ // Swallowing this silently would make "the dependency is missing or the
187
+ // hash is malformed" indistinguishable from "wrong password" — the account
188
+ // would simply never be able to sign in, with nothing said about it.
189
+ if (!bcryptFailureReported) {
190
+ bcryptFailureReported = true
191
+ console.error('[user-management] bcrypt verification failed; imported accounts cannot sign in:', error && error.message)
192
+ }
193
+ return false
194
+ }
195
+ }
196
+ if (!record.salt) return false
197
+ const derived = scryptSync(password, record.salt, KEY_LEN, { N: SCRYPT_COST, r: 8, p: 1 })
198
+ const expected = Buffer.from(record.passHash, 'hex')
199
+ if (derived.length !== expected.length) return false
200
+ return timingSafeEqual(derived, expected)
201
+ }
202
+
203
+ /** Random URL-safe token (session ids, one-time reset passwords). */
204
+ function randomToken(bytes = 32) {
205
+ return randomBytes(bytes).toString('base64url')
206
+ }
207
+
208
+ /** Temps are 12 chars but human-typeable: groups of 4. */
209
+ function tempPassword() {
210
+ const raw = randomToken(9) // 12 base64url chars
211
+ return raw.slice(0, 4) + '-' + raw.slice(4, 8) + '-' + raw.slice(8, 12)
212
+ }
213
+
214
+ /** Stable token fingerprint for ledger entries — never log the token itself. */
215
+ function tokenFingerprint(token) {
216
+ return createHash('sha256').update(String(token)).digest('hex').slice(0, 12)
217
+ }
218
+
219
+ function normalizeIp(raw) {
220
+ if (!raw) return ''
221
+ let ip = String(raw)
222
+ if (ip.startsWith('::ffff:')) ip = ip.slice(7)
223
+ if (ip === '::1') ip = '127.0.0.1'
224
+ return ip
225
+ }
226
+
227
+ /**
228
+ * Create a store rooted at `<home>/user-management`. All state is cached in
229
+ * memory after load; writes persist through serialized atomic replacements.
230
+ */
231
+ function createStore({ home, now = () => Date.now() } = {}) {
232
+ const dir = join(home, 'user-management')
233
+ const usersFile = join(dir, USERS_FILE)
234
+ const sessionsFile = join(dir, SESSIONS_FILE)
235
+ const activityFile = join(dir, ACTIVITY_FILE)
236
+ const auditFile = join(dir, AUDIT_FILE)
237
+ const bansFile = join(dir, BANS_FILE)
238
+ const usageFile = join(dir, USAGE_FILE)
239
+ const workspacesFile = join(dir, WORKSPACES_FILE)
240
+
241
+ /** @type {{seq:number, users:Array}} */
242
+ let usersDoc = { seq: 0, users: [] }
243
+ /** @type {{tokens:Object<string,{userId,username,createdAt,expiresAt}>}} */
244
+ let sessionsDoc = { tokens: {} }
245
+ /** @type {{bans:Array<{ip,note,createdAt,createdBy}>}} */
246
+ let bansDoc = { bans: [] }
247
+ /**
248
+ * Per-account quota counters, keyed by lower-cased username.
249
+ *
250
+ * Keyed by username rather than user id so the same accounting works for a
251
+ * store session and for a signed identity arriving from a login gateway
252
+ * (whose id is synthetic). A rename therefore starts a fresh window — an
253
+ * acceptable trade for a quota, which is a rate limit rather than an audit
254
+ * record.
255
+ *
256
+ * @type {Record<string, {day:string, activeSeconds:number, firstSeenAt:number, lastActiveAt:number, hourlyWindowStart:number, hourlyTokens:number}>}
257
+ */
258
+ let usageDoc = { users: {} }
259
+ /**
260
+ * Workspace ownership: normalized path → owning username.
261
+ *
262
+ * Ported from dsh-passwords' user_workspaces table. It answers one question
263
+ * the permission switches cannot: this folder is inside your allow-list, but
264
+ * it is somebody else's workspace — so it is not yours to see, rename or
265
+ * delete. The owning account is exempt, and so is an administrator.
266
+ *
267
+ * @type {{owners: Record<string, string>}}
268
+ */
269
+ let workspacesDoc = { owners: {} }
270
+
271
+ /** Lookup used during load, before the public helpers are meaningful. */
272
+ function findUserByUsernameRaw(username) {
273
+ const needle = String(username || '').toLowerCase()
274
+ return usersDoc.users.find((u) => String(u.username).toLowerCase() === needle) || null
275
+ }
276
+
277
+ /** Local-time day key, matching how "daily" is understood by a human. */
278
+ function todayKey(ts) {
279
+ const d = new Date(ts)
280
+ const month = String(d.getMonth() + 1).padStart(2, '0')
281
+ const day = String(d.getDate()).padStart(2, '0')
282
+ return `${d.getFullYear()}-${month}-${day}`
283
+ }
284
+
285
+ /** Roll a record over when the local day changed. */
286
+ function rollDay(entry, ts) {
287
+ const day = todayKey(ts)
288
+ if (entry.day !== day) {
289
+ entry.day = day
290
+ entry.activeSeconds = 0
291
+ entry.hourlyTokens = 0
292
+ entry.hourlyWindowStart = ts
293
+ }
294
+ return entry
295
+ }
296
+ let ledgerLines = 0
297
+ let auditLines = 0
298
+ let auditSeq = 0
299
+ const writeChain = { users: Promise.resolve(), sessions: Promise.resolve(), ledger: Promise.resolve(), audit: Promise.resolve() }
300
+
301
+ function atomicWrite(file, content) {
302
+ return fsP.mkdir(dir, { recursive: true }).then(() => {
303
+ const temp = join(dir, `.${randomBytes(6).toString('hex')}.tmp`)
304
+ return fsP.writeFile(temp, content, { encoding: 'utf8', mode: 0o600 })
305
+ .then(() => fsP.rename(temp, file))
306
+ })
307
+ }
308
+
309
+ function persistUsers() {
310
+ const run = writeChain.users.then(() => atomicWrite(usersFile, JSON.stringify(usersDoc, null, 2)))
311
+ writeChain.users = run.catch(() => {})
312
+ return run
313
+ }
314
+
315
+ function persistWorkspaces() {
316
+ const run = writeChain.users.then(() => atomicWrite(workspacesFile, JSON.stringify(workspacesDoc, null, 2)))
317
+ writeChain.users = run.catch(() => {})
318
+ return run
319
+ }
320
+
321
+ function persistUsage() {
322
+ const run = writeChain.users.then(() => atomicWrite(usageFile, JSON.stringify(usageDoc, null, 2)))
323
+ writeChain.users = run.catch(() => {})
324
+ return run
325
+ }
326
+
327
+ function persistBans() {
328
+ const run = writeChain.users.then(() => atomicWrite(bansFile, JSON.stringify(bansDoc, null, 2)))
329
+ writeChain.users = run.catch(() => {})
330
+ return run
331
+ }
332
+
333
+ function persistSessions() {
334
+ const run = writeChain.sessions.then(() => atomicWrite(sessionsFile, JSON.stringify(sessionsDoc)))
335
+ writeChain.sessions = run.catch(() => {})
336
+ return run
337
+ }
338
+
339
+ async function load() {
340
+ await fsP.mkdir(dir, { recursive: true })
341
+ for (const [file, assign] of [
342
+ [usersFile, (data) => { usersDoc = data }],
343
+ [sessionsFile, (data) => { sessionsDoc = data }],
344
+ [bansFile, (data) => { bansDoc = data }],
345
+ [usageFile, (data) => { usageDoc = data }],
346
+ [workspacesFile, (data) => { workspacesDoc = data }],
347
+ ]) {
348
+ try {
349
+ const raw = await fsP.readFile(file, 'utf8')
350
+ const parsed = JSON.parse(raw)
351
+ if (parsed && typeof parsed === 'object') assign(parsed)
352
+ } catch (e) {
353
+ if (e && e.code !== 'ENOENT') throw e
354
+ }
355
+ }
356
+ if (!Array.isArray(usersDoc.users)) usersDoc.users = []
357
+ if (!Number.isFinite(usersDoc.seq)) usersDoc.seq = usersDoc.users.length
358
+ // Records written before the permission model existed carry no block: fill
359
+ // in the defaults in place so every reader sees one shape.
360
+ for (const user of usersDoc.users) user.permissions = normalizePermissions(user.permissions)
361
+ if (!sessionsDoc.tokens || typeof sessionsDoc.tokens !== 'object') sessionsDoc.tokens = {}
362
+ if (!Array.isArray(bansDoc.bans)) bansDoc.bans = []
363
+ if (!usageDoc || typeof usageDoc.users !== 'object' || usageDoc.users === null) usageDoc = { users: {} }
364
+ if (!workspacesDoc || typeof workspacesDoc.owners !== 'object' || workspacesDoc.owners === null) workspacesDoc = { owners: {} }
365
+ // A claim whose account no longer exists would hide that folder from
366
+ // everyone but an administrator, so drop those on load.
367
+ for (const [path, owner] of Object.entries(workspacesDoc.owners)) {
368
+ if (findUserByUsernameRaw(owner) === null) delete workspacesDoc.owners[path]
369
+ }
370
+ // Usage is a rolling counter, not a ledger: anything older than a couple of
371
+ // days can never affect a quota again, so it is dropped rather than kept
372
+ // forever in a file that gets rewritten on every touch.
373
+ for (const [name, entry] of Object.entries(usageDoc.users)) {
374
+ if (!entry || typeof entry !== 'object' || entry.day !== todayKey(now()) || entry.day !== todayKey(entry.lastActiveAt || 0)) {
375
+ delete usageDoc.users[name]
376
+ }
377
+ }
378
+ // expired tokens are dropped on boot; ledger line count for rollover
379
+ const ts = now()
380
+ for (const token of Object.keys(sessionsDoc.tokens)) {
381
+ if (!sessionsDoc.tokens[token] || sessionsDoc.tokens[token].expiresAt <= ts) delete sessionsDoc.tokens[token]
382
+ }
383
+ try { ledgerLines = (await fsP.readFile(activityFile, 'utf8')).split('\n').filter((l) => l.trim() !== '').length } catch { ledgerLines = 0 }
384
+ try {
385
+ const raw = await fsP.readFile(auditFile, 'utf8')
386
+ const lines = raw.split('\n').filter((l) => l.trim() !== '')
387
+ auditLines = lines.length
388
+ for (const line of lines) {
389
+ try {
390
+ const id = JSON.parse(line).id
391
+ const match = typeof id === 'string' ? /^a(\d+)$/.exec(id) : null
392
+ if (match) auditSeq = Math.max(auditSeq, Number(match[1]))
393
+ } catch { /* skip malformed line */ }
394
+ }
395
+ } catch { auditLines = 0 }
396
+ return { users: usersDoc.users.length, sessions: Object.keys(sessionsDoc.tokens).length }
397
+ }
398
+
399
+ // ── users ────────────────────────────────────────────────────────────────
400
+
401
+ function publicUser(user) {
402
+ if (!user) return null
403
+ return {
404
+ id: user.id, username: user.username, role: user.role,
405
+ disabled: !!user.disabled,
406
+ totpEnabled: !!user.totpSecret,
407
+ createdAt: user.createdAt, lastLoginAt: user.lastLoginAt || null,
408
+ permissions: normalizePermissions(user.permissions),
409
+ }
410
+ }
411
+
412
+ function findUser(id) {
413
+ return usersDoc.users.find((u) => u.id === id) || null
414
+ }
415
+
416
+ function findUserByUsername(username) {
417
+ const needle = String(username || '').toLowerCase()
418
+ return usersDoc.users.find((u) => u.username.toLowerCase() === needle) || null
419
+ }
420
+
421
+ function countAdmins() {
422
+ return usersDoc.users.filter((u) => u.role === 'admin').length
423
+ }
424
+
425
+ async function createUser({ username, password, role = 'user', disabled = false, permissions }) {
426
+ if (!isValidUsername(username)) throw new StoreError('bad_username', '用户名需 2-32 位,仅限字母/数字/下划线/连字符')
427
+ if (!isValidPassword(password)) throw new StoreError('bad_password', `密码至少 ${MIN_PASSWORD_LEN} 位`)
428
+ if (findUserByUsername(username)) throw new StoreError('duplicate', '用户名已存在')
429
+ if (role !== 'admin' && role !== 'user') throw new StoreError('bad_role', 'role 必须是 admin 或 user')
430
+ const { salt, passHash } = await hashPassword(password)
431
+ const user = {
432
+ id: `u_${++usersDoc.seq}_${randomToken(6)}`,
433
+ username: String(username),
434
+ role,
435
+ salt,
436
+ passHash,
437
+ createdAt: now(),
438
+ lastLoginAt: null,
439
+ permissions: normalizePermissions(permissions),
440
+ }
441
+ // Approval-mode registrations land disabled (the admin enable button is
442
+ // the approval action); admin-created accounts are always active.
443
+ if (disabled) user.disabled = true
444
+ usersDoc.users.push(user)
445
+ await persistUsers()
446
+ return publicUser(user)
447
+ }
448
+
449
+ /**
450
+ * Merge a partial permissions patch onto a user (admin action). Absent keys
451
+ * keep their current value; an explicit null clears a limit. Everything goes
452
+ * through normalizePermissions, so the stored shape is always complete.
453
+ */
454
+ async function setPermissions(user, patch) {
455
+ if (!user) throw new StoreError('not_found', '用户不存在')
456
+ const next = { ...normalizePermissions(user.permissions) }
457
+ for (const [key, value] of Object.entries(patch || {})) {
458
+ if (value !== undefined) next[key] = value
459
+ }
460
+ user.permissions = normalizePermissions(next)
461
+ await persistUsers()
462
+ return publicUser(user)
463
+ }
464
+
465
+ /**
466
+ * Create an account from an exported record, preserving its existing password
467
+ * hash verbatim (see verifyPassword for how a foreign algorithm is handled).
468
+ *
469
+ * Used by the dsh-passwords migration: re-hashing is impossible without the
470
+ * plaintext password, and asking every user to reset would make the move
471
+ * disruptive enough that nobody does it.
472
+ *
473
+ * @param options.replaceCredentials - also adopt the imported password hash
474
+ * when the username already exists here (see the collision note below).
475
+ * @returns {{ user: object, created: boolean, credentialsReplaced?: boolean }}
476
+ * whether the account was newly created; an existing username keeps
477
+ * its own hash unless `replaceCredentials` asks otherwise, so
478
+ * re-running a migration is safe by default.
479
+ */
480
+ async function importUser({ username, role = 'user', passHash, algo, salt, createdAt, lastLoginAt, disabled = false, permissions, replaceCredentials = false }) {
481
+ if (!isValidUsername(username)) throw new StoreError('bad_username', '用户名需 2-32 位,仅限字母/数字/下划线/连字符')
482
+ if (typeof passHash !== 'string' || passHash === '') throw new StoreError('bad_password', '导入记录缺少密码哈希')
483
+ if (role !== 'admin' && role !== 'user') throw new StoreError('bad_role', 'role 必须是 admin 或 user')
484
+
485
+ const existing = findUserByUsername(username)
486
+ if (existing) {
487
+ existing.permissions = normalizePermissions(permissions)
488
+ if (typeof role === 'string') existing.role = role
489
+ // A name collision means the two systems each had an account under this
490
+ // username with unrelated password hashes. The caller decides which side
491
+ // wins: replacing is the right call when the imported system is the one
492
+ // people actually sign in to today, but it must be asked for explicitly
493
+ // (silently swapping someone's credentials is not a migration's job).
494
+ let credentialsReplaced = false
495
+ if (replaceCredentials) {
496
+ existing.passHash = passHash
497
+ if (typeof algo === 'string' && algo !== '') existing.algo = algo
498
+ else delete existing.algo
499
+ if (typeof salt === 'string' && salt !== '') existing.salt = salt
500
+ else delete existing.salt
501
+ credentialsReplaced = true
502
+ }
503
+ await persistUsers()
504
+ return { user: publicUser(existing), created: false, credentialsReplaced }
505
+ }
506
+
507
+ const user = {
508
+ id: `u_${++usersDoc.seq}_${randomToken(6)}`,
509
+ username: String(username),
510
+ role,
511
+ passHash,
512
+ createdAt: Number.isFinite(createdAt) ? createdAt : now(),
513
+ lastLoginAt: Number.isFinite(lastLoginAt) ? lastLoginAt : null,
514
+ permissions: normalizePermissions(permissions),
515
+ }
516
+ // A native (scrypt) hash keeps its salt; an imported foreign hash keeps its
517
+ // algorithm marker so verification knows which path to take.
518
+ if (typeof salt === 'string' && salt !== '') user.salt = salt
519
+ if (typeof algo === 'string' && algo !== '') user.algo = algo
520
+ if (disabled) user.disabled = true
521
+ usersDoc.users.push(user)
522
+ await persistUsers()
523
+ return { user: publicUser(user), created: true }
524
+ }
525
+
526
+ // ── workspace ownership ──────────────────────────────────────────────────
527
+
528
+ /** path → owner map (a copy; callers must not mutate store state). */
529
+ function workspaceOwners() {
530
+ return { ...workspacesDoc.owners }
531
+ }
532
+
533
+ /** Who owns this folder, or null when nobody has claimed it. */
534
+ function workspaceOwnerOf(path) {
535
+ return workspacesDoc.owners[normalizePath(path)] || null
536
+ }
537
+
538
+ /** Claim a folder for an account (a no-op when someone already owns it). */
539
+ async function claimWorkspace(path, username) {
540
+ const key = normalizePath(path)
541
+ if (key === '' || key === '.' || key === '/') return null
542
+ if (workspacesDoc.owners[key] !== undefined) return workspacesDoc.owners[key]
543
+ workspacesDoc.owners[key] = String(username)
544
+ await persistWorkspaces()
545
+ return workspacesDoc.owners[key]
546
+ }
547
+
548
+ /** Release a claim (the folder is gone). */
549
+ async function releaseWorkspace(path) {
550
+ const key = normalizePath(path)
551
+ if (workspacesDoc.owners[key] === undefined) return false
552
+ delete workspacesDoc.owners[key]
553
+ await persistWorkspaces()
554
+ return true
555
+ }
556
+
557
+ /** Move a claim with its folder. */
558
+ async function renameWorkspace(oldPath, newPath) {
559
+ const from = normalizePath(oldPath)
560
+ const to = normalizePath(newPath)
561
+ if (workspacesDoc.owners[from] === undefined) return false
562
+ workspacesDoc.owners[to] = workspacesDoc.owners[from]
563
+ delete workspacesDoc.owners[from]
564
+ await persistWorkspaces()
565
+ return true
566
+ }
567
+
568
+ // ── usage / quota counters ───────────────────────────────────────────────
569
+
570
+ /** Counters for an account, or null when it has no record for today. */
571
+ function getUsage(username) {
572
+ const key = String(username || '').toLowerCase()
573
+ if (key === '') return null
574
+ const entry = usageDoc.users[key]
575
+ if (!entry) return null
576
+ rollDay(entry, now())
577
+ return { ...entry }
578
+ }
579
+
580
+ /**
581
+ * Record activity against an account's daily allowance.
582
+ *
583
+ * Called from the request path, so it stays cheap: the caller throttles by
584
+ * wall clock (a request every few seconds must not become a file write every
585
+ * few seconds), and this only persists when the caller passed real time.
586
+ */
587
+ async function touchUsage(username, seconds = 0) {
588
+ const key = String(username || '').toLowerCase()
589
+ if (key === '') return null
590
+ const ts = now()
591
+ const entry = rollDay(usageDoc.users[key] || {
592
+ day: todayKey(ts),
593
+ activeSeconds: 0,
594
+ firstSeenAt: ts,
595
+ lastActiveAt: ts,
596
+ hourlyWindowStart: ts,
597
+ hourlyTokens: 0,
598
+ }, ts)
599
+ entry.lastActiveAt = ts
600
+ if (Number.isFinite(seconds) && seconds > 0) entry.activeSeconds += Math.round(seconds)
601
+ usageDoc.users[key] = entry
602
+ await persistUsage()
603
+ return { ...entry }
604
+ }
605
+
606
+ /**
607
+ * Add reported tokens to the rolling hourly window.
608
+ *
609
+ * The client reports deltas of dsh's own token projection: the gateway cannot
610
+ * see provider accounting, and reconstructing it from response bodies was
611
+ * abandoned upstream as unreliable.
612
+ */
613
+ async function addTokens(username, tokens) {
614
+ const key = String(username || '').toLowerCase()
615
+ const amount = Math.round(Number(tokens))
616
+ if (key === '' || !Number.isFinite(amount) || amount <= 0) return getUsage(username)
617
+ const ts = now()
618
+ const entry = rollDay(usageDoc.users[key] || {
619
+ day: todayKey(ts),
620
+ activeSeconds: 0,
621
+ firstSeenAt: ts,
622
+ lastActiveAt: ts,
623
+ hourlyWindowStart: ts,
624
+ hourlyTokens: 0,
625
+ }, ts)
626
+ if (ts - entry.hourlyWindowStart >= HOURLY_WINDOW_MS) {
627
+ entry.hourlyWindowStart = ts
628
+ entry.hourlyTokens = 0
629
+ }
630
+ entry.hourlyTokens += amount
631
+ entry.lastActiveAt = ts
632
+ usageDoc.users[key] = entry
633
+ await persistUsage()
634
+ return { ...entry }
635
+ }
636
+
637
+ /** First account in an empty system becomes admin (see README). */
638
+ function roleForNextRegistration() {
639
+ return usersDoc.users.length === 0 ? 'admin' : 'user'
640
+ }
641
+
642
+ async function verifyLogin(username, password) {
643
+ const outcome = await checkLogin(username, password)
644
+ return outcome.result === 'ok' || outcome.result === 'disabled' ? outcome.user : null
645
+ }
646
+
647
+ /**
648
+ * Full login outcome: 'ok' | 'invalid' | 'disabled' (correct credentials
649
+ * but the account is disabled). The invalid path burns hash time so
650
+ * missing users are not distinguishable; the disabled path only exists
651
+ * behind correct credentials, so revealing it is safe.
652
+ */
653
+ async function checkLogin(username, password) {
654
+ const user = findUserByUsername(username)
655
+ if (!user) {
656
+ await hashPassword(password || '', '00000000000000000000000000000000')
657
+ return { result: 'invalid' }
658
+ }
659
+ if (!verifyPassword(user, password)) return { result: 'invalid' }
660
+ // Migrated accounts: the bcrypt hash verified, so this is the moment to move
661
+ // them onto the native scheme. Best effort — a failed re-hash must not fail
662
+ // a correct sign-in (the old hash still works next time).
663
+ if (user.algo === BCRYPT_ALGO) {
664
+ try {
665
+ const { salt, passHash } = await hashPassword(password)
666
+ user.salt = salt
667
+ user.passHash = passHash
668
+ delete user.algo
669
+ await persistUsers()
670
+ } catch { /* keep the imported hash */ }
671
+ }
672
+ return { result: user.disabled ? 'disabled' : 'ok', user }
673
+ }
674
+
675
+ /** Disable / enable an account. Disabled users fail login and lose all
676
+ * live sessions (resolveSession also re-checks the flag defensively). */
677
+ async function setDisabled(user, disabled) {
678
+ if (typeof disabled !== 'boolean') throw new StoreError('bad_request', 'disabled 必须是布尔值')
679
+ if (disabled && user.role === 'admin' && countAdmins() <= 1) {
680
+ throw new StoreError('last_admin', '不能禁用最后一个管理员')
681
+ }
682
+ if (disabled) user.disabled = true
683
+ else delete user.disabled
684
+ await persistUsers()
685
+ }
686
+
687
+ async function setPassword(user, password) {
688
+ if (!isValidPassword(password)) throw new StoreError('bad_password', `密码至少 ${MIN_PASSWORD_LEN} 位`)
689
+ const { salt, passHash } = await hashPassword(password)
690
+ user.salt = salt
691
+ user.passHash = passHash
692
+ await persistUsers()
693
+ }
694
+
695
+ async function setRole(user, role) {
696
+ if (role !== 'admin' && role !== 'user') throw new StoreError('bad_role', 'role 必须是 admin 或 user')
697
+ if (user.role === 'admin' && role !== 'admin' && countAdmins() <= 1) {
698
+ throw new StoreError('last_admin', '不能降级最后一个管理员')
699
+ }
700
+ user.role = role
701
+ await persistUsers()
702
+ }
703
+
704
+ // ── TOTP two-step verification ───────────────────────────────────────────
705
+ //
706
+ // Enrollment is two-phase so a typo in the secret can never lock the user
707
+ // out: startTotpSetup parks the fresh secret in totpPendingSecret, and only
708
+ // activateTotp — which demands a code the authenticator generated FROM that
709
+ // secret — promotes it to totpSecret (the enabled flag is `totpSecret`
710
+ // itself). verifyLoginOtp enforces single use by persisting the matched
711
+ // step counter; older/equal counters are rejected as replays.
712
+
713
+ /** Generate a pending secret; the API layer builds the otpauth URI + QR. */
714
+ async function startTotpSetup(user) {
715
+ if (user.totpSecret) throw new StoreError('totp_enabled', '两步验证已开启,请先关闭再重新绑定')
716
+ user.totpPendingSecret = generateSecret()
717
+ await persistUsers()
718
+ return { secret: user.totpPendingSecret }
719
+ }
720
+
721
+ /** Promote the pending secret to enabled after the user proves possession. */
722
+ async function activateTotp(user, code) {
723
+ if (!user.totpPendingSecret) throw new StoreError('no_pending_totp', '请先开始两步验证绑定')
724
+ const verdict = verifyTotp(user.totpPendingSecret, code, { nowMs: now() })
725
+ if (!verdict.ok) return false
726
+ user.totpSecret = user.totpPendingSecret
727
+ user.totpLastCounter = verdict.counter
728
+ user.totpEnabledAt = now()
729
+ delete user.totpPendingSecret
730
+ await persistUsers()
731
+ return true
732
+ }
733
+
734
+ /** Drop every TOTP field (self-service disable and admin reset share this). */
735
+ async function disableTotp(user) {
736
+ if (!user.totpSecret) throw new StoreError('totp_not_enabled', '该账号未开启两步验证')
737
+ delete user.totpSecret
738
+ delete user.totpPendingSecret
739
+ delete user.totpLastCounter
740
+ delete user.totpEnabledAt
741
+ await persistUsers()
742
+ }
743
+
744
+ /**
745
+ * Login-time OTP check. `notEnabled` lets the caller skip the OTP branch
746
+ * entirely; a successful check persists the matched counter so the same
747
+ * code can never be accepted twice.
748
+ */
749
+ async function verifyLoginOtp(user, code) {
750
+ if (!user.totpSecret) return { ok: false, notEnabled: true }
751
+ const verdict = verifyTotp(user.totpSecret, code, { nowMs: now(), lastCounter: user.totpLastCounter || 0 })
752
+ if (verdict.ok) {
753
+ user.totpLastCounter = verdict.counter
754
+ await persistUsers()
755
+ }
756
+ return verdict
757
+ }
758
+
759
+ async function removeUser(user) {
760
+ const index = usersDoc.users.indexOf(user)
761
+ if (index === -1) throw new StoreError('not_found', '用户不存在')
762
+ if (user.role === 'admin' && countAdmins() <= 1) {
763
+ throw new StoreError('last_admin', '不能删除最后一个管理员')
764
+ }
765
+ usersDoc.users.splice(index, 1)
766
+ for (const token of Object.keys(sessionsDoc.tokens)) {
767
+ if (sessionsDoc.tokens[token].userId === user.id) delete sessionsDoc.tokens[token]
768
+ }
769
+ await persistUsers()
770
+ return persistSessions()
771
+ }
772
+
773
+ async function touchLogin(user) {
774
+ user.lastLoginAt = now()
775
+ await persistUsers()
776
+ }
777
+
778
+ function listUsers() {
779
+ return usersDoc.users.map(publicUser)
780
+ }
781
+
782
+ // ── sessions ─────────────────────────────────────────────────────────────
783
+
784
+ async function createSession(user) {
785
+ const token = randomToken()
786
+ const ts = now()
787
+ sessionsDoc.tokens[token] = { userId: user.id, username: user.username, createdAt: ts, expiresAt: ts + SESSION_TTL_MS }
788
+ await pruneSessions()
789
+ return { token, expiresAt: ts + SESSION_TTL_MS }
790
+ }
791
+
792
+ async function pruneSessions() {
793
+ const ts = now()
794
+ for (const token of Object.keys(sessionsDoc.tokens)) {
795
+ const session = sessionsDoc.tokens[token]
796
+ if (!session || session.expiresAt <= ts) delete sessionsDoc.tokens[token]
797
+ }
798
+ return persistSessions()
799
+ }
800
+
801
+ /** Resolve a token → {user, session, token}. Sliding renewal past half-life. */
802
+ async function resolveSession(token) {
803
+ if (!token || typeof token !== 'string') return null
804
+ const session = sessionsDoc.tokens[token]
805
+ if (!session) return null
806
+ const ts = now()
807
+ if (session.expiresAt <= ts) {
808
+ delete sessionsDoc.tokens[token]
809
+ await persistSessions()
810
+ return null
811
+ }
812
+ const user = findUser(session.userId)
813
+ if (!user) {
814
+ delete sessionsDoc.tokens[token]
815
+ await persistSessions()
816
+ return null
817
+ }
818
+ if (user.disabled) {
819
+ delete sessionsDoc.tokens[token]
820
+ await persistSessions()
821
+ return null
822
+ }
823
+ if (session.expiresAt - ts < SESSION_RENEW_MS) {
824
+ session.expiresAt = ts + SESSION_TTL_MS
825
+ await persistSessions()
826
+ }
827
+ return { user, session, token }
828
+ }
829
+
830
+ async function dropSession(token) {
831
+ if (token && sessionsDoc.tokens[token]) {
832
+ delete sessionsDoc.tokens[token]
833
+ await persistSessions()
834
+ }
835
+ }
836
+
837
+ /** Kill every session of a user (password reset / role change / delete). */
838
+ async function dropUserSessions(userId, exceptToken) {
839
+ let dropped = false
840
+ for (const token of Object.keys(sessionsDoc.tokens)) {
841
+ if (sessionsDoc.tokens[token].userId !== userId) continue
842
+ if (token === exceptToken) continue
843
+ delete sessionsDoc.tokens[token]
844
+ dropped = true
845
+ }
846
+ if (dropped) await persistSessions()
847
+ }
848
+
849
+ // ── IP bans ──────────────────────────────────────────────────────────────
850
+
851
+ function isBanned(ip) {
852
+ if (!ip) return false
853
+ return bansDoc.bans.some((b) => b.ip === ip)
854
+ }
855
+
856
+ function listBans() {
857
+ return bansDoc.bans.slice().sort((a, b) => b.createdAt - a.createdAt)
858
+ }
859
+
860
+ /** Ban an IP (validated). Loopback addresses are rejected outright and
861
+ * the self-lockout guard lives in the API layer — it needs the
862
+ * requester's live IP, the store does not. */
863
+ async function banIp(ip, { note = '', by = null } = {}) {
864
+ const normalized = String(ip || '').trim()
865
+ if (net.isIP(normalized) === 0) throw new StoreError('bad_ip', '不是合法的 IP 地址')
866
+ if (/^127\./.test(normalized) || normalized === '::1') throw new StoreError('protected_ip', '不能封禁回环地址')
867
+ if (isBanned(normalized)) throw new StoreError('duplicate', '该 IP 已在封禁列表')
868
+ bansDoc.bans.push({ ip: normalized, note: String(note || '').slice(0, 140), createdAt: now(), createdBy: by })
869
+ return persistBans()
870
+ }
871
+
872
+ async function unbanIp(ip) {
873
+ const before = bansDoc.bans.length
874
+ bansDoc.bans = bansDoc.bans.filter((b) => b.ip !== ip)
875
+ if (bansDoc.bans.length === before) throw new StoreError('not_found', '该 IP 不在封禁列表')
876
+ return persistBans()
877
+ }
878
+
879
+ // ── activity + audit ledgers ─────────────────────────────────────────────
880
+
881
+ function ledgerLine(entry) {
882
+ return JSON.stringify({
883
+ ts: now(),
884
+ type: entry.type,
885
+ username: entry.username || null,
886
+ userId: entry.userId || null,
887
+ ip: entry.ip || '',
888
+ detail: entry.detail || '',
889
+ })
890
+ }
891
+
892
+ /** Serialized append to a JSONL ledger with 2×-cap tail rollover. */
893
+ function appendJsonl({ file, line, getLines, setLines, cap }) {
894
+ const run = writeChain.ledger
895
+ .then(() => fsP.appendFile(file, line + '\n', { encoding: 'utf8', mode: 0o600 }))
896
+ .then(async () => {
897
+ setLines(getLines() + 1)
898
+ if (getLines() > cap * 2) {
899
+ const raw = await fsP.readFile(file, 'utf8')
900
+ const lines = raw.split('\n').filter((l) => l.trim() !== '')
901
+ const keep = lines.slice(-cap)
902
+ setLines(keep.length)
903
+ await atomicWrite(file, keep.join('\n') + '\n')
904
+ }
905
+ })
906
+ writeChain.ledger = run.catch(() => {})
907
+ return run
908
+ }
909
+
910
+ async function appendActivity(entry) {
911
+ return appendJsonl({
912
+ file: activityFile,
913
+ line: ledgerLine(entry),
914
+ getLines: () => ledgerLines,
915
+ setLines: (n) => { ledgerLines = n },
916
+ cap: MAX_LEDGER_LINES,
917
+ })
918
+ }
919
+
920
+ /** Operation audit: every gated API/WebSocket action of a signed-in user. */
921
+ async function appendAudit(entry) {
922
+ const id = `a${++auditSeq}`
923
+ return appendJsonl({
924
+ file: auditFile,
925
+ line: JSON.stringify({
926
+ id,
927
+ ts: now(),
928
+ type: entry.type || 'api',
929
+ username: entry.username || null,
930
+ userId: entry.userId || null,
931
+ ip: entry.ip || '',
932
+ method: entry.method || '',
933
+ path: entry.path || '',
934
+ status: entry.status || null,
935
+ }),
936
+ getLines: () => auditLines,
937
+ setLines: (n) => { auditLines = n },
938
+ cap: MAX_AUDIT_LINES,
939
+ })
940
+ }
941
+
942
+ /** Remove one audit entry by id (admin manual cleanup). */
943
+ async function removeAuditEntry(id) {
944
+ const run = writeChain.ledger.then(async () => {
945
+ let raw = ''
946
+ try { raw = await fsP.readFile(auditFile, 'utf8') } catch { return false }
947
+ const lines = raw.split('\n').filter((l) => l.trim() !== '')
948
+ const kept = []
949
+ let removed = false
950
+ for (const line of lines) {
951
+ try {
952
+ if (JSON.parse(line).id === id) { removed = true; continue }
953
+ } catch { /* keep malformed lines as-is */ }
954
+ kept.push(line)
955
+ }
956
+ if (!removed) return false
957
+ auditLines = kept.length
958
+ await atomicWrite(auditFile, kept.length === 0 ? '' : kept.join('\n') + '\n')
959
+ return true
960
+ })
961
+ writeChain.ledger = run.catch(() => {})
962
+ return run
963
+ }
964
+
965
+ /** Drop the entire audit ledger (admin manual cleanup). */
966
+ async function clearAudit() {
967
+ const run = writeChain.ledger.then(async () => {
968
+ auditLines = 0
969
+ await atomicWrite(auditFile, '')
970
+ })
971
+ writeChain.ledger = run.catch(() => {})
972
+ return run
973
+ }
974
+
975
+ /**
976
+ * Read ledger entries newest-first. Filters: type (exact), userId, and a
977
+ * set of types (`types`). `limit` caps the scan window from the tail.
978
+ */
979
+ async function listActivity({ type, types, userId, limit = ACTIVITY_LIMIT_DEFAULT } = {}) {
980
+ return listJsonl(activityFile, (entry) => {
981
+ if (type && entry.type !== type) return false
982
+ if (types) {
983
+ const set = Array.isArray(types) ? new Set(types) : types
984
+ if (!set.has(entry.type)) return false
985
+ }
986
+ if (userId && entry.userId !== userId) return false
987
+ return true
988
+ }, limit)
989
+ }
990
+
991
+ /**
992
+ * Read the operation audit newest-first. Filters: username, method
993
+ * (exact), path substring, and a response-status class ('2'|'3'|'4'|'5').
994
+ */
995
+ async function listAudit({ username, method, path, statusClass, limit = ACTIVITY_LIMIT_DEFAULT } = {}) {
996
+ return listJsonl(auditFile, (entry) => {
997
+ if (username && entry.username !== username) return false
998
+ if (method && entry.method !== method) return false
999
+ if (path && !String(entry.path || '').includes(path)) return false
1000
+ if (statusClass && String(entry.status || '').charAt(0) !== statusClass) return false
1001
+ return true
1002
+ }, limit)
1003
+ }
1004
+
1005
+ async function listJsonl(file, predicate, limit) {
1006
+ let raw = ''
1007
+ try { raw = await fsP.readFile(file, 'utf8') } catch { return [] }
1008
+ const lines = raw.split('\n').filter((l) => l.trim() !== '')
1009
+ const cap = Math.max(1, Math.min(Number(limit) || ACTIVITY_LIMIT_DEFAULT, MAX_AUDIT_LINES))
1010
+ const out = []
1011
+ for (let i = lines.length - 1; i >= 0 && out.length < cap; i -= 1) {
1012
+ let entry
1013
+ try { entry = JSON.parse(lines[i]) } catch { continue }
1014
+ if (predicate(entry)) out.push(entry)
1015
+ }
1016
+ return out
1017
+ }
1018
+
1019
+ return {
1020
+ // lifecycle
1021
+ load,
1022
+ // users
1023
+ createUser, verifyLogin, checkLogin, setPassword, setRole, setDisabled, removeUser, touchLogin,
1024
+ listUsers, findUser, findUserByUsername, countAdmins, roleForNextRegistration, publicUser,
1025
+ setPermissions, normalizePermissions, isWorkspaceRestricted, DENY_ALL_WORKSPACES, importUser,
1026
+ // totp
1027
+ startTotpSetup, activateTotp, disableTotp, verifyLoginOtp,
1028
+ // sessions
1029
+ createSession, resolveSession, dropSession, dropUserSessions, pruneSessions,
1030
+ // ip bans
1031
+ isBanned, listBans, banIp, unbanIp,
1032
+ // usage / quotas
1033
+ getUsage, touchUsage, addTokens,
1034
+ // workspace ownership
1035
+ workspaceOwners, workspaceOwnerOf, claimWorkspace, releaseWorkspace, renameWorkspace,
1036
+ // ledger
1037
+ appendActivity, listActivity, appendAudit, listAudit, removeAuditEntry, clearAudit,
1038
+ // internals for tests
1039
+ DEFAULT_PERMISSIONS,
1040
+ SANDBOX_MODES,
1041
+ HOURLY_WINDOW_MS,
1042
+ __files: { dir, usersFile, sessionsFile, activityFile, auditFile, bansFile, usageFile, workspacesFile },
1043
+ __state: () => ({ users: usersDoc.users, sessions: sessionsDoc.tokens, ledgerLines, auditLines, bans: bansDoc.bans, usage: usageDoc.users }),
1044
+ StoreError,
1045
+ }
1046
+ }
1047
+
1048
+ export {
1049
+ createStore,
1050
+ normalizePermissions,
1051
+ isWorkspaceRestricted,
1052
+ DENY_ALL_WORKSPACES,
1053
+ DEFAULT_PERMISSIONS,
1054
+ SANDBOX_MODES,
1055
+ dshHome,
1056
+ isValidUsername,
1057
+ isValidPassword,
1058
+ verifyPassword,
1059
+ hashPassword,
1060
+ randomToken,
1061
+ tempPassword,
1062
+ tokenFingerprint,
1063
+ normalizeIp,
1064
+ StoreError,
1065
+ SESSION_TTL_MS,
1066
+ SESSION_RENEW_MS,
1067
+ MAX_LEDGER_LINES,
1068
+ MAX_AUDIT_LINES,
1069
+ USERNAME_RE,
1070
+ MIN_PASSWORD_LEN,
1071
+ ACTIVITY_LIMIT_DEFAULT,
1072
+ }