@openchambery/relay-server 1.19.0-beta.37 → 1.19.0-beta.38

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/DOCUMENTATION.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Relay Server Package Documentation
2
2
 
3
- `packages/relay-server/` owns the self-hosted Layer 1 Relay server, the `openchamber-relay` CLI, and the package release and deployment contract.
3
+ `packages/relay-server/` owns the self-hosted Layer 1 Relay server, the isolated Push Relay process, the `openchamber-relay` and `openchamber-push-relay` CLIs, and the package release and deployment contract.
4
4
 
5
5
  ## Purpose and security boundary
6
6
 
@@ -12,6 +12,8 @@ Relay v1 admission accepts anonymous Client route requests. Per-IP, global, pend
12
12
 
13
13
  The Relay keeps process-local routing state only. Hosts reconnect after Relay restarts, and a control disconnect retains its Host route for the 30-second grace period.
14
14
 
15
+ Layer 1 and Push are separate processes in this package. Layer 1 never holds APNs credentials or the device-token database. Push never sees Relay tunnel frames, pairing secrets, or client bearer credentials. Give Apple secrets only to the Push process. SQLite token storage is single-instance: one Push process per database file.
16
+
15
17
  ## Quick deployment
16
18
 
17
19
  Install the package, then start the Relay:
@@ -27,6 +29,18 @@ The default listener is `127.0.0.1:8787` and the WebSocket path is `/ws`. Deploy
27
29
  openchamber-relay --public-url wss://relay.example.com/ws
28
30
  ```
29
31
 
32
+ Push is a second executable from the same package. Default listen address is `127.0.0.1:8788`:
33
+
34
+ ```sh
35
+ export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
36
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
37
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
38
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
39
+ openchamber-push-relay --host 127.0.0.1 --port 8788
40
+ ```
41
+
42
+ The Host maps the effective Relay `wss://`/`ws://` URL to the same host as `https://`/`http://` `/v1/push/send`. Set `OPENCHAMBER_PUSH_RELAY_URL` on the Host to override. After a Relay switch, the Host re-registers persisted tokens and binds them before the first send.
43
+
30
44
  ### Caddy
31
45
 
32
46
  ```caddyfile
@@ -39,6 +53,24 @@ relay.example.com {
39
53
 
40
54
  Run the Relay with `--public-url wss://relay.example.com/ws`. Caddy proxies WebSocket upgrades for `/ws` and serves `/healthz` and `/readyz` through the same upstream. `header_up X-Forwarded-For {remote_host}` replaces the inbound value with the single client source IP.
41
55
 
56
+ Shared hostname with Push:
57
+
58
+ ```caddyfile
59
+ relay.example.com {
60
+ handle /v1/push/* {
61
+ reverse_proxy 127.0.0.1:8788 {
62
+ header_up X-Forwarded-For {remote_host}
63
+ }
64
+ }
65
+
66
+ handle {
67
+ reverse_proxy 127.0.0.1:8787 {
68
+ header_up X-Forwarded-For {remote_host}
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
42
74
  ### Nginx
43
75
 
44
76
  ```nginx
@@ -60,11 +92,11 @@ server {
60
92
  }
61
93
  ```
62
94
 
63
- Run the Relay with `--public-url wss://relay.example.com/ws`. The `/ws` path in the public URL and Relay configuration must match. `proxy_set_header X-Forwarded-For $remote_addr;` replaces the inbound value with the single client source IP.
95
+ Run the Relay with `--public-url wss://relay.example.com/ws`. The `/ws` path in the public URL and Relay configuration must match. `proxy_set_header X-Forwarded-For $remote_addr;` replaces the inbound value with the single client source IP. For Push on the same hostname, proxy `/v1/push/` to `127.0.0.1:8788` and keep `/` including `/ws` on `127.0.0.1:8787`, replacing `X-Forwarded-For` on both locations.
64
96
 
65
97
  ## Docker
66
98
 
67
- Each non-dry-run OpenChamber release publishes a multi-platform Relay image for `linux/amd64` and `linux/arm64` to Docker Hub. CI builds each architecture natively in parallel (`ubuntu-latest` and `ubuntu-24.04-arm`), then merges digests into a single multi-arch manifest tagged as:
99
+ Each non-dry-run OpenChamber `v*` release and each `relay/v*` Relay-only release publishes a multi-platform Relay image for `linux/amd64` and `linux/arm64` to Docker Hub. The image default entrypoint is Layer 1. The same image includes Node 24 plus `openchamber-push-relay` source/bin/package files. Layer 1 is compiled with Bun 1.3.14; Push is not Bun-compiled and runs under Node 24 (`node:sqlite`). The container user is non-root, ports `8787` and `8788` are exposed, and the image health check uses Node against Layer 1 `/healthz`. CI builds each architecture natively in parallel (`ubuntu-latest` and `ubuntu-24.04-arm`), then merges digests into a single multi-arch manifest tagged as:
68
100
 
69
101
  ```text
70
102
  <DOCKERHUB_USERNAME>/openchamber-relay:<version>
@@ -109,6 +141,27 @@ curl -fsS https://relay.example.com/healthz
109
141
  curl -fsS https://relay.example.com/readyz
110
142
  ```
111
143
 
