dsh-mobile 0.6.13 → 0.6.15

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/lib/index.mjs CHANGED
@@ -17,6 +17,7 @@ import { promisify } from "node:util";
17
17
  import { createGzip, gzip } from "node:zlib";
18
18
  import Bonjour from "bonjour-service";
19
19
  import * as QRCode from "qrcode";
20
+ import { createParser } from "eventsource-parser";
20
21
  import WebSocket, { WebSocketServer } from "ws";
21
22
  import { Logger, Service } from "@deepseek-ai/cordis";
22
23
  import { Worker } from "node:worker_threads";
@@ -1810,6 +1811,7 @@ function setSecurityHeaders(response, tls, framing = "gateway") {
1810
1811
  "font-src 'self' data:",
1811
1812
  "connect-src 'self'",
1812
1813
  "worker-src 'self' blob:",
1814
+ ...proxied ? ["media-src 'self' blob:"] : [],
1813
1815
  ...proxied ? [PROXIED_FRAME_SRC] : []
1814
1816
  ].join("; "));
1815
1817
  const microphone = proxied ? "microphone=(self)" : "microphone=()";
@@ -1899,6 +1901,7 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
1899
1901
  if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
1900
1902
  const host = localAuthority(request.headers.host);
1901
1903
  if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
1904
+ if (request.method === "OPTIONS") throw new HttpError(403, "forbidden");
1902
1905
  const site = request.headers["sec-fetch-site"];
1903
1906
  if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
1904
1907
  const origin = request.headers.origin;
@@ -1910,7 +1913,9 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
1910
1913
  throw new HttpError(403, "forbidden");
1911
1914
  }
1912
1915
  const desktopForward = site === void 0 && request.headers["x-dsh-mobile-desktop-admin"] === "dsh-app://app";
1913
- if (requireBrowserOrigin && origin === void 0 && !(desktopForward && authenticateDesktop())) throw new HttpError(403, "forbidden");
1916
+ const contentType = request.headers["content-type"];
1917
+ const webRequest = request.method === "POST" && (site === void 0 || site === "same-origin") && request.headers["x-dsh-mobile-web-admin"] === "same-origin-json" && request.headers["x-dsh-mobile-desktop-admin"] === void 0 && typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
1918
+ if (requireBrowserOrigin && origin === void 0 && !webRequest && !(desktopForward && authenticateDesktop())) throw new HttpError(403, "forbidden");
1914
1919
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1915
1920
  }
1916
1921
  //#endregion
@@ -1968,7 +1973,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
1968
1973
  }
1969
1974
  //#endregion
1970
1975
  //#region src/auth-failure-log.ts
1971
- const LOG_INTERVAL_MS$1 = 3e4;
1976
+ const LOG_INTERVAL_MS$2 = 3e4;
1972
1977
  const MAX_LOGGED_ORIGIN_LENGTH = 512;
