@dsh-plus/access-gate 0.3.33 → 0.3.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -3,12 +3,10 @@ import { UnwrapVolatile } from "@dsh-plus/shared";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  //#region src/config.d.ts
5
5
  declare const Config: z<Schemastery.ObjectS<NoInfer<{
6
- enabled: z<boolean, boolean, "volatile-defined">;
7
6
  allowedIps: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
8
7
  trustForwardedFor: z<boolean, boolean, "volatile-defined">;
9
8
  autoLoginTrustedIps: z<boolean, boolean, "volatile-defined">;
10
9
  }>>, Schemastery.ObjectT<NoInfer<{
11
- enabled: z<boolean, boolean, "volatile-defined">;
12
10
  allowedIps: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
13
11
  trustForwardedFor: z<boolean, boolean, "volatile-defined">;
14
12
  autoLoginTrustedIps: z<boolean, boolean, "volatile-defined">;
@@ -29,17 +27,21 @@ declare module '@deepseek-ai/cordis' {
29
27
  /** 围栏服务:持有当前配置读取器(volatile 活动引用的平面解包,读即最新)。 */
30
28
  declare class AccessGateService extends Service {
31
29
  private current;
32
- /** 官方 browser-auth 签名密钥(惰性读取缓存;读取失败 = null,autoLogin fail-safe 降级)。 */
30
+ /**
31
+ * 官方 browser-auth 签名密钥(经官方凭据服务的记录读接口取,成功即缓存;
32
+ * 读不到 = null,autoLogin fail-safe 降级为 token 输入页)。
33
+ */
33
34
  private signingSecret;
34
35
  constructor(ctx: Context, config: AccessGateConfig | AccessGateConfigFields);
35
36
  /** 当前生效配置(诊断用)。 */
36
37
  policy(): AccessGateConfig;
37
- /** 官方签名密钥(惰性缓存;失败返回 null 并记录,autoLogin 降级 fail-safe)。 */
38
- private readSigningSecret;
38
+ /**
39
+ * 读取官方签名密钥并缓存:记录缺席/未挂凭据服务时保持未就绪(不缓存失败,
40
+ * 记录建立后由 credentials/record-updated 触发重读)。
41
+ */
42
+ private loadSigningSecret;
39
43
  /** IP 信任自动登录可用性(诊断用):签名密钥可读即为可用。 */
40
- autoLoginSecretReady(logger: {
41
- warn(message: string): void;
42
- }): boolean;
44
+ autoLoginSecretReady(): boolean;
43
45
  }
44
46
  declare function apply(ctx: Context, config: AccessGateConfig | AccessGateConfigFields): void;
45
47
  //#endregion
package/lib/index.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
- import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
2
+ import { credentialKey } from "@deepseek-ai/dsh-credentials";
3
3
  import { unwrapVolatile } from "@dsh-plus/shared";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { createHash, createHmac } from "node:crypto";
6
- import { readFileSync } from "node:fs";
7
- import { join } from "node:path";
8
6
  //#region src/config.ts
9
7
  /**
10
8
  * 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
@@ -13,13 +11,11 @@ import { join } from "node:path";
13
11
  *
14
12
  * 与官方认证合并后:访问凭据唯一化为官方 browser-auth cookie(dsh-auth-*,
15
13
  * 由 client-connection 的 ?token= 启动令牌交换签发),本插件不再持有自有
16
- * token/cookie;allowedIps 降级为可选的附加 IP 围栏。
17
- * 旧配置中的 token/cookieMaxAgeHours/loginFailLimit/loginCooldownMs 键由
18
- * schemastery 透传忽略,不阻断加载(已实测验证)。
14
+ * token/cookie;allowedIps 降级为可选的附加 IP 围栏。整行开关由官方行开关
15
+ * 承担(bundle 层默认 `disabled: true` = 一切放行),配置内无第二套总开关。
19
16
  * @module access-gate/config
20
17
  */
21
18
  const Config = z.object({
22
- enabled: z.boolean().description("访问围栏总开关(false = 一切放行,等价插件缺席)").default(false).volatile(),
23
19
  allowedIps: z.array(z.string()).description("附加 IP 围栏:非空时仅白名单条目(精确 IP 或 CIDR,IPv4/IPv6 皆可)可通行与登录;空 = 不限制来源 IP(仅官方登录保护)").default([]).volatile(),
24
20
  trustForwardedFor: z.boolean().description("是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)").default(true).volatile(),
25
21
  autoLoginTrustedIps: z.boolean().description("IP 信任自动登录:来源命中 allowedIps 白名单且未认证时,服务端自动签发官方登录 cookie(免启动 token,仅对白名单来源生效);默认关闭,需显式开启").default(false).volatile()
@@ -35,10 +31,10 @@ const Config = z.object({
35
31
  * 用户全程无感、免启动 token。
36
32
  *
37
33
  * 契约(对官方 client-connection browser-auth 逐行核实,见 docs/README.md
38
- * 「信任模型」;0.2.1-alpha.2 复核不变):
39
- * - 签名密钥持久化于 $DSH_HOME/.credentials.yaml
40
- * (records → client-connection/browser-session → payload.secret,
41
- * base64url 无填充 32B;跨重启不变);
34
+ * 「信任模型」;0.2.1-rc.2 复核不变):
35
+ * - 签名密钥由官方凭据服务持有:记录键 `client-connection/browser-session`,
36
+ * grant payload `{version: 1, secret: <base64url 无填充 32B>}`(跨重启不变);
37
+ * 本模块只解析官方 readRecord 交回的记录,不再自行读文件;
42
38
  * - cookie 名 = `dsh-auth-<base64url(sha256(authority))>`,
43
39
  * authority = 请求 Host 经 `new URL('http://'+host).host` 归一化
44
40
  * (host:port,含端口;与官方 requestAuthority() 同实现);
@@ -49,8 +45,9 @@ const Config = z.object({
49
45
  * - 服务端校验 `expiresAt - issuedAt <= cookieMaxAgeDays`(默认 30 天)且
50
46
  * 下界 1 天;本模块取官方默认 30 天。
51
47
  *
52
- * 本模块不 import 官方包:密钥从文件读取(dshctl auth.py 同款定向文本
53
- * 解析),crypto 用 node: 内置。全部为纯函数/可注入,测试零网络零依赖。
48
+ * 本模块不 import 官方包:密钥以「已读出的记录值」入参(调用方经
49
+ * ctx.get('credentials').readRecord 取,见 index.ts),crypto 用 node: 内置。
50
+ * 全部为纯函数/可注入,测试零网络零依赖。
54
51
  * 安全:任何解析失败都返回明确错误(fail-loud / fail-safe),绝不产出
55
52
  * 半成品 cookie。
56
53
  * @module access-gate/cookie
@@ -63,8 +60,10 @@ const PAYLOAD_VERSION = 1;
63
60
  const SECRET_BYTES = 32;
64
61
  /** 自签 cookie 有效期:官方默认 cookieMaxAgeDays。 */
65
62
  const COOKIE_MAX_AGE_MS = 2592e6;
66
- /** .credentials.yaml 中 browser-session 段名(AUTH_RECORD_KEY 的 id)。 */
63
+ /** 官方记录键(credentialKey('client-connection', 'browser-session') 的联合形态)。 */
67
64
  const SECRET_SECTION = "client-connection/browser-session";
65
+ /** 官方 grant payload 版本(官方 STORED_SECRET_VERSION)。 */
66
+ const STORED_SECRET_VERSION = 1;
68
67
  /** payload 键序(官方 encodeCookie 的 JSON.stringify 紧凑序列化键序)。 */
69
68
  const PAYLOAD_KEYS = [
70
69
  "version",
@@ -89,33 +88,29 @@ function requestAuthority(headers) {
89
88
  function cookieName(authority) {
90
89
  return COOKIE_PREFIX + base64url(createHash("sha256").update(authority).digest());
91
90
  }
92
- /** 定向解析 .credentials.yaml 中 browser-session 段(dshctl auth.py 同款)。 */
93
- function readSigningSecretFromFile(credentialsYamlPath) {
94
- let text;
95
- try {
96
- text = readFileSync(credentialsYamlPath, "utf8");
97
- } catch (error) {
98
- const detail = error instanceof Error ? error.message : String(error);
99
- throw new Error(`read .credentials.yaml failed (${credentialsYamlPath}): ${detail}`);
100
- }
101
- let inSection = false;
102
- for (const line of text.split(/\r?\n/)) {
103
- const stripped = line.trim();
104
- if (stripped === "" || stripped.startsWith("#")) continue;
105
- if (line.length - line.replace(/^\s*/, "").length <= 2) {
106
- inSection = stripped === `${SECRET_SECTION}:`;
107
- continue;
108
- }
109
- if (inSection) {
110
- const match = /^secret:\s*(\S+)\s*$/.exec(stripped);
111
- if (match !== null) {
112
- const secret = Buffer.from(match[1] ?? "", "base64url");
113
- if (secret.byteLength !== SECRET_BYTES) throw new Error(`browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`);
114
- return secret;
115
- }
116
- }
117
- }
118
- throw new Error(`browser-session secret not found in ${credentialsYamlPath}`);
91
+ /**
92
+ * 从官方记录里取签名密钥:只认 grant + payload.version === 1 + 32 字节
93
+ * base64url secret(与官方 storedSecret() 同判据);任何不符即抛错,
94
+ * 由调用方降级 fail-safe(不铸 cookie)。
95
+ * @param record - `ctx.credentials.readRecord(credentialKey('client-connection', 'browser-session'))` 的返回值。
96
+ * @returns 32 字节签名密钥。
97
+ */
98
+ function signingSecretOfRecord(record) {
99
+ const payload = grantPayloadOf(record);
100
+ if (payload?.version !== STORED_SECRET_VERSION) throw new Error(`${SECRET_SECTION}: credential record has an unsupported format (want version ${STORED_SECRET_VERSION})`);
101
+ const raw = payload.secret;
102
+ if (typeof raw !== "string" || !/^[A-Za-z0-9_-]+$/.test(raw)) throw new Error(`${SECRET_SECTION}: credential record has an invalid secret`);
103
+ const secret = Buffer.from(raw, "base64url");
104
+ if (secret.byteLength !== SECRET_BYTES) throw new Error(`browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`);
105
+ return secret;
106
+ }
107
+ /** 取 grant 记录的 payload 对象;非 grant/非对象返回 undefined。 */
108
+ function grantPayloadOf(record) {
109
+ if (typeof record !== "object" || record === null) return void 0;
110
+ const candidate = record;
111
+ if (candidate.kind !== "grant") return void 0;
112
+ const payload = candidate.payload;
113
+ return typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload : void 0;
119
114
  }
120
115
  /** 官方 sessionCookie() 序列化(属性逐字同款;nowMs 注入保持纯函数可测)。 */
121
116
  function serializeSessionCookie(name, value, expiresAt, nowMs) {
@@ -134,14 +129,6 @@ function mintSessionCookie(secret, authority, nowMs) {
134
129
  const signature = base64url(createHmac("sha256", secret).update(body).digest());
135
130
  return serializeSessionCookie(cookieName(authority), `v1.${body}.${signature}`, expiresAt, nowMs);
136
131
  }
137
- /** 拼接 DSH_HOME 下凭据文件路径(默认 home 与 $DSH_HOME 语义同官方 home-paths)。 */
138
- function credentialsPath(dshHome) {
139
- return join(dshHome, ".credentials.yaml");
140
- }
141
- /** 完整铸造入口:读密钥 + 铸造;任何失败抛带上下文错误(调用方降级 fail-safe)。 */
142
- function mintAutoLoginCookie(credentialsYamlPath, authority, nowMs) {
143
- return mintSessionCookie(readSigningSecretFromFile(credentialsYamlPath), authority, nowMs);
144
- }
145
132
  //#endregion
146
133
  //#region src/ip.ts
147
134
  /** 解析 IPv4 十进制点分字面量;非法返回 null。 */
@@ -295,7 +282,7 @@ function compileAllowlist(entries) {
295
282
  }
296
283
  /**
297
284
  * 围栏判定(纯函数,无任何副作用与 IO)。
298
- * 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
285
+ * 逻辑次序:豁免路径由调用方前置处理;确定有效来源
299
286
  * (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
300
287
  * 白名单非空时作附加 IP 围栏,不命中即拒;autoLoginTrustedIps=true 且
301
288
  * 白名单命中但官方未认证 → autologin(拦截器负责落官方 cookie);
@@ -312,10 +299,6 @@ function compileAllowlist(entries) {
312
299
  * 视同未认证)。
313
300
  */
314
301
  function decideGate(req, policy, officialAuthed) {
315
- if (!policy.enabled) return {
316
- verdict: "pass",
317
- reason: "local"
318
- };
319
302
  const forwarded = forwardedIp(req.headers);
320
303
  const trustedForwarded = policy.trustForwardedFor ? forwarded : void 0;
321
304
  const clientIpText = trustedForwarded ?? req.remoteAddress;
@@ -389,7 +372,6 @@ function createGateRoutes(deps) {
389
372
  remoteAddress: req.socket?.remoteAddress
390
373
  }, config, deps.officialAuth(req));
391
374
  sendJson(res, 200, {
392
- enabled: config.enabled,
393
375
  verdict: decision.verdict,
394
376
  clientIp: decision.clientIp ?? resolveIp(req) ?? null,
395
377
  reason: decision.reason ?? null,
@@ -685,7 +667,6 @@ function installGateInterceptor(ctx, server, deps) {
685
667
  const originalMatch = server.match.bind(server);
686
668
  const gatedMatch = (pathname) => {
687
669
  if (isExemptPath(pathname)) return originalMatch(pathname);
688
- if (!deps.config().enabled) return originalMatch(pathname);
689
670
  const route = originalMatch(pathname);
690
671
  if (route === void 0) return void 0;
691
672
  return {
@@ -744,24 +725,30 @@ function assertGateStructure(server) {
744
725
  //#endregion
745
726
  //#region src/index.ts
746
727
  const name = "dsh-plus-access-gate";
728
+ /** 官方 browser-auth 签名密钥记录键(官方 AUTH_RECORD_KEY 同值)。 */
729
+ const AUTH_RECORD_KEY = credentialKey("client-connection", "browser-session");
747
730
  const inject = ["webServer", "connection"];
748
731
  /** 围栏服务:持有当前配置读取器(volatile 活动引用的平面解包,读即最新)。 */
749
732
  var AccessGateService = class extends Service {
750
733
  current;
751
- /** 官方 browser-auth 签名密钥(惰性读取缓存;读取失败 = null,autoLogin fail-safe 降级)。 */
734
+ /**
735
+ * 官方 browser-auth 签名密钥(经官方凭据服务的记录读接口取,成功即缓存;
736
+ * 读不到 = null,autoLogin fail-safe 降级为 token 输入页)。
737
+ */
752
738
  signingSecret;
753
739
  constructor(ctx, config) {
754
740
  super(ctx, "accessGate");
755
741
  this.current = () => unwrapVolatile(config);
756
742
  const logger = ctx.logger("access-gate");
757
743
  const officialAuth = (req) => ctx.connection.requestRejection({ headers: req.headers }) === void 0;
758
- const secretOf = () => this.readSigningSecret(logger);
744
+ const secretOf = () => this.signingSecret ?? null;
745
+ this.loadSigningSecret(ctx, logger);
759
746
  const autoLoginCookie = (req) => {
760
747
  const secret = secretOf();
761
748
  const authority = requestAuthority(req.headers);
762
749
  if (secret === null || authority === void 0) return null;
763
750
  try {
764
- return mintAutoLoginCookie(credentialsPath(dshHomePath()), authority, Date.now());
751
+ return mintSessionCookie(secret, authority, Date.now());
765
752
  } catch (error) {
766
753
  const message = error instanceof Error ? error.message : String(error);
767
754
  logger.warn(`auto-login cookie mint failed: ${message}`);
@@ -772,7 +759,7 @@ var AccessGateService = class extends Service {
772
759
  config: () => this.current(),
773
760
  officialAuth,
774
761
  authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
775
- autoLoginReady: () => this.autoLoginSecretReady(logger),
762
+ autoLoginReady: () => this.autoLoginSecretReady(),
776
763
  onError: (message) => logger.warn(message)
777
764
  });
778
765
  const undo = installGateInterceptor(ctx, ctx.webServer, {
@@ -787,21 +774,33 @@ var AccessGateService = class extends Service {
787
774
  policy() {
788
775
  return this.current();
789
776
  }
790
- /** 官方签名密钥(惰性缓存;失败返回 null 并记录,autoLogin 降级 fail-safe)。 */
791
- readSigningSecret(logger) {
792
- if (this.signingSecret !== void 0) return this.signingSecret;
777
+ /**
778
+ * 读取官方签名密钥并缓存:记录缺席/未挂凭据服务时保持未就绪(不缓存失败,
779
+ * 记录建立后由 credentials/record-updated 触发重读)。
780
+ */
781
+ async loadSigningSecret(ctx, logger) {
782
+ if (this.signingSecret != null) return;
783
+ const credentials = ctx.get("credentials");
784
+ if (credentials === void 0) {
785
+ logger.warn("auto-login disabled: 当前组合未挂载凭据服务(dsh-credentials-local)");
786
+ return;
787
+ }
793
788
  try {
794
- this.signingSecret = readSigningSecretFromFile(credentialsPath(dshHomePath()));
789
+ const record = await credentials.readRecord(AUTH_RECORD_KEY);
790
+ this.signingSecret = signingSecretOfRecord(record);
791
+ return;
795
792
  } catch (error) {
796
793
  const message = error instanceof Error ? error.message : String(error);
797
- logger.warn(`auto-login disabled: ${message}`);
798
- this.signingSecret = null;
794
+ logger.warn(`auto-login secret unavailable: ${message}`);
799
795
  }
800
- return this.signingSecret;
796
+ ctx.on("credentials/record-updated", (key) => {
797
+ if (String(key) !== String(AUTH_RECORD_KEY) || this.signingSecret != null) return;
798
+ this.loadSigningSecret(ctx, logger);
799
+ });
801
800
  }
802
801
  /** IP 信任自动登录可用性(诊断用):签名密钥可读即为可用。 */
803
- autoLoginSecretReady(logger) {
804
- return this.readSigningSecret(logger) !== null;
802
+ autoLoginSecretReady() {
803
+ return this.signingSecret != null;
805
804
  }
806
805
  };
807
806
  function apply(ctx, config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/access-gate",
3
- "version": "0.3.33",
3
+ "version": "0.3.34",
4
4
  "description": "dsh-plus service+ui plugin: dsh web 全量访问围栏——与官方 browser-auth 合并:官方 cookie 为唯一凭据,可选 IP 附加围栏,未认证导航渲染 token 输入页(PWA 可恢复),loopback 管理通道",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,10 +31,11 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@dsh-plus/shared": "0.1.43"
34
+ "@dsh-plus/shared": "0.1.44"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "^4.0.5",
38
+ "@deepseek-ai/dsh-credentials": "^0.2.1-rc.2",
38
39
  "@deepseek-ai/dsh-home-paths": "^0.2.1-rc.2",
39
40
  "@deepseek-ai/dsh-settings": "^0.2.1-rc.2",
40
41
  "@deepseek-ai/schemastery": "^3.18.5"
@@ -42,6 +43,7 @@
42
43
  "devDependencies": {
43
44
  "@deepseek-ai/cordis": "4.0.5",
44
45
  "@deepseek-ai/dsh-client-connection": "0.2.1-rc.2",
46
+ "@deepseek-ai/dsh-credentials": "0.2.1-rc.2",
45
47
  "@deepseek-ai/dsh-home-paths": "0.2.1-rc.2",
46
48
  "@deepseek-ai/dsh-host-webserver": "0.2.1-rc.2",
47
49
  "@deepseek-ai/dsh-settings": "0.2.1-rc.2",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 「访问控制」配置卡片:经 injectPluginConfigCard 注册(插件页 plugins.row.config /
3
3
  * plugins.bundle.config,view 分发 summary/page)。
4
- * 外壳与基础控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/CheckRow),
4
+ * 外壳与基础控件走 @dsh-plus/shared/client 套件(ConfigShell/TextField/CheckRow),
5
5
  * 本文件保留业务字段(白名单 textarea/诊断面板)与保存逻辑。
6
6
  * 配置读写经共享层 ctx.remote.settings 直连(settingsScope 已由
7
7
  * configForms 取代):value 为 schema 解析后的视图,保存经 settings.update 深合并。
@@ -14,10 +14,10 @@
14
14
  */
15
15
 
16
16
  import {
17
- CardChrome,
18
17
  CardLoading,
19
18
  type CardStatusState,
20
19
  CheckRow,
20
+ ConfigShell,
21
21
  IDLE_STATUS,
22
22
  type NamespaceSettingsApi,
23
23
  type PluginConfigViewProps,
@@ -35,21 +35,18 @@ export interface CardProps extends PluginConfigViewProps {
35
35
 
36
36
  /** settings 命名空间的解析值。 */
37
37
  export interface ConfigValue {
38
- enabled: boolean
39
38
  allowedIps: string[]
40
39
  trustForwardedFor: boolean
41
40
  autoLoginTrustedIps: boolean
42
41
  }
43
42
 
44
43
  interface Draft {
45
- enabled: boolean
46
44
  allowedIpsText: string
47
45
  trustForwardedFor: boolean
48
46
  autoLoginTrustedIps: boolean
49
47
  }
50
48
 
51
49
  interface Diag {
52
- enabled: boolean
53
50
  verdict: string
54
51
  clientIp: string | null
55
52
  reason: string | null
@@ -63,7 +60,6 @@ interface Diag {
63
60
 
64
61
  function draftFromValue(value: ConfigValue): Draft {
65
62
  return {
66
- enabled: value.enabled,
67
63
  allowedIpsText: value.allowedIps.join('\n'),
68
64
  trustForwardedFor: value.trustForwardedFor,
69
65
  autoLoginTrustedIps: value.autoLoginTrustedIps,
@@ -72,7 +68,6 @@ function draftFromValue(value: ConfigValue): Draft {
72
68
 
73
69
  function toPatch(draft: Draft): Record<string, unknown> {
74
70
  return {
75
- enabled: draft.enabled,
76
71
  allowedIps: draft.allowedIpsText
77
72
  .split('\n')
78
73
  .map((s) => s.trim())
@@ -96,7 +91,6 @@ function reasonText(t: Translate, reason: string): string {
96
91
  }
97
92
 
98
93
  function verdictLabel(t: Translate, diag: Diag): string {
99
- if (!diag.enabled) return t('diagOff')
100
94
  if (diag.verdict === 'pass') return t('diagPass')
101
95
  if (diag.verdict === 'token-page') return t('diagTokenPage')
102
96
  if (diag.verdict === 'autologin') return t('diagAutoLogin')
@@ -226,11 +220,10 @@ export function AccessGateCard(props: CardProps): ReactElement | string | null {
226
220
 
227
221
  const disabled = !snapshot.writable
228
222
  return (
229
- <CardChrome
223
+ <ConfigShell
230
224
  prefix="dag"
231
225
  title={t('title')}
232
226
  description={t('description')}
233
- statusBadge={{ text: t(draft.enabled ? 'enabledOn' : 'enabledOff'), on: draft.enabled }}
234
227
  dirty={dirty}
235
228
  dirtyLabel={t('unsaved')}
236
229
  readOnlyNotice={disabled ? t('readOnly') : undefined}
@@ -255,14 +248,6 @@ export function AccessGateCard(props: CardProps): ReactElement | string | null {
255
248
  ]}
256
249
  >
257
250
  <p className="dag-hint">{t('mergedHint')}</p>
258
- <CheckRow
259
- prefix="dag"
260
- id="dag-enabled"
261
- label={t('enabledHint')}
262
- checked={draft.enabled}
263
- disabled={disabled}
264
- onEdit={(v) => edit('enabled', v)}
265
- />
266
251
  <div className="dag-field">
267
252
  <div className="dag-head">
268
253
  <label className="dag-label" htmlFor="dag-allowed">
@@ -296,6 +281,6 @@ export function AccessGateCard(props: CardProps): ReactElement | string | null {
296
281
  onEdit={(v) => edit('trustForwardedFor', v)}
297
282
  />
298
283
  <DiagPanel t={t} diag={diag} />
299
- </CardChrome>
284
+ </ConfigShell>
300
285
  )
301
286
  }
@@ -12,8 +12,6 @@ const ownZh = {
12
12
  description:
13
13
  'Web 访问围栏:与官方登录合并——官方 cookie 为唯一凭据;未认证导航显示启动令牌输入页(PWA 内可用);可选 IP 附加围栏。',
14
14
  summaryLine: 'Web 访问围栏:官方登录合并、启动令牌页与可选 IP 白名单。',
15
- enabled: '启用访问围栏',
16
- enabledHint: '关闭即完全旁路(等价插件缺席);启用后未认证流量被拦截到令牌输入页或拒绝。',
17
15
  mergedHint:
18
16
  '登录凭据由官方认证持有(启动令牌换 30 天 cookie)。获取当前令牌:服务器执行 dshctl url,或本机访问 /dsh-plus/gate/launch-url。',
19
17
  allowedIps: '附加 IP 围栏白名单',
@@ -41,7 +39,6 @@ const ownZh = {
41
39
  diagAutoLoginVerdict: '自动登录(免 token 进入)',
42
40
  diagAutoLoginReady: '已就绪(签名密钥可读)',
43
41
  diagAutoLoginNoSecret: '不可用(签名密钥读取失败,已降级)',
44
- diagOff: '围栏未启用',
45
42
  diagInvalid: '白名单存在无法解析的条目:',
46
43
  } as const
47
44
 
@@ -50,9 +47,6 @@ const ownEn = {
50
47
  description:
51
48
  'Web access gate merged with official auth: the official cookie is the sole credential; unauthenticated navigations get a launch-token page (works inside the PWA); optional IP fence.',
52
49
  summaryLine: 'Web access gate: merged official auth, launch-token page, optional IP allowlist.',
53
- enabled: 'Enable access gate',
54
- enabledHint:
55
- 'Off means full bypass (as if absent); on, unauthenticated traffic hits the token page or is refused.',
56
50
  mergedHint:
57
51
  'Credentials are owned by official auth (launch token exchange, 30-day cookie). Get the current token: run dshctl url on the server, or visit /dsh-plus/gate/launch-url locally.',
58
52
  allowedIps: 'Additional IP fence allowlist',
@@ -80,7 +74,6 @@ const ownEn = {
80
74
  diagAutoLoginVerdict: 'auto login (token-free entry)',
81
75
  diagAutoLoginReady: 'ready (signing secret readable)',
82
76
  diagAutoLoginNoSecret: 'unavailable (signing secret unreadable, degraded)',
83
- diagOff: 'gate disabled',
84
77
  diagInvalid: 'Allowlist entries that failed to parse:',
85
78
  } as const
86
79
 
package/src/config.ts CHANGED
@@ -5,9 +5,8 @@
5
5
  *
6
6
  * 与官方认证合并后:访问凭据唯一化为官方 browser-auth cookie(dsh-auth-*,
7
7
  * 由 client-connection 的 ?token= 启动令牌交换签发),本插件不再持有自有
8
- * token/cookie;allowedIps 降级为可选的附加 IP 围栏。
9
- * 旧配置中的 token/cookieMaxAgeHours/loginFailLimit/loginCooldownMs 键由
10
- * schemastery 透传忽略,不阻断加载(已实测验证)。
8
+ * token/cookie;allowedIps 降级为可选的附加 IP 围栏。整行开关由官方行开关
9
+ * 承担(bundle 层默认 `disabled: true` = 一切放行),配置内无第二套总开关。
11
10
  * @module access-gate/config
12
11
  */
13
12
  import z from '@deepseek-ai/schemastery'
@@ -22,11 +21,6 @@ export const SETTINGS_NS = NS_LITERAL
22
21
  // 行级 config,GUI 写入原位提交热生效),同时使本条目进入 settings describe
23
22
  // 视图(配置卡片可读写);缺 volatile 标记的条目会被 describe 跳过。
24
23
  export const Config = z.object({
25
- enabled: z
26
- .boolean()
27
- .description('访问围栏总开关(false = 一切放行,等价插件缺席)')
28
- .default(false)
29
- .volatile(),
30
24
  allowedIps: z
31
25
  .array(z.string())
32
26
  .description(
@@ -55,9 +49,11 @@ export type AccessGateConfigFields = Schemastery.TypeT<typeof Config>
55
49
  /** 平面配置形态(服务与判定面的消费形态,由活动引用解包得到)。 */
56
50
  export type AccessGateConfig = UnwrapVolatile<AccessGateConfigFields>
57
51
 
58
- /** 判定所需的最小配置视图(测试与决策函数共用窄面)。 */
52
+ /**
53
+ * 判定所需的最小配置视图(测试与决策函数共用窄面)。
54
+ * 整行开关由官方行开关承担(bundle 层默认 `disabled: true`,等价「一切放行」)。
55
+ */
59
56
  export interface GatePolicy {
60
- readonly enabled: boolean
61
57
  readonly allowedIps: readonly string[]
62
58
  readonly trustForwardedFor: boolean
63
59
  readonly autoLoginTrustedIps: boolean
package/src/cookie.ts CHANGED
@@ -7,10 +7,10 @@
7
7
  * 用户全程无感、免启动 token。
8
8
  *
9
9
  * 契约(对官方 client-connection browser-auth 逐行核实,见 docs/README.md
10
- * 「信任模型」;0.2.1-alpha.2 复核不变):
11
- * - 签名密钥持久化于 $DSH_HOME/.credentials.yaml
12
- * (records → client-connection/browser-session → payload.secret,
13
- * base64url 无填充 32B;跨重启不变);
10
+ * 「信任模型」;0.2.1-rc.2 复核不变):
11
+ * - 签名密钥由官方凭据服务持有:记录键 `client-connection/browser-session`,
12
+ * grant payload `{version: 1, secret: <base64url 无填充 32B>}`(跨重启不变);
13
+ * 本模块只解析官方 readRecord 交回的记录,不再自行读文件;
14
14
  * - cookie 名 = `dsh-auth-<base64url(sha256(authority))>`,
15
15
  * authority = 请求 Host 经 `new URL('http://'+host).host` 归一化
16
16
  * (host:port,含端口;与官方 requestAuthority() 同实现);
@@ -21,15 +21,14 @@
21
21
  * - 服务端校验 `expiresAt - issuedAt <= cookieMaxAgeDays`(默认 30 天)且
22
22
  * 下界 1 天;本模块取官方默认 30 天。
23
23
  *
24
- * 本模块不 import 官方包:密钥从文件读取(dshctl auth.py 同款定向文本
25
- * 解析),crypto 用 node: 内置。全部为纯函数/可注入,测试零网络零依赖。
24
+ * 本模块不 import 官方包:密钥以「已读出的记录值」入参(调用方经
25
+ * ctx.get('credentials').readRecord 取,见 index.ts),crypto 用 node: 内置。
26
+ * 全部为纯函数/可注入,测试零网络零依赖。
26
27
  * 安全:任何解析失败都返回明确错误(fail-loud / fail-safe),绝不产出
27
28
  * 半成品 cookie。
28
29
  * @module access-gate/cookie
29
30
  */
30
31
  import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
31
- import { readFileSync } from 'node:fs'
32
- import { join } from 'node:path'
33
32
 
34
33
  /** 官方 cookie 名前缀(client-connection browser-auth 常量)。 */
35
34
  export const COOKIE_PREFIX = 'dsh-auth-'
@@ -39,8 +38,10 @@ const PAYLOAD_VERSION = 1
39
38
  const SECRET_BYTES = 32
40
39
  /** 自签 cookie 有效期:官方默认 cookieMaxAgeDays。 */
41
40
  export const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
42
- /** .credentials.yaml 中 browser-session 段名(AUTH_RECORD_KEY 的 id)。 */
41
+ /** 官方记录键(credentialKey('client-connection', 'browser-session') 的联合形态)。 */
43
42
  const SECRET_SECTION = 'client-connection/browser-session'
43
+ /** 官方 grant payload 版本(官方 STORED_SECRET_VERSION)。 */
44
+ const STORED_SECRET_VERSION = 1
44
45
  /** payload 键序(官方 encodeCookie 的 JSON.stringify 紧凑序列化键序)。 */
45
46
  const PAYLOAD_KEYS = ['version', 'authority', 'issuedAt', 'expiresAt'] as const
46
47
 
@@ -135,38 +136,42 @@ export function isAuthenticatedCookie(
135
136
  return false
136
137
  }
137
138
 
138
- /** 定向解析 .credentials.yaml 中 browser-session 段(dshctl auth.py 同款)。 */
139
- export function readSigningSecretFromFile(credentialsYamlPath: string): Buffer {
140
- let text: string
141
- try {
142
- text = readFileSync(credentialsYamlPath, 'utf8')
143
- } catch (error) {
144
- const detail = error instanceof Error ? error.message : String(error)
145
- throw new Error(`read .credentials.yaml failed (${credentialsYamlPath}): ${detail}`)
139
+ /**
140
+ * 从官方记录里取签名密钥:只认 grant + payload.version === 1 + 32 字节
141
+ * base64url secret(与官方 storedSecret() 同判据);任何不符即抛错,
142
+ * 由调用方降级 fail-safe(不铸 cookie)。
143
+ * @param record - `ctx.credentials.readRecord(credentialKey('client-connection', 'browser-session'))` 的返回值。
144
+ * @returns 32 字节签名密钥。
145
+ */
146
+ export function signingSecretOfRecord(record: unknown): Buffer {
147
+ const payload = grantPayloadOf(record)
148
+ if (payload?.version !== STORED_SECRET_VERSION) {
149
+ throw new Error(
150
+ `${SECRET_SECTION}: credential record has an unsupported format (want version ${STORED_SECRET_VERSION})`,
151
+ )
152
+ }
153
+ const raw = payload.secret
154
+ if (typeof raw !== 'string' || !/^[A-Za-z0-9_-]+$/.test(raw)) {
155
+ throw new Error(`${SECRET_SECTION}: credential record has an invalid secret`)
146
156
  }
147
- let inSection = false
148
- for (const line of text.split(/\r?\n/)) {
149
- const stripped = line.trim()
150
- if (stripped === '' || stripped.startsWith('#')) continue
151
- const indent = line.length - line.replace(/^\s*/, '').length
152
- if (indent <= 2) {
153
- inSection = stripped === `${SECRET_SECTION}:`
154
- continue
155
- }
156
- if (inSection) {
157
- const match = /^secret:\s*(\S+)\s*$/.exec(stripped)
158
- if (match !== null) {
159
- const secret = Buffer.from(match[1] ?? '', 'base64url')
160
- if (secret.byteLength !== SECRET_BYTES) {
161
- throw new Error(
162
- `browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`,
163
- )
164
- }
165
- return secret
166
- }
167
- }
157
+ const secret = Buffer.from(raw, 'base64url')
158
+ if (secret.byteLength !== SECRET_BYTES) {
159
+ throw new Error(
160
+ `browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`,
161
+ )
168
162
  }
169
- throw new Error(`browser-session secret not found in ${credentialsYamlPath}`)
163
+ return secret
164
+ }
165
+
166
+ /** 取 grant 记录的 payload 对象;非 grant/非对象返回 undefined。 */
167
+ function grantPayloadOf(record: unknown): Record<string, unknown> | undefined {
168
+ if (typeof record !== 'object' || record === null) return undefined
169
+ const candidate = record as { kind?: unknown; payload?: unknown }
170
+ if (candidate.kind !== 'grant') return undefined
171
+ const payload = candidate.payload
172
+ return typeof payload === 'object' && payload !== null && !Array.isArray(payload)
173
+ ? (payload as Record<string, unknown>)
174
+ : undefined
170
175
  }
171
176
 
172
177
  /** 官方 sessionCookie() 序列化(属性逐字同款;nowMs 注入保持纯函数可测)。 */
@@ -195,18 +200,3 @@ export function mintSessionCookie(secret: Buffer, authority: string, nowMs: numb
195
200
  const signature = base64url(createHmac('sha256', secret).update(body).digest())
196
201
  return serializeSessionCookie(cookieName(authority), `v1.${body}.${signature}`, expiresAt, nowMs)
197
202
  }
198
-
199
- /** 拼接 DSH_HOME 下凭据文件路径(默认 home 与 $DSH_HOME 语义同官方 home-paths)。 */
200
- export function credentialsPath(dshHome: string): string {
201
- return join(dshHome, '.credentials.yaml')
202
- }
203
-
204
- /** 完整铸造入口:读密钥 + 铸造;任何失败抛带上下文错误(调用方降级 fail-safe)。 */
205
- export function mintAutoLoginCookie(
206
- credentialsYamlPath: string,
207
- authority: string,
208
- nowMs: number,
209
- ): string {
210
- const secret = readSigningSecretFromFile(credentialsYamlPath)
211
- return mintSessionCookie(secret, authority, nowMs)
212
- }