144
+ Layer 1 plus Push from the same immutable image uses [`docker-compose.relay-push.remote.yml`](../../docker-compose.relay-push.remote.yml). Layer 1 receives no APNs secrets. Push receives only APNs Key ID / Team ID / Bundle ID and the `.p8` Docker secret path, plus a persistent SQLite volume, with a read-only root filesystem. Caddy serves one hostname: `/v1/push/*` to `push:8788`, everything else including `/ws` to `relay:8787`, each replacing `X-Forwarded-For` once. Both services have health checks; Caddy waits until both are healthy.
145
+
146
+ ```sh
147
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
148
+ RELAY_DOMAIN=relay.example.com \
149
+ ACME_EMAIL=admin@example.com \
150
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
151
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
152
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber \
153
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_FILE=/etc/openchamber/AuthKey.p8 \
154
+ docker compose -f docker-compose.relay-push.remote.yml up -d
155
+ ```
156
+
157
+ ```sh
158
+ docker compose -f docker-compose.relay-push.remote.yml ps
159
+ curl -fsS https://relay.example.com/healthz
160
+ curl -fsS https://relay.example.com/readyz
161
+ ```
162
+
163
+ Public `/healthz` and `/readyz` are Layer 1. Push health remains on the internal `8788` listener and the Compose health check. Existing Layer-1-only Compose files keep their previous behavior.
164
+
112
165
  From the repository root, build and start the supplied service. The compatibility assets are [`Dockerfile.relay`](../../Dockerfile.relay) and [`docker-compose.relay.yml`](../../docker-compose.relay.yml):
113
166
 
114
167
  ```sh
@@ -117,7 +170,7 @@ OPENCHAMBER_RELAY_PUBLISHED_PORT=8787 \
117
170
  docker compose -f docker-compose.relay.yml up -d --build
118
171
  ```
119
172
 
120
- Compose publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` by default; use `OPENCHAMBER_RELAY_PUBLISHED_PORT` to select the host port. The Compose service uses an ephemeral filesystem and keeps Host identity keys on each OpenChamber Host. Its image health check calls `GET /healthz`. Terminate public TLS at an external reverse proxy and publish `wss://relay.example.com/ws`. A public Relay port binding requires firewall rules and TLS; loopback publishing with a TLS reverse proxy is the deployment path.
173
+ Compose publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` by default; use `OPENCHAMBER_RELAY_PUBLISHED_PORT` to select the host port. The Compose service uses an ephemeral filesystem and keeps Host identity keys on each OpenChamber Host. Its image health check uses Node to call Layer 1 `GET /healthz`. Terminate public TLS at an external reverse proxy and publish `wss://relay.example.com/ws`. A public Relay port binding requires firewall rules and TLS; loopback publishing with a TLS reverse proxy is the deployment path.
121
174
 
122
175
  ## Connect Hosts
123
176
 
@@ -134,7 +187,7 @@ Existing clients switch to a new Relay after a new pairing flow; generate a fres
134
187
 
135
188
  ## Configuration
136
189
 
137
- Configuration precedence is command flags, then `OPENCHAMBER_RELAY_SERVER_*` variables, then defaults. `--host`, `--port`, `--path`, `--public-url`, `--trust-proxy`, `--no-trust-proxy`, `--json`, and `--quiet` are available.
190
+ Configuration precedence is command flags, then `OPENCHAMBER_RELAY_SERVER_*` variables, then defaults. `--host`, `--port`, `--path`, `--public-url`, `--trust-proxy`, `--no-trust-proxy`, `--json`, and `--quiet` are available. Push flags are `--host`, `--port`, `--trust-proxy`, `--no-trust-proxy`, `--json`, and `--quiet`, with `OPENCHAMBER_PUSH_RELAY_*` variables.
138
191
 
139
192
  `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` affects startup output. `OPENCHAMBER_RELAY_SERVER_PATH` selects the actual WebSocket upgrade endpoint. Relay listens on loopback by default. Enable `OPENCHAMBER_RELAY_SERVER_TRUST_PROXY=true` when a trusted reverse proxy fully isolates Relay ingress and replaces any client-supplied `X-Forwarded-For` value with one canonical client IP. Relay accepts one forwarded IP in this mode.
140
193
 
