@h-ai/reach 0.1.0-alpha.72 → 0.1.0-alpha.74

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.en.md CHANGED
@@ -217,6 +217,12 @@ Use `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` for passwo
217
217
 
218
218
  `migrateReach({ dryRun: true })` previews supported migrations, applied during repository initialization. Statuses/identifiers remain; legacy in-flight work becomes unknown. Legacy payloads are retired once and pending old payloads discarded. Back up and stop old writers before upgrading; never replay old credentials.
219
219
 
220
+ ## API acknowledgements and destination boundaries
221
+
222
+ An empty successful 2xx response, including 204/205, means receiver acceptance; messageId is optional and acceptance does not prove final delivery. Nonempty responses must be JSON objects with an optional messageId string of at most 255 characters. Responses are bounded to 64 KiB. Invalid responses, timeouts and network failures never become false success or expose receiver content.
223
+
224
+ API `redirect` defaults to `error`; ordinary messages may explicitly use `follow`. Sensitive messages always reject redirects without forwarding payloads or private headers. `requireHttps:true` enforces initial HTTPS and cannot be combined with follow. When omitted, production sensitive messages require HTTPS; explicit false is only for an operator-approved trusted internal connection. Unknown source-side effects still require reconciliation and sensitive messages are never retried automatically. Application allowlists and network egress controls remain necessary.
225
+
220
226
  ## Development and testing
221
227
 
222
228
  ```bash
package/README.ja.md CHANGED
@@ -217,6 +217,12 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
217
217
 
218
218
  `migrateReach({ dryRun: true })` で事前確認し、リポジトリー初期化で対応する移行を実行します。状態と識別子を保持し、旧処理中は unknown、旧ペイロードは一度廃棄し、旧待機ペイロードは discarded にします。更新前は旧書込を停止しバックアップし、古い認証情報を再送しないでください。
219
219
 
220
+ ## API 応答と送信先の境界
221
+
222
+ 204/205 を含む空の成功 2xx は受付成功を表し、messageId は任意で、最終配信の証明ではありません。本文がある場合は JSON オブジェクトとし、messageId は255文字以下の任意文字列です。応答上限は64 KiBです。不正応答・タイムアウト・通信失敗を成功扱いにせず、応答本文をログへ出しません。
223
+
224
+ API の `redirect` は既定で `error`、通常通知のみ明示的に `follow` を選択できます。sensitive は常に転送を拒否し、本文や独自ヘッダーを転送しません。`requireHttps:true` は開始URLの HTTPS を必須とし follow と併用できません。省略時は本番の機密通知に HTTPS を要求し、明示 false は運用者が確認した信頼できる内部接続専用です。元の送信先での副作用が不明なら確認が必要で、機密通知は自動再送しません。アプリの許可リストとネットワーク出口制御は引き続き必要です。
225
+
220
226
  ## 開発とテスト
221
227
 
222
228
  ```bash
package/README.md CHANGED
@@ -217,6 +217,12 @@ if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
217
217
 
218
218
  `migrateReach({ dryRun: true })` 预检;仓库初始化自动执行受支持步骤。升级保留发送状态与标识,旧在途转 unknown;旧正文/主题/模板变量/extra 一次性退役,待发旧载荷 discarded。生产需先备份停旧写入,不能恢复旧凭据重新投递。
219
219
 
220
+ ## API 回执与目标边界
221
+
222
+ 空正文的成功 2xx(含 204/205)表示接收端受理,messageId 可缺省,不代表最终送达。非空正文须为 JSON 对象,messageId 若存在须为不超过 255 字符的字符串;响应最多 64 KiB,非法响应、超时和网络失败不会假装成功或输出第三方正文。
223
+
224
+ API Provider 的 `redirect` 默认为 `error`;只有普通消息可显式设为 `follow`。`sensitive:true` 始终拒绝跳转,不转发正文和私有头。`requireHttps:true` 强制初始 HTTPS 且与 follow 互斥;省略时生产敏感消息要求 HTTPS,显式 false 仅适用于操作者确认的受信内网。超时或源端已产生的未知副作用仍须核对,敏感消息不自动重投。域名授权、内网地址限制和出口策略由应用及部署环境负责。
225
+
220
226
  ## 开发测试
221
227
 
