diff --git a/README.md b/README.md index 6882a0b..f22b158 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Each family's latest generation only — the app's **Models** page lists every b ## Installation -Every route installs the same `penguin` command: `penguin web` launches the full Web experience (multi-session chat, agent/skill/model management, usage stats, Trace observability, evaluation center; first login admin / penguin-2026 — change the password right after), and models are configured on the in-app Models page. The online installers bundle their own Node runtime — unpack and run; upgrades and reinstalls never touch your data. +Every route installs the same `penguin` command: `penguin web` launches the full Web experience (multi-session chat, agent/skill/model management, usage stats, Trace observability, evaluation center; first login is `admin` with the initial password printed on the server's first start, of the form `penguin-1234` — change it right after), and models are configured on the in-app Models page. The online installers bundle their own Node runtime — unpack and run; upgrades and reinstalls never touch your data. ### 🐧 Linux (online install) diff --git a/README.zh.md b/README.zh.md index ca3f3c8..b446036 100644 --- a/README.zh.md +++ b/README.zh.md @@ -108,7 +108,7 @@ https://github.com/user-attachments/assets/aec49ae9-b743-467b-b247-37bedfeaa36e ## 安装 -每种方式装出的都是同一个 `penguin` 命令:`penguin web` 启动完整 Web 体验(多会话对话、Agent / 技能 / 模型管理、用量统计、轨迹观测、评估中心;首次登录 admin / penguin-2026,登录后请尽快修改密码),在应用内模型页配置模型后即可对话。在线安装器自带 Node 运行时,解压即用,升级与重装不触碰数据。 +每种方式装出的都是同一个 `penguin` 命令:`penguin web` 启动完整 Web 体验(多会话对话、Agent / 技能 / 模型管理、用量统计、轨迹观测、评估中心;首次登录使用 `admin`,初始密码在服务端首次启动时打印,形如 `penguin-1234`,登录后请尽快修改密码),在应用内模型页配置模型后即可对话。在线安装器自带 Node 运行时,解压即用,升级与重装不触碰数据。 ### 🐧 Linux(在线安装) diff --git a/install.ps1 b/install.ps1 index d6b993d..20dd2e6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -442,5 +442,5 @@ if ($PathUpdateMessage) { Write-Host "" Write-Host "Get started:" Write-Host " penguin --help # all commands" -Write-Host " penguin web # start the Web UI at http://127.0.0.1:7364 (initial login: admin / penguin-2026)" +Write-Host " penguin web # start the Web UI at http://127.0.0.1:7364 (login: admin, initial password printed on first start)" Write-Host " penguin server # headless server (PORT / HOST to override)" diff --git a/install.sh b/install.sh index 8f55698..5fb8b93 100644 --- a/install.sh +++ b/install.sh @@ -441,5 +441,5 @@ fi echo "" echo "Get started:" echo " penguin --help # all commands" -echo " penguin web # start the Web UI at http://127.0.0.1:7364 (initial login: admin / penguin-2026)" +echo " penguin web # start the Web UI at http://127.0.0.1:7364 (login: admin, initial password printed on first start)" echo " penguin server # headless server (PORT / HOST to override)" diff --git a/packages/docs/content/configuration.en.md b/packages/docs/content/configuration.en.md index b0f0b4e..08f64e6 100644 --- a/packages/docs/content/configuration.en.md +++ b/packages/docs/content/configuration.en.md @@ -17,6 +17,7 @@ The CLI and the server automatically load a `.env` file from the working directo | `PENGUIN_WEB_DB` | Server SQLite database path | `/web.db` | | `PENGUIN_WEB_DIST` | Front-end static assets directory | the npm server package falls back to its bundled web-dist | | `PENGUIN_PREVIEW_ORIGIN` | Origin that serves Workspace HTML previews, e.g. `https://preview.example.com` | unset — the loopback counterpart is derived per request | +| `PENGUIN_SEED_ADMIN_PASSWORD` | Fixed initial password for the seeded built-in admin (automated tests / e2e) | unset — a random `penguin-<4 digits>` password is generated and printed once at seed time | | `PENGUIN_LANG` | CLI language (`en` / `zh`), set via `penguin config lang` | `en` | | `PENGUIN_UPDATE_CHECK` | `off` disables the web app's new-release check (the server's only outbound internet call) | enabled | diff --git a/packages/docs/content/configuration.zh.md b/packages/docs/content/configuration.zh.md index 2c0a599..7778014 100644 --- a/packages/docs/content/configuration.zh.md +++ b/packages/docs/content/configuration.zh.md @@ -17,6 +17,7 @@ CLI 与服务端启动时会自动加载工作目录下的 `.env` 文件。 | `PENGUIN_WEB_DB` | 服务端 SQLite 数据库路径 | `/web.db` | | `PENGUIN_WEB_DIST` | 前端静态资源目录 | npm 安装的服务端包回退到内置 web-dist | | `PENGUIN_PREVIEW_ORIGIN` | 提供 Workspace HTML 预览的独立源,如 `https://preview.example.com` | 未设置,按请求推导回环对应名 | +| `PENGUIN_SEED_ADMIN_PASSWORD` | 固定内置管理员的种子初始密码(自动化测试 / e2e 使用) | 未设置,种子时随机生成 `penguin-<四位数字>` 并打印一次 | | `PENGUIN_LANG` | CLI 语言(`en` / `zh`),用 `penguin config lang` 设置 | `en` | | `PENGUIN_UPDATE_CHECK` | 设为 `off` 关闭 Web 应用的新版本检查(服务端唯一的对外网络请求) | 开启 | diff --git a/packages/docs/content/quickstart.en.md b/packages/docs/content/quickstart.en.md index fb12a52..8ce24af 100644 --- a/packages/docs/content/quickstart.en.md +++ b/packages/docs/content/quickstart.en.md @@ -30,7 +30,7 @@ penguin config model add --provider deepseek --model-id deepseek-v4-flash --api- penguin web ``` -The service runs at http://127.0.0.1:7364 and opens your browser (`--no-open` to skip). First login is `admin` / `penguin-2026` — change it right away. `penguin server` starts the same process headless. +The service runs at http://127.0.0.1:7364 and opens your browser (`--no-open` to skip). First login is `admin` — the server prints the initial password (of the form `penguin-1234`) on first start; change it right away. `penguin server` starts the same process headless. ## One-shot run diff --git a/packages/docs/content/quickstart.zh.md b/packages/docs/content/quickstart.zh.md index 3121597..1dc26cc 100644 --- a/packages/docs/content/quickstart.zh.md +++ b/packages/docs/content/quickstart.zh.md @@ -30,7 +30,7 @@ penguin config model add --provider deepseek --model-id deepseek-v4-flash --api- penguin web ``` -服务运行在 http://127.0.0.1:7364 并自动打开浏览器(`--no-open` 跳过)。首次登录使用 `admin` / `penguin-2026`,请立即修改密码。`penguin server` 启动同一进程的 headless 版本。 +服务运行在 http://127.0.0.1:7364 并自动打开浏览器(`--no-open` 跳过)。首次登录使用 `admin`,初始密码(形如 `penguin-1234`)在服务端首次启动时打印,请立即修改密码。`penguin server` 启动同一进程的 headless 版本。 ## 单次运行 diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index f7eef10..7923659 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -35,12 +35,13 @@ packages/server/src - Cookie session: `penguin_session` (HttpOnly, SameSite=Lax), valid for 7 days with sliding renewal; - Passwords are stored as scrypt hashes; the server keeps only the sha256 of the session token, never the plaintext; -- No open registration: the built-in admin `admin` / `penguin-2026` is seeded at startup, and all other accounts are created by an admin; +- No open registration: the built-in admin `admin` is seeded at startup with a random initial password (of the form `penguin-1234`) printed once to the server console — `PENGUIN_SEED_ADMIN_PASSWORD` pins it for automation — and all other accounts are created by an admin; - Same-origin only — no CORS middleware is enabled. ```bash +# Use the initial password printed at first start (or your changed one). curl -c cookies.txt -H "Content-Type: application/json" \ - -d '{"userId":"admin","password":"penguin-2026"}' \ + -d '{"userId":"admin","password":"penguin-1234"}' \ http://127.0.0.1:7364/api/auth/login ``` diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index f8a0eb3..b97ca3d 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -35,12 +35,13 @@ packages/server/src - Cookie 会话:`penguin_session`(HttpOnly、SameSite=Lax),有效期 7 天,滑动续期; - 密码以 scrypt 哈希存储;服务端只保存会话 Token 的 sha256,不落明文; -- 不开放注册:启动时种子化内置管理员 `admin` / `penguin-2026`,其余账号由管理员创建; +- 不开放注册:启动时种子化内置管理员 `admin`,初始密码随机生成(形如 `penguin-1234`)并仅在种子当次打印到服务端控制台——自动化可用 `PENGUIN_SEED_ADMIN_PASSWORD` 固定——其余账号由管理员创建; - 仅限同源访问,未启用 CORS 中间件。 ```bash +# 密码用首次启动时打印的初始密码(或改过之后的密码)。 curl -c cookies.txt -H "Content-Type: application/json" \ - -d '{"userId":"admin","password":"penguin-2026"}' \ + -d '{"userId":"admin","password":"penguin-1234"}' \ http://127.0.0.1:7364/api/auth/login ``` diff --git a/packages/docs/content/web-app.en.md b/packages/docs/content/web-app.en.md index 1a53757..e0cfb73 100644 --- a/packages/docs/content/web-app.en.md +++ b/packages/docs/content/web-app.en.md @@ -23,7 +23,7 @@ penguin web # open http://127.0.0.1:7364 ``` -The initial account is `admin` / `penguin-2026`. There is no self-registration: accounts are created by an admin on the user-management page, and every new user automatically gets an independent initial Project named `-default_project`. While the initial password is still in use, a banner prompts the user to change it. +The initial account is `admin`; its initial password (of the form `penguin-1234`) is printed in the server startup output on first start. There is no self-registration: accounts are created by an admin on the user-management page, and every new user automatically gets an independent initial Project named `-default_project`. While the initial password is still in use, a banner prompts the user to change it. Logins persist for 7 days with sliding renewal; an admin password reset invalidates all of that user's login sessions. diff --git a/packages/docs/content/web-app.zh.md b/packages/docs/content/web-app.zh.md index 4434621..0efd506 100644 --- a/packages/docs/content/web-app.zh.md +++ b/packages/docs/content/web-app.zh.md @@ -23,7 +23,7 @@ penguin web # 打开 http://127.0.0.1:7364 ``` -初始账号为 `admin` / `penguin-2026`。系统不开放自助注册:账号由管理员在用户管理页创建;每个新用户会自动获得一个独立的初始 Project,命名为 `-default_project`。仍在使用初始密码时,页面会以横幅提示尽快修改。 +初始账号为 `admin`,初始密码(形如 `penguin-1234`)在服务端首次启动时打印。系统不开放自助注册:账号由管理员在用户管理页创建;每个新用户会自动获得一个独立的初始 Project,命名为 `-default_project`。仍在使用初始密码时,页面会以横幅提示尽快修改。 登录状态保持 7 天(滑动续期);管理员重置密码会使该用户的全部登录会话失效。 diff --git a/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.en.md b/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.en.md index 6680094..a922811 100644 --- a/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.en.md +++ b/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.en.md @@ -78,7 +78,7 @@ One install gives you all five rows of that first table, sharing one data direct ```bash curl -fsSL https://penguin.ooo/install.sh | sh -penguin web # http://127.0.0.1:7364 — first login: admin / penguin-2026 +penguin web # http://127.0.0.1:7364 — first login: admin, initial password printed on first start ``` Multi-session chat, agent and skill management, model configuration, usage and cost statistics, **Trace observability**, and an **evaluation center** — in the box, wired together, nothing to subscribe to and nothing to self-host separately. Every request, tool call and approval decision is already recorded; a session restores completely from its trace. There is no tracing SDK to install because there is no seam to instrument across. diff --git a/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.zh.md b/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.zh.md index 639546d..fa058f0 100644 --- a/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.zh.md +++ b/packages/landing/content/blog/easiest-way-to-build-ai-agents-2026.zh.md @@ -72,7 +72,7 @@ LangChain 自己也把遗留的 chain、retriever 和 hub 模块挪进了独立 ```bash curl -fsSL https://penguin.ooo/install.sh | sh -penguin web # http://127.0.0.1:7364 — 首次登录:admin / penguin-2026 +penguin web # http://127.0.0.1:7364 — 首次登录:admin,初始密码见首次启动输出 ``` 多会话对话、Agent 与技能管理、模型配置、用量与成本统计、**Trace 可观测**、**评测中心**,开箱即有,彼此已经打通,**不用订阅什么,也不用另外自建什么**。每个请求、每次工具调用、每个审批决策都已经记下来了,会话可以从 Trace 完整恢复。**这里没有追踪 SDK 要接,因为根本不存在需要跨越的接缝。** diff --git a/packages/landing/content/blog/fireworks-credits-amd.en.md b/packages/landing/content/blog/fireworks-credits-amd.en.md index 7e25153..f8732f2 100644 --- a/packages/landing/content/blog/fireworks-credits-amd.en.md +++ b/packages/landing/content/blog/fireworks-credits-amd.en.md @@ -65,7 +65,7 @@ With the API key in hand, three steps: ```bash curl -fsSL https://penguin.ooo/install.sh | sh -penguin web # opens http://127.0.0.1:7364 (first login: admin / penguin-2026) +penguin web # opens http://127.0.0.1:7364 (first login: admin, initial password printed on first start) ``` **2. Configure a Fireworks model** diff --git a/packages/landing/content/blog/fireworks-credits-amd.zh.md b/packages/landing/content/blog/fireworks-credits-amd.zh.md index d62e454..80214cd 100644 --- a/packages/landing/content/blog/fireworks-credits-amd.zh.md +++ b/packages/landing/content/blog/fireworks-credits-amd.zh.md @@ -65,7 +65,7 @@ AMD 会验证账户与申请资料,通常需要 **2–3 个工作日**;实 ```bash curl -fsSL https://penguin.ooo/install.sh | sh -penguin web # 打开 http://127.0.0.1:7364(首次登录:admin / penguin-2026) +penguin web # 打开 http://127.0.0.1:7364(首次登录:admin,初始密码见首次启动输出) ``` **2. 配置 Fireworks 模型** diff --git a/packages/landing/content/blog/introducing-penguinharness.en.md b/packages/landing/content/blog/introducing-penguinharness.en.md index 26f2f55..da6acb4 100644 --- a/packages/landing/content/blog/introducing-penguinharness.en.md +++ b/packages/landing/content/blog/introducing-penguinharness.en.md @@ -92,7 +92,7 @@ Install with one command (Linux / macOS, x64 / arm64, bundled Node runtime), the ```bash curl -fsSL https://penguin.ooo/install.sh | sh -penguin web # opens http://127.0.0.1:7364 (first login: admin / penguin-2026) +penguin web # opens http://127.0.0.1:7364 (first login: admin, initial password printed on first start) ``` Open the Models page, paste an API key under the DeepSeek or OpenRouter group and set it as default; then head back to Chat and hand the Agent its first task — e.g. "Analyze data.csv and summarize quarterly sales". diff --git a/packages/landing/content/blog/introducing-penguinharness.zh.md b/packages/landing/content/blog/introducing-penguinharness.zh.md index aa476fd..edf27d5 100644 --- a/packages/landing/content/blog/introducing-penguinharness.zh.md +++ b/packages/landing/content/blog/introducing-penguinharness.zh.md @@ -92,7 +92,7 @@ Token 与成本均为全套题目合计,不是单次均值。数据分析套 ```bash curl -fsSL https://penguin.ooo/install.sh | sh -penguin web # 打开 http://127.0.0.1:7364(首次登录:admin / penguin-2026) +penguin web # 打开 http://127.0.0.1:7364(首次登录:admin,初始密码见首次启动输出) ``` 进入「模型仓库」页,在 DeepSeek 或 OpenRouter 分组里粘贴 API key 并设为默认;回到对话页,把第一个任务交给 Agent——例如「分析 data.csv,输出各季度销售额汇总」。 diff --git a/packages/landing/scripts/capture-blog-013-shots.mjs b/packages/landing/scripts/capture-blog-013-shots.mjs index f055ff0..f0b6529 100644 --- a/packages/landing/scripts/capture-blog-013-shots.mjs +++ b/packages/landing/scripts/capture-blog-013-shots.mjs @@ -45,6 +45,8 @@ const ROOT = path.resolve(HERE, "../../.."); const OUT_DIR = path.resolve(HERE, "../.blog-assets"); const MOCK_PORT = 8953; // Distinct from capture-shots (8940/8941) and blog-shots (8944). const SRV_PORT = 8952; +// Pins the otherwise-random seeded admin password (PENGUIN_SEED_ADMIN_PASSWORD below). +const ADMIN_PASSWORD = "penguin-0000"; // The App is canonically served on `localhost` (127.0.0.1 is the Workspace-preview host). const BASE = `http://localhost:${SRV_PORT}`; const MOCK = `http://127.0.0.1:${MOCK_PORT}`; @@ -932,6 +934,7 @@ const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], { PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"), PORT: String(SRV_PORT), HOST: "127.0.0.1", + PENGUIN_SEED_ADMIN_PASSWORD: ADMIN_PASSWORD, }, stdio: ["ignore", "pipe", "pipe"], }); @@ -950,7 +953,7 @@ try { await waitFor(`${BASE}/`); console.log(`[blog-013] server ready on ${BASE}`); - const adminCookie = await login("admin", "penguin-2026"); + const adminCookie = await login("admin", ADMIN_PASSWORD); const browser = await chromium.launch(); // WebP encoder: Chromium re-encodes the PNG screenshot via canvas (capture-shots convention). diff --git a/packages/landing/scripts/capture-blog-shots.mjs b/packages/landing/scripts/capture-blog-shots.mjs index 0895f31..ebf8cff 100644 --- a/packages/landing/scripts/capture-blog-shots.mjs +++ b/packages/landing/scripts/capture-blog-shots.mjs @@ -36,6 +36,8 @@ const ROOT = path.resolve(HERE, "../../.."); // Gitignored staging dir: these images are hosted in the community repo, not committed here. const OUT_DIR = path.resolve(HERE, "../.blog-assets"); const SRV_PORT = 8944; // Distinct from capture-shots.mjs (8940/8941) so both can run. +// Pins the otherwise-random seeded admin password (PENGUIN_SEED_ADMIN_PASSWORD below). +const ADMIN_PASSWORD = "penguin-0000"; // On loopback binds the App is canonically served on `localhost`; the 127.0.0.1 // counterpart is the Workspace-preview host, where /api deliberately answers 401 // (see server app.ts's canonical-host guard). @@ -113,6 +115,7 @@ const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], { PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"), PORT: String(SRV_PORT), HOST: "127.0.0.1", + PENGUIN_SEED_ADMIN_PASSWORD: ADMIN_PASSWORD, }, stdio: ["ignore", "pipe", "pipe"], }); @@ -127,7 +130,7 @@ try { await waitFor(`${BASE}/`); console.log(`[blog-shots] server ready on ${BASE}`); - const adminCookie = await login("admin", "penguin-2026"); + const adminCookie = await login("admin", ADMIN_PASSWORD); const browser = await chromium.launch(); // WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas (same diff --git a/packages/landing/scripts/capture-shots.mjs b/packages/landing/scripts/capture-shots.mjs index 314a8fe..7af362b 100644 --- a/packages/landing/scripts/capture-shots.mjs +++ b/packages/landing/scripts/capture-shots.mjs @@ -27,6 +27,8 @@ const OUT_DIR = path.resolve(HERE, "../src/assets/shots"); const MOCK_PORT = 8941; const SRV_PORT = 8940; const BASE = `http://127.0.0.1:${SRV_PORT}`; +// Pins the otherwise-random seeded admin password (PENGUIN_SEED_ADMIN_PASSWORD below). +const ADMIN_PASSWORD = "penguin-0000"; const MOCK = `http://127.0.0.1:${MOCK_PORT}`; // --------------------------------------------------------------------------- @@ -523,6 +525,7 @@ const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], { PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"), PORT: String(SRV_PORT), HOST: "127.0.0.1", + PENGUIN_SEED_ADMIN_PASSWORD: ADMIN_PASSWORD, }, stdio: ["ignore", "pipe", "pipe"], }); @@ -542,7 +545,7 @@ try { await waitFor(`${BASE}/`); console.log(`[shots] server ready on ${BASE}`); - const admin = await login("admin", "penguin-2026"); + const admin = await login("admin", ADMIN_PASSWORD); const browser = await chromium.launch(); // WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas, which diff --git a/packages/landing/src/lib/strings-en.ts b/packages/landing/src/lib/strings-en.ts index fefdf84..974bfd4 100644 --- a/packages/landing/src/lib/strings-en.ts +++ b/packages/landing/src/lib/strings-en.ts @@ -172,7 +172,7 @@ export const en: Strings = { tabCli: "CLI", webStep2: "Open the web interface", webStep2Desc: - "penguin web starts the local service and opens your browser; sign in with the built-in admin account admin / penguin-2026 (change the password right after).", + "penguin web starts the local service and opens your browser; sign in as the built-in admin “admin” with the initial password printed in the terminal on first start (looks like penguin-1234; change it right after).", webCmd: "penguin web # opens http://127.0.0.1:7364", webStep3: "Configure a model in the UI and start chatting", webStep3Desc: diff --git a/packages/landing/src/lib/strings.ts b/packages/landing/src/lib/strings.ts index 55acb71..5e5e975 100644 --- a/packages/landing/src/lib/strings.ts +++ b/packages/landing/src/lib/strings.ts @@ -176,7 +176,7 @@ export const zh = { tabCli: "命令行", webStep2: "启动 Web 界面", webStep2Desc: - "penguin web 启动本地服务并打开浏览器,用内置管理员 admin / penguin-2026 登录(登录后请尽快修改密码)。", + "penguin web 启动本地服务并打开浏览器,用内置管理员 admin 登录——初始密码在服务端首次启动时打印到终端(形如 penguin-1234),登录后请尽快修改密码。", webCmd: "penguin web # 打开 http://127.0.0.1:7364", webStep3: "在界面里配置模型,开始对话", webStep3Desc: diff --git a/packages/server/README.md b/packages/server/README.md index 8095060..8e0737e 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -36,8 +36,8 @@ pnpm --filter @prismshadow/penguin-server start # node dist/index.js ## Security notes (known MVP limits) - **CSRF**: session cookie is `SameSite=Lax` and writes accept only `Content-Type: application/json`; no CSRF token yet. -- **No login rate limiting**: add throttling at a reverse proxy for public deployments. -- **Built-in admin starts as `admin` / `penguin-2026`**: change it immediately (a banner keeps reminding until you do). +- **Login throttling**: per-username exponential backoff after 5 consecutive failures (1s doubling to a 60s cap, `429 too_many_attempts` inside the window, reset on success; in-memory, so a restart clears it). Unknown usernames are throttled identically, so it is not an account-existence oracle. A reverse proxy can still add IP-level limits for public deployments. +- **Built-in admin `admin` starts with a random initial password** of the form `penguin-1234`, printed once to the server console at seed time (`PENGUIN_SEED_ADMIN_PASSWORD` pins it for tests/e2e): change it immediately (a banner keeps reminding until you do). - Passwords use `node:crypto` scrypt (`scrypt$N$r$p$salt$hash`, timingSafeEqual); login sessions renew on a 7-day sliding window; the DB stores only the token's sha256. - Model credentials live in the Project's hidden 0600 config file; the API always masks them. - Behind a reverse proxy, disable response buffering for SSE paths (the server already sends `X-Accel-Buffering: no`) and forward `x-forwarded-proto` to enable Secure cookies. diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 1745886..89b8135 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -214,6 +214,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides authSessions: authSessionsRepo, provisionInitialProject: (user, isAdmin) => projectService.provisionInitialProject(user, isAdmin), + seedAdminPassword: config.seedAdminPassword, sessionTtlMs: config.authSessionTtlMs, sessionRenewMs: config.authSessionRenewMs, ...(overrides.now ? { now: overrides.now } : {}), diff --git a/packages/server/src/auth/service.ts b/packages/server/src/auth/service.ts index d1ba279..6f91779 100644 --- a/packages/server/src/auth/service.ts +++ b/packages/server/src/auth/service.ts @@ -3,15 +3,16 @@ * login / logout / password change / session validation. * * - No open registration: on startup, if there are no users at all, the built-in - * admin `admin` is seeded (initial password penguin-2026), and it adopts - * `default_project`; all other users are created by an admin via the user - * backend (admin-service). + * admin `admin` is seeded with a random `penguin-<4 digits>` initial password + * (printed once by the startup entrypoint; PENGUIN_SEED_ADMIN_PASSWORD injects + * a fixed one for tests/e2e), and it adopts `default_project`; all other users + * are created by an admin via the user backend (admin-service). * - An initial password (whether seeded or set by an admin) is flagged with * password_is_initial, which the frontend uses to prompt for a password change soon. * - Sessions: a 32-byte random token, with only its sha256 hash stored in the DB; * valid for 7 days, with sliding renewal once less than 6 days remain. */ -import { createHash, randomBytes } from "node:crypto"; +import { createHash, randomBytes, randomInt } from "node:crypto"; import type { UserInfo } from "../api/types.js"; import { HttpError } from "../http/errors.js"; import type { AuthSessionsRepo } from "../db/repos/auth-sessions.js"; @@ -20,9 +21,37 @@ import { hashPassword, verifyPassword } from "./password.js"; export const MIN_PASSWORD_LENGTH = 8; -/** Built-in admin: user_id and initial password (matches the README and login-page hint). */ +/** Built-in admin user_id. */ export const ADMIN_USER_ID = "admin"; -export const ADMIN_INITIAL_PASSWORD = "penguin-2026"; + +/** + * Login throttling (per userId): the seeded initial password is `penguin-<4 digits>` — + * 10,000 combinations — so unthrottled guessing would enumerate it in minutes. After + * LOGIN_FREE_ATTEMPTS consecutive failures, the next attempt is admitted only after an + * exponentially growing delay from the last failure (1s, 2s, … capped at 60s; attempts + * inside the window are 429 `too_many_attempts` and do not extend it). Beyond ~40 + * failures that is one guess per minute, so the 10k space stops being enumerable, while + * a legitimate user who mistyped a few times never waits more than the cap. A successful + * login clears the counter. Counters are process memory (a restart clears them — + * restarting is slower than waiting out the cap) and are kept for nonexistent userIds + * too, so throttling is not an account-existence oracle. Known limit: a concurrent burst + * can slip in before its first failure is recorded; the steady-state backoff still + * dominates the search space. + */ +const LOGIN_FREE_ATTEMPTS = 5; +const LOGIN_BACKOFF_START_MS = 1000; +const LOGIN_BACKOFF_CAP_MS = 60_000; +/** Failure entries idle longer than this are swept (bounds the map; far above the cap). */ +const LOGIN_FAILURE_IDLE_MS = 15 * 60_000; + +/** + * Random initial password for the seeded admin: `penguin-<4 digits>` — brand-related and + * easy to type, shown once in the server startup output (the README, docs and login-page + * hint all describe this form). + */ +export function generateInitialAdminPassword(): string { + return "penguin-" + String(randomInt(0, 10000)).padStart(4, "0"); +} function sha256Hex(value: string): string { return createHash("sha256").update(value).digest("hex"); @@ -42,6 +71,8 @@ export interface AuthServiceDeps { authSessions: AuthSessionsRepo; /** Provisions the initial Project at signup (injected by project-service, to avoid a circular dependency). */ provisionInitialProject: (user: UserRow, isAdmin: boolean) => Promise; + /** Fixed initial password for the seeded admin (config.seedAdminPassword); null generates a random one at seed time. */ + seedAdminPassword: string | null; sessionTtlMs: number; sessionRenewMs: number; now?: () => Date; @@ -58,12 +89,25 @@ export class AuthService { * Startup seeding (idempotent): creates the built-in admin and adopts * default_project when the users table is empty; if the initial Project fails, * the user row is rolled back and the server retries on next startup. + * Returns the initial password when it actually seeded — the caller prints it, + * the only place a generated password is ever shown — and null when users + * already exist. */ - async seedAdmin(): Promise { - if (this.deps.users.count() > 0) return; + async seedAdmin(): Promise { + if (this.deps.users.count() > 0) return null; + const password = this.deps.seedAdminPassword ?? generateInitialAdminPassword(); + // The override (PENGUIN_SEED_ADMIN_PASSWORD) must meet the same policy as every + // other initial/reset password; rejecting it here, before any insert, keeps a + // configuration typo from creating a trivially weak privileged account. Generated + // passwords are always 12 characters and never trip this. + if (password.length < MIN_PASSWORD_LENGTH) { + throw new Error( + `PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`, + ); + } const user: UserRow = { userId: ADMIN_USER_ID, - passwordHash: await hashPassword(ADMIN_INITIAL_PASSWORD), + passwordHash: await hashPassword(password), isAdmin: true, passwordIsInitial: true, createdAt: this.now().toISOString(), @@ -75,14 +119,45 @@ export class AuthService { this.deps.users.delete(user.userId); throw err; } + return password; + } + + /** Consecutive login failures per userId (see the throttling comment on the constants). */ + private readonly loginFailures = new Map(); + + /** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */ + private loginDelayMs(failures: number): number { + const excess = failures - LOGIN_FREE_ATTEMPTS; + if (excess <= 0) return 0; + return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS); } async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> { + const nowMs = this.now().getTime(); + for (const [key, entry] of this.loginFailures) { + if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key); + } + const failed = this.loginFailures.get(userId); + if (failed) { + const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures); + if (nowMs < readyAt) { + throw new HttpError( + 429, + "too_many_attempts", + `Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1000)}s.`, + ); + } + } const row = this.deps.users.findById(userId); const ok = row !== null && (await verifyPassword(password, row.passwordHash)); if (!row || !ok) { + this.loginFailures.set(userId, { + failures: (failed?.failures ?? 0) + 1, + lastFailureAt: this.now().getTime(), + }); throw new HttpError(401, "invalid_credentials", "Incorrect username or password."); } + this.loginFailures.delete(userId); this.deps.authSessions.deleteExpired(this.now().toISOString()); return { user: toUserInfo(row), token: this.issueSession(row.userId) }; } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 44b14bc..6219fe2 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -32,6 +32,12 @@ export interface ServerConfig { * derived per request instead. See design § "Workspace 文件预览". */ previewOrigin: string | null; + /** + * Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD), + * used by automated tests and e2e; null (the norm) makes the seed generate a random + * `penguin-<4 digits>` password, printed once to the server console. + */ + seedAdminPassword: string | null; /** Login session validity period (7 days). */ authSessionTtlMs: number; /** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */ @@ -73,7 +79,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null { return url.origin; } -/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN). */ +/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD). */ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const root = env.PENGUIN_HOME ?? resolveRoot(); // An empty PORT string is treated as unset (the common `.env` case of an empty @@ -90,6 +96,8 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"), webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(), previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN), + // An empty/whitespace value is treated as unset (→ random seed password). + seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null, authSessionTtlMs: 7 * DAY_MS, authSessionRenewMs: 6 * DAY_MS, }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f61f5e6..9f3a9f9 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,8 +21,15 @@ const config = resolveServerConfig(); const deps = buildAppDeps(config); const app = createApp(deps); -// Built-in admin seed (idempotent): creates admin (initial password penguin-2026) and adopts default_project when the users table is empty. -await deps.authService.seedAdmin(); +// Built-in admin seed (idempotent): creates admin and adopts default_project when the +// users table is empty. The returned initial password (random unless pinned via +// PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown. +const seededAdminPassword = await deps.authService.seedAdmin(); +if (seededAdminPassword !== null) { + console.log( + `Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`, + ); +} // Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running. await deps.scheduler.start(); diff --git a/packages/server/test/auth.test.ts b/packages/server/test/auth.test.ts index 38063b0..efca8d9 100644 --- a/packages/server/test/auth.test.ts +++ b/packages/server/test/auth.test.ts @@ -6,7 +6,18 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MeResponse, ProjectsResponse } from "../src/api/types.js"; -import { apiClient, createTestApp, loginAdmin, loginUser, provisionUser } from "./helpers.js"; +import { buildAppDeps } from "../src/app.js"; +import { generateInitialAdminPassword } from "../src/auth/service.js"; +import { + apiClient, + createTestApp, + loginAdmin, + loginUser, + makeTempRoot, + provisionUser, + TEST_ADMIN_PASSWORD, + testConfig, +} from "./helpers.js"; import type { TestApp } from "./helpers.js"; describe("auth", () => { @@ -55,8 +66,8 @@ describe("auth", () => { await expect( fs.access(path.join(t.root, "default_project", "agents", "default_agent", "agent_state")), ).resolves.toBeUndefined(); - // Seeding is idempotent: re-seeding does not create a duplicate account. - await t.deps.authService.seedAdmin(); + // Seeding is idempotent: re-seeding returns null and does not create a duplicate account. + expect(await t.deps.authService.seedAdmin()).toBeNull(); expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1); }); @@ -159,6 +170,98 @@ describe("auth", () => { expect(got.prefs.theme).toBe("dark"); }); + it("seedAdmin without an injected password generates penguin-<4 digits> and returns it", async () => { + // Bypass the fixed test password: null matches the production default (random generation). + const fresh = await createTestApp({ config: { seedAdminPassword: null } }); + try { + expect(fresh.adminPassword).toMatch(/^penguin-\d{4}$/); + // The returned password is the one that actually logs in. + await loginUser(fresh.app, "admin", fresh.adminPassword); + // Users exist now: re-seeding reports that nothing was seeded. + expect(await fresh.deps.authService.seedAdmin()).toBeNull(); + } finally { + await fresh.cleanup(); + } + }); + + it("seedAdmin honors the injected seedAdminPassword", async () => { + const fresh = await createTestApp({ config: { seedAdminPassword: "penguin-7777" } }); + try { + expect(fresh.adminPassword).toBe("penguin-7777"); + await loginUser(fresh.app, "admin", "penguin-7777"); + } finally { + await fresh.cleanup(); + } + }); + + it("seedAdmin rejects an override below the password policy before creating the account", async () => { + const root = await makeTempRoot(); + const deps = buildAppDeps({ ...testConfig(root), seedAdminPassword: "x" }, { log: () => {} }); + try { + await expect(deps.authService.seedAdmin()).rejects.toThrow(/at least 8 characters/); + // Rejected before any insert: no half-created privileged account to retry around. + expect(deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(0); + } finally { + deps.channels.dispose(); + deps.db.close(); + await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }); + + it("throttles login failures per username with exponential backoff and resets on success", async () => { + let clock = Date.parse("2026-08-03T00:00:00Z"); + const fresh = await createTestApp({ now: () => new Date(clock) }); + try { + const attempt = (password: string) => + fresh.app.request("/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: "admin", password }), + }); + // Five free failures, and the sixth still reaches verification (backoff starts after it). + for (let i = 0; i < 6; i++) expect((await attempt("wrong-password")).status).toBe(401); + // Inside the 1s window: rejected without touching credentials — even the CORRECT password. + const throttled = await attempt("wrong-password"); + expect(throttled.status).toBe(429); + const body = (await throttled.json()) as { error: { code: string } }; + expect(body.error.code).toBe("too_many_attempts"); + expect((await attempt(TEST_ADMIN_PASSWORD)).status).toBe(429); + // Past the window, the correct password signs in and clears the counter… + clock += 1100; + await loginUser(fresh.app, "admin", TEST_ADMIN_PASSWORD); + // …so the next failure is an ordinary 401 again, not a 429. + expect((await attempt("wrong-password")).status).toBe(401); + } finally { + await fresh.cleanup(); + } + }); + + it("throttles unknown usernames identically (no account-existence oracle)", async () => { + let clock = Date.parse("2026-08-03T00:00:00Z"); + const fresh = await createTestApp({ now: () => new Date(clock) }); + try { + const attempt = () => + fresh.app.request("/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: "ghost", password: "whatever-123" }), + }); + for (let i = 0; i < 6; i++) expect((await attempt()).status).toBe(401); + expect((await attempt()).status).toBe(429); + // The window expires on the same schedule as for real accounts. + clock += 1100; + expect((await attempt()).status).toBe(401); + } finally { + await fresh.cleanup(); + } + }); + + it("generateInitialAdminPassword matches penguin-<4 digits>", () => { + for (let i = 0; i < 32; i++) { + expect(generateInitialAdminPassword()).toMatch(/^penguin-\d{4}$/); + } + }); + it("PUT prefs shallow-merges without clobbering other writers' fields", async () => { const { cookie } = await provisionUser(t.app, "fred"); const api = apiClient(t.app, cookie); diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts index 9479c00..b7eb8a1 100644 --- a/packages/server/test/config.test.ts +++ b/packages/server/test/config.test.ts @@ -1,9 +1,12 @@ /** - * resolveServerConfig PORT parsing tests: both the default (missing) and empty string - * (the common `PORT=` empty value in `.env`) fall back to 7364 — Number("") === 0 used - * to make the empty string pass range validation and bind to a random port; explicit - * "0" is preserved (explicit semantics for a random available port); invalid values - * throw. This matches the CLI's resolvePort semantics (packages/cli serve). + * resolveServerConfig parsing tests. + * + * PORT: both the default (missing) and empty string (the common `PORT=` empty value in + * `.env`) fall back to 7364 — Number("") === 0 used to make the empty string pass range + * validation and bind to a random port; explicit "0" is preserved (explicit semantics + * for a random available port); invalid values throw. This matches the CLI's + * resolvePort semantics (packages/cli serve). + * PENGUIN_SEED_ADMIN_PASSWORD: unset/empty/whitespace → null (random seed password). */ import { describe, expect, it } from "vitest"; import { resolveServerConfig } from "../src/config.js"; @@ -27,3 +30,19 @@ describe("resolveServerConfig: PORT parsing", () => { } }); }); + +describe("resolveServerConfig: PENGUIN_SEED_ADMIN_PASSWORD parsing", () => { + it("unset/empty/whitespace → null; a value is kept trimmed", () => { + expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull(); + expect( + resolveServerConfig({ ...base, PENGUIN_SEED_ADMIN_PASSWORD: "" }).seedAdminPassword, + ).toBeNull(); + expect( + resolveServerConfig({ ...base, PENGUIN_SEED_ADMIN_PASSWORD: " " }).seedAdminPassword, + ).toBeNull(); + expect( + resolveServerConfig({ ...base, PENGUIN_SEED_ADMIN_PASSWORD: " penguin-9999 " }) + .seedAdminPassword, + ).toBe("penguin-9999"); + }); +}); diff --git a/packages/server/test/helpers.ts b/packages/server/test/helpers.ts index b8b9b2a..69e1a88 100644 --- a/packages/server/test/helpers.ts +++ b/packages/server/test/helpers.ts @@ -11,7 +11,7 @@ import type { OmniMessage } from "@prismshadow/penguin-core"; import { buildAppDeps, createApp } from "../src/app.js"; import type { AppDeps, BuildDepsOverrides } from "../src/app.js"; import type { AppEnv } from "../src/auth/middleware.js"; -import { ADMIN_INITIAL_PASSWORD, ADMIN_USER_ID } from "../src/auth/service.js"; +import { ADMIN_USER_ID } from "../src/auth/service.js"; import type { ServerConfig } from "../src/config.js"; import type { UserInfo } from "../src/api/types.js"; @@ -21,6 +21,9 @@ export async function makeTempRoot(): Promise { const DAY_MS = 24 * 60 * 60 * 1000; +/** Fixed seeded-admin password injected into every test app (in production the seed generates a random one). */ +export const TEST_ADMIN_PASSWORD = "penguin-0000"; + export function testConfig(root: string): ServerConfig { return { root, @@ -32,6 +35,8 @@ export function testConfig(root: string): ServerConfig { previewOrigin: null, // Points to a nonexistent directory: static hosting is disabled in tests. webDist: path.join(root, "__no_web_dist__"), + // Fixed seed password so loginAdmin needs no seed-time capture. + seedAdminPassword: TEST_ADMIN_PASSWORD, authSessionTtlMs: 7 * DAY_MS, authSessionRenewMs: 6 * DAY_MS, }; @@ -41,6 +46,8 @@ export interface TestApp { app: Hono; deps: AppDeps; root: string; + /** Initial password of the seeded admin (TEST_ADMIN_PASSWORD unless overridden via `config.seedAdminPassword`). */ + adminPassword: string; cleanup(): Promise; } @@ -56,13 +63,15 @@ export async function createTestApp(options: TestAppOptions = {}): Promise {}, ...overrides }); - // Consistent with the startup entrypoint: seed the built-in admin (owning default_project). - await deps.authService.seedAdmin(); + // Consistent with the startup entrypoint: seed the built-in admin (owning default_project), + // keeping the password it returns (only null if a beforeSeed hook ever pre-created users). + const adminPassword = (await deps.authService.seedAdmin()) ?? TEST_ADMIN_PASSWORD; const app = createApp(deps); return { app, deps, root, + adminPassword, cleanup: async () => { deps.channels.dispose(); deps.db.close(); @@ -95,9 +104,9 @@ export async function loginUser( return { cookie: setCookie.split(";")[0]!, user: body.user }; } -/** Logs in as the seeded admin. */ +/** Logs in as the seeded admin (every test app seeds with TEST_ADMIN_PASSWORD). */ export function loginAdmin(app: Hono): Promise<{ cookie: string; user: UserInfo }> { - return loginUser(app, ADMIN_USER_ID, ADMIN_INITIAL_PASSWORD); + return loginUser(app, ADMIN_USER_ID, TEST_ADMIN_PASSWORD); } /** Admin creates the account and logs in as that user (the only way to create test users while registration is closed). */ diff --git a/packages/web/e2e/auth.mjs b/packages/web/e2e/auth.mjs index 4d471a5..9f4adbb 100644 --- a/packages/web/e2e/auth.mjs +++ b/packages/web/e2e/auth.mjs @@ -1,9 +1,11 @@ /** * e2e auth helper: with signup disabled, test users are always provisioned via - * the built-in admin account, then logged in. The server seeds an admin - * (admin / penguin-2026) on startup; a single e2e run shares one data root, and - * provisioning is idempotent (reuses the user if it already exists) so a - * single spec can be rerun on its own. + * the built-in admin account, then logged in. The seeded admin password is + * random in production; run.sh starts the e2e server with + * PENGUIN_SEED_ADMIN_PASSWORD=penguin-2026 to pin it to the constant below. + * A single e2e run shares one data root, and provisioning is idempotent + * (reuses the user if it already exists) so a single spec can be rerun on its + * own. */ import { request } from "@playwright/test"; diff --git a/packages/web/e2e/run.sh b/packages/web/e2e/run.sh index eeafa69..d09c875 100644 --- a/packages/web/e2e/run.sh +++ b/packages/web/e2e/run.sh @@ -32,8 +32,11 @@ MOCK_PORT=$MOCK_PORT node "$HERE/mock-llm.mjs" & MOCK_PID=$! echo "== start server ==" +# PENGUIN_SEED_ADMIN_PASSWORD pins the otherwise-random seeded admin password to the +# constant the specs use (ADMIN_PASSWORD in auth.mjs). PENGUIN_HOME="$DATA" PORT=$SRV_PORT HOST=127.0.0.1 PENGUIN_WEB_DB="$DATA/web.db" \ PENGUIN_WEB_DIST="$ROOT/packages/web/dist" \ + PENGUIN_SEED_ADMIN_PASSWORD=penguin-2026 \ node "$ROOT/packages/server/dist/index.js" & SRV_PID=$! diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index da862ae..f1df81c 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -130,13 +130,14 @@ export const en: Strings = { logout: "Sign out", admin: "Admin", defaultAdminNote: - "First run: sign in as the built-in admin (admin / penguin-2026), then change the password soon", + "First run: sign in as the built-in admin “admin” with the initial password printed in the server startup output (looks like penguin-1234), then change it soon", }, account: { changePassword: "Change password", oldPassword: "Current password", - oldPasswordHint: "The built-in admin's default initial password is penguin-2026", + oldPasswordHint: + "The built-in admin's initial password is printed in the server startup output (looks like penguin-1234)", newPassword: "New password", confirmPassword: "Confirm new password", passwordMismatch: "New passwords do not match", @@ -1061,6 +1062,7 @@ Scenarios: /** Localized text for the common server error codes (server error messages are English-only); looked up by ApiError.code in apiErrorText, falling back to the raw message for unmapped codes. */ byCode: { invalid_credentials: "Incorrect username or password.", + too_many_attempts: "Too many failed sign-in attempts. Try again shortly.", password_mismatch: "The current password is incorrect.", invalid_password: "Password must be at least 8 characters.", admin_required: "Only an admin can perform this operation.", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 7f47b83..ee8fe18 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -127,13 +127,14 @@ export const zh = { login: "登录", logout: "登出", admin: "管理员", - defaultAdminNote: "首次使用请以内置管理员登录:admin / penguin-2026,登录后请尽快修改密码", + defaultAdminNote: + "首次使用请以内置管理员 admin 登录,初始密码在服务端首次启动时打印(形如 penguin-1234),登录后请尽快修改密码", }, account: { changePassword: "修改密码", oldPassword: "当前密码", - oldPasswordHint: "内置管理员的默认初始密码为 penguin-2026", + oldPasswordHint: "内置管理员的初始密码在服务端首次启动时打印(形如 penguin-1234)", newPassword: "新密码", confirmPassword: "确认新密码", passwordMismatch: "两次输入的新密码不一致", @@ -1040,6 +1041,7 @@ Benchmark: /** Localized text for the common server error codes (server error messages are English-only); looked up by ApiError.code in apiErrorText, falling back to the raw message for unmapped codes. */ byCode: { invalid_credentials: "用户名或密码错误。", + too_many_attempts: "登录失败次数过多,请稍后重试。", password_mismatch: "当前密码不正确。", invalid_password: "密码至少 8 位。", admin_required: "仅管理员可执行此操作。",