@@ -179,12 +232,44 @@ For an IPv6 literal in a public URL, enclose the host in brackets: `wss://[2001:
179
232
  | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
180
233
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
181
234
 
235
+ Host Push URL (OpenChamber Host, not the Push process):
236
+
237
+ | Variable | Default | Unit / purpose |
238
+ | --- | --- | --- |
239
+ | `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay URL | explicit `…/v1/push/send` override |
240
+ | `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | `true` disables Push Relay on the Host |
241
+
242
+ Push process:
243
+
244
+ | Variable | Default | Unit / purpose |
245
+ | --- | --- | --- |
246
+ | `OPENCHAMBER_PUSH_RELAY_HOST` | `127.0.0.1` | Listener address |
247
+ | `OPENCHAMBER_PUSH_RELAY_PORT` | `8788` | TCP port |
248
+ | `OPENCHAMBER_PUSH_RELAY_TRUST_PROXY` | `false` | Read one canonical client IP from proxy-replaced `X-Forwarded-For` |
249
+ | `OPENCHAMBER_PUSH_RELAY_DATABASE_PATH` | `./data/push-relay.sqlite` | SQLite file |
250
+ | `OPENCHAMBER_PUSH_RELAY_TIMESTAMP_SKEW_MS` | `300000` | ms signed `ts` window |
251
+ | `OPENCHAMBER_PUSH_RELAY_REPLAY_MS` | `600000` | ms replay-record lifetime; at least twice timestamp skew |
252
+ | `OPENCHAMBER_PUSH_RELAY_MAX_REPLAY_ENTRIES` | `10000` | replay records |
253
+ | `OPENCHAMBER_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE` | `60` | register requests per client IP per minute |
254
+ | `OPENCHAMBER_PUSH_RELAY_SEND_LIMIT_PER_MINUTE` | `60` | send requests per client IP per minute |
255
+ | `OPENCHAMBER_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE` | `120` | send requests per `serverId` per minute |
256
+ | `OPENCHAMBER_PUSH_RELAY_MAX_TOKENS` | `100000` | persisted device-token bindings |
257
+ | `OPENCHAMBER_PUSH_RELAY_MAX_IN_FLIGHT` | `64` | concurrent APNs deliveries |
258
+ | `OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID` | required | Apple APNs key ID |
259
+ | `OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID` | required | Apple Team ID |
260
+ | `OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.openchamber` | App bundle ID |
261
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM |
262
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file |
263
+
264
+ TestFlight and App Store Hosts use `OPENCHAMBER_APNS_ENVIRONMENT=production`. Each send request carries `env`; the Push process does not pick sandbox vs production itself.
265
+
182
266
  ## Operations
183
267
 
184
268
  - `GET` and `HEAD` requests to `/healthz` return process health. `/readyz` returns ready status after the listener reaches running state.
185
269
  - `SIGTERM` and `SIGINT` begin graceful Relay shutdown. Docker grants a 30-second stop period.
186
270
  - Hosts automatically reconnect after a Relay process restart. Relay state remains ephemeral.
187
- - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, and encrypted payloads.
271
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, APNs `.p8` material, and device tokens.
272
+ - Run one Push process per SQLite database file.
188
273
 
189
274
  ### systemd
190
275
 
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  `openchamber-relay` is the self-hosted Layer 1 relay for OpenChamber remote access. It gives OpenChamber Hosts an outbound Relay connection and carries encrypted client traffic through that connection.
4
4
 
5
+ The same `@openchambery/relay-server` package also ships `openchamber-push-relay`, an isolated APNs push process. Layer 1 never receives Apple credentials. Push never terminates E2EE tunnels.
6
+
5
7
  The Relay routes opaque Layer 2/3 frames verbatim. E2EE terminates at the OpenChamber Host and Client, so the Relay has routing metadata and transport state while the endpoints hold application plaintext, pairing secrets, and client bearer credentials. Host Relay connections authenticate with the Host's long-lived P-256 signing key. Relay reachability grants transport access; endpoint validation continues to enforce pairing and client credentials.
6
8
 
7
9
  ## Architecture and transport
@@ -19,10 +21,19 @@ Relay state lives in process memory. Hosts reconnect after a Relay restart. A di
19
21
 
20
22
  Relay v1 accepts anonymous Client route requests. Admission, connection, frame, queue, and socket limits bound this public entry point.
21
23
 
24
+ ## Dual-process security boundary
25
+
26
+ Layer 1 (`openchamber-relay`) and Push (`openchamber-push-relay`) are separate processes in one package:
27
+
28
+ - Layer 1 authenticates Hosts, brokers WebSocket routes, and forwards opaque frames. It has no APNs key, no device-token database, and no `/v1/push/*` handlers.
29
+ - Push verifies Host signatures, binds `token → serverId` in a local SQLite database, and holds the project APNs `.p8` key. It has no access to Relay tunnels, pairing secrets, or client bearer credentials.
30
+ - Deploy them as two containers from the same immutable image. Give APNs Key ID / Team ID / Bundle ID and the `.p8` secret only to the Push container. Keep Layer 1 free of those secrets.
31
+ - SQLite is a single-writer store. Run one Push instance per database file. Do not share that volume across replicas.
32
+
22
33
  ## Requirements
23
34
 
24
- - `@openchambery/relay-server` installation: Node.js 22 or later and a supported package manager such as npm, pnpm, yarn, or Bun.
25
- - Single-file bundle build: Bun. This repository uses Bun 1.3.14.
35
+ - `@openchambery/relay-server` installation: Node.js 22.13 or later and a supported package manager such as npm, pnpm, yarn, or Bun. Push uses Node's built-in `node:sqlite`.
36
+ - Single-file Layer 1 bundle build: Bun. This repository uses Bun 1.3.14. Do not Bun-compile the Push entry; Docker runs it with Node 24.
26
37
  - Public deployment: a DNS name, TLS certificate, reverse proxy, and firewall policy appropriate for the deployment.
27
38
 
28
39
  ## Install and quick start
@@ -36,6 +47,20 @@ openchamber-relay --public-url wss://relay.example.com/ws
36
47
 
37
48
  The default listener is `127.0.0.1:8787` and the default WebSocket upgrade path is `/ws`. Keep this loopback listener behind a TLS reverse proxy and set `--public-url` to the public `ws://` or `wss://` URL with the same path.
38
49
 
50
+ Start the isolated Push process from the same package. The default listener is `127.0.0.1:8788`. APNs Key ID, Team ID, and a `.p8` value or file path are required:
51
+
52
+ ```sh
53
+ export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
54
+ export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
55
+ export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
56
+ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
57
+ openchamber-push-relay --host 127.0.0.1 --port 8788
58
+ ```
59
+
60
+ Keep Push on loopback behind the same TLS reverse proxy. Route only `/v1/push/*` to port 8788.
61
+
62
+ OpenChamber Hosts do not need a separate Push URL when they already have a Relay URL. The effective `wss://` or `ws://` Relay URL maps to the same host as `https://` or `http://` `/v1/push/send` (register is `/v1/push/register-token`). Set `OPENCHAMBER_PUSH_RELAY_URL` on the Host to override that mapping. After a Relay switch, the Host re-registers persisted device tokens and binds them again before the first send.
63
+
39
64
  ### Build a standalone executable
40
65
 
41
66
  Run these commands from the repository root. `bun build --compile` creates a single executable for the current platform and architecture.
@@ -91,6 +116,18 @@ Available CLI options:
91
116
  --version, -v
92
117
  ```
93
118
 
119
+ Push CLI options:
120
+
121
+ ```text
122
+ --host HOST
123
+ --port PORT
124
+ --trust-proxy | --no-trust-proxy
125
+ --json
126
+ --quiet, -q
127
+ --help, -h
128
+ --version, -v
129
+ ```
130
+
94
131
  ## Connect OpenChamber Hosts
95
132
 
96
133
  Set the public Relay URL in every OpenChamber Host environment, then start the Host and create a Relay pairing link or enable Relay pairing in the application.
@@ -120,6 +157,24 @@ relay.example.com {
120
157
 
121
158
  Run the Relay with `--public-url wss://relay.example.com/ws`. Caddy forwards WebSocket upgrades and serves `/healthz` and `/readyz` from the same upstream. The `X-Forwarded-For` rule writes one canonical Client source IP.
122
159
 
160
+ When Push shares the hostname, send `/v1/push/*` to the Push listener and replace `X-Forwarded-For` once on each upstream:
161
+
162
+ ```caddyfile
163
+ relay.example.com {
164
+ handle /v1/push/* {
165
+ reverse_proxy 127.0.0.1:8788 {
166
+ header_up X-Forwarded-For {remote_host}
167
+ }
168
+ }
169
+
170
+ handle {
171
+ reverse_proxy 127.0.0.1:8787 {
172
+ header_up X-Forwarded-For {remote_host}
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
123
178
  ### Nginx
124
179
 
125
180
  ```nginx
@@ -143,6 +198,8 @@ server {
143
198
 
144
199
  Run the Relay with `--public-url wss://relay.example.com/ws`. Nginx writes one canonical Client source IP with `$remote_addr` and forwards WebSocket upgrades over HTTP/1.1.
145
200
 
201
+ To share the hostname with Push, proxy `/v1/push/` to `127.0.0.1:8788` and keep `/` (including `/ws`) on `127.0.0.1:8787`. Replace `X-Forwarded-For` with `$remote_addr` on both locations.
202
+
146
203
  ## Trusted proxies and capacity
147
204
 
148
205
  Enable `OPENCHAMBER_RELAY_SERVER_TRUST_PROXY=true` or `--trust-proxy` when a trusted reverse proxy fully isolates Relay ingress and replaces each inbound `X-Forwarded-For` value with one canonical Client IP.
@@ -195,6 +252,43 @@ Trusted-proxy mode accepts exactly one valid IP address in `X-Forwarded-For`. Cl
195
252
  | `OPENCHAMBER_RELAY_SERVER_MAX_ADMISSION_ENTRIES` | `10000` | tracked role/IP admission records |
196
253
  | `OPENCHAMBER_RELAY_SERVER_ID_ATTEMPTS` | `4` | random connection-ID attempts |
197
254
 
255
+ ### Host Push URL override
256
+
257
+ These variables belong on the OpenChamber Host, not on the Push process:
258
+
259
+ | Variable | Default | Unit / purpose |
260
+ | --- | --- | --- |
261
+ | `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay `ws`/`wss` URL | Host override for `https://` or `http://` `…/v1/push/send` |
262
+ | `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | Host-only; `true` skips Push Relay and uses direct APNs |
263
+
264
+ The derived send URL always uses `/v1/push/send` on the same host and port as the Relay URL. `wss` maps to `https`; `ws` maps to `http`. Register is the same origin with `/v1/push/register-token`.
265
+
266
+ ### Push process environment
267
+
268
+ Command flags take precedence over `OPENCHAMBER_PUSH_RELAY_*` variables, which take precedence over defaults. APNs Key ID, Team ID, and `.p8` material are required.
269
+
270
+ | Variable | Default | Unit / purpose |
271
+ | --- | --- | --- |
272
+ | `OPENCHAMBER_PUSH_RELAY_HOST` | `127.0.0.1` | Listener address |
273
+ | `OPENCHAMBER_PUSH_RELAY_PORT` | `8788` | TCP port |
274
+ | `OPENCHAMBER_PUSH_RELAY_TRUST_PROXY` | `false` | Read one canonical Client IP from proxy-replaced `X-Forwarded-For` |
275
+ | `OPENCHAMBER_PUSH_RELAY_DATABASE_PATH` | `./data/push-relay.sqlite` | SQLite file; directory must be writable |
276
+ | `OPENCHAMBER_PUSH_RELAY_TIMESTAMP_SKEW_MS` | `300000` | ms signed `ts` window |
277
+ | `OPENCHAMBER_PUSH_RELAY_REPLAY_MS` | `600000` | ms replay-record lifetime; at least twice timestamp skew |
278
+ | `OPENCHAMBER_PUSH_RELAY_MAX_REPLAY_ENTRIES` | `10000` | replay records |
279
+ | `OPENCHAMBER_PUSH_RELAY_REGISTER_LIMIT_PER_MINUTE` | `60` | register requests per Client IP per minute |
280
+ | `OPENCHAMBER_PUSH_RELAY_SEND_LIMIT_PER_MINUTE` | `60` | send requests per Client IP per minute |
281
+ | `OPENCHAMBER_PUSH_RELAY_SERVER_SEND_LIMIT_PER_MINUTE` | `120` | send requests per `serverId` per minute |
282
+ | `OPENCHAMBER_PUSH_RELAY_MAX_TOKENS` | `100000` | persisted device-token bindings |
283
+ | `OPENCHAMBER_PUSH_RELAY_MAX_IN_FLIGHT` | `64` | concurrent APNs deliveries |
284
+ | `OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID` | required | Apple APNs key ID |
285
+ | `OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID` | required | Apple Team ID |
286
+ | `OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID` | `com.yee94.openchamber` | App bundle ID |
287
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8` | required unless path set | APNs `.p8` PEM; literal `\n` accepted |
288
+ | `OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH` | unset | Path to the `.p8` file; used when `APNS_P8` is empty |
289
+
290
+ TestFlight and App Store Hosts send `env: production` (`OPENCHAMBER_APNS_ENVIRONMENT=production` on the Host). Xcode development builds use `sandbox`. The Push process does not choose the APNs environment; each send request carries it.
291
+
198
292
  ## systemd
199
293
 
200
294
  Create `/etc/openchamber-relay.env`:
@@ -236,11 +330,12 @@ sudo systemctl status openchamber-relay
236
330
  - `SIGTERM` and `SIGINT` start graceful Relay shutdown. Hosts reconnect after a process restart.
237
331
  - Size Host, Client, pending, socket, frame, and queue limits for expected concurrency and message volume.
238
332
  - Keep the default loopback listener, terminate public TLS at a reverse proxy, restrict ingress with firewall rules, and publish the matching `wss://` URL.
239
- - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, pairing material, and bearer credentials.
333
+ - Keep logs and metrics snapshots free of URL query strings, `sig`, `pk`, `grant`, encrypted payloads, pairing material, bearer credentials, APNs `.p8` contents, Key ID / Team ID values, and device tokens.
334
+ - Run a single Push process per SQLite file. WAL mode does not make multi-instance sharing safe.
240
335
 
241
336
  ## Docker delivery assets
242
337
 
243
- Each non-dry-run OpenChamber release publishes a Docker Hub image for `linux/amd64` and `linux/arm64` as `<DOCKERHUB_USERNAME>/openchamber-relay:<version>` and `<DOCKERHUB_USERNAME>/openchamber-relay:latest`. The release pipeline requires the `DOCKERHUB_USERNAME` GitHub Actions repository variable and a `DOCKERHUB_TOKEN` repository secret with Docker Hub Read and Write permissions. Image publication must succeed before the GitHub Release is finalized.
338
+ Each non-dry-run OpenChamber `v*` release and each `relay/v*` Relay-only release publishes a Docker Hub image for `linux/amd64` and `linux/arm64` as `<DOCKERHUB_USERNAME>/openchamber-relay:<version>` and `<DOCKERHUB_USERNAME>/openchamber-relay:latest`. The image default entrypoint is Layer 1. The same image also contains Node 24 and the `openchamber-push-relay` source/bin/package files. Layer 1 is a Bun 1.3.14 compile standalone; Push is executed with Node 24 and `node:sqlite`. The container runs as a non-root user, exposes `8787` and `8788`, and health-checks Layer 1 with Node. The release pipeline requires the `DOCKERHUB_USERNAME` GitHub Actions repository variable and a `DOCKERHUB_TOKEN` repository secret with Docker Hub Read and Write permissions. Image publication must succeed before the GitHub Release is finalized. `relay/v*` publishes the same npm package and Docker image without desktop, mobile, TestFlight, or OTA artifacts.
244
339
 
245
340
  The `Relay Docker` workflow can republish only the current Relay package version without creating or modifying a GitHub Release or other platform artifacts.
246
341
 
@@ -270,6 +365,23 @@ docker compose -f docker-compose.relay.remote.yml up -d
270
365
 
271
366
  `OPENCHAMBER_RELAY_IMAGE` is required so the repository never binds this reusable deployment file to a personal registry namespace. Use an immutable version-and-digest reference in production.
272
367
 
368
+ To run Layer 1 and Push from that same immutable image, with APNs secrets only on Push, a persistent SQLite volume, read-only root filesystems, and Caddy routing `/v1/push/*` to Push, use [`docker-compose.relay-push.remote.yml`](../../docker-compose.relay-push.remote.yml):
369
+
370
+ ```sh
371
+ OPENCHAMBER_RELAY_IMAGE='<dockerhub-username>/openchamber-relay:<version>@sha256:<manifest-digest>' \
372
+ RELAY_DOMAIN=relay.example.com \
373
+ ACME_EMAIL=admin@example.com \
374
+ OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>' \
375
+ OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>' \
376
+ OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber \
377
+ OPENCHAMBER_PUSH_RELAY_APNS_P8_FILE=/etc/openchamber/AuthKey.p8 \
378
+ docker compose -f docker-compose.relay-push.remote.yml up -d
379
+ ```
380
+
381
+ The Push container reads the `.p8` from the Docker secret path `/run/secrets/apns_p8`. Layer 1 does not receive that secret. Inspect both health checks with `docker compose -f docker-compose.relay-push.remote.yml ps`. Public `/healthz` and `/readyz` are Layer 1. Push `/healthz` stays on the internal listener.
382
+
383
+ Existing Layer-1-only Compose files keep their current behavior: [`docker-compose.relay.yml`](../../docker-compose.relay.yml) for loopback, and [`docker-compose.relay.remote.yml`](../../docker-compose.relay.remote.yml) for public Layer 1 without Push.
384
+
273
385
  The repository provides optional Docker delivery assets at [`Dockerfile.relay`](../../Dockerfile.relay) and [`docker-compose.relay.yml`](../../docker-compose.relay.yml). The Compose service publishes `127.0.0.1:${OPENCHAMBER_RELAY_PUBLISHED_PORT:-8787}` and accepts `OPENCHAMBER_RELAY_SERVER_PUBLIC_URL` plus selected Relay limits.
274
386
 
275
387
  ```sh
@@ -291,6 +403,9 @@ These assets define an optional follow-on deployment path. Validate the image, p
291
403
  | Per-Client IP limits behave as proxy limits | Enable trusted-proxy mode, fully isolate Relay ingress behind that proxy, and configure a single replaced `X-Forwarded-For` IP. |
292
404
  | Existing clients continue using an earlier endpoint | Refresh the candidate or create a new pairing link after changing `OPENCHAMBER_RELAY_URL`. |
293
405
  | WebSocket application traffic fails while HTTP works | Confirm the Host endpoint mints and supplies a short-lived `oc_url_token` for the WebSocket path. |
406
+ | Push register or send returns 404 on the public hostname | Confirm the reverse proxy sends `/v1/push/*` to the Push listener on port 8788, not to Layer 1. |
407
+ | Push container is unhealthy | Confirm APNs Key ID / Team ID / Bundle ID and the `.p8` secret path, and that the SQLite volume is writable by the non-root user. |
408
+ | Device tokens stop receiving after a Relay URL change | Confirm the Host re-registered against the new Push origin before the first send, or set `OPENCHAMBER_PUSH_RELAY_URL` explicitly. |
294
409
 
295
410
  ## Development and test coverage
296
411
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/relay-server",
3
- "version": "1.19.0-beta.37",
3
+ "version": "1.19.0-beta.38",
4
4
  "description": "Self-hosted private relay server for OpenChamber",
5
5
  "private": false,
6
6
  "type": "module",
package/src/push/apns.js CHANGED
@@ -6,6 +6,7 @@ const APNS_HOST = {
6
6
  sandbox: 'https://api.sandbox.push.apple.com',
7
7
  };
8
8
  const JWT_TTL_MS = 50 * 60 * 1000;
9
+ const SESSION_TTL_MS = 30 * 60 * 1000;
9
10
  const REQUEST_TIMEOUT_MS = 5_000;
10
11
  const MAX_RESPONSE_BYTES = 4_096;
11
12
  export const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
@@ -15,6 +16,7 @@ const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/
15
16
  export const createApnsProvider = (options = {}) => {
16
17
  const clock = { now: Date.now, setTimeout, clearTimeout, ...options.clock };
17
18
  const connect = options.http2?.connect ?? http2.connect;
19
+ const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
18
20
  let privateKey;
19
21
  try {
20
22
  privateKey = crypto.createPrivateKey(normalizePem(options.p8));
@@ -41,17 +43,21 @@ export const createApnsProvider = (options = {}) => {
41
43
  };
42
44
 
43
45
  const dropSession = (env, client) => {
44
- if (sessions.get(env) === client) sessions.delete(env);
46
+ const current = sessions.get(env);
47
+ if (current?.client === client) sessions.delete(env);
45
48
  try { client.close(); } catch { /* session already gone */ }
