From 927fafd3d7cf89331f9d8d9797dea280a6351791 Mon Sep 17 00:00:00 2001 From: TecFancy Date: Mon, 26 Oct 2026 20:26:55 +0800 Subject: [PATCH 1/3] feat: add first-run launch-token onboarding for unconfigured instances --- lib/bootstrap-wiring.d.ts | 54 ++++ lib/bootstrap-wiring.d.ts.map | 1 + lib/bootstrap-wiring.js | 183 ++++++++++++ lib/bootstrap-wiring.js.map | 1 + lib/client.js | 63 ++-- lib/client.js.map | 2 +- lib/host/entities/session/index.d.ts | 2 +- lib/host/entities/session/index.d.ts.map | 2 +- lib/host/entities/session/index.js | 2 +- lib/host/entities/session/index.js.map | 2 +- .../entities/session/model/session-store.d.ts | 15 +- .../session/model/session-store.d.ts.map | 2 +- .../entities/session/model/session-store.js | 25 +- .../session/model/session-store.js.map | 2 +- .../bootstrap/api/bootstrap-endpoint.d.ts | 28 ++ .../bootstrap/api/bootstrap-endpoint.d.ts.map | 1 + .../bootstrap/api/bootstrap-endpoint.js | 56 ++++ .../bootstrap/api/bootstrap-endpoint.js.map | 1 + .../bootstrap/api/continue-endpoint.d.ts | 22 ++ .../bootstrap/api/continue-endpoint.d.ts.map | 1 + .../bootstrap/api/continue-endpoint.js | 34 +++ .../bootstrap/api/continue-endpoint.js.map | 1 + .../bootstrap/api/setup-endpoint.d.ts | 48 ++++ .../bootstrap/api/setup-endpoint.d.ts.map | 1 + .../features/bootstrap/api/setup-endpoint.js | 183 ++++++++++++ .../bootstrap/api/setup-endpoint.js.map | 1 + lib/host/features/bootstrap/index.d.ts | 13 + lib/host/features/bootstrap/index.d.ts.map | 1 + lib/host/features/bootstrap/index.js | 9 + lib/host/features/bootstrap/index.js.map | 1 + lib/host/features/bootstrap/model/probe.d.ts | 32 +++ .../features/bootstrap/model/probe.d.ts.map | 1 + lib/host/features/bootstrap/model/probe.js | 40 +++ .../features/bootstrap/model/probe.js.map | 1 + .../features/bootstrap/model/respond.d.ts | 14 + .../features/bootstrap/model/respond.d.ts.map | 1 + lib/host/features/bootstrap/model/respond.js | 30 ++ .../features/bootstrap/model/respond.js.map | 1 + .../features/bootstrap/model/setup-page.d.ts | 20 ++ .../bootstrap/model/setup-page.d.ts.map | 1 + .../features/bootstrap/model/setup-page.js | 119 ++++++++ .../bootstrap/model/setup-page.js.map | 1 + .../password/model/password-gate.d.ts | 5 +- .../password/model/password-gate.d.ts.map | 2 +- .../features/password/model/password-gate.js | 17 +- .../password/model/password-gate.js.map | 2 +- lib/host/features/token/model/token-gate.d.ts | 25 +- .../features/token/model/token-gate.d.ts.map | 2 +- lib/host/features/token/model/token-gate.js | 58 ++-- .../features/token/model/token-gate.js.map | 2 +- lib/host/shared/crypto/index.d.ts | 6 + lib/host/shared/crypto/index.d.ts.map | 1 + lib/host/shared/crypto/index.js | 6 + lib/host/shared/crypto/index.js.map | 1 + lib/host/shared/crypto/safe-equal.d.ts | 9 + lib/host/shared/crypto/safe-equal.d.ts.map | 1 + lib/host/shared/crypto/safe-equal.js | 14 + lib/host/shared/crypto/safe-equal.js.map | 1 + lib/host/shared/http/bootstrap-hint.d.ts | 15 + lib/host/shared/http/bootstrap-hint.d.ts.map | 1 + lib/host/shared/http/bootstrap-hint.js | 29 ++ lib/host/shared/http/bootstrap-hint.js.map | 1 + lib/host/shared/http/index.d.ts | 1 + lib/host/shared/http/index.d.ts.map | 2 +- lib/host/shared/http/index.js | 1 + lib/host/shared/http/index.js.map | 2 +- lib/host/shared/http/origin.d.ts | 5 +- lib/host/shared/http/origin.d.ts.map | 2 +- lib/host/shared/http/origin.js.map | 2 +- lib/host/shared/login-page/index.d.ts | 5 +- lib/host/shared/login-page/index.d.ts.map | 2 +- lib/host/shared/login-page/index.js | 5 +- lib/host/shared/login-page/index.js.map | 2 +- lib/host/shared/protocol/index.d.ts | 2 +- lib/host/shared/protocol/index.d.ts.map | 2 +- lib/host/shared/protocol/index.js | 2 +- lib/host/shared/protocol/index.js.map | 2 +- lib/index.d.ts.map | 2 +- lib/index.js | 27 +- lib/index.js.map | 2 +- lib/launch-token-bridge.d.ts | 7 + lib/launch-token-bridge.d.ts.map | 2 +- lib/launch-token-bridge.js | 68 +++-- lib/launch-token-bridge.js.map | 2 +- lib/protocol/index.d.ts | 11 + lib/protocol/index.d.ts.map | 2 +- lib/protocol/index.js | 11 + lib/protocol/index.js.map | 2 +- lib/protocol/payloads.d.ts | 7 +- lib/protocol/payloads.d.ts.map | 2 +- src/bootstrap-wiring.test.ts | 207 +++++++++++++ src/bootstrap-wiring.ts | 253 ++++++++++++++++ src/host/entities/session/index.ts | 1 + .../session/model/session-store.test.ts | 48 +++- .../entities/session/model/session-store.ts | 27 +- .../bootstrap/api/bootstrap-endpoint.test.ts | 165 +++++++++++ .../bootstrap/api/bootstrap-endpoint.ts | 82 ++++++ .../bootstrap/api/continue-endpoint.test.ts | 131 +++++++++ .../bootstrap/api/continue-endpoint.ts | 53 ++++ .../api/setup-endpoint.configured.test.ts | 70 +++++ .../bootstrap/api/setup-endpoint.test.ts | 233 +++++++++++++++ .../features/bootstrap/api/setup-endpoint.ts | 271 ++++++++++++++++++ src/host/features/bootstrap/index.ts | 18 ++ .../bootstrap/model/probe.password.test.ts | 79 +++++ .../bootstrap/model/probe.token.test.ts | 58 ++++ src/host/features/bootstrap/model/probe.ts | 63 ++++ src/host/features/bootstrap/model/respond.ts | 40 +++ .../features/bootstrap/model/setup-page.ts | 154 ++++++++++ .../password/model/password-gate.test.ts | 55 ++++ .../features/password/model/password-gate.ts | 21 +- .../features/token/model/token-gate.test.ts | 64 +++++ src/host/features/token/model/token-gate.ts | 70 +++-- src/host/shared/crypto/index.ts | 5 + src/host/shared/crypto/safe-equal.test.ts | 20 ++ src/host/shared/crypto/safe-equal.ts | 14 + src/host/shared/http/bootstrap-hint.test.ts | 56 ++++ src/host/shared/http/bootstrap-hint.ts | 28 ++ src/host/shared/http/index.ts | 1 + src/host/shared/http/origin.ts | 5 +- src/host/shared/login-page/index.ts | 5 +- src/host/shared/protocol/index.ts | 4 + src/index.ts | 28 +- src/launch-token-bridge.test.ts | 89 ++++++ src/launch-token-bridge.ts | 84 ++++-- src/protocol/index.test.ts | 14 + src/protocol/index.ts | 15 + src/protocol/payloads.ts | 7 +- test/bootstrap-harness.ts | 171 +++++++++++ test/bootstrap-wiring-harness.ts | 132 +++++++++ test/integration/integration-auth-helpers.ts | 49 +++- .../integration-bootstrap-helpers.ts | 50 ++++ .../integration.bootstrap-window.test.ts | 130 +++++++++ .../integration/integration.bootstrap.test.ts | 244 ++++++++++++++++ 133 files changed, 4486 insertions(+), 226 deletions(-) create mode 100644 lib/bootstrap-wiring.d.ts create mode 100644 lib/bootstrap-wiring.d.ts.map create mode 100644 lib/bootstrap-wiring.js create mode 100644 lib/bootstrap-wiring.js.map create mode 100644 lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts create mode 100644 lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts.map create mode 100644 lib/host/features/bootstrap/api/bootstrap-endpoint.js create mode 100644 lib/host/features/bootstrap/api/bootstrap-endpoint.js.map create mode 100644 lib/host/features/bootstrap/api/continue-endpoint.d.ts create mode 100644 lib/host/features/bootstrap/api/continue-endpoint.d.ts.map create mode 100644 lib/host/features/bootstrap/api/continue-endpoint.js create mode 100644 lib/host/features/bootstrap/api/continue-endpoint.js.map create mode 100644 lib/host/features/bootstrap/api/setup-endpoint.d.ts create mode 100644 lib/host/features/bootstrap/api/setup-endpoint.d.ts.map create mode 100644 lib/host/features/bootstrap/api/setup-endpoint.js create mode 100644 lib/host/features/bootstrap/api/setup-endpoint.js.map create mode 100644 lib/host/features/bootstrap/index.d.ts create mode 100644 lib/host/features/bootstrap/index.d.ts.map create mode 100644 lib/host/features/bootstrap/index.js create mode 100644 lib/host/features/bootstrap/index.js.map create mode 100644 lib/host/features/bootstrap/model/probe.d.ts create mode 100644 lib/host/features/bootstrap/model/probe.d.ts.map create mode 100644 lib/host/features/bootstrap/model/probe.js create mode 100644 lib/host/features/bootstrap/model/probe.js.map create mode 100644 lib/host/features/bootstrap/model/respond.d.ts create mode 100644 lib/host/features/bootstrap/model/respond.d.ts.map create mode 100644 lib/host/features/bootstrap/model/respond.js create mode 100644 lib/host/features/bootstrap/model/respond.js.map create mode 100644 lib/host/features/bootstrap/model/setup-page.d.ts create mode 100644 lib/host/features/bootstrap/model/setup-page.d.ts.map create mode 100644 lib/host/features/bootstrap/model/setup-page.js create mode 100644 lib/host/features/bootstrap/model/setup-page.js.map create mode 100644 lib/host/shared/crypto/index.d.ts create mode 100644 lib/host/shared/crypto/index.d.ts.map create mode 100644 lib/host/shared/crypto/index.js create mode 100644 lib/host/shared/crypto/index.js.map create mode 100644 lib/host/shared/crypto/safe-equal.d.ts create mode 100644 lib/host/shared/crypto/safe-equal.d.ts.map create mode 100644 lib/host/shared/crypto/safe-equal.js create mode 100644 lib/host/shared/crypto/safe-equal.js.map create mode 100644 lib/host/shared/http/bootstrap-hint.d.ts create mode 100644 lib/host/shared/http/bootstrap-hint.d.ts.map create mode 100644 lib/host/shared/http/bootstrap-hint.js create mode 100644 lib/host/shared/http/bootstrap-hint.js.map create mode 100644 src/bootstrap-wiring.test.ts create mode 100644 src/bootstrap-wiring.ts create mode 100644 src/host/features/bootstrap/api/bootstrap-endpoint.test.ts create mode 100644 src/host/features/bootstrap/api/bootstrap-endpoint.ts create mode 100644 src/host/features/bootstrap/api/continue-endpoint.test.ts create mode 100644 src/host/features/bootstrap/api/continue-endpoint.ts create mode 100644 src/host/features/bootstrap/api/setup-endpoint.configured.test.ts create mode 100644 src/host/features/bootstrap/api/setup-endpoint.test.ts create mode 100644 src/host/features/bootstrap/api/setup-endpoint.ts create mode 100644 src/host/features/bootstrap/index.ts create mode 100644 src/host/features/bootstrap/model/probe.password.test.ts create mode 100644 src/host/features/bootstrap/model/probe.token.test.ts create mode 100644 src/host/features/bootstrap/model/probe.ts create mode 100644 src/host/features/bootstrap/model/respond.ts create mode 100644 src/host/features/bootstrap/model/setup-page.ts create mode 100644 src/host/shared/crypto/index.ts create mode 100644 src/host/shared/crypto/safe-equal.test.ts create mode 100644 src/host/shared/crypto/safe-equal.ts create mode 100644 src/host/shared/http/bootstrap-hint.test.ts create mode 100644 src/host/shared/http/bootstrap-hint.ts create mode 100644 src/launch-token-bridge.test.ts create mode 100644 test/bootstrap-harness.ts create mode 100644 test/bootstrap-wiring-harness.ts create mode 100644 test/integration/integration-bootstrap-helpers.ts create mode 100644 test/integration/integration.bootstrap-window.test.ts create mode 100644 test/integration/integration.bootstrap.test.ts diff --git a/lib/bootstrap-wiring.d.ts b/lib/bootstrap-wiring.d.ts new file mode 100644 index 0000000..ba97baa --- /dev/null +++ b/lib/bootstrap-wiring.d.ts @@ -0,0 +1,54 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { WrappableServer } from "./host/shared/guard/index.js"; +import type { LaunchTokenBridge } from "./host/shared/http/index.js"; +import { type SessionStore } from "./host/entities/session/index.js"; +import { type ConfiguredProbe } from "./host/features/bootstrap/index.js"; +/** 只看 bootstrap 需要的启动期配置(结构兼容 `AuthConfig`,根文件传整个 config 即可)。 */ +export interface BootstrapConfig { + mode: "token" | "password"; + cookieName: string; + cookieSecure: boolean; + sessionTtl: number; + tokenRef: string; + publicHost: string; +} +export interface BootstrapWiringInput { + ctx: Context; + server: WrappableServer; + config: BootstrapConfig; + /** 会话层(异步就绪,每次现取)。 */ + auth: { + sessions: SessionStore | undefined; + }; + usersPath: string; + launchTokenBridge: LaunchTokenBridge; + log: { + info(message: unknown): void; + warn(message: unknown): void; + error(message: unknown): void; + }; +} +export interface BootstrapWiring { + /** 注册三条路由(`/auth/bootstrap`、`/auth/setup`、`/auth/continue`),返回合并 disposer。 */ + mount: () => () => void; + probe: ConfiguredProbe; + /** 进程内「已配置」标志:向导写入成功后置位,探针立即改口,不等服务端缓存刷新。 */ + markedConfigured: { + value: boolean; + }; + /** 会话层就绪后的启动清扫:已配置实例上的残留 bootstrap 会话一律吊销。 */ + onSessionsReady: (store: SessionStore) => void; +} +/** + * 首次引导通道的 root 侧装配(与 `admin-wiring.ts` 同性质,从 `src/index.ts` 拆出)。 + * + * 三件事:把探针、三个端点与页面渲染收拢成 deps;把「生成并保存访问令牌 / 建管理员」这两个 + * 需要宿主能力的动作落成实现;注册路由并在会话层就绪时清掉残留的 bootstrap 会话。 + * **访问令牌绝不写日志**:只出现在提交结果页的 HTML 里(一次性展示)。 + * + * 未配置探针**不用** `makeTokenResolver`:那个解析器把「服务缺失 / 解析抛错」都吞成 + * `undefined`,会让「凭据服务尚未就绪」误判成「实例未配置」,于是在已配置实例上开引导窗口、 + * 甚至让向导覆盖掉已存在的 DSH_AUTH_TOKEN。这里用严格读取器,失败一律上抛成 `unknown`。 + */ +export declare function makeBootstrapWiring(input: BootstrapWiringInput): BootstrapWiring; +//# sourceMappingURL=bootstrap-wiring.d.ts.map \ No newline at end of file diff --git a/lib/bootstrap-wiring.d.ts.map b/lib/bootstrap-wiring.d.ts.map new file mode 100644 index 0000000..322e4b2 --- /dev/null +++ b/lib/bootstrap-wiring.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"bootstrap-wiring.d.ts","sourceRoot":"","sources":["../src/bootstrap-wiring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAQrE,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAGrF,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,oCAAoC,CAAC;AAY5C,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,OAAO,CAAC;IACb,MAAM,EAAE,eAAe,CAAC;IACxB,MAAM,EAAE,eAAe,CAAC;IACxB,sBAAsB;IACtB,IAAI,EAAE;QAAE,QAAQ,EAAE,YAAY,GAAG,SAAS,CAAA;KAAE,CAAC;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,iBAAiB,CAAC;IACrC,GAAG,EAAE;QACH,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;QAC7B,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;QAC7B,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;KAC/B,CAAC;CACH;AAED,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,KAAK,EAAE,MAAM,MAAM,IAAI,CAAC;IACxB,KAAK,EAAE,eAAe,CAAC;IACvB,6CAA6C;IAC7C,gBAAgB,EAAE;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC;IACrC,8CAA8C;IAC9C,eAAe,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;CAChD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,oBAAoB,GAAG,eAAe,CAyEhF"} \ No newline at end of file diff --git a/lib/bootstrap-wiring.js b/lib/bootstrap-wiring.js new file mode 100644 index 0000000..fa28866 --- /dev/null +++ b/lib/bootstrap-wiring.js @@ -0,0 +1,183 @@ +import { randomBytes } from "node:crypto"; +import { methodNotAllowed } from "./host/shared/http/index.js"; +import { BOOTSTRAP_PATH, BOOTSTRAP_SESSION_KIND, CONTINUE_PATH, SETUP_PATH, } from "./host/shared/protocol/index.js"; +import { buildSetCookie } from "./host/entities/session/index.js"; +import { checkPasswordPolicy, mutateUsersFile, USERNAME_RE } from "./host/entities/user/index.js"; +import { hashPassword } from "./host/features/password/index.js"; +import { makeBootstrapEndpoint, makeConfiguredProbe, makeContinueEndpoint, makeSetupHandlers, } from "./host/features/bootstrap/index.js"; +import { readLaunchToken } from "./launch-token-bridge.js"; +/** 与 `Config` 的 `tokenRef` 模式一致:credential-ref 是 POSIX 环境变量名。 */ +const CREDENTIAL_REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** + * 首次引导通道的 root 侧装配(与 `admin-wiring.ts` 同性质,从 `src/index.ts` 拆出)。 + * + * 三件事:把探针、三个端点与页面渲染收拢成 deps;把「生成并保存访问令牌 / 建管理员」这两个 + * 需要宿主能力的动作落成实现;注册路由并在会话层就绪时清掉残留的 bootstrap 会话。 + * **访问令牌绝不写日志**:只出现在提交结果页的 HTML 里(一次性展示)。 + * + * 未配置探针**不用** `makeTokenResolver`:那个解析器把「服务缺失 / 解析抛错」都吞成 + * `undefined`,会让「凭据服务尚未就绪」误判成「实例未配置」,于是在已配置实例上开引导窗口、 + * 甚至让向导覆盖掉已存在的 DSH_AUTH_TOKEN。这里用严格读取器,失败一律上抛成 `unknown`。 + */ +export function makeBootstrapWiring(input) { + const markedConfigured = { value: false }; + const markConfigured = () => { + markedConfigured.value = true; + }; + const probe = makeConfiguredProbe({ + mode: input.config.mode, + resolveToken: input.config.mode === "token" + ? makeStrictTokenReader(input.ctx, input.config.tokenRef) + : undefined, + usersPath: input.usersPath, + markedConfigured, + }); + const setup = makeSetupHandlers({ + mode: input.config.mode, + probe, + sessions: () => input.auth.sessions, + cookieName: input.config.cookieName, + cookieSecure: input.config.cookieSecure, + sessionTtlSeconds: input.config.sessionTtl, + publicHost: input.config.publicHost, + tokenRef: input.config.tokenRef, + usersPath: input.usersPath, + saveToken: () => saveToken(input, markConfigured), + createAdmin: (username, password) => createAdmin(input, markConfigured, username, password), + issueFullSession: (subject, res) => issueFullSession(input, subject, res), + logger: input.log, + }); + const bootstrap = makeBootstrapEndpoint({ + probe, + readLaunchToken: () => readLaunchToken(input.ctx), + sessions: () => input.auth.sessions, + cookieName: input.config.cookieName, + cookieSecure: input.config.cookieSecure, + sessionTtlSeconds: input.config.sessionTtl, + logger: input.log, + }); + const continueHandler = makeContinueEndpoint({ + sessions: () => input.auth.sessions, + cookieName: input.config.cookieName, + bridge: input.launchTokenBridge, + logger: input.log, + }); + const setupRoute = (req, res) => { + if (req.method === "GET") + return setup.get(req, res); + if (req.method === "POST") + return setup.post(req, res); + methodNotAllowed(res, "GET, POST"); + }; + return { + probe, + markedConfigured, + mount() { + const disposers = [ + input.server.register({ kind: "exact", path: BOOTSTRAP_PATH, handler: bootstrap }), + input.server.register({ kind: "exact", path: SETUP_PATH, handler: setupRoute }), + input.server.register({ kind: "exact", path: CONTINUE_PATH, handler: continueHandler }), + ]; + return () => { + for (const dispose of [...disposers].reverse()) + dispose(); + }; + }, + onSessionsReady: (store) => { + void probe + .state() + .then(async (state) => { + if (state !== "configured") + return; + const revoked = await store.revokeByKind(BOOTSTRAP_SESSION_KIND); + if (revoked > 0) + input.log.info(`stale bootstrap sessions revoked: ${revoked}`); + }) + .catch(() => input.log.warn("bootstrap session sweep failed")); + }, + }; +} +/** 生成并保存访问令牌:写盘 → 读回比对 → 关窗。读不回新值也算保存成功(只是要重启)。 */ +async function saveToken(input, markConfigured) { + try { + const credentials = credentialsOf(input.ctx); + if (credentials === undefined || typeof credentials.set !== "function") { + return { status: "failed" }; + } + if (!CREDENTIAL_REF_RE.test(input.config.tokenRef)) + return { status: "failed" }; + const token = randomBytes(32).toString("base64url"); + await credentials.set(input.config.tokenRef, token); + const readable = await readBack(credentials, input.config.tokenRef, token); + markConfigured(); + return readable ? { status: "saved", token } : { status: "saved-unreadable", token }; + } + catch { + return { status: "failed" }; + } +} +/** + * 严格读取共享 token(只给未配置探针用):每次现取凭据服务,**服务缺失或 resolve 抛错一律 + * 上抛**,由探针映射成 `unknown`(不引导)。只有「解析成功且值为空」才返回 `undefined` + * (= 真的未配置)。与 `makeTokenResolver` 的差别就在这里:那边的吞错是给门的 fail-closed + * 用的,不能拿来判断「有没有配置」。 + */ +function makeStrictTokenReader(ctx, tokenRef) { + return async () => { + const credentials = credentialsOf(ctx); + if (credentials === undefined) + throw new Error("credentials service is unavailable"); + const resolved = await credentials.resolve(tokenRef); + return resolved?.value; + }; +} +/** 凭据服务(惰性现取:harness 并行挂载行,服务可能晚于本行就绪)。 */ +function credentialsOf(ctx) { + return ctx.get("credentials"); +} +/** 读回刚写的值;读失败或服务有缓存 → false(调用方据此提示「重启后生效」)。 */ +async function readBack(credentials, ref, token) { + try { + const resolved = await credentials.resolve(ref); + return resolved?.value === token; + } + catch { + return false; + } +} +/** 建第一个管理员:用户名 → 口令策略 → scrypt 哈希 → 锁内写 users.yaml。 */ +async function createAdmin(input, markConfigured, username, password) { + try { + if (!USERNAME_RE.test(username)) + return "invalid"; + if (!(await checkPasswordPolicy(password)).ok) + return "invalid"; + const passwordHash = await hashPassword(password); + const outcome = await mutateUsersFile(input.usersPath, (snapshot) => { + if (snapshot.users.has(username)) + return "conflict"; + snapshot.users.set(username, { passwordHash, disabled: false, role: "admin" }); + return "ok"; + }); + if (outcome === "ok") + markConfigured(); + return outcome; + } + catch { + return "failed"; + } +} +/** 向导完成的正式会话:与登录成功同形(cookie 属性、TTL 一致),失败只记日志不放宽。 */ +async function issueFullSession(input, subject, res) { + const store = input.auth.sessions; + if (store === undefined) { + input.log.error("setup completed but the session store is unavailable"); + return; + } + const ttl = input.config.sessionTtl; + const issued = await store.create(subject, ttl * 1000, "full"); + res.setHeader("cache-control", "no-store"); + res.setHeader("referrer-policy", "no-referrer"); + res.setHeader("set-cookie", buildSetCookie(input.config.cookieName, issued.token, ttl, input.config.cookieSecure)); +} +//# sourceMappingURL=bootstrap-wiring.js.map \ No newline at end of file diff --git a/lib/bootstrap-wiring.js.map b/lib/bootstrap-wiring.js.map new file mode 100644 index 0000000..dbc7a71 --- /dev/null +++ b/lib/bootstrap-wiring.js.map @@ -0,0 +1 @@ +{"version":3,"file":"bootstrap-wiring.js","sourceRoot":"","sources":["../src/bootstrap-wiring.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/client.js b/lib/client.js index cfccce3..9439d1a 100644 --- a/lib/client.js +++ b/lib/client.js @@ -6,7 +6,7 @@ window.__ModuleLoader__.load({ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); let react = require("react"); let react_jsx_runtime = require("react/jsx-runtime"); - //#region src/client/shared/i18n/account-copy.ts + // src/client/shared/i18n/account-copy.ts /** 本面板用到的词典键(集中常量,避免散落字符串)。 */ const ACCOUNT_KEYS = { nav: "account", @@ -144,8 +144,7 @@ window.__ModuleLoader__.load({ [ACCOUNT_KEYS.ruleSpecial]: "a special character (not a letter or digit)", [ACCOUNT_KEYS.ruleSameAsOld]: "different from the current password" }; - //#endregion - //#region src/client/shared/ui/account-icon.ts + // src/client/shared/ui/account-icon.ts /** * 自设计图标(P1.1 / D24):「盾 + 钥匙孔」,16px outline,规格对齐宿主图标 * (`fill:none` / `stroke:currentColor` / `stroke-width:1.5` / 圆角线端)。 @@ -162,8 +161,7 @@ window.__ModuleLoader__.load({ const ACCOUNT_ICON_VIEW_BOX = "0 0 16 16"; /** 图标路径(两个 `d`:盾牌外框 + 钥匙孔圆与柄)。 */ const ACCOUNT_ICON_PATHS = ["M8 1.7 13.1 3.55V7.9c0 3.05-2.08 5.42-5.1 6.4-3.02-.98-5.1-3.35-5.1-6.4V3.55Z", "M9.3 7a1.3 1.3 0 1 1-2.6 0 1.3 1.3 0 1 1 2.6 0M8 8.3v2.1"]; - //#endregion - //#region src/client/shared/ui/account-styles.ts + // src/client/shared/ui/account-styles.ts /** * account 面板样式:颜色只取宿主设计 token(`--dsw-*`),不硬编码色值, * 跟随主题/明暗切换。几何(间距/圆角/字号)照 logout-action.tsx 现状自绘。 @@ -302,8 +300,7 @@ window.__ModuleLoader__.load({ fontSize: 12, lineHeight: "18px" }; - //#endregion - //#region src/client/features/nav-icon/ui/account-nav-icon.ts + // src/client/features/nav-icon/ui/account-nav-icon.ts /** * 「账号安全」导航行图标:临时 DOM 垫片(D24.1)。 * @@ -458,8 +455,7 @@ window.__ModuleLoader__.load({ restoreRows(doc); }; } - //#endregion - //#region src/client/shared/api/form.ts + // src/client/shared/api/form.ts /** * 把一个请求体对象编码成 `application/x-www-form-urlencoded`。调用方先把对象标注成 * `src/protocol/` 里的请求体类型(`PasswordChangeRequest` / `AdminResetRequest`),键名就受契约 @@ -470,8 +466,7 @@ window.__ModuleLoader__.load({ for (const [name, value] of Object.entries(fields)) if (typeof value === "string") body.set(name, value); return body; } - //#endregion - //#region src/protocol/index.ts + // src/protocol/index.ts /** 登录页(GET 渲染、POST 提交)。 */ const LOGIN_PATH = "/auth/login"; /** 登出(POST-only,`next` 只从 query 取)。 */ @@ -488,8 +483,7 @@ window.__ModuleLoader__.load({ const PASSWORD_CHANGED_LOGIN_URL = `${LOGIN_PATH}?next=%2F¬ice=password-changed`; /** 设置页「退出登录」按钮的提交目标(登出后回首页)。 */ const LOGOUT_URL = `${LOGOUT_PATH}?next=/`; - //#endregion - //#region src/client/features/change-password/lib/account-redirect.ts + // src/client/features/change-password/lib/account-redirect.ts /** * 改密成功后的去向(P1.1 / D24;D24.1 起改为**立即**跳转)。 * @@ -517,8 +511,7 @@ window.__ModuleLoader__.load({ function defaultReplace(url) { window.location.replace(url); } - //#endregion - //#region src/client/features/change-password/ui/account-fields.tsx + // src/client/features/change-password/ui/account-fields.tsx /** 字段/节点 id 前缀(一个面板只有一个实例,静态 id 即可)。 */ const PREFIX$1 = "dsh-auth-gate-account"; /** 状态区 id:aria-live 播报位,也是错误字段 aria-describedby 的落点(成功态复用)。 */ @@ -623,8 +616,7 @@ window.__ModuleLoader__.load({ })] }); } - //#endregion - //#region src/client/features/change-password/model/account-submit.ts + // src/client/features/change-password/model/account-submit.ts /** 自助改密端点(契约 §1:POST + application/x-www-form-urlencoded)。 */ const PASSWORD_TARGET = PASSWORD_PATH; const EMPTY_VALUES = { @@ -731,8 +723,7 @@ window.__ModuleLoader__.load({ failure: describeFailure(res.status, body, t) }; } - //#endregion - //#region src/client/features/change-password/ui/account-form.tsx + // src/client/features/change-password/ui/account-form.tsx /** * 本插件自设计的图标(P1.1 / D24):盾 + 钥匙孔,16px outline。 * @@ -918,8 +909,7 @@ window.__ModuleLoader__.load({ ] }); } - //#endregion - //#region src/client/features/admin-users/lib/admin-copy.ts + // src/client/features/admin-users/lib/admin-copy.ts /** 管理块词典键(集中常量,避免散落字符串)。 */ const ADMIN_KEYS = { title: "admin.title", @@ -1055,8 +1045,7 @@ window.__ModuleLoader__.load({ [ADMIN_KEYS.lockedPlain]: "Too many attempts. Try again later.", [ADMIN_KEYS.generic]: "Could not reset the password. Please try again later." }; - //#endregion - //#region src/client/features/admin-users/lib/admin-failure.ts + // src/client/features/admin-users/lib/admin-failure.ts /** * 管理重置的失败映射(CONTRACT-pr2 §1.3 + §9/A10)。从 `admin-api.ts` 预拆出来(A12), * 避免单文件贴上限。**全函数**:只认下表的 (status, error) 字面量组合, @@ -1115,8 +1104,7 @@ window.__ModuleLoader__.load({ if (status === 429) return describeLocked(error, record.retryAfter, t); return view(t(ADMIN_KEYS.generic)); } - //#endregion - //#region src/client/features/admin-users/api/admin-api.ts + // src/client/features/admin-users/api/admin-api.ts const USERS_TARGET = USERS_PATH; const RESET_TARGET = USERS_PASSWORD_PATH; /** 对象守卫:键按契约形状 `T` 读(读错键编译不过),值仍是未校验的 `unknown`。 */ @@ -1261,8 +1249,7 @@ window.__ModuleLoader__.load({ }; } } - //#endregion - //#region src/client/features/admin-users/ui/admin-fields.tsx + // src/client/features/admin-users/ui/admin-fields.tsx /** * 管理块的共享展示件(lead 批的 A12 预拆):id 常量 + 输入行 + 状态播报位。 * 只被 `admin-form.tsx` / `admin-block.tsx` **单向** import;本文件不 import 它们(避免循环)。 @@ -1336,8 +1323,7 @@ window.__ModuleLoader__.load({ children: success.text }) : null] }); } - //#endregion - //#region src/client/features/admin-users/model/admin-reset.ts + // src/client/features/admin-users/model/admin-reset.ts /** 表单初值:提交成功后必须清回这一份(契约 §3.8)。 */ const EMPTY_ADMIN_RESET = { target: "", @@ -1345,8 +1331,7 @@ window.__ModuleLoader__.load({ confirm: "", code: "" }; - //#endregion - //#region src/client/features/admin-users/ui/admin-form.tsx + // src/client/features/admin-users/ui/admin-form.tsx /** * 表单状态机(与自助面同范式): * - 双锁 = in-flight ref(硬锁,Enter/点击并发都挡)+ submitting 按钮 disabled(可见锁); @@ -1538,8 +1523,7 @@ window.__ModuleLoader__.load({ ] }); } - //#endregion - //#region src/client/widgets/account-section/ui/admin-block.tsx + // src/client/widgets/account-section/ui/admin-block.tsx const TITLE_ID = "dsh-auth-gate-admin-title"; /** * 挂载后拉一次列表(契约 §3.4:非 admin 根本不会挂载本组件,这里不判断角色); @@ -1692,8 +1676,7 @@ window.__ModuleLoader__.load({ ] }); } - //#endregion - //#region src/client/entities/account/api/account-status.ts + // src/client/entities/account/api/account-status.ts /** 会话探针(只认 cookie,语义同 `/auth/status`,契约 §1.1)。 */ const STATUS_TARGET = STATUS_PATH; /** 字符串字段守卫:非字符串一律 `undefined`(旧服务端/异常形状都不渲染管理块)。 */ @@ -1751,8 +1734,7 @@ window.__ModuleLoader__.load({ }, []); return status; } - //#endregion - //#region src/client/widgets/account-section/ui/account-section.tsx + // src/client/widgets/account-section/ui/account-section.tsx /** 提示态 id:loading / 未登录共用一个 aria-live 播报位。 */ const NOTICE_ID = "dsh-auth-gate-account-notice"; /** 无表单的提示态(loading / 请先登录),保留 aria-live 播报。 */ @@ -1796,8 +1778,7 @@ window.__ModuleLoader__.load({ actorTotpEnabled: status.totpEnabled !== false }) : null] }); } - //#endregion - //#region src/client/features/logout/ui/logout-action.tsx + // src/client/features/logout/ui/logout-action.tsx /** 登出目标:POST-only(M22:next 仅从 query 取,校验回落 /)。 */ const LOGOUT_TARGET = LOGOUT_URL; /** @@ -1905,8 +1886,7 @@ window.__ModuleLoader__.load({ }) }); } - //#endregion - //#region src/client/index.tsx + // src/client/index.tsx /** 本插件文案的词典命名域(locale 服务按 (ns, locale) 分开注册)。 */ const AUTH_NS = "auth"; /** 命名词典里登出键。 */ @@ -1995,7 +1975,6 @@ window.__ModuleLoader__.load({ }, SettingsAccountSection)); ctx.effect(() => installAccountNavIcon(globalThis.document), "auth: account nav icon shim (D24.1)"); } - //#endregion exports.apply = apply; exports.inject = inject; return module.exports; diff --git a/lib/client.js.map b/lib/client.js.map index dc99e48..21bad79 100644 --- a/lib/client.js.map +++ b/lib/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","names":["PREFIX","isError","useState","useRef","isRecord","useState","useRef","useState","useState","useState"],"sources":["../src/client/shared/i18n/account-copy.ts","../src/client/shared/ui/account-icon.ts","../src/client/shared/ui/account-styles.ts","../src/client/features/nav-icon/ui/account-nav-icon.ts","../src/client/shared/api/form.ts","../src/protocol/index.ts","../src/client/features/change-password/lib/account-redirect.ts","../src/client/features/change-password/ui/account-fields.tsx","../src/client/features/change-password/model/account-submit.ts","../src/client/features/change-password/ui/account-form.tsx","../src/client/features/admin-users/lib/admin-copy.ts","../src/client/features/admin-users/lib/admin-failure.ts","../src/client/features/admin-users/api/admin-api.ts","../src/client/features/admin-users/ui/admin-fields.tsx","../src/client/features/admin-users/model/admin-reset.ts","../src/client/features/admin-users/ui/admin-form.tsx","../src/client/widgets/account-section/ui/admin-block.tsx","../src/client/entities/account/api/account-status.ts","../src/client/widgets/account-section/ui/account-section.tsx","../src/client/features/logout/ui/logout-action.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * `auth` 词典的 account 分片:键名集中常量 + zh/en 两套文案。\n *\n * `index.tsx` 把两套词典注册进 `auth` 命名域(与 logout 同一 ns),\n * `account-section.tsx` 只通过槽位注入的 `t` 读取本文件的键,不自己拼字符串。\n * 策略文案里的 14/256 字符镜像契约 §2(`PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`):\n * client bundle 不 import 服务端 shared 模块,半边保持解耦。\n */\nimport type { PasswordRule } from \"../api/index.ts\";\n\n/** 槽位注入的标准 `t` seat(host locale translate 的形)。 */\nexport type AccountTranslate = (key: string, params?: Record) => string;\n\n/** 本面板用到的词典键(集中常量,避免散落字符串)。 */\nexport const ACCOUNT_KEYS = {\n nav: \"account\",\n title: \"account.title\",\n intro: \"account.intro\",\n /** 作用域说明(P1.1):0.1.7 起宿主有官方「账户」段,这里要讲清\"管的是本地登录凭据\"。 */\n scope: \"account.scope\",\n loading: \"account.loading\",\n loginRequired: \"account.loginRequired\",\n current: \"account.current\",\n password: \"account.password\",\n confirm: \"account.confirm\",\n code: \"account.code\",\n codeHint: \"account.codeHint\",\n policyHint: \"account.policyHint\",\n submit: \"account.submit\",\n submitting: \"account.submitting\",\n success: \"account.success\",\n /**\n * 成功态按钮(P1.1 / D24):不再是「关闭」:服务端此刻已吊销全部会话,关掉弹窗只会\n * 停在「死会话 SPA」上;改为直接去登录页。\n */\n relogin: \"account.relogin\",\n currentRequired: \"account.currentRequired\",\n passwordRequired: \"account.passwordRequired\",\n mismatch: \"account.mismatch\",\n invalidCredentials: \"account.invalidCredentials\",\n invalidTotp: \"account.invalidTotp\",\n policyIntro: \"account.policyIntro\",\n locked: \"account.locked\",\n lockedPlain: \"account.lockedPlain\",\n unavailable: \"account.unavailable\",\n generic: \"account.generic\",\n ruleMinLength: \"account.rule.minLength\",\n ruleMaxLength: \"account.rule.maxLength\",\n ruleUppercase: \"account.rule.uppercase\",\n ruleLowercase: \"account.rule.lowercase\",\n ruleDigit: \"account.rule.digit\",\n ruleSpecial: \"account.rule.special\",\n ruleSameAsOld: \"account.rule.sameAsOld\",\n} as const;\n\n/** 契约 §2 策略规则名 → 词典键(`satisfies` 保证与契约的 `PasswordRule` 一一对应)。 */\nconst RULE_KEYS: Record = {\n minLength: ACCOUNT_KEYS.ruleMinLength,\n maxLength: ACCOUNT_KEYS.ruleMaxLength,\n uppercase: ACCOUNT_KEYS.ruleUppercase,\n lowercase: ACCOUNT_KEYS.ruleLowercase,\n digit: ACCOUNT_KEYS.ruleDigit,\n special: ACCOUNT_KEYS.ruleSpecial,\n sameAsOld: ACCOUNT_KEYS.ruleSameAsOld,\n} satisfies Record;\n\n/** 规则名翻译;未知规则原样回显(不发明契约之外的文案)。 */\nexport function ruleText(rule: string, t: AccountTranslate): string {\n const key = RULE_KEYS[rule];\n return key === undefined ? rule : t(key);\n}\n\n/** host locale 的插值规则(`{name}`;缺参数时保留占位符不吞字)。 */\nexport function formatCopy(template: string, params?: Record): string {\n if (params === undefined) return template;\n return template.replace(/\\{(\\w+)\\}/g, (match, name: string) =>\n name in params ? String(params[name]) : match,\n );\n}\n\n/** 用一套词典兜底成 translate(槽位未注入 `t` 时降级为英文,不显示键名)。 */\nexport function translateFrom(dict: Record): AccountTranslate {\n return (key, params) => formatCopy(dict[key] ?? key, params);\n}\n\n/** 中文词典。 */\nexport const ACCOUNT_DICT_ZH: Record = {\n [ACCOUNT_KEYS.nav]: \"账号安全\",\n [ACCOUNT_KEYS.title]: \"修改密码\",\n [ACCOUNT_KEYS.intro]: \"改密成功后,当前设备也会被登出,需要用新密码重新登录。\",\n [ACCOUNT_KEYS.scope]: \"这里是本实例的本地登录凭据,与 DeepSeek 云账户无关。\",\n [ACCOUNT_KEYS.loading]: \"正在确认登录状态...\",\n [ACCOUNT_KEYS.loginRequired]: \"请先登录后再修改密码。\",\n [ACCOUNT_KEYS.current]: \"当前密码\",\n [ACCOUNT_KEYS.password]: \"新密码\",\n [ACCOUNT_KEYS.confirm]: \"确认新密码\",\n [ACCOUNT_KEYS.code]: \"动态验证码\",\n [ACCOUNT_KEYS.codeHint]: \"已开启两步验证时填写 6 位动态码,否则留空。\",\n [ACCOUNT_KEYS.policyHint]:\n \"新密码至少 14 个字符,且包含大写字母、小写字母、数字和特殊字符,不能与当前密码相同。\",\n [ACCOUNT_KEYS.submit]: \"修改密码\",\n [ACCOUNT_KEYS.submitting]: \"提交中...\",\n [ACCOUNT_KEYS.success]: \"密码已改,请重新登录。当前设备也已登出。\",\n [ACCOUNT_KEYS.relogin]: \"重新登录\",\n [ACCOUNT_KEYS.currentRequired]: \"请输入当前密码。\",\n [ACCOUNT_KEYS.passwordRequired]: \"请输入新密码。\",\n [ACCOUNT_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ACCOUNT_KEYS.invalidCredentials]: \"当前密码不正确。\",\n [ACCOUNT_KEYS.invalidTotp]: \"动态验证码不正确,或该验证码已被使用,请等待下一枚验证码后重试。\",\n [ACCOUNT_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ACCOUNT_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ACCOUNT_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ACCOUNT_KEYS.unavailable]: \"服务暂时不可用,请稍后重试。\",\n [ACCOUNT_KEYS.generic]: \"修改失败,请稍后重试。\",\n [ACCOUNT_KEYS.ruleMinLength]: \"至少 14 个字符\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"不超过 256 个字符\",\n [ACCOUNT_KEYS.ruleUppercase]: \"包含大写字母\",\n [ACCOUNT_KEYS.ruleLowercase]: \"包含小写字母\",\n [ACCOUNT_KEYS.ruleDigit]: \"包含数字\",\n [ACCOUNT_KEYS.ruleSpecial]: \"包含特殊字符(非字母数字)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"不能与当前密码相同\",\n};\n\n/** 英文词典。 */\nexport const ACCOUNT_DICT_EN: Record = {\n [ACCOUNT_KEYS.nav]: \"Account security\",\n [ACCOUNT_KEYS.title]: \"Change password\",\n [ACCOUNT_KEYS.intro]:\n \"After the change, this device is signed out too. Sign in again with your new password.\",\n [ACCOUNT_KEYS.scope]:\n \"These are this instance's local sign-in credentials, not your DeepSeek account.\",\n [ACCOUNT_KEYS.loading]: \"Checking your session...\",\n [ACCOUNT_KEYS.loginRequired]: \"Sign in first to change your password.\",\n [ACCOUNT_KEYS.current]: \"Current password\",\n [ACCOUNT_KEYS.password]: \"New password\",\n [ACCOUNT_KEYS.confirm]: \"Confirm new password\",\n [ACCOUNT_KEYS.code]: \"Verification code\",\n [ACCOUNT_KEYS.codeHint]:\n \"Enter the 6-digit code when two-factor authentication is on, otherwise leave it empty.\",\n [ACCOUNT_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character; it must differ from the current password.\",\n [ACCOUNT_KEYS.submit]: \"Change password\",\n [ACCOUNT_KEYS.submitting]: \"Submitting...\",\n [ACCOUNT_KEYS.success]:\n \"Password changed. Please sign in again. This device has been signed out too.\",\n [ACCOUNT_KEYS.relogin]: \"Sign in again\",\n [ACCOUNT_KEYS.currentRequired]: \"Enter your current password.\",\n [ACCOUNT_KEYS.passwordRequired]: \"Enter a new password.\",\n [ACCOUNT_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ACCOUNT_KEYS.invalidCredentials]: \"The current password is incorrect.\",\n [ACCOUNT_KEYS.invalidTotp]:\n \"The verification code is incorrect or already used; wait for the next code and try again.\",\n [ACCOUNT_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ACCOUNT_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ACCOUNT_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ACCOUNT_KEYS.unavailable]: \"The service is temporarily unavailable. Please try again later.\",\n [ACCOUNT_KEYS.generic]: \"Could not change the password. Please try again later.\",\n [ACCOUNT_KEYS.ruleMinLength]: \"at least 14 characters\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"at most 256 characters\",\n [ACCOUNT_KEYS.ruleUppercase]: \"an uppercase letter\",\n [ACCOUNT_KEYS.ruleLowercase]: \"a lowercase letter\",\n [ACCOUNT_KEYS.ruleDigit]: \"a digit\",\n [ACCOUNT_KEYS.ruleSpecial]: \"a special character (not a letter or digit)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"different from the current password\",\n};\n","/**\n * 自设计图标(P1.1 / D24):「盾 + 钥匙孔」,16px outline,规格对齐宿主图标\n * (`fill:none` / `stroke:currentColor` / `stroke-width:1.5` / 圆角线端)。\n *\n * 为什么自设计:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `dsh-client-ui-settings-general` 的 `navIcon(id)` 硬编码 if 链决定(0.1.5-rc.2 与\n * 0.1.7-alpha.2 都如此,且没有 `icon` 选项)。插件侧没有官方挂载点,也**不去改宿主的\n * nav DOM**(与宿主 React 抢树,评审已否掉),所以图标只画在我们自己的内容区标题行\n * (见 `account-form.tsx` 的 AccountIcon)。\n *\n * 路径数据与 `docs/demo/account-security.svg` 同源,由 `account-icon.test.ts` 逐条比对;\n * 改一处必须同步另一处。\n */\nexport const ACCOUNT_ICON_VIEW_BOX = \"0 0 16 16\";\n\n/** 图标路径(两个 `d`:盾牌外框 + 钥匙孔圆与柄)。 */\nexport const ACCOUNT_ICON_PATHS: readonly string[] = [\n \"M8 1.7 13.1 3.55V7.9c0 3.05-2.08 5.42-5.1 6.4-3.02-.98-5.1-3.35-5.1-6.4V3.55Z\",\n \"M9.3 7a1.3 1.3 0 1 1-2.6 0 1.3 1.3 0 1 1 2.6 0M8 8.3v2.1\",\n];\n","import type { CSSProperties } from \"react\";\n\n/**\n * account 面板样式:颜色只取宿主设计 token(`--dsw-*`),不硬编码色值,\n * 跟随主题/明暗切换。几何(间距/圆角/字号)照 logout-action.tsx 现状自绘。\n */\n\n/** 面板根部:纵向排布;`minHeight: 0` 让本页在设置内容列里参与收缩,列内可滚动。 */\nexport const PANEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n minHeight: 0,\n padding: \"4px 0 8px\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"var(--dsw-font-family)\",\n};\n\nexport const TITLE_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 16,\n fontWeight: 600,\n lineHeight: \"24px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\n/** 标题行(P1.1):自带图标 + 文案横排,图标随文字色(currentColor)跟随主题。 */\nexport const TITLE_ROW_STYLE: CSSProperties = {\n ...TITLE_STYLE,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n};\n\nexport const HINT_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const FORM_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n maxWidth: 420,\n};\n\nexport const LABEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 6,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-secondary)\",\n};\n\n/** 输入框基线。 */\nconst INPUT_BASE: CSSProperties = {\n boxSizing: \"border-box\",\n width: \"100%\",\n padding: \"8px 12px\",\n borderRadius: 8,\n border: \"1px solid var(--dsw-alias-border-l2)\",\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n lineHeight: \"22px\",\n};\n\nexport const INPUT_STYLE: CSSProperties = INPUT_BASE;\n\n/** 校验失败字段(契约 §1 的 401/400 分支)加错误描边。 */\nexport const INVALID_INPUT_STYLE: CSSProperties = {\n ...INPUT_BASE,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n};\n\nexport const STATUS_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 4,\n minHeight: 20,\n fontSize: 13,\n lineHeight: \"20px\",\n};\n\nexport const ERROR_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const SUCCESS_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-state-success-primary)\",\n};\n\nexport const RULES_STYLE: CSSProperties = {\n margin: 0,\n paddingLeft: 18,\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const BUTTON_STYLE: CSSProperties = {\n alignSelf: \"flex-start\",\n padding: \"8px 20px\",\n borderRadius: 10,\n border: \"1px solid var(--dsw-alias-button-primary-fill)\",\n background: \"var(--dsw-alias-button-primary-fill)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** 提交中(双锁的可见态):置灰但不移除,避免布局跳动。 */\nexport const BUTTON_BUSY_STYLE: CSSProperties = {\n ...BUTTON_STYLE,\n opacity: 0.6,\n cursor: \"default\",\n};\n\n// ---------------------------------------------------------------------------\n// P2 PR2 管理块(admin):以下全部为**追加**常量,上面既有值一个都不动\n// (自助改密页像素稳定性,契约 §2 account-styles 行)。\n// ---------------------------------------------------------------------------\n\n/** 管理块根部:与上方自助改密区之间用一条分隔线 + 上间距,纵向排布。 */\nexport const ADMIN_BLOCK_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n marginTop: 4,\n paddingTop: 12,\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 用户表:占满宽度、折叠边框;只做排版,不自绘视觉。 */\nexport const ADMIN_TABLE_STYLE: CSSProperties = {\n width: \"100%\",\n borderCollapse: \"collapse\",\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\nexport const ADMIN_TH_STYLE: CSSProperties = {\n textAlign: \"left\",\n padding: \"6px 8px 6px 0\",\n fontWeight: 500,\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const ADMIN_TD_STYLE: CSSProperties = {\n padding: \"6px 8px 6px 0\",\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 只读徽标(角色 / 状态 / 两步验证 / 本人):浅底 + 次级文字色,随主题切换。 */\nexport const ADMIN_BADGE_STYLE: CSSProperties = {\n display: \"inline-block\",\n padding: \"0 6px\",\n borderRadius: 6,\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-secondary)\",\n fontSize: 12,\n lineHeight: \"18px\",\n};\n","/**\n * 「账号安全」导航行图标:临时 DOM 垫片(D24.1)。\n *\n * **为什么需要它**:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `@deepseek-ai/dsh-client-ui-settings-general` 里硬编码的 `navIcon(id)` if 链给出\n * (0.1.5-rc.2 只认 models / agent-presets / plugins,0.1.7-alpha.2 多认官方 `account`),\n * 第三方段一律落到默认齿轮。插件侧没有图标挂载点,所以这里只对我们自己那一行做一次\n * 视觉替换:把宿主齿轮 `display:none`,在它前面插进我们自设计的盾牌 SVG。\n *\n * **不闪**:观察器在插件 `apply()` 时就装好,远早于设置浮层出现;宿主 React 提交 DOM 后,\n * MutationObserver 回调作为**微任务**在同一个任务里排队,而浏览器绘制发生在微任务清空之后。\n * 因此\"宿主图标进入 DOM / 被重写内联样式\"这件事总是在画出那一帧之前被修正为\"藏起来 + 我们的\n * 图标在场\",无论它发生在浮层整体插入的同一次提交、还是后来的某一次提交(后到的那次插入本身\n * 也会触发回调)。隔离实例里用 rAF 逐帧探针(每帧记录行内图标状态)验过:出现行的第一帧即已是\n * 盾牌,0 帧出现齿轮。\n *\n * **只碰我们这一行**:判定条件是「`\n \n );\n }\n const isInvalid = (field: FieldName): boolean => form.failure?.fields.includes(field) === true;\n return (\n
\n

\n \n {t(ACCOUNT_KEYS.title)}\n

\n

{t(ACCOUNT_KEYS.intro)}

\n

{t(ACCOUNT_KEYS.scope)}

\n {\n form.composing.current = true;\n }}\n onCompositionEnd={() => {\n form.composing.current = false;\n }}\n >\n \n \n \n {form.submitting ? t(ACCOUNT_KEYS.submitting) : t(ACCOUNT_KEYS.submit)}\n \n \n
\n );\n}\n","import { formatCopy, type AccountTranslate } from \"../../../shared/i18n/index.ts\";\n\n/**\n * 管理块(PR2)的 `auth` 词典分片:键名集中常量 + zh/en 两套文案。\n *\n * 与 account 分片同命名域(`index.tsx` 把两套词典合并注册),管理块只通过注入的 `t` 读键。\n * 键与文案对齐 CONTRACT-pr2 §4 全表 + §9/A4 的 2 个新增键(冻结键集 = 42);\n * 策略规则名复用 `account-copy.ts` 的 `ruleText`,本文件不新造规则文案。\n */\n\n/** 管理块 translate:与自助改密同形(宿主只注入一个 `t` seat)。 */\nexport type AdminTranslate = AccountTranslate;\n\n/** 管理块词典键(集中常量,避免散落字符串)。 */\nexport const ADMIN_KEYS = {\n title: \"admin.title\",\n intro: \"admin.intro\",\n selfHint: \"admin.selfHint\",\n loading: \"admin.loading\",\n unavailable: \"admin.unavailable\",\n empty: \"admin.empty\",\n colUser: \"admin.colUser\",\n colRole: \"admin.colRole\",\n colState: \"admin.colState\",\n colTotp: \"admin.colTotp\",\n roleAdmin: \"admin.roleAdmin\",\n roleUser: \"admin.roleUser\",\n you: \"admin.you\",\n stateDisabled: \"admin.stateDisabled\",\n stateMustChange: \"admin.stateMustChange\",\n stateOk: \"admin.stateOk\",\n totpOn: \"admin.totpOn\",\n totpOff: \"admin.totpOff\",\n target: \"admin.target\",\n targetPlaceholder: \"admin.targetPlaceholder\",\n password: \"admin.password\",\n confirm: \"admin.confirm\",\n code: \"admin.code\",\n codeHint: \"admin.codeHint\",\n policyHint: \"admin.policyHint\",\n submit: \"admin.submit\",\n submitting: \"admin.submitting\",\n successRevoked: \"admin.successRevoked\",\n successKept: \"admin.successKept\",\n targetRequired: \"admin.targetRequired\",\n passwordRequired: \"admin.passwordRequired\",\n mismatch: \"admin.mismatch\",\n codeRequired: \"admin.codeRequired\",\n forbidden: \"admin.forbidden\",\n badTarget: \"admin.badTarget\",\n notFound: \"admin.notFound\",\n policyIntro: \"admin.policyIntro\",\n invalidTotp: \"admin.invalidTotp\",\n unauthorized: \"admin.unauthorized\",\n locked: \"admin.locked\",\n lockedPlain: \"admin.lockedPlain\",\n generic: \"admin.generic\",\n} as const;\n\n/** 中文词典(键序与 §4 表一致,A4 的 2 键插在语义位置)。 */\nexport const ADMIN_DICT_ZH: Record = {\n [ADMIN_KEYS.title]: \"用户管理\",\n [ADMIN_KEYS.intro]: \"重置后,该用户下次登录会被要求立即改密;会话吊销若失败会单独提示。\",\n [ADMIN_KEYS.selfHint]:\n \"修改自己的密码请用上方表单。忘记自己的口令走 CLI `dsh-auth user passwd`(管理面不允许重置自己)。\",\n [ADMIN_KEYS.loading]: \"正在加载用户列表...\",\n [ADMIN_KEYS.unavailable]: \"用户列表暂时不可用,请稍后重试。\",\n [ADMIN_KEYS.empty]: \"没有可重置的其他用户。\",\n [ADMIN_KEYS.colUser]: \"用户\",\n [ADMIN_KEYS.colRole]: \"角色\",\n [ADMIN_KEYS.colState]: \"状态\",\n [ADMIN_KEYS.colTotp]: \"两步验证\",\n [ADMIN_KEYS.roleAdmin]: \"管理员\",\n [ADMIN_KEYS.roleUser]: \"用户\",\n [ADMIN_KEYS.you]: \"本人\",\n [ADMIN_KEYS.stateDisabled]: \"已禁用\",\n [ADMIN_KEYS.stateMustChange]: \"需改密\",\n [ADMIN_KEYS.stateOk]: \"正常\",\n [ADMIN_KEYS.totpOn]: \"已开启\",\n [ADMIN_KEYS.totpOff]: \"未开启\",\n [ADMIN_KEYS.target]: \"目标用户\",\n [ADMIN_KEYS.targetPlaceholder]: \"请选择用户\",\n [ADMIN_KEYS.password]: \"新密码\",\n [ADMIN_KEYS.confirm]: \"确认新密码\",\n [ADMIN_KEYS.code]: \"动态验证码\",\n [ADMIN_KEYS.codeHint]: \"你已开启两步验证,重置他人密码需填写动态码。\",\n [ADMIN_KEYS.policyHint]: \"新密码至少 14 个字符,且含大写字母、小写字母、数字和特殊字符。\",\n [ADMIN_KEYS.submit]: \"重置密码\",\n [ADMIN_KEYS.submitting]: \"提交中...\",\n [ADMIN_KEYS.successRevoked]: \"已重置,该用户的会话已全部吊销。\",\n [ADMIN_KEYS.successKept]: \"已重置,但该用户的现有会话仍然有效(吊销失败),请手动处理。\",\n [ADMIN_KEYS.targetRequired]: \"请选择目标用户。\",\n [ADMIN_KEYS.passwordRequired]: \"请输入新密码。\",\n [ADMIN_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ADMIN_KEYS.codeRequired]: \"请输入动态验证码。\",\n [ADMIN_KEYS.forbidden]: \"没有权限执行此操作,或当前会话不允许。\",\n [ADMIN_KEYS.badTarget]: \"目标用户名不合法。\",\n [ADMIN_KEYS.notFound]: \"目标用户不存在。\",\n [ADMIN_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ADMIN_KEYS.invalidTotp]: \"动态验证码不正确,或已被使用。\",\n [ADMIN_KEYS.unauthorized]: \"登录状态已失效,请重新登录。\",\n [ADMIN_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ADMIN_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ADMIN_KEYS.generic]: \"重置失败,请稍后重试。\",\n};\n\n/** 英文词典(键序与中文一致)。 */\nexport const ADMIN_DICT_EN: Record = {\n [ADMIN_KEYS.title]: \"User management\",\n [ADMIN_KEYS.intro]:\n \"After a reset the user must change the password at the next sign-in; if revocation fails, that is reported separately.\",\n [ADMIN_KEYS.selfHint]:\n \"Use the form above to change your own password. If you forgot it, use the CLI `dsh-auth user passwd` (this panel cannot reset your own account).\",\n [ADMIN_KEYS.loading]: \"Loading users...\",\n [ADMIN_KEYS.unavailable]: \"The user list is temporarily unavailable. Please try again later.\",\n [ADMIN_KEYS.empty]: \"There are no other users to reset.\",\n [ADMIN_KEYS.colUser]: \"User\",\n [ADMIN_KEYS.colRole]: \"Role\",\n [ADMIN_KEYS.colState]: \"State\",\n [ADMIN_KEYS.colTotp]: \"Two-factor\",\n [ADMIN_KEYS.roleAdmin]: \"Admin\",\n [ADMIN_KEYS.roleUser]: \"User\",\n [ADMIN_KEYS.you]: \"You\",\n [ADMIN_KEYS.stateDisabled]: \"Disabled\",\n [ADMIN_KEYS.stateMustChange]: \"Must change\",\n [ADMIN_KEYS.stateOk]: \"Normal\",\n [ADMIN_KEYS.totpOn]: \"On\",\n [ADMIN_KEYS.totpOff]: \"Off\",\n [ADMIN_KEYS.target]: \"Target user\",\n [ADMIN_KEYS.targetPlaceholder]: \"Choose a user\",\n [ADMIN_KEYS.password]: \"New password\",\n [ADMIN_KEYS.confirm]: \"Confirm new password\",\n [ADMIN_KEYS.code]: \"Verification code\",\n [ADMIN_KEYS.codeHint]:\n \"Two-factor is on for your account, so resetting another user needs a code.\",\n [ADMIN_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character.\",\n [ADMIN_KEYS.submit]: \"Reset password\",\n [ADMIN_KEYS.submitting]: \"Submitting...\",\n [ADMIN_KEYS.successRevoked]: \"Reset done. All of that user's sessions were revoked.\",\n [ADMIN_KEYS.successKept]:\n \"Reset done, but that user's existing sessions are still valid (revocation failed); handle them manually.\",\n [ADMIN_KEYS.targetRequired]: \"Choose a target user.\",\n [ADMIN_KEYS.passwordRequired]: \"Enter a new password.\",\n [ADMIN_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ADMIN_KEYS.codeRequired]: \"Enter the verification code.\",\n [ADMIN_KEYS.forbidden]: \"You are not allowed to do this, or this session is not allowed to.\",\n [ADMIN_KEYS.badTarget]: \"The target user name is invalid.\",\n [ADMIN_KEYS.notFound]: \"The target user does not exist.\",\n [ADMIN_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ADMIN_KEYS.invalidTotp]: \"The verification code is incorrect or already used.\",\n [ADMIN_KEYS.unauthorized]: \"Your session has expired. Please sign in again.\",\n [ADMIN_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ADMIN_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ADMIN_KEYS.generic]: \"Could not reset the password. Please try again later.\",\n};\n\n/**\n * 用一套词典兜底成 translate:缺键先回落英文词典,两边都缺时回空串,\n * **绝不把键名当文案显示**(§4 表注)。\n */\nexport function translateAdminFrom(dict: Record): AdminTranslate {\n return (key, params) => formatCopy(dict[key] ?? ADMIN_DICT_EN[key] ?? \"\", params);\n}\n","import type { AdminResetErrorBody, Unchecked } from \"../../../shared/api/index.ts\";\nimport { ruleText } from \"../../../shared/i18n/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"./admin-copy.ts\";\nimport type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\n\n/**\n * 管理重置的失败映射(CONTRACT-pr2 §1.3 + §9/A10)。从 `admin-api.ts` 预拆出来(A12),\n * 避免单文件贴上限。**全函数**:只认下表的 (status, error) 字面量组合,\n * 其余一律 `admin.generic`,不自造错误码、不做前缀/子串匹配。\n *\n * 已核对 PR1:`self`(自助误走管理面)也回 `{error:\"forbidden\"}`,没有 `{error:\"self\"}`。\n */\n\nfunction view(message: string, rules: string[] = [], fields: AdminField[] = []): AdminFailureView {\n return { message, rules, fields };\n}\n\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 错误码比较:码的字面量受契约(D31)约束,写错码编译不过;值本身仍是未校验的 `unknown`。 */\nfunction isError(value: unknown, code: AdminResetErrorBody[\"error\"]): boolean {\n return value === code;\n}\n\n/** 规则列表:只保留字符串项,未知规则名原样回显(复用 account 的 `ruleText`)。 */\nfunction ruleList(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((rule): rule is string => typeof rule === \"string\")\n : [];\n}\n\n/** 400:`bad_target` / `policy`(携带 rules);其余(含缺 error)→ generic。 */\nfunction describeBadRequest(error: unknown, rules: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"policy\")) {\n return view(\n t(ADMIN_KEYS.policyIntro),\n ruleList(rules).map((rule) => ruleText(rule, t)),\n [\"password\"],\n );\n }\n if (isError(error, \"bad_target\")) return view(t(ADMIN_KEYS.badTarget), [], [\"target\"]);\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 401:`invalid_totp` / `unauthorized`;其余 → generic。 */\nfunction describeUnauthorized(error: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"invalid_totp\")) return view(t(ADMIN_KEYS.invalidTotp), [], [\"code\"]);\n if (isError(error, \"unauthorized\")) return view(t(ADMIN_KEYS.unauthorized));\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 429:`locked` + 数字 retryAfter 插值,缺数字用无参文案;其余 → generic。 */\nfunction describeLocked(error: unknown, retryAfter: unknown, t: AdminTranslate): AdminFailureView {\n if (!isError(error, \"locked\")) return view(t(ADMIN_KEYS.generic));\n return typeof retryAfter === \"number\"\n ? view(t(ADMIN_KEYS.locked, { seconds: retryAfter }))\n : view(t(ADMIN_KEYS.lockedPlain));\n}\n\n/**\n * 状态码矩阵 → 文案键:\n * 400 bad_target / policy+rules、401 invalid_totp / unauthorized、403 forbidden、\n * 404 not_found、429 locked(+retryAfter)、其余(413/415/503 的 text/plain 与所有未列出组合,\n * 含缺 `error` 的 403/404/429)一律 generic。\n */\nexport function describeAdminFailure(\n status: number,\n body: unknown,\n t: AdminTranslate,\n): AdminFailureView {\n const record = isRecord(body) ? body : {};\n const error = record.error;\n if (status === 400) return describeBadRequest(error, record.rules, t);\n if (status === 401) return describeUnauthorized(error, t);\n if (status === 403 && isError(error, \"forbidden\")) return view(t(ADMIN_KEYS.forbidden));\n if (status === 404 && isError(error, \"not_found\")) {\n return view(t(ADMIN_KEYS.notFound), [], [\"target\"]);\n }\n if (status === 429) return describeLocked(error, record.retryAfter, t);\n return view(t(ADMIN_KEYS.generic));\n}\n","import {\n encodeForm,\n USERS_PASSWORD_PATH,\n USERS_PATH,\n type AdminResetErrorBody,\n type AdminResetRequest,\n type AdminResetResponse,\n type AdminUserRow,\n type Unchecked,\n type UsersResponse,\n} from \"../../../shared/api/index.ts\";\nimport { describeAdminFailure } from \"../lib/admin-failure.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport type {\n AdminFailureView,\n AdminResetResult,\n AdminResetValues,\n AdminUsersResult,\n} from \"../model/admin-reset.ts\";\n\n/**\n * 管理面客户端 API(CONTRACT-pr2 §1.2 / §1.3 + §9):列表读取 + 重置提交。\n *\n * 线上形状来自跨半边契约(D31,经 `shared/api`),表单与结果类型在 `model/admin-reset.ts`;失败映射在 `admin-failure.ts`(A12 预拆,此处按\n * 冻结面原样再导出)。不手写 `Origin`(同源 POST 由浏览器自动携带)、不做 `Accept` 子串匹配、\n * 不重排服务端已排好的顺序。\n */\n\nexport { describeAdminFailure };\n\nconst USERS_TARGET = USERS_PATH;\nconst RESET_TARGET = USERS_PASSWORD_PATH;\n\n/** 对象守卫:键按契约形状 `T` 读(读错键编译不过),值仍是未校验的 `unknown`。 */\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 单行形状校验:任一必需字段类型不符即丢弃该行(不整表失败,契约 §1.2)。 */\nfunction asRow(value: unknown): AdminUserRow | undefined {\n if (!isRecord(value)) return undefined;\n const { name, role, disabled, totpEnabled, mustChangePassword } = value;\n if (typeof name !== \"string\" || name === \"\") return undefined;\n if (role !== \"admin\" && role !== \"user\") return undefined;\n if (typeof disabled !== \"boolean\" || typeof totpEnabled !== \"boolean\") return undefined;\n if (typeof mustChangePassword !== \"boolean\") return undefined;\n return { name, role, disabled, totpEnabled, mustChangePassword };\n}\n\n/** 解析 200 body;`users` 不是数组视为形状非法(整表 failure)。 */\nfunction parseUsers(body: unknown): AdminUserRow[] | undefined {\n if (!isRecord(body)) return undefined;\n const raw = body.users;\n if (!Array.isArray(raw)) return undefined;\n // 保持服务端顺序:只过滤,不排序。\n return raw.map(asRow).filter((row): row is AdminUserRow => row !== undefined);\n}\n\n/** 重置端点的全部 JSON 形状(成功体 + 错误体),按契约读键。 */\ntype ResetBody = AdminResetResponse | AdminResetErrorBody;\n\n/** 读 JSON;非对象或解析失败统一回空体(映射只看状态码)。 */\nasync function readJsonSafe(res: Response): Promise> {\n try {\n const body: unknown = await res.json();\n return isRecord(body) ? body : {};\n } catch {\n return {};\n }\n}\n\n/**\n * `GET /auth/users`(cookie only,无 Origin 要求)。\n * 403 → `denied`(静默降级);401 → `unauthorized`(整页登录态已死,必须提示,§9/A2);\n * 非 200 / 形状非法 / 网络抛错 → `failure`;中止 → `aborted`(abort 后不解析响应体)。\n */\nexport async function fetchAdminUsers(signal: AbortSignal): Promise {\n try {\n const res = await fetch(USERS_TARGET, { method: \"GET\", signal, credentials: \"same-origin\" });\n if (signal.aborted) return { kind: \"aborted\" };\n if (res.status === 403) return { kind: \"denied\" };\n if (res.status === 401) return { kind: \"unauthorized\" };\n if (!res.ok) return { kind: \"failure\" };\n const body: unknown = await res.json();\n if (signal.aborted) return { kind: \"aborted\" };\n const users = parseUsers(body);\n return users === undefined ? { kind: \"failure\" } : { kind: \"ok\", users };\n } catch {\n return signal.aborted ? { kind: \"aborted\" } : { kind: \"failure\" };\n }\n}\n\n/**\n * 本地校验闭表(§9/A6):target → password → confirm → code。\n * `code` 只在 `actorTotpEnabled === true` 时校验,且用 `trim()` 判空(口令字段不 trim)。\n * 第三个参数缺省 false,只传两个实参的调用方行为不变。\n */\nexport function validateAdminReset(\n values: AdminResetValues,\n t: AdminTranslate,\n actorTotpEnabled = false,\n): AdminFailureView | null {\n if (values.target === \"\") {\n return { message: t(ADMIN_KEYS.targetRequired), rules: [], fields: [\"target\"] };\n }\n if (values.password === \"\") {\n return { message: t(ADMIN_KEYS.passwordRequired), rules: [], fields: [\"password\"] };\n }\n if (values.password !== values.confirm) {\n return { message: t(ADMIN_KEYS.mismatch), rules: [], fields: [\"password\", \"confirm\"] };\n }\n if (actorTotpEnabled && values.code.trim() === \"\") {\n return { message: t(ADMIN_KEYS.codeRequired), rules: [], fields: [\"code\"] };\n }\n return null;\n}\n\n/**\n * 提交请求体(§9/A3):只发 `target` / `password`,`actorTotpEnabled === true` 时才带 `code`\n * (缺码场景不发空串);**`confirm` 永不发送**。\n */\nfunction postAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n): Promise {\n const request: AdminResetRequest = {\n target: values.target,\n password: values.password,\n ...(options.actorTotpEnabled ? { code: values.code } : {}),\n };\n return fetch(RESET_TARGET, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: encodeForm(request),\n signal: options.signal,\n credentials: \"same-origin\",\n });\n}\n\n/**\n * `POST /auth/users/password`:\n * **成功只认契约 §1.3 的唯一形状** `200 {ok:true}`(grok 实现期必修 1):2xx 但缺 `ok:true`\n * (空 JSON、`{ok:false}`、中间层 HTML 拦截页)一律走失败映射,宁可报错也不谎报\"已重置\"\n * 再清空现场口令。`sessionsRevoked` 缺失/非布尔/解析失败按 `false`(§9/A8+A10);\n * 中止 → `aborted`。\n */\nexport async function submitAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n t: AdminTranslate,\n): Promise {\n try {\n const res = await postAdminReset(values, options);\n if (options.signal.aborted) return { kind: \"aborted\" };\n const body = await readJsonSafe(res);\n if (options.signal.aborted) return { kind: \"aborted\" };\n if (res.status === 200 && body.ok === true) {\n return { kind: \"ok\", sessionsRevoked: body.sessionsRevoked === true };\n }\n return { kind: \"failure\", failure: describeAdminFailure(res.status, body, t) };\n } catch {\n if (options.signal.aborted) return { kind: \"aborted\" };\n return {\n kind: \"failure\",\n failure: { message: t(ADMIN_KEYS.generic), rules: [], fields: [] },\n };\n }\n}\n","import type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\nimport {\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n RULES_STYLE,\n STATUS_STYLE,\n SUCCESS_TEXT_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 管理块的共享展示件(lead 批的 A12 预拆):id 常量 + 输入行 + 状态播报位。\n * 只被 `admin-form.tsx` / `admin-block.tsx` **单向** import;本文件不 import 它们(避免循环)。\n */\n\n/** id 前缀冻结(契约 §3.10):一个面板只有一个管理块实例,静态 id 即可。 */\nconst PREFIX = \"dsh-auth-gate-admin\";\n\n/** 状态播报位:aria-live 落点,也是错误字段 aria-describedby 的目标。 */\nexport const ADMIN_STATUS_ID = `${PREFIX}-status`;\nexport const ADMIN_CODE_HINT_ID = `${PREFIX}-code-hint`;\n\nexport function adminFieldId(field: AdminField): string {\n return `${PREFIX}-${field}`;\n}\n\n/** 成功视图(A8):`warning` = `sessionsRevoked:false`,是安全失败,走 alert + 错误色。 */\nexport interface AdminSuccessView {\n text: string;\n warning: boolean;\n}\n\n/** 带 label / 错误描边 / aria 关联的输入行(与自助面同范式,id 换管理块前缀)。 */\nexport function AdminTextField({\n field,\n label,\n value,\n invalid,\n onChange,\n hint,\n hintId,\n}: {\n field: AdminField;\n label: string;\n value: string;\n invalid: boolean;\n onChange: (value: string) => void;\n hint?: string | undefined;\n hintId?: string | undefined;\n}) {\n const described = [invalid ? ADMIN_STATUS_ID : undefined, hintId].filter(\n (item) => item !== undefined,\n );\n const id = adminFieldId(field);\n // 字段语义固定:新口令两框 `new-password`,动态码 `one-time-code` + 数字键盘。\n const isCode = field === \"code\";\n return (\n
\n \n onChange(event.target.value)}\n />\n {hint === undefined ? null : (\n \n {hint}\n \n )}\n
\n );\n}\n\n/** 状态区:失败消息 + 已翻译规则列表 + 就地成功/警告文案(契约 §3.8 / A8)。 */\nexport function AdminStatus({\n failure,\n success,\n}: {\n failure: AdminFailureView | null;\n success: AdminSuccessView | null;\n}) {\n const warning = success?.warning === true;\n return (\n <>\n
\n {failure === null ? null : {failure.message}}\n {failure === null || failure.rules.length === 0 ? null : (\n
    \n {failure.rules.map((rule) => (\n
  • {rule}
  • \n ))}\n
\n )}\n {success === null || warning ? null : (\n {success.text}\n )}\n
\n {warning && success !== null ? (\n

\n {success.text}\n

\n ) : null}\n \n );\n}\n","import type { AdminUserRow } from \"../../../shared/api/index.ts\";\n\n/**\n * 管理重置表单与结果的 client 侧类型(契约 §2)。只放类型与常量,不放逻辑。\n *\n * 线上形状(列表行、请求体、响应体、错误体)在跨半边契约 `src/protocol/`(D31),经\n * `shared/api` 进来;这里只剩表单状态和解析后的结果,它们不上线,host 不认识。\n */\n\n/** 管理重置表单字段(`confirm` 只在客户端比对,不进请求体,契约 §1.3)。 */\nexport type AdminField = \"target\" | \"password\" | \"confirm\" | \"code\";\n\nexport type AdminResetValues = Record;\n\n/** 表单初值:提交成功后必须清回这一份(契约 §3.8)。 */\nexport const EMPTY_ADMIN_RESET: AdminResetValues = {\n target: \"\",\n password: \"\",\n confirm: \"\",\n code: \"\",\n};\n\n/** 失败视图:已翻译消息 + 逐条策略文案 + 需标红的字段。 */\nexport interface AdminFailureView {\n message: string;\n rules: string[];\n fields: AdminField[];\n}\n\n/**\n * 列表结果(评审修正 A2):\n * - 403 `forbidden` → `denied`:这张 cookie 不是 admin / 不是 full 会话 → **静默**不渲染管理块;\n * - 401 `unauthorized` → `unauthorized`:整页登录态已死 → 必须提示 `admin.unauthorized`,\n * 不能静默消失(否则上方的自助改密表单会继续按过期 status 显示\"已登录\");\n * - 503 / 网络 / 解析失败 → `failure`(提示 `admin.unavailable`);\n * - 卸载中止 → `aborted`(回调里必须原样收手)。\n */\nexport type AdminUsersResult =\n | { kind: \"ok\"; users: AdminUserRow[] }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"aborted\" };\n\n/** 重置结果:`sessionsRevoked:false` 仍算成功(服务端写盘已成功,契约 §1.3)。 */\nexport type AdminResetResult =\n | { kind: \"ok\"; sessionsRevoked: boolean }\n | { kind: \"failure\"; failure: AdminFailureView }\n | { kind: \"aborted\" };\n","import {\n useEffect,\n useRef,\n useState,\n type FormEvent,\n type KeyboardEvent,\n type MutableRefObject,\n} from \"react\";\nimport { submitAdminReset, validateAdminReset } from \"../api/admin-api.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport {\n AdminStatus,\n AdminTextField,\n ADMIN_CODE_HINT_ID,\n ADMIN_STATUS_ID,\n adminFieldId,\n type AdminSuccessView,\n} from \"./admin-fields.tsx\";\nimport {\n EMPTY_ADMIN_RESET,\n type AdminFailureView,\n type AdminField,\n type AdminResetValues,\n} from \"../model/admin-reset.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n BUTTON_BUSY_STYLE,\n BUTTON_STYLE,\n FORM_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 表单状态机(与自助面同范式):\n * - 双锁 = in-flight ref(硬锁,Enter/点击并发都挡)+ submitting 按钮 disabled(可见锁);\n * - IME 组合期由 composing ref 挡住,避免中文输入法确认键提交;\n * - 请求带 AbortController,卸载即 abort,回调再看 mounted,绝不卸载后 setState;\n * - 成功:落成功文案(`sessionsRevoked:false` 走 A8 的安全失败样式),清空四字段,再重拉列表。\n */\n/** 表单事件:IME 组合期(Enter 确认键)一律不提交;并发双锁由 `submit` 自己兜底。 */\nfunction formHandlers(\n composing: MutableRefObject,\n submit: () => Promise,\n): {\n onSubmit: (event: FormEvent) => void;\n onKeyDown: (event: KeyboardEvent) => void;\n onCompositionStart: () => void;\n onCompositionEnd: () => void;\n} {\n return {\n onCompositionStart: () => {\n composing.current = true;\n },\n onCompositionEnd: () => {\n composing.current = false;\n },\n onSubmit: (event) => {\n event.preventDefault();\n if (composing.current) return;\n void submit();\n },\n onKeyDown: (event) => {\n if (event.key !== \"Enter\") return;\n // 焦点在下拉里时 Enter 是\"确认选项\":既不 preventDefault 也不提交(grok 回顾 #5)。\n if (event.target instanceof HTMLSelectElement) return;\n event.preventDefault();\n if (composing.current || event.nativeEvent.isComposing) return;\n void submit();\n },\n };\n}\n\n/** 成功视图(A8):`sessionsRevoked:false` 是安全失败,文案与样式都换一套。 */\nfunction successView(sessionsRevoked: boolean, t: AdminTranslate): AdminSuccessView {\n return sessionsRevoked\n ? { text: t(ADMIN_KEYS.successRevoked), warning: false }\n : { text: t(ADMIN_KEYS.successKept), warning: true };\n}\n\nfunction useAdminReset(\n t: AdminTranslate,\n actorTotpEnabled: boolean,\n onReset: (() => void) | undefined,\n) {\n const [values, setValues] = useState(EMPTY_ADMIN_RESET);\n const [submitting, setSubmitting] = useState(false);\n const [failure, setFailure] = useState(null);\n const [success, setSuccess] = useState(null);\n const lock = useRef(false);\n const composing = useRef(false);\n const abort = useRef(null);\n const mounted = useRef(true);\n\n useEffect(() => {\n // StrictMode 双挂载:effect 会「跑 → 清理 → 再跑」,所以挂载时必须**重新置 true**\n // (只写清理会把 mounted 永久钉在 false → 提交结果全丢、按钮卡在\"提交中\")。\n mounted.current = true;\n return () => {\n mounted.current = false;\n abort.current?.abort();\n };\n }, []);\n\n const setValue = (field: AdminField, value: string): void => {\n setValues((previous) => ({ ...previous, [field]: value }));\n };\n\n const submit = async (): Promise => {\n if (lock.current) return;\n const invalid = validateAdminReset(values, t, actorTotpEnabled);\n if (invalid !== null) {\n setFailure(invalid);\n return;\n }\n lock.current = true;\n setSubmitting(true);\n setFailure(null);\n setSuccess(null);\n const controller = new AbortController();\n abort.current = controller;\n const live = (): boolean => mounted.current && !controller.signal.aborted;\n try {\n const result = await submitAdminReset(\n values,\n { signal: controller.signal, actorTotpEnabled },\n t,\n );\n if (!live()) return;\n if (result.kind === \"ok\") {\n setSuccess(successView(result.sessionsRevoked, t));\n setValues({ ...EMPTY_ADMIN_RESET });\n onReset?.(); // A7:成功后重拉列表刷新徽标;失败路径不动行\n return;\n }\n if (result.kind === \"failure\") setFailure(result.failure);\n } catch {\n if (!live()) return;\n setFailure({ message: t(ADMIN_KEYS.generic), rules: [], fields: [] });\n } finally {\n lock.current = false;\n if (mounted.current) setSubmitting(false);\n }\n };\n\n const handlers = formHandlers(composing, submit);\n return { values, submitting, failure, success, setValue, ...handlers };\n}\n\nexport interface AdminResetFormProps {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n actorTotpEnabled: boolean;\n /** A7:成功回调(管理块用它重拉列表刷新徽标);失败路径不调用。 */\n onReset?: (() => void) | undefined;\n}\n\n/** 管理重置表单:目标下拉(排除本人)+ 新口令 ×2 + 条件式动态码;成功后清空四字段、不跳转。 */\nexport function AdminResetForm({\n t,\n users,\n actorName,\n actorTotpEnabled,\n onReset,\n}: AdminResetFormProps) {\n const form = useAdminReset(t, actorTotpEnabled, onReset);\n const targets = users.filter((user) => user.name !== actorName);\n const fields: { field: AdminField; label: string }[] = [\n { field: \"password\", label: t(ADMIN_KEYS.password) },\n { field: \"confirm\", label: t(ADMIN_KEYS.confirm) },\n ...(actorTotpEnabled ? [{ field: \"code\" as AdminField, label: t(ADMIN_KEYS.code) }] : []),\n ];\n const isInvalid = (field: AdminField): boolean => form.failure?.fields.includes(field) === true;\n const targetInvalid = isInvalid(\"target\");\n return (\n \n

{t(ADMIN_KEYS.selfHint)}

\n
\n \n form.setValue(\"target\", event.target.value)}\n >\n \n {targets.map((user) => (\n \n ))}\n \n
\n {fields.map((item) => (\n form.setValue(item.field, value)}\n />\n ))}\n

{t(ADMIN_KEYS.policyHint)}

\n \n \n {form.submitting ? t(ADMIN_KEYS.submitting) : t(ADMIN_KEYS.submit)}\n \n \n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n fetchAdminUsers,\n AdminResetForm,\n ADMIN_STATUS_ID,\n} from \"../../../features/admin-users/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../../../features/admin-users/index.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n ADMIN_BADGE_STYLE,\n ADMIN_BLOCK_STYLE,\n ADMIN_TABLE_STYLE,\n ADMIN_TD_STYLE,\n ADMIN_TH_STYLE,\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n TITLE_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\nconst TITLE_ID = \"dsh-auth-gate-admin-title\";\n\n/**\n * 列表状态(评审修正 A2):`denied`(403 非 admin / 受限)静默返回 null;\n * `unauthorized`(401 登录态已死)必须提示,不能静默消失;\n * `failure`(503/网络/解析)提示不可用。\n */\ntype UsersState =\n | { kind: \"loading\" }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"ok\"; users: AdminUserRow[] };\n\n/**\n * 挂载后拉一次列表(契约 §3.4:非 admin 根本不会挂载本组件,这里不判断角色);\n * `reload` 供 A7 成功后静默刷新徽标:刷新失败**不改行**(首拉才降级),同一 abort 规则。\n */\nfunction useAdminUsers(): { state: UsersState; reload: () => void } {\n const [state, setState] = useState({ kind: \"loading\" });\n const [attempt, setAttempt] = useState(0);\n useEffect(() => {\n const controller = new AbortController();\n let live = true;\n const first = attempt === 0;\n void fetchAdminUsers(controller.signal)\n .then((result) => {\n if (!live || result.kind === \"aborted\") return;\n if (result.kind === \"ok\") setState({ kind: \"ok\", users: result.users });\n else if (first) setState({ kind: result.kind });\n })\n .catch(() => {\n if (live && first) setState({ kind: \"failure\" });\n });\n return () => {\n live = false;\n controller.abort();\n };\n }, [attempt]);\n return { state, reload: () => setAttempt((value) => value + 1) };\n}\n\n/** 角色文案:`admin`/`user` 走词典;**未知取值原样渲染**,不新增键(A9)。 */\nfunction roleText(role: string, t: AdminTranslate): string {\n if (role === \"admin\") return t(ADMIN_KEYS.roleAdmin);\n if (role === \"user\") return t(ADMIN_KEYS.roleUser);\n return String(role);\n}\n\n/** 状态徽标**互斥**,优先级 disabled > mustChangePassword > ok(A9)。 */\nfunction stateText(user: AdminUserRow, t: AdminTranslate): string {\n if (user.disabled) return t(ADMIN_KEYS.stateDisabled);\n return user.mustChangePassword ? t(ADMIN_KEYS.stateMustChange) : t(ADMIN_KEYS.stateOk);\n}\n\n/** 只读用户表:**保持服务端顺序**,不做任何重排(契约 §1.2 / §3.5)。 */\nfunction AdminUsersTable({\n t,\n users,\n actorName,\n}: {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n}) {\n return (\n \n \n \n \n \n \n \n \n \n \n {users.map((user) => (\n \n \n \n \n \n \n ))}\n \n
{t(ADMIN_KEYS.colUser)}{t(ADMIN_KEYS.colRole)}{t(ADMIN_KEYS.colState)}{t(ADMIN_KEYS.colTotp)}
\n {user.name}\n {user.name === actorName ? (\n {t(ADMIN_KEYS.you)}\n ) : null}\n {roleText(user.role, t)}\n {stateText(user, t)}\n \n \n {user.totpEnabled ? t(ADMIN_KEYS.totpOn) : t(ADMIN_KEYS.totpOff)}\n \n
\n );\n}\n\n/** 提示态(loading / unauthorized / unavailable):同一条 aria-live 播报位。 */\nfunction AdminNotice({ text, error }: { text: string; error?: boolean }) {\n return (\n \n {text}\n

\n );\n}\n\nexport interface AdminUsersBlockProps {\n /** 槽位注入的翻译 seat(含 ADMIN 词典分片)。 */\n t: AdminTranslate;\n /** 当前登录用户名(status.name):用于「本人」标记与下拉排除。 */\n actorName: string;\n /** 当前账号是否启用两步验证:决定 `code` 输入框是否渲染(契约 §3.7)。 */\n actorTotpEnabled: boolean;\n}\n\n/**\n * 管理块(契约 §3 / A2):loading → denied 静默 null → unauthorized / failure 提示\n * → ok 列表 + 表单。「只有本人」时显示 `admin.empty` 且**不渲染表单**(§3.6)。\n */\nexport function AdminUsersBlock({ t, actorName, actorTotpEnabled }: AdminUsersBlockProps) {\n const { state, reload } = useAdminUsers();\n if (state.kind === \"loading\") return ;\n if (state.kind === \"denied\") return null;\n if (state.kind === \"unauthorized\") {\n return ;\n }\n if (state.kind === \"failure\") return ;\n const targets = state.users.filter((user) => user.name !== actorName);\n return (\n
\n

\n {t(ADMIN_KEYS.title)}\n

\n

{t(ADMIN_KEYS.intro)}

\n \n {targets.length === 0 ? (\n

\n {t(ADMIN_KEYS.empty)}\n

\n ) : (\n \n )}\n
\n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n STATUS_PATH,\n type SessionKind,\n type StatusResponse,\n type Unchecked,\n type UserRole,\n} from \"../../../shared/api/index.ts\";\n\n/**\n * `GET /auth/status` 的「关于我」视图:线上形状 `StatusResponse`(跨半边契约 D31)去掉本视图\n * 不用的 `logoutOrder`(由 client 入口单独读)与 `mustChangePassword`。\n *\n * **加法兼容**:身份字段是 PR1 才有的(旧服务端只回 `authenticated`/`logoutOrder`),\n * 任一身份字段缺失即视为「不渲染管理块」,其余行为一字不变(契约 §1.1 / §3.3)。\n */\nexport type AccountStatusView = Omit;\n\n/** 会话探针(只认 cookie,语义同 `/auth/status`,契约 §1.1)。 */\nconst STATUS_TARGET = STATUS_PATH;\n\n/** 字符串字段守卫:非字符串一律 `undefined`(旧服务端/异常形状都不渲染管理块)。 */\nfunction optionalString(value: unknown): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\n/** 布尔字段守卫。 */\nfunction optionalBoolean(value: unknown): boolean | undefined {\n return typeof value === \"boolean\" ? value : undefined;\n}\n\n/** role 白名单:只认 `admin` / `user`,其余(含非法字符串)留 `undefined`。 */\nfunction optionalRole(value: unknown): UserRole | undefined {\n return value === \"admin\" || value === \"user\" ? value : undefined;\n}\n\n/** sessionKind 白名单:只认 `full` / `password-change-only`。 */\nfunction optionalSessionKind(value: unknown): SessionKind | undefined {\n return value === \"full\" || value === \"password-change-only\" ? value : undefined;\n}\n\n/**\n * `/auth/status` 响应体 → 视图对象。**加法兼容**(契约 §1.1/§3.3):\n * 身份字段缺失、类型不符或取值不在白名单内,一律留 `undefined`;只有\n * `authenticated === true` 才可能让调用方继续渲染登录后的 UI。\n */\nfunction parseAccountStatus(body: unknown): AccountStatusView {\n const source = (\n typeof body === \"object\" && body !== null ? body : {}\n ) as Unchecked;\n return {\n authenticated: source.authenticated === true,\n name: optionalString(source.name),\n role: optionalRole(source.role),\n disabled: optionalBoolean(source.disabled),\n totpEnabled: optionalBoolean(source.totpEnabled),\n sessionKind: optionalSessionKind(source.sessionKind),\n };\n}\n\n/**\n * 会话状态探针:`null` = 首次响应返回前(调用方自己渲染 loading)。\n * `credentials: \"same-origin\"` 保证带 cookie;卸载即 abort,回调里再看一眼 signal,\n * 避免卸载后 setState。任何失败(网络/解析/中止之外的异常)→ `{authenticated:false}`。\n */\nexport function useAccountStatus(): AccountStatusView | null {\n const [status, setStatus] = useState(null);\n useEffect(() => {\n const controller = new AbortController();\n fetch(STATUS_TARGET, { signal: controller.signal, credentials: \"same-origin\" })\n .then((res) => res.json() as Promise)\n .then((body) => {\n if (!controller.signal.aborted) setStatus(parseAccountStatus(body));\n })\n .catch(() => {\n if (!controller.signal.aborted) setStatus({ authenticated: false });\n });\n return () => {\n controller.abort();\n };\n }, []);\n return status;\n}\n","import {\n ACCOUNT_DICT_EN,\n ACCOUNT_KEYS,\n translateFrom,\n type AccountTranslate,\n} from \"../../../shared/i18n/index.ts\";\nimport { AccountPasswordForm } from \"../../../features/change-password/index.ts\";\nimport { HINT_STYLE, PANEL_STYLE } from \"../../../shared/ui/index.ts\";\nimport { AdminUsersBlock } from \"./admin-block.tsx\";\nimport { ADMIN_DICT_EN } from \"../../../features/admin-users/index.ts\";\nimport type { AccountStatusView } from \"../../../entities/account/index.ts\";\nimport { useAccountStatus } from \"../../../entities/account/index.ts\";\n\n/** 提示态 id:loading / 未登录共用一个 aria-live 播报位。 */\nconst NOTICE_ID = \"dsh-auth-gate-account-notice\";\n\n/** 无表单的提示态(loading / 请先登录),保留 aria-live 播报。 */\nfunction Notice({ text }: { text: string }) {\n return (\n
\n

\n {text}\n

\n
\n );\n}\n\n/**\n * 管理块渲染门(契约 §9-A1,四条**同时**成立才渲染):`role === \"admin\"`、\n * `disabled === false`、`sessionKind === \"full\"`、`name` 是字符串。\n * **不做 `?? \"full\"` 兜底**:任一字段缺失或非该值 = 不渲染且零管理请求\n * (旧服务端/部分字段与「非 admin 不试拉」加法兼容;禁用 admin 不挂块)。\n */\nfunction showsAdminBlock(\n status: AccountStatusView,\n): status is AccountStatusView & { name: string } {\n return (\n status.role === \"admin\" &&\n status.disabled === false &&\n status.sessionKind === \"full\" &&\n typeof status.name === \"string\"\n );\n}\n\n/**\n * 槽位注入的 props:`t` 来自注册时的 `locale: \"auth\"` seat。\n * 宿主 owner props 还有 `close`;P1.1 起不再往下传(成功后去登录页,而不是关弹窗)。\n */\nexport interface SettingsAccountSectionProps {\n t?: AccountTranslate | undefined;\n}\n\n/**\n * 「账户」设置页(`settings.section`):未登录不给表单;已登录渲染自助改密表单,\n * 并在身份满足渲染门时(admin + 正式会话)追加管理块。`t` 缺失时降级到英文词典\n * (不显示键名),由 account-form / admin-block 承担各自的提交逻辑。\n */\nexport function SettingsAccountSection({ t }: SettingsAccountSectionProps) {\n const status = useAccountStatus();\n // `t` 缺失的降级词典必须同时含 account 与 admin 两片(否则管理块会显示键名,grok 回顾 #1)。\n const translate =\n typeof t === \"function\" ? t : translateFrom({ ...ACCOUNT_DICT_EN, ...ADMIN_DICT_EN });\n if (status === null) return ;\n if (status.authenticated !== true) return ;\n return (\n <>\n \n {showsAdminBlock(status) ? (\n \n ) : null}\n \n );\n}\n","import { useEffect, useState, type CSSProperties } from \"react\";\nimport { LOGOUT_URL, STATUS_PATH } from \"../../../shared/api/index.ts\";\n\n/** 登出目标:POST-only(M22:next 仅从 query 取,校验回落 /)。 */\nconst LOGOUT_TARGET = LOGOUT_URL;\n\n/**\n * 登出图标:16px 按钮图标(viewBox 24 不变,只设 width/height 16)。\n * 沿用原 32px 圆形按钮的同一个 SVG(方框 + 箭头)。\n */\nfunction renderLogoutIcon() {\n return (\n \n \n \n \n \n );\n}\n\n/**\n * 设置面板内醒目的登出 CTA:错误强调色(危险动作语义)填充按钮 +\n * 反色标签 `--dsw-alias-label-primary-inverted`,面板内水平居中(General 页底部)。\n */\nconst CTA_STYLE: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"10px 24px\",\n borderRadius: 12,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n background: \"var(--dsw-alias-state-error-primary)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** hover 态轻微提亮(随主题自适应,不硬编码色值)。 */\nconst CTA_HOVER_FILTER = \"brightness(1.08)\";\n\n/** 面板内水平居中容器(General 页最后一条行之后)。 */\nconst CTA_WRAP_STYLE: CSSProperties = {\n display: \"flex\",\n justifyContent: \"center\",\n padding: \"20px 0 4px\",\n};\n\nconst formStyle: CSSProperties = {\n display: \"contents\",\n};\n\n/**\n * 会话状态门控:挂载时 fetch /auth/status 一次(只认 cookie)。\n * @returns authenticated:null = 未知(第一次请求前),true/false。\n */\nfunction useAuthenticated(): boolean | null {\n const [authenticated, setAuthenticated] = useState(null);\n useEffect(() => {\n let cancelled = false;\n fetch(STATUS_PATH)\n .then((res) => res.json())\n .then((body: { authenticated?: unknown }) => {\n if (!cancelled) setAuthenticated(body.authenticated === true);\n })\n .catch(() => {\n if (!cancelled) setAuthenticated(false);\n });\n return () => {\n cancelled = true;\n };\n }, []);\n return authenticated;\n}\n\n/** 槽位渲染器按注册 `locale` 注入的 translate 形(本插件 `auth` 词典的 `logout` 键)。 */\nexport type LogoutTranslate = (key: string, params?: Record) => string;\n\n/** 设置面板里的登出按钮组件(`settings.general.item` 槽,root 作用域)。 */\nexport interface SettingsLogoutActionProps {\n /** 注入的本地化 translate(locale seat)。 */\n t?: LogoutTranslate;\n}\n\n/**\n * 可复用的登出提交按钮:原生 form POST(零 JS 依赖)+ 16px 方块图标 + 本地化文字。\n * 渲染进 `settings.general.item`(设置 → 通用设置 的追加行槽,order 30 → 页面底部),\n * 水平居中的醒目 CTA;文案随界面语言在「退出登录」/ \"Sign out\" 间切换。\n */\nexport function SettingsLogoutAction({ t }: SettingsLogoutActionProps) {\n const authenticated = useAuthenticated();\n const [hovered, setHovered] = useState(false);\n if (authenticated !== true) return null;\n const label = typeof t === \"function\" ? t(\"logout\") : \"Sign out\";\n return (\n
\n
\n setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n >\n {renderLogoutIcon()}\n {label}\n \n
\n
\n );\n}\n","import { ACCOUNT_DICT_EN, ACCOUNT_DICT_ZH, ACCOUNT_KEYS } from \"./shared/i18n/index.ts\";\nimport { installAccountNavIcon } from \"./features/nav-icon/index.ts\";\nimport { SettingsAccountSection } from \"./widgets/account-section/index.ts\";\nimport { ADMIN_DICT_EN, ADMIN_DICT_ZH } from \"./features/admin-users/index.ts\";\nimport {\n STATUS_PATH,\n type AuthContext,\n type StatusResponse,\n type Unchecked,\n} from \"./shared/api/index.ts\";\nimport { SettingsLogoutAction } from \"./features/logout/index.ts\";\n\n/** 本插件文案的词典命名域(locale 服务按 (ns, locale) 分开注册)。 */\nconst AUTH_NS = \"auth\";\n/** 命名词典里登出键。 */\nconst LOGOUT_KEY = \"logout\";\n\n/** 「账号安全」设置页(自助改密)的 section id。 */\nconst ACCOUNT_SECTION_ID = \"dsh-auth-gate-account\";\n/**\n * 「账号安全」页在设置导航里的排列位置。\n *\n * 已知占用:宿主 general 0 / models 10 / plugins 15 / agent-presets 20(0.1.7 起还有官方云账户\n * `account` -10),姊妹包 dsh-plugin-subscriptions 的订阅页 90,全部远小于 900,所以 900\n * 表示「大于已知的全部条目、落在导航靠后」(账号安全压轴是设置页惯例,宿主 nav 列也没有滚动条)。\n *\n * **不保证全局最后**:宿主只按 order 升序排、没有并列决胜(同值按插件注册顺序,跨安装不稳定),\n * 后来者随时可能注册更大或相同的值。谁先到谁先用 900,撞车时按 901/902 递增(ADR D24 已记录)。\n */\nconst ACCOUNT_SECTION_ORDER = 900;\n\n/**\n * 默认槽位 order:注册时先用它(与 host 端 Config 默认一致),随后 `/auth/status`\n * 探针读到 host 配置的 `logoutOrder` 时按配置重注册。1000 已大于 dsh 自带条目\n * (agent-preset -25 / permission -20 / language 0 / appearance 10 / composer-enter 20),\n * 除非第三方插件注册更大的 order,按钮始终留在通用设置页最底部。\n */\nconst DEFAULT_LOGOUT_ORDER = 1000;\n\n/**\n * dsh-auth-gate client 半边:认证后在**设置面板**(设置 → 通用设置 页底部)挂一\n * 个醒目的「退出登录 / Sign out」按钮:`settings.general.item`(root 作用域、\n * 可追加列表槽,由 ui-settings-general 的 General 页堆叠渲染,按 order 升序)。\n *\n * 顺序可配置:先以默认 order(1000)注册(探针失败/未开始前按钮也可见),再探\n * `/auth/status` 读取 host 配置的 `logoutOrder`,与默认不同则按配置值重注册\n * (同 id 注册 = 槽位替换,先注册新条目再释放旧条目,避免中间态空白)。\n *\n * 换槽对比:不再往会话页 `conversation.session.header.utilities`、新会话页\n * `shell.overlay` 注册(右上角两处入口已移除),也不占侧边栏 footer 脚区。\n * 文案挂进 dsh 现有的 locale 机制(与「设置」里的语言切换同一套):注册 `auth`\n * 词典(zh/en),再以 `locale: \"auth\"` 给注册条目注入 `t` seat,按钮文字随界面\n * 语言在「退出登录」/ \"Sign out\" 间实时切换。不改任何服务端端点/会话语义。\n *\n * 同一次 apply 还注册「账号安全」设置页(`settings.section`,id\n * dsh-auth-gate-account,order 900):内容为自助改密表单(见 account-section.tsx),\n * 文案复用同一个 `auth` 词典的 account 键。登出按钮的注册逻辑与 order 语义不变。\n *\n * 导航行图标(D24.1):宿主 `settings.section` 没有 `icon` 选项(只有 id/order/label),\n * 第三方段的图标恒为宿主默认齿轮;插件侧没有官方挂载点,所以 `account-nav-icon.ts` 只对\n * 我们这一行做一次临时 DOM 垫片(自设计盾牌图标),上游支持 `icon` 后整体删除。\n */\nexport const inject = [\"slots\", \"locale\"];\n\nexport function apply(ctx: AuthContext): void {\n // 词典注册(zh/en 双语,挂 fiber 卸载级联):登出 CTA + 账户页 + 管理块共用 auth\n // 命名域。admin 键(`admin.*`)与 account 键(`account.*`)、`logout` 不重名,可安全合并。\n ctx.effect(\n () => [\n ctx.locale.register(AUTH_NS, \"zh\", {\n [LOGOUT_KEY]: \"退出登录\",\n ...ACCOUNT_DICT_ZH,\n ...ADMIN_DICT_ZH,\n }),\n ctx.locale.register(AUTH_NS, \"en\", {\n [LOGOUT_KEY]: \"Sign out\",\n ...ACCOUNT_DICT_EN,\n ...ADMIN_DICT_EN,\n }),\n ],\n \"auth: zh/en dictionaries (logout + account + admin)\",\n );\n\n // 绑定 translate:读取活动语言(thunk 每次投影重读,跟随语言切换)。\n const t = ctx.locale.bind(AUTH_NS);\n\n ctx.slots.inject(\"settings.general.item\", () => {\n const mount = (order: number): (() => void) =>\n ctx.slots.register(\n {\n name: \"settings.general.item\",\n id: \"dsh-auth-gate-logout\",\n locale: AUTH_NS,\n order,\n label: () => t(LOGOUT_KEY),\n },\n SettingsLogoutAction,\n );\n let dispose: (() => void) | undefined = mount(DEFAULT_LOGOUT_ORDER);\n // 与 SettingsLogoutAction 相同的 status 探针:读 host 的 logoutOrder 配置并在\n // 不同时重注册。探针抛错(如测试环境无 fetch)保持默认,绝不吞掉注册。\n try {\n void fetch(STATUS_PATH)\n .then((res) => res.json() as Promise>)\n .then((body: Unchecked) => {\n const order = body.logoutOrder;\n if (\n typeof order !== \"number\" ||\n !Number.isInteger(order) ||\n order === DEFAULT_LOGOUT_ORDER\n ) {\n return;\n }\n const previous = dispose;\n dispose = mount(order);\n previous?.();\n })\n .catch(() => undefined);\n } catch {\n // fetch 不可用(测试环境/旧浏览器):保持默认 order。\n }\n return () => dispose?.();\n });\n\n // 「账号安全」设置页:注册 `settings.section`(root 作用域、可追加列表槽,导航按\n // order 升序)。内容是自助改密表单,未登录由组件自查 `/auth/status` 后隐藏。\n ctx.slots.inject(\"settings.section\", () =>\n ctx.slots.register(\n {\n name: \"settings.section\",\n id: ACCOUNT_SECTION_ID,\n locale: AUTH_NS,\n order: ACCOUNT_SECTION_ORDER,\n label: () => t(ACCOUNT_KEYS.nav),\n },\n SettingsAccountSection,\n ),\n );\n\n // 导航行图标垫片(D24.1):只替换我们自己那一行,找不到就保持宿主齿轮;没有 DOM 的环境\n // (node 单测)直接 no-op。上游给 `settings.section` 加 `icon` 后,这一行与\n // account-nav-icon.ts 一起删除。\n ctx.effect(\n () => installAccountNavIcon(globalThis.document),\n \"auth: account nav icon shim (D24.1)\",\n );\n}\n"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"client.js","names":["PREFIX","isError","useState","useRef","isRecord","useState","useRef","useState","useState","useState"],"sources":["../src/client/shared/i18n/account-copy.ts","../src/client/shared/ui/account-icon.ts","../src/client/shared/ui/account-styles.ts","../src/client/features/nav-icon/ui/account-nav-icon.ts","../src/client/shared/api/form.ts","../src/protocol/index.ts","../src/client/features/change-password/lib/account-redirect.ts","../src/client/features/change-password/ui/account-fields.tsx","../src/client/features/change-password/model/account-submit.ts","../src/client/features/change-password/ui/account-form.tsx","../src/client/features/admin-users/lib/admin-copy.ts","../src/client/features/admin-users/lib/admin-failure.ts","../src/client/features/admin-users/api/admin-api.ts","../src/client/features/admin-users/ui/admin-fields.tsx","../src/client/features/admin-users/model/admin-reset.ts","../src/client/features/admin-users/ui/admin-form.tsx","../src/client/widgets/account-section/ui/admin-block.tsx","../src/client/entities/account/api/account-status.ts","../src/client/widgets/account-section/ui/account-section.tsx","../src/client/features/logout/ui/logout-action.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * `auth` 词典的 account 分片:键名集中常量 + zh/en 两套文案。\n *\n * `index.tsx` 把两套词典注册进 `auth` 命名域(与 logout 同一 ns),\n * `account-section.tsx` 只通过槽位注入的 `t` 读取本文件的键,不自己拼字符串。\n * 策略文案里的 14/256 字符镜像契约 §2(`PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`):\n * client bundle 不 import 服务端 shared 模块,半边保持解耦。\n */\nimport type { PasswordRule } from \"../api/index.ts\";\n\n/** 槽位注入的标准 `t` seat(host locale translate 的形)。 */\nexport type AccountTranslate = (key: string, params?: Record) => string;\n\n/** 本面板用到的词典键(集中常量,避免散落字符串)。 */\nexport const ACCOUNT_KEYS = {\n nav: \"account\",\n title: \"account.title\",\n intro: \"account.intro\",\n /** 作用域说明(P1.1):0.1.7 起宿主有官方「账户」段,这里要讲清\"管的是本地登录凭据\"。 */\n scope: \"account.scope\",\n loading: \"account.loading\",\n loginRequired: \"account.loginRequired\",\n current: \"account.current\",\n password: \"account.password\",\n confirm: \"account.confirm\",\n code: \"account.code\",\n codeHint: \"account.codeHint\",\n policyHint: \"account.policyHint\",\n submit: \"account.submit\",\n submitting: \"account.submitting\",\n success: \"account.success\",\n /**\n * 成功态按钮(P1.1 / D24):不再是「关闭」:服务端此刻已吊销全部会话,关掉弹窗只会\n * 停在「死会话 SPA」上;改为直接去登录页。\n */\n relogin: \"account.relogin\",\n currentRequired: \"account.currentRequired\",\n passwordRequired: \"account.passwordRequired\",\n mismatch: \"account.mismatch\",\n invalidCredentials: \"account.invalidCredentials\",\n invalidTotp: \"account.invalidTotp\",\n policyIntro: \"account.policyIntro\",\n locked: \"account.locked\",\n lockedPlain: \"account.lockedPlain\",\n unavailable: \"account.unavailable\",\n generic: \"account.generic\",\n ruleMinLength: \"account.rule.minLength\",\n ruleMaxLength: \"account.rule.maxLength\",\n ruleUppercase: \"account.rule.uppercase\",\n ruleLowercase: \"account.rule.lowercase\",\n ruleDigit: \"account.rule.digit\",\n ruleSpecial: \"account.rule.special\",\n ruleSameAsOld: \"account.rule.sameAsOld\",\n} as const;\n\n/** 契约 §2 策略规则名 → 词典键(`satisfies` 保证与契约的 `PasswordRule` 一一对应)。 */\nconst RULE_KEYS: Record = {\n minLength: ACCOUNT_KEYS.ruleMinLength,\n maxLength: ACCOUNT_KEYS.ruleMaxLength,\n uppercase: ACCOUNT_KEYS.ruleUppercase,\n lowercase: ACCOUNT_KEYS.ruleLowercase,\n digit: ACCOUNT_KEYS.ruleDigit,\n special: ACCOUNT_KEYS.ruleSpecial,\n sameAsOld: ACCOUNT_KEYS.ruleSameAsOld,\n} satisfies Record;\n\n/** 规则名翻译;未知规则原样回显(不发明契约之外的文案)。 */\nexport function ruleText(rule: string, t: AccountTranslate): string {\n const key = RULE_KEYS[rule];\n return key === undefined ? rule : t(key);\n}\n\n/** host locale 的插值规则(`{name}`;缺参数时保留占位符不吞字)。 */\nexport function formatCopy(template: string, params?: Record): string {\n if (params === undefined) return template;\n return template.replace(/\\{(\\w+)\\}/g, (match, name: string) =>\n name in params ? String(params[name]) : match,\n );\n}\n\n/** 用一套词典兜底成 translate(槽位未注入 `t` 时降级为英文,不显示键名)。 */\nexport function translateFrom(dict: Record): AccountTranslate {\n return (key, params) => formatCopy(dict[key] ?? key, params);\n}\n\n/** 中文词典。 */\nexport const ACCOUNT_DICT_ZH: Record = {\n [ACCOUNT_KEYS.nav]: \"账号安全\",\n [ACCOUNT_KEYS.title]: \"修改密码\",\n [ACCOUNT_KEYS.intro]: \"改密成功后,当前设备也会被登出,需要用新密码重新登录。\",\n [ACCOUNT_KEYS.scope]: \"这里是本实例的本地登录凭据,与 DeepSeek 云账户无关。\",\n [ACCOUNT_KEYS.loading]: \"正在确认登录状态...\",\n [ACCOUNT_KEYS.loginRequired]: \"请先登录后再修改密码。\",\n [ACCOUNT_KEYS.current]: \"当前密码\",\n [ACCOUNT_KEYS.password]: \"新密码\",\n [ACCOUNT_KEYS.confirm]: \"确认新密码\",\n [ACCOUNT_KEYS.code]: \"动态验证码\",\n [ACCOUNT_KEYS.codeHint]: \"已开启两步验证时填写 6 位动态码,否则留空。\",\n [ACCOUNT_KEYS.policyHint]:\n \"新密码至少 14 个字符,且包含大写字母、小写字母、数字和特殊字符,不能与当前密码相同。\",\n [ACCOUNT_KEYS.submit]: \"修改密码\",\n [ACCOUNT_KEYS.submitting]: \"提交中...\",\n [ACCOUNT_KEYS.success]: \"密码已改,请重新登录。当前设备也已登出。\",\n [ACCOUNT_KEYS.relogin]: \"重新登录\",\n [ACCOUNT_KEYS.currentRequired]: \"请输入当前密码。\",\n [ACCOUNT_KEYS.passwordRequired]: \"请输入新密码。\",\n [ACCOUNT_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ACCOUNT_KEYS.invalidCredentials]: \"当前密码不正确。\",\n [ACCOUNT_KEYS.invalidTotp]: \"动态验证码不正确,或该验证码已被使用,请等待下一枚验证码后重试。\",\n [ACCOUNT_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ACCOUNT_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ACCOUNT_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ACCOUNT_KEYS.unavailable]: \"服务暂时不可用,请稍后重试。\",\n [ACCOUNT_KEYS.generic]: \"修改失败,请稍后重试。\",\n [ACCOUNT_KEYS.ruleMinLength]: \"至少 14 个字符\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"不超过 256 个字符\",\n [ACCOUNT_KEYS.ruleUppercase]: \"包含大写字母\",\n [ACCOUNT_KEYS.ruleLowercase]: \"包含小写字母\",\n [ACCOUNT_KEYS.ruleDigit]: \"包含数字\",\n [ACCOUNT_KEYS.ruleSpecial]: \"包含特殊字符(非字母数字)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"不能与当前密码相同\",\n};\n\n/** 英文词典。 */\nexport const ACCOUNT_DICT_EN: Record = {\n [ACCOUNT_KEYS.nav]: \"Account security\",\n [ACCOUNT_KEYS.title]: \"Change password\",\n [ACCOUNT_KEYS.intro]:\n \"After the change, this device is signed out too. Sign in again with your new password.\",\n [ACCOUNT_KEYS.scope]:\n \"These are this instance's local sign-in credentials, not your DeepSeek account.\",\n [ACCOUNT_KEYS.loading]: \"Checking your session...\",\n [ACCOUNT_KEYS.loginRequired]: \"Sign in first to change your password.\",\n [ACCOUNT_KEYS.current]: \"Current password\",\n [ACCOUNT_KEYS.password]: \"New password\",\n [ACCOUNT_KEYS.confirm]: \"Confirm new password\",\n [ACCOUNT_KEYS.code]: \"Verification code\",\n [ACCOUNT_KEYS.codeHint]:\n \"Enter the 6-digit code when two-factor authentication is on, otherwise leave it empty.\",\n [ACCOUNT_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character; it must differ from the current password.\",\n [ACCOUNT_KEYS.submit]: \"Change password\",\n [ACCOUNT_KEYS.submitting]: \"Submitting...\",\n [ACCOUNT_KEYS.success]:\n \"Password changed. Please sign in again. This device has been signed out too.\",\n [ACCOUNT_KEYS.relogin]: \"Sign in again\",\n [ACCOUNT_KEYS.currentRequired]: \"Enter your current password.\",\n [ACCOUNT_KEYS.passwordRequired]: \"Enter a new password.\",\n [ACCOUNT_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ACCOUNT_KEYS.invalidCredentials]: \"The current password is incorrect.\",\n [ACCOUNT_KEYS.invalidTotp]:\n \"The verification code is incorrect or already used; wait for the next code and try again.\",\n [ACCOUNT_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ACCOUNT_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ACCOUNT_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ACCOUNT_KEYS.unavailable]: \"The service is temporarily unavailable. Please try again later.\",\n [ACCOUNT_KEYS.generic]: \"Could not change the password. Please try again later.\",\n [ACCOUNT_KEYS.ruleMinLength]: \"at least 14 characters\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"at most 256 characters\",\n [ACCOUNT_KEYS.ruleUppercase]: \"an uppercase letter\",\n [ACCOUNT_KEYS.ruleLowercase]: \"a lowercase letter\",\n [ACCOUNT_KEYS.ruleDigit]: \"a digit\",\n [ACCOUNT_KEYS.ruleSpecial]: \"a special character (not a letter or digit)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"different from the current password\",\n};\n","/**\n * 自设计图标(P1.1 / D24):「盾 + 钥匙孔」,16px outline,规格对齐宿主图标\n * (`fill:none` / `stroke:currentColor` / `stroke-width:1.5` / 圆角线端)。\n *\n * 为什么自设计:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `dsh-client-ui-settings-general` 的 `navIcon(id)` 硬编码 if 链决定(0.1.5-rc.2 与\n * 0.1.7-alpha.2 都如此,且没有 `icon` 选项)。插件侧没有官方挂载点,也**不去改宿主的\n * nav DOM**(与宿主 React 抢树,评审已否掉),所以图标只画在我们自己的内容区标题行\n * (见 `account-form.tsx` 的 AccountIcon)。\n *\n * 路径数据与 `docs/demo/account-security.svg` 同源,由 `account-icon.test.ts` 逐条比对;\n * 改一处必须同步另一处。\n */\nexport const ACCOUNT_ICON_VIEW_BOX = \"0 0 16 16\";\n\n/** 图标路径(两个 `d`:盾牌外框 + 钥匙孔圆与柄)。 */\nexport const ACCOUNT_ICON_PATHS: readonly string[] = [\n \"M8 1.7 13.1 3.55V7.9c0 3.05-2.08 5.42-5.1 6.4-3.02-.98-5.1-3.35-5.1-6.4V3.55Z\",\n \"M9.3 7a1.3 1.3 0 1 1-2.6 0 1.3 1.3 0 1 1 2.6 0M8 8.3v2.1\",\n];\n","import type { CSSProperties } from \"react\";\n\n/**\n * account 面板样式:颜色只取宿主设计 token(`--dsw-*`),不硬编码色值,\n * 跟随主题/明暗切换。几何(间距/圆角/字号)照 logout-action.tsx 现状自绘。\n */\n\n/** 面板根部:纵向排布;`minHeight: 0` 让本页在设置内容列里参与收缩,列内可滚动。 */\nexport const PANEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n minHeight: 0,\n padding: \"4px 0 8px\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"var(--dsw-font-family)\",\n};\n\nexport const TITLE_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 16,\n fontWeight: 600,\n lineHeight: \"24px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\n/** 标题行(P1.1):自带图标 + 文案横排,图标随文字色(currentColor)跟随主题。 */\nexport const TITLE_ROW_STYLE: CSSProperties = {\n ...TITLE_STYLE,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n};\n\nexport const HINT_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const FORM_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n maxWidth: 420,\n};\n\nexport const LABEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 6,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-secondary)\",\n};\n\n/** 输入框基线。 */\nconst INPUT_BASE: CSSProperties = {\n boxSizing: \"border-box\",\n width: \"100%\",\n padding: \"8px 12px\",\n borderRadius: 8,\n border: \"1px solid var(--dsw-alias-border-l2)\",\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n lineHeight: \"22px\",\n};\n\nexport const INPUT_STYLE: CSSProperties = INPUT_BASE;\n\n/** 校验失败字段(契约 §1 的 401/400 分支)加错误描边。 */\nexport const INVALID_INPUT_STYLE: CSSProperties = {\n ...INPUT_BASE,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n};\n\nexport const STATUS_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 4,\n minHeight: 20,\n fontSize: 13,\n lineHeight: \"20px\",\n};\n\nexport const ERROR_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const SUCCESS_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-state-success-primary)\",\n};\n\nexport const RULES_STYLE: CSSProperties = {\n margin: 0,\n paddingLeft: 18,\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const BUTTON_STYLE: CSSProperties = {\n alignSelf: \"flex-start\",\n padding: \"8px 20px\",\n borderRadius: 10,\n border: \"1px solid var(--dsw-alias-button-primary-fill)\",\n background: \"var(--dsw-alias-button-primary-fill)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** 提交中(双锁的可见态):置灰但不移除,避免布局跳动。 */\nexport const BUTTON_BUSY_STYLE: CSSProperties = {\n ...BUTTON_STYLE,\n opacity: 0.6,\n cursor: \"default\",\n};\n\n// ---------------------------------------------------------------------------\n// P2 PR2 管理块(admin):以下全部为**追加**常量,上面既有值一个都不动\n// (自助改密页像素稳定性,契约 §2 account-styles 行)。\n// ---------------------------------------------------------------------------\n\n/** 管理块根部:与上方自助改密区之间用一条分隔线 + 上间距,纵向排布。 */\nexport const ADMIN_BLOCK_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n marginTop: 4,\n paddingTop: 12,\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 用户表:占满宽度、折叠边框;只做排版,不自绘视觉。 */\nexport const ADMIN_TABLE_STYLE: CSSProperties = {\n width: \"100%\",\n borderCollapse: \"collapse\",\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\nexport const ADMIN_TH_STYLE: CSSProperties = {\n textAlign: \"left\",\n padding: \"6px 8px 6px 0\",\n fontWeight: 500,\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const ADMIN_TD_STYLE: CSSProperties = {\n padding: \"6px 8px 6px 0\",\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 只读徽标(角色 / 状态 / 两步验证 / 本人):浅底 + 次级文字色,随主题切换。 */\nexport const ADMIN_BADGE_STYLE: CSSProperties = {\n display: \"inline-block\",\n padding: \"0 6px\",\n borderRadius: 6,\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-secondary)\",\n fontSize: 12,\n lineHeight: \"18px\",\n};\n","/**\n * 「账号安全」导航行图标:临时 DOM 垫片(D24.1)。\n *\n * **为什么需要它**:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `@deepseek-ai/dsh-client-ui-settings-general` 里硬编码的 `navIcon(id)` if 链给出\n * (0.1.5-rc.2 只认 models / agent-presets / plugins,0.1.7-alpha.2 多认官方 `account`),\n * 第三方段一律落到默认齿轮。插件侧没有图标挂载点,所以这里只对我们自己那一行做一次\n * 视觉替换:把宿主齿轮 `display:none`,在它前面插进我们自设计的盾牌 SVG。\n *\n * **不闪**:观察器在插件 `apply()` 时就装好,远早于设置浮层出现;宿主 React 提交 DOM 后,\n * MutationObserver 回调作为**微任务**在同一个任务里排队,而浏览器绘制发生在微任务清空之后。\n * 因此\"宿主图标进入 DOM / 被重写内联样式\"这件事总是在画出那一帧之前被修正为\"藏起来 + 我们的\n * 图标在场\",无论它发生在浮层整体插入的同一次提交、还是后来的某一次提交(后到的那次插入本身\n * 也会触发回调)。隔离实例里用 rAF 逐帧探针(每帧记录行内图标状态)验过:出现行的第一帧即已是\n * 盾牌,0 帧出现齿轮。\n *\n * **只碰我们这一行**:判定条件是「`\n \n );\n }\n const isInvalid = (field: FieldName): boolean => form.failure?.fields.includes(field) === true;\n return (\n
\n

\n \n {t(ACCOUNT_KEYS.title)}\n

\n

{t(ACCOUNT_KEYS.intro)}

\n

{t(ACCOUNT_KEYS.scope)}

\n {\n form.composing.current = true;\n }}\n onCompositionEnd={() => {\n form.composing.current = false;\n }}\n >\n \n \n \n {form.submitting ? t(ACCOUNT_KEYS.submitting) : t(ACCOUNT_KEYS.submit)}\n \n \n
\n );\n}\n","import { formatCopy, type AccountTranslate } from \"../../../shared/i18n/index.ts\";\n\n/**\n * 管理块(PR2)的 `auth` 词典分片:键名集中常量 + zh/en 两套文案。\n *\n * 与 account 分片同命名域(`index.tsx` 把两套词典合并注册),管理块只通过注入的 `t` 读键。\n * 键与文案对齐 CONTRACT-pr2 §4 全表 + §9/A4 的 2 个新增键(冻结键集 = 42);\n * 策略规则名复用 `account-copy.ts` 的 `ruleText`,本文件不新造规则文案。\n */\n\n/** 管理块 translate:与自助改密同形(宿主只注入一个 `t` seat)。 */\nexport type AdminTranslate = AccountTranslate;\n\n/** 管理块词典键(集中常量,避免散落字符串)。 */\nexport const ADMIN_KEYS = {\n title: \"admin.title\",\n intro: \"admin.intro\",\n selfHint: \"admin.selfHint\",\n loading: \"admin.loading\",\n unavailable: \"admin.unavailable\",\n empty: \"admin.empty\",\n colUser: \"admin.colUser\",\n colRole: \"admin.colRole\",\n colState: \"admin.colState\",\n colTotp: \"admin.colTotp\",\n roleAdmin: \"admin.roleAdmin\",\n roleUser: \"admin.roleUser\",\n you: \"admin.you\",\n stateDisabled: \"admin.stateDisabled\",\n stateMustChange: \"admin.stateMustChange\",\n stateOk: \"admin.stateOk\",\n totpOn: \"admin.totpOn\",\n totpOff: \"admin.totpOff\",\n target: \"admin.target\",\n targetPlaceholder: \"admin.targetPlaceholder\",\n password: \"admin.password\",\n confirm: \"admin.confirm\",\n code: \"admin.code\",\n codeHint: \"admin.codeHint\",\n policyHint: \"admin.policyHint\",\n submit: \"admin.submit\",\n submitting: \"admin.submitting\",\n successRevoked: \"admin.successRevoked\",\n successKept: \"admin.successKept\",\n targetRequired: \"admin.targetRequired\",\n passwordRequired: \"admin.passwordRequired\",\n mismatch: \"admin.mismatch\",\n codeRequired: \"admin.codeRequired\",\n forbidden: \"admin.forbidden\",\n badTarget: \"admin.badTarget\",\n notFound: \"admin.notFound\",\n policyIntro: \"admin.policyIntro\",\n invalidTotp: \"admin.invalidTotp\",\n unauthorized: \"admin.unauthorized\",\n locked: \"admin.locked\",\n lockedPlain: \"admin.lockedPlain\",\n generic: \"admin.generic\",\n} as const;\n\n/** 中文词典(键序与 §4 表一致,A4 的 2 键插在语义位置)。 */\nexport const ADMIN_DICT_ZH: Record = {\n [ADMIN_KEYS.title]: \"用户管理\",\n [ADMIN_KEYS.intro]: \"重置后,该用户下次登录会被要求立即改密;会话吊销若失败会单独提示。\",\n [ADMIN_KEYS.selfHint]:\n \"修改自己的密码请用上方表单。忘记自己的口令走 CLI `dsh-auth user passwd`(管理面不允许重置自己)。\",\n [ADMIN_KEYS.loading]: \"正在加载用户列表...\",\n [ADMIN_KEYS.unavailable]: \"用户列表暂时不可用,请稍后重试。\",\n [ADMIN_KEYS.empty]: \"没有可重置的其他用户。\",\n [ADMIN_KEYS.colUser]: \"用户\",\n [ADMIN_KEYS.colRole]: \"角色\",\n [ADMIN_KEYS.colState]: \"状态\",\n [ADMIN_KEYS.colTotp]: \"两步验证\",\n [ADMIN_KEYS.roleAdmin]: \"管理员\",\n [ADMIN_KEYS.roleUser]: \"用户\",\n [ADMIN_KEYS.you]: \"本人\",\n [ADMIN_KEYS.stateDisabled]: \"已禁用\",\n [ADMIN_KEYS.stateMustChange]: \"需改密\",\n [ADMIN_KEYS.stateOk]: \"正常\",\n [ADMIN_KEYS.totpOn]: \"已开启\",\n [ADMIN_KEYS.totpOff]: \"未开启\",\n [ADMIN_KEYS.target]: \"目标用户\",\n [ADMIN_KEYS.targetPlaceholder]: \"请选择用户\",\n [ADMIN_KEYS.password]: \"新密码\",\n [ADMIN_KEYS.confirm]: \"确认新密码\",\n [ADMIN_KEYS.code]: \"动态验证码\",\n [ADMIN_KEYS.codeHint]: \"你已开启两步验证,重置他人密码需填写动态码。\",\n [ADMIN_KEYS.policyHint]: \"新密码至少 14 个字符,且含大写字母、小写字母、数字和特殊字符。\",\n [ADMIN_KEYS.submit]: \"重置密码\",\n [ADMIN_KEYS.submitting]: \"提交中...\",\n [ADMIN_KEYS.successRevoked]: \"已重置,该用户的会话已全部吊销。\",\n [ADMIN_KEYS.successKept]: \"已重置,但该用户的现有会话仍然有效(吊销失败),请手动处理。\",\n [ADMIN_KEYS.targetRequired]: \"请选择目标用户。\",\n [ADMIN_KEYS.passwordRequired]: \"请输入新密码。\",\n [ADMIN_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ADMIN_KEYS.codeRequired]: \"请输入动态验证码。\",\n [ADMIN_KEYS.forbidden]: \"没有权限执行此操作,或当前会话不允许。\",\n [ADMIN_KEYS.badTarget]: \"目标用户名不合法。\",\n [ADMIN_KEYS.notFound]: \"目标用户不存在。\",\n [ADMIN_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ADMIN_KEYS.invalidTotp]: \"动态验证码不正确,或已被使用。\",\n [ADMIN_KEYS.unauthorized]: \"登录状态已失效,请重新登录。\",\n [ADMIN_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ADMIN_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ADMIN_KEYS.generic]: \"重置失败,请稍后重试。\",\n};\n\n/** 英文词典(键序与中文一致)。 */\nexport const ADMIN_DICT_EN: Record = {\n [ADMIN_KEYS.title]: \"User management\",\n [ADMIN_KEYS.intro]:\n \"After a reset the user must change the password at the next sign-in; if revocation fails, that is reported separately.\",\n [ADMIN_KEYS.selfHint]:\n \"Use the form above to change your own password. If you forgot it, use the CLI `dsh-auth user passwd` (this panel cannot reset your own account).\",\n [ADMIN_KEYS.loading]: \"Loading users...\",\n [ADMIN_KEYS.unavailable]: \"The user list is temporarily unavailable. Please try again later.\",\n [ADMIN_KEYS.empty]: \"There are no other users to reset.\",\n [ADMIN_KEYS.colUser]: \"User\",\n [ADMIN_KEYS.colRole]: \"Role\",\n [ADMIN_KEYS.colState]: \"State\",\n [ADMIN_KEYS.colTotp]: \"Two-factor\",\n [ADMIN_KEYS.roleAdmin]: \"Admin\",\n [ADMIN_KEYS.roleUser]: \"User\",\n [ADMIN_KEYS.you]: \"You\",\n [ADMIN_KEYS.stateDisabled]: \"Disabled\",\n [ADMIN_KEYS.stateMustChange]: \"Must change\",\n [ADMIN_KEYS.stateOk]: \"Normal\",\n [ADMIN_KEYS.totpOn]: \"On\",\n [ADMIN_KEYS.totpOff]: \"Off\",\n [ADMIN_KEYS.target]: \"Target user\",\n [ADMIN_KEYS.targetPlaceholder]: \"Choose a user\",\n [ADMIN_KEYS.password]: \"New password\",\n [ADMIN_KEYS.confirm]: \"Confirm new password\",\n [ADMIN_KEYS.code]: \"Verification code\",\n [ADMIN_KEYS.codeHint]:\n \"Two-factor is on for your account, so resetting another user needs a code.\",\n [ADMIN_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character.\",\n [ADMIN_KEYS.submit]: \"Reset password\",\n [ADMIN_KEYS.submitting]: \"Submitting...\",\n [ADMIN_KEYS.successRevoked]: \"Reset done. All of that user's sessions were revoked.\",\n [ADMIN_KEYS.successKept]:\n \"Reset done, but that user's existing sessions are still valid (revocation failed); handle them manually.\",\n [ADMIN_KEYS.targetRequired]: \"Choose a target user.\",\n [ADMIN_KEYS.passwordRequired]: \"Enter a new password.\",\n [ADMIN_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ADMIN_KEYS.codeRequired]: \"Enter the verification code.\",\n [ADMIN_KEYS.forbidden]: \"You are not allowed to do this, or this session is not allowed to.\",\n [ADMIN_KEYS.badTarget]: \"The target user name is invalid.\",\n [ADMIN_KEYS.notFound]: \"The target user does not exist.\",\n [ADMIN_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ADMIN_KEYS.invalidTotp]: \"The verification code is incorrect or already used.\",\n [ADMIN_KEYS.unauthorized]: \"Your session has expired. Please sign in again.\",\n [ADMIN_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ADMIN_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ADMIN_KEYS.generic]: \"Could not reset the password. Please try again later.\",\n};\n\n/**\n * 用一套词典兜底成 translate:缺键先回落英文词典,两边都缺时回空串,\n * **绝不把键名当文案显示**(§4 表注)。\n */\nexport function translateAdminFrom(dict: Record): AdminTranslate {\n return (key, params) => formatCopy(dict[key] ?? ADMIN_DICT_EN[key] ?? \"\", params);\n}\n","import type { AdminResetErrorBody, Unchecked } from \"../../../shared/api/index.ts\";\nimport { ruleText } from \"../../../shared/i18n/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"./admin-copy.ts\";\nimport type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\n\n/**\n * 管理重置的失败映射(CONTRACT-pr2 §1.3 + §9/A10)。从 `admin-api.ts` 预拆出来(A12),\n * 避免单文件贴上限。**全函数**:只认下表的 (status, error) 字面量组合,\n * 其余一律 `admin.generic`,不自造错误码、不做前缀/子串匹配。\n *\n * 已核对 PR1:`self`(自助误走管理面)也回 `{error:\"forbidden\"}`,没有 `{error:\"self\"}`。\n */\n\nfunction view(message: string, rules: string[] = [], fields: AdminField[] = []): AdminFailureView {\n return { message, rules, fields };\n}\n\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 错误码比较:码的字面量受契约(D31)约束,写错码编译不过;值本身仍是未校验的 `unknown`。 */\nfunction isError(value: unknown, code: AdminResetErrorBody[\"error\"]): boolean {\n return value === code;\n}\n\n/** 规则列表:只保留字符串项,未知规则名原样回显(复用 account 的 `ruleText`)。 */\nfunction ruleList(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((rule): rule is string => typeof rule === \"string\")\n : [];\n}\n\n/** 400:`bad_target` / `policy`(携带 rules);其余(含缺 error)→ generic。 */\nfunction describeBadRequest(error: unknown, rules: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"policy\")) {\n return view(\n t(ADMIN_KEYS.policyIntro),\n ruleList(rules).map((rule) => ruleText(rule, t)),\n [\"password\"],\n );\n }\n if (isError(error, \"bad_target\")) return view(t(ADMIN_KEYS.badTarget), [], [\"target\"]);\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 401:`invalid_totp` / `unauthorized`;其余 → generic。 */\nfunction describeUnauthorized(error: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"invalid_totp\")) return view(t(ADMIN_KEYS.invalidTotp), [], [\"code\"]);\n if (isError(error, \"unauthorized\")) return view(t(ADMIN_KEYS.unauthorized));\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 429:`locked` + 数字 retryAfter 插值,缺数字用无参文案;其余 → generic。 */\nfunction describeLocked(error: unknown, retryAfter: unknown, t: AdminTranslate): AdminFailureView {\n if (!isError(error, \"locked\")) return view(t(ADMIN_KEYS.generic));\n return typeof retryAfter === \"number\"\n ? view(t(ADMIN_KEYS.locked, { seconds: retryAfter }))\n : view(t(ADMIN_KEYS.lockedPlain));\n}\n\n/**\n * 状态码矩阵 → 文案键:\n * 400 bad_target / policy+rules、401 invalid_totp / unauthorized、403 forbidden、\n * 404 not_found、429 locked(+retryAfter)、其余(413/415/503 的 text/plain 与所有未列出组合,\n * 含缺 `error` 的 403/404/429)一律 generic。\n */\nexport function describeAdminFailure(\n status: number,\n body: unknown,\n t: AdminTranslate,\n): AdminFailureView {\n const record = isRecord(body) ? body : {};\n const error = record.error;\n if (status === 400) return describeBadRequest(error, record.rules, t);\n if (status === 401) return describeUnauthorized(error, t);\n if (status === 403 && isError(error, \"forbidden\")) return view(t(ADMIN_KEYS.forbidden));\n if (status === 404 && isError(error, \"not_found\")) {\n return view(t(ADMIN_KEYS.notFound), [], [\"target\"]);\n }\n if (status === 429) return describeLocked(error, record.retryAfter, t);\n return view(t(ADMIN_KEYS.generic));\n}\n","import {\n encodeForm,\n USERS_PASSWORD_PATH,\n USERS_PATH,\n type AdminResetErrorBody,\n type AdminResetRequest,\n type AdminResetResponse,\n type AdminUserRow,\n type Unchecked,\n type UsersResponse,\n} from \"../../../shared/api/index.ts\";\nimport { describeAdminFailure } from \"../lib/admin-failure.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport type {\n AdminFailureView,\n AdminResetResult,\n AdminResetValues,\n AdminUsersResult,\n} from \"../model/admin-reset.ts\";\n\n/**\n * 管理面客户端 API(CONTRACT-pr2 §1.2 / §1.3 + §9):列表读取 + 重置提交。\n *\n * 线上形状来自跨半边契约(D31,经 `shared/api`),表单与结果类型在 `model/admin-reset.ts`;失败映射在 `admin-failure.ts`(A12 预拆,此处按\n * 冻结面原样再导出)。不手写 `Origin`(同源 POST 由浏览器自动携带)、不做 `Accept` 子串匹配、\n * 不重排服务端已排好的顺序。\n */\n\nexport { describeAdminFailure };\n\nconst USERS_TARGET = USERS_PATH;\nconst RESET_TARGET = USERS_PASSWORD_PATH;\n\n/** 对象守卫:键按契约形状 `T` 读(读错键编译不过),值仍是未校验的 `unknown`。 */\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 单行形状校验:任一必需字段类型不符即丢弃该行(不整表失败,契约 §1.2)。 */\nfunction asRow(value: unknown): AdminUserRow | undefined {\n if (!isRecord(value)) return undefined;\n const { name, role, disabled, totpEnabled, mustChangePassword } = value;\n if (typeof name !== \"string\" || name === \"\") return undefined;\n if (role !== \"admin\" && role !== \"user\") return undefined;\n if (typeof disabled !== \"boolean\" || typeof totpEnabled !== \"boolean\") return undefined;\n if (typeof mustChangePassword !== \"boolean\") return undefined;\n return { name, role, disabled, totpEnabled, mustChangePassword };\n}\n\n/** 解析 200 body;`users` 不是数组视为形状非法(整表 failure)。 */\nfunction parseUsers(body: unknown): AdminUserRow[] | undefined {\n if (!isRecord(body)) return undefined;\n const raw = body.users;\n if (!Array.isArray(raw)) return undefined;\n // 保持服务端顺序:只过滤,不排序。\n return raw.map(asRow).filter((row): row is AdminUserRow => row !== undefined);\n}\n\n/** 重置端点的全部 JSON 形状(成功体 + 错误体),按契约读键。 */\ntype ResetBody = AdminResetResponse | AdminResetErrorBody;\n\n/** 读 JSON;非对象或解析失败统一回空体(映射只看状态码)。 */\nasync function readJsonSafe(res: Response): Promise> {\n try {\n const body: unknown = await res.json();\n return isRecord(body) ? body : {};\n } catch {\n return {};\n }\n}\n\n/**\n * `GET /auth/users`(cookie only,无 Origin 要求)。\n * 403 → `denied`(静默降级);401 → `unauthorized`(整页登录态已死,必须提示,§9/A2);\n * 非 200 / 形状非法 / 网络抛错 → `failure`;中止 → `aborted`(abort 后不解析响应体)。\n */\nexport async function fetchAdminUsers(signal: AbortSignal): Promise {\n try {\n const res = await fetch(USERS_TARGET, { method: \"GET\", signal, credentials: \"same-origin\" });\n if (signal.aborted) return { kind: \"aborted\" };\n if (res.status === 403) return { kind: \"denied\" };\n if (res.status === 401) return { kind: \"unauthorized\" };\n if (!res.ok) return { kind: \"failure\" };\n const body: unknown = await res.json();\n if (signal.aborted) return { kind: \"aborted\" };\n const users = parseUsers(body);\n return users === undefined ? { kind: \"failure\" } : { kind: \"ok\", users };\n } catch {\n return signal.aborted ? { kind: \"aborted\" } : { kind: \"failure\" };\n }\n}\n\n/**\n * 本地校验闭表(§9/A6):target → password → confirm → code。\n * `code` 只在 `actorTotpEnabled === true` 时校验,且用 `trim()` 判空(口令字段不 trim)。\n * 第三个参数缺省 false,只传两个实参的调用方行为不变。\n */\nexport function validateAdminReset(\n values: AdminResetValues,\n t: AdminTranslate,\n actorTotpEnabled = false,\n): AdminFailureView | null {\n if (values.target === \"\") {\n return { message: t(ADMIN_KEYS.targetRequired), rules: [], fields: [\"target\"] };\n }\n if (values.password === \"\") {\n return { message: t(ADMIN_KEYS.passwordRequired), rules: [], fields: [\"password\"] };\n }\n if (values.password !== values.confirm) {\n return { message: t(ADMIN_KEYS.mismatch), rules: [], fields: [\"password\", \"confirm\"] };\n }\n if (actorTotpEnabled && values.code.trim() === \"\") {\n return { message: t(ADMIN_KEYS.codeRequired), rules: [], fields: [\"code\"] };\n }\n return null;\n}\n\n/**\n * 提交请求体(§9/A3):只发 `target` / `password`,`actorTotpEnabled === true` 时才带 `code`\n * (缺码场景不发空串);**`confirm` 永不发送**。\n */\nfunction postAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n): Promise {\n const request: AdminResetRequest = {\n target: values.target,\n password: values.password,\n ...(options.actorTotpEnabled ? { code: values.code } : {}),\n };\n return fetch(RESET_TARGET, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: encodeForm(request),\n signal: options.signal,\n credentials: \"same-origin\",\n });\n}\n\n/**\n * `POST /auth/users/password`:\n * **成功只认契约 §1.3 的唯一形状** `200 {ok:true}`(grok 实现期必修 1):2xx 但缺 `ok:true`\n * (空 JSON、`{ok:false}`、中间层 HTML 拦截页)一律走失败映射,宁可报错也不谎报\"已重置\"\n * 再清空现场口令。`sessionsRevoked` 缺失/非布尔/解析失败按 `false`(§9/A8+A10);\n * 中止 → `aborted`。\n */\nexport async function submitAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n t: AdminTranslate,\n): Promise {\n try {\n const res = await postAdminReset(values, options);\n if (options.signal.aborted) return { kind: \"aborted\" };\n const body = await readJsonSafe(res);\n if (options.signal.aborted) return { kind: \"aborted\" };\n if (res.status === 200 && body.ok === true) {\n return { kind: \"ok\", sessionsRevoked: body.sessionsRevoked === true };\n }\n return { kind: \"failure\", failure: describeAdminFailure(res.status, body, t) };\n } catch {\n if (options.signal.aborted) return { kind: \"aborted\" };\n return {\n kind: \"failure\",\n failure: { message: t(ADMIN_KEYS.generic), rules: [], fields: [] },\n };\n }\n}\n","import type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\nimport {\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n RULES_STYLE,\n STATUS_STYLE,\n SUCCESS_TEXT_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 管理块的共享展示件(lead 批的 A12 预拆):id 常量 + 输入行 + 状态播报位。\n * 只被 `admin-form.tsx` / `admin-block.tsx` **单向** import;本文件不 import 它们(避免循环)。\n */\n\n/** id 前缀冻结(契约 §3.10):一个面板只有一个管理块实例,静态 id 即可。 */\nconst PREFIX = \"dsh-auth-gate-admin\";\n\n/** 状态播报位:aria-live 落点,也是错误字段 aria-describedby 的目标。 */\nexport const ADMIN_STATUS_ID = `${PREFIX}-status`;\nexport const ADMIN_CODE_HINT_ID = `${PREFIX}-code-hint`;\n\nexport function adminFieldId(field: AdminField): string {\n return `${PREFIX}-${field}`;\n}\n\n/** 成功视图(A8):`warning` = `sessionsRevoked:false`,是安全失败,走 alert + 错误色。 */\nexport interface AdminSuccessView {\n text: string;\n warning: boolean;\n}\n\n/** 带 label / 错误描边 / aria 关联的输入行(与自助面同范式,id 换管理块前缀)。 */\nexport function AdminTextField({\n field,\n label,\n value,\n invalid,\n onChange,\n hint,\n hintId,\n}: {\n field: AdminField;\n label: string;\n value: string;\n invalid: boolean;\n onChange: (value: string) => void;\n hint?: string | undefined;\n hintId?: string | undefined;\n}) {\n const described = [invalid ? ADMIN_STATUS_ID : undefined, hintId].filter(\n (item) => item !== undefined,\n );\n const id = adminFieldId(field);\n // 字段语义固定:新口令两框 `new-password`,动态码 `one-time-code` + 数字键盘。\n const isCode = field === \"code\";\n return (\n
\n \n onChange(event.target.value)}\n />\n {hint === undefined ? null : (\n \n {hint}\n \n )}\n
\n );\n}\n\n/** 状态区:失败消息 + 已翻译规则列表 + 就地成功/警告文案(契约 §3.8 / A8)。 */\nexport function AdminStatus({\n failure,\n success,\n}: {\n failure: AdminFailureView | null;\n success: AdminSuccessView | null;\n}) {\n const warning = success?.warning === true;\n return (\n <>\n
\n {failure === null ? null : {failure.message}}\n {failure === null || failure.rules.length === 0 ? null : (\n
    \n {failure.rules.map((rule) => (\n
  • {rule}
  • \n ))}\n
\n )}\n {success === null || warning ? null : (\n {success.text}\n )}\n
\n {warning && success !== null ? (\n

\n {success.text}\n

\n ) : null}\n \n );\n}\n","import type { AdminUserRow } from \"../../../shared/api/index.ts\";\n\n/**\n * 管理重置表单与结果的 client 侧类型(契约 §2)。只放类型与常量,不放逻辑。\n *\n * 线上形状(列表行、请求体、响应体、错误体)在跨半边契约 `src/protocol/`(D31),经\n * `shared/api` 进来;这里只剩表单状态和解析后的结果,它们不上线,host 不认识。\n */\n\n/** 管理重置表单字段(`confirm` 只在客户端比对,不进请求体,契约 §1.3)。 */\nexport type AdminField = \"target\" | \"password\" | \"confirm\" | \"code\";\n\nexport type AdminResetValues = Record;\n\n/** 表单初值:提交成功后必须清回这一份(契约 §3.8)。 */\nexport const EMPTY_ADMIN_RESET: AdminResetValues = {\n target: \"\",\n password: \"\",\n confirm: \"\",\n code: \"\",\n};\n\n/** 失败视图:已翻译消息 + 逐条策略文案 + 需标红的字段。 */\nexport interface AdminFailureView {\n message: string;\n rules: string[];\n fields: AdminField[];\n}\n\n/**\n * 列表结果(评审修正 A2):\n * - 403 `forbidden` → `denied`:这张 cookie 不是 admin / 不是 full 会话 → **静默**不渲染管理块;\n * - 401 `unauthorized` → `unauthorized`:整页登录态已死 → 必须提示 `admin.unauthorized`,\n * 不能静默消失(否则上方的自助改密表单会继续按过期 status 显示\"已登录\");\n * - 503 / 网络 / 解析失败 → `failure`(提示 `admin.unavailable`);\n * - 卸载中止 → `aborted`(回调里必须原样收手)。\n */\nexport type AdminUsersResult =\n | { kind: \"ok\"; users: AdminUserRow[] }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"aborted\" };\n\n/** 重置结果:`sessionsRevoked:false` 仍算成功(服务端写盘已成功,契约 §1.3)。 */\nexport type AdminResetResult =\n | { kind: \"ok\"; sessionsRevoked: boolean }\n | { kind: \"failure\"; failure: AdminFailureView }\n | { kind: \"aborted\" };\n","import {\n useEffect,\n useRef,\n useState,\n type FormEvent,\n type KeyboardEvent,\n type MutableRefObject,\n} from \"react\";\nimport { submitAdminReset, validateAdminReset } from \"../api/admin-api.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport {\n AdminStatus,\n AdminTextField,\n ADMIN_CODE_HINT_ID,\n ADMIN_STATUS_ID,\n adminFieldId,\n type AdminSuccessView,\n} from \"./admin-fields.tsx\";\nimport {\n EMPTY_ADMIN_RESET,\n type AdminFailureView,\n type AdminField,\n type AdminResetValues,\n} from \"../model/admin-reset.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n BUTTON_BUSY_STYLE,\n BUTTON_STYLE,\n FORM_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 表单状态机(与自助面同范式):\n * - 双锁 = in-flight ref(硬锁,Enter/点击并发都挡)+ submitting 按钮 disabled(可见锁);\n * - IME 组合期由 composing ref 挡住,避免中文输入法确认键提交;\n * - 请求带 AbortController,卸载即 abort,回调再看 mounted,绝不卸载后 setState;\n * - 成功:落成功文案(`sessionsRevoked:false` 走 A8 的安全失败样式),清空四字段,再重拉列表。\n */\n/** 表单事件:IME 组合期(Enter 确认键)一律不提交;并发双锁由 `submit` 自己兜底。 */\nfunction formHandlers(\n composing: MutableRefObject,\n submit: () => Promise,\n): {\n onSubmit: (event: FormEvent) => void;\n onKeyDown: (event: KeyboardEvent) => void;\n onCompositionStart: () => void;\n onCompositionEnd: () => void;\n} {\n return {\n onCompositionStart: () => {\n composing.current = true;\n },\n onCompositionEnd: () => {\n composing.current = false;\n },\n onSubmit: (event) => {\n event.preventDefault();\n if (composing.current) return;\n void submit();\n },\n onKeyDown: (event) => {\n if (event.key !== \"Enter\") return;\n // 焦点在下拉里时 Enter 是\"确认选项\":既不 preventDefault 也不提交(grok 回顾 #5)。\n if (event.target instanceof HTMLSelectElement) return;\n event.preventDefault();\n if (composing.current || event.nativeEvent.isComposing) return;\n void submit();\n },\n };\n}\n\n/** 成功视图(A8):`sessionsRevoked:false` 是安全失败,文案与样式都换一套。 */\nfunction successView(sessionsRevoked: boolean, t: AdminTranslate): AdminSuccessView {\n return sessionsRevoked\n ? { text: t(ADMIN_KEYS.successRevoked), warning: false }\n : { text: t(ADMIN_KEYS.successKept), warning: true };\n}\n\nfunction useAdminReset(\n t: AdminTranslate,\n actorTotpEnabled: boolean,\n onReset: (() => void) | undefined,\n) {\n const [values, setValues] = useState(EMPTY_ADMIN_RESET);\n const [submitting, setSubmitting] = useState(false);\n const [failure, setFailure] = useState(null);\n const [success, setSuccess] = useState(null);\n const lock = useRef(false);\n const composing = useRef(false);\n const abort = useRef(null);\n const mounted = useRef(true);\n\n useEffect(() => {\n // StrictMode 双挂载:effect 会「跑 → 清理 → 再跑」,所以挂载时必须**重新置 true**\n // (只写清理会把 mounted 永久钉在 false → 提交结果全丢、按钮卡在\"提交中\")。\n mounted.current = true;\n return () => {\n mounted.current = false;\n abort.current?.abort();\n };\n }, []);\n\n const setValue = (field: AdminField, value: string): void => {\n setValues((previous) => ({ ...previous, [field]: value }));\n };\n\n const submit = async (): Promise => {\n if (lock.current) return;\n const invalid = validateAdminReset(values, t, actorTotpEnabled);\n if (invalid !== null) {\n setFailure(invalid);\n return;\n }\n lock.current = true;\n setSubmitting(true);\n setFailure(null);\n setSuccess(null);\n const controller = new AbortController();\n abort.current = controller;\n const live = (): boolean => mounted.current && !controller.signal.aborted;\n try {\n const result = await submitAdminReset(\n values,\n { signal: controller.signal, actorTotpEnabled },\n t,\n );\n if (!live()) return;\n if (result.kind === \"ok\") {\n setSuccess(successView(result.sessionsRevoked, t));\n setValues({ ...EMPTY_ADMIN_RESET });\n onReset?.(); // A7:成功后重拉列表刷新徽标;失败路径不动行\n return;\n }\n if (result.kind === \"failure\") setFailure(result.failure);\n } catch {\n if (!live()) return;\n setFailure({ message: t(ADMIN_KEYS.generic), rules: [], fields: [] });\n } finally {\n lock.current = false;\n if (mounted.current) setSubmitting(false);\n }\n };\n\n const handlers = formHandlers(composing, submit);\n return { values, submitting, failure, success, setValue, ...handlers };\n}\n\nexport interface AdminResetFormProps {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n actorTotpEnabled: boolean;\n /** A7:成功回调(管理块用它重拉列表刷新徽标);失败路径不调用。 */\n onReset?: (() => void) | undefined;\n}\n\n/** 管理重置表单:目标下拉(排除本人)+ 新口令 ×2 + 条件式动态码;成功后清空四字段、不跳转。 */\nexport function AdminResetForm({\n t,\n users,\n actorName,\n actorTotpEnabled,\n onReset,\n}: AdminResetFormProps) {\n const form = useAdminReset(t, actorTotpEnabled, onReset);\n const targets = users.filter((user) => user.name !== actorName);\n const fields: { field: AdminField; label: string }[] = [\n { field: \"password\", label: t(ADMIN_KEYS.password) },\n { field: \"confirm\", label: t(ADMIN_KEYS.confirm) },\n ...(actorTotpEnabled ? [{ field: \"code\" as AdminField, label: t(ADMIN_KEYS.code) }] : []),\n ];\n const isInvalid = (field: AdminField): boolean => form.failure?.fields.includes(field) === true;\n const targetInvalid = isInvalid(\"target\");\n return (\n \n

{t(ADMIN_KEYS.selfHint)}

\n
\n \n form.setValue(\"target\", event.target.value)}\n >\n \n {targets.map((user) => (\n \n ))}\n \n
\n {fields.map((item) => (\n form.setValue(item.field, value)}\n />\n ))}\n

{t(ADMIN_KEYS.policyHint)}

\n \n \n {form.submitting ? t(ADMIN_KEYS.submitting) : t(ADMIN_KEYS.submit)}\n \n \n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n fetchAdminUsers,\n AdminResetForm,\n ADMIN_STATUS_ID,\n} from \"../../../features/admin-users/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../../../features/admin-users/index.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n ADMIN_BADGE_STYLE,\n ADMIN_BLOCK_STYLE,\n ADMIN_TABLE_STYLE,\n ADMIN_TD_STYLE,\n ADMIN_TH_STYLE,\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n TITLE_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\nconst TITLE_ID = \"dsh-auth-gate-admin-title\";\n\n/**\n * 列表状态(评审修正 A2):`denied`(403 非 admin / 受限)静默返回 null;\n * `unauthorized`(401 登录态已死)必须提示,不能静默消失;\n * `failure`(503/网络/解析)提示不可用。\n */\ntype UsersState =\n | { kind: \"loading\" }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"ok\"; users: AdminUserRow[] };\n\n/**\n * 挂载后拉一次列表(契约 §3.4:非 admin 根本不会挂载本组件,这里不判断角色);\n * `reload` 供 A7 成功后静默刷新徽标:刷新失败**不改行**(首拉才降级),同一 abort 规则。\n */\nfunction useAdminUsers(): { state: UsersState; reload: () => void } {\n const [state, setState] = useState({ kind: \"loading\" });\n const [attempt, setAttempt] = useState(0);\n useEffect(() => {\n const controller = new AbortController();\n let live = true;\n const first = attempt === 0;\n void fetchAdminUsers(controller.signal)\n .then((result) => {\n if (!live || result.kind === \"aborted\") return;\n if (result.kind === \"ok\") setState({ kind: \"ok\", users: result.users });\n else if (first) setState({ kind: result.kind });\n })\n .catch(() => {\n if (live && first) setState({ kind: \"failure\" });\n });\n return () => {\n live = false;\n controller.abort();\n };\n }, [attempt]);\n return { state, reload: () => setAttempt((value) => value + 1) };\n}\n\n/** 角色文案:`admin`/`user` 走词典;**未知取值原样渲染**,不新增键(A9)。 */\nfunction roleText(role: string, t: AdminTranslate): string {\n if (role === \"admin\") return t(ADMIN_KEYS.roleAdmin);\n if (role === \"user\") return t(ADMIN_KEYS.roleUser);\n return String(role);\n}\n\n/** 状态徽标**互斥**,优先级 disabled > mustChangePassword > ok(A9)。 */\nfunction stateText(user: AdminUserRow, t: AdminTranslate): string {\n if (user.disabled) return t(ADMIN_KEYS.stateDisabled);\n return user.mustChangePassword ? t(ADMIN_KEYS.stateMustChange) : t(ADMIN_KEYS.stateOk);\n}\n\n/** 只读用户表:**保持服务端顺序**,不做任何重排(契约 §1.2 / §3.5)。 */\nfunction AdminUsersTable({\n t,\n users,\n actorName,\n}: {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n}) {\n return (\n \n \n \n \n \n \n \n \n \n \n {users.map((user) => (\n \n \n \n \n \n \n ))}\n \n
{t(ADMIN_KEYS.colUser)}{t(ADMIN_KEYS.colRole)}{t(ADMIN_KEYS.colState)}{t(ADMIN_KEYS.colTotp)}
\n {user.name}\n {user.name === actorName ? (\n {t(ADMIN_KEYS.you)}\n ) : null}\n {roleText(user.role, t)}\n {stateText(user, t)}\n \n \n {user.totpEnabled ? t(ADMIN_KEYS.totpOn) : t(ADMIN_KEYS.totpOff)}\n \n
\n );\n}\n\n/** 提示态(loading / unauthorized / unavailable):同一条 aria-live 播报位。 */\nfunction AdminNotice({ text, error }: { text: string; error?: boolean }) {\n return (\n \n {text}\n

\n );\n}\n\nexport interface AdminUsersBlockProps {\n /** 槽位注入的翻译 seat(含 ADMIN 词典分片)。 */\n t: AdminTranslate;\n /** 当前登录用户名(status.name):用于「本人」标记与下拉排除。 */\n actorName: string;\n /** 当前账号是否启用两步验证:决定 `code` 输入框是否渲染(契约 §3.7)。 */\n actorTotpEnabled: boolean;\n}\n\n/**\n * 管理块(契约 §3 / A2):loading → denied 静默 null → unauthorized / failure 提示\n * → ok 列表 + 表单。「只有本人」时显示 `admin.empty` 且**不渲染表单**(§3.6)。\n */\nexport function AdminUsersBlock({ t, actorName, actorTotpEnabled }: AdminUsersBlockProps) {\n const { state, reload } = useAdminUsers();\n if (state.kind === \"loading\") return ;\n if (state.kind === \"denied\") return null;\n if (state.kind === \"unauthorized\") {\n return ;\n }\n if (state.kind === \"failure\") return ;\n const targets = state.users.filter((user) => user.name !== actorName);\n return (\n
\n

\n {t(ADMIN_KEYS.title)}\n

\n

{t(ADMIN_KEYS.intro)}

\n \n {targets.length === 0 ? (\n

\n {t(ADMIN_KEYS.empty)}\n

\n ) : (\n \n )}\n
\n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n STATUS_PATH,\n type SessionKind,\n type StatusResponse,\n type Unchecked,\n type UserRole,\n} from \"../../../shared/api/index.ts\";\n\n/**\n * `GET /auth/status` 的「关于我」视图:线上形状 `StatusResponse`(跨半边契约 D31)去掉本视图\n * 不用的 `logoutOrder`(由 client 入口单独读)与 `mustChangePassword`。\n *\n * **加法兼容**:身份字段是 PR1 才有的(旧服务端只回 `authenticated`/`logoutOrder`),\n * 任一身份字段缺失即视为「不渲染管理块」,其余行为一字不变(契约 §1.1 / §3.3)。\n */\nexport type AccountStatusView = Omit;\n\n/** 会话探针(只认 cookie,语义同 `/auth/status`,契约 §1.1)。 */\nconst STATUS_TARGET = STATUS_PATH;\n\n/** 字符串字段守卫:非字符串一律 `undefined`(旧服务端/异常形状都不渲染管理块)。 */\nfunction optionalString(value: unknown): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\n/** 布尔字段守卫。 */\nfunction optionalBoolean(value: unknown): boolean | undefined {\n return typeof value === \"boolean\" ? value : undefined;\n}\n\n/** role 白名单:只认 `admin` / `user`,其余(含非法字符串)留 `undefined`。 */\nfunction optionalRole(value: unknown): UserRole | undefined {\n return value === \"admin\" || value === \"user\" ? value : undefined;\n}\n\n/** sessionKind 白名单:只认 `full` / `password-change-only`。 */\nfunction optionalSessionKind(value: unknown): SessionKind | undefined {\n return value === \"full\" || value === \"password-change-only\" ? value : undefined;\n}\n\n/**\n * `/auth/status` 响应体 → 视图对象。**加法兼容**(契约 §1.1/§3.3):\n * 身份字段缺失、类型不符或取值不在白名单内,一律留 `undefined`;只有\n * `authenticated === true` 才可能让调用方继续渲染登录后的 UI。\n */\nfunction parseAccountStatus(body: unknown): AccountStatusView {\n const source = (\n typeof body === \"object\" && body !== null ? body : {}\n ) as Unchecked;\n return {\n authenticated: source.authenticated === true,\n name: optionalString(source.name),\n role: optionalRole(source.role),\n disabled: optionalBoolean(source.disabled),\n totpEnabled: optionalBoolean(source.totpEnabled),\n sessionKind: optionalSessionKind(source.sessionKind),\n };\n}\n\n/**\n * 会话状态探针:`null` = 首次响应返回前(调用方自己渲染 loading)。\n * `credentials: \"same-origin\"` 保证带 cookie;卸载即 abort,回调里再看一眼 signal,\n * 避免卸载后 setState。任何失败(网络/解析/中止之外的异常)→ `{authenticated:false}`。\n */\nexport function useAccountStatus(): AccountStatusView | null {\n const [status, setStatus] = useState(null);\n useEffect(() => {\n const controller = new AbortController();\n fetch(STATUS_TARGET, { signal: controller.signal, credentials: \"same-origin\" })\n .then((res) => res.json() as Promise)\n .then((body) => {\n if (!controller.signal.aborted) setStatus(parseAccountStatus(body));\n })\n .catch(() => {\n if (!controller.signal.aborted) setStatus({ authenticated: false });\n });\n return () => {\n controller.abort();\n };\n }, []);\n return status;\n}\n","import {\n ACCOUNT_DICT_EN,\n ACCOUNT_KEYS,\n translateFrom,\n type AccountTranslate,\n} from \"../../../shared/i18n/index.ts\";\nimport { AccountPasswordForm } from \"../../../features/change-password/index.ts\";\nimport { HINT_STYLE, PANEL_STYLE } from \"../../../shared/ui/index.ts\";\nimport { AdminUsersBlock } from \"./admin-block.tsx\";\nimport { ADMIN_DICT_EN } from \"../../../features/admin-users/index.ts\";\nimport type { AccountStatusView } from \"../../../entities/account/index.ts\";\nimport { useAccountStatus } from \"../../../entities/account/index.ts\";\n\n/** 提示态 id:loading / 未登录共用一个 aria-live 播报位。 */\nconst NOTICE_ID = \"dsh-auth-gate-account-notice\";\n\n/** 无表单的提示态(loading / 请先登录),保留 aria-live 播报。 */\nfunction Notice({ text }: { text: string }) {\n return (\n
\n

\n {text}\n

\n
\n );\n}\n\n/**\n * 管理块渲染门(契约 §9-A1,四条**同时**成立才渲染):`role === \"admin\"`、\n * `disabled === false`、`sessionKind === \"full\"`、`name` 是字符串。\n * **不做 `?? \"full\"` 兜底**:任一字段缺失或非该值 = 不渲染且零管理请求\n * (旧服务端/部分字段与「非 admin 不试拉」加法兼容;禁用 admin 不挂块)。\n */\nfunction showsAdminBlock(\n status: AccountStatusView,\n): status is AccountStatusView & { name: string } {\n return (\n status.role === \"admin\" &&\n status.disabled === false &&\n status.sessionKind === \"full\" &&\n typeof status.name === \"string\"\n );\n}\n\n/**\n * 槽位注入的 props:`t` 来自注册时的 `locale: \"auth\"` seat。\n * 宿主 owner props 还有 `close`;P1.1 起不再往下传(成功后去登录页,而不是关弹窗)。\n */\nexport interface SettingsAccountSectionProps {\n t?: AccountTranslate | undefined;\n}\n\n/**\n * 「账户」设置页(`settings.section`):未登录不给表单;已登录渲染自助改密表单,\n * 并在身份满足渲染门时(admin + 正式会话)追加管理块。`t` 缺失时降级到英文词典\n * (不显示键名),由 account-form / admin-block 承担各自的提交逻辑。\n */\nexport function SettingsAccountSection({ t }: SettingsAccountSectionProps) {\n const status = useAccountStatus();\n // `t` 缺失的降级词典必须同时含 account 与 admin 两片(否则管理块会显示键名,grok 回顾 #1)。\n const translate =\n typeof t === \"function\" ? t : translateFrom({ ...ACCOUNT_DICT_EN, ...ADMIN_DICT_EN });\n if (status === null) return ;\n if (status.authenticated !== true) return ;\n return (\n <>\n \n {showsAdminBlock(status) ? (\n \n ) : null}\n \n );\n}\n","import { useEffect, useState, type CSSProperties } from \"react\";\nimport { LOGOUT_URL, STATUS_PATH } from \"../../../shared/api/index.ts\";\n\n/** 登出目标:POST-only(M22:next 仅从 query 取,校验回落 /)。 */\nconst LOGOUT_TARGET = LOGOUT_URL;\n\n/**\n * 登出图标:16px 按钮图标(viewBox 24 不变,只设 width/height 16)。\n * 沿用原 32px 圆形按钮的同一个 SVG(方框 + 箭头)。\n */\nfunction renderLogoutIcon() {\n return (\n \n \n \n \n \n );\n}\n\n/**\n * 设置面板内醒目的登出 CTA:错误强调色(危险动作语义)填充按钮 +\n * 反色标签 `--dsw-alias-label-primary-inverted`,面板内水平居中(General 页底部)。\n */\nconst CTA_STYLE: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"10px 24px\",\n borderRadius: 12,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n background: \"var(--dsw-alias-state-error-primary)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** hover 态轻微提亮(随主题自适应,不硬编码色值)。 */\nconst CTA_HOVER_FILTER = \"brightness(1.08)\";\n\n/** 面板内水平居中容器(General 页最后一条行之后)。 */\nconst CTA_WRAP_STYLE: CSSProperties = {\n display: \"flex\",\n justifyContent: \"center\",\n padding: \"20px 0 4px\",\n};\n\nconst formStyle: CSSProperties = {\n display: \"contents\",\n};\n\n/**\n * 会话状态门控:挂载时 fetch /auth/status 一次(只认 cookie)。\n * @returns authenticated:null = 未知(第一次请求前),true/false。\n */\nfunction useAuthenticated(): boolean | null {\n const [authenticated, setAuthenticated] = useState(null);\n useEffect(() => {\n let cancelled = false;\n fetch(STATUS_PATH)\n .then((res) => res.json())\n .then((body: { authenticated?: unknown }) => {\n if (!cancelled) setAuthenticated(body.authenticated === true);\n })\n .catch(() => {\n if (!cancelled) setAuthenticated(false);\n });\n return () => {\n cancelled = true;\n };\n }, []);\n return authenticated;\n}\n\n/** 槽位渲染器按注册 `locale` 注入的 translate 形(本插件 `auth` 词典的 `logout` 键)。 */\nexport type LogoutTranslate = (key: string, params?: Record) => string;\n\n/** 设置面板里的登出按钮组件(`settings.general.item` 槽,root 作用域)。 */\nexport interface SettingsLogoutActionProps {\n /** 注入的本地化 translate(locale seat)。 */\n t?: LogoutTranslate;\n}\n\n/**\n * 可复用的登出提交按钮:原生 form POST(零 JS 依赖)+ 16px 方块图标 + 本地化文字。\n * 渲染进 `settings.general.item`(设置 → 通用设置 的追加行槽,order 30 → 页面底部),\n * 水平居中的醒目 CTA;文案随界面语言在「退出登录」/ \"Sign out\" 间切换。\n */\nexport function SettingsLogoutAction({ t }: SettingsLogoutActionProps) {\n const authenticated = useAuthenticated();\n const [hovered, setHovered] = useState(false);\n if (authenticated !== true) return null;\n const label = typeof t === \"function\" ? t(\"logout\") : \"Sign out\";\n return (\n
\n
\n setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n >\n {renderLogoutIcon()}\n {label}\n \n
\n
\n );\n}\n","import { ACCOUNT_DICT_EN, ACCOUNT_DICT_ZH, ACCOUNT_KEYS } from \"./shared/i18n/index.ts\";\nimport { installAccountNavIcon } from \"./features/nav-icon/index.ts\";\nimport { SettingsAccountSection } from \"./widgets/account-section/index.ts\";\nimport { ADMIN_DICT_EN, ADMIN_DICT_ZH } from \"./features/admin-users/index.ts\";\nimport {\n STATUS_PATH,\n type AuthContext,\n type StatusResponse,\n type Unchecked,\n} from \"./shared/api/index.ts\";\nimport { SettingsLogoutAction } from \"./features/logout/index.ts\";\n\n/** 本插件文案的词典命名域(locale 服务按 (ns, locale) 分开注册)。 */\nconst AUTH_NS = \"auth\";\n/** 命名词典里登出键。 */\nconst LOGOUT_KEY = \"logout\";\n\n/** 「账号安全」设置页(自助改密)的 section id。 */\nconst ACCOUNT_SECTION_ID = \"dsh-auth-gate-account\";\n/**\n * 「账号安全」页在设置导航里的排列位置。\n *\n * 已知占用:宿主 general 0 / models 10 / plugins 15 / agent-presets 20(0.1.7 起还有官方云账户\n * `account` -10),姊妹包 dsh-plugin-subscriptions 的订阅页 90,全部远小于 900,所以 900\n * 表示「大于已知的全部条目、落在导航靠后」(账号安全压轴是设置页惯例,宿主 nav 列也没有滚动条)。\n *\n * **不保证全局最后**:宿主只按 order 升序排、没有并列决胜(同值按插件注册顺序,跨安装不稳定),\n * 后来者随时可能注册更大或相同的值。谁先到谁先用 900,撞车时按 901/902 递增(ADR D24 已记录)。\n */\nconst ACCOUNT_SECTION_ORDER = 900;\n\n/**\n * 默认槽位 order:注册时先用它(与 host 端 Config 默认一致),随后 `/auth/status`\n * 探针读到 host 配置的 `logoutOrder` 时按配置重注册。1000 已大于 dsh 自带条目\n * (agent-preset -25 / permission -20 / language 0 / appearance 10 / composer-enter 20),\n * 除非第三方插件注册更大的 order,按钮始终留在通用设置页最底部。\n */\nconst DEFAULT_LOGOUT_ORDER = 1000;\n\n/**\n * dsh-auth-gate client 半边:认证后在**设置面板**(设置 → 通用设置 页底部)挂一\n * 个醒目的「退出登录 / Sign out」按钮:`settings.general.item`(root 作用域、\n * 可追加列表槽,由 ui-settings-general 的 General 页堆叠渲染,按 order 升序)。\n *\n * 顺序可配置:先以默认 order(1000)注册(探针失败/未开始前按钮也可见),再探\n * `/auth/status` 读取 host 配置的 `logoutOrder`,与默认不同则按配置值重注册\n * (同 id 注册 = 槽位替换,先注册新条目再释放旧条目,避免中间态空白)。\n *\n * 换槽对比:不再往会话页 `conversation.session.header.utilities`、新会话页\n * `shell.overlay` 注册(右上角两处入口已移除),也不占侧边栏 footer 脚区。\n * 文案挂进 dsh 现有的 locale 机制(与「设置」里的语言切换同一套):注册 `auth`\n * 词典(zh/en),再以 `locale: \"auth\"` 给注册条目注入 `t` seat,按钮文字随界面\n * 语言在「退出登录」/ \"Sign out\" 间实时切换。不改任何服务端端点/会话语义。\n *\n * 同一次 apply 还注册「账号安全」设置页(`settings.section`,id\n * dsh-auth-gate-account,order 900):内容为自助改密表单(见 account-section.tsx),\n * 文案复用同一个 `auth` 词典的 account 键。登出按钮的注册逻辑与 order 语义不变。\n *\n * 导航行图标(D24.1):宿主 `settings.section` 没有 `icon` 选项(只有 id/order/label),\n * 第三方段的图标恒为宿主默认齿轮;插件侧没有官方挂载点,所以 `account-nav-icon.ts` 只对\n * 我们这一行做一次临时 DOM 垫片(自设计盾牌图标),上游支持 `icon` 后整体删除。\n */\nexport const inject = [\"slots\", \"locale\"];\n\nexport function apply(ctx: AuthContext): void {\n // 词典注册(zh/en 双语,挂 fiber 卸载级联):登出 CTA + 账户页 + 管理块共用 auth\n // 命名域。admin 键(`admin.*`)与 account 键(`account.*`)、`logout` 不重名,可安全合并。\n ctx.effect(\n () => [\n ctx.locale.register(AUTH_NS, \"zh\", {\n [LOGOUT_KEY]: \"退出登录\",\n ...ACCOUNT_DICT_ZH,\n ...ADMIN_DICT_ZH,\n }),\n ctx.locale.register(AUTH_NS, \"en\", {\n [LOGOUT_KEY]: \"Sign out\",\n ...ACCOUNT_DICT_EN,\n ...ADMIN_DICT_EN,\n }),\n ],\n \"auth: zh/en dictionaries (logout + account + admin)\",\n );\n\n // 绑定 translate:读取活动语言(thunk 每次投影重读,跟随语言切换)。\n const t = ctx.locale.bind(AUTH_NS);\n\n ctx.slots.inject(\"settings.general.item\", () => {\n const mount = (order: number): (() => void) =>\n ctx.slots.register(\n {\n name: \"settings.general.item\",\n id: \"dsh-auth-gate-logout\",\n locale: AUTH_NS,\n order,\n label: () => t(LOGOUT_KEY),\n },\n SettingsLogoutAction,\n );\n let dispose: (() => void) | undefined = mount(DEFAULT_LOGOUT_ORDER);\n // 与 SettingsLogoutAction 相同的 status 探针:读 host 的 logoutOrder 配置并在\n // 不同时重注册。探针抛错(如测试环境无 fetch)保持默认,绝不吞掉注册。\n try {\n void fetch(STATUS_PATH)\n .then((res) => res.json() as Promise>)\n .then((body: Unchecked) => {\n const order = body.logoutOrder;\n if (\n typeof order !== \"number\" ||\n !Number.isInteger(order) ||\n order === DEFAULT_LOGOUT_ORDER\n ) {\n return;\n }\n const previous = dispose;\n dispose = mount(order);\n previous?.();\n })\n .catch(() => undefined);\n } catch {\n // fetch 不可用(测试环境/旧浏览器):保持默认 order。\n }\n return () => dispose?.();\n });\n\n // 「账号安全」设置页:注册 `settings.section`(root 作用域、可追加列表槽,导航按\n // order 升序)。内容是自助改密表单,未登录由组件自查 `/auth/status` 后隐藏。\n ctx.slots.inject(\"settings.section\", () =>\n ctx.slots.register(\n {\n name: \"settings.section\",\n id: ACCOUNT_SECTION_ID,\n locale: AUTH_NS,\n order: ACCOUNT_SECTION_ORDER,\n label: () => t(ACCOUNT_KEYS.nav),\n },\n SettingsAccountSection,\n ),\n );\n\n // 导航行图标垫片(D24.1):只替换我们自己那一行,找不到就保持宿主齿轮;没有 DOM 的环境\n // (node 单测)直接 no-op。上游给 `settings.section` 加 `icon` 后,这一行与\n // account-nav-icon.ts 一起删除。\n ctx.effect(\n () => installAccountNavIcon(globalThis.document),\n \"auth: account nav icon shim (D24.1)\",\n );\n}\n"],"mappings":"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"} \ No newline at end of file diff --git a/lib/host/entities/session/index.d.ts b/lib/host/entities/session/index.d.ts index 18ebcbf..2c14621 100644 --- a/lib/host/entities/session/index.d.ts +++ b/lib/host/entities/session/index.d.ts @@ -3,7 +3,7 @@ * token / password 两个认证面共同消费;跨 slice import 只能走本 barrel。 * 2026-09-13:由 `export *` 改为显式清单;新增导出必须在此登记。 */ -export { buildSetCookie, COOKIE_FLAGS, digestToken, sessionDomainSpec, SessionStore, } from "./model/session-store.js"; +export { buildSetCookie, COOKIE_FLAGS, digestToken, isBootstrapSession, sessionDomainSpec, SessionStore, } from "./model/session-store.js"; export type { IssuedSession, Session } from "./model/session-store.js"; export { handleLogout, handleStatus } from "./api/session-endpoints.js"; export type { AuthHttpDeps } from "./api/session-endpoints.js"; diff --git a/lib/host/entities/session/index.d.ts.map b/lib/host/entities/session/index.d.ts.map index d202afb..2b5f74a 100644 --- a/lib/host/entities/session/index.d.ts.map +++ b/lib/host/entities/session/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/entities/session/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,WAAW,EACX,iBAAiB,EACjB,YAAY,GACb,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AACxE,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/entities/session/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,GACb,MAAM,0BAA0B,CAAC;AAClC,YAAY,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AACxE,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC"} \ No newline at end of file diff --git a/lib/host/entities/session/index.js b/lib/host/entities/session/index.js index 2c8611f..74b2a72 100644 --- a/lib/host/entities/session/index.js +++ b/lib/host/entities/session/index.js @@ -3,6 +3,6 @@ * token / password 两个认证面共同消费;跨 slice import 只能走本 barrel。 * 2026-09-13:由 `export *` 改为显式清单;新增导出必须在此登记。 */ -export { buildSetCookie, COOKIE_FLAGS, digestToken, sessionDomainSpec, SessionStore, } from "./model/session-store.js"; +export { buildSetCookie, COOKIE_FLAGS, digestToken, isBootstrapSession, sessionDomainSpec, SessionStore, } from "./model/session-store.js"; export { handleLogout, handleStatus } from "./api/session-endpoints.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/host/entities/session/index.js.map b/lib/host/entities/session/index.js.map index 8b96c23..ed19599 100644 --- a/lib/host/entities/session/index.js.map +++ b/lib/host/entities/session/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/entities/session/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,WAAW,EACX,iBAAiB,EACjB,YAAY,GACb,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/entities/session/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,GACb,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC"} \ No newline at end of file diff --git a/lib/host/entities/session/model/session-store.d.ts b/lib/host/entities/session/model/session-store.d.ts index 12b960c..c44f49b 100644 --- a/lib/host/entities/session/model/session-store.d.ts +++ b/lib/host/entities/session/model/session-store.d.ts @@ -1,5 +1,5 @@ import { type KvTable } from "@deepseek-ai/dsh-storage-domain"; -import type { SessionKind } from "../../../shared/protocol/index.js"; +import { type SessionKind } from "../../../shared/protocol/index.js"; /** 会话 cookie 属性(M2 起使用;M1 冻结并测试)。 */ export declare const COOKIE_FLAGS = "Path=/; HttpOnly; Secure; SameSite=Lax"; /** @@ -28,6 +28,11 @@ export interface IssuedSession { token: string; session: Session; } +/** + * 是否 bootstrap 会话(只读 `kind`;缺省 / 其它类别一律 false)。 + * 门用它把「只放行 `/auth/*`」的临时会话与正式会话分开,不做任何 IO。 + */ +export declare function isBootstrapSession(session: Session | undefined): boolean; /** 键 = digest(sha256 hex),不进 row;row 只存以上四字段。 */ export declare const sessionDomainSpec: { name: string; @@ -38,7 +43,7 @@ export declare const sessionDomainSpec: { createdAt: number; expiresAt: number; revoked: boolean; - kind?: "full" | "password-change-only" | undefined; + kind?: "full" | "password-change-only" | "bootstrap" | undefined; }>; }; }; @@ -57,6 +62,12 @@ export declare class SessionStore { * 行内没有主体索引,故按 row.subject 全表扫描(会话量级为单机用户数,成本可忽略)。 */ revokeBySubject(subject: string): Promise; + /** + * 吊销某个类别的**全部**会话行,返回吊销条数(写盘)。 + * 用途:首次配置向导成功后关掉引导窗口(`bootstrap` 会话一律失效)。 + * 行内没有类别索引,故按 row.kind 全表扫描(与 `revokeBySubject` 同款理由)。 + */ + revokeByKind(kind: SessionKind): Promise; /** 全表扫描删除过期行,返回删除数。 */ pruneExpired(now?: number): Promise; } diff --git a/lib/host/entities/session/model/session-store.d.ts.map b/lib/host/entities/session/model/session-store.d.ts.map index f6c20da..1994a3d 100644 --- a/lib/host/entities/session/model/session-store.d.ts.map +++ b/lib/host/entities/session/model/session-store.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../../../../../src/host/entities/session/model/session-store.ts"],"names":[],"mappings":"AACA,OAAO,EAA6B,KAAK,OAAO,EAAE,MAAM,iCAAiC,CAAC;AAE1F,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAC;AAErE,qCAAqC;AACrC,eAAO,MAAM,YAAY,2CAA2C,CAAC;AAErE;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,EACrB,MAAM,UAAO,GACZ,MAAM,CAER;AAED,0DAA0D;AAC1D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,MAAM,WAAW,OAAO;IACtB,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB;AAaD,iDAAiD;AACjD,eAAO,MAAM,iBAAiB;;;;;;;;;;;;CAK5B,CAAC;AAEH,6DAA6D;AAC7D,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA2B;gBAErC,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC;IAIrC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,WAAoB,GAAG,OAAO,CAAC,aAAa,CAAC;IAehG,2CAA2C;IAC3C,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS;IAQ9C,qCAAqC;IACrC,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI9C;;;;OAIG;IACG,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAUvD,uBAAuB;IACjB,YAAY,CAAC,GAAG,GAAE,MAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;CAU9D"} \ No newline at end of file +{"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../../../../../src/host/entities/session/model/session-store.ts"],"names":[],"mappings":"AACA,OAAO,EAA6B,KAAK,OAAO,EAAE,MAAM,iCAAiC,CAAC;AAE1F,OAAO,EAA0B,KAAK,WAAW,EAAE,MAAM,mCAAmC,CAAC;AAE7F,qCAAqC;AACrC,eAAO,MAAM,YAAY,2CAA2C,CAAC;AAErE;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,EACrB,MAAM,UAAO,GACZ,MAAM,CAER;AAED,0DAA0D;AAC1D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,MAAM,WAAW,OAAO;IACtB,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAExE;AAaD,iDAAiD;AACjD,eAAO,MAAM,iBAAiB;;;;;;;;;;;;CAK5B,CAAC;AAEH,6DAA6D;AAC7D,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA2B;gBAErC,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC;IAIrC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,WAAoB,GAAG,OAAO,CAAC,aAAa,CAAC;IAehG,2CAA2C;IAC3C,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS;IAQ9C,qCAAqC;IACrC,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI9C;;;;OAIG;IACG,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAUvD;;;;OAIG;IACG,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IAUtD,uBAAuB;IACjB,YAAY,CAAC,GAAG,GAAE,MAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;CAU9D"} \ No newline at end of file diff --git a/lib/host/entities/session/model/session-store.js b/lib/host/entities/session/model/session-store.js index 87c911f..64db7ea 100644 --- a/lib/host/entities/session/model/session-store.js +++ b/lib/host/entities/session/model/session-store.js @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain"; import { z } from "zod"; +import { BOOTSTRAP_SESSION_KIND } from "../../../shared/protocol/index.js"; /** 会话 cookie 属性(M2 起使用;M1 冻结并测试)。 */ export const COOKIE_FLAGS = "Path=/; HttpOnly; Secure; SameSite=Lax"; /** @@ -14,6 +15,13 @@ export function buildSetCookie(cookieName, token, maxAgeSeconds, secure = true) export function digestToken(token) { return createHash("sha256").update(token).digest("hex"); } +/** + * 是否 bootstrap 会话(只读 `kind`;缺省 / 其它类别一律 false)。 + * 门用它把「只放行 `/auth/*`」的临时会话与正式会话分开,不做任何 IO。 + */ +export function isBootstrapSession(session) { + return session?.kind === BOOTSTRAP_SESSION_KIND; +} const sessionRowSchema = z.object({ subject: z.string(), createdAt: z.number().int().nonnegative(), @@ -22,7 +30,7 @@ const sessionRowSchema = z.object({ // 可选:P2 之前写入的行没有该字段,必须继续可解析(缺省按 full 处理)。 // 注意:本仓库的 zod 是 v4(`z.enum`/`z.literal`),没有 `z.const` // (后者是 @deepseek-ai/schemastery 的 API,见 src/index.ts 的 Config)。 - kind: z.enum(["full", "password-change-only"]).optional(), + kind: z.enum(["full", "password-change-only", "bootstrap"]).optional(), }); /** 键 = digest(sha256 hex),不进 row;row 只存以上四字段。 */ export const sessionDomainSpec = defineDomain({ @@ -78,6 +86,21 @@ export class SessionStore { } return count; } + /** + * 吊销某个类别的**全部**会话行,返回吊销条数(写盘)。 + * 用途:首次配置向导成功后关掉引导窗口(`bootstrap` 会话一律失效)。 + * 行内没有类别索引,故按 row.kind 全表扫描(与 `revokeBySubject` 同款理由)。 + */ + async revokeByKind(kind) { + let count = 0; + for (const [key, row] of this.table.entries()) { + if (row.kind !== kind) + continue; + await this.table.delete(key); + count += 1; + } + return count; + } /** 全表扫描删除过期行,返回删除数。 */ async pruneExpired(now = Date.now()) { let count = 0; diff --git a/lib/host/entities/session/model/session-store.js.map b/lib/host/entities/session/model/session-store.js.map index 9f951ce..0c5b609 100644 --- a/lib/host/entities/session/model/session-store.js.map +++ b/lib/host/entities/session/model/session-store.js.map @@ -1 +1 @@ -{"version":3,"file":"session-store.js","sourceRoot":"","sources":["../../../../../src/host/entities/session/model/session-store.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"session-store.js","sourceRoot":"","sources":["../../../../../src/host/entities/session/model/session-store.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts b/lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts new file mode 100644 index 0000000..b32f910 --- /dev/null +++ b/lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts @@ -0,0 +1,28 @@ +import type { HttpHandler } from "../../../shared/guard/index.js"; +import { type SessionStore } from "../../../entities/session/index.js"; +import type { ConfiguredProbe } from "../model/probe.js"; +export interface BootstrapEndpointDeps { + /** 未配置探针(每次请求现算)。 */ + probe: ConfiguredProbe; + /** 当前进程的 launch token(与登录后桥读到的是同一枚)。 */ + readLaunchToken: () => string | undefined; + sessions: () => SessionStore | undefined; + cookieName: string; + cookieSecure: boolean; + sessionTtlSeconds: number; + logger: { + info(message: unknown): void; + warn(message: unknown): void; + error(message: unknown): void; + }; +} +/** + * `GET /auth/bootstrap?token=`:校验 launch token、确认实例仍然未配置, + * 然后签发一枚 `bootstrap` 会话并 302 到 `/auth/setup`。 + * + * 这里是首次引导通道唯一的校验点:门只按请求形状重定向过来,不做校验也不做 IO。 + * 任何失败分支(形状不对、token 不对、已配置、探针 unknown、会话存储缺失、异常)都回到 + * `/auth/login?next=%2F` 且不下发 cookie,绝不放行未认证请求给 dsh。 + */ +export declare function makeBootstrapEndpoint(deps: BootstrapEndpointDeps): HttpHandler; +//# sourceMappingURL=bootstrap-endpoint.d.ts.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts.map b/lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts.map new file mode 100644 index 0000000..76fceb1 --- /dev/null +++ b/lib/host/features/bootstrap/api/bootstrap-endpoint.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"bootstrap-endpoint.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/api/bootstrap-endpoint.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAIlE,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAMzD,MAAM,WAAW,qBAAqB;IACpC,qBAAqB;IACrB,KAAK,EAAE,eAAe,CAAC;IACvB,wCAAwC;IACxC,eAAe,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAC1C,QAAQ,EAAE,MAAM,YAAY,GAAG,SAAS,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,MAAM,EAAE;QACN,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;QAC7B,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;QAC7B,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;KAC/B,CAAC;CACH;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,qBAAqB,GAAG,WAAW,CAwC9E"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/bootstrap-endpoint.js b/lib/host/features/bootstrap/api/bootstrap-endpoint.js new file mode 100644 index 0000000..5c51915 --- /dev/null +++ b/lib/host/features/bootstrap/api/bootstrap-endpoint.js @@ -0,0 +1,56 @@ +import { methodNotAllowed, queryOf, singleTokenParam } from "../../../shared/http/index.js"; +import { safeEqual } from "../../../shared/crypto/index.js"; +import { BOOTSTRAP_SESSION_KIND, SETUP_PATH } from "../../../shared/protocol/index.js"; +import { buildSetCookie } from "../../../entities/session/index.js"; +import { redirectTo, redirectToLogin } from "../model/respond.js"; +/** bootstrap 会话的最长寿命:窗口再长也没有意义,且它只放行 `/auth/*`。 */ +const BOOTSTRAP_TTL_CAP_SECONDS = 3600; +/** + * `GET /auth/bootstrap?token=`:校验 launch token、确认实例仍然未配置, + * 然后签发一枚 `bootstrap` 会话并 302 到 `/auth/setup`。 + * + * 这里是首次引导通道唯一的校验点:门只按请求形状重定向过来,不做校验也不做 IO。 + * 任何失败分支(形状不对、token 不对、已配置、探针 unknown、会话存储缺失、异常)都回到 + * `/auth/login?next=%2F` 且不下发 cookie,绝不放行未认证请求给 dsh。 + */ +export function makeBootstrapEndpoint(deps) { + return async (req, res) => { + if (req.method !== "GET") { + methodNotAllowed(res, "GET"); + return; + } + try { + const token = singleTokenParam(queryOf(req)); + if (token === undefined || !launchTokenMatches(deps.readLaunchToken(), token)) { + redirectToLogin(res); + return; + } + if ((await deps.probe.state()) !== "unconfigured") { + redirectToLogin(res); + return; + } + const store = deps.sessions(); + if (store === undefined) { + deps.logger.error("bootstrap refused: session store unavailable"); + redirectToLogin(res); + return; + } + const ttlSeconds = Math.min(deps.sessionTtlSeconds, BOOTSTRAP_TTL_CAP_SECONDS); + const issued = await store.create(BOOTSTRAP_SESSION_KIND, ttlSeconds * 1000, BOOTSTRAP_SESSION_KIND); + res.setHeader("set-cookie", buildSetCookie(deps.cookieName, issued.token, ttlSeconds, deps.cookieSecure)); + deps.logger.info("bootstrap session issued"); + redirectTo(res, SETUP_PATH); + } + catch { + // fail-closed:响应已写出就不补写,未写出则退回登录页。绝不记录 token 本身。 + if (res.headersSent !== true) + redirectToLogin(res); + deps.logger.warn("bootstrap failed: unexpected error while issuing the session"); + } + }; +} +/** 常量时间比对;任一侧为空一律不匹配(空 launch token 不是凭证)。 */ +function launchTokenMatches(launch, token) { + return launch !== undefined && launch !== "" && safeEqual(token, launch); +} +//# sourceMappingURL=bootstrap-endpoint.js.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/bootstrap-endpoint.js.map b/lib/host/features/bootstrap/api/bootstrap-endpoint.js.map new file mode 100644 index 0000000..0154f6c --- /dev/null +++ b/lib/host/features/bootstrap/api/bootstrap-endpoint.js.map @@ -0,0 +1 @@ +{"version":3,"file":"bootstrap-endpoint.js","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/api/bootstrap-endpoint.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAC5F,OAAO,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAC5D,OAAO,EAAE,sBAAsB,EAAE,UAAU,EAAE,MAAM,mCAAmC,CAAC;AACvF,OAAO,EAAE,cAAc,EAAqB,MAAM,oCAAoC,CAAC;AAEvF,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAElE,mDAAmD;AACnD,MAAM,yBAAyB,GAAG,IAAI,CAAC;AAkBvC;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAA2B;IAC/D,OAAO,KAAK,EAAE,GAAoB,EAAE,GAAmB,EAAiB,EAAE;QACxE,IAAI,GAAG,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;YACzB,gBAAgB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAC7B,OAAO;QACT,CAAC;QACD,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;YAC7C,IAAI,KAAK,KAAK,SAAS,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,eAAe,EAAE,EAAE,KAAK,CAAC,EAAE,CAAC;gBAC9E,eAAe,CAAC,GAAG,CAAC,CAAC;gBACrB,OAAO;YACT,CAAC;YACD,IAAI,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,KAAK,cAAc,EAAE,CAAC;gBAClD,eAAe,CAAC,GAAG,CAAC,CAAC;gBACrB,OAAO;YACT,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC9B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAC;gBAClE,eAAe,CAAC,GAAG,CAAC,CAAC;gBACrB,OAAO;YACT,CAAC;YACD,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,iBAAiB,EAAE,yBAAyB,CAAC,CAAC;YAC/E,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,MAAM,CAC/B,sBAAsB,EACtB,UAAU,GAAG,IAAI,EACjB,sBAAsB,CACvB,CAAC;YACF,GAAG,CAAC,SAAS,CACX,YAAY,EACZ,cAAc,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,CAAC,YAAY,CAAC,CAC7E,CAAC;YACF,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;YAC7C,UAAU,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC;QAC9B,CAAC;QAAC,MAAM,CAAC;YACP,iDAAiD;YACjD,IAAI,GAAG,CAAC,WAAW,KAAK,IAAI;gBAAE,eAAe,CAAC,GAAG,CAAC,CAAC;YACnD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,8DAA8D,CAAC,CAAC;QACnF,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,8CAA8C;AAC9C,SAAS,kBAAkB,CAAC,MAA0B,EAAE,KAAa;IACnE,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,IAAI,SAAS,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAC3E,CAAC"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/continue-endpoint.d.ts b/lib/host/features/bootstrap/api/continue-endpoint.d.ts new file mode 100644 index 0000000..4315e51 --- /dev/null +++ b/lib/host/features/bootstrap/api/continue-endpoint.d.ts @@ -0,0 +1,22 @@ +import type { HttpHandler } from "../../../shared/guard/index.js"; +import { type LaunchTokenBridge } from "../../../shared/http/index.js"; +import { type SessionStore } from "../../../entities/session/index.js"; +export interface ContinueEndpointDeps { + sessions: () => SessionStore | undefined; + cookieName: string; + /** dsh 的 launch token 桥;不可用时回落到 `/`,让 dsh 自己决定怎么认人。 */ + bridge: LaunchTokenBridge | undefined; + logger: { + warn?(message: unknown): void; + }; +} +/** + * `GET /auth/continue`:向导完成后的最后一跳,302 到 dsh 的 `/?token=`, + * 由 dsh 自己 mint 浏览器 cookie。 + * + * 只认 cookie 里的**正式**会话:没有会话、会话失效、或拿的是 bootstrap 会话,一律回登录页 + * (且不回显 query),不靠「跳到 `/` 再被门弹回向导」兜底。它不认 query,也**不签发**任何 + * 会话,所以不会和数据面形成循环。桥缺失 / 抛错 → 回落到 `/`。 + */ +export declare function makeContinueEndpoint(deps: ContinueEndpointDeps): HttpHandler; +//# sourceMappingURL=continue-endpoint.d.ts.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/continue-endpoint.d.ts.map b/lib/host/features/bootstrap/api/continue-endpoint.d.ts.map new file mode 100644 index 0000000..d706d36 --- /dev/null +++ b/lib/host/features/bootstrap/api/continue-endpoint.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"continue-endpoint.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/api/continue-endpoint.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAClE,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,oCAAoC,CAAC;AAG5C,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,YAAY,GAAG,SAAS,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,MAAM,EAAE,iBAAiB,GAAG,SAAS,CAAC;IACtC,MAAM,EAAE;QAAE,IAAI,CAAC,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;KAAE,CAAC;CAC3C;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,oBAAoB,GAAG,WAAW,CAa5E"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/continue-endpoint.js b/lib/host/features/bootstrap/api/continue-endpoint.js new file mode 100644 index 0000000..761be93 --- /dev/null +++ b/lib/host/features/bootstrap/api/continue-endpoint.js @@ -0,0 +1,34 @@ +import { methodNotAllowed, parseCookieHeader, resolvePostLoginLocation, } from "../../../shared/http/index.js"; +import { isBootstrapSession, } from "../../../entities/session/index.js"; +import { redirectTo, redirectToLogin } from "../model/respond.js"; +/** + * `GET /auth/continue`:向导完成后的最后一跳,302 到 dsh 的 `/?token=`, + * 由 dsh 自己 mint 浏览器 cookie。 + * + * 只认 cookie 里的**正式**会话:没有会话、会话失效、或拿的是 bootstrap 会话,一律回登录页 + * (且不回显 query),不靠「跳到 `/` 再被门弹回向导」兜底。它不认 query,也**不签发**任何 + * 会话,所以不会和数据面形成循环。桥缺失 / 抛错 → 回落到 `/`。 + */ +export function makeContinueEndpoint(deps) { + return async (req, res) => { + if (req.method !== "GET") { + methodNotAllowed(res, "GET"); + return; + } + const session = sessionOf(deps, req); + if (session === undefined || isBootstrapSession(session)) { + redirectToLogin(res); + return; + } + redirectTo(res, await resolvePostLoginLocation(deps.bridge, "/", deps.logger)); + }; +} +/** 会话行(只认 cookie):无 store / 无 cookie / 已吊销 / 已过期 → undefined。 */ +function sessionOf(deps, req) { + const store = deps.sessions(); + if (store === undefined) + return undefined; + const cookie = parseCookieHeader(req.headers.cookie, deps.cookieName); + return cookie === undefined || cookie === "" ? undefined : store.getByToken(cookie); +} +//# sourceMappingURL=continue-endpoint.js.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/continue-endpoint.js.map b/lib/host/features/bootstrap/api/continue-endpoint.js.map new file mode 100644 index 0000000..c52d1d2 --- /dev/null +++ b/lib/host/features/bootstrap/api/continue-endpoint.js.map @@ -0,0 +1 @@ +{"version":3,"file":"continue-endpoint.js","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/api/continue-endpoint.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,wBAAwB,GAEzB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EACL,kBAAkB,GAGnB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAUlE;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAA0B;IAC7D,OAAO,KAAK,EAAE,GAAoB,EAAE,GAAmB,EAAiB,EAAE;QACxE,IAAI,GAAG,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;YACzB,gBAAgB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAC7B,OAAO;QACT,CAAC;QACD,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QACrC,IAAI,OAAO,KAAK,SAAS,IAAI,kBAAkB,CAAC,OAAO,CAAC,EAAE,CAAC;YACzD,eAAe,CAAC,GAAG,CAAC,CAAC;YACrB,OAAO;QACT,CAAC;QACD,UAAU,CAAC,GAAG,EAAE,MAAM,wBAAwB,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IACjF,CAAC,CAAC;AACJ,CAAC;AAED,iEAAiE;AACjE,SAAS,SAAS,CAAC,IAA0B,EAAE,GAAoB;IACjE,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;IAC9B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC1C,MAAM,MAAM,GAAG,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;IACtE,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AACtF,CAAC"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/setup-endpoint.d.ts b/lib/host/features/bootstrap/api/setup-endpoint.d.ts new file mode 100644 index 0000000..20b93b7 --- /dev/null +++ b/lib/host/features/bootstrap/api/setup-endpoint.d.ts @@ -0,0 +1,48 @@ +import type { ServerResponse } from "node:http"; +import type { HttpHandler } from "../../../shared/guard/index.js"; +import type { SessionStore } from "../../../entities/session/index.js"; +import type { ConfiguredProbe } from "../model/probe.js"; +/** token 模式的写入结果:`saved-unreadable` = 写盘成功但当前进程读不回新值。 */ +export interface SetupTokenOutcome { + status: "saved" | "saved-unreadable" | "failed"; + /** 只在 saved / saved-unreadable 时给页面渲染一次。 */ + token?: string | undefined; +} +export interface SetupEndpointDeps { + mode: "token" | "password"; + /** 未配置探针:每次渲染/提交前复查,配置一旦成立就拒绝继续写盘。 */ + probe: ConfiguredProbe; + sessions: () => SessionStore | undefined; + cookieName: string; + cookieSecure: boolean; + sessionTtlSeconds: number; + publicHost: string; + tokenRef: string; + usersPath: string; + /** token 模式:服务端自己生成并保存访问令牌(没有 body 参数)。 */ + saveToken: () => Promise; + /** password 模式:建第一个管理员;策略由实现方判定。 */ + createAdmin: (username: string, password: string) => Promise<"ok" | "conflict" | "invalid" | "failed">; + /** 向导完成的正式会话(写 Set-Cookie):token 模式 subject=token,password 模式=用户名。 */ + issueFullSession: (subject: string, res: ServerResponse) => Promise; + logger: { + info(message: unknown): void; + warn(message: unknown): void; + error(message: unknown): void; + }; +} +export interface SetupHandlers { + get: HttpHandler; + post: HttpHandler; +} +/** + * 首次配置向导的两个方法(`/auth/setup`:GET 渲染、POST 提交;webserver 的 exact 表按 + * pathname 建键,GET/POST 不能注册成两条路由)。 + * + * 三条硬约束:只有有效的 `bootstrap` 会话能进(正式会话看到「已配置」页,无会话回登录页); + * **每次 GET/POST 都复查未配置探针**:bootstrap 会话能跨重启活最多 1 小时,期间运维可能已经 + * 用 CLI 补好凭据,配置一旦成立就不能再从这张旧会话写盘;POST 还必须过同源校验,与既有写路径 + * 同一条纪律。向导完成后先吊销全部 bootstrap 会话,再给当前响应签发正式会话。 + */ +export declare function makeSetupHandlers(deps: SetupEndpointDeps): SetupHandlers; +//# sourceMappingURL=setup-endpoint.d.ts.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/setup-endpoint.d.ts.map b/lib/host/features/bootstrap/api/setup-endpoint.d.ts.map new file mode 100644 index 0000000..e2e7c5e --- /dev/null +++ b/lib/host/features/bootstrap/api/setup-endpoint.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"setup-endpoint.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/api/setup-endpoint.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAmB,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAQlE,OAAO,KAAK,EAAW,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAEhF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAIzD,yDAAyD;AACzD,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,OAAO,GAAG,kBAAkB,GAAG,QAAQ,CAAC;IAChD,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;IAC3B,sCAAsC;IACtC,KAAK,EAAE,eAAe,CAAC;IACvB,QAAQ,EAAE,MAAM,YAAY,GAAG,SAAS,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,SAAS,EAAE,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC5C,oCAAoC;IACpC,WAAW,EAAE,CACX,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,IAAI,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC,CAAC;IACvD,sEAAsE;IACtE,gBAAgB,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1E,MAAM,EAAE;QACN,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;QAC7B,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;QAC7B,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;KAC/B,CAAC;CACH;AAaD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,WAAW,CAAC;IACjB,IAAI,EAAE,WAAW,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAKxE"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/setup-endpoint.js b/lib/host/features/bootstrap/api/setup-endpoint.js new file mode 100644 index 0000000..c4b62cc --- /dev/null +++ b/lib/host/features/bootstrap/api/setup-endpoint.js @@ -0,0 +1,183 @@ +import { isNavigationRequest } from "../../../shared/guard/index.js"; +import { checkRequestOrigin, parseCookieHeader, parseFormBody, } from "../../../shared/http/index.js"; +import { BOOTSTRAP_SESSION_KIND, CONTINUE_PATH } from "../../../shared/protocol/index.js"; +import { isBootstrapSession } from "../../../entities/session/index.js"; +import { redirectTo, redirectToLogin, sendHtml, sendText } from "../model/respond.js"; +import { setupConfiguredHtml, setupPageHtml, setupResultHtml } from "../model/setup-page.js"; +const UNTRUSTED_ORIGIN = "untrusted origin"; +const ALREADY_CONFIGURED = "already configured"; +const UNAUTHORIZED = "unauthorized"; +const SAVE_FAILED = "Could not store the access token. Set DSH_AUTH_TOKEN in the environment and restart dsh."; +const ADMIN_FAILED = "Could not create the administrator account. Check the users file and retry."; +const MISMATCH = "The two passwords do not match."; +const INVALID = "Check the username and password rules and retry. Passwords need at least 14 characters with upper case, lower case, a digit and a symbol."; +const CONFLICT = "That username already exists."; +/** + * 首次配置向导的两个方法(`/auth/setup`:GET 渲染、POST 提交;webserver 的 exact 表按 + * pathname 建键,GET/POST 不能注册成两条路由)。 + * + * 三条硬约束:只有有效的 `bootstrap` 会话能进(正式会话看到「已配置」页,无会话回登录页); + * **每次 GET/POST 都复查未配置探针**:bootstrap 会话能跨重启活最多 1 小时,期间运维可能已经 + * 用 CLI 补好凭据,配置一旦成立就不能再从这张旧会话写盘;POST 还必须过同源校验,与既有写路径 + * 同一条纪律。向导完成后先吊销全部 bootstrap 会话,再给当前响应签发正式会话。 + */ +export function makeSetupHandlers(deps) { + return { + get: (req, res) => handleGet(deps, req, res), + post: (req, res) => handlePost(deps, req, res), + }; +} +async function handleGet(deps, req, res) { + const session = sessionOf(deps, req); + if (session === undefined) { + redirectToLogin(res); + return; + } + // 正式会话、或配置在 bootstrap 会话签发之后成立了:都只给「已配置」页,绝不再渲染向导。 + if (!isBootstrapSession(session) || (await deps.probe.state()) !== "unconfigured") { + sendHtml(res, 200, setupConfiguredHtml()); + return; + } + sendHtml(res, 200, setupPageHtml(pageInput(deps))); +} +async function handlePost(deps, req, res) { + if (!isBootstrapSession(sessionOf(deps, req))) { + denyUnauthenticated(req, res); + return; + } + if ((await deps.probe.state()) !== "unconfigured") { + await denyAlreadyConfigured(deps, req, res); + return; + } + const origin = checkRequestOrigin(req, deps.publicHost); + if (!origin.ok) { + deps.logger.warn(`setup rejected: ${origin.detail}`); + sendText(res, 403, UNTRUSTED_ORIGIN); + return; + } + const params = await readForm(req, res); + if (params === undefined) + return; + if (deps.mode === "token") { + await completeTokenSetup(deps, res); + return; + } + await completePasswordSetup(deps, res, { + username: params.get("username") ?? "", + password: params.get("password") ?? "", + confirm: params.get("confirm") ?? "", + }); +} +/** token 模式:服务端生成并保存令牌 → 吊销 bootstrap 会话 → 签发正式会话 → 结果页。 */ +async function completeTokenSetup(deps, res) { + const outcome = await deps.saveToken(); + if (outcome.status === "failed") { + deps.logger.warn("setup failed: access token not stored"); + sendText(res, 500, SAVE_FAILED); + return; + } + await revokeBootstrapSessions(deps); + await deps.issueFullSession("token", res); + deps.logger.info("setup complete: access token saved"); + sendHtml(res, 200, setupResultHtml({ + token: outcome.token ?? "", + unreadable: outcome.status === "saved-unreadable", + })); +} +/** password 模式:确认口令 → 建管理员 → 吊销 bootstrap 会话 → 签发正式会话 → 回首页。 */ +async function completePasswordSetup(deps, res, form) { + if (form.password !== form.confirm) { + renderForm(deps, res, MISMATCH); + return; + } + const outcome = await deps.createAdmin(form.username, form.password); + if (outcome === "invalid") { + renderForm(deps, res, INVALID); + return; + } + if (outcome === "conflict") { + renderForm(deps, res, CONFLICT); + return; + } + if (outcome === "failed") { + deps.logger.warn("setup failed: administrator account not created"); + sendText(res, 500, ADMIN_FAILED); + return; + } + await revokeBootstrapSessions(deps); + await deps.issueFullSession(form.username, res); + deps.logger.info("setup complete: administrator created"); + redirectTo(res, CONTINUE_PATH); +} +/** 表单级失败:400 重渲染向导页(错误文案是固定句子,不回显表单内容)。 */ +function renderForm(deps, res, error) { + sendHtml(res, 400, setupPageHtml({ ...pageInput(deps), error })); +} +function pageInput(deps) { + return { + mode: deps.mode, + tokenRef: deps.tokenRef, + cookieSecure: deps.cookieSecure, + sessionTtl: deps.sessionTtlSeconds, + usersPath: deps.usersPath, + }; +} +/** 会话行(只认 cookie):无 store / 无 cookie / 已吊销 / 已过期 → undefined。 */ +function sessionOf(deps, req) { + const store = deps.sessions(); + if (store === undefined) + return undefined; + const cookie = parseCookieHeader(req.headers.cookie, deps.cookieName); + return cookie === undefined || cookie === "" ? undefined : store.getByToken(cookie); +} +/** + * 配置已经成立、但持着旧 bootstrap 会话的 POST:先吊销全部 bootstrap 会话,再拒绝。 + * 导航 302 登录页(浏览器表单就是导航),其余 403 固定句。**绝不写盘。** + */ +async function denyAlreadyConfigured(deps, req, res) { + deps.logger.warn("setup rejected: instance already configured"); + await revokeBootstrapSessions(deps); + if (isNavigationRequest(req)) { + redirectToLogin(res); + return; + } + sendText(res, 403, ALREADY_CONFIGURED); +} +/** 未认证的 POST:导航 302 登录页(浏览器表单就是导航),其余 401。 */ +function denyUnauthenticated(req, res) { + if (isNavigationRequest(req)) { + redirectToLogin(res); + return; + } + sendText(res, 401, UNAUTHORIZED); +} +/** 读表单体;413/415 在这里直接写响应,其余流异常向上抛(webserver 统一处理)。 */ +async function readForm(req, res) { + try { + return await parseFormBody(req); + } + catch (error) { + const failed = error; + if (typeof failed.status !== "number") + throw error; + if (failed.status === 413) + res.setHeader("connection", "close"); + sendText(res, failed.status, failed.message ?? "bad request"); + return undefined; + } +} +/** 关窗:向导完成后所有 bootstrap 会话立即失效;吊销失败不改变已经成立的成功语义。 */ +async function revokeBootstrapSessions(deps) { + const store = deps.sessions(); + if (store === undefined) + return; + try { + const revoked = await store.revokeByKind(BOOTSTRAP_SESSION_KIND); + if (revoked > 0) + deps.logger.info(`bootstrap sessions revoked: ${revoked}`); + } + catch { + deps.logger.warn("bootstrap session revoke failed"); + } +} +//# sourceMappingURL=setup-endpoint.js.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/api/setup-endpoint.js.map b/lib/host/features/bootstrap/api/setup-endpoint.js.map new file mode 100644 index 0000000..140408e --- /dev/null +++ b/lib/host/features/bootstrap/api/setup-endpoint.js.map @@ -0,0 +1 @@ +{"version":3,"file":"setup-endpoint.js","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/api/setup-endpoint.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/index.d.ts b/lib/host/features/bootstrap/index.d.ts new file mode 100644 index 0000000..8a468ae --- /dev/null +++ b/lib/host/features/bootstrap/index.d.ts @@ -0,0 +1,13 @@ +/** + * bootstrap 功能片段的公共面(显式导出清单):首次引导的探针、三个端点与服务端渲染页面。 + */ +export { makeBootstrapEndpoint } from "./api/bootstrap-endpoint.js"; +export type { BootstrapEndpointDeps } from "./api/bootstrap-endpoint.js"; +export { makeContinueEndpoint } from "./api/continue-endpoint.js"; +export type { ContinueEndpointDeps } from "./api/continue-endpoint.js"; +export { makeSetupHandlers } from "./api/setup-endpoint.js"; +export type { SetupEndpointDeps, SetupHandlers, SetupTokenOutcome } from "./api/setup-endpoint.js"; +export { makeConfiguredProbe } from "./model/probe.js"; +export type { ConfiguredProbe, ConfiguredProbeDeps, ConfiguredState } from "./model/probe.js"; +export { setupConfiguredHtml, setupPageHtml, setupResultHtml, type SetupPageInput, type SetupResultInput, } from "./model/setup-page.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/index.d.ts.map b/lib/host/features/bootstrap/index.d.ts.map new file mode 100644 index 0000000..4db7e09 --- /dev/null +++ b/lib/host/features/bootstrap/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/features/bootstrap/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AACpE,YAAY,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AACzE,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAClE,YAAY,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AACvE,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACnG,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,YAAY,EAAE,eAAe,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC9F,OAAO,EACL,mBAAmB,EACnB,aAAa,EACb,eAAe,EACf,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,uBAAuB,CAAC"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/index.js b/lib/host/features/bootstrap/index.js new file mode 100644 index 0000000..f6821ec --- /dev/null +++ b/lib/host/features/bootstrap/index.js @@ -0,0 +1,9 @@ +/** + * bootstrap 功能片段的公共面(显式导出清单):首次引导的探针、三个端点与服务端渲染页面。 + */ +export { makeBootstrapEndpoint } from "./api/bootstrap-endpoint.js"; +export { makeContinueEndpoint } from "./api/continue-endpoint.js"; +export { makeSetupHandlers } from "./api/setup-endpoint.js"; +export { makeConfiguredProbe } from "./model/probe.js"; +export { setupConfiguredHtml, setupPageHtml, setupResultHtml, } from "./model/setup-page.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/index.js.map b/lib/host/features/bootstrap/index.js.map new file mode 100644 index 0000000..803a021 --- /dev/null +++ b/lib/host/features/bootstrap/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/features/bootstrap/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AAEpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAElE,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAE5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EACL,mBAAmB,EACnB,aAAa,EACb,eAAe,GAGhB,MAAM,uBAAuB,CAAC"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/probe.d.ts b/lib/host/features/bootstrap/model/probe.d.ts new file mode 100644 index 0000000..71abc0e --- /dev/null +++ b/lib/host/features/bootstrap/model/probe.d.ts @@ -0,0 +1,32 @@ +/** + * 实例是否已经配置(首次引导的唯一判据)。每次引导请求现算,不缓存、不冻结: + * 向导刚写完、或运维手工补上凭据后,引导窗口必须立刻关闭。 + */ +export type ConfiguredState = "unconfigured" | "configured" | "unknown"; +export interface ConfiguredProbeDeps { + mode: "token" | "password"; + /** token 模式的凭证解析器(缺席 = 无可用解析器,按 unknown 处理)。 */ + resolveToken?: (() => Promise) | undefined; + /** password 模式的 `users.yaml` 路径。 */ + usersPath: string; + /** 进程内标志:向导成功写入后置 true,此后一律 configured(服务端缓存不再延后关窗)。 */ + markedConfigured: { + value: boolean; + }; +} +export interface ConfiguredProbe { + /** 现读一次:`configured` 与 `unknown` 都按「不引导」处理(fail-closed)。 */ + state(): Promise; + markConfigured(): void; +} +/** + * 未配置探针。 + * + * - token 模式:调用方**必须**传严格读取器,解析器给出空值 → `unconfigured`;非空 → + * `configured`;读取器缺席或抛错 → `unknown`。不要把 `makeTokenResolver` 传进来:它把 + * 服务缺失与解析异常都吞成 `undefined`,会让读故障被误判成未配置;吞错语义只服务门自己。 + * - password 模式:文件缺失或零用户 → `unconfigured`;有用户 → `configured`;读/解析/权限 + * 错误 → `unknown`。 + */ +export declare function makeConfiguredProbe(deps: ConfiguredProbeDeps): ConfiguredProbe; +//# sourceMappingURL=probe.d.ts.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/probe.d.ts.map b/lib/host/features/bootstrap/model/probe.d.ts.map new file mode 100644 index 0000000..a59e884 --- /dev/null +++ b/lib/host/features/bootstrap/model/probe.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/model/probe.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,YAAY,GAAG,SAAS,CAAC;AAExE,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;IAC3B,gDAAgD;IAChD,YAAY,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,GAAG,SAAS,CAAC;IAC/D,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,gBAAgB,EAAE;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC;CACtC;AAED,MAAM,WAAW,eAAe;IAC9B,4DAA4D;IAC5D,KAAK,IAAI,OAAO,CAAC,eAAe,CAAC,CAAC;IAClC,cAAc,IAAI,IAAI,CAAC;CACxB;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,mBAAmB,GAAG,eAAe,CAgB9E"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/probe.js b/lib/host/features/bootstrap/model/probe.js new file mode 100644 index 0000000..8caf498 --- /dev/null +++ b/lib/host/features/bootstrap/model/probe.js @@ -0,0 +1,40 @@ +import { loadUsersFile } from "../../../entities/user/index.js"; +/** + * 未配置探针。 + * + * - token 模式:调用方**必须**传严格读取器,解析器给出空值 → `unconfigured`;非空 → + * `configured`;读取器缺席或抛错 → `unknown`。不要把 `makeTokenResolver` 传进来:它把 + * 服务缺失与解析异常都吞成 `undefined`,会让读故障被误判成未配置;吞错语义只服务门自己。 + * - password 模式:文件缺失或零用户 → `unconfigured`;有用户 → `configured`;读/解析/权限 + * 错误 → `unknown`。 + */ +export function makeConfiguredProbe(deps) { + return { + async state() { + if (deps.markedConfigured.value) + return "configured"; + try { + return deps.mode === "password" + ? await probePassword(deps.usersPath) + : await probeToken(deps.resolveToken); + } + catch { + return "unknown"; + } + }, + markConfigured() { + deps.markedConfigured.value = true; + }, + }; +} +async function probeToken(resolveToken) { + if (resolveToken === undefined) + return "unknown"; + const token = await resolveToken(); + return token === undefined || token === "" ? "unconfigured" : "configured"; +} +async function probePassword(usersPath) { + const loaded = await loadUsersFile(usersPath); + return loaded.missing || loaded.snapshot.users.size === 0 ? "unconfigured" : "configured"; +} +//# sourceMappingURL=probe.js.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/probe.js.map b/lib/host/features/bootstrap/model/probe.js.map new file mode 100644 index 0000000..acc4753 --- /dev/null +++ b/lib/host/features/bootstrap/model/probe.js.map @@ -0,0 +1 @@ +{"version":3,"file":"probe.js","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/model/probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAwBhE;;;;;;;;GAQG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAyB;IAC3D,OAAO;QACL,KAAK,CAAC,KAAK;YACT,IAAI,IAAI,CAAC,gBAAgB,CAAC,KAAK;gBAAE,OAAO,YAAY,CAAC;YACrD,IAAI,CAAC;gBACH,OAAO,IAAI,CAAC,IAAI,KAAK,UAAU;oBAC7B,CAAC,CAAC,MAAM,aAAa,CAAC,IAAI,CAAC,SAAS,CAAC;oBACrC,CAAC,CAAC,MAAM,UAAU,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YAC1C,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,SAAS,CAAC;YACnB,CAAC;QACH,CAAC;QACD,cAAc;YACZ,IAAI,CAAC,gBAAgB,CAAC,KAAK,GAAG,IAAI,CAAC;QACrC,CAAC;KACF,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,UAAU,CACvB,YAA6D;IAE7D,IAAI,YAAY,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACjD,MAAM,KAAK,GAAG,MAAM,YAAY,EAAE,CAAC;IACnC,OAAO,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,YAAY,CAAC;AAC7E,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,SAAiB;IAC5C,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,SAAS,CAAC,CAAC;IAC9C,OAAO,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,YAAY,CAAC;AAC5F,CAAC"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/respond.d.ts b/lib/host/features/bootstrap/model/respond.d.ts new file mode 100644 index 0000000..cbd903d --- /dev/null +++ b/lib/host/features/bootstrap/model/respond.d.ts @@ -0,0 +1,14 @@ +import type { ServerResponse } from "node:http"; +/** + * 首次引导三个端点的响应小件:一律 `no-store`;重定向一律带 `referrer-policy: no-referrer` + * (被拒的 URL 里可能带着 dsh 的 launch token,不能让浏览器把它当 Referer 传给下一跳)。 + */ +/** 302 到登录页(`next=/`):所有失败分支唯一的去处(fail-closed)。 */ +export declare function redirectToLogin(res: ServerResponse): void; +/** 302 到站内相对地址,禁缓存、禁 Referer。 */ +export declare function redirectTo(res: ServerResponse, location: string): void; +/** 渲染 HTML(禁缓存、禁 Referer:与三条重定向出口保持同一纪律)。 */ +export declare function sendHtml(res: ServerResponse, status: number, html: string): void; +/** 纯文本拒绝(禁缓存):固定句子,绝不回显请求内容。 */ +export declare function sendText(res: ServerResponse, status: number, body: string, headers?: Record): void; +//# sourceMappingURL=respond.d.ts.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/respond.d.ts.map b/lib/host/features/bootstrap/model/respond.d.ts.map new file mode 100644 index 0000000..26e5363 --- /dev/null +++ b/lib/host/features/bootstrap/model/respond.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"respond.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/model/respond.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGhD;;;GAGG;AAEH,mDAAmD;AACnD,wBAAgB,eAAe,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI,CAEzD;AAED,iCAAiC;AACjC,wBAAgB,UAAU,CAAC,GAAG,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAKtE;AAED,6CAA6C;AAC7C,wBAAgB,QAAQ,CAAC,GAAG,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAKhF;AAED,gCAAgC;AAChC,wBAAgB,QAAQ,CACtB,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACnC,IAAI,CAIN"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/respond.js b/lib/host/features/bootstrap/model/respond.js new file mode 100644 index 0000000..87ed65e --- /dev/null +++ b/lib/host/features/bootstrap/model/respond.js @@ -0,0 +1,30 @@ +import { loginPath } from "../../../shared/protocol/index.js"; +/** + * 首次引导三个端点的响应小件:一律 `no-store`;重定向一律带 `referrer-policy: no-referrer` + * (被拒的 URL 里可能带着 dsh 的 launch token,不能让浏览器把它当 Referer 传给下一跳)。 + */ +/** 302 到登录页(`next=/`):所有失败分支唯一的去处(fail-closed)。 */ +export function redirectToLogin(res) { + redirectTo(res, loginPath("/")); +} +/** 302 到站内相对地址,禁缓存、禁 Referer。 */ +export function redirectTo(res, location) { + res.setHeader("cache-control", "no-store"); + res.setHeader("referrer-policy", "no-referrer"); + res.writeHead(302, { location }); + res.end(); +} +/** 渲染 HTML(禁缓存、禁 Referer:与三条重定向出口保持同一纪律)。 */ +export function sendHtml(res, status, html) { + res.setHeader("cache-control", "no-store"); + res.setHeader("referrer-policy", "no-referrer"); + res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); + res.end(html); +} +/** 纯文本拒绝(禁缓存):固定句子,绝不回显请求内容。 */ +export function sendText(res, status, body, headers = {}) { + res.setHeader("cache-control", "no-store"); + res.writeHead(status, { "content-type": "text/plain", ...headers }); + res.end(body); +} +//# sourceMappingURL=respond.js.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/respond.js.map b/lib/host/features/bootstrap/model/respond.js.map new file mode 100644 index 0000000..36024a8 --- /dev/null +++ b/lib/host/features/bootstrap/model/respond.js.map @@ -0,0 +1 @@ +{"version":3,"file":"respond.js","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/model/respond.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAE9D;;;GAGG;AAEH,mDAAmD;AACnD,MAAM,UAAU,eAAe,CAAC,GAAmB;IACjD,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;AAClC,CAAC;AAED,iCAAiC;AACjC,MAAM,UAAU,UAAU,CAAC,GAAmB,EAAE,QAAgB;IAC9D,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IAC3C,GAAG,CAAC,SAAS,CAAC,iBAAiB,EAAE,aAAa,CAAC,CAAC;IAChD,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC;IACjC,GAAG,CAAC,GAAG,EAAE,CAAC;AACZ,CAAC;AAED,6CAA6C;AAC7C,MAAM,UAAU,QAAQ,CAAC,GAAmB,EAAE,MAAc,EAAE,IAAY;IACxE,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IAC3C,GAAG,CAAC,SAAS,CAAC,iBAAiB,EAAE,aAAa,CAAC,CAAC;IAChD,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE,CAAC,CAAC;IACtE,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAChB,CAAC;AAED,gCAAgC;AAChC,MAAM,UAAU,QAAQ,CACtB,GAAmB,EACnB,MAAc,EACd,IAAY,EACZ,UAAkC,EAAE;IAEpC,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IAC3C,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,cAAc,EAAE,YAAY,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IACpE,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAChB,CAAC"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/setup-page.d.ts b/lib/host/features/bootstrap/model/setup-page.d.ts new file mode 100644 index 0000000..b319907 --- /dev/null +++ b/lib/host/features/bootstrap/model/setup-page.d.ts @@ -0,0 +1,20 @@ +export interface SetupPageInput { + mode: "token" | "password"; + tokenRef: string; + cookieSecure: boolean; + sessionTtl: number; + usersPath: string; + error?: string | undefined; +} +export interface SetupResultInput { + token: string; + /** true = 写盘成功但当前进程读不回来:重启 dsh 后它才成为唯一凭据。 */ + unreadable: boolean; +} +/** 向导页(GET,bootstrap 会话)。 */ +export declare function setupPageHtml(input: SetupPageInput): string; +/** token 模式的提交结果页:新令牌只在这里出现一次。 */ +export declare function setupResultHtml(input: SetupResultInput): string; +/** 已有正式会话访问向导:实例已经配置过,说明去哪儿。 */ +export declare function setupConfiguredHtml(): string; +//# sourceMappingURL=setup-page.d.ts.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/setup-page.d.ts.map b/lib/host/features/bootstrap/model/setup-page.d.ts.map new file mode 100644 index 0000000..05fff3e --- /dev/null +++ b/lib/host/features/bootstrap/model/setup-page.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"setup-page.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/model/setup-page.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,UAAU,EAAE,OAAO,CAAC;CACrB;AAoDD,6BAA6B;AAC7B,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAkB3D;AAED,kCAAkC;AAClC,wBAAgB,eAAe,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAiB/D;AAED,gCAAgC;AAChC,wBAAgB,mBAAmB,IAAI,MAAM,CAa5C"} \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/setup-page.js b/lib/host/features/bootstrap/model/setup-page.js new file mode 100644 index 0000000..ef4ad8a --- /dev/null +++ b/lib/host/features/bootstrap/model/setup-page.js @@ -0,0 +1,119 @@ +import { CARD_STYLE } from "../../../shared/login-page/index.js"; +import { CONTINUE_PATH, LOGIN_PATH, SETUP_PATH } from "../../../shared/protocol/index.js"; +/** + * 首次配置向导的服务端渲染(英文,与登录页同一套卡片样式)。 + * + * 页面里只出现配置摘要与表单,绝不渲染 launch token、也绝不渲染既有访问令牌; + * 新生成的访问令牌只在 `setupResultHtml` 里出现一次。所有动态值一律 HTML-escape。 + */ +const HTML_ESCAPES = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; +function escapeHtml(value) { + return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch); +} +/** 向导页的少量补充样式(卡片骨架与配色全部来自登录页的 `CARD_STYLE`)。 */ +const SETUP_STYLE = ` +.card.wide{max-width:27rem} +.summary{margin:1.15rem 0 0;padding-top:1.15rem;border-top:1px solid var(--line);font-size:13px;line-height:20px;color:var(--muted)} +.summary dt{margin-top:10px;font-size:12px;font-weight:500;line-height:18px;color:var(--ink)} +.summary dd{margin:2px 0 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all} +.note{margin:12px 0 0;font-size:12px;line-height:18px;color:var(--muted)} +.token{margin:1.15rem 0 0;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--field)} +.token code{display:block;font:600 13px/20px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all} +`; +function page(title, body) { + return ` + + + + +${escapeHtml(title)} + + + +${body} + + +`; +} +/** 配置摘要:告诉运营者当前生效的启动期设置,并指明要改得编辑哪个文件。 */ +function summaryHtml(input) { + const rows = input.mode === "token" + ? [ + ["Mode", "token (one shared access token)"], + ["Credential reference", input.tokenRef], + ] + : [ + ["Mode", "password (per-user accounts)"], + ["Users file", input.usersPath], + ]; + rows.push(["Cookie Secure flag", input.cookieSecure ? "on (HTTPS only)" : "off (plain HTTP)"]); + rows.push(["Session lifetime", `${input.sessionTtl} seconds`]); + const items = rows + .map(([name, value]) => `
${escapeHtml(name)}
${escapeHtml(value)}
`) + .join(""); + return `
${items}
+

These are startup settings. To change them, edit $DSH_HOME/cordis.patch.yml and restart dsh.

`; +} +/** 模式相关的表单主体:token 模式只有一个按钮,password 模式是三个字段的建号表单。 */ +function formBodyHtml(input) { + if (input.mode === "token") { + return `

Create one access token for this instance. It is shown once, right after it is saved.

+`; + } + return `

Create the first administrator account. Passwords need at least 14 characters with upper case, lower case, a digit and a symbol.

+
+
+
+`; +} +/** 向导页(GET,bootstrap 会话)。 */ +export function setupPageHtml(input) { + const errorHtml = input.error === undefined + ? "" + : ``; + return page("First run - dsh-auth-gate", `
+
+

First run

+

Set up this instance

+
+
+${errorHtml}${formBodyHtml(input)} +
+${summaryHtml(input)} +
`); +} +/** token 模式的提交结果页:新令牌只在这里出现一次。 */ +export function setupResultHtml(input) { + const unreadableHtml = input.unreadable + ? `

This process could not read the token back. Restart dsh so it becomes the only credential, and keep the token safe until then.

` + : ""; + return page("Access token - dsh-auth-gate", `
+
+

Access token

+

Saved

+
+

Copy this token now. It is shown once and cannot be recovered; if you lose it, overwrite or delete that entry in $DSH_HOME/.credentials.yaml (stored credentials take precedence over the environment) and restart dsh.

+
${escapeHtml(input.token)}
+${unreadableHtml} +Continue to dsh +
`); +} +/** 已有正式会话访问向导:实例已经配置过,说明去哪儿。 */ +export function setupConfiguredHtml() { + return page("Already configured - dsh-auth-gate", `
+
+

Already configured

+

Nothing to do

+
+

This instance already has credentials, so the first-run wizard is closed.

+Go to dsh +Sign in +
`); +} +//# sourceMappingURL=setup-page.js.map \ No newline at end of file diff --git a/lib/host/features/bootstrap/model/setup-page.js.map b/lib/host/features/bootstrap/model/setup-page.js.map new file mode 100644 index 0000000..9885a45 --- /dev/null +++ b/lib/host/features/bootstrap/model/setup-page.js.map @@ -0,0 +1 @@ +{"version":3,"file":"setup-page.js","sourceRoot":"","sources":["../../../../../src/host/features/bootstrap/model/setup-page.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/host/features/password/model/password-gate.d.ts b/lib/host/features/password/model/password-gate.d.ts index ee4c89b..f5a9df6 100644 --- a/lib/host/features/password/model/password-gate.d.ts +++ b/lib/host/features/password/model/password-gate.d.ts @@ -1,6 +1,6 @@ import type { IncomingMessage } from "node:http"; import type { Gate, GateDecision, GuardKind } from "../../../shared/guard/index.js"; -import type { SessionStore } from "../../../entities/session/index.js"; +import { type SessionStore } from "../../../entities/session/index.js"; export interface PasswordGateOptions { /** 会话访问器(M16 同形):每次 decide 现取;undefined = 会话通道不可用。 */ sessions: () => SessionStore | undefined; @@ -15,6 +15,9 @@ export interface PasswordGateOptions { * `GET /auth/login` → 302 改密页、`POST /auth/login` → 403;其余(含 `/auth/users*`) * 放行进 handler,由 handler 按 kind 细分(管理面回 403)。宿主 `/api`、静态、`/plugins` * 与 WS 一律 deny(导航 302 `/auth/password`,API 401,WS 拒握手)。 + * + * `session.kind === "bootstrap"` 的首次引导会话只放行 `/auth` 前缀;其余一律 deny(导航 + * 302 向导页,API 401,WS 拒握手)。未认证的 `GET /?token=` 按请求形状 302 到引导端点。 */ export declare class PasswordGate implements Gate { private readonly sessions; diff --git a/lib/host/features/password/model/password-gate.d.ts.map b/lib/host/features/password/model/password-gate.d.ts.map index fb01f26..049f0a0 100644 --- a/lib/host/features/password/model/password-gate.d.ts.map +++ b/lib/host/features/password/model/password-gate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"password-gate.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/password/model/password-gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAEjD,OAAO,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAGpF,OAAO,KAAK,EAAW,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAGhF,MAAM,WAAW,mBAAmB;IAClC,sDAAsD;IACtD,QAAQ,EAAE,MAAM,YAAY,GAAG,SAAS,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,qBAAa,YAAa,YAAW,IAAI;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAExB,OAAO,EAAE,mBAAmB;IAKxC,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY;IAW7E,gEAAgE;IAChE,OAAO,CAAC,cAAc;IAatB,oEAAoE;IACpE,OAAO,CAAC,SAAS;CAYlB"} \ No newline at end of file +{"version":3,"file":"password-gate.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/password/model/password-gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAEjD,OAAO,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAGpF,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,oCAAoC,CAAC;AAG5C,MAAM,WAAW,mBAAmB;IAClC,sDAAsD;IACtD,QAAQ,EAAE,MAAM,YAAY,GAAG,SAAS,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,YAAa,YAAW,IAAI;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAExB,OAAO,EAAE,mBAAmB;IAKxC,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY;IAc7E,gEAAgE;IAChE,OAAO,CAAC,cAAc;IAatB,oEAAoE;IACpE,OAAO,CAAC,SAAS;CAYlB"} \ No newline at end of file diff --git a/lib/host/features/password/model/password-gate.js b/lib/host/features/password/model/password-gate.js index 0605f63..b3bc84b 100644 --- a/lib/host/features/password/model/password-gate.js +++ b/lib/host/features/password/model/password-gate.js @@ -1,6 +1,7 @@ -import { parseCookieHeader } from "../../../shared/http/index.js"; +import { bootstrapRedirectPath, parseCookieHeader } from "../../../shared/http/index.js"; import { isNavigationRequest, isPublicStaticPath } from "../../../shared/guard/index.js"; -import { isAuthPath, LOGIN_PATH, loginPath } from "../../../shared/protocol/index.js"; +import { isAuthPath, LOGIN_PATH, loginPath, SETUP_PATH } from "../../../shared/protocol/index.js"; +import { isBootstrapSession, } from "../../../entities/session/index.js"; import { isRestrictedSession, RESTRICTED_REDIRECT_PATH } from "./restricted-session.js"; /** * password 模式门(P12 + P2 ④ kind 感知):白名单(`/auth` 前缀 + 公开只读静态路径) @@ -11,6 +12,9 @@ import { isRestrictedSession, RESTRICTED_REDIRECT_PATH } from "./restricted-sess * `GET /auth/login` → 302 改密页、`POST /auth/login` → 403;其余(含 `/auth/users*`) * 放行进 handler,由 handler 按 kind 细分(管理面回 403)。宿主 `/api`、静态、`/plugins` * 与 WS 一律 deny(导航 302 `/auth/password`,API 401,WS 拒握手)。 + * + * `session.kind === "bootstrap"` 的首次引导会话只放行 `/auth` 前缀;其余一律 deny(导航 + * 302 向导页,API 401,WS 拒握手)。未认证的 `GET /?token=` 按请求形状 302 到引导端点。 */ export class PasswordGate { sessions; @@ -26,10 +30,15 @@ export class PasswordGate { const session = this.sessionOf(req); if (isRestrictedSession(session)) return denyRestricted(req, kind); + if (isBootstrapSession(session)) + return denyBootstrap(req); if (isPublicStaticPath(kind, pathname)) return "allow"; if (session !== undefined) return "allow"; + const hint = bootstrapRedirectPath(req); + if (hint !== undefined) + return { deny: { redirect: hint } }; return denyAnonymous(req, pathname); } /** `/auth` 前缀内:只有受限会话需要细分;其余一律交 handler(method 矩阵在 handler)。 */ @@ -71,6 +80,10 @@ function denyRestricted(req, kind) { ? { deny: { redirect: RESTRICTED_REDIRECT_PATH } } : { deny: { status: 401 } }; } +/** 首次引导会话被拒的宿主路径:导航 → 302 向导页;WS → 拒握手;其余(API)→ 401。 */ +function denyBootstrap(req) { + return isNavigationRequest(req) ? { deny: { redirect: SETUP_PATH } } : "deny"; +} /** 未认证(无会话/会话失效):导航 → 302 登录页 + next;其余 → 字符串 deny(401)。 */ function denyAnonymous(req, pathname) { return isNavigationRequest(req) ? { deny: { redirect: loginPath(pathname) } } : "deny"; diff --git a/lib/host/features/password/model/password-gate.js.map b/lib/host/features/password/model/password-gate.js.map index 604882e..5422753 100644 --- a/lib/host/features/password/model/password-gate.js.map +++ b/lib/host/features/password/model/password-gate.js.map @@ -1 +1 @@ -{"version":3,"file":"password-gate.js","sourceRoot":"","sources":["../../../../../src/host/features/password/model/password-gate.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"password-gate.js","sourceRoot":"","sources":["../../../../../src/host/features/password/model/password-gate.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/host/features/token/model/token-gate.d.ts b/lib/host/features/token/model/token-gate.d.ts index 6e2edc9..b7320ff 100644 --- a/lib/host/features/token/model/token-gate.d.ts +++ b/lib/host/features/token/model/token-gate.d.ts @@ -1,11 +1,9 @@ import type { IncomingMessage } from "node:http"; -import type { Gate, GuardKind } from "../../../shared/guard/index.js"; -import type { SessionStore } from "../../../entities/session/index.js"; -/** - * 恒时比较:双方 sha256 Buffer 摘要后 timingSafeEqual(M17:timingSafeEqual 只接受 - * Buffer/TypedArray:hex 字符串直接 TypeError;Buffer 摘要双方恒 32 字节,无长度侧信道)。 - */ -export declare function safeEqual(input: string, stored: string): boolean; +import type { Gate, GateDecision, GuardKind } from "../../../shared/guard/index.js"; +import { safeEqual } from "../../../shared/crypto/index.js"; +import { type SessionStore } from "../../../entities/session/index.js"; +/** 恒时比较在 `shared/crypto`,这里转口导出:既有调用点(含 barrel)不用改 import。 */ +export { safeEqual }; export interface TokenGateOptions { /** 每次 decide 调用的凭证解析器(index.ts 注入 credentials.resolve 闭包)。 */ resolveToken: () => Promise; @@ -13,12 +11,21 @@ export interface TokenGateOptions { sessions: () => SessionStore | undefined; cookieName: string; } -/** 共享 token 门(M2):白名单(`/auth` 前缀 + 公开只读静态路径)→ 会话 cookie → Bearer,恒时校验,fail-closed。 */ +/** + * 共享 token 门(M2):`/auth` 前缀 + 公开只读静态路径白名单 → 会话 cookie → Bearer, + * 恒时校验,fail-closed。 + * + * 首次引导通道:本来要拒绝的 `GET /?token=` 只按请求形状 302 到 `/auth/bootstrap?token=`, + * 门内不校验 token、不读文件、不做 IO。bootstrap 会话只放行 `/auth/*`(上面已放行), + * 其余导航 302 回向导页,API 401,WS 拒握手。 + */ export declare class TokenGate implements Gate { private readonly resolveToken; private readonly sessions; private readonly cookieName; constructor(options: TokenGateOptions); - decide(req: IncomingMessage, kind: GuardKind, pathname: string): Promise<"allow" | "deny">; + decide(req: IncomingMessage, kind: GuardKind, pathname: string): Promise; + /** 会话行(只认会话 cookie,与既有 token 通道一致):无 store/无 cookie/失效 → undefined。 */ + private sessionOf; } //# sourceMappingURL=token-gate.d.ts.map \ No newline at end of file diff --git a/lib/host/features/token/model/token-gate.d.ts.map b/lib/host/features/token/model/token-gate.d.ts.map index c781b62..8147726 100644 --- a/lib/host/features/token/model/token-gate.d.ts.map +++ b/lib/host/features/token/model/token-gate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"token-gate.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/token/model/token-gate.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAGtE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAGvE;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAIhE;AAED,MAAM,WAAW,gBAAgB;IAC/B,8DAA8D;IAC9D,YAAY,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAChD,oEAAoE;IACpE,QAAQ,EAAE,MAAM,YAAY,GAAG,SAAS,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,sFAAsF;AACtF,qBAAa,SAAU,YAAW,IAAI;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAoC;IACjE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAExB,OAAO,EAAE,gBAAgB;IAM/B,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC;CAuBjG"} \ No newline at end of file +{"version":3,"file":"token-gate.d.ts","sourceRoot":"","sources":["../../../../../src/host/features/token/model/token-gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAGpF,OAAO,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAC5D,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,oCAAoC,CAAC;AAG5C,8DAA8D;AAC9D,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,MAAM,WAAW,gBAAgB;IAC/B,8DAA8D;IAC9D,YAAY,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAChD,oEAAoE;IACpE,QAAQ,EAAE,MAAM,YAAY,GAAG,SAAS,CAAC;IACzC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,qBAAa,SAAU,YAAW,IAAI;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAoC;IACjE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAExB,OAAO,EAAE,gBAAgB;IAM/B,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAmB5F,uEAAuE;IACvE,OAAO,CAAC,SAAS;CAMlB"} \ No newline at end of file diff --git a/lib/host/features/token/model/token-gate.js b/lib/host/features/token/model/token-gate.js index 405e8d1..eeafcd6 100644 --- a/lib/host/features/token/model/token-gate.js +++ b/lib/host/features/token/model/token-gate.js @@ -1,17 +1,18 @@ -import { createHash, timingSafeEqual } from "node:crypto"; -import { isPublicStaticPath } from "../../../shared/guard/index.js"; -import { isAuthPath } from "../../../shared/protocol/index.js"; -import { parseCookieHeader } from "../../../shared/http/index.js"; +import { isNavigationRequest, isPublicStaticPath } from "../../../shared/guard/index.js"; +import { isAuthPath, SETUP_PATH } from "../../../shared/protocol/index.js"; +import { safeEqual } from "../../../shared/crypto/index.js"; +import { isBootstrapSession, } from "../../../entities/session/index.js"; +import { bootstrapRedirectPath, parseCookieHeader } from "../../../shared/http/index.js"; +/** 恒时比较在 `shared/crypto`,这里转口导出:既有调用点(含 barrel)不用改 import。 */ +export { safeEqual }; /** - * 恒时比较:双方 sha256 Buffer 摘要后 timingSafeEqual(M17:timingSafeEqual 只接受 - * Buffer/TypedArray:hex 字符串直接 TypeError;Buffer 摘要双方恒 32 字节,无长度侧信道)。 + * 共享 token 门(M2):`/auth` 前缀 + 公开只读静态路径白名单 → 会话 cookie → Bearer, + * 恒时校验,fail-closed。 + * + * 首次引导通道:本来要拒绝的 `GET /?token=` 只按请求形状 302 到 `/auth/bootstrap?token=`, + * 门内不校验 token、不读文件、不做 IO。bootstrap 会话只放行 `/auth/*`(上面已放行), + * 其余导航 302 回向导页,API 401,WS 拒握手。 */ -export function safeEqual(input, stored) { - const inputDigest = createHash("sha256").update(input).digest(); - const storedDigest = createHash("sha256").update(stored).digest(); - return timingSafeEqual(inputDigest, storedDigest); -} -/** 共享 token 门(M2):白名单(`/auth` 前缀 + 公开只读静态路径)→ 会话 cookie → Bearer,恒时校验,fail-closed。 */ export class TokenGate { resolveToken; sessions; @@ -24,29 +25,38 @@ export class TokenGate { async decide(req, kind, pathname) { if (isAuthPath(pathname)) return "allow"; + const session = this.sessionOf(req); + if (session !== undefined) + return isBootstrapSession(session) ? denyBootstrap(req) : "allow"; if (isPublicStaticPath(kind, pathname)) return "allow"; - const cookie = parseCookieHeader(req.headers.cookie, this.cookieName); - if (cookie !== undefined && cookie !== "") { - const store = this.sessions(); - if (store?.getByToken(cookie) !== undefined) - return "allow"; - } - const authorization = req.headers.authorization; - const match = /^Bearer\s+(.+)$/i.exec(authorization ?? ""); - if (match !== null) { - const bearer = match[1]; + const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "")?.[1]; + if (bearer !== undefined) { try { const stored = await this.resolveToken(); - if (bearer !== undefined && stored !== undefined && safeEqual(bearer, stored)) { + if (stored !== undefined && safeEqual(bearer, stored)) return "allow"; - } } catch { return "deny"; // fail-closed 双保险(index.ts 的 resolver 已自行 catch 并记日志) } } + const hint = bootstrapRedirectPath(req); + if (hint !== undefined) + return { deny: { redirect: hint } }; return "deny"; } + /** 会话行(只认会话 cookie,与既有 token 通道一致):无 store/无 cookie/失效 → undefined。 */ + sessionOf(req) { + const store = this.sessions(); + if (store === undefined) + return undefined; + const cookie = parseCookieHeader(req.headers.cookie, this.cookieName); + return cookie === undefined || cookie === "" ? undefined : store.getByToken(cookie); + } +} +/** bootstrap 会话被拒的宿主路径:导航 → 302 向导页;WS → 拒握手;其余(API)→ 401。 */ +function denyBootstrap(req) { + return isNavigationRequest(req) ? { deny: { redirect: SETUP_PATH } } : "deny"; } //# sourceMappingURL=token-gate.js.map \ No newline at end of file diff --git a/lib/host/features/token/model/token-gate.js.map b/lib/host/features/token/model/token-gate.js.map index 21c542a..67a72ab 100644 --- a/lib/host/features/token/model/token-gate.js.map +++ b/lib/host/features/token/model/token-gate.js.map @@ -1 +1 @@ -{"version":3,"file":"token-gate.js","sourceRoot":"","sources":["../../../../../src/host/features/token/model/token-gate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,EAAE,UAAU,EAAE,MAAM,mCAAmC,CAAC;AAE/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAElE;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,MAAc;IACrD,MAAM,WAAW,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;IAChE,MAAM,YAAY,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;IAClE,OAAO,eAAe,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC;AACpD,CAAC;AAUD,sFAAsF;AACtF,MAAM,OAAO,SAAS;IACH,YAAY,CAAoC;IAChD,QAAQ,CAAiC;IACzC,UAAU,CAAS;IAEpC,YAAY,OAAyB;QACnC,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;QACzC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;QACjC,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IACvC,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAoB,EAAE,IAAe,EAAE,QAAgB;QAClE,IAAI,UAAU,CAAC,QAAQ,CAAC;YAAE,OAAO,OAAO,CAAC;QACzC,IAAI,kBAAkB,CAAC,IAAI,EAAE,QAAQ,CAAC;YAAE,OAAO,OAAO,CAAC;QACvD,MAAM,MAAM,GAAG,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;QACtE,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;YAC1C,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC9B,IAAI,KAAK,EAAE,UAAU,CAAC,MAAM,CAAC,KAAK,SAAS;gBAAE,OAAO,OAAO,CAAC;QAC9D,CAAC;QACD,MAAM,aAAa,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;QAChD,MAAM,KAAK,GAAG,kBAAkB,CAAC,IAAI,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC;QAC3D,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;YACnB,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACxB,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,YAAY,EAAE,CAAC;gBACzC,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,IAAI,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;oBAC9E,OAAO,OAAO,CAAC;gBACjB,CAAC;YACH,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,MAAM,CAAC,CAAC,sDAAsD;YACvE,CAAC;QACH,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;CACF"} \ No newline at end of file +{"version":3,"file":"token-gate.js","sourceRoot":"","sources":["../../../../../src/host/features/token/model/token-gate.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AACzF,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,mCAAmC,CAAC;AAC3E,OAAO,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAC5D,OAAO,EACL,kBAAkB,GAGnB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEzF,8DAA8D;AAC9D,OAAO,EAAE,SAAS,EAAE,CAAC;AAUrB;;;;;;;GAOG;AACH,MAAM,OAAO,SAAS;IACH,YAAY,CAAoC;IAChD,QAAQ,CAAiC;IACzC,UAAU,CAAS;IAEpC,YAAY,OAAyB;QACnC,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;QACzC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;QACjC,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IACvC,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAoB,EAAE,IAAe,EAAE,QAAgB;QAClE,IAAI,UAAU,CAAC,QAAQ,CAAC;YAAE,OAAO,OAAO,CAAC;QACzC,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACpC,IAAI,OAAO,KAAK,SAAS;YAAE,OAAO,kBAAkB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;QAC7F,IAAI,kBAAkB,CAAC,IAAI,EAAE,QAAQ,CAAC;YAAE,OAAO,OAAO,CAAC;QACvD,MAAM,MAAM,GAAG,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC7E,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,YAAY,EAAE,CAAC;gBACzC,IAAI,MAAM,KAAK,SAAS,IAAI,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC;oBAAE,OAAO,OAAO,CAAC;YACxE,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,MAAM,CAAC,CAAC,sDAAsD;YACvE,CAAC;QACH,CAAC;QACD,MAAM,IAAI,GAAG,qBAAqB,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,CAAC;QAC5D,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,uEAAuE;IAC/D,SAAS,CAAC,GAAoB;QACpC,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC9B,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QAC1C,MAAM,MAAM,GAAG,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;QACtE,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;IACtF,CAAC;CACF;AAED,8DAA8D;AAC9D,SAAS,aAAa,CAAC,GAAoB;IACzC,OAAO,mBAAmB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;AAChF,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/crypto/index.d.ts b/lib/host/shared/crypto/index.d.ts new file mode 100644 index 0000000..3d1020d --- /dev/null +++ b/lib/host/shared/crypto/index.d.ts @@ -0,0 +1,6 @@ +/** + * shared/crypto segment public API: 与协议无关的密码学小件。 + * 显式导出清单:新增导出必须在此登记。 + */ +export { safeEqual } from "./safe-equal.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/lib/host/shared/crypto/index.d.ts.map b/lib/host/shared/crypto/index.d.ts.map new file mode 100644 index 0000000..14ee62e --- /dev/null +++ b/lib/host/shared/crypto/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/crypto/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/crypto/index.js b/lib/host/shared/crypto/index.js new file mode 100644 index 0000000..cb2f1b6 --- /dev/null +++ b/lib/host/shared/crypto/index.js @@ -0,0 +1,6 @@ +/** + * shared/crypto segment public API: 与协议无关的密码学小件。 + * 显式导出清单:新增导出必须在此登记。 + */ +export { safeEqual } from "./safe-equal.js"; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/host/shared/crypto/index.js.map b/lib/host/shared/crypto/index.js.map new file mode 100644 index 0000000..b5fc8f6 --- /dev/null +++ b/lib/host/shared/crypto/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/shared/crypto/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/crypto/safe-equal.d.ts b/lib/host/shared/crypto/safe-equal.d.ts new file mode 100644 index 0000000..f001c26 --- /dev/null +++ b/lib/host/shared/crypto/safe-equal.d.ts @@ -0,0 +1,9 @@ +/** + * 恒时比较(M17):双方 sha256 Buffer 摘要后 `timingSafeEqual`。 + * `timingSafeEqual` 只接受 Buffer/TypedArray;hex 字符串直接 TypeError。Buffer 摘要双方 + * 恒 32 字节,所以比较本身不泄漏长度侧信道。 + * + * 只回答「两个字符串是否相等」,不区分失败原因;调用方一律把假当作拒绝。 + */ +export declare function safeEqual(input: string, stored: string): boolean; +//# sourceMappingURL=safe-equal.d.ts.map \ No newline at end of file diff --git a/lib/host/shared/crypto/safe-equal.d.ts.map b/lib/host/shared/crypto/safe-equal.d.ts.map new file mode 100644 index 0000000..ebc5ffb --- /dev/null +++ b/lib/host/shared/crypto/safe-equal.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"safe-equal.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/crypto/safe-equal.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAIhE"} \ No newline at end of file diff --git a/lib/host/shared/crypto/safe-equal.js b/lib/host/shared/crypto/safe-equal.js new file mode 100644 index 0000000..5fcaa11 --- /dev/null +++ b/lib/host/shared/crypto/safe-equal.js @@ -0,0 +1,14 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +/** + * 恒时比较(M17):双方 sha256 Buffer 摘要后 `timingSafeEqual`。 + * `timingSafeEqual` 只接受 Buffer/TypedArray;hex 字符串直接 TypeError。Buffer 摘要双方 + * 恒 32 字节,所以比较本身不泄漏长度侧信道。 + * + * 只回答「两个字符串是否相等」,不区分失败原因;调用方一律把假当作拒绝。 + */ +export function safeEqual(input, stored) { + const inputDigest = createHash("sha256").update(input).digest(); + const storedDigest = createHash("sha256").update(stored).digest(); + return timingSafeEqual(inputDigest, storedDigest); +} +//# sourceMappingURL=safe-equal.js.map \ No newline at end of file diff --git a/lib/host/shared/crypto/safe-equal.js.map b/lib/host/shared/crypto/safe-equal.js.map new file mode 100644 index 0000000..727dee2 --- /dev/null +++ b/lib/host/shared/crypto/safe-equal.js.map @@ -0,0 +1 @@ +{"version":3,"file":"safe-equal.js","sourceRoot":"","sources":["../../../../src/host/shared/crypto/safe-equal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE1D;;;;;;GAMG;AACH,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,MAAc;IACrD,MAAM,WAAW,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;IAChE,MAAM,YAAY,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;IAClE,OAAO,eAAe,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC;AACpD,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/http/bootstrap-hint.d.ts b/lib/host/shared/http/bootstrap-hint.d.ts new file mode 100644 index 0000000..74791bc --- /dev/null +++ b/lib/host/shared/http/bootstrap-hint.d.ts @@ -0,0 +1,15 @@ +import type { IncomingMessage } from "node:http"; +/** + * `token` 查询参数:必须**恰好一项**且 trim 后非空,否则 undefined。 + * 多值一律拒绝(`?token=a&token=b` 没有"哪个才算"的正确答案),空串/纯空白同理。 + */ +export declare function singleTokenParam(params: URLSearchParams): string | undefined; +/** + * 首次引导的请求形状判定:`GET` + pathname 恰为 `/` + 恰好一个非空 `token` 查询 → 引导端点 + * 路径(token 原样重新编码);其它形状 → undefined。 + * + * 门只做这一步形态判断,**不校验** token、不读文件、不 await:真正的校验、未配置探针与会话 + * 签发都在 `GET /auth/bootstrap` 里完成,被冻结的守卫词表因此一行不用改。 + */ +export declare function bootstrapRedirectPath(req: IncomingMessage): string | undefined; +//# sourceMappingURL=bootstrap-hint.d.ts.map \ No newline at end of file diff --git a/lib/host/shared/http/bootstrap-hint.d.ts.map b/lib/host/shared/http/bootstrap-hint.d.ts.map new file mode 100644 index 0000000..1490a79 --- /dev/null +++ b/lib/host/shared/http/bootstrap-hint.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"bootstrap-hint.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/http/bootstrap-hint.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAGjD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAK5E;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAM9E"} \ No newline at end of file diff --git a/lib/host/shared/http/bootstrap-hint.js b/lib/host/shared/http/bootstrap-hint.js new file mode 100644 index 0000000..f5a0902 --- /dev/null +++ b/lib/host/shared/http/bootstrap-hint.js @@ -0,0 +1,29 @@ +import { BOOTSTRAP_PATH } from "../protocol/index.js"; +/** + * `token` 查询参数:必须**恰好一项**且 trim 后非空,否则 undefined。 + * 多值一律拒绝(`?token=a&token=b` 没有"哪个才算"的正确答案),空串/纯空白同理。 + */ +export function singleTokenParam(params) { + const values = params.getAll("token"); + if (values.length !== 1) + return undefined; + const value = values[0]; + return value === undefined || value.trim() === "" ? undefined : value; +} +/** + * 首次引导的请求形状判定:`GET` + pathname 恰为 `/` + 恰好一个非空 `token` 查询 → 引导端点 + * 路径(token 原样重新编码);其它形状 → undefined。 + * + * 门只做这一步形态判断,**不校验** token、不读文件、不 await:真正的校验、未配置探针与会话 + * 签发都在 `GET /auth/bootstrap` 里完成,被冻结的守卫词表因此一行不用改。 + */ +export function bootstrapRedirectPath(req) { + if (req.method !== "GET") + return undefined; + const url = new URL(req.url ?? "/", "http://x"); + if (url.pathname !== "/") + return undefined; + const token = singleTokenParam(url.searchParams); + return token === undefined ? undefined : `${BOOTSTRAP_PATH}?token=${encodeURIComponent(token)}`; +} +//# sourceMappingURL=bootstrap-hint.js.map \ No newline at end of file diff --git a/lib/host/shared/http/bootstrap-hint.js.map b/lib/host/shared/http/bootstrap-hint.js.map new file mode 100644 index 0000000..efdfc57 --- /dev/null +++ b/lib/host/shared/http/bootstrap-hint.js.map @@ -0,0 +1 @@ +{"version":3,"file":"bootstrap-hint.js","sourceRoot":"","sources":["../../../../src/host/shared/http/bootstrap-hint.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAuB;IACtD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IAC1C,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACxB,OAAO,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC;AACxE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,GAAoB;IACxD,IAAI,GAAG,CAAC,MAAM,KAAK,KAAK;QAAE,OAAO,SAAS,CAAC;IAC3C,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,UAAU,CAAC,CAAC;IAChD,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3C,MAAM,KAAK,GAAG,gBAAgB,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;IACjD,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,cAAc,UAAU,kBAAkB,CAAC,KAAK,CAAC,EAAE,CAAC;AAClG,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/http/index.d.ts b/lib/host/shared/http/index.d.ts index 4ca8bdb..aa22232 100644 --- a/lib/host/shared/http/index.d.ts +++ b/lib/host/shared/http/index.d.ts @@ -7,6 +7,7 @@ * grow by accident. */ export { isSafeRelativeTarget, validateNext } from "./auth-common.js"; +export { bootstrapRedirectPath, singleTokenParam } from "./bootstrap-hint.js"; export { DEFAULT_TRUSTED_PROXIES, makeClientIpResolver, parseClientIpPolicy, resolveClientIp, } from "./client-ip.js"; export type { ClientIpLogger, ClientIpPolicy, ClientIpResolver } from "./client-ip.js"; export { parseCookieHeader } from "./cookie.js"; diff --git a/lib/host/shared/http/index.d.ts.map b/lib/host/shared/http/index.d.ts.map index 9298393..edd5f94 100644 --- a/lib/host/shared/http/index.d.ts.map +++ b/lib/host/shared/http/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,GAChB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvF,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACvE,YAAY,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,YAAY,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC9E,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,GAChB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvF,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACvE,YAAY,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,YAAY,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/http/index.js b/lib/host/shared/http/index.js index dd0c327..ea86921 100644 --- a/lib/host/shared/http/index.js +++ b/lib/host/shared/http/index.js @@ -7,6 +7,7 @@ * grow by accident. */ export { isSafeRelativeTarget, validateNext } from "./auth-common.js"; +export { bootstrapRedirectPath, singleTokenParam } from "./bootstrap-hint.js"; export { DEFAULT_TRUSTED_PROXIES, makeClientIpResolver, parseClientIpPolicy, resolveClientIp, } from "./client-ip.js"; export { parseCookieHeader } from "./cookie.js"; export { authCatchAll, methodNotAllowed, queryOf } from "./endpoints.js"; diff --git a/lib/host/shared/http/index.js.map b/lib/host/shared/http/index.js.map index 8bcb478..f37b945 100644 --- a/lib/host/shared/http/index.js.map +++ b/lib/host/shared/http/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/shared/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,GAChB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/shared/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC9E,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,GAChB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/http/origin.d.ts b/lib/host/shared/http/origin.d.ts index 3846825..74a5e26 100644 --- a/lib/host/shared/http/origin.d.ts +++ b/lib/host/shared/http/origin.d.ts @@ -2,8 +2,9 @@ import type { IncomingMessage } from "node:http"; /** * 已认证状态变更请求的同源校验(P2 §6 的 CSRF 纵深)。 * - * 只服务两条**已认证**的写路径:`POST /auth/users/password` 与 `POST /auth/password`。 - * 登录路径(未认证)**不覆盖**:那边失败会让全员锁死,风险与收益不成比例(契约 §10 残留)。 + * 只服务三条**已认证**的写路径:`POST /auth/users/password`、`POST /auth/password` 与首次 + * 配置向导的 `POST /auth/setup`(只有 bootstrap 会话能进,校验后不写盘)。登录路径 + * (未认证)**不覆盖**:那边失败会让全员锁死,风险与收益不成比例(契约 §10 残留)。 * * 判定顺序(任一通过即放行,两者都缺则 fail-closed): * 1. `Sec-Fetch-Site: same-origin`:浏览器强制设置、页面脚本无法伪造,是最可靠的来源证明。 diff --git a/lib/host/shared/http/origin.d.ts.map b/lib/host/shared/http/origin.d.ts.map index aa166ce..617b28a 100644 --- a/lib/host/shared/http/origin.d.ts.map +++ b/lib/host/shared/http/origin.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"origin.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/http/origin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAGjD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,GAAG,QAAQ,CAAA;CAAE,GACnE;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAAC;AAE1F,8BAA8B;AAC9B,MAAM,MAAM,aAAa,GACvB,SAAS,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,UAAU,GAAG,0BAA0B,CAAC;AAE1F;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAC;IAC7C,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAKD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,GAAG,aAAa,CAwB7F"} \ No newline at end of file +{"version":3,"file":"origin.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/http/origin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAGjD;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,GAAG,QAAQ,CAAA;CAAE,GACnE;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAAC;AAE1F,8BAA8B;AAC9B,MAAM,MAAM,aAAa,GACvB,SAAS,GAAG,MAAM,GAAG,UAAU,GAAG,WAAW,GAAG,UAAU,GAAG,0BAA0B,CAAC;AAE1F;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAC;IAC7C,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAKD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,GAAG,aAAa,CAwB7F"} \ No newline at end of file diff --git a/lib/host/shared/http/origin.js.map b/lib/host/shared/http/origin.js.map index 99c3063..a2c9e01 100644 --- a/lib/host/shared/http/origin.js.map +++ b/lib/host/shared/http/origin.js.map @@ -1 +1 @@ -{"version":3,"file":"origin.js","sourceRoot":"","sources":["../../../../src/host/shared/http/origin.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"origin.js","sourceRoot":"","sources":["../../../../src/host/shared/http/origin.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/host/shared/login-page/index.d.ts b/lib/host/shared/login-page/index.d.ts index 5da368a..87187a7 100644 --- a/lib/host/shared/login-page/index.d.ts +++ b/lib/host/shared/login-page/index.d.ts @@ -1,6 +1,9 @@ /** * shared/login-page segment public API: the server-rendered login, password and TOTP - * challenge pages. Explicit export list: register new exports here. + * challenge pages, plus the card stylesheet the first-run wizard reuses so every + * server-rendered page of this plugin looks the same. Explicit export list: register new + * exports here. */ export { loginPageHtml, passwordLoginPageHtml, totpChallengePageHtml } from "./login-page.js"; +export { CARD_STYLE } from "./login-page-assets.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/lib/host/shared/login-page/index.d.ts.map b/lib/host/shared/login-page/index.d.ts.map index 95d0945..835c6c8 100644 --- a/lib/host/shared/login-page/index.d.ts.map +++ b/lib/host/shared/login-page/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/login-page/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/login-page/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAC9F,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/login-page/index.js b/lib/host/shared/login-page/index.js index 67bea1f..56fe709 100644 --- a/lib/host/shared/login-page/index.js +++ b/lib/host/shared/login-page/index.js @@ -1,6 +1,9 @@ /** * shared/login-page segment public API: the server-rendered login, password and TOTP - * challenge pages. Explicit export list: register new exports here. + * challenge pages, plus the card stylesheet the first-run wizard reuses so every + * server-rendered page of this plugin looks the same. Explicit export list: register new + * exports here. */ export { loginPageHtml, passwordLoginPageHtml, totpChallengePageHtml } from "./login-page.js"; +export { CARD_STYLE } from "./login-page-assets.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/host/shared/login-page/index.js.map b/lib/host/shared/login-page/index.js.map index bf6b1eb..8457de8 100644 --- a/lib/host/shared/login-page/index.js.map +++ b/lib/host/shared/login-page/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/shared/login-page/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/shared/login-page/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAC9F,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/protocol/index.d.ts b/lib/host/shared/protocol/index.d.ts index d58cff5..e82e785 100644 --- a/lib/host/shared/protocol/index.d.ts +++ b/lib/host/shared/protocol/index.d.ts @@ -3,6 +3,6 @@ * host 的任何文件都经这里拿 `/auth/*` 路径与载荷类型,不直接引 `src/protocol/`,也不写路径字面量。 * `shared` 是最低层,entities 与 features 都能直接引它,不需要 `@x`。 */ -export { AUTH_PATH_PREFIX, isAuthPath, LOGIN_PATH, loginPath, LOGOUT_PATH, PASSWORD_CHANGED_NOTICE, PASSWORD_PATH, STATUS_PATH, USERS_PASSWORD_PATH, USERS_PATH, } from "../../../protocol/index.js"; +export { AUTH_PATH_PREFIX, BOOTSTRAP_PATH, BOOTSTRAP_SESSION_KIND, CONTINUE_PATH, isAuthPath, LOGIN_PATH, loginPath, LOGOUT_PATH, PASSWORD_CHANGED_NOTICE, PASSWORD_PATH, SETUP_PATH, STATUS_PATH, USERS_PASSWORD_PATH, USERS_PATH, } from "../../../protocol/index.js"; export type { AdminResetErrorBody, AdminResetRequest, AdminResetResponse, AdminUserRow, LockedErrorBody, PasswordChangeErrorBody, PasswordChangeRequest, PasswordChangeResponse, PasswordRule, PolicyErrorBody, SessionKind, StatusResponse, Unchecked, UserRole, UsersErrorBody, UsersResponse, } from "../../../protocol/index.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/lib/host/shared/protocol/index.d.ts.map b/lib/host/shared/protocol/index.d.ts.map index 780d594..a3f2a0a 100644 --- a/lib/host/shared/protocol/index.d.ts.map +++ b/lib/host/shared/protocol/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,SAAS,EACT,WAAW,EACX,uBAAuB,EACvB,aAAa,EACb,WAAW,EACX,mBAAmB,EACnB,UAAU,GACX,MAAM,4BAA4B,CAAC;AACpC,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,uBAAuB,EACvB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,EACZ,eAAe,EACf,WAAW,EACX,cAAc,EACd,SAAS,EACT,QAAQ,EACR,cAAc,EACd,aAAa,GACd,MAAM,4BAA4B,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/host/shared/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,aAAa,EACb,UAAU,EACV,UAAU,EACV,SAAS,EACT,WAAW,EACX,uBAAuB,EACvB,aAAa,EACb,UAAU,EACV,WAAW,EACX,mBAAmB,EACnB,UAAU,GACX,MAAM,4BAA4B,CAAC;AACpC,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,uBAAuB,EACvB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,EACZ,eAAe,EACf,WAAW,EACX,cAAc,EACd,SAAS,EACT,QAAQ,EACR,cAAc,EACd,aAAa,GACd,MAAM,4BAA4B,CAAC"} \ No newline at end of file diff --git a/lib/host/shared/protocol/index.js b/lib/host/shared/protocol/index.js index b6cf43d..d937603 100644 --- a/lib/host/shared/protocol/index.js +++ b/lib/host/shared/protocol/index.js @@ -3,5 +3,5 @@ * host 的任何文件都经这里拿 `/auth/*` 路径与载荷类型,不直接引 `src/protocol/`,也不写路径字面量。 * `shared` 是最低层,entities 与 features 都能直接引它,不需要 `@x`。 */ -export { AUTH_PATH_PREFIX, isAuthPath, LOGIN_PATH, loginPath, LOGOUT_PATH, PASSWORD_CHANGED_NOTICE, PASSWORD_PATH, STATUS_PATH, USERS_PASSWORD_PATH, USERS_PATH, } from "../../../protocol/index.js"; +export { AUTH_PATH_PREFIX, BOOTSTRAP_PATH, BOOTSTRAP_SESSION_KIND, CONTINUE_PATH, isAuthPath, LOGIN_PATH, loginPath, LOGOUT_PATH, PASSWORD_CHANGED_NOTICE, PASSWORD_PATH, SETUP_PATH, STATUS_PATH, USERS_PASSWORD_PATH, USERS_PATH, } from "../../../protocol/index.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/host/shared/protocol/index.js.map b/lib/host/shared/protocol/index.js.map index 4c00fa3..345087f 100644 --- a/lib/host/shared/protocol/index.js.map +++ b/lib/host/shared/protocol/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/shared/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,SAAS,EACT,WAAW,EACX,uBAAuB,EACvB,aAAa,EACb,WAAW,EACX,mBAAmB,EACnB,UAAU,GACX,MAAM,4BAA4B,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/host/shared/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,aAAa,EACb,UAAU,EACV,UAAU,EACV,SAAS,EACT,WAAW,EACX,uBAAuB,EACvB,aAAa,EACb,UAAU,EACV,WAAW,EACX,mBAAmB,EACnB,UAAU,GACX,MAAM,4BAA4B,CAAC"} \ No newline at end of file diff --git a/lib/index.d.ts.map b/lib/index.d.ts.map index 3ad80a5..949aee8 100644 --- a/lib/index.d.ts.map +++ b/lib/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,OAAO,CAAC,MAAM,0BAA0B,CAAC;AAEzC,OAAO,EAAc,KAAK,IAAI,EAAwB,MAAM,8BAA8B,CAAC;AAoB3F,OAAO,EAAqB,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAEnF,kCAAkC;AAClC,eAAO,MAAM,IAAI,kBAAkB,CAAC;AAEpC,qEAAqE;AACrE,eAAO,MAAM,MAAM,wBAAyB,CAAC;AAE7C,MAAM,WAAW,UAAU;IACzB,mCAAmC;IACnC,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;IAC3B,iBAAiB;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,mBAAmB;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,YAAY,EAAE,OAAO,CAAC;IACtB,sDAAsD;IACtD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B;;;OAGG;IACH,IAAI,EAAE,KAAK,GAAG,UAAU,GAAG,UAAU,CAAC;IACtC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,UAAU,CAiB/B,CAAC;AAEH,uCAAuC;AACvC,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,QAAQ,EAAE,YAAY,GAAG,SAAS,CAAC;IACnC,+DAA+D;IAC/D,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,IAAI,CAAC,EAAE,WAAW,CAAC;KACpB;CACF;AAmID;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CAsE5D"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,OAAO,CAAC,MAAM,0BAA0B,CAAC;AAEzC,OAAO,EAAc,KAAK,IAAI,EAAwB,MAAM,8BAA8B,CAAC;AAqB3F,OAAO,EAAqB,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAEnF,kCAAkC;AAClC,eAAO,MAAM,IAAI,kBAAkB,CAAC;AAEpC,qEAAqE;AACrE,eAAO,MAAM,MAAM,wBAAyB,CAAC;AAE7C,MAAM,WAAW,UAAU;IACzB,mCAAmC;IACnC,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;IAC3B,iBAAiB;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,mBAAmB;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,YAAY,EAAE,OAAO,CAAC;IACtB,sDAAsD;IACtD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B;;;OAGG;IACH,IAAI,EAAE,KAAK,GAAG,UAAU,GAAG,UAAU,CAAC;IACtC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,UAAU,CAiB/B,CAAC;AAEH,uCAAuC;AACvC,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,QAAQ,EAAE,YAAY,GAAG,SAAS,CAAC;IACnC,+DAA+D;IAC/D,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,IAAI,CAAC,EAAE,WAAW,CAAC;KACpB;CACF;AAuID;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,CAiF5D"} \ No newline at end of file diff --git a/lib/index.js b/lib/index.js index ffac27c..6cb80c2 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,6 +4,7 @@ import { registerAuthEndpoints, safeEqual, TokenGate } from "./host/features/tok import { wrapServer } from "./host/shared/guard/index.js"; import { DisabledSessionSweeper, PasswordGate, makePasswordChangeWiring, registerPasswordEndpoints, verifyPassword, } from "./host/features/password/index.js"; import { makeAdminWiring } from "./admin-wiring.js"; +import { makeBootstrapWiring } from "./bootstrap-wiring.js"; import { TotpReplayGuard, verifyTotpCode } from "./host/features/totp/index.js"; import { DEFAULT_TRUSTED_PROXIES, makeClientIpResolver, parseClientIpPolicy, } from "./host/shared/http/index.js"; import { LoginRateLimiter } from "./host/shared/rate-limit/index.js"; @@ -36,9 +37,10 @@ export const Config = z.object({ }); /** * 会话层软接(M1 逻辑不变):storageDomain 缺失 → error 日志后守卫照常挂载; - * 存在 → effect 内 open domain,就绪后把 SessionStore 挂到 auth.sessions。 + * 存在 → effect 内 open domain,就绪后把 SessionStore 挂到 auth.sessions,再通知 `onReady` + * (首次引导的启动清扫要等这一刻)。 */ -function mountSessionDomain(ctx, auth, log) { +function mountSessionDomain(ctx, auth, log, onReady) { const storageDomain = ctx.get("storageDomain"); if (storageDomain === undefined) { log.error("storage-domain is unavailable: session persistence is disabled (guards stay mounted)"); @@ -52,7 +54,9 @@ function mountSessionDomain(ctx, auth, log) { void domain.close(); return; } - auth.sessions = new SessionStore(domain.table("sessions")); + const store = new SessionStore(domain.table("sessions")); + auth.sessions = store; + onReady?.(store); log.info("session domain opened: dsh_auth_sessions"); }, (error) => { log.error(`session domain open failed: ${error instanceof Error ? error.message : String(error)}`); @@ -165,10 +169,18 @@ export function apply(ctx, config) { }), }; ctx.provide("auth", auth); - const sessionDisposer = mountSessionDomain(ctx, auth, log); - if (sessionDisposer !== undefined) { + const bootstrap = makeBootstrapWiring({ + ctx, + server, + config, + auth, + usersPath, + launchTokenBridge, + log, + }); + const sessionDisposer = mountSessionDomain(ctx, auth, log, bootstrap.onSessionsReady); + if (sessionDisposer !== undefined) ctx.effect(sessionDisposer, "dsh-auth-gate: session domain"); - } // 禁用用户 → 已发会话即时失效(D8):插件侧周期扫描 users.yaml,把变更落到会话表; // 门保持 P12 的同步零 IO 语义,CLI 也不需要访问会话存储。 if (config.mode === "password" && config.revokeSweepMs > 0) { @@ -182,6 +194,9 @@ export function apply(ctx, config) { } const unwrap = wrapServer(server, () => auth.gate, log); ctx.effect(() => unwrap, "dsh-auth-gate: guard unwrap"); + // 首次引导通道(`/auth/bootstrap` → `/auth/setup` → `/auth/continue`):只在实例未配置时签发 + // 一次性 bootstrap 会话,向导完成后关窗。三条路径在 `/auth` 前缀内,门直接放行。 + ctx.effect(() => bootstrap.mount(), "dsh-auth-gate: bootstrap routes"); ctx.effect(() => mountAuthEndpoints(server, config, auth, resolveToken, launchTokenBridge, usersPath, limiter, replayGuard, challengeMacKey, log), "dsh-auth-gate: auth endpoints"); const failures = assertGuarded(server); if (failures.length > 0) { diff --git a/lib/index.js.map b/lib/index.js.map index e4032b4..8156f4e 100644 --- a/lib/index.js.map +++ b/lib/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/lib/launch-token-bridge.d.ts b/lib/launch-token-bridge.d.ts index 980b2d4..210fba6 100644 --- a/lib/launch-token-bridge.d.ts +++ b/lib/launch-token-bridge.d.ts @@ -1,4 +1,11 @@ import type { Context } from "@deepseek-ai/cordis"; +/** + * 当前进程的 launch token(只从 `connection.authenticatedUrl` 取)。 + * + * 服务缺失 / 旧版 dsh(无 `authenticatedUrl`)/ 返回里没有 token / 抛错 → `undefined`。 + * 首次引导通道拿它做常量时间比较,桥拿它拼登录后的 `/?token=`,两处读的是同一枚 token。 + */ +export declare function readLaunchToken(ctx: Context): string | undefined; /** * dsh launch-token 桥(0.1.2-alpha 起 client-connection 的页面 token 门):登录成功后 * 302 到相对 `/?token=`,浏览器自动 mint dsh cookie,免去手动复制 diff --git a/lib/launch-token-bridge.d.ts.map b/lib/launch-token-bridge.d.ts.map index fe4641b..c12177e 100644 --- a/lib/launch-token-bridge.d.ts.map +++ b/lib/launch-token-bridge.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"launch-token-bridge.d.ts","sourceRoot":"","sources":["../src/launch-token-bridge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE;IAAE,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,GACpC,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAuCnC"} \ No newline at end of file +{"version":3,"file":"launch-token-bridge.d.ts","sourceRoot":"","sources":["../src/launch-token-bridge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAiCnD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAGhE;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE;IAAE,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,GACpC,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAuBnC"} \ No newline at end of file diff --git a/lib/launch-token-bridge.js b/lib/launch-token-bridge.js index 816a8c3..f7fb4ff 100644 --- a/lib/launch-token-bridge.js +++ b/lib/launch-token-bridge.js @@ -1,3 +1,35 @@ +/** 只从 authenticatedUrl 提取 token 的内部探针;三条失败原因分开,供桥选告警文案。 */ +function probeLaunchToken(ctx) { + try { + const connection = ctx.get("connection"); + if (connection === undefined || typeof connection.authenticatedUrl !== "function") { + return { kind: "inactive" }; + } + // 固定 loopback base 仅用于触发 authenticatedUrl 的 URL 解析;返回值中的 + // host/scheme/其它 query 全部丢弃,只保留 launch token。 + const token = new URL(connection.authenticatedUrl("http://127.0.0.1")).searchParams.get("token"); + if (token === null || token === "") { + return { kind: "failed", detail: "authenticatedUrl returned no token" }; + } + return { kind: "ok", token }; + } + catch (error) { + return { + kind: "failed", + detail: error instanceof Error ? error.message : String(error), + }; + } +} +/** + * 当前进程的 launch token(只从 `connection.authenticatedUrl` 取)。 + * + * 服务缺失 / 旧版 dsh(无 `authenticatedUrl`)/ 返回里没有 token / 抛错 → `undefined`。 + * 首次引导通道拿它做常量时间比较,桥拿它拼登录后的 `/?token=`,两处读的是同一枚 token。 + */ +export function readLaunchToken(ctx) { + const probe = probeLaunchToken(ctx); + return probe.kind === "ok" ? probe.token : undefined; +} /** * dsh launch-token 桥(0.1.2-alpha 起 client-connection 的页面 token 门):登录成功后 * 302 到相对 `/?token=`,浏览器自动 mint dsh cookie,免去手动复制 @@ -11,34 +43,22 @@ export function makeLaunchTokenBridge(ctx, log) { let warnedInactive = false; let warnedError = false; return () => { - try { - const connection = ctx.get("connection"); - if (connection === undefined || typeof connection.authenticatedUrl !== "function") { - if (!warnedInactive) { - warnedInactive = true; - log.warn("launch-token bridge inactive: no connection.authenticatedUrl (dsh < 0.1.2-alpha?)"); - } - return Promise.resolve(undefined); - } - // 固定 loopback base 仅用于触发 authenticatedUrl 的 URL 解析;返回值中的 - // host/scheme/其它 query 全部丢弃,只保留 launch token。 - const token = new URL(connection.authenticatedUrl("http://127.0.0.1")).searchParams.get("token"); - if (token === null || token === "") { - if (!warnedError) { - warnedError = true; - log.warn("launch-token bridge unavailable: authenticatedUrl returned no token"); - } - return Promise.resolve(undefined); - } - return Promise.resolve(`/?token=${encodeURIComponent(token)}`); + const probe = probeLaunchToken(ctx); + if (probe.kind === "ok") { + return Promise.resolve(`/?token=${encodeURIComponent(probe.token)}`); } - catch (error) { - if (!warnedError) { - warnedError = true; - log.warn(`launch-token bridge unavailable: ${error instanceof Error ? error.message : String(error)}`); + if (probe.kind === "inactive") { + if (!warnedInactive) { + warnedInactive = true; + log.warn("launch-token bridge inactive: no connection.authenticatedUrl (dsh < 0.1.2-alpha?)"); } return Promise.resolve(undefined); } + if (!warnedError) { + warnedError = true; + log.warn(`launch-token bridge unavailable: ${probe.detail}`); + } + return Promise.resolve(undefined); }; } //# sourceMappingURL=launch-token-bridge.js.map \ No newline at end of file diff --git a/lib/launch-token-bridge.js.map b/lib/launch-token-bridge.js.map index a0a2019..5efeb3e 100644 --- a/lib/launch-token-bridge.js.map +++ b/lib/launch-token-bridge.js.map @@ -1 +1 @@ -{"version":3,"file":"launch-token-bridge.js","sourceRoot":"","sources":["../src/launch-token-bridge.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,UAAU,qBAAqB,CACnC,GAAY,EACZ,GAAqC;IAErC,IAAI,cAAc,GAAG,KAAK,CAAC;IAC3B,IAAI,WAAW,GAAG,KAAK,CAAC;IACxB,OAAO,GAAgC,EAAE;QACvC,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,GAAG,CAAC,GAAG,CAAC,YAAY,CACqB,CAAC;YAC7D,IAAI,UAAU,KAAK,SAAS,IAAI,OAAO,UAAU,CAAC,gBAAgB,KAAK,UAAU,EAAE,CAAC;gBAClF,IAAI,CAAC,cAAc,EAAE,CAAC;oBACpB,cAAc,GAAG,IAAI,CAAC;oBACtB,GAAG,CAAC,IAAI,CACN,mFAAmF,CACpF,CAAC;gBACJ,CAAC;gBACD,OAAO,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;YACpC,CAAC;YACD,yDAAyD;YACzD,8CAA8C;YAC9C,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,gBAAgB,CAAC,kBAAkB,CAAC,CAAC,CAAC,YAAY,CAAC,GAAG,CACrF,OAAO,CACR,CAAC;YACF,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;gBACnC,IAAI,CAAC,WAAW,EAAE,CAAC;oBACjB,WAAW,GAAG,IAAI,CAAC;oBACnB,GAAG,CAAC,IAAI,CAAC,qEAAqE,CAAC,CAAC;gBAClF,CAAC;gBACD,OAAO,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;YACpC,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,kBAAkB,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACjE,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,WAAW,EAAE,CAAC;gBACjB,WAAW,GAAG,IAAI,CAAC;gBACnB,GAAG,CAAC,IAAI,CACN,oCAAoC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAC7F,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACpC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"} \ No newline at end of file +{"version":3,"file":"launch-token-bridge.js","sourceRoot":"","sources":["../src/launch-token-bridge.ts"],"names":[],"mappings":"AAQA,2DAA2D;AAC3D,SAAS,gBAAgB,CAAC,GAAY;IACpC,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,GAAG,CAAC,GAAG,CAAC,YAAY,CACqB,CAAC;QAC7D,IAAI,UAAU,KAAK,SAAS,IAAI,OAAO,UAAU,CAAC,gBAAgB,KAAK,UAAU,EAAE,CAAC;YAClF,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC;QAC9B,CAAC;QACD,yDAAyD;QACzD,8CAA8C;QAC9C,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,gBAAgB,CAAC,kBAAkB,CAAC,CAAC,CAAC,YAAY,CAAC,GAAG,CACrF,OAAO,CACR,CAAC;QACF,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;YACnC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,oCAAoC,EAAE,CAAC;QAC1E,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IAC/B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO;YACL,IAAI,EAAE,QAAQ;YACd,MAAM,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAC/D,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,GAAY;IAC1C,MAAM,KAAK,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC;IACpC,OAAO,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACvD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,qBAAqB,CACnC,GAAY,EACZ,GAAqC;IAErC,IAAI,cAAc,GAAG,KAAK,CAAC;IAC3B,IAAI,WAAW,GAAG,KAAK,CAAC;IACxB,OAAO,GAAgC,EAAE;QACvC,MAAM,KAAK,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC;QACpC,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,EAAE,CAAC;YACxB,OAAO,OAAO,CAAC,OAAO,CAAC,WAAW,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACvE,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YAC9B,IAAI,CAAC,cAAc,EAAE,CAAC;gBACpB,cAAc,GAAG,IAAI,CAAC;gBACtB,GAAG,CAAC,IAAI,CACN,mFAAmF,CACpF,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACpC,CAAC;QACD,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,WAAW,GAAG,IAAI,CAAC;YACnB,GAAG,CAAC,IAAI,CAAC,oCAAoC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;QAC/D,CAAC;QACD,OAAO,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACpC,CAAC,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/lib/protocol/index.d.ts b/lib/protocol/index.d.ts index b347d7f..83b79c4 100644 --- a/lib/protocol/index.d.ts +++ b/lib/protocol/index.d.ts @@ -25,6 +25,17 @@ export declare const PASSWORD_PATH = "/auth/password"; export declare const USERS_PATH = "/auth/users"; /** 管理面:管理员重置他人口令。 */ export declare const USERS_PASSWORD_PATH = "/auth/users/password"; +/** 首次引导:校验 dsh launch token 并签发一次性的 bootstrap 会话(GET)。 */ +export declare const BOOTSTRAP_PATH = "/auth/bootstrap"; +/** 首次配置向导页(GET 渲染、POST 提交);也是 bootstrap 会话被拒时的导航目标。 */ +export declare const SETUP_PATH = "/auth/setup"; +/** 向导完成后回首页:只认会话 cookie,302 到 dsh 的 `/?token=`。 */ +export declare const CONTINUE_PATH = "/auth/continue"; +/** + * bootstrap 会话的 `kind` 值:只在首次引导通道签发,TTL 有上限,只能访问本插件的 + * `/auth/*`。向导完成后被整体吊销,不是第二种长期登录方式。 + */ +export declare const BOOTSTRAP_SESSION_KIND = "bootstrap"; /** 登录页 `notice` 白名单键:改密成功后被登出。 */ export declare const PASSWORD_CHANGED_NOTICE = "password-changed"; /** 改密成功后 client 把当前设备送去的登录页(回首页,并带上被登出的原因键)。 */ diff --git a/lib/protocol/index.d.ts.map b/lib/protocol/index.d.ts.map index d8a1ecb..e2639b4 100644 --- a/lib/protocol/index.d.ts.map +++ b/lib/protocol/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,uBAAuB,EACvB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,EACZ,eAAe,EACf,WAAW,EACX,cAAc,EACd,SAAS,EACT,QAAQ,EACR,cAAc,EACd,aAAa,GACd,MAAM,eAAe,CAAC;AAEvB,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,UAAU,CAAC;AAExC,2BAA2B;AAC3B,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAExC,uCAAuC;AACvC,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAE1C,uBAAuB;AACvB,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAE1C,8CAA8C;AAC9C,eAAO,MAAM,aAAa,mBAAmB,CAAC;AAE9C,gBAAgB;AAChB,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAExC,qBAAqB;AACrB,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,qBAAqB,CAAC;AAE1D,gDAAgD;AAChD,eAAO,MAAM,0BAA0B,iDAA6D,CAAC;AAErG,gCAAgC;AAChC,eAAO,MAAM,UAAU,wBAA0B,CAAC;AAElD,wDAAwD;AACxD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,qCAAqC;AACrC,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,UAAU,GAAG,MAAM,CAGlE"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,uBAAuB,EACvB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,EACZ,eAAe,EACf,WAAW,EACX,cAAc,EACd,SAAS,EACT,QAAQ,EACR,cAAc,EACd,aAAa,GACd,MAAM,eAAe,CAAC;AAEvB,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,UAAU,CAAC;AAExC,2BAA2B;AAC3B,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAExC,uCAAuC;AACvC,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAE1C,uBAAuB;AACvB,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAE1C,8CAA8C;AAC9C,eAAO,MAAM,aAAa,mBAAmB,CAAC;AAE9C,gBAAgB;AAChB,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAExC,qBAAqB;AACrB,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D,0DAA0D;AAC1D,eAAO,MAAM,cAAc,oBAAoB,CAAC;AAEhD,uDAAuD;AACvD,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAExC,mDAAmD;AACnD,eAAO,MAAM,aAAa,mBAAmB,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,sBAAsB,cAAc,CAAC;AAElD,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,qBAAqB,CAAC;AAE1D,gDAAgD;AAChD,eAAO,MAAM,0BAA0B,iDAA6D,CAAC;AAErG,gCAAgC;AAChC,eAAO,MAAM,UAAU,wBAA0B,CAAC;AAElD,wDAAwD;AACxD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,qCAAqC;AACrC,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,UAAU,GAAG,MAAM,CAGlE"} \ No newline at end of file diff --git a/lib/protocol/index.js b/lib/protocol/index.js index 3085b3b..57c1fb9 100644 --- a/lib/protocol/index.js +++ b/lib/protocol/index.js @@ -24,6 +24,17 @@ export const PASSWORD_PATH = "/auth/password"; export const USERS_PATH = "/auth/users"; /** 管理面:管理员重置他人口令。 */ export const USERS_PASSWORD_PATH = "/auth/users/password"; +/** 首次引导:校验 dsh launch token 并签发一次性的 bootstrap 会话(GET)。 */ +export const BOOTSTRAP_PATH = "/auth/bootstrap"; +/** 首次配置向导页(GET 渲染、POST 提交);也是 bootstrap 会话被拒时的导航目标。 */ +export const SETUP_PATH = "/auth/setup"; +/** 向导完成后回首页:只认会话 cookie,302 到 dsh 的 `/?token=`。 */ +export const CONTINUE_PATH = "/auth/continue"; +/** + * bootstrap 会话的 `kind` 值:只在首次引导通道签发,TTL 有上限,只能访问本插件的 + * `/auth/*`。向导完成后被整体吊销,不是第二种长期登录方式。 + */ +export const BOOTSTRAP_SESSION_KIND = "bootstrap"; /** 登录页 `notice` 白名单键:改密成功后被登出。 */ export const PASSWORD_CHANGED_NOTICE = "password-changed"; /** 改密成功后 client 把当前设备送去的登录页(回首页,并带上被登出的原因键)。 */ diff --git a/lib/protocol/index.js.map b/lib/protocol/index.js.map index 1080a18..667482d 100644 --- a/lib/protocol/index.js.map +++ b/lib/protocol/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAqBH,kDAAkD;AAClD,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC;AAExC,2BAA2B;AAC3B,MAAM,CAAC,MAAM,UAAU,GAAG,aAAa,CAAC;AAExC,uCAAuC;AACvC,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAE1C,uBAAuB;AACvB,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAE1C,8CAA8C;AAC9C,MAAM,CAAC,MAAM,aAAa,GAAG,gBAAgB,CAAC;AAE9C,gBAAgB;AAChB,MAAM,CAAC,MAAM,UAAU,GAAG,aAAa,CAAC;AAExC,qBAAqB;AACrB,MAAM,CAAC,MAAM,mBAAmB,GAAG,sBAAsB,CAAC;AAE1D,kCAAkC;AAClC,MAAM,CAAC,MAAM,uBAAuB,GAAG,kBAAkB,CAAC;AAE1D,gDAAgD;AAChD,MAAM,CAAC,MAAM,0BAA0B,GAAG,GAAG,UAAU,oBAAoB,uBAAuB,EAAE,CAAC;AAErG,gCAAgC;AAChC,MAAM,CAAC,MAAM,UAAU,GAAG,GAAG,WAAW,SAAS,CAAC;AAElD,wDAAwD;AACxD,MAAM,UAAU,UAAU,CAAC,QAAgB;IACzC,OAAO,QAAQ,KAAK,gBAAgB,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,gBAAgB,GAAG,CAAC,CAAC;AACtF,CAAC;AAED,qCAAqC;AACrC,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,KAAkB;IACxD,MAAM,IAAI,GAAG,GAAG,UAAU,SAAS,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC;IAC9D,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,UAAU,KAAK,EAAE,CAAC;AAC/D,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/protocol/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAqBH,kDAAkD;AAClD,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC;AAExC,2BAA2B;AAC3B,MAAM,CAAC,MAAM,UAAU,GAAG,aAAa,CAAC;AAExC,uCAAuC;AACvC,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAE1C,uBAAuB;AACvB,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAE1C,8CAA8C;AAC9C,MAAM,CAAC,MAAM,aAAa,GAAG,gBAAgB,CAAC;AAE9C,gBAAgB;AAChB,MAAM,CAAC,MAAM,UAAU,GAAG,aAAa,CAAC;AAExC,qBAAqB;AACrB,MAAM,CAAC,MAAM,mBAAmB,GAAG,sBAAsB,CAAC;AAE1D,0DAA0D;AAC1D,MAAM,CAAC,MAAM,cAAc,GAAG,iBAAiB,CAAC;AAEhD,uDAAuD;AACvD,MAAM,CAAC,MAAM,UAAU,GAAG,aAAa,CAAC;AAExC,mDAAmD;AACnD,MAAM,CAAC,MAAM,aAAa,GAAG,gBAAgB,CAAC;AAE9C;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,WAAW,CAAC;AAElD,kCAAkC;AAClC,MAAM,CAAC,MAAM,uBAAuB,GAAG,kBAAkB,CAAC;AAE1D,gDAAgD;AAChD,MAAM,CAAC,MAAM,0BAA0B,GAAG,GAAG,UAAU,oBAAoB,uBAAuB,EAAE,CAAC;AAErG,gCAAgC;AAChC,MAAM,CAAC,MAAM,UAAU,GAAG,GAAG,WAAW,SAAS,CAAC;AAElD,wDAAwD;AACxD,MAAM,UAAU,UAAU,CAAC,QAAgB;IACzC,OAAO,QAAQ,KAAK,gBAAgB,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,gBAAgB,GAAG,CAAC,CAAC;AACtF,CAAC;AAED,qCAAqC;AACrC,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,KAAkB;IACxD,MAAM,IAAI,GAAG,GAAG,UAAU,SAAS,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC;IAC9D,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,UAAU,KAAK,EAAE,CAAC;AAC/D,CAAC"} \ No newline at end of file diff --git a/lib/protocol/payloads.d.ts b/lib/protocol/payloads.d.ts index 1f41e88..0e1d936 100644 --- a/lib/protocol/payloads.d.ts +++ b/lib/protocol/payloads.d.ts @@ -9,8 +9,11 @@ */ /** 用户角色(`users.yaml` 的 `role`,也是 `/auth/status` 与 `/auth/users` 里的 `role`)。 */ export type UserRole = "user" | "admin"; -/** 会话类别(D25):受限会话只能改密,不进管理面。 */ -export type SessionKind = "full" | "password-change-only"; +/** + * 会话类别(D25):受限会话只能改密,不进管理面;bootstrap 会话只能访问本插件的 + * `/auth/*`,只在首次引导通道签发,向导完成后整体吊销。 + */ +export type SessionKind = "full" | "password-change-only" | "bootstrap"; /** 口令策略未通过项(值即 400 `policy` 错误体里 `rules` 数组的元素,顺序同 host 判定顺序)。 */ export type PasswordRule = "minLength" | "maxLength" | "uppercase" | "lowercase" | "digit" | "special" | "sameAsOld"; /** diff --git a/lib/protocol/payloads.d.ts.map b/lib/protocol/payloads.d.ts.map index c04909a..16974ac 100644 --- a/lib/protocol/payloads.d.ts.map +++ b/lib/protocol/payloads.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"payloads.d.ts","sourceRoot":"","sources":["../../src/protocol/payloads.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,+EAA+E;AAC/E,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAExC,gCAAgC;AAChC,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,sBAAsB,CAAC;AAE1D,kEAAkE;AAClE,MAAM,MAAM,YAAY,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;AAE5F;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC,SAAS,OAAO,GAAG,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC;AAEzF;;;;;;GAMG;AACH,MAAM,WAAW,cAAc;IAC7B,aAAa,EAAE,OAAO,CAAC;IACvB,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,IAAI,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAClC,kBAAkB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACzC,WAAW,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CACvC;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,OAAO,CAAC;IACrB,kBAAkB,EAAE,OAAO,CAAC;CAC7B;AAED,4DAA4D;AAC5D,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,YAAY,EAAE,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,cAAc,GAAG,WAAW,CAAC;CACrC;AAED,2CAA2C;AAC3C,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,YAAY,EAAE,CAAC;CACvB;AAED,gEAAgE;AAChE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,GAAG,CAAC,EAAE,GAAG,CAAC;CACX;AAED,qDAAqD;AACrD,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,IAAI,CAAC;CACV;AAED;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAC/B;IAAE,KAAK,EAAE,cAAc,GAAG,qBAAqB,GAAG,cAAc,GAAG,YAAY,CAAA;CAAE,GACjF,eAAe,GACf,eAAe,CAAC;AAEpB;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,iFAAiF;AACjF,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,IAAI,CAAC;IACT,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,KAAK,EAAE,cAAc,GAAG,WAAW,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,CAAA;CAAE,GACrF,eAAe,GACf,eAAe,CAAC"} \ No newline at end of file +{"version":3,"file":"payloads.d.ts","sourceRoot":"","sources":["../../src/protocol/payloads.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,+EAA+E;AAC/E,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAExC;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,sBAAsB,GAAG,WAAW,CAAC;AAExE,kEAAkE;AAClE,MAAM,MAAM,YAAY,GACtB,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;AAE5F;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC,SAAS,OAAO,GAAG,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC;AAEzF;;;;;;GAMG;AACH,MAAM,WAAW,cAAc;IAC7B,aAAa,EAAE,OAAO,CAAC;IACvB,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,IAAI,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAClC,kBAAkB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACzC,WAAW,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CACvC;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,OAAO,CAAC;IACrB,kBAAkB,EAAE,OAAO,CAAC;CAC7B;AAED,4DAA4D;AAC5D,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,YAAY,EAAE,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,cAAc,GAAG,WAAW,CAAC;CACrC;AAED,2CAA2C;AAC3C,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,YAAY,EAAE,CAAC;CACvB;AAED,gEAAgE;AAChE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,GAAG,CAAC,EAAE,GAAG,CAAC;CACX;AAED,qDAAqD;AACrD,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,IAAI,CAAC;CACV;AAED;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAC/B;IAAE,KAAK,EAAE,cAAc,GAAG,qBAAqB,GAAG,cAAc,GAAG,YAAY,CAAA;CAAE,GACjF,eAAe,GACf,eAAe,CAAC;AAEpB;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,iFAAiF;AACjF,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,IAAI,CAAC;IACT,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,KAAK,EAAE,cAAc,GAAG,WAAW,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,CAAA;CAAE,GACrF,eAAe,GACf,eAAe,CAAC"} \ No newline at end of file diff --git a/src/bootstrap-wiring.test.ts b/src/bootstrap-wiring.test.ts new file mode 100644 index 0000000..650208e --- /dev/null +++ b/src/bootstrap-wiring.test.ts @@ -0,0 +1,207 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadUsersFile } from "./host/entities/user/index.js"; +import { BOOTSTRAP_PATH, CONTINUE_PATH, SETUP_PATH } from "./host/shared/protocol/index.js"; +import { fakeReq, makeRes } from "../test/bootstrap-harness.js"; +import { + LAUNCH, + LOGIN_REDIRECT, + makeWiringHarness, + type Credentials, +} from "../test/bootstrap-wiring-harness.js"; + +const directories: string[] = []; +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "dsh-auth-wiring-")); + directories.push(dir); + return dir; +} + +afterEach(async () => { + for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +function form(fields: Record): string { + return new URLSearchParams(fields).toString(); +} + +async function waitFor(condition: () => boolean, timeoutMs = 2_000): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) throw new Error("timed out waiting for condition"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +function tokenCookie(token: string): string { + return token.slice(token.indexOf("=") + 1); +} + +describe("makeBootstrapWiring: routes", () => { + it("mounts the three first-run routes and unregisters them on dispose", async () => { + const harness = makeWiringHarness({ mount: false }); + const dispose = harness.wiring.mount(); + expect([...harness.server.routes.keys()].sort((a, b) => a.localeCompare(b))).toEqual([ + BOOTSTRAP_PATH, + CONTINUE_PATH, + SETUP_PATH, + ]); + dispose(); + expect(harness.server.routes.size).toBe(0); + + const anonymous = makeRes(); + await makeWiringHarness().handler(SETUP_PATH)(fakeReq({ url: SETUP_PATH }), anonymous.res); + expect(anonymous.status).toBe(302); + expect(anonymous.headers["location"]).toBe(LOGIN_REDIRECT); + }); + + it("keeps the method matrix of the continue endpoint on the route table", async () => { + const res = makeRes(); + await makeWiringHarness().handler(CONTINUE_PATH)( + fakeReq({ method: "PUT", url: CONTINUE_PATH }), + res.res, + ); + expect(res.status).toBe(405); + expect(res.headers["allow"]).toBe("GET"); + }); +}); + +describe("makeBootstrapWiring: token mode", () => { + it("saves a fresh token into the credential store and closes the window at once", async () => { + const values = new Map(); + const credentials: Credentials = { + resolve: (ref) => Promise.resolve(values.has(ref) ? { value: values.get(ref)! } : undefined), + set: (ref, value) => { + values.set(ref, value); + return Promise.resolve(); + }, + }; + const harness = makeWiringHarness({ credentials }); + expect(await harness.wiring.probe.state()).toBe("unconfigured"); + const res = await harness.post(); + const token = values.get("DSH_AUTH_TOKEN"); + expect(token).toBeTruthy(); + expect(res.status).toBe(200); + expect(res.body.split(token!).length - 1).toBe(1); + expect(harness.wiring.markedConfigured.value).toBe(true); + expect(await harness.wiring.probe.state()).toBe("configured"); + // 令牌绝不写日志。 + expect(JSON.stringify(harness.logs)).not.toContain(token!); + }); + + it("fails closed when the credential store cannot write, without leaking a token", async () => { + const harness = makeWiringHarness({ + credentials: { resolve: () => Promise.resolve(undefined) }, + }); + const res = await harness.post(); + expect(res.status).toBe(500); + expect(res.body).toContain("DSH_AUTH_TOKEN"); + expect(harness.wiring.markedConfigured.value).toBe(false); + expect(await harness.wiring.probe.state()).toBe("unconfigured"); + expect(harness.logs.some((log) => log.level === "warn")).toBe(true); + }); + + it("reports saved-unreadable when the write lands but reads back nothing", async () => { + const harness = makeWiringHarness({ + credentials: { resolve: () => Promise.resolve(undefined), set: () => Promise.resolve() }, + }); + const res = await harness.post(); + expect(res.status).toBe(200); + expect(res.body).toContain("Restart dsh"); + expect(harness.wiring.markedConfigured.value).toBe(true); + }); +}); + +describe("makeBootstrapWiring: password mode", () => { + it("writes the first administrator into a real users.yaml", async () => { + const usersPath = join(await tempDir(), "users.yaml"); + const harness = makeWiringHarness({ mode: "password", usersPath }); + const res = await harness.post( + form({ username: "root", password: "Sup3r-secret-pw", confirm: "Sup3r-secret-pw" }), + ); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(CONTINUE_PATH); + const record = (await loadUsersFile(usersPath)).snapshot.users.get("root"); + expect(record?.role).toBe("admin"); + expect(record?.disabled).toBe(false); + expect(record?.passwordHash).not.toContain("Sup3r-secret-pw"); + expect(harness.wiring.markedConfigured.value).toBe(true); + }); + + it("rejects a weak password and an invalid username before touching the file", async () => { + const usersPath = join(await tempDir(), "users.yaml"); + const cases = [ + { username: "root", password: "short" }, + { username: "-bad name", password: "Sup3r-secret-pw" }, + ]; + for (const testCase of cases) { + const harness = makeWiringHarness({ mode: "password", usersPath }); + const res = await harness.post(form({ ...testCase, confirm: testCase.password })); + expect({ user: testCase.username, status: res.status }).toEqual({ + user: testCase.username, + status: 400, + }); + expect(harness.wiring.markedConfigured.value).toBe(false); + } + expect((await loadUsersFile(usersPath)).missing).toBe(true); + }); +}); + +describe("makeBootstrapWiring: startup sweep", () => { + it("revokes stale bootstrap sessions only on a configured instance", async () => { + const configured = makeWiringHarness({ + credentials: { resolve: () => Promise.resolve({ value: "live" }) }, + }); + const stale = tokenCookie(await configured.cookie()); + configured.wiring.onSessionsReady(configured.store); + await waitFor(() => configured.store.getByToken(stale) === undefined); + expect(configured.logs.some((log) => String(log.message).includes("stale bootstrap"))).toBe( + true, + ); + + const fresh = makeWiringHarness({ credentials: { resolve: () => Promise.resolve(undefined) } }); + const kept = tokenCookie(await fresh.cookie()); + fresh.wiring.onSessionsReady(fresh.store); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fresh.store.getByToken(kept)?.kind).toBe("bootstrap"); + }); +}); + +describe("makeBootstrapWiring: strict token probe", () => { + const bootstrapHit = `${BOOTSTRAP_PATH}?token=${LAUNCH}`; + + it("reads a failing credential resolve as unknown and issues no bootstrap session", async () => { + const harness = makeWiringHarness({ + credentials: { resolve: () => Promise.reject(new Error("credentials not ready")) }, + }); + expect(await harness.wiring.probe.state()).toBe("unknown"); + const res = makeRes(); + await harness.handler(BOOTSTRAP_PATH)(fakeReq({ url: bootstrapHit }), res.res); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + expect(res.headers["set-cookie"]).toBeUndefined(); + }); + + it("reads a missing credentials service as unknown instead of unconfigured", async () => { + const harness = makeWiringHarness(); + expect(await harness.wiring.probe.state()).toBe("unknown"); + const res = makeRes(); + await harness.handler(BOOTSTRAP_PATH)(fakeReq({ url: bootstrapHit }), res.res); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + expect(res.headers["set-cookie"]).toBeUndefined(); + }); + + it("opens the window only for a healthy resolve that returns nothing", async () => { + const harness = makeWiringHarness({ + credentials: { resolve: () => Promise.resolve(undefined) }, + }); + expect(await harness.wiring.probe.state()).toBe("unconfigured"); + const res = makeRes(); + await harness.handler(BOOTSTRAP_PATH)(fakeReq({ url: bootstrapHit }), res.res); + expect(res.headers["location"]).toBe("/auth/setup"); + expect(res.headers["set-cookie"]).toContain("dsh_auth="); + }); +}); diff --git a/src/bootstrap-wiring.ts b/src/bootstrap-wiring.ts new file mode 100644 index 0000000..07f9f6a --- /dev/null +++ b/src/bootstrap-wiring.ts @@ -0,0 +1,253 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { randomBytes } from "node:crypto"; +import type { WrappableServer } from "./host/shared/guard/index.js"; +import type { LaunchTokenBridge } from "./host/shared/http/index.js"; +import { methodNotAllowed } from "./host/shared/http/index.js"; +import { + BOOTSTRAP_PATH, + BOOTSTRAP_SESSION_KIND, + CONTINUE_PATH, + SETUP_PATH, +} from "./host/shared/protocol/index.js"; +import { buildSetCookie, type SessionStore } from "./host/entities/session/index.js"; +import { checkPasswordPolicy, mutateUsersFile, USERNAME_RE } from "./host/entities/user/index.js"; +import { hashPassword } from "./host/features/password/index.js"; +import { + makeBootstrapEndpoint, + makeConfiguredProbe, + makeContinueEndpoint, + makeSetupHandlers, + type ConfiguredProbe, + type SetupTokenOutcome, +} from "./host/features/bootstrap/index.js"; +import { readLaunchToken } from "./launch-token-bridge.js"; + +/** `ctx.get("credentials")` 的结构镜像(dsh ≥ 0.2 的 `CredentialProvider` 的子集)。 */ +interface CredentialsLike { + resolve(ref: string): Promise<{ value: string } | undefined>; + set?(ref: string, value: string): Promise; +} + +/** 与 `Config` 的 `tokenRef` 模式一致:credential-ref 是 POSIX 环境变量名。 */ +const CREDENTIAL_REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** 只看 bootstrap 需要的启动期配置(结构兼容 `AuthConfig`,根文件传整个 config 即可)。 */ +export interface BootstrapConfig { + mode: "token" | "password"; + cookieName: string; + cookieSecure: boolean; + sessionTtl: number; + tokenRef: string; + publicHost: string; +} + +export interface BootstrapWiringInput { + ctx: Context; + server: WrappableServer; + config: BootstrapConfig; + /** 会话层(异步就绪,每次现取)。 */ + auth: { sessions: SessionStore | undefined }; + usersPath: string; + launchTokenBridge: LaunchTokenBridge; + log: { + info(message: unknown): void; + warn(message: unknown): void; + error(message: unknown): void; + }; +} + +export interface BootstrapWiring { + /** 注册三条路由(`/auth/bootstrap`、`/auth/setup`、`/auth/continue`),返回合并 disposer。 */ + mount: () => () => void; + probe: ConfiguredProbe; + /** 进程内「已配置」标志:向导写入成功后置位,探针立即改口,不等服务端缓存刷新。 */ + markedConfigured: { value: boolean }; + /** 会话层就绪后的启动清扫:已配置实例上的残留 bootstrap 会话一律吊销。 */ + onSessionsReady: (store: SessionStore) => void; +} + +/** + * 首次引导通道的 root 侧装配(与 `admin-wiring.ts` 同性质,从 `src/index.ts` 拆出)。 + * + * 三件事:把探针、三个端点与页面渲染收拢成 deps;把「生成并保存访问令牌 / 建管理员」这两个 + * 需要宿主能力的动作落成实现;注册路由并在会话层就绪时清掉残留的 bootstrap 会话。 + * **访问令牌绝不写日志**:只出现在提交结果页的 HTML 里(一次性展示)。 + * + * 未配置探针**不用** `makeTokenResolver`:那个解析器把「服务缺失 / 解析抛错」都吞成 + * `undefined`,会让「凭据服务尚未就绪」误判成「实例未配置」,于是在已配置实例上开引导窗口、 + * 甚至让向导覆盖掉已存在的 DSH_AUTH_TOKEN。这里用严格读取器,失败一律上抛成 `unknown`。 + */ +export function makeBootstrapWiring(input: BootstrapWiringInput): BootstrapWiring { + const markedConfigured = { value: false }; + const markConfigured = (): void => { + markedConfigured.value = true; + }; + const probe = makeConfiguredProbe({ + mode: input.config.mode, + resolveToken: + input.config.mode === "token" + ? makeStrictTokenReader(input.ctx, input.config.tokenRef) + : undefined, + usersPath: input.usersPath, + markedConfigured, + }); + const setup = makeSetupHandlers({ + mode: input.config.mode, + probe, + sessions: () => input.auth.sessions, + cookieName: input.config.cookieName, + cookieSecure: input.config.cookieSecure, + sessionTtlSeconds: input.config.sessionTtl, + publicHost: input.config.publicHost, + tokenRef: input.config.tokenRef, + usersPath: input.usersPath, + saveToken: () => saveToken(input, markConfigured), + createAdmin: (username, password) => createAdmin(input, markConfigured, username, password), + issueFullSession: (subject, res) => issueFullSession(input, subject, res), + logger: input.log, + }); + const bootstrap = makeBootstrapEndpoint({ + probe, + readLaunchToken: () => readLaunchToken(input.ctx), + sessions: () => input.auth.sessions, + cookieName: input.config.cookieName, + cookieSecure: input.config.cookieSecure, + sessionTtlSeconds: input.config.sessionTtl, + logger: input.log, + }); + const continueHandler = makeContinueEndpoint({ + sessions: () => input.auth.sessions, + cookieName: input.config.cookieName, + bridge: input.launchTokenBridge, + logger: input.log, + }); + const setupRoute = (req: IncomingMessage, res: ServerResponse): void | Promise => { + if (req.method === "GET") return setup.get(req, res); + if (req.method === "POST") return setup.post(req, res); + methodNotAllowed(res, "GET, POST"); + }; + return { + probe, + markedConfigured, + mount(): () => void { + const disposers = [ + input.server.register({ kind: "exact", path: BOOTSTRAP_PATH, handler: bootstrap }), + input.server.register({ kind: "exact", path: SETUP_PATH, handler: setupRoute }), + input.server.register({ kind: "exact", path: CONTINUE_PATH, handler: continueHandler }), + ]; + return () => { + for (const dispose of [...disposers].reverse()) dispose(); + }; + }, + onSessionsReady: (store: SessionStore): void => { + void probe + .state() + .then(async (state) => { + if (state !== "configured") return; + const revoked = await store.revokeByKind(BOOTSTRAP_SESSION_KIND); + if (revoked > 0) input.log.info(`stale bootstrap sessions revoked: ${revoked}`); + }) + .catch(() => input.log.warn("bootstrap session sweep failed")); + }, + }; +} + +/** 生成并保存访问令牌:写盘 → 读回比对 → 关窗。读不回新值也算保存成功(只是要重启)。 */ +async function saveToken( + input: BootstrapWiringInput, + markConfigured: () => void, +): Promise { + try { + const credentials = credentialsOf(input.ctx); + if (credentials === undefined || typeof credentials.set !== "function") { + return { status: "failed" }; + } + if (!CREDENTIAL_REF_RE.test(input.config.tokenRef)) return { status: "failed" }; + const token = randomBytes(32).toString("base64url"); + await credentials.set(input.config.tokenRef, token); + const readable = await readBack(credentials, input.config.tokenRef, token); + markConfigured(); + return readable ? { status: "saved", token } : { status: "saved-unreadable", token }; + } catch { + return { status: "failed" }; + } +} + +/** + * 严格读取共享 token(只给未配置探针用):每次现取凭据服务,**服务缺失或 resolve 抛错一律 + * 上抛**,由探针映射成 `unknown`(不引导)。只有「解析成功且值为空」才返回 `undefined` + * (= 真的未配置)。与 `makeTokenResolver` 的差别就在这里:那边的吞错是给门的 fail-closed + * 用的,不能拿来判断「有没有配置」。 + */ +function makeStrictTokenReader(ctx: Context, tokenRef: string): () => Promise { + return async (): Promise => { + const credentials = credentialsOf(ctx); + if (credentials === undefined) throw new Error("credentials service is unavailable"); + const resolved = await credentials.resolve(tokenRef); + return resolved?.value; + }; +} + +/** 凭据服务(惰性现取:harness 并行挂载行,服务可能晚于本行就绪)。 */ +function credentialsOf(ctx: Context): CredentialsLike | undefined { + return ctx.get("credentials") as CredentialsLike | undefined; +} + +/** 读回刚写的值;读失败或服务有缓存 → false(调用方据此提示「重启后生效」)。 */ +async function readBack( + credentials: CredentialsLike, + ref: string, + token: string, +): Promise { + try { + const resolved = await credentials.resolve(ref); + return resolved?.value === token; + } catch { + return false; + } +} + +/** 建第一个管理员:用户名 → 口令策略 → scrypt 哈希 → 锁内写 users.yaml。 */ +async function createAdmin( + input: BootstrapWiringInput, + markConfigured: () => void, + username: string, + password: string, +): Promise<"ok" | "conflict" | "invalid" | "failed"> { + try { + if (!USERNAME_RE.test(username)) return "invalid"; + if (!(await checkPasswordPolicy(password)).ok) return "invalid"; + const passwordHash = await hashPassword(password); + const outcome = await mutateUsersFile(input.usersPath, (snapshot) => { + if (snapshot.users.has(username)) return "conflict" as const; + snapshot.users.set(username, { passwordHash, disabled: false, role: "admin" }); + return "ok" as const; + }); + if (outcome === "ok") markConfigured(); + return outcome; + } catch { + return "failed"; + } +} + +/** 向导完成的正式会话:与登录成功同形(cookie 属性、TTL 一致),失败只记日志不放宽。 */ +async function issueFullSession( + input: BootstrapWiringInput, + subject: string, + res: ServerResponse, +): Promise { + const store = input.auth.sessions; + if (store === undefined) { + input.log.error("setup completed but the session store is unavailable"); + return; + } + const ttl = input.config.sessionTtl; + const issued = await store.create(subject, ttl * 1000, "full"); + res.setHeader("cache-control", "no-store"); + res.setHeader("referrer-policy", "no-referrer"); + res.setHeader( + "set-cookie", + buildSetCookie(input.config.cookieName, issued.token, ttl, input.config.cookieSecure), + ); +} diff --git a/src/host/entities/session/index.ts b/src/host/entities/session/index.ts index d90faa4..60a022d 100644 --- a/src/host/entities/session/index.ts +++ b/src/host/entities/session/index.ts @@ -7,6 +7,7 @@ export { buildSetCookie, COOKIE_FLAGS, digestToken, + isBootstrapSession, sessionDomainSpec, SessionStore, } from "./model/session-store.js"; diff --git a/src/host/entities/session/model/session-store.test.ts b/src/host/entities/session/model/session-store.test.ts index cd778d9..0381717 100644 --- a/src/host/entities/session/model/session-store.test.ts +++ b/src/host/entities/session/model/session-store.test.ts @@ -1,6 +1,12 @@ import type { KvTable } from "@deepseek-ai/dsh-storage-domain"; import { describe, expect, it } from "vitest"; -import { buildSetCookie, digestToken, SessionStore, type Session } from "./session-store.js"; +import { + buildSetCookie, + digestToken, + isBootstrapSession, + SessionStore, + type Session, +} from "./session-store.js"; class MemTable implements KvTable { private readonly map = new Map(); @@ -114,6 +120,46 @@ describe("SessionStore", () => { expect(store.getByToken(other.token)?.subject).toBe("bob"); expect(await store.revokeBySubject("alice")).toBe(0); }); + + it("revokes every session of one kind and leaves the other kinds alone", async () => { + const table = new MemTable(); + const store = new SessionStore(table); + const bootstrap1 = await store.create("bootstrap", 60_000, "bootstrap"); + const bootstrap2 = await store.create("bootstrap", 60_000, "bootstrap"); + const full = await store.create("token", 60_000); + const restricted = await store.create("alice", 60_000, "password-change-only"); + + expect(await store.revokeByKind("bootstrap")).toBe(2); + expect(store.getByToken(bootstrap1.token)).toBeUndefined(); + expect(store.getByToken(bootstrap2.token)).toBeUndefined(); + expect(store.getByToken(full.token)?.subject).toBe("token"); + expect(store.getByToken(restricted.token)?.kind).toBe("password-change-only"); + // 再删一遍:已没有同类行,条数为 0(幂等)。 + expect(await store.revokeByKind("bootstrap")).toBe(0); + }); + + it("keeps the session kind on the stored row", async () => { + const table = new MemTable(); + const store = new SessionStore(table); + const issued = await store.create("bootstrap", 60_000, "bootstrap"); + expect(table.get(digestToken(issued.token))?.kind).toBe("bootstrap"); + expect(issued.session.kind).toBe("bootstrap"); + }); +}); + +describe("isBootstrapSession", () => { + const base: Session = { subject: "token", createdAt: 0, expiresAt: 1, revoked: false }; + + it("recognises only the bootstrap kind", () => { + expect(isBootstrapSession({ ...base, kind: "bootstrap" })).toBe(true); + expect(isBootstrapSession({ ...base, kind: "full" })).toBe(false); + expect(isBootstrapSession({ ...base, kind: "password-change-only" })).toBe(false); + }); + + it("treats a missing kind and a missing session as not bootstrap", () => { + expect(isBootstrapSession(base)).toBe(false); + expect(isBootstrapSession(undefined)).toBe(false); + }); }); describe("buildSetCookie", () => { diff --git a/src/host/entities/session/model/session-store.ts b/src/host/entities/session/model/session-store.ts index 62d8745..d4a402f 100644 --- a/src/host/entities/session/model/session-store.ts +++ b/src/host/entities/session/model/session-store.ts @@ -1,7 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; import { defineDomain, domainTable, type KvTable } from "@deepseek-ai/dsh-storage-domain"; import { z } from "zod"; -import type { SessionKind } from "../../../shared/protocol/index.js"; +import { BOOTSTRAP_SESSION_KIND, type SessionKind } from "../../../shared/protocol/index.js"; /** 会话 cookie 属性(M2 起使用;M1 冻结并测试)。 */ export const COOKIE_FLAGS = "Path=/; HttpOnly; Secure; SameSite=Lax"; @@ -45,6 +45,14 @@ export interface IssuedSession { session: Session; } +/** + * 是否 bootstrap 会话(只读 `kind`;缺省 / 其它类别一律 false)。 + * 门用它把「只放行 `/auth/*`」的临时会话与正式会话分开,不做任何 IO。 + */ +export function isBootstrapSession(session: Session | undefined): boolean { + return session?.kind === BOOTSTRAP_SESSION_KIND; +} + const sessionRowSchema = z.object({ subject: z.string(), createdAt: z.number().int().nonnegative(), @@ -53,7 +61,7 @@ const sessionRowSchema = z.object({ // 可选:P2 之前写入的行没有该字段,必须继续可解析(缺省按 full 处理)。 // 注意:本仓库的 zod 是 v4(`z.enum`/`z.literal`),没有 `z.const` // (后者是 @deepseek-ai/schemastery 的 API,见 src/index.ts 的 Config)。 - kind: z.enum(["full", "password-change-only"]).optional(), + kind: z.enum(["full", "password-change-only", "bootstrap"]).optional(), }); /** 键 = digest(sha256 hex),不进 row;row 只存以上四字段。 */ @@ -116,6 +124,21 @@ export class SessionStore { return count; } + /** + * 吊销某个类别的**全部**会话行,返回吊销条数(写盘)。 + * 用途:首次配置向导成功后关掉引导窗口(`bootstrap` 会话一律失效)。 + * 行内没有类别索引,故按 row.kind 全表扫描(与 `revokeBySubject` 同款理由)。 + */ + async revokeByKind(kind: SessionKind): Promise { + let count = 0; + for (const [key, row] of this.table.entries()) { + if (row.kind !== kind) continue; + await this.table.delete(key); + count += 1; + } + return count; + } + /** 全表扫描删除过期行,返回删除数。 */ async pruneExpired(now: number = Date.now()): Promise { let count = 0; diff --git a/src/host/features/bootstrap/api/bootstrap-endpoint.test.ts b/src/host/features/bootstrap/api/bootstrap-endpoint.test.ts new file mode 100644 index 0000000..13a141d --- /dev/null +++ b/src/host/features/bootstrap/api/bootstrap-endpoint.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { SessionStore } from "../../../entities/session/index.js"; +import { fakeReq, makeRes, MemTable, probeOf } from "../../../../../test/bootstrap-harness.js"; +import { makeBootstrapEndpoint, type BootstrapEndpointDeps } from "./bootstrap-endpoint.js"; +import type { ConfiguredState } from "../model/probe.js"; + +const LOGIN_REDIRECT = "/auth/login?next=%2F"; + +function harness(overrides: Partial = {}): { + deps: BootstrapEndpointDeps; + store: SessionStore; + logs: { level: string; message: unknown }[]; +} { + const store = new SessionStore(new MemTable()); + const logs: { level: string; message: unknown }[] = []; + const deps: BootstrapEndpointDeps = { + probe: probeOf("unconfigured"), + readLaunchToken: () => "launch-token", + sessions: () => store, + cookieName: "dsh_auth", + cookieSecure: false, + sessionTtlSeconds: 604800, + logger: { + error: (message) => logs.push({ level: "error", message }), + info: (message) => logs.push({ level: "info", message }), + warn: (message) => logs.push({ level: "warn", message }), + }, + ...overrides, + }; + return { deps, store, logs }; +} + +function cookieValue(setCookie: string | undefined): string { + expect(setCookie).toBeDefined(); + return setCookie!.split(";")[0]!.split("=")[1]!; +} + +describe("GET /auth/bootstrap", () => { + it("issues a bootstrap session and redirects to the wizard", async () => { + const { deps, store, logs } = harness(); + const res = makeRes(); + await makeBootstrapEndpoint(deps)( + fakeReq({ url: "/auth/bootstrap?token=launch-token" }), + res.res, + ); + + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe("/auth/setup"); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + const cookie = res.headers["set-cookie"]!; + expect(cookie).toContain("dsh_auth="); + expect(cookie).toContain("Max-Age=3600"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + // secure=false(测试/HTTP 开发)时不能带 Secure,否则 cookie 落不了地。 + expect(cookie).not.toContain("Secure"); + expect(store.getByToken(cookieValue(cookie))?.kind).toBe("bootstrap"); + // 响应里绝不出现 launch token(Location 指向站点内路径,不是带 token 的 URL)。 + expect(JSON.stringify(res.headers)).not.toContain("launch-token"); + expect(res.body).toBe(""); + expect(logs.some((log) => log.level === "info")).toBe(true); + }); + + it("caps the bootstrap lifetime at one hour and honours a shorter configured TTL", async () => { + const capped = makeRes(); + await makeBootstrapEndpoint(harness().deps)( + fakeReq({ url: "/auth/bootstrap?token=launch-token" }), + capped.res, + ); + expect(capped.headers["set-cookie"]).toContain("Max-Age=3600"); + + const shorter = makeRes(); + await makeBootstrapEndpoint(harness({ sessionTtlSeconds: 120 }).deps)( + fakeReq({ url: "/auth/bootstrap?token=launch-token" }), + shorter.res, + ); + expect(shorter.headers["set-cookie"]).toContain("Max-Age=120"); + }); +}); + +describe("GET /auth/bootstrap: refusal paths", () => { + it("refuses malformed, wrong-token and already-configured requests without a cookie", async () => { + const unreachable = (): never => { + throw new Error("sessions must not be touched"); + }; + const cases: { deps: Partial; url: string }[] = [ + { deps: {}, url: "/auth/bootstrap?token=wrong-token" }, + { deps: {}, url: "/auth/bootstrap?token=launch-token&token=launch-token" }, + { deps: {}, url: "/auth/bootstrap" }, + { deps: {}, url: "/auth/bootstrap?token=%20" }, + { deps: { readLaunchToken: () => undefined }, url: "/auth/bootstrap?token=launch-token" }, + { deps: { readLaunchToken: () => "" }, url: "/auth/bootstrap?token=launch-token" }, + { deps: { probe: probeOf("configured") }, url: "/auth/bootstrap?token=launch-token" }, + { deps: { probe: probeOf("unknown") }, url: "/auth/bootstrap?token=launch-token" }, + { deps: { sessions: () => undefined }, url: "/auth/bootstrap?token=launch-token" }, + { deps: { sessions: unreachable }, url: "/auth/bootstrap" }, + ]; + for (const testCase of cases) { + const res = makeRes(); + await makeBootstrapEndpoint(harness(testCase.deps).deps)( + fakeReq({ url: testCase.url }), + res.res, + ); + expect({ url: testCase.url, status: res.status, location: res.headers["location"] }).toEqual({ + url: testCase.url, + status: 302, + location: LOGIN_REDIRECT, + }); + expect(res.headers["set-cookie"]).toBeUndefined(); + expect(res.body).not.toContain("launch-token"); + } + }); + + it("treats every non-unconfigured probe state as refuse", async () => { + const states: ConfiguredState[] = ["configured", "unknown"]; + for (const state of states) { + const res = makeRes(); + await makeBootstrapEndpoint(harness({ probe: probeOf(state) }).deps)( + fakeReq({ url: "/auth/bootstrap?token=launch-token" }), + res.res, + ); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + expect(res.headers["set-cookie"]).toBeUndefined(); + } + }); + + it("fails closed when the store refuses to create the session", async () => { + const logs: { level: string; message: unknown }[] = []; + const refusing = { + create: () => Promise.reject(new Error("store gone")), + } as unknown as SessionStore; + const res = makeRes(); + await makeBootstrapEndpoint( + harness({ + sessions: () => refusing, + logger: { + error: (message) => logs.push({ level: "error", message }), + info: (message) => logs.push({ level: "info", message }), + warn: (message) => logs.push({ level: "warn", message }), + }, + }).deps, + )(fakeReq({ url: "/auth/bootstrap?token=launch-token" }), res.res); + + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + expect(res.headers["set-cookie"]).toBeUndefined(); + expect(logs.filter((log) => log.level === "warn")).toHaveLength(1); + }); + + it("answers 405 for any non-GET method", async () => { + for (const method of ["POST", "HEAD", "PUT", "DELETE"]) { + const res = makeRes(); + await makeBootstrapEndpoint(harness().deps)( + fakeReq({ method, url: "/auth/bootstrap?token=launch-token" }), + res.res, + ); + expect(res.status).toBe(405); + expect(res.headers["allow"]).toBe("GET"); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["set-cookie"]).toBeUndefined(); + } + }); +}); diff --git a/src/host/features/bootstrap/api/bootstrap-endpoint.ts b/src/host/features/bootstrap/api/bootstrap-endpoint.ts new file mode 100644 index 0000000..ca5cb38 --- /dev/null +++ b/src/host/features/bootstrap/api/bootstrap-endpoint.ts @@ -0,0 +1,82 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { HttpHandler } from "../../../shared/guard/index.js"; +import { methodNotAllowed, queryOf, singleTokenParam } from "../../../shared/http/index.js"; +import { safeEqual } from "../../../shared/crypto/index.js"; +import { BOOTSTRAP_SESSION_KIND, SETUP_PATH } from "../../../shared/protocol/index.js"; +import { buildSetCookie, type SessionStore } from "../../../entities/session/index.js"; +import type { ConfiguredProbe } from "../model/probe.js"; +import { redirectTo, redirectToLogin } from "../model/respond.js"; + +/** bootstrap 会话的最长寿命:窗口再长也没有意义,且它只放行 `/auth/*`。 */ +const BOOTSTRAP_TTL_CAP_SECONDS = 3600; + +export interface BootstrapEndpointDeps { + /** 未配置探针(每次请求现算)。 */ + probe: ConfiguredProbe; + /** 当前进程的 launch token(与登录后桥读到的是同一枚)。 */ + readLaunchToken: () => string | undefined; + sessions: () => SessionStore | undefined; + cookieName: string; + cookieSecure: boolean; + sessionTtlSeconds: number; + logger: { + info(message: unknown): void; + warn(message: unknown): void; + error(message: unknown): void; + }; +} + +/** + * `GET /auth/bootstrap?token=`:校验 launch token、确认实例仍然未配置, + * 然后签发一枚 `bootstrap` 会话并 302 到 `/auth/setup`。 + * + * 这里是首次引导通道唯一的校验点:门只按请求形状重定向过来,不做校验也不做 IO。 + * 任何失败分支(形状不对、token 不对、已配置、探针 unknown、会话存储缺失、异常)都回到 + * `/auth/login?next=%2F` 且不下发 cookie,绝不放行未认证请求给 dsh。 + */ +export function makeBootstrapEndpoint(deps: BootstrapEndpointDeps): HttpHandler { + return async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== "GET") { + methodNotAllowed(res, "GET"); + return; + } + try { + const token = singleTokenParam(queryOf(req)); + if (token === undefined || !launchTokenMatches(deps.readLaunchToken(), token)) { + redirectToLogin(res); + return; + } + if ((await deps.probe.state()) !== "unconfigured") { + redirectToLogin(res); + return; + } + const store = deps.sessions(); + if (store === undefined) { + deps.logger.error("bootstrap refused: session store unavailable"); + redirectToLogin(res); + return; + } + const ttlSeconds = Math.min(deps.sessionTtlSeconds, BOOTSTRAP_TTL_CAP_SECONDS); + const issued = await store.create( + BOOTSTRAP_SESSION_KIND, + ttlSeconds * 1000, + BOOTSTRAP_SESSION_KIND, + ); + res.setHeader( + "set-cookie", + buildSetCookie(deps.cookieName, issued.token, ttlSeconds, deps.cookieSecure), + ); + deps.logger.info("bootstrap session issued"); + redirectTo(res, SETUP_PATH); + } catch { + // fail-closed:响应已写出就不补写,未写出则退回登录页。绝不记录 token 本身。 + if (res.headersSent !== true) redirectToLogin(res); + deps.logger.warn("bootstrap failed: unexpected error while issuing the session"); + } + }; +} + +/** 常量时间比对;任一侧为空一律不匹配(空 launch token 不是凭证)。 */ +function launchTokenMatches(launch: string | undefined, token: string): boolean { + return launch !== undefined && launch !== "" && safeEqual(token, launch); +} diff --git a/src/host/features/bootstrap/api/continue-endpoint.test.ts b/src/host/features/bootstrap/api/continue-endpoint.test.ts new file mode 100644 index 0000000..49c3676 --- /dev/null +++ b/src/host/features/bootstrap/api/continue-endpoint.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { SessionStore } from "../../../entities/session/index.js"; +import { fakeReq, makeRes, MemTable } from "../../../../../test/bootstrap-harness.js"; +import { makeContinueEndpoint, type ContinueEndpointDeps } from "./continue-endpoint.js"; + +const LOGIN_REDIRECT = "/auth/login?next=%2F"; + +function harness(overrides: Partial = {}): ContinueEndpointDeps { + return { + sessions: () => new SessionStore(new MemTable()), + cookieName: "dsh_auth", + bridge: () => Promise.resolve("/?token=launch-token"), + logger: { warn: () => undefined }, + ...overrides, + }; +} + +async function withSession( + deps: Partial = {}, +): Promise<{ deps: ContinueEndpointDeps; cookie: string }> { + const store = new SessionStore(new MemTable()); + const issued = await store.create("token", 60_000); + return { + deps: harness({ sessions: () => store, ...deps }), + cookie: `dsh_auth=${issued.token}`, + }; +} + +describe("GET /auth/continue", () => { + it("sends the finished wizard to the launch-token URL", async () => { + const { deps, cookie } = await withSession(); + const res = makeRes(); + await makeContinueEndpoint(deps)(fakeReq({ url: "/auth/continue", cookie }), res.res); + + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe("/?token=launch-token"); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + expect(res.headers["set-cookie"]).toBeUndefined(); + }); + + it("falls back to the bare root when the bridge has nothing to say", async () => { + const missing = await withSession({ bridge: () => Promise.resolve(undefined) }); + const missingRes = makeRes(); + await makeContinueEndpoint(missing.deps)( + fakeReq({ url: "/auth/continue", cookie: missing.cookie }), + missingRes.res, + ); + expect(missingRes.headers["location"]).toBe("/"); + + const absent = await withSession({ bridge: undefined }); + const absentRes = makeRes(); + await makeContinueEndpoint(absent.deps)( + fakeReq({ url: "/auth/continue", cookie: absent.cookie }), + absentRes.res, + ); + expect(absentRes.headers["location"]).toBe("/"); + }); + + it("falls back to the bare root when the bridge throws or returns an unsafe target", async () => { + const thrown = await withSession({ bridge: () => Promise.reject(new Error("boom")) }); + const thrownRes = makeRes(); + await makeContinueEndpoint(thrown.deps)( + fakeReq({ url: "/auth/continue", cookie: thrown.cookie }), + thrownRes.res, + ); + expect(thrownRes.headers["location"]).toBe("/"); + + const unsafe = await withSession({ bridge: () => Promise.resolve("//evil.example") }); + const unsafeRes = makeRes(); + await makeContinueEndpoint(unsafe.deps)( + fakeReq({ url: "/auth/continue", cookie: unsafe.cookie }), + unsafeRes.res, + ); + expect(unsafeRes.headers["location"]).toBe("/"); + }); + + it("requires a session and never echoes the query", async () => { + const deps = harness(); + const noCookie = makeRes(); + await makeContinueEndpoint(deps)( + fakeReq({ url: "/auth/continue?next=//evil.example" }), + noCookie.res, + ); + expect(noCookie.status).toBe(302); + expect(noCookie.headers["location"]).toBe(LOGIN_REDIRECT); + + const unknownCookie = makeRes(); + await makeContinueEndpoint(deps)( + fakeReq({ url: "/auth/continue", cookie: "dsh_auth=missing" }), + unknownCookie.res, + ); + expect(unknownCookie.headers["location"]).toBe(LOGIN_REDIRECT); + + const noStore = makeRes(); + await makeContinueEndpoint(harness({ sessions: () => undefined }))( + fakeReq({ url: "/auth/continue" }), + noStore.res, + ); + expect(noStore.headers["location"]).toBe(LOGIN_REDIRECT); + expect(noStore.headers["cache-control"]).toBe("no-store"); + expect(noStore.headers["referrer-policy"]).toBe("no-referrer"); + }); + + it("answers 405 for any non-GET method", async () => { + const { deps, cookie } = await withSession(); + const res = makeRes(); + await makeContinueEndpoint(deps)( + fakeReq({ method: "POST", url: "/auth/continue", cookie }), + res.res, + ); + expect(res.status).toBe(405); + expect(res.headers["allow"]).toBe("GET"); + expect(res.headers["cache-control"]).toBe("no-store"); + }); +}); + +describe("GET /auth/continue: bootstrap sessions", () => { + it("refuses a bootstrap session instead of relying on the root bounce", async () => { + const store = new SessionStore(new MemTable()); + const issued = await store.create("bootstrap", 60_000, "bootstrap"); + const res = makeRes(); + await makeContinueEndpoint(harness({ sessions: () => store }))( + fakeReq({ url: "/auth/continue", cookie: `dsh_auth=${issued.token}` }), + res.res, + ); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + expect(res.headers["set-cookie"]).toBeUndefined(); + }); +}); diff --git a/src/host/features/bootstrap/api/continue-endpoint.ts b/src/host/features/bootstrap/api/continue-endpoint.ts new file mode 100644 index 0000000..d12865b --- /dev/null +++ b/src/host/features/bootstrap/api/continue-endpoint.ts @@ -0,0 +1,53 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { HttpHandler } from "../../../shared/guard/index.js"; +import { + methodNotAllowed, + parseCookieHeader, + resolvePostLoginLocation, + type LaunchTokenBridge, +} from "../../../shared/http/index.js"; +import { + isBootstrapSession, + type Session, + type SessionStore, +} from "../../../entities/session/index.js"; +import { redirectTo, redirectToLogin } from "../model/respond.js"; + +export interface ContinueEndpointDeps { + sessions: () => SessionStore | undefined; + cookieName: string; + /** dsh 的 launch token 桥;不可用时回落到 `/`,让 dsh 自己决定怎么认人。 */ + bridge: LaunchTokenBridge | undefined; + logger: { warn?(message: unknown): void }; +} + +/** + * `GET /auth/continue`:向导完成后的最后一跳,302 到 dsh 的 `/?token=`, + * 由 dsh 自己 mint 浏览器 cookie。 + * + * 只认 cookie 里的**正式**会话:没有会话、会话失效、或拿的是 bootstrap 会话,一律回登录页 + * (且不回显 query),不靠「跳到 `/` 再被门弹回向导」兜底。它不认 query,也**不签发**任何 + * 会话,所以不会和数据面形成循环。桥缺失 / 抛错 → 回落到 `/`。 + */ +export function makeContinueEndpoint(deps: ContinueEndpointDeps): HttpHandler { + return async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== "GET") { + methodNotAllowed(res, "GET"); + return; + } + const session = sessionOf(deps, req); + if (session === undefined || isBootstrapSession(session)) { + redirectToLogin(res); + return; + } + redirectTo(res, await resolvePostLoginLocation(deps.bridge, "/", deps.logger)); + }; +} + +/** 会话行(只认 cookie):无 store / 无 cookie / 已吊销 / 已过期 → undefined。 */ +function sessionOf(deps: ContinueEndpointDeps, req: IncomingMessage): Session | undefined { + const store = deps.sessions(); + if (store === undefined) return undefined; + const cookie = parseCookieHeader(req.headers.cookie, deps.cookieName); + return cookie === undefined || cookie === "" ? undefined : store.getByToken(cookie); +} diff --git a/src/host/features/bootstrap/api/setup-endpoint.configured.test.ts b/src/host/features/bootstrap/api/setup-endpoint.configured.test.ts new file mode 100644 index 0000000..de4de3c --- /dev/null +++ b/src/host/features/bootstrap/api/setup-endpoint.configured.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + getSetupPage, + makeSetupHarness, + postSetupForm, + probeOf, +} from "../../../../../test/bootstrap-harness.js"; + +/** + * 配置在 bootstrap 会话签发之后成立:向导的 GET/POST 都必须拒绝,且绝不写盘。 + * 这也是「bootstrap 会话能跨重启活 1 小时」的唯一安全兜底。 + */ + +const LOGIN_REDIRECT = "/auth/login?next=%2F"; +const TRUSTED = { "sec-fetch-site": "same-origin" }; +const NAVIGATION = { "sec-fetch-dest": "document" }; + +function cookieToken(cookie: string): string { + return cookie.slice(cookie.indexOf("=") + 1); +} + +describe("setup endpoints when the instance becomes configured", () => { + it("shows the already-configured page and refuses the POST without writing", async () => { + let saves = 0; + let admins = 0; + const harness = makeSetupHarness({ + mode: "token", + probe: probeOf("configured"), + saveToken: () => { + saves += 1; + return Promise.resolve({ status: "saved", token: "never-shown" }); + }, + createAdmin: () => { + admins += 1; + return Promise.resolve("ok"); + }, + }); + const cookie = await harness.cookie(); + const page = await getSetupPage(harness, cookie); + expect(page.status).toBe(200); + expect(page.body).toContain("Already configured"); + expect(page.body).not.toContain("Generate access token"); + + const post = await postSetupForm(harness, { + cookie, + headers: { ...TRUSTED, ...NAVIGATION }, + }); + expect(post.status).toBe(302); + expect(post.headers["location"]).toBe(LOGIN_REDIRECT); + expect(post.headers["set-cookie"]).toBeUndefined(); + expect(harness.issued).toEqual([]); + expect(saves).toBe(0); + expect(admins).toBe(0); + // 旧 bootstrap 会话被吊销:同一个 cookie 再来就进不去了。 + expect(harness.store.getByToken(cookieToken(cookie))).toBeUndefined(); + }); + + it("refuses a non-navigation POST for both configured and unknown probes", async () => { + for (const state of ["configured", "unknown"] as const) { + const harness = makeSetupHarness({ probe: probeOf(state) }); + const page = await getSetupPage(harness, await harness.cookie()); + expect(page.status).toBe(200); + expect(page.body).toContain("Already configured"); + const res = await postSetupForm(harness, { cookie: await harness.cookie() }); + expect(res.status).toBe(403); + expect(res.body).toBe("already configured"); + expect(res.headers["set-cookie"]).toBeUndefined(); + } + }); +}); diff --git a/src/host/features/bootstrap/api/setup-endpoint.test.ts b/src/host/features/bootstrap/api/setup-endpoint.test.ts new file mode 100644 index 0000000..c0659ce --- /dev/null +++ b/src/host/features/bootstrap/api/setup-endpoint.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { + getSetupPage, + makeSetupHarness, + postSetupForm, +} from "../../../../../test/bootstrap-harness.js"; +import type { SetupTokenOutcome } from "./setup-endpoint.js"; + +const LOGIN_REDIRECT = "/auth/login?next=%2F"; +const TRUSTED = { "sec-fetch-site": "same-origin" }; +const NAVIGATION = { "sec-fetch-dest": "document" }; + +function form(fields: Record): string { + return new URLSearchParams(fields).toString(); +} + +function cookieToken(cookie: string): string { + return cookie.slice(cookie.indexOf("=") + 1); +} + +describe("GET /auth/setup", () => { + it("renders the wizard for a bootstrap session", async () => { + const harness = makeSetupHarness({ mode: "token" }); + const res = await getSetupPage(harness, await harness.cookie()); + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["content-type"]).toContain("text/html"); + expect(res.body).toContain('action="/auth/setup"'); + expect(res.body).toContain("Generate access token"); + expect(res.body).toContain("DSH_AUTH_TOKEN"); + expect(res.body).toContain("604800 seconds"); + expect(res.body).toContain("cordis.patch.yml"); + expect(res.body).not.toContain("/?token="); + }); + + it("renders the administrator form in password mode", async () => { + const harness = makeSetupHarness({ mode: "password" }); + const res = await getSetupPage(harness, await harness.cookie()); + expect(res.body).toContain('name="username"'); + expect(res.body).toContain('name="confirm"'); + expect(res.body).toContain("Create administrator"); + expect(res.body).toContain("users.yaml"); + }); + + it("sends an anonymous visitor to the login page", async () => { + const res = await getSetupPage(makeSetupHarness()); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + }); + + it("sends a visitor to the login page when the session store is missing", async () => { + const res = await getSetupPage(makeSetupHarness({ store: undefined }), "dsh_auth=x"); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + }); + + it("shows the already-configured page to a full session", async () => { + const harness = makeSetupHarness(); + const res = await getSetupPage(harness, await harness.cookie("full")); + expect(res.status).toBe(200); + expect(res.body).toContain("Already configured"); + expect(res.body).not.toContain("Generate access token"); + }); +}); + +describe("POST /auth/setup (token mode)", () => { + it("saves the token, closes the window and shows it exactly once", async () => { + const harness = makeSetupHarness({ mode: "token" }); + const cookie = await harness.cookie(); + const res = await postSetupForm(harness, { cookie }); + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.body.split("new-token").length - 1).toBe(1); + expect(res.body).toContain('href="/auth/continue"'); + expect(harness.issued).toEqual(["token"]); + // 旧的 bootstrap 会话已吊销,正式会话另发一枚。 + expect(harness.store.getByToken(cookieToken(cookie))).toBeUndefined(); + expect(res.headers["set-cookie"]).toContain("dsh_auth="); + }); + + it("asks for a restart when the new token cannot be read back", async () => { + const harness = makeSetupHarness({ + mode: "token", + saveToken: () => Promise.resolve({ status: "saved-unreadable", token: "new-token" }), + }); + const res = await postSetupForm(harness, { cookie: await harness.cookie() }); + expect(res.status).toBe(200); + expect(res.body).toContain("Restart dsh"); + }); + + it("fails with a fixed sentence and no secret when the write fails", async () => { + const harness = makeSetupHarness({ + mode: "token", + saveToken: (): Promise => Promise.resolve({ status: "failed" }), + }); + const res = await postSetupForm(harness, { cookie: await harness.cookie() }); + expect(res.status).toBe(500); + expect(res.body).toContain("DSH_AUTH_TOKEN"); + expect(harness.issued).toEqual([]); + expect(res.headers["set-cookie"]).toBeUndefined(); + expect(harness.logs.some((log) => log.level === "warn")).toBe(true); + }); + + it("rejects an untrusted origin before touching the store", async () => { + let saves = 0; + const harness = makeSetupHarness({ + mode: "token", + saveToken: () => { + saves += 1; + return Promise.resolve({ status: "saved", token: "new-token" }); + }, + }); + const res = await postSetupForm(harness, { cookie: await harness.cookie(), headers: {} }); + expect(res.status).toBe(403); + expect(res.body).toBe("untrusted origin"); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(saves).toBe(0); + }); +}); + +describe("POST /auth/setup (password mode)", () => { + const PASS = form({ username: "alice", password: "Sup3r-secret-pw", confirm: "Sup3r-secret-pw" }); + + it("creates the administrator, closes the window and continues to dsh", async () => { + const harness = makeSetupHarness({ mode: "password" }); + const cookie = await harness.cookie(); + const res = await postSetupForm(harness, { cookie, body: PASS }); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe("/auth/continue"); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + expect(harness.issued).toEqual(["alice"]); + expect(harness.store.getByToken(cookieToken(cookie))).toBeUndefined(); + }); + + it("re-renders the form on a password mismatch without calling createAdmin", async () => { + let created = 0; + const harness = makeSetupHarness({ + mode: "password", + createAdmin: () => { + created += 1; + return Promise.resolve("ok"); + }, + }); + const res = await postSetupForm(harness, { + cookie: await harness.cookie(), + body: form({ username: "alice", password: "Sup3r-secret-pw", confirm: "other" }), + }); + expect(res.status).toBe(400); + expect(res.body).toContain("do not match"); + expect(created).toBe(0); + }); + + it("re-renders the form with the policy copy when the password is too weak", async () => { + const harness = makeSetupHarness({ + mode: "password", + createAdmin: () => Promise.resolve("invalid"), + }); + const res = await postSetupForm(harness, { + cookie: await harness.cookie(), + body: form({ username: "alice", password: "weak", confirm: "weak" }), + }); + expect(res.status).toBe(400); + expect(res.body).toContain("at least 14 characters"); + expect(res.body).toContain('name="username"'); + }); + + it("reports an existing username as a conflict", async () => { + const harness = makeSetupHarness({ + mode: "password", + createAdmin: () => Promise.resolve("conflict"), + }); + const res = await postSetupForm(harness, { cookie: await harness.cookie(), body: PASS }); + expect(res.status).toBe(400); + expect(res.body).toContain("already exists"); + }); + + it("fails with a fixed sentence when the users file cannot be written", async () => { + const harness = makeSetupHarness({ + mode: "password", + createAdmin: () => Promise.resolve("failed"), + }); + const res = await postSetupForm(harness, { cookie: await harness.cookie(), body: PASS }); + expect(res.status).toBe(500); + expect(res.body).toContain("Could not create the administrator account"); + expect(harness.issued).toEqual([]); + }); +}); + +describe("POST /auth/setup (guards)", () => { + it("refuses a post without a bootstrap session", async () => { + const harness = makeSetupHarness(); + const navigation = await postSetupForm(harness, { + headers: { ...TRUSTED, ...NAVIGATION }, + }); + expect(navigation.status).toBe(302); + expect(navigation.headers["location"]).toBe(LOGIN_REDIRECT); + + const api = await postSetupForm(harness); + expect(api.status).toBe(401); + expect(api.body).toBe("unauthorized"); + }); + + it("refuses a full session, which must not run the wizard again", async () => { + const harness = makeSetupHarness(); + const res = await postSetupForm(harness, { + cookie: await harness.cookie("full"), + headers: { ...TRUSTED, ...NAVIGATION }, + }); + expect(res.status).toBe(302); + expect(res.headers["location"]).toBe(LOGIN_REDIRECT); + expect(harness.issued).toEqual([]); + }); + + it("rejects a non-form content type", async () => { + const harness = makeSetupHarness(); + const res = await postSetupForm(harness, { + cookie: await harness.cookie(), + body: "{}", + form: false, + }); + expect(res.status).toBe(415); + expect(res.headers["cache-control"]).toBe("no-store"); + }); + + it("accepts a same-origin browser form even when publicHost is unset", async () => { + const harness = makeSetupHarness({ mode: "token", publicHost: "" }); + const res = await postSetupForm(harness, { cookie: await harness.cookie() }); + expect(res.status).toBe(200); + expect(harness.issued).toEqual(["token"]); + }); +}); diff --git a/src/host/features/bootstrap/api/setup-endpoint.ts b/src/host/features/bootstrap/api/setup-endpoint.ts new file mode 100644 index 0000000..927f15d --- /dev/null +++ b/src/host/features/bootstrap/api/setup-endpoint.ts @@ -0,0 +1,271 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { HttpHandler } from "../../../shared/guard/index.js"; +import { isNavigationRequest } from "../../../shared/guard/index.js"; +import { + checkRequestOrigin, + parseCookieHeader, + parseFormBody, +} from "../../../shared/http/index.js"; +import { BOOTSTRAP_SESSION_KIND, CONTINUE_PATH } from "../../../shared/protocol/index.js"; +import type { Session, SessionStore } from "../../../entities/session/index.js"; +import { isBootstrapSession } from "../../../entities/session/index.js"; +import type { ConfiguredProbe } from "../model/probe.js"; +import { redirectTo, redirectToLogin, sendHtml, sendText } from "../model/respond.js"; +import { setupConfiguredHtml, setupPageHtml, setupResultHtml } from "../model/setup-page.js"; + +/** token 模式的写入结果:`saved-unreadable` = 写盘成功但当前进程读不回新值。 */ +export interface SetupTokenOutcome { + status: "saved" | "saved-unreadable" | "failed"; + /** 只在 saved / saved-unreadable 时给页面渲染一次。 */ + token?: string | undefined; +} + +export interface SetupEndpointDeps { + mode: "token" | "password"; + /** 未配置探针:每次渲染/提交前复查,配置一旦成立就拒绝继续写盘。 */ + probe: ConfiguredProbe; + sessions: () => SessionStore | undefined; + cookieName: string; + cookieSecure: boolean; + sessionTtlSeconds: number; + publicHost: string; + tokenRef: string; + usersPath: string; + /** token 模式:服务端自己生成并保存访问令牌(没有 body 参数)。 */ + saveToken: () => Promise; + /** password 模式:建第一个管理员;策略由实现方判定。 */ + createAdmin: ( + username: string, + password: string, + ) => Promise<"ok" | "conflict" | "invalid" | "failed">; + /** 向导完成的正式会话(写 Set-Cookie):token 模式 subject=token,password 模式=用户名。 */ + issueFullSession: (subject: string, res: ServerResponse) => Promise; + logger: { + info(message: unknown): void; + warn(message: unknown): void; + error(message: unknown): void; + }; +} + +const UNTRUSTED_ORIGIN = "untrusted origin"; +const ALREADY_CONFIGURED = "already configured"; +const UNAUTHORIZED = "unauthorized"; +const SAVE_FAILED = + "Could not store the access token. Set DSH_AUTH_TOKEN in the environment and restart dsh."; +const ADMIN_FAILED = "Could not create the administrator account. Check the users file and retry."; +const MISMATCH = "The two passwords do not match."; +const INVALID = + "Check the username and password rules and retry. Passwords need at least 14 characters with upper case, lower case, a digit and a symbol."; +const CONFLICT = "That username already exists."; + +export interface SetupHandlers { + get: HttpHandler; + post: HttpHandler; +} + +/** + * 首次配置向导的两个方法(`/auth/setup`:GET 渲染、POST 提交;webserver 的 exact 表按 + * pathname 建键,GET/POST 不能注册成两条路由)。 + * + * 三条硬约束:只有有效的 `bootstrap` 会话能进(正式会话看到「已配置」页,无会话回登录页); + * **每次 GET/POST 都复查未配置探针**:bootstrap 会话能跨重启活最多 1 小时,期间运维可能已经 + * 用 CLI 补好凭据,配置一旦成立就不能再从这张旧会话写盘;POST 还必须过同源校验,与既有写路径 + * 同一条纪律。向导完成后先吊销全部 bootstrap 会话,再给当前响应签发正式会话。 + */ +export function makeSetupHandlers(deps: SetupEndpointDeps): SetupHandlers { + return { + get: (req, res) => handleGet(deps, req, res), + post: (req, res) => handlePost(deps, req, res), + }; +} + +async function handleGet( + deps: SetupEndpointDeps, + req: IncomingMessage, + res: ServerResponse, +): Promise { + const session = sessionOf(deps, req); + if (session === undefined) { + redirectToLogin(res); + return; + } + // 正式会话、或配置在 bootstrap 会话签发之后成立了:都只给「已配置」页,绝不再渲染向导。 + if (!isBootstrapSession(session) || (await deps.probe.state()) !== "unconfigured") { + sendHtml(res, 200, setupConfiguredHtml()); + return; + } + sendHtml(res, 200, setupPageHtml(pageInput(deps))); +} + +async function handlePost( + deps: SetupEndpointDeps, + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!isBootstrapSession(sessionOf(deps, req))) { + denyUnauthenticated(req, res); + return; + } + if ((await deps.probe.state()) !== "unconfigured") { + await denyAlreadyConfigured(deps, req, res); + return; + } + const origin = checkRequestOrigin(req, deps.publicHost); + if (!origin.ok) { + deps.logger.warn(`setup rejected: ${origin.detail}`); + sendText(res, 403, UNTRUSTED_ORIGIN); + return; + } + const params = await readForm(req, res); + if (params === undefined) return; + if (deps.mode === "token") { + await completeTokenSetup(deps, res); + return; + } + await completePasswordSetup(deps, res, { + username: params.get("username") ?? "", + password: params.get("password") ?? "", + confirm: params.get("confirm") ?? "", + }); +} + +/** token 模式:服务端生成并保存令牌 → 吊销 bootstrap 会话 → 签发正式会话 → 结果页。 */ +async function completeTokenSetup(deps: SetupEndpointDeps, res: ServerResponse): Promise { + const outcome = await deps.saveToken(); + if (outcome.status === "failed") { + deps.logger.warn("setup failed: access token not stored"); + sendText(res, 500, SAVE_FAILED); + return; + } + await revokeBootstrapSessions(deps); + await deps.issueFullSession("token", res); + deps.logger.info("setup complete: access token saved"); + sendHtml( + res, + 200, + setupResultHtml({ + token: outcome.token ?? "", + unreadable: outcome.status === "saved-unreadable", + }), + ); +} + +interface PasswordForm { + username: string; + password: string; + confirm: string; +} + +/** password 模式:确认口令 → 建管理员 → 吊销 bootstrap 会话 → 签发正式会话 → 回首页。 */ +async function completePasswordSetup( + deps: SetupEndpointDeps, + res: ServerResponse, + form: PasswordForm, +): Promise { + if (form.password !== form.confirm) { + renderForm(deps, res, MISMATCH); + return; + } + const outcome = await deps.createAdmin(form.username, form.password); + if (outcome === "invalid") { + renderForm(deps, res, INVALID); + return; + } + if (outcome === "conflict") { + renderForm(deps, res, CONFLICT); + return; + } + if (outcome === "failed") { + deps.logger.warn("setup failed: administrator account not created"); + sendText(res, 500, ADMIN_FAILED); + return; + } + await revokeBootstrapSessions(deps); + await deps.issueFullSession(form.username, res); + deps.logger.info("setup complete: administrator created"); + redirectTo(res, CONTINUE_PATH); +} + +/** 表单级失败:400 重渲染向导页(错误文案是固定句子,不回显表单内容)。 */ +function renderForm(deps: SetupEndpointDeps, res: ServerResponse, error: string): void { + sendHtml(res, 400, setupPageHtml({ ...pageInput(deps), error })); +} + +function pageInput(deps: SetupEndpointDeps): { + mode: "token" | "password"; + tokenRef: string; + cookieSecure: boolean; + sessionTtl: number; + usersPath: string; +} { + return { + mode: deps.mode, + tokenRef: deps.tokenRef, + cookieSecure: deps.cookieSecure, + sessionTtl: deps.sessionTtlSeconds, + usersPath: deps.usersPath, + }; +} + +/** 会话行(只认 cookie):无 store / 无 cookie / 已吊销 / 已过期 → undefined。 */ +function sessionOf(deps: SetupEndpointDeps, req: IncomingMessage): Session | undefined { + const store = deps.sessions(); + if (store === undefined) return undefined; + const cookie = parseCookieHeader(req.headers.cookie, deps.cookieName); + return cookie === undefined || cookie === "" ? undefined : store.getByToken(cookie); +} + +/** + * 配置已经成立、但持着旧 bootstrap 会话的 POST:先吊销全部 bootstrap 会话,再拒绝。 + * 导航 302 登录页(浏览器表单就是导航),其余 403 固定句。**绝不写盘。** + */ +async function denyAlreadyConfigured( + deps: SetupEndpointDeps, + req: IncomingMessage, + res: ServerResponse, +): Promise { + deps.logger.warn("setup rejected: instance already configured"); + await revokeBootstrapSessions(deps); + if (isNavigationRequest(req)) { + redirectToLogin(res); + return; + } + sendText(res, 403, ALREADY_CONFIGURED); +} + +/** 未认证的 POST:导航 302 登录页(浏览器表单就是导航),其余 401。 */ +function denyUnauthenticated(req: IncomingMessage, res: ServerResponse): void { + if (isNavigationRequest(req)) { + redirectToLogin(res); + return; + } + sendText(res, 401, UNAUTHORIZED); +} + +/** 读表单体;413/415 在这里直接写响应,其余流异常向上抛(webserver 统一处理)。 */ +async function readForm( + req: IncomingMessage, + res: ServerResponse, +): Promise { + try { + return await parseFormBody(req); + } catch (error) { + const failed = error as { status?: number; message?: string }; + if (typeof failed.status !== "number") throw error; + if (failed.status === 413) res.setHeader("connection", "close"); + sendText(res, failed.status, failed.message ?? "bad request"); + return undefined; + } +} + +/** 关窗:向导完成后所有 bootstrap 会话立即失效;吊销失败不改变已经成立的成功语义。 */ +async function revokeBootstrapSessions(deps: SetupEndpointDeps): Promise { + const store = deps.sessions(); + if (store === undefined) return; + try { + const revoked = await store.revokeByKind(BOOTSTRAP_SESSION_KIND); + if (revoked > 0) deps.logger.info(`bootstrap sessions revoked: ${revoked}`); + } catch { + deps.logger.warn("bootstrap session revoke failed"); + } +} diff --git a/src/host/features/bootstrap/index.ts b/src/host/features/bootstrap/index.ts new file mode 100644 index 0000000..8936e18 --- /dev/null +++ b/src/host/features/bootstrap/index.ts @@ -0,0 +1,18 @@ +/** + * bootstrap 功能片段的公共面(显式导出清单):首次引导的探针、三个端点与服务端渲染页面。 + */ +export { makeBootstrapEndpoint } from "./api/bootstrap-endpoint.js"; +export type { BootstrapEndpointDeps } from "./api/bootstrap-endpoint.js"; +export { makeContinueEndpoint } from "./api/continue-endpoint.js"; +export type { ContinueEndpointDeps } from "./api/continue-endpoint.js"; +export { makeSetupHandlers } from "./api/setup-endpoint.js"; +export type { SetupEndpointDeps, SetupHandlers, SetupTokenOutcome } from "./api/setup-endpoint.js"; +export { makeConfiguredProbe } from "./model/probe.js"; +export type { ConfiguredProbe, ConfiguredProbeDeps, ConfiguredState } from "./model/probe.js"; +export { + setupConfiguredHtml, + setupPageHtml, + setupResultHtml, + type SetupPageInput, + type SetupResultInput, +} from "./model/setup-page.js"; diff --git a/src/host/features/bootstrap/model/probe.password.test.ts b/src/host/features/bootstrap/model/probe.password.test.ts new file mode 100644 index 0000000..4ec41d5 --- /dev/null +++ b/src/host/features/bootstrap/model/probe.password.test.ts @@ -0,0 +1,79 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { writeUsersFile, type UserRecord } from "../../../entities/user/index.js"; +import { makeConfiguredProbe } from "./probe.js"; + +const directories: string[] = []; + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "dsh-auth-probe-")); + directories.push(dir); + return dir; +} + +afterEach(async () => { + for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +function probeFor(usersPath: string): ReturnType { + return makeConfiguredProbe({ + mode: "password", + usersPath, + markedConfigured: { value: false }, + }); +} + +const ALICE: UserRecord = { + passwordHash: "scrypt$1$2$3$4$hash", + disabled: false, + role: "admin", +}; + +describe("makeConfiguredProbe (password mode)", () => { + it("reads a missing users file as unconfigured", async () => { + const probe = probeFor(join(await tempDir(), "users.yaml")); + expect(await probe.state()).toBe("unconfigured"); + }); + + it("reads a users file without accounts as unconfigured", async () => { + const usersPath = join(await tempDir(), "users.yaml"); + await writeUsersFile(usersPath, { users: new Map() }); + expect(await probeFor(usersPath).state()).toBe("unconfigured"); + }); + + it("reads a users file with one account as configured", async () => { + const usersPath = join(await tempDir(), "users.yaml"); + await writeUsersFile(usersPath, { users: new Map([["alice", ALICE]]) }); + expect(await probeFor(usersPath).state()).toBe("configured"); + }); + + it("reads an unparseable users file as unknown", async () => { + const usersPath = join(await tempDir(), "users.yaml"); + await writeFile(usersPath, "version: 1\nusers: [", { mode: 0o600 }); + expect(await probeFor(usersPath).state()).toBe("unknown"); + }); + + it.skipIf(process.platform === "win32")( + "reads a world-readable users file as unknown", + async () => { + const usersPath = join(await tempDir(), "users.yaml"); + await writeUsersFile(usersPath, { users: new Map([["alice", ALICE]]) }); + await chmod(usersPath, 0o644); + expect(await probeFor(usersPath).state()).toBe("unknown"); + }, + ); + + it("stops probing once the wizard marks the instance configured", async () => { + const markedConfigured = { value: false }; + const probe = makeConfiguredProbe({ + mode: "password", + usersPath: join(await tempDir(), "users.yaml"), + markedConfigured, + }); + expect(await probe.state()).toBe("unconfigured"); + probe.markConfigured(); + expect(await probe.state()).toBe("configured"); + }); +}); diff --git a/src/host/features/bootstrap/model/probe.token.test.ts b/src/host/features/bootstrap/model/probe.token.test.ts new file mode 100644 index 0000000..ba2315e --- /dev/null +++ b/src/host/features/bootstrap/model/probe.token.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { makeConfiguredProbe, type ConfiguredProbeDeps } from "./probe.js"; + +function deps(overrides: Partial = {}): ConfiguredProbeDeps { + return { + mode: "token", + resolveToken: () => Promise.resolve(undefined), + usersPath: "/nonexistent/users.yaml", + markedConfigured: { value: false }, + ...overrides, + }; +} + +describe("makeConfiguredProbe (token mode)", () => { + it("reads a missing or empty credential as unconfigured", async () => { + const missing = makeConfiguredProbe(deps()); + expect(await missing.state()).toBe("unconfigured"); + + const empty = makeConfiguredProbe(deps({ resolveToken: () => Promise.resolve("") })); + expect(await empty.state()).toBe("unconfigured"); + }); + + it("reads a non-empty credential as configured", async () => { + const probe = makeConfiguredProbe(deps({ resolveToken: () => Promise.resolve("live-token") })); + expect(await probe.state()).toBe("configured"); + }); + + it("reads a throwing resolver or a missing resolver as unknown", async () => { + const throwing = makeConfiguredProbe( + deps({ resolveToken: () => Promise.reject(new Error("credentials gone")) }), + ); + expect(await throwing.state()).toBe("unknown"); + + const absent = makeConfiguredProbe(deps({ resolveToken: undefined })); + expect(await absent.state()).toBe("unknown"); + }); + + it("answers configured forever once the wizard marks the instance", async () => { + const probe = makeConfiguredProbe(deps()); + expect(await probe.state()).toBe("unconfigured"); + probe.markConfigured(); + expect(await probe.state()).toBe("configured"); + // 即使解析器此后抛错,标记也不回退:引导窗口一旦关闭就不再打开。 + const marked = makeConfiguredProbe({ + ...deps({ resolveToken: () => Promise.reject(new Error("boom")) }), + markedConfigured: { value: true }, + }); + expect(await marked.state()).toBe("configured"); + }); + + it("re-reads the credential on every call, never caching the answer", async () => { + const state: { value: string | undefined } = { value: undefined }; + const probe = makeConfiguredProbe(deps({ resolveToken: () => Promise.resolve(state.value) })); + expect(await probe.state()).toBe("unconfigured"); + state.value = "written-by-the-cli"; + expect(await probe.state()).toBe("configured"); + }); +}); diff --git a/src/host/features/bootstrap/model/probe.ts b/src/host/features/bootstrap/model/probe.ts new file mode 100644 index 0000000..9735f9a --- /dev/null +++ b/src/host/features/bootstrap/model/probe.ts @@ -0,0 +1,63 @@ +import { loadUsersFile } from "../../../entities/user/index.js"; + +/** + * 实例是否已经配置(首次引导的唯一判据)。每次引导请求现算,不缓存、不冻结: + * 向导刚写完、或运维手工补上凭据后,引导窗口必须立刻关闭。 + */ +export type ConfiguredState = "unconfigured" | "configured" | "unknown"; + +export interface ConfiguredProbeDeps { + mode: "token" | "password"; + /** token 模式的凭证解析器(缺席 = 无可用解析器,按 unknown 处理)。 */ + resolveToken?: (() => Promise) | undefined; + /** password 模式的 `users.yaml` 路径。 */ + usersPath: string; + /** 进程内标志:向导成功写入后置 true,此后一律 configured(服务端缓存不再延后关窗)。 */ + markedConfigured: { value: boolean }; +} + +export interface ConfiguredProbe { + /** 现读一次:`configured` 与 `unknown` 都按「不引导」处理(fail-closed)。 */ + state(): Promise; + markConfigured(): void; +} + +/** + * 未配置探针。 + * + * - token 模式:调用方**必须**传严格读取器,解析器给出空值 → `unconfigured`;非空 → + * `configured`;读取器缺席或抛错 → `unknown`。不要把 `makeTokenResolver` 传进来:它把 + * 服务缺失与解析异常都吞成 `undefined`,会让读故障被误判成未配置;吞错语义只服务门自己。 + * - password 模式:文件缺失或零用户 → `unconfigured`;有用户 → `configured`;读/解析/权限 + * 错误 → `unknown`。 + */ +export function makeConfiguredProbe(deps: ConfiguredProbeDeps): ConfiguredProbe { + return { + async state(): Promise { + if (deps.markedConfigured.value) return "configured"; + try { + return deps.mode === "password" + ? await probePassword(deps.usersPath) + : await probeToken(deps.resolveToken); + } catch { + return "unknown"; + } + }, + markConfigured(): void { + deps.markedConfigured.value = true; + }, + }; +} + +async function probeToken( + resolveToken: (() => Promise) | undefined, +): Promise { + if (resolveToken === undefined) return "unknown"; + const token = await resolveToken(); + return token === undefined || token === "" ? "unconfigured" : "configured"; +} + +async function probePassword(usersPath: string): Promise { + const loaded = await loadUsersFile(usersPath); + return loaded.missing || loaded.snapshot.users.size === 0 ? "unconfigured" : "configured"; +} diff --git a/src/host/features/bootstrap/model/respond.ts b/src/host/features/bootstrap/model/respond.ts new file mode 100644 index 0000000..ece69fa --- /dev/null +++ b/src/host/features/bootstrap/model/respond.ts @@ -0,0 +1,40 @@ +import type { ServerResponse } from "node:http"; +import { loginPath } from "../../../shared/protocol/index.js"; + +/** + * 首次引导三个端点的响应小件:一律 `no-store`;重定向一律带 `referrer-policy: no-referrer` + * (被拒的 URL 里可能带着 dsh 的 launch token,不能让浏览器把它当 Referer 传给下一跳)。 + */ + +/** 302 到登录页(`next=/`):所有失败分支唯一的去处(fail-closed)。 */ +export function redirectToLogin(res: ServerResponse): void { + redirectTo(res, loginPath("/")); +} + +/** 302 到站内相对地址,禁缓存、禁 Referer。 */ +export function redirectTo(res: ServerResponse, location: string): void { + res.setHeader("cache-control", "no-store"); + res.setHeader("referrer-policy", "no-referrer"); + res.writeHead(302, { location }); + res.end(); +} + +/** 渲染 HTML(禁缓存、禁 Referer:与三条重定向出口保持同一纪律)。 */ +export function sendHtml(res: ServerResponse, status: number, html: string): void { + res.setHeader("cache-control", "no-store"); + res.setHeader("referrer-policy", "no-referrer"); + res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); + res.end(html); +} + +/** 纯文本拒绝(禁缓存):固定句子,绝不回显请求内容。 */ +export function sendText( + res: ServerResponse, + status: number, + body: string, + headers: Record = {}, +): void { + res.setHeader("cache-control", "no-store"); + res.writeHead(status, { "content-type": "text/plain", ...headers }); + res.end(body); +} diff --git a/src/host/features/bootstrap/model/setup-page.ts b/src/host/features/bootstrap/model/setup-page.ts new file mode 100644 index 0000000..55a9f3d --- /dev/null +++ b/src/host/features/bootstrap/model/setup-page.ts @@ -0,0 +1,154 @@ +import { CARD_STYLE } from "../../../shared/login-page/index.js"; +import { CONTINUE_PATH, LOGIN_PATH, SETUP_PATH } from "../../../shared/protocol/index.js"; + +/** + * 首次配置向导的服务端渲染(英文,与登录页同一套卡片样式)。 + * + * 页面里只出现配置摘要与表单,绝不渲染 launch token、也绝不渲染既有访问令牌; + * 新生成的访问令牌只在 `setupResultHtml` 里出现一次。所有动态值一律 HTML-escape。 + */ + +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch); +} + +/** 向导页的少量补充样式(卡片骨架与配色全部来自登录页的 `CARD_STYLE`)。 */ +const SETUP_STYLE = ` +.card.wide{max-width:27rem} +.summary{margin:1.15rem 0 0;padding-top:1.15rem;border-top:1px solid var(--line);font-size:13px;line-height:20px;color:var(--muted)} +.summary dt{margin-top:10px;font-size:12px;font-weight:500;line-height:18px;color:var(--ink)} +.summary dd{margin:2px 0 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all} +.note{margin:12px 0 0;font-size:12px;line-height:18px;color:var(--muted)} +.token{margin:1.15rem 0 0;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--field)} +.token code{display:block;font:600 13px/20px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all} +`; + +export interface SetupPageInput { + mode: "token" | "password"; + tokenRef: string; + cookieSecure: boolean; + sessionTtl: number; + usersPath: string; + error?: string | undefined; +} + +export interface SetupResultInput { + token: string; + /** true = 写盘成功但当前进程读不回来:重启 dsh 后它才成为唯一凭据。 */ + unreadable: boolean; +} + +function page(title: string, body: string): string { + return ` + + + + +${escapeHtml(title)} + + + +${body} + + +`; +} + +/** 配置摘要:告诉运营者当前生效的启动期设置,并指明要改得编辑哪个文件。 */ +function summaryHtml(input: SetupPageInput): string { + const rows: [string, string][] = + input.mode === "token" + ? [ + ["Mode", "token (one shared access token)"], + ["Credential reference", input.tokenRef], + ] + : [ + ["Mode", "password (per-user accounts)"], + ["Users file", input.usersPath], + ]; + rows.push(["Cookie Secure flag", input.cookieSecure ? "on (HTTPS only)" : "off (plain HTTP)"]); + rows.push(["Session lifetime", `${input.sessionTtl} seconds`]); + const items = rows + .map(([name, value]) => `
${escapeHtml(name)}
${escapeHtml(value)}
`) + .join(""); + return `
${items}
+

These are startup settings. To change them, edit $DSH_HOME/cordis.patch.yml and restart dsh.

`; +} + +/** 模式相关的表单主体:token 模式只有一个按钮,password 模式是三个字段的建号表单。 */ +function formBodyHtml(input: SetupPageInput): string { + if (input.mode === "token") { + return `

Create one access token for this instance. It is shown once, right after it is saved.

+`; + } + return `

Create the first administrator account. Passwords need at least 14 characters with upper case, lower case, a digit and a symbol.

+
+
+
+`; +} + +/** 向导页(GET,bootstrap 会话)。 */ +export function setupPageHtml(input: SetupPageInput): string { + const errorHtml = + input.error === undefined + ? "" + : ``; + return page( + "First run - dsh-auth-gate", + `
+
+

First run

+

Set up this instance

+
+
+${errorHtml}${formBodyHtml(input)} +
+${summaryHtml(input)} +
`, + ); +} + +/** token 模式的提交结果页:新令牌只在这里出现一次。 */ +export function setupResultHtml(input: SetupResultInput): string { + const unreadableHtml = input.unreadable + ? `

This process could not read the token back. Restart dsh so it becomes the only credential, and keep the token safe until then.

` + : ""; + return page( + "Access token - dsh-auth-gate", + `
+
+

Access token

+

Saved

+
+

Copy this token now. It is shown once and cannot be recovered; if you lose it, overwrite or delete that entry in $DSH_HOME/.credentials.yaml (stored credentials take precedence over the environment) and restart dsh.

+
${escapeHtml(input.token)}
+${unreadableHtml} +Continue to dsh +
`, + ); +} + +/** 已有正式会话访问向导:实例已经配置过,说明去哪儿。 */ +export function setupConfiguredHtml(): string { + return page( + "Already configured - dsh-auth-gate", + `
+
+

Already configured

+

Nothing to do

+
+

This instance already has credentials, so the first-run wizard is closed.

+Go to dsh +Sign in +
`, + ); +} diff --git a/src/host/features/password/model/password-gate.test.ts b/src/host/features/password/model/password-gate.test.ts index a0bdc1e..164c8cd 100644 --- a/src/host/features/password/model/password-gate.test.ts +++ b/src/host/features/password/model/password-gate.test.ts @@ -2,6 +2,7 @@ import type { IncomingMessage } from "node:http"; import { describe, expect, it } from "vitest"; import { PasswordGate } from "./password-gate.js"; import type { SessionStore } from "../../../entities/session/index.js"; +import type { SessionKind } from "../../../shared/protocol/index.js"; function fakeReq(headers: Record): IncomingMessage { return { headers } as IncomingMessage; @@ -93,3 +94,57 @@ describe("PasswordGate", () => { expect(decision).toBe("allow"); }); }); + +describe("PasswordGate: first-run bootstrap channel", () => { + function fakeSessionsWithKind(kind: SessionKind): SessionStore { + const session = { + subject: "bootstrap", + createdAt: 0, + expiresAt: Date.now() + 60_000, + revoked: false, + kind, + }; + return { getByToken: () => session } as unknown as SessionStore; + } + + it("redirects a bare token launch URL to the bootstrap endpoint", () => { + const gate = makeGate(); + expect( + gate.decide( + { method: "GET", url: "/?token=launch-token", headers: {} } as IncomingMessage, + "exact", + "/", + ), + ).toEqual({ deny: { redirect: "/auth/bootstrap?token=launch-token" } }); + expect(gate.decide(fakeReq({}), "exact", "/api/x")).toBe("deny"); + expect( + gate.decide( + { method: "POST", url: "/?token=x", headers: {} } as IncomingMessage, + "exact", + "/", + ), + ).toBe("deny"); + }); + + it("keeps a bootstrap session inside /auth", () => { + const gate = makeGate({ sessions: () => fakeSessionsWithKind("bootstrap") }); + expect(gate.decide(fakeReq({ cookie: "dsh_auth=boot" }), "exact", "/auth/setup")).toBe("allow"); + expect( + gate.decide(fakeReq({ cookie: "dsh_auth=boot", "sec-fetch-dest": "document" }), "exact", "/"), + ).toEqual({ deny: { redirect: "/auth/setup" } }); + expect(gate.decide(fakeReq({ cookie: "dsh_auth=boot" }), "exact", "/api/x")).toBe("deny"); + // 公开静态路径也不例外:bootstrap 会话只能碰本插件的 /auth/*。 + expect( + gate.decide(fakeReq({ cookie: "dsh_auth=boot" }), "fallback", "/manifest.webmanifest"), + ).toBe("deny"); + expect(gate.decide(fakeReq({ cookie: "dsh_auth=boot" }), "upgrade", "/events")).toBe("deny"); + }); + + it("still lets a full session through and keeps the restricted flow untouched", () => { + const gate = makeGate({ sessions: () => fakeSessionsWithKind("full") }); + expect(gate.decide(fakeReq({ cookie: "dsh_auth=full" }), "exact", "/api/x")).toBe("allow"); + expect(gate.decide(fakeReq({ cookie: "dsh_auth=full" }), "exact", "/?token=launch-token")).toBe( + "allow", + ); + }); +}); diff --git a/src/host/features/password/model/password-gate.ts b/src/host/features/password/model/password-gate.ts index 6a7ff87..40db81d 100644 --- a/src/host/features/password/model/password-gate.ts +++ b/src/host/features/password/model/password-gate.ts @@ -1,9 +1,13 @@ import type { IncomingMessage } from "node:http"; -import { parseCookieHeader } from "../../../shared/http/index.js"; +import { bootstrapRedirectPath, parseCookieHeader } from "../../../shared/http/index.js"; import type { Gate, GateDecision, GuardKind } from "../../../shared/guard/index.js"; import { isNavigationRequest, isPublicStaticPath } from "../../../shared/guard/index.js"; -import { isAuthPath, LOGIN_PATH, loginPath } from "../../../shared/protocol/index.js"; -import type { Session, SessionStore } from "../../../entities/session/index.js"; +import { isAuthPath, LOGIN_PATH, loginPath, SETUP_PATH } from "../../../shared/protocol/index.js"; +import { + isBootstrapSession, + type Session, + type SessionStore, +} from "../../../entities/session/index.js"; import { isRestrictedSession, RESTRICTED_REDIRECT_PATH } from "./restricted-session.js"; export interface PasswordGateOptions { @@ -21,6 +25,9 @@ export interface PasswordGateOptions { * `GET /auth/login` → 302 改密页、`POST /auth/login` → 403;其余(含 `/auth/users*`) * 放行进 handler,由 handler 按 kind 细分(管理面回 403)。宿主 `/api`、静态、`/plugins` * 与 WS 一律 deny(导航 302 `/auth/password`,API 401,WS 拒握手)。 + * + * `session.kind === "bootstrap"` 的首次引导会话只放行 `/auth` 前缀;其余一律 deny(导航 + * 302 向导页,API 401,WS 拒握手)。未认证的 `GET /?token=` 按请求形状 302 到引导端点。 */ export class PasswordGate implements Gate { private readonly sessions: () => SessionStore | undefined; @@ -37,8 +44,11 @@ export class PasswordGate implements Gate { } const session = this.sessionOf(req); if (isRestrictedSession(session)) return denyRestricted(req, kind); + if (isBootstrapSession(session)) return denyBootstrap(req); if (isPublicStaticPath(kind, pathname)) return "allow"; if (session !== undefined) return "allow"; + const hint = bootstrapRedirectPath(req); + if (hint !== undefined) return { deny: { redirect: hint } }; return denyAnonymous(req, pathname); } @@ -79,6 +89,11 @@ function denyRestricted(req: IncomingMessage, kind: GuardKind): GateDecision { : { deny: { status: 401 } }; } +/** 首次引导会话被拒的宿主路径:导航 → 302 向导页;WS → 拒握手;其余(API)→ 401。 */ +function denyBootstrap(req: IncomingMessage): GateDecision { + return isNavigationRequest(req) ? { deny: { redirect: SETUP_PATH } } : "deny"; +} + /** 未认证(无会话/会话失效):导航 → 302 登录页 + next;其余 → 字符串 deny(401)。 */ function denyAnonymous(req: IncomingMessage, pathname: string): GateDecision { return isNavigationRequest(req) ? { deny: { redirect: loginPath(pathname) } } : "deny"; diff --git a/src/host/features/token/model/token-gate.test.ts b/src/host/features/token/model/token-gate.test.ts index ce99728..23a956c 100644 --- a/src/host/features/token/model/token-gate.test.ts +++ b/src/host/features/token/model/token-gate.test.ts @@ -1,6 +1,7 @@ import type { IncomingMessage } from "node:http"; import { describe, expect, it } from "vitest"; import type { SessionStore } from "../../../entities/session/index.js"; +import type { SessionKind } from "../../../shared/protocol/index.js"; import { safeEqual, TokenGate } from "./token-gate.js"; function fakeReq(headers: Record): IncomingMessage { @@ -119,3 +120,66 @@ describe("safeEqual", () => { expect(safeEqual("", "x")).toBe(false); }); }); + +describe("TokenGate: first-run bootstrap channel", () => { + function fakeSessionsWithKind(kind: SessionKind): SessionStore { + const session = { + subject: "bootstrap", + createdAt: 0, + expiresAt: Date.now() + 60_000, + revoked: false, + kind, + }; + return { getByToken: () => session } as unknown as SessionStore; + } + + it("redirects a bare token launch URL to the bootstrap endpoint", async () => { + const gate = makeGate(); + expect( + await gate.decide( + { method: "GET", url: "/?token=launch-token", headers: {} } as IncomingMessage, + "exact", + "/", + ), + ).toEqual({ deny: { redirect: "/auth/bootstrap?token=launch-token" } }); + }); + + it("leaves every other anonymous request on the plain deny", async () => { + const gate = makeGate(); + expect(await gate.decide(fakeReq({}), "exact", "/api/x")).toBe("deny"); + expect(await gate.decide(fakeReq({}), "exact", "/")).toBe("deny"); + expect( + await gate.decide( + { method: "POST", url: "/?token=x", headers: {} } as IncomingMessage, + "exact", + "/", + ), + ).toBe("deny"); + }); + + it("keeps a bootstrap session inside /auth", async () => { + const gate = makeGate({ sessions: () => fakeSessionsWithKind("bootstrap") }); + expect(await gate.decide(fakeReq({ cookie: "dsh_auth=boot" }), "exact", "/auth/setup")).toBe( + "allow", + ); + expect( + await gate.decide( + fakeReq({ cookie: "dsh_auth=boot", "sec-fetch-dest": "document" }), + "exact", + "/", + ), + ).toEqual({ deny: { redirect: "/auth/setup" } }); + expect(await gate.decide(fakeReq({ cookie: "dsh_auth=boot" }), "exact", "/api/x")).toBe("deny"); + // 公开静态路径也不例外:bootstrap 会话只能碰本插件的 /auth/*。 + expect( + await gate.decide(fakeReq({ cookie: "dsh_auth=boot" }), "fallback", "/manifest.webmanifest"), + ).toBe("deny"); + }); + + it("still lets a full session through", async () => { + const gate = makeGate({ sessions: () => fakeSessionsWithKind("full") }); + expect(await gate.decide(fakeReq({ cookie: "dsh_auth=full" }), "exact", "/api/x")).toBe( + "allow", + ); + }); +}); diff --git a/src/host/features/token/model/token-gate.ts b/src/host/features/token/model/token-gate.ts index c1c9540..d41894e 100644 --- a/src/host/features/token/model/token-gate.ts +++ b/src/host/features/token/model/token-gate.ts @@ -1,20 +1,17 @@ -import { createHash, timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; -import type { Gate, GuardKind } from "../../../shared/guard/index.js"; -import { isPublicStaticPath } from "../../../shared/guard/index.js"; -import { isAuthPath } from "../../../shared/protocol/index.js"; -import type { SessionStore } from "../../../entities/session/index.js"; -import { parseCookieHeader } from "../../../shared/http/index.js"; +import type { Gate, GateDecision, GuardKind } from "../../../shared/guard/index.js"; +import { isNavigationRequest, isPublicStaticPath } from "../../../shared/guard/index.js"; +import { isAuthPath, SETUP_PATH } from "../../../shared/protocol/index.js"; +import { safeEqual } from "../../../shared/crypto/index.js"; +import { + isBootstrapSession, + type Session, + type SessionStore, +} from "../../../entities/session/index.js"; +import { bootstrapRedirectPath, parseCookieHeader } from "../../../shared/http/index.js"; -/** - * 恒时比较:双方 sha256 Buffer 摘要后 timingSafeEqual(M17:timingSafeEqual 只接受 - * Buffer/TypedArray:hex 字符串直接 TypeError;Buffer 摘要双方恒 32 字节,无长度侧信道)。 - */ -export function safeEqual(input: string, stored: string): boolean { - const inputDigest = createHash("sha256").update(input).digest(); - const storedDigest = createHash("sha256").update(stored).digest(); - return timingSafeEqual(inputDigest, storedDigest); -} +/** 恒时比较在 `shared/crypto`,这里转口导出:既有调用点(含 barrel)不用改 import。 */ +export { safeEqual }; export interface TokenGateOptions { /** 每次 decide 调用的凭证解析器(index.ts 注入 credentials.resolve 闭包)。 */ @@ -24,7 +21,14 @@ export interface TokenGateOptions { cookieName: string; } -/** 共享 token 门(M2):白名单(`/auth` 前缀 + 公开只读静态路径)→ 会话 cookie → Bearer,恒时校验,fail-closed。 */ +/** + * 共享 token 门(M2):`/auth` 前缀 + 公开只读静态路径白名单 → 会话 cookie → Bearer, + * 恒时校验,fail-closed。 + * + * 首次引导通道:本来要拒绝的 `GET /?token=` 只按请求形状 302 到 `/auth/bootstrap?token=`, + * 门内不校验 token、不读文件、不做 IO。bootstrap 会话只放行 `/auth/*`(上面已放行), + * 其余导航 302 回向导页,API 401,WS 拒握手。 + */ export class TokenGate implements Gate { private readonly resolveToken: () => Promise; private readonly sessions: () => SessionStore | undefined; @@ -36,27 +40,35 @@ export class TokenGate implements Gate { this.cookieName = options.cookieName; } - async decide(req: IncomingMessage, kind: GuardKind, pathname: string): Promise<"allow" | "deny"> { + async decide(req: IncomingMessage, kind: GuardKind, pathname: string): Promise { if (isAuthPath(pathname)) return "allow"; + const session = this.sessionOf(req); + if (session !== undefined) return isBootstrapSession(session) ? denyBootstrap(req) : "allow"; if (isPublicStaticPath(kind, pathname)) return "allow"; - const cookie = parseCookieHeader(req.headers.cookie, this.cookieName); - if (cookie !== undefined && cookie !== "") { - const store = this.sessions(); - if (store?.getByToken(cookie) !== undefined) return "allow"; - } - const authorization = req.headers.authorization; - const match = /^Bearer\s+(.+)$/i.exec(authorization ?? ""); - if (match !== null) { - const bearer = match[1]; + const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "")?.[1]; + if (bearer !== undefined) { try { const stored = await this.resolveToken(); - if (bearer !== undefined && stored !== undefined && safeEqual(bearer, stored)) { - return "allow"; - } + if (stored !== undefined && safeEqual(bearer, stored)) return "allow"; } catch { return "deny"; // fail-closed 双保险(index.ts 的 resolver 已自行 catch 并记日志) } } + const hint = bootstrapRedirectPath(req); + if (hint !== undefined) return { deny: { redirect: hint } }; return "deny"; } + + /** 会话行(只认会话 cookie,与既有 token 通道一致):无 store/无 cookie/失效 → undefined。 */ + private sessionOf(req: IncomingMessage): Session | undefined { + const store = this.sessions(); + if (store === undefined) return undefined; + const cookie = parseCookieHeader(req.headers.cookie, this.cookieName); + return cookie === undefined || cookie === "" ? undefined : store.getByToken(cookie); + } +} + +/** bootstrap 会话被拒的宿主路径:导航 → 302 向导页;WS → 拒握手;其余(API)→ 401。 */ +function denyBootstrap(req: IncomingMessage): GateDecision { + return isNavigationRequest(req) ? { deny: { redirect: SETUP_PATH } } : "deny"; } diff --git a/src/host/shared/crypto/index.ts b/src/host/shared/crypto/index.ts new file mode 100644 index 0000000..5acfdb0 --- /dev/null +++ b/src/host/shared/crypto/index.ts @@ -0,0 +1,5 @@ +/** + * shared/crypto segment public API: 与协议无关的密码学小件。 + * 显式导出清单:新增导出必须在此登记。 + */ +export { safeEqual } from "./safe-equal.js"; diff --git a/src/host/shared/crypto/safe-equal.test.ts b/src/host/shared/crypto/safe-equal.test.ts new file mode 100644 index 0000000..eff84dc --- /dev/null +++ b/src/host/shared/crypto/safe-equal.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { safeEqual } from "./safe-equal.js"; + +describe("safeEqual", () => { + it("accepts equal strings and rejects different ones", () => { + expect(safeEqual("abc", "abc")).toBe(true); + expect(safeEqual("abc", "abd")).toBe(false); + expect(safeEqual("", "")).toBe(true); + }); + + it("rejects different lengths without throwing", () => { + expect(safeEqual("", "x")).toBe(false); + expect(safeEqual("short", "a much longer value")).toBe(false); + }); + + it("compares the full string, not a prefix", () => { + expect(safeEqual("token", "token-and-more")).toBe(false); + expect(safeEqual("token-and-more", "token")).toBe(false); + }); +}); diff --git a/src/host/shared/crypto/safe-equal.ts b/src/host/shared/crypto/safe-equal.ts new file mode 100644 index 0000000..c50328f --- /dev/null +++ b/src/host/shared/crypto/safe-equal.ts @@ -0,0 +1,14 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +/** + * 恒时比较(M17):双方 sha256 Buffer 摘要后 `timingSafeEqual`。 + * `timingSafeEqual` 只接受 Buffer/TypedArray;hex 字符串直接 TypeError。Buffer 摘要双方 + * 恒 32 字节,所以比较本身不泄漏长度侧信道。 + * + * 只回答「两个字符串是否相等」,不区分失败原因;调用方一律把假当作拒绝。 + */ +export function safeEqual(input: string, stored: string): boolean { + const inputDigest = createHash("sha256").update(input).digest(); + const storedDigest = createHash("sha256").update(stored).digest(); + return timingSafeEqual(inputDigest, storedDigest); +} diff --git a/src/host/shared/http/bootstrap-hint.test.ts b/src/host/shared/http/bootstrap-hint.test.ts new file mode 100644 index 0000000..a762c06 --- /dev/null +++ b/src/host/shared/http/bootstrap-hint.test.ts @@ -0,0 +1,56 @@ +import type { IncomingMessage } from "node:http"; +import { describe, expect, it } from "vitest"; +import { bootstrapRedirectPath, singleTokenParam } from "./bootstrap-hint.js"; + +function req(url: string, method = "GET"): IncomingMessage { + return { method, url, headers: {} } as IncomingMessage; +} + +describe("bootstrapRedirectPath", () => { + it("maps a bare token launch URL to the bootstrap endpoint", () => { + expect(bootstrapRedirectPath(req("/?token=launch"))).toBe("/auth/bootstrap?token=launch"); + }); + + it("keeps the token verbatim and re-encodes it in the location", () => { + // 查询串里的 `+` 解码成空格;位置里重新编码后必须还是同一个值,不能再变一次。 + expect(bootstrapRedirectPath(req("/?token=a+b/c="))).toBe( + `/auth/bootstrap?token=${encodeURIComponent("a b/c=")}`, + ); + expect(bootstrapRedirectPath(req("/?token=a%20b"))).toBe("/auth/bootstrap?token=a%20b"); + expect(bootstrapRedirectPath(req("/?token=A%2FB_9-"))).toBe("/auth/bootstrap?token=A%2FB_9-"); + }); + + it("ignores everything that is not the bare root with a single token", () => { + expect(bootstrapRedirectPath(req("/"))).toBeUndefined(); + expect(bootstrapRedirectPath(req("/index.html?token=x"))).toBeUndefined(); + expect(bootstrapRedirectPath(req("/api/x?token=x"))).toBeUndefined(); + expect(bootstrapRedirectPath(req("/?token=a&token=b"))).toBeUndefined(); + expect(bootstrapRedirectPath(req("/?token="))).toBeUndefined(); + expect(bootstrapRedirectPath(req("/?token=%20%20"))).toBeUndefined(); + expect(bootstrapRedirectPath(req("/?other=x"))).toBeUndefined(); + }); + + it("requires GET", () => { + expect(bootstrapRedirectPath(req("/?token=x", "POST"))).toBeUndefined(); + expect(bootstrapRedirectPath(req("/?token=x", "HEAD"))).toBeUndefined(); + }); + + it("tolerates a missing url", () => { + const missing = { method: "GET", headers: {} } as IncomingMessage; + expect(bootstrapRedirectPath(missing)).toBeUndefined(); + }); +}); + +describe("singleTokenParam", () => { + it("accepts exactly one non-blank value", () => { + expect(singleTokenParam(new URLSearchParams("token=x"))).toBe("x"); + expect(singleTokenParam(new URLSearchParams("token=%20x%20"))).toBe(" x "); + }); + + it("rejects zero, several and blank values", () => { + expect(singleTokenParam(new URLSearchParams(""))).toBeUndefined(); + expect(singleTokenParam(new URLSearchParams("token=a&token=b"))).toBeUndefined(); + expect(singleTokenParam(new URLSearchParams("token="))).toBeUndefined(); + expect(singleTokenParam(new URLSearchParams("token=%09"))).toBeUndefined(); + }); +}); diff --git a/src/host/shared/http/bootstrap-hint.ts b/src/host/shared/http/bootstrap-hint.ts new file mode 100644 index 0000000..d65398a --- /dev/null +++ b/src/host/shared/http/bootstrap-hint.ts @@ -0,0 +1,28 @@ +import type { IncomingMessage } from "node:http"; +import { BOOTSTRAP_PATH } from "../protocol/index.js"; + +/** + * `token` 查询参数:必须**恰好一项**且 trim 后非空,否则 undefined。 + * 多值一律拒绝(`?token=a&token=b` 没有"哪个才算"的正确答案),空串/纯空白同理。 + */ +export function singleTokenParam(params: URLSearchParams): string | undefined { + const values = params.getAll("token"); + if (values.length !== 1) return undefined; + const value = values[0]; + return value === undefined || value.trim() === "" ? undefined : value; +} + +/** + * 首次引导的请求形状判定:`GET` + pathname 恰为 `/` + 恰好一个非空 `token` 查询 → 引导端点 + * 路径(token 原样重新编码);其它形状 → undefined。 + * + * 门只做这一步形态判断,**不校验** token、不读文件、不 await:真正的校验、未配置探针与会话 + * 签发都在 `GET /auth/bootstrap` 里完成,被冻结的守卫词表因此一行不用改。 + */ +export function bootstrapRedirectPath(req: IncomingMessage): string | undefined { + if (req.method !== "GET") return undefined; + const url = new URL(req.url ?? "/", "http://x"); + if (url.pathname !== "/") return undefined; + const token = singleTokenParam(url.searchParams); + return token === undefined ? undefined : `${BOOTSTRAP_PATH}?token=${encodeURIComponent(token)}`; +} diff --git a/src/host/shared/http/index.ts b/src/host/shared/http/index.ts index 92113da..4140bd8 100644 --- a/src/host/shared/http/index.ts +++ b/src/host/shared/http/index.ts @@ -7,6 +7,7 @@ * grow by accident. */ export { isSafeRelativeTarget, validateNext } from "./auth-common.js"; +export { bootstrapRedirectPath, singleTokenParam } from "./bootstrap-hint.js"; export { DEFAULT_TRUSTED_PROXIES, makeClientIpResolver, diff --git a/src/host/shared/http/origin.ts b/src/host/shared/http/origin.ts index e0d3aab..3bfa56a 100644 --- a/src/host/shared/http/origin.ts +++ b/src/host/shared/http/origin.ts @@ -4,8 +4,9 @@ import { resolvePublicHost } from "./host.js"; /** * 已认证状态变更请求的同源校验(P2 §6 的 CSRF 纵深)。 * - * 只服务两条**已认证**的写路径:`POST /auth/users/password` 与 `POST /auth/password`。 - * 登录路径(未认证)**不覆盖**:那边失败会让全员锁死,风险与收益不成比例(契约 §10 残留)。 + * 只服务三条**已认证**的写路径:`POST /auth/users/password`、`POST /auth/password` 与首次 + * 配置向导的 `POST /auth/setup`(只有 bootstrap 会话能进,校验后不写盘)。登录路径 + * (未认证)**不覆盖**:那边失败会让全员锁死,风险与收益不成比例(契约 §10 残留)。 * * 判定顺序(任一通过即放行,两者都缺则 fail-closed): * 1. `Sec-Fetch-Site: same-origin`:浏览器强制设置、页面脚本无法伪造,是最可靠的来源证明。 diff --git a/src/host/shared/login-page/index.ts b/src/host/shared/login-page/index.ts index df35d36..a89c10b 100644 --- a/src/host/shared/login-page/index.ts +++ b/src/host/shared/login-page/index.ts @@ -1,5 +1,8 @@ /** * shared/login-page segment public API: the server-rendered login, password and TOTP - * challenge pages. Explicit export list: register new exports here. + * challenge pages, plus the card stylesheet the first-run wizard reuses so every + * server-rendered page of this plugin looks the same. Explicit export list: register new + * exports here. */ export { loginPageHtml, passwordLoginPageHtml, totpChallengePageHtml } from "./login-page.js"; +export { CARD_STYLE } from "./login-page-assets.js"; diff --git a/src/host/shared/protocol/index.ts b/src/host/shared/protocol/index.ts index 0296f3f..fc69d0e 100644 --- a/src/host/shared/protocol/index.ts +++ b/src/host/shared/protocol/index.ts @@ -5,12 +5,16 @@ */ export { AUTH_PATH_PREFIX, + BOOTSTRAP_PATH, + BOOTSTRAP_SESSION_KIND, + CONTINUE_PATH, isAuthPath, LOGIN_PATH, loginPath, LOGOUT_PATH, PASSWORD_CHANGED_NOTICE, PASSWORD_PATH, + SETUP_PATH, STATUS_PATH, USERS_PASSWORD_PATH, USERS_PATH, diff --git a/src/index.ts b/src/index.ts index 8355395..37bf06e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { verifyPassword, } from "./host/features/password/index.js"; import { makeAdminWiring } from "./admin-wiring.js"; +import { makeBootstrapWiring } from "./bootstrap-wiring.js"; import { TotpReplayGuard, verifyTotpCode } from "./host/features/totp/index.js"; import { DEFAULT_TRUSTED_PROXIES, @@ -119,12 +120,14 @@ declare module "@deepseek-ai/cordis" { /** * 会话层软接(M1 逻辑不变):storageDomain 缺失 → error 日志后守卫照常挂载; - * 存在 → effect 内 open domain,就绪后把 SessionStore 挂到 auth.sessions。 + * 存在 → effect 内 open domain,就绪后把 SessionStore 挂到 auth.sessions,再通知 `onReady` + * (首次引导的启动清扫要等这一刻)。 */ function mountSessionDomain( ctx: Context, auth: AuthService, log: { error(message: unknown): void; info(message: unknown): void }, + onReady?: (store: SessionStore) => void, ): (() => () => Promise) | undefined { const storageDomain = ctx.get("storageDomain"); if (storageDomain === undefined) { @@ -142,7 +145,9 @@ function mountSessionDomain( void domain.close(); return; } - auth.sessions = new SessionStore(domain.table("sessions")); + const store = new SessionStore(domain.table("sessions")); + auth.sessions = store; + onReady?.(store); log.info("session domain opened: dsh_auth_sessions"); }, (error: unknown) => { @@ -280,10 +285,17 @@ export function apply(ctx: Context, config: AuthConfig): void { }; ctx.provide("auth", auth); - const sessionDisposer = mountSessionDomain(ctx, auth, log); - if (sessionDisposer !== undefined) { - ctx.effect(sessionDisposer, "dsh-auth-gate: session domain"); - } + const bootstrap = makeBootstrapWiring({ + ctx, + server, + config, + auth, + usersPath, + launchTokenBridge, + log, + }); + const sessionDisposer = mountSessionDomain(ctx, auth, log, bootstrap.onSessionsReady); + if (sessionDisposer !== undefined) ctx.effect(sessionDisposer, "dsh-auth-gate: session domain"); // 禁用用户 → 已发会话即时失效(D8):插件侧周期扫描 users.yaml,把变更落到会话表; // 门保持 P12 的同步零 IO 语义,CLI 也不需要访问会话存储。 @@ -300,6 +312,10 @@ export function apply(ctx: Context, config: AuthConfig): void { const unwrap = wrapServer(server, () => auth.gate, log); ctx.effect(() => unwrap, "dsh-auth-gate: guard unwrap"); + // 首次引导通道(`/auth/bootstrap` → `/auth/setup` → `/auth/continue`):只在实例未配置时签发 + // 一次性 bootstrap 会话,向导完成后关窗。三条路径在 `/auth` 前缀内,门直接放行。 + ctx.effect(() => bootstrap.mount(), "dsh-auth-gate: bootstrap routes"); + ctx.effect( () => mountAuthEndpoints( diff --git a/src/launch-token-bridge.test.ts b/src/launch-token-bridge.test.ts new file mode 100644 index 0000000..3e3ddee --- /dev/null +++ b/src/launch-token-bridge.test.ts @@ -0,0 +1,89 @@ +import type { Context } from "@deepseek-ai/cordis"; +import { describe, expect, it } from "vitest"; +import { makeLaunchTokenBridge, readLaunchToken } from "./launch-token-bridge.js"; + +/** 只实现 `get` 的假 Context:桥与首次引导都只读 `connection` 这一个服务。 */ +function ctxWith(connection: unknown): Context { + return { + get: (name: string) => (name === "connection" ? connection : undefined), + } as unknown as Context; +} + +function connections(authenticatedUrl: () => string): unknown { + return { authenticatedUrl }; +} + +describe("readLaunchToken", () => { + it("reads the same token the login bridge hands out", async () => { + const ctx = ctxWith(connections(() => "http://other.example:9999/deep/path?extra=1&token=abc")); + const token = readLaunchToken(ctx); + expect(token).toBe("abc"); + const bridged = await makeLaunchTokenBridge(ctx, { warn: () => undefined })(); + expect(bridged).toBe(`/?token=${encodeURIComponent(token!)}`); + }); + + it("keeps only the token out of the authenticated URL", () => { + const ctx = ctxWith(connections(() => "https://dsh.example.com:8443/gate?token=a%2Fb%2Bc")); + expect(readLaunchToken(ctx)).toBe("a/b+c"); + }); + + it("returns undefined for every unusable connection shape", () => { + expect(readLaunchToken(ctxWith(undefined))).toBeUndefined(); + // 旧版 dsh:服务在,但没有 authenticatedUrl。 + expect(readLaunchToken(ctxWith({}))).toBeUndefined(); + expect(readLaunchToken(ctxWith(connections(() => "http://dsh.example.com")))).toBeUndefined(); + expect( + readLaunchToken(ctxWith(connections(() => "http://dsh.example.com?token="))), + ).toBeUndefined(); + expect( + readLaunchToken( + ctxWith( + connections(() => { + throw new Error("connection exploded"); + }), + ), + ), + ).toBeUndefined(); + }); +}); + +describe("makeLaunchTokenBridge", () => { + it("warns once per process when the bridge is inactive", async () => { + const warnings: unknown[] = []; + const bridge = makeLaunchTokenBridge(ctxWith(undefined), { warn: (m) => warnings.push(m) }); + expect(await bridge()).toBeUndefined(); + expect(await bridge()).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(String(warnings[0])).toContain("launch-token bridge inactive"); + }); + + it("warns once per process when authenticatedUrl yields no token", async () => { + const warnings: unknown[] = []; + const bridge = makeLaunchTokenBridge(ctxWith(connections(() => "http://h")), { + warn: (m) => warnings.push(m), + }); + expect(await bridge()).toBeUndefined(); + expect(await bridge()).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(String(warnings[0])).toContain("returned no token"); + }); + + it("warns once per process and stays silent for successful reads", async () => { + const warnings: unknown[] = []; + let explode = true; + const bridge = makeLaunchTokenBridge( + ctxWith( + connections(() => { + if (explode) throw new Error("boom"); + return "http://h/?token=abc"; + }), + ), + { warn: (m) => warnings.push(m) }, + ); + expect(await bridge()).toBeUndefined(); + explode = false; + expect(await bridge()).toBe("/?token=abc"); + expect(warnings).toHaveLength(1); + expect(String(warnings[0])).toContain("boom"); + }); +}); diff --git a/src/launch-token-bridge.ts b/src/launch-token-bridge.ts index 577ad08..10fdfa1 100644 --- a/src/launch-token-bridge.ts +++ b/src/launch-token-bridge.ts @@ -1,5 +1,47 @@ import type { Context } from "@deepseek-ai/cordis"; +/** 一次 launch-token 读取的结果:成功带 token,失败带原因(原因只用于日志选择,不外泄)。 */ +type LaunchTokenProbe = + | { readonly kind: "ok"; readonly token: string } + | { readonly kind: "inactive" } + | { readonly kind: "failed"; readonly detail: string }; + +/** 只从 authenticatedUrl 提取 token 的内部探针;三条失败原因分开,供桥选告警文案。 */ +function probeLaunchToken(ctx: Context): LaunchTokenProbe { + try { + const connection = ctx.get("connection") as unknown as + { authenticatedUrl?(baseUrl: string): string } | undefined; + if (connection === undefined || typeof connection.authenticatedUrl !== "function") { + return { kind: "inactive" }; + } + // 固定 loopback base 仅用于触发 authenticatedUrl 的 URL 解析;返回值中的 + // host/scheme/其它 query 全部丢弃,只保留 launch token。 + const token = new URL(connection.authenticatedUrl("http://127.0.0.1")).searchParams.get( + "token", + ); + if (token === null || token === "") { + return { kind: "failed", detail: "authenticatedUrl returned no token" }; + } + return { kind: "ok", token }; + } catch (error) { + return { + kind: "failed", + detail: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * 当前进程的 launch token(只从 `connection.authenticatedUrl` 取)。 + * + * 服务缺失 / 旧版 dsh(无 `authenticatedUrl`)/ 返回里没有 token / 抛错 → `undefined`。 + * 首次引导通道拿它做常量时间比较,桥拿它拼登录后的 `/?token=`,两处读的是同一枚 token。 + */ +export function readLaunchToken(ctx: Context): string | undefined { + const probe = probeLaunchToken(ctx); + return probe.kind === "ok" ? probe.token : undefined; +} + /** * dsh launch-token 桥(0.1.2-alpha 起 client-connection 的页面 token 门):登录成功后 * 302 到相对 `/?token=`,浏览器自动 mint dsh cookie,免去手动复制 @@ -16,39 +58,23 @@ export function makeLaunchTokenBridge( let warnedInactive = false; let warnedError = false; return (): Promise => { - try { - const connection = ctx.get("connection") as unknown as - { authenticatedUrl?(baseUrl: string): string } | undefined; - if (connection === undefined || typeof connection.authenticatedUrl !== "function") { - if (!warnedInactive) { - warnedInactive = true; - log.warn( - "launch-token bridge inactive: no connection.authenticatedUrl (dsh < 0.1.2-alpha?)", - ); - } - return Promise.resolve(undefined); - } - // 固定 loopback base 仅用于触发 authenticatedUrl 的 URL 解析;返回值中的 - // host/scheme/其它 query 全部丢弃,只保留 launch token。 - const token = new URL(connection.authenticatedUrl("http://127.0.0.1")).searchParams.get( - "token", - ); - if (token === null || token === "") { - if (!warnedError) { - warnedError = true; - log.warn("launch-token bridge unavailable: authenticatedUrl returned no token"); - } - return Promise.resolve(undefined); - } - return Promise.resolve(`/?token=${encodeURIComponent(token)}`); - } catch (error) { - if (!warnedError) { - warnedError = true; + const probe = probeLaunchToken(ctx); + if (probe.kind === "ok") { + return Promise.resolve(`/?token=${encodeURIComponent(probe.token)}`); + } + if (probe.kind === "inactive") { + if (!warnedInactive) { + warnedInactive = true; log.warn( - `launch-token bridge unavailable: ${error instanceof Error ? error.message : String(error)}`, + "launch-token bridge inactive: no connection.authenticatedUrl (dsh < 0.1.2-alpha?)", ); } return Promise.resolve(undefined); } + if (!warnedError) { + warnedError = true; + log.warn(`launch-token bridge unavailable: ${probe.detail}`); + } + return Promise.resolve(undefined); }; } diff --git a/src/protocol/index.test.ts b/src/protocol/index.test.ts index 6b6cdaa..c42a0e4 100644 --- a/src/protocol/index.test.ts +++ b/src/protocol/index.test.ts @@ -1,6 +1,9 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { AUTH_PATH_PREFIX, + BOOTSTRAP_PATH, + BOOTSTRAP_SESSION_KIND, + CONTINUE_PATH, isAuthPath, LOGIN_PATH, loginPath, @@ -9,6 +12,7 @@ import { PASSWORD_CHANGED_LOGIN_URL, PASSWORD_CHANGED_NOTICE, PASSWORD_PATH, + SETUP_PATH, STATUS_PATH, USERS_PASSWORD_PATH, USERS_PATH, @@ -31,6 +35,9 @@ describe("protocol: /auth contract", () => { PASSWORD_PATH, USERS_PATH, USERS_PASSWORD_PATH, + BOOTSTRAP_PATH, + SETUP_PATH, + CONTINUE_PATH, }).toEqual({ AUTH_PATH_PREFIX: "/auth", LOGIN_PATH: "/auth/login", @@ -39,9 +46,16 @@ describe("protocol: /auth contract", () => { PASSWORD_PATH: "/auth/password", USERS_PATH: "/auth/users", USERS_PASSWORD_PATH: "/auth/users/password", + BOOTSTRAP_PATH: "/auth/bootstrap", + SETUP_PATH: "/auth/setup", + CONTINUE_PATH: "/auth/continue", }); }); + it("pins the bootstrap session kind", () => { + expect(BOOTSTRAP_SESSION_KIND).toBe("bootstrap"); + }); + it("pins the composed URLs and the notice key", () => { expect(PASSWORD_CHANGED_NOTICE).toBe("password-changed"); expect(PASSWORD_CHANGED_LOGIN_URL).toBe("/auth/login?next=%2F¬ice=password-changed"); diff --git a/src/protocol/index.ts b/src/protocol/index.ts index 5057881..c04d5ed 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -51,6 +51,21 @@ export const USERS_PATH = "/auth/users"; /** 管理面:管理员重置他人口令。 */ export const USERS_PASSWORD_PATH = "/auth/users/password"; +/** 首次引导:校验 dsh launch token 并签发一次性的 bootstrap 会话(GET)。 */ +export const BOOTSTRAP_PATH = "/auth/bootstrap"; + +/** 首次配置向导页(GET 渲染、POST 提交);也是 bootstrap 会话被拒时的导航目标。 */ +export const SETUP_PATH = "/auth/setup"; + +/** 向导完成后回首页:只认会话 cookie,302 到 dsh 的 `/?token=`。 */ +export const CONTINUE_PATH = "/auth/continue"; + +/** + * bootstrap 会话的 `kind` 值:只在首次引导通道签发,TTL 有上限,只能访问本插件的 + * `/auth/*`。向导完成后被整体吊销,不是第二种长期登录方式。 + */ +export const BOOTSTRAP_SESSION_KIND = "bootstrap"; + /** 登录页 `notice` 白名单键:改密成功后被登出。 */ export const PASSWORD_CHANGED_NOTICE = "password-changed"; diff --git a/src/protocol/payloads.ts b/src/protocol/payloads.ts index ae0e014..91711b9 100644 --- a/src/protocol/payloads.ts +++ b/src/protocol/payloads.ts @@ -11,8 +11,11 @@ /** 用户角色(`users.yaml` 的 `role`,也是 `/auth/status` 与 `/auth/users` 里的 `role`)。 */ export type UserRole = "user" | "admin"; -/** 会话类别(D25):受限会话只能改密,不进管理面。 */ -export type SessionKind = "full" | "password-change-only"; +/** + * 会话类别(D25):受限会话只能改密,不进管理面;bootstrap 会话只能访问本插件的 + * `/auth/*`,只在首次引导通道签发,向导完成后整体吊销。 + */ +export type SessionKind = "full" | "password-change-only" | "bootstrap"; /** 口令策略未通过项(值即 400 `policy` 错误体里 `rules` 数组的元素,顺序同 host 判定顺序)。 */ export type PasswordRule = diff --git a/test/bootstrap-harness.ts b/test/bootstrap-harness.ts new file mode 100644 index 0000000..6b4eed4 --- /dev/null +++ b/test/bootstrap-harness.ts @@ -0,0 +1,171 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { MemTable, makeRes } from "./token-endpoints-harness.js"; +import { SessionStore } from "../src/host/entities/session/index.js"; +import type { SessionKind } from "../src/host/shared/protocol/index.js"; +import { + makeSetupHandlers, + type ConfiguredProbe, + type ConfiguredState, + type SetupEndpointDeps, + type SetupTokenOutcome, +} from "../src/host/features/bootstrap/index.js"; + +/** + * 首次引导功能片段的单元夹具:假请求、假探针、可定制的 setup deps,加一个真的 + * `SessionStore`(MemTable 之上,与生产同一份实现)。 + * 不是 `.test.ts`,所以 vitest 不收集它。 + */ +export { MemTable, makeRes } from "./token-endpoints-harness.js"; +export type { FakeRes } from "./token-endpoints-harness.js"; + +export interface FakeRequestOptions { + method?: string; + url?: string; + cookie?: string; + headers?: Record; + body?: string; + contentType?: string; +} + +/** 假请求:url/method/headers 齐全,body 走 iterator(`parseFormBody` 要读它)。 */ +export function fakeReq(options: FakeRequestOptions = {}): IncomingMessage { + const body = options.body; + return { + method: options.method ?? "GET", + url: options.url ?? "/", + socket: {}, + headers: { + ...(options.cookie === undefined ? {} : { cookie: options.cookie }), + ...(options.contentType === undefined ? {} : { "content-type": options.contentType }), + ...options.headers, + }, + *[Symbol.asyncIterator](): Generator { + if (body !== undefined) yield Buffer.from(body); + }, + } as unknown as IncomingMessage; +} + +/** 固定(或按次计算)结论的探针。 */ +export function probeOf( + state: ConfiguredState | (() => Promise), +): ConfiguredProbe { + return { + state: () => Promise.resolve(typeof state === "function" ? state() : state), + markConfigured: () => undefined, + }; +} + +/** 建一枚会话并回传可直接塞进 Cookie 头的值。 */ +export async function cookieFor( + store: SessionStore, + cookieName = "dsh_auth", + kind: SessionKind = "bootstrap", +): Promise { + const issued = await store.create(kind, 60_000, kind); + return `${cookieName}=${issued.token}`; +} + +export interface SetupHarnessOptions { + mode?: "token" | "password"; + /** 未配置探针的结论(默认未配置)。 */ + probe?: ConfiguredProbe; + saveToken?: () => Promise; + createAdmin?: ( + username: string, + password: string, + ) => Promise<"ok" | "conflict" | "invalid" | "failed">; + publicHost?: string; + sessionTtlSeconds?: number; + store?: SessionStore | undefined; +} + +export interface SetupHarness { + deps: SetupEndpointDeps; + store: SessionStore; + /** 每次 `issueFullSession` 收到的 subject(顺序即调用顺序)。 */ + issued: string[]; + logs: { level: string; message: unknown }[]; + cookie(kind?: SessionKind): Promise; +} + +/** setup 端点的默认 deps:token 模式能一次成功,password 模式能建号成功。 */ +export function makeSetupHarness(options: SetupHarnessOptions = {}): SetupHarness { + const store = options.store ?? new SessionStore(new MemTable()); + const issued: string[] = []; + const logs: { level: string; message: unknown }[] = []; + const deps: SetupEndpointDeps = { + mode: options.mode ?? "token", + probe: options.probe ?? probeOf("unconfigured"), + sessions: () => store, + cookieName: "dsh_auth", + cookieSecure: false, + sessionTtlSeconds: options.sessionTtlSeconds ?? 604800, + publicHost: options.publicHost ?? "dsh.example.com", + tokenRef: "DSH_AUTH_TOKEN", + usersPath: "/tmp/dsh-auth-test/users.yaml", + saveToken: + options.saveToken ?? (() => Promise.resolve({ status: "saved", token: "new-token" })), + createAdmin: options.createAdmin ?? (() => Promise.resolve("ok")), + issueFullSession: (subject, res) => { + issued.push(subject); + res.setHeader("set-cookie", "dsh_auth=full-session; Max-Age=604800; Path=/; HttpOnly"); + return Promise.resolve(); + }, + logger: { + error: (message) => logs.push({ level: "error", message }), + info: (message) => logs.push({ level: "info", message }), + warn: (message) => logs.push({ level: "warn", message }), + }, + }; + return { deps, store, issued, logs, cookie: (kind) => cookieFor(store, "dsh_auth", kind) }; +} + +/** setup 端点的两个处理函数(测试里省掉把 deps 再包一层的样板)。 */ +export interface SetupHandlers { + get: (req: IncomingMessage, res: ServerResponse) => void | Promise; + post: (req: IncomingMessage, res: ServerResponse) => void | Promise; +} + +/** GET 向导页:返回假响应(状态/头/体)。 */ +export async function getSetupPage( + harness: SetupHarness, + cookie?: string, +): Promise> { + const res = makeRes(); + await makeSetupHandlers(harness.deps).get( + fakeReq({ url: "/auth/setup", ...(cookie === undefined ? {} : { cookie }) }), + res.res, + ); + return res; +} + +/** + * POST 向导表单。`headers` 缺省带同源凭据(`sec-fetch-site`);`method` 固定 POST, + * `contentType` 缺省 urlencoded,`form === false` 时给一个非表单类型(415 用)。 + */ +export async function postSetupForm( + harness: SetupHarness, + options: { + cookie?: string; + headers?: Record; + body?: string; + form?: boolean; + contentType?: string; + } = {}, +): Promise> { + const res = makeRes(); + await makeSetupHandlers(harness.deps).post( + fakeReq({ + method: "POST", + url: "/auth/setup", + ...(options.cookie === undefined ? {} : { cookie: options.cookie }), + headers: options.headers ?? { "sec-fetch-site": "same-origin" }, + contentType: + options.contentType ?? + (options.form === false ? "application/json" : "application/x-www-form-urlencoded"), + ...(options.body === undefined ? {} : { body: options.body }), + }), + res.res, + ); + return res; +} diff --git a/test/bootstrap-wiring-harness.ts b/test/bootstrap-wiring-harness.ts new file mode 100644 index 0000000..379c3ae --- /dev/null +++ b/test/bootstrap-wiring-harness.ts @@ -0,0 +1,132 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { + HttpHandler, + WrappableRoute, + WrappableServer, +} from "../src/host/shared/guard/index.js"; +import { SessionStore } from "../src/host/entities/session/index.js"; +import { SETUP_PATH } from "../src/host/shared/protocol/index.js"; +import { makeBootstrapWiring, type BootstrapWiringInput } from "../src/bootstrap-wiring.js"; +import { fakeReq, makeRes, MemTable } from "./bootstrap-harness.js"; + +/** + * `bootstrap-wiring.test.ts` 的夹具:假 webServer 路由表 + 假 Context(credentials / + * connection)+ 真 `SessionStore`。不碰网络,也不碰真实 DSH_HOME。 + */ + +export const LAUNCH = "launch-token"; +export const FORM = "application/x-www-form-urlencoded"; +export const LOGIN_REDIRECT = "/auth/login?next=%2F"; + +export interface Credentials { + resolve(ref: string): Promise<{ value: string } | undefined>; + set?(ref: string, value: string): Promise; +} + +type FakeServer = WrappableServer & { routes: Map }; + +function makeServer(): FakeServer { + const routes = new Map(); + return { + routes, + exact: routes, + prefixes: new Map(), + upgrades: new Map(), + fallback: undefined, + register(route: WrappableRoute) { + routes.set(route.path, route); + return () => routes.delete(route.path); + }, + registerUpgrade: () => () => undefined, + registerFallback: () => () => undefined, + }; +} + +/** 只回答 credentials / connection 的假 Context;其余服务一律不存在。 */ +function makeCtx(credentials: Credentials | undefined): Context { + return { + get: (name: string) => { + if (name === "credentials") return credentials; + if (name === "connection") { + return { authenticatedUrl: () => `http://dsh.example.com/?token=${LAUNCH}` }; + } + return undefined; + }, + } as Context; +} + +export interface WiringHarness { + wiring: ReturnType; + server: FakeServer; + store: SessionStore; + logs: { level: string; message: unknown }[]; + handler(path: string): HttpHandler; + cookie(kind?: "bootstrap" | "full"): Promise; + /** POST `/auth/setup`(同源、urlencoded),返回假响应。 */ + post(body?: string, session?: Promise): Promise>; +} + +export function makeWiringHarness( + options: { + mode?: "token" | "password"; + credentials?: Credentials; + usersPath?: string; + mount?: boolean; + tokenRef?: string; + } = {}, +): WiringHarness { + const mode = options.mode ?? "token"; + const credentials = options.credentials; + const server = makeServer(); + const store = new SessionStore(new MemTable()); + const logs: { level: string; message: unknown }[] = []; + const input: BootstrapWiringInput = { + ctx: makeCtx(credentials), + server, + config: { + mode, + cookieName: "dsh_auth", + cookieSecure: false, + sessionTtl: 3600, + tokenRef: options.tokenRef ?? "DSH_AUTH_TOKEN", + publicHost: "dsh.example.com", + }, + auth: { sessions: store }, + usersPath: options.usersPath ?? "/tmp/dsh-auth-wiring/users.yaml", + launchTokenBridge: () => Promise.resolve(`/?token=${LAUNCH}`), + log: { + error: (message) => logs.push({ level: "error", message }), + info: (message) => logs.push({ level: "info", message }), + warn: (message) => logs.push({ level: "warn", message }), + }, + }; + const wiring = makeBootstrapWiring(input); + if (options.mount !== false) wiring.mount(); + const cookie = async (kind: "bootstrap" | "full" = "bootstrap"): Promise => { + const issued = await store.create(kind, 60_000, kind); + return `dsh_auth=${issued.token}`; + }; + return { + wiring, + server, + store, + logs, + handler: (path) => server.routes.get(path)!.handler, + cookie, + async post(body = "", session?: Promise): Promise> { + const res = makeRes(); + await server.routes.get(SETUP_PATH)!.handler( + fakeReq({ + method: "POST", + url: SETUP_PATH, + cookie: session === undefined ? await cookie() : await session, + headers: { "sec-fetch-site": "same-origin" }, + contentType: FORM, + body, + }), + res.res, + ); + return res; + }, + }; +} diff --git a/test/integration/integration-auth-helpers.ts b/test/integration/integration-auth-helpers.ts index 88a45b6..8319a8f 100644 --- a/test/integration/integration-auth-helpers.ts +++ b/test/integration/integration-auth-helpers.ts @@ -54,7 +54,8 @@ export async function waitFor(condition: () => boolean, timeoutMs = 5_000): Prom } } -export async function mountStack(options: { +/** 首次引导的测试要能读回「向导写进去的那枚 token」,所以可写凭据服务单独一档。 */ +export interface MountStackOptions { withCredentials: boolean; /** * 假 dsh connection 的形状:`authenticatedUrl` = dsh ≥ 0.1.2-alpha;`bare` = 服务在但没有 @@ -62,17 +63,48 @@ export async function mountStack(options: { * 用来锁住桥「登录时才 `ctx.get("connection")`」的惰性查找(晚注册的 connection 同样有效)。 */ connection?: "authenticatedUrl" | "bare"; -}): Promise<{ + /** 认证流(默认 token);password 模式需要 `usersFile`(默认落在临时目录里)。 */ + mode?: "token" | "password"; + /** password 模式的 users.yaml 路径;缺省用临时目录下的一份(绝不碰真实 DSH_HOME)。 */ + usersFile?: string; + /** 提供带 `set` 的可写凭据服务(`resolve` 读同一份内存表),用于首次配置写 token。 */ + writableCredentials?: boolean; + /** 反钓鱼 host / CSRF 的公开来源(默认空串,即只用 Sec-Fetch-Site 通道)。 */ + publicHost?: string; +} + +export interface MountedStack { ctx: Context; port: number; fibers: Fiber[]; root: string; token: string | undefined; -}> { + usersFile: string; + /** 可写凭据服务的落点(`writableCredentials` 时存在);token 写到这里。 */ + credentialsStore: Map | undefined; +} + +export async function mountStack(options: MountStackOptions): Promise { const root = mkdtempSync(join(tmpdir(), "dsh-auth-it-")); const ctx = new Context(); + const mode = options.mode ?? "token"; + const usersFile = options.usersFile ?? join(root, "auth", "users.yaml"); const token = options.withCredentials ? randomBytes(24).toString("base64url") : undefined; - if (options.withCredentials) { + let credentialsStore: Map | undefined; + if (options.writableCredentials === true) { + credentialsStore = new Map(); + const store = credentialsStore; + ctx.provide("credentials", { + resolve: (ref: string) => { + const value = store.get(ref); + return Promise.resolve(value === undefined ? undefined : { value, source: "test" }); + }, + set: (ref: string, value: string) => { + store.set(ref, value); + return Promise.resolve(); + }, + }); + } else if (options.withCredentials) { ctx.provide("credentials", { resolve: (ref: string) => ref === "DSH_AUTH_TOKEN" @@ -106,7 +138,12 @@ export async function mountStack(options: { ); fibers.push(await ctx.plugin(WebServer, { host: "127.0.0.1", port: 0 })); fibers.push( - await ctx.plugin({ name, inject, apply, Config }, { cookieSecure: false } as AuthConfig), + await ctx.plugin({ name, inject, apply, Config }, { + cookieSecure: false, + mode, + usersFile: mode === "password" ? usersFile : "", + publicHost: options.publicHost ?? "", + } as AuthConfig), ); const server = ctx.get("webServer") as unknown as RealServer; registerProbes(server); @@ -127,7 +164,7 @@ export async function mountStack(options: { }); } if (options.connection === "bare") ctx.provide("connection", {}); - return { ctx, port: server.port, fibers, root, token }; + return { ctx, port: server.port, fibers, root, token, usersFile, credentialsStore }; } /** diff --git a/test/integration/integration-bootstrap-helpers.ts b/test/integration/integration-bootstrap-helpers.ts new file mode 100644 index 0000000..178c4cd --- /dev/null +++ b/test/integration/integration-bootstrap-helpers.ts @@ -0,0 +1,50 @@ +import { + mountStack, + unmountStack, + type MountedStack, + type MountStackOptions, +} from "./integration-auth-helpers.js"; + +/** + * `integration.bootstrap.test.ts` 的共享夹具:真栈的生命周期、首跳断言与 cookie 提取。 + * 不是 `.test.ts`,所以 vitest 不收集它。 + */ + +/** 假 connection 里的 launch token(与 `mountStack` 的 authenticatedUrl 同值)。 */ +export const LAUNCH = "launchTok-it"; +/** 浏览器顶层文档导航的请求头。 */ +export const NAVIGATION = { "sec-fetch-dest": "document" }; +/** 表单提交的 content type。 */ +export const FORM = "application/x-www-form-urlencoded"; + +export function baseUrl(stack: MountedStack): string { + return `http://127.0.0.1:${stack.port}`; +} + +/** 起真栈、跑用例、收栈(栈本身不关的话,storage 目录会留在临时目录里)。 */ +export async function withStack( + options: MountStackOptions, + body: (stack: MountedStack, base: string) => Promise, +): Promise { + const stack = await mountStack(options); + try { + return await body(stack, baseUrl(stack)); + } finally { + await unmountStack(stack.fibers, stack.root); + } +} + +/** 走完「launch URL → 引导端点」两跳,返回 bootstrap 会话 cookie。 */ +export async function bootstrapCookie(base: string): Promise { + const hop = await fetch(`${base}/?token=${LAUNCH}`, { redirect: "manual" }); + const endpoint = await fetch(`${base}${hop.headers.get("location")!}`, { redirect: "manual" }); + if (endpoint.status !== 302) throw new Error(`bootstrap endpoint returned ${endpoint.status}`); + return sessionCookie(endpoint); +} + +/** `Set-Cookie` 的第一个 `name=value` 段(直接塞回 Cookie 头用)。 */ +export function sessionCookie(response: Response): string { + const setCookie = response.headers.get("set-cookie"); + if (setCookie === null) throw new Error("expected a Set-Cookie header"); + return setCookie.split(";")[0]!; +} diff --git a/test/integration/integration.bootstrap-window.test.ts b/test/integration/integration.bootstrap-window.test.ts new file mode 100644 index 0000000..1cf90ac --- /dev/null +++ b/test/integration/integration.bootstrap-window.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { loadUsersFile, writeUsersFile } from "../../src/host/entities/user/index.js"; +import { + bootstrapCookie, + FORM, + LAUNCH, + NAVIGATION, + withStack, +} from "./integration-bootstrap-helpers.js"; + +/** + * 引导窗口的「关窗」真栈测试:配置一旦成立(凭据补齐、用户文件出现),已经签发的 bootstrap + * 会话就不能再渲染向导、也不能再写盘;凭据服务缺失时探针只能给 unknown,窗口不许打开。 + */ + +/** 新装实例:没有配置,但凭据服务已经在位(解析为空 = 真未配置)。 */ +const FRESH = { + withCredentials: false, + writableCredentials: true, + connection: "authenticatedUrl", +} as const; +/** 连凭据服务都没有:探针只能给 unknown,引导窗口必须关着。 */ +const NO_CREDENTIALS = { withCredentials: false, connection: "authenticatedUrl" } as const; + +describe("integration: no window without a healthy credentials service", () => { + it("10. refuses the launch URL while credentials are missing, then opens once they arrive", async () => { + await withStack(NO_CREDENTIALS, async (stack, base) => { + const blocked = await fetch(`${base}/auth/bootstrap?token=${LAUNCH}`, { redirect: "manual" }); + expect(blocked.status).toBe(302); + expect(blocked.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(blocked.headers.get("set-cookie")).toBeNull(); + + // 凭据服务晚到(apply 之后才 provide)且解析为空 = 真未配置,窗口这时才该打开。 + const values = new Map(); + stack.ctx.provide("credentials", { + resolve: (ref: string) => + Promise.resolve(values.has(ref) ? { value: values.get(ref)! } : undefined), + set: (ref: string, value: string) => { + values.set(ref, value); + return Promise.resolve(); + }, + }); + const opened = await fetch(`${base}/auth/bootstrap?token=${LAUNCH}`, { redirect: "manual" }); + expect(opened.status).toBe(302); + expect(opened.headers.get("location")).toBe("/auth/setup"); + expect(opened.headers.get("set-cookie")).toContain("dsh_auth="); + }); + }); +}); + +describe("integration: the wizard closes when the instance becomes configured", () => { + it("11. refuses both methods and keeps a pre-existing token intact", async () => { + await withStack({ ...FRESH, publicHost: "dsh.example.com" }, async (stack, base) => { + const cookie = await bootstrapCookie(base); + // 运维用 CLI/手写文件补上了令牌:实例此刻已配置。 + stack.credentialsStore?.set("DSH_AUTH_TOKEN", "pre-existing-token"); + + const page = await fetch(`${base}/auth/setup`, { headers: { cookie } }); + expect(page.status).toBe(200); + expect(await page.text()).toContain("Already configured"); + + const post = await fetch(`${base}/auth/setup`, { + method: "POST", + headers: { + cookie, + "content-type": FORM, + origin: "http://dsh.example.com", + ...NAVIGATION, + }, + body: "", + redirect: "manual", + }); + expect(post.status).toBe(302); + expect(post.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(post.headers.get("set-cookie")).toBeNull(); + expect(stack.credentialsStore?.get("DSH_AUTH_TOKEN")).toBe("pre-existing-token"); + + // 旧 bootstrap 会话已被吊销。 + const after = await fetch(`${base}/auth/setup`, { headers: { cookie }, redirect: "manual" }); + expect(after.status).toBe(302); + expect(after.headers.get("location")).toBe("/auth/login?next=%2F"); + }); + }); + + it("12. refuses to create a second administrator once users exist", async () => { + await withStack( + { + withCredentials: false, + mode: "password", + connection: "authenticatedUrl", + publicHost: "dsh.example.com", + }, + async (stack, base) => { + const cookie = await bootstrapCookie(base); + await writeUsersFile(stack.usersFile, { + users: new Map([ + ["existing", { passwordHash: "scrypt$1$2$3$4$hash", disabled: false, role: "admin" }], + ]), + }); + + const page = await fetch(`${base}/auth/setup`, { headers: { cookie } }); + expect(page.status).toBe(200); + expect(await page.text()).toContain("Already configured"); + + const post = await fetch(`${base}/auth/setup`, { + method: "POST", + headers: { + cookie, + "content-type": FORM, + origin: "http://dsh.example.com", + ...NAVIGATION, + }, + body: new URLSearchParams({ + username: "root2", + password: "Sup3r-secret-pw", + confirm: "Sup3r-secret-pw", + }).toString(), + redirect: "manual", + }); + expect(post.status).toBe(302); + expect(post.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(post.headers.get("set-cookie")).toBeNull(); + + const loaded = await loadUsersFile(stack.usersFile); + expect(loaded.snapshot.users.has("root2")).toBe(false); + expect(loaded.snapshot.users.has("existing")).toBe(true); + }, + ); + }); +}); diff --git a/test/integration/integration.bootstrap.test.ts b/test/integration/integration.bootstrap.test.ts new file mode 100644 index 0000000..85832cb --- /dev/null +++ b/test/integration/integration.bootstrap.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { loadUsersFile } from "../../src/host/entities/user/index.js"; +import { + bootstrapCookie, + FORM, + LAUNCH, + NAVIGATION, + sessionCookie, + withStack, +} from "./integration-bootstrap-helpers.js"; + +/** + * issue #115 首次引导通道的真栈集成测试:真 cordis + 真 webserver + 真 storage, + * 只有 dsh 的 connection 与 credentials 是假的(测试里可写)。 + * + * 状态机逐条覆盖(设计 §3):门只按形状重定向 → 引导端点校验 launch token 并签发 + * bootstrap 会话 → 向导页 → 提交后关窗并签发正式会话 → `/auth/continue` 回到 dsh。 + */ + +/** 新装实例:没有配置,但凭据服务已经在位(解析为空 = 真未配置)。 */ +const FRESH = { + withCredentials: false, + writableCredentials: true, + connection: "authenticatedUrl", +} as const; +/** 连凭据服务都没有:探针只能给 unknown,引导窗口必须关着。 */ +const NO_CREDENTIALS = { withCredentials: false, connection: "authenticatedUrl" } as const; + +describe("integration: launch URL enters the bootstrap channel", () => { + it("1. sends a fresh token-mode instance to the bootstrap endpoint", async () => { + await withStack(FRESH, async (_stack, base) => { + const res = await fetch(`${base}/?token=${LAUNCH}`, { redirect: "manual" }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(`/auth/bootstrap?token=${LAUNCH}`); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + }); + + it("2. issues a bootstrap session and redirects to the wizard", async () => { + await withStack(FRESH, async (_stack, base) => { + const res = await fetch(`${base}/auth/bootstrap?token=${LAUNCH}`, { redirect: "manual" }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/auth/setup"); + const setCookie = res.headers.get("set-cookie")!; + expect(setCookie).toContain("dsh_auth="); + expect(setCookie).toContain("Max-Age=3600"); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Lax"); + expect(res.headers.get("cache-control")).toBe("no-store"); + // 响应头与响应体都不带 launch token。 + expect(JSON.stringify([...res.headers])).not.toContain(LAUNCH); + expect(await res.text()).not.toContain(LAUNCH); + }); + }); +}); + +describe("integration: wizard page", () => { + it("3. requires the bootstrap session", async () => { + await withStack(FRESH, async (_stack, base) => { + const anonymous = await fetch(`${base}/auth/setup`, { redirect: "manual" }); + expect(anonymous.status).toBe(302); + expect(anonymous.headers.get("location")).toBe("/auth/login?next=%2F"); + + const cookie = await bootstrapCookie(base); + const page = await fetch(`${base}/auth/setup`, { headers: { cookie } }); + expect(page.status).toBe(200); + expect(page.headers.get("cache-control")).toBe("no-store"); + const html = await page.text(); + expect(html).toContain("First run"); + expect(html).toContain("Generate access token"); + expect(html).not.toContain(LAUNCH); + }); + }); +}); + +describe("integration: token-mode setup", () => { + const WRITABLE = { ...FRESH, writableCredentials: true, publicHost: "dsh.example.com" } as const; + const POST_HEADERS = { + "content-type": FORM, + origin: "http://dsh.example.com", + }; + + it("4. writes the credential, closes the window and reaches dsh", async () => { + await withStack(WRITABLE, async (stack, base) => { + const cookie = await bootstrapCookie(base); + const post = await fetch(`${base}/auth/setup`, { + method: "POST", + headers: { ...POST_HEADERS, cookie }, + body: "", + redirect: "manual", + }); + expect(post.status).toBe(200); + const written = stack.credentialsStore?.get("DSH_AUTH_TOKEN"); + expect(written).toBeTruthy(); + const body = await post.text(); + // 新令牌只出现一次,且页面不被缓存。 + expect(body.split(written!).length - 1).toBe(1); + expect(post.headers.get("cache-control")).toBe("no-store"); + + // 关窗:旧 launch URL 不再能换 bootstrap 会话,也不再下发 cookie。 + const hop = await fetch(`${base}/?token=${LAUNCH}`, { redirect: "manual" }); + expect(hop.headers.get("location")).toBe(`/auth/bootstrap?token=${LAUNCH}`); + const closed = await fetch(`${base}${hop.headers.get("location")!}`, { redirect: "manual" }); + expect(closed.status).toBe(302); + expect(closed.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(closed.headers.get("set-cookie")).toBeNull(); + + // 正式会话把浏览器送回 dsh 的 launch URL。 + const cont = await fetch(`${base}/auth/continue`, { + headers: { cookie: sessionCookie(post) }, + redirect: "manual", + }); + expect(cont.status).toBe(302); + expect(cont.headers.get("location")).toBe(`/?token=${LAUNCH}`); + expect(cont.headers.get("cache-control")).toBe("no-store"); + }); + }); +}); + +describe("integration: password-mode setup", () => { + it("5. creates the first admin and closes the window", async () => { + await withStack( + { ...FRESH, mode: "password", publicHost: "dsh.example.com" }, + async (stack, base) => { + const cookie = await bootstrapCookie(base); + const page = await fetch(`${base}/auth/setup`, { headers: { cookie } }); + expect(await page.text()).toContain('name="username"'); + + const post = await fetch(`${base}/auth/setup`, { + method: "POST", + headers: { + cookie, + "content-type": FORM, + origin: "http://dsh.example.com", + }, + body: new URLSearchParams({ + username: "root", + password: "Sup3r-secret-pw", + confirm: "Sup3r-secret-pw", + }).toString(), + redirect: "manual", + }); + expect(post.status).toBe(302); + expect(post.headers.get("location")).toBe("/auth/continue"); + + const loaded = await loadUsersFile(stack.usersFile); + expect(loaded.missing).toBe(false); + expect(loaded.snapshot.users.get("root")?.role).toBe("admin"); + + const closed = await fetch(`${base}/auth/bootstrap?token=${LAUNCH}`, { + redirect: "manual", + }); + expect(closed.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(closed.headers.get("set-cookie")).toBeNull(); + + const cont = await fetch(`${base}/auth/continue`, { + headers: { cookie: sessionCookie(post) }, + redirect: "manual", + }); + expect(cont.headers.get("location")).toBe(`/?token=${LAUNCH}`); + }, + ); + }); +}); + +describe("integration: no bootstrap window when it must not open", () => { + it("6. never bootstraps an already configured instance", async () => { + await withStack( + { withCredentials: true, connection: "authenticatedUrl" }, + async (stack, base) => { + const hop = await fetch(`${base}/?token=${stack.token}`, { redirect: "manual" }); + expect(hop.headers.get("location")).toBe(`/auth/bootstrap?token=${stack.token}`); + const endpoint = await fetch(`${base}${hop.headers.get("location")!}`, { + redirect: "manual", + }); + expect(endpoint.status).toBe(302); + expect(endpoint.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(endpoint.headers.get("set-cookie")).toBeNull(); + }, + ); + }); + + it("7. keeps today's deny on an old dsh without connection.authenticatedUrl", async () => { + await withStack({ ...NO_CREDENTIALS, connection: "bare" }, async (_stack, base) => { + const endpoint = await fetch(`${base}/auth/bootstrap?token=${LAUNCH}`, { + redirect: "manual", + }); + expect(endpoint.status).toBe(302); + expect(endpoint.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(endpoint.headers.get("set-cookie")).toBeNull(); + // 未认证的数据面行为不变。 + expect((await fetch(`${base}/__probe`)).status).toBe(401); + }); + }); +}); + +describe("integration: bootstrap session hardening", () => { + it("8. keeps a bootstrap session inside /auth", async () => { + await withStack(FRESH, async (_stack, base) => { + const cookie = await bootstrapCookie(base); + const nav = await fetch(`${base}/`, { + headers: { cookie, ...NAVIGATION }, + redirect: "manual", + }); + expect(nav.status).toBe(302); + expect(nav.headers.get("location")).toBe("/auth/setup"); + expect(nav.headers.get("referrer-policy")).toBe("no-referrer"); + expect((await fetch(`${base}/__probe`, { headers: { cookie } })).status).toBe(401); + expect((await fetch(`${base}/auth/setup`, { headers: { cookie } })).status).toBe(200); + }); + }); + + it("9. leaks no Set-Cookie on any failure path", async () => { + await withStack( + { ...FRESH, writableCredentials: true, publicHost: "dsh.example.com" }, + async (_stack, base) => { + const paths = [ + "/auth/bootstrap?token=wrong", + "/auth/bootstrap", + `/auth/bootstrap?token=${LAUNCH}&token=${LAUNCH}`, + "/auth/bootstrap?token=%20", + ]; + for (const path of paths) { + const res = await fetch(`${base}${path}`, { redirect: "manual" }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/auth/login?next=%2F"); + expect(res.headers.get("set-cookie")).toBeNull(); + } + + const cookie = await bootstrapCookie(base); + const untrusted = await fetch(`${base}/auth/setup`, { + method: "POST", + headers: { cookie, "content-type": FORM }, + body: "", + redirect: "manual", + }); + expect(untrusted.status).toBe(403); + expect(untrusted.headers.get("set-cookie")).toBeNull(); + expect(await untrusted.text()).toBe("untrusted origin"); + }, + ); + }); +}); From 268e6ef733d6ce59fe48438b6388bfbb7dfdec7b Mon Sep 17 00:00:00 2001 From: TecFancy Date: Mon, 26 Oct 2026 20:27:28 +0800 Subject: [PATCH 2/3] docs: document the first-run bootstrap channel and rewrite quick start --- .agents/skills/dsh-auth-gate-config/SKILL.md | 11 ++- README.md | 90 +++++++++++++++--- README.zh.md | 69 +++++++++++--- docs/README.md | 2 +- docs/decisions.md | 15 +++ ...026-10-26-first-run-launch-bootstrap.en.md | 94 +++++++++++++++++++ ...026-10-26-first-run-launch-bootstrap.zh.md | 68 ++++++++++++++ docs/deployed/known-limitations.md | 23 +++++ docs/deployed/known-limitations_zh.md | 14 +++ docs/implemented/impl-launch-token-bridge.md | 27 +++++- .../impl-launch-token-bridge_zh.md | 21 ++++- 11 files changed, 404 insertions(+), 30 deletions(-) create mode 100644 docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.en.md create mode 100644 docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.zh.md diff --git a/.agents/skills/dsh-auth-gate-config/SKILL.md b/.agents/skills/dsh-auth-gate-config/SKILL.md index 247a590..ac127b3 100644 --- a/.agents/skills/dsh-auth-gate-config/SKILL.md +++ b/.agents/skills/dsh-auth-gate-config/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-auth-gate-config -description: Use when the user asks about dsh-auth-gate configuration - supported options (mode/totp/sessionTtl/cookieName/tokenRef/cookieSecure/usersFile/publicHost/revokeSweepMs/clientIpHeader/trustedProxyCidrs/logoutOrder), how to configure them, the dsh-auth CLI (user add/passwd/role/list/disable/enable/totp, skill install), the password policy (14+ chars, four character classes, not the old password), the Settings account-security block (self-service change, admin reset), login trouble, rate limiting, or the logout button. Ships with the dsh-auth-gate package; install with `dsh-auth skill install`. / 用户询问 dsh-auth-gate 配置(mode/totp/sessionTtl/cookieName/tokenRef/cookieSecure/usersFile/publicHost/revokeSweepMs/clientIpHeader/trustedProxyCidrs/logoutOrder)、dsh-auth CLI(user add/passwd/role/list/disable/enable/totp、skill install)、口令策略(14 位起、四类字符、不得同旧)、设置面板账号安全块(自助改密、管理员重置)、登录故障、限速或退出按钮时使用。随 dsh-auth-gate 包发布;用 `dsh-auth skill install` 安装。 +description: Use when the user asks about dsh-auth-gate configuration - supported options (mode/totp/sessionTtl/cookieName/tokenRef/cookieSecure/usersFile/publicHost/revokeSweepMs/clientIpHeader/trustedProxyCidrs/logoutOrder), how to configure them, the dsh-auth CLI (user add/passwd/role/list/disable/enable/totp, skill install), the password policy (14+ chars, four character classes, not the old password), the Settings account-security block (self-service change, admin reset), login trouble, the first-run setup wizard, rate limiting, or the logout button. Ships with the dsh-auth-gate package; install with `dsh-auth skill install`. / 用户询问 dsh-auth-gate 配置(mode/totp/sessionTtl/cookieName/tokenRef/cookieSecure/usersFile/publicHost/revokeSweepMs/clientIpHeader/trustedProxyCidrs/logoutOrder)、dsh-auth CLI(user add/passwd/role/list/disable/enable/totp、skill install)、口令策略(14 位起、四类字符、不得同旧)、设置面板账号安全块(自助改密、管理员重置)、登录故障、首次配置向导、限速或退出按钮时使用。随 dsh-auth-gate 包发布;用 `dsh-auth skill install` 安装。 # 低频查询技能:不让模型自动发现/调用(避免常驻技能目录稀释注意力), # 用户显式打开技能面板调用(user-invocable 默认 true,UI 显示 "user-only")。 disable-model-invocation: true @@ -17,9 +17,9 @@ CLI 用法、常见故障时使用。 ## Triggers / 触发词 auth-gate / login door / logout button / cookieSecure / usersFile / logoutOrder / -mode / totp / two-factor / authenticator code / dsh-auth commands / login failures / rate-limit 429 / config 403 +mode / totp / two-factor / authenticator code / dsh-auth commands / login failures / rate-limit 429 / config 403 / first run / bootstrap / setup wizard auth-gate / 登录门 / 退出登录按钮 / cookieSecure / usersFile / logoutOrder / mode / -totp / 两步验证 / 验证码 / dsh-auth 命令 / 登录不上 / 限速锁定 429 / 配置面 403 +totp / 两步验证 / 验证码 / dsh-auth 命令 / 登录不上 / 限速锁定 429 / 配置面 403 / 首次运行 / 配置向导 / 引导会话 ## What it is / 一句话定位 @@ -59,6 +59,7 @@ Override by `id` in `$DSH_HOME/cordis.patch.yml`: `.credentials.yaml`(`DSH_AUTH_TOKEN`)。 - `users.yaml` / `.credentials.yaml` must be `0600`. / 均 0600。 - Token mode then hands the browser dsh's own launch token after a successful sign-in (a `302` to `/?token=...`, needs dsh >= 0.1.2-alpha), so you never paste `?token=` by hand; on older hosts the login keeps its plain redirect. / token 模式登录成功后会把 dsh 的 launch token 交给浏览器(`302` 到 `/?token=...`,需 dsh >= 0.1.2-alpha),不用手动粘 `?token=`;旧版宿主退回普通跳转。 +- First run needs no shell: an **unconfigured** instance (the credential read succeeds and returns nothing, or `users.yaml` has zero users) turns dsh's launch URL into a one-time bootstrap session: `/auth/bootstrap` validates the token, the browser lands on `/auth/setup`, and the wizard either generates and saves the shared token or creates the first administrator. The window closes as soon as one of those exists, so the same launch URL then goes to `/auth/bootstrap`, which finds the instance configured and answers `302 /auth/login?next=%2F` with no cookie; that is expected, not a lost credential. Remove the token or the last user and restart dsh to get the wizard back. The wizard cannot change `mode`/`totp`/`cookieSecure`/`sessionTtl`/`proxy`, which need `$DSH_HOME/cordis.patch.yml` plus a restart. / 首次运行不用开 shell:**未配置**的实例(凭据读取成功但为空,或 `users.yaml` 零用户)会把 dsh 的启动链接换成一次性 bootstrap 会话,`/auth/bootstrap` 校验令牌后浏览器落到 `/auth/setup`,向导在那里生成并保存共享令牌,或创建第一个管理员。两者之一出现后窗口关闭,同一条启动链接改成先到 `/auth/bootstrap`、再回 `302 /auth/login?next=%2F`,全程不下发 cookie;这是预期,不是凭据丢了。要重开向导,删掉令牌或最后一个用户再重启 dsh。向导改不了 `mode`/`totp`/`cookieSecure`/`sessionTtl`/`proxy`,这些得改 `$DSH_HOME/cordis.patch.yml` 再重启。 ## CLI (`dsh-auth`) @@ -123,6 +124,9 @@ change, 0.16.0 user management). / 密码模式在 设置 → 账号安全 有 | `dsh-auth-proxy` exits right after start | Since 0.16.0 `--target` is required (there is no default upstream); since 0.17.0 the bin also works when it is reached through a symlink. Pass `--listen 127.0.0.1:8443 --target `. / 代理起来就退出:0.16.0 起 `--target` 必填(不再有默认上游);0.17.0 修了 symlink 入口。写成 `--listen 127.0.0.1:8443 --target ` | | Opening dsh from another site shows `401 dsh web authentication required` | Fixed: a cross-site top-level navigation (link from another site, `127.0.0.1` -> `localhost`) carries the Lax `dsh_auth` cookie but not dsh's Strict cookie; the gate now answers an authenticated one with a same-origin bounce page that re-navigates to the same URL (brief blank flash; without JS click `Continue`). Upgrade the plugin; F5 never helped. / 已修复:跨站顶层导航只带 Lax 的 `dsh_auth`,不带 dsh 的 Strict cookie;门现对已认证请求回同源弹回页重新导航到同一 URL(可能闪一下;无 JS 点 `Continue`)。升级插件即可,刷新无效 | | Upgrading the host to dsh 0.2.x | Since 0.16.0 the package declares its host corridor in both `engines.dsh` and the storage-domain peer (identical strings, D12/D26); install or upgrade through the host's own `dsh plugin` and the gate mounts normally. / 升级宿主到 dsh 0.2.x:0.16.0 起包内在 `engines.dsh` 与 storage-domain peer 两处声明同一条走廊(D12/D26),照常用宿主自己的 `dsh plugin` 安装或升级即可 | +| Fresh install, but the launch URL shows the Access token box, no wizard | The instance already counts as configured, so the window is closed: the token reference resolves to something non-empty, `users.yaml` holds at least one user, or the probe errored (unreadable file, bad permissions). Clear the credential or the user list and restart; a cached credentials value may need exactly that restart. / 全新安装却停在 Access token 输入框、没有向导:实例已被判为已配置(令牌引用非空、`users.yaml` 至少一个用户,或探针读取出错)。清掉凭据或用户列表再重启;credentials 缓存可能正需要这次重启 | +| Login over plain http, then back on the login page | `cookieSecure: true` (the default) makes the browser refuse the session cookie on `http://`. Set it to `false` for plain-http tests and keep `true` behind TLS. / 纯 http 登录后又回到登录页:`cookieSecure` 默认 true,浏览器在 `http://` 下不保存会话 cookie。纯 http 测试设 false,TLS 之后保持 true | +| The launch URL stopped working after setup | Expected: the wizard closes the window once a token or a user exists, and the URL then goes to `/auth/bootstrap` and ends at `302 /auth/login?next=%2F`. Sign in on the login page; to reopen the wizard, remove the token or the last user and restart dsh. / 配好之后启动链接进不来:预期行为,令牌或用户一出现窗口就关闭,之后该 URL 先到 `/auth/bootstrap`、再回 `302 /auth/login?next=%2F`。用登录页登录;要重开向导就删掉令牌或最后一个用户再重启 | ## Verification tips / 验证技巧 @@ -144,6 +148,7 @@ Run on an isolated test instance. / 隔离测试实例上验证: page (6-digit code input); the challenge lasts ≤5 min and rides the session cookie. / TOTP 第二步:密码正确后登录页切到 "Verify" 页(6 位验证码); 挑战态最长 5 分钟、随会话 cookie 携带。 +- First run: on an unconfigured instance `GET /?token=` -> `302 /auth/bootstrap?token=...`, then `302 /auth/setup` with a `dsh_auth` cookie whose `Max-Age` is at most 3600 plus `HttpOnly` and `SameSite=Lax`. The wizard shows the generated token once; a second `GET /?token=` goes to `/auth/bootstrap` and then answers `302 /auth/login?next=%2F` with no `Set-Cookie`. / 首次运行:未配置实例上 `GET /?token=<启动令牌>` 先 302 到 `/auth/bootstrap?token=...`,再 302 到 `/auth/setup`,同时下发 `dsh_auth` cookie(`Max-Age` 不超过 3600、`HttpOnly`、`SameSite=Lax`)。向导只显示一次令牌;之后再 `GET /?token=` 会先到 `/auth/bootstrap`、再 302 到 `/auth/login?next=%2F`,且不带 `Set-Cookie`。 - Account security: signing in as an admin shows the user list in Settings; a non-admin sees only the self-service form. A reset makes the target user set a new password diff --git a/README.md b/README.md index a1aa8ed..e714724 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ in first. - [What it does not do](#what-it-does-not-do) - [The market's credentials badge](#the-markets-credentials-badge) - [Quick start](#quick-start) +- [First run](#first-run) - [See it in action](#see-it-in-action) - [Configuration](#configuration) - [Command-line tool](#command-line-tool) @@ -119,26 +120,55 @@ If you would rather run an instance that never reads a credential, use password ## Quick start +Install the plugin, restart dsh, and open the link dsh prints on startup. A fresh +install has nothing configured yet, so that link goes straight into the first-run +wizard: getting in needs no shell command and no configuration edit. + ```sh # 1. Install the plugin from npm into your dsh profile. # Since 0.4.1 the package declares a `dsh.bundle` manifest, so `dsh plugin add` # also registers the mount (dsh.profile.bundles) automatically: dsh plugin --profile web add dsh-auth-gate -# 2. Create an admin account. -# `dsh plugin add` installs the plugin into the profile's node_modules -# ($DSH_HOME/profiles/web, default ~/.dsh/...) — the CLI is NOT added to your -# PATH, so call it through the profile. `dsh plugin` already requires pnpm: -printf '%s\n' 'choose-a-strong-password' | \ - pnpm --dir "$DSH_HOME/profiles/web" exec dsh-auth user add admin --password-stdin - -# 3. Turn on password login: override the plugin config in $DSH_HOME/cordis.patch.yml -# (a ready-to-use config-override template ships in deploy/cordis.patch.yml; -# see Configuration below — the mount itself needs no manual patch row) - -# 4. Restart dsh. Open your site — you will be asked to sign in. +# 2. Restart dsh, then open the URL it prints at startup +# (http://:/?token=). +dsh --profile web ``` +The wizard offers what the current mode allows: `token` mode, the shipped default, +generates and saves the shared access token, and `password` mode creates the first +administrator. The new token is shown once on the result page, and one click continues +into dsh. From the next visit on that launch link is just a URL again: sign in on the +login page with the token or the password you just set. Everything else (more users, +TOTP, changing `mode`) still happens in the shell: see [First run](#first-run), +[Configuration](#configuration) and [Command-line tool](#command-line-tool). + +## First run + +Whether the launch link opens the wizard is decided on every request, from one +question: is this instance configured? + +- **The default is `mode: "token"`.** With no `DSH_AUTH_TOKEN`, an unconfigured + token-mode instance rejects everyone, so the launch link is the only way in. Only a + successful read that returns nothing counts as unconfigured; a missing credentials + service or a throwing read is treated like a configured instance and opens no wizard. + `password` mode follows the same rule while `users.yaml` is missing or holds no users. +- **What `?token=` means.** It is dsh's own launch token, printed at startup and valid + only for the lifetime of that dsh process. The plugin takes it as proof that you can + read the server console, then issues a bootstrap session: at most one hour, and it + can open nothing but the plugin's own `/auth/*` pages. +- **What the wizard can change.** It generates and saves the shared token (token mode) + or creates the first administrator (password mode), and the window closes as soon as + one of the two exists. It cannot change `mode`, `totp`, `cookieSecure`, `sessionTtl` + or `proxy`: edit `$DSH_HOME/cordis.patch.yml` and restart dsh for those. +- **What happens to the launch URL afterwards.** It stops admitting you, in two hops: the + gate first bounces the request to `/auth/bootstrap?token=...`, that endpoint finds the + instance configured, and it answers `302 /auth/login?next=%2F`. Neither hop sets a + cookie and nothing is let through, so the launch token is not a second key. +- **How to open the wizard again.** Remove the saved token (or leave `users.yaml` with + no users) and restart dsh. If a credentials service hands back a cached value, that + restart is what makes it see the change. + ## See it in action Every screenshot below uses the **English UI**, and both READMEs share the same set of @@ -386,6 +416,34 @@ request the gate allows, it answers with a tiny same-origin page that re-navigat same URL, so the second request carries both cookies. You may see a brief blank flash; no action is needed. Without JavaScript, click the `Continue` link. +### The session cookie does not stick on plain HTTP + +With `cookieSecure: true`, the default, the browser is told to store the session cookie +for HTTPS only. On a plain `http://` site the login submits, the server answers normally, +and the browser then drops the cookie, so you land back on the login page with no +session. Set `cookieSecure: false` for a plain-http test instance and keep it `true` +wherever TLS terminates in front of dsh. The same trap bites twice when +`$DSH_HOME/cordis.patch.yml`, a layer every profile shares, sets `true` while a test +profile sets `false`: boot the test profile with `--patch` to override it. + +### The launch URL no longer signs me in + +That is expected once the instance is configured. The launch token only opens the +first-run wizard, and the wizard closes that window the moment a token or a user exists; +afterwards the request goes to `/auth/bootstrap?token=...`, that endpoint finds the +instance configured, and it answers `302 /auth/login?next=%2F` without setting a cookie, +exactly as before the wizard existed. Sign in on the login page instead. Nothing is lost: +to open the wizard again, remove the saved token (or leave `users.yaml` with no users) and +restart dsh. + +### The launch link lands on the login page right after a restart + +dsh opens the launch URL while the credentials service may still be starting up. Until +that service answers, the plugin cannot tell "not configured" from "cannot read the +configuration", so it fails closed and shows the login page. Reload the link once the +instance has finished booting: on a truly unconfigured instance the wizard opens, and on a +configured one the login page is the correct destination. + ## Deployment - [Reverse-proxy deployment guide](docs/deployed/reverse-proxy.md) — Caddy/nginx @@ -477,6 +535,14 @@ is in `docs/deployed/known-limitations.md`. it sign in (the login path rejects disabled users), so run `dsh-auth user enable ` first. - An administrator without TOTP makes the admin endpoints single-factor; enable TOTP for administrators in production. +- The first-run wizard is a one-time window: its bootstrap session lasts at most one hour, + can only reach `/auth/*`, and closes as soon as a token or a user exists. Removing that + token (or the last user) and restarting dsh opens it again. +- The wizard assumes a single dsh process, because each process mints its own launch token; + behind several replicas without sticky routing the request can land on a process that does + not know the token and falls back to the login page. A credentials service that caches + values may need a restart before the token saved in the wizard becomes the only credential, + and dsh versions without `connection.authenticatedUrl` have no wizard at all. - The plugin protects dsh's web surface only. Keep the OS user and the config files private. diff --git a/README.zh.md b/README.zh.md index 449a003..bc87092 100644 --- a/README.zh.md +++ b/README.zh.md @@ -24,6 +24,7 @@ - [它不做什么](#它不做什么) - [市场的「会用你的密钥」徽章](#市场的会用你的密钥徽章) - [快速开始](#快速开始) +- [首次运行](#首次运行) - [效果预览](#效果预览) - [配置](#配置) - [命令行工具](#命令行工具) @@ -96,26 +97,44 @@ _读凭据的同时还能联网_。这是披露,不是判决;对本插件来 ## 快速开始 +装上插件、重启 dsh,然后打开 dsh 启动时打印的那条链接。全新安装的实例还没有任何配置,所以这条链接 +会直接进入首次配置向导:整个过程不需要开 shell,也不需要改配置文件。 + ```sh # 1. 从 npm 装进你的 dsh profile。 # 自 0.4.1 起,包里声明了 dsh.bundle manifest,`dsh plugin add` 会顺带自动注册挂载 # (dsh.profile.bundles),不需要再手动添加挂载行: dsh plugin --profile web add dsh-auth-gate -# 2. 创建管理员账号。 -# `dsh plugin add` 把插件装进 profile 的 node_modules -# ($DSH_HOME/profiles/web,默认 ~/.dsh/...);CLI 不会被加入你的 PATH, -# 所以要通过 profile 调用。`dsh plugin` 本身就依赖 pnpm: -printf '%s\n' '选一个强密码' | \ - pnpm --dir "$DSH_HOME/profiles/web" exec dsh-auth user add admin --password-stdin - -# 3. 启用密码登录:在 $DSH_HOME/cordis.patch.yml 里覆盖插件配置 -# (仓库自带可直接使用的覆盖模板 deploy/cordis.patch.yml,见下文"配置"; -# 挂载这一步本身不需要手动写 patch 行) - -# 4. 重启 dsh,打开站点,就会先要求登录。 +# 2. 重启 dsh,然后打开它启动时打印的链接 +# (http://:/?token=)。 +dsh --profile web ``` +向导给出的选项跟随当前模式:token 模式(出厂默认)生成并保存共享访问令牌,password 模式创建第一个 +管理员。新令牌只在结果页显示一次,点一下就能继续进入 dsh。从下次访问起,那条启动链接又只是一条普通 URL: +在登录页用刚设好的令牌或密码登录即可。其余的事(加用户、开 TOTP、改 `mode`)仍然在 shell 里做: +见 [首次运行](#首次运行)、[配置](#配置) 和 [命令行工具](#命令行工具)。 + +## 首次运行 + +启动链接会不会打开向导,每次请求都会重新判一次,判据只有一个:这个实例配置好了没有? + +- **默认模式是 `mode: "token"`。** 没有 `DSH_AUTH_TOKEN` 时,未配置的 token 模式实例会拒绝所有人, + 所以启动链接是唯一的入口。只有读取成功且值为空才算未配置;credentials 服务缺失或读取抛错,一律按 + 已配置处理,不会打开向导。`password` 模式在 `users.yaml` 缺失或零用户时同理。 +- **`?token=` 是什么。** 它是 dsh 自己的启动令牌,dsh 每次启动打印一次,只在这个进程活着的时候有效。 + 插件把它当成「你能读服务器控制台」的凭证,随后签一张 bootstrap 会话:最长 1 小时,除了插件自己的 + `/auth/*` 页面什么都打不开。 +- **向导能改什么。** 它生成并保存共享令牌(token 模式)或创建第一个管理员(password 模式),两者一旦 + 存在,窗口立刻关闭。它改不了 `mode`、`totp`、`cookieSecure`、`sessionTtl` 和 `proxy`:这些要改 + `$DSH_HOME/cordis.patch.yml` 再重启 dsh。 +- **之后启动链接会怎样。** 它不再放你进来,而且要走两跳:门先把请求转到 + `/auth/bootstrap?token=...`,端点判定实例已配置,再回 `302 /auth/login?next=%2F`。两跳都不下发 + cookie、都不放行,所以启动令牌不是第二把钥匙。 +- **怎么再打开向导。** 删掉已保存的令牌(或者让 `users.yaml` 没有用户),然后重启 dsh。如果 credentials + 服务返回的是缓存值,正是这次重启让它看到变化。 + ## 效果预览 本节的截图统一使用**英文界面**:插件自己的面板跟随 GUI 语言,服务端渲染的页面在任何语言下都是 @@ -325,6 +344,27 @@ pnpm --dir "${DSH_HOME:-$HOME/.dsh}/profiles/" exec dsh-auth skill inst 让浏览器重新导航到同一 URL,第二次请求就同时带上两个 cookie。可能看到一次短暂的空白闪烁,无需操作; 没有 JavaScript 时点 `Continue` 链接即可。 +### 纯 HTTP 下会话 cookie 存不住 + +`cookieSecure` 默认是 `true`,浏览器被告知只在 HTTPS 下保存会话 cookie。纯 `http://` 站点上登录会正常 +提交、服务端也正常应答,随后浏览器把 cookie 丢掉,于是你又回到登录页,等于没有会话。纯 http 的测试实例 +把 `cookieSecure` 设成 `false`;TLS 在 dsh 前面终结的部署则保持 `true`。同一个坑还会踩两次: +`$DSH_HOME/cordis.patch.yml` 是所有 profile 共享的一层,它写 `true`、测试 profile 写 `false` 时以共享层 +为准,启动测试 profile 时加 `--patch` 覆盖。 + +### 配好之后启动链接进不来了 + +这是预期行为。启动令牌只用来打开首次配置向导,而向导在令牌或用户出现的那一刻就关掉了这个窗口;之后 +请求先到 `/auth/bootstrap?token=...`,端点判定实例已配置,再回 `302 /auth/login?next=%2F`,全程不下发 +cookie,与向导出现之前完全一样。改用登录页登录即可。什么都没丢:要再打开向导,删掉已保存的令牌(或让 +`users.yaml` 没有用户)再重启 dsh。 + +### 刚重启时启动链接停在登录页 + +dsh 打开启动链接时,credentials 服务可能还没就绪。在它应答之前,插件分不清「没有配置」和 +「读不到配置」,于是按 fail-closed 停在登录页。等实例启动完成后刷新一次:真未配置就会进向导, +已配置则登录页本来就是正确去处。 + ## 部署 - [反向代理部署指南](docs/deployed/reverse-proxy_zh.md):Caddy/nginx 配置、浏览器信任栅栏的坑 @@ -399,6 +439,11 @@ systemd 示例:`deploy/systemd/dsh-auth-proxy.service.example`。 - 重置不会改动目标的 TOTP 密钥;重置一个仍被禁用的账号也不会让它登得进来(登录路径恒拒禁用 用户),需要先执行 `dsh-auth user enable `。 - 管理员自己没启用 TOTP 时,管理端点是单因素认证;生产环境应给管理员启用 TOTP。 +- 首次配置向导是一次性窗口:它的 bootstrap 会话最长 1 小时、只能访问 `/auth/*`,令牌或用户一出现窗口 + 就关闭。删掉那枚令牌(或最后一个用户)再重启 dsh,可以重新打开。 +- 向导假设只有单个 dsh 进程,因为每个进程各自生成自己的启动令牌;多副本没有粘滞路由时,请求可能落到 + 不认识这枚令牌的进程,回落到登录页。credentials 服务若缓存值,可能要在重启之后才认向导里保存的令牌; + 没有 `connection.authenticatedUrl` 的 dsh 版本则完全没有向导。 - 本插件只保护 dsh 的 Web 入口;操作系统账号和配置文件需要你自己保持私密。 ## 开发 diff --git a/docs/README.md b/docs/README.md index ce2246e..1597264 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ dsh-auth-gate 的文档按**生命周期**分层组织(2026-08-30 整理): | [`handoff/`](handoff/) | 执行交接(每阶段新 session 必读):handoff-m2 / m3 / m4(m4 含 0.11.1 修订) | 新 session 执行对应规格前 | | [`implemented/`](implemented/) | 已交付规格与规划:impl-m1~m4(当前实现基准,m4 最新)、impl-m2-admin(P2/D25 管理面切片)、impl-launch-token-bridge(0.1.2-alpha 兼容层)、impl-client-logout、login-page-polish-plan(已落地视觉基线)、totp-fix-plan(0.11.1 修复计划,已实施) | 核对「当前行为依据」时 | | [`plans/`](plans/) | 进行中规划:pr53-login-page-fix-plan(#53 移植) | #53 落地时 | -| [`decisions.md`](decisions.md) + [`decisions/`](decisions/) | 重大决策编号索引 D1–D28(双语记录,proposed / implemented / archived) | 理解「为什么」时 | +| [`decisions.md`](decisions.md) + [`decisions/`](decisions/) | 重大决策编号索引 D1–D32(双语记录,proposed / implemented / archived) | 理解「为什么」时 | | [`demo/`](demo/) | README 效果图(登录页 / TOTP 验证码页 / 实例 / 设置导航「账号安全」行 / 改密面板 / 用户管理块 / 重置成功态;**统一英文界面 + 浅色主题**,中英共用同一套) | README 引用 | | [`reviews/`](reviews/) | 外部评审记录(grok-4.6 launch-token bridge review 等) | 变更合入前对照 | diff --git a/docs/decisions.md b/docs/decisions.md index 31dc806..caed0eb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -445,3 +445,18 @@ gate 已放行、但请求是跨站顶层文档导航(`GET` + `Sec-Fetch-Site: **为什么**:一份定义直接消除漂移的来源,契约零依赖所以两种构建都能安全包含,桥让 D30 的分层检查不受影响。 → [zh](decisions/implemented/2026-10-16-cross-half-protocol.zh.md) · [en](decisions/implemented/2026-10-16-cross-half-protocol.en.md) + +## D32. 未配置实例认 launch token 进首次配置向导,配置成立即关窗 + +全新安装后,`GET /?token=` 在实例未配置时不再 302 到登录页,而是签一个最长 1 小时、只放行 +`/auth/*` 的 bootstrap 会话并 302 到 `/auth/setup` 向导;在向导里生成共享令牌或创建第一个管理员后窗口立即 +关闭,旧 launch URL 再访问按原样拒绝。校验、未配置探针与会话签发全在新端点 `/auth/bootstrap`,两个门只在 +本来就要 deny 的位置多做一次形态重定向,`guard.ts`/`wrapServer`/`Gate` 词表一行不改。 +**替代方案**:launch token 永久放行(等于一把绕过 TOTP 与密码的第二把钥匙);守卫 `next` 保留 search +(D-bridge-9 已否决);给 guard 加引导钩子(动冻结契约,收益不足);探针把读盘失败算作未配置(一次故障即 +开门);只在 client 面板做入口(bundle 挂了再次锁死)。 +**为什么**:未配置可以每次请求实时判定,配置成立后判据自然为假,不需要额外状态;门保持零 IO,冻结契约不动; +引导会话有 1 小时与 `/auth/*` 两重边界,临时放人进来仍然进不到 dsh。代价如实记档:launch token 会出现在 +`Location: /auth/bootstrap?token=` 上,与原始请求 URL 同级暴露。 +→ [zh](decisions/implemented/2026-10-26-first-run-launch-bootstrap.zh.md) · +[en](decisions/implemented/2026-10-26-first-run-launch-bootstrap.en.md) diff --git a/docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.en.md b/docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.en.md new file mode 100644 index 0000000..e507c31 --- /dev/null +++ b/docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.en.md @@ -0,0 +1,94 @@ +# Unconfigured instances get a first-run wizard from dsh's launch token + +## Decision + +The plugin trusts dsh's launch token only while the instance is unconfigured. After a +fresh install and a dsh restart, the browser opens the URL the startup log printed +(`http://host:port/?token=`) and no longer stops at an Access token box: +it receives a bootstrap session and lands on the `/auth/setup` wizard, which either +generates and saves the shared token (token mode) or creates the first administrator +(password mode), and then lets you into dsh. As soon as the configuration exists the +window closes, in two hops: the gate bounces the request to +`/auth/bootstrap?token=...`, that endpoint finds the instance configured, and answers +`302 /auth/login?next=%2F`. Neither hop sets a cookie and nothing is admitted, so an old +launch URL cannot act as a second permanent key. + +Verification lives in a new endpoint, `GET /auth/bootstrap`. The two gates (TokenGate / +PasswordGate) only add one shape test at the point where they were about to deny +anyway: `GET` plus pathname `/` plus exactly one non-empty `token` query returns +`{deny:{redirect:"/auth/bootstrap?token=..."}}`. The gates compare no token, read no +file and perform no IO. The probe, session issuance and wizard pages all live in +separate endpoints. The bootstrap session TTL is `min(sessionTtl, 3600)` seconds, it +reaches `/auth/*` only, and completing the wizard revokes every bootstrap session +before issuing a full one; `/auth/continue` then reuses the launch token bridge to +return to `/?token=`. + +## Context + +The shipped configuration is `mode: "token"`, resolving a shared token from +`DSH_AUTH_TOKEN`. On a fresh install that reference does not resolve, the gate +fails closed for every request, and nobody gets in, including the person who just +installed the plugin. dsh prints a URL carrying its launch token on every start, and +`authorizeIndex` accepts that URL and mints a 30 day cookie. In 0.17.4 the auth-gate +gate saw "no session, no Bearer" first and answered `302` to the login page, so the +launch token never had a chance to be used. Installing and restarting therefore still +required a shell command to create an administrator, or writing `DSH_AUTH_TOKEN` by +hand and restarting again, which is exactly the step issue #115 asks to remove. + +Three constraints applied at the same time. `guard.ts`, `wrapServer` and the `Gate` +vocabulary are frozen (D2, D27) and must not change for this. The "`next` keeps the +pathname only" rule was settled in D-bridge-9. This release adds no configuration +option and no client change, and older dsh versions (no `connection.authenticatedUrl`) +must behave exactly as in 0.17.4. + +## Alternatives Considered + +- **Treat the launch token as a permanent second key**: `?token=` would admit forever, + which is a long-lived key that bypasses TOTP and passwords and weakens the whole gate. + dsh also rotates its launch token per process, so it was never meant to be held. +- **Keep the query in the guard's `next`**: suggested in issue #99 and rejected in + D-bridge-9, because it reflects the launch token into the unauthenticated login page + URL and its Referer. +- **Add a `tryBootstrap` hook to the guard, or a new `Gate` decision type**: both + external designs took that route. It edits the frozen `guard.ts` and `Gate` vocabulary + to save one endpoint. +- **Let the probe treat "cannot read the configuration" as unconfigured**: a permission + error or a YAML parse failure would open the wizard, so one disk fault would open the + door. That violates fail-closed. +- **Make the client panel the only entry point**: a bundle that fails to load or a page + that will not render locks the instance again, exactly when recovery is needed. +- **Add a `bootstrap: true/false` option**: one more way to lock yourself out, and it + turns the rollback from "revert the code" into "edit the config, then revert". + +## Why + +- Unconfigured is decidable: token mode checks whether the credential read succeeds and + returns empty (a missing service or a throwing `resolve` is `unknown` and opens no + wizard), password mode checks whether `users.yaml` is missing or has zero users. Once + a configuration exists the predicate is false by itself, so there is no "bootstrap + already used" state to maintain. The process also keeps a `markedConfigured` flag that + the wizard sets after a successful write, which closes the window immediately even if + a credentials service caches values and `resolveToken()` cannot see the new one yet. +- The gates only redirect by shape, so `guard.ts`, `wrapServer` and the `Gate` vocabulary + stay untouched and the risk surface stays small. All IO and all decisions sit in the + separate endpoint, which fails closed in order: missing `sessions`, a throwing probe, + a missing `connection` or a throwing `sessions.create` all fall back to + `302 /auth/login?next=%2F`. +- The bootstrap session has its own fence: at most one hour, `/auth/*` only, revoked the + moment the wizard completes. It is not a channel into dsh, so "let this person in + temporarily" cannot become "open the instance temporarily". + +## Consequences and trade-offs + +- The launch token appears in `Location: /auth/bootstrap?token=...`. That is the same + exposure class as the token in the original request URL (D-bridge-5 already accepts + that the launch token reaches access logs and browser history), and the 302 carries + `referrer-policy: no-referrer`, so no new category of exposure is added. +- The channel assumes a single process: every process mints its own launch token, so + behind several replicas without sticky routing `/auth/bootstrap` can be answered by a + process that does not know the token and falls back to the login page. +- A credentials service that caches resolved values may need a dsh restart before the + token saved by the wizard becomes the only credential; the result page says so. The + window itself does not wait, since `markedConfigured` applies immediately. +- Older dsh versions (no `connection.authenticatedUrl`) have no wizard at all: there is + no launch token to read, so their behavior is unchanged from 0.17.4. diff --git a/docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.zh.md b/docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.zh.md new file mode 100644 index 0000000..9c2d101 --- /dev/null +++ b/docs/decisions/implemented/2026-10-26-first-run-launch-bootstrap.zh.md @@ -0,0 +1,68 @@ +# 未配置实例用 dsh 的 launch token 进入首次配置向导 + +## 决定了什么 + +只在实例还没配置时认 dsh 的 launch token。全新安装后重启 dsh,浏览器打开启动日志里那条 +`http://host:port/?token=`,不再停在 Access token 输入框,而是拿到一个 bootstrap +会话并进入 `/auth/setup` 向导:token 模式下生成并保存共享令牌,password 模式下创建第一个管理员, +做完就进 dsh。配置一旦成立,窗口立刻关闭:门先把请求转到 `/auth/bootstrap?token=...`,端点判定已 +配置,再回 `302 /auth/login?next=%2F`,两跳都不下发 cookie、也不放行,旧 launch URL 当不了第二把 +永久钥匙。 + +校验放在新端点 `GET /auth/bootstrap`。两个门(TokenGate / PasswordGate)只在本来就要 deny 的位置 +多做一次形态判断:`GET` 加 pathname `/` 加恰好一个非空 `token` query,命中就返回 +`{deny:{redirect:"/auth/bootstrap?token=..."}}`。门不比对 token、不读文件、不做 IO。探针、会话签发、 +向导页全在独立端点里完成。bootstrap 会话 TTL 取 `min(sessionTtl, 3600)` 秒,只放行 `/auth/*`; +向导完成时先吊销全部 bootstrap 会话,再给当前响应签发正式会话,经 `/auth/continue` 复用 launch +token 桥回到 `/?token=`。 + +## 背景 + +出厂配置是 `mode: "token"`,靠 `DSH_AUTH_TOKEN` 解析共享令牌。全新安装时这个引用解析不到,门按 +fail-closed 拒绝所有请求,于是谁都进不去,包括刚装好插件的人。dsh 自己每次启动会打印一条带 launch +token 的地址,`authorizeIndex` 认这条 URL 并铸出 30 天 cookie;但在 0.17.4 里,auth-gate 的门先一步 +看到「没有会话、没有 Bearer」的请求,302 到登录页,launch token 根本没机会被 dsh 用上。结果装完重启 +之后,用户还得开 shell 用 CLI 建管理员,或者手写 `DSH_AUTH_TOKEN` 再重启一次,这正是 issue #115 +要取消的那一步。 + +同时有三条硬约束。`guard.ts`、`wrapServer` 与 `Gate` 词表已经冻结(D2、D27),不能为这件事改; +`next` 只取 pathname 的行为由 D-bridge-9 定下不改;本版不新增配置项、不动客户端,旧版 dsh(没有 +`connection.authenticatedUrl`)的行为必须与 0.17.4 完全一致。 + +## 考虑过的替代方案 + +- **把 launch token 变成永久第二把钥匙**:`?token=` 永远能进,等于留一把绕过 TOTP 与密码的长效钥匙, + 削弱整套认证;何况 dsh 的 launch token 每个进程都换,它本来就不是能长期持有的凭据。 +- **让守卫的 `next` 保留 search**:issue #99 提过,D-bridge-9 已否决,它把 launch token 反射进未认证 + 的登录页 URL 与它的 Referer。 +- **给 guard 加 `tryBootstrap` 钩子,或给 `Gate` 增加一种决策类型**:外部两版方案都走这条路,要动冻结 + 的 `guard.ts` 与 `Gate` 词表,换来的只是少一个端点。 +- **探针把「读不到配置」也算成未配置**:权限错误、YAML 解析失败都会进向导,等于一次读盘故障就把登录门 + 打开,违反 fail-closed。 +- **只在客户端面板里做入口**:bundle 没加载或页面打不开时就再次锁死,而那正是最需要恢复的情形。 +- **加 `bootstrap: true/false` 开关**:多一个能把自己锁在门外的开关,还把回滚从「revert 代码」变成 + 「改配置再 revert」,收益为零。 + +## 为什么这样选 + +- 未配置是可判定的:token 模式看凭据读取是否成功且返回空值(服务缺失或读取抛错算 `unknown`,不打开 + 向导),password 模式看 `users.yaml` 是否缺失或零用户。配置一成立,这个判据自然为假,不需要再维护 + 「引导已用完」这类状态。 + 另外有一个进程内 `markedConfigured` 标志,向导写盘成功后窗口立即关闭,即使 credentials 服务有缓存、 + `resolveToken()` 暂时读不到新值。 +- 门只做形态重定向,`guard.ts`、`wrapServer`、`Gate` 词表一行不改,风险面最小。全部 IO 与判定收在独立 + 端点里,端点按顺序 fail-closed:缺 `sessions`、探针抛错、`connection` 缺失、`sessions.create` 抛错, + 一律回到「302 到 `/auth/login?next=%2F`」。 +- 引导会话本身有边界:最长 1 小时,只放行 `/auth/*`,向导完成即吊销。它构不成一条能访问 dsh 的通道, + 所以「临时把人放进来」不会变成「临时把实例打开」。 + +## 后果与取舍 + +- launch token 会出现在 `Location: /auth/bootstrap?token=...` 里。它与原始请求 URL 里的 token 同级暴露 + (D-bridge-5 已经承认 launch token 会进访问日志与浏览器历史),这条件 302 带 + `referrer-policy: no-referrer`,不新增暴露类别。 +- 引导通道假设单进程:launch token 每个进程各自随机,多副本没有粘滞路由时,`/auth/bootstrap` 可能落到 + 不认识这枚 token 的进程,回落为登录页。 +- credentials 服务若缓存旧值,向导里新保存的令牌要重启 dsh 才成为唯一凭据,结果页会写明这一点;窗口 + 本身不等人,`markedConfigured` 立刻生效。 +- 旧版 dsh(没有 `connection.authenticatedUrl`)没有引导:读不到 launch token,行为与 0.17.4 一致。 diff --git a/docs/deployed/known-limitations.md b/docs/deployed/known-limitations.md index 2540ae5..edbebb5 100644 --- a/docs/deployed/known-limitations.md +++ b/docs/deployed/known-limitations.md @@ -90,6 +90,29 @@ plugin, with the mechanism behind it and the decision record that fixed it. runtime behaviour plus the in-process full-stack audit tests in this repository (32 cases across the two audit unit suites and the three P2 integration suites). That is a logging-visibility limitation, not a missing audit trail. +- The first-run wizard (`/auth/setup`, reached from the launch URL through + `/auth/bootstrap`) exists only while the instance is **unconfigured**: token mode counts + as unconfigured only when the credential read succeeds and comes back empty, password + mode when `users.yaml` is missing or holds zero users. A non-empty token, a single user, + a missing credentials service, a throwing `resolve` or an unreadable file all count as + "not unconfigured" and keep today's denial. The probe runs on every request, and the + process also sets a `markedConfigured` flag once the wizard writes, so the window closes + immediately. +- A bootstrap session lasts at most `min(sessionTtl, 3600)` seconds (one hour with the + defaults), carries `subject = "bootstrap"`, and reaches `/auth/*` only: any other + navigation answers `302 /auth/setup`, an API call answers `401` and a WebSocket upgrade + is refused. Completing the wizard revokes every bootstrap session before it issues the + full one, so that ticket cannot be replayed. +- The channel assumes a single dsh process. The launch token is minted per process start, + so behind several replicas without sticky routing `/auth/bootstrap` can land on a + process that does not know that token; it fails closed to the login page. +- A credentials service that caches resolved values may keep returning the old (empty) + token after the wizard saves a new one. The result page says a restart is needed, and + the new token becomes the only credential after dsh restarts; the window itself still + closes right away. +- dsh versions without `connection.authenticatedUrl` (older than 0.1.2-alpha) expose no + launch token, so there is no wizard at all: those instances keep the 0.17.4 behavior + and are configured through the CLI. - The plugin only protects dsh's web surface. It is not a replacement for server-level security: keep the server OS user locked down and the config files private (`.credentials.yaml` and `auth/users.yaml` are created with diff --git a/docs/deployed/known-limitations_zh.md b/docs/deployed/known-limitations_zh.md index 3062abf..2b0e803 100644 --- a/docs/deployed/known-limitations_zh.md +++ b/docs/deployed/known-limitations_zh.md @@ -52,5 +52,19 @@ README 里只留了短清单;这里是完整版:每条已知局限、它背 - 审计事件走结构化 logger,而 dsh 0.1.5-rc.2 **不会**把 `ctx.logger` 的 info/error 落盘,因此在该 版本上,验证只能靠运行侧可观察结果 + 仓内的 in-process 真栈审计测试(32 例,分布在两个审计单测 套件与三个 P2 集成套件里)。这是日志可见性限制,不是「没有审计」。 +- 首次配置向导(`/auth/setup`,从启动链接经 `/auth/bootstrap` 进入)只在实例**未配置**时存在:token + 模式只有在凭据读取成功、且读到空值时才判为未配置;password 模式要求 `users.yaml` 缺失或零用户。令牌 + 非空、已有一个用户、credentials 服务缺失、`resolve` 抛错,或者文件不可读,都按「不是未配置」处理, + 维持原来的拒绝。探针每次请求实时判定,另外向导写盘成功后进程内会置 `markedConfigured` 标志,窗口 + 立即关闭。 +- bootstrap 会话最长 `min(sessionTtl, 3600)` 秒(默认配置下就是 1 小时),`subject = "bootstrap"`, + 只放行 `/auth/*`:其它导航回 `302 /auth/setup`,API 请求回 `401`,WebSocket 升级握手直接拒绝。向导 + 完成时先吊销全部 bootstrap 会话再签发正式会话,所以这张票不能重放。 +- 引导通道假设单进程。launch token 每个进程启动时各自生成,多副本没有粘滞路由时,`/auth/bootstrap` + 可能落到不认识这枚 token 的进程,按 fail-closed 回落到登录页。 +- credentials 服务若缓存已解析的值,向导保存新令牌之后可能仍然返回旧的(空)值。结果页会提示需要重启, + 新令牌在 dsh 重启后成为唯一凭据;窗口本身不受影响,仍然立刻关闭。 +- 低于 0.1.2-alpha 的 dsh 没有 `connection.authenticatedUrl`,读不到 launch token,也就没有引导:这类 + 实例维持 0.17.4 的行为,只能通过 CLI 配置。 - 本插件只保护 dsh 的网页入口,不能替代服务器层面的安全:请保持服务器系统用户最小权限、 配置文件私密(`.credentials.yaml` 和 `auth/users.yaml` 创建时即为 `0600` 权限)。 diff --git a/docs/implemented/impl-launch-token-bridge.md b/docs/implemented/impl-launch-token-bridge.md index 5c9ba63..3cd833f 100644 --- a/docs/implemented/impl-launch-token-bridge.md +++ b/docs/implemented/impl-launch-token-bridge.md @@ -137,10 +137,35 @@ throwing) never affects sign-in success. bounced back to the login page; with a connection service that has no `authenticatedUrl`, the redirect falls back to `next`. -## 6. Change Log +## 6. Bootstrap channel: the bridge is reused, not consulted + +The first-run wizard adds no second token path. It reuses this bridge at its exit and +leaves the "should this visitor see the wizard?" decision to the bootstrap feature. + +- **The wizard ends where a sign-in ends.** `/auth/continue` calls + `resolvePostLoginLocation(bridge, "/")`, the same helper both sign-in success paths use, + so the browser leaves `/auth/setup` for `/?token=` and dsh mints its own + cookie exactly as after a normal login. D-bridge-1 and D-bridge-2 apply unchanged: the + Location stays relative and `next` is dropped. +- **"Is this instance configured?" is not answered here.** The probe lives in the + bootstrap feature and runs per request (`credentials.resolve(tokenRef)` in token mode, + `users.yaml` in password mode). The bridge never reads configuration; it only maps a + successful sign-in to a redirect target. +- **The launch token is read once.** `/auth/bootstrap` compares the presented token with + `readLaunchToken(ctx)`, the same extraction the bridge uses, so the wizard and the bridge + always compare the same per-process token instead of two readers that could drift. +- **The window sits in front of the bridge.** Once the wizard has written a token or a + user, the probe answers "configured" and `/auth/bootstrap` denies, whatever the bridge + would return; an old launch URL cannot mint a bootstrap session again. +- **Failure semantics are unchanged.** The bridge stays fail-open and falls back to `/` + when the connection service is missing or throws, so a broken bridge cannot trap a user + inside the wizard; it only costs the automatic launch-token handoff (D-bridge-3). + +## 7. Change Log | commit | Content | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `19c8431` | First version: `makeLaunchTokenBridge` + `issueSession` passes host through (absolute URL) | | `b7e48e5` | grok-4.6 review F1–F6 landed: relative redirect taking only the token, two warn latches, host dependency removed, `src/launch-token-bridge.ts` extracted (root-layer whitelist), integration test locks the assembly side, this document + reverse-proxy doc notes | | `#103` | issue #99: token mode uses the same bridge (D-bridge-7); the success 302s and the guard's login redirect carry `referrer-policy: no-referrer` (D-bridge-8); the fallback rule is extracted into `resolvePostLoginLocation`, shared by both success paths; D-bridge-9 records the rejected guard-query change, while the multi-replica sticky-session constraint lives in §4 and `docs/deployed/reverse-proxy*.md` | +| `#115` | issue #115: an unconfigured instance turns the launch URL into a one-time bootstrap session plus the `/auth/setup` wizard, and `/auth/continue` reuses this bridge on the way out (D32); the bridge contract itself is unchanged, the "is it configured?" probe lives in the bootstrap feature | diff --git a/docs/implemented/impl-launch-token-bridge_zh.md b/docs/implemented/impl-launch-token-bridge_zh.md index 12cdda1..95a2afe 100644 --- a/docs/implemented/impl-launch-token-bridge_zh.md +++ b/docs/implemented/impl-launch-token-bridge_zh.md @@ -124,10 +124,29 @@ issue #99 补上另一半:**token 模式**(M2 共享口令)的登录成功 - 会话 cookie + 带 cookie 跟随第二跳 `GET /?token=` 不再被打回登录页;connection 服务 在但没有 `authenticatedUrl` 时回落 `next`。 -## 6. 变更记录 +## 6. 引导通道复用桥,但判定不在桥里 + +首次配置向导没有再开一条 token 通道。它在出口复用这座桥,把「要不要放这个人进向导」交给 +bootstrap feature 决定。 + +- **向导的出口就是登录的出口。** `/auth/continue` 调 `resolvePostLoginLocation(bridge, "/")`,与两条 + 登录成功路径同一个函数,所以浏览器从 `/auth/setup` 走到 `/?token=`,dsh 照常铸自己的 + cookie。D-bridge-1 与 D-bridge-2 原样适用:Location 仍然相对,`next` 仍然丢弃。 +- **「实例配置好没有」不在这里判。** 探针在 bootstrap feature 里,每次请求实时判(token 模式看 + `credentials.resolve(tokenRef)`,password 模式看 `users.yaml`)。桥不读配置,它只负责把「登录成功」 + 映射成一个跳转目标。 +- **launch token 只读一份。** `/auth/bootstrap` 拿请求里的 token 与 `readLaunchToken(ctx)` 比对,用的 + 就是桥也走的那个提取函数,所以向导和桥比的永远是同一枚进程级 token,不会出现两份读法各自漂移。 +- **窗口在桥前面。** 向导一旦写入了令牌或用户,探针就答「已配置」,`/auth/bootstrap` 不管桥会返回什么 + 都拒绝,旧 launch URL 再也换不到 bootstrap 会话。 +- **失败语义不变。** 桥保持 fail-open,connection 服务缺失或抛错时回落到 `/`,所以桥坏掉不会把人困在 + 向导里,只是省掉了自动交接 launch token 这一步(D-bridge-3)。 + +## 7. 变更记录 | commit | 内容 | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `19c8431` | 首版:`makeLaunchTokenBridge` + `issueSession` 透传 host(绝对 URL) | | `b7e48e5` | grok-4.6 review F1–F6 落地:相对跳转只取 token、两把闩、去 host 依赖、抽 `src/launch-token-bridge.ts`(root 层白名单)、集成测试锁装配边、本文档 + 反代文档附注 | | `#103` | issue #99:token 模式接上同一座桥(D-bridge-7);成功 302 与守卫登录跳转补 `referrer-policy: no-referrer`(D-bridge-8);回落规则抽成两条成功路径共用的 `resolvePostLoginLocation`;记录 D-bridge-9(否决守卫保留 query);多副本粘滞约束记在 §4 与 `docs/deployed/reverse-proxy*.md` | +| `#115` | issue #115:未配置实例把 launch URL 换成一次性 bootstrap 会话与 `/auth/setup` 向导,出口复用本桥(D32);桥自身的契约不变,「配置好没有」的探针在 bootstrap feature 里 | From 7761acbf84d61ac6f14aabaafb1619f4ff3e169e Mon Sep 17 00:00:00 2001 From: TecFancy Date: Mon, 26 Oct 2026 20:51:50 +0800 Subject: [PATCH 3/3] build: regenerate lib/client.js with the locked rolldown --- lib/client.js | 63 +++++++++++++++++++++++++++++++---------------- lib/client.js.map | 2 +- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/lib/client.js b/lib/client.js index 9439d1a..cfccce3 100644 --- a/lib/client.js +++ b/lib/client.js @@ -6,7 +6,7 @@ window.__ModuleLoader__.load({ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); let react = require("react"); let react_jsx_runtime = require("react/jsx-runtime"); - // src/client/shared/i18n/account-copy.ts + //#region src/client/shared/i18n/account-copy.ts /** 本面板用到的词典键(集中常量,避免散落字符串)。 */ const ACCOUNT_KEYS = { nav: "account", @@ -144,7 +144,8 @@ window.__ModuleLoader__.load({ [ACCOUNT_KEYS.ruleSpecial]: "a special character (not a letter or digit)", [ACCOUNT_KEYS.ruleSameAsOld]: "different from the current password" }; - // src/client/shared/ui/account-icon.ts + //#endregion + //#region src/client/shared/ui/account-icon.ts /** * 自设计图标(P1.1 / D24):「盾 + 钥匙孔」,16px outline,规格对齐宿主图标 * (`fill:none` / `stroke:currentColor` / `stroke-width:1.5` / 圆角线端)。 @@ -161,7 +162,8 @@ window.__ModuleLoader__.load({ const ACCOUNT_ICON_VIEW_BOX = "0 0 16 16"; /** 图标路径(两个 `d`:盾牌外框 + 钥匙孔圆与柄)。 */ const ACCOUNT_ICON_PATHS = ["M8 1.7 13.1 3.55V7.9c0 3.05-2.08 5.42-5.1 6.4-3.02-.98-5.1-3.35-5.1-6.4V3.55Z", "M9.3 7a1.3 1.3 0 1 1-2.6 0 1.3 1.3 0 1 1 2.6 0M8 8.3v2.1"]; - // src/client/shared/ui/account-styles.ts + //#endregion + //#region src/client/shared/ui/account-styles.ts /** * account 面板样式:颜色只取宿主设计 token(`--dsw-*`),不硬编码色值, * 跟随主题/明暗切换。几何(间距/圆角/字号)照 logout-action.tsx 现状自绘。 @@ -300,7 +302,8 @@ window.__ModuleLoader__.load({ fontSize: 12, lineHeight: "18px" }; - // src/client/features/nav-icon/ui/account-nav-icon.ts + //#endregion + //#region src/client/features/nav-icon/ui/account-nav-icon.ts /** * 「账号安全」导航行图标:临时 DOM 垫片(D24.1)。 * @@ -455,7 +458,8 @@ window.__ModuleLoader__.load({ restoreRows(doc); }; } - // src/client/shared/api/form.ts + //#endregion + //#region src/client/shared/api/form.ts /** * 把一个请求体对象编码成 `application/x-www-form-urlencoded`。调用方先把对象标注成 * `src/protocol/` 里的请求体类型(`PasswordChangeRequest` / `AdminResetRequest`),键名就受契约 @@ -466,7 +470,8 @@ window.__ModuleLoader__.load({ for (const [name, value] of Object.entries(fields)) if (typeof value === "string") body.set(name, value); return body; } - // src/protocol/index.ts + //#endregion + //#region src/protocol/index.ts /** 登录页(GET 渲染、POST 提交)。 */ const LOGIN_PATH = "/auth/login"; /** 登出(POST-only,`next` 只从 query 取)。 */ @@ -483,7 +488,8 @@ window.__ModuleLoader__.load({ const PASSWORD_CHANGED_LOGIN_URL = `${LOGIN_PATH}?next=%2F¬ice=password-changed`; /** 设置页「退出登录」按钮的提交目标(登出后回首页)。 */ const LOGOUT_URL = `${LOGOUT_PATH}?next=/`; - // src/client/features/change-password/lib/account-redirect.ts + //#endregion + //#region src/client/features/change-password/lib/account-redirect.ts /** * 改密成功后的去向(P1.1 / D24;D24.1 起改为**立即**跳转)。 * @@ -511,7 +517,8 @@ window.__ModuleLoader__.load({ function defaultReplace(url) { window.location.replace(url); } - // src/client/features/change-password/ui/account-fields.tsx + //#endregion + //#region src/client/features/change-password/ui/account-fields.tsx /** 字段/节点 id 前缀(一个面板只有一个实例,静态 id 即可)。 */ const PREFIX$1 = "dsh-auth-gate-account"; /** 状态区 id:aria-live 播报位,也是错误字段 aria-describedby 的落点(成功态复用)。 */ @@ -616,7 +623,8 @@ window.__ModuleLoader__.load({ })] }); } - // src/client/features/change-password/model/account-submit.ts + //#endregion + //#region src/client/features/change-password/model/account-submit.ts /** 自助改密端点(契约 §1:POST + application/x-www-form-urlencoded)。 */ const PASSWORD_TARGET = PASSWORD_PATH; const EMPTY_VALUES = { @@ -723,7 +731,8 @@ window.__ModuleLoader__.load({ failure: describeFailure(res.status, body, t) }; } - // src/client/features/change-password/ui/account-form.tsx + //#endregion + //#region src/client/features/change-password/ui/account-form.tsx /** * 本插件自设计的图标(P1.1 / D24):盾 + 钥匙孔,16px outline。 * @@ -909,7 +918,8 @@ window.__ModuleLoader__.load({ ] }); } - // src/client/features/admin-users/lib/admin-copy.ts + //#endregion + //#region src/client/features/admin-users/lib/admin-copy.ts /** 管理块词典键(集中常量,避免散落字符串)。 */ const ADMIN_KEYS = { title: "admin.title", @@ -1045,7 +1055,8 @@ window.__ModuleLoader__.load({ [ADMIN_KEYS.lockedPlain]: "Too many attempts. Try again later.", [ADMIN_KEYS.generic]: "Could not reset the password. Please try again later." }; - // src/client/features/admin-users/lib/admin-failure.ts + //#endregion + //#region src/client/features/admin-users/lib/admin-failure.ts /** * 管理重置的失败映射(CONTRACT-pr2 §1.3 + §9/A10)。从 `admin-api.ts` 预拆出来(A12), * 避免单文件贴上限。**全函数**:只认下表的 (status, error) 字面量组合, @@ -1104,7 +1115,8 @@ window.__ModuleLoader__.load({ if (status === 429) return describeLocked(error, record.retryAfter, t); return view(t(ADMIN_KEYS.generic)); } - // src/client/features/admin-users/api/admin-api.ts + //#endregion + //#region src/client/features/admin-users/api/admin-api.ts const USERS_TARGET = USERS_PATH; const RESET_TARGET = USERS_PASSWORD_PATH; /** 对象守卫:键按契约形状 `T` 读(读错键编译不过),值仍是未校验的 `unknown`。 */ @@ -1249,7 +1261,8 @@ window.__ModuleLoader__.load({ }; } } - // src/client/features/admin-users/ui/admin-fields.tsx + //#endregion + //#region src/client/features/admin-users/ui/admin-fields.tsx /** * 管理块的共享展示件(lead 批的 A12 预拆):id 常量 + 输入行 + 状态播报位。 * 只被 `admin-form.tsx` / `admin-block.tsx` **单向** import;本文件不 import 它们(避免循环)。 @@ -1323,7 +1336,8 @@ window.__ModuleLoader__.load({ children: success.text }) : null] }); } - // src/client/features/admin-users/model/admin-reset.ts + //#endregion + //#region src/client/features/admin-users/model/admin-reset.ts /** 表单初值:提交成功后必须清回这一份(契约 §3.8)。 */ const EMPTY_ADMIN_RESET = { target: "", @@ -1331,7 +1345,8 @@ window.__ModuleLoader__.load({ confirm: "", code: "" }; - // src/client/features/admin-users/ui/admin-form.tsx + //#endregion + //#region src/client/features/admin-users/ui/admin-form.tsx /** * 表单状态机(与自助面同范式): * - 双锁 = in-flight ref(硬锁,Enter/点击并发都挡)+ submitting 按钮 disabled(可见锁); @@ -1523,7 +1538,8 @@ window.__ModuleLoader__.load({ ] }); } - // src/client/widgets/account-section/ui/admin-block.tsx + //#endregion + //#region src/client/widgets/account-section/ui/admin-block.tsx const TITLE_ID = "dsh-auth-gate-admin-title"; /** * 挂载后拉一次列表(契约 §3.4:非 admin 根本不会挂载本组件,这里不判断角色); @@ -1676,7 +1692,8 @@ window.__ModuleLoader__.load({ ] }); } - // src/client/entities/account/api/account-status.ts + //#endregion + //#region src/client/entities/account/api/account-status.ts /** 会话探针(只认 cookie,语义同 `/auth/status`,契约 §1.1)。 */ const STATUS_TARGET = STATUS_PATH; /** 字符串字段守卫:非字符串一律 `undefined`(旧服务端/异常形状都不渲染管理块)。 */ @@ -1734,7 +1751,8 @@ window.__ModuleLoader__.load({ }, []); return status; } - // src/client/widgets/account-section/ui/account-section.tsx + //#endregion + //#region src/client/widgets/account-section/ui/account-section.tsx /** 提示态 id:loading / 未登录共用一个 aria-live 播报位。 */ const NOTICE_ID = "dsh-auth-gate-account-notice"; /** 无表单的提示态(loading / 请先登录),保留 aria-live 播报。 */ @@ -1778,7 +1796,8 @@ window.__ModuleLoader__.load({ actorTotpEnabled: status.totpEnabled !== false }) : null] }); } - // src/client/features/logout/ui/logout-action.tsx + //#endregion + //#region src/client/features/logout/ui/logout-action.tsx /** 登出目标:POST-only(M22:next 仅从 query 取,校验回落 /)。 */ const LOGOUT_TARGET = LOGOUT_URL; /** @@ -1886,7 +1905,8 @@ window.__ModuleLoader__.load({ }) }); } - // src/client/index.tsx + //#endregion + //#region src/client/index.tsx /** 本插件文案的词典命名域(locale 服务按 (ns, locale) 分开注册)。 */ const AUTH_NS = "auth"; /** 命名词典里登出键。 */ @@ -1975,6 +1995,7 @@ window.__ModuleLoader__.load({ }, SettingsAccountSection)); ctx.effect(() => installAccountNavIcon(globalThis.document), "auth: account nav icon shim (D24.1)"); } + //#endregion exports.apply = apply; exports.inject = inject; return module.exports; diff --git a/lib/client.js.map b/lib/client.js.map index 21bad79..a43ee5c 100644 --- a/lib/client.js.map +++ b/lib/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","names":["PREFIX","isError","useState","useRef","isRecord","useState","useRef","useState","useState","useState"],"sources":["../src/client/shared/i18n/account-copy.ts","../src/client/shared/ui/account-icon.ts","../src/client/shared/ui/account-styles.ts","../src/client/features/nav-icon/ui/account-nav-icon.ts","../src/client/shared/api/form.ts","../src/protocol/index.ts","../src/client/features/change-password/lib/account-redirect.ts","../src/client/features/change-password/ui/account-fields.tsx","../src/client/features/change-password/model/account-submit.ts","../src/client/features/change-password/ui/account-form.tsx","../src/client/features/admin-users/lib/admin-copy.ts","../src/client/features/admin-users/lib/admin-failure.ts","../src/client/features/admin-users/api/admin-api.ts","../src/client/features/admin-users/ui/admin-fields.tsx","../src/client/features/admin-users/model/admin-reset.ts","../src/client/features/admin-users/ui/admin-form.tsx","../src/client/widgets/account-section/ui/admin-block.tsx","../src/client/entities/account/api/account-status.ts","../src/client/widgets/account-section/ui/account-section.tsx","../src/client/features/logout/ui/logout-action.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * `auth` 词典的 account 分片:键名集中常量 + zh/en 两套文案。\n *\n * `index.tsx` 把两套词典注册进 `auth` 命名域(与 logout 同一 ns),\n * `account-section.tsx` 只通过槽位注入的 `t` 读取本文件的键,不自己拼字符串。\n * 策略文案里的 14/256 字符镜像契约 §2(`PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`):\n * client bundle 不 import 服务端 shared 模块,半边保持解耦。\n */\nimport type { PasswordRule } from \"../api/index.ts\";\n\n/** 槽位注入的标准 `t` seat(host locale translate 的形)。 */\nexport type AccountTranslate = (key: string, params?: Record) => string;\n\n/** 本面板用到的词典键(集中常量,避免散落字符串)。 */\nexport const ACCOUNT_KEYS = {\n nav: \"account\",\n title: \"account.title\",\n intro: \"account.intro\",\n /** 作用域说明(P1.1):0.1.7 起宿主有官方「账户」段,这里要讲清\"管的是本地登录凭据\"。 */\n scope: \"account.scope\",\n loading: \"account.loading\",\n loginRequired: \"account.loginRequired\",\n current: \"account.current\",\n password: \"account.password\",\n confirm: \"account.confirm\",\n code: \"account.code\",\n codeHint: \"account.codeHint\",\n policyHint: \"account.policyHint\",\n submit: \"account.submit\",\n submitting: \"account.submitting\",\n success: \"account.success\",\n /**\n * 成功态按钮(P1.1 / D24):不再是「关闭」:服务端此刻已吊销全部会话,关掉弹窗只会\n * 停在「死会话 SPA」上;改为直接去登录页。\n */\n relogin: \"account.relogin\",\n currentRequired: \"account.currentRequired\",\n passwordRequired: \"account.passwordRequired\",\n mismatch: \"account.mismatch\",\n invalidCredentials: \"account.invalidCredentials\",\n invalidTotp: \"account.invalidTotp\",\n policyIntro: \"account.policyIntro\",\n locked: \"account.locked\",\n lockedPlain: \"account.lockedPlain\",\n unavailable: \"account.unavailable\",\n generic: \"account.generic\",\n ruleMinLength: \"account.rule.minLength\",\n ruleMaxLength: \"account.rule.maxLength\",\n ruleUppercase: \"account.rule.uppercase\",\n ruleLowercase: \"account.rule.lowercase\",\n ruleDigit: \"account.rule.digit\",\n ruleSpecial: \"account.rule.special\",\n ruleSameAsOld: \"account.rule.sameAsOld\",\n} as const;\n\n/** 契约 §2 策略规则名 → 词典键(`satisfies` 保证与契约的 `PasswordRule` 一一对应)。 */\nconst RULE_KEYS: Record = {\n minLength: ACCOUNT_KEYS.ruleMinLength,\n maxLength: ACCOUNT_KEYS.ruleMaxLength,\n uppercase: ACCOUNT_KEYS.ruleUppercase,\n lowercase: ACCOUNT_KEYS.ruleLowercase,\n digit: ACCOUNT_KEYS.ruleDigit,\n special: ACCOUNT_KEYS.ruleSpecial,\n sameAsOld: ACCOUNT_KEYS.ruleSameAsOld,\n} satisfies Record;\n\n/** 规则名翻译;未知规则原样回显(不发明契约之外的文案)。 */\nexport function ruleText(rule: string, t: AccountTranslate): string {\n const key = RULE_KEYS[rule];\n return key === undefined ? rule : t(key);\n}\n\n/** host locale 的插值规则(`{name}`;缺参数时保留占位符不吞字)。 */\nexport function formatCopy(template: string, params?: Record): string {\n if (params === undefined) return template;\n return template.replace(/\\{(\\w+)\\}/g, (match, name: string) =>\n name in params ? String(params[name]) : match,\n );\n}\n\n/** 用一套词典兜底成 translate(槽位未注入 `t` 时降级为英文,不显示键名)。 */\nexport function translateFrom(dict: Record): AccountTranslate {\n return (key, params) => formatCopy(dict[key] ?? key, params);\n}\n\n/** 中文词典。 */\nexport const ACCOUNT_DICT_ZH: Record = {\n [ACCOUNT_KEYS.nav]: \"账号安全\",\n [ACCOUNT_KEYS.title]: \"修改密码\",\n [ACCOUNT_KEYS.intro]: \"改密成功后,当前设备也会被登出,需要用新密码重新登录。\",\n [ACCOUNT_KEYS.scope]: \"这里是本实例的本地登录凭据,与 DeepSeek 云账户无关。\",\n [ACCOUNT_KEYS.loading]: \"正在确认登录状态...\",\n [ACCOUNT_KEYS.loginRequired]: \"请先登录后再修改密码。\",\n [ACCOUNT_KEYS.current]: \"当前密码\",\n [ACCOUNT_KEYS.password]: \"新密码\",\n [ACCOUNT_KEYS.confirm]: \"确认新密码\",\n [ACCOUNT_KEYS.code]: \"动态验证码\",\n [ACCOUNT_KEYS.codeHint]: \"已开启两步验证时填写 6 位动态码,否则留空。\",\n [ACCOUNT_KEYS.policyHint]:\n \"新密码至少 14 个字符,且包含大写字母、小写字母、数字和特殊字符,不能与当前密码相同。\",\n [ACCOUNT_KEYS.submit]: \"修改密码\",\n [ACCOUNT_KEYS.submitting]: \"提交中...\",\n [ACCOUNT_KEYS.success]: \"密码已改,请重新登录。当前设备也已登出。\",\n [ACCOUNT_KEYS.relogin]: \"重新登录\",\n [ACCOUNT_KEYS.currentRequired]: \"请输入当前密码。\",\n [ACCOUNT_KEYS.passwordRequired]: \"请输入新密码。\",\n [ACCOUNT_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ACCOUNT_KEYS.invalidCredentials]: \"当前密码不正确。\",\n [ACCOUNT_KEYS.invalidTotp]: \"动态验证码不正确,或该验证码已被使用,请等待下一枚验证码后重试。\",\n [ACCOUNT_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ACCOUNT_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ACCOUNT_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ACCOUNT_KEYS.unavailable]: \"服务暂时不可用,请稍后重试。\",\n [ACCOUNT_KEYS.generic]: \"修改失败,请稍后重试。\",\n [ACCOUNT_KEYS.ruleMinLength]: \"至少 14 个字符\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"不超过 256 个字符\",\n [ACCOUNT_KEYS.ruleUppercase]: \"包含大写字母\",\n [ACCOUNT_KEYS.ruleLowercase]: \"包含小写字母\",\n [ACCOUNT_KEYS.ruleDigit]: \"包含数字\",\n [ACCOUNT_KEYS.ruleSpecial]: \"包含特殊字符(非字母数字)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"不能与当前密码相同\",\n};\n\n/** 英文词典。 */\nexport const ACCOUNT_DICT_EN: Record = {\n [ACCOUNT_KEYS.nav]: \"Account security\",\n [ACCOUNT_KEYS.title]: \"Change password\",\n [ACCOUNT_KEYS.intro]:\n \"After the change, this device is signed out too. Sign in again with your new password.\",\n [ACCOUNT_KEYS.scope]:\n \"These are this instance's local sign-in credentials, not your DeepSeek account.\",\n [ACCOUNT_KEYS.loading]: \"Checking your session...\",\n [ACCOUNT_KEYS.loginRequired]: \"Sign in first to change your password.\",\n [ACCOUNT_KEYS.current]: \"Current password\",\n [ACCOUNT_KEYS.password]: \"New password\",\n [ACCOUNT_KEYS.confirm]: \"Confirm new password\",\n [ACCOUNT_KEYS.code]: \"Verification code\",\n [ACCOUNT_KEYS.codeHint]:\n \"Enter the 6-digit code when two-factor authentication is on, otherwise leave it empty.\",\n [ACCOUNT_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character; it must differ from the current password.\",\n [ACCOUNT_KEYS.submit]: \"Change password\",\n [ACCOUNT_KEYS.submitting]: \"Submitting...\",\n [ACCOUNT_KEYS.success]:\n \"Password changed. Please sign in again. This device has been signed out too.\",\n [ACCOUNT_KEYS.relogin]: \"Sign in again\",\n [ACCOUNT_KEYS.currentRequired]: \"Enter your current password.\",\n [ACCOUNT_KEYS.passwordRequired]: \"Enter a new password.\",\n [ACCOUNT_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ACCOUNT_KEYS.invalidCredentials]: \"The current password is incorrect.\",\n [ACCOUNT_KEYS.invalidTotp]:\n \"The verification code is incorrect or already used; wait for the next code and try again.\",\n [ACCOUNT_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ACCOUNT_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ACCOUNT_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ACCOUNT_KEYS.unavailable]: \"The service is temporarily unavailable. Please try again later.\",\n [ACCOUNT_KEYS.generic]: \"Could not change the password. Please try again later.\",\n [ACCOUNT_KEYS.ruleMinLength]: \"at least 14 characters\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"at most 256 characters\",\n [ACCOUNT_KEYS.ruleUppercase]: \"an uppercase letter\",\n [ACCOUNT_KEYS.ruleLowercase]: \"a lowercase letter\",\n [ACCOUNT_KEYS.ruleDigit]: \"a digit\",\n [ACCOUNT_KEYS.ruleSpecial]: \"a special character (not a letter or digit)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"different from the current password\",\n};\n","/**\n * 自设计图标(P1.1 / D24):「盾 + 钥匙孔」,16px outline,规格对齐宿主图标\n * (`fill:none` / `stroke:currentColor` / `stroke-width:1.5` / 圆角线端)。\n *\n * 为什么自设计:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `dsh-client-ui-settings-general` 的 `navIcon(id)` 硬编码 if 链决定(0.1.5-rc.2 与\n * 0.1.7-alpha.2 都如此,且没有 `icon` 选项)。插件侧没有官方挂载点,也**不去改宿主的\n * nav DOM**(与宿主 React 抢树,评审已否掉),所以图标只画在我们自己的内容区标题行\n * (见 `account-form.tsx` 的 AccountIcon)。\n *\n * 路径数据与 `docs/demo/account-security.svg` 同源,由 `account-icon.test.ts` 逐条比对;\n * 改一处必须同步另一处。\n */\nexport const ACCOUNT_ICON_VIEW_BOX = \"0 0 16 16\";\n\n/** 图标路径(两个 `d`:盾牌外框 + 钥匙孔圆与柄)。 */\nexport const ACCOUNT_ICON_PATHS: readonly string[] = [\n \"M8 1.7 13.1 3.55V7.9c0 3.05-2.08 5.42-5.1 6.4-3.02-.98-5.1-3.35-5.1-6.4V3.55Z\",\n \"M9.3 7a1.3 1.3 0 1 1-2.6 0 1.3 1.3 0 1 1 2.6 0M8 8.3v2.1\",\n];\n","import type { CSSProperties } from \"react\";\n\n/**\n * account 面板样式:颜色只取宿主设计 token(`--dsw-*`),不硬编码色值,\n * 跟随主题/明暗切换。几何(间距/圆角/字号)照 logout-action.tsx 现状自绘。\n */\n\n/** 面板根部:纵向排布;`minHeight: 0` 让本页在设置内容列里参与收缩,列内可滚动。 */\nexport const PANEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n minHeight: 0,\n padding: \"4px 0 8px\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"var(--dsw-font-family)\",\n};\n\nexport const TITLE_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 16,\n fontWeight: 600,\n lineHeight: \"24px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\n/** 标题行(P1.1):自带图标 + 文案横排,图标随文字色(currentColor)跟随主题。 */\nexport const TITLE_ROW_STYLE: CSSProperties = {\n ...TITLE_STYLE,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n};\n\nexport const HINT_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const FORM_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n maxWidth: 420,\n};\n\nexport const LABEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 6,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-secondary)\",\n};\n\n/** 输入框基线。 */\nconst INPUT_BASE: CSSProperties = {\n boxSizing: \"border-box\",\n width: \"100%\",\n padding: \"8px 12px\",\n borderRadius: 8,\n border: \"1px solid var(--dsw-alias-border-l2)\",\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n lineHeight: \"22px\",\n};\n\nexport const INPUT_STYLE: CSSProperties = INPUT_BASE;\n\n/** 校验失败字段(契约 §1 的 401/400 分支)加错误描边。 */\nexport const INVALID_INPUT_STYLE: CSSProperties = {\n ...INPUT_BASE,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n};\n\nexport const STATUS_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 4,\n minHeight: 20,\n fontSize: 13,\n lineHeight: \"20px\",\n};\n\nexport const ERROR_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const SUCCESS_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-state-success-primary)\",\n};\n\nexport const RULES_STYLE: CSSProperties = {\n margin: 0,\n paddingLeft: 18,\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const BUTTON_STYLE: CSSProperties = {\n alignSelf: \"flex-start\",\n padding: \"8px 20px\",\n borderRadius: 10,\n border: \"1px solid var(--dsw-alias-button-primary-fill)\",\n background: \"var(--dsw-alias-button-primary-fill)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** 提交中(双锁的可见态):置灰但不移除,避免布局跳动。 */\nexport const BUTTON_BUSY_STYLE: CSSProperties = {\n ...BUTTON_STYLE,\n opacity: 0.6,\n cursor: \"default\",\n};\n\n// ---------------------------------------------------------------------------\n// P2 PR2 管理块(admin):以下全部为**追加**常量,上面既有值一个都不动\n// (自助改密页像素稳定性,契约 §2 account-styles 行)。\n// ---------------------------------------------------------------------------\n\n/** 管理块根部:与上方自助改密区之间用一条分隔线 + 上间距,纵向排布。 */\nexport const ADMIN_BLOCK_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n marginTop: 4,\n paddingTop: 12,\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 用户表:占满宽度、折叠边框;只做排版,不自绘视觉。 */\nexport const ADMIN_TABLE_STYLE: CSSProperties = {\n width: \"100%\",\n borderCollapse: \"collapse\",\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\nexport const ADMIN_TH_STYLE: CSSProperties = {\n textAlign: \"left\",\n padding: \"6px 8px 6px 0\",\n fontWeight: 500,\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const ADMIN_TD_STYLE: CSSProperties = {\n padding: \"6px 8px 6px 0\",\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 只读徽标(角色 / 状态 / 两步验证 / 本人):浅底 + 次级文字色,随主题切换。 */\nexport const ADMIN_BADGE_STYLE: CSSProperties = {\n display: \"inline-block\",\n padding: \"0 6px\",\n borderRadius: 6,\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-secondary)\",\n fontSize: 12,\n lineHeight: \"18px\",\n};\n","/**\n * 「账号安全」导航行图标:临时 DOM 垫片(D24.1)。\n *\n * **为什么需要它**:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `@deepseek-ai/dsh-client-ui-settings-general` 里硬编码的 `navIcon(id)` if 链给出\n * (0.1.5-rc.2 只认 models / agent-presets / plugins,0.1.7-alpha.2 多认官方 `account`),\n * 第三方段一律落到默认齿轮。插件侧没有图标挂载点,所以这里只对我们自己那一行做一次\n * 视觉替换:把宿主齿轮 `display:none`,在它前面插进我们自设计的盾牌 SVG。\n *\n * **不闪**:观察器在插件 `apply()` 时就装好,远早于设置浮层出现;宿主 React 提交 DOM 后,\n * MutationObserver 回调作为**微任务**在同一个任务里排队,而浏览器绘制发生在微任务清空之后。\n * 因此\"宿主图标进入 DOM / 被重写内联样式\"这件事总是在画出那一帧之前被修正为\"藏起来 + 我们的\n * 图标在场\",无论它发生在浮层整体插入的同一次提交、还是后来的某一次提交(后到的那次插入本身\n * 也会触发回调)。隔离实例里用 rAF 逐帧探针(每帧记录行内图标状态)验过:出现行的第一帧即已是\n * 盾牌,0 帧出现齿轮。\n *\n * **只碰我们这一行**:判定条件是「`\n \n );\n }\n const isInvalid = (field: FieldName): boolean => form.failure?.fields.includes(field) === true;\n return (\n
\n

\n \n {t(ACCOUNT_KEYS.title)}\n

\n

{t(ACCOUNT_KEYS.intro)}

\n

{t(ACCOUNT_KEYS.scope)}

\n {\n form.composing.current = true;\n }}\n onCompositionEnd={() => {\n form.composing.current = false;\n }}\n >\n \n \n \n {form.submitting ? t(ACCOUNT_KEYS.submitting) : t(ACCOUNT_KEYS.submit)}\n \n \n
\n );\n}\n","import { formatCopy, type AccountTranslate } from \"../../../shared/i18n/index.ts\";\n\n/**\n * 管理块(PR2)的 `auth` 词典分片:键名集中常量 + zh/en 两套文案。\n *\n * 与 account 分片同命名域(`index.tsx` 把两套词典合并注册),管理块只通过注入的 `t` 读键。\n * 键与文案对齐 CONTRACT-pr2 §4 全表 + §9/A4 的 2 个新增键(冻结键集 = 42);\n * 策略规则名复用 `account-copy.ts` 的 `ruleText`,本文件不新造规则文案。\n */\n\n/** 管理块 translate:与自助改密同形(宿主只注入一个 `t` seat)。 */\nexport type AdminTranslate = AccountTranslate;\n\n/** 管理块词典键(集中常量,避免散落字符串)。 */\nexport const ADMIN_KEYS = {\n title: \"admin.title\",\n intro: \"admin.intro\",\n selfHint: \"admin.selfHint\",\n loading: \"admin.loading\",\n unavailable: \"admin.unavailable\",\n empty: \"admin.empty\",\n colUser: \"admin.colUser\",\n colRole: \"admin.colRole\",\n colState: \"admin.colState\",\n colTotp: \"admin.colTotp\",\n roleAdmin: \"admin.roleAdmin\",\n roleUser: \"admin.roleUser\",\n you: \"admin.you\",\n stateDisabled: \"admin.stateDisabled\",\n stateMustChange: \"admin.stateMustChange\",\n stateOk: \"admin.stateOk\",\n totpOn: \"admin.totpOn\",\n totpOff: \"admin.totpOff\",\n target: \"admin.target\",\n targetPlaceholder: \"admin.targetPlaceholder\",\n password: \"admin.password\",\n confirm: \"admin.confirm\",\n code: \"admin.code\",\n codeHint: \"admin.codeHint\",\n policyHint: \"admin.policyHint\",\n submit: \"admin.submit\",\n submitting: \"admin.submitting\",\n successRevoked: \"admin.successRevoked\",\n successKept: \"admin.successKept\",\n targetRequired: \"admin.targetRequired\",\n passwordRequired: \"admin.passwordRequired\",\n mismatch: \"admin.mismatch\",\n codeRequired: \"admin.codeRequired\",\n forbidden: \"admin.forbidden\",\n badTarget: \"admin.badTarget\",\n notFound: \"admin.notFound\",\n policyIntro: \"admin.policyIntro\",\n invalidTotp: \"admin.invalidTotp\",\n unauthorized: \"admin.unauthorized\",\n locked: \"admin.locked\",\n lockedPlain: \"admin.lockedPlain\",\n generic: \"admin.generic\",\n} as const;\n\n/** 中文词典(键序与 §4 表一致,A4 的 2 键插在语义位置)。 */\nexport const ADMIN_DICT_ZH: Record = {\n [ADMIN_KEYS.title]: \"用户管理\",\n [ADMIN_KEYS.intro]: \"重置后,该用户下次登录会被要求立即改密;会话吊销若失败会单独提示。\",\n [ADMIN_KEYS.selfHint]:\n \"修改自己的密码请用上方表单。忘记自己的口令走 CLI `dsh-auth user passwd`(管理面不允许重置自己)。\",\n [ADMIN_KEYS.loading]: \"正在加载用户列表...\",\n [ADMIN_KEYS.unavailable]: \"用户列表暂时不可用,请稍后重试。\",\n [ADMIN_KEYS.empty]: \"没有可重置的其他用户。\",\n [ADMIN_KEYS.colUser]: \"用户\",\n [ADMIN_KEYS.colRole]: \"角色\",\n [ADMIN_KEYS.colState]: \"状态\",\n [ADMIN_KEYS.colTotp]: \"两步验证\",\n [ADMIN_KEYS.roleAdmin]: \"管理员\",\n [ADMIN_KEYS.roleUser]: \"用户\",\n [ADMIN_KEYS.you]: \"本人\",\n [ADMIN_KEYS.stateDisabled]: \"已禁用\",\n [ADMIN_KEYS.stateMustChange]: \"需改密\",\n [ADMIN_KEYS.stateOk]: \"正常\",\n [ADMIN_KEYS.totpOn]: \"已开启\",\n [ADMIN_KEYS.totpOff]: \"未开启\",\n [ADMIN_KEYS.target]: \"目标用户\",\n [ADMIN_KEYS.targetPlaceholder]: \"请选择用户\",\n [ADMIN_KEYS.password]: \"新密码\",\n [ADMIN_KEYS.confirm]: \"确认新密码\",\n [ADMIN_KEYS.code]: \"动态验证码\",\n [ADMIN_KEYS.codeHint]: \"你已开启两步验证,重置他人密码需填写动态码。\",\n [ADMIN_KEYS.policyHint]: \"新密码至少 14 个字符,且含大写字母、小写字母、数字和特殊字符。\",\n [ADMIN_KEYS.submit]: \"重置密码\",\n [ADMIN_KEYS.submitting]: \"提交中...\",\n [ADMIN_KEYS.successRevoked]: \"已重置,该用户的会话已全部吊销。\",\n [ADMIN_KEYS.successKept]: \"已重置,但该用户的现有会话仍然有效(吊销失败),请手动处理。\",\n [ADMIN_KEYS.targetRequired]: \"请选择目标用户。\",\n [ADMIN_KEYS.passwordRequired]: \"请输入新密码。\",\n [ADMIN_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ADMIN_KEYS.codeRequired]: \"请输入动态验证码。\",\n [ADMIN_KEYS.forbidden]: \"没有权限执行此操作,或当前会话不允许。\",\n [ADMIN_KEYS.badTarget]: \"目标用户名不合法。\",\n [ADMIN_KEYS.notFound]: \"目标用户不存在。\",\n [ADMIN_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ADMIN_KEYS.invalidTotp]: \"动态验证码不正确,或已被使用。\",\n [ADMIN_KEYS.unauthorized]: \"登录状态已失效,请重新登录。\",\n [ADMIN_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ADMIN_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ADMIN_KEYS.generic]: \"重置失败,请稍后重试。\",\n};\n\n/** 英文词典(键序与中文一致)。 */\nexport const ADMIN_DICT_EN: Record = {\n [ADMIN_KEYS.title]: \"User management\",\n [ADMIN_KEYS.intro]:\n \"After a reset the user must change the password at the next sign-in; if revocation fails, that is reported separately.\",\n [ADMIN_KEYS.selfHint]:\n \"Use the form above to change your own password. If you forgot it, use the CLI `dsh-auth user passwd` (this panel cannot reset your own account).\",\n [ADMIN_KEYS.loading]: \"Loading users...\",\n [ADMIN_KEYS.unavailable]: \"The user list is temporarily unavailable. Please try again later.\",\n [ADMIN_KEYS.empty]: \"There are no other users to reset.\",\n [ADMIN_KEYS.colUser]: \"User\",\n [ADMIN_KEYS.colRole]: \"Role\",\n [ADMIN_KEYS.colState]: \"State\",\n [ADMIN_KEYS.colTotp]: \"Two-factor\",\n [ADMIN_KEYS.roleAdmin]: \"Admin\",\n [ADMIN_KEYS.roleUser]: \"User\",\n [ADMIN_KEYS.you]: \"You\",\n [ADMIN_KEYS.stateDisabled]: \"Disabled\",\n [ADMIN_KEYS.stateMustChange]: \"Must change\",\n [ADMIN_KEYS.stateOk]: \"Normal\",\n [ADMIN_KEYS.totpOn]: \"On\",\n [ADMIN_KEYS.totpOff]: \"Off\",\n [ADMIN_KEYS.target]: \"Target user\",\n [ADMIN_KEYS.targetPlaceholder]: \"Choose a user\",\n [ADMIN_KEYS.password]: \"New password\",\n [ADMIN_KEYS.confirm]: \"Confirm new password\",\n [ADMIN_KEYS.code]: \"Verification code\",\n [ADMIN_KEYS.codeHint]:\n \"Two-factor is on for your account, so resetting another user needs a code.\",\n [ADMIN_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character.\",\n [ADMIN_KEYS.submit]: \"Reset password\",\n [ADMIN_KEYS.submitting]: \"Submitting...\",\n [ADMIN_KEYS.successRevoked]: \"Reset done. All of that user's sessions were revoked.\",\n [ADMIN_KEYS.successKept]:\n \"Reset done, but that user's existing sessions are still valid (revocation failed); handle them manually.\",\n [ADMIN_KEYS.targetRequired]: \"Choose a target user.\",\n [ADMIN_KEYS.passwordRequired]: \"Enter a new password.\",\n [ADMIN_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ADMIN_KEYS.codeRequired]: \"Enter the verification code.\",\n [ADMIN_KEYS.forbidden]: \"You are not allowed to do this, or this session is not allowed to.\",\n [ADMIN_KEYS.badTarget]: \"The target user name is invalid.\",\n [ADMIN_KEYS.notFound]: \"The target user does not exist.\",\n [ADMIN_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ADMIN_KEYS.invalidTotp]: \"The verification code is incorrect or already used.\",\n [ADMIN_KEYS.unauthorized]: \"Your session has expired. Please sign in again.\",\n [ADMIN_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ADMIN_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ADMIN_KEYS.generic]: \"Could not reset the password. Please try again later.\",\n};\n\n/**\n * 用一套词典兜底成 translate:缺键先回落英文词典,两边都缺时回空串,\n * **绝不把键名当文案显示**(§4 表注)。\n */\nexport function translateAdminFrom(dict: Record): AdminTranslate {\n return (key, params) => formatCopy(dict[key] ?? ADMIN_DICT_EN[key] ?? \"\", params);\n}\n","import type { AdminResetErrorBody, Unchecked } from \"../../../shared/api/index.ts\";\nimport { ruleText } from \"../../../shared/i18n/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"./admin-copy.ts\";\nimport type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\n\n/**\n * 管理重置的失败映射(CONTRACT-pr2 §1.3 + §9/A10)。从 `admin-api.ts` 预拆出来(A12),\n * 避免单文件贴上限。**全函数**:只认下表的 (status, error) 字面量组合,\n * 其余一律 `admin.generic`,不自造错误码、不做前缀/子串匹配。\n *\n * 已核对 PR1:`self`(自助误走管理面)也回 `{error:\"forbidden\"}`,没有 `{error:\"self\"}`。\n */\n\nfunction view(message: string, rules: string[] = [], fields: AdminField[] = []): AdminFailureView {\n return { message, rules, fields };\n}\n\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 错误码比较:码的字面量受契约(D31)约束,写错码编译不过;值本身仍是未校验的 `unknown`。 */\nfunction isError(value: unknown, code: AdminResetErrorBody[\"error\"]): boolean {\n return value === code;\n}\n\n/** 规则列表:只保留字符串项,未知规则名原样回显(复用 account 的 `ruleText`)。 */\nfunction ruleList(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((rule): rule is string => typeof rule === \"string\")\n : [];\n}\n\n/** 400:`bad_target` / `policy`(携带 rules);其余(含缺 error)→ generic。 */\nfunction describeBadRequest(error: unknown, rules: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"policy\")) {\n return view(\n t(ADMIN_KEYS.policyIntro),\n ruleList(rules).map((rule) => ruleText(rule, t)),\n [\"password\"],\n );\n }\n if (isError(error, \"bad_target\")) return view(t(ADMIN_KEYS.badTarget), [], [\"target\"]);\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 401:`invalid_totp` / `unauthorized`;其余 → generic。 */\nfunction describeUnauthorized(error: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"invalid_totp\")) return view(t(ADMIN_KEYS.invalidTotp), [], [\"code\"]);\n if (isError(error, \"unauthorized\")) return view(t(ADMIN_KEYS.unauthorized));\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 429:`locked` + 数字 retryAfter 插值,缺数字用无参文案;其余 → generic。 */\nfunction describeLocked(error: unknown, retryAfter: unknown, t: AdminTranslate): AdminFailureView {\n if (!isError(error, \"locked\")) return view(t(ADMIN_KEYS.generic));\n return typeof retryAfter === \"number\"\n ? view(t(ADMIN_KEYS.locked, { seconds: retryAfter }))\n : view(t(ADMIN_KEYS.lockedPlain));\n}\n\n/**\n * 状态码矩阵 → 文案键:\n * 400 bad_target / policy+rules、401 invalid_totp / unauthorized、403 forbidden、\n * 404 not_found、429 locked(+retryAfter)、其余(413/415/503 的 text/plain 与所有未列出组合,\n * 含缺 `error` 的 403/404/429)一律 generic。\n */\nexport function describeAdminFailure(\n status: number,\n body: unknown,\n t: AdminTranslate,\n): AdminFailureView {\n const record = isRecord(body) ? body : {};\n const error = record.error;\n if (status === 400) return describeBadRequest(error, record.rules, t);\n if (status === 401) return describeUnauthorized(error, t);\n if (status === 403 && isError(error, \"forbidden\")) return view(t(ADMIN_KEYS.forbidden));\n if (status === 404 && isError(error, \"not_found\")) {\n return view(t(ADMIN_KEYS.notFound), [], [\"target\"]);\n }\n if (status === 429) return describeLocked(error, record.retryAfter, t);\n return view(t(ADMIN_KEYS.generic));\n}\n","import {\n encodeForm,\n USERS_PASSWORD_PATH,\n USERS_PATH,\n type AdminResetErrorBody,\n type AdminResetRequest,\n type AdminResetResponse,\n type AdminUserRow,\n type Unchecked,\n type UsersResponse,\n} from \"../../../shared/api/index.ts\";\nimport { describeAdminFailure } from \"../lib/admin-failure.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport type {\n AdminFailureView,\n AdminResetResult,\n AdminResetValues,\n AdminUsersResult,\n} from \"../model/admin-reset.ts\";\n\n/**\n * 管理面客户端 API(CONTRACT-pr2 §1.2 / §1.3 + §9):列表读取 + 重置提交。\n *\n * 线上形状来自跨半边契约(D31,经 `shared/api`),表单与结果类型在 `model/admin-reset.ts`;失败映射在 `admin-failure.ts`(A12 预拆,此处按\n * 冻结面原样再导出)。不手写 `Origin`(同源 POST 由浏览器自动携带)、不做 `Accept` 子串匹配、\n * 不重排服务端已排好的顺序。\n */\n\nexport { describeAdminFailure };\n\nconst USERS_TARGET = USERS_PATH;\nconst RESET_TARGET = USERS_PASSWORD_PATH;\n\n/** 对象守卫:键按契约形状 `T` 读(读错键编译不过),值仍是未校验的 `unknown`。 */\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 单行形状校验:任一必需字段类型不符即丢弃该行(不整表失败,契约 §1.2)。 */\nfunction asRow(value: unknown): AdminUserRow | undefined {\n if (!isRecord(value)) return undefined;\n const { name, role, disabled, totpEnabled, mustChangePassword } = value;\n if (typeof name !== \"string\" || name === \"\") return undefined;\n if (role !== \"admin\" && role !== \"user\") return undefined;\n if (typeof disabled !== \"boolean\" || typeof totpEnabled !== \"boolean\") return undefined;\n if (typeof mustChangePassword !== \"boolean\") return undefined;\n return { name, role, disabled, totpEnabled, mustChangePassword };\n}\n\n/** 解析 200 body;`users` 不是数组视为形状非法(整表 failure)。 */\nfunction parseUsers(body: unknown): AdminUserRow[] | undefined {\n if (!isRecord(body)) return undefined;\n const raw = body.users;\n if (!Array.isArray(raw)) return undefined;\n // 保持服务端顺序:只过滤,不排序。\n return raw.map(asRow).filter((row): row is AdminUserRow => row !== undefined);\n}\n\n/** 重置端点的全部 JSON 形状(成功体 + 错误体),按契约读键。 */\ntype ResetBody = AdminResetResponse | AdminResetErrorBody;\n\n/** 读 JSON;非对象或解析失败统一回空体(映射只看状态码)。 */\nasync function readJsonSafe(res: Response): Promise> {\n try {\n const body: unknown = await res.json();\n return isRecord(body) ? body : {};\n } catch {\n return {};\n }\n}\n\n/**\n * `GET /auth/users`(cookie only,无 Origin 要求)。\n * 403 → `denied`(静默降级);401 → `unauthorized`(整页登录态已死,必须提示,§9/A2);\n * 非 200 / 形状非法 / 网络抛错 → `failure`;中止 → `aborted`(abort 后不解析响应体)。\n */\nexport async function fetchAdminUsers(signal: AbortSignal): Promise {\n try {\n const res = await fetch(USERS_TARGET, { method: \"GET\", signal, credentials: \"same-origin\" });\n if (signal.aborted) return { kind: \"aborted\" };\n if (res.status === 403) return { kind: \"denied\" };\n if (res.status === 401) return { kind: \"unauthorized\" };\n if (!res.ok) return { kind: \"failure\" };\n const body: unknown = await res.json();\n if (signal.aborted) return { kind: \"aborted\" };\n const users = parseUsers(body);\n return users === undefined ? { kind: \"failure\" } : { kind: \"ok\", users };\n } catch {\n return signal.aborted ? { kind: \"aborted\" } : { kind: \"failure\" };\n }\n}\n\n/**\n * 本地校验闭表(§9/A6):target → password → confirm → code。\n * `code` 只在 `actorTotpEnabled === true` 时校验,且用 `trim()` 判空(口令字段不 trim)。\n * 第三个参数缺省 false,只传两个实参的调用方行为不变。\n */\nexport function validateAdminReset(\n values: AdminResetValues,\n t: AdminTranslate,\n actorTotpEnabled = false,\n): AdminFailureView | null {\n if (values.target === \"\") {\n return { message: t(ADMIN_KEYS.targetRequired), rules: [], fields: [\"target\"] };\n }\n if (values.password === \"\") {\n return { message: t(ADMIN_KEYS.passwordRequired), rules: [], fields: [\"password\"] };\n }\n if (values.password !== values.confirm) {\n return { message: t(ADMIN_KEYS.mismatch), rules: [], fields: [\"password\", \"confirm\"] };\n }\n if (actorTotpEnabled && values.code.trim() === \"\") {\n return { message: t(ADMIN_KEYS.codeRequired), rules: [], fields: [\"code\"] };\n }\n return null;\n}\n\n/**\n * 提交请求体(§9/A3):只发 `target` / `password`,`actorTotpEnabled === true` 时才带 `code`\n * (缺码场景不发空串);**`confirm` 永不发送**。\n */\nfunction postAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n): Promise {\n const request: AdminResetRequest = {\n target: values.target,\n password: values.password,\n ...(options.actorTotpEnabled ? { code: values.code } : {}),\n };\n return fetch(RESET_TARGET, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: encodeForm(request),\n signal: options.signal,\n credentials: \"same-origin\",\n });\n}\n\n/**\n * `POST /auth/users/password`:\n * **成功只认契约 §1.3 的唯一形状** `200 {ok:true}`(grok 实现期必修 1):2xx 但缺 `ok:true`\n * (空 JSON、`{ok:false}`、中间层 HTML 拦截页)一律走失败映射,宁可报错也不谎报\"已重置\"\n * 再清空现场口令。`sessionsRevoked` 缺失/非布尔/解析失败按 `false`(§9/A8+A10);\n * 中止 → `aborted`。\n */\nexport async function submitAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n t: AdminTranslate,\n): Promise {\n try {\n const res = await postAdminReset(values, options);\n if (options.signal.aborted) return { kind: \"aborted\" };\n const body = await readJsonSafe(res);\n if (options.signal.aborted) return { kind: \"aborted\" };\n if (res.status === 200 && body.ok === true) {\n return { kind: \"ok\", sessionsRevoked: body.sessionsRevoked === true };\n }\n return { kind: \"failure\", failure: describeAdminFailure(res.status, body, t) };\n } catch {\n if (options.signal.aborted) return { kind: \"aborted\" };\n return {\n kind: \"failure\",\n failure: { message: t(ADMIN_KEYS.generic), rules: [], fields: [] },\n };\n }\n}\n","import type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\nimport {\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n RULES_STYLE,\n STATUS_STYLE,\n SUCCESS_TEXT_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 管理块的共享展示件(lead 批的 A12 预拆):id 常量 + 输入行 + 状态播报位。\n * 只被 `admin-form.tsx` / `admin-block.tsx` **单向** import;本文件不 import 它们(避免循环)。\n */\n\n/** id 前缀冻结(契约 §3.10):一个面板只有一个管理块实例,静态 id 即可。 */\nconst PREFIX = \"dsh-auth-gate-admin\";\n\n/** 状态播报位:aria-live 落点,也是错误字段 aria-describedby 的目标。 */\nexport const ADMIN_STATUS_ID = `${PREFIX}-status`;\nexport const ADMIN_CODE_HINT_ID = `${PREFIX}-code-hint`;\n\nexport function adminFieldId(field: AdminField): string {\n return `${PREFIX}-${field}`;\n}\n\n/** 成功视图(A8):`warning` = `sessionsRevoked:false`,是安全失败,走 alert + 错误色。 */\nexport interface AdminSuccessView {\n text: string;\n warning: boolean;\n}\n\n/** 带 label / 错误描边 / aria 关联的输入行(与自助面同范式,id 换管理块前缀)。 */\nexport function AdminTextField({\n field,\n label,\n value,\n invalid,\n onChange,\n hint,\n hintId,\n}: {\n field: AdminField;\n label: string;\n value: string;\n invalid: boolean;\n onChange: (value: string) => void;\n hint?: string | undefined;\n hintId?: string | undefined;\n}) {\n const described = [invalid ? ADMIN_STATUS_ID : undefined, hintId].filter(\n (item) => item !== undefined,\n );\n const id = adminFieldId(field);\n // 字段语义固定:新口令两框 `new-password`,动态码 `one-time-code` + 数字键盘。\n const isCode = field === \"code\";\n return (\n
\n \n onChange(event.target.value)}\n />\n {hint === undefined ? null : (\n \n {hint}\n \n )}\n
\n );\n}\n\n/** 状态区:失败消息 + 已翻译规则列表 + 就地成功/警告文案(契约 §3.8 / A8)。 */\nexport function AdminStatus({\n failure,\n success,\n}: {\n failure: AdminFailureView | null;\n success: AdminSuccessView | null;\n}) {\n const warning = success?.warning === true;\n return (\n <>\n
\n {failure === null ? null : {failure.message}}\n {failure === null || failure.rules.length === 0 ? null : (\n
    \n {failure.rules.map((rule) => (\n
  • {rule}
  • \n ))}\n
\n )}\n {success === null || warning ? null : (\n {success.text}\n )}\n
\n {warning && success !== null ? (\n

\n {success.text}\n

\n ) : null}\n \n );\n}\n","import type { AdminUserRow } from \"../../../shared/api/index.ts\";\n\n/**\n * 管理重置表单与结果的 client 侧类型(契约 §2)。只放类型与常量,不放逻辑。\n *\n * 线上形状(列表行、请求体、响应体、错误体)在跨半边契约 `src/protocol/`(D31),经\n * `shared/api` 进来;这里只剩表单状态和解析后的结果,它们不上线,host 不认识。\n */\n\n/** 管理重置表单字段(`confirm` 只在客户端比对,不进请求体,契约 §1.3)。 */\nexport type AdminField = \"target\" | \"password\" | \"confirm\" | \"code\";\n\nexport type AdminResetValues = Record;\n\n/** 表单初值:提交成功后必须清回这一份(契约 §3.8)。 */\nexport const EMPTY_ADMIN_RESET: AdminResetValues = {\n target: \"\",\n password: \"\",\n confirm: \"\",\n code: \"\",\n};\n\n/** 失败视图:已翻译消息 + 逐条策略文案 + 需标红的字段。 */\nexport interface AdminFailureView {\n message: string;\n rules: string[];\n fields: AdminField[];\n}\n\n/**\n * 列表结果(评审修正 A2):\n * - 403 `forbidden` → `denied`:这张 cookie 不是 admin / 不是 full 会话 → **静默**不渲染管理块;\n * - 401 `unauthorized` → `unauthorized`:整页登录态已死 → 必须提示 `admin.unauthorized`,\n * 不能静默消失(否则上方的自助改密表单会继续按过期 status 显示\"已登录\");\n * - 503 / 网络 / 解析失败 → `failure`(提示 `admin.unavailable`);\n * - 卸载中止 → `aborted`(回调里必须原样收手)。\n */\nexport type AdminUsersResult =\n | { kind: \"ok\"; users: AdminUserRow[] }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"aborted\" };\n\n/** 重置结果:`sessionsRevoked:false` 仍算成功(服务端写盘已成功,契约 §1.3)。 */\nexport type AdminResetResult =\n | { kind: \"ok\"; sessionsRevoked: boolean }\n | { kind: \"failure\"; failure: AdminFailureView }\n | { kind: \"aborted\" };\n","import {\n useEffect,\n useRef,\n useState,\n type FormEvent,\n type KeyboardEvent,\n type MutableRefObject,\n} from \"react\";\nimport { submitAdminReset, validateAdminReset } from \"../api/admin-api.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport {\n AdminStatus,\n AdminTextField,\n ADMIN_CODE_HINT_ID,\n ADMIN_STATUS_ID,\n adminFieldId,\n type AdminSuccessView,\n} from \"./admin-fields.tsx\";\nimport {\n EMPTY_ADMIN_RESET,\n type AdminFailureView,\n type AdminField,\n type AdminResetValues,\n} from \"../model/admin-reset.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n BUTTON_BUSY_STYLE,\n BUTTON_STYLE,\n FORM_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 表单状态机(与自助面同范式):\n * - 双锁 = in-flight ref(硬锁,Enter/点击并发都挡)+ submitting 按钮 disabled(可见锁);\n * - IME 组合期由 composing ref 挡住,避免中文输入法确认键提交;\n * - 请求带 AbortController,卸载即 abort,回调再看 mounted,绝不卸载后 setState;\n * - 成功:落成功文案(`sessionsRevoked:false` 走 A8 的安全失败样式),清空四字段,再重拉列表。\n */\n/** 表单事件:IME 组合期(Enter 确认键)一律不提交;并发双锁由 `submit` 自己兜底。 */\nfunction formHandlers(\n composing: MutableRefObject,\n submit: () => Promise,\n): {\n onSubmit: (event: FormEvent) => void;\n onKeyDown: (event: KeyboardEvent) => void;\n onCompositionStart: () => void;\n onCompositionEnd: () => void;\n} {\n return {\n onCompositionStart: () => {\n composing.current = true;\n },\n onCompositionEnd: () => {\n composing.current = false;\n },\n onSubmit: (event) => {\n event.preventDefault();\n if (composing.current) return;\n void submit();\n },\n onKeyDown: (event) => {\n if (event.key !== \"Enter\") return;\n // 焦点在下拉里时 Enter 是\"确认选项\":既不 preventDefault 也不提交(grok 回顾 #5)。\n if (event.target instanceof HTMLSelectElement) return;\n event.preventDefault();\n if (composing.current || event.nativeEvent.isComposing) return;\n void submit();\n },\n };\n}\n\n/** 成功视图(A8):`sessionsRevoked:false` 是安全失败,文案与样式都换一套。 */\nfunction successView(sessionsRevoked: boolean, t: AdminTranslate): AdminSuccessView {\n return sessionsRevoked\n ? { text: t(ADMIN_KEYS.successRevoked), warning: false }\n : { text: t(ADMIN_KEYS.successKept), warning: true };\n}\n\nfunction useAdminReset(\n t: AdminTranslate,\n actorTotpEnabled: boolean,\n onReset: (() => void) | undefined,\n) {\n const [values, setValues] = useState(EMPTY_ADMIN_RESET);\n const [submitting, setSubmitting] = useState(false);\n const [failure, setFailure] = useState(null);\n const [success, setSuccess] = useState(null);\n const lock = useRef(false);\n const composing = useRef(false);\n const abort = useRef(null);\n const mounted = useRef(true);\n\n useEffect(() => {\n // StrictMode 双挂载:effect 会「跑 → 清理 → 再跑」,所以挂载时必须**重新置 true**\n // (只写清理会把 mounted 永久钉在 false → 提交结果全丢、按钮卡在\"提交中\")。\n mounted.current = true;\n return () => {\n mounted.current = false;\n abort.current?.abort();\n };\n }, []);\n\n const setValue = (field: AdminField, value: string): void => {\n setValues((previous) => ({ ...previous, [field]: value }));\n };\n\n const submit = async (): Promise => {\n if (lock.current) return;\n const invalid = validateAdminReset(values, t, actorTotpEnabled);\n if (invalid !== null) {\n setFailure(invalid);\n return;\n }\n lock.current = true;\n setSubmitting(true);\n setFailure(null);\n setSuccess(null);\n const controller = new AbortController();\n abort.current = controller;\n const live = (): boolean => mounted.current && !controller.signal.aborted;\n try {\n const result = await submitAdminReset(\n values,\n { signal: controller.signal, actorTotpEnabled },\n t,\n );\n if (!live()) return;\n if (result.kind === \"ok\") {\n setSuccess(successView(result.sessionsRevoked, t));\n setValues({ ...EMPTY_ADMIN_RESET });\n onReset?.(); // A7:成功后重拉列表刷新徽标;失败路径不动行\n return;\n }\n if (result.kind === \"failure\") setFailure(result.failure);\n } catch {\n if (!live()) return;\n setFailure({ message: t(ADMIN_KEYS.generic), rules: [], fields: [] });\n } finally {\n lock.current = false;\n if (mounted.current) setSubmitting(false);\n }\n };\n\n const handlers = formHandlers(composing, submit);\n return { values, submitting, failure, success, setValue, ...handlers };\n}\n\nexport interface AdminResetFormProps {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n actorTotpEnabled: boolean;\n /** A7:成功回调(管理块用它重拉列表刷新徽标);失败路径不调用。 */\n onReset?: (() => void) | undefined;\n}\n\n/** 管理重置表单:目标下拉(排除本人)+ 新口令 ×2 + 条件式动态码;成功后清空四字段、不跳转。 */\nexport function AdminResetForm({\n t,\n users,\n actorName,\n actorTotpEnabled,\n onReset,\n}: AdminResetFormProps) {\n const form = useAdminReset(t, actorTotpEnabled, onReset);\n const targets = users.filter((user) => user.name !== actorName);\n const fields: { field: AdminField; label: string }[] = [\n { field: \"password\", label: t(ADMIN_KEYS.password) },\n { field: \"confirm\", label: t(ADMIN_KEYS.confirm) },\n ...(actorTotpEnabled ? [{ field: \"code\" as AdminField, label: t(ADMIN_KEYS.code) }] : []),\n ];\n const isInvalid = (field: AdminField): boolean => form.failure?.fields.includes(field) === true;\n const targetInvalid = isInvalid(\"target\");\n return (\n \n

{t(ADMIN_KEYS.selfHint)}

\n
\n \n form.setValue(\"target\", event.target.value)}\n >\n \n {targets.map((user) => (\n \n ))}\n \n
\n {fields.map((item) => (\n form.setValue(item.field, value)}\n />\n ))}\n

{t(ADMIN_KEYS.policyHint)}

\n \n \n {form.submitting ? t(ADMIN_KEYS.submitting) : t(ADMIN_KEYS.submit)}\n \n \n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n fetchAdminUsers,\n AdminResetForm,\n ADMIN_STATUS_ID,\n} from \"../../../features/admin-users/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../../../features/admin-users/index.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n ADMIN_BADGE_STYLE,\n ADMIN_BLOCK_STYLE,\n ADMIN_TABLE_STYLE,\n ADMIN_TD_STYLE,\n ADMIN_TH_STYLE,\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n TITLE_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\nconst TITLE_ID = \"dsh-auth-gate-admin-title\";\n\n/**\n * 列表状态(评审修正 A2):`denied`(403 非 admin / 受限)静默返回 null;\n * `unauthorized`(401 登录态已死)必须提示,不能静默消失;\n * `failure`(503/网络/解析)提示不可用。\n */\ntype UsersState =\n | { kind: \"loading\" }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"ok\"; users: AdminUserRow[] };\n\n/**\n * 挂载后拉一次列表(契约 §3.4:非 admin 根本不会挂载本组件,这里不判断角色);\n * `reload` 供 A7 成功后静默刷新徽标:刷新失败**不改行**(首拉才降级),同一 abort 规则。\n */\nfunction useAdminUsers(): { state: UsersState; reload: () => void } {\n const [state, setState] = useState({ kind: \"loading\" });\n const [attempt, setAttempt] = useState(0);\n useEffect(() => {\n const controller = new AbortController();\n let live = true;\n const first = attempt === 0;\n void fetchAdminUsers(controller.signal)\n .then((result) => {\n if (!live || result.kind === \"aborted\") return;\n if (result.kind === \"ok\") setState({ kind: \"ok\", users: result.users });\n else if (first) setState({ kind: result.kind });\n })\n .catch(() => {\n if (live && first) setState({ kind: \"failure\" });\n });\n return () => {\n live = false;\n controller.abort();\n };\n }, [attempt]);\n return { state, reload: () => setAttempt((value) => value + 1) };\n}\n\n/** 角色文案:`admin`/`user` 走词典;**未知取值原样渲染**,不新增键(A9)。 */\nfunction roleText(role: string, t: AdminTranslate): string {\n if (role === \"admin\") return t(ADMIN_KEYS.roleAdmin);\n if (role === \"user\") return t(ADMIN_KEYS.roleUser);\n return String(role);\n}\n\n/** 状态徽标**互斥**,优先级 disabled > mustChangePassword > ok(A9)。 */\nfunction stateText(user: AdminUserRow, t: AdminTranslate): string {\n if (user.disabled) return t(ADMIN_KEYS.stateDisabled);\n return user.mustChangePassword ? t(ADMIN_KEYS.stateMustChange) : t(ADMIN_KEYS.stateOk);\n}\n\n/** 只读用户表:**保持服务端顺序**,不做任何重排(契约 §1.2 / §3.5)。 */\nfunction AdminUsersTable({\n t,\n users,\n actorName,\n}: {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n}) {\n return (\n \n \n \n \n \n \n \n \n \n \n {users.map((user) => (\n \n \n \n \n \n \n ))}\n \n
{t(ADMIN_KEYS.colUser)}{t(ADMIN_KEYS.colRole)}{t(ADMIN_KEYS.colState)}{t(ADMIN_KEYS.colTotp)}
\n {user.name}\n {user.name === actorName ? (\n {t(ADMIN_KEYS.you)}\n ) : null}\n {roleText(user.role, t)}\n {stateText(user, t)}\n \n \n {user.totpEnabled ? t(ADMIN_KEYS.totpOn) : t(ADMIN_KEYS.totpOff)}\n \n
\n );\n}\n\n/** 提示态(loading / unauthorized / unavailable):同一条 aria-live 播报位。 */\nfunction AdminNotice({ text, error }: { text: string; error?: boolean }) {\n return (\n \n {text}\n

\n );\n}\n\nexport interface AdminUsersBlockProps {\n /** 槽位注入的翻译 seat(含 ADMIN 词典分片)。 */\n t: AdminTranslate;\n /** 当前登录用户名(status.name):用于「本人」标记与下拉排除。 */\n actorName: string;\n /** 当前账号是否启用两步验证:决定 `code` 输入框是否渲染(契约 §3.7)。 */\n actorTotpEnabled: boolean;\n}\n\n/**\n * 管理块(契约 §3 / A2):loading → denied 静默 null → unauthorized / failure 提示\n * → ok 列表 + 表单。「只有本人」时显示 `admin.empty` 且**不渲染表单**(§3.6)。\n */\nexport function AdminUsersBlock({ t, actorName, actorTotpEnabled }: AdminUsersBlockProps) {\n const { state, reload } = useAdminUsers();\n if (state.kind === \"loading\") return ;\n if (state.kind === \"denied\") return null;\n if (state.kind === \"unauthorized\") {\n return ;\n }\n if (state.kind === \"failure\") return ;\n const targets = state.users.filter((user) => user.name !== actorName);\n return (\n
\n

\n {t(ADMIN_KEYS.title)}\n

\n

{t(ADMIN_KEYS.intro)}

\n \n {targets.length === 0 ? (\n

\n {t(ADMIN_KEYS.empty)}\n

\n ) : (\n \n )}\n
\n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n STATUS_PATH,\n type SessionKind,\n type StatusResponse,\n type Unchecked,\n type UserRole,\n} from \"../../../shared/api/index.ts\";\n\n/**\n * `GET /auth/status` 的「关于我」视图:线上形状 `StatusResponse`(跨半边契约 D31)去掉本视图\n * 不用的 `logoutOrder`(由 client 入口单独读)与 `mustChangePassword`。\n *\n * **加法兼容**:身份字段是 PR1 才有的(旧服务端只回 `authenticated`/`logoutOrder`),\n * 任一身份字段缺失即视为「不渲染管理块」,其余行为一字不变(契约 §1.1 / §3.3)。\n */\nexport type AccountStatusView = Omit;\n\n/** 会话探针(只认 cookie,语义同 `/auth/status`,契约 §1.1)。 */\nconst STATUS_TARGET = STATUS_PATH;\n\n/** 字符串字段守卫:非字符串一律 `undefined`(旧服务端/异常形状都不渲染管理块)。 */\nfunction optionalString(value: unknown): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\n/** 布尔字段守卫。 */\nfunction optionalBoolean(value: unknown): boolean | undefined {\n return typeof value === \"boolean\" ? value : undefined;\n}\n\n/** role 白名单:只认 `admin` / `user`,其余(含非法字符串)留 `undefined`。 */\nfunction optionalRole(value: unknown): UserRole | undefined {\n return value === \"admin\" || value === \"user\" ? value : undefined;\n}\n\n/** sessionKind 白名单:只认 `full` / `password-change-only`。 */\nfunction optionalSessionKind(value: unknown): SessionKind | undefined {\n return value === \"full\" || value === \"password-change-only\" ? value : undefined;\n}\n\n/**\n * `/auth/status` 响应体 → 视图对象。**加法兼容**(契约 §1.1/§3.3):\n * 身份字段缺失、类型不符或取值不在白名单内,一律留 `undefined`;只有\n * `authenticated === true` 才可能让调用方继续渲染登录后的 UI。\n */\nfunction parseAccountStatus(body: unknown): AccountStatusView {\n const source = (\n typeof body === \"object\" && body !== null ? body : {}\n ) as Unchecked;\n return {\n authenticated: source.authenticated === true,\n name: optionalString(source.name),\n role: optionalRole(source.role),\n disabled: optionalBoolean(source.disabled),\n totpEnabled: optionalBoolean(source.totpEnabled),\n sessionKind: optionalSessionKind(source.sessionKind),\n };\n}\n\n/**\n * 会话状态探针:`null` = 首次响应返回前(调用方自己渲染 loading)。\n * `credentials: \"same-origin\"` 保证带 cookie;卸载即 abort,回调里再看一眼 signal,\n * 避免卸载后 setState。任何失败(网络/解析/中止之外的异常)→ `{authenticated:false}`。\n */\nexport function useAccountStatus(): AccountStatusView | null {\n const [status, setStatus] = useState(null);\n useEffect(() => {\n const controller = new AbortController();\n fetch(STATUS_TARGET, { signal: controller.signal, credentials: \"same-origin\" })\n .then((res) => res.json() as Promise)\n .then((body) => {\n if (!controller.signal.aborted) setStatus(parseAccountStatus(body));\n })\n .catch(() => {\n if (!controller.signal.aborted) setStatus({ authenticated: false });\n });\n return () => {\n controller.abort();\n };\n }, []);\n return status;\n}\n","import {\n ACCOUNT_DICT_EN,\n ACCOUNT_KEYS,\n translateFrom,\n type AccountTranslate,\n} from \"../../../shared/i18n/index.ts\";\nimport { AccountPasswordForm } from \"../../../features/change-password/index.ts\";\nimport { HINT_STYLE, PANEL_STYLE } from \"../../../shared/ui/index.ts\";\nimport { AdminUsersBlock } from \"./admin-block.tsx\";\nimport { ADMIN_DICT_EN } from \"../../../features/admin-users/index.ts\";\nimport type { AccountStatusView } from \"../../../entities/account/index.ts\";\nimport { useAccountStatus } from \"../../../entities/account/index.ts\";\n\n/** 提示态 id:loading / 未登录共用一个 aria-live 播报位。 */\nconst NOTICE_ID = \"dsh-auth-gate-account-notice\";\n\n/** 无表单的提示态(loading / 请先登录),保留 aria-live 播报。 */\nfunction Notice({ text }: { text: string }) {\n return (\n
\n

\n {text}\n

\n
\n );\n}\n\n/**\n * 管理块渲染门(契约 §9-A1,四条**同时**成立才渲染):`role === \"admin\"`、\n * `disabled === false`、`sessionKind === \"full\"`、`name` 是字符串。\n * **不做 `?? \"full\"` 兜底**:任一字段缺失或非该值 = 不渲染且零管理请求\n * (旧服务端/部分字段与「非 admin 不试拉」加法兼容;禁用 admin 不挂块)。\n */\nfunction showsAdminBlock(\n status: AccountStatusView,\n): status is AccountStatusView & { name: string } {\n return (\n status.role === \"admin\" &&\n status.disabled === false &&\n status.sessionKind === \"full\" &&\n typeof status.name === \"string\"\n );\n}\n\n/**\n * 槽位注入的 props:`t` 来自注册时的 `locale: \"auth\"` seat。\n * 宿主 owner props 还有 `close`;P1.1 起不再往下传(成功后去登录页,而不是关弹窗)。\n */\nexport interface SettingsAccountSectionProps {\n t?: AccountTranslate | undefined;\n}\n\n/**\n * 「账户」设置页(`settings.section`):未登录不给表单;已登录渲染自助改密表单,\n * 并在身份满足渲染门时(admin + 正式会话)追加管理块。`t` 缺失时降级到英文词典\n * (不显示键名),由 account-form / admin-block 承担各自的提交逻辑。\n */\nexport function SettingsAccountSection({ t }: SettingsAccountSectionProps) {\n const status = useAccountStatus();\n // `t` 缺失的降级词典必须同时含 account 与 admin 两片(否则管理块会显示键名,grok 回顾 #1)。\n const translate =\n typeof t === \"function\" ? t : translateFrom({ ...ACCOUNT_DICT_EN, ...ADMIN_DICT_EN });\n if (status === null) return ;\n if (status.authenticated !== true) return ;\n return (\n <>\n \n {showsAdminBlock(status) ? (\n \n ) : null}\n \n );\n}\n","import { useEffect, useState, type CSSProperties } from \"react\";\nimport { LOGOUT_URL, STATUS_PATH } from \"../../../shared/api/index.ts\";\n\n/** 登出目标:POST-only(M22:next 仅从 query 取,校验回落 /)。 */\nconst LOGOUT_TARGET = LOGOUT_URL;\n\n/**\n * 登出图标:16px 按钮图标(viewBox 24 不变,只设 width/height 16)。\n * 沿用原 32px 圆形按钮的同一个 SVG(方框 + 箭头)。\n */\nfunction renderLogoutIcon() {\n return (\n \n \n \n \n \n );\n}\n\n/**\n * 设置面板内醒目的登出 CTA:错误强调色(危险动作语义)填充按钮 +\n * 反色标签 `--dsw-alias-label-primary-inverted`,面板内水平居中(General 页底部)。\n */\nconst CTA_STYLE: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"10px 24px\",\n borderRadius: 12,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n background: \"var(--dsw-alias-state-error-primary)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** hover 态轻微提亮(随主题自适应,不硬编码色值)。 */\nconst CTA_HOVER_FILTER = \"brightness(1.08)\";\n\n/** 面板内水平居中容器(General 页最后一条行之后)。 */\nconst CTA_WRAP_STYLE: CSSProperties = {\n display: \"flex\",\n justifyContent: \"center\",\n padding: \"20px 0 4px\",\n};\n\nconst formStyle: CSSProperties = {\n display: \"contents\",\n};\n\n/**\n * 会话状态门控:挂载时 fetch /auth/status 一次(只认 cookie)。\n * @returns authenticated:null = 未知(第一次请求前),true/false。\n */\nfunction useAuthenticated(): boolean | null {\n const [authenticated, setAuthenticated] = useState(null);\n useEffect(() => {\n let cancelled = false;\n fetch(STATUS_PATH)\n .then((res) => res.json())\n .then((body: { authenticated?: unknown }) => {\n if (!cancelled) setAuthenticated(body.authenticated === true);\n })\n .catch(() => {\n if (!cancelled) setAuthenticated(false);\n });\n return () => {\n cancelled = true;\n };\n }, []);\n return authenticated;\n}\n\n/** 槽位渲染器按注册 `locale` 注入的 translate 形(本插件 `auth` 词典的 `logout` 键)。 */\nexport type LogoutTranslate = (key: string, params?: Record) => string;\n\n/** 设置面板里的登出按钮组件(`settings.general.item` 槽,root 作用域)。 */\nexport interface SettingsLogoutActionProps {\n /** 注入的本地化 translate(locale seat)。 */\n t?: LogoutTranslate;\n}\n\n/**\n * 可复用的登出提交按钮:原生 form POST(零 JS 依赖)+ 16px 方块图标 + 本地化文字。\n * 渲染进 `settings.general.item`(设置 → 通用设置 的追加行槽,order 30 → 页面底部),\n * 水平居中的醒目 CTA;文案随界面语言在「退出登录」/ \"Sign out\" 间切换。\n */\nexport function SettingsLogoutAction({ t }: SettingsLogoutActionProps) {\n const authenticated = useAuthenticated();\n const [hovered, setHovered] = useState(false);\n if (authenticated !== true) return null;\n const label = typeof t === \"function\" ? t(\"logout\") : \"Sign out\";\n return (\n
\n
\n setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n >\n {renderLogoutIcon()}\n {label}\n \n
\n
\n );\n}\n","import { ACCOUNT_DICT_EN, ACCOUNT_DICT_ZH, ACCOUNT_KEYS } from \"./shared/i18n/index.ts\";\nimport { installAccountNavIcon } from \"./features/nav-icon/index.ts\";\nimport { SettingsAccountSection } from \"./widgets/account-section/index.ts\";\nimport { ADMIN_DICT_EN, ADMIN_DICT_ZH } from \"./features/admin-users/index.ts\";\nimport {\n STATUS_PATH,\n type AuthContext,\n type StatusResponse,\n type Unchecked,\n} from \"./shared/api/index.ts\";\nimport { SettingsLogoutAction } from \"./features/logout/index.ts\";\n\n/** 本插件文案的词典命名域(locale 服务按 (ns, locale) 分开注册)。 */\nconst AUTH_NS = \"auth\";\n/** 命名词典里登出键。 */\nconst LOGOUT_KEY = \"logout\";\n\n/** 「账号安全」设置页(自助改密)的 section id。 */\nconst ACCOUNT_SECTION_ID = \"dsh-auth-gate-account\";\n/**\n * 「账号安全」页在设置导航里的排列位置。\n *\n * 已知占用:宿主 general 0 / models 10 / plugins 15 / agent-presets 20(0.1.7 起还有官方云账户\n * `account` -10),姊妹包 dsh-plugin-subscriptions 的订阅页 90,全部远小于 900,所以 900\n * 表示「大于已知的全部条目、落在导航靠后」(账号安全压轴是设置页惯例,宿主 nav 列也没有滚动条)。\n *\n * **不保证全局最后**:宿主只按 order 升序排、没有并列决胜(同值按插件注册顺序,跨安装不稳定),\n * 后来者随时可能注册更大或相同的值。谁先到谁先用 900,撞车时按 901/902 递增(ADR D24 已记录)。\n */\nconst ACCOUNT_SECTION_ORDER = 900;\n\n/**\n * 默认槽位 order:注册时先用它(与 host 端 Config 默认一致),随后 `/auth/status`\n * 探针读到 host 配置的 `logoutOrder` 时按配置重注册。1000 已大于 dsh 自带条目\n * (agent-preset -25 / permission -20 / language 0 / appearance 10 / composer-enter 20),\n * 除非第三方插件注册更大的 order,按钮始终留在通用设置页最底部。\n */\nconst DEFAULT_LOGOUT_ORDER = 1000;\n\n/**\n * dsh-auth-gate client 半边:认证后在**设置面板**(设置 → 通用设置 页底部)挂一\n * 个醒目的「退出登录 / Sign out」按钮:`settings.general.item`(root 作用域、\n * 可追加列表槽,由 ui-settings-general 的 General 页堆叠渲染,按 order 升序)。\n *\n * 顺序可配置:先以默认 order(1000)注册(探针失败/未开始前按钮也可见),再探\n * `/auth/status` 读取 host 配置的 `logoutOrder`,与默认不同则按配置值重注册\n * (同 id 注册 = 槽位替换,先注册新条目再释放旧条目,避免中间态空白)。\n *\n * 换槽对比:不再往会话页 `conversation.session.header.utilities`、新会话页\n * `shell.overlay` 注册(右上角两处入口已移除),也不占侧边栏 footer 脚区。\n * 文案挂进 dsh 现有的 locale 机制(与「设置」里的语言切换同一套):注册 `auth`\n * 词典(zh/en),再以 `locale: \"auth\"` 给注册条目注入 `t` seat,按钮文字随界面\n * 语言在「退出登录」/ \"Sign out\" 间实时切换。不改任何服务端端点/会话语义。\n *\n * 同一次 apply 还注册「账号安全」设置页(`settings.section`,id\n * dsh-auth-gate-account,order 900):内容为自助改密表单(见 account-section.tsx),\n * 文案复用同一个 `auth` 词典的 account 键。登出按钮的注册逻辑与 order 语义不变。\n *\n * 导航行图标(D24.1):宿主 `settings.section` 没有 `icon` 选项(只有 id/order/label),\n * 第三方段的图标恒为宿主默认齿轮;插件侧没有官方挂载点,所以 `account-nav-icon.ts` 只对\n * 我们这一行做一次临时 DOM 垫片(自设计盾牌图标),上游支持 `icon` 后整体删除。\n */\nexport const inject = [\"slots\", \"locale\"];\n\nexport function apply(ctx: AuthContext): void {\n // 词典注册(zh/en 双语,挂 fiber 卸载级联):登出 CTA + 账户页 + 管理块共用 auth\n // 命名域。admin 键(`admin.*`)与 account 键(`account.*`)、`logout` 不重名,可安全合并。\n ctx.effect(\n () => [\n ctx.locale.register(AUTH_NS, \"zh\", {\n [LOGOUT_KEY]: \"退出登录\",\n ...ACCOUNT_DICT_ZH,\n ...ADMIN_DICT_ZH,\n }),\n ctx.locale.register(AUTH_NS, \"en\", {\n [LOGOUT_KEY]: \"Sign out\",\n ...ACCOUNT_DICT_EN,\n ...ADMIN_DICT_EN,\n }),\n ],\n \"auth: zh/en dictionaries (logout + account + admin)\",\n );\n\n // 绑定 translate:读取活动语言(thunk 每次投影重读,跟随语言切换)。\n const t = ctx.locale.bind(AUTH_NS);\n\n ctx.slots.inject(\"settings.general.item\", () => {\n const mount = (order: number): (() => void) =>\n ctx.slots.register(\n {\n name: \"settings.general.item\",\n id: \"dsh-auth-gate-logout\",\n locale: AUTH_NS,\n order,\n label: () => t(LOGOUT_KEY),\n },\n SettingsLogoutAction,\n );\n let dispose: (() => void) | undefined = mount(DEFAULT_LOGOUT_ORDER);\n // 与 SettingsLogoutAction 相同的 status 探针:读 host 的 logoutOrder 配置并在\n // 不同时重注册。探针抛错(如测试环境无 fetch)保持默认,绝不吞掉注册。\n try {\n void fetch(STATUS_PATH)\n .then((res) => res.json() as Promise>)\n .then((body: Unchecked) => {\n const order = body.logoutOrder;\n if (\n typeof order !== \"number\" ||\n !Number.isInteger(order) ||\n order === DEFAULT_LOGOUT_ORDER\n ) {\n return;\n }\n const previous = dispose;\n dispose = mount(order);\n previous?.();\n })\n .catch(() => undefined);\n } catch {\n // fetch 不可用(测试环境/旧浏览器):保持默认 order。\n }\n return () => dispose?.();\n });\n\n // 「账号安全」设置页:注册 `settings.section`(root 作用域、可追加列表槽,导航按\n // order 升序)。内容是自助改密表单,未登录由组件自查 `/auth/status` 后隐藏。\n ctx.slots.inject(\"settings.section\", () =>\n ctx.slots.register(\n {\n name: \"settings.section\",\n id: ACCOUNT_SECTION_ID,\n locale: AUTH_NS,\n order: ACCOUNT_SECTION_ORDER,\n label: () => t(ACCOUNT_KEYS.nav),\n },\n SettingsAccountSection,\n ),\n );\n\n // 导航行图标垫片(D24.1):只替换我们自己那一行,找不到就保持宿主齿轮;没有 DOM 的环境\n // (node 单测)直接 no-op。上游给 `settings.section` 加 `icon` 后,这一行与\n // account-nav-icon.ts 一起删除。\n ctx.effect(\n () => installAccountNavIcon(globalThis.document),\n \"auth: account nav icon shim (D24.1)\",\n );\n}\n"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"client.js","names":["PREFIX","isError","useState","useRef","isRecord","useState","useRef","useState","useState","useState"],"sources":["../src/client/shared/i18n/account-copy.ts","../src/client/shared/ui/account-icon.ts","../src/client/shared/ui/account-styles.ts","../src/client/features/nav-icon/ui/account-nav-icon.ts","../src/client/shared/api/form.ts","../src/protocol/index.ts","../src/client/features/change-password/lib/account-redirect.ts","../src/client/features/change-password/ui/account-fields.tsx","../src/client/features/change-password/model/account-submit.ts","../src/client/features/change-password/ui/account-form.tsx","../src/client/features/admin-users/lib/admin-copy.ts","../src/client/features/admin-users/lib/admin-failure.ts","../src/client/features/admin-users/api/admin-api.ts","../src/client/features/admin-users/ui/admin-fields.tsx","../src/client/features/admin-users/model/admin-reset.ts","../src/client/features/admin-users/ui/admin-form.tsx","../src/client/widgets/account-section/ui/admin-block.tsx","../src/client/entities/account/api/account-status.ts","../src/client/widgets/account-section/ui/account-section.tsx","../src/client/features/logout/ui/logout-action.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * `auth` 词典的 account 分片:键名集中常量 + zh/en 两套文案。\n *\n * `index.tsx` 把两套词典注册进 `auth` 命名域(与 logout 同一 ns),\n * `account-section.tsx` 只通过槽位注入的 `t` 读取本文件的键,不自己拼字符串。\n * 策略文案里的 14/256 字符镜像契约 §2(`PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`):\n * client bundle 不 import 服务端 shared 模块,半边保持解耦。\n */\nimport type { PasswordRule } from \"../api/index.ts\";\n\n/** 槽位注入的标准 `t` seat(host locale translate 的形)。 */\nexport type AccountTranslate = (key: string, params?: Record) => string;\n\n/** 本面板用到的词典键(集中常量,避免散落字符串)。 */\nexport const ACCOUNT_KEYS = {\n nav: \"account\",\n title: \"account.title\",\n intro: \"account.intro\",\n /** 作用域说明(P1.1):0.1.7 起宿主有官方「账户」段,这里要讲清\"管的是本地登录凭据\"。 */\n scope: \"account.scope\",\n loading: \"account.loading\",\n loginRequired: \"account.loginRequired\",\n current: \"account.current\",\n password: \"account.password\",\n confirm: \"account.confirm\",\n code: \"account.code\",\n codeHint: \"account.codeHint\",\n policyHint: \"account.policyHint\",\n submit: \"account.submit\",\n submitting: \"account.submitting\",\n success: \"account.success\",\n /**\n * 成功态按钮(P1.1 / D24):不再是「关闭」:服务端此刻已吊销全部会话,关掉弹窗只会\n * 停在「死会话 SPA」上;改为直接去登录页。\n */\n relogin: \"account.relogin\",\n currentRequired: \"account.currentRequired\",\n passwordRequired: \"account.passwordRequired\",\n mismatch: \"account.mismatch\",\n invalidCredentials: \"account.invalidCredentials\",\n invalidTotp: \"account.invalidTotp\",\n policyIntro: \"account.policyIntro\",\n locked: \"account.locked\",\n lockedPlain: \"account.lockedPlain\",\n unavailable: \"account.unavailable\",\n generic: \"account.generic\",\n ruleMinLength: \"account.rule.minLength\",\n ruleMaxLength: \"account.rule.maxLength\",\n ruleUppercase: \"account.rule.uppercase\",\n ruleLowercase: \"account.rule.lowercase\",\n ruleDigit: \"account.rule.digit\",\n ruleSpecial: \"account.rule.special\",\n ruleSameAsOld: \"account.rule.sameAsOld\",\n} as const;\n\n/** 契约 §2 策略规则名 → 词典键(`satisfies` 保证与契约的 `PasswordRule` 一一对应)。 */\nconst RULE_KEYS: Record = {\n minLength: ACCOUNT_KEYS.ruleMinLength,\n maxLength: ACCOUNT_KEYS.ruleMaxLength,\n uppercase: ACCOUNT_KEYS.ruleUppercase,\n lowercase: ACCOUNT_KEYS.ruleLowercase,\n digit: ACCOUNT_KEYS.ruleDigit,\n special: ACCOUNT_KEYS.ruleSpecial,\n sameAsOld: ACCOUNT_KEYS.ruleSameAsOld,\n} satisfies Record;\n\n/** 规则名翻译;未知规则原样回显(不发明契约之外的文案)。 */\nexport function ruleText(rule: string, t: AccountTranslate): string {\n const key = RULE_KEYS[rule];\n return key === undefined ? rule : t(key);\n}\n\n/** host locale 的插值规则(`{name}`;缺参数时保留占位符不吞字)。 */\nexport function formatCopy(template: string, params?: Record): string {\n if (params === undefined) return template;\n return template.replace(/\\{(\\w+)\\}/g, (match, name: string) =>\n name in params ? String(params[name]) : match,\n );\n}\n\n/** 用一套词典兜底成 translate(槽位未注入 `t` 时降级为英文,不显示键名)。 */\nexport function translateFrom(dict: Record): AccountTranslate {\n return (key, params) => formatCopy(dict[key] ?? key, params);\n}\n\n/** 中文词典。 */\nexport const ACCOUNT_DICT_ZH: Record = {\n [ACCOUNT_KEYS.nav]: \"账号安全\",\n [ACCOUNT_KEYS.title]: \"修改密码\",\n [ACCOUNT_KEYS.intro]: \"改密成功后,当前设备也会被登出,需要用新密码重新登录。\",\n [ACCOUNT_KEYS.scope]: \"这里是本实例的本地登录凭据,与 DeepSeek 云账户无关。\",\n [ACCOUNT_KEYS.loading]: \"正在确认登录状态...\",\n [ACCOUNT_KEYS.loginRequired]: \"请先登录后再修改密码。\",\n [ACCOUNT_KEYS.current]: \"当前密码\",\n [ACCOUNT_KEYS.password]: \"新密码\",\n [ACCOUNT_KEYS.confirm]: \"确认新密码\",\n [ACCOUNT_KEYS.code]: \"动态验证码\",\n [ACCOUNT_KEYS.codeHint]: \"已开启两步验证时填写 6 位动态码,否则留空。\",\n [ACCOUNT_KEYS.policyHint]:\n \"新密码至少 14 个字符,且包含大写字母、小写字母、数字和特殊字符,不能与当前密码相同。\",\n [ACCOUNT_KEYS.submit]: \"修改密码\",\n [ACCOUNT_KEYS.submitting]: \"提交中...\",\n [ACCOUNT_KEYS.success]: \"密码已改,请重新登录。当前设备也已登出。\",\n [ACCOUNT_KEYS.relogin]: \"重新登录\",\n [ACCOUNT_KEYS.currentRequired]: \"请输入当前密码。\",\n [ACCOUNT_KEYS.passwordRequired]: \"请输入新密码。\",\n [ACCOUNT_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ACCOUNT_KEYS.invalidCredentials]: \"当前密码不正确。\",\n [ACCOUNT_KEYS.invalidTotp]: \"动态验证码不正确,或该验证码已被使用,请等待下一枚验证码后重试。\",\n [ACCOUNT_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ACCOUNT_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ACCOUNT_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ACCOUNT_KEYS.unavailable]: \"服务暂时不可用,请稍后重试。\",\n [ACCOUNT_KEYS.generic]: \"修改失败,请稍后重试。\",\n [ACCOUNT_KEYS.ruleMinLength]: \"至少 14 个字符\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"不超过 256 个字符\",\n [ACCOUNT_KEYS.ruleUppercase]: \"包含大写字母\",\n [ACCOUNT_KEYS.ruleLowercase]: \"包含小写字母\",\n [ACCOUNT_KEYS.ruleDigit]: \"包含数字\",\n [ACCOUNT_KEYS.ruleSpecial]: \"包含特殊字符(非字母数字)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"不能与当前密码相同\",\n};\n\n/** 英文词典。 */\nexport const ACCOUNT_DICT_EN: Record = {\n [ACCOUNT_KEYS.nav]: \"Account security\",\n [ACCOUNT_KEYS.title]: \"Change password\",\n [ACCOUNT_KEYS.intro]:\n \"After the change, this device is signed out too. Sign in again with your new password.\",\n [ACCOUNT_KEYS.scope]:\n \"These are this instance's local sign-in credentials, not your DeepSeek account.\",\n [ACCOUNT_KEYS.loading]: \"Checking your session...\",\n [ACCOUNT_KEYS.loginRequired]: \"Sign in first to change your password.\",\n [ACCOUNT_KEYS.current]: \"Current password\",\n [ACCOUNT_KEYS.password]: \"New password\",\n [ACCOUNT_KEYS.confirm]: \"Confirm new password\",\n [ACCOUNT_KEYS.code]: \"Verification code\",\n [ACCOUNT_KEYS.codeHint]:\n \"Enter the 6-digit code when two-factor authentication is on, otherwise leave it empty.\",\n [ACCOUNT_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character; it must differ from the current password.\",\n [ACCOUNT_KEYS.submit]: \"Change password\",\n [ACCOUNT_KEYS.submitting]: \"Submitting...\",\n [ACCOUNT_KEYS.success]:\n \"Password changed. Please sign in again. This device has been signed out too.\",\n [ACCOUNT_KEYS.relogin]: \"Sign in again\",\n [ACCOUNT_KEYS.currentRequired]: \"Enter your current password.\",\n [ACCOUNT_KEYS.passwordRequired]: \"Enter a new password.\",\n [ACCOUNT_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ACCOUNT_KEYS.invalidCredentials]: \"The current password is incorrect.\",\n [ACCOUNT_KEYS.invalidTotp]:\n \"The verification code is incorrect or already used; wait for the next code and try again.\",\n [ACCOUNT_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ACCOUNT_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ACCOUNT_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ACCOUNT_KEYS.unavailable]: \"The service is temporarily unavailable. Please try again later.\",\n [ACCOUNT_KEYS.generic]: \"Could not change the password. Please try again later.\",\n [ACCOUNT_KEYS.ruleMinLength]: \"at least 14 characters\",\n [ACCOUNT_KEYS.ruleMaxLength]: \"at most 256 characters\",\n [ACCOUNT_KEYS.ruleUppercase]: \"an uppercase letter\",\n [ACCOUNT_KEYS.ruleLowercase]: \"a lowercase letter\",\n [ACCOUNT_KEYS.ruleDigit]: \"a digit\",\n [ACCOUNT_KEYS.ruleSpecial]: \"a special character (not a letter or digit)\",\n [ACCOUNT_KEYS.ruleSameAsOld]: \"different from the current password\",\n};\n","/**\n * 自设计图标(P1.1 / D24):「盾 + 钥匙孔」,16px outline,规格对齐宿主图标\n * (`fill:none` / `stroke:currentColor` / `stroke-width:1.5` / 圆角线端)。\n *\n * 为什么自设计:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `dsh-client-ui-settings-general` 的 `navIcon(id)` 硬编码 if 链决定(0.1.5-rc.2 与\n * 0.1.7-alpha.2 都如此,且没有 `icon` 选项)。插件侧没有官方挂载点,也**不去改宿主的\n * nav DOM**(与宿主 React 抢树,评审已否掉),所以图标只画在我们自己的内容区标题行\n * (见 `account-form.tsx` 的 AccountIcon)。\n *\n * 路径数据与 `docs/demo/account-security.svg` 同源,由 `account-icon.test.ts` 逐条比对;\n * 改一处必须同步另一处。\n */\nexport const ACCOUNT_ICON_VIEW_BOX = \"0 0 16 16\";\n\n/** 图标路径(两个 `d`:盾牌外框 + 钥匙孔圆与柄)。 */\nexport const ACCOUNT_ICON_PATHS: readonly string[] = [\n \"M8 1.7 13.1 3.55V7.9c0 3.05-2.08 5.42-5.1 6.4-3.02-.98-5.1-3.35-5.1-6.4V3.55Z\",\n \"M9.3 7a1.3 1.3 0 1 1-2.6 0 1.3 1.3 0 1 1 2.6 0M8 8.3v2.1\",\n];\n","import type { CSSProperties } from \"react\";\n\n/**\n * account 面板样式:颜色只取宿主设计 token(`--dsw-*`),不硬编码色值,\n * 跟随主题/明暗切换。几何(间距/圆角/字号)照 logout-action.tsx 现状自绘。\n */\n\n/** 面板根部:纵向排布;`minHeight: 0` 让本页在设置内容列里参与收缩,列内可滚动。 */\nexport const PANEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n minHeight: 0,\n padding: \"4px 0 8px\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"var(--dsw-font-family)\",\n};\n\nexport const TITLE_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 16,\n fontWeight: 600,\n lineHeight: \"24px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\n/** 标题行(P1.1):自带图标 + 文案横排,图标随文字色(currentColor)跟随主题。 */\nexport const TITLE_ROW_STYLE: CSSProperties = {\n ...TITLE_STYLE,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n};\n\nexport const HINT_STYLE: CSSProperties = {\n margin: 0,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const FORM_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n maxWidth: 420,\n};\n\nexport const LABEL_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 6,\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-secondary)\",\n};\n\n/** 输入框基线。 */\nconst INPUT_BASE: CSSProperties = {\n boxSizing: \"border-box\",\n width: \"100%\",\n padding: \"8px 12px\",\n borderRadius: 8,\n border: \"1px solid var(--dsw-alias-border-l2)\",\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-primary)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n lineHeight: \"22px\",\n};\n\nexport const INPUT_STYLE: CSSProperties = INPUT_BASE;\n\n/** 校验失败字段(契约 §1 的 401/400 分支)加错误描边。 */\nexport const INVALID_INPUT_STYLE: CSSProperties = {\n ...INPUT_BASE,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n};\n\nexport const STATUS_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 4,\n minHeight: 20,\n fontSize: 13,\n lineHeight: \"20px\",\n};\n\nexport const ERROR_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const SUCCESS_TEXT_STYLE: CSSProperties = {\n color: \"var(--dsw-alias-state-success-primary)\",\n};\n\nexport const RULES_STYLE: CSSProperties = {\n margin: 0,\n paddingLeft: 18,\n color: \"var(--dsw-alias-label-error)\",\n};\n\nexport const BUTTON_STYLE: CSSProperties = {\n alignSelf: \"flex-start\",\n padding: \"8px 20px\",\n borderRadius: 10,\n border: \"1px solid var(--dsw-alias-button-primary-fill)\",\n background: \"var(--dsw-alias-button-primary-fill)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** 提交中(双锁的可见态):置灰但不移除,避免布局跳动。 */\nexport const BUTTON_BUSY_STYLE: CSSProperties = {\n ...BUTTON_STYLE,\n opacity: 0.6,\n cursor: \"default\",\n};\n\n// ---------------------------------------------------------------------------\n// P2 PR2 管理块(admin):以下全部为**追加**常量,上面既有值一个都不动\n// (自助改密页像素稳定性,契约 §2 account-styles 行)。\n// ---------------------------------------------------------------------------\n\n/** 管理块根部:与上方自助改密区之间用一条分隔线 + 上间距,纵向排布。 */\nexport const ADMIN_BLOCK_STYLE: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n marginTop: 4,\n paddingTop: 12,\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 用户表:占满宽度、折叠边框;只做排版,不自绘视觉。 */\nexport const ADMIN_TABLE_STYLE: CSSProperties = {\n width: \"100%\",\n borderCollapse: \"collapse\",\n fontSize: 13,\n lineHeight: \"20px\",\n color: \"var(--dsw-alias-label-primary)\",\n};\n\nexport const ADMIN_TH_STYLE: CSSProperties = {\n textAlign: \"left\",\n padding: \"6px 8px 6px 0\",\n fontWeight: 500,\n color: \"var(--dsw-alias-label-tertiary)\",\n};\n\nexport const ADMIN_TD_STYLE: CSSProperties = {\n padding: \"6px 8px 6px 0\",\n borderTop: \"1px solid var(--dsw-alias-border-l2)\",\n};\n\n/** 只读徽标(角色 / 状态 / 两步验证 / 本人):浅底 + 次级文字色,随主题切换。 */\nexport const ADMIN_BADGE_STYLE: CSSProperties = {\n display: \"inline-block\",\n padding: \"0 6px\",\n borderRadius: 6,\n background: \"var(--dsw-alias-bg-layer-2)\",\n color: \"var(--dsw-alias-label-secondary)\",\n fontSize: 12,\n lineHeight: \"18px\",\n};\n","/**\n * 「账号安全」导航行图标:临时 DOM 垫片(D24.1)。\n *\n * **为什么需要它**:宿主 `settings.section` 只投影 `id`/`order`/`label`,导航图标由\n * `@deepseek-ai/dsh-client-ui-settings-general` 里硬编码的 `navIcon(id)` if 链给出\n * (0.1.5-rc.2 只认 models / agent-presets / plugins,0.1.7-alpha.2 多认官方 `account`),\n * 第三方段一律落到默认齿轮。插件侧没有图标挂载点,所以这里只对我们自己那一行做一次\n * 视觉替换:把宿主齿轮 `display:none`,在它前面插进我们自设计的盾牌 SVG。\n *\n * **不闪**:观察器在插件 `apply()` 时就装好,远早于设置浮层出现;宿主 React 提交 DOM 后,\n * MutationObserver 回调作为**微任务**在同一个任务里排队,而浏览器绘制发生在微任务清空之后。\n * 因此\"宿主图标进入 DOM / 被重写内联样式\"这件事总是在画出那一帧之前被修正为\"藏起来 + 我们的\n * 图标在场\",无论它发生在浮层整体插入的同一次提交、还是后来的某一次提交(后到的那次插入本身\n * 也会触发回调)。隔离实例里用 rAF 逐帧探针(每帧记录行内图标状态)验过:出现行的第一帧即已是\n * 盾牌,0 帧出现齿轮。\n *\n * **只碰我们这一行**:判定条件是「`\n \n );\n }\n const isInvalid = (field: FieldName): boolean => form.failure?.fields.includes(field) === true;\n return (\n
\n

\n \n {t(ACCOUNT_KEYS.title)}\n

\n

{t(ACCOUNT_KEYS.intro)}

\n

{t(ACCOUNT_KEYS.scope)}

\n {\n form.composing.current = true;\n }}\n onCompositionEnd={() => {\n form.composing.current = false;\n }}\n >\n \n \n \n {form.submitting ? t(ACCOUNT_KEYS.submitting) : t(ACCOUNT_KEYS.submit)}\n \n \n
\n );\n}\n","import { formatCopy, type AccountTranslate } from \"../../../shared/i18n/index.ts\";\n\n/**\n * 管理块(PR2)的 `auth` 词典分片:键名集中常量 + zh/en 两套文案。\n *\n * 与 account 分片同命名域(`index.tsx` 把两套词典合并注册),管理块只通过注入的 `t` 读键。\n * 键与文案对齐 CONTRACT-pr2 §4 全表 + §9/A4 的 2 个新增键(冻结键集 = 42);\n * 策略规则名复用 `account-copy.ts` 的 `ruleText`,本文件不新造规则文案。\n */\n\n/** 管理块 translate:与自助改密同形(宿主只注入一个 `t` seat)。 */\nexport type AdminTranslate = AccountTranslate;\n\n/** 管理块词典键(集中常量,避免散落字符串)。 */\nexport const ADMIN_KEYS = {\n title: \"admin.title\",\n intro: \"admin.intro\",\n selfHint: \"admin.selfHint\",\n loading: \"admin.loading\",\n unavailable: \"admin.unavailable\",\n empty: \"admin.empty\",\n colUser: \"admin.colUser\",\n colRole: \"admin.colRole\",\n colState: \"admin.colState\",\n colTotp: \"admin.colTotp\",\n roleAdmin: \"admin.roleAdmin\",\n roleUser: \"admin.roleUser\",\n you: \"admin.you\",\n stateDisabled: \"admin.stateDisabled\",\n stateMustChange: \"admin.stateMustChange\",\n stateOk: \"admin.stateOk\",\n totpOn: \"admin.totpOn\",\n totpOff: \"admin.totpOff\",\n target: \"admin.target\",\n targetPlaceholder: \"admin.targetPlaceholder\",\n password: \"admin.password\",\n confirm: \"admin.confirm\",\n code: \"admin.code\",\n codeHint: \"admin.codeHint\",\n policyHint: \"admin.policyHint\",\n submit: \"admin.submit\",\n submitting: \"admin.submitting\",\n successRevoked: \"admin.successRevoked\",\n successKept: \"admin.successKept\",\n targetRequired: \"admin.targetRequired\",\n passwordRequired: \"admin.passwordRequired\",\n mismatch: \"admin.mismatch\",\n codeRequired: \"admin.codeRequired\",\n forbidden: \"admin.forbidden\",\n badTarget: \"admin.badTarget\",\n notFound: \"admin.notFound\",\n policyIntro: \"admin.policyIntro\",\n invalidTotp: \"admin.invalidTotp\",\n unauthorized: \"admin.unauthorized\",\n locked: \"admin.locked\",\n lockedPlain: \"admin.lockedPlain\",\n generic: \"admin.generic\",\n} as const;\n\n/** 中文词典(键序与 §4 表一致,A4 的 2 键插在语义位置)。 */\nexport const ADMIN_DICT_ZH: Record = {\n [ADMIN_KEYS.title]: \"用户管理\",\n [ADMIN_KEYS.intro]: \"重置后,该用户下次登录会被要求立即改密;会话吊销若失败会单独提示。\",\n [ADMIN_KEYS.selfHint]:\n \"修改自己的密码请用上方表单。忘记自己的口令走 CLI `dsh-auth user passwd`(管理面不允许重置自己)。\",\n [ADMIN_KEYS.loading]: \"正在加载用户列表...\",\n [ADMIN_KEYS.unavailable]: \"用户列表暂时不可用,请稍后重试。\",\n [ADMIN_KEYS.empty]: \"没有可重置的其他用户。\",\n [ADMIN_KEYS.colUser]: \"用户\",\n [ADMIN_KEYS.colRole]: \"角色\",\n [ADMIN_KEYS.colState]: \"状态\",\n [ADMIN_KEYS.colTotp]: \"两步验证\",\n [ADMIN_KEYS.roleAdmin]: \"管理员\",\n [ADMIN_KEYS.roleUser]: \"用户\",\n [ADMIN_KEYS.you]: \"本人\",\n [ADMIN_KEYS.stateDisabled]: \"已禁用\",\n [ADMIN_KEYS.stateMustChange]: \"需改密\",\n [ADMIN_KEYS.stateOk]: \"正常\",\n [ADMIN_KEYS.totpOn]: \"已开启\",\n [ADMIN_KEYS.totpOff]: \"未开启\",\n [ADMIN_KEYS.target]: \"目标用户\",\n [ADMIN_KEYS.targetPlaceholder]: \"请选择用户\",\n [ADMIN_KEYS.password]: \"新密码\",\n [ADMIN_KEYS.confirm]: \"确认新密码\",\n [ADMIN_KEYS.code]: \"动态验证码\",\n [ADMIN_KEYS.codeHint]: \"你已开启两步验证,重置他人密码需填写动态码。\",\n [ADMIN_KEYS.policyHint]: \"新密码至少 14 个字符,且含大写字母、小写字母、数字和特殊字符。\",\n [ADMIN_KEYS.submit]: \"重置密码\",\n [ADMIN_KEYS.submitting]: \"提交中...\",\n [ADMIN_KEYS.successRevoked]: \"已重置,该用户的会话已全部吊销。\",\n [ADMIN_KEYS.successKept]: \"已重置,但该用户的现有会话仍然有效(吊销失败),请手动处理。\",\n [ADMIN_KEYS.targetRequired]: \"请选择目标用户。\",\n [ADMIN_KEYS.passwordRequired]: \"请输入新密码。\",\n [ADMIN_KEYS.mismatch]: \"两次输入的新密码不一致。\",\n [ADMIN_KEYS.codeRequired]: \"请输入动态验证码。\",\n [ADMIN_KEYS.forbidden]: \"没有权限执行此操作,或当前会话不允许。\",\n [ADMIN_KEYS.badTarget]: \"目标用户名不合法。\",\n [ADMIN_KEYS.notFound]: \"目标用户不存在。\",\n [ADMIN_KEYS.policyIntro]: \"新密码不符合以下要求:\",\n [ADMIN_KEYS.invalidTotp]: \"动态验证码不正确,或已被使用。\",\n [ADMIN_KEYS.unauthorized]: \"登录状态已失效,请重新登录。\",\n [ADMIN_KEYS.locked]: \"尝试次数过多,请在 {seconds} 秒后重试。\",\n [ADMIN_KEYS.lockedPlain]: \"尝试次数过多,请稍后重试。\",\n [ADMIN_KEYS.generic]: \"重置失败,请稍后重试。\",\n};\n\n/** 英文词典(键序与中文一致)。 */\nexport const ADMIN_DICT_EN: Record = {\n [ADMIN_KEYS.title]: \"User management\",\n [ADMIN_KEYS.intro]:\n \"After a reset the user must change the password at the next sign-in; if revocation fails, that is reported separately.\",\n [ADMIN_KEYS.selfHint]:\n \"Use the form above to change your own password. If you forgot it, use the CLI `dsh-auth user passwd` (this panel cannot reset your own account).\",\n [ADMIN_KEYS.loading]: \"Loading users...\",\n [ADMIN_KEYS.unavailable]: \"The user list is temporarily unavailable. Please try again later.\",\n [ADMIN_KEYS.empty]: \"There are no other users to reset.\",\n [ADMIN_KEYS.colUser]: \"User\",\n [ADMIN_KEYS.colRole]: \"Role\",\n [ADMIN_KEYS.colState]: \"State\",\n [ADMIN_KEYS.colTotp]: \"Two-factor\",\n [ADMIN_KEYS.roleAdmin]: \"Admin\",\n [ADMIN_KEYS.roleUser]: \"User\",\n [ADMIN_KEYS.you]: \"You\",\n [ADMIN_KEYS.stateDisabled]: \"Disabled\",\n [ADMIN_KEYS.stateMustChange]: \"Must change\",\n [ADMIN_KEYS.stateOk]: \"Normal\",\n [ADMIN_KEYS.totpOn]: \"On\",\n [ADMIN_KEYS.totpOff]: \"Off\",\n [ADMIN_KEYS.target]: \"Target user\",\n [ADMIN_KEYS.targetPlaceholder]: \"Choose a user\",\n [ADMIN_KEYS.password]: \"New password\",\n [ADMIN_KEYS.confirm]: \"Confirm new password\",\n [ADMIN_KEYS.code]: \"Verification code\",\n [ADMIN_KEYS.codeHint]:\n \"Two-factor is on for your account, so resetting another user needs a code.\",\n [ADMIN_KEYS.policyHint]:\n \"At least 14 characters with an uppercase letter, a lowercase letter, a digit and a special character.\",\n [ADMIN_KEYS.submit]: \"Reset password\",\n [ADMIN_KEYS.submitting]: \"Submitting...\",\n [ADMIN_KEYS.successRevoked]: \"Reset done. All of that user's sessions were revoked.\",\n [ADMIN_KEYS.successKept]:\n \"Reset done, but that user's existing sessions are still valid (revocation failed); handle them manually.\",\n [ADMIN_KEYS.targetRequired]: \"Choose a target user.\",\n [ADMIN_KEYS.passwordRequired]: \"Enter a new password.\",\n [ADMIN_KEYS.mismatch]: \"The two new passwords do not match.\",\n [ADMIN_KEYS.codeRequired]: \"Enter the verification code.\",\n [ADMIN_KEYS.forbidden]: \"You are not allowed to do this, or this session is not allowed to.\",\n [ADMIN_KEYS.badTarget]: \"The target user name is invalid.\",\n [ADMIN_KEYS.notFound]: \"The target user does not exist.\",\n [ADMIN_KEYS.policyIntro]: \"The new password does not meet these requirements:\",\n [ADMIN_KEYS.invalidTotp]: \"The verification code is incorrect or already used.\",\n [ADMIN_KEYS.unauthorized]: \"Your session has expired. Please sign in again.\",\n [ADMIN_KEYS.locked]: \"Too many attempts. Try again in {seconds} seconds.\",\n [ADMIN_KEYS.lockedPlain]: \"Too many attempts. Try again later.\",\n [ADMIN_KEYS.generic]: \"Could not reset the password. Please try again later.\",\n};\n\n/**\n * 用一套词典兜底成 translate:缺键先回落英文词典,两边都缺时回空串,\n * **绝不把键名当文案显示**(§4 表注)。\n */\nexport function translateAdminFrom(dict: Record): AdminTranslate {\n return (key, params) => formatCopy(dict[key] ?? ADMIN_DICT_EN[key] ?? \"\", params);\n}\n","import type { AdminResetErrorBody, Unchecked } from \"../../../shared/api/index.ts\";\nimport { ruleText } from \"../../../shared/i18n/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"./admin-copy.ts\";\nimport type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\n\n/**\n * 管理重置的失败映射(CONTRACT-pr2 §1.3 + §9/A10)。从 `admin-api.ts` 预拆出来(A12),\n * 避免单文件贴上限。**全函数**:只认下表的 (status, error) 字面量组合,\n * 其余一律 `admin.generic`,不自造错误码、不做前缀/子串匹配。\n *\n * 已核对 PR1:`self`(自助误走管理面)也回 `{error:\"forbidden\"}`,没有 `{error:\"self\"}`。\n */\n\nfunction view(message: string, rules: string[] = [], fields: AdminField[] = []): AdminFailureView {\n return { message, rules, fields };\n}\n\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 错误码比较:码的字面量受契约(D31)约束,写错码编译不过;值本身仍是未校验的 `unknown`。 */\nfunction isError(value: unknown, code: AdminResetErrorBody[\"error\"]): boolean {\n return value === code;\n}\n\n/** 规则列表:只保留字符串项,未知规则名原样回显(复用 account 的 `ruleText`)。 */\nfunction ruleList(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((rule): rule is string => typeof rule === \"string\")\n : [];\n}\n\n/** 400:`bad_target` / `policy`(携带 rules);其余(含缺 error)→ generic。 */\nfunction describeBadRequest(error: unknown, rules: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"policy\")) {\n return view(\n t(ADMIN_KEYS.policyIntro),\n ruleList(rules).map((rule) => ruleText(rule, t)),\n [\"password\"],\n );\n }\n if (isError(error, \"bad_target\")) return view(t(ADMIN_KEYS.badTarget), [], [\"target\"]);\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 401:`invalid_totp` / `unauthorized`;其余 → generic。 */\nfunction describeUnauthorized(error: unknown, t: AdminTranslate): AdminFailureView {\n if (isError(error, \"invalid_totp\")) return view(t(ADMIN_KEYS.invalidTotp), [], [\"code\"]);\n if (isError(error, \"unauthorized\")) return view(t(ADMIN_KEYS.unauthorized));\n return view(t(ADMIN_KEYS.generic));\n}\n\n/** 429:`locked` + 数字 retryAfter 插值,缺数字用无参文案;其余 → generic。 */\nfunction describeLocked(error: unknown, retryAfter: unknown, t: AdminTranslate): AdminFailureView {\n if (!isError(error, \"locked\")) return view(t(ADMIN_KEYS.generic));\n return typeof retryAfter === \"number\"\n ? view(t(ADMIN_KEYS.locked, { seconds: retryAfter }))\n : view(t(ADMIN_KEYS.lockedPlain));\n}\n\n/**\n * 状态码矩阵 → 文案键:\n * 400 bad_target / policy+rules、401 invalid_totp / unauthorized、403 forbidden、\n * 404 not_found、429 locked(+retryAfter)、其余(413/415/503 的 text/plain 与所有未列出组合,\n * 含缺 `error` 的 403/404/429)一律 generic。\n */\nexport function describeAdminFailure(\n status: number,\n body: unknown,\n t: AdminTranslate,\n): AdminFailureView {\n const record = isRecord(body) ? body : {};\n const error = record.error;\n if (status === 400) return describeBadRequest(error, record.rules, t);\n if (status === 401) return describeUnauthorized(error, t);\n if (status === 403 && isError(error, \"forbidden\")) return view(t(ADMIN_KEYS.forbidden));\n if (status === 404 && isError(error, \"not_found\")) {\n return view(t(ADMIN_KEYS.notFound), [], [\"target\"]);\n }\n if (status === 429) return describeLocked(error, record.retryAfter, t);\n return view(t(ADMIN_KEYS.generic));\n}\n","import {\n encodeForm,\n USERS_PASSWORD_PATH,\n USERS_PATH,\n type AdminResetErrorBody,\n type AdminResetRequest,\n type AdminResetResponse,\n type AdminUserRow,\n type Unchecked,\n type UsersResponse,\n} from \"../../../shared/api/index.ts\";\nimport { describeAdminFailure } from \"../lib/admin-failure.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport type {\n AdminFailureView,\n AdminResetResult,\n AdminResetValues,\n AdminUsersResult,\n} from \"../model/admin-reset.ts\";\n\n/**\n * 管理面客户端 API(CONTRACT-pr2 §1.2 / §1.3 + §9):列表读取 + 重置提交。\n *\n * 线上形状来自跨半边契约(D31,经 `shared/api`),表单与结果类型在 `model/admin-reset.ts`;失败映射在 `admin-failure.ts`(A12 预拆,此处按\n * 冻结面原样再导出)。不手写 `Origin`(同源 POST 由浏览器自动携带)、不做 `Accept` 子串匹配、\n * 不重排服务端已排好的顺序。\n */\n\nexport { describeAdminFailure };\n\nconst USERS_TARGET = USERS_PATH;\nconst RESET_TARGET = USERS_PASSWORD_PATH;\n\n/** 对象守卫:键按契约形状 `T` 读(读错键编译不过),值仍是未校验的 `unknown`。 */\nfunction isRecord(value: unknown): value is Unchecked {\n return typeof value === \"object\" && value !== null;\n}\n\n/** 单行形状校验:任一必需字段类型不符即丢弃该行(不整表失败,契约 §1.2)。 */\nfunction asRow(value: unknown): AdminUserRow | undefined {\n if (!isRecord(value)) return undefined;\n const { name, role, disabled, totpEnabled, mustChangePassword } = value;\n if (typeof name !== \"string\" || name === \"\") return undefined;\n if (role !== \"admin\" && role !== \"user\") return undefined;\n if (typeof disabled !== \"boolean\" || typeof totpEnabled !== \"boolean\") return undefined;\n if (typeof mustChangePassword !== \"boolean\") return undefined;\n return { name, role, disabled, totpEnabled, mustChangePassword };\n}\n\n/** 解析 200 body;`users` 不是数组视为形状非法(整表 failure)。 */\nfunction parseUsers(body: unknown): AdminUserRow[] | undefined {\n if (!isRecord(body)) return undefined;\n const raw = body.users;\n if (!Array.isArray(raw)) return undefined;\n // 保持服务端顺序:只过滤,不排序。\n return raw.map(asRow).filter((row): row is AdminUserRow => row !== undefined);\n}\n\n/** 重置端点的全部 JSON 形状(成功体 + 错误体),按契约读键。 */\ntype ResetBody = AdminResetResponse | AdminResetErrorBody;\n\n/** 读 JSON;非对象或解析失败统一回空体(映射只看状态码)。 */\nasync function readJsonSafe(res: Response): Promise> {\n try {\n const body: unknown = await res.json();\n return isRecord(body) ? body : {};\n } catch {\n return {};\n }\n}\n\n/**\n * `GET /auth/users`(cookie only,无 Origin 要求)。\n * 403 → `denied`(静默降级);401 → `unauthorized`(整页登录态已死,必须提示,§9/A2);\n * 非 200 / 形状非法 / 网络抛错 → `failure`;中止 → `aborted`(abort 后不解析响应体)。\n */\nexport async function fetchAdminUsers(signal: AbortSignal): Promise {\n try {\n const res = await fetch(USERS_TARGET, { method: \"GET\", signal, credentials: \"same-origin\" });\n if (signal.aborted) return { kind: \"aborted\" };\n if (res.status === 403) return { kind: \"denied\" };\n if (res.status === 401) return { kind: \"unauthorized\" };\n if (!res.ok) return { kind: \"failure\" };\n const body: unknown = await res.json();\n if (signal.aborted) return { kind: \"aborted\" };\n const users = parseUsers(body);\n return users === undefined ? { kind: \"failure\" } : { kind: \"ok\", users };\n } catch {\n return signal.aborted ? { kind: \"aborted\" } : { kind: \"failure\" };\n }\n}\n\n/**\n * 本地校验闭表(§9/A6):target → password → confirm → code。\n * `code` 只在 `actorTotpEnabled === true` 时校验,且用 `trim()` 判空(口令字段不 trim)。\n * 第三个参数缺省 false,只传两个实参的调用方行为不变。\n */\nexport function validateAdminReset(\n values: AdminResetValues,\n t: AdminTranslate,\n actorTotpEnabled = false,\n): AdminFailureView | null {\n if (values.target === \"\") {\n return { message: t(ADMIN_KEYS.targetRequired), rules: [], fields: [\"target\"] };\n }\n if (values.password === \"\") {\n return { message: t(ADMIN_KEYS.passwordRequired), rules: [], fields: [\"password\"] };\n }\n if (values.password !== values.confirm) {\n return { message: t(ADMIN_KEYS.mismatch), rules: [], fields: [\"password\", \"confirm\"] };\n }\n if (actorTotpEnabled && values.code.trim() === \"\") {\n return { message: t(ADMIN_KEYS.codeRequired), rules: [], fields: [\"code\"] };\n }\n return null;\n}\n\n/**\n * 提交请求体(§9/A3):只发 `target` / `password`,`actorTotpEnabled === true` 时才带 `code`\n * (缺码场景不发空串);**`confirm` 永不发送**。\n */\nfunction postAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n): Promise {\n const request: AdminResetRequest = {\n target: values.target,\n password: values.password,\n ...(options.actorTotpEnabled ? { code: values.code } : {}),\n };\n return fetch(RESET_TARGET, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: encodeForm(request),\n signal: options.signal,\n credentials: \"same-origin\",\n });\n}\n\n/**\n * `POST /auth/users/password`:\n * **成功只认契约 §1.3 的唯一形状** `200 {ok:true}`(grok 实现期必修 1):2xx 但缺 `ok:true`\n * (空 JSON、`{ok:false}`、中间层 HTML 拦截页)一律走失败映射,宁可报错也不谎报\"已重置\"\n * 再清空现场口令。`sessionsRevoked` 缺失/非布尔/解析失败按 `false`(§9/A8+A10);\n * 中止 → `aborted`。\n */\nexport async function submitAdminReset(\n values: AdminResetValues,\n options: { signal: AbortSignal; actorTotpEnabled: boolean },\n t: AdminTranslate,\n): Promise {\n try {\n const res = await postAdminReset(values, options);\n if (options.signal.aborted) return { kind: \"aborted\" };\n const body = await readJsonSafe(res);\n if (options.signal.aborted) return { kind: \"aborted\" };\n if (res.status === 200 && body.ok === true) {\n return { kind: \"ok\", sessionsRevoked: body.sessionsRevoked === true };\n }\n return { kind: \"failure\", failure: describeAdminFailure(res.status, body, t) };\n } catch {\n if (options.signal.aborted) return { kind: \"aborted\" };\n return {\n kind: \"failure\",\n failure: { message: t(ADMIN_KEYS.generic), rules: [], fields: [] },\n };\n }\n}\n","import type { AdminFailureView, AdminField } from \"../model/admin-reset.ts\";\nimport {\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n RULES_STYLE,\n STATUS_STYLE,\n SUCCESS_TEXT_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 管理块的共享展示件(lead 批的 A12 预拆):id 常量 + 输入行 + 状态播报位。\n * 只被 `admin-form.tsx` / `admin-block.tsx` **单向** import;本文件不 import 它们(避免循环)。\n */\n\n/** id 前缀冻结(契约 §3.10):一个面板只有一个管理块实例,静态 id 即可。 */\nconst PREFIX = \"dsh-auth-gate-admin\";\n\n/** 状态播报位:aria-live 落点,也是错误字段 aria-describedby 的目标。 */\nexport const ADMIN_STATUS_ID = `${PREFIX}-status`;\nexport const ADMIN_CODE_HINT_ID = `${PREFIX}-code-hint`;\n\nexport function adminFieldId(field: AdminField): string {\n return `${PREFIX}-${field}`;\n}\n\n/** 成功视图(A8):`warning` = `sessionsRevoked:false`,是安全失败,走 alert + 错误色。 */\nexport interface AdminSuccessView {\n text: string;\n warning: boolean;\n}\n\n/** 带 label / 错误描边 / aria 关联的输入行(与自助面同范式,id 换管理块前缀)。 */\nexport function AdminTextField({\n field,\n label,\n value,\n invalid,\n onChange,\n hint,\n hintId,\n}: {\n field: AdminField;\n label: string;\n value: string;\n invalid: boolean;\n onChange: (value: string) => void;\n hint?: string | undefined;\n hintId?: string | undefined;\n}) {\n const described = [invalid ? ADMIN_STATUS_ID : undefined, hintId].filter(\n (item) => item !== undefined,\n );\n const id = adminFieldId(field);\n // 字段语义固定:新口令两框 `new-password`,动态码 `one-time-code` + 数字键盘。\n const isCode = field === \"code\";\n return (\n
\n \n onChange(event.target.value)}\n />\n {hint === undefined ? null : (\n \n {hint}\n \n )}\n
\n );\n}\n\n/** 状态区:失败消息 + 已翻译规则列表 + 就地成功/警告文案(契约 §3.8 / A8)。 */\nexport function AdminStatus({\n failure,\n success,\n}: {\n failure: AdminFailureView | null;\n success: AdminSuccessView | null;\n}) {\n const warning = success?.warning === true;\n return (\n <>\n
\n {failure === null ? null : {failure.message}}\n {failure === null || failure.rules.length === 0 ? null : (\n
    \n {failure.rules.map((rule) => (\n
  • {rule}
  • \n ))}\n
\n )}\n {success === null || warning ? null : (\n {success.text}\n )}\n
\n {warning && success !== null ? (\n

\n {success.text}\n

\n ) : null}\n \n );\n}\n","import type { AdminUserRow } from \"../../../shared/api/index.ts\";\n\n/**\n * 管理重置表单与结果的 client 侧类型(契约 §2)。只放类型与常量,不放逻辑。\n *\n * 线上形状(列表行、请求体、响应体、错误体)在跨半边契约 `src/protocol/`(D31),经\n * `shared/api` 进来;这里只剩表单状态和解析后的结果,它们不上线,host 不认识。\n */\n\n/** 管理重置表单字段(`confirm` 只在客户端比对,不进请求体,契约 §1.3)。 */\nexport type AdminField = \"target\" | \"password\" | \"confirm\" | \"code\";\n\nexport type AdminResetValues = Record;\n\n/** 表单初值:提交成功后必须清回这一份(契约 §3.8)。 */\nexport const EMPTY_ADMIN_RESET: AdminResetValues = {\n target: \"\",\n password: \"\",\n confirm: \"\",\n code: \"\",\n};\n\n/** 失败视图:已翻译消息 + 逐条策略文案 + 需标红的字段。 */\nexport interface AdminFailureView {\n message: string;\n rules: string[];\n fields: AdminField[];\n}\n\n/**\n * 列表结果(评审修正 A2):\n * - 403 `forbidden` → `denied`:这张 cookie 不是 admin / 不是 full 会话 → **静默**不渲染管理块;\n * - 401 `unauthorized` → `unauthorized`:整页登录态已死 → 必须提示 `admin.unauthorized`,\n * 不能静默消失(否则上方的自助改密表单会继续按过期 status 显示\"已登录\");\n * - 503 / 网络 / 解析失败 → `failure`(提示 `admin.unavailable`);\n * - 卸载中止 → `aborted`(回调里必须原样收手)。\n */\nexport type AdminUsersResult =\n | { kind: \"ok\"; users: AdminUserRow[] }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"aborted\" };\n\n/** 重置结果:`sessionsRevoked:false` 仍算成功(服务端写盘已成功,契约 §1.3)。 */\nexport type AdminResetResult =\n | { kind: \"ok\"; sessionsRevoked: boolean }\n | { kind: \"failure\"; failure: AdminFailureView }\n | { kind: \"aborted\" };\n","import {\n useEffect,\n useRef,\n useState,\n type FormEvent,\n type KeyboardEvent,\n type MutableRefObject,\n} from \"react\";\nimport { submitAdminReset, validateAdminReset } from \"../api/admin-api.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../lib/admin-copy.ts\";\nimport {\n AdminStatus,\n AdminTextField,\n ADMIN_CODE_HINT_ID,\n ADMIN_STATUS_ID,\n adminFieldId,\n type AdminSuccessView,\n} from \"./admin-fields.tsx\";\nimport {\n EMPTY_ADMIN_RESET,\n type AdminFailureView,\n type AdminField,\n type AdminResetValues,\n} from \"../model/admin-reset.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n BUTTON_BUSY_STYLE,\n BUTTON_STYLE,\n FORM_STYLE,\n HINT_STYLE,\n INPUT_STYLE,\n INVALID_INPUT_STYLE,\n LABEL_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\n/**\n * 表单状态机(与自助面同范式):\n * - 双锁 = in-flight ref(硬锁,Enter/点击并发都挡)+ submitting 按钮 disabled(可见锁);\n * - IME 组合期由 composing ref 挡住,避免中文输入法确认键提交;\n * - 请求带 AbortController,卸载即 abort,回调再看 mounted,绝不卸载后 setState;\n * - 成功:落成功文案(`sessionsRevoked:false` 走 A8 的安全失败样式),清空四字段,再重拉列表。\n */\n/** 表单事件:IME 组合期(Enter 确认键)一律不提交;并发双锁由 `submit` 自己兜底。 */\nfunction formHandlers(\n composing: MutableRefObject,\n submit: () => Promise,\n): {\n onSubmit: (event: FormEvent) => void;\n onKeyDown: (event: KeyboardEvent) => void;\n onCompositionStart: () => void;\n onCompositionEnd: () => void;\n} {\n return {\n onCompositionStart: () => {\n composing.current = true;\n },\n onCompositionEnd: () => {\n composing.current = false;\n },\n onSubmit: (event) => {\n event.preventDefault();\n if (composing.current) return;\n void submit();\n },\n onKeyDown: (event) => {\n if (event.key !== \"Enter\") return;\n // 焦点在下拉里时 Enter 是\"确认选项\":既不 preventDefault 也不提交(grok 回顾 #5)。\n if (event.target instanceof HTMLSelectElement) return;\n event.preventDefault();\n if (composing.current || event.nativeEvent.isComposing) return;\n void submit();\n },\n };\n}\n\n/** 成功视图(A8):`sessionsRevoked:false` 是安全失败,文案与样式都换一套。 */\nfunction successView(sessionsRevoked: boolean, t: AdminTranslate): AdminSuccessView {\n return sessionsRevoked\n ? { text: t(ADMIN_KEYS.successRevoked), warning: false }\n : { text: t(ADMIN_KEYS.successKept), warning: true };\n}\n\nfunction useAdminReset(\n t: AdminTranslate,\n actorTotpEnabled: boolean,\n onReset: (() => void) | undefined,\n) {\n const [values, setValues] = useState(EMPTY_ADMIN_RESET);\n const [submitting, setSubmitting] = useState(false);\n const [failure, setFailure] = useState(null);\n const [success, setSuccess] = useState(null);\n const lock = useRef(false);\n const composing = useRef(false);\n const abort = useRef(null);\n const mounted = useRef(true);\n\n useEffect(() => {\n // StrictMode 双挂载:effect 会「跑 → 清理 → 再跑」,所以挂载时必须**重新置 true**\n // (只写清理会把 mounted 永久钉在 false → 提交结果全丢、按钮卡在\"提交中\")。\n mounted.current = true;\n return () => {\n mounted.current = false;\n abort.current?.abort();\n };\n }, []);\n\n const setValue = (field: AdminField, value: string): void => {\n setValues((previous) => ({ ...previous, [field]: value }));\n };\n\n const submit = async (): Promise => {\n if (lock.current) return;\n const invalid = validateAdminReset(values, t, actorTotpEnabled);\n if (invalid !== null) {\n setFailure(invalid);\n return;\n }\n lock.current = true;\n setSubmitting(true);\n setFailure(null);\n setSuccess(null);\n const controller = new AbortController();\n abort.current = controller;\n const live = (): boolean => mounted.current && !controller.signal.aborted;\n try {\n const result = await submitAdminReset(\n values,\n { signal: controller.signal, actorTotpEnabled },\n t,\n );\n if (!live()) return;\n if (result.kind === \"ok\") {\n setSuccess(successView(result.sessionsRevoked, t));\n setValues({ ...EMPTY_ADMIN_RESET });\n onReset?.(); // A7:成功后重拉列表刷新徽标;失败路径不动行\n return;\n }\n if (result.kind === \"failure\") setFailure(result.failure);\n } catch {\n if (!live()) return;\n setFailure({ message: t(ADMIN_KEYS.generic), rules: [], fields: [] });\n } finally {\n lock.current = false;\n if (mounted.current) setSubmitting(false);\n }\n };\n\n const handlers = formHandlers(composing, submit);\n return { values, submitting, failure, success, setValue, ...handlers };\n}\n\nexport interface AdminResetFormProps {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n actorTotpEnabled: boolean;\n /** A7:成功回调(管理块用它重拉列表刷新徽标);失败路径不调用。 */\n onReset?: (() => void) | undefined;\n}\n\n/** 管理重置表单:目标下拉(排除本人)+ 新口令 ×2 + 条件式动态码;成功后清空四字段、不跳转。 */\nexport function AdminResetForm({\n t,\n users,\n actorName,\n actorTotpEnabled,\n onReset,\n}: AdminResetFormProps) {\n const form = useAdminReset(t, actorTotpEnabled, onReset);\n const targets = users.filter((user) => user.name !== actorName);\n const fields: { field: AdminField; label: string }[] = [\n { field: \"password\", label: t(ADMIN_KEYS.password) },\n { field: \"confirm\", label: t(ADMIN_KEYS.confirm) },\n ...(actorTotpEnabled ? [{ field: \"code\" as AdminField, label: t(ADMIN_KEYS.code) }] : []),\n ];\n const isInvalid = (field: AdminField): boolean => form.failure?.fields.includes(field) === true;\n const targetInvalid = isInvalid(\"target\");\n return (\n \n

{t(ADMIN_KEYS.selfHint)}

\n
\n \n form.setValue(\"target\", event.target.value)}\n >\n \n {targets.map((user) => (\n \n ))}\n \n
\n {fields.map((item) => (\n form.setValue(item.field, value)}\n />\n ))}\n

{t(ADMIN_KEYS.policyHint)}

\n \n \n {form.submitting ? t(ADMIN_KEYS.submitting) : t(ADMIN_KEYS.submit)}\n \n \n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n fetchAdminUsers,\n AdminResetForm,\n ADMIN_STATUS_ID,\n} from \"../../../features/admin-users/index.ts\";\nimport { ADMIN_KEYS, type AdminTranslate } from \"../../../features/admin-users/index.ts\";\nimport type { AdminUserRow } from \"../../../shared/api/index.ts\";\nimport {\n ADMIN_BADGE_STYLE,\n ADMIN_BLOCK_STYLE,\n ADMIN_TABLE_STYLE,\n ADMIN_TD_STYLE,\n ADMIN_TH_STYLE,\n ERROR_TEXT_STYLE,\n HINT_STYLE,\n TITLE_STYLE,\n} from \"../../../shared/ui/index.ts\";\n\nconst TITLE_ID = \"dsh-auth-gate-admin-title\";\n\n/**\n * 列表状态(评审修正 A2):`denied`(403 非 admin / 受限)静默返回 null;\n * `unauthorized`(401 登录态已死)必须提示,不能静默消失;\n * `failure`(503/网络/解析)提示不可用。\n */\ntype UsersState =\n | { kind: \"loading\" }\n | { kind: \"denied\" }\n | { kind: \"unauthorized\" }\n | { kind: \"failure\" }\n | { kind: \"ok\"; users: AdminUserRow[] };\n\n/**\n * 挂载后拉一次列表(契约 §3.4:非 admin 根本不会挂载本组件,这里不判断角色);\n * `reload` 供 A7 成功后静默刷新徽标:刷新失败**不改行**(首拉才降级),同一 abort 规则。\n */\nfunction useAdminUsers(): { state: UsersState; reload: () => void } {\n const [state, setState] = useState({ kind: \"loading\" });\n const [attempt, setAttempt] = useState(0);\n useEffect(() => {\n const controller = new AbortController();\n let live = true;\n const first = attempt === 0;\n void fetchAdminUsers(controller.signal)\n .then((result) => {\n if (!live || result.kind === \"aborted\") return;\n if (result.kind === \"ok\") setState({ kind: \"ok\", users: result.users });\n else if (first) setState({ kind: result.kind });\n })\n .catch(() => {\n if (live && first) setState({ kind: \"failure\" });\n });\n return () => {\n live = false;\n controller.abort();\n };\n }, [attempt]);\n return { state, reload: () => setAttempt((value) => value + 1) };\n}\n\n/** 角色文案:`admin`/`user` 走词典;**未知取值原样渲染**,不新增键(A9)。 */\nfunction roleText(role: string, t: AdminTranslate): string {\n if (role === \"admin\") return t(ADMIN_KEYS.roleAdmin);\n if (role === \"user\") return t(ADMIN_KEYS.roleUser);\n return String(role);\n}\n\n/** 状态徽标**互斥**,优先级 disabled > mustChangePassword > ok(A9)。 */\nfunction stateText(user: AdminUserRow, t: AdminTranslate): string {\n if (user.disabled) return t(ADMIN_KEYS.stateDisabled);\n return user.mustChangePassword ? t(ADMIN_KEYS.stateMustChange) : t(ADMIN_KEYS.stateOk);\n}\n\n/** 只读用户表:**保持服务端顺序**,不做任何重排(契约 §1.2 / §3.5)。 */\nfunction AdminUsersTable({\n t,\n users,\n actorName,\n}: {\n t: AdminTranslate;\n users: AdminUserRow[];\n actorName: string;\n}) {\n return (\n \n \n \n \n \n \n \n \n \n \n {users.map((user) => (\n \n \n \n \n \n \n ))}\n \n
{t(ADMIN_KEYS.colUser)}{t(ADMIN_KEYS.colRole)}{t(ADMIN_KEYS.colState)}{t(ADMIN_KEYS.colTotp)}
\n {user.name}\n {user.name === actorName ? (\n {t(ADMIN_KEYS.you)}\n ) : null}\n {roleText(user.role, t)}\n {stateText(user, t)}\n \n \n {user.totpEnabled ? t(ADMIN_KEYS.totpOn) : t(ADMIN_KEYS.totpOff)}\n \n
\n );\n}\n\n/** 提示态(loading / unauthorized / unavailable):同一条 aria-live 播报位。 */\nfunction AdminNotice({ text, error }: { text: string; error?: boolean }) {\n return (\n \n {text}\n

\n );\n}\n\nexport interface AdminUsersBlockProps {\n /** 槽位注入的翻译 seat(含 ADMIN 词典分片)。 */\n t: AdminTranslate;\n /** 当前登录用户名(status.name):用于「本人」标记与下拉排除。 */\n actorName: string;\n /** 当前账号是否启用两步验证:决定 `code` 输入框是否渲染(契约 §3.7)。 */\n actorTotpEnabled: boolean;\n}\n\n/**\n * 管理块(契约 §3 / A2):loading → denied 静默 null → unauthorized / failure 提示\n * → ok 列表 + 表单。「只有本人」时显示 `admin.empty` 且**不渲染表单**(§3.6)。\n */\nexport function AdminUsersBlock({ t, actorName, actorTotpEnabled }: AdminUsersBlockProps) {\n const { state, reload } = useAdminUsers();\n if (state.kind === \"loading\") return ;\n if (state.kind === \"denied\") return null;\n if (state.kind === \"unauthorized\") {\n return ;\n }\n if (state.kind === \"failure\") return ;\n const targets = state.users.filter((user) => user.name !== actorName);\n return (\n
\n

\n {t(ADMIN_KEYS.title)}\n

\n

{t(ADMIN_KEYS.intro)}

\n \n {targets.length === 0 ? (\n

\n {t(ADMIN_KEYS.empty)}\n

\n ) : (\n \n )}\n
\n );\n}\n","import { useEffect, useState } from \"react\";\nimport {\n STATUS_PATH,\n type SessionKind,\n type StatusResponse,\n type Unchecked,\n type UserRole,\n} from \"../../../shared/api/index.ts\";\n\n/**\n * `GET /auth/status` 的「关于我」视图:线上形状 `StatusResponse`(跨半边契约 D31)去掉本视图\n * 不用的 `logoutOrder`(由 client 入口单独读)与 `mustChangePassword`。\n *\n * **加法兼容**:身份字段是 PR1 才有的(旧服务端只回 `authenticated`/`logoutOrder`),\n * 任一身份字段缺失即视为「不渲染管理块」,其余行为一字不变(契约 §1.1 / §3.3)。\n */\nexport type AccountStatusView = Omit;\n\n/** 会话探针(只认 cookie,语义同 `/auth/status`,契约 §1.1)。 */\nconst STATUS_TARGET = STATUS_PATH;\n\n/** 字符串字段守卫:非字符串一律 `undefined`(旧服务端/异常形状都不渲染管理块)。 */\nfunction optionalString(value: unknown): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\n/** 布尔字段守卫。 */\nfunction optionalBoolean(value: unknown): boolean | undefined {\n return typeof value === \"boolean\" ? value : undefined;\n}\n\n/** role 白名单:只认 `admin` / `user`,其余(含非法字符串)留 `undefined`。 */\nfunction optionalRole(value: unknown): UserRole | undefined {\n return value === \"admin\" || value === \"user\" ? value : undefined;\n}\n\n/** sessionKind 白名单:只认 `full` / `password-change-only`。 */\nfunction optionalSessionKind(value: unknown): SessionKind | undefined {\n return value === \"full\" || value === \"password-change-only\" ? value : undefined;\n}\n\n/**\n * `/auth/status` 响应体 → 视图对象。**加法兼容**(契约 §1.1/§3.3):\n * 身份字段缺失、类型不符或取值不在白名单内,一律留 `undefined`;只有\n * `authenticated === true` 才可能让调用方继续渲染登录后的 UI。\n */\nfunction parseAccountStatus(body: unknown): AccountStatusView {\n const source = (\n typeof body === \"object\" && body !== null ? body : {}\n ) as Unchecked;\n return {\n authenticated: source.authenticated === true,\n name: optionalString(source.name),\n role: optionalRole(source.role),\n disabled: optionalBoolean(source.disabled),\n totpEnabled: optionalBoolean(source.totpEnabled),\n sessionKind: optionalSessionKind(source.sessionKind),\n };\n}\n\n/**\n * 会话状态探针:`null` = 首次响应返回前(调用方自己渲染 loading)。\n * `credentials: \"same-origin\"` 保证带 cookie;卸载即 abort,回调里再看一眼 signal,\n * 避免卸载后 setState。任何失败(网络/解析/中止之外的异常)→ `{authenticated:false}`。\n */\nexport function useAccountStatus(): AccountStatusView | null {\n const [status, setStatus] = useState(null);\n useEffect(() => {\n const controller = new AbortController();\n fetch(STATUS_TARGET, { signal: controller.signal, credentials: \"same-origin\" })\n .then((res) => res.json() as Promise)\n .then((body) => {\n if (!controller.signal.aborted) setStatus(parseAccountStatus(body));\n })\n .catch(() => {\n if (!controller.signal.aborted) setStatus({ authenticated: false });\n });\n return () => {\n controller.abort();\n };\n }, []);\n return status;\n}\n","import {\n ACCOUNT_DICT_EN,\n ACCOUNT_KEYS,\n translateFrom,\n type AccountTranslate,\n} from \"../../../shared/i18n/index.ts\";\nimport { AccountPasswordForm } from \"../../../features/change-password/index.ts\";\nimport { HINT_STYLE, PANEL_STYLE } from \"../../../shared/ui/index.ts\";\nimport { AdminUsersBlock } from \"./admin-block.tsx\";\nimport { ADMIN_DICT_EN } from \"../../../features/admin-users/index.ts\";\nimport type { AccountStatusView } from \"../../../entities/account/index.ts\";\nimport { useAccountStatus } from \"../../../entities/account/index.ts\";\n\n/** 提示态 id:loading / 未登录共用一个 aria-live 播报位。 */\nconst NOTICE_ID = \"dsh-auth-gate-account-notice\";\n\n/** 无表单的提示态(loading / 请先登录),保留 aria-live 播报。 */\nfunction Notice({ text }: { text: string }) {\n return (\n
\n

\n {text}\n

\n
\n );\n}\n\n/**\n * 管理块渲染门(契约 §9-A1,四条**同时**成立才渲染):`role === \"admin\"`、\n * `disabled === false`、`sessionKind === \"full\"`、`name` 是字符串。\n * **不做 `?? \"full\"` 兜底**:任一字段缺失或非该值 = 不渲染且零管理请求\n * (旧服务端/部分字段与「非 admin 不试拉」加法兼容;禁用 admin 不挂块)。\n */\nfunction showsAdminBlock(\n status: AccountStatusView,\n): status is AccountStatusView & { name: string } {\n return (\n status.role === \"admin\" &&\n status.disabled === false &&\n status.sessionKind === \"full\" &&\n typeof status.name === \"string\"\n );\n}\n\n/**\n * 槽位注入的 props:`t` 来自注册时的 `locale: \"auth\"` seat。\n * 宿主 owner props 还有 `close`;P1.1 起不再往下传(成功后去登录页,而不是关弹窗)。\n */\nexport interface SettingsAccountSectionProps {\n t?: AccountTranslate | undefined;\n}\n\n/**\n * 「账户」设置页(`settings.section`):未登录不给表单;已登录渲染自助改密表单,\n * 并在身份满足渲染门时(admin + 正式会话)追加管理块。`t` 缺失时降级到英文词典\n * (不显示键名),由 account-form / admin-block 承担各自的提交逻辑。\n */\nexport function SettingsAccountSection({ t }: SettingsAccountSectionProps) {\n const status = useAccountStatus();\n // `t` 缺失的降级词典必须同时含 account 与 admin 两片(否则管理块会显示键名,grok 回顾 #1)。\n const translate =\n typeof t === \"function\" ? t : translateFrom({ ...ACCOUNT_DICT_EN, ...ADMIN_DICT_EN });\n if (status === null) return ;\n if (status.authenticated !== true) return ;\n return (\n <>\n \n {showsAdminBlock(status) ? (\n \n ) : null}\n \n );\n}\n","import { useEffect, useState, type CSSProperties } from \"react\";\nimport { LOGOUT_URL, STATUS_PATH } from \"../../../shared/api/index.ts\";\n\n/** 登出目标:POST-only(M22:next 仅从 query 取,校验回落 /)。 */\nconst LOGOUT_TARGET = LOGOUT_URL;\n\n/**\n * 登出图标:16px 按钮图标(viewBox 24 不变,只设 width/height 16)。\n * 沿用原 32px 圆形按钮的同一个 SVG(方框 + 箭头)。\n */\nfunction renderLogoutIcon() {\n return (\n \n \n \n \n \n );\n}\n\n/**\n * 设置面板内醒目的登出 CTA:错误强调色(危险动作语义)填充按钮 +\n * 反色标签 `--dsw-alias-label-primary-inverted`,面板内水平居中(General 页底部)。\n */\nconst CTA_STYLE: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"10px 24px\",\n borderRadius: 12,\n border: \"1px solid var(--dsw-alias-state-error-primary)\",\n background: \"var(--dsw-alias-state-error-primary)\",\n color: \"var(--dsw-alias-label-primary-inverted)\",\n fontFamily: \"inherit\",\n fontSize: 14,\n fontWeight: 500,\n lineHeight: \"22px\",\n cursor: \"pointer\",\n};\n\n/** hover 态轻微提亮(随主题自适应,不硬编码色值)。 */\nconst CTA_HOVER_FILTER = \"brightness(1.08)\";\n\n/** 面板内水平居中容器(General 页最后一条行之后)。 */\nconst CTA_WRAP_STYLE: CSSProperties = {\n display: \"flex\",\n justifyContent: \"center\",\n padding: \"20px 0 4px\",\n};\n\nconst formStyle: CSSProperties = {\n display: \"contents\",\n};\n\n/**\n * 会话状态门控:挂载时 fetch /auth/status 一次(只认 cookie)。\n * @returns authenticated:null = 未知(第一次请求前),true/false。\n */\nfunction useAuthenticated(): boolean | null {\n const [authenticated, setAuthenticated] = useState(null);\n useEffect(() => {\n let cancelled = false;\n fetch(STATUS_PATH)\n .then((res) => res.json())\n .then((body: { authenticated?: unknown }) => {\n if (!cancelled) setAuthenticated(body.authenticated === true);\n })\n .catch(() => {\n if (!cancelled) setAuthenticated(false);\n });\n return () => {\n cancelled = true;\n };\n }, []);\n return authenticated;\n}\n\n/** 槽位渲染器按注册 `locale` 注入的 translate 形(本插件 `auth` 词典的 `logout` 键)。 */\nexport type LogoutTranslate = (key: string, params?: Record) => string;\n\n/** 设置面板里的登出按钮组件(`settings.general.item` 槽,root 作用域)。 */\nexport interface SettingsLogoutActionProps {\n /** 注入的本地化 translate(locale seat)。 */\n t?: LogoutTranslate;\n}\n\n/**\n * 可复用的登出提交按钮:原生 form POST(零 JS 依赖)+ 16px 方块图标 + 本地化文字。\n * 渲染进 `settings.general.item`(设置 → 通用设置 的追加行槽,order 30 → 页面底部),\n * 水平居中的醒目 CTA;文案随界面语言在「退出登录」/ \"Sign out\" 间切换。\n */\nexport function SettingsLogoutAction({ t }: SettingsLogoutActionProps) {\n const authenticated = useAuthenticated();\n const [hovered, setHovered] = useState(false);\n if (authenticated !== true) return null;\n const label = typeof t === \"function\" ? t(\"logout\") : \"Sign out\";\n return (\n
\n
\n setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n >\n {renderLogoutIcon()}\n {label}\n \n
\n
\n );\n}\n","import { ACCOUNT_DICT_EN, ACCOUNT_DICT_ZH, ACCOUNT_KEYS } from \"./shared/i18n/index.ts\";\nimport { installAccountNavIcon } from \"./features/nav-icon/index.ts\";\nimport { SettingsAccountSection } from \"./widgets/account-section/index.ts\";\nimport { ADMIN_DICT_EN, ADMIN_DICT_ZH } from \"./features/admin-users/index.ts\";\nimport {\n STATUS_PATH,\n type AuthContext,\n type StatusResponse,\n type Unchecked,\n} from \"./shared/api/index.ts\";\nimport { SettingsLogoutAction } from \"./features/logout/index.ts\";\n\n/** 本插件文案的词典命名域(locale 服务按 (ns, locale) 分开注册)。 */\nconst AUTH_NS = \"auth\";\n/** 命名词典里登出键。 */\nconst LOGOUT_KEY = \"logout\";\n\n/** 「账号安全」设置页(自助改密)的 section id。 */\nconst ACCOUNT_SECTION_ID = \"dsh-auth-gate-account\";\n/**\n * 「账号安全」页在设置导航里的排列位置。\n *\n * 已知占用:宿主 general 0 / models 10 / plugins 15 / agent-presets 20(0.1.7 起还有官方云账户\n * `account` -10),姊妹包 dsh-plugin-subscriptions 的订阅页 90,全部远小于 900,所以 900\n * 表示「大于已知的全部条目、落在导航靠后」(账号安全压轴是设置页惯例,宿主 nav 列也没有滚动条)。\n *\n * **不保证全局最后**:宿主只按 order 升序排、没有并列决胜(同值按插件注册顺序,跨安装不稳定),\n * 后来者随时可能注册更大或相同的值。谁先到谁先用 900,撞车时按 901/902 递增(ADR D24 已记录)。\n */\nconst ACCOUNT_SECTION_ORDER = 900;\n\n/**\n * 默认槽位 order:注册时先用它(与 host 端 Config 默认一致),随后 `/auth/status`\n * 探针读到 host 配置的 `logoutOrder` 时按配置重注册。1000 已大于 dsh 自带条目\n * (agent-preset -25 / permission -20 / language 0 / appearance 10 / composer-enter 20),\n * 除非第三方插件注册更大的 order,按钮始终留在通用设置页最底部。\n */\nconst DEFAULT_LOGOUT_ORDER = 1000;\n\n/**\n * dsh-auth-gate client 半边:认证后在**设置面板**(设置 → 通用设置 页底部)挂一\n * 个醒目的「退出登录 / Sign out」按钮:`settings.general.item`(root 作用域、\n * 可追加列表槽,由 ui-settings-general 的 General 页堆叠渲染,按 order 升序)。\n *\n * 顺序可配置:先以默认 order(1000)注册(探针失败/未开始前按钮也可见),再探\n * `/auth/status` 读取 host 配置的 `logoutOrder`,与默认不同则按配置值重注册\n * (同 id 注册 = 槽位替换,先注册新条目再释放旧条目,避免中间态空白)。\n *\n * 换槽对比:不再往会话页 `conversation.session.header.utilities`、新会话页\n * `shell.overlay` 注册(右上角两处入口已移除),也不占侧边栏 footer 脚区。\n * 文案挂进 dsh 现有的 locale 机制(与「设置」里的语言切换同一套):注册 `auth`\n * 词典(zh/en),再以 `locale: \"auth\"` 给注册条目注入 `t` seat,按钮文字随界面\n * 语言在「退出登录」/ \"Sign out\" 间实时切换。不改任何服务端端点/会话语义。\n *\n * 同一次 apply 还注册「账号安全」设置页(`settings.section`,id\n * dsh-auth-gate-account,order 900):内容为自助改密表单(见 account-section.tsx),\n * 文案复用同一个 `auth` 词典的 account 键。登出按钮的注册逻辑与 order 语义不变。\n *\n * 导航行图标(D24.1):宿主 `settings.section` 没有 `icon` 选项(只有 id/order/label),\n * 第三方段的图标恒为宿主默认齿轮;插件侧没有官方挂载点,所以 `account-nav-icon.ts` 只对\n * 我们这一行做一次临时 DOM 垫片(自设计盾牌图标),上游支持 `icon` 后整体删除。\n */\nexport const inject = [\"slots\", \"locale\"];\n\nexport function apply(ctx: AuthContext): void {\n // 词典注册(zh/en 双语,挂 fiber 卸载级联):登出 CTA + 账户页 + 管理块共用 auth\n // 命名域。admin 键(`admin.*`)与 account 键(`account.*`)、`logout` 不重名,可安全合并。\n ctx.effect(\n () => [\n ctx.locale.register(AUTH_NS, \"zh\", {\n [LOGOUT_KEY]: \"退出登录\",\n ...ACCOUNT_DICT_ZH,\n ...ADMIN_DICT_ZH,\n }),\n ctx.locale.register(AUTH_NS, \"en\", {\n [LOGOUT_KEY]: \"Sign out\",\n ...ACCOUNT_DICT_EN,\n ...ADMIN_DICT_EN,\n }),\n ],\n \"auth: zh/en dictionaries (logout + account + admin)\",\n );\n\n // 绑定 translate:读取活动语言(thunk 每次投影重读,跟随语言切换)。\n const t = ctx.locale.bind(AUTH_NS);\n\n ctx.slots.inject(\"settings.general.item\", () => {\n const mount = (order: number): (() => void) =>\n ctx.slots.register(\n {\n name: \"settings.general.item\",\n id: \"dsh-auth-gate-logout\",\n locale: AUTH_NS,\n order,\n label: () => t(LOGOUT_KEY),\n },\n SettingsLogoutAction,\n );\n let dispose: (() => void) | undefined = mount(DEFAULT_LOGOUT_ORDER);\n // 与 SettingsLogoutAction 相同的 status 探针:读 host 的 logoutOrder 配置并在\n // 不同时重注册。探针抛错(如测试环境无 fetch)保持默认,绝不吞掉注册。\n try {\n void fetch(STATUS_PATH)\n .then((res) => res.json() as Promise>)\n .then((body: Unchecked) => {\n const order = body.logoutOrder;\n if (\n typeof order !== \"number\" ||\n !Number.isInteger(order) ||\n order === DEFAULT_LOGOUT_ORDER\n ) {\n return;\n }\n const previous = dispose;\n dispose = mount(order);\n previous?.();\n })\n .catch(() => undefined);\n } catch {\n // fetch 不可用(测试环境/旧浏览器):保持默认 order。\n }\n return () => dispose?.();\n });\n\n // 「账号安全」设置页:注册 `settings.section`(root 作用域、可追加列表槽,导航按\n // order 升序)。内容是自助改密表单,未登录由组件自查 `/auth/status` 后隐藏。\n ctx.slots.inject(\"settings.section\", () =>\n ctx.slots.register(\n {\n name: \"settings.section\",\n id: ACCOUNT_SECTION_ID,\n locale: AUTH_NS,\n order: ACCOUNT_SECTION_ORDER,\n label: () => t(ACCOUNT_KEYS.nav),\n },\n SettingsAccountSection,\n ),\n );\n\n // 导航行图标垫片(D24.1):只替换我们自己那一行,找不到就保持宿主齿轮;没有 DOM 的环境\n // (node 单测)直接 no-op。上游给 `settings.section` 加 `icon` 后,这一行与\n // account-nav-icon.ts 一起删除。\n ctx.effect(\n () => installAccountNavIcon(globalThis.document),\n \"auth: account nav icon shim (D24.1)\",\n );\n}\n"],"mappings":"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"} \ No newline at end of file