@linxin666/dsh-remote-web-ui 0.2.3 → 0.2.5
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/README.i18n.yaml +2 -2
- package/README.md +148 -77
- package/README.zh.md +32 -26
- package/lib/client.js +610 -74
- package/lib/client.js.map +1 -1
- package/lib/index.js +676 -35
- package/lib/mobile.js +10 -7
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FenceNotice.d.ts +21 -0
- package/lib/types/client/FenceNotice.d.ts.map +1 -0
- package/lib/types/client/FenceNotice.js +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +18 -11
- package/lib/types/client/RemotePanel.d.ts +10 -4
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -6
- package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -1
- package/lib/types/client/device-name.d.ts +3 -0
- package/lib/types/client/device-name.d.ts.map +1 -0
- package/lib/types/client/device-name.js +30 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +48 -0
- package/lib/types/client/locales.d.ts +51 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +83 -33
- package/lib/types/client/pair-api.d.ts +35 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +39 -0
- package/lib/types/client/remote-channel.d.ts +93 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -0
- package/lib/types/client/remote-channel.js +246 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +5 -2
- package/lib/types/index.d.ts +19 -12
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +73 -5
- package/lib/types/loopback-proxy.d.ts +28 -0
- package/lib/types/loopback-proxy.d.ts.map +1 -0
- package/lib/types/loopback-proxy.js +112 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +6 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +6 -2
- package/lib/types/mobile-routes.js +1 -1
- package/lib/types/pairing.d.ts +63 -11
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +137 -20
- package/lib/types/pairing.persist.test.js +60 -5
- package/lib/types/posture.d.ts +75 -0
- package/lib/types/posture.d.ts.map +1 -0
- package/lib/types/posture.js +116 -0
- package/lib/types/remote-api.d.ts +60 -0
- package/lib/types/remote-api.d.ts.map +1 -0
- package/lib/types/remote-api.js +181 -0
- package/lib/types/remote-methods.d.ts +33 -0
- package/lib/types/remote-methods.d.ts.map +1 -0
- package/lib/types/remote-methods.js +54 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +33 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +10 -2
- package/package.json +13 -13
- package/src/client/FenceNotice.tsx +43 -0
- package/src/client/RemoteEntry.tsx +19 -9
- package/src/client/RemotePanel.tsx +83 -10
- package/src/client/RemoteSettingsCard.tsx +16 -0
- package/src/client/device-name.ts +31 -0
- package/src/client/index.ts +46 -0
- package/src/client/locales.ts +83 -33
- package/src/client/pair-api.ts +60 -0
- package/src/client/remote-channel.ts +269 -0
- package/src/client/remote.module.css +240 -16
- package/src/client/settings-form.ts +8 -6
- package/src/dsh-home.ts +36 -0
- package/src/gate.ts +5 -2
- package/src/index.ts +89 -17
- package/src/loopback-proxy.ts +126 -0
- package/src/mobile/views/SessionListView.tsx +6 -1
- package/src/mobile-api.ts +6 -2
- package/src/mobile-routes.ts +1 -1
- package/src/pairing.persist.test.ts +64 -5
- package/src/pairing.ts +167 -20
- package/src/posture.ts +154 -0
- package/src/remote-api.ts +217 -0
- package/src/remote-methods.ts +62 -0
- package/src/routes.ts +33 -4
- package/src/update.ts +14 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote desktop data channel: `/remote` is this plugin's own prefix, so
|
|
3
|
+
* the paired-device cookie is the access control (exactly like `/m/api`).
|
|
4
|
+
* After that gate, every fenced same-origin path the browser rewrote here is
|
|
5
|
+
* re-issued to 127.0.0.1 as a loopback-shaped request so sibling plugin
|
|
6
|
+
* fences (and the connection plugin's `/api`) accept it — no `--trusted-host`
|
|
7
|
+
* and no per-plugin pairing consult.
|
|
8
|
+
*
|
|
9
|
+
* Security model:
|
|
10
|
+
* - Every request must carry a live paired-device cookie, enforced before
|
|
11
|
+
* any bytes are forwarded and before any host call.
|
|
12
|
+
* - The SDK's loopback-only privileged methods (native dialogs, the settings
|
|
13
|
+
* plane, credentials — the `PRIVILEGED_METHODS` set of client-connection)
|
|
14
|
+
* are denied here. The set is pinned by tests/remote-contract.spec.ts.
|
|
15
|
+
* - `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`,
|
|
16
|
+
* `/api/dsh-desktop-launcher/*` and `/api/dsh-web-ui-settings/*` stay physically local.
|
|
17
|
+
* - Everything else is HTTP- or WebSocket-proxied to the local port with
|
|
18
|
+
* Host rewritten, Origin and cookies dropped, and a synthetic same-origin
|
|
19
|
+
* browser marker added after authentication. Plugin loopback fences then
|
|
20
|
+
* pass. The pairing cookie never leaves this process.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
24
|
+
import type { Duplex } from 'node:stream'
|
|
25
|
+
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
26
|
+
import type { PairingService } from './pairing.ts'
|
|
27
|
+
import { readCookie } from './gate.ts'
|
|
28
|
+
import { writeJson } from './http.ts'
|
|
29
|
+
import { proxyLoopbackHttp, proxyLoopbackUpgrade } from './loopback-proxy.ts'
|
|
30
|
+
import {
|
|
31
|
+
DESKTOP_LAUNCHER_PATH,
|
|
32
|
+
LOOPBACK_ONLY_METHODS,
|
|
33
|
+
PLUGIN_MANAGER_PATH,
|
|
34
|
+
REMOTE_API_PATHS,
|
|
35
|
+
REMOTE_PREFIX,
|
|
36
|
+
REMOTE_UPGRADE_PATHS,
|
|
37
|
+
WEB_UI_SETTINGS_BRIDGE_PATH,
|
|
38
|
+
} from './remote-methods.ts'
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
DESKTOP_LAUNCHER_PATH,
|
|
42
|
+
LOOPBACK_ONLY_METHODS,
|
|
43
|
+
PLUGIN_MANAGER_PATH,
|
|
44
|
+
REMOTE_API_PATHS,
|
|
45
|
+
REMOTE_PREFIX,
|
|
46
|
+
REMOTE_UPGRADE_PATHS,
|
|
47
|
+
WEB_UI_SETTINGS_BRIDGE_PATH,
|
|
48
|
+
} from './remote-methods.ts'
|
|
49
|
+
export { REMOTE_API_PREFIX } from './remote-methods.ts'
|
|
50
|
+
|
|
51
|
+
const ALLOWED_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'])
|
|
52
|
+
|
|
53
|
+
/** Reject traversal and empty segments; allow plugin file-path characters. */
|
|
54
|
+
function isSafeSegment(segment: string): boolean {
|
|
55
|
+
if (segment === '') return false
|
|
56
|
+
let decoded: string
|
|
57
|
+
try {
|
|
58
|
+
decoded = decodeURIComponent(segment)
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
return decoded !== '.' && decoded !== '..' && !decoded.includes('/') && !decoded.includes('\\') && !decoded.includes('\0')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Route-family dependencies. */
|
|
66
|
+
export interface RemoteApiDeps {
|
|
67
|
+
/** The pairing service (device gate + cookie name). */
|
|
68
|
+
service: PairingService
|
|
69
|
+
/** The local webServer port the loopback proxy connects to. */
|
|
70
|
+
port: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One SDK-shaped error envelope (keeps the desktop client's parse path intact). */
|
|
74
|
+
function envelopeError(res: ServerResponse, status: number, rpcId: string, code: string, message: string): void {
|
|
75
|
+
writeJson(res, status, {
|
|
76
|
+
type: 'server-response',
|
|
77
|
+
rpcId,
|
|
78
|
+
result: { ok: false, error: { code, message, details: { issues: [] } } },
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Map `/remote/...` to the inner path, or undefined when the outer path is
|
|
84
|
+
* not a safe rewrite target.
|
|
85
|
+
*/
|
|
86
|
+
export function innerPathOf(pathname: string): string | undefined {
|
|
87
|
+
if (pathname === REMOTE_PREFIX || pathname === `${REMOTE_PREFIX}/`) return undefined
|
|
88
|
+
if (!pathname.startsWith(`${REMOTE_PREFIX}/`)) return undefined
|
|
89
|
+
const rest = pathname.slice(REMOTE_PREFIX.length)
|
|
90
|
+
if (!rest.startsWith('/')) return undefined
|
|
91
|
+
const segments = rest.slice(1).split('/')
|
|
92
|
+
if (segments.length === 0 || segments.some(segment => !isSafeSegment(segment))) {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
return rest
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a paired inner path must stay physically local.
|
|
100
|
+
* @returns a denial message, or undefined when the path may be proxied.
|
|
101
|
+
*/
|
|
102
|
+
export function loopbackOnlyDenial(innerPath: string): string | undefined {
|
|
103
|
+
if (innerPath === '/api/pair' || innerPath.startsWith('/api/pair/')) {
|
|
104
|
+
return 'pairing endpoints stay loopback-only and stay unreachable from a paired remote desktop'
|
|
105
|
+
}
|
|
106
|
+
if (innerPath === '/api/update' || innerPath.startsWith('/api/update/')) {
|
|
107
|
+
return 'update endpoints stay loopback-only and stay unreachable from a paired remote desktop'
|
|
108
|
+
}
|
|
109
|
+
if (innerPath === PLUGIN_MANAGER_PATH || innerPath.startsWith(`${PLUGIN_MANAGER_PATH}/`)) {
|
|
110
|
+
return 'plugin-manager stays loopback-only and stays unreachable from a paired remote desktop'
|
|
111
|
+
}
|
|
112
|
+
if (innerPath === DESKTOP_LAUNCHER_PATH || innerPath.startsWith(`${DESKTOP_LAUNCHER_PATH}/`)) {
|
|
113
|
+
return 'desktop-launcher endpoints stay loopback-only and stay unreachable from a paired remote desktop'
|
|
114
|
+
}
|
|
115
|
+
if (innerPath === WEB_UI_SETTINGS_BRIDGE_PATH || innerPath.startsWith(`${WEB_UI_SETTINGS_BRIDGE_PATH}/`)) {
|
|
116
|
+
return 'settings-bridge endpoints stay loopback-only and stay unreachable from a paired remote desktop'
|
|
117
|
+
}
|
|
118
|
+
if (!innerPath.startsWith('/api/')) return undefined
|
|
119
|
+
const method = innerPath.slice('/api/'.length)
|
|
120
|
+
if (method !== '' && !method.includes('/') && LOOPBACK_ONLY_METHODS.has(method)) {
|
|
121
|
+
return `${method} is loopback-only and stays unreachable from a paired remote desktop`
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the remote desktop channel HTTP routes.
|
|
128
|
+
* @param deps - pairing service + local port.
|
|
129
|
+
* @returns the routes to register on webServer.
|
|
130
|
+
*/
|
|
131
|
+
export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
|
|
132
|
+
const { service, port } = deps
|
|
133
|
+
|
|
134
|
+
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
135
|
+
// Cookie gate first — same order as /m/api. Do not buffer an unpaired body.
|
|
136
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
137
|
+
const paired = deviceId !== undefined && service.touchDevice(deviceId)
|
|
138
|
+
if (!paired) {
|
|
139
|
+
req.resume()
|
|
140
|
+
envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const method = req.method ?? 'GET'
|
|
145
|
+
if (!ALLOWED_METHODS.has(method)) {
|
|
146
|
+
req.resume()
|
|
147
|
+
res.writeHead(405).end()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
152
|
+
const inner = innerPathOf(url.pathname)
|
|
153
|
+
if (inner === undefined) {
|
|
154
|
+
req.resume()
|
|
155
|
+
res.writeHead(404).end()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const denied = loopbackOnlyDenial(inner)
|
|
159
|
+
if (denied !== undefined) {
|
|
160
|
+
req.resume()
|
|
161
|
+
envelopeError(res, 403, 'invalid-request', 'forbidden', denied)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
proxyLoopbackHttp(req, res, port, `${inner}${url.search}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Map one outer upgrade URL onto the loopback path (query string included).
|
|
173
|
+
*/
|
|
174
|
+
export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string {
|
|
175
|
+
if (reqUrl === undefined || reqUrl === '') return fallbackPath
|
|
176
|
+
let url: URL
|
|
177
|
+
try {
|
|
178
|
+
url = new URL(reqUrl, 'http://127.0.0.1')
|
|
179
|
+
} catch {
|
|
180
|
+
return fallbackPath
|
|
181
|
+
}
|
|
182
|
+
const inner = innerPathOf(url.pathname)
|
|
183
|
+
if (inner === undefined) return fallbackPath
|
|
184
|
+
return `${inner}${url.search}`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build the WebSocket upgrade routes for the event streams and known plugin
|
|
189
|
+
* sockets. webServer matches upgrades by exact path.
|
|
190
|
+
* @param deps - pairing service + local port.
|
|
191
|
+
* @returns the upgrade routes to register on webServer.
|
|
192
|
+
*/
|
|
193
|
+
export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[] {
|
|
194
|
+
const { service, port } = deps
|
|
195
|
+
|
|
196
|
+
const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
|
|
197
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
198
|
+
if (deviceId === undefined || !service.touchDevice(deviceId)) {
|
|
199
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
200
|
+
socket.destroy()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
const inner = upgradeInnerPath(req.url, fallbackPath)
|
|
204
|
+
const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner)
|
|
205
|
+
if (denied !== undefined) {
|
|
206
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
207
|
+
socket.destroy()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
proxyLoopbackUpgrade(req, socket, head, port, inner)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return REMOTE_UPGRADE_PATHS.map((path) => ({
|
|
214
|
+
path,
|
|
215
|
+
handler: handlerFor(path.slice(REMOTE_PREFIX.length)),
|
|
216
|
+
}))
|
|
217
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote desktop channel constants — SDK-independent so tests and the
|
|
3
|
+
* client half can pin them without importing the host SDK graph.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
|
|
7
|
+
export const REMOTE_PREFIX = '/remote'
|
|
8
|
+
|
|
9
|
+
/** Connection-plugin method prefix under the gated channel. */
|
|
10
|
+
export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
|
|
11
|
+
|
|
12
|
+
/** WebSocket event-stream paths served by the channel (client rewrites to these). */
|
|
13
|
+
export const REMOTE_API_PATHS = {
|
|
14
|
+
mux: `${REMOTE_API_PREFIX}/events.mux`,
|
|
15
|
+
host: `${REMOTE_API_PREFIX}/events.host`,
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Exact upgrade paths registered on webServer (the SDK matches upgrades by
|
|
20
|
+
* exact path, not prefix). Query strings ride on the request URL.
|
|
21
|
+
*/
|
|
22
|
+
export const REMOTE_UPGRADE_PATHS = [
|
|
23
|
+
REMOTE_API_PATHS.mux,
|
|
24
|
+
REMOTE_API_PATHS.host,
|
|
25
|
+
`${REMOTE_PREFIX}/sidebar/ws/terminal`,
|
|
26
|
+
`${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
|
|
27
|
+
`${REMOTE_API_PREFIX}/dsh-ssh/terminal`,
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
/** Plugin-manager HTTP prefix: install/remove stay physically local. */
|
|
31
|
+
export const PLUGIN_MANAGER_PATH = '/api/plugin-manager'
|
|
32
|
+
|
|
33
|
+
/** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
|
|
34
|
+
export const DESKTOP_LAUNCHER_PATH = '/api/dsh-desktop-launcher'
|
|
35
|
+
|
|
36
|
+
/** Family settings-bridge HTTP prefix: describe/mutate stay physically local. */
|
|
37
|
+
export const WEB_UI_SETTINGS_BRIDGE_PATH = '/api/dsh-web-ui-settings'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Loopback-only methods of the host API surface, mirrored from
|
|
41
|
+
* client-connection's `PRIVILEGED_METHODS` (pinned by
|
|
42
|
+
* tests/remote-contract.spec.ts against the installed SDK). They stay
|
|
43
|
+
* unreachable from a paired remote desktop, matching the SDK's own stance
|
|
44
|
+
* that the configuration plane is loopback-same-origin only.
|
|
45
|
+
*/
|
|
46
|
+
export const LOOPBACK_ONLY_METHODS = new Set([
|
|
47
|
+
'agentPreset.read',
|
|
48
|
+
'agentPreset.copy',
|
|
49
|
+
'agentPreset.openDocument',
|
|
50
|
+
'agentPreset.remove',
|
|
51
|
+
'host.pickDirectory',
|
|
52
|
+
'host.openPath',
|
|
53
|
+
'settings.describe',
|
|
54
|
+
'settings.openDocument',
|
|
55
|
+
'settings.update',
|
|
56
|
+
'settings.replace',
|
|
57
|
+
'settings.mutate',
|
|
58
|
+
'credentials.describe',
|
|
59
|
+
'credentials.set',
|
|
60
|
+
'credentials.unset',
|
|
61
|
+
'llm.discoverModels',
|
|
62
|
+
])
|
package/src/routes.ts
CHANGED
|
@@ -88,6 +88,7 @@ export const PAIR_PATHS = {
|
|
|
88
88
|
issue: '/api/pair/issue',
|
|
89
89
|
accept: '/api/pair/accept',
|
|
90
90
|
stop: '/api/pair/stop',
|
|
91
|
+
revoke: '/api/pair/revoke',
|
|
91
92
|
heartbeat: '/api/pair/heartbeat',
|
|
92
93
|
status: '/api/pair/status',
|
|
93
94
|
events: '/api/pair/events',
|
|
@@ -108,6 +109,9 @@ export const issuePayloadSchema = z.object({
|
|
|
108
109
|
export const acceptPayloadSchema = z.object({
|
|
109
110
|
token: z.string().default(''),
|
|
110
111
|
})
|
|
112
|
+
export const revokePayloadSchema = z.object({
|
|
113
|
+
deviceId: z.string().min(1),
|
|
114
|
+
})
|
|
111
115
|
export const pairActionPayloadSchema = z.object({}).passthrough()
|
|
112
116
|
|
|
113
117
|
/**
|
|
@@ -310,7 +314,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
310
314
|
writeJson(res, 400, { ok: false, code: 'bad-payload' })
|
|
311
315
|
return
|
|
312
316
|
}
|
|
313
|
-
const
|
|
317
|
+
const ua = req.headers['user-agent']
|
|
318
|
+
const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined)
|
|
314
319
|
if (!result.ok) {
|
|
315
320
|
writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
|
|
316
321
|
return
|
|
@@ -342,6 +347,26 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
342
347
|
writeJson(res, 200, { ok: true })
|
|
343
348
|
}
|
|
344
349
|
|
|
350
|
+
const handleRevoke = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
351
|
+
if (!requireMethod(req, res, 'POST')) return
|
|
352
|
+
if (!loopbackFence(req)) {
|
|
353
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
const body = await readJsonBody(req)
|
|
357
|
+
const payload = parsePairPayload(revokePayloadSchema, body)
|
|
358
|
+
if (payload === undefined) {
|
|
359
|
+
writeJson(res, 400, { ok: false, code: 'bad-payload' })
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
const revoked = service.revoke(payload.deviceId)
|
|
363
|
+
if (!revoked) {
|
|
364
|
+
writeJson(res, 404, { ok: false, code: 'unknown-device' })
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
writeJson(res, 200, { ok: true })
|
|
368
|
+
}
|
|
369
|
+
|
|
345
370
|
const handleHeartbeat = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
346
371
|
if (!requireMethod(req, res, 'POST')) return
|
|
347
372
|
if (!lanFence(req)) {
|
|
@@ -371,10 +396,13 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
371
396
|
const paired = deviceId !== undefined && service.hasDevice(deviceId)
|
|
372
397
|
const snapshot = service.snapshot()
|
|
373
398
|
// Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
|
|
374
|
-
// token expiry,
|
|
375
|
-
//
|
|
399
|
+
// token expiry, public tunnel URL, and counts are an oracle for targeting
|
|
400
|
+
// and stay behind a live device cookie. The per-device roster (ids are
|
|
401
|
+
// session credentials) is never returned here — only the loopback events
|
|
402
|
+
// stream carries it to the desktop panel.
|
|
403
|
+
const { devices: _devices, ...rest } = snapshot
|
|
376
404
|
const visible = paired
|
|
377
|
-
?
|
|
405
|
+
? rest
|
|
378
406
|
: { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses }
|
|
379
407
|
writeJson(res, 200, { ok: true, paired, ...visible })
|
|
380
408
|
}
|
|
@@ -394,6 +422,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
394
422
|
{ kind: 'exact', path: PAIR_PATHS.issue, handler: handleIssue },
|
|
395
423
|
{ kind: 'exact', path: PAIR_PATHS.accept, handler: handleAccept },
|
|
396
424
|
{ kind: 'exact', path: PAIR_PATHS.stop, handler: handleStop },
|
|
425
|
+
{ kind: 'exact', path: PAIR_PATHS.revoke, handler: handleRevoke },
|
|
397
426
|
{ kind: 'exact', path: PAIR_PATHS.heartbeat, handler: handleHeartbeat },
|
|
398
427
|
{ kind: 'exact', path: PAIR_PATHS.status, handler: handleStatus },
|
|
399
428
|
{ kind: 'exact', path: PAIR_PATHS.events, handler: handleEvents },
|
package/src/update.ts
CHANGED
|
@@ -144,6 +144,18 @@ export function isLinkedSpec(spec: DependencySpec): boolean {
|
|
|
144
144
|
return /^(?:link|file):|^\.{1,2}(?:[/\\]|$)/.test(spec)
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Whether a direct local dependency overrides one of the aggregate's children. */
|
|
148
|
+
function hasLinkedFamilyOverride(
|
|
149
|
+
anchorManifest: Record<string, unknown>,
|
|
150
|
+
profileManifest: Record<string, unknown> | undefined,
|
|
151
|
+
): boolean {
|
|
152
|
+
const dependencies = profileManifest?.dependencies
|
|
153
|
+
if (typeof dependencies !== 'object' || dependencies === null) return false
|
|
154
|
+
return familyChildren(anchorManifest).some(name =>
|
|
155
|
+
isLinkedSpec((dependencies as Record<string, DependencySpec>)[name]),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
147
159
|
/** One package's current-vs-latest comparison. */
|
|
148
160
|
export interface UpdatePackageStatus {
|
|
149
161
|
/** Package name. */
|
|
@@ -234,7 +246,7 @@ export function resolveUpdateTarget(
|
|
|
234
246
|
if (profile === undefined) return { error: 'link' }
|
|
235
247
|
const profileManifest = readManifest(join(profile.dir, 'package.json'))
|
|
236
248
|
const spec = (profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor]
|
|
237
|
-
if (isLinkedSpec(spec)) return { error: 'link' }
|
|
249
|
+
if (isLinkedSpec(spec) || hasLinkedFamilyOverride(manifest, profileManifest)) return { error: 'link' }
|
|
238
250
|
// Standalone installs carry no aggregate: the anchor's own dependency
|
|
239
251
|
// list misses every sibling @linxin666/* plugin installed directly into
|
|
240
252
|
// the profile, so union the profile's direct family deps (#377).
|
|
@@ -353,6 +365,7 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
|
|
|
353
365
|
const profileManifest = profile === undefined ? undefined : readManifest(join(profile.dir, "package.json"))
|
|
354
366
|
const linked = profile === undefined
|
|
355
367
|
|| isLinkedSpec((profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor])
|
|
368
|
+
|| hasLinkedFamilyOverride(manifest, profileManifest)
|
|
356
369
|
if (profile === undefined) {
|
|
357
370
|
return { mode: 'link', packages: [], outdated: false }
|
|
358
371
|
}
|