@dsh-remote/relay 3.1.8 → 3.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/README.md CHANGED
@@ -18,19 +18,22 @@ curl -s http://127.0.0.1:8787/healthz
18
18
 
19
19
  ### 常用配置
20
20
 
21
- | 变量 | 必填 | 默认 | 说明 |
22
- | ----------------------------- | ---- | ----------- | ------------------------------------------------------------------------------------------------ |
23
- | `DRC_HOST_TOKEN` | ✅ | — | 主机出站认证凭据。`openssl rand -hex 32` 生成。**与主机插件那份必须逐字一致** |
24
- | `DRC_PORT` | | `8787` | 监听端口。`0` = 让系统分配(测试与容器用) |
25
- | `DRC_BIND` | | `127.0.0.1` | 绑定地址。默认只绑回环:TLS 由反代终止。**只有自己就是边缘(容器直接发布端口)时才设 `0.0.0.0`** |
26
- | `DRC_PUBLIC_URL` | | 空 | `/api/info` 返回的对外地址。公网填 `wss://你的域名` |
27
- | `DRC_LOG_LEVEL` | | `info` | `debug` / `info` / `warn` / `error` / `silent`。**区分大小写** |
28
- | `DRC_PAIR_TTL_MS` | | `120000` | 配对码的**服务端权威**寿命(毫秒) |
29
- | `DRC_STATE_FILE` | | 空(关闭) | 会话表落盘路径。默认关闭 = 纯内存;配上后启动先读它、会话表有增删时写回 |
30
- | `DRC_STATE_SAVE_MS` | | `60000` | 周期补写状态文件的间隔(只为刷新 `lastActivityAt`) |
31
- | `DRC_MAX_BUFFERED_BYTES` | | `1048576` | 慢消费者阈值:发送缓冲区**连续**超限达**本角色窗口**才 1008 断开 |
32
- | `DRC_SLOW_CONSUMER_HOST_MS` | | `10000` | **主机**侧慢消费者窗口(毫秒)。主机卡住就是所有人卡住,所以窗口短 |
33
- | `DRC_SLOW_CONSUMER_CLIENT_MS` | | `45000` | **客户端**侧慢消费者窗口(毫秒)。**必须大于手机自己的重连周期**(≈42.5 秒) |
21
+ | 变量 | 必填 | 默认 | 说明 |
22
+ | ----------------------------- | ---- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `DRC_HOST_TOKEN` | ✅ | — | 主机出站认证凭据。`openssl rand -hex 32` 生成。**与主机插件那份必须逐字一致** |
24
+ | `DRC_PORT` | | `8787` | 监听端口。`0` = 让系统分配(测试与容器用) |
25
+ | `DRC_BIND` | | `127.0.0.1` | 绑定地址。默认只绑回环:TLS 由反代终止。**只有自己就是边缘(容器直接发布端口)时才设 `0.0.0.0`** |
26
+ | `DRC_PUBLIC_URL` | | 空 | `/api/info` 返回的对外地址。公网填 `wss://你的域名` |
27
+ | `DRC_LOG_LEVEL` | | `info` | `debug` / `info` / `warn` / `error` / `silent`。**区分大小写** |
28
+ | `DRC_PAIR_TTL_MS` | | `120000` | 配对码的**服务端权威**寿命(毫秒) |
29
+ | `DRC_MP_APPID` | | — | 小程序 appid。与 `DRC_MP_SECRET` 同时配置才启用 `GET /code`(生成小程序码);不配则插件回退旧 `dshr:` 码 |
30
+ | `DRC_MP_SECRET` | | — | 小程序 secret。**只放在中继**,永不进插件包;换了 secret 记得同步这里 |
31
+ | `DRC_MP_ENV` | | `release` | 小程序码打开哪一版:`release` / `trial` / `develop`。**新版只上了体验版时必须设 `trial`**,否则码把用户带进老版本(能扫进去、配对必失败) |
32
+ | `DRC_STATE_FILE` | | 空(关闭) | 会话表落盘路径。默认关闭 = 纯内存;配上后启动先读它、会话表有增删时写回 |
33
+ | `DRC_STATE_SAVE_MS` | | `60000` | 周期补写状态文件的间隔(只为刷新 `lastActivityAt`) |
34
+ | `DRC_MAX_BUFFERED_BYTES` | | `1048576` | 慢消费者阈值:发送缓冲区**连续**超限达**本角色窗口**才 1008 断开 |
35
+ | `DRC_SLOW_CONSUMER_HOST_MS` | | `10000` | **主机**侧慢消费者窗口(毫秒)。主机卡住就是所有人卡住,所以窗口短 |
36
+ | `DRC_SLOW_CONSUMER_CLIENT_MS` | | `45000` | **客户端**侧慢消费者窗口(毫秒)。**必须大于手机自己的重连周期**(≈42.5 秒) |
34
37
 
