mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 14:24:03 +08:00
refactor(shared): consolidate the pairing trust fence into shared/host/pair-access.ts
Git-graph, pet, and skill-explorer carried byte-identical fence logic. The decision now lives in one shared source distributed as generated sync-shared copies; each package keeps a self-describing thin wrapper. Canonical tests added under shared/tests; wrapper specs stay as wiring tests.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Bilingual-pair consistency record (docs/i18n.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with git hash-object.
|
||||
2026-08-26-shared-pair-access-fence.md: 14892fe3fbd3c64088a02c4cdd2ba71f678a3ac6
|
||||
2026-08-26-shared-pair-access-fence.zh.md: 5f24edefefabb96a531eae5be4bcea73f8e9b1be
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: Shared pairing trust fence (pair-access) via sync-shared
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Three plugins — git-graph (`src/host/access.ts`), pet (`src/access.ts`), and skill-explorer (`src/access.ts`) — carried byte-identical trust-fence decision logic (loopback short-circuit, `remoteWebUiPairing` structural lookup with ctx.get/property fallback) differing only in the exported function name and header comment. A duplicated security check must drift in lockstep across copies or silently diverge; the audit flagged all three locations plus their triple-cloned tests.
|
||||
|
||||
## Decision
|
||||
|
||||
The decision logic now lives once in `shared/host/pair-access.ts` (`isPairedOrLoopbackAllowed`), distributed to the three packages as generated copies through the existing `scripts/sync-shared.mjs` table — the same mechanism that already syncs `loopback.ts`/`http.ts`/`dsh-home.ts`. Each package keeps a hand-written `access.ts` wrapper exporting its self-describing name (`isGitAllowed`, `isPetAllowed`, `isSkillExplorerAllowed`) that delegates to the synced copy, so no call site changes. A canonical test for the core logic lives in `shared/tests/pair-access.spec.ts`; the per-package wrapper specs stay as wiring tests.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
A new shared runtime package imported as a dependency was rejected: every existing shared helper in this monorepo ships via sync-shared copies (packages must stay independently publishable without a workspace-internal dependency chain), and pair-access must not be the first exception. Replacing the wrappers entirely (renaming call sites to a single function) was rejected as needless churn — the wrapper keeps each package's public vocabulary and documents which routes the fence guards. Syncing the tests was rejected: sync-shared copies source files only, and the per-package specs double as wiring verification. The deprecated dsh-aionui-panel has no fence code (client-only), so nothing was consolidated there.
|
||||
|
||||
## Consequences
|
||||
|
||||
Fence fixes now propagate by editing one shared source and running `node scripts/sync-shared.mjs`; the drift gate (`test:scripts`) fails CI if any copy diverges. The sync-shared test's copy-count buckets grew (97 to 100 entries, 45 to 48 host copies) and its fake tree gained a pair-access source. No behavior change: identical logic, verified by the unchanged per-package specs.
|
||||
|
||||
## Testing
|
||||
|
||||
`pnpm test:scripts` (copy-count and drift suites), `pnpm docs:check`, and per-package `pnpm typecheck` + `pnpm test` for dsh-git-graph (141), dsh-pet (445), dsh-skill-explorer (72) — all pass. Shared spec: 15 tests in shared/tests (pair-access + loopback).
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: 经 sync-shared 共享配对信任闸门(pair-access)
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
三个插件——git-graph(`src/host/access.ts`)、pet(`src/access.ts`)与 skill-explorer(`src/access.ts`)——各自携带逐字节相同的信任闸门判定逻辑(loopback 短路、`remoteWebUiPairing` 结构化查找及 ctx.get/属性回退),差别仅在导出函数名与头注释。安全检查的重复副本必须同步漂移,否则会静默分叉;审计点名了全部三处位置及其三份克隆测试。
|
||||
|
||||
## Decision
|
||||
|
||||
判定逻辑现在只存在一份:`shared/host/pair-access.ts`(`isPairedOrLoopbackAllowed`),通过既有 `scripts/sync-shared.mjs` 副本表以生成副本分发到三个包——与 `loopback.ts`/`http.ts`/`dsh-home.ts` 的同步机制相同。每个包保留手写的 `access.ts` 薄包装,导出自描述的名字(`isGitAllowed`、`isPetAllowed`、`isSkillExplorerAllowed`)并委托给同步副本,调用方零改动。核心逻辑的权威测试落在 `shared/tests/pair-access.spec.ts`;各包的包装规格保留为接线测试。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
新建共享运行时包以依赖方式引入的方案被否决:本仓所有既有共享助手都经 sync-shared 副本分发(各包必须可独立发布,不能引入 workspace 内部依赖链),pair-access 不应成为第一个例外。彻底去掉包装(调用方统一改用单一函数名)被否决,属于无谓 churn——包装保留了各包的公开词汇并注明闸门守护的路由。同步测试文件的方案被否决:sync-shared 只复制源文件,且各包规格兼作接线验证。已弃用的 dsh-aionui-panel 没有闸门代码(纯 client),无可合并内容。
|
||||
|
||||
## Consequences
|
||||
|
||||
闸门修复现在只需改一份共享源码并运行 `node scripts/sync-shared.mjs`;任何副本分叉都会被漂移门禁(`test:scripts`)在 CI 拦下。sync-shared 测试的副本计数桶随之更新(总条目 97→100,host 副本 45→48),其临时目录假树新增 pair-access 源文件。无行为变化:逻辑相同,由各包未改动的规格验证。
|
||||
|
||||
## Testing
|
||||
|
||||
`pnpm test:scripts`(副本计数与漂移套件)、`pnpm docs:check`,以及 dsh-git-graph(141)、dsh-pet(445)、dsh-skill-explorer(72)三包各自的 `pnpm typecheck` + `pnpm test`——全部通过。共享规格:shared/tests 共 15 项(pair-access + loopback)。
|
||||
@@ -2,22 +2,12 @@
|
||||
* Git-graph trust fence: loopback (the desktop) always passes; a live
|
||||
* paired-device cookie is an additional allow path when remote-web-ui is
|
||||
* loaded. The plugin never depends on that plugin — without the service the
|
||||
* fence stays loopback-only (same pattern as skill-explorer / aionui-panel).
|
||||
* fence stays loopback-only. The decision logic lives in the generated
|
||||
* pair-access.ts copy (shared by git-graph / pet / skill-explorer).
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isLoopbackRequest } from './loopback.ts'
|
||||
|
||||
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
||||
interface PairingAccess {
|
||||
isPairedDevice(request: IncomingMessage): boolean
|
||||
}
|
||||
|
||||
/** ctx.get is optional on the test harness; production Context always has it. */
|
||||
type LookupCtx = Context & {
|
||||
get?(name: string, strict?: boolean): unknown
|
||||
remoteWebUiPairing?: PairingAccess
|
||||
}
|
||||
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
|
||||
|
||||
/**
|
||||
* Whether this request may enter any /git route (JSON operations or SSE).
|
||||
@@ -26,15 +16,5 @@ type LookupCtx = Context & {
|
||||
* @returns true for loopback, or a live paired-device cookie.
|
||||
*/
|
||||
export function isGitAllowed(ctx: Context, request: IncomingMessage): boolean {
|
||||
if (isLoopbackRequest(request)) return true
|
||||
const bag = ctx as LookupCtx
|
||||
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
||||
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
||||
return pairing?.isPairedDevice(request) === true
|
||||
}
|
||||
|
||||
function isPairingAccess(value: unknown): value is PairingAccess {
|
||||
return value !== undefined
|
||||
&& value !== null
|
||||
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
||||
return isPairedOrLoopbackAllowed(ctx, request)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
||||
/**
|
||||
* Pairing trust fence shared by plugins that expose host routes: loopback
|
||||
* (the desktop) always passes; a live paired-device cookie is an additional
|
||||
* allow path when remote-web-ui is loaded. The consuming plugin never
|
||||
* depends on that plugin — without the service the fence stays
|
||||
* loopback-only.
|
||||
*
|
||||
* Per-package wrappers (access.ts) call this with their own name so each
|
||||
* plugin keeps a self-describing export; the security decision lives only
|
||||
* here.
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isLoopbackRequest } from './loopback.ts'
|
||||
|
||||
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
||||
interface PairingAccess {
|
||||
isPairedDevice(request: IncomingMessage): boolean
|
||||
}
|
||||
|
||||
/** ctx.get is optional on the test harness; production Context always has it. */
|
||||
type LookupCtx = Context & {
|
||||
get?(name: string, strict?: boolean): unknown
|
||||
remoteWebUiPairing?: PairingAccess
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request may enter the plugin's host routes.
|
||||
* @param ctx - host context; may expose remoteWebUiPairing.
|
||||
* @param request - the incoming HTTP request.
|
||||
* @returns true for loopback, or a live paired-device cookie.
|
||||
*/
|
||||
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
|
||||
if (isLoopbackRequest(request)) return true
|
||||
const bag = ctx as LookupCtx
|
||||
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
||||
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
||||
return pairing?.isPairedDevice(request) === true
|
||||
}
|
||||
|
||||
function isPairingAccess(value: unknown): value is PairingAccess {
|
||||
return value !== undefined
|
||||
&& value !== null
|
||||
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
/**
|
||||
* Pet trust fence: loopback (the desktop) always passes; a live paired-device
|
||||
* cookie is an additional allow path when remote-web-ui is loaded. The pet
|
||||
* never depends on that plugin — without the service the fence stays
|
||||
* loopback-only (same pattern as skill-explorer / aionui-panel).
|
||||
* Pet trust fence: loopback (the desktop) always passes; a live
|
||||
* paired-device cookie is an additional allow path when remote-web-ui is
|
||||
* loaded. The pet never depends on that plugin — without the service the
|
||||
* fence stays loopback-only. The decision logic lives in the generated
|
||||
* pair-access.ts copy (shared by git-graph / pet / skill-explorer).
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isLoopbackRequest } from './loopback.ts'
|
||||
|
||||
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
||||
interface PairingAccess {
|
||||
isPairedDevice(request: IncomingMessage): boolean
|
||||
}
|
||||
|
||||
/** ctx.get is optional on the test harness; production Context always has it. */
|
||||
type LookupCtx = Context & {
|
||||
get?(name: string, strict?: boolean): unknown
|
||||
remoteWebUiPairing?: PairingAccess
|
||||
}
|
||||
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
|
||||
|
||||
/**
|
||||
* Whether this request may enter any /api/pet or /pet asset route.
|
||||
@@ -26,15 +16,5 @@ type LookupCtx = Context & {
|
||||
* @returns true for loopback, or a live paired-device cookie.
|
||||
*/
|
||||
export function isPetAllowed(ctx: Context, request: IncomingMessage): boolean {
|
||||
if (isLoopbackRequest(request)) return true
|
||||
const bag = ctx as LookupCtx
|
||||
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
||||
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
||||
return pairing?.isPairedDevice(request) === true
|
||||
}
|
||||
|
||||
function isPairingAccess(value: unknown): value is PairingAccess {
|
||||
return value !== undefined
|
||||
&& value !== null
|
||||
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
||||
return isPairedOrLoopbackAllowed(ctx, request)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
||||
/**
|
||||
* Pairing trust fence shared by plugins that expose host routes: loopback
|
||||
* (the desktop) always passes; a live paired-device cookie is an additional
|
||||
* allow path when remote-web-ui is loaded. The consuming plugin never
|
||||
* depends on that plugin — without the service the fence stays
|
||||
* loopback-only.
|
||||
*
|
||||
* Per-package wrappers (access.ts) call this with their own name so each
|
||||
* plugin keeps a self-describing export; the security decision lives only
|
||||
* here.
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isLoopbackRequest } from './loopback.ts'
|
||||
|
||||
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
||||
interface PairingAccess {
|
||||
isPairedDevice(request: IncomingMessage): boolean
|
||||
}
|
||||
|
||||
/** ctx.get is optional on the test harness; production Context always has it. */
|
||||
type LookupCtx = Context & {
|
||||
get?(name: string, strict?: boolean): unknown
|
||||
remoteWebUiPairing?: PairingAccess
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request may enter the plugin's host routes.
|
||||
* @param ctx - host context; may expose remoteWebUiPairing.
|
||||
* @param request - the incoming HTTP request.
|
||||
* @returns true for loopback, or a live paired-device cookie.
|
||||
*/
|
||||
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
|
||||
if (isLoopbackRequest(request)) return true
|
||||
const bag = ctx as LookupCtx
|
||||
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
||||
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
||||
return pairing?.isPairedDevice(request) === true
|
||||
}
|
||||
|
||||
function isPairingAccess(value: unknown): value is PairingAccess {
|
||||
return value !== undefined
|
||||
&& value !== null
|
||||
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
||||
}
|
||||
@@ -2,22 +2,12 @@
|
||||
* Skill-center trust fence: loopback (the desktop) always passes; a live
|
||||
* paired-device cookie is an additional allow path when remote-web-ui is
|
||||
* loaded. The plugin never depends on that plugin — without the service the
|
||||
* fence stays loopback-only (same pattern as aionui-panel, issue #146).
|
||||
* fence stays loopback-only. The decision logic lives in the generated
|
||||
* pair-access.ts copy (shared by git-graph / pet / skill-explorer).
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isLoopbackRequest } from './loopback.ts'
|
||||
|
||||
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
||||
interface PairingAccess {
|
||||
isPairedDevice(request: IncomingMessage): boolean
|
||||
}
|
||||
|
||||
/** ctx.get is optional on the test harness; production Context always has it. */
|
||||
type LookupCtx = Context & {
|
||||
get?(name: string, strict?: boolean): unknown
|
||||
remoteWebUiPairing?: PairingAccess
|
||||
}
|
||||
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
|
||||
|
||||
/**
|
||||
* Whether this request may enter any /api/dsh-skill-explorer route.
|
||||
@@ -26,15 +16,5 @@ type LookupCtx = Context & {
|
||||
* @returns true for loopback, or a live paired-device cookie.
|
||||
*/
|
||||
export function isSkillExplorerAllowed(ctx: Context, request: IncomingMessage): boolean {
|
||||
if (isLoopbackRequest(request)) return true
|
||||
const bag = ctx as LookupCtx
|
||||
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
||||
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
||||
return pairing?.isPairedDevice(request) === true
|
||||
}
|
||||
|
||||
function isPairingAccess(value: unknown): value is PairingAccess {
|
||||
return value !== undefined
|
||||
&& value !== null
|
||||
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
||||
return isPairedOrLoopbackAllowed(ctx, request)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
||||
/**
|
||||
* Pairing trust fence shared by plugins that expose host routes: loopback
|
||||
* (the desktop) always passes; a live paired-device cookie is an additional
|
||||
* allow path when remote-web-ui is loaded. The consuming plugin never
|
||||
* depends on that plugin — without the service the fence stays
|
||||
* loopback-only.
|
||||
*
|
||||
* Per-package wrappers (access.ts) call this with their own name so each
|
||||
* plugin keeps a self-describing export; the security decision lives only
|
||||
* here.
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isLoopbackRequest } from './loopback.ts'
|
||||
|
||||
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
||||
interface PairingAccess {
|
||||
isPairedDevice(request: IncomingMessage): boolean
|
||||
}
|
||||
|
||||
/** ctx.get is optional on the test harness; production Context always has it. */
|
||||
type LookupCtx = Context & {
|
||||
get?(name: string, strict?: boolean): unknown
|
||||
remoteWebUiPairing?: PairingAccess
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request may enter the plugin's host routes.
|
||||
* @param ctx - host context; may expose remoteWebUiPairing.
|
||||
* @param request - the incoming HTTP request.
|
||||
* @returns true for loopback, or a live paired-device cookie.
|
||||
*/
|
||||
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
|
||||
if (isLoopbackRequest(request)) return true
|
||||
const bag = ctx as LookupCtx
|
||||
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
||||
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
||||
return pairing?.isPairedDevice(request) === true
|
||||
}
|
||||
|
||||
function isPairingAccess(value: unknown): value is PairingAccess {
|
||||
return value !== undefined
|
||||
&& value !== null
|
||||
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
||||
}
|
||||
@@ -130,6 +130,11 @@ const MANIFEST = [
|
||||
source: 'shared/client/sse-leader.ts',
|
||||
targets: ['packages/dsh-git-graph/src/client/sse-leader.ts'],
|
||||
},
|
||||
{
|
||||
file: 'pair-access.ts',
|
||||
source: 'shared/host/pair-access.ts',
|
||||
targets: ['packages/dsh-git-graph/src/host/pair-access.ts', 'packages/dsh-pet/src/pair-access.ts', 'packages/dsh-skill-explorer/src/pair-access.ts'],
|
||||
},
|
||||
{
|
||||
file: 'loopback.ts',
|
||||
source: 'shared/host/loopback.ts',
|
||||
|
||||
@@ -21,16 +21,17 @@ test('copies cover the settings trio for eight consumers plus host and http help
|
||||
// Normalize separators: node:path join yields backslashes on Windows, and
|
||||
// the copy-count buckets below match on forward slashes.
|
||||
const entries = copyEntries().map(entry => ({ ...entry, target: entry.target.replaceAll('\\', '/') }))
|
||||
assert.equal(entries.length, 97)
|
||||
assert.equal(entries.length, 100)
|
||||
const clientTrio = entries.filter(entry => entry.target.includes('/src/client/'))
|
||||
assert.equal(clientTrio.length, 43)
|
||||
const hostCopies = entries.filter(entry => entry.target.includes('/src/host/')
|
||||
|| entry.target.includes('/src/dsh-home.ts')
|
||||
|| entry.target.includes('/src/mount-once.ts')
|
||||
|| entry.target.includes('/src/loopback.ts')
|
||||
|| entry.target.includes('/src/pair-access.ts')
|
||||
|| entry.target.includes('/src/agent/')
|
||||
|| entry.target.endsWith('/packages/dsh-task-board/src/http.ts'))
|
||||
assert.equal(hostCopies.length, 45)
|
||||
assert.equal(hostCopies.length, 48)
|
||||
})
|
||||
|
||||
test('checkSync detects drift and applySync repairs it', async () => {
|
||||
@@ -50,6 +51,7 @@ test('checkSync detects drift and applySync repairs it', async () => {
|
||||
await writeFile(join(hostDir, 'poll-guard.ts'), 'export const guard = 1' + String.fromCharCode(10))
|
||||
await writeFile(join(hostDir, 'dsh-home.ts'), 'export const home = 1' + String.fromCharCode(10))
|
||||
await writeFile(join(hostDir, 'loopback.ts'), 'export const loop = 1' + String.fromCharCode(10))
|
||||
await writeFile(join(hostDir, 'pair-access.ts'), 'export const fence = 1' + String.fromCharCode(10))
|
||||
await writeFile(join(hostDir, 'git-runner.ts'), 'export const runner = 1' + String.fromCharCode(10))
|
||||
await writeFile(join(hostDir, 'mount-once.ts'), 'export const once = 1' + String.fromCharCode(10))
|
||||
await writeFile(join(hostDir, 'http.ts'), 'export const http = 1' + String.fromCharCode(10))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Pairing trust fence shared by plugins that expose host routes: loopback
|
||||
* (the desktop) always passes; a live paired-device cookie is an additional
|
||||
* allow path when remote-web-ui is loaded. The consuming plugin never
|
||||
* depends on that plugin — without the service the fence stays
|
||||
* loopback-only.
|
||||
*
|
||||
* Per-package wrappers (access.ts) call this with their own name so each
|
||||
* plugin keeps a self-describing export; the security decision lives only
|
||||
* here.
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isLoopbackRequest } from './loopback.ts'
|
||||
|
||||
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
||||
interface PairingAccess {
|
||||
isPairedDevice(request: IncomingMessage): boolean
|
||||
}
|
||||
|
||||
/** ctx.get is optional on the test harness; production Context always has it. */
|
||||
type LookupCtx = Context & {
|
||||
get?(name: string, strict?: boolean): unknown
|
||||
remoteWebUiPairing?: PairingAccess
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request may enter the plugin's host routes.
|
||||
* @param ctx - host context; may expose remoteWebUiPairing.
|
||||
* @param request - the incoming HTTP request.
|
||||
* @returns true for loopback, or a live paired-device cookie.
|
||||
*/
|
||||
export function isPairedOrLoopbackAllowed(ctx: Context, request: IncomingMessage): boolean {
|
||||
if (isLoopbackRequest(request)) return true
|
||||
const bag = ctx as LookupCtx
|
||||
const fromGet = typeof bag.get === 'function' ? bag.get('remoteWebUiPairing', false) : undefined
|
||||
const pairing = (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)
|
||||
return pairing?.isPairedDevice(request) === true
|
||||
}
|
||||
|
||||
function isPairingAccess(value: unknown): value is PairingAccess {
|
||||
return value !== undefined
|
||||
&& value !== null
|
||||
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Canonical tests for the shared pairing trust fence (shared/host/pair-access.ts);
|
||||
* each consuming package keeps a small wrapper spec for its own wiring.
|
||||
*/
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isPairedOrLoopbackAllowed } from '../host/pair-access.ts'
|
||||
|
||||
function request(options: {
|
||||
remoteAddress?: string
|
||||
host?: string
|
||||
cookie?: string
|
||||
} = {}): IncomingMessage {
|
||||
return {
|
||||
headers: {
|
||||
host: options.host ?? '127.0.0.1:3000',
|
||||
...(options.cookie === undefined ? {} : { cookie: options.cookie }),
|
||||
},
|
||||
socket: { remoteAddress: options.remoteAddress ?? '127.0.0.1' },
|
||||
} as IncomingMessage
|
||||
}
|
||||
|
||||
describe('isPairedOrLoopbackAllowed', () => {
|
||||
it('allows loopback without a pairing service', () => {
|
||||
expect(isPairedOrLoopbackAllowed({} as never, request())).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a LAN client when no pairing service is present', () => {
|
||||
const ctx = { get: () => undefined }
|
||||
expect(isPairedOrLoopbackAllowed(ctx as never, request({
|
||||
remoteAddress: '192.168.1.20',
|
||||
host: 'dsh.example:443',
|
||||
}))).toBe(false)
|
||||
})
|
||||
|
||||
it('allows a LAN client with a live paired-device cookie', () => {
|
||||
const isPairedDevice = vi.fn(() => true)
|
||||
const ctx = { get: (name: string) => name === 'remoteWebUiPairing' ? { isPairedDevice } : undefined }
|
||||
const req = request({
|
||||
remoteAddress: '192.168.1.20',
|
||||
host: 'dsh.example:443',
|
||||
cookie: 'dsh_pair=dev-1',
|
||||
})
|
||||
expect(isPairedOrLoopbackAllowed(ctx as never, req)).toBe(true)
|
||||
expect(isPairedDevice).toHaveBeenCalledWith(req)
|
||||
})
|
||||
|
||||
it('refuses a LAN client when pairing returns false (revoked or unknown)', () => {
|
||||
const ctx = { get: () => ({ isPairedDevice: () => false }) }
|
||||
expect(isPairedOrLoopbackAllowed(ctx as never, request({
|
||||
remoteAddress: '192.168.1.20',
|
||||
host: 'dsh.example:443',
|
||||
cookie: 'dsh_pair=revoked',
|
||||
}))).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the direct remoteWebUiPairing property when ctx.get is absent', () => {
|
||||
const ctx = { remoteWebUiPairing: { isPairedDevice: () => true } }
|
||||
expect(isPairedOrLoopbackAllowed(ctx as never, request({
|
||||
remoteAddress: '192.168.1.20',
|
||||
host: 'dsh.example:443',
|
||||
cookie: 'dsh_pair=dev-1',
|
||||
}))).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user