46
49
  };
47
50
 
48
51
  const getSession = (env) => {
49
52
  const existing = sessions.get(env);
50
- if (existing && !existing.closed && existing.destroyed !== true) return existing;
53
+ if (existing?.client && !existing.client.closed && existing.client.destroyed !== true) {
54
+ if (clock.now() - existing.createdAt < sessionTtlMs) return existing.client;
55
+ dropSession(env, existing.client);
56
+ }
51
57
  const client = connect(APNS_HOST[env]);
52
58
  client.on('error', () => dropSession(env, client));
53
- client.on('close', () => { if (sessions.get(env) === client) sessions.delete(env); });
54
- sessions.set(env, client);
59
+ client.on('close', () => { if (sessions.get(env)?.client === client) sessions.delete(env); });
60
+ sessions.set(env, { client, createdAt: clock.now() });
55
61
  return client;
56
62
  };
57
63
 
@@ -69,9 +75,14 @@ export const createApnsProvider = (options = {}) => {
69
75
  };
70
76
  if (input.collapseId) headers['apns-collapse-id'] = input.collapseId;
71
77
  let req;
72
- try { req = client.request(headers); } catch { resolve({ ok: false }); return; }
78
+ try { req = client.request(headers); } catch {
79
+ dropSession(input.env, client);
80
+ resolve({ ok: false });
81
+ return;
82
+ }
73
83
  let status = 0;
