dsh-skill-hub 0.3.21 → 0.3.23

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.
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
4
4
  import type { IncomingMessage } from 'node:http'
5
5
  import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill'
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
- import { makeRoutes, type SkillHubRouteDeps } from './routes.ts'
7
+ import { hostTrustFence, makeRoutes, type SkillHubRouteDeps } from './routes.ts'
8
8
  import { lastMarketCheck } from './routes/route-state.ts'
9
9
  import { SkillHubStore, statePath } from './store.ts'
10
10
  import { SKILL_HUB_API, SKILL_HUB_API_ROOT, type CatalogResponse, type ConfigResponse, type ErrorResponse, type HubConfig } from './protocol.ts'
@@ -27,12 +27,12 @@ class FakeResponse {
27
27
  }
28
28
 
29
29
  /** Minimal request double (loopback by default; body available for POST). */
30
- function fakeReq(method: string, url = '/', body?: unknown, remoteAddress = '127.0.0.1'): IncomingMessage {
30
+ function fakeReq(method: string, url = '/', body?: unknown, remoteAddress = '127.0.0.1', host = '127.0.0.1:3080'): IncomingMessage {
31
31
  const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))]
32
32
  return {
33
33
  method,
34
34
  url,
35
- headers: { host: '127.0.0.1:3080' },
35
+ headers: { host },
36
36
  socket: { remoteAddress },
37
37
  async *[Symbol.asyncIterator]() {
38
38
  for (const chunk of chunks) yield chunk
@@ -105,10 +105,76 @@ describe('skill-hub routes', () => {
105
105
  return route
106
106
  }
107
107
 
108
- it('rejects non-loopback requests', async () => {
108
+ it('rejects non-loopback requests with the literal fallback when no host fence is wired', async () => {
109
+ // deps 里没有 fence(非 web 宿主、单测)⇒ 退回历史的字面回环判定。
109
110
  const res = new FakeResponse()
110
111
  await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog, undefined, '10.0.0.5'), res as never)
111
112
  expect(res.status).toBe(403)
113
+ expect(res.json()).toEqual({ error: 'forbidden: untrusted authority' })
114
+ })
115
+
116
+ it('delegates to the host fence and passes its verdict through verbatim (issue #17)', async () => {
117
+ // 反代形态:socket 是网关地址、Host 是公开域名。宿主栅栏认它,本插件不许再挡一道。
118
+ const seen: string[] = []
119
+ deps.fence = (request) => {
120
+ seen.push(String(request.headers.host))
121
+ return undefined
122
+ }
123
+ const ok = new FakeResponse()
124
+ await routeFor(SKILL_HUB_API.catalog)
125
+ .handler(fakeReq('GET', SKILL_HUB_API.catalog, undefined, '172.19.0.1', 'dsh.example.com'), ok as never)
126
+ expect(seen).toEqual(['dsh.example.com'])
127
+ expect(ok.status).toBe(200)
128
+
129
+ // 宿主判定「栅栏过了但没有浏览器会话」时,401 同样原样透传。
130
+ deps.fence = () => 401
131
+ const unauth = new FakeResponse()
132
+ await routeFor(SKILL_HUB_API.catalog).handler(fakeReq('GET', SKILL_HUB_API.catalog), unauth as never)
133
+ expect(unauth.status).toBe(401)
134
+ expect(unauth.json()).toEqual({ error: 'unauthorized: browser session required' })
135
+ })
136
+
137
+ it('hostTrustFence: literal fallback without Connection, delegation with it, fail closed when it throws', () => {
138
+ const remote = fakeReq('GET', '/', undefined, '10.0.0.5')
139
+ expect(hostTrustFence(() => undefined)(remote)).toBe(403)
140
+ expect(hostTrustFence(() => undefined)(fakeReq('GET', '/'))).toBeUndefined()
141
+
142
+ let calls = 0
143
+ const delegated = hostTrustFence(() => ({
144
+ requestRejection: () => { calls += 1; return 401 },
145
+ } as never))
146
+ expect(delegated(remote)).toBe(401)
147
+ expect(calls).toBe(1)
148
+
149
+ const broken = hostTrustFence(() => ({
150
+ requestRejection: () => { throw new Error('fence unavailable') },
151
+ } as never))
152
+ expect(broken(remote)).toBe(403)
153
+ })
154
+
155
+ it('hostTrustFence: a service appearing later un-latches the fallback, and onMissing fires once (issue #17)', () => {
156
+ const remote = fakeReq('GET', '/', undefined, '10.0.0.5')
157
+ let connection: import('@deepseek-ai/dsh-client-connection').HostConnectionHandle | undefined
158
+ let misses = 0
159
+ const fence = hostTrustFence(
160
+ () => connection,
161
+ () => { misses += 1 },
162
+ )
163
+
164
+ // 启动早于 Connection:前几次请求走降级,且只报一次缺失。
165
+ expect(fence(remote)).toBe(403)
166
+ expect(fence(remote)).toBe(403)
167
+ expect(misses).toBe(1)
168
+
169
+ // Connection 上线后同一 fence 立即切换,不锁死在降级分支。
170
+ connection = { requestRejection: () => undefined } as unknown as import('@deepseek-ai/dsh-client-connection').HostConnectionHandle
171
+ expect(fence(remote)).toBeUndefined()
172
+ expect(misses).toBe(1)
173
+
174
+ // 服务再次消失(理论上不该发生)不重复报警,判定回到降级。
175
+ connection = undefined
176
+ expect(fence(remote)).toBe(403)
177
+ expect(misses).toBe(1)
112
178
  })
113
179
 
114
180
  it('reports stats unavailable without a reader, or when all usage displays are off', async () => {
package/src/routes.ts CHANGED
@@ -3,18 +3,22 @@
3
3
  * official registry + hub-disabled skills + discovery diagnostics), skill
4
4
  * detail, enable/disable toggle, new-skill scaffold, user groups (tags +
5
5
  * origin collections), and upstream source tracking (check/sync/follow
6
- * upstream deletion into a restorable trash). Every route carries a
7
- * loopback-only trust fence — these endpoints rename files under the user's
8
- * skill roots, so LAN-exposed dsh web deployments must not serve them.
6
+ * upstream deletion into a restorable trash). Every route carries the host's
7
+ * own `/api` trust fence (loopback, the listener's bind address, each
8
+ * `--trusted-host` authority, then the browser session) — these endpoints
9
+ * rename files under the user's skill roots, so they must never be laxer than
10
+ * the GUI around them (issue #17).
9
11
  *
10
12
  * 按域拆分后的聚合入口:各域 handler 在 ./routes/<domain>.ts,共享围栏在
11
13
  * ./routes/helpers.ts,节流/任务状态在 ./routes/route-state.ts。
12
14
  * 老 `from './routes.ts'`(makeRoutes + SkillHubRouteDeps)保持可用。
13
15
  */
14
16
 
17
+ import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
15
18
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
19
  import { SKILL_HUB_API_ROOT } from './protocol.ts'
17
20
  import { createRoute, writeError, type SkillHubRouteDeps } from './routes/helpers.ts'
21
+ import { loopbackOnlyFence, type RouteFence } from './routes/http.ts'
18
22
  import { catalogRoutes } from './routes/catalog.ts'
19
23
  import { configRoutes } from './routes/config.ts'
20
24
  import { marketRoutes } from './routes/market.ts'
@@ -23,14 +27,54 @@ import { groupRoutes } from './routes/groups.ts'
23
27
  import { sourceRoutes } from './routes/sources.ts'
24
28
 
25
29
  export type { SkillHubRouteDeps, SkillLookupLike } from './routes/helpers.ts'
30
+ export type { RouteFence } from './routes/http.ts'
31
+
32
+ /**
33
+ * Build the family's trust fence from the host's `/api` model.
34
+ *
35
+ * `ctx.connection.requestRejection` is the documented hook for fencing another
36
+ * Web route; with no Connection service (a webserver-only composition, or a
37
+ * unit test) the literal-loopback check stands in, so the fallback is the old
38
+ * behaviour rather than an open route family.
39
+ *
40
+ * The service is read **per request**, not once at mount: plugins start in
41
+ * composition order, so a one-shot read at `apply()` time would latch the
42
+ * fallback whenever this plugin happens to come up before Connection. A
43
+ * persistent absence is almost certainly an upstream composition change rather
44
+ * than a transient startup order, so the first miss fires `onMissing` once —
45
+ * without that signal the fallback would silently reproduce issue #17 after
46
+ * any future upstream rename of the service.
47
+ * @param read - resolves `ctx.connection`, or `undefined` when absent.
48
+ * @param onMissing - called once, the first time the service reads absent.
49
+ * @returns the fence every route in this family runs first.
50
+ */
51
+ export function hostTrustFence(read: () => HostConnectionHandle | undefined, onMissing?: () => void): RouteFence {
52
+ let missingReported = false
53
+ return (request) => {
54
+ const connection = read()
55
+ if (connection === undefined) {
56
+ if (!missingReported) {
57
+ missingReported = true
58
+ onMissing?.()
59
+ }
60
+ return loopbackOnlyFence(request)
61
+ }
62
+ try {
63
+ return connection.requestRejection(request)
64
+ } catch {
65
+ // Fail closed: a broken fence must never open file-mutating routes.
66
+ return 403
67
+ }
68
+ }
69
+ }
26
70
 
27
71
  /**
28
72
  * Build every /api/skill-hub route.
29
- * @param deps - skill registry view + sidecar store.
73
+ * @param deps - skill registry view + sidecar store + trust fence.
30
74
  * @returns the exact-path routes plus the family's 404 catch-all.
31
75
  */
32
76
  export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
33
- // 各域返回裸 spec,统一在这里包上请求围栏(回环信任 → 方法 → 总开关
77
+ // 各域返回裸 spec,统一在这里包上请求围栏(信任栅栏 → 方法 → 总开关
34
78
  // → JSON 体 → 统一错误映射),新路由不会漏掉围栏。
35
79
  const specs = [
36
80
  ...catalogRoutes(deps),