From 2d9c31a782ad92f64ef50d573a4ed81cd1b54cd8 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 11 Mar 2026 08:44:19 +0000 Subject: [PATCH 1/7] Initial Python SDK v2 implementation Complete Allow2 Device SDK for Python matching Node.js SDK v2 behavioral parity. Asyncio-based with httpx for HTTP, custom EventEmitter, SHA-256 PIN verification with constant-time comparison, progressive lockout, warning fire-once semantics, offline deny-by-default, and full feedback support with feedbackDeviceAuth. 19 source files, ~2,500 lines covering: DeviceDaemon orchestrator, Checker loop, ChildShield, PairingWizard, WarningScheduler, OfflineHandler, RequestManager, UpdatePoller, FeedbackManager, and plaintext/keyring credential backends. Co-Authored-By: claude-flow --- LICENSE | 5 + docs/DESIGN.md | 1466 +++++++++++++++++++++ pyproject.toml | 69 + src/allow2/__init__.py | 42 + src/allow2/api.py | 307 +++++ src/allow2/checker.py | 226 ++++ src/allow2/child_resolver/__init__.py | 10 + src/allow2/child_resolver/env_var.py | 30 + src/allow2/child_resolver/linux_user.py | 47 + src/allow2/child_resolver/selector.py | 19 + src/allow2/child_shield.py | 239 ++++ src/allow2/credentials/__init__.py | 33 + src/allow2/credentials/keyring_backend.py | 62 + src/allow2/credentials/plaintext.py | 55 + src/allow2/daemon.py | 553 ++++++++ src/allow2/events.py | 43 + src/allow2/feedback.py | 17 + src/allow2/models.py | 177 +++ src/allow2/offline.py | 89 ++ src/allow2/pairing.py | 411 ++++++ src/allow2/py.typed | 0 src/allow2/request.py | 108 ++ src/allow2/updates.py | 126 ++ src/allow2/warnings.py | 57 + 24 files changed, 4191 insertions(+) create mode 100644 LICENSE create mode 100644 docs/DESIGN.md create mode 100644 pyproject.toml create mode 100644 src/allow2/__init__.py create mode 100644 src/allow2/api.py create mode 100644 src/allow2/checker.py create mode 100644 src/allow2/child_resolver/__init__.py create mode 100644 src/allow2/child_resolver/env_var.py create mode 100644 src/allow2/child_resolver/linux_user.py create mode 100644 src/allow2/child_resolver/selector.py create mode 100644 src/allow2/child_shield.py create mode 100644 src/allow2/credentials/__init__.py create mode 100644 src/allow2/credentials/keyring_backend.py create mode 100644 src/allow2/credentials/plaintext.py create mode 100644 src/allow2/daemon.py create mode 100644 src/allow2/events.py create mode 100644 src/allow2/feedback.py create mode 100644 src/allow2/models.py create mode 100644 src/allow2/offline.py create mode 100644 src/allow2/pairing.py create mode 100644 src/allow2/py.typed create mode 100644 src/allow2/request.py create mode 100644 src/allow2/updates.py create mode 100644 src/allow2/warnings.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bce3d4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +Copyright (c) 2024-2026 Allow2 Pty Ltd + +All rights reserved. + +SEE LICENSE IN LICENSE FILE at https://github.com/Allow2/allow2python diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..fd83095 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,1466 @@ +# Allow2 Python SDK v2 -- Design Document + +**Status:** Draft +**Date:** 2026-03-11 +**Gold Standard:** Node.js SDK v2 (`/workspace/ai/allow2/sdk/node/src/`, 14 files, ~2,450 lines) +**Target:** Functional equivalence with Node.js SDK v2 + +--- + +## 1. Overview + +The Allow2 Python SDK v2 is a device-side SDK for integrating Allow2 Parental Freedom into Python applications: games (Pygame, Ren'Py), IoT devices (Raspberry Pi), desktop apps, automation scripts, and system services. + +It mirrors the Node.js SDK v2 architecture exactly: same state machine, same API endpoints, same event names, same enforcement semantics. A developer familiar with one SDK can immediately use the other. + +### Package Identity + +- **PyPI name:** `allow2` +- **Import:** `import allow2` or `from allow2 import DeviceDaemon` +- **Version:** `2.0.0a1` (PEP 440 alpha) +- **Python:** 3.10+ +- **License:** Same as Node SDK (SEE LICENSE IN LICENSE FILE) + +--- + +## 2. File Structure + +``` +sdk/python/ +├── pyproject.toml +├── LICENSE +└── src/ + └── allow2/ + ├── __init__.py # Public exports + ├── py.typed # PEP 561 marker + ├── models.py # Dataclasses for all types + ├── api.py # Allow2Api HTTP client + ├── daemon.py # DeviceDaemon orchestrator + ├── checker.py # Check loop + per-activity enforcement + ├── child_shield.py # PIN verification + lockout + ├── pairing.py # QR/PIN pairing wizard + ├── warnings.py # Warning scheduler + ├── offline.py # Offline cache + grace period + ├── request.py # Request More Time + ├── updates.py # getUpdates polling + ├── feedback.py # Feedback submit/load/reply (extracted from daemon) + ├── credentials/ + │ ├── __init__.py # CredentialBackend protocol + factory + │ ├── plaintext.py # JSON file backend (~/.allow2/credentials.json) + │ └── keyring_backend.py # keyring-based backend + └── child_resolver/ + ├── __init__.py # ChildResolver protocol + ├── linux_user.py # OS username -> child mapping + ├── selector.py # Interactive selector (returns None) + └── env_var.py # ALLOW2_CHILD_ID env var (for scripts/CI) +``` + +**File count:** 18 source files (vs Node's 14). The delta is: +- `models.py` (new; Node uses ad-hoc objects, Python uses typed dataclasses) +- `feedback.py` (extracted; in Node it lives partly in daemon.js and api.js) +- `keyring_backend.py` (new; Node has libsecret TODO, we ship keyring) +- `env_var.py` (new; useful for headless/script scenarios) + +--- + +## 3. Dependency Choices + +### Required Dependencies (installed with `pip install allow2`) + +| Package | Version | Rationale | +|---------|---------|-----------| +| `httpx` | `>=0.27` | Modern async-first HTTP client. Supports sync and async on the same API. Has built-in timeout, connection pooling, HTTP/2. Replaces Node's native `fetch`. | + +### Optional Dependencies (extras) + +| Extra | Packages | Rationale | +|-------|----------|-----------| +| `[keyring]` | `keyring>=25` | System keyring credential storage (GNOME Keyring, KDE Wallet, macOS Keychain, Windows Credential Locker). Replaces Node's libsecret TODO. | +| `[server]` | `aiohttp>=3.9` | Local pairing web server. Equivalent to Node's Express. Only needed if the integration wants to serve the pairing HTML page. Most integrations will handle pairing via their own UI. | +| `[all]` | All of the above | Convenience extra for full feature set. | + +### Why httpx over alternatives + +- **`urllib`/`urllib3`**: No native async. Would require `asyncio.to_thread()` wrappers, losing true async benefits (connection pooling, multiplexing). +- **`aiohttp`**: Async-only. Cannot be used in sync contexts without running an event loop. httpx supports both sync (`httpx.Client`) and async (`httpx.AsyncClient`) on the same code. +- **`requests`**: Sync-only, requires `aiohttp` or `httpx` for async anyway. httpx is the modern successor. + +### Zero-dependency fallback + +The SDK will NOT provide a zero-dependency fallback. Python 3.10+ always has `urllib`, but wrapping it to match httpx's interface would be significant work for marginal benefit. httpx is a pure-Python wheel with no native compilation -- it installs everywhere Python runs. + +--- + +## 4. Type System (models.py) + +All data structures are `dataclasses` with full type annotations. This provides IDE autocompletion, runtime validation, and JSON serialization without external schema libraries. + +```python +# --- models.py --- + +from __future__ import annotations +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class DaemonState(str, Enum): + """Mirrors Node SDK states exactly.""" + UNPAIRED = "unpaired" + PAIRING = "pairing" + PAIRED = "paired" + ENFORCING = "enforcing" + PARENT = "parent" + + +class WarningLevel(str, Enum): + INFO = "info" + URGENT = "urgent" + FINAL = "final" + COUNTDOWN = "countdown" + + +class FeedbackCategory(str, Enum): + BYPASS = "bypass" + MISSING_FEATURE = "missing_feature" + NOT_WORKING = "not_working" + QUESTION = "question" + OTHER = "other" + + +class VerificationLevel(str, Enum): + HONOUR = "honour" + PIN = "pin" + PARENT_ONLY = "parent-only" + + +@dataclass +class Activity: + """An activity to monitor (e.g., Gaming, Internet, Screen Time).""" + id: int + + def to_check_map(self) -> dict[str, int]: + return {str(self.id): 1} + + +@dataclass +class Child: + """A child entity from the controller's account.""" + id: int + name: str + pin_hash: str | None = None + pin_salt: str | None = None + avatar_url: str | None = None + color: str | None = None + has_account: bool = False + linked_user_id: int | None = None + os_username: str | None = None + last_used_at: str | None = None # ISO 8601 + + +@dataclass +class Credentials: + """Stored pairing credentials.""" + uuid: str + user_id: int + pair_id: int + pair_token: str + children: list[Child] = field(default_factory=list) + + +@dataclass +class ActivityState: + """Per-activity enforcement state from a check response.""" + allowed: bool + remaining: float # seconds, math.inf if unlimited + + +@dataclass +class CheckResult: + """Parsed result from /serviceapi/check.""" + activities: dict[str, ActivityState] = field(default_factory=dict) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class WarningThreshold: + """A single warning threshold.""" + remaining: int # seconds + level: WarningLevel + + +@dataclass +class PairingInfo: + """Returned by PairingWizard.start().""" + pin: str + port: int + url: str + qr_url: str + connected: bool + + +@dataclass +class RequestResult: + """Returned by create_request().""" + request_id: str + status_secret: str + + +@dataclass +class DeviceContext: + """Context sent with feedback submissions.""" + device_name: str = "Allow2 Device" + platform: str = "python" + sdk_version: str = "2.0.0" + product_name: str = "allow2" +``` + +### Serialization Convention + +All dataclasses will have a `to_dict()` method (via a mixin or utility function) for JSON serialization, and a `@classmethod from_dict(cls, data: dict)` for deserialization from API responses. Field names in Python use `snake_case`; serialization maps to `camelCase` for API compatibility. + +--- + +## 5. Event / Callback System + +### Design Decision: Callback Registry (not asyncio.Event, not third-party) + +Python has no built-in `EventEmitter`. The options: + +1. **asyncio.Event / asyncio.Condition**: One-shot or condition-based; wrong abstraction for multi-listener event streams. +2. **Third-party (pyee, blinker)**: Adds dependency for a simple pattern. +3. **Custom callback registry**: Minimal, typed, zero-dependency. + +We use option 3: a simple `EventEmitter` mixin that mirrors the Node.js pattern. + +```python +# Embedded in daemon.py (or a small _events.py internal module) + +from typing import Callable, Any +from collections import defaultdict + +class EventEmitter: + """ + Minimal EventEmitter matching the Node.js pattern. + Supports: on(), off(), once(), emit(). + Thread-safe via a simple lock for add/remove. + """ + + def __init__(self) -> None: + self._listeners: dict[str, list[Callable]] = defaultdict(list) + + def on(self, event: str, callback: Callable[..., Any]) -> None: + """Register a listener for an event. Same callback can be registered multiple times.""" + self._listeners[event].append(callback) + + def off(self, event: str, callback: Callable[..., Any]) -> None: + """Remove a specific listener.""" + try: + self._listeners[event].remove(callback) + except ValueError: + pass + + def once(self, event: str, callback: Callable[..., Any]) -> None: + """Register a listener that fires once then auto-removes.""" + def wrapper(*args: Any, **kwargs: Any) -> Any: + self.off(event, wrapper) + return callback(*args, **kwargs) + self.on(event, wrapper) + + def emit(self, event: str, *args: Any, **kwargs: Any) -> None: + """ + Fire all listeners for an event. + If a listener is a coroutine function, it is scheduled on the + running event loop (fire-and-forget). If no loop is running, + it is called synchronously (assuming it returns a coroutine + that the caller can await or ignore). + """ + for listener in self._listeners[event][:]: # copy to allow mutation + result = listener(*args, **kwargs) + # If the listener returned a coroutine, schedule it + if asyncio.iscoroutine(result): + try: + loop = asyncio.get_running_loop() + loop.create_task(result) + except RuntimeError: + pass # No running loop; coroutine is discarded + + def remove_all_listeners(self, event: str | None = None) -> None: + """Remove all listeners, or all for a specific event.""" + if event is None: + self._listeners.clear() + else: + self._listeners.pop(event, None) +``` + +### Event Names (exact match with Node SDK) + +All event names use the same kebab-case strings as the Node SDK for cross-SDK consistency: + +| Event | Payload | Emitted By | +|-------|---------|------------| +| `"paired"` | `{"user_id": int, "children": list[Child]}` | Daemon | +| `"unpaired"` | `{"error": Exception \| None}` | Daemon, Checker | +| `"pairing-required"` | `PairingInfo` | Daemon | +| `"pairing-error"` | `Exception` | Daemon | +| `"pairing-connection-status"` | `{"connected": bool}` | Daemon | +| `"child-select-required"` | `{"children": list[Child]}` | Daemon | +| `"child-selected"` | `{"child_id": int, "name": str \| None}` | Daemon | +| `"child-pin-failed"` | `{"child_id": int, "attempts_remaining": int}` | Daemon, ChildShield | +| `"child-locked-out"` | `{"child_id": int}` or `int` (seconds) | Daemon, ChildShield | +| `"parent-mode"` | `{}` | Daemon | +| `"parent-mode-entered"` | (none) | ChildShield | +| `"session-timeout"` | `{}` | Daemon, ChildShield | +| `"status-requested"` | `{"state": DaemonState, ...}` | Daemon | +| `"warning"` | `{"level": WarningLevel, "activity_id": str, "remaining": int}` | Checker (via WarningScheduler) | +| `"activity-blocked"` | `{"activity_id": int, "activity": str, "remaining": 0}` | Checker | +| `"soft-lock"` | `{"reason": str}` | Checker | +| `"hard-lock"` | `{"reason": str}` | Checker | +| `"unlock"` | `{"reason": str, "activity_id": int \| None}` | Checker | +| `"offline-grace"` | `{"since": float, "grace_remaining": float}` | Checker | +| `"offline-deny"` | `{"since": float, "offline_duration": float}` | Checker | +| `"children-updated"` | `{"children": list[Child]}` | Daemon | +| `"request-created"` | `{"request_id": str}` | RequestManager | +| `"request-approved"` | `{"request_id": str, ...}` | Daemon, RequestManager | +| `"request-denied"` | `{"request_id": str, ...}` | Daemon, RequestManager | +| `"request-timeout"` | (none) | RequestManager | +| `"request-error"` | `Exception` | RequestManager | +| `"feedback-submitted"` | `{"discussion_id": str, "category": str}` | Daemon | +| `"feedback-loaded"` | `{"discussions": list}` | Daemon | +| `"feedback-reply-sent"` | `{"discussion_id": str, "message_id": str}` | Daemon | +| `"extension"` | `{"child_id": int, "activity": int, ...}` | UpdatePoller | +| `"day-type-changed"` | `{"child_id": int, "day_type": str}` | UpdatePoller | +| `"quota-updated"` | `{"child_id": int, "activity": int, ...}` | UpdatePoller | +| `"ban"` | `{"child_id": int, "activity": int, "banned": bool}` | UpdatePoller | + +--- + +## 6. File-by-File Specification + +### 6.1 `api.py` -- Allow2Api + +Port of `api.js` (302 lines). HTTP client wrapping all Allow2 REST endpoints. + +```python +class Allow2Api: + """ + Low-level async HTTP client for the Allow2 REST API. + + VID/Token resolution order: explicit arg > env var > baked-in default. + Env vars: ALLOW2_API_URL, ALLOW2_VID, ALLOW2_TOKEN. + """ + + def __init__( + self, + *, + api_url: str | None = None, + vid: int | None = None, + token: str | None = None, + timeout: float = 15.0, + ) -> None: ... + + # -- Properties -- + @property + def base_url(self) -> str: ... + @property + def vid(self) -> int: ... + + # -- Internal -- + async def _fetch(self, path: str, *, method: str = "GET", + json_body: dict | None = None, + headers: dict | None = None) -> dict: ... + """ + Core HTTP method. Uses httpx.AsyncClient. + - Sets Content-Type: application/json + - Parses JSON response + - On non-2xx: raises Allow2ApiError with .status, .code, .body + - Timeout via httpx.Timeout + """ + + # -- Pairing -- + async def init_qr_pairing(self, *, uuid: str, device_name: str, + platform: str = "python") -> dict: ... + async def init_pin_pairing(self, *, uuid: str, device_name: str, + platform: str = "python") -> dict: ... + async def check_pairing_status(self, pairing_session_id: str) -> dict: ... + + # -- Check -- + async def check(self, *, user_id: int, pair_id: int, pair_token: str, + child_id: int, activities: dict[str, int], + tz: str, log: bool = True) -> dict: ... + + # -- Updates -- + async def get_updates(self, *, user_id: int, pair_id: int, + pair_token: str, + timestamp_millis: int | None = None) -> dict: ... + + # -- Requests -- + async def create_request(self, *, user_id: int, pair_id: int, + pair_token: str, child_id: int, + duration: int, activity: int, + message: str | None = None) -> dict: ... + async def get_request_status(self, request_id: str, + status_secret: str) -> dict: ... + + # -- Feedback -- + async def submit_feedback(self, *, user_id: int, pair_id: int, + pair_token: str, child_id: int | None, + vid: int | None, category: str, + message: str, + device_context: dict | None = None) -> dict: ... + async def load_feedback(self, *, user_id: int, pair_id: int, + pair_token: str) -> dict: ... + async def feedback_reply(self, *, user_id: int, pair_id: int, + pair_token: str, discussion_id: str, + message: str) -> dict: ... + + # -- Usage -- + async def log_usage(self, *, user_id: int, pair_id: int, + pair_token: str, child_id: int, + activities: dict) -> dict: ... +``` + +**Error class:** + +```python +class Allow2ApiError(Exception): + """Raised on non-2xx API responses.""" + def __init__(self, message: str, *, status: int, code: str | None = None, + body: dict | None = None) -> None: + super().__init__(message) + self.status = status + self.code = code + self.body = body +``` + +**httpx client lifecycle:** A single `httpx.AsyncClient` instance is created lazily on first request and reused for connection pooling. Exposes `async close()` for clean shutdown. Supports context manager protocol (`async with Allow2Api() as api:`). + +### 6.2 `daemon.py` -- DeviceDaemon + +Port of `daemon.js` (769 lines). Main orchestrator. + +```python +class DeviceDaemon(EventEmitter): + """ + Main entry point for the Allow2 Device SDK. + + Manages the full device lifecycle: + unpaired -> pairing -> paired -> enforcing + -> parent (unrestricted) + """ + + def __init__( + self, + *, + activities: list[Activity], + credential_backend: CredentialBackend, + child_resolver: ChildResolver, + device_name: str = "Allow2 Device", + check_interval: int = 60, # seconds + grace_period: int = 300, # seconds + hard_lock_timeout: int = 300, # seconds + warnings: list[WarningThreshold] | None = None, + pairing_port: int = 3000, + api_url: str | None = None, + vid: int | None = None, + token: str | None = None, + ) -> None: ... + + # -- Properties (read-only) -- + @property + def api(self) -> Allow2Api: ... + @property + def credentials(self) -> Credentials | None: ... + @property + def child_id(self) -> int | None: ... + @property + def running(self) -> bool: ... + @property + def paired(self) -> bool: ... + @property + def state(self) -> DaemonState: ... + @property + def is_parent_mode(self) -> bool: ... + @property + def can_submit_feedback(self) -> bool: ... + + # -- Lifecycle -- + async def start(self) -> None: ... + def stop(self) -> None: ... + async def open_app(self) -> None: ... + def close_app(self) -> None: ... + def enter_parent_mode(self) -> None: ... + async def on_pairing_complete(self, credentials: Credentials) -> None: ... + + # -- Child Management -- + async def select_child(self, child_id: int, name: str | None = None) -> None: ... + def child_pin_failed(self, child_id: int, attempts_remaining: int) -> None: ... + def session_timeout(self) -> None: ... + + # -- Requests -- + async def request_more_time(self, *, duration: int, activity: int, + message: str | None = None) -> dict: ... + async def poll_request_status(self, request_id: str, + status_secret: str) -> dict: ... + + # -- Feedback -- + async def submit_feedback(self, *, category: FeedbackCategory, + message: str, + device_context: DeviceContext | None = None) -> dict: ... + async def load_device_feedback(self) -> dict: ... + async def reply_to_feedback(self, discussion_id: str, message: str) -> dict: ... + + @staticmethod + def feedback_params_to_text(category: FeedbackCategory) -> str: ... + + # -- Internal -- + async def _start_pairing(self) -> None: ... + async def _on_paired(self, credentials: Credentials) -> None: ... + async def _begin_enforcement(self) -> None: ... + async def _resolve_child(self) -> None: ... + def _start_checker(self) -> None: ... + def _start_heartbeat(self) -> None: ... + def _stop_heartbeat(self) -> None: ... + def _update_last_used(self, child_id: int) -> None: ... +``` + +**Async task management:** The daemon uses `asyncio.create_task()` for the check loop, heartbeat, and pairing poll. All tasks are tracked in a `set[asyncio.Task]` and cancelled in `stop()`. Timer-based operations (heartbeat, hard-lock timeout) use `asyncio.call_later()` or `asyncio.sleep()` in a loop. + +### 6.3 `checker.py` -- Checker + +Port of `checker.js` (296 lines). + +```python +SCREEN_TIME_ACTIVITY: int = 8 + +class Checker: + """ + Periodic check loop with per-activity enforcement. + + Not an EventEmitter itself -- delegates to the daemon's emit(). + """ + + def __init__( + self, + *, + api: Allow2Api, + emit: Callable[..., None], + credentials: Credentials, + child_id: int, + activities: list[Activity], + check_interval: int = 60, # seconds + hard_lock_timeout: int = 300, # seconds + grace_period: int = 300, # seconds + warning_thresholds: list[WarningThreshold] | None = None, + ) -> None: ... + + # -- Public -- + def start(self) -> None: ... # Creates asyncio task for _run_check loop + def stop(self) -> None: ... # Cancels task, clears timers + def on_time_extended(self, activity_id: int) -> None: ... + def get_remaining(self) -> dict[str, ActivityState] | None: ... + def reset(self, child_id: int) -> None: ... + + # -- Internal -- + async def _run_check(self) -> None: ... # Loop: _do_check() then sleep(interval) + async def _do_check(self) -> None: ... # API call, clear offline, process result + def _process_result(self, result: dict) -> None: ... + def _trigger_soft_lock(self, reason: str) -> None: ... + def _handle_error(self, err: Exception) -> None: ... +``` + +**Key behavioral details ported exactly:** +- `_state: dict[str, ActivityState]` tracks per-activity allowed/remaining. +- Detects allowed-to-blocked transition per activity; emits `activity-blocked`. +- Screen Time (ID 8) exhaustion triggers `soft-lock`. +- All activities blocked also triggers `soft-lock`. +- `soft-lock` starts a hard-lock countdown (`asyncio.call_later`). +- Blocked-to-allowed cancels soft-lock, emits `unlock`. +- HTTP 401 emits `unpaired` and stops the loop. +- Network errors trigger offline grace / deny logic. +- Timezone via `time.tzname` or `zoneinfo` (Python 3.9+ stdlib). + +### 6.4 `child_shield.py` -- ChildShield + +Port of `child-shield.js` (411 lines). + +```python +MAX_PIN_ATTEMPTS: int = 5 +LOCKOUT_DURATION: float = 300.0 # seconds +DEFAULT_SESSION_TIMEOUT: float = 300.0 # seconds + +def hash_pin(pin: str, salt: str) -> str: + """SHA-256(pin + salt) as lowercase hex. Uses hashlib.""" + ... + +def safe_compare(a: str, b: str) -> bool: + """Constant-time comparison via hmac.compare_digest.""" + ... + +class ChildShield(EventEmitter): + """ + Child identification, PIN verification, and session management. + """ + + def __init__( + self, + *, + children: list[Child], + verification_level: VerificationLevel = VerificationLevel.PIN, + session_timeout: float = DEFAULT_SESSION_TIMEOUT, + on_select_required: Callable | None = None, + ) -> None: ... + + # -- Public -- + def select_child(self, child_id: int, pin: str | None = None) -> bool: ... + def select_parent(self, pin: str) -> bool: ... + def clear_selection(self) -> None: ... + def get_current_child(self) -> Child | None: ... + def is_parent_mode(self) -> bool: ... + def record_activity(self) -> None: ... + def update_children(self, children: list[Child]) -> None: ... + def get_children(self) -> list[dict]: ... # Safe copy without PINs + def destroy(self) -> None: ... + + # -- Internal -- + def _find_child(self, child_id: int) -> Child | None: ... + def _find_parent_entry(self) -> Child | None: ... + def _activate_child(self, child: Child) -> None: ... + def _enter_parent_mode(self) -> None: ... + def _reset_session_timer(self) -> None: ... + def _stop_session_timer(self) -> None: ... + def _on_session_timeout(self) -> None: ... + def _get_attempt_record(self, key: str | int) -> dict: ... + def _is_locked_out(self, key: str | int) -> bool: ... + def _lockout_remaining(self, key: str | int) -> float: ... + def _record_failed_attempt(self, key: str | int) -> None: ... + def _clear_attempts(self, key: str | int) -> None: ... +``` + +**Session timer:** Uses `threading.Timer` (not asyncio) because ChildShield may be used outside an async context (e.g., in a Pygame main loop). The timer calls `_on_session_timeout()` which emits events synchronously. + +### 6.5 `pairing.py` -- PairingWizard + +Port of `pairing.js` (698 lines, including HTML templates). + +```python +class PairingWizard(EventEmitter): + """ + Manages one-time device pairing. Parents NEVER enter credentials on device. + + Flow: + 1. Call API to register pairing session (initPINPairing) + 2. API returns server-assigned PIN + session ID + 3. Device displays PIN (and QR deep link) + 4. Parent enters PIN in Allow2 app on their phone + 5. Wizard polls checkPairingStatus until confirmed + 6. On confirmation: store credentials, emit 'paired' + """ + + def __init__( + self, + *, + api: Allow2Api, + credential_backend: CredentialBackend, + port: int = 3000, + device_name: str = "Python Device", + uuid: str | None = None, + ) -> None: ... + + # -- Public -- + async def start(self) -> PairingInfo: ... + async def stop(self) -> None: ... + def get_pin(self) -> str | None: ... + def get_qr_url(self) -> str | None: ... + async def complete_pairing(self, pairing_data: dict) -> Credentials: ... + + # -- Internal -- + async def _load_or_create_uuid(self) -> str: ... + def _start_init_retry(self) -> None: ... + def _start_polling(self) -> None: ... + async def _start_http_server(self) -> None: ... # Optional, uses aiohttp if available +``` + +**HTTP server design:** The local pairing web server is optional. If `aiohttp` is installed (via `[server]` extra), PairingWizard starts an aiohttp server serving the same HTML as the Node SDK. If `aiohttp` is not installed, `start()` still works -- it just skips the web server and returns the PairingInfo for the integration to display however it wants. + +**Polling:** Uses `asyncio.create_task` with a loop that sleeps 5s between polls. Max 360 polls (30 minutes). Retry logic on init failure matches Node SDK exactly (5s retry interval). + +### 6.6 `warnings.py` -- WarningScheduler + +Port of `warnings.js` (87 lines). Smallest module. + +```python +DEFAULT_THRESHOLDS: list[WarningThreshold] = [ + WarningThreshold(remaining=900, level=WarningLevel.INFO), # 15 min + WarningThreshold(remaining=300, level=WarningLevel.URGENT), # 5 min + WarningThreshold(remaining=60, level=WarningLevel.FINAL), # 1 min + WarningThreshold(remaining=30, level=WarningLevel.COUNTDOWN), # 30 sec +] + +class WarningScheduler: + """ + Tracks remaining time per activity and emits 'warning' events + when thresholds are crossed. Fire-once-per-level per activity. + """ + + def __init__( + self, + *, + emit: Callable[..., None], + thresholds: list[WarningThreshold] | None = None, + ) -> None: ... + + def update(self, activities: dict[str, dict]) -> None: ... + """Called by Checker after each check. Evaluates thresholds.""" + + def reset_activity(self, activity_id: str) -> None: ... + def reset_all(self) -> None: ... +``` + +Internal state: `_fired: dict[str, set[WarningLevel]]` -- maps activity ID to set of levels already fired. + +### 6.7 `offline.py` -- OfflineHandler + +Port of `offline.js` (133 lines). + +```python +DEFAULT_GRACE_PERIOD: int = 300 # seconds + +class OfflineHandler(EventEmitter): + """ + Caches last successful check result. Enforces grace period. + After grace: deny-by-default. + Cache persisted to ~/.allow2/cache.json. + """ + + def __init__( + self, + *, + grace_period: int = DEFAULT_GRACE_PERIOD, + cache_path: str | None = None, # Default: ~/.allow2/cache.json + ) -> None: ... + + async def cache_result(self, check_result: dict) -> None: ... + async def get_cached_result(self) -> dict | None: ... + async def get_grace_elapsed(self) -> float: ... # seconds, math.inf if no cache + async def is_in_grace_period(self) -> bool: ... + async def should_deny(self) -> bool: ... + + # -- Internal -- + async def _write_disk(self, data: dict) -> None: ... + async def _load_disk(self) -> None: ... +``` + +Uses `aiofiles` if available, otherwise `asyncio.to_thread(pathlib.Path.write_text, ...)` for non-blocking disk I/O. File permissions set via `os.chmod(path, 0o600)`. + +### 6.8 `request.py` -- RequestManager + +Port of `request.js` (124 lines). + +```python +class RequestManager(EventEmitter): + """Request More Time flow with polling.""" + + def __init__( + self, + *, + api: Allow2Api, + poll_interval: float = 5.0, # seconds + timeout: float = 300.0, # seconds + ) -> None: ... + + async def create_request( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + child_id: int, + duration: int, + activity: int, + message: str | None = None, + ) -> RequestResult: ... + + def start_polling(self, request_id: str, status_secret: str) -> None: ... + def stop_polling(self) -> None: ... + + # -- Internal -- + async def _poll(self, request_id: str, status_secret: str) -> None: ... +``` + +### 6.9 `updates.py` -- UpdatePoller + +Port of `updates.js` (179 lines). + +```python +class UpdatePoller(EventEmitter): + """ + Polls GET /api/getUpdates for delta changes. + Emits: extension, day-type-changed, quota-updated, ban, children-updated. + """ + + def __init__( + self, + *, + api: Allow2Api, + poll_interval: float = 30.0, # seconds + ) -> None: ... + + def start(self, credentials: Credentials) -> None: ... + def stop(self) -> None: ... + + # -- Internal -- + async def _poll(self) -> None: ... + async def _fetch_updates(self) -> None: ... + def _process_updates(self, result: dict) -> None: ... + def _handle_error(self, err: Exception) -> None: ... +``` + +### 6.10 `feedback.py` -- Feedback helpers + +Extracted from Node's `daemon.js` and `api.js` for cleaner separation. The daemon delegates to these, but they can also be used standalone. + +```python +VALID_CATEGORIES: list[FeedbackCategory] = list(FeedbackCategory) + +CATEGORY_LABELS: dict[FeedbackCategory, str] = { + FeedbackCategory.BYPASS: "Bypass / Circumvention report", + FeedbackCategory.MISSING_FEATURE: "Missing Feature report", + FeedbackCategory.NOT_WORKING: "Not Working report", + FeedbackCategory.QUESTION: "Question", + FeedbackCategory.OTHER: "General feedback", +} + +def feedback_category_to_text(category: FeedbackCategory) -> str: ... +``` + +The actual API calls live in `Allow2Api`. The daemon's `submit_feedback()`, `load_device_feedback()`, and `reply_to_feedback()` methods construct the params and call through `self._api`. + +### 6.11 `credentials/__init__.py` -- CredentialBackend Protocol + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class CredentialBackend(Protocol): + """ + Interface for credential storage backends. + All methods are async to support both file I/O and keyring lookups. + """ + + async def store(self, data: dict) -> None: ... + async def load(self) -> dict | None: ... + async def clear(self) -> None: ... + async def load_last_used(self) -> dict[str, str]: ... # childId -> ISO timestamp + async def update_last_used(self, child_id: int) -> None: ... + + +async def create_backend( + backend_type: str = "plaintext", + **kwargs: Any, +) -> CredentialBackend: + """ + Factory for credential backends. + Types: 'plaintext', 'keyring'. + """ + ... +``` + +### 6.12 `credentials/plaintext.py` -- PlaintextBackend + +Port of `credentials/plaintext.js` (97 lines). + +```python +class PlaintextBackend: + """ + Stores credentials as JSON in ~/.allow2/credentials.json. + File permissions: 0o600. Directory permissions: 0o700. + """ + + def __init__(self, *, path: str | None = None) -> None: ... + # Default: Path.home() / ".allow2" / "credentials.json" + + async def store(self, data: dict) -> None: ... + async def load(self) -> dict | None: ... + async def clear(self) -> None: ... + async def load_last_used(self) -> dict[str, str]: ... + async def update_last_used(self, child_id: int) -> None: ... +``` + +Uses `pathlib.Path` for path manipulation, `json` for serialization, `os.chmod` for permissions. + +### 6.13 `credentials/keyring_backend.py` -- KeyringBackend + +New (Node has libsecret as TODO). + +```python +class KeyringBackend: + """ + Stores credentials in the system keyring via the `keyring` package. + Service name: 'allow2-sdk'. + Keys: 'credentials', 'last-used'. + Values: JSON-serialized strings. + + Requires: pip install allow2[keyring] + """ + + def __init__(self, *, service_name: str = "allow2-sdk") -> None: ... + + async def store(self, data: dict) -> None: ... + async def load(self) -> dict | None: ... + async def clear(self) -> None: ... + async def load_last_used(self) -> dict[str, str]: ... + async def update_last_used(self, child_id: int) -> None: ... +``` + +All keyring calls are wrapped in `asyncio.to_thread()` since `keyring` is synchronous and may block on D-Bus (GNOME Keyring) or other system calls. + +### 6.14 `child_resolver/__init__.py` -- ChildResolver Protocol + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class ChildResolver(Protocol): + """ + Interface for resolving which child is using the device. + Can be a function or an object with resolve(). + """ + + async def resolve(self, children: list[Child]) -> dict | None: + """ + Return {"child_id": int, "child_name": str} or None. + Returning None means interactive selection is required. + """ + ... +``` + +The daemon also accepts a plain `Callable[[list[Child]], dict | None]` (sync or async) for simple use cases, matching the Node SDK's function-or-object pattern. + +### 6.15 `child_resolver/linux_user.py` + +Port of `child-resolver/linux-user.js` (81 lines). + +```python +def get_linux_username() -> str: + """Get current username from $USER or os.getlogin().""" + ... + +async def resolve(children: list[Child]) -> dict | None: + """ + Match current Linux username to a child's name or os_username field. + Case-insensitive. Skips parent entry (id=0 or name='__parent__'). + Returns {"child_id": int, "child_name": str} or None. + """ + ... + +# Also available as a class for Protocol compliance: +class LinuxUserResolver: + async def resolve(self, children: list[Child]) -> dict | None: + return await resolve(children) +``` + +### 6.16 `child_resolver/selector.py` + +Port of `child-resolver/selector.js` (50 lines). + +```python +async def resolve(children: list[Child]) -> None: + """Always returns None. Selection must happen via UI.""" + return None + +class SelectorResolver: + async def resolve(self, children: list[Child]) -> None: + return None + +def request_selection(child_shield: ChildShield) -> None: + """Emit child-select-required on the given ChildShield.""" + child_shield.emit("child-select-required", child_shield.get_children()) +``` + +### 6.17 `child_resolver/env_var.py` (new) + +Python-specific addition for headless/script scenarios. + +```python +class EnvVarResolver: + """ + Resolves child from ALLOW2_CHILD_ID environment variable. + Useful for automation scripts, CI, Raspberry Pi headless setups. + """ + + def __init__(self, *, env_var: str = "ALLOW2_CHILD_ID") -> None: ... + + async def resolve(self, children: list[Child]) -> dict | None: + """ + If ALLOW2_CHILD_ID is set, find matching child by ID. + Returns {"child_id": int, "child_name": str} or None. + """ + ... +``` + +### 6.18 `__init__.py` -- Public Exports + +```python +""" +Allow2 Device SDK v2 -- Parental Freedom for apps and devices. +https://developer.allow2.com +""" + +# Core +from allow2.daemon import DeviceDaemon +from allow2.child_shield import ChildShield +from allow2.pairing import PairingWizard +from allow2.api import Allow2Api, Allow2ApiError + +# Utilities +from allow2.updates import UpdatePoller +from allow2.request import RequestManager +from allow2.offline import OfflineHandler +from allow2.warnings import WarningScheduler +from allow2.feedback import feedback_category_to_text + +# Models +from allow2.models import ( + Activity, Child, Credentials, DaemonState, WarningLevel, + WarningThreshold, FeedbackCategory, VerificationLevel, + PairingInfo, RequestResult, DeviceContext, ActivityState, + CheckResult, +) + +# Credential backends +from allow2.credentials import CredentialBackend, create_backend +from allow2.credentials.plaintext import PlaintextBackend + +# Child resolvers +from allow2.child_resolver.linux_user import LinuxUserResolver +from allow2.child_resolver.selector import SelectorResolver +from allow2.child_resolver.env_var import EnvVarResolver + +__version__ = "2.0.0a1" +__all__ = [ + "DeviceDaemon", "ChildShield", "PairingWizard", "Allow2Api", + "Allow2ApiError", "UpdatePoller", "RequestManager", "OfflineHandler", + "WarningScheduler", "feedback_category_to_text", + "Activity", "Child", "Credentials", "DaemonState", "WarningLevel", + "WarningThreshold", "FeedbackCategory", "VerificationLevel", + "PairingInfo", "RequestResult", "DeviceContext", "ActivityState", + "CheckResult", + "CredentialBackend", "create_backend", "PlaintextBackend", + "LinuxUserResolver", "SelectorResolver", "EnvVarResolver", + "__version__", +] +``` + +--- + +## 7. Async vs Sync Design Decision + +**Primary API: async (asyncio).** All public methods that do I/O are `async def`. + +**Sync wrapper: NOT provided in v1.** Rationale: +- Adding sync wrappers doubles the API surface and test burden. +- Python 3.10+ developers working with daemons/services expect async. +- For simple scripts, `asyncio.run(daemon.start())` is one line. +- Pygame/Ren'Py integrations can run the event loop in a background thread. + +If demand arises, a `allow2.sync` module can be added later wrapping every async method with `asyncio.run()` or `loop.run_until_complete()`. + +--- + +## 8. Packaging (pyproject.toml) + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "allow2" +version = "2.0.0a1" +description = "Allow2 Device SDK -- Parental Freedom for apps and devices" +readme = "README.md" +license = {text = "SEE LICENSE IN LICENSE FILE"} +requires-python = ">=3.10" +authors = [ + {name = "Allow2 Pty Ltd"}, +] +keywords = [ + "parental-controls", + "parental-freedom", + "allow2", + "screen-time", + "device-management", + "child-safety", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", + "Framework :: AsyncIO", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27", +] + +[project.optional-dependencies] +keyring = ["keyring>=25"] +server = ["aiohttp>=3.9"] +all = ["keyring>=25", "aiohttp>=3.9"] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "respx>=0.22", # httpx mock + "coverage>=7", + "mypy>=1.11", + "ruff>=0.6", +] + +[project.urls] +Homepage = "https://developer.allow2.com" +Repository = "https://github.com/Allow2/allow2python" +Issues = "https://github.com/Allow2/allow2python/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/allow2"] + +[tool.mypy] +python_version = "3.10" +strict = true + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +--- + +## 9. Testing Strategy + +### Test Structure + +``` +tests/ +├── conftest.py # Shared fixtures (mock API, mock credentials) +├── test_api.py # Allow2Api HTTP client tests +├── test_daemon.py # DeviceDaemon lifecycle + state machine tests +├── test_checker.py # Check loop + enforcement logic tests +├── test_child_shield.py # PIN verification + lockout tests +├── test_pairing.py # Pairing wizard tests +├── test_warnings.py # Warning scheduler tests +├── test_offline.py # Offline handler tests +├── test_request.py # Request More Time tests +├── test_updates.py # UpdatePoller tests +├── test_feedback.py # Feedback tests +├── test_credentials.py # PlaintextBackend + KeyringBackend tests +├── test_child_resolvers.py # All resolver tests +└── test_models.py # Dataclass serialization tests +``` + +### Testing Approach + +| Layer | Tool | Approach | +|-------|------|----------| +| HTTP client | `respx` | Mock httpx requests. Test all endpoints, error codes (401, 500), timeouts, JSON parsing failures. | +| State machine | `pytest-asyncio` | Test all state transitions: unpaired->pairing->paired->enforcing->parent. Test edge cases: double-start, stop-while-pairing, 401-during-enforcement. | +| PIN verification | Pure unit tests | Test hash_pin, safe_compare, lockout progression, session timeout. No I/O. | +| Warning scheduler | Pure unit tests | Test threshold firing, fire-once-per-level, reset behavior. | +| Offline handler | tmp_path fixture | Test cache write/read, grace period math, deny-by-default, corrupt file handling. | +| Credentials | tmp_path fixture | Test store/load/clear, file permissions (on Linux), last-used tracking. | +| Child resolvers | monkeypatch | Mock $USER, test case-insensitive matching, parent entry skipping. | +| Integration | Full stack with respx | DeviceDaemon with mocked API, test complete flows: pair -> select child -> check loop -> warning -> soft-lock -> hard-lock. | + +### Coverage Target + +80% line coverage minimum. 100% on critical paths: state machine transitions, PIN verification, offline deny logic, 401 handling. + +### CI Matrix + +```yaml +# GitHub Actions +strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + os: [ubuntu-latest, macos-latest] +``` + +Windows is not a target for the device SDK (the Node SDK targets Linux specifically), but the test suite should pass on Windows with the exception of file-permission tests. + +--- + +## 10. Usage Examples + +### 10.1 Basic Desktop App (Linux) + +```python +import asyncio +from allow2 import ( + DeviceDaemon, Activity, PlaintextBackend, LinuxUserResolver, +) + +async def main(): + backend = PlaintextBackend() + resolver = LinuxUserResolver() + + daemon = DeviceDaemon( + activities=[Activity(id=1), Activity(id=8)], # Internet + Screen Time + credential_backend=backend, + child_resolver=resolver, + device_name="Living Room PC", + ) + + # Wire up event handlers + daemon.on("child-select-required", lambda data: + print(f"Select a child: {[c['name'] for c in data['children']]}")) + daemon.on("warning", lambda w: + print(f"Warning [{w['level']}]: {w['remaining']}s remaining")) + daemon.on("soft-lock", lambda d: + print(f"LOCKED: {d['reason']}")) + daemon.on("unpaired", lambda d: + print("Device unpaired!")) + + await daemon.start() + + # Keep running until interrupted + try: + await asyncio.Event().wait() + except KeyboardInterrupt: + daemon.stop() + +asyncio.run(main()) +``` + +### 10.2 Raspberry Pi Kiosk + +```python +import asyncio +from allow2 import ( + DeviceDaemon, Activity, PlaintextBackend, SelectorResolver, +) + +async def main(): + daemon = DeviceDaemon( + activities=[Activity(id=3)], # Gaming + credential_backend=PlaintextBackend(), + child_resolver=SelectorResolver(), + device_name="Pi Arcade Cabinet", + check_interval=30, + ) + + daemon.on("pairing-required", lambda info: + show_on_display(f"PIN: {info.pin}")) + daemon.on("child-select-required", lambda data: + show_child_picker(data["children"])) + daemon.on("soft-lock", lambda _: + blank_screen()) + daemon.on("unlock", lambda _: + resume_game()) + + await daemon.start() + await daemon.open_app() # Trigger pairing if not paired + + await asyncio.Event().wait() + +asyncio.run(main()) +``` + +### 10.3 Automation Script (headless) + +```python +import asyncio +from allow2 import DeviceDaemon, Activity, PlaintextBackend, EnvVarResolver + +async def main(): + # Pre-paired device, child set via ALLOW2_CHILD_ID=123 + daemon = DeviceDaemon( + activities=[Activity(id=1)], + credential_backend=PlaintextBackend(), + child_resolver=EnvVarResolver(), + device_name="Homework Timer", + ) + + daemon.on("activity-blocked", lambda d: + print(f"Activity {d['activity_id']} blocked. Stopping.")) + + await daemon.start() + + # Do work while enforcement runs in background... + await do_homework_timer() + + daemon.stop() + +asyncio.run(main()) +``` + +### 10.4 Pygame Integration + +```python +import asyncio +import threading +from allow2 import DeviceDaemon, Activity, PlaintextBackend, SelectorResolver + +# Run Allow2 daemon in background thread +daemon = DeviceDaemon( + activities=[Activity(id=3), Activity(id=8)], + credential_backend=PlaintextBackend(), + child_resolver=SelectorResolver(), + device_name="My Pygame Game", +) + +locked = False + +def on_soft_lock(_): + global locked + locked = True + +def on_unlock(_): + global locked + locked = False + +daemon.on("soft-lock", on_soft_lock) +daemon.on("unlock", on_unlock) + +def run_daemon(): + loop = asyncio.new_event_loop() + loop.run_until_complete(daemon.start()) + loop.run_forever() + +daemon_thread = threading.Thread(target=run_daemon, daemon=True) +daemon_thread.start() + +# Pygame main loop +import pygame +pygame.init() +screen = pygame.display.set_mode((800, 600)) +running = True + +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + if locked: + screen.fill((0, 0, 0)) + # Show "Time's up!" overlay + else: + # Normal game rendering + screen.fill((30, 30, 60)) + + pygame.display.flip() + +daemon.stop() +pygame.quit() +``` + +--- + +## 11. Key Design Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Async framework | asyncio (stdlib) | No external dependency. Universal in Python 3.10+. | +| HTTP client | httpx | Async-native, sync fallback, modern, well-maintained. | +| Event system | Custom EventEmitter | Zero-dependency, matches Node SDK API exactly. | +| Type system | dataclasses + enums | Stdlib, IDE-friendly, no pydantic dependency. | +| Sync wrappers | Not in v1 | Avoids API surface bloat. `asyncio.run()` suffices. | +| Credential keyring | Optional via `[keyring]` extra | Not everyone has GNOME/KDE. Plaintext is the default. | +| Pairing HTTP server | Optional via `[server]` extra | Most integrations handle pairing UI themselves. | +| Build system | hatchling | Modern, fast, PEP 621 compliant. No setup.py needed. | +| Linter | ruff | Fast, replaces flake8+isort+pyupgrade. | +| Type checker | mypy (strict) | Full type safety. `py.typed` marker for downstream. | + +--- + +## 12. Implementation Order + +Recommended implementation sequence, with each step producing a testable unit: + +| Phase | Files | Depends On | Est. Lines | +|-------|-------|------------|------------| +| 1 | `models.py`, `__init__.py`, `pyproject.toml` | Nothing | ~200 | +| 2 | `api.py` | models | ~250 | +| 3 | `credentials/` (all) | models | ~200 | +| 4 | `child_shield.py` | models, EventEmitter | ~350 | +| 5 | `warnings.py` | models | ~60 | +| 6 | `offline.py` | models | ~100 | +| 7 | `checker.py` | api, models, warnings | ~250 | +| 8 | `child_resolver/` (all) | models | ~100 | +| 9 | `request.py` | api, models, EventEmitter | ~100 | +| 10 | `updates.py` | api, models, EventEmitter | ~140 | +| 11 | `feedback.py` | models | ~30 | +| 12 | `pairing.py` | api, credentials, EventEmitter | ~300 | +| 13 | `daemon.py` | everything | ~600 | +| 14 | Tests | everything | ~1500 | + +**Total estimated:** ~2,880 lines of source + ~1,500 lines of tests. + +--- + +## 13. API Endpoint Mapping + +Complete mapping of Node SDK API calls to Python SDK methods, ensuring nothing is missed: + +| Node Method | Python Method | Endpoint | HTTP | +|-------------|---------------|----------|------| +| `initQRPairing` | `init_qr_pairing` | `/api/pair/qr/init` | POST | +| `initPINPairing` | `init_pin_pairing` | `/api/pair/pin/init` | POST | +| `checkPairingStatus` | `check_pairing_status` | `/api/pair/status/{id}` | GET | +| `check` | `check` | `/serviceapi/check` | POST | +| `getUpdates` | `get_updates` | `/api/getUpdates` | GET | +| `createRequest` | `create_request` | `/api/request/createRequest` | POST | +| `getRequestStatus` | `get_request_status` | `/api/request/{id}/status` | GET | +| `submitFeedback` | `submit_feedback` | `/api/feedback/submit` | POST | +| `loadFeedback` | `load_feedback` | `/api/feedback/load` | POST | +| `feedbackReply` | `feedback_reply` | `/api/feedback/reply` | POST | +| `logUsage` | `log_usage` | `/api/logUsage` | POST | + +All endpoints use `deviceToken` from the API client's `self.token` (the application-level token, not per-device). The per-device identity is `pairId` + `pairToken`. + +--- + +## 14. Critical Behavioral Parity Checklist + +These behaviors MUST match the Node SDK exactly. Each should have a dedicated test: + +- [ ] State machine transitions: unpaired -> pairing -> paired -> enforcing -> parent +- [ ] HTTP 401 from any endpoint -> clear credentials, emit `unpaired`, stop loops +- [ ] Screen Time (activity 8) exhaustion -> `soft-lock` with reason `screen-time-exhausted` +- [ ] All activities blocked -> `soft-lock` with reason `all-activities-blocked` +- [ ] Soft-lock timeout -> `hard-lock` after `hard_lock_timeout` seconds +- [ ] Soft-lock cancelled on unlock -> clear hard-lock timer +- [ ] Warning fire-once-per-level: same (activity, level) never emits twice +- [ ] Warning reset on time extension or child change +- [ ] PIN hash: `SHA-256(pin + salt)` as lowercase hex +- [ ] PIN comparison: constant-time (`hmac.compare_digest`) +- [ ] PIN lockout: 5 failed attempts -> 300s lockout +- [ ] Lockout expiry: attempts reset after lockout period +- [ ] Session timeout: clears child, emits `session-timeout`, requests re-selection +- [ ] Offline grace period: use cached result for `grace_period` seconds +- [ ] Offline deny: after grace period, emit `offline-deny` +- [ ] Offline clear: successful API check clears offline state +- [ ] Pairing PIN: server-assigned (not locally generated) +- [ ] Pairing QR URL: `https://app.allow2.com/pair?pin=XXXXXX` +- [ ] Pairing poll: 5s interval, 30-minute max (360 polls) +- [ ] Pairing retry: if init fails, retry every 5s until connected +- [ ] Pairing connection-status: emit after 2 consecutive poll failures +- [ ] Request polling: 5s interval, 5-minute timeout +- [ ] feedbackDeviceAuth: uses userId + pairId + pairToken (NOT deviceToken) +- [ ] Feedback categories: bypass, missing_feature, not_working, question, other +- [ ] canSubmitFeedback: parent=true, child with linked account=true, child without=false +- [ ] Heartbeat: runs when paired but no child selected, 60s interval +- [ ] Heartbeat detects 401 -> unpair +- [ ] Children sorted by lastUsedAt descending (most recent first) +- [ ] Child resolver accepts function or object with resolve() +- [ ] Credential backend file permissions: 0o600 (file), 0o700 (directory) +- [ ] UUID generation: random UUID, persisted across restarts +- [ ] Check loop: log=true by default, includes timezone diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..da58c6e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "allow2" +version = "2.0.0a1" +description = "Allow2 Device SDK -- Parental Freedom for apps and devices" +license = {text = "SEE LICENSE IN LICENSE FILE"} +requires-python = ">=3.10" +authors = [ + {name = "Allow2 Pty Ltd"}, +] +keywords = [ + "parental-controls", + "parental-freedom", + "allow2", + "screen-time", + "device-management", + "child-safety", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", + "Framework :: AsyncIO", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27", +] + +[project.optional-dependencies] +keyring = ["keyring>=25"] +server = ["aiohttp>=3.9"] +all = ["keyring>=25", "aiohttp>=3.9"] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "respx>=0.22", + "coverage>=7", + "mypy>=1.11", + "ruff>=0.6", +] + +[project.urls] +Homepage = "https://developer.allow2.com" +Repository = "https://github.com/Allow2/allow2python" +Issues = "https://github.com/Allow2/allow2python/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/allow2"] + +[tool.mypy] +python_version = "3.10" +strict = true + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/src/allow2/__init__.py b/src/allow2/__init__.py new file mode 100644 index 0000000..82e7a10 --- /dev/null +++ b/src/allow2/__init__.py @@ -0,0 +1,42 @@ +"""Allow2 Device SDK -- Parental Freedom for apps and devices.""" + +from allow2.daemon import DeviceDaemon +from allow2.child_shield import ChildShield +from allow2.api import Allow2Api, Allow2ApiError +from allow2.events import EventEmitter +from allow2.models import ( + Activity, + Child, + Credentials, + DaemonState, + DeviceContext, + FeedbackCategory, + PairingInfo, + RequestResult, + VerificationLevel, + WarningLevel, + WarningThreshold, +) +from allow2.credentials.plaintext import PlaintextBackend + +__version__ = "2.0.0a1" + +__all__ = [ + "DeviceDaemon", + "ChildShield", + "Allow2Api", + "Allow2ApiError", + "EventEmitter", + "PlaintextBackend", + "Activity", + "Child", + "Credentials", + "DaemonState", + "DeviceContext", + "FeedbackCategory", + "PairingInfo", + "RequestResult", + "VerificationLevel", + "WarningLevel", + "WarningThreshold", +] diff --git a/src/allow2/api.py b/src/allow2/api.py new file mode 100644 index 0000000..3d3c718 --- /dev/null +++ b/src/allow2/api.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import os +import warnings +from typing import Any + +import httpx + +DEFAULT_API_URL = "https://api.allow2.com" +DEFAULT_VID = 0 +DEFAULT_TOKEN = "" + + +class Allow2ApiError(Exception): + def __init__( + self, + message: str, + *, + status: int, + code: str | None = None, + body: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status = status + self.code = code + self.body = body + + +class Allow2Api: + """Low-level async HTTP client for the Allow2 REST API.""" + + def __init__( + self, + *, + api_url: str | None = None, + vid: int | None = None, + token: str | None = None, + timeout: float = 15.0, + ) -> None: + self._base_url = api_url or os.environ.get("ALLOW2_API_URL") or DEFAULT_API_URL + self._timeout = timeout + + env_vid = os.environ.get("ALLOW2_VID") + self._vid = vid or (int(env_vid) if env_vid else 0) or DEFAULT_VID + self._token = token or os.environ.get("ALLOW2_TOKEN") or DEFAULT_TOKEN + + if not self._vid or not self._token: + warnings.warn( + "Allow2 API: VID/Token not configured. " + "Set ALLOW2_VID and ALLOW2_TOKEN environment variables, " + "or pass vid/token in options.", + stacklevel=2, + ) + + self._client: httpx.AsyncClient | None = None + + @property + def base_url(self) -> str: + return self._base_url + + @property + def vid(self) -> int: + return self._vid + + @property + def token(self) -> str: + return self._token + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + timeout=httpx.Timeout(self._timeout), + ) + return self._client + + async def close(self) -> None: + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> Allow2Api: + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + async def _fetch( + self, + path: str, + *, + method: str = "GET", + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + url = self._base_url + path + client = self._get_client() + + req_headers: dict[str, str] = {"Content-Type": "application/json"} + if headers: + req_headers.update(headers) + + response = await client.request( + method, + url, + json=json_body if json_body is not None else None, + headers=req_headers, + ) + + body: dict[str, Any] | None = None + try: + body = response.json() + except Exception: + if not response.is_success: + raise Allow2ApiError( + f"API error {response.status_code}", + status=response.status_code, + ) + raise Allow2ApiError( + "Unexpected non-JSON response from API", + status=response.status_code, + ) + + if not response.is_success: + msg = (body.get("message") if body else None) or f"API error {response.status_code}" + raise Allow2ApiError( + msg, + status=response.status_code, + code=body.get("code") if body else None, + body=body, + ) + + return body # type: ignore[return-value] + + async def init_qr_pairing( + self, + *, + uuid: str, + device_name: str, + platform: str = "python", + ) -> dict[str, Any]: + return await self._fetch("/api/pair/qr/init", method="POST", json_body={ + "uuid": uuid, + "name": device_name, + "deviceToken": self._token, + "vid": self._vid, + "platform": platform, + }) + + async def init_pin_pairing( + self, + *, + uuid: str, + device_name: str, + platform: str = "python", + ) -> dict[str, Any]: + return await self._fetch("/api/pair/pin/init", method="POST", json_body={ + "uuid": uuid, + "name": device_name, + "deviceToken": self._token, + "vid": self._vid, + "platform": platform, + }) + + async def check_pairing_status(self, pairing_session_id: str) -> dict[str, Any]: + return await self._fetch(f"/api/pair/status/{pairing_session_id}") + + async def check( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + child_id: int, + activities: dict[str, int], + tz: str, + log: bool = True, + ) -> dict[str, Any]: + return await self._fetch("/serviceapi/check", method="POST", json_body={ + "userId": user_id, + "pairId": pair_id, + "pairToken": pair_token, + "deviceToken": self._token, + "tz": tz, + "childId": child_id, + "activities": activities, + "log": log, + }) + + async def get_updates( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + timestamp_millis: int | None = None, + ) -> dict[str, Any]: + params: dict[str, str] = { + "userId": str(user_id), + "pairId": str(pair_id), + "pairToken": pair_token, + "deviceToken": self._token, + } + if timestamp_millis is not None: + params["timestampMillis"] = str(timestamp_millis) + + query = "&".join(f"{k}={v}" for k, v in params.items()) + return await self._fetch(f"/api/getUpdates?{query}") + + async def create_request( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + child_id: int, + duration: int, + activity: int, + message: str | None = None, + ) -> dict[str, Any]: + return await self._fetch("/api/request/createRequest", method="POST", json_body={ + "userId": user_id, + "pairId": pair_id, + "pairToken": pair_token, + "childId": child_id, + "duration": duration, + "activity": activity, + "message": message, + }) + + async def get_request_status( + self, request_id: str, status_secret: str + ) -> dict[str, Any]: + return await self._fetch( + f"/api/request/{request_id}/status", + headers={"X-Status-Secret": status_secret}, + ) + + async def submit_feedback( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + child_id: int | None = None, + vid: int | None = None, + category: str, + message: str, + device_context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + return await self._fetch("/api/feedback/submit", method="POST", json_body={ + "userId": user_id, + "pairId": pair_id, + "pairToken": pair_token, + "childId": child_id, + "vid": vid or self._vid, + "category": category, + "message": message, + "deviceContext": device_context, + }) + + async def load_feedback( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + ) -> dict[str, Any]: + return await self._fetch("/api/feedback/load", method="POST", json_body={ + "userId": user_id, + "pairId": pair_id, + "pairToken": pair_token, + }) + + async def feedback_reply( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + discussion_id: str, + message: str, + ) -> dict[str, Any]: + return await self._fetch("/api/feedback/reply", method="POST", json_body={ + "userId": user_id, + "pairId": pair_id, + "pairToken": pair_token, + "discussionId": discussion_id, + "message": message, + }) + + async def log_usage( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + child_id: int, + activities: dict[str, Any], + ) -> dict[str, Any]: + return await self._fetch("/api/logUsage", method="POST", json_body={ + "userId": user_id, + "pairId": pair_id, + "pairToken": pair_token, + "deviceToken": self._token, + "childId": child_id, + "activities": activities, + }) diff --git a/src/allow2/checker.py b/src/allow2/checker.py new file mode 100644 index 0000000..bfa9a32 --- /dev/null +++ b/src/allow2/checker.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import asyncio +import math +import time +from typing import Any, Callable + +from allow2.api import Allow2Api, Allow2ApiError +from allow2.models import Activity, ActivityState, Credentials, WarningThreshold +from allow2.warnings import WarningScheduler + +SCREEN_TIME_ACTIVITY = 8 + + +class Checker: + """Periodic check loop with per-activity enforcement. + Not an EventEmitter itself -- delegates to the daemon's emit().""" + + def __init__( + self, + *, + api: Allow2Api, + emit: Callable[..., Any], + credentials: Credentials, + child_id: int, + activities: list[Activity], + check_interval: int = 60, + hard_lock_timeout: int = 300, + grace_period: int = 300, + warning_thresholds: list[WarningThreshold] | None = None, + ) -> None: + self._api = api + self._emit = emit + self._credentials = credentials + self._child_id = child_id + self._activities = activities + self._check_interval = check_interval + self._hard_lock_timeout = hard_lock_timeout + self._grace_period = grace_period + + try: + self._tz = time.tzname[0] + except (IndexError, AttributeError): + self._tz = "UTC" + + self._state: dict[str, ActivityState] = {} + self._soft_locked = False + self._soft_lock_handle: asyncio.TimerHandle | None = None + self._offline_since: float | None = None + self._offline_grace_emitted = False + self._task: asyncio.Task[None] | None = None + self._running = False + + self._warnings = WarningScheduler( + emit=self._emit, + thresholds=warning_thresholds, + ) + + def start(self) -> None: + if self._running: + return + self._running = True + try: + loop = asyncio.get_running_loop() + self._task = loop.create_task(self._run_check()) + except RuntimeError: + pass + + def stop(self) -> None: + self._running = False + if self._task is not None: + self._task.cancel() + self._task = None + if self._soft_lock_handle is not None: + self._soft_lock_handle.cancel() + self._soft_lock_handle = None + + def on_time_extended(self, activity_id: int) -> None: + self._warnings.reset_activity(str(activity_id)) + if self._soft_locked: + self._soft_locked = False + if self._soft_lock_handle is not None: + self._soft_lock_handle.cancel() + self._soft_lock_handle = None + self._emit("unlock", {"reason": "time-extended", "activityId": activity_id}) + + def get_remaining(self) -> dict[str, dict[str, Any]] | None: + if not self._state: + return None + result: dict[str, dict[str, Any]] = {} + for key, val in self._state.items(): + result[key] = {"allowed": val.allowed, "remaining": val.remaining} + return result + + def reset(self, child_id: int) -> None: + self._child_id = child_id + self._state.clear() + self._soft_locked = False + self._offline_since = None + self._offline_grace_emitted = False + self._warnings.reset_all() + if self._soft_lock_handle is not None: + self._soft_lock_handle.cancel() + self._soft_lock_handle = None + + async def _run_check(self) -> None: + while self._running: + try: + await self._do_check() + except Exception as err: + self._handle_error(err) + + if self._running: + await asyncio.sleep(self._check_interval) + + async def _do_check(self) -> None: + activity_map: dict[str, int] = {} + for act in self._activities: + activity_map[str(act.id)] = 1 + + result = await self._api.check( + user_id=self._credentials.user_id, + pair_id=self._credentials.pair_id, + pair_token=self._credentials.pair_token, + tz=self._tz, + child_id=self._child_id, + activities=activity_map, + log=True, + ) + + if self._offline_since is not None: + self._offline_since = None + self._offline_grace_emitted = False + + self._process_result(result) + + def _process_result(self, result: dict[str, Any]) -> None: + activities = result.get("activities", {}) + ids = list(activities.keys()) + + all_blocked = True + warning_data: dict[str, dict[str, Any]] = {} + + for act_id in ids: + current = activities[act_id] + allowed = bool(current.get("allowed")) + remaining_val = current.get("remaining") + remaining = remaining_val if remaining_val is not None else math.inf + + prev = self._state.get(act_id) + was_allowed = prev.allowed if prev else True + + if was_allowed and not allowed: + self._emit("activity-blocked", { + "activityId": int(act_id), + "activity": current.get("activity", act_id), + "remaining": 0, + }) + if int(act_id) == SCREEN_TIME_ACTIVITY: + self._trigger_soft_lock("screen-time-exhausted") + + if not was_allowed and allowed and prev is not None: + self._warnings.reset_activity(act_id) + + self._state[act_id] = ActivityState(allowed=allowed, remaining=remaining) + + if allowed: + all_blocked = False + warning_data[act_id] = {"remaining": remaining} + + if len(ids) > 0 and all_blocked and not self._soft_locked: + self._trigger_soft_lock("all-activities-blocked") + + if self._soft_locked and not all_blocked: + self._soft_locked = False + if self._soft_lock_handle is not None: + self._soft_lock_handle.cancel() + self._soft_lock_handle = None + self._emit("unlock", {"reason": "activity-unblocked"}) + + if warning_data: + self._warnings.update(warning_data) + + def _trigger_soft_lock(self, reason: str) -> None: + if self._soft_locked: + return + self._soft_locked = True + self._emit("soft-lock", {"reason": reason}) + + try: + loop = asyncio.get_running_loop() + self._soft_lock_handle = loop.call_later( + self._hard_lock_timeout, + self._on_hard_lock_timeout, + ) + except RuntimeError: + pass + + def _on_hard_lock_timeout(self) -> None: + if self._soft_locked and self._running: + self._emit("hard-lock", {"reason": "soft-lock-timeout"}) + + def _handle_error(self, err: Exception) -> None: + if isinstance(err, Allow2ApiError) and err.status == 401: + self._emit("unpaired", {"error": err}) + self.stop() + return + + now = time.time() * 1000 + if self._offline_since is None: + self._offline_since = now + + offline_duration = now - self._offline_since + + if offline_duration < self._grace_period * 1000: + if not self._offline_grace_emitted: + self._offline_grace_emitted = True + self._emit("offline-grace", { + "since": self._offline_since, + "graceRemaining": self._grace_period * 1000 - offline_duration, + }) + else: + self._emit("offline-deny", { + "since": self._offline_since, + "offlineDuration": offline_duration, + }) diff --git a/src/allow2/child_resolver/__init__.py b/src/allow2/child_resolver/__init__.py new file mode 100644 index 0000000..9f869dd --- /dev/null +++ b/src/allow2/child_resolver/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from allow2.models import Child + + +@runtime_checkable +class ChildResolver(Protocol): + async def resolve(self, children: list[Child]) -> dict[str, Any] | None: ... diff --git a/src/allow2/child_resolver/env_var.py b/src/allow2/child_resolver/env_var.py new file mode 100644 index 0000000..ccb8570 --- /dev/null +++ b/src/allow2/child_resolver/env_var.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os +from typing import Any + +from allow2.models import Child + + +class EnvVarResolver: + """Resolves child from ALLOW2_CHILD_ID environment variable. + Useful for automation scripts, CI, Raspberry Pi headless setups.""" + + def __init__(self, *, env_var: str = "ALLOW2_CHILD_ID") -> None: + self._env_var = env_var + + async def resolve(self, children: list[Child]) -> dict[str, Any] | None: + raw = os.environ.get(self._env_var) + if not raw: + return None + + try: + target_id = int(raw) + except ValueError: + return None + + for child in children: + if child.id == target_id: + return {"child_id": child.id, "child_name": child.name} + + return None diff --git a/src/allow2/child_resolver/linux_user.py b/src/allow2/child_resolver/linux_user.py new file mode 100644 index 0000000..891054e --- /dev/null +++ b/src/allow2/child_resolver/linux_user.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os +import subprocess +from typing import Any + +from allow2.models import Child + + +def get_linux_username() -> str: + username = os.environ.get("USER") + if username: + return username + try: + return subprocess.check_output( + ["whoami"], text=True, timeout=5 + ).strip() + except Exception: + return "" + + +async def resolve(children: list[Child]) -> dict[str, Any] | None: + if not children: + return None + + username = get_linux_username() + if not username: + return None + + lower = username.lower() + + for child in children: + if child.id == 0 or child.name == "__parent__": + continue + + if child.os_username and child.os_username.lower() == lower: + return {"child_id": child.id, "child_name": child.name} + + if child.name and child.name.lower() == lower: + return {"child_id": child.id, "child_name": child.name} + + return None + + +class LinuxUserResolver: + async def resolve(self, children: list[Child]) -> dict[str, Any] | None: + return await resolve(children) diff --git a/src/allow2/child_resolver/selector.py b/src/allow2/child_resolver/selector.py new file mode 100644 index 0000000..407ea0a --- /dev/null +++ b/src/allow2/child_resolver/selector.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Any + +from allow2.models import Child + + +async def resolve(children: list[Child]) -> None: + return None + + +class SelectorResolver: + async def resolve(self, children: list[Child]) -> None: + return None + + +def request_selection(child_shield: Any) -> None: + if child_shield and hasattr(child_shield, "emit") and hasattr(child_shield, "get_children"): + child_shield.emit("child-select-required", child_shield.get_children()) diff --git a/src/allow2/child_shield.py b/src/allow2/child_shield.py new file mode 100644 index 0000000..1ea0621 --- /dev/null +++ b/src/allow2/child_shield.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import hashlib +import hmac +import threading +import time +from typing import Any, Callable + +from allow2.events import EventEmitter +from allow2.models import Child, VerificationLevel + +MAX_PIN_ATTEMPTS = 5 +LOCKOUT_DURATION = 300.0 +DEFAULT_SESSION_TIMEOUT = 300.0 + + +def hash_pin(pin: str, salt: str) -> str: + return hashlib.sha256((pin + salt).encode()).hexdigest() + + +def safe_compare(a: str, b: str) -> bool: + if not isinstance(a, str) or not isinstance(b, str): + return False + return hmac.compare_digest(a, b) + + +class ChildShield(EventEmitter): + """Child identification, PIN verification, and session management.""" + + def __init__( + self, + *, + children: list[Child], + verification_level: VerificationLevel = VerificationLevel.PIN, + session_timeout: float = DEFAULT_SESSION_TIMEOUT, + on_select_required: Callable[..., Any] | None = None, + ) -> None: + super().__init__() + self._children = list(children) + self._verification_level = verification_level + self._session_timeout = session_timeout + + self._current_child: Child | None = None + self._parent_mode = False + self._session_timer: threading.Timer | None = None + self._last_activity: float = 0 + + self._attempts: dict[str | int, dict[str, Any]] = {} + + if on_select_required is not None: + self.on("child-select-required", on_select_required) + + def select_child(self, child_id: int, pin: str | None = None) -> bool: + if self._verification_level == VerificationLevel.PARENT_ONLY: + return False + + child = self._find_child(child_id) + if child is None: + return False + + if self._is_locked_out(child_id): + remaining = self._lockout_remaining(child_id) + self.emit("child-locked-out", int(remaining + 0.5)) + return False + + if self._verification_level == VerificationLevel.PIN: + if pin is None: + return False + if child.pin_hash and child.pin_salt: + computed = hash_pin(pin, child.pin_salt) + if not safe_compare(computed, child.pin_hash): + self._record_failed_attempt(child_id) + return False + + self._clear_attempts(child_id) + self._activate_child(child) + return True + + def select_parent(self, pin: str) -> bool: + if not pin: + return False + + parent_entry = self._find_parent_entry() + if parent_entry is None: + return False + + if self._is_locked_out("parent"): + remaining = self._lockout_remaining("parent") + self.emit("child-locked-out", int(remaining + 0.5)) + return False + + if not parent_entry.pin_hash or not parent_entry.pin_salt: + return False + + computed = hash_pin(pin, parent_entry.pin_salt) + if not safe_compare(computed, parent_entry.pin_hash): + self._record_failed_attempt("parent") + return False + + self._clear_attempts("parent") + self._enter_parent_mode() + return True + + def clear_selection(self) -> None: + self._stop_session_timer() + self._current_child = None + self._parent_mode = False + self._last_activity = 0 + self.emit("child-select-required", self.get_children()) + + def get_current_child(self) -> Child | None: + return self._current_child + + def is_parent_mode(self) -> bool: + return self._parent_mode + + def record_activity(self) -> None: + self._last_activity = time.time() + if self._current_child or self._parent_mode: + self._reset_session_timer() + + def update_children(self, children: list[Child]) -> None: + self._children = list(children) + if self._current_child is not None: + still = self._find_child(self._current_child.id) + if still is None: + self.clear_selection() + else: + self._current_child = Child( + id=still.id, + name=still.name, + pin_hash=still.pin_hash, + pin_salt=still.pin_salt, + avatar_url=still.avatar_url, + color=still.color, + has_account=still.has_account, + linked_user_id=still.linked_user_id, + os_username=still.os_username, + last_used_at=still.last_used_at, + ) + + def get_children(self) -> list[dict[str, Any]]: + return [c.safe_copy() for c in self._children] + + def destroy(self) -> None: + self._stop_session_timer() + self.remove_all_listeners() + + def _find_child(self, child_id: int) -> Child | None: + for child in self._children: + if child.id == child_id: + return child + return None + + def _find_parent_entry(self) -> Child | None: + for child in self._children: + if child.id == 0 or child.name == "__parent__": + return child + return None + + def _activate_child(self, child: Child) -> None: + self._parent_mode = False + self._current_child = Child( + id=child.id, + name=child.name, + pin_hash=child.pin_hash, + pin_salt=child.pin_salt, + avatar_url=child.avatar_url, + color=child.color, + has_account=child.has_account, + linked_user_id=child.linked_user_id, + os_username=child.os_username, + last_used_at=child.last_used_at, + ) + self._last_activity = time.time() + self._reset_session_timer() + self.emit("child-selected", child.id, child.name) + + def _enter_parent_mode(self) -> None: + self._current_child = None + self._parent_mode = True + self._last_activity = time.time() + self._reset_session_timer() + self.emit("parent-mode-entered") + + def _reset_session_timer(self) -> None: + self._stop_session_timer() + if self._session_timeout <= 0: + return + self._session_timer = threading.Timer( + self._session_timeout, self._on_session_timeout + ) + self._session_timer.daemon = True + self._session_timer.start() + + def _stop_session_timer(self) -> None: + if self._session_timer is not None: + self._session_timer.cancel() + self._session_timer = None + + def _on_session_timeout(self) -> None: + self._session_timer = None + self._current_child = None + self._parent_mode = False + self._last_activity = 0 + self.emit("session-timeout") + self.emit("child-select-required", self.get_children()) + + def _get_attempt_record(self, key: str | int) -> dict[str, Any]: + if key not in self._attempts: + self._attempts[key] = {"failed": 0, "lockoutUntil": 0.0} + return self._attempts[key] + + def _is_locked_out(self, key: str | int) -> bool: + record = self._get_attempt_record(key) + now = time.time() + if record["lockoutUntil"] > 0 and now < record["lockoutUntil"]: + return True + if record["lockoutUntil"] > 0 and now >= record["lockoutUntil"]: + record["failed"] = 0 + record["lockoutUntil"] = 0.0 + return False + + def _lockout_remaining(self, key: str | int) -> float: + record = self._get_attempt_record(key) + remaining = record["lockoutUntil"] - time.time() + return remaining if remaining > 0 else 0 + + def _record_failed_attempt(self, key: str | int) -> None: + record = self._get_attempt_record(key) + record["failed"] += 1 + if record["failed"] >= MAX_PIN_ATTEMPTS: + record["lockoutUntil"] = time.time() + LOCKOUT_DURATION + self.emit("child-locked-out", int(LOCKOUT_DURATION)) + else: + self.emit("child-pin-failed", record["failed"], MAX_PIN_ATTEMPTS) + + def _clear_attempts(self, key: str | int) -> None: + self._attempts.pop(key, None) diff --git a/src/allow2/credentials/__init__.py b/src/allow2/credentials/__init__.py new file mode 100644 index 0000000..8805eec --- /dev/null +++ b/src/allow2/credentials/__init__.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class CredentialBackend(Protocol): + async def store(self, data: dict[str, Any]) -> None: ... + async def load(self) -> dict[str, Any] | None: ... + async def clear(self) -> None: ... + async def load_last_used(self) -> dict[str, str]: ... + async def update_last_used(self, child_id: int) -> None: ... + + +async def create_backend( + backend_type: str = "plaintext", + **kwargs: Any, +) -> CredentialBackend: + if backend_type == "plaintext": + from allow2.credentials.plaintext import PlaintextBackend + return PlaintextBackend(**kwargs) + + if backend_type == "keyring": + try: + from allow2.credentials.keyring_backend import KeyringBackend + except ImportError: + raise ImportError( + "keyring backend requires the 'keyring' package. " + "Install it with: pip install allow2[keyring]" + ) from None + return KeyringBackend(**kwargs) + + raise ValueError(f"Unknown credential backend type: {backend_type}") diff --git a/src/allow2/credentials/keyring_backend.py b/src/allow2/credentials/keyring_backend.py new file mode 100644 index 0000000..837a5c8 --- /dev/null +++ b/src/allow2/credentials/keyring_backend.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timezone +from typing import Any + +try: + import keyring +except ImportError: + keyring = None # type: ignore[assignment] + + +class KeyringBackend: + """Stores credentials in the system keyring via the keyring package. + Requires: pip install allow2[keyring]""" + + def __init__(self, *, service_name: str = "allow2-sdk") -> None: + if keyring is None: + raise ImportError( + "keyring backend requires the 'keyring' package. " + "Install it with: pip install allow2[keyring]" + ) + self._service = service_name + + async def store(self, data: dict[str, Any]) -> None: + value = json.dumps(data) + await asyncio.to_thread(keyring.set_password, self._service, "credentials", value) + + async def load(self) -> dict[str, Any] | None: + raw = await asyncio.to_thread(keyring.get_password, self._service, "credentials") + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + async def clear(self) -> None: + try: + await asyncio.to_thread(keyring.delete_password, self._service, "credentials") + except Exception: + pass + try: + await asyncio.to_thread(keyring.delete_password, self._service, "last-used") + except Exception: + pass + + async def load_last_used(self) -> dict[str, str]: + raw = await asyncio.to_thread(keyring.get_password, self._service, "last-used") + if raw is None: + return {} + try: + return json.loads(raw) + except json.JSONDecodeError: + return {} + + async def update_last_used(self, child_id: int) -> None: + data = await self.load_last_used() + data[str(child_id)] = datetime.now(timezone.utc).isoformat() + value = json.dumps(data) + await asyncio.to_thread(keyring.set_password, self._service, "last-used", value) diff --git a/src/allow2/credentials/plaintext.py b/src/allow2/credentials/plaintext.py new file mode 100644 index 0000000..8e28a28 --- /dev/null +++ b/src/allow2/credentials/plaintext.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DEFAULT_PATH = str(Path.home() / ".allow2" / "credentials.json") + + +class PlaintextBackend: + """Stores credentials as JSON in ~/.allow2/credentials.json. + File permissions: 0o600. Directory permissions: 0o700.""" + + def __init__(self, *, path: str | None = None) -> None: + self._path = path or DEFAULT_PATH + + async def store(self, data: dict[str, Any]) -> None: + dir_path = os.path.dirname(self._path) + os.makedirs(dir_path, mode=0o700, exist_ok=True) + with open(self._path, "w") as f: + json.dump(data, f, indent=2) + os.chmod(self._path, 0o600) + + async def load(self) -> dict[str, Any] | None: + try: + with open(self._path) as f: + return json.load(f) + except FileNotFoundError: + return None + + async def clear(self) -> None: + try: + os.unlink(self._path) + except FileNotFoundError: + pass + + async def load_last_used(self) -> dict[str, str]: + last_used_path = os.path.join(os.path.dirname(self._path), "last-used.json") + try: + with open(last_used_path) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + async def update_last_used(self, child_id: int) -> None: + last_used_path = os.path.join(os.path.dirname(self._path), "last-used.json") + data = await self.load_last_used() + data[str(child_id)] = datetime.now(timezone.utc).isoformat() + dir_path = os.path.dirname(last_used_path) + os.makedirs(dir_path, mode=0o700, exist_ok=True) + with open(last_used_path, "w") as f: + json.dump(data, f, indent=2) + os.chmod(last_used_path, 0o600) diff --git a/src/allow2/daemon.py b/src/allow2/daemon.py new file mode 100644 index 0000000..b684ffd --- /dev/null +++ b/src/allow2/daemon.py @@ -0,0 +1,553 @@ +from __future__ import annotations + +import asyncio +import inspect +import logging +from typing import Any, Callable + +from allow2.api import Allow2Api +from allow2.checker import Checker +from allow2.events import EventEmitter +from allow2.feedback import CATEGORY_LABELS, VALID_CATEGORIES +from allow2.models import ( + Activity, + Child, + Credentials, + DaemonState, + DeviceContext, + FeedbackCategory, + WarningThreshold, +) +from allow2.pairing import PairingWizard + +logger = logging.getLogger("allow2.daemon") + + +class DeviceDaemon(EventEmitter): + """Main entry point for the Allow2 Device SDK. + + Manages the full device lifecycle: + unpaired -> pairing -> paired -> enforcing | parent + """ + + def __init__( + self, + *, + activities: list[Activity], + credential_backend: Any, + child_resolver: Any, + device_name: str = "Allow2 Device", + check_interval: int = 60, + grace_period: int = 300, + hard_lock_timeout: int = 300, + warnings: list[WarningThreshold] | None = None, + pairing_port: int = 3000, + api_url: str | None = None, + vid: int | None = None, + token: str | None = None, + ) -> None: + super().__init__() + + if not activities: + raise ValueError("activities list is required and must not be empty") + if credential_backend is None: + raise ValueError("credential_backend is required") + if child_resolver is None: + raise ValueError("child_resolver is required") + + self._device_name = device_name + self._activities = activities + self._check_interval = check_interval + self._credential_backend = credential_backend + self._child_resolver = child_resolver + self._grace_period = grace_period + self._hard_lock_timeout = hard_lock_timeout + self._warning_thresholds = warnings + self._pairing_port = pairing_port + + self._api = Allow2Api( + api_url=api_url, + vid=vid, + token=token, + ) + + self._checker: Checker | None = None + self._credentials: Credentials | None = None + self._child_id: int | None = None + self._running = False + self._pairing_wizard: PairingWizard | None = None + self._heartbeat_task: asyncio.Task[None] | None = None + + self._state = DaemonState.UNPAIRED + + @property + def api(self) -> Allow2Api: + return self._api + + @property + def credentials(self) -> Credentials | None: + return self._credentials + + @property + def child_id(self) -> int | None: + return self._child_id + + @property + def running(self) -> bool: + return self._running + + @property + def paired(self) -> bool: + return ( + self._credentials is not None + and bool(self._credentials.pair_id) + and bool(self._credentials.pair_token) + ) + + @property + def state(self) -> DaemonState: + return self._state + + @property + def is_parent_mode(self) -> bool: + return self._state == DaemonState.PARENT + + @property + def can_submit_feedback(self) -> bool: + if self._state == DaemonState.PARENT: + return True + if self._credentials is None or not self._credentials.user_id: + return False + if self._child_id and self._credentials.children: + for child in self._credentials.children: + cid = child.id + if cid == self._child_id: + return child.linked_user_id is not None + return False + return bool(self._credentials.user_id) + + async def start(self) -> None: + if self._running: + return + self._running = True + + try: + creds_data = await self._credential_backend.load() + if creds_data: + self._credentials = Credentials.from_dict(creds_data) + else: + self._credentials = None + except Exception as err: + logger.error("Failed to load credentials: %s", err) + self._credentials = None + + if not self._credentials or not self._credentials.pair_id or not self._credentials.pair_token: + self._state = DaemonState.UNPAIRED + logger.info("Device not paired. Waiting for user to open Allow2 app.") + return + + self._state = DaemonState.PAIRED + await self._begin_enforcement() + + def stop(self) -> None: + self._running = False + self._stop_heartbeat() + if self._checker is not None: + self._checker.stop() + self._checker = None + if self._pairing_wizard is not None: + asyncio.ensure_future(self._pairing_wizard.stop()) + self._pairing_wizard = None + self._child_id = None + + if self._credentials and self._credentials.pair_id: + self._state = DaemonState.PAIRED + else: + self._state = DaemonState.UNPAIRED + + def close_app(self) -> None: + if self._pairing_wizard is not None: + asyncio.ensure_future(self._pairing_wizard.stop()) + self._pairing_wizard = None + if not self._credentials or not self._credentials.pair_id: + self._state = DaemonState.UNPAIRED + + async def open_app(self) -> None: + if self._state == DaemonState.UNPAIRED or not self._credentials or not self._credentials.pair_id: + await self._start_pairing() + else: + remaining = self._checker.get_remaining() if self._checker else None + self.emit("status-requested", { + "state": self._state.value, + "children": [c.to_dict() for c in (self._credentials.children if self._credentials else [])], + "currentChildId": self._child_id, + "remaining": remaining, + }) + + def enter_parent_mode(self) -> None: + if self._checker is not None: + self._checker.stop() + self._checker = None + self._state = DaemonState.PARENT + self.emit("parent-mode", {}) + + async def on_pairing_complete(self, credentials: Credentials) -> None: + await self._on_paired(credentials) + + async def select_child(self, child_id: int, name: str | None = None) -> None: + if self._checker is not None: + self._checker.stop() + + self._child_id = child_id + self._state = DaemonState.ENFORCING + self.emit("child-selected", {"childId": child_id, "name": name}) + + self._update_last_used(child_id) + + if self._running and self._credentials: + self._start_checker() + + def child_pin_failed(self, child_id: int, attempts_remaining: int) -> None: + self.emit("child-pin-failed", { + "childId": child_id, + "attemptsRemaining": attempts_remaining, + }) + if attempts_remaining <= 0: + self.emit("child-locked-out", {"childId": child_id}) + + def session_timeout(self) -> None: + if self._checker is not None: + self._checker.stop() + self._checker = None + self._child_id = None + self._state = DaemonState.PAIRED + self.emit("session-timeout", {}) + + if self._running and self._credentials: + asyncio.ensure_future(self._resolve_child()) + + async def request_more_time( + self, + *, + duration: int, + activity: int, + message: str | None = None, + ) -> dict[str, Any]: + if self._child_id is None: + raise RuntimeError("No child selected") + if self._credentials is None: + raise RuntimeError("Not paired") + return await self._api.create_request( + user_id=self._credentials.user_id, + pair_id=self._credentials.pair_id, + pair_token=self._credentials.pair_token, + child_id=self._child_id, + duration=duration, + activity=activity, + message=message, + ) + + async def poll_request_status( + self, request_id: str, status_secret: str + ) -> dict[str, Any]: + result = await self._api.get_request_status(request_id, status_secret) + + if result and result.get("status") == "approved": + self.emit("request-approved", { + "requestId": request_id, + "activityId": result.get("activityId"), + "duration": result.get("duration"), + }) + if self._checker and result.get("activityId"): + self._checker.on_time_extended(result["activityId"]) + elif result and result.get("status") == "denied": + self.emit("request-denied", { + "requestId": request_id, + "reason": result.get("reason"), + }) + + return result + + async def submit_feedback( + self, + *, + category: FeedbackCategory, + message: str, + device_context: DeviceContext | None = None, + ) -> dict[str, Any]: + if not self.can_submit_feedback: + raise RuntimeError("Cannot submit feedback: no account associated") + if not category or not message: + raise ValueError("category and message are required") + if category not in VALID_CATEGORIES: + raise ValueError( + f"Invalid category. Must be one of: {', '.join(c.value for c in VALID_CATEGORIES)}" + ) + + context = DeviceContext(device_name=self._device_name) + if device_context is not None: + context.device_name = device_context.device_name + context.platform = device_context.platform + context.sdk_version = device_context.sdk_version + context.product_name = device_context.product_name + + if self._credentials is None: + raise RuntimeError("Not paired") + + result = await self._api.submit_feedback( + user_id=self._credentials.user_id, + pair_id=self._credentials.pair_id, + pair_token=self._credentials.pair_token, + child_id=self._child_id, + vid=self._api.vid, + category=category.value, + message=message, + device_context=context.to_dict(), + ) + + self.emit("feedback-submitted", { + "discussionId": result.get("discussionId"), + "category": category.value, + }) + + return result + + async def load_device_feedback(self) -> dict[str, Any]: + if self._credentials is None or not self._credentials.pair_id: + raise RuntimeError("Device not paired") + + result = await self._api.load_feedback( + user_id=self._credentials.user_id, + pair_id=self._credentials.pair_id, + pair_token=self._credentials.pair_token, + ) + + self.emit("feedback-loaded", { + "discussions": (result.get("discussions") if result else []) or [], + }) + + return result + + async def reply_to_feedback(self, discussion_id: str, message: str) -> dict[str, Any]: + if not discussion_id or not message: + raise ValueError("discussionId and message are required") + if self._credentials is None: + raise RuntimeError("Not paired") + + result = await self._api.feedback_reply( + user_id=self._credentials.user_id, + pair_id=self._credentials.pair_id, + pair_token=self._credentials.pair_token, + discussion_id=discussion_id, + message=message, + ) + + self.emit("feedback-reply-sent", { + "discussionId": discussion_id, + "messageId": result.get("messageId"), + }) + + return result + + @staticmethod + def feedback_params_to_text(category: FeedbackCategory) -> str: + return CATEGORY_LABELS.get(category, "Feedback") + + async def _start_pairing(self) -> None: + if self._state == DaemonState.PAIRING and self._pairing_wizard is not None: + return + + self._state = DaemonState.PAIRING + + self._pairing_wizard = PairingWizard( + api=self._api, + credential_backend=self._credential_backend, + port=self._pairing_port, + device_name=self._device_name, + ) + + self._pairing_wizard.on("paired", lambda creds: asyncio.ensure_future(self._on_wizard_paired(creds))) + self._pairing_wizard.on("error", lambda err: self.emit("pairing-error", err)) + self._pairing_wizard.on("connection-status", lambda s: self.emit("pairing-connection-status", s)) + + try: + info = await self._pairing_wizard.start() + self.emit("pairing-required", { + "wizard": self._pairing_wizard, + "pin": info.pin, + "port": info.port, + "url": info.url, + "qrUrl": info.qr_url, + "connected": info.connected, + }) + except Exception as err: + self.emit("pairing-error", err) + + async def _on_wizard_paired(self, credentials: Credentials) -> None: + self._pairing_wizard = None + await self._on_paired(credentials) + + async def _on_paired(self, credentials: Credentials) -> None: + self._credentials = credentials + self._state = DaemonState.PAIRED + + self.emit("paired", { + "userId": credentials.user_id, + "children": [c.to_dict() for c in credentials.children], + }) + + if self._running: + await self._begin_enforcement() + + def _start_heartbeat(self) -> None: + self._stop_heartbeat() + try: + loop = asyncio.get_running_loop() + self._heartbeat_task = loop.create_task(self._heartbeat_loop()) + except RuntimeError: + pass + + def _stop_heartbeat(self) -> None: + if self._heartbeat_task is not None: + self._heartbeat_task.cancel() + self._heartbeat_task = None + + async def _heartbeat_loop(self) -> None: + while True: + await asyncio.sleep(60) + + if self._checker or not self._credentials or not self._credentials.pair_id: + self._stop_heartbeat() + return + + try: + result = await self._api.get_updates( + user_id=self._credentials.user_id, + pair_id=self._credentials.pair_id, + pair_token=self._credentials.pair_token, + ) + if result and result.get("children"): + children_raw = result["children"] + self._credentials.children = [ + Child.from_dict(c) if isinstance(c, dict) else c + for c in children_raw + ] + self.emit("children-updated", {"children": [c.to_dict() for c in self._credentials.children]}) + except Exception as err: + from allow2.api import Allow2ApiError + if isinstance(err, Allow2ApiError) and err.status == 401: + self._stop_heartbeat() + self._state = DaemonState.UNPAIRED + self._credentials = None + self._child_id = None + try: + if hasattr(self._credential_backend, "clear"): + await self._credential_backend.clear() + except Exception: + pass + self.emit("unpaired", {"error": err}) + return + + async def _begin_enforcement(self) -> None: + await self._resolve_child() + + if self._child_id: + self._state = DaemonState.ENFORCING + self._stop_heartbeat() + self._start_checker() + else: + self._start_heartbeat() + + async def _resolve_child(self) -> None: + children = self._credentials.children if self._credentials else [] + + annotated: list[Child] = [] + for c in children: + copy = Child( + id=c.id, name=c.name, pin_hash=c.pin_hash, pin_salt=c.pin_salt, + avatar_url=c.avatar_url, color=c.color, has_account=c.has_account, + linked_user_id=c.linked_user_id, os_username=c.os_username, + last_used_at=None, + ) + annotated.append(copy) + + if hasattr(self._credential_backend, "load_last_used"): + try: + last_used_map = await self._credential_backend.load_last_used() + if last_used_map: + for child in annotated: + ts = last_used_map.get(str(child.id)) + if ts: + child.last_used_at = ts + except Exception as err: + logger.error("Failed to load lastUsed data: %s", err) + + annotated.sort( + key=lambda c: c.last_used_at or "", + reverse=True, + ) + + match: dict[str, Any] | None = None + if callable(self._child_resolver) and not hasattr(self._child_resolver, "resolve"): + result = self._child_resolver(annotated) + if inspect.isawaitable(result): + match = await result + else: + match = result + elif hasattr(self._child_resolver, "resolve"): + result = self._child_resolver.resolve(annotated) + if inspect.isawaitable(result): + match = await result + else: + match = result + + if match and match.get("child_id"): + self._child_id = match["child_id"] + self._state = DaemonState.ENFORCING + self._update_last_used(match["child_id"]) + self.emit("child-selected", { + "childId": match["child_id"], + "name": match.get("child_name"), + }) + else: + self.emit("child-select-required", { + "children": [c.to_dict() for c in annotated], + }) + + def _start_checker(self) -> None: + if self._checker is not None: + self._checker.stop() + + if self._credentials is None or self._child_id is None: + return + + self._checker = Checker( + api=self._api, + emit=self.emit, + credentials=self._credentials, + child_id=self._child_id, + activities=self._activities, + check_interval=self._check_interval, + hard_lock_timeout=self._hard_lock_timeout, + grace_period=self._grace_period, + warning_thresholds=self._warning_thresholds, + ) + + def on_unpaired(*args: Any, **kwargs: Any) -> None: + self._state = DaemonState.UNPAIRED + self._credentials = None + if self._checker is not None: + self._checker.stop() + self._checker = None + self._child_id = None + self.off("unpaired", on_unpaired) + + self.on("unpaired", on_unpaired) + self._checker.start() + + def _update_last_used(self, child_id: int) -> None: + if hasattr(self._credential_backend, "update_last_used"): + try: + asyncio.ensure_future(self._credential_backend.update_last_used(child_id)) + except RuntimeError: + pass diff --git a/src/allow2/events.py b/src/allow2/events.py new file mode 100644 index 0000000..9947e75 --- /dev/null +++ b/src/allow2/events.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import asyncio +from collections import defaultdict +from typing import Any, Callable + + +class EventEmitter: + """Minimal EventEmitter matching the Node.js pattern.""" + + def __init__(self) -> None: + self._listeners: dict[str, list[Callable[..., Any]]] = defaultdict(list) + + def on(self, event: str, callback: Callable[..., Any]) -> None: + self._listeners[event].append(callback) + + def off(self, event: str, callback: Callable[..., Any]) -> None: + try: + self._listeners[event].remove(callback) + except ValueError: + pass + + def once(self, event: str, callback: Callable[..., Any]) -> None: + def wrapper(*args: Any, **kwargs: Any) -> Any: + self.off(event, wrapper) + return callback(*args, **kwargs) + self.on(event, wrapper) + + def emit(self, event: str, *args: Any, **kwargs: Any) -> None: + for listener in self._listeners[event][:]: + result = listener(*args, **kwargs) + if asyncio.iscoroutine(result): + try: + loop = asyncio.get_running_loop() + loop.create_task(result) + except RuntimeError: + pass + + def remove_all_listeners(self, event: str | None = None) -> None: + if event is None: + self._listeners.clear() + else: + self._listeners.pop(event, None) diff --git a/src/allow2/feedback.py b/src/allow2/feedback.py new file mode 100644 index 0000000..3608530 --- /dev/null +++ b/src/allow2/feedback.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from allow2.models import FeedbackCategory + +VALID_CATEGORIES: list[FeedbackCategory] = list(FeedbackCategory) + +CATEGORY_LABELS: dict[FeedbackCategory, str] = { + FeedbackCategory.BYPASS: "Bypass / Circumvention report", + FeedbackCategory.MISSING_FEATURE: "Missing Feature report", + FeedbackCategory.NOT_WORKING: "Not Working report", + FeedbackCategory.QUESTION: "Question", + FeedbackCategory.OTHER: "General feedback", +} + + +def feedback_category_to_text(category: FeedbackCategory) -> str: + return CATEGORY_LABELS.get(category, "Feedback") diff --git a/src/allow2/models.py b/src/allow2/models.py new file mode 100644 index 0000000..901ec52 --- /dev/null +++ b/src/allow2/models.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class DaemonState(str, Enum): + UNPAIRED = "unpaired" + PAIRING = "pairing" + PAIRED = "paired" + ENFORCING = "enforcing" + PARENT = "parent" + + +class WarningLevel(str, Enum): + INFO = "info" + URGENT = "urgent" + FINAL = "final" + COUNTDOWN = "countdown" + + +class FeedbackCategory(str, Enum): + BYPASS = "bypass" + MISSING_FEATURE = "missing_feature" + NOT_WORKING = "not_working" + QUESTION = "question" + OTHER = "other" + + +class VerificationLevel(str, Enum): + HONOUR = "honour" + PIN = "pin" + PARENT_ONLY = "parent-only" + + +@dataclass +class Activity: + id: int + + def to_check_map(self) -> dict[str, int]: + return {str(self.id): 1} + + +@dataclass +class Child: + id: int + name: str + pin_hash: str | None = None + pin_salt: str | None = None + avatar_url: str | None = None + color: str | None = None + has_account: bool = False + linked_user_id: int | None = None + os_username: str | None = None + last_used_at: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "pinHash": self.pin_hash, + "pinSalt": self.pin_salt, + "avatarUrl": self.avatar_url, + "color": self.color, + "hasAccount": self.has_account, + "LinkedUserId": self.linked_user_id, + "osUsername": self.os_username, + "lastUsedAt": self.last_used_at, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Child: + return cls( + id=data.get("id") or data.get("childId", 0), + name=data.get("name", ""), + pin_hash=data.get("pinHash"), + pin_salt=data.get("pinSalt"), + avatar_url=data.get("avatarUrl"), + color=data.get("color"), + has_account=bool(data.get("hasAccount", False)), + linked_user_id=data.get("LinkedUserId") or data.get("linkedUserId"), + os_username=data.get("osUsername"), + last_used_at=data.get("lastUsedAt"), + ) + + def safe_copy(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "avatarUrl": self.avatar_url, + "color": self.color, + "hasAccount": self.has_account, + } + + +@dataclass +class Credentials: + uuid: str + user_id: int + pair_id: int + pair_token: str + children: list[Child] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "uuid": self.uuid, + "userId": self.user_id, + "pairId": self.pair_id, + "pairToken": self.pair_token, + "children": [c.to_dict() for c in self.children], + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Credentials: + children_raw = data.get("children", []) + children = [ + Child.from_dict(c) if isinstance(c, dict) else c + for c in children_raw + ] + return cls( + uuid=data.get("uuid", ""), + user_id=data.get("userId", 0), + pair_id=data.get("pairId", 0), + pair_token=data.get("pairToken", ""), + children=children, + ) + + +@dataclass +class ActivityState: + allowed: bool + remaining: float = math.inf + + +@dataclass +class CheckResult: + activities: dict[str, ActivityState] = field(default_factory=dict) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class WarningThreshold: + remaining: int + level: WarningLevel + + +@dataclass +class PairingInfo: + pin: str + port: int + url: str + qr_url: str + connected: bool + + +@dataclass +class RequestResult: + request_id: str + status_secret: str + + +@dataclass +class DeviceContext: + device_name: str = "Allow2 Device" + platform: str = "python" + sdk_version: str = "2.0.0" + product_name: str = "allow2" + + def to_dict(self) -> dict[str, Any]: + return { + "deviceName": self.device_name, + "platform": self.platform, + "sdkVersion": self.sdk_version, + "productName": self.product_name, + } diff --git a/src/allow2/offline.py b/src/allow2/offline.py new file mode 100644 index 0000000..475fabb --- /dev/null +++ b/src/allow2/offline.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json +import math +import os +import time +from pathlib import Path +from typing import Any + +from allow2.events import EventEmitter + +DEFAULT_GRACE_PERIOD = 300 +DEFAULT_CACHE_PATH = str(Path.home() / ".allow2" / "cache.json") + + +class OfflineHandler(EventEmitter): + """Caches last successful check result. Enforces grace period. + After grace: deny-by-default. Cache persisted to ~/.allow2/cache.json.""" + + def __init__( + self, + *, + grace_period: int = DEFAULT_GRACE_PERIOD, + cache_path: str | None = None, + ) -> None: + super().__init__() + self._grace_period = grace_period + self._cache_path = cache_path or DEFAULT_CACHE_PATH + self._cached: dict[str, Any] | None = None + self._loaded = False + + async def cache_result(self, check_result: dict[str, Any]) -> None: + self._cached = { + "result": check_result, + "timestamp": time.time() * 1000, + } + await self._write_disk(self._cached) + + async def get_cached_result(self) -> dict[str, Any] | None: + if not self._loaded: + await self._load_disk() + if self._cached is None: + return None + return self._cached["result"] + + async def get_grace_elapsed(self) -> float: + if not self._loaded: + await self._load_disk() + if self._cached is None: + return math.inf + return (time.time() * 1000 - self._cached["timestamp"]) / 1000 + + async def is_in_grace_period(self) -> bool: + elapsed = await self.get_grace_elapsed() + if elapsed < self._grace_period: + self.emit("offline-grace", elapsed) + return True + return False + + async def should_deny(self) -> bool: + elapsed = await self.get_grace_elapsed() + if elapsed >= self._grace_period: + self.emit("offline-deny") + return True + return False + + async def _write_disk(self, data: dict[str, Any]) -> None: + try: + dir_path = os.path.dirname(self._cache_path) + os.makedirs(dir_path, mode=0o700, exist_ok=True) + with open(self._cache_path, "w") as f: + json.dump(data, f) + os.chmod(self._cache_path, 0o600) + except OSError: + pass + + async def _load_disk(self) -> None: + self._loaded = True + try: + with open(self._cache_path) as f: + parsed = json.load(f) + if ( + isinstance(parsed, dict) + and "result" in parsed + and isinstance(parsed.get("timestamp"), (int, float)) + ): + self._cached = parsed + except (OSError, json.JSONDecodeError): + pass diff --git a/src/allow2/pairing.py b/src/allow2/pairing.py new file mode 100644 index 0000000..e25570a --- /dev/null +++ b/src/allow2/pairing.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import asyncio +import logging +import uuid as uuid_mod +from typing import Any + +from allow2.api import Allow2Api +from allow2.events import EventEmitter +from allow2.models import Credentials, PairingInfo + +logger = logging.getLogger("allow2.pairing") + + +class PairingWizard(EventEmitter): + """Manages one-time device pairing. Parents NEVER enter credentials on device.""" + + def __init__( + self, + *, + api: Allow2Api, + credential_backend: Any, + port: int = 3000, + device_name: str = "Python Device", + uuid: str | None = None, + ) -> None: + super().__init__() + self._api = api + self._credential_backend = credential_backend + self._port = port + self._device_name = device_name + self._uuid = uuid + + self._pin: str | None = None + self._session_id: str | None = None + self._paired = False + self._pairing_result: dict[str, Any] | None = None + self._poll_task: asyncio.Task[None] | None = None + self._init_retry_task: asyncio.Task[None] | None = None + self._qr_url: str | None = None + self._connected = False + self._consecutive_errors = 0 + self._server: Any = None + + async def start(self) -> PairingInfo: + if self._poll_task is not None or self._init_retry_task is not None: + logger.warning("start() called but already running -- ignoring") + return PairingInfo( + pin=self._pin or "------", + port=self._port, + url=f"http://localhost:{self._port}", + qr_url=self._qr_url or "", + connected=self._connected, + ) + + self._paired = False + self._pairing_result = None + + if not self._uuid: + self._uuid = await self._load_or_create_uuid() + + api_result: dict[str, Any] | None = None + try: + logger.info( + "Calling initPINPairing (uuid=%s, device=%s)", + self._uuid, self._device_name, + ) + api_result = await self._api.init_pin_pairing( + uuid=self._uuid, + device_name=self._device_name, + ) + except Exception as err: + logger.warning("API initPINPairing failed: %s -- falling back", err) + api_result = None + + if api_result and api_result.get("pin"): + self._pin = str(api_result["pin"]) + self._session_id = ( + api_result.get("sessionId") + or api_result.get("pairingSessionId") + ) + self._connected = True + self._consecutive_errors = 0 + else: + self._pin = "------" + self._session_id = None + self._connected = False + logger.warning("API unreachable -- will retry") + self._start_init_retry() + + self._qr_url = f"https://app.allow2.com/pair?pin={self._pin}" + + try: + await self._start_http_server() + except Exception as err: + logger.warning( + "HTTP server failed on port %d: %s -- pairing still works via PIN/QR", + self._port, err, + ) + self._server = None + + if self._session_id: + self._start_polling() + + info = PairingInfo( + pin=self._pin, + port=self._port, + url=f"http://localhost:{self._port}", + qr_url=self._qr_url, + connected=self._connected, + ) + self.emit("started", { + "pin": self._pin, + "port": self._port, + "qrUrl": self._qr_url, + "connected": self._connected, + }) + return info + + async def stop(self) -> None: + if self._poll_task is not None: + self._poll_task.cancel() + try: + await self._poll_task + except (asyncio.CancelledError, Exception): + pass + self._poll_task = None + + if self._init_retry_task is not None: + self._init_retry_task.cancel() + try: + await self._init_retry_task + except (asyncio.CancelledError, Exception): + pass + self._init_retry_task = None + + if self._server is not None: + try: + await self._server.shutdown() + except Exception: + pass + self._server = None + + def get_pin(self) -> str | None: + return self._pin + + def get_qr_url(self) -> str | None: + return self._qr_url + + async def complete_pairing(self, pairing_data: dict[str, Any]) -> Credentials: + if not pairing_data.get("userId") or not pairing_data.get("pairId") or not pairing_data.get("pairToken"): + raise ValueError( + "Pairing callback missing required fields (userId, pairId, pairToken)" + ) + + from allow2.models import Child + + children_raw = pairing_data.get("children", []) + children = [ + Child.from_dict(c) if isinstance(c, dict) else c + for c in children_raw + ] + + credentials = Credentials( + uuid=self._uuid or "", + user_id=pairing_data["userId"], + pair_id=pairing_data["pairId"], + pair_token=pairing_data["pairToken"], + children=children, + ) + + await self._credential_backend.store(credentials.to_dict()) + + self._paired = True + self._pairing_result = credentials.to_dict() + self.emit("paired", credentials) + + await self.stop() + return credentials + + async def _load_or_create_uuid(self) -> str: + try: + creds = await self._credential_backend.load() + if creds and creds.get("uuid"): + return creds["uuid"] + except Exception: + pass + + new_uuid = str(uuid_mod.uuid4()) + try: + await self._credential_backend.store({"uuid": new_uuid}) + except Exception: + pass + return new_uuid + + def _start_init_retry(self) -> None: + if self._init_retry_task is not None: + return + try: + loop = asyncio.get_running_loop() + self._init_retry_task = loop.create_task(self._init_retry_loop()) + except RuntimeError: + pass + + async def _init_retry_loop(self) -> None: + while not self._paired and self._session_id is None: + await asyncio.sleep(5) + try: + result = await self._api.init_pin_pairing( + uuid=self._uuid or "", + device_name=self._device_name, + ) + if result and result.get("pin"): + self._pin = str(result["pin"]) + self._session_id = ( + result.get("sessionId") + or result.get("pairingSessionId") + ) + self._qr_url = f"https://app.allow2.com/pair?pin={self._pin}" + self._connected = True + self._consecutive_errors = 0 + logger.info("Reconnected! PIN: %s (session=%s)", self._pin, self._session_id) + self.emit("connection-status", { + "connected": True, + "pin": self._pin, + "qrUrl": self._qr_url, + }) + self._start_polling() + break + except Exception as err: + logger.warning("Init retry failed: %s", err) + self.emit("connection-status", {"connected": False}) + + self._init_retry_task = None + + def _start_polling(self) -> None: + if self._poll_task is not None: + return + try: + loop = asyncio.get_running_loop() + self._poll_task = loop.create_task(self._poll_loop()) + except RuntimeError: + pass + + async def _poll_loop(self) -> None: + poll_count = 0 + max_polls = 360 + + while not self._paired: + await asyncio.sleep(5) + poll_count += 1 + + if poll_count > max_polls: + self.emit("error", Exception("Pairing timed out after 30 minutes")) + break + + try: + result = await self._api.check_pairing_status(self._session_id or "") + if ( + result + and result.get("paired") + and result.get("userId") + and result.get("pairId") + and result.get("pairToken") + ): + await self.complete_pairing(result) + break + + if not self._connected: + self._connected = True + self._consecutive_errors = 0 + logger.info("Connection restored") + self.emit("connection-status", {"connected": True}) + except Exception as err: + self._consecutive_errors += 1 + if self._consecutive_errors >= 2 and self._connected: + self._connected = False + logger.warning("Connection lost: %s", err) + self.emit("connection-status", {"connected": False}) + if poll_count % 12 == 0: + logger.warning("Poll error: %s", err) + + self._poll_task = None + + async def _start_http_server(self) -> None: + try: + import aiohttp.web as web + except ImportError: + return + + app = web.Application() + wizard = self + + async def handle_root(_request: Any) -> Any: + return web.Response( + text=_build_pairing_page(wizard._pin or "", wizard._port, wizard._qr_url or ""), + content_type="text/html", + ) + + async def handle_status(_request: Any) -> Any: + return web.json_response({ + "paired": wizard._paired, + "result": {"userId": wizard._pairing_result.get("userId")} if wizard._paired and wizard._pairing_result else None, + }) + + async def handle_callback(request: Any) -> Any: + if wizard._paired: + return web.json_response({"error": "Already paired"}, status=409) + body = await request.json() + if not body or not body.get("userId") or not body.get("pairId") or not body.get("pairToken"): + return web.json_response( + {"error": "Missing required fields (userId, pairId, pairToken)"}, + status=400, + ) + try: + credentials = await wizard.complete_pairing(body) + return web.json_response({"success": True, "userId": credentials.user_id}) + except Exception as err: + return web.json_response({"error": str(err)}, status=500) + + async def handle_success(_request: Any) -> Any: + return web.Response(text=_build_success_page(), content_type="text/html") + + app.router.add_get("/", handle_root) + app.router.add_get("/status", handle_status) + app.router.add_post("/pair-callback", handle_callback) + app.router.add_get("/success", handle_success) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "localhost", self._port) + await site.start() + self._server = runner + + +def _build_pairing_page(pin: str, port: int, qr_url: str) -> str: + digits = "".join(f'{d}' for d in pin) + return f""" + + + + +Allow2 - Device Pairing + + + +
+ +
Device Pairing
+