74
- let responseBody = '';
84
+ const chunks = [];
85
+ let responseBytes = 0;
75
86
  let settled = false;
76
87
  const finish = (result) => {
77
88
  if (settled) return;
@@ -84,19 +95,25 @@ export const createApnsProvider = (options = {}) => {
84
95
  finish({ ok: false });
85
96
  }, options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
86
97
  req.on('response', (responseHeaders) => { status = Number(responseHeaders[':status']) || 0; });
87
- req.setEncoding('utf8');
88
98
  req.on('data', (chunk) => {
89
- if (responseBody.length < MAX_RESPONSE_BYTES) responseBody += chunk.slice(0, MAX_RESPONSE_BYTES - responseBody.length);
99
+ if (responseBytes >= MAX_RESPONSE_BYTES) return;
100
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
101
+ const take = buf.length > MAX_RESPONSE_BYTES - responseBytes ? buf.subarray(0, MAX_RESPONSE_BYTES - responseBytes) : buf;
102
+ chunks.push(take);
103
+ responseBytes += take.length;
90
104
  });
91
105
  req.on('end', () => {
92
106
  if (status === 200) { finish({ ok: true }); return; }
93
107
  let reason = '';
94
- try { reason = JSON.parse(responseBody)?.reason || ''; } catch { /* non-JSON */ }
108
+ try { reason = JSON.parse(Buffer.concat(chunks, responseBytes).toString('utf8'))?.reason || ''; } catch { /* non-JSON */ }
95
109
  if (reason === 'ExpiredProviderToken') { finish({ ok: false, expired: true }); return; }
96
110
  if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) { finish({ ok: false, drop: true }); return; }
97
111
  finish({ ok: false });
98
112
  });
