add astra draft

This commit is contained in:
2026-09-17 17:59:34 +02:00
parent 88588dcfcc
commit 41458afc76
38 changed files with 2392 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-q"
+9
View File
@@ -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
+15
View File
@@ -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
View File
+364
View File
@@ -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
+86
View File
@@ -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)
+172
View File
@@ -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));
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Family controls</title><link rel="stylesheet" href="/static/style.css">
<script defer src="/static/app.js"></script>
</head>
<body>
<header><div><span class="eyebrow">ADVANCED PARENTAL CONTROLS · DEVELOPMENT MVP</span>
<h1>One family. Shared rules.</h1><p>Change a rule once. Give each child only the exceptions they need.</p></div>
<div id="actions" hidden><button id="refresh" type="button">Refresh status</button>
<button id="logout" type="button" class="secondary">Sign out</button></div></header>
<main>
<p id="message" role="status" aria-live="polite"></p>
<section id="login-panel"><h2>Parent sign-in</h2>
<p>Use the administration token configured on your server. Never put it on a child's phone.</p>
<form id="login"><label>Administration token<input name="token" type="password" required minlength="32" autocomplete="current-password"></label>
<button>Sign in</button></form></section>
<div id="dashboard" hidden>
<aside>Experimental enforcement: keep Apple's safety protections enabled. Phone updates apply when the child app opens or syncs; status is not live telemetry.</aside>
<section><div class="section-heading"><h2>Shared family policy</h2><span id="revision"></span></div>
<p>These values are inherited unless a child has an explicit override. The example limits are not age-based recommendations.</p>
<form id="policy"><div id="policy-fields" class="fields"></div><button>Save shared policy</button></form></section>
<section><h2>Children</h2><form id="add-child" class="inline"><label>Display name<input name="name" required maxlength="60" autocomplete="off"></label><button>Add child</button></form>
<div id="children" class="children"></div></section>
</div>
</main>
<dialog id="code-dialog"><h2 id="code-title">Pair phone</h2><p>This single-use code expires in ten minutes. Keep it private.</p>
<p><code id="issued-code"></code></p><p id="code-help"></p><button id="copy-code" type="button">Copy code</button>
<button id="close-code" type="button" class="secondary">Close</button></dialog>
<footer>v0.1 · One household · One active iPhone per child · No app names or browsing history leave the phone</footer>
</body></html>
+24
View File
@@ -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; } }
+80
View File
@@ -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]
+222
View File
@@ -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