Enter this PIN in the Allow2 app on your phone to pair this device.

+
{digits}
+
Waiting for parent to confirm...
+
+ + +""" + + +def _build_success_page() -> str: + return """ + + + +Allow2 - Paired Successfully + + + +
+ +
Device Paired Successfully
+

This device is now connected to your Allow2 account. You can close this window.

+
+ +""" diff --git a/src/allow2/py.typed b/src/allow2/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/allow2/request.py b/src/allow2/request.py new file mode 100644 index 0000000..5e15033 --- /dev/null +++ b/src/allow2/request.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from allow2.api import Allow2Api +from allow2.events import EventEmitter +from allow2.models import RequestResult + +DEFAULT_POLL_INTERVAL = 5.0 +DEFAULT_TIMEOUT = 300.0 + + +class RequestManager(EventEmitter): + """Request More Time flow with polling.""" + + def __init__( + self, + *, + api: Allow2Api, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + super().__init__() + self._api = api + self._poll_interval = poll_interval + self._timeout = timeout + self._poll_task: asyncio.Task[None] | None = None + self._timeout_handle: asyncio.TimerHandle | None = None + + async def create_request( + self, + *, + user_id: int, + pair_id: int, + pair_token: str, + child_id: int, + duration: int, + activity: int, + message: str | None = None, + ) -> RequestResult: + try: + response = await self._api.create_request( + user_id=user_id, + pair_id=pair_id, + pair_token=pair_token, + child_id=child_id, + duration=duration, + activity=activity, + message=message, + ) + request_id = response["requestId"] + status_secret = response["statusSecret"] + + self.emit("request-created", {"requestId": request_id}) + self.start_polling(request_id, status_secret) + + return RequestResult(request_id=request_id, status_secret=status_secret) + except Exception as err: + self.emit("request-error", err) + raise + + def start_polling(self, request_id: str, status_secret: str) -> None: + self.stop_polling() + + try: + loop = asyncio.get_running_loop() + self._timeout_handle = loop.call_later( + self._timeout, + self._on_timeout, + ) + self._poll_task = loop.create_task(self._poll(request_id, status_secret)) + except RuntimeError: + pass + + def stop_polling(self) -> None: + if self._poll_task is not None: + self._poll_task.cancel() + self._poll_task = None + if self._timeout_handle is not None: + self._timeout_handle.cancel() + self._timeout_handle = None + + def _on_timeout(self) -> None: + self.stop_polling() + self.emit("request-timeout") + + async def _poll(self, request_id: str, status_secret: str) -> None: + while True: + await asyncio.sleep(self._poll_interval) + try: + status = await self._api.get_request_status(request_id, status_secret) + + if status.get("status") == "approved": + self.stop_polling() + self.emit("request-approved", { + "requestId": request_id, + "extension": status.get("extension"), + }) + return + + if status.get("status") == "denied": + self.stop_polling() + self.emit("request-denied", {"requestId": request_id}) + return + + except Exception as err: + self.emit("request-error", err) diff --git a/src/allow2/updates.py b/src/allow2/updates.py new file mode 100644 index 0000000..598e34d --- /dev/null +++ b/src/allow2/updates.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from allow2.api import Allow2Api, Allow2ApiError +from allow2.events import EventEmitter +from allow2.models import Credentials + +logger = logging.getLogger("allow2.updates") + + +class UpdatePoller(EventEmitter): + """Polls GET /api/getUpdates for delta changes.""" + + def __init__( + self, + *, + api: Allow2Api, + poll_interval: float = 30.0, + ) -> None: + super().__init__() + self._api = api + self._poll_interval = poll_interval + + self._credentials: Credentials | None = None + self._last_timestamp: int | None = None + self._task: asyncio.Task[None] | None = None + self._running = False + + def start(self, credentials: Credentials) -> None: + if self._running: + return + self._credentials = credentials + self._running = True + try: + loop = asyncio.get_running_loop() + self._task = loop.create_task(self._poll()) + except RuntimeError: + pass + + def stop(self) -> None: + self._running = False + if self._task is not None: + self._task.cancel() + self._task = None + + async def _poll(self) -> None: + while self._running: + try: + await self._fetch_updates() + except Exception as err: + self._handle_error(err) + + if self._running: + await asyncio.sleep(self._poll_interval) + + async def _fetch_updates(self) -> None: + if self._credentials is None: + return + + kwargs: dict[str, Any] = { + "user_id": self._credentials.user_id, + "pair_id": self._credentials.pair_id, + "pair_token": self._credentials.pair_token, + } + if self._last_timestamp is not None: + kwargs["timestamp_millis"] = self._last_timestamp + + result = await self._api.get_updates(**kwargs) + + if result and result.get("timestampMillis"): + self._last_timestamp = result["timestampMillis"] + + self._process_updates(result) + + def _process_updates(self, result: dict[str, Any]) -> None: + if not result: + return + + extensions = result.get("extensions") + if extensions: + for ext in extensions: + self.emit("extension", { + "childId": ext.get("childId"), + "activity": ext.get("activity"), + "additionalMinutes": ext.get("additionalMinutes"), + }) + + day_type_changes = result.get("dayTypeChanges") + if day_type_changes: + for dtc in day_type_changes: + self.emit("day-type-changed", { + "childId": dtc.get("childId"), + "dayType": dtc.get("dayType"), + }) + + quota_updates = result.get("quotaUpdates") + if quota_updates: + for qu in quota_updates: + self.emit("quota-updated", { + "childId": qu.get("childId"), + "activity": qu.get("activity"), + "newQuota": qu.get("newQuota"), + }) + + bans = result.get("bans") + if bans: + for ban in bans: + self.emit("ban", { + "childId": ban.get("childId"), + "activity": ban.get("activity"), + "banned": ban.get("banned"), + }) + + children = result.get("children") + if children: + self.emit("children-updated", children) + + def _handle_error(self, err: Exception) -> None: + if isinstance(err, Allow2ApiError) and err.status == 401: + self.emit("unpaired", {"error": err}) + self.stop() + return + self.emit("error", err) diff --git a/src/allow2/warnings.py b/src/allow2/warnings.py new file mode 100644 index 0000000..42fff30 --- /dev/null +++ b/src/allow2/warnings.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Any, Callable + +from allow2.models import WarningLevel, WarningThreshold + +DEFAULT_THRESHOLDS: list[WarningThreshold] = [ + WarningThreshold(remaining=15 * 60, level=WarningLevel.INFO), + WarningThreshold(remaining=5 * 60, level=WarningLevel.URGENT), + WarningThreshold(remaining=60, level=WarningLevel.FINAL), + WarningThreshold(remaining=30, level=WarningLevel.COUNTDOWN), +] + + +class WarningScheduler: + """Tracks remaining time per activity and emits 'warning' events + when configurable thresholds are crossed. Fire-once per level per activity.""" + + def __init__( + self, + *, + emit: Callable[..., Any], + thresholds: list[WarningThreshold] | None = None, + ) -> None: + self._emit = emit + self._thresholds = sorted( + (thresholds or DEFAULT_THRESHOLDS)[:], + key=lambda t: t.remaining, + reverse=True, + ) + self._fired: dict[str, set[WarningLevel]] = {} + + def update(self, activities: dict[str, dict[str, Any]]) -> None: + for activity_id, data in activities.items(): + remaining = data.get("remaining") + if remaining is None or remaining < 0: + continue + + fired_set = self._fired.get(activity_id) + if fired_set is None: + fired_set = set() + self._fired[activity_id] = fired_set + + for threshold in self._thresholds: + if remaining <= threshold.remaining and threshold.level not in fired_set: + fired_set.add(threshold.level) + self._emit("warning", { + "level": threshold.level.value, + "activityId": activity_id, + "remaining": remaining, + }) + + def reset_activity(self, activity_id: str) -> None: + self._fired.pop(activity_id, None) + + def reset_all(self) -> None: + self._fired.clear() From d8d3c81a3ade3e134fe2d2fe967b0e77f4b2d0b4 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 11 Mar 2026 08:50:55 +0000 Subject: [PATCH 2/7] Add CI/CD workflows for PyPI publishing and GitHub Releases - ci.yml: lint (ruff), type check (mypy), test (pytest) across Python 3.10-3.13 - publish.yml: on v* tags, build wheel+sdist, publish to PyPI via OIDC trusted publishing, create GitHub Release with artifacts attached Co-Authored-By: claude-flow --- .github/workflows/ci.yml | 34 +++++++++++++++++++ .github/workflows/publish.yml | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..53eff8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check . + + - name: Type check with mypy + run: mypy . + + - name: Run tests + run: pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6d8643a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,62 @@ +name: Publish + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-pypi: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true From ff068e4c76a374ef7ee9fe6125e4d1bff7ee117e Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 11 Mar 2026 08:58:49 +0000 Subject: [PATCH 3/7] Add comprehensive README for Python SDK Covers installation (pip/keyring), quick start with asyncio, module reference, code examples for all features, and the Allow2 Device Operational Lifecycle architecture. Co-Authored-By: claude-flow --- README.md | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3c930f --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# Allow2 SDK for Python + +[![PyPI version](https://img.shields.io/pypi/v/allow2.svg?style=flat-square)](https://pypi.org/project/allow2/) +[![Python versions](https://img.shields.io/pypi/pyversions/allow2.svg?style=flat-square)](https://pypi.org/project/allow2/) +[![CI](https://img.shields.io/github/actions/workflow/status/Allow2/allow2python/ci.yml?style=flat-square)](https://github.com/Allow2/allow2python/actions) + +Official Allow2 Parental Freedom SDK for Python. + +| | | +|---|---| +| **Package** | `allow2` | +| **Targets** | Python 3.10+ | +| **Dependencies** | `httpx` | +| **Optional** | `aiohttp` (pairing server), `keyring` (secure credential storage) | +| **Language** | Python (asyncio) | + +## Installation + +```bash +pip install allow2 +``` + +With optional secure credential storage: + +```bash +pip install allow2[keyring] +``` + +## Quick Start + +```python +import asyncio +from allow2 import DeviceDaemon, PlaintextBackend, Activity + +async def main(): + backend = PlaintextBackend() + + daemon = DeviceDaemon( + device_name='Living Room PC', + activities=[Activity(id=1), Activity(id=8)], # Internet + Screen Time + credential_backend=backend, + child_resolver=lambda children: None, # interactive selection + ) + + daemon.on('pairing-required', lambda info: print(f"Enter PIN: {info['pin']}")) + daemon.on('child-select-required', lambda data: print('Select child:', [c['name'] for c in data['children']])) + daemon.on('warning', lambda w: print(f"Warning: {w['level']}, {w['remaining']}s left")) + daemon.on('soft-lock', lambda _: print('Time is up!')) + + await daemon.start() + await daemon.open_app() # triggers pairing if unpaired + +asyncio.run(main()) +``` + +## Modules + +| Module | File | Purpose | +|--------|------|---------| +| **DeviceDaemon** | `daemon.py` | Main orchestrator managing the full device lifecycle | +| **Allow2Api** | `api.py` | httpx-based async REST client for all Allow2 endpoints | +| **PairingWizard** | `pairing.py` | aiohttp-based pairing wizard (QR code + PIN display) | +| **ChildShield** | `child_shield.py` | PIN hashing (SHA-256 + salt), rate limiting, session timeout | +| **Checker** | `checker.py` | Permission check loop with per-activity enforcement and stacking | +| **Warnings** | `warnings.py` | Configurable progressive warning scheduler | +| **OfflineHandler** | `offline.py` | Response cache, grace period, deny-by-default fallback | +| **RequestManager** | `request.py` | "Request More Time" flow with polling | +| **UpdatePoller** | `updates.py` | Poll for children, quota, ban, and day type changes | +| **Credentials** | `credentials/` | `PlaintextBackend` default + optional `KeyringBackend` | + +## Permission Checks + +```python +# The check loop runs automatically once a child is selected. +# Listen for results: +@daemon.on('check-result') +def on_check(result): + for activity_id, activity in result['activities'].items(): + print(f"{activity_id}: allowed={activity['allowed']}, remaining={activity['remaining']}s") + print(f"Today: {result['day_type_today']}, Tomorrow: {result['day_type_tomorrow']}") +``` + +## Request More Time + +```python +# Child requests 30 more minutes of gaming +result = await daemon.request_more_time( + activity=3, # Gaming + duration=30, # minutes + message="Can I please have more time? Almost done with this level.", +) + +# Poll until parent responds +status = await daemon.poll_request_status(result['request_id'], result['status_secret']) + +if status['status'] == 'approved': + print(f"Approved! {status['duration']} extra minutes.") +elif status['status'] == 'denied': + print("Request denied.") +``` + +## Feedback + +```python +# Submit feedback +result = await daemon.submit_feedback( + category='not_working', + message='The block screen appears even when time is remaining.', +) + +# Load feedback threads +feedback = await daemon.load_device_feedback() +for thread in feedback['discussions']: + print(f"[{thread['category']}] {thread['status']} - {thread['message_count']} messages") + +# Reply to a thread +await daemon.reply_to_feedback(result['discussion_id'], 'This happens every Tuesday.') +``` + +## Warnings + +The SDK fires progressive warnings as time runs out: + +``` +15 min -> 5 min -> 1 min -> 30 sec -> 10 sec -> BLOCKED +``` + +```python +@daemon.on('warning') +def on_warning(data): + show_warning_banner(f"{data['remaining']} seconds remaining") + +@daemon.on('soft-lock') +def on_lock(_): + show_block_screen() +``` + +## Credential Storage + +The SDK uses a pluggable credential backend. The default `PlaintextBackend` writes to `~/.allow2/credentials.json` with `0600` permissions. + +For production, use the `KeyringBackend` (backed by the system keychain) or implement your own: + +```python +class MyCredentialBackend: + async def load(self) -> dict | None: + """Return {'user_id': ..., 'pair_id': ..., 'pair_token': ..., 'children': [...]} or None.""" + ... + + async def store(self, credentials: dict) -> None: + """Persist credentials.""" + ... + + async def clear(self) -> None: + """Remove stored credentials.""" + ... +``` + +## Target Platforms + +| Platform | Notes | +|----------|-------| +| **Linux** | Desktop apps, daemons, Raspberry Pi | +| **macOS** | Desktop apps, system services | +| **Windows** | Desktop apps, services | +| **Embedded** | Any device with Python 3.10+ and httpx | +| **Server** | Django, FastAPI, Flask service integrations | + +## Architecture + +The SDK follows the Allow2 Device Operational Lifecycle: + +1. **Pairing** (one-time) -- QR code or 6-digit PIN, parent never enters credentials on device +2. **Child Identification** (every session) -- OS account mapping or child selector with PIN +3. **Permission Checks** (continuous) -- POST to service URL every 30-60s with `log: true` +4. **Warnings & Countdowns** -- progressive alerts before blocking +5. **Request More Time** -- child requests, parent approves/denies from their phone +6. **Feedback** -- in-app bug reports and feature requests + +All API communication uses `httpx` with async support. The check endpoint POSTs to the **service URL** (`service.allow2.com`), while all other endpoints use the **API URL** (`api.allow2.com`). + +Environment overrides via `ALLOW2_API_URL`, `ALLOW2_VID`, and `ALLOW2_TOKEN` environment variables. + +## License + +See [LICENSE](LICENSE) for details. From 991689fa5e535916188a469c63adc97d723d29cb Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 11 Mar 2026 09:42:42 +0000 Subject: [PATCH 4/7] Update Device Operational Lifecycle in README Corrected to 7 steps: added child verification via Allow2 app/web portal, parent access mode, offline request support, and clarified feedback goes directly to the developer. Co-Authored-By: claude-flow --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f3c930f..99f3f6c 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,12 @@ class MyCredentialBackend: The SDK follows the Allow2 Device Operational Lifecycle: 1. **Pairing** (one-time) -- QR code or 6-digit PIN, parent never enters credentials on device -2. **Child Identification** (every session) -- OS account mapping or child selector with PIN -3. **Permission Checks** (continuous) -- POST to service URL every 30-60s with `log: true` -4. **Warnings & Countdowns** -- progressive alerts before blocking -5. **Request More Time** -- child requests, parent approves/denies from their phone -6. **Feedback** -- in-app bug reports and feature requests +2. **Child Identification** (every session) -- OS account mapping, child selector with PIN, or verification via the child's Allow2 app (iOS/Android) or web portal +3. **Parent Access** -- parent verifies via their Allow2 app or locally with PIN for unrestricted mode +4. **Permission Checks** (continuous) -- POST to service URL every 30-60s with `log: true` +5. **Warnings & Countdowns** -- progressive alerts before blocking +6. **Request More Time** -- child requests, parent approves/denies from their phone (also works offline) +7. **Feedback** -- bug reports and feature requests sent directly to you, the developer All API communication uses `httpx` with async support. The check endpoint POSTs to the **service URL** (`service.allow2.com`), while all other endpoints use the **API URL** (`api.allow2.com`). From cb66aee02793dff3b7dff1fe76a5f4edb8bb226b Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 11 Mar 2026 09:46:05 +0000 Subject: [PATCH 5/7] Add parent app/web portal verification and offline operation section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parent access: added iOS/Android app and web portal as verification methods - New Offline Operation section explaining cached permissions, deny-by-default, offline request-more-time (including voice codes over the phone), and automatic resync — making clear the device is always manageable once paired Co-Authored-By: claude-flow --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 99f3f6c..f2561f4 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ The SDK follows the Allow2 Device Operational Lifecycle: 1. **Pairing** (one-time) -- QR code or 6-digit PIN, parent never enters credentials on device 2. **Child Identification** (every session) -- OS account mapping, child selector with PIN, or verification via the child's Allow2 app (iOS/Android) or web portal -3. **Parent Access** -- parent verifies via their Allow2 app or locally with PIN for unrestricted mode +3. **Parent Access** -- parent verifies via their Allow2 app (iOS/Android), web portal, or locally with PIN for unrestricted mode 4. **Permission Checks** (continuous) -- POST to service URL every 30-60s with `log: true` 5. **Warnings & Countdowns** -- progressive alerts before blocking 6. **Request More Time** -- child requests, parent approves/denies from their phone (also works offline) @@ -182,6 +182,19 @@ All API communication uses `httpx` with async support. The check endpoint POSTs Environment overrides via `ALLOW2_API_URL`, `ALLOW2_VID`, and `ALLOW2_TOKEN` environment variables. +## Offline Operation + +Once a device is paired, Allow2 remains fully configurable even when the device is offline. The parent can still manage the child's limits, approve requests, and change settings from their Allow2 app or the web portal -- changes are synchronised the next time the device connects. + +On the device side: + +- **Cached permissions** -- the last successful check result is cached locally. During a configurable grace period (default 5 minutes), the device continues to enforce the cached result. +- **Deny-by-default** -- after the grace period expires without connectivity, all activities are blocked. This prevents children from bypassing controls by disabling Wi-Fi or enabling airplane mode. +- **Request More Time (offline)** -- children can still submit requests even when the device is offline. The request is presented to the parent via their app or a voice code that can be read over the phone. The parent approves or denies from their end, and the device applies the result when connectivity resumes (or immediately via a voice code response entered locally). +- **Automatic resync** -- when the device comes back online, it immediately fetches the latest permissions, processes any queued requests, and resumes normal check polling. + +This means a paired device is never "unmanageable" -- the parent always has control, regardless of the device's network state. + ## License See [LICENSE](LICENSE) for details. From ee861ed260612b5b9b0c75153b3a6429b26f6eb6 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 11 Mar 2026 11:02:32 +0000 Subject: [PATCH 6/7] Broaden Request system in README: more time, day type change, ban lift Requests are the mechanism by which the child drives the configuration of the system. Update lifecycle step 6 and offline section to reflect all three request types, not just "Request More Time". Co-Authored-By: claude-flow --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f2561f4..e8351df 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ asyncio.run(main()) | **Checker** | `checker.py` | Permission check loop with per-activity enforcement and stacking | | **Warnings** | `warnings.py` | Configurable progressive warning scheduler | | **OfflineHandler** | `offline.py` | Response cache, grace period, deny-by-default fallback | -| **RequestManager** | `request.py` | "Request More Time" flow with polling | +| **RequestManager** | `request.py` | Request flow (more time, day type change, ban lift) with polling | | **UpdatePoller** | `updates.py` | Poll for children, quota, ban, and day type changes | | **Credentials** | `credentials/` | `PlaintextBackend` default + optional `KeyringBackend` | @@ -175,7 +175,7 @@ The SDK follows the Allow2 Device Operational Lifecycle: 3. **Parent Access** -- parent verifies via their Allow2 app (iOS/Android), web portal, or locally with PIN for unrestricted mode 4. **Permission Checks** (continuous) -- POST to service URL every 30-60s with `log: true` 5. **Warnings & Countdowns** -- progressive alerts before blocking -6. **Request More Time** -- child requests, parent approves/denies from their phone (also works offline) +6. **Requests** -- child requests changes (more time, day type change, ban lift), parent approves/denies from their phone (also works offline via voice codes) 7. **Feedback** -- bug reports and feature requests sent directly to you, the developer All API communication uses `httpx` with async support. The check endpoint POSTs to the **service URL** (`service.allow2.com`), while all other endpoints use the **API URL** (`api.allow2.com`). @@ -190,7 +190,7 @@ On the device side: - **Cached permissions** -- the last successful check result is cached locally. During a configurable grace period (default 5 minutes), the device continues to enforce the cached result. - **Deny-by-default** -- after the grace period expires without connectivity, all activities are blocked. This prevents children from bypassing controls by disabling Wi-Fi or enabling airplane mode. -- **Request More Time (offline)** -- children can still submit requests even when the device is offline. The request is presented to the parent via their app or a voice code that can be read over the phone. The parent approves or denies from their end, and the device applies the result when connectivity resumes (or immediately via a voice code response entered locally). +- **Requests (offline)** -- children can still submit all request types (more time, day type change, ban lift) even when the device is offline. The request is presented to the parent via their app or a voice code that can be read over the phone. The parent approves or denies from their end, and the device applies the result when connectivity resumes (or immediately via a voice code response entered locally). - **Automatic resync** -- when the device comes back online, it immediately fetches the latest permissions, processes any queued requests, and resumes normal check polling. This means a paired device is never "unmanageable" -- the parent always has control, regardless of the device's network state. From 7ed92139caf33b5d1c2ab6262e8da2045a499c8b Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 11 Mar 2026 20:59:22 +0000 Subject: [PATCH 7/7] Add standardised bump-version.sh script Unified version bumping across all Allow2 SDKs: ./scripts/bump-version.sh prerelease --preid alpha ./scripts/bump-version.sh [patch|minor|major] Handles language-specific version formats (npm, PEP 440, Gradle, .csproj, CMake, git tags), commits, and tags. Co-Authored-By: claude-flow --- scripts/bump-version.sh | 84 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100755 scripts/bump-version.sh diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..bbd642b --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# bump-version.sh — Standardised version bumping for Allow2 Python SDK +# Usage: ./scripts/bump-version.sh [prerelease|patch|minor|major] [--preid alpha|beta|rc] +# +# Python uses PEP 440: 2.0.0a1 (alpha), 2.0.0b1 (beta), 2.0.0rc1 (release candidate) +# +# Examples: +# ./scripts/bump-version.sh prerelease --preid alpha # 2.0.0a1 → 2.0.0a2 +# ./scripts/bump-version.sh prerelease --preid beta # 2.0.0a2 → 2.0.0b1 +# ./scripts/bump-version.sh patch # 2.0.0a2 → 2.0.1 +# ./scripts/bump-version.sh minor # 2.0.1 → 2.1.0 +# ./scripts/bump-version.sh major # 2.1.0 → 3.0.0 +set -euo pipefail +cd "$(dirname "$0")/.." + +VERSION_FILE="pyproject.toml" +BUMP="${1:-prerelease}" +PREID="" +if [[ "${2:-}" == "--preid" ]]; then PREID="${3:-alpha}"; fi + +# Map preid to PEP 440 suffix +pep440_suffix() { + case "$1" in + alpha) echo "a" ;; + beta) echo "b" ;; + rc) echo "rc" ;; + *) echo "a" ;; + esac +} + +# Read current version from pyproject.toml +OLD=$(grep '^version = ' "$VERSION_FILE" | head -1 | sed 's/version = "\(.*\)"/\1/') + +# Parse version: 2.0.0a1 → major=2 minor=0 patch=0 pre_type=a pre_num=1 +if [[ "$OLD" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(a|b|rc)?([0-9]+)?$ ]]; then + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + PRE_TYPE="${BASH_REMATCH[4]:-}" + PRE_NUM="${BASH_REMATCH[5]:-}" +else + echo "Cannot parse version: $OLD" >&2; exit 1 +fi + +case "$BUMP" in + prerelease) + SUFFIX=$(pep440_suffix "${PREID:-alpha}") + if [[ "$PRE_TYPE" == "$SUFFIX" && -n "$PRE_NUM" ]]; then + NEW="${MAJOR}.${MINOR}.${PATCH}${SUFFIX}$((PRE_NUM + 1))" + else + NEW="${MAJOR}.${MINOR}.${PATCH}${SUFFIX}1" + fi + ;; + patch) + NEW="$MAJOR.$MINOR.$((PATCH + 1))" + ;; + minor) + NEW="$MAJOR.$((MINOR + 1)).0" + ;; + major) + NEW="$((MAJOR + 1)).0.0" + ;; + *) + echo "Usage: $0 [prerelease|patch|minor|major] [--preid alpha|beta|rc]" >&2 + exit 1 + ;; +esac + +# Write back +sed -i "s/^version = \"${OLD}\"/version = \"${NEW}\"/" "$VERSION_FILE" + +echo "$OLD → $NEW" +git add "$VERSION_FILE" +git commit -m "v$NEW" +# Tag uses semver format for CI publish workflow +TAG="v${MAJOR}.${MINOR}.${PATCH}" +if [[ "$NEW" != "${MAJOR}.${MINOR}.${PATCH}" ]]; then + # Convert PEP 440 back to semver for tag: 2.0.0a2 → v2.0.0-alpha.2 + TAG_PRE=$(echo "$NEW" | sed "s/${MAJOR}\.${MINOR}\.${PATCH}//") + TAG_PRE=$(echo "$TAG_PRE" | sed 's/^a/-alpha./; s/^b/-beta./; s/^rc/-rc./') + TAG="v${MAJOR}.${MINOR}.${PATCH}${TAG_PRE}" +fi +git tag "$TAG" +echo "Tagged $TAG — push with: git push origin master --tags"