Kaynağa Gözat

将 Web 默认端口迁移到 15300 并移除固定管理员密码

LSamien 1 ay önce
ebeveyn
işleme
4276e77fb9
65 değiştirilmiş dosya ile 406 ekleme ve 361 silme
  1. 1 1
      .env.example
  2. 3 3
      Dockerfile
  3. 1 1
      README.md
  4. 1 1
      bin/hermes-yaoyao-updater.mjs
  5. 1 1
      bin/hermes-yaoyao.mjs
  6. 3 3
      compose.yaml
  7. 2 2
      docker.env.example
  8. 2 2
      docs/agent-install.md
  9. 2 2
      docs/http-sse-realtime.md
  10. 5 5
      docs/loopback-authorization.md
  11. 3 3
      docs/read-cache.md
  12. 6 0
      docs/releases/v0.3.22.md
  13. 2 2
      package-lock.json
  14. 1 1
      package.json
  15. 3 3
      release.json
  16. 9 0
      src/client/api/auth.ts
  17. 1 1
      src/client/api/httpRealtime.ts
  18. 2 2
      src/client/components/app/AccountSecurityPanel.vue
  19. 3 3
      src/client/components/app/NodePairingDialog.vue
  20. 4 4
      src/client/components/app/NodePairingPanel.vue
  21. 10 10
      src/client/components/app/SystemManagementPanel.vue
  22. 1 1
      src/client/components/app/SystemOverviewPanel.vue
  23. 1 1
      src/client/components/app/SystemUpdateDialog.vue
  24. 1 1
      src/client/components/app/SystemUpdatePanel.vue
  25. 15 2
      src/client/stores/auth.ts
  26. 1 1
      src/client/views/GroupsView.vue
  27. 14 5
      src/client/views/LoginView.vue
  28. 1 1
      src/server/apnsConfiguration.ts
  29. 2 1
      src/server/app.ts
  30. 1 1
      src/server/config.ts
  31. 2 2
      src/server/dashboardSupervisor.ts
  32. 1 1
      src/server/fcmConfiguration.ts
  33. 23 6
      src/server/localAuth.ts
  34. 1 1
      src/server/pushCoordinator.ts
  35. 15 3
      src/server/routes.ts
  36. 1 0
      src/shared/types.ts
  37. 3 3
      tests/client/systemManagementDialog.test.ts
  38. 10 14
      tests/e2e/hermesMedia.spec.ts
  39. 2 7
      tests/e2e/offlineUpdate.spec.ts
  40. 6 18
      tests/e2e/workspace.spec.ts
  41. 1 1
      tests/fixtures/fake-hermes.mjs
  42. 1 1
      tests/server/accountPairing.test.ts
  43. 13 13
      tests/server/agentManagementRoutes.test.ts
  44. 5 5
      tests/server/allowedHostsConfiguration.test.ts
  45. 45 43
      tests/server/app.test.ts
  46. 2 2
      tests/server/dashboardSupervisor.test.ts
  47. 2 2
      tests/server/hermesMediaRoutes.test.ts
  48. 3 3
      tests/server/kanbanRoutes.test.ts
  49. 17 25
      tests/server/localAuth.test.ts
  50. 41 45
      tests/server/localAuthRoutes.test.ts
  51. 11 5
      tests/server/offlineSystemUpdate.test.ts
  52. 13 13
      tests/server/pairing.test.ts
  53. 3 3
      tests/server/pushEvents.test.ts
  54. 24 24
      tests/server/pushRoutes.test.ts
  55. 1 1
      tests/server/pushRuntime.test.ts
  56. 13 15
      tests/server/readCacheRoutes.test.ts
  57. 11 15
      tests/server/realtimeApi.test.ts
  58. 2 2
      tests/server/realtimeMigration.test.ts
  59. 11 11
      tests/server/security.test.ts
  60. 6 6
      tests/server/systemUpdateRoutes.test.ts
  61. 1 1
      tests/server/updateManager.test.ts
  62. 1 1
      tests/server/upstream.test.ts
  63. 3 3
      tests/server/workspaceRoutes.test.ts
  64. 8 0
      tests/serviceCli.test.ts
  65. 2 2
      tests/updater.test.ts

+ 1 - 1
.env.example

@@ -1,6 +1,6 @@
 # Manual npm/Docker runs stay loopback-only unless changed; macOS `service install` defaults to 0.0.0.0.
 HERMES_YAOYAO_HOST=127.0.0.1
-HERMES_YAOYAO_PORT=8800
+HERMES_YAOYAO_PORT=15300
 HERMES_YAOYAO_UPSTREAM=http://127.0.0.1:9119
 # Comma-separated additional DNS names. Localhost and active private IP literals are allowed automatically.
 HERMES_YAOYAO_ALLOWED_HOSTS=

+ 3 - 3
Dockerfile

@@ -21,7 +21,7 @@ FROM node:24-bookworm-slim AS runtime
 
 ENV NODE_ENV=production \
     HERMES_YAOYAO_HOST=0.0.0.0 \
-    HERMES_YAOYAO_PORT=8800 \
+    HERMES_YAOYAO_PORT=15300 \
     HERMES_YAOYAO_HOME=/var/lib/hermes-yaoyao \
     HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 
@@ -36,9 +36,9 @@ COPY --chown=node:node --from=builder /app/dist-server ./dist-server
 
 USER node
 
-EXPOSE 8800
+EXPOSE 15300
 
 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