35
38
  ### 端点
36
39
 
@@ -66,6 +66,18 @@ services:
66
66
  DRC_MAX_CONNS: ${DRC_MAX_CONNS:-200}
67
67
  DRC_CONV_IDLE_TTL_MS: ${DRC_CONV_IDLE_TTL_MS:-604800000}
68
68
  DRC_HOST_GRACE_MS: ${DRC_HOST_GRACE_MS:-120000}
69
+ # 小程序码签发(2026-10-21「双码合一」):**只有中继持有小程序的 appSecret**,
70
+ # 主机插件那份永远拿不到它。两个都配了才启用 `GET /code?token=`;
71
+ # 不配(自部署常态)那条路由回 501,主机自动回落到旧 `dshr:` 码。
72
+ #
73
+ # ⚠️ **必须显式转发**:compose 只把这里列出的变量送进容器,
74
+ # 写在 `deploy/docker/.env` 里而这一行没有 ⇒ 进程读到的是 undefined ⇒ 501,
75
+ # 而日志一字不提(2026-10-21 上线当场踩到:healthz 已经 3.1.9、/code 仍 501)。
76
+ DRC_MP_APPID: ${DRC_MP_APPID:-}
77
+ DRC_MP_SECRET: ${DRC_MP_SECRET:-}
78
+ # 码打开哪一版:release / trial / develop(缺省 release)。
79
+ # 新版只上了体验版时必须设 trial,否则码把用户带进老版本:能扫进去、配对必失败。
80
+ DRC_MP_ENV: ${DRC_MP_ENV:-}
69
81
 
70
82
  volumes:
71
83
  - ${DRC_DATA_DIR:-relay-data}:/data
@@ -23443,7 +23443,7 @@ function date4(params) {
23443
23443
  return _coercedDate(ZodDate, params);
23444
23444
  }
23445
23445
 
23446
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23446
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23447
23447
  var MAX_RELAY_MESSAGE_BYTES = 1024 * 1024;
23448
23448
  var MAX_CIPHERTEXT_BYTES = MAX_RELAY_MESSAGE_BYTES - 8 * 1024;
23449
23449
  var MAX_IMAGE_ATTACHMENTS = 4;
@@ -23452,7 +23452,7 @@ var MAX_ATTACHMENT_BYTES = 512 * 1024;
23452
23452
  var MAX_ATTACHMENT_TOTAL_BYTES = 512 * 1024;
23453
23453
  var IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
23454
23454
 
23455
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23455
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23456
23456
  var PROTOCOL_VERSION = 1;
23457
23457
  var PAIRING_TOKEN_RE = /^\d{6}$/;
23458
23458
  var nonEmpty = external_exports.string().min(1);
@@ -23481,13 +23481,40 @@ var helloFrame = external_exports.object({
23481
23481
  clientId: external_exports.string().min(1).max(128).optional(),
23482
23482
  clientMeta: clientMeta.optional()
23483
23483
  });
23484
+ var publicKeyB64 = external_exports.string().regex(/^[A-Za-z0-9+/]{43}=$/, "\u5FC5\u987B\u662F 32 \u5B57\u8282 base64 \u516C\u94A5");
23484
23485
  var pairBeginFrame = external_exports.object({
23485
23486
  t: external_exports.literal("pair-begin"),
23486
23487
  pairingToken
23487
23488
  });
