dsh-zen-remote 2.0.2 → 2.2.0
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/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +63 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +5 -2
- package/lib/intercept.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +340 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +665 -7
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +81 -1
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/intercept.ts +5 -2
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +413 -4
- package/src/relay-server.ts +687 -9
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin bridge's loopback executor (PB-3): performs one GET against one
|
|
3
|
+
* of THIS server's own plugin routes, over real loopback HTTP, so the
|
|
4
|
+
* plugin's own handler — its authentication, its req/res semantics — runs
|
|
5
|
+
* exactly as it would for a local browser (docs/adr/0001). The relay route
|
|
6
|
+
* hands this module a validated path + query and gets the plugin's answer
|
|
7
|
+
* back as a status code plus a byte stream to pump at the sub-client.
|
|
8
|
+
*
|
|
9
|
+
* Why the cookie dance: DSH 0.1.2+ requires a signed cookie on EVERY route.
|
|
10
|
+
* The gateway half solved this for browsers by "playing courier" — one
|
|
11
|
+
* `GET /?token=…` (the only URL that accepts a token), then the 303's
|
|
12
|
+
* `set-cookie` straight onto the client (lib/lan-gate-server.cjs
|
|
13
|
+
* attemptAuthExchange). This module is the same courier for the relay: it
|
|
14
|
+
* keeps ONE cookie in memory for the process, exchanges it once on first
|
|
15
|
+
* use, and re-exchanges exactly once when the plugin route answers 401 (a
|
|
16
|
+
* rotated signing key). The exchange is the lan-gate.mjs wiring's shape:
|
|
17
|
+
* `connection.authenticatedUrl(protocol//127.0.0.1:port)` builds the token
|
|
18
|
+
* URL — absent on 0.1.1 hosts, where there is nothing to exchange and every
|
|
19
|
+
* bridged call fails `loopback-auth` honestly.
|
|
20
|
+
*
|
|
21
|
+
* The cookie never leaves this module: no log, no state file, no response —
|
|
22
|
+
* it exists only as the `cookie` request header on the loopback hop.
|
|
23
|
+
*
|
|
24
|
+
* Transport is node:http / node:https on purpose: the plugin's answer must
|
|
25
|
+
* stream (chunk after chunk, with real backpressure) and must die the
|
|
26
|
+
* moment the sub-client hangs up (an AbortSignal reaches the socket), and
|
|
27
|
+
* 0.2.1's self-signed https needs `rejectUnauthorized: false` — a per-
|
|
28
|
+
* request knob fetch cannot express. The loosened verification is safe BY
|
|
29
|
+
* CONSTRUCTION: the connection is dialed to the literal 127.0.0.1 the
|
|
30
|
+
* wiring pinned, never to a name that could be spoofed; the worst an
|
|
31
|
+
* on-path attacker could forge is data from the machine's own web server
|
|
32
|
+
* to itself.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import http from 'node:http'
|
|
36
|
+
import https from 'node:https'
|
|
37
|
+
import type { IncomingMessage } from 'node:http'
|
|
38
|
+
import { ADAPTER_RESPONSE_HEADER_ALLOWLIST } from './plugin-adapter-core.js'
|
|
39
|
+
|
|
40
|
+
/** The adapter allowlist's headers off one loopback answer, verbatim. */
|
|
41
|
+
function extraAdapterHeadersOf(response: IncomingMessage): Record<string, string> {
|
|
42
|
+
const extra: Record<string, string> = {}
|
|
43
|
+
for (const name of ADAPTER_RESPONSE_HEADER_ALLOWLIST) {
|
|
44
|
+
const value = response.headers[name]
|
|
45
|
+
if (typeof value === 'string' && value !== '') extra[name] = value
|
|
46
|
+
}
|
|
47
|
+
return extra
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The loopback bind info, re-read live per request by the wiring. */
|
|
51
|
+
export interface PluginLoopbackTarget {
|
|
52
|
+
protocol: 'http:' | 'https:'
|
|
53
|
+
port: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The request headers this bridge passes through to the plugin. */
|
|
57
|
+
export interface PluginLoopbackHeaders {
|
|
58
|
+
accept?: string
|
|
59
|
+
range?: string
|
|
60
|
+
/** PB-9: a forwarded request body's content type. */
|
|
61
|
+
contentType?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The plugin route's answer, headers whitelisted to what the relay may
|
|
65
|
+
* forward. `stream` is the RAW IncomingMessage: the caller reads it (and
|
|
66
|
+
* only it) — the module has already drained every other outcome.
|
|
67
|
+
* `extraHeaders` (PB-12) carries the ADAPTER allowlist — the headers an
|
|
68
|
+
* adapter's answer may hand the browser verbatim (the HTML preview's CSP
|
|
69
|
+
* sandbox, download dispositions, range headers); the cooperative relay
|
|
70
|
+
* branch ignores it. */
|
|
71
|
+
export interface PluginLoopbackResponse {
|
|
72
|
+
status: number
|
|
73
|
+
contentType: string | undefined
|
|
74
|
+
contentLength: string | undefined
|
|
75
|
+
cacheControl: string | undefined
|
|
76
|
+
extraHeaders: Record<string, string>
|
|
77
|
+
stream: IncomingMessage
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The one failure the relay maps onto 502 `loopback-auth`: the cookie
|
|
81
|
+
* exchange could not run (no token URL on this host) or did not yield a
|
|
82
|
+
* cookie (wrong token, a non-redirect answer, a network error). */
|
|
83
|
+
export class LoopbackAuthError extends Error {
|
|
84
|
+
constructor(message: string) {
|
|
85
|
+
super(message)
|
|
86
|
+
this.name = 'LoopbackAuthError'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isLoopbackAuthError(error: unknown): error is LoopbackAuthError {
|
|
91
|
+
return error instanceof LoopbackAuthError
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CreatePluginLoopbackOptions {
|
|
95
|
+
/** The loopback bind, read live per request (a restarted web server may
|
|
96
|
+
* have moved; a snapshot would keep dialing the old port). */
|
|
97
|
+
getTarget: () => PluginLoopbackTarget
|
|
98
|
+
/** The authenticated token URL (the lan-gate wiring's
|
|
99
|
+
* `connection.authenticatedUrl(protocol//127.0.0.1:port)` shape), read
|
|
100
|
+
* live per exchange; `undefined` — a host without the method — means the
|
|
101
|
+
* cookie can never be obtained and every bridged call fails
|
|
102
|
+
* `loopback-auth`. */
|
|
103
|
+
getTokenUrl: () => string | undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface PluginLoopback {
|
|
107
|
+
/** The cookie the tunnel executor (PB-10) needs to write its OWN upgrade
|
|
108
|
+
* request — a raw socket dial, which cannot ride this module's `request`
|
|
109
|
+
* (it owns the cookie internally). Resolves the current pair, exchanging
|
|
110
|
+
* once when none is cached; empty string means the host has no token URL
|
|
111
|
+
* and the tunnel cannot authenticate. The cookie stays in memory. */
|
|
112
|
+
cookieHeader(): Promise<string>
|
|
113
|
+
/** One validated request against the local plugin route. `path` arrives
|
|
114
|
+
* undecoded and `query` is the RELAY-CHECKED serialization (the caller
|
|
115
|
+
* never passes raw wire input — the parse-once discipline). `body` (PB-9)
|
|
116
|
+
* is the relay request's body streamed verbatim (an IncomingMessage or a
|
|
117
|
+
* byte stream), `null` for bodyless methods; it is pumped into the
|
|
118
|
+
* loopback request as-is — sizing it is the ROUTE's job, transport is
|
|
119
|
+
* this module's. Throws {@link LoopbackAuthError} when the cookie cannot
|
|
120
|
+
* be obtained; every other failure (target unreachable, the signal
|
|
121
|
+
* aborted, the body dying mid-flight) rejects with the underlying error
|
|
122
|
+
* and is the caller's `internal`/hang-up case. */
|
|
123
|
+
request(
|
|
124
|
+
method: string,
|
|
125
|
+
path: string,
|
|
126
|
+
query: string,
|
|
127
|
+
headers: PluginLoopbackHeaders,
|
|
128
|
+
body: import('node:http').IncomingMessage | import('node:stream').Readable | null,
|
|
129
|
+
signal: AbortSignal,
|
|
130
|
+
): Promise<PluginLoopbackResponse>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One raw loopback request that never follows redirects and resolves with
|
|
134
|
+
* the raw IncomingMessage (the caller drains or streams it). `body`, when
|
|
135
|
+
* given, is pumped into the request verbatim (PB-9's write forwarding); a
|
|
136
|
+
* body dying mid-flight destroys the request, which rejects the promise.
|
|
137
|
+
* The loosened certificate verification (PB-3-fix) applies ONLY to a
|
|
138
|
+
* literal `127.0.0.1` hostname — the plugin hop the wiring pins — never to
|
|
139
|
+
* the token exchange when its URL names anything else. */
|
|
140
|
+
function rawRequest(
|
|
141
|
+
target: { protocol: string; hostname: string; port: number },
|
|
142
|
+
method: string,
|
|
143
|
+
pathWithQuery: string,
|
|
144
|
+
headers: Record<string, string>,
|
|
145
|
+
body: import('node:http').IncomingMessage | import('node:stream').Readable | null,
|
|
146
|
+
signal: AbortSignal,
|
|
147
|
+
): Promise<IncomingMessage> {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const transport = target.protocol === 'https:' ? https : http
|
|
150
|
+
// The signal reaching request() tears the socket down on abort — the
|
|
151
|
+
// sub-client's hang-up propagates here without extra wiring.
|
|
152
|
+
const request = transport.request(
|
|
153
|
+
{
|
|
154
|
+
protocol: target.protocol,
|
|
155
|
+
hostname: target.hostname,
|
|
156
|
+
port: target.port,
|
|
157
|
+
method,
|
|
158
|
+
path: pathWithQuery,
|
|
159
|
+
headers,
|
|
160
|
+
// 0.2.1 serves self-signed https; the dial is the literal loopback
|
|
161
|
+
// address (see the module comment), so certificate identity carries
|
|
162
|
+
// no information to verify — and ONLY there: any other hostname
|
|
163
|
+
// (the token URL's, should the wiring ever point it elsewhere) keeps
|
|
164
|
+
// full verification.
|
|
165
|
+
...(target.protocol === 'https:' && target.hostname === '127.0.0.1' ? { rejectUnauthorized: false } : {}),
|
|
166
|
+
signal,
|
|
167
|
+
},
|
|
168
|
+
resolve,
|
|
169
|
+
)
|
|
170
|
+
request.on('error', reject)
|
|
171
|
+
if (body === null) {
|
|
172
|
+
request.end()
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
// The body streams through as-is: node's pipe owns the backpressure and
|
|
176
|
+
// ends the request when the body ends. A source error (the relay
|
|
177
|
+
// request's socket dying) tears the loopback request down, which
|
|
178
|
+
// rejects through the handler above.
|
|
179
|
+
body.on('error', (error: Error) => {
|
|
180
|
+
request.destroy(error)
|
|
181
|
+
})
|
|
182
|
+
body.pipe(request)
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The `name=value` pair of the FIRST set-cookie header, or undefined. */
|
|
187
|
+
function cookiePairOf(setCookies: string[] | undefined): string | undefined {
|
|
188
|
+
const first = setCookies?.[0]
|
|
189
|
+
if (first === undefined) return undefined
|
|
190
|
+
const pair = first.split(';', 1)[0] ?? ''
|
|
191
|
+
return pair.includes('=') && pair !== '' ? pair : undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build the loopback executor. The cookie lives only in this closure.
|
|
196
|
+
*/
|
|
197
|
+
export function createPluginLoopback(options: CreatePluginLoopbackOptions): PluginLoopback {
|
|
198
|
+
let cookie: string | undefined
|
|
199
|
+
|
|
200
|
+
/** One `GET <tokenUrl>` (never redirected — node:http already does not
|
|
201
|
+
* follow): a 3xx carrying a set-cookie yields the pair; anything else is
|
|
202
|
+
* an exchange failure. The token response body is always drained and
|
|
203
|
+
* discarded — it is the DSH homepage, none of this module's business. */
|
|
204
|
+
const exchangeCookie = async (signal: AbortSignal): Promise<void> => {
|
|
205
|
+
const tokenUrl = options.getTokenUrl()
|
|
206
|
+
if (tokenUrl === undefined) throw new LoopbackAuthError('this host exposes no authenticated token URL')
|
|
207
|
+
let url: URL
|
|
208
|
+
try {
|
|
209
|
+
url = new URL(tokenUrl)
|
|
210
|
+
} catch {
|
|
211
|
+
throw new LoopbackAuthError('the token URL does not parse')
|
|
212
|
+
}
|
|
213
|
+
let response: IncomingMessage
|
|
214
|
+
try {
|
|
215
|
+
response = await rawRequest(
|
|
216
|
+
{ protocol: url.protocol, hostname: url.hostname, port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80) },
|
|
217
|
+
'GET',
|
|
218
|
+
`${url.pathname}${url.search}`,
|
|
219
|
+
{ accept: 'text/html' },
|
|
220
|
+
null,
|
|
221
|
+
signal,
|
|
222
|
+
)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (signal.aborted) throw error
|
|
225
|
+
throw new LoopbackAuthError(`the token exchange request failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
226
|
+
}
|
|
227
|
+
const status = response.statusCode ?? 0
|
|
228
|
+
const pair = cookiePairOf(response.headers['set-cookie'])
|
|
229
|
+
response.resume()
|
|
230
|
+
if (status >= 300 && status < 400 && pair !== undefined) {
|
|
231
|
+
cookie = pair
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
throw new LoopbackAuthError(`the token exchange answered ${status}${pair === undefined ? ' without a cookie' : ''}`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
async cookieHeader() {
|
|
239
|
+
if (cookie === undefined) await exchangeCookie(new AbortController().signal)
|
|
240
|
+
return cookie ?? ''
|
|
241
|
+
},
|
|
242
|
+
async request(method, path, query, headers, body, signal) {
|
|
243
|
+
const target = options.getTarget()
|
|
244
|
+
const pathWithQuery = query === '' ? path : `${path}?${query}`
|
|
245
|
+
// A body cannot be replayed: the first attempt pumps it into the
|
|
246
|
+
// loopback socket, and a 401 arriving afterwards leaves nothing to
|
|
247
|
+
// retry with. So a body-bearing request WARMS the cookie first (one
|
|
248
|
+
// exchange, no plugin request), and a 401 after that travels verbatim
|
|
249
|
+
// — the plugin's own auth answer, like any other status. Bodyless
|
|
250
|
+
// requests keep the full 401 → re-exchange → retry ladder.
|
|
251
|
+
if (body !== null && cookie === undefined) {
|
|
252
|
+
await exchangeCookie(signal)
|
|
253
|
+
}
|
|
254
|
+
const attempt = async (): Promise<IncomingMessage> => {
|
|
255
|
+
const requestHeaders: Record<string, string> = { host: `127.0.0.1:${target.port}` }
|
|
256
|
+
if (headers.accept !== undefined) requestHeaders.accept = headers.accept
|
|
257
|
+
if (headers.range !== undefined) requestHeaders.range = headers.range
|
|
258
|
+
// PB-9: the forwarded body's content type — the ONE body header the
|
|
259
|
+
// bridge passes through.
|
|
260
|
+
if (headers.contentType !== undefined) requestHeaders['content-type'] = headers.contentType
|
|
261
|
+
// The cookie rides ONLY here. An absent cookie still attempts the
|
|
262
|
+
// route — a plugin route that skipped DSH auth answers honestly.
|
|
263
|
+
if (cookie !== undefined) requestHeaders.cookie = cookie
|
|
264
|
+
return rawRequest(
|
|
265
|
+
{ protocol: target.protocol, hostname: '127.0.0.1', port: target.port },
|
|
266
|
+
method,
|
|
267
|
+
pathWithQuery,
|
|
268
|
+
requestHeaders,
|
|
269
|
+
body,
|
|
270
|
+
signal,
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
let response = await attempt()
|
|
274
|
+
if (response.statusCode === 401 && body === null) {
|
|
275
|
+
// Stale or missing cookie: exchange ONCE and retry. A 401 after the
|
|
276
|
+
// fresh cookie is the plugin's own answer — it travels to the
|
|
277
|
+
// sub-client verbatim, like any other status.
|
|
278
|
+
response.resume()
|
|
279
|
+
cookie = undefined
|
|
280
|
+
await exchangeCookie(signal)
|
|
281
|
+
response = await attempt()
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
status: response.statusCode ?? 0,
|
|
285
|
+
contentType: typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : undefined,
|
|
286
|
+
contentLength: typeof response.headers['content-length'] === 'string' ? response.headers['content-length'] : undefined,
|
|
287
|
+
cacheControl: typeof response.headers['cache-control'] === 'string' ? response.headers['cache-control'] : undefined,
|
|
288
|
+
// PB-12: the adapter allowlist — the HTML preview's CSP sandbox, a
|
|
289
|
+
// download's content-disposition, range headers. Collected here so
|
|
290
|
+
// the adapter forwards can hand them to the browser; the relay's
|
|
291
|
+
// cooperative branch ignores the field.
|
|
292
|
+
extraHeaders: extraAdapterHeadersOf(response),
|
|
293
|
+
stream: response,
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export { isLoopbackAuthError }
|