@dsh-plus/access-gate 0.3.5 → 0.3.7
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/client.js +11 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +2 -2
- package/src/client/card.tsx +12 -5
- package/src/client/client.ts +4 -24
- package/src/client/i18n.ts +10 -4
- package/src/cookie.ts +24 -20
- package/src/decision.ts +13 -5
- package/src/interceptor.ts +5 -2
package/lib/client.js
CHANGED
|
@@ -133,7 +133,10 @@ window.__ModuleLoader__.load({
|
|
|
133
133
|
confirm: "Confirm",
|
|
134
134
|
cancel: "Cancel"
|
|
135
135
|
};
|
|
136
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* own 覆盖同键合并(返回新对象,入参不可变)。
|
|
138
|
+
* 返回类型保留两侧键集合,调用方据此派生 DictKey。
|
|
139
|
+
*/
|
|
137
140
|
function mergeDict(base, own) {
|
|
138
141
|
return {
|
|
139
142
|
...base,
|
|
@@ -369,12 +372,17 @@ ${extra}
|
|
|
369
372
|
autoLoginTrustedIps: draft.autoLoginTrustedIps
|
|
370
373
|
};
|
|
371
374
|
}
|
|
372
|
-
/** reason 枚举 → 文案 key
|
|
375
|
+
/** reason 枚举 → 文案 key(未知 reason 原样展示服务端文本,不当作文案键)。 */
|
|
373
376
|
const REASON_KEYS = {
|
|
374
377
|
local: "diagLocal",
|
|
375
378
|
cookie: "diagCookie",
|
|
376
379
|
"trusted-ip": "diagTrustedIp"
|
|
377
380
|
};
|
|
381
|
+
/** reason → 展示文本:已知枚举走本地化,未知回落服务端原串。 */
|
|
382
|
+
function reasonText(t, reason) {
|
|
383
|
+
const key = REASON_KEYS[reason];
|
|
384
|
+
return key === void 0 ? reason : t(key);
|
|
385
|
+
}
|
|
378
386
|
function verdictLabel(t, diag) {
|
|
379
387
|
if (!diag.enabled) return t("diagOff");
|
|
380
388
|
if (diag.verdict === "pass") return t("diagPass");
|
|
@@ -554,7 +562,7 @@ ${extra}
|
|
|
554
562
|
children: t("diagReason")
|
|
555
563
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
556
564
|
className: "dag-diagVal",
|
|
557
|
-
children: diag.reason !== null ? t
|
|
565
|
+
children: diag.reason !== null ? reasonText(t, diag.reason) : "—"
|
|
558
566
|
})]
|
|
559
567
|
}),
|
|
560
568
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
package/lib/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ type AccessGateConfig = Schemastery.TypeT<typeof Config>;
|
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region src/index.d.ts
|
|
18
18
|
declare const name = "dsh-plus-access-gate";
|
|
19
|
-
declare const inject: readonly [
|
|
19
|
+
declare const inject: readonly ['webServer', 'connection'];
|
|
20
20
|
declare module '@deepseek-ai/cordis' {
|
|
21
21
|
interface Context {
|
|
22
22
|
accessGate: AccessGateService;
|
package/lib/index.js
CHANGED
|
@@ -726,7 +726,7 @@ function installGateInterceptor(ctx, server, deps) {
|
|
|
726
726
|
wrapped.clear();
|
|
727
727
|
} });
|
|
728
728
|
const undo = () => {
|
|
729
|
-
for (
|
|
729
|
+
for (const restore of [...restores].reverse()) restore.undo();
|
|
730
730
|
};
|
|
731
731
|
return undo;
|
|
732
732
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/access-gate",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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,7 +31,7 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@dsh-plus/shared": "0.1.
|
|
34
|
+
"@dsh-plus/shared": "0.1.16"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@deepseek-ai/cordis": "^4.0.2",
|
package/src/client/card.tsx
CHANGED
|
@@ -21,9 +21,10 @@ import {
|
|
|
21
21
|
type Scope,
|
|
22
22
|
} from '@dsh-plus/shared/client'
|
|
23
23
|
import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
24
|
+
import type { DictKey, Translate } from './i18n.ts'
|
|
24
25
|
|
|
25
26
|
export interface CardProps {
|
|
26
|
-
t
|
|
27
|
+
t: Translate
|
|
27
28
|
scope: Scope
|
|
28
29
|
api: NamespaceSettingsApi
|
|
29
30
|
}
|
|
@@ -77,14 +78,20 @@ function toPatch(draft: Draft): Record<string, unknown> {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/** reason 枚举 → 文案 key
|
|
81
|
-
const REASON_KEYS: Record<string,
|
|
81
|
+
/** reason 枚举 → 文案 key(未知 reason 原样展示服务端文本,不当作文案键)。 */
|
|
82
|
+
const REASON_KEYS: Record<string, DictKey | undefined> = {
|
|
82
83
|
local: 'diagLocal',
|
|
83
84
|
cookie: 'diagCookie',
|
|
84
85
|
'trusted-ip': 'diagTrustedIp',
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
/** reason → 展示文本:已知枚举走本地化,未知回落服务端原串。 */
|
|
89
|
+
function reasonText(t: Translate, reason: string): string {
|
|
90
|
+
const key = REASON_KEYS[reason]
|
|
91
|
+
return key === undefined ? reason : t(key)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function verdictLabel(t: Translate, diag: Diag): string {
|
|
88
95
|
if (!diag.enabled) return t('diagOff')
|
|
89
96
|
if (diag.verdict === 'pass') return t('diagPass')
|
|
90
97
|
if (diag.verdict === 'token-page') return t('diagTokenPage')
|
|
@@ -251,7 +258,7 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
|
|
|
251
258
|
<div className="dag-diagRow">
|
|
252
259
|
<span className="dag-diagKey">{t('diagReason')}</span>
|
|
253
260
|
<span className="dag-diagVal">
|
|
254
|
-
{diag.reason !== null ? t
|
|
261
|
+
{diag.reason !== null ? reasonText(t, diag.reason) : '—'}
|
|
255
262
|
</span>
|
|
256
263
|
</div>
|
|
257
264
|
<div className="dag-diagRow">
|
package/src/client/client.ts
CHANGED
|
@@ -16,11 +16,11 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
16
16
|
import {
|
|
17
17
|
createNamespaceApi,
|
|
18
18
|
createSettingsScope,
|
|
19
|
-
type
|
|
19
|
+
type PluginClientContext,
|
|
20
20
|
} from '@dsh-plus/shared/client'
|
|
21
21
|
import { SETTINGS_NS } from '../ns.ts'
|
|
22
22
|
import { AccessGateCard } from './card.tsx'
|
|
23
|
-
import { en, NS, zh } from './i18n.ts'
|
|
23
|
+
import { type DictKey, en, NS, zh } from './i18n.ts'
|
|
24
24
|
import { injectStyle } from './styles.ts'
|
|
25
25
|
|
|
26
26
|
export const name = 'dsh-plus-access-gate'
|
|
@@ -28,28 +28,8 @@ export const name = 'dsh-plus-access-gate'
|
|
|
28
28
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
29
29
|
export const inject = ['slots', 'locale', 'remote', 'remote.settings'] as const
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
register(options: Record<string, unknown>, component: unknown): () => void
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface LocaleLike {
|
|
37
|
-
register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
|
|
38
|
-
bind(ns: string): (key: string) => string
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface RemoteLike {
|
|
42
|
-
settings: SettingsRemoteFace
|
|
43
|
-
$on(event: string, listener: (payload?: unknown) => void): () => void
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface ClientContext {
|
|
47
|
-
slots: SlotsLike
|
|
48
|
-
locale: LocaleLike
|
|
49
|
-
get(key: 'remote'): RemoteLike
|
|
50
|
-
on(event: string, listener: () => void): () => void
|
|
51
|
-
effect(execute: () => () => void, label?: string): unknown
|
|
52
|
-
}
|
|
31
|
+
/** 宿主窄面收编在 @dsh-plus/shared/client;TKey 传入本包 DictKey 使键名受检。 */
|
|
32
|
+
type ClientContext = PluginClientContext<DictKey>
|
|
53
33
|
|
|
54
34
|
export function apply(ctx: Context): void {
|
|
55
35
|
const c = ctx as unknown as ClientContext
|
package/src/client/i18n.ts
CHANGED
|
@@ -42,7 +42,7 @@ const ownZh = {
|
|
|
42
42
|
diagAutoLoginNoSecret: '不可用(签名密钥读取失败,已降级)',
|
|
43
43
|
diagOff: '围栏未启用',
|
|
44
44
|
diagInvalid: '白名单存在无法解析的条目:',
|
|
45
|
-
}
|
|
45
|
+
} as const
|
|
46
46
|
|
|
47
47
|
const ownEn = {
|
|
48
48
|
title: 'Access Control',
|
|
@@ -80,7 +80,13 @@ const ownEn = {
|
|
|
80
80
|
diagAutoLoginNoSecret: 'unavailable (signing secret unreadable, degraded)',
|
|
81
81
|
diagOff: 'gate disabled',
|
|
82
82
|
diagInvalid: 'Allowlist entries that failed to parse:',
|
|
83
|
-
}
|
|
83
|
+
} as const
|
|
84
84
|
|
|
85
|
-
export
|
|
86
|
-
|
|
85
|
+
export type DictKey = keyof typeof commonZh | keyof typeof ownZh
|
|
86
|
+
|
|
87
|
+
/** 卡片/面板共用的翻译函数类型(slot 注入的 bind 结果按此消费)。 */
|
|
88
|
+
export type Translate = (key: DictKey) => string
|
|
89
|
+
|
|
90
|
+
// 标注为 Record<DictKey, string>:en 缺任一键即编译期报错(中英强制对齐)。
|
|
91
|
+
export const zh: Record<DictKey, string> = mergeDict(commonZh, ownZh)
|
|
92
|
+
export const en: Record<DictKey, string> = mergeDict(commonEn, ownEn)
|
package/src/cookie.ts
CHANGED
|
@@ -74,11 +74,29 @@ function validSignature(value: string, secret: Buffer): boolean {
|
|
|
74
74
|
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** cookie 载荷的三字段(官方 encodeCookie 的键集;version 由形状守卫单判)。 */
|
|
78
|
+
interface CookiePayload {
|
|
79
|
+
authority: string
|
|
80
|
+
issuedAt: number
|
|
81
|
+
expiresAt: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 载荷形状守卫(类型谓词):version 必须等于 PAYLOAD_VERSION,三字段类型正确。
|
|
86
|
+
* 逐字段 `in` + typeof 收窄取代裸断言——JSON.parse 回来的是 unknown,
|
|
87
|
+
* 只有守卫通过后才允许按 CookiePayload 访问。
|
|
88
|
+
*/
|
|
89
|
+
function isCookiePayload(value: unknown): value is CookiePayload {
|
|
90
|
+
if (typeof value !== 'object' || value === null) return false
|
|
91
|
+
if (!('version' in value) || value.version !== PAYLOAD_VERSION) return false
|
|
92
|
+
if (!('authority' in value) || typeof value.authority !== 'string') return false
|
|
93
|
+
if (!('issuedAt' in value) || typeof value.issuedAt !== 'number') return false
|
|
94
|
+
if (!('expiresAt' in value) || typeof value.expiresAt !== 'number') return false
|
|
95
|
+
return Number.isSafeInteger(value.issuedAt) && Number.isSafeInteger(value.expiresAt)
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/** 解码 cookie 载荷(返回 null = 值损坏/签名不符/字段缺失,语义与官方一致)。 */
|
|
78
|
-
export function decodeCookiePayload(
|
|
79
|
-
value: string,
|
|
80
|
-
secret: Buffer,
|
|
81
|
-
): { authority: string; issuedAt: number; expiresAt: number } | null {
|
|
99
|
+
export function decodeCookiePayload(value: string, secret: Buffer): CookiePayload | null {
|
|
82
100
|
if (!validSignature(value, secret)) return null
|
|
83
101
|
const body = value.split('.')[1]
|
|
84
102
|
if (body === undefined) return null
|
|
@@ -88,22 +106,8 @@ export function decodeCookiePayload(
|
|
|
88
106
|
} catch {
|
|
89
107
|
return null
|
|
90
108
|
}
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
decoded === null ||
|
|
94
|
-
!('version' in decoded) ||
|
|
95
|
-
(decoded as { version?: unknown }).version !== PAYLOAD_VERSION ||
|
|
96
|
-
typeof (decoded as { authority?: unknown }).authority !== 'string' ||
|
|
97
|
-
!Number.isSafeInteger((decoded as { issuedAt?: unknown }).issuedAt) ||
|
|
98
|
-
!Number.isSafeInteger((decoded as { expiresAt?: unknown }).expiresAt)
|
|
99
|
-
) {
|
|
100
|
-
return null
|
|
101
|
-
}
|
|
102
|
-
const { authority, issuedAt, expiresAt } = decoded as {
|
|
103
|
-
authority: string
|
|
104
|
-
issuedAt: number
|
|
105
|
-
expiresAt: number
|
|
106
|
-
}
|
|
109
|
+
if (!isCookiePayload(decoded)) return null
|
|
110
|
+
const { authority, issuedAt, expiresAt } = decoded
|
|
107
111
|
const now = Date.now()
|
|
108
112
|
if (issuedAt > now || expiresAt <= now || expiresAt <= issuedAt) return null
|
|
109
113
|
if (expiresAt - issuedAt > COOKIE_MAX_AGE_MS) return null
|
package/src/decision.ts
CHANGED
|
@@ -29,12 +29,18 @@ export type GateVerdict =
|
|
|
29
29
|
/** 判定输出。 */
|
|
30
30
|
export interface GateDecision {
|
|
31
31
|
verdict: GateVerdict
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* 还原出的客户端 IP(有 XFF 或非 loopback 直连时存在)。
|
|
34
|
+
*
|
|
35
|
+
* 三个可选字段并上 `| undefined`:构造处用条件表达式直接给值
|
|
36
|
+
* (如 `const invalidEntries = ... ? x : undefined`),而消费方一律按
|
|
37
|
+
* 「缺席或 undefined 等价」处理(routes.ts 用 `?? []` 兜底)。
|
|
38
|
+
*/
|
|
39
|
+
clientIp?: string | undefined
|
|
34
40
|
/** 放行原因(诊断/日志用):local / cookie / trusted-ip。 */
|
|
35
|
-
reason?: 'local' | 'cookie' | 'trusted-ip'
|
|
41
|
+
reason?: 'local' | 'cookie' | 'trusted-ip' | undefined
|
|
36
42
|
/** 无效白名单条目(卡片诊断用,判定时忽略它们)。 */
|
|
37
|
-
invalidEntries?: string[]
|
|
43
|
+
invalidEntries?: string[] | undefined
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
/** 判定输入的请求事实(headers 小写键,node:http 原生形态)。 */
|
|
@@ -42,7 +48,9 @@ export interface GateRequest {
|
|
|
42
48
|
readonly url: string
|
|
43
49
|
readonly method: string
|
|
44
50
|
readonly headers: Record<string, unknown>
|
|
45
|
-
|
|
51
|
+
// 并上 undefined:来源是 `req.socket?.remoteAddress`(socket 可能缺席),
|
|
52
|
+
// 消费方 isLoopbackAddress 本就接受 string | undefined。
|
|
53
|
+
readonly remoteAddress?: string | undefined
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/** 取 XFF 最左条目(serve 单跳覆盖语义下即真实客户端 IP)。 */
|
package/src/interceptor.ts
CHANGED
|
@@ -41,7 +41,9 @@ type UpgradeRoute = { path: string; handler: UpgradeHandler }
|
|
|
41
41
|
export interface GateWebServer {
|
|
42
42
|
match(pathname: string): unknown
|
|
43
43
|
upgrades: Map<string, UpgradeRoute>
|
|
44
|
-
fallback
|
|
44
|
+
// 并上 undefined:本插件需在卸载时把 fallback 还原为「原本就没有」的状态
|
|
45
|
+
// (`server.fallback = originalFallback`,其值可能为 undefined)。
|
|
46
|
+
fallback?: HttpHandler | undefined
|
|
45
47
|
registerFallback(handler: HttpHandler): unknown
|
|
46
48
|
registerUpgrade(route: UpgradeRoute): unknown
|
|
47
49
|
[key: string]: unknown
|
|
@@ -293,7 +295,8 @@ export function installGateInterceptor(
|
|
|
293
295
|
})
|
|
294
296
|
|
|
295
297
|
const undo = (): void => {
|
|
296
|
-
|
|
298
|
+
// 逆序还原(后装先撤);forEach 直接取项,免去索引访问的 undefined 分支。
|
|
299
|
+
for (const restore of [...restores].reverse()) restore.undo()
|
|
297
300
|
}
|
|
298
301
|
void ctx
|
|
299
302
|
return undo
|