222
228
  ```bash
package/dist/index.d.ts CHANGED
@@ -80,6 +80,11 @@ declare const ApiProviderConfigSchema: z.ZodObject<{
80
80
  }>>;
81
81
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
82
82
  timeout: z.ZodDefault<z.ZodNumber>;
83
+ redirect: z.ZodDefault<z.ZodEnum<{
84
+ error: "error";
85
+ follow: "follow";
86
+ }>>;
87
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
83
88
  idempotent: z.ZodDefault<z.ZodBoolean>;
84
89
  }, z.core.$strip>;
85
90
  /**
@@ -120,6 +125,11 @@ declare const ProviderConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
120
125
  }>>;
121
126
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
122
127
  timeout: z.ZodDefault<z.ZodNumber>;
128
+ redirect: z.ZodDefault<z.ZodEnum<{
129
+ error: "error";
130
+ follow: "follow";
131
+ }>>;
132
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
123
133
  idempotent: z.ZodDefault<z.ZodBoolean>;
124
134
  }, z.core.$strip>], "type">;
125
135
  /**
@@ -344,6 +354,11 @@ declare const ReachConfigSchema: z.ZodObject<{
344
354
  }>>;
345
355
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
346
356
  timeout: z.ZodDefault<z.ZodNumber>;
357
+ redirect: z.ZodDefault<z.ZodEnum<{
358
+ error: "error";
359
+ follow: "follow";
360
+ }>>;
361
+ requireHttps: z.ZodOptional<z.ZodBoolean>;
347
362
  idempotent: z.ZodDefault<z.ZodBoolean>;
348
363
  }, z.core.$strip>], "type">>;
349
364
  templates: z.ZodOptional<z.ZodArray<z.ZodObject<{
package/dist/index.js CHANGED
@@ -53,7 +53,11 @@ var en_US_default = {
53
53
  reach_productionProviderRequired: "Production requires a delivery provider; console is available only in development or explicit test mode",
54
54
  reach_sensitivePolicyRequired: "Credential notifications require a database receipt, stable idempotency key and expiry",
55
55
  reach_sensitiveDeliveryUnconfirmed: "Credential delivery is unconfirmed and will not be retried automatically. Check the receipt or request new credentials.",
56
- reach_messageExpired: "The credentials expired before delivery"
56
+ reach_messageExpired: "The credentials expired before delivery",
57
+ reach_apiResponseTooLarge: "API acknowledgement exceeds the 64 KiB limit",
58
+ reach_apiResponseInvalid: "API acknowledgement must be empty or a JSON object with an optional messageId string",
59
+ reach_apiRequestFailed: "API request failed or timed out; delivery is unconfirmed",
60
+ reach_apiHttpsRequired: "This API policy requires HTTPS and disallows automatic redirects"
57
61
  };
58
62
 
59
63
  // messages/ja-JP.json
@@ -101,7 +105,11 @@ var ja_JP_default = {
101
105
  reach_productionProviderRequired: "\u672C\u756A\u74B0\u5883\u306B\u306F\u914D\u4FE1\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u5FC5\u8981\u3067\u3059\u3002console \u306F\u958B\u767A\u74B0\u5883\u307E\u305F\u306F\u660E\u793A\u7684\u306A\u30C6\u30B9\u30C8\u30E2\u30FC\u30C9\u3067\u306E\u307F\u4F7F\u7528\u3067\u304D\u307E\u3059",
102
106
  reach_sensitivePolicyRequired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u901A\u77E5\u306B\u306F\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u306E\u53D7\u9818\u8A18\u9332\u3001\u56FA\u5B9A\u306E\u51AA\u7B49\u30AD\u30FC\u3068\u6709\u52B9\u671F\u9650\u304C\u5FC5\u8981\u3067\u3059",
103
107
  reach_sensitiveDeliveryUnconfirmed: "\u8A8D\u8A3C\u60C5\u5831\u306E\u914D\u4FE1\u3092\u78BA\u8A8D\u3067\u304D\u307E\u305B\u3093\u3002\u81EA\u52D5\u518D\u9001\u305B\u305A\u3001\u53D7\u9818\u8A18\u9332\u306E\u78BA\u8A8D\u307E\u305F\u306F\u8A8D\u8A3C\u60C5\u5831\u306E\u518D\u767A\u884C\u304C\u5FC5\u8981\u3067\u3059\u3002",
104
- reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F"
108
+ reach_messageExpired: "\u8A8D\u8A3C\u60C5\u5831\u306E\u671F\u9650\u304C\u5207\u308C\u305F\u305F\u3081\u914D\u4FE1\u3057\u307E\u305B\u3093\u3067\u3057\u305F",
109
+ reach_apiResponseTooLarge: "API \u5FDC\u7B54\u304C 64 KiB \u306E\u4E0A\u9650\u3092\u8D85\u3048\u3066\u3044\u307E\u3059",
110
+ reach_apiResponseInvalid: "API \u5FDC\u7B54\u306F\u7A7A\u304B\u3001\u7701\u7565\u53EF\u80FD\u306A messageId \u6587\u5B57\u5217\u3092\u6301\u3064 JSON \u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059",
111
+ reach_apiRequestFailed: "API \u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u5931\u6557\u307E\u305F\u306F\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F\u3002\u914D\u4FE1\u7D50\u679C\u306F\u672A\u78BA\u8A8D\u3067\u3059",
112
+ reach_apiHttpsRequired: "\u3053\u306E API \u30DD\u30EA\u30B7\u30FC\u3067\u306F HTTPS \u304C\u5FC5\u8981\u3067\u3001\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u306F\u7981\u6B62\u3055\u308C\u3066\u3044\u307E\u3059"
105
113
  };
106
114
 
107
115
  // messages/zh-CN.json
@@ -149,7 +157,11 @@ var zh_CN_default = {
149
157
  reach_productionProviderRequired: "\u751F\u4EA7\u73AF\u5883\u5FC5\u987B\u914D\u7F6E\u771F\u5B9E\u89E6\u8FBE\u670D\u52A1\uFF1Bconsole \u4EC5\u53EF\u7528\u4E8E\u5F00\u53D1\u6216\u663E\u5F0F\u6D4B\u8BD5\u6A21\u5F0F",
150
158
  reach_sensitivePolicyRequired: "\u51ED\u636E\u901A\u77E5\u9700\u8981\u6570\u636E\u5E93\u56DE\u6267\u3001\u7A33\u5B9A\u5E42\u7B49\u952E\u548C\u6709\u6548\u671F",
151
159
  reach_sensitiveDeliveryUnconfirmed: "\u51ED\u636E\u6295\u9012\u5C1A\u672A\u786E\u8BA4\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u91CD\u590D\u53D1\u9001\uFF0C\u8BF7\u67E5\u8BE2\u56DE\u6267\u6216\u7533\u8BF7\u65B0\u51ED\u636E",
152
- reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012"
160
+ reach_messageExpired: "\u51ED\u636E\u5DF2\u8FC7\u671F\uFF0C\u672A\u6295\u9012",
161
+ reach_apiResponseTooLarge: "API \u56DE\u6267\u8D85\u8FC7 64 KiB \u4E0A\u9650",
162
+ reach_apiResponseInvalid: "API \u56DE\u6267\u5FC5\u987B\u4E3A\u7A7A\u6216\u542B\u53EF\u9009 messageId \u5B57\u7B26\u4E32\u7684 JSON \u5BF9\u8C61",
163
+ reach_apiRequestFailed: "API \u8BF7\u6C42\u5931\u8D25\u6216\u8D85\u65F6\uFF0C\u6295\u9012\u7ED3\u679C\u672A\u786E\u8BA4",
164
+ reach_apiHttpsRequired: "\u6B64 API \u7B56\u7565\u8981\u6C42 HTTPS \u4E14\u7981\u6B62\u81EA\u52A8\u91CD\u5B9A\u5411"
153
165
  };
154
166
 
155
167
  // src/reach-i18n.ts
@@ -209,6 +221,10 @@ var ApiProviderConfigSchema = z.object({
209
221
  headers: z.record(z.string(), z.string()).optional(),
210
222
  /** 请求超时毫秒数(默认 10000) */
211
223
  timeout: z.number().int().min(0).default(1e4),
224
+ /** HTTP 重定向策略;默认拒绝。敏感发送始终拒绝,即使普通发送显式允许。 */
225
+ redirect: z.enum(["error", "follow"]).default("error"),
226
+ /** 强制 HTTPS;省略时生产敏感发送要求 HTTPS,显式 false 仅用于受信内网传输。 */
227
+ requireHttps: z.boolean().optional(),
212
228
  /** 接收端按 Idempotency-Key 原子持久去重,且覆盖所有重试与旧节点迟到请求时才启用;默认 false。 */
213
229
  idempotent: z.boolean().default(false)
214
230
  });
@@ -387,15 +403,51 @@ function getSafeEndpoint(url) {
387
403
  return "[invalid-url]";
388
404
  }
389
405
  }
390
- function toReachError2(error) {
406
+ function toReachError2() {
391
407
  return {
392
408
  code: HaiReachError.SEND_FAILED.code,
393
409
  message: reachM("reach_apiSendFailed", {
394
- params: { error: error instanceof Error ? error.message : String(error) }
395
- }),
396
- cause: error
410
+ params: { error: reachM("reach_apiRequestFailed") }
411
+ })
397
412
  };
398
413
  }
414
+ async function readAcknowledgement(response) {
415
+ const reader = response.body?.getReader();
416
+ let complete = false;
417
+ try {
418
+ let text = "";
419
+ let bytes = 0;
420
+ const decoder = new TextDecoder("utf-8", { fatal: true });
421
+ if (reader) {
422
+ while (true) {
423
+ const chunk = await reader.read();
424
+ if (chunk.done)
425
+ break;
426
+ bytes += chunk.value.byteLength;
427
+ if (bytes > 65536)
428
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseTooLarge"));
429
+ text += decoder.decode(chunk.value, { stream: true });
430
+ }
431
+ text += decoder.decode();
432
+ }
433
+ complete = true;
434
+ if (text.length === 0)
435
+ return ok({ success: true });
436
+ const result = JSON.parse(text);
437
+ if (!result || typeof result !== "object" || Array.isArray(result))
438
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
439
+ const messageId = "messageId" in result ? result.messageId : void 0;
440
+ if (messageId !== void 0 && (typeof messageId !== "string" || messageId.length > 255))
441
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
442
+ return ok({ success: true, ...messageId === void 0 ? {} : { messageId } });
443
+ } catch {
444
+ return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
445
+ } finally {
446
+ if (!complete)
447
+ await reader?.cancel().catch(() => void 0);
448
+ reader?.releaseLock();
449
+ }
450
+ }
399
451
  function createApiProvider() {
400
452
  let apiConfig = null;
401
453
  return {
@@ -411,6 +463,10 @@ function createApiProvider() {
411
463
  );
412
464
  }
413
465
  apiConfig = config;
466
+ if (config.requireHttps && new URL(config.url).protocol !== "https:" || config.requireHttps && config.redirect === "follow") {
467
+ apiConfig = null;
468
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
469
+ }
414
470
  logger2.info("API provider connected", { endpoint: getSafeEndpoint(config.url), method: config.method });
415
471
  return ok(void 0);
416
472
  },
@@ -429,6 +485,9 @@ function createApiProvider() {
429
485
  );
430
486
  }
431
487
  logger2.debug("Sending via API callback", { endpoint: getSafeEndpoint(apiConfig.url) });
488
+ const requireHttps = apiConfig.requireHttps ?? (message.sensitive === true && core.module.requiresSharedState(process.env));
489
+ if (requireHttps && new URL(apiConfig.url).protocol !== "https:")
490
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
432
491
  try {
433
492
  const payload = {
434
493
  to: message.to,
@@ -450,25 +509,28 @@ function createApiProvider() {
450
509
  method: apiConfig.method,
451
510
  headers,
452
511
  body: JSON.stringify(payload),
453
- signal: controller.signal
512
+ signal: controller.signal,
513
+ redirect: message.sensitive || apiConfig.redirect !== "follow" ? "manual" : "follow"
454
514
  });
455
515
  if (!response.ok) {
516
+ await response.body?.cancel();
456
517
  logger2.warn("API callback returned non-OK status", { status: response.status });
457
- const rejected = response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
518
+ const rejected = response.status >= 300 && response.status < 400 || response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
458
519
  return err(
459
520
  rejected ? HaiReachError.DELIVERY_REJECTED : HaiReachError.SEND_FAILED,
460
521
  reachM("reach_apiSendFailed", { params: { error: `HTTP ${response.status}` } })
461
522
  );
462
523
  }
463
- const result = await response.json();
464
- logger2.info("API callback sent", { messageId: result.messageId });
465
- return ok({ success: true, messageId: result.messageId });
524
+ const result = await readAcknowledgement(response);
525
+ if (result.success)
526
+ logger2.info("API callback accepted", { hasMessageId: result.data.messageId !== void 0 });
527
+ return result;
466
528
  } finally {
467
529
  clearTimeout(timeoutId);
468
530
  }
469
- } catch (error) {
470
- logger2.error("API callback send failed", { error });
471
- return err(toReachError2(error));
531
+ } catch {
532
+ logger2.error("API callback send failed", { errorCode: HaiReachError.SEND_FAILED.code });
533
+ return err(toReachError2());
472
534
  }
473
535
  }
474
536
  };