# 中继—推送网关协议

[Meshline Protocol 1.0](../README.md)

本协议规定推送网关地址的获取方式，以及中继向网关发送消息通知的报文、认证和交互规则，能力标识为 `push.notify.v1`。

## 网关声明与能力

设备通过 [`DeviceCertificate`](../client-relay/core-objects/accounts-and-devices.md#devicecertificate) 中的 `push_gateway` 声明完整回调地址。省略表示没有声明网关。该地址可随设备证书传播；不得在地址或证书中放入推送平台用于定位设备的标识、回调密钥或平台凭据。

中继通过 `push.notify.v1` 声明遵循本协议的推送能力。

### 地址来源与更新

中继通过已验证的设备证书获取推送网关地址。网关声明在本网络内按 `(account, device_id)` 区分；采用设备证书时，以其中的 `push_gateway` 更新声明，字段省略时清除原声明。连接关闭或会话到期不清除声明。

中继确认设备不再获得账户授权时，清除其网关声明；恢复授权后，须重新取得并验证设备证书。发送通知时遵循[发送目标验证](#发送目标验证)。

### 网关地址

`push_gateway` 必须是具有有效主机的完整绝对 HTTPS URL：以 `https://` 开头，最多 512 UTF-8 bytes，允许 query，不得包含空白、fragment、userinfo 或反斜杠；空 fragment 也不允许。

该完整地址就是接收端点，中继不得追加固定路径。设备证书和通知中的 `push_gateway` 必须保留原地址；不得通过补齐路径、删除部分、大小写转换或其他改写后接受不合规地址。目标地址绑定按完整字符串逐字比较，不按 URL 等价关系比较。

地址格式验证不依赖当时的 DNS 可用性。每次实际连接还须满足[回调连接安全](#回调连接安全)。

## `PushNotification`

每个通知只面向一个接收设备。字段表示和未知属性遵循[协议总则](../general.md)。

| 字段 | 类型 | 必需 | 语义与约束 |
|---|---|---|---|
| `$type` | string | 是 | 固定为 `meshline.push.notification` |
| `network` | string | 是 | 完整规范[网络上下文](../general.md#网络上下文)，同时包含 Neo network magic 和 Registry 合约地址 |
| `relay_id` | string | 是 | 报告事件的[中继 ID](../registry/core-objects.md#中继-id) |
| `public_key` | string | 是 | 33-byte SEC1 compressed `secp256r1` 公钥，无 padding base64url；必须派生出 `relay_id` |
| `push_gateway` | string | 是 | 本次接收端点，满足[网关地址](#网关地址)规则 |
| `issued_at` | integer | 是 | 本通知签发时的非负 UTC Unix 秒 |
| `message_type` | string | 是 | 中继可见的原始消息对象的 `$type`，须逐字保留；非空，不限定枚举值 |
| `to` | string | 是 | 接收账户的[账户 ID](../client-relay/core-objects/accounts-and-devices.md#账户-id) |
| `to_device_id` | string | 是 | 接收设备 ID，遵循[设备 ID](../client-relay/core-objects/accounts-and-devices.md#设备-id)格式 |
| `message_id` | string | 是 | 原始[消息 ID](../client-relay/core-objects/messages-and-content.md#消息-id) |
| `from` | string | 是 | 发送账户 ID |
| `accepted_at` | integer | 是 | 本中继记录的原消息接受时间，非负 UTC Unix 秒 |
| `sequence` | integer | 是 | 原记录在本中继上的时间线位置，非负安全整数；具体取值遵循原记录的约束 |
| `sender_nickname` | string | 否 | 发送账户的公开昵称，取自有效的 [`AccountProfile.nickname`](../client-relay/core-objects/accounts-and-devices.md#accountprofile)；非空且不得仅包含[空白字符](../general.md#文本空白字符)，最多 256 UTF-8 bytes |
| `subject_id` | string | 否 | 原消息关联对象的标识；含义和格式由 `message_type` 对应的消息定义确定 |
| `subject_name` | string | 否 | 该关联对象的显示名称，遵循对应对象的名称约束 |
| `relay_signature` | string | 是 | 中继账户对本通知生成的 64-byte Neo N3 签名，无 padding base64url |

通知中的消息信息必须与原消息及其记录一致。网关不得仅因未识别 `message_type` 而拒绝通知。

未取得昵称或昵称为空时省略 `sender_nickname`。

通知不得包含原消息信封、密文、密钥盒、设备证书或附件内容，也不得通过未知属性携带这些内容。其用途只限提示、展示和导航；接收方不得根据通知推进同步游标、标记消息已读或认定消息内容有效。

### 签名与认证

签名时只排除根 `relay_signature`，保留全部其他字段及未知属性，按[网络绑定 JSON 输入](../general.md#网络绑定-json-输入)和[中继账户签名](../general.md#验签与签名复用)规则签署。

网关必须从可信配置取得预期网络和自己的完整公开回调地址，而不是采用请求或未经信任的转发 header 建立预期值。接受通知前必须完成：

1. 验证 JSON 表示、对象类型、字段、大小和标识格式；账户 network magic 必须匹配预期网络。
2. 逐字比较 `network` 与预期网络、`push_gateway` 与配置的公开接收地址；从预期网络构造签名输入。
3. 验证公钥与 `relay_id` 绑定及通知签名；中继公钥、签名表示沿用 `RelayDescriptor` 的相应字段约束。
4. 通过预期网络的 Registry 确认中继登记为 `active`。
5. 完成[网关接受与去重](#网关接受与去重)。

网关对通知请求返回成功响应前必须完成上述验证，重复请求也不例外。

网关按上述规则认证发送通知的中继，不要求接收设备对各中继另行授权。

## HTTP 回调

### 发送目标验证

每次发送通知前，按[地址来源与更新](#地址来源与更新)核对当前网关地址。已签发通知中的 `push_gateway` 与当前地址不一致时，不得继续发送。

发送前必须确认原消息允许推送、目标设备当前获账户授权且有权访问通知涉及的信息。账户消息的 [`MessageEnvelope`](../client-relay/core-objects/messages-and-content.md#messageenvelope) 和群消息的 [`GroupMessageEnvelope`](../client-relay/groups/concepts/messaging-and-encryption.md#groupmessageenvelope) 仅当 `notify` 为 `true` 时允许推送。

设备当前授权由其归属中继确认：本中继负责该账户时读取本地当前权威设备状态，否则使用 [`device.status`](../relay-rpc/methods/account-queries.md#devicestatus) 的已验证结果；缓存的复用和失效遵循该方法及[缓存失效与补查规则](../relay-rpc/notifications/README.md#处理规则)。没有可用的已验证授权结果时不得发送。

### 网关确认

中继向 `push_gateway` 的完整地址发送不含请求体的 HTTPS `OPTIONS` 请求，遵循[回调连接安全](#回调连接安全)。支持本协议的端点返回：

```http
HTTP/1.1 204 No Content
Meshline-Push: push.notify.v1
```

只有收到 `204 No Content` 且 `Meshline-Push` 响应头的值为 `push.notify.v1` 时，确认才成功；字段名大小写不敏感，字段值大小写敏感。

中继可复用已取得的确认结果，但当前网关地址必须与确认请求的完整 URL 逐字相同；地址变更后须重新确认。未确认成功时，不得向该地址发送通知。

### 请求

中继向 `push_gateway` 的完整地址发送 HTTPS `POST`，使用 `Content-Type: application/json` 和 UTF-8 编码的完整 `PushNotification`，不使用 JSON-RPC 封装。请求体最多 4 KiB（4,096 bytes），计入空白和未知属性。

### 成功响应

通知请求成功时，网关返回 `200 OK`、`Content-Type: application/json` 和结果对象：

```json
{"status":"accepted"}
```

| 字段 | 类型 | 必需 | 语义与约束 |
|---|---|---|---|
| `status` | string | 是 | `accepted`、`skipped` 或 `duplicate`，含义如下 |

| 状态 | 含义 |
|---|---|
| `accepted` | 网关已接受本通知，负责后续平台发送和失败处理 |
| `skipped` | 网关已跳过本通知，不向平台发送 |
| `duplicate` | 相同通知已被接受或跳过，本次请求不另行触发处理 |

三种状态都终止中继重试。`accepted` 不表示平台已经接受，更不表示设备已经收到、展示或读取通知。

响应可以增加不改变上述含义的扩展属性，接收方忽略这些属性。只有 `200 OK` 与符合本节定义的结果对象组合才构成成功响应；不满足该条件的 `2xx` 响应视为结果未知。

### 错误与发送重试

| HTTP 状态 | 含义 |
|---|---|
| `400 Bad Request` | 格式或字段无效 |
| `403 Forbidden` | 网络、目标地址、签名、身份绑定或已确认的中继资格不符合要求 |
| `413 Content Too Large` | 请求体超过上限 |
| `429 Too Many Requests` | 请求被限流 |
| `503 Service Unavailable` | 接受前临时不可用，例如无法确认 Registry 资格或无法满足去重要求 |

错误正文可以提供诊断信息。网关接受通知后，不得因后续平台处理失败而返回可重试的 HTTP 错误。

选择重试的中继可以对连接失败、结果未知、`408`、`429` 和 `5xx` 重试；其他 `4xx` 终止本通知。`429` 可以提供非负整数秒的 `Retry-After`；中继重试时必须满足该等待时间，非法值不得按零处理。

重试必须原样复用完整已签名通知。重试前还须执行[发送目标验证](#发送目标验证)。

### 网关接受与去重

去重要求适用于同一网关的单次运行。网关在同一网络、同一接收账户和设备范围内，以 `(from, message_id)` 判断重复通知。验证通过后，同一通知在去重有效期内只能接受或跳过一次；后续及并发重复请求必须返回 `duplicate`，不得另行触发处理，即使原通知尚未处理完成。

### 回调连接安全

每次建立连接时，必须验证最终实际目标为公网可路由单播 IP，拒绝本机、环回、私网、链路本地、多播、未指定及其他非公网地址，包括 IPv4 映射 IPv6 中对应的受限 IPv4 地址。

DNS 解析检查必须与实际连接目标保持一致；不得检查一个地址后再不经检查地重新解析或连接另一个地址。必须验证 TLS 证书与原 URL 主机的绑定，不得关闭证书验证。任何 `3xx` 都不得跟随，也不根据其中的地址改投。未通过上述验证时不得发送请求。

## 兼容性与一致性要求

符合本模块的实现必须通过[推送通知测试向量](../test-vectors/push-notifications.md)，并按[一致性测试边界](../test-vectors/README.md#一致性测试边界)覆盖：

- 网关声明：证书字段省略及新增、修改、移除，双签名覆盖、设备 ID 稳定、未知字段保留、地址格式与大小；从设备证书取得地址，以及证书更新、会话结束和设备失效时的声明处理规则。
- 通知报文与认证：字段约束、签名及身份伪造、跨网络隔离和目标地址绑定。
- 发送目标：消息推送条件、当前设备授权与信息访问权限、地址变更后的目标校验，以及授权结果不可用时禁止发送。
- 网关确认：空请求体、响应状态与能力响应头、完整 URL 绑定、地址变更后重新确认，以及确认失败时禁止发送通知。
- HTTP 交互：三种成功结果、错误响应、可选重试、等待时间、原样重试、并发去重、去重范围及不可用时的拒绝。
- 回调连接安全：受限 IP、DNS 目标切换、TLS 主机验证及重定向。