23488
23489
  var pairClaimFrame = external_exports.object({
23489
23490
  t: external_exports.literal("pair-begin-client"),
23490
- pairingToken
23491
+ pairingToken,
23492
+ clientPub: publicKeyB64.optional()
23493
+ });
23494
+ var pairRequestFrame = external_exports.object({
23495
+ t: external_exports.literal("pair-request"),
23496
+ pairingToken,
23497
+ clientPub: publicKeyB64,
23498
+ /**
23499
+ * 认领这条码时**新开的会话号**。中继在 claim 的同一时刻就知道它,一并带来:
23500
+ * 主机回 `pair-grant` 必须带会话号(见 pairGrantFrame),否则中继无处可送。
23501
+ */
23502
+ sessionId: nonEmpty,
23503
+ hostId: nonEmpty.optional()
23504
+ });
23505
+ var pairGrantFrame = external_exports.object({
23506
+ t: external_exports.literal("pair-grant"),
23507
+ /**
23508
+ * **带 sessionId 而不是 pairingToken**(2026-10-21 拍板)。
23509
+ *
23510
+ * 中继在 `claim()` 之后就没有 token→会话 的映射了(pendingPairs 当场消费),
23511
+ * 若按 token 转发就得再养一张 60s 的短命表。而主机在 `peer-joined` 里**已经拿到
23512
+ * sessionId**,让它把会话号原样带回来,中继按会话成员转发即可,零新状态。
23513
+ */
23514
+ sessionId: nonEmpty,
23515
+ hostPub: publicKeyB64,
23516
+ nonce: nonEmpty,
23517
+ wrappedPsk: nonEmpty
23491
23518
  });
