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.
Files changed (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import os
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ CONNECTD_STATUS_URL = "http://127.0.0.1:7774/status"
11
+ PAIRLING_CONNECT_ROUTE_SOURCE = "pairling_connectd"
12
+ PAIRLING_CONNECT_ROUTE_KIND = "tailnet"
13
+ PAIRLING_CONNECT_PORT = 7773
14
+
15
+
16
+ def fetch_connectd_status(timeout_seconds: float = 1.5) -> dict[str, Any]:
17
+ fixture = os.environ.get("PAIRLING_TEST_CONNECTD_STATUS_JSON")
18
+ if fixture:
19
+ payload = json.loads(fixture)
20
+ return payload if isinstance(payload, dict) else {}
21
+
22
+ req = urllib.request.Request(CONNECTD_STATUS_URL, method="GET")
23
+ try:
24
+ with urllib.request.urlopen(req, timeout=timeout_seconds) as response:
25
+ payload = json.loads(response.read().decode("utf-8"))
26
+ except Exception:
27
+ return {}
28
+ return payload if isinstance(payload, dict) else {}
29
+
30
+
31
+ def advertised_pairling_connect_routes(status: dict[str, Any]) -> list[dict[str, Any]]:
32
+ if not isinstance(status, dict) or int(status.get("schema_version") or 0) < 2:
33
+ return []
34
+ routes = status.get("advertised_routes") or []
35
+ if not isinstance(routes, list):
36
+ return []
37
+
38
+ valid_routes: list[dict[str, Any]] = []
39
+ for route in routes:
40
+ if not isinstance(route, dict):
41
+ continue
42
+ if route.get("source") != PAIRLING_CONNECT_ROUTE_SOURCE:
43
+ continue
44
+ if route.get("kind") != PAIRLING_CONNECT_ROUTE_KIND:
45
+ continue
46
+ if route.get("status") != "ready":
47
+ continue
48
+ host = str(route.get("host") or "").strip()
49
+ try:
50
+ port = int(route.get("port") or 0)
51
+ except (TypeError, ValueError):
52
+ continue
53
+ if port != PAIRLING_CONNECT_PORT or not _is_tailnet_host(host):
54
+ continue
55
+ base_url = _sanitized_base_url(route.get("base_url"), host, port)
56
+ if not base_url:
57
+ continue
58
+ valid = {
59
+ "id": str(route.get("id") or "pairling-connect-tailnet"),
60
+ "kind": PAIRLING_CONNECT_ROUTE_KIND,
61
+ "source": PAIRLING_CONNECT_ROUTE_SOURCE,
62
+ "priority": int(route.get("priority") or 100),
63
+ "base_url": base_url,
64
+ "host": host,
65
+ "port": port,
66
+ "status": "ready",
67
+ }
68
+ valid_routes.append(valid)
69
+ valid_routes.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
70
+ return valid_routes
71
+
72
+
73
+ def preferred_pairling_connect_base_url(status: dict[str, Any]) -> str | None:
74
+ routes = advertised_pairling_connect_routes(status)
75
+ if not routes:
76
+ return None
77
+ return str(routes[0]["base_url"])
78
+
79
+
80
+ def redacted_connectd_summary(status: dict[str, Any]) -> dict[str, Any]:
81
+ routes = advertised_pairling_connect_routes(status)
82
+ return {
83
+ "schema_version": int(status.get("schema_version") or 0) if isinstance(status, dict) else 0,
84
+ "status": "ready" if routes else _degraded_status(status),
85
+ "auth_state": str(status.get("auth_state") or "unknown") if isinstance(status, dict) else "unknown",
86
+ "route_ready": bool(routes),
87
+ "route": routes[0] if routes else None,
88
+ "auth_url_present": bool(status.get("auth_url_present")) if isinstance(status, dict) else False,
89
+ "tailnet_ip_count": int(status.get("tailnet_ip_count") or 0) if isinstance(status, dict) else 0,
90
+ "listener_running": bool(status.get("listener_running")) if isinstance(status, dict) else False,
91
+ "upstream_reachable": bool(status.get("upstream_reachable")) if isinstance(status, dict) else False,
92
+ "local_pairing_available": True,
93
+ "next_action": _next_action(status, bool(routes)),
94
+ }
95
+
96
+
97
+ def _degraded_status(status: dict[str, Any]) -> str:
98
+ if not status:
99
+ return "connectd_unavailable"
100
+ if status.get("auth_state") != "authenticated":
101
+ return "auth_pending" if status.get("auth_url_present") else "auth_unknown"
102
+ if not status.get("listener_running"):
103
+ return "listener_down"
104
+ if not status.get("upstream_reachable"):
105
+ return "upstream_unreachable"
106
+ if not status.get("tailnet_ip"):
107
+ return "no_tailnet_ip"
108
+ return "route_missing"
109
+
110
+
111
+ def _next_action(status: dict[str, Any], route_ready: bool) -> dict[str, str]:
112
+ if route_ready:
113
+ return {
114
+ "id": "pair_iphone",
115
+ "label": "Pair iPhone",
116
+ "message": "Scan the Mac pairing code in Pairling.",
117
+ }
118
+ if status and status.get("auth_url_present"):
119
+ return {
120
+ "id": "authenticate_pairling_connect",
121
+ "label": "Authenticate Pairling Connect",
122
+ "message": "Approve Pairling Connect in the browser, then recheck this Mac.",
123
+ }
124
+ return {
125
+ "id": "use_local_pairing",
126
+ "label": "Use local pairing",
127
+ "message": "Pair locally now, or retry Pairling Connect after this Mac is ready.",
128
+ }
129
+
130
+
131
+ def _sanitized_base_url(value: Any, host: str, port: int) -> str | None:
132
+ raw = str(value or f"http://{host}:{port}").strip()
133
+ try:
134
+ parsed = urllib.parse.urlparse(raw)
135
+ except Exception:
136
+ return None
137
+ if parsed.scheme != "http" or parsed.hostname != host or parsed.port != port:
138
+ return None
139
+ return urllib.parse.urlunparse(("http", f"{host}:{port}", "", "", "", ""))
140
+
141
+
142
+ def _is_tailnet_host(host: str) -> bool:
143
+ if host.endswith(".ts.net"):
144
+ return True
145
+ try:
146
+ ip = ipaddress.ip_address(host)
147
+ except ValueError:
148
+ return False
149
+ return ip.version == 4 and ip in ipaddress.ip_network("100.64.0.0/10")
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env python3
2
+ """Per-device token registry for the Pairling Mac runtime."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import secrets
10
+ import sqlite3
11
+ import time
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Iterable
16
+
17
+ from runtime_contract import DEFAULT_DEVICE_SCOPES
18
+ from runtime_paths import audit_log_path, devices_db_path, install_id_path
19
+
20
+
21
+ SQLITE_BUSY_TIMEOUT_MS = int(os.environ.get("PAIRLING_SQLITE_BUSY_TIMEOUT_MS", "5000"))
22
+
23
+
24
+ def utc_epoch() -> float:
25
+ return time.time()
26
+
27
+
28
+ def hash_token(token: str) -> str:
29
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
30
+
31
+
32
+ def generate_token() -> str:
33
+ return "pld_" + secrets.token_urlsafe(32)
34
+
35
+
36
+ def generate_device_id() -> str:
37
+ return "dev_" + secrets.token_hex(16)
38
+
39
+
40
+ def generate_proof_secret() -> str:
41
+ return "prf_" + secrets.token_urlsafe(32)
42
+
43
+
44
+ def _redact_for_audit(value: Any) -> Any:
45
+ if isinstance(value, dict):
46
+ redacted: dict[str, Any] = {}
47
+ for key, item in value.items():
48
+ lowered = str(key).lower()
49
+ if any(marker in lowered for marker in ("token", "secret", "proof", "authorization")):
50
+ redacted[str(key)] = "[redacted]"
51
+ else:
52
+ redacted[str(key)] = _redact_for_audit(item)
53
+ return redacted
54
+ if isinstance(value, list):
55
+ return [_redact_for_audit(item) for item in value]
56
+ return value
57
+
58
+
59
+ def _write_private_text(path: Path, text: str) -> None:
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ try:
62
+ os.chmod(path.parent, 0o700)
63
+ except OSError:
64
+ pass
65
+ tmp = path.with_suffix(path.suffix + ".tmp")
66
+ with tmp.open("w") as fh:
67
+ fh.write(text)
68
+ fh.flush()
69
+ os.fsync(fh.fileno())
70
+ os.replace(tmp, path)
71
+ try:
72
+ os.chmod(path, 0o600)
73
+ except OSError:
74
+ pass
75
+
76
+
77
+ def load_or_create_install_id(path: Path | None = None) -> str:
78
+ target = path or install_id_path()
79
+ try:
80
+ value = target.read_text().strip()
81
+ if value:
82
+ return value
83
+ except FileNotFoundError:
84
+ pass
85
+ value = "inst_" + secrets.token_hex(16)
86
+ _write_private_text(target, value + "\n")
87
+ return value
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class DeviceAuthResult:
92
+ ok: bool
93
+ status: int
94
+ reason: str
95
+ device_id: str | None = None
96
+ install_id: str | None = None
97
+ proof_secret: str | None = None
98
+ scopes: frozenset[str] = frozenset()
99
+
100
+ def has_scope(self, scope: str) -> bool:
101
+ return scope in self.scopes
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class CreatedDevice:
106
+ device_id: str
107
+ token: str
108
+ proof_secret: str
109
+ scopes: tuple[str, ...]
110
+ install_id: str
111
+ relay_device_id: str | None = None
112
+ attestation_status: str = "none"
113
+
114
+
115
+ class DeviceRegistry:
116
+ def __init__(self, db_path: Path | None = None, audit_path: Path | None = None):
117
+ self.db_path = db_path or devices_db_path()
118
+ self.audit_path = audit_path or audit_log_path()
119
+
120
+ @contextmanager
121
+ def connect(self):
122
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
123
+ try:
124
+ os.chmod(self.db_path.parent, 0o700)
125
+ except OSError:
126
+ pass
127
+ conn = sqlite3.connect(
128
+ str(self.db_path),
129
+ timeout=max(SQLITE_BUSY_TIMEOUT_MS, 1) / 1000,
130
+ )
131
+ try:
132
+ conn.row_factory = sqlite3.Row
133
+ conn.execute("PRAGMA journal_mode=WAL")
134
+ self._ensure_schema(conn)
135
+ yield conn
136
+ conn.commit()
137
+ except Exception:
138
+ conn.rollback()
139
+ raise
140
+ finally:
141
+ conn.close()
142
+ try:
143
+ os.chmod(self.db_path, 0o600)
144
+ except OSError:
145
+ pass
146
+
147
+ def _ensure_schema(self, conn: sqlite3.Connection) -> None:
148
+ conn.execute(
149
+ """
150
+ CREATE TABLE IF NOT EXISTS devices (
151
+ device_id TEXT PRIMARY KEY,
152
+ device_name TEXT NOT NULL,
153
+ token_hash TEXT NOT NULL UNIQUE,
154
+ scopes_json TEXT NOT NULL,
155
+ install_id TEXT NOT NULL,
156
+ created_at REAL NOT NULL,
157
+ last_seen_at REAL,
158
+ revoked_at REAL
159
+ )
160
+ """
161
+ )
162
+ existing = {
163
+ row["name"]
164
+ for row in conn.execute("PRAGMA table_info(devices)").fetchall()
165
+ }
166
+ additive_columns = {
167
+ "relay_device_id": "ALTER TABLE devices ADD COLUMN relay_device_id TEXT",
168
+ "attestation_status": "ALTER TABLE devices ADD COLUMN attestation_status TEXT DEFAULT 'none'",
169
+ "apns_registered_at": "ALTER TABLE devices ADD COLUMN apns_registered_at REAL",
170
+ "relay_pair_secret_ref": "ALTER TABLE devices ADD COLUMN relay_pair_secret_ref TEXT",
171
+ "device_display_name": "ALTER TABLE devices ADD COLUMN device_display_name TEXT",
172
+ "superseded_by_device_id": "ALTER TABLE devices ADD COLUMN superseded_by_device_id TEXT",
173
+ "proof_secret": "ALTER TABLE devices ADD COLUMN proof_secret TEXT",
174
+ }
175
+ for column, statement in additive_columns.items():
176
+ if column not in existing:
177
+ conn.execute(statement)
178
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_devices_token_hash ON devices(token_hash)")
179
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_devices_relay_device_id ON devices(relay_device_id)")
180
+ conn.execute(
181
+ """
182
+ CREATE TABLE IF NOT EXISTS audit_events (
183
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
184
+ ts REAL NOT NULL,
185
+ event TEXT NOT NULL,
186
+ device_id TEXT,
187
+ outcome TEXT NOT NULL,
188
+ path TEXT,
189
+ detail_json TEXT NOT NULL
190
+ )
191
+ """
192
+ )
193
+
194
+ def create_device(
195
+ self,
196
+ *,
197
+ device_name: str,
198
+ scopes: Iterable[str] | None = None,
199
+ install_id: str | None = None,
200
+ token: str | None = None,
201
+ proof_secret: str | None = None,
202
+ device_id: str | None = None,
203
+ relay_device_id: str | None = None,
204
+ attestation_status: str = "none",
205
+ device_display_name: str | None = None,
206
+ relay_pair_secret_ref: str | None = None,
207
+ ) -> CreatedDevice:
208
+ normalized_scopes = tuple(sorted(set(scopes or DEFAULT_DEVICE_SCOPES)))
209
+ token_value = token or generate_token()
210
+ proof_secret_value = proof_secret or generate_proof_secret()
211
+ device_id_value = device_id or generate_device_id()
212
+ install_id_value = install_id or load_or_create_install_id()
213
+ attestation_value = attestation_status if attestation_status in {
214
+ "none",
215
+ "development",
216
+ "production",
217
+ "unsupported",
218
+ "failed",
219
+ } else "failed"
220
+ now = utc_epoch()
221
+ with self.connect() as conn:
222
+ if relay_device_id:
223
+ superseded = conn.execute(
224
+ """
225
+ SELECT device_id FROM devices
226
+ WHERE relay_device_id = ?
227
+ AND revoked_at IS NULL
228
+ """,
229
+ (relay_device_id,),
230
+ ).fetchall()
231
+ for row in superseded:
232
+ old_device_id = row["device_id"]
233
+ conn.execute(
234
+ """
235
+ UPDATE devices
236
+ SET revoked_at = ?, superseded_by_device_id = ?
237
+ WHERE device_id = ?
238
+ """,
239
+ (now, device_id_value, old_device_id),
240
+ )
241
+ self.record_audit(
242
+ "device.superseded",
243
+ device_id=old_device_id,
244
+ outcome="ok",
245
+ detail={
246
+ "relay_device_id": relay_device_id,
247
+ "new_device_id": device_id_value,
248
+ "policy": "relay_repair_supersedes_old_local_token",
249
+ },
250
+ conn=conn,
251
+ )
252
+ conn.execute(
253
+ """
254
+ INSERT INTO devices
255
+ (device_id, device_name, token_hash, scopes_json, install_id,
256
+ created_at, last_seen_at, revoked_at, relay_device_id,
257
+ attestation_status, apns_registered_at, relay_pair_secret_ref,
258
+ device_display_name, proof_secret)
259
+ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?, ?)
260
+ """,
261
+ (
262
+ device_id_value,
263
+ device_name,
264
+ hash_token(token_value),
265
+ json.dumps(normalized_scopes),
266
+ install_id_value,
267
+ now,
268
+ relay_device_id,
269
+ attestation_value,
270
+ relay_pair_secret_ref,
271
+ device_display_name or device_name,
272
+ proof_secret_value,
273
+ ),
274
+ )
275
+ self.record_audit(
276
+ "device.created",
277
+ device_id=device_id_value,
278
+ outcome="ok",
279
+ detail={
280
+ "scopes": list(normalized_scopes),
281
+ "attestation_status": attestation_value,
282
+ "relay_device_id": relay_device_id,
283
+ },
284
+ conn=conn,
285
+ )
286
+ return CreatedDevice(
287
+ device_id_value,
288
+ token_value,
289
+ proof_secret_value,
290
+ normalized_scopes,
291
+ install_id_value,
292
+ relay_device_id,
293
+ attestation_value,
294
+ )
295
+
296
+ def authenticate(
297
+ self,
298
+ token: str | None,
299
+ *,
300
+ required_scopes: Iterable[str] = (),
301
+ path: str | None = None,
302
+ ) -> DeviceAuthResult:
303
+ if not token:
304
+ return DeviceAuthResult(False, 401, "missing_token")
305
+ required = set(required_scopes)
306
+ token_hash = hash_token(token)
307
+ with self.connect() as conn:
308
+ row = conn.execute(
309
+ "SELECT * FROM devices WHERE token_hash = ?",
310
+ (token_hash,),
311
+ ).fetchone()
312
+ if row is None:
313
+ self.record_audit(
314
+ "auth.denied",
315
+ device_id=None,
316
+ outcome="invalid_token",
317
+ path=path,
318
+ conn=conn,
319
+ )
320
+ return DeviceAuthResult(False, 403, "invalid_token")
321
+ if row["revoked_at"] is not None:
322
+ self.record_audit(
323
+ "auth.denied",
324
+ device_id=row["device_id"],
325
+ outcome="revoked",
326
+ path=path,
327
+ conn=conn,
328
+ )
329
+ return DeviceAuthResult(False, 403, "revoked")
330
+ scopes = frozenset(json.loads(row["scopes_json"] or "[]"))
331
+ missing = sorted(required.difference(scopes))
332
+ if missing:
333
+ self.record_audit(
334
+ "auth.denied",
335
+ device_id=row["device_id"],
336
+ outcome="missing_scope",
337
+ path=path,
338
+ detail={"missing": missing},
339
+ conn=conn,
340
+ )
341
+ return DeviceAuthResult(
342
+ False,
343
+ 403,
344
+ "missing_scope",
345
+ device_id=row["device_id"],
346
+ install_id=row["install_id"],
347
+ proof_secret=row["proof_secret"],
348
+ scopes=scopes,
349
+ )
350
+ return DeviceAuthResult(
351
+ True,
352
+ 200,
353
+ "ok",
354
+ device_id=row["device_id"],
355
+ install_id=row["install_id"],
356
+ proof_secret=row["proof_secret"],
357
+ scopes=scopes,
358
+ )
359
+
360
+ def revoke_device(self, device_id: str, *, reason: str = "revoked") -> bool:
361
+ with self.connect() as conn:
362
+ cur = conn.execute(
363
+ "UPDATE devices SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL",
364
+ (utc_epoch(), device_id),
365
+ )
366
+ changed = cur.rowcount > 0
367
+ self.record_audit(
368
+ "device.revoked",
369
+ device_id=device_id,
370
+ outcome="ok" if changed else "not_found",
371
+ detail={"reason": reason},
372
+ conn=conn,
373
+ )
374
+ return changed
375
+
376
+ def revoke_devices_named(self, device_name: str, *, reason: str = "revoked") -> int:
377
+ now = utc_epoch()
378
+ with self.connect() as conn:
379
+ rows = conn.execute(
380
+ "SELECT device_id FROM devices WHERE device_name = ? AND revoked_at IS NULL",
381
+ (device_name,),
382
+ ).fetchall()
383
+ for row in rows:
384
+ device_id = row["device_id"]
385
+ conn.execute(
386
+ "UPDATE devices SET revoked_at = ? WHERE device_id = ?",
387
+ (now, device_id),
388
+ )
389
+ self.record_audit(
390
+ "device.revoked",
391
+ device_id=device_id,
392
+ outcome="ok",
393
+ detail={"reason": reason, "device_name": device_name},
394
+ conn=conn,
395
+ )
396
+ return len(rows)
397
+
398
+ def rotate_token(self, device_id: str) -> str | None:
399
+ token = generate_token()
400
+ with self.connect() as conn:
401
+ cur = conn.execute(
402
+ """
403
+ UPDATE devices
404
+ SET token_hash = ?, revoked_at = NULL
405
+ WHERE device_id = ?
406
+ """,
407
+ (hash_token(token), device_id),
408
+ )
409
+ if cur.rowcount <= 0:
410
+ self.record_audit(
411
+ "device.rotate_token",
412
+ device_id=device_id,
413
+ outcome="not_found",
414
+ conn=conn,
415
+ )
416
+ return None
417
+ self.record_audit(
418
+ "device.rotate_token",
419
+ device_id=device_id,
420
+ outcome="ok",
421
+ conn=conn,
422
+ )
423
+ return token
424
+
425
+ def record_audit(
426
+ self,
427
+ event: str,
428
+ *,
429
+ device_id: str | None,
430
+ outcome: str,
431
+ path: str | None = None,
432
+ detail: dict[str, Any] | None = None,
433
+ conn: sqlite3.Connection | None = None,
434
+ ) -> None:
435
+ audit_detail = _redact_for_audit(detail or {})
436
+ payload = json.dumps(audit_detail, sort_keys=True)
437
+ if conn is not None:
438
+ conn.execute(
439
+ """
440
+ INSERT INTO audit_events (ts, event, device_id, outcome, path, detail_json)
441
+ VALUES (?, ?, ?, ?, ?, ?)
442
+ """,
443
+ (utc_epoch(), event, device_id, outcome, path, payload),
444
+ )
445
+ self.audit_path.parent.mkdir(parents=True, exist_ok=True)
446
+ line = json.dumps({
447
+ "ts": utc_epoch(),
448
+ "event": event,
449
+ "device_id": device_id,
450
+ "outcome": outcome,
451
+ "path": path,
452
+ "detail": audit_detail,
453
+ }, sort_keys=True)
454
+ with self.audit_path.open("a") as fh:
455
+ fh.write(line + "\n")
456
+ try:
457
+ os.chmod(self.audit_path, 0o600)
458
+ except OSError:
459
+ pass