-  CMD ["node", "-e", "fetch('http://127.0.0.1:8800/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
+  CMD ["node", "-e", "fetch('http://127.0.0.1:15300/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
 
 CMD ["node", "dist-server/server/index.js"]

+ 1 - 1
README.md

@@ -24,7 +24,7 @@ npm run build
 npm start
 ```
 
-默认 Web 地址为 `http://127.0.0.1:8800`,上游为 `http://127.0.0.1:9119`。首次使用 `admin/admin` 登录并修改密码。远端 Hermes 的凭据可在系统设置中配置。
+默认 Web 地址为 `http://127.0.0.1:15300`,上游为 `http://127.0.0.1:9119`。首次打开 Web 时自行创建管理员账号;服务不会生成固定的默认账号或密码。远端 Hermes 的凭据可在系统设置中配置。
 
 ```sh
 HERMES_YAOYAO_UPSTREAM=http://服务器:9119 npm start

+ 1 - 1
bin/hermes-yaoyao-updater.mjs

@@ -210,7 +210,7 @@ export async function verifyRuntime(timeoutMs = 45_000, fetchImpl = fetch) {
     try {
       // /readyz deliberately includes 9119 reachability. Only the Web's own
       // health decides update/rollback success, including older releases.
-      const health = await responseJSON('http://127.0.0.1:8800/healthz', fetchImpl)
+      const health = await responseJSON('http://127.0.0.1:15300/healthz', fetchImpl)
       if (health.ok === true) return
       lastError = 'Web 服务尚未就绪'
     } catch (error) {

+ 1 - 1
bin/hermes-yaoyao.mjs

@@ -68,7 +68,7 @@ export function launchAgentPlist(options = {}) {
     PATH: `${dirname(node)}:${join(homedir(), '.local', 'bin')}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
     NODE_ENV: 'production',
     HERMES_YAOYAO_HOME: dataHome,
-    HERMES_YAOYAO_PORT: '8800',
+    HERMES_YAOYAO_PORT: '15300',
     HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:9119',
     HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1',
     ...environment(options.environment),

+ 3 - 3
compose.yaml

@@ -14,16 +14,16 @@ services:
     cap_drop:
       - ALL
     ports:
-      - "${HERMES_YAOYAO_BIND_ADDRESS:-127.0.0.1}:${HERMES_YAOYAO_PUBLISHED_PORT:-8800}:8800"
+      - "${HERMES_YAOYAO_BIND_ADDRESS:-127.0.0.1}:${HERMES_YAOYAO_PUBLISHED_PORT:-15300}:15300"
     extra_hosts:
       - "host.docker.internal:host-gateway"
     environment:
       NODE_ENV: production
       HERMES_YAOYAO_HOST: 0.0.0.0
-      HERMES_YAOYAO_PORT: 8800
+      HERMES_YAOYAO_PORT: 15300
       HERMES_YAOYAO_UPSTREAM: "${HERMES_YAOYAO_UPSTREAM:-http://host.docker.internal:9119}"
       # Permit localhost access when the published host port differs from the
-      # container's fixed 8800 port; configured public host names remain opt-in.
+      # container's fixed 15300 port; configured public host names remain opt-in.
       HERMES_YAOYAO_ALLOWED_HOSTS: "localhost,127.0.0.1,${HERMES_YAOYAO_ALLOWED_HOSTS:-}"
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: "1"
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"

+ 2 - 2
docker.env.example

@@ -1,6 +1,6 @@
-# 宿主机仅在本机发布 8800;需要通过反向代理或可信局域网访问时再改为 0.0.0.0。
+# 宿主机仅在本机发布 15300;需要通过反向代理或可信局域网访问时再改为 0.0.0.0。
 HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
-HERMES_YAOYAO_PUBLISHED_PORT=8800
+HERMES_YAOYAO_PUBLISHED_PORT=15300
 
 # 9119 由项目外部的 Hermes Dashboard 提供,Compose 不会创建、启动或停止它。
 # Docker Desktop 可用这个地址访问宿主机;Linux 上的 9119 还必须监听容器可达的宿主机地址。

+ 2 - 2
docs/agent-install.md

@@ -8,11 +8,11 @@
 
 ## 源码安装
 
-在仓库执行 `npm ci`、`npm run build`,然后执行 `npm start`。Web 默认监听 `127.0.0.1:8800`。
+在仓库执行 `npm ci`、`npm run build`,然后执行 `npm start`。Web 默认监听 `127.0.0.1:15300`。
 
 上游地址由 `HERMES_YAOYAO_UPSTREAM` 配置。远端凭据在 Web 系统设置中保存。需要环境配置时使用 `HERMES_YAOYAO_UPSTREAM_USERNAME` 和 `HERMES_YAOYAO_UPSTREAM_PASSWORD`,不要把凭据嵌入 URL。
 
-首次登录使用 `admin/admin`,随后必须修改密码。iOS 使用 Web 的账号或手机登录二维码。
+首次打开 Web 时创建管理员账号和至少 8 位的密码。服务不会生成固定的默认账号或密码;iOS 使用 Web 账号或手机登录二维码。
 
 ## 局域网与容器
 

+ 2 - 2
docs/http-sse-realtime.md

@@ -1,6 +1,6 @@
 # HTTP + SSE realtime bridge
 
-8800 remains the only client entrypoint. It owns authenticated upstream WebSocket connections to 9119; it does not run a second Agent engine or move Python group data.
+15300 remains the only client entrypoint. It owns authenticated upstream WebSocket connections to 9119; it does not run a second Agent engine or move Python group data.
 
 ## Contract (version 1)
 
@@ -36,7 +36,7 @@ Chat frames retain their upstream seq separately from the opaque SSE `id`. Send
 - Ordinary chat push recovery uses broker transports. Group push retains its independent durable journal cursor.
 - Different credential scopes are not silently pooled. If they target the same already-owned runtime, the bridge fails closed with `session_scope_conflict` instead of stealing its upstream event receiver.
 
-This does not guarantee Agent survival after 8800/9119 restarts or prolonged upstream loss. Upstream recovery uses `session.events.since` when available; missing/truncated replay falls back to REST history, not to an old client transport. Web and shipping iOS clients require HTTP+SSE capability version 1. Missing capabilities, authentication errors and network errors never downgrade to WebSocket.
+This does not guarantee Agent survival after 15300/9119 restarts or prolonged upstream loss. Upstream recovery uses `session.events.since` when available; missing/truncated replay falls back to REST history, not to an old client transport. Web and shipping iOS clients require HTTP+SSE capability version 1. Missing capabilities, authentication errors and network errors never downgrade to WebSocket.
 
 ## Verification
 

+ 5 - 5
docs/loopback-authorization.md

@@ -1,7 +1,7 @@
-# 8800 到 9119 的本机授权
+# 15300 到 9119 的本机授权
 
 本机 Web 安装不再要求预先配置 9119 用户名密码,也不生成或写入 Dashboard 账号。
-8800 的用户登录、权限、CSRF、设备 scope 与 HTTP+SSE 接口保持不变。
+15300 的用户登录、权限、CSRF、设备 scope 与 HTTP+SSE 接口保持不变。
 
 ## 自动选择授权方式
 
@@ -13,11 +13,11 @@
 
 ## 安装与已有部署
 
-仅配置为 `http://127.0.0.1:9119` 的受管实例会启动本机 Dashboard。9119 未运行时,以 `--host 127.0.0.1` 启动;已运行则保持不变,不自动删除已有账号、重启、修改监听或放宽认证。8800 仍是客户端唯一入口。
+仅配置为 `http://127.0.0.1:9119` 的受管实例会启动本机 Dashboard。9119 未运行时,以 `--host 127.0.0.1` 启动;已运行则保持不变,不自动删除已有账号、重启、修改监听或放宽认证。15300 仍是客户端唯一入口。
 
 已有 LAN 监听或 `dashboard.public_url` 等设置可能使 Hermes 继续要求账号鉴权;这种情况下需要服务账号,不能仅凭“客户端与服务在同一台机器”免密。Docker 桥接访问宿主机也不属于本容器回环;维持远程账号鉴权。
 
-本机令牌是服务端授权,不是可分发的设备凭据。现有子节点配对仍使用独立、可撤销的上游会话;不会复制或提升为本机服务令牌。普通 iOS/Web 账号登录 8800 不受此限制。
+本机令牌是服务端授权,不是可分发的设备凭据。现有子节点配对仍使用独立、可撤销的上游会话;不会复制或提升为本机服务令牌。普通 iOS/Web 账号登录 15300 不受此限制。
 
 本次不会修改 9119 源码或现有配置,旧客户端 WebSocket 入口仍不兼容。
 
@@ -26,4 +26,4 @@
 `npm test` 包含本机令牌、账号门禁、代理/重定向、令牌轮换、CSRF 与监督器测试。
 `npm run test:e2e` 使用账号鉴权模拟上游;`FAKE_HERMES_LOCAL_AUTH=1 npm run test:e2e`
 对同一组浏览器流程使用无账号、强制原生令牌的模拟上游。运行前确保 18801/19119
-未被其他测试实例占用,避免复用到错误授权模式;这些端口与实际 8800/9119 无关。
+未被其他测试实例占用,避免复用到错误授权模式;这些端口与实际 15300/9119 无关。

+ 3 - 3
docs/read-cache.md

@@ -1,10 +1,10 @@
-# 8800 热点读取缓存
+# 15300 热点读取缓存
 
 本功能仅优化数据读取与上游认证检查,不改变模型生成速度,不迁移 9119 数据库,也不替代 iOS 本地缓存。
 
 ## 上游认证租期
 
-`UpstreamServiceSession` 将成功的状态/身份检查复用 30 秒,并合并同一时间的并发检查。每个客户端请求仍执行 8800 的用户、角色或配对设备授权。
+`UpstreamServiceSession` 将成功的状态/身份检查复用 30 秒,并合并同一时间的并发检查。每个客户端请求仍执行 15300 的用户、角色或配对设备授权。
 
 上游返回 401 时仅对该请求重新认证并重试一次;并发 401 共用登录任务。不会因为网络超时或 5xx 自动重放写请求。服务账号配置变化会立即使租期及读取缓存失效;旧验证或登录响应不能覆盖新凭据。鉴权端点本身不进入读取缓存。
 
@@ -18,7 +18,7 @@
 | 会话详情、最近消息、团队详情 | 1 秒 | 无 |
 | 已加载的较早消息页 | 10 秒 | 无 |
 
-仅缓存白名单 GET 的成功 JSON。容量上限为 64 MiB / 512 项,每项不超过 2 MiB,使用 LRU 淘汰;相同读取会合并。缓存键包含已验证的 8800 用户/授权版本或配对设备、上游 Cookie 的摘要、Profile、分页参数和请求头。缓存键不记录原始凭据。
+仅缓存白名单 GET 的成功 JSON。容量上限为 64 MiB / 512 项,每项不超过 2 MiB,使用 LRU 淘汰;相同读取会合并。缓存键包含已验证的 15300 用户/授权版本或配对设备、上游 Cookie 的摘要、Profile、分页参数和请求头。缓存键不记录原始凭据。
 
 后台推送确认、命令回执、鉴权、搜索、未读确认、团队游标能力端点、增量 `afterSeq`、Range/条件读取以及错误响应不缓存。上游显式 `no-store` 或 `Set-Cookie` 响应不缓存。
 

+ 6 - 0
docs/releases/v0.3.22.md

@@ -0,0 +1,6 @@
+# v0.3.22
+
+- Web、LaunchAgent 与 Docker 默认端口从 8800 迁移到 15300。
+- 首次安装不再创建固定的 `admin/admin`,首次打开 Web 时自行创建管理员账号。
+- 受管 9119 启动不写入或重置账号,也不再默认附加 `--no-open`。
+- 已有用户库和显式端口、上游凭据配置保持有效。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.21",
+  "version": "0.3.22",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.21",
+      "version": "0.3.22",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.21",
+  "version": "0.3.22",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.21",
-  "webVersion": "0.3.21",
-  "gitTag": "v0.3.21"
+  "releaseVersion": "0.3.22",
+  "webVersion": "0.3.22",
+  "gitTag": "v0.3.22"
 }

+ 9 - 0
src/client/api/auth.ts

@@ -13,6 +13,7 @@ function normalizeBootstrap(payload: unknown): BootstrapResponse {
   const response: BootstrapResponse = {
     status: string(status.state ?? root.state) || undefined,
     authRequired,
+    setupRequired: bool(root.setupRequired ?? root.setup_required),
     profiles,
     csrfToken,
     insecureLan: bool(root.insecureLan ?? root.insecure_lan),
@@ -47,6 +48,14 @@ export async function login(input: LoginInput): Promise<BootstrapResponse> {
   return response
 }
 
+export async function setup(input: LoginInput): Promise<BootstrapResponse> {
+  return normalizeBootstrap(await apiRequest<unknown>('/api/app/setup', {
+    method: 'POST',
+    body: input as unknown as JsonValue,
+    notifyUnauthorized: false,
+  }))
+}
+
 export async function logout(): Promise<void> {
   await apiRequest('/api/app/logout', { method: 'POST', body: {}, notifyUnauthorized: false })
   setApiCsrfToken('')

+ 1 - 1
src/client/api/httpRealtime.ts

@@ -18,7 +18,7 @@ export class HTTPRealtimeChannel {
     let value: { protocolVersion: number; csrfToken?: string }
     try { value = await apiRequest('/api/realtime/capabilities') }
     catch (e) {
-      if (e instanceof ApiError && [404, 405, 410].includes(e.status)) throw new ApiError('此服务端不支持 HTTP+SSE,请升级 8800 服务', 409, 'HTTP_SSE_REQUIRED')
+      if (e instanceof ApiError && [404, 405, 410].includes(e.status)) throw new ApiError('此服务端不支持 HTTP+SSE,请升级 15300 服务', 409, 'HTTP_SSE_REQUIRED')
       throw e
     }
     if (value.protocolVersion !== 1) throw new ApiError('不支持的实时协议版本', 409, 'UNSUPPORTED_REALTIME_PROTOCOL')

+ 2 - 2
src/client/components/app/AccountSecurityPanel.vue

@@ -173,7 +173,7 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
       <div>
         <p class="panel-eyebrow">当前账号</p>
         <h2>登录与安全</h2>
-        <p class="panel-description">管理此 8800 Web 账号的登录凭据。修改成功后,请使用新凭据登录。</p>
+        <p class="panel-description">管理此 15300 Web 账号的登录凭据。修改成功后,请使用新凭据登录。</p>
       </div>
       <span class="account-badge">{{ auth.user?.username || '当前账号' }}</span>
     </header>
@@ -191,7 +191,7 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
       <label v-if="isAdmin" class="field">
         <span>管理员用户名</span>
         <input v-model="username" name="username" autocomplete="username" :disabled="busy" />
-        <small>仅修改当前 8800 Web 管理账号,不会更改 9119 服务账号。</small>
+        <small>仅修改当前 15300 Web 管理账号,不会更改 9119 服务账号。</small>
       </label>
 
       <h3 class="security-section-title security-section-title--password">修改密码</h3>

+ 3 - 3
src/client/components/app/NodePairingDialog.vue

@@ -233,7 +233,7 @@ onBeforeUnmount(() => { stopPolling(); stopAccountPolling(); stopScanning() })
             <button class="icon-button" type="button" aria-label="关闭手机与节点" @click="emit('close')"><AppIcon name="close" /></button>
           </header>
 
-          <p class="pairing-intro">手机扫描登录二维码后,会把这台 8800 保存为普通服务器账号;不会添加成子节点。</p>
+          <p class="pairing-intro">手机扫描登录二维码后,会把这台 15300 保存为普通服务器账号;不会添加成子节点。</p>
           <p v-if="insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="15" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
           <p v-if="error" class="pairing-error">{{ error }}</p>
 
@@ -250,7 +250,7 @@ onBeforeUnmount(() => { stopPolling(); stopAccountPolling(); stopScanning() })
           </section>
 
           <section v-if="isAdmin" class="device-section">
-            <div class="device-heading"><strong>授权为 8800 子节点</strong><small>管理员</small></div>
+            <div class="device-heading"><strong>授权为 15300 子节点</strong><small>管理员</small></div>
           <section v-if="pairing" class="qr-card">
             <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
             <strong>仅用于子节点配对</strong>
@@ -272,7 +272,7 @@ onBeforeUnmount(() => { stopPolling(); stopAccountPolling(); stopScanning() })
             </div>
           </section>
           <section v-if="isAdmin" class="device-section child-section">
-            <div class="device-heading"><strong>添加 8800 子节点</strong><small>只管理直接子节点</small></div>
+            <div class="device-heading"><strong>添加 15300 子节点</strong><small>只管理直接子节点</small></div>
             <label><span>节点名称(可选)</span><input v-model="childName" placeholder="例如:办公室 Mac" /></label>
             <label><span>子节点配对码</span><textarea v-model="childCode" rows="3" placeholder="粘贴子节点生成的 yaoyao://pair…" /></label>
             <video v-if="scanning" ref="scanVideo" class="scanner-video" muted playsinline />

+ 4 - 4
src/client/components/app/NodePairingPanel.vue

@@ -315,8 +315,8 @@ onBeforeUnmount(resetTransientState)
 
 <template>
   <section class="pairing-panel" :aria-label="mode === 'account' ? '手机登录' : '节点与设备'">
-    <p v-if="mode === 'account'" class="pairing-intro">手机扫描登录二维码后,会把这台 8800 保存为普通服务器账号;不会添加成子节点。</p>
-    <p v-else class="pairing-intro">管理当前 8800 的直接子节点与已授权设备。账号登录设备不会在这里显示。</p>
+    <p v-if="mode === 'account'" class="pairing-intro">手机扫描登录二维码后,会把这台 15300 保存为普通服务器账号;不会添加成子节点。</p>
+    <p v-else class="pairing-intro">管理当前 15300 的直接子节点与已授权设备。账号登录设备不会在这里显示。</p>
     <p v-if="mode === 'account' && insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="16" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
     <p v-if="error" class="pairing-error" role="alert">{{ error }}</p>
 
@@ -335,7 +335,7 @@ onBeforeUnmount(resetTransientState)
 
     <template v-else-if="isAdmin">
       <section class="device-section device-section--first">
-        <div class="device-heading"><strong>授权为 8800 子节点</strong><small>管理员</small></div>
+        <div class="device-heading"><strong>授权为 15300 子节点</strong><small>管理员</small></div>
         <section v-if="pairing" class="qr-card">
           <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
           <strong>仅用于子节点配对</strong>
@@ -356,7 +356,7 @@ onBeforeUnmount(resetTransientState)
       </section>
 
       <section class="device-section child-section">
-        <div class="device-heading"><strong>添加 8800 子节点</strong><small>只管理直接子节点</small></div>
+        <div class="device-heading"><strong>添加 15300 子节点</strong><small>只管理直接子节点</small></div>
         <label><span>节点名称(可选)</span><input v-model="childName" :disabled="busy" placeholder="例如:办公室 Mac" /></label>
         <label><span>子节点配对码</span><textarea v-model="childCode" rows="3" :disabled="busy" placeholder="粘贴子节点生成的 yaoyao://pair…" /></label>
         <video v-if="scanning" ref="scanVideo" class="scanner-video" muted playsinline />

+ 10 - 10
src/client/components/app/SystemManagementPanel.vue

@@ -304,7 +304,7 @@ async function savePush() {
   pushError.value = ''
   pushNotice.value = ''
   if (!keyFile.startsWith('/')) {
-    pushError.value = '请输入 8800 所在机器上的 .p8 文件绝对路径'
+    pushError.value = '请输入 15300 所在机器上的 .p8 文件绝对路径'
     return
   }
   if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) {
@@ -347,7 +347,7 @@ async function saveFCM() {
   fcmError.value = ''
   fcmNotice.value = ''
   if (!serviceAccountFile.startsWith('/')) {
-    fcmError.value = '请输入 8800 所在机器上的服务账号 JSON 文件绝对路径'
+    fcmError.value = '请输入 15300 所在机器上的服务账号 JSON 文件绝对路径'
     return
   }
   if (!/^[a-z][a-z0-9-]{4,61}[a-z0-9]$/.test(projectId)) {
@@ -419,13 +419,13 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
       <div class="connection-summary" aria-label="Hermes 连接状态">
         <span><small>9119 地址</small><b>{{ connectionStatus?.endpoint || '等待检测' }}</b></span>
         <span><small>认证模式</small><b>{{ authModeLabel }}</b></span>
-        <span><small>8800 网络范围</small><b>{{ connectionStatus?.webNetworkScope === 'local' ? '仅本机' : '局域网可访问' }}</b></span>
+        <span><small>15300 网络范围</small><b>{{ connectionStatus?.webNetworkScope === 'local' ? '仅本机' : '局域网可访问' }}</b></span>
         <span><small>9119 网络范围</small><b>{{ connectionStatus?.networkScope === 'network' ? '网络可访问' : '仅本机' }}</b></span>
         <span class="wide"><small>最近验证</small><b>{{ verifiedAtLabel }}</b></span>
       </div>
-      <p v-if="connectionStatus?.authMode === 'loopback-token'" class="connection-mode-note">9119 使用本机临时 Token,8800 会自动读取并在 9119 重启后刷新;Token 不会在设置中显示。</p>
+      <p v-if="connectionStatus?.authMode === 'loopback-token'" class="connection-mode-note">9119 使用本机临时 Token,15300 会自动读取并在 9119 重启后刷新;Token 不会在设置中显示。</p>
       <p v-else-if="connectionStatus?.authMode === 'loopback-direct'" class="connection-mode-note">当前 9119 允许本机直接访问,不需要账号或 Token;该兼容模式只允许回环地址使用。</p>
-      <p class="network-description">直连本机 127.0.0.1 或 ::1 且 Hermes 启用本机授权时,自动使用临时会话令牌,无需配置用户名和密码。远程上游或已启用账号鉴权的 9119 仍需服务账号;8800 登录保护保持不变,不会自动修改 Hermes 账号配置。</p>
+      <p class="network-description">直连本机 127.0.0.1 或 ::1 且 Hermes 启用本机授权时,自动使用临时会话令牌,无需配置用户名和密码。远程上游或已启用账号鉴权的 9119 仍需服务账号;15300 登录保护保持不变,不会自动修改 Hermes 账号配置。</p>
       <h4 v-if="passwordAuthentication">服务账号(按需配置)</h4>
       <form v-if="passwordAuthentication" @submit.prevent="saveUpstream">
         <label><span>9119 用户名</span><input v-model="upstreamUsername" name="upstream-username" autocomplete="off" :disabled="busy" /></label>
@@ -434,7 +434,7 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
       </form>
       <div class="block network-access-block">
         <h3>外网访问地址</h3>
-        <p class="network-description">允许通过指定域名或公网 IP 访问 8800。每行填写一个地址,不要包含 <code>http://</code>、端口或路径。</p>
+        <p class="network-description">允许通过指定域名或公网 IP 访问 15300。每行填写一个地址,不要包含 <code>http://</code>、端口或路径。</p>
         <form class="allowed-hosts-form" @submit.prevent="saveAllowedHosts">
           <label>
             <span>允许的域名和 IP</span>
@@ -465,9 +465,9 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
 
         <template v-if="pushStatus?.managementAvailable">
           <p v-if="pushManagedByEnvironment" class="push-managed-note">当前 APNs 配置由服务环境变量管理,Web 仅可查看。请修改 LaunchAgent、Docker 或进程环境后重启服务。</p>
-          <p v-else-if="!pushStatus.editable" class="push-managed-note">当前 APNs 配置为只读,请在 8800 服务端修改后重启服务。</p>
+          <p v-else-if="!pushStatus.editable" class="push-managed-note">当前 APNs 配置为只读,请在 15300 服务端修改后重启服务。</p>
           <form class="push-config-form" @submit.prevent="savePush">
-            <label class="wide"><span>.p8 本地路径</span><input v-model="pushKeyFile" name="push-key-file" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/AuthKey_XXXXXXXXXX.p8" /><small>填写 8800 所在机器上的绝对路径,私钥内容不会经过浏览器。</small></label>
+            <label class="wide"><span>.p8 本地路径</span><input v-model="pushKeyFile" name="push-key-file" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/AuthKey_XXXXXXXXXX.p8" /><small>填写 15300 所在机器上的绝对路径,私钥内容不会经过浏览器。</small></label>
             <label><span>Key ID</span><input v-model="pushKeyId" name="push-key-id" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
             <label><span>Team ID</span><input v-model="pushTeamId" name="push-team-id" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
             <label class="wide"><span>Topic(Bundle ID)</span><input v-model="pushTopic" name="push-topic" :readonly="!pushStatus.editable || pushBusy || fcmBusy" autocomplete="off" spellcheck="false" /></label>
@@ -498,9 +498,9 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
 
         <template v-if="fcmStatus?.managementAvailable">
           <p v-if="fcmManagedByEnvironment" class="push-managed-note">当前 FCM 配置由服务环境变量管理,Web 仅可查看。请修改 LaunchAgent、Docker 或进程环境后重启服务。</p>
-          <p v-else-if="!fcmStatus.editable" class="push-managed-note">当前 FCM 配置为只读,请在 8800 服务端修改后重启服务。</p>
+          <p v-else-if="!fcmStatus.editable" class="push-managed-note">当前 FCM 配置为只读,请在 15300 服务端修改后重启服务。</p>
           <form class="push-config-form" @submit.prevent="saveFCM">
-            <label class="wide"><span>服务账号 JSON 本地路径</span><input v-model="fcmServiceAccountFile" name="fcm-service-account-file" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/firebase-service-account.json" /><small>只填写 8800 所在机器上的绝对路径;JSON 内容和私钥不会经过浏览器,也不会写入夭夭配置。</small></label>
+            <label class="wide"><span>服务账号 JSON 本地路径</span><input v-model="fcmServiceAccountFile" name="fcm-service-account-file" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" placeholder="/Users/…/firebase-service-account.json" /><small>只填写 15300 所在机器上的绝对路径;JSON 内容和私钥不会经过浏览器,也不会写入夭夭配置。</small></label>
             <label><span>Firebase Project ID</span><input v-model="fcmProjectId" name="fcm-project-id" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" /></label>
             <label><span>Android 包名</span><input v-model="fcmPackageName" name="fcm-package-name" :readonly="!fcmStatus.editable || fcmBusy || pushBusy" autocomplete="off" spellcheck="false" /></label>
             <div v-if="fcmStatus.warnings.length" class="wide push-warnings" aria-live="polite">

+ 1 - 1
src/client/components/app/SystemOverviewPanel.vue

@@ -109,7 +109,7 @@ watch(() => props.active, active => { if (active) void refresh() }, { immediate:
       </button>
     </div>
 
-    <p class="ownership-note">8800 通过官方 API 管理 Hermes 9119;外部文件与进程不在此处控制。</p>
+    <p class="ownership-note">15300 通过官方 API 管理 Hermes 9119;外部文件与进程不在此处控制。</p>
   </section>
 </template>
 

+ 1 - 1
src/client/components/app/SystemUpdateDialog.vue

@@ -49,7 +49,7 @@ async function poll(next: UpdateJob) {
     try {
       job.value = await systemUpdateJob(next.id)
     } catch {
-      // 8800 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
+      // 15300 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
     }
   }
   if (token === pollToken && job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {

+ 1 - 1
src/client/components/app/SystemUpdatePanel.vue

@@ -57,7 +57,7 @@ async function poll(next: UpdateJob) {
     try {
       job.value = await systemUpdateJob(next.id)
     } catch {
-      // 8800 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
+      // 15300 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
     }
   }
   if (token === pollToken && job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {

+ 15 - 2
src/client/stores/auth.ts

@@ -16,6 +16,7 @@ export const useAuthStore = defineStore('auth', () => {
   const csrfToken = ref('')
   const error = ref<string>()
   const authRequired = ref(true)
+  const setupRequired = ref(false)
   const insecureLan = ref(false)
   const groupUploadsEnabled = ref(false)
   const upstreamReady = ref(false)
@@ -28,6 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
 
   function publish(response: BootstrapResponse): void {
     authRequired.value = response.authRequired
+    setupRequired.value = Boolean(response.setupRequired)
     csrfToken.value = response.csrfToken
     insecureLan.value = Boolean(response.insecureLan)
     groupUploadsEnabled.value = Boolean(response.groupUploadsEnabled)
@@ -80,6 +82,17 @@ export const useAuthStore = defineStore('auth', () => {
     }
   }
 
+  async function setup(input: authApi.LoginInput): Promise<void> {
+    status.value = 'authenticating'
+    error.value = undefined
+    try { publish(await authApi.setup(input)) }
+    catch (cause) {
+      status.value = 'anonymous'
+      error.value = message(cause)
+      throw cause
+    }
+  }
+
   async function logout(): Promise<void> {
     try { await authApi.logout() } catch { /* local state still signs out */ }
     status.value = 'anonymous'
@@ -124,8 +137,8 @@ export const useAuthStore = defineStore('auth', () => {
   }
 
   return {
-    status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, insecureLan, groupUploadsEnabled,
-    upstreamReady, upstreamError, isAuthenticated, bootstrap, login, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
+    status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, setupRequired, insecureLan, groupUploadsEnabled,
+    upstreamReady, upstreamError, isAuthenticated, bootstrap, login, setup, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
     changeCredentials, updateAccountAvatar, expire,
   }
 })

+ 1 - 1
src/client/views/GroupsView.vue

@@ -535,7 +535,7 @@ async function loadPushSubscriptions() {
       ? await getGroupPushSubscriptions()
       : new Set()
   } catch {
-    // Older 8800 installations do not expose push capability. Keep group chat
+    // Older 15300 installations do not expose push capability. Keep group chat
     // fully functional and simply omit the subscription control.
     pushProtocolSupported.value = false
     pushConfigured.value = false

+ 14 - 5
src/client/views/LoginView.vue

@@ -9,14 +9,21 @@ const auth = useAuthStore()
 const theme = useThemeStore()
 const username = ref('')
 const password = ref('')
+const passwordConfirmation = ref('')
 const localError = ref('')
 const submitting = computed(() => auth.status === 'authenticating')
 
 async function login() {
   if (!username.value.trim() || !password.value) return
+  if (auth.setupRequired && password.value !== passwordConfirmation.value) {
+    localError.value = '两次输入的密码不一致'
+    return
+  }
   localError.value = ''
   try {
-    await auth.login({ username: username.value.trim(), password: password.value })
+    const input = { username: username.value.trim(), password: password.value }
+    if (auth.setupRequired) await auth.setup(input)
+    else await auth.login(input)
   } catch (error) {
     localError.value = error instanceof Error ? error.message : '登录失败,请检查账号和密码'
     password.value = ''
@@ -35,14 +42,16 @@ async function login() {
     <section class="login-panel" aria-labelledby="login-title">
       <BrandMark :size="72" />
       <div class="login-copy">
-        <h1 id="login-title">登录夭夭</h1>
+        <h1 id="login-title">{{ auth.setupRequired ? '创建管理员' : '登录夭夭' }}</h1>
+        <p v-if="auth.setupRequired">首次启动请设置 15300 管理账号,不会创建固定默认密码。</p>
       </div>
       <form @submit.prevent="login">
         <label><span>账号</span><input v-model="username" name="username" autocomplete="username" autofocus required placeholder="用户名" /></label>
         <label><span>密码</span><input id="password" v-model="password" name="password" type="password" autocomplete="current-password" required placeholder="密码" /></label>
+        <label v-if="auth.setupRequired"><span>确认密码</span><input v-model="passwordConfirmation" name="password-confirmation" type="password" autocomplete="new-password" minlength="8" required placeholder="再次输入密码" /></label>
         <p v-if="localError || auth.error" class="login-error" role="alert"><AppIcon name="alert" :size="14" />{{ localError || auth.error }}</p>
-        <button class="login-submit solid-button" type="submit" :disabled="submitting || !username.trim() || !password">
-          <span>{{ submitting ? '正在登录…' : '登录' }}</span><AppIcon v-if="!submitting" name="arrow-up" :size="15" />
+        <button class="login-submit solid-button" type="submit" :disabled="submitting || !username.trim() || password.length < (auth.setupRequired ? 8 : 1) || (auth.setupRequired && password !== passwordConfirmation)">
+          <span>{{ submitting ? '正在提交…' : auth.setupRequired ? '创建并登录' : '登录' }}</span><AppIcon v-if="!submitting" name="arrow-up" :size="15" />
         </button>
       </form>
     </section>
@@ -54,7 +63,7 @@ async function login() {
 .theme-button { position: absolute; z-index: 3; top: max(15px, env(safe-area-inset-top)); right: 16px; }
 .login-atmosphere { position: absolute; inset: 0; pointer-events: none; opacity: .75; }.login-atmosphere i { position: absolute; display: block; border: 1px solid var(--line); border-radius: 50%; }.login-atmosphere i:nth-child(1) { width: 520px; height: 520px; top: -360px; left: -180px; }.login-atmosphere i:nth-child(2) { width: 310px; height: 310px; right: -190px; bottom: -170px; }.login-atmosphere i:nth-child(3) { width: 3px; height: 3px; top: 22%; right: 18%; border: 0; background: var(--text-muted); box-shadow: -80px 160px 0 var(--line-strong), 90px 270px 0 var(--line-strong), -900px 340px 0 var(--line-strong); }
 .login-panel { position: relative; z-index: 2; width: min(368px, 100%); animation: login-enter 360ms var(--ease-out) both; }.login-panel :deep(.brand-mark) { display: flex; justify-content: center; width: 100%; }
-.login-copy { margin: 34px 0 22px; text-align: center; }.login-copy h1 { margin: 0; font-size: 18px; font-weight: 630; letter-spacing: -.03em; }
+.login-copy { margin: 34px 0 22px; text-align: center; }.login-copy h1 { margin: 0; font-size: 18px; font-weight: 630; letter-spacing: -.03em; }.login-copy p { margin: 9px 0 0; color: var(--text-muted); font-size: 11px; line-height: 1.6; }
 form { display: flex; flex-direction: column; gap: 13px; } label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; } label span { display: flex; justify-content: space-between; } label small { color: var(--text-muted); font-weight: 400; }
 input { width: 100%; height: 43px; padding: 0 11px; border: 1px solid var(--line); border-radius: 11px; outline: 0; background: var(--surface-raised); color: var(--text-primary); font-size: 13px; transition: border-color 140ms ease, box-shadow 140ms ease; } input::placeholder { color: var(--text-muted); } input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .provider { margin-top: 1px; }.provider input { height: 37px; background: var(--surface-soft); font-size: 11px; }

+ 1 - 1
src/server/apnsConfiguration.ts

@@ -138,7 +138,7 @@ export function validateAPNsConfiguration(
     if (cause instanceof HttpError) throw cause
     throw new HttpError(
       400,
-      '8800 cannot read the APNs key file; check the local path and service account permissions',
+      '15300 cannot read the APNs key file; check the local path and service account permissions',
       'invalid_apns_key_file',
     )
   }

+ 2 - 1
src/server/app.ts

@@ -108,7 +108,7 @@ function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
     writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
     return generated
   } catch (error) {
-    // Another 8800 process can finish first during launchd hand-off. Never
+    // Another 15300 process can finish first during launchd hand-off. Never
     // rotate its secret; use the one it safely wrote instead.
     if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
     throw error
@@ -274,6 +274,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     ctx.state.localAuth = auth
     ctx.state.upstreamSession = upstreamSession
     const anonymous = ctx.path === '/api/app/bootstrap'
+      || ctx.path === '/api/app/setup'
       || ctx.path === '/api/app/login'
       || ctx.path === '/api/status'
       || ctx.path === '/api/auth/providers'

+ 1 - 1
src/server/config.ts

@@ -62,7 +62,7 @@ function flag(value: string | undefined): boolean {
 }
 
 function parsePort(value: string | undefined): number {
-  const port = Number(value ?? '8800')
+  const port = Number(value ?? '15300')
   if (!Number.isInteger(port) || port < 1 || port > 65_535) {
     throw new Error('HERMES_YAOYAO_PORT must be an integer between 1 and 65535')
   }

+ 2 - 2
src/server/dashboardSupervisor.ts

@@ -48,7 +48,7 @@ function delay(milliseconds: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, milliseconds))
 }
 
-/** Keeps the local Hermes Dashboard available for the managed 8800 service. */
+/** Keeps the local Hermes Dashboard available for the managed 15300 service. */
 export class DashboardSupervisor {
   private readonly command: string
   private readonly dashboardHost = '127.0.0.1'
@@ -101,7 +101,7 @@ export class DashboardSupervisor {
       const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
       if (running) return
       this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
-      this.launch(['dashboard', '--host', this.dashboardHost, '--no-open'])
+      this.launch(['dashboard', '--host', this.dashboardHost])
     } catch (error) {
       this.log(`Hermes Dashboard supervision failed: ${error instanceof Error ? error.message : String(error)}`)
     } finally {

+ 1 - 1
src/server/fcmConfiguration.ts

@@ -104,7 +104,7 @@ export function validateFCMConfiguration(
     if (cause instanceof HttpError) throw cause
     throw new HttpError(
       400,
-      '8800 cannot read the FCM service account file; check the local path and service account permissions',
+      '15300 cannot read the FCM service account file; check the local path and service account permissions',
       'invalid_fcm_service_account_file',
     )
   }

+ 23 - 6
src/server/localAuth.ts

@@ -119,6 +119,27 @@ export class LocalAuthStore {
     this.#loadSessions()
   }
 
+  get setupRequired(): boolean {
+    return this.#users.length === 0
+  }
+
+  setupAdmin(
+    ctx: Koa.Context,
+    usernameValue: string,
+    passwordValue: string,
+  ): LocalUser {
+    if (!this.setupRequired) {
+      throw new HttpError(409, '管理员已经初始化', 'setup_already_completed')
+    }
+    const username = canonicalUsername(usernameValue)
+    const password = validatePassword(passwordValue)
+    const now = Date.now()
+    const admin = this.#newUser(username, password, 'admin', false, now)
+    this.#users = [admin]
+    this.#saveUsers()
+    return this.#issueSession(ctx, admin)
+  }
+
   login(ctx: Koa.Context, usernameValue: string, password: string): LocalUser {
     const normalized = usernameValue.trim().toLocaleLowerCase('en-US')
     const user = this.#users.find(candidate => candidate.normalizedUsername === normalized)
@@ -391,13 +412,9 @@ export class LocalAuthStore {
       return value.users
     } catch (error) {
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
-        throw new Error('8800 用户库损坏,已停止启动以避免覆盖数据')
+        throw new Error('15300 用户库损坏,已停止启动以避免覆盖数据')
       }
-      const now = Date.now()
-      const users = [this.#newUser('admin', 'admin', 'admin', true, now)]
-      this.#users = users
-      this.#saveUsers()
-      return users
+      return []
     }
   }
 

+ 1 - 1
src/server/pushCoordinator.ts

@@ -1561,7 +1561,7 @@ export class PushCoordinator {
       })
     } catch {
       // The in-memory status remains available when the push state volume is
-      // the reason persistence failed. Never let optional push stop 8800.
+      // the reason persistence failed. Never let optional push stop 15300.
     }
   }
 

+ 15 - 3
src/server/routes.ts

@@ -646,6 +646,7 @@ async function bootstrap(
       status: { state: 'ready' },
       authRequired: true,
       authenticated: false,
+      setupRequired: dependencies.auth.setupRequired,
       profiles: [],
       csrfToken,
       insecureLan: dependencies.config.insecureLan,
@@ -676,7 +677,7 @@ async function bootstrap(
           }
         })(),
         new Promise<never>((_resolve, reject) => {
-          timeout = setTimeout(() => reject(new Error('9119 连接超时;仍可管理 8800 和升级 Web')), 3_000)
+          timeout = setTimeout(() => reject(new Error('9119 连接超时;仍可管理 15300 和升级 Web')), 3_000)
           timeout.unref()
         }),
       ])
@@ -716,6 +717,14 @@ async function login(ctx: Koa.Context, dependencies: RouteDependencies): Promise
   await bootstrap(ctx, dependencies, true)
 }
 
+async function setup(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
+  const request = body(ctx)
+  const username = typeof request.username === 'string' ? request.username : ''
+  const password = typeof request.password === 'string' ? request.password : ''
+  dependencies.auth.setupAdmin(ctx, username, password)
+  await bootstrap(ctx, dependencies, true)
+}
+
 async function independentPairingCookies(
   ctx: Koa.Context,
   dependencies: RouteDependencies,
@@ -952,7 +961,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       auth_required: true,
       gateway_running: true,
       server_kind: 'yaoyao-web',
-      node_transport: '8800',
+      node_transport: '15300',
     })
   })
   router.get('/api/auth/providers', (ctx) => {
@@ -1418,6 +1427,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.get('/api/app/bootstrap', async (ctx) => {
     await bootstrap(ctx, dependencies)
   })
+  router.post('/api/app/setup', async (ctx) => {
+    await setup(ctx, dependencies)
+  })
   router.post('/api/app/login', async (ctx) => {
     await login(ctx, dependencies)
   })
@@ -1865,7 +1877,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       search: new URLSearchParams(ctx.querystring),
       body: ['GET', 'HEAD'].includes(ctx.method) ? undefined : optionalBody(ctx),
       // Hermes binds WebSocket tickets to the request address. The relay's
-      // upstream connection originates from 8800 itself, so forwarding the
+      // upstream connection originates from 15300 itself, so forwarding the
       // phone's address here would make 9119 reject the ticket during Upgrade.
       clientAddress: upstreamPath === '/api/auth/ws-ticket'
         ? undefined : ctx.req.socket.remoteAddress,

+ 1 - 0
src/shared/types.ts

@@ -46,6 +46,7 @@ export interface ModelOption {
 export interface BootstrapResponse {
   status?: string
   authRequired: boolean
+  setupRequired?: boolean
   user?: CurrentUser
   profiles: Profile[]
   csrfToken: string

+ 3 - 3
tests/client/systemManagementDialog.test.ts

@@ -84,7 +84,7 @@ afterEach(() => {
 })
 
 describe('System management APNs configuration', () => {
-  it('shows automatic loopback authentication and separate 8800/9119 network boundaries', async () => {
+  it('shows automatic loopback authentication and separate 15300/9119 network boundaries', async () => {
     const wrapper = await mountOpen()
 
     expect(document.body.textContent).toContain('本机 Session Token(自动)')
@@ -281,10 +281,10 @@ describe('System management APNs configuration', () => {
     development.checked = true
     development.dispatchEvent(new Event('change'))
     await nextTick()
-    vi.mocked(savePushSystemConfig).mockRejectedValueOnce(new Error('8800 无法读取该密钥文件,请检查路径和服务账户权限'))
+    vi.mocked(savePushSystemConfig).mockRejectedValueOnce(new Error('15300 无法读取该密钥文件,请检查路径和服务账户权限'))
     button('验证并启用')!.click()
     await flushPromises()
-    expect(document.querySelector('[role="alert"]')?.textContent).toContain('8800 无法读取该密钥文件')
+    expect(document.querySelector('[role="alert"]')?.textContent).toContain('15300 无法读取该密钥文件')
     expect(input('push-key-file').value).toBe('/srv/secrets/AuthKey_TEST.p8')
     wrapper.unmount()
   })

+ 10 - 14
tests/e2e/hermesMedia.spec.ts

@@ -7,24 +7,20 @@ const mediaPaths = [
 const mediaNames = mediaPaths.map(path => path.split('/').at(-1)!)
 
 async function signIn(request: APIRequestContext, origin: string) {
-  for (const password of ['e2e-password', 'admin']) {
-    const bootstrap = await (await request.get('/api/app/bootstrap')).json()
-    const login = await request.post('/api/app/login', {
+  const bootstrap = await (await request.get('/api/app/bootstrap')).json()
+  if (bootstrap.setupRequired) {
+    const setup = await request.post('/api/app/setup', {
       headers: { Origin: origin, 'X-CSRF-Token': bootstrap.csrfToken },
-      data: { username: 'admin', password },
+      data: { username: 'admin', password: 'e2e-password' },
     })
-    if (!login.ok()) continue
-    const account = await login.json()
-    if (account.user.mustChangePassword) {
-      const changed = await request.put('/api/app/account/credentials', {
-        headers: { Origin: origin, 'X-CSRF-Token': account.csrfToken },
-        data: { currentPassword: password, newPassword: 'e2e-password', username: 'admin' },
-      })
-      expect(changed.ok()).toBe(true)
-    }
+    expect(setup.ok()).toBe(true)
     return
   }
-  throw new Error('Could not sign in to the fixture Web server')
+  const login = await request.post('/api/app/login', {
+    headers: { Origin: origin, 'X-CSRF-Token': bootstrap.csrfToken },
+    data: { username: 'admin', password: 'e2e-password' },
+  })
+  expect(login.ok()).toBe(true)
 }
 
 async function expectDecodedImage(image: Locator) {

+ 2 - 7
tests/e2e/offlineUpdate.spec.ts

@@ -43,16 +43,11 @@ test('opens settings and schedules an independent Web update while all 9119 requ
   const origin = `http://127.0.0.1:${(node.server.address() as AddressInfo).port}`
   try {
     const bootstrap = await (await page.request.get(origin + '/api/app/bootstrap')).json()
-    const login = await page.request.post(origin + '/api/app/login', {
-      headers: { Origin: origin, 'X-CSRF-Token': bootstrap.csrfToken }, data: { username: 'admin', password: 'admin' },
+    const login = await page.request.post(origin + '/api/app/setup', {
+      headers: { Origin: origin, 'X-CSRF-Token': bootstrap.csrfToken }, data: { username: 'admin', password: 'offline-e2e-password' },
     })
     const signedIn = await login.json()
     expect(login.ok(), `${login.status()} ${signedIn.error || ''}`).toBe(true)
-    const changed = await page.request.put(origin + '/api/app/account/credentials', {
-      headers: { Origin: origin, 'X-CSRF-Token': signedIn.csrfToken },
-      data: { currentPassword: 'admin', newPassword: 'offline-e2e-password' },
-    })
-    expect(changed.ok()).toBe(true)
     expect((await page.request.get(origin + '/readyz')).status()).toBe(503)
     await page.goto(origin + '/chat')
     await page.getByRole('button', { name: /^(打开设置中心|设置与模式)$/ }).click()

+ 6 - 18
tests/e2e/workspace.spec.ts

@@ -5,25 +5,13 @@ const WIDE_DOCX_BASE64 = 'UEsDBAoAAAAIAKwmFF15bjPX6AAAAK0BAAATAAAAW0NvbnRlbnRfVH
 const E2E_PASSWORD = 'e2e-password'
 
 test.beforeAll(async ({ request }) => {
-  const signIn = async (password: string) => {
-    const initial = await request.get('/api/app/bootstrap')
-    const initialBody = await initial.json() as { csrfToken: string }
-    return request.post('/api/app/login', {
-      headers: { Origin: 'http://127.0.0.1:18801', 'X-CSRF-Token': initialBody.csrfToken },
-      data: { username: 'admin', password },
-    })
-  }
-  let login = await signIn(E2E_PASSWORD)
-  if (!login.ok()) login = await signIn('admin')
+  const initial = await request.get('/api/app/bootstrap')
+  const initialBody = await initial.json() as { csrfToken: string; setupRequired?: boolean }
+  const login = await request.post(initialBody.setupRequired ? '/api/app/setup' : '/api/app/login', {
+    headers: { Origin: 'http://127.0.0.1:18801', 'X-CSRF-Token': initialBody.csrfToken },
+    data: { username: 'admin', password: E2E_PASSWORD },
+  })
   expect(login.ok()).toBe(true)
-  const loginBody = await login.json() as { csrfToken: string; user: { mustChangePassword?: boolean } }
-  if (loginBody.user.mustChangePassword) {
-    const changed = await request.put('/api/app/account/credentials', {
-      headers: { Origin: 'http://127.0.0.1:18801', 'X-CSRF-Token': loginBody.csrfToken },
-      data: { currentPassword: 'admin', newPassword: E2E_PASSWORD, username: 'admin' },
-    })
-    expect(changed.ok()).toBe(true)
-  }
 })
 
 test.beforeEach(async ({ page }) => {

+ 1 - 1
tests/fixtures/fake-hermes.mjs

@@ -26,7 +26,7 @@ let kanbanTaskSequence = 3
 let kanbanEventSequence = 3
 const kanbanTasks = [
   { id: 't_web001', board: 'default', title: '完成 Web 看板验收', body: '覆盖桌面与 390px 响应式布局', status: 'running', assignee: 'yaoyao', tenant: 'web', priority: 3, created_by: 'dashboard', created_at: now() - 1800, started_at: now() - 420, latest_summary: '正在执行浏览器验收', comment_count: 1, link_counts: { parents: 0, children: 1 }, progress: { done: 0, total: 1 } },
-  { id: 't_ios001', board: 'default', title: '核对 iOS 双端口', body: '9119 与 8800 使用同一契约', status: 'ready', assignee: 'yaoer', tenant: 'mobile', priority: 2, created_by: 'dashboard', created_at: now() - 1200, comment_count: 0, link_counts: { parents: 1, children: 0 }, progress: null },
+  { id: 't_ios001', board: 'default', title: '核对 iOS 双端口', body: '9119 与 15300 使用同一契约', status: 'ready', assignee: 'yaoer', tenant: 'mobile', priority: 2, created_by: 'dashboard', created_at: now() - 1200, comment_count: 0, link_counts: { parents: 1, children: 0 }, progress: null },
   { id: 't_done01', board: 'mobile-release', title: '准备发布说明', body: '整理已完成事项', status: 'done', assignee: 'yaoyao', tenant: 'release', priority: 1, created_by: 'dashboard', created_at: now() - 7200, completed_at: now() - 3600, latest_summary: '发布说明已完成', comment_count: 0, link_counts: { parents: 0, children: 0 }, progress: null },
 ]
 const kanbanComments = new Map([

+ 1 - 1
tests/server/accountPairing.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { AccountLoginPairingStore } from '../../src/server/accountPairing.js'
 
-describe('8800 account login pairing', () => {
+describe('15300 account login pairing', () => {
   it('issues a one-time expiring claim for a normal server account', () => {
     let now = 1_000
     const store = new AccountLoginPairingStore(() => now)

+ 13 - 13
tests/server/agentManagementRoutes.test.ts

@@ -15,21 +15,21 @@ afterEach(() => {
 })
 
 describe('Agent management admin routes', () => {
-  it('rejects default-model writes from a regular 8800 user', async () => {
+  it('rejects default-model writes from a regular 15300 user', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-agent-model-user-'))
     roots.push(home)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }
     const runtime = createUserAuthenticatedApplication({ config })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     await agent.put('/api/app/admin/profiles/default/model')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({ provider: 'opencode-free', model: 'free-a' })
       .expect(403, /需要管理员权限/)
@@ -39,7 +39,7 @@ describe('Agent management admin routes', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-agent-management-'))
     roots.push(home)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }
@@ -71,26 +71,26 @@ describe('Agent management admin routes', () => {
     const runtime = createAuthenticatedApplication({ config, fetchImpl })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const headers = (value: request.Test) => value
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
 
-    await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:8800').expect(200)
+    await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:15300').expect(200)
     await headers(agent.post('/api/app/admin/model-services?profile=worker')).send({ name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', discover_models: true, make_default: false }).expect(200)
     await headers(agent.post('/api/app/admin/model-services/validate')).send({ name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', discover_models: true, make_default: false }).expect(200)
     await headers(agent.post('/api/app/admin/model-services/local/activate?profile=worker')).send({}).expect(200)
     await headers(agent.put('/api/app/admin/profiles/worker/model')).send({ provider: 'opencode-free', model: 'free-a' }).expect(200)
     await headers(agent.delete('/api/app/admin/model-services/local?profile=worker')).send({}).expect(200)
-    const legacy = await agent.get('/api/app/admin/legacy-model-services?profile=worker').set('Host', '127.0.0.1:8800').expect(200)
+    const legacy = await agent.get('/api/app/admin/legacy-model-services?profile=worker').set('Host', '127.0.0.1:15300').expect(200)
     expect(legacy.body.items[0]).toMatchObject({ id: 'custom:tingly', models: ['omni', 'old'], has_api_key: true, can_edit_api_key: true, is_current: true })
     expect(JSON.stringify(legacy.body)).not.toContain('TINGLY_API_KEY')
     await headers(agent.put('/api/app/admin/legacy-model-services/custom%3Atingly?profile=worker')).send({
       name: 'tingly', base_url: 'http://tingly.test/v2', model: 'omni-2', models: ['omni', 'omni-2'],
       discover_models: false, make_default: false, api_key: 'replacement-secret',
     }).expect(200)
-    const voice = await agent.get('/api/app/admin/duplex-voice').set('Host', '127.0.0.1:8800').expect(200)
+    const voice = await agent.get('/api/app/admin/duplex-voice').set('Host', '127.0.0.1:15300').expect(200)
     expect(voice.body).not.toHaveProperty('apiKey')
     await headers(agent.put('/api/app/admin/duplex-voice')).send({ voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a' }).expect(200)
 
@@ -113,7 +113,7 @@ describe('Agent management admin routes', () => {
       .toEqual({ provider: 'opencode-free', model: 'free-a' })
 
     unsupported = true
-    await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:8800')
+    await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:15300')
       .expect(501, /当前上游版本不支持此管理功能/)
   })
 })

+ 5 - 5
tests/server/allowedHostsConfiguration.test.ts

@@ -29,7 +29,7 @@ function temporaryHome(): string {
 
 function config(home: string, allowedHosts = new Set<string>()): ServerConfig {
   return {
-    host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+    host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
     allowedHosts, home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
     mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
   }
@@ -41,7 +41,7 @@ describe('allowed hosts configuration', () => {
     expect(normalizeAllowedHost('[2001:db8::10]')).toBe('2001:db8::10')
     expect(normalizeAllowedHost('例子.测试')).toBe('xn--fsqu00a.xn--0zwm56d')
     expect(() => normalizeAllowedHost('https://yaoyao.example.com')).toThrow(/不能包含协议/)
-    expect(() => normalizeAllowedHost('203.0.113.10:8800')).toThrow(/不能附加端口/)
+    expect(() => normalizeAllowedHost('203.0.113.10:15300')).toThrow(/不能附加端口/)
   })
 
   it('persists Web entries with restrictive permissions and merges environment entries after restart', () => {
@@ -84,11 +84,11 @@ describe('allowed hosts configuration', () => {
     const runtime = createAuthenticatedApplication({ config: serverConfig })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
 
     const saved = await agent.put('/api/app/system/allowed-hosts')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({ hosts: ['yaoyao.example.com', '203.0.113.10'] })
       .expect(200)

+ 45 - 43
tests/server/app.test.ts

@@ -34,7 +34,7 @@ function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
   homes.push(home)
   return {
     host: '127.0.0.1',
-    port: 8800,
+    port: 15300,
     upstream: new URL(upstream),
     allowedHosts: new Set(),
     home,
@@ -103,12 +103,12 @@ function cookieHeader(response: request.Response): string {
   return values.map((value) => value.split(';', 1)[0]).join('; ')
 }
 
-describe('8800 BFF', () => {
-  it('keeps existing CSRF tokens valid across an 8800 restart', async () => {
+describe('15300 BFF', () => {
+  it('keeps existing CSRF tokens valid across an 15300 restart', async () => {
     const config = makeConfig()
     const first = createApplication({ config, fetchImpl: fakeGateway([]) })
     const bootstrap = await request(first.app.callback())
-      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const cookies = cookieHeader(bootstrap)
     first.close()
 
@@ -122,15 +122,15 @@ describe('8800 BFF', () => {
     const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
     runtimes.push(runtime)
     const bootstrap = await request(runtime.app.callback())
-      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const cookies = cookieHeader(bootstrap)
 
     const response = await request(runtime.app.callback())
-      .get('/Users/samien/Agents/报告.txt').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(200)
+      .get('/Users/samien/Agents/报告.txt').set('Host', '127.0.0.1:15300').set('Cookie', cookies).expect(200)
     expect(response.text).toBe('历史文件内容')
     expect(response.headers['content-disposition']).toContain('inline')
     await request(runtime.app.callback())
-      .get('/Users/samien/Agents/../state.db').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(404)
+      .get('/Users/samien/Agents/../state.db').set('Host', '127.0.0.1:15300').set('Cookie', cookies).expect(404)
   })
   it('serves persisted Hermes attachment references from the restricted attachment root', async () => {
     const config = makeConfig()
@@ -138,18 +138,18 @@ describe('8800 BFF', () => {
     const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
     runtimes.push(runtime)
     const bootstrap = await request(runtime.app.callback())
-      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const response = await request(runtime.app.callback())
       .get('/Users/samien/.hermes/attachments/报告.docx')
-      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
+      .set('Host', '127.0.0.1:15300').set('Cookie', cookieHeader(bootstrap)).expect(200)
     expect(response.headers['content-length']).toBe(String(Buffer.byteLength('document bytes')))
     expect(response.headers['content-disposition']).toContain('inline')
     await request(runtime.app.callback())
       .get('/attachments/报告.docx')
-      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
+      .set('Host', '127.0.0.1:15300').set('Cookie', cookieHeader(bootstrap)).expect(200)
     await request(runtime.app.callback())
       .get('/attachments/../state.db')
-      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(404)
+      .set('Host', '127.0.0.1:15300').set('Cookie', cookieHeader(bootstrap)).expect(404)
   })
   it('serves persisted Hermes image references from the restricted images root', async () => {
     const config = makeConfig()
@@ -157,14 +157,14 @@ describe('8800 BFF', () => {
     const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
     runtimes.push(runtime)
     const bootstrap = await request(runtime.app.callback())
-      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const response = await request(runtime.app.callback())
       .get('/Users/samien/.hermes/images/照片.png')
-      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
+      .set('Host', '127.0.0.1:15300').set('Cookie', cookieHeader(bootstrap)).expect(200)
     expect(response.body.toString()).toBe('image bytes')
     await request(runtime.app.callback())
       .get('/Users/samien/.hermes/images/../state.db')
-      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(404)
+      .set('Host', '127.0.0.1:15300').set('Cookie', cookieHeader(bootstrap)).expect(404)
   })
   it('bootstraps sequentially without exposing the upstream address', async () => {
     const records: RecordedRequest[] = []
@@ -172,7 +172,7 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     const response = await request(runtime.app.callback())
       .get('/api/app/bootstrap')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     expect(records.map((entry) => entry.path)).toEqual([
       '/api/status', '/api/auth/me', '/api/status',
@@ -201,7 +201,7 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     const response = await request(runtime.app.callback())
       .get('/api/app/profiles')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     expect(records.map(entry => entry.path)).toEqual([
       '/api/status', '/api/auth/me', '/api/profiles',
@@ -217,13 +217,13 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     const lan = await request(runtime.app.callback())
       .get('/healthz')
-      .set('Host', '192.168.153.155:8800')
+      .set('Host', '192.168.153.155:15300')
       .expect(200)
     expect(lan.headers['cross-origin-opener-policy']).toBeUndefined()
 
     const loopback = await request(runtime.app.callback())
       .get('/healthz')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     expect(loopback.headers['cross-origin-opener-policy']).toBe('same-origin')
   })
@@ -250,18 +250,18 @@ describe('8800 BFF', () => {
 
     const unread = await request(runtime.app.callback())
       .get('/api/app/sessions/unread?profile=yaoer')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     expect(unread.body).toEqual({ profile: 'yaoer', total_unread: 0, sessions: [], supported: false })
 
     const bootstrap = await request(runtime.app.callback())
       .get('/api/app/bootstrap')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     const marked = await request(runtime.app.callback())
       .patch('/api/app/sessions/unread/session-1?profile=yaoer')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('Cookie', cookieHeader(bootstrap))
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({ readMessageCount: 12 })
@@ -275,30 +275,32 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     const bootstrap = await request(runtime.app.callback())
       .get('/api/app/bootstrap')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     const cookies = cookieHeader(bootstrap)
     await request(runtime.app.callback())
-      .post('/api/app/login')
-      .set('Host', '127.0.0.1:8800')
+      .post('/api/app/setup')
+      .set('Host', '127.0.0.1:15300')
       .set('Cookie', cookies)
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
-      .send({ username: 'admin', password: 'admin' })
+      .send({ username: 'owner', password: 'fixture-password' })
       .expect(403)
 
     records.length = 0
     const response = await request(runtime.app.callback())
-      .post('/api/app/login')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .post('/api/app/setup')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('Cookie', cookies)
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
-      .send({ username: 'admin', password: 'admin' })
+      .send({ username: 'owner', password: 'fixture-password' })
       .expect(200)
-    expect(records).toEqual([])
+    expect(records.every(record => record.method === 'GET')).toBe(true)
+    expect(records.some(record => record.path === '/auth/password-login')).toBe(false)
+    expect(JSON.stringify(records)).not.toContain('fixture-password')
     expect(response.body.authenticated).toBe(true)
-    expect(response.body.user).toMatchObject({ username: 'admin', role: 'admin', mustChangePassword: true })
-    expect(response.body.profiles).toEqual([])
+    expect(response.body.user).toMatchObject({ username: 'owner', role: 'admin', mustChangePassword: false })
+    expect(response.body.profiles).toHaveLength(1)
     expect(JSON.stringify(response.body)).not.toContain('passwordHash')
   })
 
@@ -308,14 +310,14 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     await request(runtime.app.callback())
       .get('/api/app/sessions?exclude_sources=evil')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     expect(records.at(-1)?.path).toBe('/api/profiles/sessions')
     expect(records.at(-1)?.search.get('exclude_sources')).toBe('cron,ios_group,yaoyao_workspace')
 
     await request(runtime.app.callback())
       .get('/api/app/sessions/session-1/messages?offset=3&limit=50&order=oldest&include_compacted=false&profile=default')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     const history = records.at(-1)!
     expect(history.search.get('offset')).toBe('3')
@@ -330,7 +332,7 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     const response = await request(runtime.app.callback())
       .get('/api/anything')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(404)
     expect(response.body.code).toBe('node_route_not_found')
   })
@@ -340,11 +342,11 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     const first = await request(runtime.app.callback())
       .get('/api/app/bootstrap')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     const second = await request(runtime.app.callback())
       .get('/api/app/bootstrap')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .set('Cookie', cookieHeader(first))
       .expect(200)
     expect(second.body.csrfToken).toBe(first.body.csrfToken)
@@ -361,19 +363,19 @@ describe('8800 BFF', () => {
     runtimes.push(runtime)
     const bootstrap = await request(runtime.app.callback())
       .get('/api/app/bootstrap')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     await request(runtime.app.callback())
       .post('/api/app/group-uploads')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('Cookie', cookieHeader(bootstrap))
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .attach('files', Buffer.from('hello'), { filename: 'hello.txt', contentType: 'text/plain' })
       .expect(401)
   })
 
-  it('forces active file content to download instead of executing on the 8800 origin', async () => {
+  it('forces active file content to download instead of executing on the 15300 origin', async () => {
     const fetchImpl = (async (input: string | URL | Request) => {
       const path = new URL(input instanceof Request ? input.url : String(input)).pathname
       if (path === '/api/status') return jsonResponse({ auth_required: true })
@@ -390,7 +392,7 @@ describe('8800 BFF', () => {
     runtime.workspace.put('test-admin','file','1',{id:'1',name:'unsafe.html',path:file,mimeType:'text/html',size:37,sender:'agent',createdAt:Date.now()})
     const response = await request(runtime.app.callback())
       .get('/api/app/files/1/preview')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     expect(response.headers['content-type']).toMatch(/^application\/octet-stream/)
     expect(response.headers['content-disposition']).toContain('attachment')

+ 2 - 2
tests/server/dashboardSupervisor.test.ts

@@ -22,7 +22,7 @@ describe('DashboardSupervisor', () => {
     const f = supervisor()
     await f.instance.checkNow()
     expect(f.calls).toEqual([])
-    expect(f.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+    expect(f.launches).toEqual([['dashboard', '--host', '127.0.0.1']])
     await f.instance.checkNow()
     expect(f.launches).toHaveLength(1)
   })
@@ -37,7 +37,7 @@ describe('DashboardSupervisor', () => {
     const f = supervisor(true)
     await f.instance.restart()
     expect(f.calls).toEqual([['dashboard', '--stop']])
-    expect(f.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+    expect(f.launches).toEqual([['dashboard', '--host', '127.0.0.1']])
   })
   it('coalesces concurrent checks', async () => {
     const f = supervisor()

+ 2 - 2
tests/server/hermesMediaRoutes.test.ts

@@ -8,7 +8,7 @@ import { createApplication, type ApplicationRuntime } from '../../src/server/app
 import type { ServerConfig } from '../../src/server/config.js'
 import { createAuthenticatedApplication } from './authenticatedApplication.js'
 
-const HOST = '127.0.0.1:8800'
+const HOST = '127.0.0.1:15300'
 const IMAGE_NAME = 'openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png'
 const IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR5kAAAAASUVORK5CYII=', 'base64')
 const homes: string[] = []
@@ -29,7 +29,7 @@ function config(): ServerConfig {
   const home = mkdtempSync(join(tmpdir(), 'yaoyao-hermes-media-'))
   homes.push(home)
   return {
-    host: '127.0.0.1', port: 8800,
+    host: '127.0.0.1', port: 15300,
     upstream: new URL('http://10.10.1.200:9119'),
     upstreamUsername: 'fixture-service', upstreamPassword: 'fixture-password',
     allowedHosts: new Set(), home,

+ 3 - 3
tests/server/kanbanRoutes.test.ts

@@ -10,7 +10,7 @@ import {
   createUserAuthenticatedApplication,
 } from './authenticatedApplication.js'
 
-const HOST = '127.0.0.1:8800'
+const HOST = '127.0.0.1:15300'
 const ORIGIN = `http://${HOST}`
 const roots: string[] = []
 const runtimes: ApplicationRuntime[] = []
@@ -39,7 +39,7 @@ function testConfig(): ServerConfig {
   roots.push(home)
   return {
     host: '127.0.0.1',
-    port: 8800,
+    port: 15300,
     upstream: new URL('http://127.0.0.1:9119'),
     allowedHosts: new Set(),
     home,
@@ -306,7 +306,7 @@ describe('Kanban proxy routes', () => {
     }
   })
 
-  it('maps server-owned upstream 401 responses without expiring the local 8800 session', async () => {
+  it('maps server-owned upstream 401 responses without expiring the local 15300 session', async () => {
     const fixture = upstreamFixture()
     const config = testConfig()
     config.production = true

+ 17 - 25
tests/server/localAuth.test.ts

@@ -27,22 +27,20 @@ afterEach(() => {
   for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
 })
 
-describe('8800 local users', () => {
-  it('creates admin/admin and forces a credential change', () => {
+describe('15300 local users', () => {
+  it('requires an explicit first administrator and persists its session', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-auth-'))
     homes.push(home)
     const store = new LocalAuthStore(home)
-    const login = context()
-    const admin = store.login(login, 'admin', 'admin')
-    expect(admin).toMatchObject({ role: 'admin', mustChangePassword: true })
-    const authenticated = context(issuedCookie(login))
-    expect(() => store.require(authenticated)).toThrow(/修改初始密码/)
-
-    const changed = store.changeCredentials(authenticated, 'admin', 'new-password', 'owner')
-    expect(changed).toMatchObject({ username: 'owner', mustChangePassword: false })
-    expect(store.require(context(issuedCookie(authenticated))).username).toBe('owner')
+    expect(store.setupRequired).toBe(true)
+    const setup = context()
+    const admin = store.setupAdmin(setup, 'owner', 'new-password')
+    expect(admin).toMatchObject({ username: 'owner', role: 'admin', mustChangePassword: false })
+    expect(store.setupRequired).toBe(false)
+    expect(() => store.setupAdmin(context(), 'second', 'new-password')).toThrow(/已经初始化/)
+    expect(store.require(context(issuedCookie(setup))).username).toBe('owner')
     const restarted = new LocalAuthStore(home)
-    expect(restarted.require(context(issuedCookie(authenticated))).username).toBe('owner')
+    expect(restarted.require(context(issuedCookie(setup))).username).toBe('owner')
   })
 
   it('lets the administrator create, reset, disable and delete ordinary users', () => {
@@ -50,10 +48,8 @@ describe('8800 local users', () => {
     homes.push(home)
     const store = new LocalAuthStore(home)
     const first = context()
-    store.login(first, 'admin', 'admin')
-    const changedContext = context(issuedCookie(first))
-    store.changeCredentials(changedContext, 'admin', 'new-password', 'admin')
-    const admin = store.require(context(issuedCookie(changedContext)))
+    store.setupAdmin(first, 'admin', 'new-password')
+    const admin = store.require(context(issuedCookie(first)))
     const user = store.create(admin, 'member', 'temporary-password')
     expect(user.mustChangePassword).toBe(true)
     expect(store.updateUser(admin, user.id, { enabled: false }).enabled).toBe(false)
@@ -64,14 +60,12 @@ describe('8800 local users', () => {
   it('persists an account image without invalidating the existing session', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-auth-')); homes.push(home)
     const store = new LocalAuthStore(home), login = context()
-    store.login(login, 'admin', 'admin')
-    const authenticated = context(issuedCookie(login))
-    store.changeCredentials(authenticated, 'admin', 'new-password', 'admin')
-    const active = context(issuedCookie(authenticated))
+    store.setupAdmin(login, 'admin', 'new-password')
+    const active = context(issuedCookie(login))
     const avatar = 'data:image/png;base64,aGVsbG8='
     expect(store.setAvatar(active, avatar).avatar).toBe(avatar)
     expect(store.require(active).avatar).toBe(avatar)
-    expect(new LocalAuthStore(home).require(context(issuedCookie(authenticated))).avatar).toBe(avatar)
+    expect(new LocalAuthStore(home).require(context(issuedCookie(login))).avatar).toBe(avatar)
     expect(() => store.setAvatar(active, 'https://example.test/avatar.png')).toThrow(/PNG、JPEG/)
     expect(store.setAvatar(active, null).avatar).toBeUndefined()
   })
@@ -81,10 +75,8 @@ describe('8800 local users', () => {
     homes.push(home)
     const store = new LocalAuthStore(home)
     const first = context()
-    store.login(first, 'admin', 'admin')
-    const changedContext = context(issuedCookie(first))
-    store.changeCredentials(changedContext, 'admin', 'new-password', 'admin')
-    const admin = store.require(context(issuedCookie(changedContext)))
+    store.setupAdmin(first, 'admin', 'new-password')
+    const admin = store.require(context(issuedCookie(first)))
     store.setUpstreamCredentials(admin, { username: 'service', password: 'very-secret' })
     expect(store.upstreamCredentials()).toEqual({ username: 'service', password: 'very-secret' })
     expect(readFileSync(join(home, 'upstream-credentials.enc'), 'utf8')).not.toContain('very-secret')

+ 41 - 45
tests/server/localAuthRoutes.test.ts

@@ -20,12 +20,12 @@ function cookies(response: request.Response): string {
   return (values ?? []).map(value => value.split(';', 1)[0]).join('; ')
 }
 
-describe('8800 local authentication routes', () => {
+describe('15300 local authentication routes', () => {
   it('installs without upstream credentials while keeping local login and CSRF mandatory', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-token-')); roots.push(home)
     const token = 'fixture_local_only_session_token_123456'
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false, superviseDashboard: true,
     }
@@ -37,35 +37,37 @@ describe('8800 local authentication routes', () => {
       return Response.json({ profiles: [{ name: 'default', is_default: true, display_name: '丫头' }] })
     }) as typeof fetch }); runtimes.push(runtime)
     expect(runtime.auth.upstreamCredentials()).toBeUndefined()
-    const agent = request.agent(runtime.app.callback()), origin = 'http://127.0.0.1:8800'
-    await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(401)
-    const boot = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
-    expect(boot.body.authRequired).toBe(true)
-    const login = await agent.post('/api/app/login').set('Host', '127.0.0.1:8800').set('Origin', origin)
-      .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
-    await agent.put('/api/app/account/credentials').set('Host', '127.0.0.1:8800').set('Origin', origin)
-      .set('X-CSRF-Token', login.body.csrfToken).send({ currentPassword: 'admin', newPassword: 'fixture-password', username: 'owner' }).expect(200)
-    const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const agent = request.agent(runtime.app.callback()), origin = 'http://127.0.0.1:15300'
+    await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
+    const boot = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
+    expect(boot.body).toMatchObject({ authRequired: true, setupRequired: true })
+    await agent.post('/api/app/login').set('Host', '127.0.0.1:15300').set('Origin', origin)
+      .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(401)
+    const setup = await agent.post('/api/app/setup').set('Host', '127.0.0.1:15300').set('Origin', origin)
+      .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
+    await agent.post('/api/app/setup').set('Host', '127.0.0.1:15300').set('Origin', origin)
+      .set('X-CSRF-Token', setup.body.csrfToken).send({ username: 'other', password: 'fixture-password' }).expect(409)
+    const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     expect(ready.body).toMatchObject({ authRequired: true, authenticated: true, upstreamReady: true })
     expect(ready.body.profiles).toHaveLength(1)
     expect(JSON.stringify([ready.body, ready.headers])).not.toContain(token)
     expect(runtime.auth.upstreamCredentials()).toBeUndefined()
     const avatar = 'data:image/png;base64,aGVsbG8='
-    const avatarUpdate = await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:8800').set('Origin', origin)
+    const avatarUpdate = await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:15300').set('Origin', origin)
       .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar }).expect(200)
     expect(avatarUpdate.body.user.avatar).toBe(avatar)
-    expect((await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)).body.user.avatar).toBe(avatar)
-    expect((await agent.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)).body.avatar).toBe(avatar)
-    await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:8800').set('Origin', origin)
+    expect((await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)).body.user.avatar).toBe(avatar)
+    expect((await agent.get('/api/auth/me').set('Host', '127.0.0.1:15300').expect(200)).body.avatar).toBe(avatar)
+    await agent.put('/api/app/account/avatar').set('Host', '127.0.0.1:15300').set('Origin', origin)
       .set('X-CSRF-Token', ready.body.csrfToken).send({ avatar: 'https://example.test/avatar.png' }).expect(400)
-    await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:8800').set('Origin', origin).send({ channel: 'chat' }).expect(403)
-    await request(runtime.app.callback()).get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(401)
+    await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:15300').set('Origin', origin).send({ channel: 'chat' }).expect(403)
+    await request(runtime.app.callback()).get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
   })
-  it('forces admin/admin to change, then serves shared profiles through the service account', async () => {
+  it('creates an explicit administrator, then serves shared profiles through the service account', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-routes-'))
     roots.push(home)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
       superviseDashboard: true,
@@ -94,24 +96,18 @@ describe('8800 local authentication routes', () => {
     const runtime = createApplication({ config, fetchImpl })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const initial = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
-    const login = await agent.post('/api/app/login')
-      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
-      .set('X-CSRF-Token', initial.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
-    expect(login.body.user).toMatchObject({ username: 'admin', role: 'admin', mustChangePassword: true })
-    expect(login.body.profiles).toEqual([])
+    const initial = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
+    const setup = await agent.post('/api/app/setup')
+      .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300')
+      .set('X-CSRF-Token', initial.body.csrfToken).send({ username: 'owner', password: 'new-password' }).expect(200)
+    expect(setup.body.user).toMatchObject({ username: 'owner', role: 'admin', mustChangePassword: false })
+    expect(setup.body.profiles).toHaveLength(1)
 
-    const changed = await agent.put('/api/app/account/credentials')
-      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
-      .set('X-CSRF-Token', login.body.csrfToken)
-      .send({ currentPassword: 'admin', newPassword: 'new-password', username: 'owner' }).expect(200)
-    expect(changed.body.user).toMatchObject({ username: 'owner', mustChangePassword: false })
-
-    const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     expect(ready.body).toMatchObject({ upstreamReady: true, serverKind: 'yaoyao-web' })
     expect(ready.body.profiles).toHaveLength(1)
     const connection = await agent.get('/api/app/admin/upstream-connection')
-      .set('Host', '127.0.0.1:8800').expect(200)
+      .set('Host', '127.0.0.1:15300').expect(200)
     expect(connection.body).toMatchObject({
       endpoint: 'http://127.0.0.1:9119',
       authMode: 'password',
@@ -122,40 +118,40 @@ describe('8800 local authentication routes', () => {
     expect(connection.body.lastVerifiedAt).toEqual(expect.any(Number))
 
     await agent.post('/api/app/admin/users')
-      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300')
       .set('X-CSRF-Token', ready.body.csrfToken)
       .send({ username: 'member', password: 'temporary-password' }).expect(201)
-    const users = await agent.get('/api/app/admin/users').set('Host', '127.0.0.1:8800').expect(200)
+    const users = await agent.get('/api/app/admin/users').set('Host', '127.0.0.1:15300').expect(200)
     expect(users.body.items.map((user: { username: string }) => user.username)).toEqual(['owner', 'member'])
-    expect(cookies(changed)).not.toContain('admin')
+    expect(cookies(setup)).not.toContain('admin')
 
     const accountPairing = await agent.post('/api/app/account-pairings')
-      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300')
       .set('X-CSRF-Token', ready.body.csrfToken).send({}).expect(201)
     const loginCode = new URL(accountPairing.body.qrPayload)
     expect(loginCode.hostname).toBe('login')
     const scanned = request.agent(runtime.app.callback())
-    await scanned.post('/api/account-pair/v1/claim').set('Host', '127.0.0.1:8800')
+    await scanned.post('/api/account-pair/v1/claim').set('Host', '127.0.0.1:15300')
       .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
       .expect(201)
-    const scannedIdentity = await scanned.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)
+    const scannedIdentity = await scanned.get('/api/auth/me').set('Host', '127.0.0.1:15300').expect(200)
     expect(scannedIdentity.body).toMatchObject({ username: 'owner', role: 'admin' })
     await request(runtime.app.callback()).post('/api/account-pair/v1/claim')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
       .expect(409)
 
     const native = request.agent(runtime.app.callback())
-    await native.get('/api/status').set('Host', '127.0.0.1:8800').expect(200, /yaoyao-web/)
-    await native.post('/auth/password-login').set('Host', '127.0.0.1:8800')
+    await native.get('/api/status').set('Host', '127.0.0.1:15300').expect(200, /yaoyao-web/)
+    await native.post('/auth/password-login').set('Host', '127.0.0.1:15300')
       .send({ provider: 'basic', username: 'owner', password: 'new-password', next: '' }).expect(200)
-    const identity = await native.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)
+    const identity = await native.get('/api/auth/me').set('Host', '127.0.0.1:15300').expect(200)
     expect(identity.body).toMatchObject({ username: 'owner', role: 'admin', server_kind: 'yaoyao-web' })
-    const nativeProfiles = await native.get('/api/profiles').set('Host', '127.0.0.1:8800').expect(200)
+    const nativeProfiles = await native.get('/api/profiles').set('Host', '127.0.0.1:15300').expect(200)
     expect(nativeProfiles.body.profiles[0]).toMatchObject({
       name: 'default', description: '丫头', display_name: '丫头', agentName: '丫头',
     })
-    await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:8800').send({}).expect(410)
+    await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:15300').send({}).expect(410)
     expect(wsTicketForwardedFor).toBeUndefined()
   })
 })

+ 11 - 5
tests/server/offlineSystemUpdate.test.ts

@@ -10,7 +10,7 @@ import { SystemUpdateManager } from '../../src/server/updateManager.js'
 
 const runtimes: ApplicationRuntime[] = []
 const roots: string[] = []
-const host = '127.0.0.1:8800'
+const host = '127.0.0.1:15300'
 const origin = `http://${host}`
 const base = '/api/app/system/update'
 const latest = { schemaVersion: 1 as const, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' }
@@ -25,7 +25,7 @@ function fixture() {
   const projectRoot = join(root, 'project'); mkdirSync(projectRoot)
   writeFileSync(join(projectRoot, 'release.json'), JSON.stringify({ ...latest, releaseVersion: '0.2.0', webVersion: '0.2.0', gitTag: 'v0.2.0' }))
   const config: ServerConfig = {
-    host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+    host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
     allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: root,
     attachmentsRoot: root, imagesRoot: root, mediaOwner: 'tester',
     allowInsecureLan: false, insecureLan: false, production: false,
@@ -39,11 +39,14 @@ function fixture() {
   return { runtime, fetchImpl, inspectRemote, launchUpdater, config }
 }
 
-async function login(runtime: ApplicationRuntime, username = 'admin', password = 'admin') {
+async function login(runtime: ApplicationRuntime, username = 'admin', password = 'offline-test-password') {
   const agent = request.agent(runtime.app.callback())
   const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
-  const signedIn = await agent.post('/api/app/login').set('Host', host).set('Origin', origin)
+  const signedIn = await agent.post(boot.body.setupRequired ? '/api/app/setup' : '/api/app/login').set('Host', host).set('Origin', origin)
     .set('X-CSRF-Token', boot.body.csrfToken).send({ username, password }).expect(200)
+  if (!signedIn.body.user.mustChangePassword) {
+    return { agent, csrf: signedIn.body.csrfToken as string, user: signedIn.body.user }
+  }
   const changed = await agent.put('/api/app/account/credentials').set('Host', host).set('Origin', origin)
     .set('X-CSRF-Token', signedIn.body.csrfToken)
     .send({ currentPassword: password, newPassword: 'offline-test-password', username }).expect(200)
@@ -98,6 +101,7 @@ describe('independent Web updates', () => {
     const admin = await login(f.runtime)
     f.runtime.auth.create(admin.user, 'member', 'temporary-password')
     const member = await login(f.runtime, 'member', 'temporary-password')
+    f.fetchImpl.mockClear()
     for (const path of getPaths) await member.agent.get(base + path).set('Host', host).expect(403)
     for (const path of ['/check', '/apply', '/rollback']) {
       await member.agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', member.csrf)
@@ -113,13 +117,14 @@ describe('independent Web updates', () => {
   it('preserves local login during a stalled bootstrap and still exposes update status', async () => {
     const f = fixture()
     const { agent } = await login(f.runtime)
+    f.fetchImpl.mockClear()
     let finish!: (response: Response) => void
     f.fetchImpl.mockImplementation(() => new Promise<Response>(resolve => { finish = resolve }))
     const started = Date.now()
     const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
     expect(Date.now() - started).toBeLessThan(8_000)
     expect(boot.body).toMatchObject({ authenticated: true, upstreamReady: false, user: { role: 'admin' } })
-    expect(boot.body.upstreamError).toContain('仍可管理 8800 和升级 Web')
+    expect(boot.body.upstreamError).toContain('仍可管理 15300 和升级 Web')
     await agent.get(base + '/status').set('Host', host).expect(200)
     expect(f.fetchImpl).toHaveBeenCalledTimes(1)
     finish(Response.json({}, { status: 503 }))
@@ -128,6 +133,7 @@ describe('independent Web updates', () => {
   it('reports real release-source failures without launching or contacting 9119', async () => {
     const f = fixture()
     const { agent, csrf } = await login(f.runtime)
+    f.fetchImpl.mockClear()
     f.inspectRemote.mockRejectedValue(new Error('Git source unreachable'))
     const failed = await agent.post(base + '/check').set('Host', host).set('Origin', origin)
       .set('X-CSRF-Token', csrf).send({}).expect(502)

+ 13 - 13
tests/server/pairing.test.ts

@@ -25,7 +25,7 @@ function temporaryHome(): string {
 function config(home: string): ServerConfig {
   return {
     host: '127.0.0.1',
-    port: 8800,
+    port: 15300,
     upstream: new URL('http://127.0.0.1:9119'),
     allowedHosts: new Set(),
     home,
@@ -145,21 +145,21 @@ describe('Hermes node pairing', () => {
 
     const bootstrap = await request(runtime.app.callback())
       .get('/api/app/bootstrap')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     const cookie = cookies(bootstrap)
     await request(runtime.app.callback())
       .post('/api/app/pairings')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('Cookie', cookie)
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({ scopes: ['unknown.scope'] })
       .expect(400)
     const pairing = await request(runtime.app.callback())
       .post('/api/app/pairings')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('Cookie', cookie)
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({
@@ -172,20 +172,20 @@ describe('Hermes node pairing', () => {
     expect(deepLink.searchParams.get('secret')).toBeTruthy()
     const claim = await request(runtime.app.callback())
       .post('/api/pair/v1/claim')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .send({
         pairingId: pairing.body.pairingId,
         secret: deepLink.searchParams.get('secret'),
         deviceName: 'YaoYao iPhone',
       })
       .expect(201)
-    expect(claim.body.serverUrl).toBe(`http://127.0.0.1:8800/node/${claim.body.deviceId}`)
+    expect(claim.body.serverUrl).toBe(`http://127.0.0.1:15300/node/${claim.body.deviceId}`)
     expect(readFileSync(join(home, 'paired-devices.json'), 'utf8'))
       .not.toContain('paired-access')
 
     const profiles = await request(runtime.app.callback())
       .get(`/node/${claim.body.deviceId}/api/profiles`)
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .set('Authorization', `Bearer ${claim.body.token}`)
       .expect(200)
     expect(profiles.body.profiles[0].name).toBe('default')
@@ -199,7 +199,7 @@ describe('Hermes node pairing', () => {
     const attachment = Buffer.from('remote attachment')
     await request(runtime.app.callback())
       .post(`/node/${claim.body.deviceId}/api/plugins/yaoyao/v1/node-worker/sessions/runtime-1/attachments`)
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .set('Authorization', `Bearer ${claim.body.token}`)
       .set('Content-Type', 'application/octet-stream')
       .set('X-File-Name-B64', Buffer.from('note.txt').toString('base64url'))
@@ -210,14 +210,14 @@ describe('Hermes node pairing', () => {
 
     await request(runtime.app.callback())
       .delete(`/api/app/paired-devices/${claim.body.deviceId}`)
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('Cookie', cookie)
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .expect(200)
     await request(runtime.app.callback())
       .get(`/node/${claim.body.deviceId}/api/profiles`)
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .set('Authorization', `Bearer ${claim.body.token}`)
       .expect(401)
   })

+ 3 - 3
tests/server/pushEvents.test.ts

@@ -475,7 +475,7 @@ describe('push event bridge', () => {
       },
     } as unknown as UpstreamServiceSession
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
       imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }
@@ -524,7 +524,7 @@ describe('push event bridge', () => {
       await closeServer(upstream)
     })
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL(`http://127.0.0.1:${port}`),
+      host: '127.0.0.1', port: 15300, upstream: new URL(`http://127.0.0.1:${port}`),
       allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
       imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }
@@ -602,7 +602,7 @@ describe('push event bridge', () => {
       await closeServer(upstream)
     })
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL(`http://127.0.0.1:${port}`),
+      host: '127.0.0.1', port: 15300, upstream: new URL(`http://127.0.0.1:${port}`),
       allowedHosts: new Set(), home: '/tmp', mediaRoot: '/tmp/media', attachmentsRoot: '/tmp/attachments',
       imagesRoot: '/tmp/images', mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }

+ 24 - 24
tests/server/pushRoutes.test.ts

@@ -44,7 +44,7 @@ describe('iOS push routes', () => {
       token_uri: 'https://oauth2.googleapis.com/token',
     }), { mode: 0o600 })
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }
@@ -61,10 +61,10 @@ describe('iOS push routes', () => {
     const runtime = createAuthenticatedApplication({ config, push, fcmConfiguration })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const mutation = () => agent.put('/api/app/system/push-config/fcm')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .send({
         serviceAccountFile,
         projectId: 'yaoyao-test-project',
@@ -95,8 +95,8 @@ describe('iOS push routes', () => {
     const accountID = '22222222-2222-4222-8222-222222222222'
     const fid = 'fcm-registration-id-1234567890'
     const registered = await agent.put(`/api/push/v1/installations/${installationID}/accounts/${accountID}`)
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .send({ platform: 'android', fid, appVersion: '1.0.0' })
       .expect(200)
     expect(registered.body.installation).toMatchObject({
@@ -106,7 +106,7 @@ describe('iOS push routes', () => {
     expect(push.fcmStatus()).toMatchObject({ registrationCount: 1 })
 
     const capabilities = await agent.get('/api/push/v1/capabilities')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200)
     expect(capabilities.body).toMatchObject({
       enabled: false,
@@ -126,7 +126,7 @@ describe('iOS push routes', () => {
     writeFileSync(keyFile, privateKey, { mode: 0o644 })
     chmodSync(keyFile, 0o644)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }
@@ -143,10 +143,10 @@ describe('iOS push routes', () => {
     const runtime = createAuthenticatedApplication({ config, push, apnsConfiguration })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const mutation = () => agent.put('/api/app/system/push-config')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .send({
         keyFile,
         keyId: 'KEY1234567',
@@ -168,7 +168,7 @@ describe('iOS push routes', () => {
     })
     expect(statSync(apnsConfigurationPath(home)).mode & 0o777).toBe(0o600)
     await agent.get('/api/app/system/push-status')
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .expect(200, /"source":"file"/)
   })
 
@@ -188,7 +188,7 @@ describe('iOS push routes', () => {
     })
     const initial = { ...validated, source: 'environment' as const, editable: false }
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
       apns: validated.config,
@@ -203,11 +203,11 @@ describe('iOS push routes', () => {
     const runtime = createAuthenticatedApplication({ config, push, apnsConfiguration })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
 
     const response = await agent.put('/api/app/system/push-config')
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
       .send({
         keyFile, keyId: 'KEY1234567', teamId: 'TEAM123456', topic: 'cn.samien.yaoyao.hermes',
@@ -221,7 +221,7 @@ describe('iOS push routes', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-routes-'))
     roots.push(home)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
       apns: { keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes' },
@@ -243,14 +243,14 @@ describe('iOS push routes', () => {
     const runtime = createAuthenticatedApplication({ config, push, fetchImpl })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const host = (value: request.Test) => value.set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+    const host = (value: request.Test) => value.set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
     const installationID = '11111111-1111-4111-8111-111111111111'
     const accountID = '22222222-2222-4222-8222-222222222222'
     const a = runtime.workspace.createAgent('test-admin', {name:'甲',profile:'default'})
     const b = runtime.workspace.createAgent('test-admin', {name:'乙',profile:'default'})
     const roomID = runtime.workspace.createGroup('test-admin', {name:'通知测试',memberIds:[a.id,b.id],administratorId:a.id}).id
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
 
     const capabilities = await host(agent.get('/api/push/v1/capabilities')).expect(200)
     expect(capabilities.body).toMatchObject({
@@ -259,7 +259,7 @@ describe('iOS push routes', () => {
       topic: 'cn.samien.yaoyao.hermes',
       maximumSummaryCharacters: 180,
     })
-    await agent.get('/api/app/push/v1/capabilities').set('Host', '127.0.0.1:8800').expect(200)
+    await agent.get('/api/app/push/v1/capabilities').set('Host', '127.0.0.1:15300').expect(200)
 
     const registered = await host(agent.put(`/api/push/v1/installations/${installationID}/accounts/${accountID}`))
       .send({ token: 'ab'.repeat(32), environment: 'development', appVersion: '1.2 (139)' })
@@ -292,7 +292,7 @@ describe('iOS push routes', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-validation-'))
     roots.push(home)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }
@@ -300,8 +300,8 @@ describe('iOS push routes', () => {
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
     const headers = (value: request.Test) => value
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
 
     await headers(agent.put('/api/push/v1/installations/11111111-1111-4111-8111-111111111111/accounts/22222222-2222-4222-8222-222222222222'))
       .send({ token: 'not-a-token', environment: 'unknown' })

+ 1 - 1
tests/server/pushRuntime.test.ts

@@ -21,7 +21,7 @@ describe('dynamic push runtime', () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-runtime-'))
     roots.push(home)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
     }

+ 13 - 15
tests/server/readCacheRoutes.test.ts

@@ -12,7 +12,7 @@ afterEach(() => { for (const runtime of runtimes.splice(0)) runtime.close() })
 describe('authenticated read cache routes', () => {
   it('hits cached reads, honors fresh reads and never bypasses logout authentication', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-read-cache-routes-'))
-    const config: ServerConfig = { host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:19119'),
+    const config: ServerConfig = { host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:19119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home, mediaOwner: 'test',
       allowInsecureLan: false, insecureLan: false, production: false }
     let sessionReads = 0, statusReads = 0, currentTitle = 'original'
@@ -25,36 +25,34 @@ describe('authenticated read cache routes', () => {
       return Response.json({ ok: true })
     }) as typeof fetch })
     runtimes.push(runtime)
-    const agent = request.agent(runtime.app.callback()), origin = 'http://127.0.0.1:8800'
-    const start = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
-    const login = await agent.post('/api/app/login').set('Host', '127.0.0.1:8800').set('Origin', origin)
-      .set('X-CSRF-Token', start.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
-    await agent.put('/api/app/account/credentials').set('Host', '127.0.0.1:8800').set('Origin', origin)
-      .set('X-CSRF-Token', login.body.csrfToken).send({ currentPassword: 'admin', newPassword: 'fixture-password', username: 'owner' }).expect(200)
+    const agent = request.agent(runtime.app.callback()), origin = 'http://127.0.0.1:15300'
+    const start = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
+    await agent.post('/api/app/setup').set('Host', '127.0.0.1:15300').set('Origin', origin)
+      .set('X-CSRF-Token', start.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
     const statusBefore = statusReads
-    const first = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(200)
+    const first = await agent.get('/api/sessions').set('Host', '127.0.0.1:15300').expect(200)
     const statusAfterColdRead = statusReads
-    const second = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(200)
+    const second = await agent.get('/api/sessions').set('Host', '127.0.0.1:15300').expect(200)
     expect(second.body).toEqual(first.body)
     expect(sessionReads).toBe(1)
     expect(statusAfterColdRead - statusBefore).toBeLessThanOrEqual(1)
     expect(statusReads).toBe(statusAfterColdRead)
     currentTitle = 'external-change'
-    const fresh = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').set('X-Yaoyao-Cache', 'bypass').expect(200)
+    const fresh = await agent.get('/api/sessions').set('Host', '127.0.0.1:15300').set('X-Yaoyao-Cache', 'bypass').expect(200)
     expect(fresh.body.sessions[0].title).toBe('external-change')
-    const after = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(200)
+    const after = await agent.get('/api/sessions').set('Host', '127.0.0.1:15300').expect(200)
     expect(after.body.sessions[0].title).toBe('external-change')
     expect(sessionReads).toBe(3)
-    await agent.patch('/api/sessions/one').set('Host', '127.0.0.1:8800')
+    await agent.patch('/api/sessions/one').set('Host', '127.0.0.1:15300')
       .send({ title: 'renamed' }).expect(410, {
         error: '原生 Hermes 会话仅供历史查看;请在聊天中开始或继续对话',
         code: 'native_sessions_read_only',
       })
-    const unchanged = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(200)
+    const unchanged = await agent.get('/api/sessions').set('Host', '127.0.0.1:15300').expect(200)
     expect(unchanged.body.sessions[0].title).toBe('external-change')
     expect(sessionReads).toBe(3)
-    await agent.post('/auth/logout').set('Host', '127.0.0.1:8800').send({}).expect(200)
-    await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(401)
+    await agent.post('/auth/logout').set('Host', '127.0.0.1:15300').send({}).expect(200)
+    await agent.get('/api/sessions').set('Host', '127.0.0.1:15300').expect(401)
     expect(sessionReads).toBe(3)
   })
 })

+ 11 - 15
tests/server/realtimeApi.test.ts

@@ -14,13 +14,13 @@ afterEach(() => {
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
 
-describe('native Hermes chat and history are separated on 8800', () => {
+describe('native Hermes chat and history are separated on 15300', () => {
   it('opens realtime chat while keeping non-Web history mutations read-only', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-native-history-'))
     roots.push(home)
     let upstreamCalls = 0, upstreamWrites = 0
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home,
       imagesRoot: home, mediaOwner: 'test', allowInsecureLan: false,
       insecureLan: false, production: false,
@@ -45,22 +45,18 @@ describe('native Hermes chat and history are separated on 8800', () => {
     })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
-    const origin = 'http://127.0.0.1:8800'
-    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
-    const login = await agent.post('/api/app/login').set('Host', '127.0.0.1:8800')
+    const origin = 'http://127.0.0.1:15300'
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
+    const setup = await agent.post('/api/app/setup').set('Host', '127.0.0.1:15300')
       .set('Origin', origin).set('X-CSRF-Token', bootstrap.body.csrfToken)
-      .send({ username: 'admin', password: 'admin' }).expect(200)
-    const credentials = await agent.put('/api/app/account/credentials')
-      .set('Host', '127.0.0.1:8800').set('Origin', origin)
-      .set('X-CSRF-Token', login.body.csrfToken)
-      .send({ username: 'owner', currentPassword: 'admin', newPassword: 'fixture-password' }).expect(200)
-    const csrf = credentials.body.csrfToken
+      .send({ username: 'owner', password: 'fixture-password' }).expect(200)
+    const csrf = setup.body.csrfToken
 
-    const capabilities = await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(200)
+    const capabilities = await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(200)
     expect(capabilities.body.channels).toEqual(['chat'])
     const beforeWrites = upstreamCalls, beforeUpstreamWrites = upstreamWrites
     for (const path of ['/api/app/sessions/session-1', '/api/sessions/session-1']) {
-      const response = await agent.patch(path).set('Host', '127.0.0.1:8800')
+      const response = await agent.patch(path).set('Host', '127.0.0.1:15300')
         .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '不能修改' }).expect(410)
       expect(response.body.code).toBe('native_sessions_read_only')
     }
@@ -72,14 +68,14 @@ describe('native Hermes chat and history are separated on 8800', () => {
     })
     const pairedWrite = await request(runtime.app.callback())
       .patch(`/node/${paired.device.id}/api/sessions/session-1`)
-      .set('Host', '127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
       .set('Authorization', `Bearer ${paired.token}`)
       .send({ title: '不能修改' })
       .expect(410)
     expect(pairedWrite.body.code).toBe('native_sessions_read_only')
     expect(upstreamCalls).toBeGreaterThan(beforeWrites)
     expect(upstreamWrites).toBe(beforeUpstreamWrites)
-    await agent.patch('/api/app/sessions/session-web?profile=default').set('Host', '127.0.0.1:8800')
+    await agent.patch('/api/app/sessions/session-web?profile=default').set('Host', '127.0.0.1:15300')
       .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '可修改的聊天' }).expect(200)
     expect(upstreamWrites).toBe(beforeUpstreamWrites + 1)
   })

+ 2 - 2
tests/server/realtimeMigration.test.ts

@@ -94,7 +94,7 @@ describe('HTTP+SSE migration', () => {
     const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-paired-ws-'))
     homes.push(home)
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800,
+      host: '127.0.0.1', port: 15300,
       upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
       allowedHosts: new Set(), home,
       mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'tester',
@@ -168,7 +168,7 @@ describe('HTTP+SSE migration', () => {
     homes.push(home)
     const config: ServerConfig = {
       host: '127.0.0.1',
-      port: 8800,
+      port: 15300,
       upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(),
       home,

+ 11 - 11
tests/server/security.test.ts

@@ -27,7 +27,7 @@ function isolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
 function config(overrides: Partial<ServerConfig> = {}): ServerConfig {
   return {
     host: '127.0.0.1',
-    port: 8800,
+    port: 15300,
     upstream: new URL('http://127.0.0.1:9119'),
     allowedHosts: new Set(),
     home: '/tmp/hermes-yaoyao-test',
@@ -41,15 +41,15 @@ function config(overrides: Partial<ServerConfig> = {}): ServerConfig {
 describe('server security boundary', () => {
   it('accepts loopback/private hosts and configured DNS only', () => {
     const value = config({ allowedHosts: new Set(['yaoyao.internal']) })
-    expect(isAllowedHostHeader('127.0.0.1:8800', value)).toBe(true)
-    expect(isAllowedHostHeader('[::1]:8800', value)).toBe(true)
-    expect(isAllowedHostHeader('192.168.10.8:8800', value)).toBe(true)
-    expect(isAllowedHostHeader('yaoyao.internal:8800', value)).toBe(true)
+    expect(isAllowedHostHeader('127.0.0.1:15300', value)).toBe(true)
+    expect(isAllowedHostHeader('[::1]:15300', value)).toBe(true)
+    expect(isAllowedHostHeader('192.168.10.8:15300', value)).toBe(true)
+    expect(isAllowedHostHeader('yaoyao.internal:15300', value)).toBe(true)
     expect(isAllowedHostHeader('127.0.0.1:9999', value)).toBe(false)
     expect(isAllowedHostHeader('127.0.0.1', value)).toBe(true)
     expect(isAllowedHostHeader('192.168.10.8:443', value)).toBe(true)
     expect(isAllowedHostHeader('yaoyao.internal', value)).toBe(true)
-    expect(isAllowedHostHeader('attacker.example:8800', value)).toBe(false)
+    expect(isAllowedHostHeader('attacker.example:15300', value)).toBe(false)
     expect(isAllowedHostHeader('127.0.0.1@attacker.example', value)).toBe(false)
   })
 
@@ -81,7 +81,7 @@ describe('server security boundary', () => {
 
   it('accepts configured IPv6 addresses and brackets them in WebSocket CSP origins', () => {
     const allowed = new Set(['2001:db8::10'])
-    expect(isAllowedHostHeader('[2001:db8::10]:8800', config({ allowedHosts: allowed }))).toBe(true)
+    expect(isAllowedHostHeader('[2001:db8::10]:15300', config({ allowedHosts: allowed }))).toBe(true)
     expect(isExactOrigin('https://[2001:db8::10]', '10.1.5.100', false, allowed)).toBe(true)
     const headers: Record<string, string> = {}
     const ctx = { host: '10.1.5.100', set(name: string, value: string) { headers[name] = value } } as unknown as Koa.Context
@@ -91,10 +91,10 @@ describe('server security boundary', () => {
   })
 
   it('requires an exact scheme and host Origin', () => {
-    expect(isExactOrigin('http://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)
-    expect(isExactOrigin('https://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)
-    expect(isExactOrigin('http://127.0.0.1:8801', '127.0.0.1:8800', false)).toBe(false)
-    expect(isExactOrigin('null', '127.0.0.1:8800', false)).toBe(false)
+    expect(isExactOrigin('http://127.0.0.1:15300', '127.0.0.1:15300', false)).toBe(true)
+    expect(isExactOrigin('https://127.0.0.1:15300', '127.0.0.1:15300', false)).toBe(true)
+    expect(isExactOrigin('http://127.0.0.1:8801', '127.0.0.1:15300', false)).toBe(false)
+    expect(isExactOrigin('null', '127.0.0.1:15300', false)).toBe(false)
   })
 
   it('uses a signed double-submit CSRF token', () => {

+ 6 - 6
tests/server/systemUpdateRoutes.test.ts

@@ -45,7 +45,7 @@ describe('system update routes', () => {
       schemaVersion: 1, releaseVersion: '0.2.0', webVersion: '0.2.0', gitTag: 'v0.2.0',
     }))
     const config: ServerConfig = {
-      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: join(root, 'media'),
       attachmentsRoot: join(root, 'attachments'), imagesRoot: join(root, 'images'), mediaOwner: 'tester',
       allowInsecureLan: false, insecureLan: false, production: false,
@@ -79,17 +79,17 @@ describe('system update routes', () => {
     const runtime = createApplication({ config, fetchImpl, updates })
     runtimes.push(runtime)
     const bootstrap = await request(runtime.app.callback())
-      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
     const cookie = cookieHeader(bootstrap)
     const mutation = (path: string) => request(runtime.app.callback())
       .post(path)
-      .set('Host', '127.0.0.1:8800')
-      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Host', '127.0.0.1:15300')
+      .set('Origin', 'http://127.0.0.1:15300')
       .set('Cookie', cookie)
       .set('X-CSRF-Token', bootstrap.body.csrfToken)
 
     const status = await request(runtime.app.callback())
-      .get('/api/app/system/update/status').set('Host', '127.0.0.1:8800').set('Cookie', cookie).expect(200)
+      .get('/api/app/system/update/status').set('Host', '127.0.0.1:15300').set('Cookie', cookie).expect(200)
     expect(status.body).toMatchObject({ updateAvailable: false })
     expect(status.body.installedPluginVersion).toBeUndefined()
 
@@ -103,7 +103,7 @@ describe('system update routes', () => {
 
     const jobResponse = await request(runtime.app.callback())
       .get(`/api/app/system/update/jobs/${queued.body.id}`)
-      .set('Host', '127.0.0.1:8800').set('Cookie', cookie)
+      .set('Host', '127.0.0.1:15300').set('Cookie', cookie)
       .expect(200)
     expect(jobResponse.body).toMatchObject({ id: queued.body.id, state: 'queued' })
   })

+ 1 - 1
tests/server/updateManager.test.ts

@@ -31,7 +31,7 @@ function fixture() {
   roots.push(root)
   writeFileSync(join(root, 'release.json'), JSON.stringify(current))
   const config: ServerConfig = {
-    host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+    host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
     allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: join(root, 'media'),
     attachmentsRoot: join(root, 'attachments'), imagesRoot: join(root, 'images'),
     mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,

+ 1 - 1
tests/server/upstream.test.ts

@@ -9,7 +9,7 @@ describe('upstream request boundary', () => {
     expect(fetchImpl).not.toHaveBeenCalled()
   })
 
-  it('derives proxy scheme from public 8800 TLS and forwards only the trusted peer IP', async () => {
+  it('derives proxy scheme from public 15300 TLS and forwards only the trusted peer IP', async () => {
     let headers = new Headers()
     const fetchImpl = vi.fn<typeof fetch>(async (_input, init) => {
       headers = new Headers(init?.headers)

+ 3 - 3
tests/server/workspaceRoutes.test.ts

@@ -40,8 +40,8 @@ class TestAuth extends LocalAuthStore {
 function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, user = 'first') {
   return request(runtime.app.callback())
     [method](path)
-    .set('Host', '127.0.0.1:8800')
-    .set('Origin', 'http://127.0.0.1:8800')
+    .set('Host', '127.0.0.1:15300')
+    .set('Origin', 'http://127.0.0.1:15300')
     .set('Cookie', cookie)
     .set('X-CSRF-Token', csrf)
     .set('x-test-user', user)
@@ -76,7 +76,7 @@ beforeEach(async () => {
   })
   const b = await request(runtime.app.callback())
     .get('/api/app/bootstrap')
-    .set('Host', '127.0.0.1:8800')
+    .set('Host', '127.0.0.1:15300')
     .expect(200)
   cookie = (b.headers['set-cookie'] as unknown as string[]).map((v) => v.split(';')[0]).join('; ')
   csrf = b.body.csrfToken

+ 8 - 0
tests/serviceCli.test.ts

@@ -15,6 +15,14 @@ describe('hermes-yaoyao LaunchAgent plist', () => {
     expect(launchAgentPlist()).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>')
   })
 
+  it('uses 15300 without injecting credentials into 9119 startup', () => {
+    const plist = launchAgentPlist({ environment: {} })
+
+    expect(plist).toContain('<key>HERMES_YAOYAO_PORT</key>\n      <string>15300</string>')
+    expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_USERNAME')
+    expect(plist).not.toContain('HERMES_YAOYAO_UPSTREAM_PASSWORD')
+  })
+
   it('binds managed Web to the trusted LAN and enables local Dashboard supervision by default', () => {
     const plist = launchAgentPlist({ environment: {} })
 

+ 2 - 2
tests/updater.test.ts

@@ -21,12 +21,12 @@ afterEach(() => {
 describe('standalone updater primitives', () => {
   it('accepts a healthy Web without probing 9119 or upstream-dependent readiness', async () => {
     const fetchImpl = vi.fn(async (url: string) => {
-      if (url !== 'http://127.0.0.1:8800/healthz') throw new Error('9119 offline')
+      if (url !== 'http://127.0.0.1:15300/healthz') throw new Error('9119 offline')
       return Response.json({ ok: true })
     })
     await verifyRuntime(1_000, fetchImpl)
     expect(fetchImpl).toHaveBeenCalledTimes(1)
-    expect(fetchImpl.mock.calls[0]?.[0]).toBe('http://127.0.0.1:8800/healthz')
+    expect(fetchImpl.mock.calls[0]?.[0]).toBe('http://127.0.0.1:15300/healthz')
   })
 
   it('still fails an unhealthy Web and retries transient startup failures', async () => {