1973
1978
  function requestPathCategory(path) {
1974
1979
  switch (path) {
@@ -2065,7 +2070,7 @@ var AuthFailureRecorder = class {
2065
2070
  return;
2066
2071
  }
2067
2072
  const suppressed = window.suppressed;
2068
- window.nextLogAt = now + LOG_INTERVAL_MS$1;
2073
+ window.nextLogAt = now + LOG_INTERVAL_MS$2;
2069
2074
  window.suppressed = 0;
2070
2075
  try {
2071
2076
  this.observe({
@@ -2119,7 +2124,7 @@ function classifyHttpParserFailure(nodeCode) {
2119
2124
  parserCode
2120
2125
  };
2121
2126
  }
2122
- const LOG_INTERVAL_MS = 3e4;
2127
+ const LOG_INTERVAL_MS$1 = 3e4;
2123
2128
  /**
2124
2129
  * Per-Gateway diagnostics with three fixed sampling slots and no timers or request-selected keys.
2125
2130
  * Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
@@ -2188,7 +2193,7 @@ var HttpFailureRecorder = class {
2188
2193
  return;
2189
2194
  }
2190
2195
  const suppressed = window.suppressed;
2191
- window.nextLogAt = now + LOG_INTERVAL_MS;
2196
+ window.nextLogAt = now + LOG_INTERVAL_MS$1;
2192
2197
  window.suppressed = 0;
2193
2198
  try {
2194
2199
  this.observe({
@@ -2205,6 +2210,72 @@ var HttpFailureRecorder = class {
2205
2210
  }
2206
2211
  };
2207
2212
  //#endregion
2213
+ //#region src/upstream-auth-log.ts
2214
+ const LOG_INTERVAL_MS = 3e4;
2215
+ /** Two fixed sampling slots keep a first failure visible even after a successful exchange. */
2216
+ var UpstreamAuthRecorder = class {
2217
+ observe;
2218
+ now;
2219
+ reportObserverFailure;
2220
+ sequence = 0;
2221
+ windows = {
2222
+ success: {
2223
+ nextLogAt: 0,
2224
+ suppressed: 0
2225
+ },
2226
+ failure: {
2227
+ nextLogAt: 0,
2228
+ suppressed: 0
2229
+ }
2230
+ };
2231
+ observerFailureReported = false;
2232
+ constructor(observe, now = () => performance.now(), reportObserverFailure = () => {
2233
+ process.emitWarning("Mobile access upstream authentication diagnostics could not be recorded", { code: "DSH_MOBILE_UPSTREAM_AUTH_LOG_FAILED" });
2234
+ }) {
2235
+ this.observe = observe;
2236
+ this.now = now;
2237
+ this.reportObserverFailure = reportObserverFailure;
2238
+ }
2239
+ /** Begin only a physical exchange, not every consumer sharing its promise or cached Cookie. */
2240
+ begin() {
2241
+ if (this.observe === void 0) return void 0;
2242
+ this.sequence = this.sequence >= Number.MAX_SAFE_INTEGER ? 1 : this.sequence + 1;
2243
+ return {
2244
+ sequence: this.sequence,
2245
+ startedAt: this.now()
2246
+ };
2247
+ }
2248
+ /** Record the completed exchange without changing authentication, retrying it or retaining its inputs. */
2249
+ record(attempt, result) {
2250
+ if (attempt === void 0 || this.observe === void 0) return;
2251
+ const now = this.now();
2252
+ const window = this.windows[result.outcome === "accepted" ? "success" : "failure"];
2253
+ if (now < window.nextLogAt) {
2254
+ window.suppressed = Math.min(Number.MAX_SAFE_INTEGER, window.suppressed + 1);
2255
+ return;
2256
+ }
2257
+ const suppressed = window.suppressed;
2258
+ window.nextLogAt = now + LOG_INTERVAL_MS;
2259
+ window.suppressed = 0;
2260
+ try {
2261
+ this.observe({
2262
+ sequence: attempt.sequence,
2263
+ outcome: result.outcome,
2264
+ stage: result.stage,
2265
+ durationMs: Math.max(0, Math.floor(now - attempt.startedAt)),
2266
+ ...result.status === void 0 ? {} : { status: result.status },
2267
+ ...suppressed === 0 ? {} : { suppressed }
2268
+ });
2269
+ } catch (observerError) {
2270
+ if (this.observerFailureReported) return;
2271
+ this.observerFailureReported = true;
2272
+ try {
2273
+ this.reportObserverFailure();
2274
+ } catch (reporterError) {}
2275
+ }
2276
+ }
2277
+ };
2278
+ //#endregion
2208
2279
  //#region src/client-module-recovery.ts
2209
2280
  const CLIENT_MODULE_RECOVERY_COPY = {
2210
2281
  zh: {
@@ -2384,6 +2455,196 @@ function weakenEntityTag(etag) {
2384
2455
  return opaque === void 0 ? void 0 : `W/${opaque}`;
2385
2456
  }
2386
2457
  //#endregion
2458
+ //#region src/mobile-graph-events.ts
2459
+ /** Bounded SSE framing for the dedicated Mobile client graph stream. */
2460
+ /** Maximum retained UTF-8 frame/output bytes, with CRLF normalized to LF. */
2461
+ const MOBILE_GRAPH_EVENT_MAX_BYTES = 4194304;
2462
+ const LINE_FEED = Buffer.from("\n");
2463
+ /** Fixed failures contain neither an upstream payload nor a projection exception. */
2464
+ var MobileGraphEventError = class extends Error {
2465
+ code;
2466
+ constructor(code) {
2467
+ super(`Mobile graph event stream: ${code}`);
2468
+ this.code = code;
2469
+ this.name = "MobileGraphEventError";
2470
+ }
2471
+ };
2472
+ var MobileGraphEventTransform = class extends Transform {
2473
+ project;
2474
+ frame = Buffer.allocUnsafe(16384);
2475
+ used = 0;
2476
+ lineBytes = 0;
2477
+ skipLf = false;
2478
+ firstFrame = true;
2479
+ disposed = false;
2480
+ readWait;
2481
+ constructor(project) {
2482
+ super();
2483
+ this.project = project;
2484
+ }
2485
+ _transform(chunk, _encoding, callback) {
2486
+ this.consume(chunk).then(() => callback(), (error) => callback(error));
2487
+ }
2488
+ _read(size) {
2489
+ super._read(size);
2490
+ const waiting = this.readWait;
2491
+ this.readWait = void 0;
2492
+ waiting?.resolve();
2493
+ }
2494
+ _flush(callback) {
2495
+ if (this.used !== 0) {
2496
+ try {
2497
+ this.decodeFrame();
2498
+ } catch {
2499
+ callback(new MobileGraphEventError("invalid_utf8"));
2500
+ return;
2501
+ }
2502
+ callback(new MobileGraphEventError("incomplete_frame"));
2503
+ return;
2504
+ }
2505
+ callback();
2506
+ }
2507
+ _destroy(error, callback) {
2508
+ this.disposed = true;
2509
+ const waiting = this.readWait;
2510
+ this.readWait = void 0;
2511
+ waiting?.reject(error ?? new MobileGraphEventError("stream_closed"));
2512
+ this.frame = Buffer.alloc(0);
2513
+ this.used = 0;
2514
+ callback(error);
2515
+ }
2516
+ requireOpen() {
2517
+ if (this.disposed || this.destroyed) throw new MobileGraphEventError("stream_closed");
2518
+ }
2519
+ append(bytes) {
2520
+ const required = this.used + bytes.length;
2521
+ if (required > 4194304) throw new MobileGraphEventError("frame_too_large");
2522
+ if (required > this.frame.length) {
2523
+ const next = Buffer.allocUnsafe(Math.min(MOBILE_GRAPH_EVENT_MAX_BYTES, Math.max(required, this.frame.length * 2)));
2524
+ this.frame.copy(next, 0, 0, this.used);
2525
+ this.frame = next;
2526
+ }
2527
+ bytes.copy(this.frame, this.used);
2528
+ this.used = required;
2529
+ }
2530
+ async consume(chunk) {
2531
+ let offset = 0;
2532
+ while (offset < chunk.length) {
2533
+ this.requireOpen();
2534
+ if (this.skipLf) {
2535
+ this.skipLf = false;
2536
+ if (chunk[offset] === 10) {
2537
+ offset += 1;
2538
+ continue;
2539
+ }
2540
+ }
2541
+ let contentEnd = offset;
2542
+ while (contentEnd < chunk.length && chunk[contentEnd] !== 13 && chunk[contentEnd] !== 10) contentEnd += 1;
2543
+ const content = chunk.subarray(offset, contentEnd);
2544
+ this.append(content);
2545
+ this.lineBytes += content.length;
2546
+ if (contentEnd === chunk.length) return;
2547
+ this.append(LINE_FEED);
2548
+ this.skipLf = chunk[contentEnd] === 13;
2549
+ const emptyLine = this.lineBytes === 0;
2550
+ this.lineBytes = 0;
2551
+ offset = contentEnd + 1;
2552
+ if (emptyLine) await this.completeFrame();
2553
+ }
2554
+ }
2555
+ decodeFrame() {
2556
+ try {
2557
+ return new TextDecoder("utf-8", {
2558
+ fatal: true,
2559
+ ignoreBOM: true
2560
+ }).decode(this.frame.subarray(0, this.used));
2561
+ } catch {
2562
+ throw new MobileGraphEventError("invalid_utf8");
2563
+ }
2564
+ }
2565
+ async completeFrame() {
2566
+ let text = this.decodeFrame();
2567
+ this.used = 0;
2568
+ if (this.firstFrame) {
2569
+ this.firstFrame = false;
2570
+ if (text.startsWith("")) text = text.slice(1);
2571
+ }
2572
+ let event;
2573
+ createParser({
2574
+ maxBufferSize: MOBILE_GRAPH_EVENT_MAX_BYTES,
2575
+ onEvent: (value) => {
2576
+ event = value;
2577
+ },
2578
+ onError: () => {
2579
+ throw new MobileGraphEventError("invalid_sse");
2580
+ }
2581
+ }).feed(text);
2582
+ const lines = text.split("\n");
2583
+ for (const line of lines) {
2584
+ if (!line.startsWith("event:")) continue;
2585
+ const type = line.slice(line[6] === " " ? 7 : 6);
2586
+ if (type !== "" && type !== "message") throw new MobileGraphEventError("unsupported_event");
2587
+ }
2588
+ if (event === void 0) {
2589
+ await this.forward(Buffer.from(text, "utf8"));
2590
+ return;
2591
+ }
2592
+ if (event.event !== void 0 && event.event !== "message") throw new MobileGraphEventError("unsupported_event");
2593
+ let value;
2594
+ try {
2595
+ value = JSON.parse(event.data);
2596
+ } catch {
2597
+ throw new MobileGraphEventError("invalid_json");
2598
+ }
2599
+ let projected;
2600
+ try {
2601
+ projected = await this.project(value);
2602
+ } catch {
2603
+ throw new MobileGraphEventError("projection_failed");
2604
+ }
2605
+ this.requireOpen();
2606
+ if (projected === void 0) return;
2607
+ let encoded;
2608
+ try {
2609
+ encoded = JSON.stringify(projected);
2610
+ } catch {
2611
+ throw new MobileGraphEventError("invalid_projection");
2612
+ }
2613
+ if (encoded === void 0) throw new MobileGraphEventError("invalid_projection");
2614
+ const metadata = lines.filter((line) => line !== "" && line !== "data" && !line.startsWith("data:"));
2615
+ const result = `${metadata.length === 0 ? "" : `${metadata.join("\n")}\n`}data: ${encoded}\n\n`;
2616
+ if (Buffer.byteLength(result) > 4194304) throw new MobileGraphEventError("frame_too_large");
2617
+ await this.forward(Buffer.from(result, "utf8"));
2618
+ }
2619
+ async forward(frame) {
2620
+ this.requireOpen();
2621
+ if (frame.length === 0 || this.push(frame)) return;
2622
+ await new Promise((resolve, reject) => {
2623
+ this.readWait = {
2624
+ resolve,
2625
+ reject
2626
+ };
2627
+ if (this.destroyed || this.disposed) {
2628
+ this.readWait = void 0;
2629
+ reject(new MobileGraphEventError("stream_closed"));
2630
+ } else if (this.readableLength < this.readableHighWaterMark) {
2631
+ this.readWait = void 0;
2632
+ resolve();
2633
+ }
2634
+ });
2635
+ }
2636
+ };
2637
+ /**
2638
+ * Project each complete JSON message serially without forwarding unprojected data.
2639
+ * Comments and valid SSE metadata are retained; only the default message event is supported.
2640
+ * Undefined suppresses that message. Malformed, oversized or truncated input terminates the stream.
2641
+ * @param project - Projects one message; caller-owned cancellation must stop any external work.
2642
+ * @returns A byte-mode Transform that honors downstream backpressure and fences late results after destroy.
2643
+ */
2644
+ function createMobileGraphEventTransform(project) {
2645
+ return new MobileGraphEventTransform(project);
2646
+ }
2647
+ //#endregion
2387
2648
  //#region src/websocket-compression.ts
2388
2649
  function payloadBytes(data) {
2389
2650
  if (Array.isArray(data)) return data.reduce((bytes, part) => bytes + part.byteLength, 0);
@@ -4660,6 +4921,7 @@ const MDNS_SERVICE_TYPE = "dsh-mobile";
4660
4921
  const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
4661
4922
  const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
4662
4923
  const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
4924
+ const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
4663
4925
  const MAX_MOBILE_BOOT_BATCH_BYTES = 33554432;
4664
4926
  const MAX_MOBILE_BOOT_ENTRY_BYTES = 8388608;
4665
4927
  /**
@@ -4741,7 +5003,9 @@ const MOBILE_LAYOUT_DEPENDENCY_PROFILES = Object.freeze([Object.freeze({
4741
5003
  function mobileCsrfFetchBootstrap(cookieName) {
4742
5004
  return `(()=>{const nativeFetch=window.fetch.bind(window);window.fetch=(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();if(method==='GET'||method==='HEAD')return nativeFetch(input,init);const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw===undefined||new URL(raw,location.href).origin!==location.origin)return nativeFetch(input,init);const headers=new Headers(init?.headers??source?.headers);if(!headers.has(${JSON.stringify(CSRF_HEADER)})){const prefix=${JSON.stringify(`${cookieName}=`)};const values=[...new Set(document.cookie.split(';').map(value=>value.trim()).filter(value=>value.startsWith(prefix)).map(value=>value.slice(prefix.length)))];if(values.length===1)headers.set(${JSON.stringify(CSRF_HEADER)},values[0])}return nativeFetch(input,{...init,headers})};})();`;
4743
5005
  }
4744
- const MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP = `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:(input,init)=>window.fetch(input,init),ownsHost:true},window.__DSH_FILE_UPLOAD__={fetch:(input,init)=>window.fetch(input,init)}})();`;
5006
+ function mobileAuthenticatedTransportBootstrap(viewId) {
5007
+ return `(()=>{if(window.__DSH_TRANSPORT__!==undefined)throw new Error('DSH Mobile cannot replace an existing transport override');window.__DSH_TRANSPORT__={fetch:${viewId === void 0 ? "(input,init)=>window.fetch(input,init)" : `(input,init)=>{const source=input instanceof Request?input:undefined;const method=String(init?.method??source?.method??'GET').toUpperCase();const raw=typeof input==='string'?input:input instanceof URL?input.href:source?.url;if(raw!==undefined&&method==='GET'){const target=new URL(raw,location.href);if(target.origin===location.origin&&target.pathname==='/plugins/events'){const headers=new Headers(init?.headers??source?.headers);headers.set(${JSON.stringify(MOBILE_DOCUMENT_VIEW_HEADER)},${JSON.stringify(viewId)});return window.fetch(input,{...init,headers})}}return window.fetch(input,init)}`},ownsHost:true},window.__DSH_FILE_UPLOAD__={fetch:(input,init)=>window.fetch(input,init)}})();`;
5008
+ }
4745
5009
  /** Use this page's gateway health, rather than OS internet validation, for DSH reconnects. */
4746
5010
  const MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP = `(()=>{
4747
5011
  const view=globalThis;
@@ -4911,6 +5175,14 @@ function parseMobileBootPlan(html, excludedClientModules = []) {
4911
5175
  const scriptEnd = html.indexOf("<\/script>", valueStart);
4912
5176
  if (scriptEnd < 0) throw new Error("upstream DSH boot manifest script is incomplete");
4913
5177
  const source = html.slice(valueStart, scriptEnd).trim().replace(/;$/u, "");
5178
+ return Object.freeze({
5179
+ ...parseMobileBootGraph(source, excludedClientModules),
5180
+ assignment: assignment[0],
5181
+ replaceStart,
5182
+ replaceEnd: scriptEnd
5183
+ });
5184
+ }
5185
+ function parseMobileBootGraph(source, excludedClientModules) {
4914
5186
  const parsed = JSON.parse(source);
4915
5187
  if (typeof parsed.rev !== "string" || !Array.isArray(parsed.entries)) throw new Error("upstream DSH boot manifest is malformed");
4916
5188
  const entries = parsed.entries;
@@ -4927,10 +5199,7 @@ function parseMobileBootPlan(html, excludedClientModules = []) {
4927
5199
  return Object.freeze({
4928
5200
  parsed,
4929
5201
  entries,
4930
- slotProvider: dependencyProfile.slots,
4931
- assignment: assignment[0],
4932
- replaceStart,
4933
- replaceEnd: scriptEnd
5202
+ slotProvider: dependencyProfile.slots
4934
5203
  });
4935
5204
  }
4936
5205
  if (!Array.isArray(parsedBatches)) throw new Error("upstream DSH boot manifest batches are malformed");
@@ -4969,9 +5238,6 @@ function parseMobileBootPlan(html, excludedClientModules = []) {
4969
5238
  entries,
4970
5239
  batches,
4971
5240
  slotProvider: dependencyProfile.slots,
4972
- assignment: assignment[0],
4973
- replaceStart,
4974
- replaceEnd: scriptEnd,
4975
5241
  layoutBatch,
4976
5242
  planEntries,
4977
5243
  originalApplicationBatches,
@@ -5055,6 +5321,17 @@ function rewriteMobileApplicationPreloads(html, replacements) {
5055
5321
  }
5056
5322
  function rewriteMobileIndexWithBatch(html, options = {}) {
5057
5323
  const plan = parseMobileBootPlan(html, options.excludedClientModules);
5324
+ const projected = projectMobileBootGraph(plan, options);
5325
+ const transportBootstrap = projected.graphTransport ? mobileAuthenticatedTransportBootstrap(options.viewId) : "";
5326
+ const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${transportBootstrap}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
5327
+ const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
5328
+ return Object.freeze({
5329
+ html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, projected.preloadReplacements)))),
5330
+ batches: projected.batches,
5331
+ graphTransport: projected.graphTransport
5332
+ });
5333
+ }
5334
+ function projectMobileBootGraph(plan, options) {
5058
5335
  const remoteSettings = orderAuthenticatedSettings(plan.entries, plan.slotProvider);
5059
5336
  let batches = [];
5060
5337
  let preloadReplacements = /* @__PURE__ */ new Map();
@@ -5080,11 +5357,10 @@ function rewriteMobileIndexWithBatch(html, options = {}) {
5080
5357
  batches: plan.batches
5081
5358
  })).digest("hex").slice(0, 16);
5082
5359
  }
5083
- const replacement = `${MOBILE_GATEWAY_REACHABILITY_BOOTSTRAP}${remoteSettings ? MOBILE_AUTHENTICATED_TRANSPORT_BOOTSTRAP : ""}${mobileCsrfFetchBootstrap(options.csrfCookie ?? "dsh_ma_csrf")}window.__DSH_MOBILE_FRONTEND__="dedicated";${plan.assignment}${JSON.stringify(plan.parsed)};`;
5084
- const rewritten = `${html.slice(0, plan.replaceStart)}${replacement}${html.slice(plan.replaceEnd)}`;
5085
5360
  return Object.freeze({
5086
- html: ensureMobileGatewayMarker(ensureMobileViewport(ensureMobileCompatibility(rewriteMobileApplicationPreloads(rewritten, preloadReplacements)))),
5087
- batches
5361
+ batches,
5362
+ preloadReplacements,
5363
+ graphTransport: remoteSettings
5088
5364
  });
5089
5365
  }
5090
5366
  /** Rewrite the dedicated mobile graph; exclusions are opt-in and leave stock DSH unchanged. */
@@ -5515,6 +5791,8 @@ var MobileAccessGateway = class {
5515
5791
  activeRequests = /* @__PURE__ */ new Map();
5516
5792
  activeWebSockets = /* @__PURE__ */ new Map();
5517
5793
  mobileBootBatches = /* @__PURE__ */ new Map();
5794
+ mobileDocumentViews = /* @__PURE__ */ new Map();
5795
+ mobileGraphTasks = /* @__PURE__ */ new Set();
5518
5796
  /** Cached upstream bundle byte sizes (HEAD probes), keyed by resource URL. */
5519
5797
  bootEntrySizeCache = /* @__PURE__ */ new Map();
5520
5798
  extensionEventListeners = /* @__PURE__ */ new Set();
@@ -5547,7 +5825,8 @@ var MobileAccessGateway = class {
5547
5825
  clientModuleRevocations = /* @__PURE__ */ new Set();
5548
5826
  authFailures;
5549
5827
  httpFailures;
5550
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog) {
5828
+ upstreamAuthDiagnostics;
5829
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog, upstreamAuthLog) {
5551
5830
  this.config = config;
5552
5831
  this.extensions = extensions;
5553
5832
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
@@ -5556,6 +5835,7 @@ var MobileAccessGateway = class {
5556
5835
  this.onDiscoveryDegraded = onDiscoveryDegraded;
5557
5836
  this.authFailures = new AuthFailureRecorder(authFailureLog);
5558
5837
  this.httpFailures = new HttpFailureRecorder(httpFailureLog);
5838
+ this.upstreamAuthDiagnostics = new UpstreamAuthRecorder(upstreamAuthLog);
5559
5839
  this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
5560
5840
  this.listenerTlsEnabled = config.tls.mode === "provided";
5561
5841
  this.tlsEnabled = config.publicTls;
@@ -5578,6 +5858,7 @@ var MobileAccessGateway = class {
5578
5858
  this.probeFailureLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5579
5859
  this.probeDeviceLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxDevices);
5580
5860
  this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
5861
+ for (const view of this.mobileDocumentViews.values()) if (view.sessionKey === authorization.sessionKey) this.removeMobileDocumentView(view);
5581
5862
  if (reason === "revoked") {
5582
5863
  const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
5583
5864
  process.emitWarning("Unable to remove revoked device client preferences", { code: "DSH_MOBILE_PREFERENCE_CLEANUP_FAILED" });
@@ -6209,6 +6490,8 @@ var MobileAccessGateway = class {
6209
6490
  }
6210
6491
  throw error;
6211
6492
  }
6493
+ const viewId = headerValue(request.headers, MOBILE_DOCUMENT_VIEW_HEADER);
6494
+ const documentView = viewId === void 0 ? void 0 : this.requireMobileDocumentView(viewId, authorization, request);
6212
6495
  if (isMutation) try {
6213
6496
  this.requireCsrf(request, authorization);
6214
6497
  } catch (error) {
@@ -6336,7 +6619,7 @@ var MobileAccessGateway = class {
6336
6619
  return;
6337
6620
  }
6338
6621
  if (stockFrontend && target.decodedPathname === "/") request.url = "/";
6339
- await this.proxyHttp(request, response, authorization);
6622
+ await this.proxyHttp(request, response, authorization, documentView);
6340
6623
  }
6341
6624
  async handleExtensionRequest(targetInfo, target, request, response, authorization) {
6342
6625
  const extensions = this.extensions;
@@ -6568,16 +6851,23 @@ var MobileAccessGateway = class {
6568
6851
  }
6569
6852
  }
6570
6853
  async exchangeUpstreamCookie() {
6571
- const authenticatedUrl = this.upstreamAuthenticatedUrl;
6572
- if (authenticatedUrl === void 0) throw new HttpError(502, "upstream_unavailable");
6573
- let target;
6574
- try {
6575
- target = new URL(authenticatedUrl);
6576
- } catch {
6577
- throw new HttpError(502, "upstream_unavailable");
6578
- }
6579
- if (target.origin !== this.config.upstreamOrigin.origin || target.pathname !== "/" || target.hash !== "" || target.search === "") throw new HttpError(502, "upstream_unavailable");
6854
+ const attempt = this.upstreamAuthDiagnostics.begin();
6855
+ let stage = "target";
6856
+ let outcome = "invalid-target";
6857
+ let status;
6858
+ let timedOut = false;
6580
6859
  try {
6860
+ const authenticatedUrl = this.upstreamAuthenticatedUrl;
6861
+ if (authenticatedUrl === void 0) throw new HttpError(502, "upstream_unavailable");
6862
+ let target;
6863
+ try {
6864
+ target = new URL(authenticatedUrl);
6865
+ } catch {
6866
+ throw new HttpError(502, "upstream_unavailable");
6867
+ }
6868
+ if (target.origin !== this.config.upstreamOrigin.origin || target.pathname !== "/" || target.hash !== "" || target.search === "") throw new HttpError(502, "upstream_unavailable");
6869
+ stage = "request";
6870
+ outcome = "transport-error";
6581
6871
  const proxied = await new Promise((resolve, reject) => {
6582
6872
  const upstreamRequest = request({
6583
6873
  protocol: "http:",
@@ -6595,27 +6885,46 @@ var MobileAccessGateway = class {
6595
6885
  });
6596
6886
  this.upstreamAuthRequest = upstreamRequest;
6597
6887
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
6888
+ timedOut = true;
6598
6889
  upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
6599
6890
  });
6600
6891
  upstreamRequest.once("response", resolve);
6601
6892
  upstreamRequest.once("error", reject);
6602
6893
  upstreamRequest.end();
6603
6894
  });
6895
+ stage = "response";
6896
+ status = proxied.statusCode;
6604
6897
  await new Promise((resolve, reject) => {
6605
6898
  proxied.once("end", resolve);
6606
6899
  proxied.once("error", reject);
6607
6900
  proxied.resume();
6608
6901
  });
6902
+ if (status !== 303) {
6903
+ outcome = "unexpected-status";
6904
+ throw new HttpError(502, "upstream_unavailable");
6905
+ }
6906
+ stage = "cookie";
6907
+ outcome = "invalid-cookie";
6609
6908
  const setCookie = proxied.headers["set-cookie"]?.[0];
6610
6909
  const pair = setCookie?.split(";", 1)[0];
6611
6910
  const maxAgeText = setCookie === void 0 ? void 0 : /(?:^|;\s*)Max-Age=(\d+)(?:;|$)/iu.exec(setCookie)?.[1];
6612
6911
  const maxAgeSeconds = maxAgeText === void 0 ? NaN : Number(maxAgeText);
6613
6912
  const expiresAt = Date.now() + maxAgeSeconds * 1e3;
6614
- if (proxied.statusCode !== 303 || pair === void 0 || pair.length > 4096 || !UPSTREAM_COOKIE_PAIR.test(pair) || !Number.isSafeInteger(expiresAt) || maxAgeSeconds <= 0) throw new HttpError(502, "upstream_unavailable");
6913
+ if (pair === void 0 || pair.length > 4096 || !UPSTREAM_COOKIE_PAIR.test(pair) || !Number.isSafeInteger(expiresAt) || maxAgeSeconds <= 0) throw new HttpError(502, "upstream_unavailable");
6615
6914
  this.upstreamCookie = pair;
6616
6915
  this.upstreamCookieExpiresAt = expiresAt;
6916
+ this.upstreamAuthDiagnostics.record(attempt, {
6917
+ outcome: "accepted",
6918
+ stage,
6919
+ status
6920
+ });
6617
6921
  return pair;
6618
6922
  } catch (error) {
6923
+ this.upstreamAuthDiagnostics.record(attempt, {
6924
+ outcome: timedOut || error instanceof HttpError && error.status === 504 ? "timeout" : outcome,
6925
+ stage,
6926
+ ...status === void 0 ? {} : { status }
6927
+ });
6619
6928
  if (error instanceof HttpError) throw error;
6620
6929
  throw new HttpError(502, "upstream_unavailable");
6621
6930
  } finally {
@@ -6789,11 +7098,15 @@ var MobileAccessGateway = class {
6789
7098
  };
6790
7099
  const cookies = browserAuthCookies(request.headers.cookie);
6791
7100
  const csrfCookie = this.tlsEnabled && cookies.has("__Host-dsh_ma_session") ? HOST_CSRF_COOKIE : CSRF_COOKIE;
7101
+ const viewId = randomBytes(16).toString("hex");
6792
7102
  const rewritten = rewriteMobileIndexWithBatch(html, {
6793
7103
  ...options,
6794
- csrfCookie
7104
+ csrfCookie,
7105
+ viewId
6795
7106
  });
7107
+ operation.signal.throwIfAborted();
6796
7108
  for (const batch of rewritten.batches) this.rememberMobileBootBatch(batch);
7109
+ if (rewritten.graphTransport) this.rememberMobileDocumentView(viewId, authorization, excludedClientModules);
6797
7110
  body = Buffer.from(rewritten.html);
6798
7111
  } catch (error) {
6799
7112
  if (error instanceof ExcludedClientModulesError) {
@@ -6845,6 +7158,53 @@ var MobileAccessGateway = class {
6845
7158
  operation.release();
6846
7159
  }
6847
7160
  }
7161
+ removeMobileDocumentView(view) {
7162
+ if (this.mobileDocumentViews.get(view.id) !== view) return;
7163
+ this.mobileDocumentViews.delete(view.id);
7164
+ for (const abort of view.abortStreams) abort();
7165
+ view.abortStreams.clear();
7166
+ }
7167
+ rememberMobileDocumentView(id, authorization, excluded) {
7168
+ for (const view of this.mobileDocumentViews.values()) if (view.expiresAt <= Date.now()) this.removeMobileDocumentView(view);
7169
+ const view = Object.freeze({
7170
+ ...authorization,
7171
+ id,
7172
+ excludedClientModules: Object.freeze([...excluded]),
7173
+ abortStreams: /* @__PURE__ */ new Set()
7174
+ });
7175
+ this.mobileDocumentViews.set(id, view);
7176
+ while (this.mobileDocumentViews.size > this.config.maxSessions) {
7177
+ const oldest = this.mobileDocumentViews.values().next().value;
7178
+ if (oldest === void 0) break;
7179
+ this.removeMobileDocumentView(oldest);
7180
+ }
7181
+ }
7182
+ requireMobileDocumentView(id, authorization, request) {
7183
+ const view = /^[a-f0-9]{32}$/u.test(id) ? this.mobileDocumentViews.get(id) : void 0;
7184
+ if (view === void 0 || view.expiresAt <= Date.now() || view.sessionKey !== authorization.sessionKey || view.deviceId !== authorization.deviceId || request.method !== "GET" || parseRequestTarget(request.url).decodedPathname !== "/plugins/events" || request.headers["transfer-encoding"] !== void 0 || request.headers["content-length"] !== void 0 && Number(request.headers["content-length"]) !== 0) throw new HttpError(409, "mobile_document_view_invalid");
7185
+ return view;
7186
+ }
7187
+ async projectMobileDocumentEvent(view, value, signal) {
7188
+ signal.throwIfAborted();
7189
+ if (this.mobileDocumentViews.get(view.id) !== view) throw new HttpError(409, "mobile_document_view_invalid");
7190
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new HttpError(502, "upstream_unavailable");
7191
+ const frame = value;
7192
+ if (frame.type === "rebuilt") {
7193
+ if (typeof frame.id !== "string" || typeof frame.rev !== "string") throw new HttpError(502, "upstream_unavailable");
7194
+ return frame.id === MOBILE_LAYOUT_MODULE ? void 0 : frame;
7195
+ }
7196
+ if (frame.type !== "graph" || frame.graph === null || typeof frame.graph !== "object" || Array.isArray(frame.graph)) throw new HttpError(502, "upstream_unavailable");
7197
+ const plan = parseMobileBootGraph(JSON.stringify(frame.graph), view.excludedClientModules);
7198
+ const projected = projectMobileBootGraph(plan, plan.rewrittenBatchEntries === void 0 ? {} : await this.resolveMobileBootSizes(plan.rewrittenBatchEntries.flatMap((batch) => batch.entries), signal));
7199
+ if (!projected.graphTransport) throw new HttpError(502, "upstream_unavailable");
7200
+ signal.throwIfAborted();
7201
+ if (this.mobileDocumentViews.get(view.id) !== view) throw new HttpError(409, "mobile_document_view_invalid");
7202
+ for (const batch of projected.batches) this.rememberMobileBootBatch(batch);
7203
+ return {
7204
+ ...frame,
7205
+ graph: plan.parsed
7206
+ };
7207
+ }
6848
7208
  rememberMobileBootBatch(plan) {
6849
7209
  const existing = this.mobileBootBatches.get(plan.key);
6850
7210
  this.mobileBootBatches.delete(plan.key);
@@ -6877,7 +7237,8 @@ var MobileAccessGateway = class {
6877
7237
  else sizes.set(source, size);
6878
7238
  }
6879
7239
  };
6880
- await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker));
7240
+ const failed = (await Promise.allSettled(Array.from({ length: Math.min(8, candidates.length) }, worker))).find((result) => result.status === "rejected");
7241
+ if (failed?.status === "rejected") throw failed.reason;
6881
7242
  return {
6882
7243
  sizes,
6883
7244
  passThrough
@@ -6896,6 +7257,7 @@ var MobileAccessGateway = class {
6896
7257
  const cached = this.bootEntrySizeCache.get(source);
6897
7258
  if (cached !== void 0 && Date.now() - cached.at < MOBILE_BOOT_SIZE_CACHE_TTL_MS) return cached.size;
6898
7259
  let upstreamRequest;
7260
+ let upstreamClosed;
6899
7261
  const abort = () => {
6900
7262
  upstreamRequest?.destroy(requestAbortedError());
6901
7263
  };
@@ -6919,6 +7281,12 @@ var MobileAccessGateway = class {
6919
7281
  },
6920
7282
  agent: false
6921
7283
  });
7284
+ const opened = upstreamRequest;
7285
+ upstreamClosed = new Promise((closed) => {
7286
+ opened.once("close", () => {
7287
+ closed();
7288
+ });
7289
+ });
6922
7290
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
6923
7291
  upstreamRequest?.destroy(upstreamTimeoutError());
6924
7292
  });
@@ -6954,6 +7322,7 @@ var MobileAccessGateway = class {
6954
7322
  } finally {
6955
7323
  signal?.removeEventListener("abort", abort);
6956
7324
  upstreamRequest?.destroy();
7325
+ await upstreamClosed;
6957
7326
  }
6958
7327
  }
6959
7328
  async serveMobileBootBatch(key, request, response, authorization) {
@@ -7152,14 +7521,15 @@ var MobileAccessGateway = class {
7152
7521
  * One request slot and cancellation signal cover attempts and backoff together.
7153
7522
  * API requests and requests with bodies are forwarded once.
7154
7523
  */
7155
- async proxyHttp(request, response, authorization) {
7524
+ async proxyHttp(request, response, authorization, documentView) {
7156
7525
  const declared = request.headers["content-length"];
7157
7526
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
7158
7527
  const replayable = request.method === "GET" && request.headers["transfer-encoding"] === void 0 && (declared === void 0 || Number(declared) === 0) && (request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request.url?.split("?", 1)[0]?.startsWith("/assets/") === true);
7159
7528
  const holder = {};
7160
7529
  const operation = this.allocateRequest(authorization, response, holder);
7530
+ documentView?.abortStreams.add(operation.abort);
7161
7531
  const onClose = () => {
7162
- operation.release();
7532
+ if (documentView === void 0) operation.release();
7163
7533
  operation.abort();
7164
7534
  };
7165
7535
  response.once("close", onClose);
@@ -7169,7 +7539,7 @@ var MobileAccessGateway = class {
7169
7539
  for (let attempt = 1; attempt <= attempts; attempt++) {
7170
7540
  operation.signal.throwIfAborted();
7171
7541
  try {
7172
- await this.proxyHttpOnce(request, response, holder, operation.signal, replayable);
7542
+ await this.proxyHttpOnce(request, response, holder, operation.signal, replayable, documentView);
7173
7543
  return;
7174
7544
  } catch (error) {
7175
7545
  if (operation.signal.aborted || response.destroyed) return;
@@ -7186,13 +7556,18 @@ var MobileAccessGateway = class {
7186
7556
  if (!operation.signal.aborted) throw error;
7187
7557
  } finally {
7188
7558
  response.removeListener("close", onClose);
7559
+ documentView?.abortStreams.delete(operation.abort);
7189
7560
  operation.release();
7190
7561
  }
7191
7562
  }
7192
- async proxyHttpOnce(request$2, response, holder, signal, replayable) {
7563
+ async proxyHttpOnce(request$2, response, holder, signal, replayable, documentView) {
7193
7564
  let bodyDone;
7565
+ const projections = /* @__PURE__ */ new Set();
7194
7566
  try {
7567
+ const pathname = parseRequestTarget(request$2.url).decodedPathname;
7195
7568
  const upstreamHeaders = sanitizeRequestHeaders(request$2, this.config.upstreamOrigin);
7569
+ delete upstreamHeaders[MOBILE_DOCUMENT_VIEW_HEADER];
7570
+ if (documentView !== void 0) upstreamHeaders["accept-encoding"] = "identity";
7196
7571
  if (request$2.headers["transfer-encoding"] !== void 0) upstreamHeaders["transfer-encoding"] = request$2.headers["transfer-encoding"];
7197
7572
  const upstreamCookie = await this.upstreamCookieHeader();
7198
7573
  signal.throwIfAborted();
@@ -7211,7 +7586,6 @@ var MobileAccessGateway = class {
7211
7586
  upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
7212
7587
  upstreamRequest.destroy(new HttpError(504, "upstream_timeout"));
7213
7588
  });
7214
- const pathname = parseRequestTarget(request$2.url).decodedPathname;
7215
7589
  if (pathname === "/api" || pathname.startsWith("/api/")) upstreamRequest.once("finish", () => {
7216
7590
  upstreamRequest.setTimeout(this.config.upstreamApiTimeoutMs);
7217
7591
  });
@@ -7223,13 +7597,28 @@ var MobileAccessGateway = class {
7223
7597
  bodyDone.catch(reject);
7224
7598
  }
7225
7599
  });
7600
+ const graphEvents = documentView !== void 0 && replayable && pathname === "/plugins/events" && proxied.statusCode === 200 && proxied.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream";
7601
+ if (documentView !== void 0 && !graphEvents) {
7602
+ proxied.destroy();
7603
+ throw new HttpError(502, "upstream_unavailable");
7604
+ }
7605
+ if (graphEvents) holder.request?.setTimeout(this.config.upstreamApiTimeoutMs);
7226
7606
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
7227
7607
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
7608
+ if (graphEvents) {
7609
+ if (proxied.headers["content-encoding"] !== void 0 && proxied.headers["content-encoding"] !== "identity") throw new HttpError(502, "upstream_unavailable");
7610
+ delete headers["content-length"];
7611
+ delete headers["content-encoding"];
7612
+ delete headers.etag;
7613
+ delete headers["last-modified"];
7614
+ delete headers["content-range"];
7615
+ delete headers["accept-ranges"];
7616
+ }
7228
7617
  const statusCode = proxied.statusCode ?? 502;
7229
7618
  this.httpFailures.recordUpstream(request$2.method, request$2.url?.split("?", 1)[0] ?? "", statusCode);
7230
- const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
7619
+ const cacheControl = graphEvents ? void 0 : revisionedStaticCacheControl(request$2, statusCode);
7231
7620
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
7232
- const compressible = canCompressResponse(request$2, proxied);
7621
+ const compressible = !graphEvents && canCompressResponse(request$2, proxied);
7233
7622
  const compressed = compressible && acceptsGzip(request$2.headers["accept-encoding"]);
7234
7623
  if (compressible) addVaryAcceptEncoding(headers);
7235
7624
  if (compressed) {
@@ -7250,7 +7639,18 @@ var MobileAccessGateway = class {
7250
7639
  addVaryAcceptEncoding(headers);
7251
7640
  }
7252
7641
  response.writeHead(statusCode, headers);
7253
- await Promise.all([bodyDone, compressed && request$2.method !== "HEAD" ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
7642
+ const eventTransform = !graphEvents || documentView === void 0 ? void 0 : createMobileGraphEventTransform((value) => {
7643
+ const task = this.projectMobileDocumentEvent(documentView, value, signal);
7644
+ projections.add(task);
7645
+ this.mobileGraphTasks.add(task);
7646
+ const settled = () => {
7647
+ projections.delete(task);
7648
+ this.mobileGraphTasks.delete(task);
7649
+ };
7650
+ task.then(settled, settled);
7651
+ return task;
7652
+ });
7653
+ await Promise.all([bodyDone, eventTransform !== void 0 ? pipeline(proxied, eventTransform, response) : compressed && request$2.method !== "HEAD" ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
7254
7654
  } catch (error) {
7255
7655
  holder.request?.destroy();
7256
7656
  await bodyDone?.catch(() => void 0);
@@ -7258,6 +7658,7 @@ var MobileAccessGateway = class {
7258
7658
  if (response.headersSent) response.destroy();
7259
7659
  throw error;
7260
7660
  } finally {
7661
+ await Promise.allSettled(projections);
7261
7662
  delete holder.request;
7262
7663
  }
7263
7664
  }
@@ -7648,9 +8049,13 @@ var MobileAccessGateway = class {
7648
8049
  this.upstreamAuthRequest = void 0;
7649
8050
  this.removeSessionListener();
7650
8051
  this.pendingDeviceRevocations.clear();
8052
+ for (const view of this.mobileDocumentViews.values()) this.removeMobileDocumentView(view);
7651
8053
  const accessClose = this.access.close();
7652
8054
  for (const stored of this.mobileBootBatches.values()) stored.assembly?.controller.abort();
7653
- for (const request of this.activeRequests.values()) request.abort();
8055
+ for (const request of this.activeRequests.values()) {
8056
+ clearTimeout(request.timer);
8057
+ request.abort();
8058
+ }
7654
8059
  for (const websocket of this.activeWebSockets.values()) {
7655
8060
  websocket.client.destroy();
7656
8061
  websocket.upstream.destroy();
@@ -7675,6 +8080,7 @@ var MobileAccessGateway = class {
7675
8080
  await accessClose;
7676
8081
  await Promise.allSettled(this.clientModuleTasks);
7677
8082
  await Promise.allSettled(this.clientModuleRevocations);
8083
+ await Promise.allSettled(this.mobileGraphTasks);
7678
8084
  this.activeRequests.clear();
7679
8085
  this.activeWebSockets.clear();
7680
8086
  this.connectedSockets.clear();
@@ -11754,12 +12160,16 @@ const MIN_ORIGIN_LISTEN_PORT = 1024;
11754
12160
  const RESERVED_LAN_GATEWAY_PORT$1 = 3443;
11755
12161
  /** DSH's own WebServer port; the plugin cannot function while it is taken. */
11756
12162
  const RESERVED_DSH_WEB_PORT = 3080;
11757
- const PRIVATE_NETWORKS = [
11758
- "10.0.0.0/8",
11759
- "172.16.0.0/12",
11760
- "192.168.0.0/16"
11761
- ].map(parseCidr);
11762
- const TRUSTED_NETWORKS = [...PRIVATE_NETWORKS, parseCidr("127.0.0.0/8")];
12163
+ const TRUSTED_NETWORKS = [
12164
+ ...[
12165
+ "10.0.0.0/8",
12166
+ "172.16.0.0/12",
12167
+ "192.168.0.0/16",
12168
+ "fc00::/7"
12169
+ ].map(parseCidr),
12170
+ parseCidr("127.0.0.0/8"),
12171
+ parseCidr("::1/128")
12172
+ ];
11763
12173
  /** Require a public HTTPS origin; custom external ports are supported. */
11764
12174
  function validateOriginPublicOrigin(value) {
11765
12175
  if (typeof value !== "string" || value.length > 512 || /[\s\u0000-\u001f\u007f\\@?#]/u.test(value)) throw new Error("origin_public_origin_invalid");
@@ -11781,10 +12191,15 @@ function validateOriginPublicOrigin(value) {
11781
12191
  if (url.protocol !== "https:" || url.pathname !== "/" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.port !== "" && Number(url.port) < 1 || (isIP(host) === 4 ? !isGloballyRoutableIpv4(host) : isIP(host) !== 0 || !validHostname)) throw new Error("origin_public_origin_invalid");
11782
12192
  return url.origin;
11783
12193
  }
11784
- /** Bind only one explicit loopback or RFC1918 IPv4 interface, never all interfaces. */
12194
+ /** Bind one explicit loopback, RFC1918 IPv4 or IPv6 ULA interface; zone IDs are unsupported. */
11785
12195
  function validateOriginListenHost(value) {
11786
- if (typeof value !== "string" || isIP(value) !== 4 || !isLoopbackAddress(value) && !addressAllowed(value, PRIVATE_NETWORKS)) throw new Error("origin_listen_host_invalid");
11787
- return value;
12196
+ const addressFamily = isIP(value);
12197
+ if (typeof value !== "string" || value.includes("%") || addressFamily === 0 || !addressAllowed(value, TRUSTED_NETWORKS.filter((range) => range.bits === (addressFamily === 6 ? 128 : 32)))) throw new Error("origin_listen_host_invalid");
12198
+ return addressFamily === 6 ? new URL("http://[" + value + "]").hostname.slice(1, -1) : value;
12199
+ }
12200
+ /** Format a validated backend listener, including the brackets required by IPv6 URLs. */
12201
+ function formatOriginBackendOrigin(listenHost, listenPort) {
12202
+ return "http://" + (isIP(listenHost) === 6 ? "[" + listenHost + "]" : listenHost) + ":" + String(listenPort);
11788
12203
  }
11789
12204
  function validateOriginListenPort(value) {
11790
12205
  if (!Number.isSafeInteger(value) || Number(value) < MIN_ORIGIN_LISTEN_PORT || Number(value) > 65535) throw new Error("origin_listen_port_invalid");
@@ -11793,14 +12208,15 @@ function validateOriginListenPort(value) {
11793
12208
  }
11794
12209
  /** Authorize direct proxy socket peers, not untrusted forwarded client headers. */
11795
12210
  function validateOriginAllowedCidrs(value, listenHost) {
11796
- const input = value === void 0 && isLoopbackAddress(listenHost) ? ["127.0.0.0/8"] : value;
12211
+ const addressFamily = isIP(listenHost);
12212
+ const input = value === void 0 && isLoopbackAddress(listenHost) ? [addressFamily === 6 ? "::1/128" : "127.0.0.0/8"] : value;
11797
12213
  if (!Array.isArray(input) || input.length === 0 || input.length > 16) throw new Error("origin_allowed_cidrs_invalid");
11798
12214
  const cidrs = [];
11799
12215
  for (const entry of input) {
11800
- if (typeof entry !== "string" || entry.length > 64 || isIP(entry.split("/")[0] ?? "") !== 4) throw new Error("origin_allowed_cidrs_invalid");
12216
+ if (typeof entry !== "string" || entry.length > 64 || entry.includes("%") || isIP(entry.split("/")[0] ?? "") !== addressFamily) throw new Error("origin_allowed_cidrs_invalid");
11801
12217
  try {
11802
12218
  const cidr = parseCidr(entry);
11803
- if (cidr.bits !== 32 || !TRUSTED_NETWORKS.some((range) => cidr.prefix >= range.prefix && addressAllowed(entry.split("/")[0], [range]))) throw new Error("untrusted CIDR");
12219
+ if (cidr.bits !== (addressFamily === 6 ? 128 : 32) || !TRUSTED_NETWORKS.some((range) => cidr.bits === range.bits && cidr.prefix >= range.prefix && addressAllowed(entry.split("/")[0], [range]))) throw new Error("untrusted CIDR");
11804
12220
  cidrs.push(cidr.source);
11805
12221
  } catch {
11806
12222
  throw new Error("origin_allowed_cidrs_invalid");
@@ -11878,7 +12294,7 @@ var OriginConfigStore = class {
11878
12294
  listenHost: settings.listenHost,
11879
12295
  listenPort: settings.listenPort,
11880
12296
  allowedCidrs: settings.allowedCidrs,
11881
- backendOrigin: "http://" + settings.listenHost + ":" + String(settings.listenPort)
12297
+ backendOrigin: formatOriginBackendOrigin(settings.listenHost, settings.listenPort)
11882
12298
  },
11883
12299
  storagePath: this.stateRoot,
11884
12300
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
@@ -12038,7 +12454,7 @@ var OriginController = class {
12038
12454
  enabled: true,
12039
12455
  state: "ready",
12040
12456
  origin: settings.publicOrigin,
12041
- backendOrigin: "http://" + settings.listenHost + ":" + String(gateway.address().port)
12457
+ backendOrigin: formatOriginBackendOrigin(settings.listenHost, gateway.address().port)
12042
12458
  });
12043
12459
  }
12044
12460
  async stop() {
@@ -17737,6 +18153,8 @@ async function apply(ctx, config) {
17737
18153
  logger.warn("auth failure %s", JSON.stringify(entry));
17738
18154
  }, (entry) => {
17739
18155
  logger.warn("http failure %s", JSON.stringify(entry));
18156
+ }, (entry) => {
18157
+ logger.info("upstream auth %s", JSON.stringify(entry));
17740
18158
  });
17741
18159
  await candidate.start();
17742
18160
  lanGateway = candidate;
@@ -17797,6 +18215,8 @@ async function apply(ctx, config) {
17797
18215
  logger.warn("auth failure %s", JSON.stringify(entry));
17798
18216
  }, (entry) => {
17799
18217
  logger.warn("http failure %s", JSON.stringify(entry));
18218
+ }, (entry) => {
18219
+ logger.info("upstream auth %s", JSON.stringify(entry));
17800
18220
  });
17801
18221
  await candidate.start();
17802
18222
  return candidate;
@@ -17817,6 +18237,8 @@ async function apply(ctx, config) {
17817
18237
  logger.warn("auth failure %s", JSON.stringify(entry));
17818
18238
  }, (entry) => {
17819
18239
  logger.warn("http failure %s", JSON.stringify(entry));
18240
+ }, (entry) => {
18241
+ logger.info("upstream auth %s", JSON.stringify(entry));
17820
18242
  });
17821
18243
  try {
17822
18244
  await candidate.start();
@@ -17832,6 +18254,8 @@ async function apply(ctx, config) {
17832
18254
  logger.warn("auth failure %s", JSON.stringify(entry));
17833
18255
  }, (entry) => {
17834
18256
  logger.warn("http failure %s", JSON.stringify(entry));
18257
+ }, (entry) => {
18258
+ logger.info("upstream auth %s", JSON.stringify(entry));
17835
18259
  });
17836
18260
  try {
17837
18261
  await candidate.start();