diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7cc5678
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+.env
+.venv
+**/.build
+**/__pycache__
+**/.pytest_cache
+data
+ios
+packages
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4bcb2d6
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+# Generate with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
+APC_ADMIN_TOKEN=CHANGE_ME_TO_A_RANDOM_32_PLUS_CHARACTER_SECRET
+# The exact origin used by the parent browser. Use HTTPS for actual iPhones.
+APC_ORIGIN=http://localhost:8000
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d2409a7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+.env
+.venv/
+__pycache__/
+.pytest_cache/
+*.py[cod]
+data/
+*.sqlite3*
+.build/
+.swiftpm/
+ios/Generated/
+ios/*.xcodeproj/
+ios/Config/Local.xcconfig
+DerivedData/
+*.xcuserstate
+*.ipa
+*.p12
+*.mobileprovision
+.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..99788ec
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM python:3.13-slim
+WORKDIR /app
+COPY backend/requirements.txt /app/requirements.txt
+RUN pip install --no-cache-dir -r requirements.txt \
+ && useradd --create-home --uid 10001 apc \
+ && mkdir -p /data && chown apc:apc /data
+COPY backend/src /app/src
+ENV PYTHONPATH=/app/src PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 APC_DATABASE=/data/apc.sqlite3
+USER apc
+EXPOSE 8000
+CMD ["uvicorn", "apc.api:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2ca8742
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,23 @@
+PYTHON ?= .venv/bin/python
+
+.PHONY: bootstrap serve test test-backend test-swift check-web ios-project
+bootstrap:
+ python3 -m venv .venv
+ $(PYTHON) -m pip install -r backend/requirements-dev.txt
+
+serve:
+ $(PYTHON) -m uvicorn apc.api:create_app --factory --app-dir backend/src --host 127.0.0.1 --port 8000
+
+test: test-backend test-swift check-web
+
+test-backend:
+ $(PYTHON) -m pytest -c backend/pyproject.toml backend/tests
+
+test-swift:
+ swift test --package-path packages/PolicyCore
+
+check-web:
+ node --check backend/src/apc/static/app.js
+
+ios-project:
+ cd ios && xcodegen generate
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..7728a82
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,4 @@
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests"]
+addopts = "-q"
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..3756dff
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,9 @@
+-r requirements.txt
+certifi==2026.5.20
+httpcore==1.0.9
+httpx==0.28.1
+iniconfig==2.3.0
+packaging==25.0
+pluggy==1.6.0
+pygments==2.20.0
+pytest==9.0.2
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..276bf0f
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,15 @@
+# Exact versions used by the local test run; update and retest as a set.
+annotated-doc==0.0.4
+annotated-types==0.7.0
+anyio==4.13.0
+click==8.1.8
+fastapi==0.128.2
+h11==0.16.0
+idna==3.17
+pydantic==2.13.4
+pydantic-core==2.46.4
+starlette==0.50.0
+typing-extensions==4.16.0
+typing-inspection==0.4.2
+tzdata==2026.2
+uvicorn==0.48.0
diff --git a/backend/src/apc/__init__.py b/backend/src/apc/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/src/apc/api.py b/backend/src/apc/api.py
new file mode 100644
index 0000000..00dc3aa
--- /dev/null
+++ b/backend/src/apc/api.py
@@ -0,0 +1,364 @@
+"""Small single-household API. Device credentials never grant parent privileges."""
+from dataclasses import dataclass
+from datetime import datetime
+import hashlib
+import hmac
+import json
+import os
+from pathlib import Path
+import secrets
+import time
+from urllib.parse import urlsplit
+import uuid
+from zoneinfo import ZoneInfo
+
+from fastapi import Depends, FastAPI, HTTPException, Request, Response
+from fastapi.responses import FileResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import ValidationError
+
+from .models import (ChildCreate, DeviceStatus, Login, OverrideUpdate, PairInput, Policy,
+ PolicyUpdate, SecretInput, Submission)
+from .store import Store, earned, effective_policy
+
+
+def digest(value: str) -> str:
+ return hashlib.sha256(value.encode()).hexdigest()
+
+
+def new_id() -> str:
+ return str(uuid.uuid4())
+
+
+@dataclass(frozen=True)
+class Settings:
+ admin_token: str
+ database: str = "data/apc.sqlite3"
+ origin: str = "http://localhost:8000"
+
+ def __post_init__(self):
+ if not 32 <= len(self.admin_token) <= 256 or self.admin_token.startswith("CHANGE_ME"):
+ raise ValueError("APC_ADMIN_TOKEN must be a randomly generated 32+ character secret")
+ parsed = urlsplit(self.origin)
+ if (not parsed.hostname or parsed.username or parsed.password or parsed.query
+ or parsed.fragment or parsed.path not in ("", "/")):
+ raise ValueError("APC_ORIGIN must be an origin, without credentials or a path")
+ if parsed.scheme != "https" and not (
+ parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")
+ ):
+ raise ValueError("HTTPS is required except for loopback development")
+
+
+def create_app(settings: Settings | None = None, clock=time.time) -> FastAPI:
+ settings = settings or Settings(
+ admin_token=os.environ.get("APC_ADMIN_TOKEN", ""),
+ database=os.environ.get("APC_DATABASE", "data/apc.sqlite3"),
+ origin=os.environ.get("APC_ORIGIN", "http://localhost:8000"),
+ )
+ store = Store(settings.database)
+ app = FastAPI(title="Advanced Parental Controls", version="0.1.0", docs_url=None,
+ redoc_url=None)
+ app.state.store = store
+ static = Path(__file__).parent / "static"
+ secure_cookie = settings.origin.startswith("https:")
+ session_name = "apc_session"
+
+ @app.middleware("http")
+ async def security_headers(request: Request, call_next):
+ # Limits normal accidental payloads. The reverse proxy must also cap streaming bodies.
+ try:
+ length = int(request.headers.get("content-length", "0") or "0")
+ except ValueError:
+ return JSONResponse({"detail": "Invalid content length"}, status_code=400)
+ if length < 0:
+ return JSONResponse({"detail": "Invalid content length"}, status_code=400)
+ if length > 32768:
+ return JSONResponse({"detail": "Request too large"}, status_code=413)
+ response = await call_next(request)
+ response.headers.update({
+ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY",
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; "
+ "frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
+ })
+ return response
+
+ def session_digest(value: str) -> str:
+ # Rotating the admin secret also invalidates existing browser sessions.
+ return hmac.new(settings.admin_token.encode(), value.encode(), hashlib.sha256).hexdigest()
+
+ def check_origin(request: Request, required=False):
+ origin = request.headers.get("origin")
+ if (required or origin is not None) and origin != settings.origin.rstrip("/"):
+ raise HTTPException(403, "Origin rejected")
+
+ def bearer(request: Request) -> str:
+ scheme, _, value = request.headers.get("authorization", "").partition(" ")
+ return value if scheme.lower() == "bearer" else ""
+
+ def parent(request: Request):
+ token = bearer(request)
+ if token and hmac.compare_digest(digest(token), digest(settings.admin_token)):
+ check_origin(request)
+ return
+ with store.transaction() as db:
+ valid = db.execute("SELECT 1 FROM sessions WHERE hash=? AND expires>?",
+ (session_digest(request.cookies.get(session_name, "")), clock())).fetchone()
+ if not valid:
+ raise HTTPException(401, "Parent authentication required")
+ if request.method not in ("GET", "HEAD"):
+ check_origin(request, required=True)
+
+ def device(request: Request):
+ token = bearer(request)
+ with store.transaction() as db:
+ row = db.execute("SELECT * FROM devices WHERE token_hash=? AND revoked=0",
+ (digest(token),)).fetchone() if token else None
+ if row is None:
+ raise HTTPException(401, "Device not paired or revoked")
+ return dict(row)
+
+ def day_for(policy: Policy):
+ return datetime.fromtimestamp(clock(), ZoneInfo(policy.timezone)).date().isoformat()
+
+ def revision_matches(db, expected):
+ current = db.execute("SELECT revision FROM family WHERE id=1").fetchone()[0]
+ if current != expected:
+ raise HTTPException(409, "Policy changed; refresh before saving")
+
+ def child_exists(db, child_id):
+ if db.execute("SELECT 1 FROM children WHERE id=?", (child_id,)).fetchone() is None:
+ raise HTTPException(404, "Child not found")
+
+ def issue_code(db, kind, subject):
+ code = secrets.token_urlsafe(18)
+ expiry = clock() + 600
+ db.execute("DELETE FROM codes WHERE expires<=? OR (kind=? AND subject=?)",
+ (clock(), kind, subject))
+ db.execute("INSERT INTO codes VALUES (?,?,?,?)", (digest(code), kind, subject, expiry))
+ return {"code": code, "expires_at": expiry}
+
+ def consume_code(db, code, kind, subject=None):
+ row = db.execute("SELECT * FROM codes WHERE hash=? AND kind=? AND expires>?",
+ (digest(code), kind, clock())).fetchone()
+ if row is None or (subject is not None and row["subject"] != subject):
+ raise HTTPException(403, "Invalid or expired code")
+ db.execute("DELETE FROM codes WHERE hash=?", (digest(code),))
+ return row["subject"]
+
+ @app.get("/healthz")
+ def health():
+ return {"status": "ok", "version": "0.1.0"}
+
+ @app.post("/v1/session")
+ def login(body: Login, request: Request, response: Response):
+ check_origin(request)
+ if not hmac.compare_digest(digest(body.token), digest(settings.admin_token)):
+ raise HTTPException(401, "Invalid parent token")
+ token = secrets.token_urlsafe(32)
+ with store.transaction() as db:
+ db.execute("DELETE FROM sessions WHERE expires<=?", (clock(),))
+ db.execute("INSERT INTO sessions VALUES (?,?)", (session_digest(token), clock() + 43200))
+ response.set_cookie(session_name, token, max_age=43200, httponly=True,
+ secure=secure_cookie, samesite="strict")
+ return {"authenticated": True}
+
+ @app.delete("/v1/session", dependencies=[Depends(parent)])
+ def logout(request: Request, response: Response):
+ with store.transaction() as db:
+ db.execute("DELETE FROM sessions WHERE hash=?",
+ (session_digest(request.cookies.get(session_name, "")),))
+ response.delete_cookie(session_name, secure=secure_cookie, httponly=True, samesite="strict")
+ return {"authenticated": False}
+
+ @app.get("/v1/parent/family", dependencies=[Depends(parent)])
+ def family():
+ with store.transaction() as db:
+ f = db.execute("SELECT * FROM family WHERE id=1").fetchone()
+ children = []
+ for c in db.execute("SELECT * FROM children ORDER BY name, id").fetchall():
+ _, policy = effective_policy(db, c["id"])
+ d = db.execute("SELECT id,name,last_seen,status FROM devices "
+ "WHERE child_id=? AND revoked=0", (c["id"],)).fetchone()
+ info = dict(d) if d else None
+ if info:
+ info["status"] = json.loads(info["status"]) if info["status"] else None
+ children.append({"id": c["id"], "name": c["name"],
+ "overrides": json.loads(c["overrides"]),
+ "effective_policy": policy.model_dump(), "device": info,
+ "earned_minutes": min(earned(db, c["id"], day_for(policy)),
+ policy.max_bonus_minutes)})
+ return {"revision": f["revision"], "policy": json.loads(f["policy"]),
+ "children": children}
+
+ @app.put("/v1/parent/policy", dependencies=[Depends(parent)])
+ def update_policy(body: PolicyUpdate):
+ with store.transaction() as db:
+ revision_matches(db, body.expected_revision)
+ try:
+ for c in db.execute("SELECT overrides FROM children").fetchall():
+ Policy.model_validate(body.policy.model_dump() | json.loads(c[0]))
+ except ValidationError:
+ raise HTTPException(422, "New policy conflicts with a child's overrides") from None
+ # Timezone changes could mint a second 'today'. Keep it fixed once rewards exist.
+ old = Policy.model_validate_json(db.execute("SELECT policy FROM family").fetchone()[0])
+ if body.policy.timezone != old.timezone and db.execute("SELECT 1 FROM rewards LIMIT 1").fetchone():
+ raise HTTPException(409, "Timezone changes after rewards exist are deferred in v0")
+ db.execute("UPDATE family SET policy=?, revision=revision+1 WHERE id=1",
+ (body.policy.model_dump_json(),))
+ return {"revision": body.expected_revision + 1}
+
+ @app.post("/v1/parent/children", dependencies=[Depends(parent)], status_code=201)
+ def add_child(body: ChildCreate):
+ child_id = new_id()
+ with store.transaction() as db:
+ db.execute("INSERT INTO children(id,name) VALUES (?,?)", (child_id, body.name))
+ return {"id": child_id, "name": body.name}
+
+ @app.put("/v1/parent/children/{child_id}/overrides", dependencies=[Depends(parent)])
+ def overrides(child_id: str, body: OverrideUpdate):
+ with store.transaction() as db:
+ child_exists(db, child_id)
+ revision_matches(db, body.expected_revision)
+ if "timezone" in body.overrides:
+ raise HTTPException(422, "Timezone belongs to the family, not child overrides")
+ base = json.loads(db.execute("SELECT policy FROM family WHERE id=1").fetchone()[0])
+ try:
+ Policy.model_validate(base | body.overrides)
+ except ValidationError:
+ raise HTTPException(422, "Invalid override fields or values") from None
+ db.execute("UPDATE children SET overrides=? WHERE id=?",
+ (json.dumps(body.overrides), child_id))
+ db.execute("UPDATE family SET revision=revision+1 WHERE id=1")
+ return {"revision": body.expected_revision + 1}
+
+ @app.post("/v1/parent/children/{child_id}/pairing-code", dependencies=[Depends(parent)])
+ def pairing_code(child_id: str):
+ with store.transaction() as db:
+ child_exists(db, child_id)
+ if db.execute("SELECT 1 FROM devices WHERE child_id=? AND revoked=0", (child_id,)).fetchone():
+ raise HTTPException(409, "One active iPhone per child in v0; revoke the old one first")
+ return issue_code(db, "pair", child_id)
+
+ @app.post("/v1/parent/devices/{device_id}/configuration-code", dependencies=[Depends(parent)])
+ def configuration_code(device_id: str):
+ with store.transaction() as db:
+ if not db.execute("SELECT 1 FROM devices WHERE id=? AND revoked=0", (device_id,)).fetchone():
+ raise HTTPException(404, "Active device not found")
+ return issue_code(db, "configure", device_id)
+
+ @app.delete("/v1/parent/devices/{device_id}", dependencies=[Depends(parent)])
+ def revoke(device_id: str):
+ with store.transaction() as db:
+ db.execute("UPDATE devices SET revoked=1 WHERE id=?", (device_id,))
+ db.execute("DELETE FROM codes WHERE subject=?", (device_id,))
+ return {"revoked": True}
+
+ @app.post("/v1/pair", status_code=201)
+ def pair(body: PairInput):
+ with store.transaction() as db:
+ child_id = consume_code(db, body.code, "pair")
+ if db.execute("SELECT 1 FROM devices WHERE child_id=? AND revoked=0", (child_id,)).fetchone():
+ raise HTTPException(409, "Child already has an active device")
+ device_id, token = new_id(), secrets.token_urlsafe(32)
+ db.execute("INSERT INTO devices(id,child_id,name,token_hash) VALUES (?,?,?,?)",
+ (device_id, child_id, body.name, digest(token)))
+ return {"device_id": device_id, "child_id": child_id, "token": token}
+
+ @app.post("/v1/device/configuration-unlock")
+ def unlock(body: SecretInput, d=Depends(device)):
+ with store.transaction() as db:
+ consume_code(db, body.code, "configure", d["id"])
+ return {"authorized": True}
+
+ @app.get("/v1/device/snapshot")
+ def snapshot(d=Depends(device)):
+ with store.transaction() as db:
+ revision, policy = effective_policy(db, d["child_id"])
+ day = day_for(policy)
+ name = db.execute("SELECT name FROM children WHERE id=?", (d["child_id"],)).fetchone()[0]
+ return {"schema_version": 1, "device_id": d["id"], "child_id": d["child_id"],
+ "child_name": name, "policy_revision": revision, "policy": policy.model_dump(),
+ "day": day, "earned_minutes": min(earned(db, d["child_id"], day),
+ policy.max_bonus_minutes), "server_time": clock()}
+
+ @app.post("/v1/device/status")
+ def status(body: DeviceStatus, d=Depends(device)):
+ with store.transaction() as db:
+ db.execute("UPDATE devices SET status=?,last_seen=? WHERE id=?",
+ (body.model_dump_json(), clock(), d["id"]))
+ return {"received": True}
+
+ def public_challenge(row):
+ return {"id": row["id"], "expires_at": row["expires"],
+ "questions": [{"id": q["id"], "prompt": q["prompt"]}
+ for q in json.loads(row["questions"])]}
+
+ @app.post("/v1/device/challenges", status_code=201)
+ def challenge(d=Depends(device)):
+ with store.transaction() as db:
+ revision, policy = effective_policy(db, d["child_id"])
+ day = day_for(policy)
+ if earned(db, d["child_id"], day) >= policy.max_bonus_minutes:
+ raise HTTPException(409, "Today's bonus cap has been reached")
+ pending = db.execute("SELECT * FROM challenges WHERE device_id=? AND day=? "
+ "AND revision=? AND expires>? AND result IS NULL ORDER BY expires DESC LIMIT 1",
+ (d["id"], day, revision, clock())).fetchone()
+ if pending:
+ return public_challenge(pending)
+ count = db.execute("SELECT COUNT(*) FROM challenges WHERE device_id=? AND day=?",
+ (d["id"], day)).fetchone()[0]
+ if count >= 100:
+ raise HTTPException(429, "Daily challenge attempt limit reached")
+ questions = []
+ for _ in range(policy.correct_answers):
+ a, b = secrets.randbelow(policy.max_operand + 1), secrets.randbelow(policy.max_operand + 1)
+ op = secrets.choice(("+", "-"))
+ if op == "-":
+ a, b = max(a, b), min(a, b)
+ questions.append({"id": new_id(), "prompt": f"{a} {op} {b}",
+ "answer": a + b if op == "+" else a - b})
+ challenge_id = new_id()
+ db.execute("INSERT INTO challenges VALUES (?,?,?,?,?,?,?,NULL)",
+ (challenge_id, d["id"], d["child_id"], day, revision, clock() + 600,
+ json.dumps(questions)))
+ row = db.execute("SELECT * FROM challenges WHERE id=?", (challenge_id,)).fetchone()
+ return public_challenge(row)
+
+ @app.post("/v1/device/challenges/{challenge_id}/submit")
+ def submit(challenge_id: str, body: Submission, d=Depends(device)):
+ with store.transaction() as db:
+ row = db.execute("SELECT * FROM challenges WHERE id=? AND device_id=?",
+ (challenge_id, d["id"])).fetchone()
+ if row is None:
+ raise HTTPException(404, "Challenge not found")
+ if row["result"] is not None:
+ return json.loads(row["result"]) # Durable idempotency, including failed attempts.
+ revision, policy = effective_policy(db, d["child_id"])
+ day = day_for(policy)
+ if row["expires"] <= clock() or row["day"] != day or row["revision"] != revision:
+ raise HTTPException(409, "Challenge expired or policy changed; start a new challenge")
+ questions = json.loads(row["questions"])
+ answers = {a.question_id: a.answer for a in body.answers}
+ if len(answers) != len(body.answers) or set(answers) != {q["id"] for q in questions}:
+ raise HTTPException(422, "Answer each question exactly once")
+ correct = sum(answers[q["id"]] == q["answer"] for q in questions)
+ passed = correct == len(questions)
+ minutes = max(0, min(policy.bonus_minutes,
+ policy.max_bonus_minutes - earned(db, d["child_id"], day))) if passed else 0
+ reward_id = new_id() if minutes else None
+ if reward_id:
+ db.execute("INSERT INTO rewards VALUES (?,?,?,?,?,?)",
+ (reward_id, challenge_id, d["child_id"], day, minutes, clock()))
+ result = {"challenge_id": challenge_id, "passed": passed, "correct_count": correct,
+ "required_count": len(questions), "awarded_minutes": minutes,
+ "reward_id": reward_id, "day": day}
+ db.execute("UPDATE challenges SET result=? WHERE id=?", (json.dumps(result), challenge_id))
+ return result
+
+ @app.get("/")
+ def dashboard():
+ return FileResponse(static / "index.html")
+
+ app.mount("/static", StaticFiles(directory=static), name="static")
+ return app
diff --git a/backend/src/apc/models.py b/backend/src/apc/models.py
new file mode 100644
index 0000000..1dd3cf9
--- /dev/null
+++ b/backend/src/apc/models.py
@@ -0,0 +1,86 @@
+"""Version-one wire contract. Examples are product defaults, not clinical guidance."""
+from typing import Any, Literal
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+
+class Model(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True)
+
+
+class Policy(Model):
+ timezone: str = "Europe/Berlin"
+ daily_minutes: int = Field(default=30, ge=0, le=240)
+ bonus_minutes: int = Field(default=10, ge=5, le=30)
+ max_bonus_minutes: int = Field(default=30, ge=0, le=60)
+ correct_answers: int = Field(default=5, ge=1, le=20)
+ max_operand: int = Field(default=20, ge=2, le=100)
+ allowed_start: int = Field(default=420, ge=0, le=1439)
+ allowed_end: int = Field(default=1200, ge=1, le=1439)
+
+ @field_validator("timezone")
+ @classmethod
+ def valid_timezone(cls, value: str) -> str:
+ try:
+ ZoneInfo(value)
+ except (ValueError, ZoneInfoNotFoundError) as exc:
+ raise ValueError("Use an IANA timezone, e.g. Europe/Berlin") from exc
+ return value
+
+ @model_validator(mode="after")
+ def valid_window(self):
+ if self.allowed_end - self.allowed_start < 60:
+ raise ValueError("v0 requires one same-day allowed window of at least 60 minutes")
+ return self
+
+
+class PolicyUpdate(Model):
+ expected_revision: int = Field(ge=1)
+ policy: Policy
+
+
+class ChildCreate(Model):
+ name: str = Field(min_length=1, max_length=60)
+
+ @field_validator("name")
+ @classmethod
+ def clean_name(cls, value: str) -> str:
+ if not value.strip():
+ raise ValueError("Name must not be blank")
+ return value.strip()
+
+
+class OverrideUpdate(Model):
+ expected_revision: int = Field(ge=1)
+ overrides: dict[str, Any]
+
+
+class SecretInput(Model):
+ code: str = Field(min_length=16, max_length=256)
+
+
+class PairInput(SecretInput):
+ name: str = Field(min_length=1, max_length=80)
+
+
+class Login(Model):
+ token: str = Field(min_length=32, max_length=256)
+
+
+class Answer(Model):
+ question_id: str = Field(max_length=64)
+ answer: int = Field(ge=-10000, le=10000)
+
+
+class Submission(Model):
+ answers: list[Answer] = Field(min_length=1, max_length=20)
+
+
+class DeviceStatus(Model):
+ policy_revision: int = Field(ge=0)
+ state: Literal["monitoring", "not_authorized", "not_configured", "error"]
+ shielded: bool
+ detail: str = Field(max_length=500)
+ consumed_lower_bound: int = Field(ge=0, le=10000)
+ usage_day: str = Field(max_length=10)
diff --git a/backend/src/apc/static/app.js b/backend/src/apc/static/app.js
new file mode 100644
index 0000000..de7ea94
--- /dev/null
+++ b/backend/src/apc/static/app.js
@@ -0,0 +1,172 @@
+"use strict";
+const $ = (s) => document.querySelector(s);
+let family;
+const fields = [
+ ["daily_minutes", "Daily entertainment minutes", 0, 240],
+ ["bonus_minutes", "Minutes per completed challenge", 5, 30],
+ ["max_bonus_minutes", "Daily bonus cap", 0, 60],
+ ["correct_answers", "Correct answers per challenge", 1, 20],
+ ["max_operand", "Largest number in a question", 2, 100],
+ ["allowed_start", "Allowed from", "time"],
+ ["allowed_end", "Allowed until", "time"],
+ ["timezone", "Family timezone", "text"],
+];
+function node(tag, text, cls) {
+ const el = document.createElement(tag);
+ if (text !== undefined) el.textContent = text;
+ if (cls) el.className = cls;
+ return el;
+}
+function message(text, error = false) {
+ $("#message").textContent = text;
+ $("#message").className = error ? "error" : "";
+}
+async function api(path, method = "GET", body) {
+ const response = await fetch(path, {
+ method, credentials: "same-origin", headers: {"Content-Type": "application/json"},
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ const result = await response.json();
+ if (!response.ok) {
+ if (response.status === 401) setSignedIn(false);
+ throw new Error(typeof result.detail === "string" ? result.detail : "Check the form values and try again.");
+ }
+ return result;
+}
+function setSignedIn(value) {
+ $("#login-panel").hidden = value;
+ $("#dashboard").hidden = !value;
+ $("#actions").hidden = !value;
+ if (!value) { family = undefined; $("#children").replaceChildren(); }
+}
+function asTime(value) {
+ return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
+}
+function buildFields(target, values, inherited) {
+ target.replaceChildren();
+ for (const [key, title, min, max] of fields) {
+ if (inherited && key === "timezone") continue;
+ const label = node("label", title);
+ const input = node("input");
+ input.name = key;
+ input.type = typeof min === "string" ? min : "number";
+ if (typeof min === "number") { input.min = min; input.max = max; input.step = 1; }
+ input.required = !inherited;
+ const present = Object.hasOwn(values, key);
+ input.value = present ? (min === "time" ? asTime(values[key]) : values[key]) : "";
+ label.append(input);
+ if (inherited) label.append(node("small", `Blank inherits: ${min === "time" ? asTime(inherited[key]) : inherited[key]}`));
+ target.append(label);
+ }
+}
+function readFields(form) {
+ const values = {};
+ for (const [key, , type] of fields) {
+ const input = form.elements.namedItem(key);
+ if (!input || input.value === "") continue;
+ values[key] = type === "time"
+ ? input.value.split(":").reduce((h, m) => Number(h) * 60 + Number(m))
+ : type === "text" ? input.value.trim() : Number(input.value);
+ }
+ return values;
+}
+async function run(button, operation) {
+ button.disabled = true;
+ try { await operation(); } catch (error) { message(error.message, true); }
+ finally { button.disabled = false; }
+}
+async function showCode(path, configuring) {
+ const result = await api(path, "POST");
+ $("#code-title").textContent = configuring ? "Allow app-selection changes" : "Pair a child's iPhone";
+ $("#issued-code").textContent = result.code;
+ $("#code-help").textContent = configuring
+ ? "Enter this in Parent setup on the child's app. It allows one app-selection session."
+ : "On the child's phone, enter this server's HTTPS address and this code. A guardian must also approve Apple's Family Controls prompt.";
+ $("#code-dialog").showModal();
+}
+function renderChild(child) {
+ const card = node("article", undefined, "child");
+ card.append(node("h3", child.name));
+ const p = child.effective_policy;
+ card.append(node("p", `${p.daily_minutes} daily minutes + ${child.earned_minutes} earned today · bonus cap ${p.max_bonus_minutes}`));
+ const device = child.device;
+ const status = device?.status;
+ const applied = status?.policy_revision === family.revision && status.state === "monitoring";
+ card.append(node("p", !device ? "No phone paired" : applied
+ ? `Last reported: monitoring revision ${status.policy_revision}${status.shielded ? " · shielded" : ""}`
+ : `Pending / needs attention: ${status?.detail || "phone has not reported configuration"}`, "status"));
+ if (device?.last_seen) card.append(node("small", `Last report: ${new Date(device.last_seen * 1000).toLocaleString()}`));
+ const actions = node("div", undefined, "buttons");
+ const code = node("button", device ? "Authorize app selection" : "Pair phone");
+ code.type = "button";
+ code.addEventListener("click", () => run(code, () => showCode(device
+ ? `/v1/parent/devices/${device.id}/configuration-code`
+ : `/v1/parent/children/${child.id}/pairing-code`, !!device)));
+ actions.append(code);
+ if (device) {
+ const revoke = node("button", "Revoke pairing", "secondary");
+ revoke.type = "button";
+ revoke.addEventListener("click", () => run(revoke, async () => {
+ if (!confirm("Revoke API access? This does not remotely remove shields from an offline phone. Guardian removal may be needed.")) return;
+ await api(`/v1/parent/devices/${device.id}`, "DELETE"); await refresh();
+ }));
+ actions.append(revoke);
+ }
+ card.append(actions);
+ const details = node("details");
+ details.append(node("summary", "Individual exceptions"));
+ const form = node("form");
+ const controls = node("div", undefined, "fields");
+ buildFields(controls, child.overrides, family.policy);
+ const save = node("button", "Save exceptions");
+ form.append(controls, save);
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ run(save, async () => {
+ await api(`/v1/parent/children/${child.id}/overrides`, "PUT", {
+ expected_revision: family.revision, overrides: readFields(form),
+ });
+ await refresh(); message("Exceptions saved. The phone will apply them at its next sync.");
+ });
+ });
+ details.append(form); card.append(details); return card;
+}
+async function refresh() {
+ family = await api("/v1/parent/family");
+ setSignedIn(true);
+ $("#revision").textContent = `Revision ${family.revision}`;
+ buildFields($("#policy-fields"), family.policy);
+ $("#children").replaceChildren(...family.children.map(renderChild));
+}
+$("#login").addEventListener("submit", (event) => {
+ event.preventDefault();
+ const form = event.currentTarget;
+ run(form.querySelector("button"), async () => {
+ const token = form.elements.token.value;
+ form.reset();
+ await api("/v1/session", "POST", {token}); await refresh(); message("");
+ });
+});
+$("#policy").addEventListener("submit", (event) => {
+ event.preventDefault(); const form = event.currentTarget;
+ run(form.querySelector("button"), async () => {
+ await api("/v1/parent/policy", "PUT", {expected_revision: family.revision, policy: readFields(form)});
+ await refresh(); message("Shared policy saved. Devices apply it at their next sync.");
+ });
+});
+$("#add-child").addEventListener("submit", (event) => {
+ event.preventDefault(); const form = event.currentTarget;
+ run(form.querySelector("button"), async () => {
+ await api("/v1/parent/children", "POST", {name: form.elements.name.value}); form.reset(); await refresh();
+ });
+});
+$("#refresh").addEventListener("click", (event) => run(event.currentTarget, refresh));
+$("#logout").addEventListener("click", (event) => run(event.currentTarget, async () => {
+ await api("/v1/session", "DELETE"); setSignedIn(false); message("");
+}));
+$("#copy-code").addEventListener("click", (event) => run(event.currentTarget, async () => {
+ await navigator.clipboard.writeText($("#issued-code").textContent);
+}));
+$("#close-code").addEventListener("click", () => $("#code-dialog").close());
+$("#code-dialog").addEventListener("close", () => { $("#issued-code").textContent = ""; });
+refresh().catch(() => setSignedIn(false));
diff --git a/backend/src/apc/static/index.html b/backend/src/apc/static/index.html
new file mode 100644
index 0000000..7c829ce
--- /dev/null
+++ b/backend/src/apc/static/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+ Family controls
+
+
+
+
+
+
+Parent sign-in
+Use the administration token configured on your server. Never put it on a child's phone.
+
+
+
+
Shared family policy
+These values are inherited unless a child has an explicit override. The example limits are not age-based recommendations.
+
+
+
+
+
+
+
diff --git a/backend/src/apc/static/style.css b/backend/src/apc/static/style.css
new file mode 100644
index 0000000..24b5442
--- /dev/null
+++ b/backend/src/apc/static/style.css
@@ -0,0 +1,24 @@
+:root { font-family: system-ui, sans-serif; color: #1c2938; background: #f3f6f8; line-height: 1.5; }
+* { box-sizing: border-box; } [hidden] { display: none !important; }
+body { margin: 0; } header, main, footer { max-width: 1120px; margin: auto; padding: 24px; }
+header { display: flex; justify-content: space-between; gap: 24px; align-items: center; padding-top: 42px; }
+h1 { font-size: clamp(1.8rem,4vw,2.6rem); line-height: 1.15; margin: 12px 0; letter-spacing: -.03em; }
+h2 { margin-top: 0; } h3 { margin-bottom: 8px; } p { margin-top: 8px; }
+.eyebrow { font-size: .73rem; letter-spacing: .11em; font-weight: 700; }
+section, .child { border: 1px solid #d8e1e8; padding: 24px; border-radius: 12px; background: white; margin-bottom: 22px; }
+.child { background: #fafcfd; margin-bottom: 0; } .children { display: grid; gap: 18px; }
+.fields { display: grid; grid-template-columns: repeat(auto-fit,minmax(210px,1fr)); gap: 16px; margin: 20px 0; }
+label { display: flex; flex-direction: column; font-weight: 600; font-size: .9rem; gap: 5px; }
+input { min-height: 44px; padding: 10px; border: 1px solid #aab8c4; border-radius: 6px; font: inherit; width: 100%; background: white; }
+button { border: 1px solid #183f53; border-radius: 6px; background: #183f53; color: white; min-height: 42px; padding: 9px 17px; font: inherit; cursor: pointer; }
+button.secondary { background: white; color: #183f53; } button:disabled { opacity: .55; cursor: progress; }
+button:focus-visible, input:focus-visible, summary:focus-visible { outline: 3px solid #6b9abd; outline-offset: 3px; }
+.inline, .buttons, .section-heading { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
+.inline { margin-bottom: 24px; } .inline label { flex: 1; } .buttons { margin: 18px 0; } .section-heading { justify-content: space-between; align-items: center; }
+small, footer { color: #526477; } small { font-size: .8rem; font-weight: normal; } .status { font-weight: 600; }
+aside { background: #fff5dd; padding: 16px; border-left: 4px solid #ab7424; margin-bottom: 22px; }
+#message:empty { display: none; } #message { padding: 12px; border: 1px solid #a8bbc4; border-radius: 6px; }
+#message.error { color: #9c2433; border-color: #9c2433; } details { border-top: 1px solid #d8e1e8; padding-top: 16px; } summary { cursor: pointer; }
+dialog { width: min(560px,calc(100% - 32px)); border: 1px solid #aab8c4; border-radius: 12px; padding: 24px; } dialog::backdrop { background: #182f4599; }
+code { overflow-wrap: anywhere; font-size: 1.1rem; } #login { max-width: 480px; } #login button { margin-top: 16px; }
+@media(max-width:650px) { header { display: block; } main, header, footer { padding: 18px; } section, .child { padding: 18px; } }
diff --git a/backend/src/apc/store.py b/backend/src/apc/store.py
new file mode 100644
index 0000000..6eec4a1
--- /dev/null
+++ b/backend/src/apc/store.py
@@ -0,0 +1,80 @@
+"""SQLite transactions serialize reward issuance; no in-memory allowance ledger."""
+from contextlib import contextmanager
+import json
+from pathlib import Path
+import sqlite3
+
+from .models import Policy
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS family (
+ id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, policy TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS children (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, overrides TEXT NOT NULL DEFAULT '{}'
+);
+CREATE TABLE IF NOT EXISTS devices (
+ id TEXT PRIMARY KEY, child_id TEXT NOT NULL REFERENCES children(id),
+ name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
+ revoked INTEGER NOT NULL DEFAULT 0, last_seen REAL, status TEXT
+);
+CREATE UNIQUE INDEX IF NOT EXISTS one_active_device ON devices(child_id) WHERE revoked=0;
+CREATE TABLE IF NOT EXISTS codes (
+ hash TEXT PRIMARY KEY, kind TEXT NOT NULL, subject TEXT NOT NULL, expires REAL NOT NULL
+);
+CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, expires REAL NOT NULL);
+CREATE TABLE IF NOT EXISTS challenges (
+ id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices(id),
+ child_id TEXT NOT NULL REFERENCES children(id), day TEXT NOT NULL,
+ revision INTEGER NOT NULL, expires REAL NOT NULL, questions TEXT NOT NULL, result TEXT
+);
+CREATE TABLE IF NOT EXISTS rewards (
+ id TEXT PRIMARY KEY, challenge_id TEXT NOT NULL UNIQUE REFERENCES challenges(id),
+ child_id TEXT NOT NULL REFERENCES children(id), day TEXT NOT NULL,
+ minutes INTEGER NOT NULL CHECK(minutes>0), created REAL NOT NULL
+);
+CREATE INDEX IF NOT EXISTS reward_day ON rewards(child_id, day);
+CREATE INDEX IF NOT EXISTS challenge_device ON challenges(device_id, day);
+PRAGMA user_version=1;
+"""
+
+
+class Store:
+ def __init__(self, path: str):
+ self.path = path
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ with self.transaction() as db:
+ version = db.execute("PRAGMA user_version").fetchone()[0]
+ if version not in (0, 1):
+ raise RuntimeError(f"Unsupported database version {version}; do not downgrade")
+ db.executescript(SCHEMA)
+ db.execute("INSERT OR IGNORE INTO family VALUES (1, 1, ?)",
+ (Policy().model_dump_json(),))
+
+ @contextmanager
+ def transaction(self):
+ db = sqlite3.connect(self.path, timeout=10)
+ db.row_factory = sqlite3.Row
+ db.execute("PRAGMA foreign_keys=ON")
+ db.execute("PRAGMA journal_mode=WAL")
+ db.execute("BEGIN IMMEDIATE")
+ try:
+ yield db
+ db.commit()
+ except BaseException:
+ db.rollback()
+ raise
+ finally:
+ db.close()
+
+
+def effective_policy(db, child_id: str) -> tuple[int, Policy]:
+ family = db.execute("SELECT * FROM family WHERE id=1").fetchone()
+ child = db.execute("SELECT * FROM children WHERE id=?", (child_id,)).fetchone()
+ values = json.loads(family["policy"]) | json.loads(child["overrides"])
+ return family["revision"], Policy.model_validate(values)
+
+
+def earned(db, child_id: str, day: str) -> int:
+ return db.execute("SELECT COALESCE(SUM(minutes),0) FROM rewards WHERE child_id=? AND day=?",
+ (child_id, day)).fetchone()[0]
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
new file mode 100644
index 0000000..9c36797
--- /dev/null
+++ b/backend/tests/test_api.py
@@ -0,0 +1,222 @@
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timezone
+import json
+
+from fastapi.testclient import TestClient
+import pytest
+
+from apc.api import Settings, create_app
+
+TOKEN = "test-only-parent-token-with-more-than-32-characters"
+HEADERS = {"Authorization": f"Bearer {TOKEN}"}
+
+
+@pytest.fixture
+def env(tmp_path):
+ clock = [datetime(2026, 9, 17, 10, tzinfo=timezone.utc).timestamp()]
+ app = create_app(Settings(TOKEN, str(tmp_path / "test.db")), clock=lambda: clock[0])
+ with TestClient(app) as client:
+ yield client, app.state.store, clock
+
+
+def enroll(client, name="Example child"):
+ child = client.post("/v1/parent/children", json={"name": name}, headers=HEADERS).json()
+ code = client.post(f"/v1/parent/children/{child['id']}/pairing-code", headers=HEADERS).json()["code"]
+ device = client.post("/v1/pair", json={"code": code, "name": "Test phone"}).json()
+ return child, device, {"Authorization": f"Bearer {device['token']}"}
+
+
+def solve(challenge):
+ result = []
+ for question in challenge["questions"]:
+ a, op, b = question["prompt"].split()
+ result.append({"question_id": question["id"],
+ "answer": int(a) + int(b) if op == "+" else int(a) - int(b)})
+ return {"answers": result}
+
+
+def policy_update(client, **updates):
+ f = client.get("/v1/parent/family", headers=HEADERS).json()
+ return client.put("/v1/parent/policy", headers=HEADERS,
+ json={"expected_revision": f["revision"], "policy": f["policy"] | updates})
+
+
+def test_parent_auth_and_device_scope(env):
+ client, _, _ = env
+ assert client.get("/v1/parent/family").status_code == 401
+ child, _, headers = enroll(client)
+ assert client.get("/v1/parent/family", headers=headers).status_code == 401
+ assert client.get("/v1/device/snapshot", headers=HEADERS).status_code == 401
+ assert client.get("/v1/device/snapshot", headers=headers).json()["child_id"] == child["id"]
+
+
+def test_inheritance_overrides_and_optimistic_concurrency(env):
+ client, _, _ = env
+ a, _, ha = enroll(client, "A")
+ _, _, hb = enroll(client, "B")
+ f = client.get("/v1/parent/family", headers=HEADERS).json()
+ body = {"expected_revision": f["revision"], "overrides": {"daily_minutes": 45}}
+ assert client.put(f"/v1/parent/children/{a['id']}/overrides", json=body, headers=HEADERS).status_code == 200
+ assert client.put(f"/v1/parent/children/{a['id']}/overrides", json=body, headers=HEADERS).status_code == 409
+ assert policy_update(client, daily_minutes=60, correct_answers=3).status_code == 200
+ pa = client.get("/v1/device/snapshot", headers=ha).json()["policy"]
+ pb = client.get("/v1/device/snapshot", headers=hb).json()["policy"]
+ assert (pa["daily_minutes"], pb["daily_minutes"]) == (45, 60)
+ assert pa["correct_answers"] == pb["correct_answers"] == 3
+ revision = client.get("/v1/parent/family", headers=HEADERS).json()["revision"]
+ assert client.put(f"/v1/parent/children/{a['id']}/overrides", headers=HEADERS,
+ json={"expected_revision": revision, "overrides": {}}).status_code == 200
+ assert client.get("/v1/device/snapshot", headers=ha).json()["policy"]["daily_minutes"] == 60
+
+
+def test_reward_is_server_graded_capped_and_idempotent(env):
+ client, store, _ = env
+ _, _, headers = enroll(client)
+ assert policy_update(client, max_bonus_minutes=15).status_code == 200
+ for expected in (10, 5):
+ challenge = client.post("/v1/device/challenges", headers=headers).json()
+ assert all("answer" not in q for q in challenge["questions"])
+ same = client.post("/v1/device/challenges", headers=headers).json()
+ assert same == challenge
+ path = f"/v1/device/challenges/{challenge['id']}/submit"
+ result = client.post(path, json=solve(challenge), headers=headers)
+ assert result.json()["awarded_minutes"] == expected
+ assert client.post(path, json=solve(challenge), headers=headers).json() == result.json()
+ assert client.post("/v1/device/challenges", headers=headers).status_code == 409
+ with store.transaction() as db:
+ assert db.execute("SELECT COUNT(*), SUM(minutes) FROM rewards").fetchone()[:] == (2, 15)
+
+
+def test_concurrent_submission_issues_one_credit(env):
+ client, store, _ = env
+ _, _, headers = enroll(client)
+ challenge = client.post("/v1/device/challenges", headers=headers).json()
+ def submit(_):
+ return client.post(f"/v1/device/challenges/{challenge['id']}/submit",
+ headers=headers, json=solve(challenge)).json()
+ with ThreadPoolExecutor(max_workers=6) as pool:
+ results = list(pool.map(submit, range(12)))
+ assert all(result == results[0] for result in results)
+ with store.transaction() as db:
+ assert db.execute("SELECT COUNT(*) FROM rewards").fetchone()[0] == 1
+
+
+def test_bad_answers_fail_and_cannot_be_retried_into_credit(env):
+ client, _, _ = env
+ _, _, headers = enroll(client)
+ ch = client.post("/v1/device/challenges", headers=headers).json()
+ bad = solve(ch)
+ bad["answers"][0]["answer"] += 1
+ path = f"/v1/device/challenges/{ch['id']}/submit"
+ result = client.post(path, headers=headers, json=bad).json()
+ assert not result["passed"] and result["awarded_minutes"] == 0
+ assert client.post(path, headers=headers, json=solve(ch)).json() == result
+
+
+def test_duplicate_question_and_cross_child_submission_rejected(env):
+ client, _, _ = env
+ _, _, ha = enroll(client, "A")
+ _, _, hb = enroll(client, "B")
+ ch = client.post("/v1/device/challenges", headers=ha).json()
+ path = f"/v1/device/challenges/{ch['id']}/submit"
+ answers = solve(ch)
+ assert client.post(path, headers=hb, json=answers).status_code == 404
+ answers["answers"][1] = answers["answers"][0]
+ assert client.post(path, headers=ha, json=answers).status_code == 422
+
+
+def test_expiry_policy_change_and_local_midnight(env):
+ client, _, clock = env
+ _, _, h = enroll(client)
+ def start(): return client.post("/v1/device/challenges", headers=h).json()
+ def submit(ch): return client.post(f"/v1/device/challenges/{ch['id']}/submit", headers=h, json=solve(ch))
+ ch = start(); clock[0] += 601
+ assert submit(ch).status_code == 409
+ ch = start(); policy_update(client, daily_minutes=40)
+ assert submit(ch).status_code == 409
+ ch = start(); assert submit(ch).json()["awarded_minutes"] == 10
+ clock[0] = datetime(2026, 9, 17, 21, 59, tzinfo=timezone.utc).timestamp()
+ ch = start(); clock[0] += 120 # Cross Berlin midnight, not UTC midnight.
+ assert submit(ch).status_code == 409
+ snapshot = client.get("/v1/device/snapshot", headers=h).json()
+ assert snapshot["day"] == "2026-09-18" and snapshot["earned_minutes"] == 0
+
+
+def test_pairing_single_use_expiry_revocation_and_configuration_scope(env):
+ client, _, clock = env
+ a, device, h = enroll(client)
+ assert client.post(f"/v1/parent/children/{a['id']}/pairing-code", headers=HEADERS).status_code == 409
+ code = client.post(f"/v1/parent/devices/{device['device_id']}/configuration-code", headers=HEADERS).json()["code"]
+ _, _, other = enroll(client, "Other")
+ assert client.post("/v1/device/configuration-unlock", json={"code": code}, headers=other).status_code == 403
+ assert client.post("/v1/device/configuration-unlock", json={"code": code}, headers=h).status_code == 200
+ assert client.post("/v1/device/configuration-unlock", json={"code": code}, headers=h).status_code == 403
+ client.delete(f"/v1/parent/devices/{device['device_id']}", headers=HEADERS)
+ assert client.get("/v1/device/snapshot", headers=h).status_code == 401
+ code = client.post(f"/v1/parent/children/{a['id']}/pairing-code", headers=HEADERS).json()["code"]
+ clock[0] += 601
+ assert client.post("/v1/pair", json={"code": code, "name": "New phone"}).status_code == 403
+
+
+def test_cookie_auth_csrf_headers_and_logout(env):
+ client, _, _ = env
+ result = client.post("/v1/session", json={"token": TOKEN}, headers={"Origin": "http://localhost:8000"})
+ assert "HttpOnly" in result.headers["set-cookie"] and "SameSite=strict" in result.headers["set-cookie"]
+ assert client.get("/v1/parent/family").status_code == 200
+ assert client.post("/v1/parent/children", json={"name": "Test"}).status_code == 403
+ assert client.post("/v1/parent/children", json={"name": "Test"}, headers={"Origin": "https://evil.test"}).status_code == 403
+ assert client.delete("/v1/session", headers={"Origin": "http://localhost:8000"}).status_code == 200
+ assert client.get("/v1/parent/family").status_code == 401
+ assert "frame-ancestors 'none'" in client.get("/").headers["content-security-policy"]
+
+
+@pytest.mark.parametrize("update", [{"daily_minutes": -1}, {"daily_minutes": True},
+ {"timezone": "Not/AZone"}, {"allowed_start": 1300},
+ {"daily_minutes": "30"}, {"unexpected": 123}])
+def test_invalid_policy_rejected(env, update):
+ assert policy_update(env[0], **update).status_code == 422
+
+
+def test_status_is_acknowledgement_not_server_usage_claim(env):
+ client, store, _ = env
+ _, device, headers = enroll(client)
+ status = {"policy_revision": 1, "state": "not_configured", "shielded": True,
+ "detail": "Select apps with guardian present", "consumed_lower_bound": 0,
+ "usage_day": "2026-09-17"}
+ assert client.post("/v1/device/status", headers=headers, json=status).status_code == 200
+ family = client.get("/v1/parent/family", headers=HEADERS).json()
+ assert family["children"][0]["device"]["status"] == status
+ with store.transaction() as db:
+ stored = db.execute("SELECT token_hash FROM devices WHERE id=?", (device["device_id"],)).fetchone()[0]
+ assert stored != device["token"] and len(stored) == 64
+
+
+def test_configuration_validation_and_sqlite_persistence(env):
+ client, store, _ = env
+ _, _, headers = enroll(client)
+ ch = client.post("/v1/device/challenges", headers=headers).json()
+ client.post(f"/v1/device/challenges/{ch['id']}/submit", headers=headers, json=solve(ch))
+ assert policy_update(client, timezone="UTC").status_code == 409
+ with store.transaction() as db:
+ assert json.loads(db.execute("SELECT result FROM challenges").fetchone()[0])["passed"]
+ with pytest.raises(ValueError): Settings("too short")
+ with pytest.raises(ValueError): Settings(TOKEN, origin="http://public.example")
+
+
+def test_session_rotation_and_restart_preserve_ledger(env):
+ client, store, clock = env
+ _, _, headers = enroll(client)
+ ch = client.post("/v1/device/challenges", headers=headers).json()
+ client.post(f"/v1/device/challenges/{ch['id']}/submit", headers=headers, json=solve(ch))
+ client.post("/v1/session", json={"token": TOKEN})
+ cookies = dict(client.cookies)
+ restarted = create_app(Settings(TOKEN + "-rotated", store.path), clock=lambda: clock[0])
+ with TestClient(restarted) as next_client:
+ next_client.cookies.update(cookies)
+ assert next_client.get("/v1/parent/family").status_code == 401
+ assert next_client.get("/v1/device/snapshot", headers=headers).json()["earned_minutes"] == 10
+
+
+@pytest.mark.parametrize("length,expected", [("invalid", 400), ("-1", 400), ("40000", 413)])
+def test_request_length_validation(env, length, expected):
+ assert env[0].post("/v1/pair", headers={"Content-Length": length}, content="{}").status_code == expected
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..aef5779
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,23 @@
+services:
+ apc:
+ build: .
+ ports:
+ - '127.0.0.1:8000:8000'
+ environment:
+ APC_ADMIN_TOKEN: ${APC_ADMIN_TOKEN:?Generate APC_ADMIN_TOKEN in .env first}
+ APC_ORIGIN: ${APC_ORIGIN:-http://localhost:8000}
+ volumes:
+ - apc-data:/data
+ read_only: true
+ tmpfs:
+ - /tmp
+ cap_drop: [ALL]
+ security_opt: [no-new-privileges:true]
+ restart: unless-stopped
+ healthcheck:
+ test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+volumes:
+ apc-data:
diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md
new file mode 100644
index 0000000..021cea4
--- /dev/null
+++ b/docs/ACCEPTANCE.md
@@ -0,0 +1,50 @@
+# Acceptance and verification
+
+## Executed in the implementation environment
+
+- Python API tests: 21 passing, including strict validation, inheritance, optimistic revisions, authorization boundaries, cookie-origin checks, session rotation, persistence, code expiry, reward caps, idempotency and concurrent submission.
+- Swift PolicyCore tests: 10 passing under Swift 6.2.1 on Linux, including day/DST boundaries, bedtime, zero allowances, monotonic usage observations, cap truncation and exhaustive reachable-bonus threshold coverage.
+- JavaScript syntax check; Swift source syntax parsing; YAML/plist/source-path validation.
+- Swift networking source type-check against Linux Foundation with a platform import and local error stub. This is not an Apple SDK build.
+- Chromium DOM + real backend smoke via an in-process ASGI bridge: login, two children, shared policy, individual override, pairing, graded credit, refreshed dashboard and logout. It does not test deployed browser cookies, TLS or a physical iPhone. Parent session/origin behavior is separately covered by API tests.
+
+Not executed here: XcodeGen project generation, Xcode compile/link/archive/sign/install, Docker build/run, Screen Time authorization, shield appearance/action on hardware, or real DeviceActivity scheduling/counting. Do not mark these as passed based on pure Swift tests.
+
+## First build-server pass
+
+- [ ] `make bootstrap && make test` succeeds on the build server.
+- [ ] `make ios-project` generates the four-target project.
+- [ ] Xcode builds with the selected SDK; check all target bundle IDs and App Group entitlements.
+- [ ] Archive/sign/install using owner-supplied profiles. Verify all extensions are embedded.
+- [ ] Docker starts with a generated secret and persistent volume; restart retains profiles and awards.
+- [ ] HTTPS and exact `APC_ORIGIN` work from parent browser and child phone; no plaintext fallback.
+
+## Physical iPhone matrix
+
+Use a dedicated test entertainment app. Start with a small base quota and the minimum five-minute reward, not essential communication apps. Record target OS version and observed callback times; the implementation intentionally does not fake sub-minute accuracy.
+
+- [ ] Child Family Sharing account: approve `.child` authorization, then cancel and retry authorization.
+- [ ] Pair once; code reuse/expired code fails. Reopening the app does not expose unrestricted selection editing.
+- [ ] Parent-selected app is actually shielded outside allowed hours. Close on the shield exits correctly.
+- [ ] Base cumulative usage reaches its threshold and shields the app with Family Quests backgrounded.
+- [ ] Five correct answers record one credit and unlock further **usage** time. Wrong answers earn none.
+- [ ] Putting the phone down does not consume a wall-clock reward; reaching the next usage threshold re-shields.
+- [ ] Repeated Sync, foreground transitions, retrying submission, duplicate/late callbacks, and relaunches do not refund usage.
+- [ ] Earning the last partial reward hits the exact cap. Another challenge cannot earn more that day.
+- [ ] Earn a bonus before base time is exhausted; cumulative accounting remains correct.
+- [ ] Bedtime shields despite unused/earned allowance. Bonus expires at the family-day boundary.
+- [ ] Keep the main app closed overnight: the next allowed window restores base only, not yesterday's bonus. Repeat over two offline days.
+- [ ] Force quit, reboot, lock/unlock and first unlock after reboot: no unexpected clearing or permanent accidental unlock.
+- [ ] Change policy mid-day (raise/lower base, reward size, cap and allowed hours); prior usage is not incorrectly refunded and obsolete callbacks do not win.
+- [ ] Change selected apps with a fresh parent code. Verify removed/new apps and cumulative historical usage; selection changes are a known accounting risk.
+- [ ] Fail monitoring registration / corrupt or make shared state unavailable in a test build: error is visible, no unconditional `clearAllSettings` recovery.
+- [ ] Offline backend, connection timeout, low-power mode and stale snapshots: cached limits remain; no unverified credit is minted.
+- [ ] Change device timezone and clock; verify configured family timezone and check the five-minute foreground skew guard. Offline clock tampering remains a known limitation.
+- [ ] Two children inherit a shared edit; only the child with an override retains its different value. Pending/error/last-reported status is truthful.
+- [ ] Parent code expires and loses validity on leaving the setup session. A child cannot point an already-paired app at a replacement server to authorize selection changes.
+- [ ] Revoke a device: API access stops, cached restrictions remain. Test same-server re-pairing with a new parent-issued code.
+- [ ] A stricter native Apple App Limit still wins. Do not interpret that as a successful override of Apple's native limit.
+
+## Stop conditions
+
+Treat early/late/missing threshold callbacks, double-counted usage, unexpected unlocks, deadlocks during monitor replacement, storage races, inaccessible essential apps or a guardian recovery failure as blockers to real use. Capture diagnostic logs with test profiles rather than real family data. Do not hide these failures behind a green `applied` label.
diff --git a/docs/APPLE_SETUP.md b/docs/APPLE_SETUP.md
new file mode 100644
index 0000000..8439d32
--- /dev/null
+++ b/docs/APPLE_SETUP.md
@@ -0,0 +1,40 @@
+# Apple build and entitlement setup
+
+## Build targets
+
+| Target | Bundle ID suffix | Extension point |
+|---|---|---|
+| FamilyQuests | `.app` | App |
+| ActivityMonitor | `.app.monitor` | `com.apple.deviceactivity.monitor-extension` |
+| ShieldConfiguration | `.app.shield` | `com.apple.ManagedSettingsUI.shield-configuration-service` |
+| ShieldAction | `.app.shieldaction` | `com.apple.ManagedSettings.shield-action-service` |
+
+The prefix is `APC_BUNDLE_PREFIX`. All targets use `APC_APP_GROUP`; the app and monitor actually exchange state through it. Defaults are in `ios/Config/Base.xcconfig`. Copy `Local.xcconfig.example` to the gitignored `Local.xcconfig` and supply the team and identifiers you control.
+
+Enable the Family Controls capability and shared App Group for the app and applicable extensions, and use provisioning profiles containing the corresponding entitlements. The checked-in entitlement file does not grant Apple's permission by itself. Request Family Controls distribution authorization for the App IDs you intend to distribute and regenerate the matching profiles. Development provisioning and distribution approval are separate steps.
+
+XcodeGen supplies the Info.plist extension identifiers/principal classes and embeds the extensions. Generated `.xcodeproj` and plists are gitignored. `make ios-project` is required after cloning or changing the specification. No signing certificates, profiles, private keys, Apple credentials or CI configuration are committed.
+
+The app requests FamilyControls authorization for a **child**. Test with the intended Family Sharing child account and a guardian available to approve. There is no automatic fallback to `.individual`. An adult's test phone cannot stand in for testing the guardian/child authorization and anti-removal behavior.
+
+## Network and local development
+
+Use the parent dashboard's real HTTPS origin on the phone, with a trusted certificate. Configure the same origin on the server; a mismatching browser Origin causes parent mutations to fail. The iPhone's `localhost` refers to that iPhone, not the build server. Debug loopback HTTP is for simulator/local harness work only. A local-network usage description is included; inspect the phone's Local Network permission when testing a private LAN server.
+
+Do not expose an unsigned debug API on the LAN or put the parent administration token in the iPhone app. Screen Time changes are applied on the child's device, not by the Mac browser calling Apple's settings remotely.
+
+## Remaining distribution work
+
+Supply app icons, final display metadata, privacy declarations/manifests appropriate to the actual shipped functionality, provisioning, signing/export options and deployment automation. No App Store/TestFlight approval or successful archive is claimed. Keep native Screen Time safety restrictions in place while validating the prototype.
+
+## Primary references
+
+- [Meet the Screen Time API](https://developer.apple.com/videos/play/wwdc2021/10123/)
+- [What's new in Screen Time API](https://developer.apple.com/videos/play/wwdc2022/110336/)
+- [Family Controls authorization](https://developer.apple.com/documentation/familycontrols/authorizationcenter/requestauthorization(for:))
+- [DeviceActivity event includesPastActivity](https://developer.apple.com/documentation/deviceactivity/deviceactivityevent/includespastactivity)
+- [DeviceActivity startMonitoring](https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter/startmonitoring(_:during:events:))
+- [Requesting the Family Controls entitlement](https://developer.apple.com/documentation/familycontrols/requesting-the-family-controls-entitlement)
+- [XcodeGen project specification](https://github.com/yonaskolb/XcodeGen/blob/master/Docs/ProjectSpec.md)
+
+These describe platform mechanisms, not evidence that this particular prototype works correctly on your hardware. Record the OS, Xcode, provisioning and observed behavior in the acceptance checklist.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..a6ff7a3
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,81 @@
+# Architecture and invariants
+
+## Components
+
+Parent browser -> same-origin FastAPI service -> SQLite policy, pairing and reward records.
+Child SwiftUI app -> authenticated HTTPS API -> validated snapshot -> local Apple enforcement.
+DeviceActivity extension -> shared App Group state -> named ManagedSettings store.
+
+The monitor extension does **not** contact the backend and does not receive its credentials. App/category tokens stay on the phone. Backend truth is limited to policies, submitted challenge results, awarded credit and device-reported status; it is not an independent observation of actual screen use.
+
+## Policy inheritance
+
+`effective_policy = family_policy + child.overrides` (shallow merge of a validated, flat schema).
+
+Empty overrides inherit everything. Clearing an override restores inheritance. Updating shared policy validates every child's resulting policy in the same transaction. A global monotonically increasing revision and `expected_revision` prevent silent lost updates. v0 invalidates outstanding challenges on any policy revision, including a change affecting another child; finer per-child revisions can be added later.
+
+The timezone is family-wide. Days and bonus caps are computed by the server using that IANA timezone, not a device-supplied date. The native monitor uses the same timezone. Changes to the timezone after rewards exist are blocked until a proper ledger migration is implemented.
+
+Default limits are configurable examples, not scientific or clinical recommendations.
+
+## Reward integrity
+
+Each challenge contains generated question IDs and server-retained correct answers. The client receives prompts only. Submission must answer every question exactly once with strict integer values; the server checks those values rather than trusting a `completed` flag or client-supplied minutes.
+
+An unfinished, unexpired challenge is reused instead of issuing parallel attempts. Attempts expire after ten minutes and at a family-day boundary or policy revision change. A wrong submission is finalized without credit; the child can start another challenge. Attempts are capped at 100 per device/day as a basic resource bound.
+
+Submission uses a SQLite `BEGIN IMMEDIATE` transaction. A unique `challenge_id` on rewards plus a persisted challenge result makes retries and concurrent submissions idempotent. The awarded amount is `min(reward_size, remaining_daily_cap)`. Existing grants are summed per child/day and clamped to the current cap in snapshots. There is no client endpoint for arbitrary credit issuance.
+
+This verifies correctness, not who did the work. A calculator, another person, or a modified client can solve arithmetic too; v0 does not claim learning-outcome attestation.
+
+## Local usage enforcement
+
+The app validates the snapshot, preserves usage observations for the current day, and installs a repeating allowed-hours monitor with cumulative usage events. The threshold ladder includes:
+
+1. Base allowance and each subsequent reward increment through the cap, including a partial final reward.
+2. The current earned offset and subsequent increments, so a mid-day reward-size change remains representable.
+
+Both the ordinary and shifted ladders are registered. On a new day, the ordinary ladder remains valid even without a server connection. Bonus entitlement is only honored on its snapshot day. Observations roll over by policy timezone, not by a foreground-only timer.
+
+A callback records the maximum reached threshold. Duplicate or out-of-order lower thresholds do not refund usage. The decision is shielded if outside allowed hours, allowance reached, or monitoring failed. A new ordinary reward just changes entitlement and reevaluates the shield; it does not restart the monitor. Policy/selection changes or lost registration install a new monitor with `includesPastActivity: true`. This is intended to preserve cumulative accounting; exact Apple behavior must be checked on the target OS.
+
+Names contain an installation generation so obsolete monitor callbacks cannot overwrite the current plan. A file lock plus atomic JSON replacement serializes app/extension updates. Failed monitor registration leaves the selected apps shielded. Unreadable shared state does not clear existing ManagedSettings. This is defensive behavior, not a guarantee against an OS failing to deliver callbacks. Midnight, policy replacement, late callbacks and restarts are explicit hardware acceptance gates.
+
+Only individual apps are supported initially. Whole categories/websites are deferred to avoid expanding enforcement and usage-accounting assumptions before the first physical tests. The app does not promise to override another Screen Time controller or a stricter native limit.
+
+## Authentication and synchronization
+
+The bootstrap parent token lives in server configuration and is never sent to the child. Parent browser sessions are random, HttpOnly, SameSite=Strict cookies; their stored hash is keyed by the admin secret, so rotating it invalidates sessions. Cookie-authenticated mutations require the configured Origin. Parent API callers may use the bootstrap bearer directly.
+
+Pairing/configuration codes carry 144 bits of randomness, expire in ten minutes, are stored hashed, and are consumed atomically. Device bearer tokens are child/device-scoped and hashed at rest on the server. The app stores its token and anchored server origin in Keychain, not UserDefaults or the extension's container. Parent-selection access lasts one short foreground setup session; Apple guardian authorization is requested separately with `.child`, never silently downgraded to individual/self-control authorization.
+
+Remote updates apply at the next foreground/manual sync. `status` is a device acknowledgement with a revision, monitoring state, shield flag, bounded detail, and last threshold observation. Parent status deliberately says **last reported**, not independently verified or live. There is no push delivery or retry worker in v0.
+
+Revoking pairing disables API access immediately but does not remotely remove local restrictions. The retired device retains its last cached rules. To replace a revoked pairing on the same phone, issue a new pairing code and use Parent setup -> Replace revoked pairing. The phone keeps the old server origin anchored. Server migration/decommissioning needs a designed guardian recovery flow before production use.
+
+## API surface
+
+| Role | Endpoint | Purpose |
+|---|---|---|
+| Parent | `POST/DELETE /v1/session` | Browser sign-in/sign-out |
+| Parent | `GET /v1/parent/family` | Policy, children, overrides, pairing/status summaries |
+| Parent | `PUT /v1/parent/policy` | Revision-checked shared policy replacement |
+| Parent | `POST /v1/parent/children` | Create a child display-name profile |
+| Parent | `PUT /v1/parent/children/{id}/overrides` | Replace sparse child overrides |
+| Parent | `POST /v1/parent/children/{id}/pairing-code` | Issue a one-use enrollment code |
+| Parent | `POST /v1/parent/devices/{id}/configuration-code` | Permit one selection-editing session |
+| Parent | `DELETE /v1/parent/devices/{id}` | Revoke API access |
+| Enrollment | `POST /v1/pair` | Consume code; return token once |
+| Device | `POST /v1/device/configuration-unlock` | Consume a device-specific parent code |
+| Device | `GET /v1/device/snapshot` | Validated effective policy and today's bonus |
+| Device | `POST /v1/device/status` | Report local applied/error status |
+| Device | `POST /v1/device/challenges` | Start/reuse generated challenge |
+| Device | `POST /v1/device/challenges/{id}/submit` | Grade and issue bounded, idempotent reward |
+
+Snapshot v1 fields: `schema_version`, `device_id`, `child_id`, `child_name`, `policy_revision`, `policy`, `day`, `earned_minutes`, `server_time` (Unix seconds). JSON uses snake_case, with shared Swift encoder/decoder conventions. Policy fields are listed in `backend/src/apc/models.py`; `packages/PolicyCore` implements their native validation and semantics.
+
+Submission result fields: `challenge_id`, `passed`, `correct_count`, `required_count`, `awarded_minutes`, nullable `reward_id`, and `day`. A credit being recorded is distinct from a subsequent successful snapshot application.
+
+## Planned extensions
+
+The narrow first slice is deliberately not a general quest engine. Add manual chores/approval and an explicit grant ledger API after iPhone enforcement is validated. Each new reward source must preserve idempotency, cap handling and the exact threshold ladder; arbitrary reward denominations need extra accounting work. Then add background synchronization, richer schedules and safe recovery. Do not introduce per-device copies of the family policy as a shortcut.
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
new file mode 100644
index 0000000..5fc45f5
--- /dev/null
+++ b/docs/SECURITY.md
@@ -0,0 +1,33 @@
+# Security and operations (development MVP)
+
+This is not security-audited or ready for unattended child-safety enforcement. Start with test profiles and nonessential apps. Keep the native Apple safety baseline and a guardian recovery path.
+
+## Deployment
+
+The server accepts one household's bootstrap administration token, generated randomly with at least 32 characters. The browser exchanges it for a 12-hour HttpOnly, SameSite=Strict session cookie. HTTPS origins produce Secure cookies. Changing `APC_ADMIN_TOKEN` invalidates old browser sessions on the next request. Never commit `.env`, device tokens, pairing/configuration codes, backups, signing credentials or real child profiles.
+
+Use a trusted HTTPS reverse proxy. Bind the upstream privately, keep the configured browser Origin exact, and add connection/rate limits and a **32 KiB request-body cap at the proxy, including chunked requests**. The app checks declared Content-Length; it is not a streaming-body firewall. Add TLS/HSTS and proxy access controls appropriate to the deployment. No CORS exceptions or wildcard origins are configured. Do not directly expose the raw development service to the public internet.
+
+The supplied Compose service is non-root, read-only except for its named data volume and temporary directory, drops capabilities and binds to loopback. Its Docker build/runtime still needs verification on the target host. Python dependencies are exact versions from the implementation environment; perform dependency vulnerability scanning and update/retest them before deployment. There is no automatic update mechanism.
+
+## Credential and client boundaries
+
+Parent administration credentials never belong on a child's phone. Device credentials authorize only the paired child's API operations. Codes and device tokens are hashed in SQLite; parent session hashes are keyed by the admin secret. Pairing and configuration codes use high-entropy random values rather than guessable short PINs.
+
+The native app stores credentials in a device-only Keychain item. App Group state holds policy, app-selection tokens, quota observations and monitor configuration, not API credentials. Redirects are refused by the native HTTP client and plain LAN HTTP is not enabled. An existing pairing anchors the server origin; re-pairing needs a new code from that same server.
+
+The threat model assumes an unmodified signed app and normal device sandboxing. A jailbroken phone, compromised parent/server, stolen bearer token, forged native status report, calculator-assisted solution, or OS scheduling failure is not solved by this MVP. Parent status is explicitly last-reported, not independent proof of enforcement.
+
+## Data and retention
+
+The server stores child display names, overrides, device labels, hashed credentials, policy revisions, generated arithmetic questions/solutions, correctness counts, awarded credit and limited device status. It does not upload application tokens, app names, URLs, browsing history, per-app usage reports, health data, or Apple account credentials. There are no analytics/advertising SDKs.
+
+SQLite is not encrypted by the application. Protect the host, filesystem permissions and backups, and use encrypted disks/backups where appropriate. No automatic retention/deletion UI is implemented. A production version needs explicit family export/deletion and retention policies before taking real users' data. Do not infer regulatory compliance from this document.
+
+## Persistence and recovery
+
+Schema version 1 is initialized in `store.py`. Future schema changes require a real versioned migration; do not silently edit an existing schema in place. Refuse to run older code against a newer database. Back up with SQLite's backup API or while the service is stopped; copying only the live `.sqlite3` file can miss WAL data.
+
+Revoking API access does not remove restrictions from an offline phone. Same-server re-pairing is supported with a new parent-issued code after revocation. Complete server loss/migration, child removal, and factory-reset/Keychain lifecycle still need a designed recovery flow. Before testing, verify that the guardian can revoke Family Controls authorization/remove the experimental app through Apple's supported controls. Never select essential communication or the task app itself.
+
+Cached policy continues locally when the backend is unavailable. Monitor registration failures deliberately keep selected apps shielded, but missing callbacks can still cause incorrect behavior. The application is not a tamper-proof replacement for MDM or all native Screen Time settings.
diff --git a/ios/App/APIClient.swift b/ios/App/APIClient.swift
new file mode 100644
index 0000000..e08c0cf
--- /dev/null
+++ b/ios/App/APIClient.swift
@@ -0,0 +1,74 @@
+import Foundation
+import PolicyCore
+
+struct Credentials: Codable {
+ var server: String
+ var token: String
+ var deviceId: String
+ var childId: String
+}
+struct PairResponse: Decodable { let deviceId: String; let childId: String; let token: String }
+struct Acknowledgement: Decodable { let received: Bool }
+struct ConfigurationGrant: Decodable { let authorized: Bool }
+struct MathChallenge: Decodable, Identifiable {
+ struct Question: Decodable, Identifiable { let id: String; let prompt: String }
+ let id: String
+ let expiresAt: Double
+ let questions: [Question]
+}
+struct SubmittedAnswer: Encodable { let questionId: String; let answer: Int }
+struct Submission: Encodable { let answers: [SubmittedAnswer] }
+struct ChallengeResult: Decodable {
+ let passed: Bool
+ let correctCount: Int
+ let requiredCount: Int
+ let awardedMinutes: Int
+}
+private struct APIError: Decodable { let detail: String }
+
+private final class NoRedirects: NSObject, URLSessionTaskDelegate {
+ func urlSession(_ session: URLSession, task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void) {
+ completionHandler(nil)
+ }
+}
+
+struct APIClient {
+ let origin: URL
+ let token: String?
+
+ init(server: String, token: String? = nil) throws {
+ guard let parts = URLComponents(string: server.trimmingCharacters(in: .whitespacesAndNewlines)),
+ let host = parts.host, parts.user == nil, parts.password == nil,
+ parts.query == nil, parts.fragment == nil, ["", "/"].contains(parts.path),
+ let url = parts.url else { throw LocalError.message("Enter the server origin, without a path") }
+ var allowed = parts.scheme == "https"
+ #if DEBUG
+ allowed = allowed || (parts.scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host))
+ #endif
+ guard allowed else { throw LocalError.message("Use HTTPS with a trusted certificate; plain LAN HTTP is not supported") }
+ origin = url
+ self.token = token
+ }
+
+ func request(_ path: String, method: String = "GET", body: Data? = nil) async throws -> T {
+ guard let url = URL(string: path, relativeTo: origin)?.absoluteURL,
+ url.host == origin.host else { throw LocalError.message("Invalid API path") }
+ var request = URLRequest(url: url)
+ request.httpMethod = method; request.httpBody = body; request.timeoutInterval = 20
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
+ let config = URLSessionConfiguration.ephemeral
+ config.httpShouldSetCookies = false; config.urlCache = nil
+ let session = URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil)
+ defer { session.finishTasksAndInvalidate() }
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else { throw LocalError.message("Invalid server response") }
+ guard (200...299).contains(http.statusCode) else {
+ let detail = (try? JSONDecoder().decode(APIError.self, from: data).detail) ?? "HTTP \(http.statusCode)"
+ throw LocalError.message(detail)
+ }
+ return try WireCoding.decoder().decode(T.self, from: data)
+ }
+}
diff --git a/ios/App/AppModel.swift b/ios/App/AppModel.swift
new file mode 100644
index 0000000..53c5534
--- /dev/null
+++ b/ios/App/AppModel.swift
@@ -0,0 +1,152 @@
+import FamilyControls
+import Foundation
+import PolicyCore
+import SwiftUI
+
+@MainActor
+final class AppModel: ObservableObject {
+ @Published private(set) var credentials: Credentials?
+ @Published var snapshot: Snapshot?
+ @Published var status: DeviceStatus?
+ @Published var challenge: MathChallenge?
+ @Published var answers: [String: String] = [:]
+ @Published var message = ""
+ @Published var rewardMessage = ""
+ @Published var busy = false
+ @Published var pickerPresented = false
+ @Published var draftSelection = FamilyActivitySelection()
+ private var setupUntil: Date?
+
+ init() {
+ do {
+ credentials = try KeychainStore.load()
+ if credentials != nil {
+ try StateStore.withState { state in
+ snapshot = state.snapshot; draftSelection = state.selection
+ }
+ }
+ } catch { message = error.localizedDescription }
+ }
+
+ private func api() throws -> APIClient {
+ guard let credentials else { throw LocalError.message("Pair this phone first") }
+ return try APIClient(server: credentials.server, token: credentials.token)
+ }
+ private func perform(_ operation: () async throws -> Void) async {
+ guard !busy else { return }
+ busy = true; message = ""
+ defer { busy = false }
+ do { try await operation() } catch { message = error.localizedDescription }
+ }
+
+ func pair(server: String, code: String) async {
+ await perform {
+ // Once enrolled, keep the server anchored in Keychain. A child's replacement server
+ // must not become a way to authorize new selection rules.
+ let endpoint = credentials?.server ?? server
+ let client = try APIClient(server: endpoint)
+ let body = try WireCoding.encoder().encode(["code": code.trimmingCharacters(in: .whitespacesAndNewlines),
+ "name": "Child iPhone"])
+ let response: PairResponse = try await client.request("/v1/pair", method: "POST", body: body)
+ let value = Credentials(server: client.origin.absoluteString, token: response.token,
+ deviceId: response.deviceId, childId: response.childId)
+ try KeychainStore.save(value); credentials = value
+ try await AuthorizationCenter.shared.requestAuthorization(for: .child)
+ try await synchronizeInternal()
+ setupUntil = Date().addingTimeInterval(300)
+ pickerPresented = true
+ }
+ }
+
+ func authorize() async {
+ await perform {
+ try await AuthorizationCenter.shared.requestAuthorization(for: .child)
+ try await synchronizeInternal()
+ }
+ }
+
+ func synchronize() async {
+ guard credentials != nil else { return }
+ await perform { try await synchronizeInternal() }
+ }
+
+ private func synchronizeInternal(selection: FamilyActivitySelection? = nil) async throws {
+ let client = try api()
+ let incoming: Snapshot = try await client.request("/v1/device/snapshot")
+ guard incoming.deviceId == credentials?.deviceId, incoming.childId == credentials?.childId else {
+ throw LocalError.message("Snapshot does not belong to this pairing")
+ }
+ try incoming.validate()
+ snapshot = incoming
+ let report: DeviceStatus
+ if AuthorizationCenter.shared.authorizationStatus != .approved {
+ report = try Enforcement.report(kind: "not_authorized", detail: "Guardian must approve Family Controls")
+ } else {
+ do { report = try Enforcement.apply(snapshot: incoming, selection: selection) }
+ catch {
+ report = try Enforcement.report(kind: "error", detail: error.localizedDescription)
+ message = error.localizedDescription
+ }
+ }
+ status = report
+ let _: Acknowledgement = try await client.request("/v1/device/status", method: "POST",
+ body: WireCoding.encoder().encode(report))
+ }
+
+ func unlockConfiguration(code: String) async {
+ await perform {
+ let grant: ConfigurationGrant = try await api().request("/v1/device/configuration-unlock",
+ method: "POST", body: WireCoding.encoder().encode(["code": code.trimmingCharacters(in: .whitespacesAndNewlines)]))
+ guard grant.authorized else { throw LocalError.message("Parent approval was not granted") }
+ setupUntil = Date().addingTimeInterval(300)
+ draftSelection = try StateStore.withState { $0.selection }
+ pickerPresented = true
+ }
+ }
+
+ func saveSelection() async {
+ await perform {
+ guard let setupUntil, setupUntil > Date() else {
+ throw LocalError.message("Parent setup expired; request a fresh configuration code")
+ }
+ guard !draftSelection.applicationTokens.isEmpty, draftSelection.categoryTokens.isEmpty,
+ draftSelection.webDomainTokens.isEmpty else {
+ throw LocalError.message("Choose individual apps, not whole categories or websites")
+ }
+ try await synchronizeInternal(selection: draftSelection)
+ self.setupUntil = nil
+ pickerPresented = false
+ }
+ }
+
+ func closeParentSetup() {
+ setupUntil = nil
+ pickerPresented = false
+ }
+
+ func startChallenge() async {
+ await perform {
+ challenge = try await api().request("/v1/device/challenges", method: "POST")
+ answers = [:]; rewardMessage = ""
+ }
+ }
+
+ func submitChallenge() async {
+ await perform {
+ guard let challenge else { return }
+ let submitted = try challenge.questions.map { question in
+ guard let answer = Int(answers[question.id, default: ""].trimmingCharacters(in: .whitespaces)) else {
+ throw LocalError.message("Enter an answer for every question")
+ }
+ return SubmittedAnswer(questionId: question.id, answer: answer)
+ }
+ let result: ChallengeResult = try await api().request("/v1/device/challenges/\(challenge.id)/submit",
+ method: "POST", body: WireCoding.encoder().encode(Submission(answers: submitted)))
+ self.challenge = nil
+ rewardMessage = result.passed
+ ? "\(result.awardedMinutes) bonus minutes recorded for today."
+ : "\(result.correctCount) of \(result.requiredCount) correct. No time added. Try a new challenge."
+ try await synchronizeInternal()
+ }
+ }
+}
diff --git a/ios/App/FamilyQuestsApp.swift b/ios/App/FamilyQuestsApp.swift
new file mode 100644
index 0000000..d777c24
--- /dev/null
+++ b/ios/App/FamilyQuestsApp.swift
@@ -0,0 +1,124 @@
+import FamilyControls
+import SwiftUI
+
+@main
+struct FamilyQuestsApp: App {
+ @StateObject private var model = AppModel()
+ @Environment(\.scenePhase) private var scenePhase
+
+ var body: some Scene {
+ WindowGroup {
+ RootView(model: model)
+ .task { await model.synchronize() }
+ .onChange(of: scenePhase) { _, phase in
+ if phase == .active { Task { await model.synchronize() } }
+ if phase == .background { model.closeParentSetup() }
+ }
+ }
+ }
+}
+
+struct RootView: View {
+ @ObservedObject var model: AppModel
+ @State private var server = ""
+ @State private var code = ""
+ @State private var configurationCode = ""
+ @State private var replacementCode = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ if model.credentials == nil {
+ Section("Parent: pair this iPhone") {
+ Text("Create a child and a pairing code in the parent web dashboard. A guardian must approve Apple Family Controls.")
+ TextField("https://your-server.example", text: $server)
+ .textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
+ SecureField("Single-use pairing code", text: $code)
+ .textInputAutocapitalization(.never).autocorrectionDisabled()
+ Button("Pair and request guardian approval") {
+ let submitted = code; code = ""
+ Task { await model.pair(server: server, code: submitted) }
+ }
+ }
+ } else {
+ Section(model.snapshot?.childName ?? "My time") {
+ if let snapshot = model.snapshot {
+ LabeledContent("Daily allowance", value: "\(snapshot.policy.dailyMinutes) minutes")
+ LabeledContent("Bonus earned today", value: "\(snapshot.bonus(at: Date())) minutes")
+ Text("This is your allowance, not a live remaining-time counter. Rewards expire at the end of the family day and never extend bedtime.")
+ .font(.footnote).foregroundStyle(.secondary)
+ }
+ Text(model.status?.detail ?? "Open or sync the app to check configuration")
+ Button("Sync policy and rewards") { Task { await model.synchronize() } }
+ }
+ Section("Maths challenge") {
+ if let challenge = model.challenge {
+ ForEach(challenge.questions) { question in
+ HStack {
+ Text("\(question.prompt) =")
+ TextField("Answer", text: Binding(
+ get: { model.answers[question.id, default: ""] },
+ set: { model.answers[question.id] = $0 }
+ )).keyboardType(.numberPad).multilineTextAlignment(.trailing)
+ .accessibilityLabel("Answer to \(question.prompt)")
+ }
+ }
+ Button("Check answers") { Task { await model.submitChallenge() } }
+ } else {
+ Text("Answer every question correctly to earn bonus app usage. An internet connection is required to verify answers.")
+ Button("Start challenge") { Task { await model.startChallenge() } }
+ .disabled(model.status?.state != "monitoring")
+ }
+ if !model.rewardMessage.isEmpty { Text(model.rewardMessage) }
+ }
+ Section("Parent setup") {
+ Text("App selection requires a fresh configuration code from the parent dashboard. Do not restrict this app, Phone, or other essential communication and school apps.")
+ .font(.footnote)
+ Button("Request guardian Screen Time authorization") { Task { await model.authorize() } }
+ SecureField("Configuration code", text: $configurationCode)
+ .textInputAutocapitalization(.never).autocorrectionDisabled()
+ Button("Authorize app selection") {
+ let value = configurationCode; configurationCode = ""
+ Task { await model.unlockConfiguration(code: value) }
+ }
+ DisclosureGroup("Replace revoked pairing") {
+ Text("The parent must revoke the old pairing and issue a new pairing code. The server address remains fixed.")
+ SecureField("New pairing code", text: $replacementCode)
+ .textInputAutocapitalization(.never).autocorrectionDisabled()
+ Button("Pair again with the same server") {
+ let value = replacementCode; replacementCode = ""
+ Task { await model.pair(server: "", code: value) }
+ }
+ }
+ }
+ }
+ if !model.message.isEmpty {
+ Section("Needs attention") { Text(model.message).foregroundStyle(.red) }
+ }
+ if model.busy { ProgressView("Working…") }
+ Section { Text("Development MVP. DeviceActivity behavior must be tested on this iOS version before relying on it.").font(.footnote) }
+ }
+ .disabled(model.busy)
+ .navigationTitle("Family Quests")
+ .sheet(isPresented: $model.pickerPresented, onDismiss: { model.closeParentSetup() }) {
+ NavigationStack {
+ VStack {
+ Text("Choose individual entertainment apps only. Leave Family Quests and essential apps accessible.")
+ .font(.footnote).padding()
+ FamilyActivityPicker(selection: $model.draftSelection)
+ }
+ .navigationTitle("Parent app selection")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) { Button("Cancel") { model.closeParentSetup() } }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") { Task { await model.saveSelection() } }.disabled(model.busy)
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ if !model.message.isEmpty { Text(model.message).font(.footnote).padding() }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/ios/App/KeychainStore.swift b/ios/App/KeychainStore.swift
new file mode 100644
index 0000000..bdbee5e
--- /dev/null
+++ b/ios/App/KeychainStore.swift
@@ -0,0 +1,35 @@
+import Foundation
+import PolicyCore
+import Security
+
+enum KeychainStore {
+ private static var query: [String: Any] {
+ [kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: Bundle.main.bundleIdentifier ?? "APC",
+ kSecAttrAccount as String: "paired-device"]
+ }
+ static func load() throws -> Credentials? {
+ var request = query
+ request[kSecReturnData as String] = true
+ request[kSecMatchLimit as String] = kSecMatchLimitOne
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(request as CFDictionary, &item)
+ if status == errSecItemNotFound { return nil }
+ guard status == errSecSuccess, let data = item as? Data else {
+ throw LocalError.message("Cannot read pairing credentials: \(status)")
+ }
+ return try WireCoding.decoder().decode(Credentials.self, from: data)
+ }
+ static func save(_ credentials: Credentials) throws {
+ let data = try WireCoding.encoder().encode(credentials)
+ let update = [kSecValueData as String: data]
+ let result = SecItemUpdate(query as CFDictionary, update as CFDictionary)
+ if result == errSecItemNotFound {
+ var insert = query
+ insert[kSecValueData as String] = data
+ insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ let status = SecItemAdd(insert as CFDictionary, nil)
+ guard status == errSecSuccess else { throw LocalError.message("Cannot save pairing: \(status)") }
+ } else if result != errSecSuccess { throw LocalError.message("Cannot update pairing: \(result)") }
+ }
+}
diff --git a/ios/Config/Base.xcconfig b/ios/Config/Base.xcconfig
new file mode 100644
index 0000000..8961fa4
--- /dev/null
+++ b/ios/Config/Base.xcconfig
@@ -0,0 +1,4 @@
+APC_BUNDLE_PREFIX = de.felixfoertsch.parentalcontrols
+APC_APP_GROUP = group.$(APC_BUNDLE_PREFIX)
+APC_DEVELOPMENT_TEAM =
+#include? "Local.xcconfig"
diff --git a/ios/Config/FamilyControls.entitlements b/ios/Config/FamilyControls.entitlements
new file mode 100644
index 0000000..f6869a0
--- /dev/null
+++ b/ios/Config/FamilyControls.entitlements
@@ -0,0 +1,6 @@
+
+
+
+ com.apple.developer.family-controls
+ com.apple.security.application-groups$(APC_APP_GROUP)
+
diff --git a/ios/Config/Local.xcconfig.example b/ios/Config/Local.xcconfig.example
new file mode 100644
index 0000000..ad4ce7c
--- /dev/null
+++ b/ios/Config/Local.xcconfig.example
@@ -0,0 +1,4 @@
+// Copy to Local.xcconfig (ignored by git). Supply your own identifiers and signing.
+APC_DEVELOPMENT_TEAM = YOURTEAMID
+APC_BUNDLE_PREFIX = de.felixfoertsch.parentalcontrols
+APC_APP_GROUP = group.$(APC_BUNDLE_PREFIX)
diff --git a/ios/Monitor/ActivityMonitor.swift b/ios/Monitor/ActivityMonitor.swift
new file mode 100644
index 0000000..aeef70e
--- /dev/null
+++ b/ios/Monitor/ActivityMonitor.swift
@@ -0,0 +1,26 @@
+import DeviceActivity
+import OSLog
+
+final class ActivityMonitor: DeviceActivityMonitor {
+ private let logger = Logger(subsystem: "APC", category: "DeviceActivity")
+ override func intervalDidStart(for activity: DeviceActivityName) {
+ super.intervalDidStart(for: activity)
+ handle(activity)
+ }
+ override func intervalDidEnd(for activity: DeviceActivityName) {
+ super.intervalDidEnd(for: activity)
+ handle(activity, ended: true)
+ }
+ override func eventDidReachThreshold(_ event: DeviceActivityEvent.Name, activity: DeviceActivityName) {
+ super.eventDidReachThreshold(event, activity: activity)
+ handle(activity, event: event)
+ }
+ private func handle(_ activity: DeviceActivityName, event: DeviceActivityEvent.Name? = nil,
+ ended: Bool = false) {
+ do { try Enforcement.callback(activity: activity, event: event, ended: ended) }
+ catch {
+ // Never clear existing restrictions because storage is temporarily unavailable.
+ logger.error("Screen Time callback could not reconcile state: \(String(describing: error), privacy: .public)")
+ }
+ }
+}
diff --git a/ios/Shared/Enforcement.swift b/ios/Shared/Enforcement.swift
new file mode 100644
index 0000000..70334e5
--- /dev/null
+++ b/ios/Shared/Enforcement.swift
@@ -0,0 +1,123 @@
+import CryptoKit
+import DeviceActivity
+import FamilyControls
+import Foundation
+import ManagedSettings
+import PolicyCore
+
+/// Native adapter. PolicyCore is unit-tested; Apple callback delivery still needs device tests.
+enum Enforcement {
+ private static let prefix = "apc.window."
+ private static var settings: ManagedSettingsStore {
+ ManagedSettingsStore(named: ManagedSettingsStore.Name("apc.entertainment"))
+ }
+
+ private static func shield(_ selection: FamilyActivitySelection, enabled: Bool) {
+ settings.shield.applications = enabled ? selection.applicationTokens : nil
+ }
+
+ static func apply(snapshot: Snapshot? = nil,
+ selection: FamilyActivitySelection? = nil) throws -> DeviceStatus {
+ try StateStore.withState { state in
+ if let snapshot {
+ try snapshot.validate()
+ guard abs(Date().timeIntervalSince1970 - snapshot.serverTime) < 300 else {
+ throw LocalError.message("Phone/server clocks differ by more than five minutes")
+ }
+ state.snapshot = snapshot
+ }
+ if let selection {
+ guard !selection.applicationTokens.isEmpty,
+ selection.categoryTokens.isEmpty, selection.webDomainTokens.isEmpty else {
+ throw LocalError.message("Select individual apps only; whole categories and websites are deferred in v0")
+ }
+ state.selection = selection
+ state.selectionRevision = UUID().uuidString
+ }
+ guard let snapshot = state.snapshot, !state.selection.applicationTokens.isEmpty else {
+ return status(state, kind: "not_configured", detail: "Pair and select individual apps with a guardian")
+ }
+ try snapshot.validate()
+ let now = Date()
+ let policy = snapshot.policy
+ state.observation.roll(to: policy.day(at: now))
+ let keyData = try WireCoding.encoder().encode(policy) + Data(state.selectionRevision.utf8)
+ let key = SHA256.hash(data: keyData).map { String(format: "%02x", $0) }.joined()
+ let budget = policy.dailyMinutes + snapshot.bonus(at: now)
+ let center = DeviceActivityCenter()
+ let activity = state.generation.map { DeviceActivityName(prefix + $0) }
+ let registered = activity.map { center.activities.contains($0) } ?? false
+ let needsPlan = state.policyKey != key || !state.monitoringReady || !registered
+ || (budget > 0 && !state.thresholds.contains(budget))
+ if needsPlan {
+ // Put the shield on before changing the monitor. A failed registration stays blocked.
+ shield(state.selection, enabled: true)
+ state.shielded = true
+ state.monitoringReady = false
+ state.generation = UUID().uuidString
+ state.thresholds = policy.thresholds(startingBonus: snapshot.bonus(at: now))
+ let name = DeviceActivityName(prefix + state.generation!)
+ var start = DateComponents(hour: policy.allowedStart / 60, minute: policy.allowedStart % 60)
+ var end = DateComponents(hour: policy.allowedEnd / 60, minute: policy.allowedEnd % 60)
+ start.timeZone = policy.calendar.timeZone; end.timeZone = policy.calendar.timeZone
+ let schedule = DeviceActivitySchedule(intervalStart: start, intervalEnd: end, repeats: true)
+ let events = Dictionary(uniqueKeysWithValues: state.thresholds.map { minutes in
+ (DeviceActivityEvent.Name("minutes.\(minutes)"), DeviceActivityEvent(
+ applications: state.selection.applicationTokens,
+ threshold: DateComponents(minute: minutes), includesPastActivity: true
+ ))
+ })
+ do {
+ try center.startMonitoring(name, during: schedule, events: events)
+ // Stop only our own obsolete schedules, after the replacement exists.
+ center.stopMonitoring(center.activities.filter { $0.rawValue.hasPrefix(prefix) && $0 != name })
+ state.monitoringReady = true
+ state.monitoringError = nil
+ state.policyKey = key
+ state.lastEvent = "Monitoring registered; awaiting system callbacks"
+ } catch {
+ center.stopMonitoring(center.activities.filter { $0.rawValue.hasPrefix(prefix) })
+ state.monitoringError = String(String(describing: error).prefix(300))
+ }
+ }
+ let decision = Decision.evaluate(snapshot, observation: state.observation, now: now)
+ state.shielded = !state.monitoringReady || decision.shielded
+ shield(state.selection, enabled: state.shielded)
+ return status(state, kind: state.monitoringReady ? "monitoring" : "error",
+ detail: state.monitoringError ?? "\(decision.reason). \(state.lastEvent)")
+ }
+ }
+
+ static func callback(activity: DeviceActivityName, event: DeviceActivityEvent.Name? = nil,
+ ended: Bool = false) throws {
+ try StateStore.withState { state in
+ guard let generation = state.generation, let snapshot = state.snapshot,
+ activity.rawValue == prefix + generation else { return } // Ignore obsolete monitors.
+ let now = Date()
+ let day = snapshot.policy.day(at: now)
+ state.observation.roll(to: day)
+ if let event, event.rawValue.hasPrefix("minutes."),
+ let minutes = Int(event.rawValue.dropFirst("minutes.".count)),
+ state.thresholds.contains(minutes), snapshot.policy.isAllowed(at: now) {
+ state.observation.record(threshold: minutes, day: day)
+ state.lastEvent = "Reached \(minutes)-minute usage threshold"
+ } else if event == nil {
+ state.lastEvent = ended ? "Allowed window ended" : "Allowed window started"
+ }
+ let decision = Decision.evaluate(snapshot, observation: state.observation, now: now)
+ state.shielded = ended || !state.monitoringReady || decision.shielded
+ shield(state.selection, enabled: state.shielded)
+ }
+ }
+
+ static func report(kind: String, detail: String) throws -> DeviceStatus {
+ try StateStore.withState { state in status(state, kind: kind, detail: detail) }
+ }
+
+ private static func status(_ state: NativeState, kind: String, detail: String) -> DeviceStatus {
+ DeviceStatus(policyRevision: state.snapshot?.policyRevision ?? 0, state: kind,
+ shielded: state.shielded, detail: String(detail.prefix(500)),
+ consumedLowerBound: state.observation.reachedMinutes,
+ usageDay: state.observation.day)
+ }
+}
diff --git a/ios/Shared/StateStore.swift b/ios/Shared/StateStore.swift
new file mode 100644
index 0000000..5370607
--- /dev/null
+++ b/ios/Shared/StateStore.swift
@@ -0,0 +1,53 @@
+import Darwin
+import FamilyControls
+import Foundation
+import PolicyCore
+
+struct NativeState: Codable {
+ var snapshot: Snapshot?
+ var selection = FamilyActivitySelection()
+ var selectionRevision = UUID().uuidString
+ var observation = Observation()
+ var generation: String?
+ var policyKey: String?
+ var thresholds: [Int] = []
+ var monitoringReady = false
+ var shielded = false
+ var lastEvent = "Not configured"
+ var monitoringError: String?
+}
+
+enum LocalError: LocalizedError {
+ case message(String)
+ var errorDescription: String? {
+ switch self { case .message(let value): return value }
+ }
+}
+
+enum StateStore {
+ /// A process-wide UserDefaults cache is not sufficient: the app and extension both write.
+ /// Lock one file while atomically replacing another. Corrupt state is never reset to defaults.
+ static func withState(_ operation: (inout NativeState) throws -> T) throws -> T {
+ guard let group = Bundle.main.object(forInfoDictionaryKey: "APCAppGroup") as? String,
+ let directory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else {
+ throw LocalError.message("App Group unavailable. Check signing and APC_APP_GROUP on all targets.")
+ }
+ let lockPath = directory.appendingPathComponent("state.lock").path
+ let fd = open(lockPath, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
+ guard fd >= 0 else { throw LocalError.message("Cannot open the shared-state lock") }
+ defer { close(fd) }
+ guard flock(fd, LOCK_EX) == 0 else { throw LocalError.message("Cannot lock shared state") }
+ defer { flock(fd, LOCK_UN) }
+ let file = directory.appendingPathComponent("state.json")
+ var state = NativeState()
+ if FileManager.default.fileExists(atPath: file.path) {
+ state = try WireCoding.decoder().decode(NativeState.self, from: Data(contentsOf: file))
+ }
+ let result = try operation(&state)
+ try WireCoding.encoder().encode(state).write(to: file, options: .atomic)
+ try FileManager.default.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], atPath: file.path
+ )
+ return result
+ }
+}
diff --git a/ios/Shield/ShieldConfigurationExtension.swift b/ios/Shield/ShieldConfigurationExtension.swift
new file mode 100644
index 0000000..45a1e5f
--- /dev/null
+++ b/ios/Shield/ShieldConfigurationExtension.swift
@@ -0,0 +1,17 @@
+import ManagedSettings
+import ManagedSettingsUI
+import UIKit
+
+final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
+ override func configuration(shielding application: Application) -> ShieldConfiguration {
+ ShieldConfiguration(
+ backgroundBlurStyle: .systemMaterial,
+ backgroundColor: .systemBackground,
+ icon: UIImage(systemName: "hourglass"),
+ title: .init(text: "Time for a break", color: .label),
+ subtitle: .init(text: "Your allowance is used, or it is outside your allowed hours. Open Family Quests to check your time or try a maths challenge. Rewards cannot extend bedtime.", color: .secondaryLabel),
+ primaryButtonLabel: .init(text: "Close", color: .white),
+ primaryButtonBackgroundColor: .systemIndigo
+ )
+ }
+}
diff --git a/ios/ShieldAction/ShieldActionExtension.swift b/ios/ShieldAction/ShieldActionExtension.swift
new file mode 100644
index 0000000..5ab8e02
--- /dev/null
+++ b/ios/ShieldAction/ShieldActionExtension.swift
@@ -0,0 +1,8 @@
+import ManagedSettings
+
+final class ShieldActionExtension: ShieldActionDelegate {
+ override func handle(action: ShieldAction, for application: ApplicationToken,
+ completionHandler: @escaping (ShieldActionResponse) -> Void) {
+ completionHandler(.close)
+ }
+}
diff --git a/ios/project.yml b/ios/project.yml
new file mode 100644
index 0000000..3cdcf88
--- /dev/null
+++ b/ios/project.yml
@@ -0,0 +1,107 @@
+name: AdvancedParentalControls
+options:
+ minimumXcodeGenVersion: 2.42.0
+ deploymentTarget:
+ iOS: '18.0'
+ createIntermediateGroups: true
+configFiles:
+ Debug: Config/Base.xcconfig
+ Release: Config/Base.xcconfig
+settings:
+ base:
+ SWIFT_VERSION: '5.0'
+ SWIFT_STRICT_CONCURRENCY: targeted
+ TARGETED_DEVICE_FAMILY: '1'
+ CODE_SIGN_STYLE: Automatic
+ DEVELOPMENT_TEAM: $(APC_DEVELOPMENT_TEAM)
+ MARKETING_VERSION: 0.1.0
+ CURRENT_PROJECT_VERSION: 1
+packages:
+ PolicyCore:
+ path: ../packages/PolicyCore
+targetTemplates:
+ ScreenTimeExtension:
+ type: app-extension
+ platform: iOS
+ settings:
+ base:
+ APPLICATION_EXTENSION_API_ONLY: YES
+ CODE_SIGN_ENTITLEMENTS: Config/FamilyControls.entitlements
+ dependencies:
+ - package: PolicyCore
+targets:
+ FamilyQuests:
+ type: application
+ platform: iOS
+ sources: [App, Shared]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app
+ CODE_SIGN_ENTITLEMENTS: Config/FamilyControls.entitlements
+ info:
+ path: Generated/App-Info.plist
+ properties:
+ CFBundleDisplayName: Family Quests
+ APCAppGroup: $(APC_APP_GROUP)
+ UILaunchScreen: {}
+ UIApplicationSceneManifest:
+ UIApplicationSupportsMultipleScenes: false
+ NSLocalNetworkUsageDescription: Connect to your family's self-hosted policy server on the local network.
+ NSAppTransportSecurity:
+ NSAllowsLocalNetworking: true
+ dependencies:
+ - package: PolicyCore
+ - target: ActivityMonitor
+ embed: true
+ - target: ShieldConfiguration
+ embed: true
+ - target: ShieldAction
+ embed: true
+ ActivityMonitor:
+ templates: [ScreenTimeExtension]
+ sources: [Monitor, Shared]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app.monitor
+ info:
+ path: Generated/Monitor-Info.plist
+ properties:
+ APCAppGroup: $(APC_APP_GROUP)
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.deviceactivity.monitor-extension
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ActivityMonitor
+ ShieldConfiguration:
+ templates: [ScreenTimeExtension]
+ sources: [Shield]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app.shield
+ info:
+ path: Generated/Shield-Info.plist
+ properties:
+ APCAppGroup: $(APC_APP_GROUP)
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.ManagedSettingsUI.shield-configuration-service
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShieldConfigurationExtension
+ ShieldAction:
+ templates: [ScreenTimeExtension]
+ sources: [ShieldAction]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app.shieldaction
+ info:
+ path: Generated/ShieldAction-Info.plist
+ properties:
+ APCAppGroup: $(APC_APP_GROUP)
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.ManagedSettings.shield-action-service
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShieldActionExtension
+schemes:
+ FamilyQuests:
+ build:
+ targets:
+ FamilyQuests: all
+ run:
+ config: Debug
+ archive:
+ config: Release
diff --git a/packages/PolicyCore/Package.swift b/packages/PolicyCore/Package.swift
new file mode 100644
index 0000000..be55f4c
--- /dev/null
+++ b/packages/PolicyCore/Package.swift
@@ -0,0 +1,12 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "PolicyCore",
+ platforms: [.iOS(.v18), .macOS(.v14)],
+ products: [.library(name: "PolicyCore", targets: ["PolicyCore"])],
+ targets: [
+ .target(name: "PolicyCore"),
+ .testTarget(name: "PolicyCoreTests", dependencies: ["PolicyCore"])
+ ]
+)
diff --git a/packages/PolicyCore/Sources/PolicyCore/PolicyCore.swift b/packages/PolicyCore/Sources/PolicyCore/PolicyCore.swift
new file mode 100644
index 0000000..c5d138b
--- /dev/null
+++ b/packages/PolicyCore/Sources/PolicyCore/PolicyCore.swift
@@ -0,0 +1,164 @@
+import Foundation
+
+public enum WireCoding {
+ public static func decoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ return decoder
+ }
+ public static func encoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+}
+
+public enum PolicyError: Error { case invalidPolicy, unsupportedSnapshot }
+
+public struct Policy: Codable, Equatable, Sendable {
+ public var timezone: String
+ public var dailyMinutes: Int
+ public var bonusMinutes: Int
+ public var maxBonusMinutes: Int
+ public var correctAnswers: Int
+ public var maxOperand: Int
+ public var allowedStart: Int
+ public var allowedEnd: Int
+
+ public init(timezone: String = "Europe/Berlin", dailyMinutes: Int = 30,
+ bonusMinutes: Int = 10, maxBonusMinutes: Int = 30,
+ correctAnswers: Int = 5, maxOperand: Int = 20,
+ allowedStart: Int = 420, allowedEnd: Int = 1200) {
+ self.timezone = timezone; self.dailyMinutes = dailyMinutes
+ self.bonusMinutes = bonusMinutes; self.maxBonusMinutes = maxBonusMinutes
+ self.correctAnswers = correctAnswers; self.maxOperand = maxOperand
+ self.allowedStart = allowedStart; self.allowedEnd = allowedEnd
+ }
+
+ public func validate() throws {
+ guard TimeZone(identifier: timezone) != nil,
+ (0...240).contains(dailyMinutes), (5...30).contains(bonusMinutes),
+ (0...60).contains(maxBonusMinutes), (1...20).contains(correctAnswers),
+ (2...100).contains(maxOperand), (0...1439).contains(allowedStart),
+ (1...1439).contains(allowedEnd), allowedEnd - allowedStart >= 60 else {
+ throw PolicyError.invalidPolicy
+ }
+ }
+
+ public var calendar: Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ // validate() is required before a policy can be armed.
+ calendar.timeZone = TimeZone(identifier: timezone) ?? TimeZone(secondsFromGMT: 0)!
+ return calendar
+ }
+
+ public func day(at date: Date) -> String {
+ let parts = calendar.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04d-%02d-%02d", parts.year!, parts.month!, parts.day!)
+ }
+
+ public func isAllowed(at date: Date) -> Bool {
+ let parts = calendar.dateComponents([.hour, .minute], from: date)
+ let minute = parts.hour! * 60 + parts.minute!
+ return minute >= allowedStart && minute < allowedEnd
+ }
+
+ /// Register a ladder once: earning a reward must not restart usage monitoring.
+ /// The second ladder accommodates an existing balance after a mid-day policy change.
+ /// Both include the partial final reward at the daily cap, and the normal next-day base.
+ public func thresholds(startingBonus: Int) -> [Int] {
+ guard bonusMinutes > 0, maxBonusMinutes >= 0 else { return [] }
+ var result = Set()
+ for start in [0, min(max(startingBonus, 0), maxBonusMinutes)] {
+ var bonus = start
+ result.insert(dailyMinutes + bonus)
+ while bonus < maxBonusMinutes {
+ bonus = min(bonus + bonusMinutes, maxBonusMinutes)
+ result.insert(dailyMinutes + bonus)
+ }
+ }
+ return result.filter { $0 > 0 }.sorted()
+ }
+}
+
+public struct Snapshot: Codable, Equatable, Sendable {
+ public var schemaVersion: Int
+ public var deviceId: String
+ public var childId: String
+ public var childName: String
+ public var policyRevision: Int
+ public var policy: Policy
+ public var day: String
+ public var earnedMinutes: Int
+ public var serverTime: Double
+
+ public init(schemaVersion: Int = 1, deviceId: String, childId: String,
+ childName: String, policyRevision: Int, policy: Policy,
+ day: String, earnedMinutes: Int, serverTime: Double) {
+ self.schemaVersion = schemaVersion; self.deviceId = deviceId
+ self.childId = childId; self.childName = childName; self.policyRevision = policyRevision
+ self.policy = policy; self.day = day; self.earnedMinutes = earnedMinutes
+ self.serverTime = serverTime
+ }
+
+ public func validate() throws {
+ try policy.validate()
+ guard schemaVersion == 1, policyRevision > 0,
+ (0...policy.maxBonusMinutes).contains(earnedMinutes),
+ serverTime.isFinite, day == policy.day(at: Date(timeIntervalSince1970: serverTime)) else {
+ throw PolicyError.unsupportedSnapshot
+ }
+ }
+
+ public func bonus(at now: Date) -> Int {
+ day == policy.day(at: now) ? min(max(0, earnedMinutes), policy.maxBonusMinutes) : 0
+ }
+}
+
+public struct Observation: Codable, Equatable, Sendable {
+ public var day: String = ""
+ public var reachedMinutes: Int = 0
+ public init() {}
+
+ public mutating func roll(to day: String) {
+ if self.day != day { self.day = day; reachedMinutes = 0 }
+ }
+ public mutating func record(threshold: Int, day: String) {
+ roll(to: day)
+ reachedMinutes = max(reachedMinutes, threshold)
+ }
+}
+
+public struct Decision: Equatable, Sendable {
+ public let shielded: Bool
+ public let budgetMinutes: Int
+ public let reason: String
+
+ public static func evaluate(_ snapshot: Snapshot, observation: Observation, now: Date) -> Decision {
+ let policy = snapshot.policy
+ let budget = policy.dailyMinutes + snapshot.bonus(at: now)
+ if !policy.isAllowed(at: now) {
+ return Decision(shielded: true, budgetMinutes: budget, reason: "Outside allowed hours")
+ }
+ let reached = observation.day == policy.day(at: now) ? observation.reachedMinutes : 0
+ let exhausted = reached >= budget
+ return Decision(shielded: exhausted, budgetMinutes: budget,
+ reason: exhausted ? "Allowance reached" : "Allowance available")
+ }
+}
+
+public struct DeviceStatus: Codable, Sendable {
+ public var policyRevision: Int
+ public var state: String
+ public var shielded: Bool
+ public var detail: String
+ public var consumedLowerBound: Int
+ public var usageDay: String
+
+ public init(policyRevision: Int, state: String, shielded: Bool, detail: String,
+ consumedLowerBound: Int, usageDay: String) {
+ self.policyRevision = policyRevision; self.state = state; self.shielded = shielded
+ self.detail = detail; self.consumedLowerBound = consumedLowerBound; self.usageDay = usageDay
+ }
+}
diff --git a/packages/PolicyCore/Tests/PolicyCoreTests/PolicyCoreTests.swift b/packages/PolicyCore/Tests/PolicyCoreTests/PolicyCoreTests.swift
new file mode 100644
index 0000000..9a31d58
--- /dev/null
+++ b/packages/PolicyCore/Tests/PolicyCoreTests/PolicyCoreTests.swift
@@ -0,0 +1,94 @@
+import Foundation
+import XCTest
+@testable import PolicyCore
+
+final class PolicyCoreTests: XCTestCase {
+ func date(_ value: String) -> Date { ISO8601DateFormatter().date(from: value)! }
+ var noon: Date { date("2026-09-17T10:00:00Z") }
+ func snapshot(_ policy: Policy = Policy(), bonus: Int = 0) -> Snapshot {
+ Snapshot(deviceId: "device", childId: "child", childName: "Example", policyRevision: 1,
+ policy: policy, day: "2026-09-17", earnedMinutes: bonus, serverTime: noon.timeIntervalSince1970)
+ }
+ func testRewardExtendsUsageNotWallClock() {
+ var observation = Observation()
+ observation.record(threshold: 30, day: "2026-09-17")
+ XCTAssertTrue(Decision.evaluate(snapshot(), observation: observation, now: noon).shielded)
+ XCTAssertFalse(Decision.evaluate(snapshot(bonus: 10), observation: observation, now: noon).shielded)
+ observation.record(threshold: 40, day: "2026-09-17")
+ XCTAssertTrue(Decision.evaluate(snapshot(bonus: 10), observation: observation, now: noon).shielded)
+ }
+ func testRefreshAndDuplicateCallbacksDoNotRefundUsage() {
+ var observation = Observation()
+ observation.record(threshold: 40, day: "2026-09-17")
+ observation.record(threshold: 30, day: "2026-09-17")
+ observation.record(threshold: 40, day: "2026-09-17")
+ observation.roll(to: "2026-09-17")
+ XCTAssertEqual(observation.reachedMinutes, 40)
+ }
+ func testBonusExpiresAndBaseResetsWithoutServer() {
+ var observation = Observation()
+ observation.record(threshold: 60, day: "2026-09-17")
+ let decision = Decision.evaluate(snapshot(bonus: 30), observation: observation,
+ now: date("2026-09-18T10:00:00Z"))
+ XCTAssertFalse(decision.shielded)
+ XCTAssertEqual(decision.budgetMinutes, 30)
+ }
+ func testBedtimeCannotBeBought() {
+ XCTAssertTrue(Decision.evaluate(snapshot(bonus: 30), observation: Observation(),
+ now: date("2026-09-17T18:00:00Z")).shielded)
+ XCTAssertTrue(Decision.evaluate(snapshot(bonus: 30), observation: Observation(),
+ now: date("2026-09-17T04:59:00Z")).shielded)
+ XCTAssertFalse(Decision.evaluate(snapshot(), observation: Observation(),
+ now: date("2026-09-17T05:00:00Z")).shielded)
+ }
+ func testZeroBaseAndZeroBonusFailClosed() {
+ let policy = Policy(dailyMinutes: 0, maxBonusMinutes: 0)
+ XCTAssertTrue(Decision.evaluate(snapshot(policy), observation: Observation(), now: noon).shielded)
+ XCTAssertEqual(policy.thresholds(startingBonus: 0), [])
+ }
+ func testThresholdLadderIncludesPartialCapAndRebasedRewards() {
+ let policy = Policy(bonusMinutes: 7, maxBonusMinutes: 23)
+ let thresholds = policy.thresholds(startingBonus: 10)
+ for budget in [30, 37, 44, 51, 53, 40, 47] { XCTAssertTrue(thresholds.contains(budget)) }
+ XCTAssertEqual(thresholds, thresholds.sorted())
+ }
+ func testEveryReachableRewardHasAThreshold() {
+ for step in 5...30 {
+ for cap in 0...60 {
+ let policy = Policy(dailyMinutes: 0, bonusMinutes: step, maxBonusMinutes: cap)
+ for initial in 0...cap {
+ let thresholds = Set(policy.thresholds(startingBonus: initial))
+ var bonus = initial
+ while bonus < cap {
+ bonus = min(bonus + step, cap)
+ XCTAssertTrue(thresholds.contains(bonus))
+ }
+ }
+ }
+ }
+ }
+ func testTimezoneMidnightAndDST() {
+ let policy = Policy()
+ XCTAssertEqual(policy.day(at: date("2026-09-17T22:01:00Z")), "2026-09-18")
+ XCTAssertTrue(policy.isAllowed(at: date("2026-10-24T05:00:00Z")))
+ XCTAssertFalse(policy.isAllowed(at: date("2026-10-25T05:00:00Z")))
+ XCTAssertTrue(policy.isAllowed(at: date("2026-10-25T06:00:00Z")))
+ }
+ func testValidationRejectsUnsupportedAndUnsafePolicies() {
+ XCTAssertThrowsError(try Policy(timezone: "Bogus/Zone").validate())
+ XCTAssertThrowsError(try Policy(bonusMinutes: 0).validate())
+ XCTAssertThrowsError(try Policy(allowedStart: 1200, allowedEnd: 420).validate())
+ var bad = snapshot(); bad.schemaVersion = 2
+ XCTAssertThrowsError(try bad.validate())
+ bad = snapshot(); bad.day = "2099-01-01"
+ XCTAssertThrowsError(try bad.validate())
+ }
+ func testWireRoundTripUsesServerFieldNames() throws {
+ let original = snapshot(bonus: 10)
+ let data = try WireCoding.encoder().encode(original)
+ XCTAssertTrue(String(decoding: data, as: UTF8.self).contains("\"policy_revision\""))
+ let decoded = try WireCoding.decoder().decode(Snapshot.self, from: data)
+ XCTAssertEqual(original, decoded)
+ try decoded.validate()
+ }
+}
diff --git a/scripts/browser_smoke.py b/scripts/browser_smoke.py
new file mode 100644
index 0000000..48a661a
--- /dev/null
+++ b/scripts/browser_smoke.py
@@ -0,0 +1,89 @@
+"""Real Chromium DOM + real ASGI backend, bridged in-process (no network deployment test).
+
+Requires playwright. Browser cookie/CSRF behavior is covered separately in test_api.py.
+The bridge uses TestClient's cookie jar and supplies the configured same-origin header.
+"""
+import os
+from pathlib import Path
+import secrets
+import sys
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "backend" / "src"))
+from fastapi.testclient import TestClient
+from playwright.sync_api import sync_playwright
+from apc.api import Settings, create_app
+
+
+def main():
+ with tempfile.TemporaryDirectory() as directory:
+ token = secrets.token_urlsafe(32)
+ app = create_app(Settings(token, str(Path(directory) / "smoke.db")))
+ with TestClient(app) as client, sync_playwright() as pw:
+ browser = pw.chromium.launch(executable_path=os.environ.get("CHROMIUM_PATH") or None,
+ args=["--no-sandbox"])
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
+ errors = []
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ def request(path, options):
+ response = client.request(options.get("method", "GET"), path,
+ content=options.get("body"), headers={"Content-Type": "application/json",
+ "Origin": "http://localhost:8000"})
+ return {"status": response.status_code, "body": response.text}
+ page.expose_function("asgiRequest", request)
+ html = client.get("/").text.replace('', "")
+ html = html.replace('', "")
+ page.set_content(html)
+ page.add_style_tag(content=client.get("/static/style.css").text)
+ page.evaluate("""() => { window.fetch = async (path, options = {}) => {
+ const response = await window.asgiRequest(path, options);
+ return new Response(response.body, {status: response.status,
+ headers: {'Content-Type': 'application/json'}});
+ }; }""")
+ page.add_script_tag(content=client.get("/static/app.js").text)
+ page.get_by_label("Administration token").fill(token)
+ page.get_by_role("button", name="Sign in", exact=True).click()
+ page.locator("#dashboard").wait_for(state="visible")
+ for name in ("Example A", "Example B"):
+ page.get_by_label("Display name", exact=True).fill(name)
+ page.get_by_role("button", name="Add child", exact=True).click()
+ page.get_by_role("heading", name=name, exact=True).wait_for()
+ page.locator("#policy [name=daily_minutes]").fill("40")
+ page.get_by_role("button", name="Save shared policy").click()
+ page.get_by_text("Shared policy saved.", exact=False).wait_for()
+ assert page.locator(".child").count() == 2
+ first = page.locator(".child").first
+ first.get_by_text("Individual exceptions", exact=True).click()
+ first.locator("[name=daily_minutes]").fill("45")
+ first.get_by_role("button", name="Save exceptions").click()
+ page.get_by_text("Exceptions saved.", exact=False).wait_for()
+ first = page.locator(".child").first
+ assert "45 daily minutes" in first.inner_text()
+ first.get_by_role("button", name="Pair phone").click()
+ page.locator("#code-dialog").wait_for(state="visible")
+ code = page.locator("#issued-code").inner_text()
+ paired = client.post("/v1/pair", json={"code": code, "name": "Smoke test"}).json()
+ headers = {"Authorization": f"Bearer {paired['token']}"}
+ challenge = client.post("/v1/device/challenges", headers=headers).json()
+ answers = []
+ for q in challenge["questions"]:
+ a, op, b = q["prompt"].split()
+ answers.append({"question_id": q["id"], "answer": int(a) + int(b) if op == "+" else int(a) - int(b)})
+ result = client.post(f"/v1/device/challenges/{challenge['id']}/submit",
+ headers=headers, json={"answers": answers})
+ assert result.json()["awarded_minutes"] == 10
+ page.get_by_role("button", name="Close", exact=True).click()
+ page.get_by_role("button", name="Refresh status").click()
+ page.get_by_text("45 daily minutes + 10 earned today", exact=False).wait_for()
+ if os.environ.get("APC_SCREENSHOT"):
+ page.screenshot(path=os.environ["APC_SCREENSHOT"], full_page=True)
+ page.get_by_role("button", name="Sign out").click()
+ page.locator("#login-panel").wait_for(state="visible")
+ assert not errors, errors
+ browser.close()
+ print("Browser/ASGI smoke passed: login, two children, policy, override, pairing, reward, logout")
+
+
+if __name__ == "__main__":
+ main()