mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591)
Sharing earned Feats on the (forthcoming) public wall is strictly opt-in,
default OFF, with a binding data-minimization contract.
- Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard)
after song-directory / before paths — publishes only display name + earned
Feats, never songs/skills/scores; off by default.
- Settings (plugins/achievements/settings.html, System tab via
settings.category): the same toggle + a "Remove me from the wall" button
(POST remove-me — wipes local synced state offline + enqueues removal).
- Core (server.py): achievements_enabled (bool, default false) in
_default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS;
mirrored to localStorage in app.js loadSettings().
- Data-minimization gate: engine.build_wall_payload is the single explicit-dict
serializer; key-set is EXACTLY {display_name, player_hash, achievement_id,
unlocked_at}, achievement_id always a Feat id. Enqueue is gated on
opted-in AND profile identity (reused player_hash); competency never
enqueues (integration law).
Verified natively: settings round-trip + validation + remove-me; opted-in
activity enqueues exactly one 4-field Feat payload; Playwright confirms the
5-step wizard + opt-in card (default unchecked), zero console errors.
29 plugin tests + new settings tests pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
05dd3d227a
commit
287c23a532
@@ -135,6 +135,26 @@ def consecutive_run_length(dates):
|
||||
return best
|
||||
|
||||
|
||||
# ── Data-minimization contract (binding, code-enforced) ──────────────────────
|
||||
# The wall payload key-set is frozen here and asserted by a unit test. The
|
||||
# serializer below is the ONLY way outbound data is built — never dict(row) or
|
||||
# **model — so a stray field cannot leak. Adding a key makes the test go red.
|
||||
WALL_PAYLOAD_KEYS = ("display_name", "player_hash", "achievement_id", "unlocked_at")
|
||||
|
||||
|
||||
def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at):
|
||||
"""Build the EXACT four-field wall payload. ``achievement_id`` must always be
|
||||
a Feat id (the caller only ever invokes this for Feat unlocks — competency
|
||||
never syncs). Explicit literal dict on purpose; do not refactor into a
|
||||
row/model splat."""
|
||||
return {
|
||||
"display_name": display_name,
|
||||
"player_hash": player_hash,
|
||||
"achievement_id": achievement_id,
|
||||
"unlocked_at": unlocked_at,
|
||||
}
|
||||
|
||||
|
||||
def diff_unlocks(prev_tiers, new_tiers):
|
||||
"""Feat ids whose tier advanced (incl. first unlock).
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ _lock = threading.Lock()
|
||||
_state = {
|
||||
"db_path": None,
|
||||
"dir": None, # plugin directory (for catalog JSON)
|
||||
"config_dir": None, # CONFIG_DIR (for reading the opt-in setting)
|
||||
"meta_db": None, # MetadataDB (for the profile identity: name + hash)
|
||||
"log": logging.getLogger("feedBack.plugin.achievements"),
|
||||
"engine": None, # sibling engine.py module (pure helpers)
|
||||
"feat_defs": [], # parsed feats.json -> list of feat defs
|
||||
@@ -108,6 +110,58 @@ def _now_iso():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _opted_in():
|
||||
"""True only when the user has opted in (core setting ``achievements_enabled``).
|
||||
|
||||
Read straight from CONFIG_DIR/config.json — the single source of truth the
|
||||
/api/settings endpoint persists. Default OFF on any read failure: nothing
|
||||
leaves the device unless explicitly enabled.
|
||||
"""
|
||||
try:
|
||||
cfg_path = Path(_state["config_dir"]) / "config.json"
|
||||
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
return bool(cfg.get("achievements_enabled") is True)
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _identity():
|
||||
"""(display_name, player_hash) from the profile, or (None, None).
|
||||
|
||||
Reused as the wall identity (server.py's documented player_hash). Sync is
|
||||
skipped entirely when either is missing.
|
||||
"""
|
||||
db = _state["meta_db"]
|
||||
if db is None or not hasattr(db, "get_profile"):
|
||||
return None, None
|
||||
try:
|
||||
prof = db.get_profile() or {}
|
||||
return (prof.get("display_name") or None), (prof.get("player_hash") or None)
|
||||
except Exception: # noqa: BLE001 — identity is best-effort; never break a request
|
||||
return None, None
|
||||
|
||||
|
||||
def _enqueue_feat_sync(conn, feat_id, unlocked_at):
|
||||
"""Enqueue a wall-sync POST for a Feat unlock — opt-in gated, identity gated.
|
||||
|
||||
Builds the outbound payload through the SINGLE code-gated serializer
|
||||
(engine.build_wall_payload, exactly four fields). Competency unlocks never
|
||||
reach this path (integration law + data-minimization contract). The drain
|
||||
worker (PR3) POSTs the queued rows; here we only persist intent.
|
||||
"""
|
||||
if not _opted_in():
|
||||
return False
|
||||
display_name, player_hash = _identity()
|
||||
if not display_name or not player_hash:
|
||||
return False
|
||||
payload = _state["engine"].build_wall_payload(display_name, player_hash, feat_id, unlocked_at)
|
||||
conn.execute(
|
||||
"INSERT INTO sync_queue(kind, payload, state) VALUES ('unlock', ?, 'pending')",
|
||||
(json.dumps(payload),),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _read_counters(conn):
|
||||
return {row["key"]: int(row["value"]) for row in conn.execute("SELECT key, value FROM counters")}
|
||||
|
||||
@@ -218,6 +272,8 @@ def setup(app, context):
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
_state["db_path"] = str(base / "achievements.db")
|
||||
_state["dir"] = str(Path(__file__).resolve().parent)
|
||||
_state["config_dir"] = str(config_dir)
|
||||
_state["meta_db"] = context.get("meta_db")
|
||||
_state["log"] = context.get("log") or _state["log"]
|
||||
# Pure helpers via the per-plugin sibling loader (constitution P-III), with a
|
||||
# plain-import fallback for pytest / standalone use.
|
||||
@@ -277,7 +333,9 @@ def setup(app, context):
|
||||
for fid in fresh:
|
||||
f = _feat_by_id(fid) or {}
|
||||
tier = new_tiers[fid]
|
||||
if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, _now_iso()):
|
||||
at = _now_iso()
|
||||
if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, at):
|
||||
_enqueue_feat_sync(conn, fid, at)
|
||||
unlocked.append(_feat_payload(fid, f, tier))
|
||||
conn.commit()
|
||||
return {"ok": True, "unlocked": unlocked, "counters": new_counters}
|
||||
@@ -287,11 +345,16 @@ def setup(app, context):
|
||||
@app.post("/api/plugins/achievements/report-unlock")
|
||||
def post_report_unlock(body: UnlockIn):
|
||||
cls = "feat" if body.kind == "feat" else "competency"
|
||||
at = body.at or _now_iso()
|
||||
with _lock:
|
||||
conn = _conn()
|
||||
try:
|
||||
changed = _record_unlock(
|
||||
conn, body.id, cls, body.category, body.sourceId, body.tier, body.at)
|
||||
conn, body.id, cls, body.category, body.sourceId, body.tier, at)
|
||||
# Only Feats sync; competency never enqueues (integration law +
|
||||
# data-minimization contract).
|
||||
if changed and cls == "feat":
|
||||
_enqueue_feat_sync(conn, body.id, at)
|
||||
conn.commit()
|
||||
return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier}
|
||||
finally:
|
||||
|
||||
@@ -1,8 +1,68 @@
|
||||
<!-- Achievements plugin settings panel. PR1 ships an informational stub; the
|
||||
Privacy opt-in toggle + "Remove me from the wall" button land in PR2. -->
|
||||
<div class="text-sm text-gray-300 space-y-2">
|
||||
<p>Your <strong>Achievements</strong> (skill milestones) and <strong>Feats of Power</strong>
|
||||
(rare activity trophies) live on your <em>Profile</em> page. Everything here is
|
||||
local and private.</p>
|
||||
<p class="text-gray-400">Sharing Feats on the public wall is opt-in and arrives in a later update.</p>
|
||||
<!-- Achievements plugin — Privacy panel (mounts under the Settings "System" tab
|
||||
via settings.category). Owns the wall opt-in toggle (bound to the core
|
||||
`achievements_enabled` setting) + the self-serve "Remove me from the wall"
|
||||
action. Default OFF — nothing publishes until the user opts in. -->
|
||||
<div class="text-sm text-gray-300 space-y-4" data-ach-privacy>
|
||||
<div>
|
||||
<p>Your <strong>Achievements</strong> and <strong>Feats of Power</strong> live on your
|
||||
<em>Profile</em> page and are local & private by default.</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="setting-achievements-enabled"
|
||||
class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-sky-500 focus:ring-sky-500">
|
||||
<span>
|
||||
<span class="font-medium text-gray-200">Share my Feats of Power on the public wall</span>
|
||||
<span class="block text-gray-400">Publishes only your display name and the rare
|
||||
<strong>Feats</strong> you earn (activity milestones) — never songs, skills, or scores.
|
||||
You can turn this off and remove yourself at any time.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<button type="button" id="ach-remove-me"
|
||||
class="px-3 py-1.5 rounded-md text-sm border border-red-500/40 text-red-300 hover:bg-red-500/10 transition">
|
||||
Remove me from the wall
|
||||
</button>
|
||||
<span id="ach-remove-status" class="ml-2 text-xs text-gray-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.currentScript && document.currentScript.previousElementSibling;
|
||||
// The panel re-injects on each Settings entry; bind once per element.
|
||||
var toggle = document.getElementById('setting-achievements-enabled');
|
||||
if (!toggle || toggle.dataset.wired === '1') return;
|
||||
toggle.dataset.wired = '1';
|
||||
|
||||
// Hydrate from the authoritative server setting.
|
||||
fetch('/api/settings').then(function (r) { return r.ok ? r.json() : {}; }).then(function (d) {
|
||||
toggle.checked = d && d.achievements_enabled === true;
|
||||
}).catch(function () { /* offline — leave unchecked */ });
|
||||
|
||||
toggle.addEventListener('change', function () {
|
||||
var on = !!toggle.checked;
|
||||
try { localStorage.setItem('achievementsEnabled', on ? '1' : '0'); } catch (_) {}
|
||||
fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ achievements_enabled: on }),
|
||||
}).catch(function () { /* best-effort; revert on failure */ });
|
||||
});
|
||||
|
||||
var removeBtn = document.getElementById('ach-remove-me');
|
||||
var status = document.getElementById('ach-remove-status');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', function () {
|
||||
removeBtn.disabled = true;
|
||||
if (status) status.textContent = 'Removing…';
|
||||
fetch('/api/plugins/achievements/remove-me', { method: 'POST' })
|
||||
.then(function (r) {
|
||||
if (status) status.textContent = r.ok ? 'Removed. Your Feats stay on your Profile.' : 'Could not reach the server — try again.';
|
||||
})
|
||||
.catch(function () { if (status) status.textContent = 'Offline — queued; it will sync when you reconnect.'; })
|
||||
.finally(function () { removeBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user