99
- req.on('error', () => finish({ ok: false }));
113
+ req.on('error', () => {
114
+ if (!settled) dropSession(input.env, client);
115
+ finish({ ok: false });
116
+ });
100
117
  req.end(JSON.stringify(input.payload));
101
118
  });
102
119
 
@@ -110,9 +127,9 @@ export const createApnsProvider = (options = {}) => {
110
127
  return { ok: first.ok === true, drop: first.drop === true ? true : undefined };
111
128
  },
112
129
  close() {
113
- for (const [env, client] of sessions) {
130
+ for (const [env, entry] of sessions) {
114
131
  sessions.delete(env);
115
- try { client.close(); } catch { /* ignore */ }
132
+ try { entry.client.close(); } catch { /* ignore */ }
116
133
  }
117
134
  cachedJwt = null;
118
135
  },
@@ -4,7 +4,7 @@ import { isIP } from 'node:net';
4
4
  export const DEFAULT_HOST = '127.0.0.1';
5
5
  export const DEFAULT_PORT = 8788;
6
6
  export const DEFAULT_DATABASE_PATH = './data/push-relay.sqlite';
7
- export const DEFAULT_BUNDLE_ID = 'com.openchamber.app';
7
+ export const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
8
8
  export const ENV_PREFIX = 'OPENCHAMBER_PUSH_RELAY_';
9
9
  export const DEFAULT_LIMITS = {
10
10
  timestampSkewMs: 300_000,
package/src/push/guard.js CHANGED
@@ -57,24 +57,89 @@ export const createSlidingWindowLimiter = ({ windowMs, maxCount, maxEntries, now
57
57
 
58
58
  export const createInFlightGate = (maxInFlight) => {
59
59
  const maxWaiters = maxInFlight;
60
+ let generation = 1;
60
61
  let active = 0;
62
+ let accepting = true;
61
63
  const waiters = [];
64
+ const idleWaiters = [];
65
+ const notifyIdle = () => {
66
+ if (active !== 0) return;
67
+ while (idleWaiters.length) idleWaiters.shift()();
68
+ };
62
69
  return {
63
70
  acquire() {
64
- if (active < maxInFlight) { active += 1; return Promise.resolve(true); }
71
+ if (!accepting) return Promise.resolve(false);
72
+ if (active < maxInFlight) {
73
+ active += 1;
74
+ return Promise.resolve(generation);
75
+ }
65
76
  if (waiters.length >= maxWaiters) return Promise.resolve(false);
66
- return new Promise((resolve) => { waiters.push(resolve); });
77
+ const gen = generation;
78
+ return new Promise((resolve) => {
79
+ waiters.push(() => {
80
+ if (!accepting || generation !== gen) { resolve(false); return; }
81
+ resolve(generation);
82
+ });
83
+ });
84
+ },
85
+ release(ticket) {
86
+ if (ticket !== generation) return;
87
+ if (accepting) {
88
+ const next = waiters.shift();
89
+ if (next) { next(); return; }
90
+ } else {
91
+ while (waiters.length) waiters.shift()();
92
+ }
93
+ active = Math.max(0, active - 1);
94
+ notifyIdle();
67
95
  },
68
- release() {
69
- const next = waiters.shift();
70
- if (next) next(true);
71
- else active = Math.max(0, active - 1);
96
+ rejectWaiters() {
97
+ accepting = false;
98
+ while (waiters.length) waiters.shift()();
72
99
  },
73
- clear() {
74
- while (waiters.length) waiters.shift()(false);
100
+ reset() {
101
+ generation += 1;
102
+ accepting = true;
75
103
  active = 0;
104
+ waiters.length = 0;
105
+ while (idleWaiters.length) idleWaiters.shift()();
106
+ },
107
+ whenIdle() {
108
+ if (active === 0) return Promise.resolve();
109
+ return new Promise((resolve) => { idleWaiters.push(resolve); });
76
110
  },
77
111
  get active() { return active; },
78
112
  get waiting() { return waiters.length; },
79
113
  };
80
114
  };
115
+
116
+ export const createWorkTracker = () => {
117
+ let generation = 1;
118
+ let active = 0;
119
+ const idleWaiters = [];
120
+ const notifyIdle = () => {
121
+ if (active !== 0) return;
122
+ while (idleWaiters.length) idleWaiters.shift()();
123
+ };
124
+ return {
125
+ begin() {
126
+ active += 1;
127
+ const gen = generation;
128
+ return () => {
129
+ if (gen !== generation) return;
130
+ active = Math.max(0, active - 1);
131
+ notifyIdle();
132
+ };
133
+ },
134
+ whenIdle() {
135
+ if (active === 0) return Promise.resolve();
136
+ return new Promise((resolve) => { idleWaiters.push(resolve); });
137
+ },
138
+ reset() {
139
+ generation += 1;
140
+ active = 0;
141
+ while (idleWaiters.length) idleWaiters.shift()();
142
+ },
143
+ get active() { return active; },
144
+ };
145
+ };
@@ -3,11 +3,12 @@ import http from 'node:http';
3
3
  import { createApnsProvider } from './apns.js';
4
4
  import { normalizePushRelayOptions, resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
5
5
  import { deriveServerId, verifyP1363 } from './crypto.js';
6
- import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter } from './guard.js';
6
+ import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
7
7
  import { JSON_BODY_BYTES, validateRegisterBody, validateSendBody } from './schema.js';
8
8
  import { createTokenStore } from './store.js';
9
9
 
10
10
  const WINDOW_MS = 60_000;
11
+ const STOP_DEADLINE_MS = 5_500;
11
12
 
12
13
  const sendJson = (response, status, payload, method = 'GET') => {
13
14
  if (response.writableEnded) return;
@@ -25,6 +26,10 @@ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
25
26
  error.code = 'PAYLOAD_TOO_LARGE';
26
27
  fail(error);
27
28
  };
29
+ const drain = () => {
30
+ request.removeListener('data', onData);
31
+ request.resume();
32
+ };
28
33
  const declared = Number(request.headers['content-length']);
29
34
  if (Number.isFinite(declared) && declared > maxBytes) {
30
35
  tooLarge();
@@ -33,15 +38,16 @@ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
33
38
  }
34
39
  const chunks = [];
35
40
  let size = 0;
36
- request.on('data', (chunk) => {
41
+ const onData = (chunk) => {
37
42
  size += chunk.length;
38
43
  if (size > maxBytes) {
44
+ drain();
39
45
  tooLarge();
40
- request.destroy();
41
46
  return;
42
47
  }
43
48
  chunks.push(chunk);
44
- });
49
+ };
50
+ request.on('data', onData);
45
51
  request.on('end', () => succeed(Buffer.concat(chunks)));
46
52
  request.on('error', fail);
47
53
  });
@@ -65,6 +71,7 @@ export const createPushRelayServer = (options = {}) => {
65
71
  }
66
72
  const liveStore = () => {
67
73
  if (!ownedStore) return store;
74
+ if (state === 'stopping') return store;
68
75
  try { store.count(); return store; } catch {
69
76
  store = createTokenStore(config.databasePath);
70
77
  return store;
@@ -75,6 +82,7 @@ export const createPushRelayServer = (options = {}) => {
75
82
  const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
76
83
  const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
77
84
  const inFlight = createInFlightGate(limits.maxInFlight);
85
+ const httpWork = createWorkTracker();
78
86
  const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
79
87
  let server = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
80
88
  const snapshot = () => {
@@ -93,9 +101,12 @@ export const createPushRelayServer = (options = {}) => {
93
101
  const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}`, parsed.sig, parsed.ts);
94
102
  if (authError) return { status: 401, body: { error: authError } };
95
103
  const serverId = deriveServerId(parsed.publicKeyJwk);
104
+ const replayKey = `register.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
105
+ if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
96
106
  const tokens = liveStore();
97
107
  const existing = tokens.get(parsed.token);
98
108
  if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
109
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
99
110
  tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
100
111
  return { status: 200, body: { ok: true } };
101
112
  };
@@ -105,7 +116,7 @@ export const createPushRelayServer = (options = {}) => {
105
116
  const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
106
117
  if (authError) return { status: 401, body: { error: authError } };
107
118
  const serverId = deriveServerId(parsed.publicKeyJwk);
108
- const replayKey = `${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
119
+ const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
109
120
  if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
110
121
  if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
111
122
  if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
@@ -125,7 +136,7 @@ export const createPushRelayServer = (options = {}) => {
125
136
  } catch {
126
137
  return { token, ok: false };
127
138
  } finally {
128
- inFlight.release();
139
+ inFlight.release(acquired);
129
140
  }
130
141
  }));
131
142
  return { status: 200, body: { results } };
@@ -142,10 +153,11 @@ export const createPushRelayServer = (options = {}) => {
142
153
  }
143
154
  const isRegister = pathname === '/v1/push/register-token';
144
155
  const isSend = pathname === '/v1/push/send';
145
- if (request.method !== 'POST' || (!isRegister && !isSend)) { response.writeHead(404); response.end(); return; }
156
+ if (request.method !== 'POST' || (!isRegister && !isSend) || state !== 'running') { response.writeHead(404); response.end(); return; }
146
157
  const ip = resolveClientIp(request);
147
158
  const limiter = isRegister ? registerIpLimit : sendIpLimit;
148
159
  if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
160
+ const endHttp = httpWork.begin();
149
161
  readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
150
162
  let body;
151
163
  try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
@@ -160,7 +172,7 @@ export const createPushRelayServer = (options = {}) => {
160
172
  }).catch((error) => {
161
173
  if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
162
174
  sendJson(response, 500, { error: 'internal' });
163
- });
175
+ }).finally(endHttp);
164
176
  };
165
177
 
166
178
  const start = () => {
@@ -168,6 +180,8 @@ export const createPushRelayServer = (options = {}) => {
168
180
  if (state === 'stopping') return stopPromise.then(() => start());
169
181
  if (startPromise) return startPromise;
170
182
  liveStore();
183
+ inFlight.reset();
184
+ httpWork.reset();
171
185
  if (ownedApns && state === 'stopped') apns = openApns();
172
186
  state = 'starting';
173
187
  const localGeneration = ++generation;
@@ -208,19 +222,36 @@ export const createPushRelayServer = (options = {}) => {
208
222
  state = 'stopping';
209
223
  generation += 1;
210
224
  const localServer = server;
211
- stopPromise = new Promise((resolve) => {
212
- inFlight.clear();
225
+ stopPromise = Promise.resolve().then(async () => {
226
+ inFlight.rejectWaiters();
213
227
  replay.clear();
214
228
  registerIpLimit.clear();
215
229
  sendIpLimit.clear();
216
230
  sendServerLimit.clear();
231
+ let deadlineTimer = null;
232
+ try {
233
+ const closed = new Promise((resolve) => {
234
+ if (!localServer) { resolve(); return; }
235
+ localServer.close(() => resolve());
236
+ try { localServer.closeIdleConnections?.(); } catch { /* ignore */ }
237
+ });
238
+ const httpIdle = httpWork.whenIdle().then(() => {
239
+ try { localServer?.closeIdleConnections?.(); } catch { /* ignore */ }
240
+ });
241
+ const graceful = Promise.all([closed, inFlight.whenIdle(), httpIdle]);
242
+ const deadline = new Promise((resolve) => {
243
+ deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
244
+ });
245
+ const winner = await Promise.race([graceful.then(() => 'graceful'), deadline]);
246
+ if (winner === 'deadline') {
247
+ try { localServer?.closeAllConnections?.(); } catch { /* ignore */ }
248
+ }
249
+ } finally {
250
+ if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
251
+ }
217
252
  try { apns.close?.(); } catch { /* ignore */ }
218
- if (!localServer) return resolve();
219
- localServer.close(() => resolve());
220
- clock.setTimeout(resolve, 100);
221
- }).then(() => {
222
- if (server === localServer) { server = null; state = 'stopped'; }
223
253
  if (ownedStore) try { store.close(); } catch { /* ignore */ }
254
+ if (server === localServer) { server = null; state = 'stopped'; }
224
255
  stopPromise = null;
225
256
  });
226
257
  return stopPromise;