pairling 0.0.1 → 0.1.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/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,1990 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local Pairling push registration and delivery state.
|
|
3
|
+
|
|
4
|
+
This is the Mac-side durable registry for APNs-capable paired devices. The
|
|
5
|
+
normal registry never stores raw APNs tokens; local development APNs sends use a
|
|
6
|
+
separate private secret store so delivery can be proven without leaking tokens
|
|
7
|
+
through status/audit responses.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import uuid
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
30
|
+
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
|
31
|
+
except Exception: # pragma: no cover - provider status still works without sends.
|
|
32
|
+
hashes = None
|
|
33
|
+
serialization = None
|
|
34
|
+
ec = None
|
|
35
|
+
utils = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
CONTRACT_VERSION = "pairling-push-devices-v1"
|
|
39
|
+
DEFAULT_PREFERENCES = {
|
|
40
|
+
"standard_push_enabled": False,
|
|
41
|
+
"live_activity_enabled": False,
|
|
42
|
+
"worker_sentinel_enabled": False,
|
|
43
|
+
"turn_done_enabled": False,
|
|
44
|
+
"push_diagnostics_enabled": True,
|
|
45
|
+
"push_snoozed_until": None,
|
|
46
|
+
"quiet_hours": None,
|
|
47
|
+
}
|
|
48
|
+
APNS_TOKEN_MIN_HEX_CHARS = 16
|
|
49
|
+
APNS_TOKEN_MAX_HEX_CHARS = 4096
|
|
50
|
+
DEFAULT_APNS_TOPIC = "dev.pairling.ios"
|
|
51
|
+
DEFAULT_TEAM_ID = "965AVD34A3"
|
|
52
|
+
APNS_ENVIRONMENTS = {"development", "sandbox", "production"}
|
|
53
|
+
APNS_KEY_ENVIRONMENTS = {"development", "sandbox", "production", "both"}
|
|
54
|
+
|
|
55
|
+
KIND_CATEGORY = {
|
|
56
|
+
"session_attention": "PAIRLING_SESSION_ATTENTION",
|
|
57
|
+
"turn_done": "PAIRLING_TURN_DONE",
|
|
58
|
+
"mac_health": "PAIRLING_MAC_HEALTH",
|
|
59
|
+
"worker_sentinel": "PAIRLING_WORKER_SENTINEL",
|
|
60
|
+
"action_required": "PAIRLING_SESSION_ATTENTION",
|
|
61
|
+
"turn_result": "PAIRLING_TURN_DONE",
|
|
62
|
+
"turn_failed": "PAIRLING_SESSION_ATTENTION",
|
|
63
|
+
"tool_risk": "PAIRLING_SESSION_ATTENTION",
|
|
64
|
+
"mac_route_risk": "PAIRLING_MAC_HEALTH",
|
|
65
|
+
"worker_pressure": "PAIRLING_WORKER_SENTINEL",
|
|
66
|
+
"deploy_result": "PAIRLING_TURN_DONE",
|
|
67
|
+
"push_diagnostic": "PAIRLING_PUSH_DIAGNOSTIC",
|
|
68
|
+
}
|
|
69
|
+
KIND_ALERT = {
|
|
70
|
+
"session_attention": ("Pairling needs input", "A session is waiting for your decision."),
|
|
71
|
+
"turn_done": ("Pairling result ready", "A useful turn result is ready."),
|
|
72
|
+
"mac_health": ("Pairling Mac health", "The paired Mac helper needs attention."),
|
|
73
|
+
"worker_sentinel": ("Pairling worker warning", "Worker automation needs review."),
|
|
74
|
+
"action_required": ("Pairling needs approval", "Review the requested action before work continues."),
|
|
75
|
+
"turn_result": ("Pairling result ready", "A useful turn result is ready."),
|
|
76
|
+
"turn_failed": ("Pairling turn failed", "A turn failed and needs review."),
|
|
77
|
+
"tool_risk": ("Pairling tool risk", "A tool signal needs review."),
|
|
78
|
+
"mac_route_risk": ("Mac route timed out", "The paired Mac route needs attention."),
|
|
79
|
+
"worker_pressure": ("Pairling worker pressure", "Worker or token pressure needs review."),
|
|
80
|
+
"deploy_result": ("Deploy result ready", "A build or deploy result is available."),
|
|
81
|
+
"push_diagnostic": ("Pairling push test", "Push delivery is configured for this device."),
|
|
82
|
+
}
|
|
83
|
+
TIME_SENSITIVE_KINDS = {"session_attention", "mac_health", "worker_sentinel", "action_required", "turn_failed", "tool_risk", "mac_route_risk", "worker_pressure"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PushDispatcherError(Exception):
|
|
87
|
+
def __init__(self, code: str, message: str, status: int = 400):
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
self.code = code
|
|
90
|
+
self.message = message
|
|
91
|
+
self.status = status
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LocalAPNSProvider:
|
|
95
|
+
"""Small APNs HTTP/2 sender for local developer-device validation."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, *, config_path: Path | None = None, now_fn=time.time, run_fn=subprocess.run):
|
|
98
|
+
self.config_path = config_path
|
|
99
|
+
self.now_fn = now_fn
|
|
100
|
+
self.run_fn = run_fn
|
|
101
|
+
|
|
102
|
+
def status(self) -> dict[str, Any]:
|
|
103
|
+
config = self._config()
|
|
104
|
+
return {
|
|
105
|
+
"mode": config["mode"],
|
|
106
|
+
"configured": config["configured"],
|
|
107
|
+
"local_apns_key_configured": config["local_apns_key_configured"],
|
|
108
|
+
"relay_url_configured": bool(config["relay_url"]),
|
|
109
|
+
"relay_url": config["relay_url"] or None,
|
|
110
|
+
"topic": config["topic"],
|
|
111
|
+
"environment": config["environment"],
|
|
112
|
+
"key_environment": config["key_environment"],
|
|
113
|
+
"key_id": config["key_id"] if config["local_apns_key_configured"] else None,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def send_alert(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
token: str,
|
|
120
|
+
event_id: str,
|
|
121
|
+
kind: str,
|
|
122
|
+
route: str,
|
|
123
|
+
title: str | None = None,
|
|
124
|
+
body: str | None = None,
|
|
125
|
+
thread_id: str | None = None,
|
|
126
|
+
pairling_extra: dict[str, Any] | None = None,
|
|
127
|
+
interruption_level: str | None = None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
config = self._config()
|
|
130
|
+
if not config["local_apns_configured"]:
|
|
131
|
+
raise PushDispatcherError("local_apns_not_configured", "local APNs provider is not configured", 503)
|
|
132
|
+
_validate_apns_token(token, "apns_token")
|
|
133
|
+
kind = kind if kind in KIND_CATEGORY else "push_diagnostic"
|
|
134
|
+
default_title, default_body = KIND_ALERT[kind]
|
|
135
|
+
title = _bounded_optional(title, 90) or default_title
|
|
136
|
+
body = _bounded_optional(body, 220) or default_body
|
|
137
|
+
pairling_payload = {
|
|
138
|
+
"event_id": event_id,
|
|
139
|
+
"kind": kind,
|
|
140
|
+
"route": route,
|
|
141
|
+
}
|
|
142
|
+
if isinstance(pairling_extra, dict):
|
|
143
|
+
for key, value in pairling_extra.items():
|
|
144
|
+
if key in {"event_id", "kind", "route"}:
|
|
145
|
+
continue
|
|
146
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
147
|
+
pairling_payload[str(key)[:80]] = _bounded_optional(value, 180) if isinstance(value, str) else value
|
|
148
|
+
payload = {
|
|
149
|
+
"aps": {
|
|
150
|
+
"alert": {"title": title, "body": body},
|
|
151
|
+
"sound": "default",
|
|
152
|
+
"category": KIND_CATEGORY[kind],
|
|
153
|
+
"thread-id": _bounded_optional(thread_id, 120) or _thread_id(kind, route),
|
|
154
|
+
},
|
|
155
|
+
"pairling": pairling_payload,
|
|
156
|
+
}
|
|
157
|
+
level = str(interruption_level or "").strip()
|
|
158
|
+
if level in {"passive", "active", "time-sensitive"}:
|
|
159
|
+
payload["aps"]["interruption-level"] = level
|
|
160
|
+
elif kind in TIME_SENSITIVE_KINDS:
|
|
161
|
+
payload["aps"]["interruption-level"] = "time-sensitive"
|
|
162
|
+
return self._send(
|
|
163
|
+
token=token,
|
|
164
|
+
payload=payload,
|
|
165
|
+
push_type="alert",
|
|
166
|
+
topic=config["topic"],
|
|
167
|
+
priority="10",
|
|
168
|
+
event_id=event_id,
|
|
169
|
+
config=config,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def send_live_activity(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
token: str,
|
|
176
|
+
event_id: str,
|
|
177
|
+
event: str,
|
|
178
|
+
content_state: dict[str, Any],
|
|
179
|
+
stale_seconds: int = 75,
|
|
180
|
+
dismissal_seconds: int = 300,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
config = self._config()
|
|
183
|
+
if not config["local_apns_configured"]:
|
|
184
|
+
raise PushDispatcherError("local_apns_not_configured", "local APNs provider is not configured", 503)
|
|
185
|
+
_validate_apns_token(token, "live_activity_token")
|
|
186
|
+
now = int(self.now_fn())
|
|
187
|
+
activity_event = "end" if event == "end" else "update"
|
|
188
|
+
content = _bounded_content_state(content_state, event_id=event_id, now=now)
|
|
189
|
+
aps: dict[str, Any] = {
|
|
190
|
+
"timestamp": now,
|
|
191
|
+
"event": activity_event,
|
|
192
|
+
"content-state": content,
|
|
193
|
+
}
|
|
194
|
+
if activity_event == "end":
|
|
195
|
+
aps["dismissal-date"] = now + max(0, int(dismissal_seconds))
|
|
196
|
+
else:
|
|
197
|
+
aps["stale-date"] = now + max(30, int(stale_seconds))
|
|
198
|
+
if content["state"] in {"attention", "failed"}:
|
|
199
|
+
aps["alert"] = {
|
|
200
|
+
"title": "Pairling",
|
|
201
|
+
"body": _live_activity_alert_body(content),
|
|
202
|
+
}
|
|
203
|
+
payload = {"aps": aps}
|
|
204
|
+
return self._send(
|
|
205
|
+
token=token,
|
|
206
|
+
payload=payload,
|
|
207
|
+
push_type="liveactivity",
|
|
208
|
+
topic=config["live_activity_topic"],
|
|
209
|
+
priority="10" if content["state"] in {"attention", "tool", "done", "failed"} else "5",
|
|
210
|
+
event_id=event_id,
|
|
211
|
+
config=config,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def probe_credentials(self) -> dict[str, Any]:
|
|
215
|
+
"""Probe APNs auth with a synthetic token without touching device tokens."""
|
|
216
|
+
config = self._config()
|
|
217
|
+
if not config["local_apns_configured"]:
|
|
218
|
+
raise PushDispatcherError("local_apns_not_configured", "local APNs provider is not configured", 503)
|
|
219
|
+
synthetic_token = "0" * 64
|
|
220
|
+
result = self._send(
|
|
221
|
+
token=synthetic_token,
|
|
222
|
+
payload={
|
|
223
|
+
"aps": {
|
|
224
|
+
"alert": {
|
|
225
|
+
"title": "Pairling APNs credential probe",
|
|
226
|
+
"body": "Synthetic-token credential probe.",
|
|
227
|
+
},
|
|
228
|
+
"sound": "default",
|
|
229
|
+
},
|
|
230
|
+
"pairling": {
|
|
231
|
+
"event_id": "apns_credential_probe",
|
|
232
|
+
"kind": "push_diagnostic",
|
|
233
|
+
"route": "pairling://settings/push",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
push_type="alert",
|
|
237
|
+
topic=config["topic"],
|
|
238
|
+
priority="10",
|
|
239
|
+
event_id=f"apns_credential_probe_{int(self.now_fn() * 1000)}",
|
|
240
|
+
config=config,
|
|
241
|
+
)
|
|
242
|
+
authenticated = result.get("apns_status") == 400 and result.get("apns_reason") == "BadDeviceToken"
|
|
243
|
+
return {
|
|
244
|
+
"ok": authenticated,
|
|
245
|
+
"authenticated": authenticated,
|
|
246
|
+
"expected_reason": "BadDeviceToken",
|
|
247
|
+
"synthetic_token_used": True,
|
|
248
|
+
"provider": self.status(),
|
|
249
|
+
"result": {key: value for key, value in result.items() if key != "apns_id"},
|
|
250
|
+
"apns_id_present": bool(result.get("apns_id")),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
def _send(
|
|
254
|
+
self,
|
|
255
|
+
*,
|
|
256
|
+
token: str,
|
|
257
|
+
payload: dict[str, Any],
|
|
258
|
+
push_type: str,
|
|
259
|
+
topic: str,
|
|
260
|
+
priority: str,
|
|
261
|
+
event_id: str,
|
|
262
|
+
config: dict[str, Any],
|
|
263
|
+
) -> dict[str, Any]:
|
|
264
|
+
jwt = self._jwt(config)
|
|
265
|
+
apns_id = str(uuid.uuid4()).upper()
|
|
266
|
+
host = "api.sandbox.push.apple.com" if config["environment"] in {"development", "sandbox"} else "api.push.apple.com"
|
|
267
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
268
|
+
with tempfile.NamedTemporaryFile("wb", delete=False) as payload_file:
|
|
269
|
+
payload_file.write(body)
|
|
270
|
+
payload_file.flush()
|
|
271
|
+
os.fsync(payload_file.fileno())
|
|
272
|
+
payload_path = payload_file.name
|
|
273
|
+
with tempfile.NamedTemporaryFile("w", delete=False) as config_file:
|
|
274
|
+
config_file.write("\n".join([
|
|
275
|
+
"silent",
|
|
276
|
+
"show-error",
|
|
277
|
+
"http2",
|
|
278
|
+
"request = \"POST\"",
|
|
279
|
+
f"url = \"https://{host}/3/device/{token}\"",
|
|
280
|
+
f"header = \"authorization: bearer {jwt}\"",
|
|
281
|
+
f"header = \"apns-topic: {topic}\"",
|
|
282
|
+
f"header = \"apns-push-type: {push_type}\"",
|
|
283
|
+
f"header = \"apns-priority: {priority}\"",
|
|
284
|
+
f"header = \"apns-id: {apns_id}\"",
|
|
285
|
+
f"data-binary = \"@{payload_path}\"",
|
|
286
|
+
"write-out = \"\\n%{http_code}\"",
|
|
287
|
+
"connect-timeout = 10",
|
|
288
|
+
"max-time = 20",
|
|
289
|
+
"",
|
|
290
|
+
]))
|
|
291
|
+
config_file.flush()
|
|
292
|
+
os.fsync(config_file.fileno())
|
|
293
|
+
config_path = config_file.name
|
|
294
|
+
try:
|
|
295
|
+
os.chmod(config_path, 0o600)
|
|
296
|
+
os.chmod(payload_path, 0o600)
|
|
297
|
+
proc = self.run_fn(
|
|
298
|
+
["/usr/bin/curl", "--config", config_path],
|
|
299
|
+
capture_output=True,
|
|
300
|
+
text=True,
|
|
301
|
+
timeout=25,
|
|
302
|
+
)
|
|
303
|
+
finally:
|
|
304
|
+
for path in [config_path, payload_path]:
|
|
305
|
+
try:
|
|
306
|
+
os.unlink(path)
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
response_text, http_status = _split_curl_status(proc.stdout)
|
|
310
|
+
reason = None
|
|
311
|
+
if response_text.strip():
|
|
312
|
+
try:
|
|
313
|
+
parsed = json.loads(response_text)
|
|
314
|
+
reason = parsed.get("reason") if isinstance(parsed, dict) else None
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
reason = response_text.strip()[:200]
|
|
317
|
+
sent = proc.returncode == 0 and http_status == 200
|
|
318
|
+
return {
|
|
319
|
+
"sent": sent,
|
|
320
|
+
"outcome": "sent" if sent else _apns_outcome(http_status, reason, proc.returncode),
|
|
321
|
+
"apns_status": http_status,
|
|
322
|
+
"apns_reason": reason,
|
|
323
|
+
"curl_exit_code": proc.returncode,
|
|
324
|
+
"apns_id": apns_id,
|
|
325
|
+
"retryable": http_status in {429, 500, 503} or proc.returncode != 0,
|
|
326
|
+
"invalid_token": http_status == 410 or reason in {"BadDeviceToken", "DeviceTokenNotForTopic", "Unregistered"},
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
def _jwt(self, config: dict[str, Any]) -> str:
|
|
330
|
+
if not all([hashes, serialization, ec, utils]):
|
|
331
|
+
raise PushDispatcherError("apns_signing_unavailable", "cryptography is required for APNs signing", 500)
|
|
332
|
+
key_path = Path(config["auth_key_path"])
|
|
333
|
+
private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
|
334
|
+
if not isinstance(private_key, ec.EllipticCurvePrivateKey) or private_key.curve.name != "secp256r1":
|
|
335
|
+
raise PushDispatcherError("invalid_apns_key", "APNs auth key must be a P-256 EC private key", 500)
|
|
336
|
+
header = {"alg": "ES256", "kid": config["key_id"]}
|
|
337
|
+
claims = {"iss": config["team_id"], "iat": int(self.now_fn())}
|
|
338
|
+
signing_input = ".".join([
|
|
339
|
+
_b64url(json.dumps(header, separators=(",", ":"), sort_keys=True).encode("utf-8")),
|
|
340
|
+
_b64url(json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")),
|
|
341
|
+
]).encode("ascii")
|
|
342
|
+
signature = private_key.sign(signing_input, ec.ECDSA(hashes.SHA256()))
|
|
343
|
+
r, s = utils.decode_dss_signature(signature)
|
|
344
|
+
raw_signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
|
345
|
+
return signing_input.decode("ascii") + "." + _b64url(raw_signature)
|
|
346
|
+
|
|
347
|
+
def _config(self) -> dict[str, Any]:
|
|
348
|
+
config = self._push_config()
|
|
349
|
+
mode = self._setting(config, "PAIRLING_PUSH_PROVIDER_MODE", "provider_mode", "not_configured")
|
|
350
|
+
relay_url = self._setting(config, "PAIRLING_PUSH_RELAY_URL", "relay_url", "")
|
|
351
|
+
auth_key_path = self._setting(config, "PAIRLING_APNS_AUTH_KEY_PATH", "apns_auth_key_path", "")
|
|
352
|
+
key_id = self._setting(config, "PAIRLING_APNS_KEY_ID", "apns_key_id", "") or _infer_apns_key_id(auth_key_path)
|
|
353
|
+
team_id = self._setting(config, "PAIRLING_APNS_TEAM_ID", "apns_team_id", DEFAULT_TEAM_ID)
|
|
354
|
+
topic = self._setting(config, "PAIRLING_APNS_TOPIC", "apns_topic", DEFAULT_APNS_TOPIC)
|
|
355
|
+
live_activity_topic = self._setting(
|
|
356
|
+
config,
|
|
357
|
+
"PAIRLING_APNS_LIVE_ACTIVITY_TOPIC",
|
|
358
|
+
"apns_live_activity_topic",
|
|
359
|
+
topic + ".push-type.liveactivity",
|
|
360
|
+
)
|
|
361
|
+
environment = _normalize_apns_environment(
|
|
362
|
+
self._setting(config, "PAIRLING_APNS_ENVIRONMENT", "apns_environment", "development")
|
|
363
|
+
)
|
|
364
|
+
key_environment = _normalize_apns_key_environment(
|
|
365
|
+
self._setting(config, "PAIRLING_APNS_KEY_ENVIRONMENT", "apns_key_environment", environment)
|
|
366
|
+
)
|
|
367
|
+
local_ready = (
|
|
368
|
+
mode == "local_apns"
|
|
369
|
+
and bool(auth_key_path)
|
|
370
|
+
and Path(auth_key_path).is_file()
|
|
371
|
+
and bool(key_id)
|
|
372
|
+
and bool(team_id)
|
|
373
|
+
and bool(topic)
|
|
374
|
+
)
|
|
375
|
+
return {
|
|
376
|
+
"mode": mode,
|
|
377
|
+
"relay_url": relay_url,
|
|
378
|
+
"configured": bool(relay_url or mode == "relay" or local_ready),
|
|
379
|
+
"local_apns_configured": local_ready,
|
|
380
|
+
"local_apns_key_configured": local_ready,
|
|
381
|
+
"auth_key_path": auth_key_path,
|
|
382
|
+
"key_id": key_id,
|
|
383
|
+
"team_id": team_id,
|
|
384
|
+
"topic": topic,
|
|
385
|
+
"live_activity_topic": live_activity_topic,
|
|
386
|
+
"environment": environment,
|
|
387
|
+
"key_environment": key_environment,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
def _push_config(self) -> dict[str, Any]:
|
|
391
|
+
if not self.config_path:
|
|
392
|
+
return {}
|
|
393
|
+
try:
|
|
394
|
+
data = json.loads(self.config_path.read_text(encoding="utf-8"))
|
|
395
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
396
|
+
return {}
|
|
397
|
+
push = data.get("push") if isinstance(data, dict) else None
|
|
398
|
+
return push if isinstance(push, dict) else {}
|
|
399
|
+
|
|
400
|
+
def _setting(self, config: dict[str, Any], env_key: str, config_key: str, default: str) -> str:
|
|
401
|
+
value = os.environ.get(env_key)
|
|
402
|
+
if value is None:
|
|
403
|
+
value = config.get(config_key, default)
|
|
404
|
+
return str(value or "").strip()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class RelayEventSender:
|
|
408
|
+
"""Mac-to-relay event client using the paired HMAC secret."""
|
|
409
|
+
|
|
410
|
+
def __init__(self, *, now_fn=time.time, opener=urllib.request.urlopen):
|
|
411
|
+
self.now_fn = now_fn
|
|
412
|
+
self.opener = opener
|
|
413
|
+
|
|
414
|
+
def submit_event(
|
|
415
|
+
self,
|
|
416
|
+
*,
|
|
417
|
+
relay_url: str,
|
|
418
|
+
relay_pair_secret: str,
|
|
419
|
+
event_body: dict[str, Any],
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
body_hash = _b64url(hashlib.sha256(_json_dump(event_body).encode("utf-8")).digest())
|
|
422
|
+
timestamp = int(self.now_fn())
|
|
423
|
+
event_id = str(event_body.get("event_id") or "")
|
|
424
|
+
canonical = f"POST\n/v1/events/submit\n{timestamp}\n{event_id}\n{body_hash}"
|
|
425
|
+
signature = _b64url(hmac.new(
|
|
426
|
+
relay_pair_secret.encode("utf-8"),
|
|
427
|
+
canonical.encode("utf-8"),
|
|
428
|
+
hashlib.sha256,
|
|
429
|
+
).digest())
|
|
430
|
+
payload = {
|
|
431
|
+
"body": event_body,
|
|
432
|
+
"body_hash": body_hash,
|
|
433
|
+
"timestamp": timestamp,
|
|
434
|
+
"event_id": event_id,
|
|
435
|
+
"signature": signature,
|
|
436
|
+
}
|
|
437
|
+
data = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
438
|
+
request = urllib.request.Request(
|
|
439
|
+
relay_url.rstrip("/") + "/v1/events/submit",
|
|
440
|
+
data=data,
|
|
441
|
+
headers={"Content-Type": "application/json"},
|
|
442
|
+
method="POST",
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
with self.opener(request, timeout=20) as response:
|
|
446
|
+
status = int(getattr(response, "status", 200))
|
|
447
|
+
response_body = response.read()
|
|
448
|
+
except urllib.error.HTTPError as exc:
|
|
449
|
+
status = int(exc.code)
|
|
450
|
+
response_body = exc.read()
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
return {
|
|
453
|
+
"accepted": False,
|
|
454
|
+
"outcome": "relay_network_error",
|
|
455
|
+
"relay_status": None,
|
|
456
|
+
"relay_error": type(exc).__name__,
|
|
457
|
+
"retryable": True,
|
|
458
|
+
"invalid_token": False,
|
|
459
|
+
}
|
|
460
|
+
try:
|
|
461
|
+
parsed = json.loads(response_body.decode("utf-8") or "{}")
|
|
462
|
+
except Exception:
|
|
463
|
+
parsed = {}
|
|
464
|
+
ok = 200 <= status < 300 and bool(parsed.get("ok"))
|
|
465
|
+
error = parsed.get("error") if isinstance(parsed.get("error"), dict) else {}
|
|
466
|
+
return {
|
|
467
|
+
"accepted": ok,
|
|
468
|
+
"outcome": str(parsed.get("state") or ("queued" if ok else error.get("code") or f"relay_http_{status}")),
|
|
469
|
+
"state": parsed.get("state"),
|
|
470
|
+
"relay_status": status,
|
|
471
|
+
"relay_error": error.get("code"),
|
|
472
|
+
"retryable": status >= 500,
|
|
473
|
+
"invalid_token": False,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class PairlingPushDispatcher:
|
|
478
|
+
def __init__(
|
|
479
|
+
self,
|
|
480
|
+
registry_path: Path,
|
|
481
|
+
*,
|
|
482
|
+
secret_path: Path | None = None,
|
|
483
|
+
now_fn=time.time,
|
|
484
|
+
apns_sender=None,
|
|
485
|
+
relay_sender=None,
|
|
486
|
+
):
|
|
487
|
+
self.registry_path = registry_path
|
|
488
|
+
self.secret_path = secret_path or registry_path.with_name("push-secrets.json")
|
|
489
|
+
self.now_fn = now_fn
|
|
490
|
+
self.apns_sender = apns_sender or LocalAPNSProvider(config_path=registry_path.parent / "config.json", now_fn=now_fn)
|
|
491
|
+
self.relay_sender = relay_sender or RelayEventSender(now_fn=now_fn)
|
|
492
|
+
self._lock = threading.RLock()
|
|
493
|
+
|
|
494
|
+
def backfill_live_activity_environments(self, *, device_id: str | None = None) -> dict[str, Any]:
|
|
495
|
+
"""Repair older Live Activity token rows that predate explicit APNs environments."""
|
|
496
|
+
data = self._read()
|
|
497
|
+
secrets_payload = self._read_secrets()
|
|
498
|
+
public_updates = 0
|
|
499
|
+
secret_updates = 0
|
|
500
|
+
for device in data.get("devices", []):
|
|
501
|
+
current_device_id = str(device.get("device_id") or "")
|
|
502
|
+
if not current_device_id or (device_id and current_device_id != device_id):
|
|
503
|
+
continue
|
|
504
|
+
secret_device = secrets_payload.setdefault("devices", {}).setdefault(current_device_id, {})
|
|
505
|
+
fallback = _normalize_apns_environment(device.get("apns_environment") or secret_device.get("apns_environment"))
|
|
506
|
+
has_live_activity = bool(device.get("live_activities")) or bool(secret_device.get("live_activity_tokens"))
|
|
507
|
+
if has_live_activity and not device.get("apns_environment"):
|
|
508
|
+
device["apns_environment"] = fallback
|
|
509
|
+
public_updates += 1
|
|
510
|
+
if has_live_activity and not secret_device.get("apns_environment"):
|
|
511
|
+
secret_device["apns_environment"] = fallback
|
|
512
|
+
secret_updates += 1
|
|
513
|
+
for item in device.get("live_activities") or []:
|
|
514
|
+
if isinstance(item, dict) and not item.get("apns_environment"):
|
|
515
|
+
item["apns_environment"] = fallback
|
|
516
|
+
public_updates += 1
|
|
517
|
+
live_tokens = secret_device.get("live_activity_tokens")
|
|
518
|
+
if isinstance(live_tokens, dict):
|
|
519
|
+
for item in live_tokens.values():
|
|
520
|
+
if isinstance(item, dict) and not item.get("apns_environment"):
|
|
521
|
+
item["apns_environment"] = fallback
|
|
522
|
+
secret_updates += 1
|
|
523
|
+
if public_updates:
|
|
524
|
+
data["updated_at"] = self.now_fn()
|
|
525
|
+
self._write(data)
|
|
526
|
+
if secret_updates:
|
|
527
|
+
self._write_secrets(secrets_payload)
|
|
528
|
+
return {
|
|
529
|
+
"ok": True,
|
|
530
|
+
"public_updates": public_updates,
|
|
531
|
+
"secret_updates": secret_updates,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
def status(self, *, device_id: str | None = None) -> dict[str, Any]:
|
|
535
|
+
payload = self._read()
|
|
536
|
+
devices = payload.get("devices", [])
|
|
537
|
+
outbox = payload.get("delivery_outbox", [])
|
|
538
|
+
deliveries = payload.get("deliveries", [])
|
|
539
|
+
if device_id:
|
|
540
|
+
devices = [item for item in devices if item.get("device_id") == device_id]
|
|
541
|
+
outbox = [item for item in outbox if item.get("device_id") == device_id]
|
|
542
|
+
deliveries = [item for item in deliveries if item.get("device_id") == device_id]
|
|
543
|
+
return {
|
|
544
|
+
"ok": True,
|
|
545
|
+
"contract_version": CONTRACT_VERSION,
|
|
546
|
+
"provider": self._provider_status(),
|
|
547
|
+
"devices": devices,
|
|
548
|
+
"delivery_outbox": outbox[-50:],
|
|
549
|
+
"deliveries": deliveries[-100:],
|
|
550
|
+
"events": payload.get("events", [])[-20:],
|
|
551
|
+
"updated_at": payload.get("updated_at"),
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
def update_preferences(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
555
|
+
device_id = _nonempty(device_id, "device_id")
|
|
556
|
+
data = self._read()
|
|
557
|
+
device = self._device_record(data, device_id, create=True)
|
|
558
|
+
now = self.now_fn()
|
|
559
|
+
device.setdefault("created_at", now)
|
|
560
|
+
device["last_registered_at"] = now
|
|
561
|
+
|
|
562
|
+
relay_device_id = payload.get("relay_device_id")
|
|
563
|
+
if isinstance(relay_device_id, str):
|
|
564
|
+
device["relay_device_id"] = relay_device_id.strip() or None
|
|
565
|
+
|
|
566
|
+
apns_environment = _normalize_apns_environment(payload.get("apns_environment") or device.get("apns_environment"))
|
|
567
|
+
if apns_environment:
|
|
568
|
+
device["apns_environment"] = apns_environment
|
|
569
|
+
|
|
570
|
+
apns_token = str(payload.get("apns_token") or "").strip().lower()
|
|
571
|
+
if apns_token:
|
|
572
|
+
_validate_apns_token(apns_token, "apns_token")
|
|
573
|
+
device["apns_token_hash"] = _sha256_hex(apns_token)
|
|
574
|
+
device["apns_environment"] = apns_environment
|
|
575
|
+
device["apns_registered_at"] = now
|
|
576
|
+
secrets_payload = self._read_secrets()
|
|
577
|
+
secret_device = secrets_payload.setdefault("devices", {}).setdefault(device_id, {})
|
|
578
|
+
secret_device["apns_token"] = apns_token
|
|
579
|
+
secret_device["apns_token_hash"] = device["apns_token_hash"]
|
|
580
|
+
secret_device["apns_environment"] = apns_environment
|
|
581
|
+
secret_device["updated_at"] = now
|
|
582
|
+
self._write_secrets(secrets_payload)
|
|
583
|
+
|
|
584
|
+
relay_pair_secret = str(payload.get("relay_pair_secret") or "").strip()
|
|
585
|
+
if relay_pair_secret:
|
|
586
|
+
relay_secret_ref = str(payload.get("relay_pair_secret_ref") or _sha256_hex(relay_pair_secret)).strip()
|
|
587
|
+
mac_install_id = str(payload.get("mac_install_id") or os.environ.get("PAIRLING_MAC_INSTALL_ID") or "").strip()
|
|
588
|
+
secrets_payload = self._read_secrets()
|
|
589
|
+
secret_device = secrets_payload.setdefault("devices", {}).setdefault(device_id, {})
|
|
590
|
+
secret_device["relay_pair_secret"] = relay_pair_secret
|
|
591
|
+
secret_device["relay_pair_secret_ref"] = relay_secret_ref
|
|
592
|
+
secret_device["relay_device_id"] = device.get("relay_device_id")
|
|
593
|
+
secret_device["mac_install_id"] = mac_install_id
|
|
594
|
+
secret_device["updated_at"] = now
|
|
595
|
+
device["relay_pair_secret_ref"] = relay_secret_ref
|
|
596
|
+
if mac_install_id:
|
|
597
|
+
device["mac_install_id"] = mac_install_id
|
|
598
|
+
self._write_secrets(secrets_payload)
|
|
599
|
+
|
|
600
|
+
for key in DEFAULT_PREFERENCES:
|
|
601
|
+
if key in payload:
|
|
602
|
+
if key == "quiet_hours":
|
|
603
|
+
device[key] = _quiet_hours(payload[key])
|
|
604
|
+
elif key == "push_snoozed_until":
|
|
605
|
+
device[key] = _optional_epoch(payload[key])
|
|
606
|
+
else:
|
|
607
|
+
device[key] = bool(payload[key])
|
|
608
|
+
|
|
609
|
+
data["updated_at"] = now
|
|
610
|
+
self._append_event(data, {
|
|
611
|
+
"event": "push.preferences.updated",
|
|
612
|
+
"device_id": device_id,
|
|
613
|
+
"outcome": "ok",
|
|
614
|
+
})
|
|
615
|
+
self._write(data)
|
|
616
|
+
return {"ok": True, "device": device, "provider": self._provider_status()}
|
|
617
|
+
|
|
618
|
+
def record_event(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
619
|
+
"""Record and dispatch a production standard APNs alert event."""
|
|
620
|
+
return self._record_alert_delivery(
|
|
621
|
+
device_id=device_id,
|
|
622
|
+
payload=payload,
|
|
623
|
+
audit_event="push.event",
|
|
624
|
+
default_event_prefix="push",
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def record_test(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
628
|
+
return self._record_alert_delivery(
|
|
629
|
+
device_id=device_id,
|
|
630
|
+
payload=payload,
|
|
631
|
+
audit_event="push.test",
|
|
632
|
+
default_event_prefix="push_test",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
def _record_alert_delivery(
|
|
636
|
+
self,
|
|
637
|
+
*,
|
|
638
|
+
device_id: str,
|
|
639
|
+
payload: dict[str, Any],
|
|
640
|
+
audit_event: str,
|
|
641
|
+
default_event_prefix: str,
|
|
642
|
+
) -> dict[str, Any]:
|
|
643
|
+
device_id = _nonempty(device_id, "device_id")
|
|
644
|
+
data = self._read()
|
|
645
|
+
device = self._device_record(data, device_id, create=True)
|
|
646
|
+
kind = str(payload.get("kind") or "push_diagnostic")[:80]
|
|
647
|
+
route = str(payload.get("route") or "pairling://settings/push")[:300]
|
|
648
|
+
title = str(payload.get("title") or "")[:90] or None
|
|
649
|
+
body = str(payload.get("body") or "")[:220] or None
|
|
650
|
+
thread_id = str(payload.get("thread_id") or "")[:120] or None
|
|
651
|
+
interruption_level = str(payload.get("interruption_level") or "").strip()[:40] or None
|
|
652
|
+
pairling_extra = _alert_pairling_extra(payload)
|
|
653
|
+
provider = self._provider_status()
|
|
654
|
+
event_id = str(payload.get("event_id") or f"{default_event_prefix}_{int(self.now_fn() * 1000)}")[:120]
|
|
655
|
+
metadata = _outbox_metadata_from_payload(payload, sent_at=self.now_fn())
|
|
656
|
+
idempotent = self._idempotent_delivery(
|
|
657
|
+
data,
|
|
658
|
+
event_id=event_id,
|
|
659
|
+
push_type="alert",
|
|
660
|
+
provider=provider,
|
|
661
|
+
audit_event=audit_event,
|
|
662
|
+
)
|
|
663
|
+
if idempotent:
|
|
664
|
+
return idempotent
|
|
665
|
+
sent = False
|
|
666
|
+
outcome = "not_configured"
|
|
667
|
+
delivery_extra: dict[str, Any] = {}
|
|
668
|
+
token_hash = device.get("apns_token_hash")
|
|
669
|
+
if not _alert_enabled_for_device(device, kind):
|
|
670
|
+
outcome = "disabled"
|
|
671
|
+
elif kind != "push_diagnostic" and _future_epoch(device.get("push_snoozed_until"), self.now_fn()):
|
|
672
|
+
outcome = "snoozed"
|
|
673
|
+
elif provider["mode"] == "local_apns" and provider["configured"]:
|
|
674
|
+
secret = self._secret_for_device(device_id)
|
|
675
|
+
token = secret.get("apns_token")
|
|
676
|
+
token_hash = secret.get("apns_token_hash") or token_hash
|
|
677
|
+
if not token:
|
|
678
|
+
outcome = "missing_token"
|
|
679
|
+
elif _key_environment_mismatch(provider):
|
|
680
|
+
outcome = "key_environment_mismatch"
|
|
681
|
+
delivery_extra = {
|
|
682
|
+
"provider_environment": _provider_environment(provider),
|
|
683
|
+
"key_environment": _key_environment(provider),
|
|
684
|
+
"retryable": False,
|
|
685
|
+
"invalid_token": False,
|
|
686
|
+
}
|
|
687
|
+
elif _token_environment(secret.get("apns_environment") or device.get("apns_environment")) != _provider_environment(provider):
|
|
688
|
+
outcome = "token_environment_mismatch"
|
|
689
|
+
delivery_extra = {
|
|
690
|
+
"provider_environment": _provider_environment(provider),
|
|
691
|
+
"token_environment": _token_environment(secret.get("apns_environment") or device.get("apns_environment")),
|
|
692
|
+
"retryable": False,
|
|
693
|
+
"invalid_token": False,
|
|
694
|
+
}
|
|
695
|
+
else:
|
|
696
|
+
outbox_row = self._upsert_outbox(
|
|
697
|
+
data,
|
|
698
|
+
event_id=event_id,
|
|
699
|
+
device_id=device_id,
|
|
700
|
+
push_type="alert",
|
|
701
|
+
route=route,
|
|
702
|
+
kind=kind,
|
|
703
|
+
token_hash=token_hash,
|
|
704
|
+
provider=provider,
|
|
705
|
+
state="sending",
|
|
706
|
+
increment_attempt=True,
|
|
707
|
+
metadata=metadata,
|
|
708
|
+
)
|
|
709
|
+
data["updated_at"] = self.now_fn()
|
|
710
|
+
self._write(data)
|
|
711
|
+
apns_result = self.apns_sender.send_alert(
|
|
712
|
+
token=token,
|
|
713
|
+
event_id=event_id,
|
|
714
|
+
kind=kind,
|
|
715
|
+
route=route,
|
|
716
|
+
title=title,
|
|
717
|
+
body=body,
|
|
718
|
+
thread_id=thread_id,
|
|
719
|
+
pairling_extra=pairling_extra,
|
|
720
|
+
interruption_level=interruption_level,
|
|
721
|
+
)
|
|
722
|
+
sent = bool(apns_result.get("sent"))
|
|
723
|
+
outcome = str(apns_result.get("outcome") or ("sent" if sent else "failed"))
|
|
724
|
+
delivery_extra = {k: v for k, v in apns_result.items() if k != "sent"}
|
|
725
|
+
if apns_result.get("invalid_token"):
|
|
726
|
+
device["last_delivery_error"] = outcome
|
|
727
|
+
self._complete_outbox(
|
|
728
|
+
data,
|
|
729
|
+
outbox_row,
|
|
730
|
+
sent=sent,
|
|
731
|
+
outcome=outcome,
|
|
732
|
+
delivery_extra=delivery_extra,
|
|
733
|
+
)
|
|
734
|
+
elif provider["mode"] == "relay" and provider["configured"]:
|
|
735
|
+
sent_at = float(self.now_fn())
|
|
736
|
+
metadata = _outbox_metadata_from_payload(payload, sent_at=sent_at)
|
|
737
|
+
relay_extra = self._submit_relay_event(
|
|
738
|
+
device_id=device_id,
|
|
739
|
+
device=device,
|
|
740
|
+
event_id=event_id,
|
|
741
|
+
kind=kind,
|
|
742
|
+
route=route,
|
|
743
|
+
push_type="alert",
|
|
744
|
+
provider=provider,
|
|
745
|
+
extra_body={
|
|
746
|
+
"title": title,
|
|
747
|
+
"body": body,
|
|
748
|
+
"thread_id": thread_id,
|
|
749
|
+
"interruption_level": interruption_level,
|
|
750
|
+
"pairling_extra": pairling_extra,
|
|
751
|
+
**metadata,
|
|
752
|
+
},
|
|
753
|
+
)
|
|
754
|
+
sent = bool(relay_extra.pop("accepted", False))
|
|
755
|
+
outcome = str(relay_extra.pop("outcome", "queued" if sent else "relay_failed"))
|
|
756
|
+
delivery_extra = relay_extra
|
|
757
|
+
device["last_delivery_error"] = None if sent else outcome
|
|
758
|
+
outbox_row = self._find_outbox(data, event_id=event_id, push_type="alert")
|
|
759
|
+
if outbox_row is None:
|
|
760
|
+
outbox_row = self._upsert_outbox(
|
|
761
|
+
data,
|
|
762
|
+
event_id=event_id,
|
|
763
|
+
device_id=device_id,
|
|
764
|
+
push_type="alert",
|
|
765
|
+
route=route,
|
|
766
|
+
kind=kind,
|
|
767
|
+
token_hash=token_hash,
|
|
768
|
+
provider=provider,
|
|
769
|
+
state=self._state_for_outcome(sent=sent, outcome=outcome, delivery_extra=delivery_extra),
|
|
770
|
+
increment_attempt=False,
|
|
771
|
+
metadata=metadata,
|
|
772
|
+
)
|
|
773
|
+
self._complete_outbox(
|
|
774
|
+
data,
|
|
775
|
+
outbox_row,
|
|
776
|
+
sent=sent,
|
|
777
|
+
outcome=outcome,
|
|
778
|
+
delivery_extra=delivery_extra,
|
|
779
|
+
)
|
|
780
|
+
event = {
|
|
781
|
+
"event": audit_event,
|
|
782
|
+
"event_id": event_id,
|
|
783
|
+
"device_id": device_id,
|
|
784
|
+
"kind": kind,
|
|
785
|
+
"route": route,
|
|
786
|
+
"sent": sent,
|
|
787
|
+
"outcome": outcome,
|
|
788
|
+
"provider_mode": provider["mode"],
|
|
789
|
+
"provider_environment": provider.get("environment"),
|
|
790
|
+
**delivery_extra,
|
|
791
|
+
}
|
|
792
|
+
self._append_event(data, event)
|
|
793
|
+
data["updated_at"] = self.now_fn()
|
|
794
|
+
self._write(data)
|
|
795
|
+
return {"ok": sent, "delivery": event, "provider": provider}
|
|
796
|
+
|
|
797
|
+
def record_live_activity_token(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
798
|
+
device_id = _nonempty(device_id, "device_id")
|
|
799
|
+
token = _nonempty(str(payload.get("live_activity_token") or ""), "live_activity_token").lower()
|
|
800
|
+
_validate_apns_token(token, "live_activity_token")
|
|
801
|
+
session_id = _nonempty(str(payload.get("session_id") or ""), "session_id")[:120]
|
|
802
|
+
activity_id = str(payload.get("activity_id") or "")[:160] or None
|
|
803
|
+
apns_environment = _normalize_apns_environment(payload.get("apns_environment"))
|
|
804
|
+
now = self.now_fn()
|
|
805
|
+
data = self._read()
|
|
806
|
+
device = self._device_record(data, device_id, create=True)
|
|
807
|
+
if not apns_environment:
|
|
808
|
+
apns_environment = _normalize_apns_environment(device.get("apns_environment"))
|
|
809
|
+
device["apns_environment"] = apns_environment
|
|
810
|
+
token_hash = _sha256_hex(token)
|
|
811
|
+
activities = device.setdefault("live_activities", [])
|
|
812
|
+
activities.append({
|
|
813
|
+
"session_id": session_id,
|
|
814
|
+
"activity_id": activity_id,
|
|
815
|
+
"token_hash": token_hash,
|
|
816
|
+
"apns_environment": apns_environment,
|
|
817
|
+
"registered_at": now,
|
|
818
|
+
"invalidated_at": None,
|
|
819
|
+
})
|
|
820
|
+
del activities[:-20]
|
|
821
|
+
secrets_payload = self._read_secrets()
|
|
822
|
+
secret_device = secrets_payload.setdefault("devices", {}).setdefault(device_id, {})
|
|
823
|
+
live_tokens = secret_device.setdefault("live_activity_tokens", {})
|
|
824
|
+
live_tokens[session_id] = {
|
|
825
|
+
"token": token,
|
|
826
|
+
"token_hash": token_hash,
|
|
827
|
+
"activity_id": activity_id,
|
|
828
|
+
"apns_environment": apns_environment,
|
|
829
|
+
"updated_at": now,
|
|
830
|
+
}
|
|
831
|
+
self._write_secrets(secrets_payload)
|
|
832
|
+
self._append_event(data, {
|
|
833
|
+
"event": "push.live_activity_token.registered",
|
|
834
|
+
"device_id": device_id,
|
|
835
|
+
"session_id": session_id,
|
|
836
|
+
"activity_id": activity_id,
|
|
837
|
+
"token_hash": token_hash,
|
|
838
|
+
"outcome": "ok",
|
|
839
|
+
})
|
|
840
|
+
data["updated_at"] = now
|
|
841
|
+
self._write(data)
|
|
842
|
+
return {"ok": True, "device": device, "provider": self._provider_status()}
|
|
843
|
+
|
|
844
|
+
def record_live_activity_event(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
845
|
+
"""Record and dispatch a bounded production Live Activity update/end event."""
|
|
846
|
+
return self._record_live_activity_delivery(
|
|
847
|
+
device_id=device_id,
|
|
848
|
+
payload=payload,
|
|
849
|
+
audit_event="push.live_activity_event",
|
|
850
|
+
default_event_prefix="la",
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def record_live_activity_test(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
854
|
+
return self._record_live_activity_delivery(
|
|
855
|
+
device_id=device_id,
|
|
856
|
+
payload=payload,
|
|
857
|
+
audit_event="push.live_activity_test",
|
|
858
|
+
default_event_prefix="la_test",
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
def _record_live_activity_delivery(
|
|
862
|
+
self,
|
|
863
|
+
*,
|
|
864
|
+
device_id: str,
|
|
865
|
+
payload: dict[str, Any],
|
|
866
|
+
audit_event: str,
|
|
867
|
+
default_event_prefix: str,
|
|
868
|
+
) -> dict[str, Any]:
|
|
869
|
+
device_id = _nonempty(device_id, "device_id")
|
|
870
|
+
self.backfill_live_activity_environments(device_id=device_id)
|
|
871
|
+
session_id = _nonempty(str(payload.get("session_id") or ""), "session_id")[:120]
|
|
872
|
+
activity_event = str(payload.get("event") or "update").strip()
|
|
873
|
+
if activity_event not in {"update", "end"}:
|
|
874
|
+
raise PushDispatcherError("invalid_live_activity_event", "event must be update or end")
|
|
875
|
+
event_id = str(payload.get("event_id") or f"{default_event_prefix}_{int(self.now_fn() * 1000)}")[:120]
|
|
876
|
+
provider = self._provider_status()
|
|
877
|
+
idempotent = self._idempotent_delivery(
|
|
878
|
+
data := self._read(),
|
|
879
|
+
event_id=event_id,
|
|
880
|
+
push_type="liveactivity",
|
|
881
|
+
provider=provider,
|
|
882
|
+
audit_event=audit_event,
|
|
883
|
+
)
|
|
884
|
+
if idempotent:
|
|
885
|
+
return idempotent
|
|
886
|
+
sent = False
|
|
887
|
+
outcome = "not_configured"
|
|
888
|
+
delivery_extra: dict[str, Any] = {}
|
|
889
|
+
device = self._device_record(data, device_id, create=True)
|
|
890
|
+
token_hash = None
|
|
891
|
+
content_state = _live_activity_content_state(payload, activity_event=activity_event, event_id=event_id, now=int(self.now_fn()))
|
|
892
|
+
bounded_content_state = _bounded_content_state(content_state, event_id=event_id, now=int(self.now_fn()))
|
|
893
|
+
metadata = _live_activity_outbox_metadata(payload, content_state=bounded_content_state, sent_at=float(self.now_fn()))
|
|
894
|
+
if not device.get("live_activity_enabled"):
|
|
895
|
+
outcome = "disabled"
|
|
896
|
+
elif provider["mode"] == "local_apns" and provider["configured"]:
|
|
897
|
+
token_record = self._secret_for_device(device_id).get("live_activity_tokens", {}).get(session_id)
|
|
898
|
+
token = token_record.get("token") if isinstance(token_record, dict) else None
|
|
899
|
+
token_hash = token_record.get("token_hash") if isinstance(token_record, dict) else None
|
|
900
|
+
if not token:
|
|
901
|
+
outcome = "missing_live_activity_token"
|
|
902
|
+
elif _key_environment_mismatch(provider):
|
|
903
|
+
outcome = "key_environment_mismatch"
|
|
904
|
+
delivery_extra = {
|
|
905
|
+
"provider_environment": _provider_environment(provider),
|
|
906
|
+
"key_environment": _key_environment(provider),
|
|
907
|
+
"retryable": False,
|
|
908
|
+
"invalid_token": False,
|
|
909
|
+
}
|
|
910
|
+
elif _token_environment(token_record.get("apns_environment")) != _provider_environment(provider):
|
|
911
|
+
outcome = "token_environment_mismatch"
|
|
912
|
+
delivery_extra = {
|
|
913
|
+
"provider_environment": _provider_environment(provider),
|
|
914
|
+
"token_environment": _token_environment(token_record.get("apns_environment")),
|
|
915
|
+
"retryable": False,
|
|
916
|
+
"invalid_token": False,
|
|
917
|
+
}
|
|
918
|
+
else:
|
|
919
|
+
outbox_row = self._upsert_outbox(
|
|
920
|
+
data,
|
|
921
|
+
event_id=event_id,
|
|
922
|
+
device_id=device_id,
|
|
923
|
+
push_type="liveactivity",
|
|
924
|
+
route="pairling://session/" + session_id,
|
|
925
|
+
kind="live_activity_" + activity_event,
|
|
926
|
+
token_hash=token_hash,
|
|
927
|
+
provider=provider,
|
|
928
|
+
state="sending",
|
|
929
|
+
increment_attempt=True,
|
|
930
|
+
metadata=metadata,
|
|
931
|
+
)
|
|
932
|
+
data["updated_at"] = self.now_fn()
|
|
933
|
+
self._write(data)
|
|
934
|
+
apns_result = self.apns_sender.send_live_activity(
|
|
935
|
+
token=token,
|
|
936
|
+
event_id=event_id,
|
|
937
|
+
event=activity_event,
|
|
938
|
+
content_state=content_state,
|
|
939
|
+
stale_seconds=int(payload.get("stale_seconds") or 75),
|
|
940
|
+
dismissal_seconds=int(payload.get("dismissal_seconds") or 300),
|
|
941
|
+
)
|
|
942
|
+
sent = bool(apns_result.get("sent"))
|
|
943
|
+
outcome = str(apns_result.get("outcome") or ("sent" if sent else "failed"))
|
|
944
|
+
delivery_extra = {k: v for k, v in apns_result.items() if k != "sent"}
|
|
945
|
+
if apns_result.get("invalid_token"):
|
|
946
|
+
self._mark_live_activity_invalid(device, session_id, event_id, outcome)
|
|
947
|
+
self._complete_outbox(
|
|
948
|
+
data,
|
|
949
|
+
outbox_row,
|
|
950
|
+
sent=sent,
|
|
951
|
+
outcome=outcome,
|
|
952
|
+
delivery_extra=delivery_extra,
|
|
953
|
+
)
|
|
954
|
+
elif provider["mode"] == "relay" and provider["configured"]:
|
|
955
|
+
sent_at = float(self.now_fn())
|
|
956
|
+
metadata = _live_activity_outbox_metadata(payload, content_state=bounded_content_state, sent_at=sent_at)
|
|
957
|
+
relay_extra = self._submit_relay_event(
|
|
958
|
+
device_id=device_id,
|
|
959
|
+
device=device,
|
|
960
|
+
event_id=event_id,
|
|
961
|
+
kind="live_activity_" + activity_event,
|
|
962
|
+
route="pairling://session/" + session_id,
|
|
963
|
+
push_type="liveactivity",
|
|
964
|
+
provider=provider,
|
|
965
|
+
extra_body={
|
|
966
|
+
"session_id": session_id,
|
|
967
|
+
"activity_event": activity_event,
|
|
968
|
+
"content_state": bounded_content_state,
|
|
969
|
+
"stale_seconds": _bounded_int(payload.get("stale_seconds"), default=75, minimum=30, maximum=3600),
|
|
970
|
+
"dismissal_seconds": _bounded_int(payload.get("dismissal_seconds"), default=300, minimum=0, maximum=86400),
|
|
971
|
+
**metadata,
|
|
972
|
+
},
|
|
973
|
+
)
|
|
974
|
+
sent = bool(relay_extra.pop("accepted", False))
|
|
975
|
+
outcome = str(relay_extra.pop("outcome", "queued" if sent else "relay_failed"))
|
|
976
|
+
delivery_extra = relay_extra
|
|
977
|
+
device["last_delivery_error"] = None if sent else outcome
|
|
978
|
+
outbox_row = self._find_outbox(data, event_id=event_id, push_type="liveactivity")
|
|
979
|
+
if outbox_row is None:
|
|
980
|
+
outbox_row = self._upsert_outbox(
|
|
981
|
+
data,
|
|
982
|
+
event_id=event_id,
|
|
983
|
+
device_id=device_id,
|
|
984
|
+
push_type="liveactivity",
|
|
985
|
+
route="pairling://session/" + session_id,
|
|
986
|
+
kind="live_activity_" + activity_event,
|
|
987
|
+
token_hash=token_hash,
|
|
988
|
+
provider=provider,
|
|
989
|
+
state=self._state_for_outcome(sent=sent, outcome=outcome, delivery_extra=delivery_extra),
|
|
990
|
+
increment_attempt=False,
|
|
991
|
+
metadata=metadata,
|
|
992
|
+
)
|
|
993
|
+
self._complete_outbox(
|
|
994
|
+
data,
|
|
995
|
+
outbox_row,
|
|
996
|
+
sent=sent,
|
|
997
|
+
outcome=outcome,
|
|
998
|
+
delivery_extra=delivery_extra,
|
|
999
|
+
)
|
|
1000
|
+
_apply_outbox_metadata(outbox_row, _live_activity_outbox_metadata(payload, content_state=content_state, sent_at=float(self.now_fn()), apns_outcome=outcome))
|
|
1001
|
+
event = {
|
|
1002
|
+
"event": audit_event,
|
|
1003
|
+
"event_id": event_id,
|
|
1004
|
+
"device_id": device_id,
|
|
1005
|
+
"session_id": session_id,
|
|
1006
|
+
"activity_event": activity_event,
|
|
1007
|
+
"sent": sent,
|
|
1008
|
+
"outcome": outcome,
|
|
1009
|
+
"provider_mode": provider["mode"],
|
|
1010
|
+
"provider_environment": provider.get("environment"),
|
|
1011
|
+
**delivery_extra,
|
|
1012
|
+
}
|
|
1013
|
+
self._append_event(data, event)
|
|
1014
|
+
data["updated_at"] = self.now_fn()
|
|
1015
|
+
self._write(data)
|
|
1016
|
+
return {"ok": sent, "delivery": event, "provider": provider}
|
|
1017
|
+
|
|
1018
|
+
def _idempotent_delivery(
|
|
1019
|
+
self,
|
|
1020
|
+
data: dict[str, Any],
|
|
1021
|
+
*,
|
|
1022
|
+
event_id: str,
|
|
1023
|
+
push_type: str,
|
|
1024
|
+
provider: dict[str, Any],
|
|
1025
|
+
audit_event: str | None = None,
|
|
1026
|
+
) -> dict[str, Any] | None:
|
|
1027
|
+
row = self._find_outbox(data, event_id=event_id, push_type=push_type)
|
|
1028
|
+
if not row:
|
|
1029
|
+
return None
|
|
1030
|
+
state = row.get("state")
|
|
1031
|
+
if state == "pending" and float(row.get("next_attempt_at") or 0) <= self.now_fn():
|
|
1032
|
+
return None
|
|
1033
|
+
delivery = self._latest_delivery(data, event_id=event_id, push_type=push_type) or {}
|
|
1034
|
+
response = {
|
|
1035
|
+
**delivery,
|
|
1036
|
+
"event": audit_event or ("push.live_activity_test" if push_type == "liveactivity" else "push.test"),
|
|
1037
|
+
"event_id": event_id,
|
|
1038
|
+
"device_id": row.get("device_id"),
|
|
1039
|
+
"sent": state == "sent",
|
|
1040
|
+
"outcome": row.get("last_outcome") or delivery.get("outcome") or state,
|
|
1041
|
+
"provider_mode": row.get("provider_mode"),
|
|
1042
|
+
"provider_environment": row.get("provider_environment"),
|
|
1043
|
+
"idempotent": True,
|
|
1044
|
+
}
|
|
1045
|
+
return {"ok": state == "sent", "delivery": response, "provider": provider}
|
|
1046
|
+
|
|
1047
|
+
def _find_outbox(self, data: dict[str, Any], *, event_id: str, push_type: str) -> dict[str, Any] | None:
|
|
1048
|
+
for item in data.setdefault("delivery_outbox", []):
|
|
1049
|
+
if item.get("event_id") == event_id and item.get("push_type") == push_type:
|
|
1050
|
+
return item
|
|
1051
|
+
return None
|
|
1052
|
+
|
|
1053
|
+
def _latest_delivery(self, data: dict[str, Any], *, event_id: str, push_type: str) -> dict[str, Any] | None:
|
|
1054
|
+
for item in reversed(data.setdefault("deliveries", [])):
|
|
1055
|
+
if item.get("event_id") == event_id and item.get("push_type") == push_type:
|
|
1056
|
+
return item
|
|
1057
|
+
return None
|
|
1058
|
+
|
|
1059
|
+
def _upsert_outbox(
|
|
1060
|
+
self,
|
|
1061
|
+
data: dict[str, Any],
|
|
1062
|
+
*,
|
|
1063
|
+
event_id: str,
|
|
1064
|
+
device_id: str,
|
|
1065
|
+
push_type: str,
|
|
1066
|
+
route: str,
|
|
1067
|
+
kind: str,
|
|
1068
|
+
token_hash: str | None,
|
|
1069
|
+
provider: dict[str, Any],
|
|
1070
|
+
state: str,
|
|
1071
|
+
increment_attempt: bool,
|
|
1072
|
+
metadata: dict[str, Any] | None = None,
|
|
1073
|
+
) -> dict[str, Any]:
|
|
1074
|
+
now = self.now_fn()
|
|
1075
|
+
row = self._find_outbox(data, event_id=event_id, push_type=push_type)
|
|
1076
|
+
if row is None:
|
|
1077
|
+
row = {
|
|
1078
|
+
"event_id": event_id,
|
|
1079
|
+
"device_id": device_id,
|
|
1080
|
+
"push_type": push_type,
|
|
1081
|
+
"kind": kind,
|
|
1082
|
+
"route": route,
|
|
1083
|
+
"token_hash": token_hash,
|
|
1084
|
+
"state": "pending",
|
|
1085
|
+
"next_attempt_at": now,
|
|
1086
|
+
"attempt_count": 0,
|
|
1087
|
+
"created_at": now,
|
|
1088
|
+
"updated_at": now,
|
|
1089
|
+
"provider_mode": provider.get("mode"),
|
|
1090
|
+
"provider_environment": provider.get("environment"),
|
|
1091
|
+
"key_environment": provider.get("key_environment"),
|
|
1092
|
+
"last_outcome": None,
|
|
1093
|
+
}
|
|
1094
|
+
data.setdefault("delivery_outbox", []).append(row)
|
|
1095
|
+
row["state"] = state
|
|
1096
|
+
row["updated_at"] = now
|
|
1097
|
+
row["token_hash"] = token_hash or row.get("token_hash")
|
|
1098
|
+
row["provider_mode"] = provider.get("mode")
|
|
1099
|
+
row["provider_environment"] = provider.get("environment")
|
|
1100
|
+
row["key_environment"] = provider.get("key_environment")
|
|
1101
|
+
if metadata:
|
|
1102
|
+
for key in [
|
|
1103
|
+
"source",
|
|
1104
|
+
"phase",
|
|
1105
|
+
"project",
|
|
1106
|
+
"observed_at",
|
|
1107
|
+
"sent_at",
|
|
1108
|
+
"collapse_id",
|
|
1109
|
+
"freshness_seconds_at_send",
|
|
1110
|
+
"content_state_hash",
|
|
1111
|
+
"apns_outcome",
|
|
1112
|
+
]:
|
|
1113
|
+
if key in metadata:
|
|
1114
|
+
row[key] = metadata[key]
|
|
1115
|
+
if increment_attempt:
|
|
1116
|
+
row["attempt_count"] = int(row.get("attempt_count") or 0) + 1
|
|
1117
|
+
row["locked_at"] = now
|
|
1118
|
+
del data.setdefault("delivery_outbox", [])[:-200]
|
|
1119
|
+
return row
|
|
1120
|
+
|
|
1121
|
+
def _complete_outbox(
|
|
1122
|
+
self,
|
|
1123
|
+
data: dict[str, Any],
|
|
1124
|
+
row: dict[str, Any],
|
|
1125
|
+
*,
|
|
1126
|
+
sent: bool,
|
|
1127
|
+
outcome: str,
|
|
1128
|
+
delivery_extra: dict[str, Any],
|
|
1129
|
+
) -> None:
|
|
1130
|
+
attempt_count = int(row.get("attempt_count") or 0)
|
|
1131
|
+
state = self._state_for_outcome(
|
|
1132
|
+
sent=sent,
|
|
1133
|
+
outcome=outcome,
|
|
1134
|
+
delivery_extra={**delivery_extra, "attempt_count": attempt_count},
|
|
1135
|
+
)
|
|
1136
|
+
now = self.now_fn()
|
|
1137
|
+
row["state"] = state
|
|
1138
|
+
row["updated_at"] = now
|
|
1139
|
+
row["last_outcome"] = outcome
|
|
1140
|
+
row["locked_at"] = None
|
|
1141
|
+
row["sent_at"] = now
|
|
1142
|
+
row["apns_outcome"] = outcome
|
|
1143
|
+
retryable = bool(delivery_extra.get("retryable"))
|
|
1144
|
+
if state == "pending" and retryable:
|
|
1145
|
+
row["next_attempt_at"] = now + min(300, 15 * (2 ** max(0, attempt_count - 1)))
|
|
1146
|
+
elif state == "dead_letter":
|
|
1147
|
+
row["next_attempt_at"] = None
|
|
1148
|
+
elif state != "pending":
|
|
1149
|
+
row["next_attempt_at"] = None
|
|
1150
|
+
final_outcome = "retry_scheduled" if state == "pending" and retryable else outcome
|
|
1151
|
+
data.setdefault("deliveries", []).append({
|
|
1152
|
+
"event_id": row.get("event_id"),
|
|
1153
|
+
"device_id": row.get("device_id"),
|
|
1154
|
+
"push_type": row.get("push_type"),
|
|
1155
|
+
"token_hash": row.get("token_hash"),
|
|
1156
|
+
"attempt_count": row.get("attempt_count") or 0,
|
|
1157
|
+
"state": state,
|
|
1158
|
+
"outcome": outcome,
|
|
1159
|
+
"final_outcome": final_outcome,
|
|
1160
|
+
"apns_id": delivery_extra.get("apns_id"),
|
|
1161
|
+
"apns_status": delivery_extra.get("apns_status"),
|
|
1162
|
+
"apns_reason": delivery_extra.get("apns_reason"),
|
|
1163
|
+
"apns_outcome": outcome,
|
|
1164
|
+
"retryable": retryable,
|
|
1165
|
+
"invalid_token": bool(delivery_extra.get("invalid_token")),
|
|
1166
|
+
"ts": now,
|
|
1167
|
+
})
|
|
1168
|
+
del data.setdefault("deliveries", [])[:-300]
|
|
1169
|
+
|
|
1170
|
+
def _state_for_outcome(self, *, sent: bool, outcome: str, delivery_extra: dict[str, Any]) -> str:
|
|
1171
|
+
if sent:
|
|
1172
|
+
return "sent"
|
|
1173
|
+
if delivery_extra.get("invalid_token"):
|
|
1174
|
+
return "invalidated"
|
|
1175
|
+
if delivery_extra.get("retryable"):
|
|
1176
|
+
return "dead_letter" if int(delivery_extra.get("attempt_count") or 0) >= 3 else "pending"
|
|
1177
|
+
if outcome in {"disabled", "snoozed"}:
|
|
1178
|
+
return "suppressed"
|
|
1179
|
+
if outcome in {
|
|
1180
|
+
"not_configured",
|
|
1181
|
+
"missing_token",
|
|
1182
|
+
"missing_live_activity_token",
|
|
1183
|
+
"key_environment_mismatch",
|
|
1184
|
+
"token_environment_mismatch",
|
|
1185
|
+
}:
|
|
1186
|
+
return "credential_blocked"
|
|
1187
|
+
return "dead_letter"
|
|
1188
|
+
|
|
1189
|
+
def _provider_status(self) -> dict[str, Any]:
|
|
1190
|
+
return self.apns_sender.status()
|
|
1191
|
+
|
|
1192
|
+
def _submit_relay_event(
|
|
1193
|
+
self,
|
|
1194
|
+
*,
|
|
1195
|
+
device_id: str,
|
|
1196
|
+
device: dict[str, Any],
|
|
1197
|
+
event_id: str,
|
|
1198
|
+
kind: str,
|
|
1199
|
+
route: str,
|
|
1200
|
+
push_type: str,
|
|
1201
|
+
provider: dict[str, Any],
|
|
1202
|
+
extra_body: dict[str, Any] | None = None,
|
|
1203
|
+
) -> dict[str, Any]:
|
|
1204
|
+
secret = self._secret_for_device(device_id)
|
|
1205
|
+
relay_pair_secret = str(secret.get("relay_pair_secret") or "").strip()
|
|
1206
|
+
relay_device_id = str(device.get("relay_device_id") or secret.get("relay_device_id") or "").strip()
|
|
1207
|
+
mac_install_id = str(secret.get("mac_install_id") or device.get("mac_install_id") or os.environ.get("PAIRLING_MAC_INSTALL_ID") or "").strip()
|
|
1208
|
+
if not relay_pair_secret or not relay_device_id or not mac_install_id:
|
|
1209
|
+
return {
|
|
1210
|
+
"accepted": False,
|
|
1211
|
+
"outcome": "relay_pair_secret_missing",
|
|
1212
|
+
"retryable": False,
|
|
1213
|
+
"invalid_token": False,
|
|
1214
|
+
}
|
|
1215
|
+
body: dict[str, Any] = {
|
|
1216
|
+
"relay_device_id": relay_device_id,
|
|
1217
|
+
"mac_install_id": mac_install_id,
|
|
1218
|
+
"event_id": event_id,
|
|
1219
|
+
"kind": kind,
|
|
1220
|
+
"severity": "warning" if kind in {
|
|
1221
|
+
"session_attention",
|
|
1222
|
+
"worker_sentinel",
|
|
1223
|
+
"mac_health",
|
|
1224
|
+
"action_required",
|
|
1225
|
+
"turn_failed",
|
|
1226
|
+
"tool_risk",
|
|
1227
|
+
"mac_route_risk",
|
|
1228
|
+
"worker_pressure",
|
|
1229
|
+
} else "info",
|
|
1230
|
+
"route": route,
|
|
1231
|
+
"dedupe_key": _thread_id(kind, route),
|
|
1232
|
+
"push_type": push_type,
|
|
1233
|
+
}
|
|
1234
|
+
if extra_body:
|
|
1235
|
+
body.update(extra_body)
|
|
1236
|
+
relay_url = str(provider.get("relay_url") or "").strip()
|
|
1237
|
+
if not relay_url:
|
|
1238
|
+
return {
|
|
1239
|
+
"accepted": False,
|
|
1240
|
+
"outcome": "relay_url_missing",
|
|
1241
|
+
"retryable": False,
|
|
1242
|
+
"invalid_token": False,
|
|
1243
|
+
}
|
|
1244
|
+
return self.relay_sender.submit_event(
|
|
1245
|
+
relay_url=relay_url,
|
|
1246
|
+
relay_pair_secret=relay_pair_secret,
|
|
1247
|
+
event_body=body,
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
def _device_record(self, data: dict[str, Any], device_id: str, *, create: bool) -> dict[str, Any]:
|
|
1251
|
+
devices = data.setdefault("devices", [])
|
|
1252
|
+
for item in devices:
|
|
1253
|
+
if item.get("device_id") == device_id:
|
|
1254
|
+
for key, value in DEFAULT_PREFERENCES.items():
|
|
1255
|
+
item.setdefault(key, value)
|
|
1256
|
+
item.setdefault("relay_device_id", None)
|
|
1257
|
+
item.setdefault("last_delivery_error", None)
|
|
1258
|
+
return item
|
|
1259
|
+
if not create:
|
|
1260
|
+
raise PushDispatcherError("push_device_not_found", "push device is not registered", 404)
|
|
1261
|
+
item = {
|
|
1262
|
+
"device_id": device_id,
|
|
1263
|
+
"relay_device_id": None,
|
|
1264
|
+
"last_registered_at": self.now_fn(),
|
|
1265
|
+
"last_delivery_error": None,
|
|
1266
|
+
**DEFAULT_PREFERENCES,
|
|
1267
|
+
}
|
|
1268
|
+
devices.append(item)
|
|
1269
|
+
return item
|
|
1270
|
+
|
|
1271
|
+
def _append_event(self, data: dict[str, Any], event: dict[str, Any]) -> None:
|
|
1272
|
+
events = data.setdefault("events", [])
|
|
1273
|
+
events.append({"ts": self.now_fn(), **event})
|
|
1274
|
+
del events[:-100]
|
|
1275
|
+
|
|
1276
|
+
def _read(self) -> dict[str, Any]:
|
|
1277
|
+
with self._lock:
|
|
1278
|
+
try:
|
|
1279
|
+
raw = self.registry_path.read_text()
|
|
1280
|
+
data = json.loads(raw)
|
|
1281
|
+
except FileNotFoundError:
|
|
1282
|
+
data = {}
|
|
1283
|
+
except json.JSONDecodeError as exc:
|
|
1284
|
+
data = self._recover_registry_json(raw, exc)
|
|
1285
|
+
if not isinstance(data, dict):
|
|
1286
|
+
raise PushDispatcherError("push_registry_corrupt", "push registry root is not an object", 500)
|
|
1287
|
+
data.setdefault("schema_version", 1)
|
|
1288
|
+
data.setdefault("contract_version", CONTRACT_VERSION)
|
|
1289
|
+
data.setdefault("devices", [])
|
|
1290
|
+
data.setdefault("events", [])
|
|
1291
|
+
data.setdefault("delivery_outbox", [])
|
|
1292
|
+
data.setdefault("deliveries", [])
|
|
1293
|
+
repaired = self._rehydrate_registry_from_quarantine_backup(data)
|
|
1294
|
+
if self._quarantine_malformed_registry_records(data):
|
|
1295
|
+
repaired = True
|
|
1296
|
+
if repaired:
|
|
1297
|
+
data["updated_at"] = self.now_fn()
|
|
1298
|
+
self._write(data)
|
|
1299
|
+
return data
|
|
1300
|
+
|
|
1301
|
+
def _quarantine_malformed_registry_records(self, data: dict[str, Any]) -> bool:
|
|
1302
|
+
repaired = False
|
|
1303
|
+
devices = data.get("devices")
|
|
1304
|
+
if not isinstance(devices, list):
|
|
1305
|
+
self._append_quarantine(
|
|
1306
|
+
data,
|
|
1307
|
+
bucket="quarantined_devices",
|
|
1308
|
+
reason="devices_not_list",
|
|
1309
|
+
index=None,
|
|
1310
|
+
value=devices,
|
|
1311
|
+
)
|
|
1312
|
+
data["devices"] = []
|
|
1313
|
+
repaired = True
|
|
1314
|
+
else:
|
|
1315
|
+
valid_devices: list[dict[str, Any]] = []
|
|
1316
|
+
for index, item in enumerate(devices):
|
|
1317
|
+
if not isinstance(item, dict):
|
|
1318
|
+
self._append_quarantine(
|
|
1319
|
+
data,
|
|
1320
|
+
bucket="quarantined_devices",
|
|
1321
|
+
reason="device_record_not_object",
|
|
1322
|
+
index=index,
|
|
1323
|
+
value=item,
|
|
1324
|
+
)
|
|
1325
|
+
repaired = True
|
|
1326
|
+
continue
|
|
1327
|
+
device_id = str(item.get("device_id") or "").strip()
|
|
1328
|
+
if not device_id:
|
|
1329
|
+
self._append_quarantine(
|
|
1330
|
+
data,
|
|
1331
|
+
bucket="quarantined_devices",
|
|
1332
|
+
reason="device_record_missing_device_id",
|
|
1333
|
+
index=index,
|
|
1334
|
+
value=item,
|
|
1335
|
+
)
|
|
1336
|
+
repaired = True
|
|
1337
|
+
continue
|
|
1338
|
+
item["device_id"] = device_id
|
|
1339
|
+
valid_devices.append(item)
|
|
1340
|
+
if len(valid_devices) != len(devices):
|
|
1341
|
+
data["devices"] = valid_devices
|
|
1342
|
+
repaired = True
|
|
1343
|
+
|
|
1344
|
+
for key in ("events", "delivery_outbox", "deliveries"):
|
|
1345
|
+
value = data.get(key)
|
|
1346
|
+
if isinstance(value, list):
|
|
1347
|
+
continue
|
|
1348
|
+
self._append_quarantine(
|
|
1349
|
+
data,
|
|
1350
|
+
bucket="quarantined_records",
|
|
1351
|
+
reason=f"{key}_not_list",
|
|
1352
|
+
index=None,
|
|
1353
|
+
value=value,
|
|
1354
|
+
)
|
|
1355
|
+
data[key] = []
|
|
1356
|
+
repaired = True
|
|
1357
|
+
|
|
1358
|
+
if repaired:
|
|
1359
|
+
self._append_event(data, {
|
|
1360
|
+
"event": "push.registry.quarantined",
|
|
1361
|
+
"outcome": "repaired",
|
|
1362
|
+
})
|
|
1363
|
+
return repaired
|
|
1364
|
+
|
|
1365
|
+
def _append_quarantine(
|
|
1366
|
+
self,
|
|
1367
|
+
data: dict[str, Any],
|
|
1368
|
+
*,
|
|
1369
|
+
bucket: str,
|
|
1370
|
+
reason: str,
|
|
1371
|
+
index: int | None,
|
|
1372
|
+
value: Any,
|
|
1373
|
+
) -> None:
|
|
1374
|
+
records = data.get(bucket)
|
|
1375
|
+
if not isinstance(records, list):
|
|
1376
|
+
records = []
|
|
1377
|
+
data[bucket] = records
|
|
1378
|
+
entry: dict[str, Any] = {
|
|
1379
|
+
"ts": self.now_fn(),
|
|
1380
|
+
"reason": reason,
|
|
1381
|
+
"value_type": type(value).__name__,
|
|
1382
|
+
"value_preview": repr(value)[:1000],
|
|
1383
|
+
}
|
|
1384
|
+
if index is not None:
|
|
1385
|
+
entry["index"] = index
|
|
1386
|
+
records.append(entry)
|
|
1387
|
+
del records[:-100]
|
|
1388
|
+
|
|
1389
|
+
def _recover_registry_json(self, raw: str, exc: json.JSONDecodeError) -> dict[str, Any]:
|
|
1390
|
+
decoder = json.JSONDecoder()
|
|
1391
|
+
try:
|
|
1392
|
+
data, end = decoder.raw_decode(raw)
|
|
1393
|
+
except json.JSONDecodeError:
|
|
1394
|
+
return self._quarantine_unreadable_registry(raw, exc)
|
|
1395
|
+
if not isinstance(data, dict):
|
|
1396
|
+
return self._quarantine_unreadable_registry(raw, exc)
|
|
1397
|
+
if raw[end:].strip() == "":
|
|
1398
|
+
return self._quarantine_unreadable_registry(raw, exc)
|
|
1399
|
+
|
|
1400
|
+
self._backup_corrupt_registry(raw)
|
|
1401
|
+
self._write(data)
|
|
1402
|
+
return data
|
|
1403
|
+
|
|
1404
|
+
def _quarantine_unreadable_registry(self, raw: str, exc: json.JSONDecodeError) -> dict[str, Any]:
|
|
1405
|
+
backup = self._backup_corrupt_registry(raw)
|
|
1406
|
+
salvaged, bucket_errors = self._salvage_registry_members(raw)
|
|
1407
|
+
data: dict[str, Any] = {
|
|
1408
|
+
"schema_version": 1,
|
|
1409
|
+
"contract_version": CONTRACT_VERSION,
|
|
1410
|
+
"devices": salvaged.get("devices") or [],
|
|
1411
|
+
"events": salvaged.get("events") or [],
|
|
1412
|
+
"delivery_outbox": salvaged.get("delivery_outbox") or [],
|
|
1413
|
+
"deliveries": salvaged.get("deliveries") or [],
|
|
1414
|
+
"updated_at": self.now_fn(),
|
|
1415
|
+
}
|
|
1416
|
+
self._append_quarantine(
|
|
1417
|
+
data,
|
|
1418
|
+
bucket="quarantined_records",
|
|
1419
|
+
reason="registry_json_decode_error",
|
|
1420
|
+
index=None,
|
|
1421
|
+
value={
|
|
1422
|
+
"message": exc.msg,
|
|
1423
|
+
"line": exc.lineno,
|
|
1424
|
+
"column": exc.colno,
|
|
1425
|
+
"position": exc.pos,
|
|
1426
|
+
"backup_path": str(backup) if backup else None,
|
|
1427
|
+
},
|
|
1428
|
+
)
|
|
1429
|
+
record = data["quarantined_records"][-1]
|
|
1430
|
+
record["line"] = exc.lineno
|
|
1431
|
+
record["column"] = exc.colno
|
|
1432
|
+
record["position"] = exc.pos
|
|
1433
|
+
if backup:
|
|
1434
|
+
record["backup_path"] = str(backup)
|
|
1435
|
+
for key, error in bucket_errors.items():
|
|
1436
|
+
self._append_quarantine(
|
|
1437
|
+
data,
|
|
1438
|
+
bucket="quarantined_records",
|
|
1439
|
+
reason=f"{key}_json_decode_error",
|
|
1440
|
+
index=None,
|
|
1441
|
+
value={
|
|
1442
|
+
"message": error,
|
|
1443
|
+
"backup_path": str(backup) if backup else None,
|
|
1444
|
+
},
|
|
1445
|
+
)
|
|
1446
|
+
self._append_event(data, {
|
|
1447
|
+
"event": "push.registry.quarantined",
|
|
1448
|
+
"outcome": "repaired",
|
|
1449
|
+
"reason": "registry_json_decode_error",
|
|
1450
|
+
})
|
|
1451
|
+
self._write(data)
|
|
1452
|
+
return data
|
|
1453
|
+
|
|
1454
|
+
def _salvage_registry_members(self, raw: str) -> tuple[dict[str, Any], dict[str, str]]:
|
|
1455
|
+
salvaged: dict[str, Any] = {}
|
|
1456
|
+
errors: dict[str, str] = {}
|
|
1457
|
+
for key in ("devices", "events", "delivery_outbox", "deliveries"):
|
|
1458
|
+
value, error = self._extract_json_member(raw, key)
|
|
1459
|
+
if error:
|
|
1460
|
+
errors[key] = error
|
|
1461
|
+
elif isinstance(value, list):
|
|
1462
|
+
salvaged[key] = value
|
|
1463
|
+
elif value is not None:
|
|
1464
|
+
errors[key] = f"{key} is {type(value).__name__}, not list"
|
|
1465
|
+
return salvaged, errors
|
|
1466
|
+
|
|
1467
|
+
def _extract_json_member(self, raw: str, key: str) -> tuple[Any | None, str | None]:
|
|
1468
|
+
needle = '"' + key + '"'
|
|
1469
|
+
pos = raw.find(needle)
|
|
1470
|
+
if pos < 0:
|
|
1471
|
+
return None, "missing"
|
|
1472
|
+
colon = raw.find(":", pos + len(needle))
|
|
1473
|
+
if colon < 0:
|
|
1474
|
+
return None, "missing colon"
|
|
1475
|
+
start = colon + 1
|
|
1476
|
+
while start < len(raw) and raw[start].isspace():
|
|
1477
|
+
start += 1
|
|
1478
|
+
if start >= len(raw):
|
|
1479
|
+
return None, "missing value"
|
|
1480
|
+
opener = raw[start]
|
|
1481
|
+
closer = {"[": "]", "{": "}"}.get(opener)
|
|
1482
|
+
if closer is None:
|
|
1483
|
+
return None, "value is not a JSON container"
|
|
1484
|
+
depth = 0
|
|
1485
|
+
in_string = False
|
|
1486
|
+
escape = False
|
|
1487
|
+
for index in range(start, len(raw)):
|
|
1488
|
+
char = raw[index]
|
|
1489
|
+
if in_string:
|
|
1490
|
+
if escape:
|
|
1491
|
+
escape = False
|
|
1492
|
+
elif char == "\\":
|
|
1493
|
+
escape = True
|
|
1494
|
+
elif char == '"':
|
|
1495
|
+
in_string = False
|
|
1496
|
+
continue
|
|
1497
|
+
if char == '"':
|
|
1498
|
+
in_string = True
|
|
1499
|
+
elif char == opener:
|
|
1500
|
+
depth += 1
|
|
1501
|
+
elif char == closer:
|
|
1502
|
+
depth -= 1
|
|
1503
|
+
if depth == 0:
|
|
1504
|
+
text = raw[start:index + 1]
|
|
1505
|
+
try:
|
|
1506
|
+
return json.loads(text), None
|
|
1507
|
+
except json.JSONDecodeError as exc:
|
|
1508
|
+
return None, str(exc)
|
|
1509
|
+
return None, "unclosed container"
|
|
1510
|
+
|
|
1511
|
+
def _rehydrate_registry_from_quarantine_backup(self, data: dict[str, Any]) -> bool:
|
|
1512
|
+
records = data.get("quarantined_records")
|
|
1513
|
+
if not isinstance(records, list):
|
|
1514
|
+
return False
|
|
1515
|
+
devices = data.setdefault("devices", [])
|
|
1516
|
+
if not isinstance(devices, list):
|
|
1517
|
+
return False
|
|
1518
|
+
existing_ids = {
|
|
1519
|
+
str(item.get("device_id") or "").strip()
|
|
1520
|
+
for item in devices
|
|
1521
|
+
if isinstance(item, dict)
|
|
1522
|
+
}
|
|
1523
|
+
for record in reversed(records):
|
|
1524
|
+
if not isinstance(record, dict):
|
|
1525
|
+
continue
|
|
1526
|
+
if record.get("reason") != "registry_json_decode_error":
|
|
1527
|
+
continue
|
|
1528
|
+
backup_path = str(record.get("backup_path") or "").strip()
|
|
1529
|
+
if not backup_path:
|
|
1530
|
+
continue
|
|
1531
|
+
try:
|
|
1532
|
+
raw = Path(backup_path).read_text(encoding="utf-8", errors="replace")
|
|
1533
|
+
except OSError:
|
|
1534
|
+
continue
|
|
1535
|
+
salvaged, _ = self._salvage_registry_members(raw)
|
|
1536
|
+
restored: list[dict[str, Any]] = []
|
|
1537
|
+
for item in salvaged.get("devices") or []:
|
|
1538
|
+
if not isinstance(item, dict):
|
|
1539
|
+
continue
|
|
1540
|
+
device_id = str(item.get("device_id") or "").strip()
|
|
1541
|
+
if not device_id or device_id in existing_ids:
|
|
1542
|
+
continue
|
|
1543
|
+
item["device_id"] = device_id
|
|
1544
|
+
devices.append(item)
|
|
1545
|
+
existing_ids.add(device_id)
|
|
1546
|
+
restored.append(item)
|
|
1547
|
+
if restored:
|
|
1548
|
+
self._append_quarantine(
|
|
1549
|
+
data,
|
|
1550
|
+
bucket="quarantined_records",
|
|
1551
|
+
reason="devices_rehydrated_from_backup",
|
|
1552
|
+
index=None,
|
|
1553
|
+
value={
|
|
1554
|
+
"backup_path": backup_path,
|
|
1555
|
+
"device_count": len(restored),
|
|
1556
|
+
},
|
|
1557
|
+
)
|
|
1558
|
+
self._append_event(data, {
|
|
1559
|
+
"event": "push.registry.rehydrated",
|
|
1560
|
+
"outcome": "repaired",
|
|
1561
|
+
"device_count": len(restored),
|
|
1562
|
+
})
|
|
1563
|
+
return True
|
|
1564
|
+
return False
|
|
1565
|
+
|
|
1566
|
+
def _backup_corrupt_registry(self, raw: str) -> Path | None:
|
|
1567
|
+
backup = self.registry_path.with_name(
|
|
1568
|
+
f"{self.registry_path.name}.corrupt-{int(self.now_fn())}-{uuid.uuid4().hex[:8]}"
|
|
1569
|
+
)
|
|
1570
|
+
try:
|
|
1571
|
+
backup.write_text(raw, encoding="utf-8")
|
|
1572
|
+
os.chmod(backup, 0o600)
|
|
1573
|
+
return backup
|
|
1574
|
+
except OSError:
|
|
1575
|
+
return None
|
|
1576
|
+
|
|
1577
|
+
def _read_secrets(self) -> dict[str, Any]:
|
|
1578
|
+
try:
|
|
1579
|
+
data = json.loads(self.secret_path.read_text())
|
|
1580
|
+
except FileNotFoundError:
|
|
1581
|
+
data = {}
|
|
1582
|
+
except json.JSONDecodeError as exc:
|
|
1583
|
+
raise PushDispatcherError("push_secret_store_corrupt", f"push secret store is corrupt: {exc}", 500)
|
|
1584
|
+
if not isinstance(data, dict):
|
|
1585
|
+
raise PushDispatcherError("push_secret_store_corrupt", "push secret store root is not an object", 500)
|
|
1586
|
+
data.setdefault("schema_version", 1)
|
|
1587
|
+
data.setdefault("devices", {})
|
|
1588
|
+
return data
|
|
1589
|
+
|
|
1590
|
+
def _secret_for_device(self, device_id: str) -> dict[str, Any]:
|
|
1591
|
+
try:
|
|
1592
|
+
data = self._read_secrets()
|
|
1593
|
+
except PushDispatcherError:
|
|
1594
|
+
return {}
|
|
1595
|
+
device = data.get("devices", {}).get(device_id)
|
|
1596
|
+
return device if isinstance(device, dict) else {}
|
|
1597
|
+
|
|
1598
|
+
def _mark_live_activity_invalid(self, device: dict[str, Any], session_id: str, event_id: str, outcome: str) -> None:
|
|
1599
|
+
for item in device.get("live_activities", []):
|
|
1600
|
+
if item.get("session_id") == session_id and not item.get("invalidated_at"):
|
|
1601
|
+
item["invalidated_at"] = self.now_fn()
|
|
1602
|
+
item["invalidated_by_event_id"] = event_id
|
|
1603
|
+
item["invalidated_reason"] = outcome
|
|
1604
|
+
|
|
1605
|
+
def _write(self, payload: dict[str, Any]) -> None:
|
|
1606
|
+
with self._lock:
|
|
1607
|
+
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1608
|
+
try:
|
|
1609
|
+
# A push registry directory must be user-private; 0o700 is stricter than world-readable defaults.
|
|
1610
|
+
os.chmod(self.registry_path.parent, stat.S_IRWXU)
|
|
1611
|
+
except OSError:
|
|
1612
|
+
pass
|
|
1613
|
+
tmp = self.registry_path.with_name(
|
|
1614
|
+
f"{self.registry_path.name}.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex}.tmp"
|
|
1615
|
+
)
|
|
1616
|
+
try:
|
|
1617
|
+
with tmp.open("w") as fh:
|
|
1618
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
1619
|
+
fh.write("\n")
|
|
1620
|
+
fh.flush()
|
|
1621
|
+
os.fsync(fh.fileno())
|
|
1622
|
+
os.replace(tmp, self.registry_path)
|
|
1623
|
+
finally:
|
|
1624
|
+
try:
|
|
1625
|
+
tmp.unlink()
|
|
1626
|
+
except FileNotFoundError:
|
|
1627
|
+
pass
|
|
1628
|
+
except OSError:
|
|
1629
|
+
pass
|
|
1630
|
+
try:
|
|
1631
|
+
os.chmod(self.registry_path, 0o600)
|
|
1632
|
+
except OSError:
|
|
1633
|
+
pass
|
|
1634
|
+
|
|
1635
|
+
def _write_secrets(self, payload: dict[str, Any]) -> None:
|
|
1636
|
+
self.secret_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1637
|
+
try:
|
|
1638
|
+
os.chmod(self.secret_path.parent, stat.S_IRWXU)
|
|
1639
|
+
except OSError:
|
|
1640
|
+
pass
|
|
1641
|
+
tmp = self.secret_path.with_suffix(self.secret_path.suffix + ".tmp")
|
|
1642
|
+
with tmp.open("w") as fh:
|
|
1643
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
1644
|
+
fh.write("\n")
|
|
1645
|
+
fh.flush()
|
|
1646
|
+
os.fsync(fh.fileno())
|
|
1647
|
+
os.replace(tmp, self.secret_path)
|
|
1648
|
+
try:
|
|
1649
|
+
os.chmod(self.secret_path, 0o600)
|
|
1650
|
+
except OSError:
|
|
1651
|
+
pass
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def _nonempty(value: str | None, field: str) -> str:
|
|
1655
|
+
text = str(value or "").strip()
|
|
1656
|
+
if not text:
|
|
1657
|
+
raise PushDispatcherError("missing_" + field, field.replace("_", " ") + " is required")
|
|
1658
|
+
return text
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def _validate_apns_token(token: str, field: str) -> None:
|
|
1662
|
+
if len(token) < APNS_TOKEN_MIN_HEX_CHARS or len(token) > APNS_TOKEN_MAX_HEX_CHARS:
|
|
1663
|
+
raise PushDispatcherError("invalid_" + field, field.replace("_", " ") + " length is invalid")
|
|
1664
|
+
try:
|
|
1665
|
+
int(token, 16)
|
|
1666
|
+
except ValueError:
|
|
1667
|
+
raise PushDispatcherError("invalid_" + field, field.replace("_", " ") + " must be hex")
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _normalize_apns_environment(value: Any) -> str:
|
|
1671
|
+
text = str(value or "").strip().lower()
|
|
1672
|
+
if text == "sandbox":
|
|
1673
|
+
return "development"
|
|
1674
|
+
if text in APNS_ENVIRONMENTS:
|
|
1675
|
+
return text
|
|
1676
|
+
return "development"
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def _normalize_apns_key_environment(value: Any) -> str:
|
|
1680
|
+
text = str(value or "").strip().lower()
|
|
1681
|
+
if text in {"any", "all"}:
|
|
1682
|
+
return "both"
|
|
1683
|
+
if text == "sandbox":
|
|
1684
|
+
return "development"
|
|
1685
|
+
if text in APNS_KEY_ENVIRONMENTS:
|
|
1686
|
+
return text
|
|
1687
|
+
return "development"
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def _token_environment(value: Any) -> str:
|
|
1691
|
+
return _normalize_apns_environment(value)
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _provider_environment(provider: dict[str, Any]) -> str:
|
|
1695
|
+
return _normalize_apns_environment(provider.get("environment"))
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def _key_environment(provider: dict[str, Any]) -> str:
|
|
1699
|
+
return _normalize_apns_key_environment(provider.get("key_environment") or provider.get("environment"))
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def _key_environment_mismatch(provider: dict[str, Any]) -> bool:
|
|
1703
|
+
key_environment = _key_environment(provider)
|
|
1704
|
+
return key_environment != "both" and key_environment != _provider_environment(provider)
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _b64url(data: bytes) -> str:
|
|
1708
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _json_dump(obj: Any) -> str:
|
|
1712
|
+
return json.dumps(obj, separators=(",", ":"), sort_keys=True)
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
def _sha256_hex(value: str) -> str:
|
|
1716
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _infer_apns_key_id(path: str) -> str:
|
|
1720
|
+
name = Path(path).name
|
|
1721
|
+
if name.startswith("AuthKey_") and name.endswith(".p8"):
|
|
1722
|
+
return name[len("AuthKey_"):-len(".p8")]
|
|
1723
|
+
return ""
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def _thread_id(kind: str, route: str) -> str:
|
|
1727
|
+
if "/session/" in route:
|
|
1728
|
+
return "pairling.session." + route.rsplit("/", 1)[-1][:80]
|
|
1729
|
+
if kind in {"mac_health", "mac_route_risk"}:
|
|
1730
|
+
return "pairling.health"
|
|
1731
|
+
if kind in {"worker_sentinel", "worker_pressure"}:
|
|
1732
|
+
return "pairling.workers"
|
|
1733
|
+
return "pairling.push"
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _alert_enabled_for_device(device: dict[str, Any], kind: str) -> bool:
|
|
1737
|
+
if not device.get("standard_push_enabled"):
|
|
1738
|
+
return False
|
|
1739
|
+
if kind == "push_diagnostic":
|
|
1740
|
+
return bool(device.get("push_diagnostics_enabled"))
|
|
1741
|
+
if kind in {"turn_done", "turn_result", "deploy_result"}:
|
|
1742
|
+
return bool(device.get("turn_done_enabled"))
|
|
1743
|
+
if kind in {"worker_sentinel", "worker_pressure"}:
|
|
1744
|
+
return bool(device.get("worker_sentinel_enabled"))
|
|
1745
|
+
return True
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def _alert_pairling_extra(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1749
|
+
allowed = {
|
|
1750
|
+
"session_id",
|
|
1751
|
+
"provider",
|
|
1752
|
+
"source",
|
|
1753
|
+
"phase",
|
|
1754
|
+
"project",
|
|
1755
|
+
"observed_at",
|
|
1756
|
+
"collapse_id",
|
|
1757
|
+
"dedupe_key",
|
|
1758
|
+
"result_summary",
|
|
1759
|
+
"required_action",
|
|
1760
|
+
"risk_summary",
|
|
1761
|
+
"route_health",
|
|
1762
|
+
"worker_summary",
|
|
1763
|
+
"build_label",
|
|
1764
|
+
"sentinel_event_id",
|
|
1765
|
+
"sentinel_level",
|
|
1766
|
+
"sentinel_key",
|
|
1767
|
+
"health_posture",
|
|
1768
|
+
"health_severity",
|
|
1769
|
+
"health_summary",
|
|
1770
|
+
}
|
|
1771
|
+
out: dict[str, Any] = {}
|
|
1772
|
+
for key in allowed:
|
|
1773
|
+
if key not in payload:
|
|
1774
|
+
continue
|
|
1775
|
+
value = payload[key]
|
|
1776
|
+
if isinstance(value, str):
|
|
1777
|
+
out[key] = value[:180]
|
|
1778
|
+
elif isinstance(value, (int, float, bool)) or value is None:
|
|
1779
|
+
out[key] = value
|
|
1780
|
+
return out
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _split_curl_status(stdout: str) -> tuple[str, int | None]:
|
|
1784
|
+
if "\n" not in stdout:
|
|
1785
|
+
return stdout, None
|
|
1786
|
+
response_text, status_text = stdout.rsplit("\n", 1)
|
|
1787
|
+
try:
|
|
1788
|
+
return response_text, int(status_text.strip())
|
|
1789
|
+
except ValueError:
|
|
1790
|
+
return stdout, None
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _apns_outcome(status: int | None, reason: str | None, curl_exit_code: int) -> str:
|
|
1794
|
+
if curl_exit_code != 0:
|
|
1795
|
+
return f"curl_error_{curl_exit_code}"
|
|
1796
|
+
if status is None:
|
|
1797
|
+
return "apns_unknown"
|
|
1798
|
+
if reason:
|
|
1799
|
+
return f"apns_{status}_{reason}"
|
|
1800
|
+
return f"apns_{status}"
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _bounded_content_state(content_state: dict[str, Any], *, event_id: str, now: int) -> dict[str, Any]:
|
|
1804
|
+
state = str(content_state.get("state") or "starting")[:40]
|
|
1805
|
+
if state not in {"starting", "thinking", "tool", "responding", "attention", "stale", "done", "failed", "idle"}:
|
|
1806
|
+
state = "starting"
|
|
1807
|
+
attention = content_state.get("attentionLevel")
|
|
1808
|
+
if attention is not None:
|
|
1809
|
+
attention = str(attention)[:20]
|
|
1810
|
+
if attention not in {"info", "warning", "critical"}:
|
|
1811
|
+
attention = None
|
|
1812
|
+
tokens = content_state.get("tokens")
|
|
1813
|
+
try:
|
|
1814
|
+
parsed_tokens = int(tokens) if tokens is not None else None
|
|
1815
|
+
except (TypeError, ValueError):
|
|
1816
|
+
parsed_tokens = None
|
|
1817
|
+
phase = str(content_state.get("phase") or state)[:32]
|
|
1818
|
+
if phase not in {"starting", "thinking", "tool", "responding", "attention", "stale", "done", "failed", "idle", "risk"}:
|
|
1819
|
+
phase = state
|
|
1820
|
+
return {
|
|
1821
|
+
"state": state,
|
|
1822
|
+
"phase": phase,
|
|
1823
|
+
"tool": _bounded_optional(content_state.get("tool"), 80),
|
|
1824
|
+
"effort": _bounded_optional(content_state.get("effort"), 40),
|
|
1825
|
+
"tokens": parsed_tokens,
|
|
1826
|
+
"verb": str(content_state.get("verb") or "Working")[:40],
|
|
1827
|
+
"attentionLevel": attention,
|
|
1828
|
+
"updatedAtEpoch": float(content_state.get("updatedAtEpoch") or now),
|
|
1829
|
+
"eventId": str(content_state.get("eventId") or event_id)[:120],
|
|
1830
|
+
"sessionTitle": _bounded_optional(content_state.get("sessionTitle"), 60),
|
|
1831
|
+
"provider": _bounded_optional(content_state.get("provider"), 32),
|
|
1832
|
+
"project": _bounded_optional(content_state.get("project"), 60),
|
|
1833
|
+
"currentStep": _bounded_optional(content_state.get("currentStep"), 60),
|
|
1834
|
+
"latestEvent": _bounded_optional(content_state.get("latestEvent"), 120),
|
|
1835
|
+
"resultSummary": _bounded_optional(content_state.get("resultSummary"), 120),
|
|
1836
|
+
"requiredAction": _bounded_optional(content_state.get("requiredAction"), 120),
|
|
1837
|
+
"freshness": _bounded_optional(content_state.get("freshness"), 40),
|
|
1838
|
+
"riskLevel": _bounded_optional(content_state.get("riskLevel"), 32),
|
|
1839
|
+
"riskSummary": _bounded_optional(content_state.get("riskSummary"), 120),
|
|
1840
|
+
"routeHealth": _bounded_optional(content_state.get("routeHealth"), 120),
|
|
1841
|
+
"workerSummary": _bounded_optional(content_state.get("workerSummary"), 120),
|
|
1842
|
+
"buildLabel": _bounded_optional(content_state.get("buildLabel"), 60),
|
|
1843
|
+
"actionRoute": _bounded_optional(content_state.get("actionRoute"), 300),
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def _live_activity_content_state(payload: dict[str, Any], *, activity_event: str, event_id: str, now: int) -> dict[str, Any]:
|
|
1848
|
+
content_state = payload.get("content_state")
|
|
1849
|
+
if isinstance(content_state, dict):
|
|
1850
|
+
return content_state
|
|
1851
|
+
state = str(payload.get("state") or ("done" if activity_event == "end" else "tool"))
|
|
1852
|
+
return {
|
|
1853
|
+
"state": state,
|
|
1854
|
+
"phase": payload.get("phase") or state,
|
|
1855
|
+
"tool": payload.get("tool"),
|
|
1856
|
+
"effort": payload.get("effort"),
|
|
1857
|
+
"tokens": payload.get("tokens"),
|
|
1858
|
+
"verb": str(payload.get("verb") or ("Done" if activity_event == "end" else "Using")),
|
|
1859
|
+
"attentionLevel": payload.get("attentionLevel"),
|
|
1860
|
+
"updatedAtEpoch": payload.get("updatedAtEpoch") or now,
|
|
1861
|
+
"eventId": event_id,
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _bounded_optional(value: Any, limit: int) -> str | None:
|
|
1866
|
+
if value in (None, ""):
|
|
1867
|
+
return None
|
|
1868
|
+
return str(value)[:limit]
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def _optional_float(value: Any) -> float | None:
|
|
1872
|
+
try:
|
|
1873
|
+
if value in (None, ""):
|
|
1874
|
+
return None
|
|
1875
|
+
return float(value)
|
|
1876
|
+
except (TypeError, ValueError):
|
|
1877
|
+
return None
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
|
|
1881
|
+
try:
|
|
1882
|
+
parsed = int(value)
|
|
1883
|
+
except (TypeError, ValueError):
|
|
1884
|
+
parsed = default
|
|
1885
|
+
return max(minimum, min(maximum, parsed))
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _outbox_metadata_from_payload(
|
|
1889
|
+
payload: dict[str, Any],
|
|
1890
|
+
*,
|
|
1891
|
+
sent_at: float,
|
|
1892
|
+
content_state: dict[str, Any] | None = None,
|
|
1893
|
+
apns_outcome: str | None = None,
|
|
1894
|
+
) -> dict[str, Any]:
|
|
1895
|
+
observed = _optional_float(payload.get("observed_at")) or sent_at
|
|
1896
|
+
freshness = payload.get("freshness_seconds_at_send", payload.get("freshness_seconds"))
|
|
1897
|
+
if freshness is None:
|
|
1898
|
+
freshness_at_send = max(0.0, float(sent_at) - float(observed))
|
|
1899
|
+
else:
|
|
1900
|
+
try:
|
|
1901
|
+
freshness_at_send = max(0.0, float(freshness))
|
|
1902
|
+
except (TypeError, ValueError):
|
|
1903
|
+
freshness_at_send = max(0.0, float(sent_at) - float(observed))
|
|
1904
|
+
content_hash = None
|
|
1905
|
+
if isinstance(content_state, dict):
|
|
1906
|
+
content_hash = _sha256_hex(_json_dump(content_state))
|
|
1907
|
+
return {
|
|
1908
|
+
"source": _bounded_optional(payload.get("source"), 80),
|
|
1909
|
+
"phase": _bounded_optional(payload.get("phase"), 32),
|
|
1910
|
+
"project": _bounded_optional(payload.get("project"), 60),
|
|
1911
|
+
"observed_at": observed,
|
|
1912
|
+
"sent_at": float(sent_at),
|
|
1913
|
+
"collapse_id": _bounded_optional(payload.get("collapse_id"), 160),
|
|
1914
|
+
"freshness_seconds_at_send": freshness_at_send,
|
|
1915
|
+
"content_state_hash": _bounded_optional(payload.get("content_state_hash"), 80) or content_hash,
|
|
1916
|
+
"apns_outcome": _bounded_optional(apns_outcome, 120),
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _live_activity_outbox_metadata(
|
|
1921
|
+
payload: dict[str, Any],
|
|
1922
|
+
*,
|
|
1923
|
+
content_state: dict[str, Any],
|
|
1924
|
+
sent_at: float,
|
|
1925
|
+
apns_outcome: str | None = None,
|
|
1926
|
+
) -> dict[str, Any]:
|
|
1927
|
+
observed = _optional_float(payload.get("observed_at")) or sent_at
|
|
1928
|
+
freshness = payload.get("freshness_seconds")
|
|
1929
|
+
if freshness is None:
|
|
1930
|
+
freshness_at_send = max(0.0, float(sent_at) - float(observed))
|
|
1931
|
+
else:
|
|
1932
|
+
try:
|
|
1933
|
+
freshness_at_send = float(freshness)
|
|
1934
|
+
except (TypeError, ValueError):
|
|
1935
|
+
freshness_at_send = max(0.0, float(sent_at) - float(observed))
|
|
1936
|
+
bounded = _bounded_content_state(content_state, event_id=str(payload.get("event_id") or ""), now=int(sent_at))
|
|
1937
|
+
return {
|
|
1938
|
+
"source": _bounded_optional(payload.get("source"), 80),
|
|
1939
|
+
"phase": _bounded_optional(payload.get("phase") or bounded.get("phase"), 32),
|
|
1940
|
+
"project": _bounded_optional(payload.get("project") or bounded.get("project"), 60),
|
|
1941
|
+
"observed_at": observed,
|
|
1942
|
+
"sent_at": float(sent_at),
|
|
1943
|
+
"collapse_id": _bounded_optional(payload.get("collapse_id"), 160),
|
|
1944
|
+
"freshness_seconds_at_send": freshness_at_send,
|
|
1945
|
+
"content_state_hash": _sha256_hex(_json_dump(bounded)),
|
|
1946
|
+
"apns_outcome": _bounded_optional(apns_outcome, 120),
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def _apply_outbox_metadata(row: dict[str, Any], metadata: dict[str, Any]) -> None:
|
|
1951
|
+
for key, value in metadata.items():
|
|
1952
|
+
if value is not None:
|
|
1953
|
+
row[key] = value
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _live_activity_alert_body(content: dict[str, Any]) -> str:
|
|
1957
|
+
if content.get("state") == "attention":
|
|
1958
|
+
return "Pairling needs your attention."
|
|
1959
|
+
if content.get("state") == "failed":
|
|
1960
|
+
return "Pairling activity failed."
|
|
1961
|
+
return "Pairling activity updated."
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def _quiet_hours(value: Any) -> dict[str, Any] | None:
|
|
1965
|
+
if value in (None, "", False):
|
|
1966
|
+
return None
|
|
1967
|
+
if not isinstance(value, dict):
|
|
1968
|
+
raise PushDispatcherError("invalid_quiet_hours", "quiet_hours must be an object or null")
|
|
1969
|
+
start = str(value.get("start") or "").strip()
|
|
1970
|
+
end = str(value.get("end") or "").strip()
|
|
1971
|
+
if not start or not end:
|
|
1972
|
+
return None
|
|
1973
|
+
return {"start": start[:5], "end": end[:5]}
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
def _optional_epoch(value: Any) -> float | None:
|
|
1977
|
+
if value in (None, "", False):
|
|
1978
|
+
return None
|
|
1979
|
+
try:
|
|
1980
|
+
parsed = float(value)
|
|
1981
|
+
except (TypeError, ValueError):
|
|
1982
|
+
return None
|
|
1983
|
+
return max(0.0, parsed)
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def _future_epoch(value: Any, now: float) -> bool:
|
|
1987
|
+
try:
|
|
1988
|
+
return float(value or 0) > float(now)
|
|
1989
|
+
except (TypeError, ValueError):
|
|
1990
|
+
return False
|