dsh-session-guard 4.1.11 → 4.1.16

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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * BridgeDocHandle — 设置卡双轨数据源的桥轨(M4/T20-b)。
3
+ *
4
+ * ≤0.1.5(settingsScope fiber 永不解 / 0.1.0-0.1.1 无 client 服务)时,
5
+ * 配置卡经本句柄走自家 webServer 桥(describe/mutate)读写宿主 settings 服务。
6
+ * 与原生 SettingsScope(scope-face.ts)同构:getSnapshot/subscribe/set/unset。
7
+ * 读 = POST /api/<ns>/settings/describe;写 = POST …/mutate(ops 转发宿主服务),
8
+ * 成功即用回填描述符刷新快照并通知订阅者(无本地第二份状态——权威值始终在宿主)。
9
+ *
10
+ * 与 perm-gate src/client/bridge-scope.ts(T13b/T20-b 泛化模板)的差异:
11
+ * ① 快照形状对齐本仓 SettingsScope(status: loading/ready/unavailable + mode);
12
+ * ② describe 未就绪(settings-unavailable/ns-not-registered)时有限重试
13
+ * (宿主 settings 服务解析晚于卡挂载的窗口),重试耗尽才落 'unavailable'。
14
+ */
15
+
16
+ /** 与 server 侧 settingsBridgeRoutesFor(src/settings-bridge.js)必须逐字符一致。 */
17
+ function settingsBridgeRoutesFor(ns: string): { describe: string; mutate: string } {
18
+ return { describe: `/api/${ns}/settings/describe`, mutate: `/api/${ns}/settings/mutate` }
19
+ }
20
+
21
+ interface Snapshot<T> {
22
+ status: 'loading' | 'ready' | 'unavailable'
23
+ value: T | undefined
24
+ revision: number | undefined
25
+ writable: boolean
26
+ base: unknown
27
+ user: unknown
28
+ mode: 'bridge'
29
+ }
30
+
31
+ interface Descriptor {
32
+ value?: unknown
33
+ revision?: number
34
+ [k: string]: unknown
35
+ }
36
+
37
+ interface BridgeBody {
38
+ ok?: boolean
39
+ code?: string
40
+ message?: string
41
+ value?: { descriptor?: Descriptor; writable?: boolean }
42
+ }
43
+
44
+ interface MutateOp {
45
+ op: 'set' | 'unset'
46
+ path: string[]
47
+ value?: unknown
48
+ }
49
+
50
+ /**
51
+ * ≤0.1.5 宿主 describe 的 descriptor.value 是「每字段 → {}」骨架(0.1.0/0.1.5
52
+ * 实测:value={enabled:{},…,officialProviders:{}},真值拆在 base(合并默认层)
53
+ * 与 user(用户层))——直接吞 value 会让卡拿到 officialProviders={},
54
+ * `?? []` 不兜底({} 非 nullish)→ ListRow values.join 崩,整族 tab 槽殉葬。
55
+ * 判特征:对象且每个自有值都是空 plain 对象(guard 字段是标量/数组,不会全空)。
56
+ * 命中即以 {...base, ...user} 重组有效值(guard 配置全是顶层标量/数组,浅合并即语义)。
57
+ */
58
+ function isFieldSkeleton(v: unknown): v is Record<string, Record<string, never>> {
59
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
60
+ const values = Object.values(v as Record<string, unknown>)
61
+ return values.length > 0
62
+ && values.every((x) => x !== null && typeof x === 'object' && !Array.isArray(x) && Object.keys(x as object).length === 0)
63
+ }
64
+
65
+ function plainObject(v: unknown): Record<string, unknown> {
66
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? { ...(v as Record<string, unknown>) } : {}
67
+ }
68
+
69
+ /** descriptor → 卡面可用 plain 值:正常代际用 value;骨架代际用 base+user 浅合并。 */
70
+ function descriptorPlainValue(d: Descriptor): unknown {
71
+ if (!isFieldSkeleton(d.value)) return d.value
72
+ return { ...plainObject(d.base), ...plainObject(d.user) }
73
+ }
74
+
75
+ /** describe 不可就绪码:宿主 settings 未解析/未注册——值得重试的瞬态。 */
76
+ const RETRYABLE_CODES = new Set(['settings-unavailable', 'ns-not-registered'])
77
+ const RETRY_DELAY_MS = 1500
78
+ const RETRY_MAX = 4
79
+
80
+ export class BridgeDocHandle<T> {
81
+ private snapshot: Snapshot<T>
82
+ private listeners = new Set<() => void>()
83
+ private revision: number | undefined
84
+ private retriesLeft = RETRY_MAX
85
+ private inflight: Promise<void> | undefined
86
+ private readonly ns: string
87
+ private readonly routes: { describe: string; mutate: string }
88
+
89
+ constructor(ns: string) {
90
+ this.ns = ns
91
+ this.routes = settingsBridgeRoutesFor(ns)
92
+ this.snapshot = {
93
+ status: 'loading',
94
+ value: undefined,
95
+ revision: undefined,
96
+ writable: true,
97
+ base: undefined,
98
+ user: undefined,
99
+ mode: 'bridge',
100
+ }
101
+ void this.refresh()
102
+ }
103
+
104
+ getSnapshot(): Snapshot<T> {
105
+ return this.snapshot
106
+ }
107
+
108
+ subscribe(listener: () => void): () => void {
109
+ this.listeners.add(listener)
110
+ return () => {
111
+ this.listeners.delete(listener)
112
+ }
113
+ }
114
+
115
+ async set(field: string, value: unknown): Promise<boolean> {
116
+ return this.mutate([{ op: 'set', path: [field], value }])
117
+ }
118
+
119
+ async unset(field: string): Promise<boolean> {
120
+ return this.mutate([{ op: 'unset', path: [field] }])
121
+ }
122
+
123
+ private notify(): void {
124
+ for (const l of [...this.listeners]) l()
125
+ }
126
+
127
+ private applyDescriptor(d: Descriptor, writable: boolean): void {
128
+ this.revision = typeof d.revision === 'number' ? d.revision : undefined
129
+ this.snapshot = {
130
+ status: 'ready',
131
+ value: descriptorPlainValue(d) as T | undefined,
132
+ revision: this.revision,
133
+ writable,
134
+ base: undefined,
135
+ user: undefined,
136
+ mode: 'bridge',
137
+ }
138
+ }
139
+
140
+ private async request(path: string, body?: unknown): Promise<BridgeBody> {
141
+ const response = await fetch(path, {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json' },
144
+ body: JSON.stringify(body ?? {}),
145
+ })
146
+ if (!response.ok) throw new Error(`bridge ${path} HTTP ${response.status}`)
147
+ return (await response.json()) as BridgeBody
148
+ }
149
+
150
+ /** describe 拉取;瞬态未就绪码在重试预算内延迟重拉,耗尽才落 unavailable。 */
151
+ refresh(): Promise<void> {
152
+ if (this.inflight !== undefined) return this.inflight
153
+ this.inflight = (async () => {
154
+ try {
155
+ const body = await this.request(this.routes.describe)
156
+ if (body.ok && body.value?.descriptor !== undefined) {
157
+ this.retriesLeft = RETRY_MAX
158
+ this.applyDescriptor(body.value.descriptor, body.value.writable !== false)
159
+ } else if (body.code !== undefined && RETRYABLE_CODES.has(body.code) && this.retriesLeft > 0) {
160
+ this.retriesLeft -= 1
161
+ this.inflight = undefined
162
+ setTimeout(() => {
163
+ void this.refresh()
164
+ }, RETRY_DELAY_MS)
165
+ return
166
+ } else {
167
+ this.snapshot = { ...this.snapshot, status: 'unavailable', writable: false }
168
+ }
169
+ } catch {
170
+ // 网络异常:按瞬态处理,预算内重试
171
+ if (this.retriesLeft > 0) {
172
+ this.retriesLeft -= 1
173
+ this.inflight = undefined
174
+ setTimeout(() => {
175
+ void this.refresh()
176
+ }, RETRY_DELAY_MS)
177
+ return
178
+ }
179
+ this.snapshot = { ...this.snapshot, status: 'unavailable', writable: false }
180
+ }
181
+ // 重试路径已在上面清过 inflight(setTimeout 后由 refresh() 重建);
182
+ // 终态路径在此清挂起标记并广播。重试等待期 notify 无害(快照未变)。
183
+ this.inflight = undefined
184
+ this.notify()
185
+ })()
186
+ return this.inflight
187
+ }
188
+
189
+ private async mutate(ops: MutateOp[]): Promise<boolean> {
190
+ const body = await this.request(this.routes.mutate, { ops, expectedRevision: this.revision })
191
+ if (body.ok && body.value?.descriptor !== undefined) {
192
+ this.applyDescriptor(body.value.descriptor, this.snapshot.writable)
193
+ this.notify()
194
+ return true
195
+ }
196
+ if (body.code === 'settings-conflict') {
197
+ await this.refresh() // 乐观并发失败:拉权威值,让下次写入带新 revision
198
+ return false
199
+ }
200
+ throw new Error(`bridge mutate failed: ${body.code ?? 'unknown'} ${body.message ?? ''}`)
201
+ }
202
+ }
@@ -223,6 +223,12 @@ function SelectRow<T extends string>(props: {
223
223
  )
224
224
  }
