dsh-skill-hub 0.3.21 → 0.3.22
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/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +2 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +59 -5
- package/lib/types/client/locales/settings.d.ts +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/protocol/config.d.ts +1 -1
- package/lib/types/routes/deps.d.ts +8 -1
- package/lib/types/routes/helpers.d.ts +11 -6
- package/lib/types/routes/http.d.ts +27 -2
- package/lib/types/routes.d.ts +29 -4
- package/package.json +1 -1
- package/src/client/locales/settings.ts +2 -2
- package/src/index.ts +15 -3
- package/src/protocol/config.ts +1 -1
- package/src/routes/deps.ts +8 -2
- package/src/routes/helpers.ts +18 -7
- package/src/routes/http.ts +31 -2
- package/src/routes.test.ts +70 -4
- package/src/routes.ts +49 -5
package/src/routes.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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),
|