23492
23519
  var encFrame = external_exports.object({
23493
23520
  t: external_exports.literal("enc"),
@@ -23525,6 +23552,8 @@ var endpointFrame = external_exports.discriminatedUnion("t", [
23525
23552
  helloFrame,
23526
23553
  pairBeginFrame,
23527
23554
  pairClaimFrame,
23555
+ // 主机发出去的:psk 密文(中继原样转交给手机)
23556
+ pairGrantFrame,
23528
23557
  resyncFrame,
23529
23558
  encFrame,
23530
23559
  encBatchFrame,
@@ -23678,6 +23707,9 @@ var pongFrame = external_exports.object({ t: external_exports.literal("pong"), t
23678
23707
  var relayFrame = external_exports.discriminatedUnion("t", [
23679
23708
  helloOkFrame,
23680
23709
  pairReadyFrame,
23710
+ // 中继发出的两条:认领请求带给主机、psk 密文带给手机
23711
+ pairRequestFrame,
23712
+ pairGrantFrame,
23681
23713
  pairedFrame,
23682
23714
  pairFailFrame,
23683
23715
  peerJoinedFrame,
@@ -23844,6 +23876,9 @@ function loadConfig(env, version2) {
23844
23876
  }
23845
23877
  const config2 = {
23846
23878
  hostToken,
23879
+ mpAppId: (env.DRC_MP_APPID ?? "").trim(),
23880
+ mpSecret: (env.DRC_MP_SECRET ?? "").trim(),
23881
+ mpEnv: (env.DRC_MP_ENV ?? "").trim(),
23847
23882
  port: portNumber(env.DRC_PORT, 8787, "DRC_PORT", problems),
23848
23883
  // 默认只绑回环:TLS 由反代终止,中继没理由出现在局域网里。
23849
23884
  // 只有自己就是边缘(容器直接发布端口)时才设 DRC_BIND=0.0.0.0。
@@ -23930,7 +23965,7 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
23930
23965
  var import_websocket = __toESM(require_websocket(), 1);
23931
23966
  var import_websocket_server = __toESM(require_websocket_server(), 1);
23932
23967
 
23933
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
23968
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
23934
23969
  import { randomBytes, randomUUID } from "node:crypto";
23935
23970
  var CONVERSATION_ID_PREFIX = "c_";
23936
23971
  function newConversationId() {
@@ -23940,7 +23975,7 @@ function newHostId() {
23940
23975
  return randomBytes(4).toString("hex");
23941
23976
  }
23942
23977
 
23943
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
23978
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
23944
23979
  function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION) {
23945
23980
  if (peerProtocol === void 0 || peerProtocol === null) {
23946
23981
  return {
@@ -23976,16 +24011,100 @@ function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION) {
23976
24011
  return { ok: true, version: peerProtocol };
23977
24012
  }
23978
24013
 
23979
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
24014
+ // src/mpcode.ts
24015
+ var TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
24016
+ var CODE_URL = "https://api.weixin.qq.com/wxa/getwxacodeunlimit";
24017
+ var LANDING_PAGE = "pages/sessions/sessions";
24018
+ var TOKEN_SKEW_MS = 6e4;
24019
+ var DEFAULT_TOKEN_TTL_MS = 7e6;
24020
+ var ENV_VERSIONS = ["release", "trial", "develop"];
24021
+ function normalizeEnvVersion(value) {
24022
+ const normalized = (value ?? "").trim().toLowerCase();
24023
+ return ENV_VERSIONS.includes(normalized) ? normalized : "release";
24024
+ }
24025
+ function createMpCodeIssuer(options) {
24026
+ const appId = options.appId.trim();
24027
+ const secret = options.secret.trim();
24028
+ const envVersion = normalizeEnvVersion(options.envVersion);
24029
+ const doFetch = options.fetch ?? ((...args) => fetch(...args));
24030
+ const now = options.now ?? (() => Date.now());
24031
+ let cachedToken;
24032
+ const images = /* @__PURE__ */ new Map();
24033
+ const IMAGE_TTL_MS = 36e5;
24034
+ async function fetchToken() {
24035
+ const url2 = `${TOKEN_URL}?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(secret)}`;
24036
+ const response = await doFetch(url2);
24037
+ const body = await response.json();
24038
+ if (typeof body.access_token !== "string" || body.access_token === "") {
24039
+ throw new Error(`\u5FAE\u4FE1 access_token \u83B7\u53D6\u5931\u8D25\uFF1A${body.errcode ?? "?"} ${body.errmsg ?? ""}`.trim());
24040
+ }
24041
+ const ttl = typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in * 1e3 : DEFAULT_TOKEN_TTL_MS;
24042
+ cachedToken = { value: body.access_token, expiresAt: now() + Math.max(0, ttl - TOKEN_SKEW_MS) };
24043
+ return body.access_token;
24044
+ }
24045
+ async function token() {
24046
+ if (cachedToken !== void 0 && cachedToken.expiresAt > now()) return cachedToken.value;
24047
+ return fetchToken();
24048
+ }
24049
+ async function requestCode(accessToken, pairingToken2) {
24050
+ const response = await doFetch(`${CODE_URL}?access_token=${encodeURIComponent(accessToken)}`, {
24051
+ method: "POST",
24052
+ headers: { "content-type": "application/json" },
24053
+ body: JSON.stringify({
24054
+ scene: pairingToken2,
24055
+ page: LANDING_PAGE,
24056
+ /**
24057
+ * `check_path: false`(默认 true):true 时 `page` 必须是**已发布版本里存在**的页面。
24058
+ * 3.2 的首页路径没变,但排错期(`env_version=trial/develop`)里那份页面可能还没发布,
24059
+ * 开着它就会得到 41030 invalid page —— 而症状只是"码生成失败",看不出是版本的事。
24060
+ */
24061
+ check_path: false,
24062
+ env_version: envVersion
24063
+ })
24064
+ });
24065
+ const bytes = new Uint8Array(await response.arrayBuffer());
24066
+ if (bytes.length > 2 && bytes[0] === 255 && bytes[1] === 216) return bytes;
24067
+ try {
24068
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
24069
+ return { errcode: typeof parsed.errcode === "number" ? parsed.errcode : -1 };
24070
+ } catch {
24071
+ return { errcode: -1 };
24072
+ }
24073
+ }
24074
+ return {
24075
+ enabled: appId !== "" && secret !== "",
24076
+ async codeFor(pairingToken2) {
24077
+ if (appId === "" || secret === "") throw new Error("\u672A\u914D\u7F6E DRC_MP_APPID / DRC_MP_SECRET");
24078
+ const hit = images.get(pairingToken2);
24079
+ if (hit !== void 0 && hit.expiresAt > now()) return hit.bytes;
24080
+ let accessToken = await token();
24081
+ let result = await requestCode(accessToken, pairingToken2);
24082
+ if (!(result instanceof Uint8Array) && (result.errcode === 40001 || result.errcode === 42001)) {
24083
+ accessToken = await fetchToken();
24084
+ result = await requestCode(accessToken, pairingToken2);
24085
+ }
24086
+ if (!(result instanceof Uint8Array)) {
24087
+ throw new Error(`\u5C0F\u7A0B\u5E8F\u7801\u751F\u6210\u5931\u8D25\uFF1Aerrcode=${result.errcode}`);
24088
+ }
24089
+ images.set(pairingToken2, { bytes: result, expiresAt: now() + IMAGE_TTL_MS });
24090
+ if (images.size > 512) {
24091
+ for (const [key, value] of images) if (value.expiresAt <= now()) images.delete(key);
24092
+ }
24093
+ return result;
24094
+ }
24095
+ };
24096
+ }
24097
+
24098
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
23980
24099
  var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
23981
24100
  function wantsRetryAfter(code) {
23982
24101
  return typeof code === "string" && WAIT_HINTABLE.has(code);
23983
24102
  }
23984
24103
 
23985
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
24104
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
23986
24105
  var SLEEP_BACKENDS = ["caffeinate", "systemd-inhibit", "unsupported"];
23987
24106
 
23988
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
24107
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
23989
24108
  var SESSION_STATES = [
23990
24109
  "idle",
23991
24110
  "running",
@@ -24411,7 +24530,7 @@ var PAYLOAD_TYPES = {
24411
24530
  evSessionHistory: "ev.session_history"
24412
24531
  };
24413
24532
 
24414
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24533
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24415
24534
  function helloOkForClient(clientId, protocol) {
24416
24535
  return { t: "hello-ok", role: "client", clientId, ...protocol === void 0 ? {} : { protocol } };
24417
24536
  }
@@ -24427,6 +24546,15 @@ function pairReady(pairingToken2, ttlMs) {
24427
24546
  function paired(sessionId, hostId) {
24428
24547
  return { t: "paired", sessionId, hostId };
24429
24548
  }
24549
+ function pairRequest(pairingToken2, clientPub, sessionId, hostId) {
24550
+ return {
24551
+ t: "pair-request",
24552
+ pairingToken: pairingToken2,
24553
+ clientPub,
24554
+ sessionId,
24555
+ ...hostId === void 0 ? {} : { hostId }
24556
+ };
24557
+ }
24430
24558
  function pairFail(reason, pairingToken2) {
24431
24559
  return { t: "pair-fail", reason, ...pairingToken2 === void 0 ? {} : { pairingToken: pairingToken2 } };
24432
24560
  }
@@ -24489,11 +24617,12 @@ function encBatchToRelay(sessionId, items) {
24489
24617
  return { t: "enc-batch", sessionId, items: items.map((item) => ({ ...item })) };
24490
24618
  }
24491
24619
 
24492
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24620
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24493
24621
  var ENDPOINT_FRAME_NAMES = [
24494
24622
  "hello",
24495
24623
  "pair-begin",
24496
24624
  "pair-begin-client",
24625
+ "pair-grant",
24497
24626
  "resync",
24498
24627
  "enc",
24499
24628
  "enc-batch",
@@ -24503,6 +24632,8 @@ var ENDPOINT_FRAME_NAMES = [
24503
24632
  var RELAY_FRAME_NAMES = [
24504
24633
  "hello-ok",
24505
24634
  "pair-ready",
24635
+ "pair-request",
24636
+ "pair-grant",
24506
24637
  "paired",
24507
24638
  "pair-fail",
24508
24639
  "peer-joined",
@@ -24551,7 +24682,7 @@ var EV_TYPES = [
24551
24682
  ];
24552
24683
  var PAYLOAD_TYPE_SET = /* @__PURE__ */ new Set([...CMD_TYPES, ...EV_TYPES]);
24553
24684
 
24554
- // node_modules/.pnpm/@dsh-remote+protocol@3.1.5/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24685
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.12/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24555
24686
  function frameTypeOf(value) {
24556
24687
  if (typeof value !== "object" || value === null || Array.isArray(value))
24557
24688
  return void 0;
@@ -25616,6 +25747,12 @@ function createRelay(config2) {
25616
25747
  }
25617
25748
  sendToPeer(peer, paired(claimed.conversationId, claimed.hostId));
25618
25749
  sendToPeer(peerOfHost(claimed.hostId), peerJoinedForHost(claimed.conversationId, peer.clientId, frame.pairingToken));
25750
+ if (frame.clientPub !== void 0) {
25751
+ const hostPeer = peerOfHost(claimed.hostId);
25752
+ if (hostPeer !== void 0) {
25753
+ sendToPeer(hostPeer, pairRequest(frame.pairingToken, frame.clientPub, claimed.conversationId, claimed.hostId));
25754
+ }
25755
+ }
25619
25756
  markStateChanged();
25620
25757
  log.info("paired", {
25621
25758
  sessionId: claimed.conversationId,
@@ -25701,6 +25838,29 @@ function createRelay(config2) {
25701
25838
  return handlePairBegin(peer, frame);
25702
25839
  case "pair-begin-client":
25703
25840
  return handlePairClaim(peer, frame);
25841
+ /**
25842
+ * 主机 → 中继 → 手机:psk 的密文(2026-10-21)。
25843
+ *
25844
+ * **只有主机能发**;中继按 `sessionId` 找会话成员,把帧原样转过去。
25845
+ * 它不解析 `wrappedPsk`,也没有能解开的密钥 —— 零知识的前提不变。
25846
+ */
25847
+ case "pair-grant": {
25848
+ if (peer.role !== "host" || !peer.hostId) {
25849
+ sendError(peer, "need_host");
25850
+ return;
25851
+ }
25852
+ const conv = state.conversations.get(frame.sessionId);
25853
+ if (conv === void 0) {
25854
+ log.info("pair-grant dropped, unknown session", { sessionId: frame.sessionId });
25855
+ return;
25856
+ }
25857
+ for (const clientId of conv.clients) {
25858
+ const clientPeer = state.clients.get(clientId);
25859
+ const socket = clientPeer?.ws;
25860
+ if (socket !== void 0) sendTo(socket, frame);
25861
+ }
25862
+ return;
25863
+ }
25704
25864
  case "resync": {
25705
25865
  if (peer.role !== "host" || !peer.hostId) {
25706
25866
  sendError(peer, "need_host");
@@ -26059,7 +26219,7 @@ function createRelay(config2) {
26059
26219
  log.info("host token claimed", { ip, hostId: rawHostId, hostTokens: state.hostTokens.size });
26060
26220
  res.end(JSON.stringify({ token, hostId: rawHostId, rotatedAt: new Date(now).toISOString() }));
26061
26221
  }
26062
- function httpHandler(req, res) {
26222
+ async function httpHandler(req, res) {
26063
26223
  try {
26064
26224
  const url2 = new URL(req.url ?? "/", "http://relay.invalid");
26065
26225
  res.setHeader("content-type", "application/json; charset=utf-8");
@@ -26078,6 +26238,31 @@ function createRelay(config2) {
26078
26238
  });
26079
26239
  return;
26080
26240
  }
26241
+ if (url2.pathname === "/code") {
26242
+ if (!mpCodes.enabled) {
26243
+ res.statusCode = 501;
26244
+ res.end(JSON.stringify({ error: "mp_not_configured" }));
26245
+ return;
26246
+ }
26247
+ const token = url2.searchParams.get("token") ?? "";
26248
+ if (!/^\d{6}$/.test(token)) {
26249
+ res.statusCode = 400;
26250
+ res.end(JSON.stringify({ error: "bad_token" }));
26251
+ return;
26252
+ }
26253
+ try {
26254
+ const bytes = await mpCodes.codeFor(token);
26255
+ res.statusCode = 200;
26256
+ res.setHeader("content-type", "image/jpeg");
26257
+ res.setHeader("cache-control", "no-store");
26258
+ res.end(Buffer.from(bytes));
26259
+ } catch (error62) {
26260
+ log.warn("mp code generation failed", { error: String(error62?.message ?? error62).slice(0, 160) });
26261
+ res.statusCode = 502;
26262
+ res.end(JSON.stringify({ error: "mp_code_failed" }));
26263
+ }
26264
+ return;
26265
+ }
26081
26266
  if (url2.pathname === "/api/info") {
26082
26267
  res.end(JSON.stringify({ publicUrl: config2.publicUrl, protocol: PROTOCOL_VERSION }));
26083
26268
  return;
@@ -26113,6 +26298,7 @@ function createRelay(config2) {
26113
26298
  }
26114
26299
  }
26115
26300
  }
26301
+ const mpCodes = createMpCodeIssuer({ appId: config2.mpAppId, secret: config2.mpSecret, envVersion: config2.mpEnv });
26116
26302
  const http = createServer(httpHandler);
26117
26303
  http.on("error", (error62) => {
26118
26304
  log.warn("http server error", { message: String(error62?.message ?? error62) });
@@ -26264,7 +26450,7 @@ function registerSignalHandlers(shutdown) {
26264
26450
 
26265
26451
  // src/main.ts
26266
26452
  function readVersion() {
26267
- if ("3.1.8") return "3.1.8";
26453
+ if ("3.2.0") return "3.2.0";
26268
26454
  try {
26269
26455
  const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
26270
26456
  if (typeof pkg.version === "string") return pkg.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-remote/relay",
3
- "version": "3.1.8",
3
+ "version": "3.2.0",
4
4
  "description": "DSH Remote Control 的零知识 WebSocket 中继:只做路由,不碰载荷明文。可自托管的单文件产物。",
5
5
  "type": "module",
6
6
  "license": "MIT",