225
225
 
226
+ /** 卡面字段消毒:宿主 describe 形态异常(≤0.1.5 骨架残留等)时槽崩会殉葬全族
227
+ * tab——ListRow 的 values 非 nullish 非数组(如 {})会让 `?? []` 失守,逐卡硬护栏。 */
228
+ function toList(v: unknown): string[] {
229
+ return Array.isArray(v) ? v.map(String) : []
230
+ }
231
+
226
232
  /** 插件配置卡片主体:可展开的 <li> + header 按钮 + pill switch 字段体。 */
227
233
  export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
228
234
  const snapshot = useSyncExternalStore(
@@ -303,7 +309,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
303
309
  <ListRow
304
310
  label="追加官方 provider id"
305
311
  description="精确匹配,优先级最高(逗号分隔)"
306
- values={value.officialProviders ?? []}
312
+ values={toList(value.officialProviders)}
307
313
  placeholder="deepseek-official"
308
314
  disabled={readonly}
309
315
  onCommit={(next) => { void scope.set('officialProviders', next) }}
@@ -311,7 +317,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
311
317
  <ListRow
312
318
  label="官方端点名单"
313
319
  description="baseURL 归一化后的 host(逗号分隔)"
314
- values={value.officialBaseURLs ?? []}
320
+ values={toList(value.officialBaseURLs)}
315
321
  placeholder="api.deepseek.com"
316
322
  disabled={readonly}
317
323
  onCommit={(next) => { void scope.set('officialBaseURLs', next) }}
@@ -319,7 +325,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
319
325
  <SelectRow<'hold' | 'error'>
320
326
  label="拦截方式"
321
327
  description="挂起等待不报错;或报错并记入延后队列"
322
- value={value.deferredMode ?? 'hold'}
328
+ value={value.deferredMode === 'error' ? 'error' : 'hold'}
323
329
  options={[
324
330
  { value: 'hold', label: '挂起等待(退峰自动放行)' },
325
331
  { value: 'error', label: '报错并延后(退峰续跑)' },
package/src/index.js CHANGED
@@ -24,6 +24,7 @@ import { detectTaskControl } from './detect.js'
24
24
  import { makeIsRoot } from './request-guard.js'
25
25
  import { createWiring } from './wiring.js'
26
26
  import { NS, DEFAULT_SETTINGS, SettingsSchema } from './settings.js'
27
+ import { registerSettingsBridgeRoutes } from './settings-bridge.js'
27
28
 
28
29
  // 0.1.7:声明式设置 —— volatile 字段即自动表单。
29
30
  export const Config = SettingsSchema
@@ -66,10 +67,13 @@ export function apply(ctx, config = {}) {
66
67
  // 否则 client 配置卡报「设置命名空间不可用」。两条 legacy 路径按服务能力
67
68
  // 自动分派:0.1.2/0.1.5 = 服务实例 installSection;0.1.0/0.1.1 = 模块级
68
69
  // installSettingsSection + settingsNamespace。fail-open:失败仅告警。
70
+ /** 宿主 settings 服务活引用(桥的惰性 getter 用;apply 期注入解到即填)。 */
71
+ let settingsSvcRef
69
72
  try {
70
73
  ctx.inject(['settings'], (sctx) => {
71
74
  const service = sctx.settings
72
75
  if (service === undefined || service === null) return
76
+ settingsSvcRef = service
73
77
  const hooks = {
74
78
  setSource: (source) => {
75
79
  configSource = typeof source === 'function' ? source() : source
@@ -460,4 +464,16 @@ export function apply(ctx, config = {}) {
460
464
  }
461
465
  }, 'session-guard: routes')
462
466
  }
467
+
468
+ // ── settings webServer 桥(M4/T20-b 泛化):describe/mutate 两端点 ──────
469
+ // 双轨数据源的桥轨:≤0.1.5(settingsScope 死路/无服务)时 client
470
+ // BridgeDocHandle 经此读写宿主 settings 服务。getSettingsSvc 惰性取
471
+ // (cordis ctx.get 永不 throw)——宿主 settings 解析早晚不影响注册。
472
+ // 置于 prefix 路由之后(routes[0] = /session-guard 前缀,契约与测试锚定)。
473
+ // fail-open:注册失败 error 告警(对齐 L474 风格),不炸插件加载。
474
+ try {
475
+ registerSettingsBridgeRoutes(ctx.webServer, () => settingsSvcRef, NS)
476
+ } catch (error) {
477
+ ctx.logger?.error?.(`[session-guard] settings bridge registration failed: ${String(error && error.message || error)}`)
478
+ }
463
479
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * dsh-session-guard — settings webServer 桥(M4/T20-b 泛化)。
3
+ *
4
+ * 设置卡双轨数据源的桥轨:≤0.1.5(settingsScope fiber 永不解 / 0.1.0-0.1.1 无
5
+ * client 服务)时,client BridgeDocHandle 经 describe/mutate 两端点读写宿主
6
+ * settings 服务。桥只是 HTTP 皮:读 = svc.describe 取自家 ns 描述符;写 =
7
+ * svc.mutate 转 ops 落宿主服务(不落第二份状态);mutate 成功即 describe 回填,
8
+ * 带 expectedRevision 乐观并发。守卫:POST-only + redactSecrets + 256KB。
9
+ * 语义与 perm-gate src/bridge.ts(5260fe6 泛化)逐字对齐,本仓为 JS 形态。
10
+ */
11
+
12
+ /** 一个 settings ns 的桥路由对——client 侧(src/client/settings-bridge.ts)必须同式派生(测试钉死一致)。 */
13
+ function settingsBridgeRoutesFor(ns) {
14
+ return { describe: `/api/${ns}/settings/describe`, mutate: `/api/${ns}/settings/mutate` }
15
+ }
16
+
17
+ function readJsonBody(req, limit = 256 * 1024) {
18
+ return new Promise((resolve, reject) => {
19
+ const chunks = []
20
+ let size = 0
21
+ req.on('data', (chunk) => {
22
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
23
+ size += buf.length
24
+ if (size > limit) {
25
+ reject(new Error('bridge body too large'))
26
+ if (typeof req.resume === 'function') req.resume()
27
+ return
28
+ }
29
+ chunks.push(buf)
30
+ })
31
+ req.on('end', () => {
32
+ const raw = Buffer.concat(chunks).toString('utf8')
33
+ if (raw === '') {
34
+ resolve({})
35
+ return
36
+ }
37
+ try {
38
+ resolve(JSON.parse(raw))
39
+ } catch (e) {
40
+ reject(e instanceof Error ? e : new Error(String(e)))
41
+ }
42
+ })
43
+ req.on('error', reject)
44
+ })
45
+ }
46
+
47
+ function sendJson(res, code, body) {
48
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
49
+ res.end(JSON.stringify(body))
50
+ }
51
+
52
+ function isConflict(e) {
53
+ return e instanceof Error && /conflict|revision/i.test(e.message)
54
+ }
55
+
56
+ function describeNs(svc, ns) {
57
+ return svc.describe({ redactSecrets: true }).find((d) => d.ns === ns)
58
+ }
59
+
60
+ /**
61
+ * 注册 describe/mutate 两端点(kind:'exact')。
62
+ * @param {unknown} server 宿主 webServer 服务(须有 register(kind,path,handler))
63
+ * @param {() => unknown} getSettingsSvc 惰性取宿主 settings 服务(cordis ctx.get 永不 throw,可安全常取)
64
+ * @param {string} ns 自家 settings 命名空间
65
+ * @returns {Array<() => void>} 注销函数数组
66
+ */
67
+ export function registerSettingsBridgeRoutes(server, getSettingsSvc, ns) {
68
+ if (typeof server !== 'object' || server === null) return []
69
+ const ws = typeof server.register === 'function' ? server : undefined
70
+ if (ws === undefined) return []
71
+ const offs = []
72
+ const routes = settingsBridgeRoutesFor(ns)
73
+
74
+ offs.push(ws.register({
75
+ kind: 'exact',
76
+ path: routes.describe,
77
+ handler: (req, res) => {
78
+ if (req.method !== 'POST') {
79
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
80
+ return
81
+ }
82
+ const svc = getSettingsSvc()
83
+ if (svc === undefined || svc === null || typeof svc.describe !== 'function') {
84
+ sendJson(res, 200, { ok: false, code: 'settings-unavailable', message: 'settings service not resolved yet' })
85
+ return
86
+ }
87
+ try {
88
+ const descriptor = describeNs(svc, ns)
89
+ if (descriptor === undefined) {
90
+ sendJson(res, 200, { ok: false, code: 'ns-not-registered', message: `settings namespace "${ns}" is not registered` })
91
+ return
92
+ }
93
+ sendJson(res, 200, { ok: true, value: { descriptor, writable: svc.writable !== false } })
94
+ } catch (e) {
95
+ sendJson(res, 200, { ok: false, code: 'internal', message: e instanceof Error ? e.message : String(e) })
96
+ }
97
+ },
98
+ }))
99
+
100
+ offs.push(ws.register({
101
+ kind: 'exact',
102
+ path: routes.mutate,
103
+ handler: (req, res) => {
104
+ if (req.method !== 'POST') {
105
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
106
+ return
107
+ }
108
+ const svc = getSettingsSvc()
109
+ if (svc === undefined || svc === null || typeof svc.mutate !== 'function') {
110
+ sendJson(res, 200, { ok: false, code: 'settings-unavailable', message: 'settings service mutate not available' })
111
+ return
112
+ }
113
+ readJsonBody(req).then((body) => {
114
+ const ops = body.ops
115
+ const valid = Array.isArray(ops) && ops.every((op) => op !== null && typeof op === 'object' && (op.op === 'set' || op.op === 'unset'))
116
+ if (!valid) {
117
+ sendJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge mutate ops' })
118
+ return
119
+ }
120
+ const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
121
+ svc.mutate(ns, ops, expectedRevision).then(() => {
122
+ // mutate 成功即 describe 回填(写路径归口宿主服务,无第二份状态)
123
+ const descriptor = describeNs(svc, ns)
124
+ if (descriptor === undefined) {
125
+ sendJson(res, 200, { ok: false, code: 'internal', message: `namespace "${ns}" disposed after mutate` })
126
+ return
127
+ }
128
+ sendJson(res, 200, { ok: true, value: { descriptor } })
129
+ }).catch((e) => {
130
+ sendJson(res, 200, isConflict(e)
131
+ ? { ok: false, code: 'settings-conflict', message: e instanceof Error ? e.message : String(e) }
132
+ : { ok: false, code: 'internal', message: e instanceof Error ? e.message : String(e) })
133
+ })
134
+ }).catch((e) => {
135
+ sendJson(res, 200, { ok: false, code: 'bad-request', message: e instanceof Error ? e.message : String(e) })
136
+ })
137
+ },
138
+ }))
139
+
140
+ return offs
141
+ }
@@ -1,69 +0,0 @@
1
- /**
2
- * dsh-session-guard — 无设置页宿主(0.1.0/0.1.1)的兜底配置入口。
3
- *
4
- * 壳级 `shell.overlay` 席位渲染:右下角固定齿轮钮,点开固定定位浮层承载
5
- * SessionGuardCard(page 形态,默认展开)。配置存储全在 host 侧(settings
6
- * 服务),浮层与宿主设置页解耦——壳再老也不影响读写。有设置页的宿主上
7
- * 该齿轮与行配置卡/家族 tab 并存,作为全局快捷入口。
8
- */
9
- import { useState } from 'react'
10
- import { SessionGuardCard } from './settings-card'
11
- import type { SessionGuardConfig } from './settings-card'
12
- import type { SettingsScope } from './scope-face'
13
-
14
- export function ShellSettingsEntry(props: {
15
- scope: SettingsScope<SessionGuardConfig>
16
- }): JSX.Element {
17
- const [open, setOpen] = useState(false)
18
- return (
19
- <div style={{ position: 'fixed', right: 16, bottom: 64, zIndex: 10000 }}>
20
- {open && (
21
- <div
22
- onClick={() => setOpen(false)}
23
- style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,.45)', zIndex: 10000 }}
24
- />
25
- )}
26
- {open && (
27
- <div
28
- style={{
29
- position: 'fixed',
30
- top: '6vh',
31
- left: '50%',
32
- transform: 'translateX(-50%)',
33
- width: 'min(760px, 92vw)',
34
- maxHeight: '84vh',
35
- overflowY: 'auto',
36
- zIndex: 10001,
37
- }}
38
- >
39
- <button
40
- type="button"
41
- onClick={() => setOpen(false)}
42
- style={{ position: 'sticky', top: 6, float: 'right', marginRight: 8, zIndex: 2 }}
43
- >
44
- ✕ 关闭
45
- </button>
46
- <SessionGuardCard scope={props.scope} />
47
- </div>
48
- )}
49
- <button
50
- type="button"
51
- title="会话守护设置"
52
- onClick={() => setOpen(state => !state)}
53
- style={{
54
- width: 40,
55
- height: 40,
56
- borderRadius: '50%',
57
- border: '1px solid rgba(127,127,127,.4)',
58
- background: 'rgba(30,30,32,.85)',
59
- color: 'inherit',
60
- cursor: 'pointer',
61
- fontSize: 16,
62
- boxShadow: '0 4px 16px rgba(0,0,0,.35)',
63
- }}
64
- >
65
- 🛡
66
- </button>
67
- </div>
68
- )
69
- }