diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a682f8..d9e6fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -Latest published Workshop version: **0.0.125** +Latest published Workshop version: **0.0.126** + +## 2026-10-30 — Community mission statistics + +- Mission statistics now connect automatically on community servers without manual registration. +- Server administrators can disable reporting under Mission → Statistics. +- Victory and failure screens show the current map's wins, failures and win rate on this server and across all reporting servers. ## 2026-10-30 — Mission results diff --git a/addons/core/Configs/ERR/Telemetry.conf b/addons/core/Configs/ERR/Telemetry.conf index 0225be6..22c79ab 100644 --- a/addons/core/Configs/ERR/Telemetry.conf +++ b/addons/core/Configs/ERR/Telemetry.conf @@ -1,3 +1,3 @@ ERR_TelemetryConfig { - Endpoint "" + Endpoint "https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events" } diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_CommunityStatistics.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_CommunityStatistics.c new file mode 100644 index 0000000..d059cda --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_CommunityStatistics.c @@ -0,0 +1,44 @@ +class ERR_CommunityMapRecord +{ + int wins = -1; + int losses = -1; + int completed = -1; + int partialMissions = -1; + bool Valid() + { + return wins >= 0 && wins <= 1000000000 && losses >= 0 && losses <= 1000000000 && completed == wins + losses && completed > 0 && partialMissions >= 0 && partialMissions <= completed; + } + string Line(string label) + { + int rate = Math.Round(100.0 * wins / completed); + return string.Format("%1: %2 wins | %3 failures | ", label, wins, losses) + rate.ToString() + "% win rate"; + } +} +class ERR_CommunityStatistics +{ + int schemaVersion; + string terrain; + string serverId; + string missionId; + string eventId; + string outcome; + ref ERR_CommunityMapRecord server; + ref ERR_CommunityMapRecord allServers; + bool Matches(string expectedServer, string expectedMission, string expectedEvent, string expectedTerrain, string expectedOutcome) + { + if (schemaVersion != 1 || serverId != expectedServer || missionId != expectedMission || eventId != expectedEvent || terrain != expectedTerrain || outcome != expectedOutcome) return false; + if (outcome != "victory" && outcome != "defeat") return false; + if (!server || !allServers || !server.Valid() || !allServers.Valid()) return false; + if (server.wins > allServers.wins || server.losses > allServers.losses) return false; + if (outcome == "victory" && server.wins == 0) return false; + if (outcome == "defeat" && server.losses == 0) return false; + return true; + } + string Summary() + { + string text = "This map's record (including this round)\n"; + text += server.Line("This server") + "\n" + allServers.Line("All reporting servers"); + if (allServers.partialMissions > 0) text += "\nIncludes rounds with partial statistics."; + return text; + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_RoundStatisticsLifecycle.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_RoundStatisticsLifecycle.c index ae05af4..8c2c3b2 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_RoundStatisticsLifecycle.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_RoundStatisticsLifecycle.c @@ -3,9 +3,21 @@ modded class ERR_GameMode protected ref ERR_RoundStatistics m_ERRStatistics; [RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")] protected string m_ERRRoundSummary; + [RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")] + protected string m_ERRCommunitySummary = "Community map records unavailable."; protected ref ScriptInvoker m_ERRStatisticsChanged = new ScriptInvoker(); ERR_RoundStatistics ERR_Statistics() { return m_ERRStatistics; } - string ERR_RoundSummary() { return m_ERRRoundSummary; } + string ERR_RoundSummary() + { + if (m_ERRRoundSummary.IsEmpty()) return ""; + return m_ERRCommunitySummary + "\n\n" + m_ERRRoundSummary; + } + void ERR_PublishCommunityStatistics(string summary) + { + if (!Replication.IsServer() || summary.Length() > 2048 || m_ERRCommunitySummary == summary) return; + m_ERRCommunitySummary = summary; + Replication.BumpMe(); ERR_OnRoundStatisticsReplicated(); + } ScriptInvoker ERR_OnStatisticsChanged() { return m_ERRStatisticsChanged; } protected void ERR_OnRoundStatisticsReplicated() { m_ERRStatisticsChanged.Invoke(); } void ERR_PublishRoundStatistics(string summary) diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c index d9fc06c..e9253c8 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c @@ -87,6 +87,9 @@ class ERR_Telemetry protected RestContext m_Context; protected string m_MissionId; protected string m_InFlight; + protected string m_FinalEventId; + protected string m_FinalOutcome; + protected bool m_CommunityReady; protected int m_Started; protected int m_LastSample; protected int m_NextHeartbeat; @@ -103,6 +106,7 @@ class ERR_Telemetry protected bool m_Dirty; protected bool m_TruncatedPlayers; protected bool m_TruncatedMods; + protected bool m_CredentialBlocked; void ERR_Telemetry(ERR_GameMode mode) { @@ -189,6 +193,7 @@ class ERR_Telemetry if (m_Mode.Settings().Get("telemetryEnabled") == 0) return "Disabled. Unsent records discarded."; if (m_Config.Endpoint.IsEmpty()) return "Not sending: receiving URL is missing or invalid."; if (m_Dirty) return "Waiting for profile storage; events have not been sent."; + if (m_CredentialBlocked) return "Telemetry credential storage needs attention; delivery suspended."; if (m_Failures > 0) return "Delivery unavailable; pending records will retry."; return string.Format("Configured. %1 delivered this session; %2 pending.", m_Delivered, m_State.pending.Count()); } @@ -289,7 +294,8 @@ class ERR_Telemetry if (!m_Ended) Sample(); JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false); json.WriteValue("schemaVersion", 1); - json.WriteValue("eventId", UUID.GenV4()); json.WriteValue("missionId", m_MissionId); + string eventId = UUID.GenV4(); + json.WriteValue("eventId", eventId); json.WriteValue("missionId", m_MissionId); json.WriteValue("serverId", m_State.serverId); json.WriteValue("serverName", m_Config.ServerName); json.WriteValue("type", type); json.WriteValue("occurredAt", System.GetUnixTime()); json.WriteValue("partial", m_Partial); @@ -324,7 +330,18 @@ class ERR_Telemetry if (remove == 0 && !m_InFlight.IsEmpty()) remove = 1; m_State.pending.Remove(remove); m_State.droppedEvents++; } - m_State.pending.Insert(payload); m_Dirty = true; + if (type == "mission_finished") + { + m_FinalEventId = eventId; m_FinalOutcome = outcome; + // Finish can arrive while a previous request is in flight. RemoveItem on its + // acknowledgement still removes that exact payload, leaving this final first. + m_State.pending.InsertAt(payload, 0); + if (outcome == "victory" || outcome == "defeat") + m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded..."); + GetGame().GetCallqueue().CallLater(Pulse, 1, false); + } + else m_State.pending.Insert(payload); + m_Dirty = true; Save(); } protected void Pulse() @@ -338,11 +355,21 @@ class ERR_Telemetry Sample(); if (now >= m_NextHeartbeat) { m_NextHeartbeat = now + 300000; Event("heartbeat"); } } - if (m_Dirty && !Save()) return; + if (m_Dirty && !Save()) { CommunityUnavailable(); return; } if (m_Callback && now - m_RequestStarted > 30000) Failed(m_Callback); if (m_Callback || m_State.pending.IsEmpty() || now < m_NextAttempt) return; + if (m_Config.Token.IsEmpty()) + { + string token; + if (!ERR_TelemetryCredentials.Resolve(m_Config.Endpoint, m_State.serverId, token)) + { + CommunityUnavailable(); + m_CredentialBlocked = true; m_NextAttempt = now + 30000; return; + } + m_Config.Token = token; m_CredentialBlocked = false; + } m_Context = GetGame().GetRestApi().GetContext(m_Config.Endpoint); - if (!m_Context) { m_NextAttempt = now + 300000; return; } + if (!m_Context) { CommunityUnavailable(); m_NextAttempt = now + 300000; return; } m_Callback = new RestCallback(); m_Callback.SetOnSuccess(Delivered); m_Callback.SetOnError(Failed); m_InFlight = m_State.pending[0]; m_RequestStarted = now; JsonSaveContext request = new JsonSaveContext(); request.EnableTypeDiscriminator(false); @@ -355,16 +382,39 @@ class ERR_Telemetry } protected void Delivered(RestCallback callback) { - if (callback != m_Callback || !Enabled()) return; + if (callback != m_Callback || !Enabled() || !Current()) return; if (!ERR_TelemetryRules.Acknowledged(callback.GetHttpCode())) { Failed(callback); return; } + UpdateCommunity(callback); m_State.pending.RemoveItem(m_InFlight); m_InFlight = ""; m_Callback = null; m_Failures = 0; m_Delivered++; m_Dirty = true; Save(); + if (m_Ended) GetGame().GetCallqueue().CallLater(Pulse, 1, false); + } + protected void CommunityUnavailable() + { + if (!m_CommunityReady && Current() && m_Ended && (m_FinalOutcome == "victory" || m_FinalOutcome == "defeat")) + m_Mode.ERR_PublishCommunityStatistics("Community map records unavailable. This round's local statistics are shown below."); + } + protected void UpdateCommunity(RestCallback callback) + { + if (!m_Ended || m_FinalEventId.IsEmpty() || !Current()) return; + JsonLoadContext sent = new JsonLoadContext(); string sentEvent; + if (!sent.LoadFromString(m_InFlight) || !sent.ReadValue("eventId", sentEvent) || sentEvent != m_FinalEventId) return; + if (m_FinalOutcome != "victory" && m_FinalOutcome != "defeat") return; + JsonLoadContext reply = new JsonLoadContext(); + ERR_CommunityStatistics community; + if (callback.GetData().Length() > 8192 || !reply.LoadFromString(callback.GetData()) || !reply.ReadValue("community", community) || !community || !community.Matches(m_State.serverId, m_MissionId, m_FinalEventId, GetGame().GetWorldFile(), m_FinalOutcome)) + { + CommunityUnavailable(); return; + } + m_CommunityReady = true; + m_Mode.ERR_PublishCommunityStatistics(community.Summary()); } protected void Failed(RestCallback callback) { if (callback != m_Callback || !Enabled()) return; m_Callback = null; m_InFlight = ""; m_Failures = Math.Min(100, m_Failures + 1); m_NextAttempt = System.GetTickCount() + 1000 * ERR_TelemetryRules.RetrySeconds(m_Failures); + CommunityUnavailable(); if (m_Failures == 1) Print("Reintroduced [telemetry]: delivery failed; queued events will retry", LogLevel.WARNING); } void Close() diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_TelemetryCredentials.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_TelemetryCredentials.c new file mode 100644 index 0000000..014c95c --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_TelemetryCredentials.c @@ -0,0 +1,52 @@ +// Each installation claims its random server ID with its own persistent secret. +// No receiver administrator secret is bundled with the mod or sent by players. +class ERR_TelemetryCredential +{ + string endpoint; + string serverId; + string token; +} +class ERR_TelemetryCredentials +{ + static const string PATH = "$profile:EscapistsTelemetryCredentials.json"; + static bool Valid(ERR_TelemetryCredential entry) + { + return entry && !entry.endpoint.IsEmpty() && UUID.IsUUID(entry.serverId) && entry.token.Length() >= 32 && entry.token.Length() <= 256; + } + static bool Resolve(string endpoint, string serverId, out string token) + { + token = ""; + ref array entries = {}; + if (FileIO.FileExists(PATH)) + { + JsonLoadContext loaded = new JsonLoadContext(); + // Never silently replace a damaged credential: the receiver already owns its + // hash, and a new secret must not take over the same server's history. + if (!loaded.LoadFromFile(PATH) || !loaded.ReadValue("credentials", entries) || !entries || entries.Count() > 16) return false; + foreach (ERR_TelemetryCredential known : entries) + { + if (!Valid(known)) return false; + if (known.endpoint == endpoint && known.serverId == serverId) token = known.token; + } + if (!token.IsEmpty()) return true; + } + if (entries.Count() >= 16) return false; + ERR_TelemetryCredential created = new ERR_TelemetryCredential(); + created.endpoint = endpoint; created.serverId = serverId; + string first = UUID.GenV4(); string second = UUID.GenV4(); + created.token = first + second; + entries.Insert(created); + JsonSaveContext saved = new JsonSaveContext(); saved.EnableTypeDiscriminator(false); + saved.WriteValue("credentials", entries); + if (!saved.SaveToFile(PATH + ".tmp")) return false; + string written; + if (!ERR_ProfileConfig.Text(PATH + ".tmp", written) || written != saved.SaveToString()) return false; + if (!FileIO.CopyFile(PATH + ".tmp", PATH)) return false; + string confirmed; + if (!ERR_ProfileConfig.Text(PATH, confirmed) || confirmed != written) return false; + FileIO.DeleteFile(PATH + ".tmp"); + // Publish to the HTTP sender only after the exact credential is durable. + token = created.token; + return true; + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 08a48af..87dfea7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -402,3 +402,11 @@ start fresh with the next mission. They work with statistics telemetry disabled and require no web service. At most 128 distinct participants are retained; the report explicitly marks truncation if that limit is reached. No account IDs are included in the displayed or replicated summary. + +After a victory or defeat is accepted by the community statistics receiver, the +screen also shows the current map's wins, failures and win rate on this server +and across all reporting servers. Totals include this round and update if they +arrive while the screen is open. Different maps are counted separately using +their loaded world resources. Startup failures, empty restarts and interrupted +sessions are excluded. Community totals show unavailable if reporting is disabled +or delivery fails; local round statistics still work. diff --git a/docs/evidence/community-statistics-2026-10-30/isolated-runner.py b/docs/evidence/community-statistics-2026-10-30/isolated-runner.py new file mode 100644 index 0000000..9e0e8bf --- /dev/null +++ b/docs/evidence/community-statistics-2026-10-30/isolated-runner.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Frozen local Workbench community-statistics fixture; no live server or installed asset writes.""" +from pathlib import Path +import hashlib,json,shutil,subprocess,sys,os,time +ROOT=Path(__file__).resolve().parents[3] +sys.path.insert(0,str(ROOT/'tools')) +from source_manifest import addon_manifest +from headless_game import assert_prefix_idle +name=sys.argv[1] +outcome=sys.argv[2] +if outcome not in {'victory','defeat'}: raise ValueError('Expected victory or defeat') +if not name.isalnum(): raise ValueError('Use an alphanumeric run suffix') +source=Path('/tmp/erri-community-statistics-source-'+name) +profile=Path('/tmp/erri-community-statistics-run-'+name) +compat=ROOT/'.local/playtest-compat-20260920' +assert_prefix_idle(compat) +source.mkdir();profile.mkdir() +for rel in addon_manifest(ROOT): + target=source/rel;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(ROOT/rel,target) +shutil.copytree(ROOT/'tools',source/'tools',ignore=shutil.ignore_patterns('__pycache__')) +(source/'docs').mkdir();shutil.copy2(ROOT/'docs/external-addons.json',source/'docs/external-addons.json') +fixture=Path(__file__).with_name('native-fixture.c') +target=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_ZCommunityStatisticsFixture.c' +target.write_text(fixture.read_text().replace('TEST_OUTCOME', outcome)) +# Opt-in fixture is independent of the historical generic Core smoke. +p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c' +p.write_text(p.read_text().replace('array independentFixtures = {','array independentFixtures = {"community-statistics", ')) +p=source/'tools/headless_workbench.py' +p.write_text(p.read_text().replace("choices=['coordinated-ai',", "choices=['community-statistics', 'coordinated-ai',").replace("a.smoke not in {'coordinated-ai',", "a.smoke not in {'community-statistics', 'coordinated-ai',")) +# The existing fatal-loss diagnostic is guarded by errSmoke; this Core-only +# runner uses errBaseSmoke. Adapt only that test-helper flag in the frozen copy. +p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c' +p.write_text(p.read_text().replace('System.GetCLIParam("errSmoke", enabled)', 'System.GetCLIParam("errBaseSmoke", enabled)')) +installed=Path.home()/'.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons' +(profile/'profile').mkdir() +(profile/'profile/EscapistsFactions.json').write_text(json.dumps({'escapists':'ussr','enemy':'us','independent':'fia'})+'\n') +(profiles:=profile/'fixture-inputs.json').write_text(json.dumps({'fixture_sha256':hashlib.sha256(fixture.read_bytes()).hexdigest(),'production_sources':addon_manifest(ROOT),'source':str(source),'profile':str(profile)},indent=2)+'\n') +command=[sys.executable,str(source/'tools/headless_workbench.py'),'--compat',str(compat),'--profile',str(profile),'--external-addons',str(installed),'--project','core','--smoke','community-statistics','--rounds','1','--timeout','300'] +with (profile/'runner-output.log').open('w') as output: + result=subprocess.Popen(command,stdout=output,stderr=subprocess.STDOUT) + captured=False + while result.poll() is None: + if not captured and any('ERR STATS UI READY' in log.read_text(errors='replace') for log in (profile/'logs').glob('*/console.log')): + for proc in Path('/proc').iterdir(): + if not proc.name.isdigit(): continue + try: + args=(proc/'cmdline').read_bytes().decode(errors='replace').replace('\\', '/') + if 'ArmaReforgerWorkbenchSteamDiag.exe' not in args or str(source/'addons/core/addon.gproj') not in args: continue + values=dict(x.split('=',1) for x in (proc/'environ').read_bytes().decode(errors='replace').split('\0') if '=' in x) + if 'DISPLAY' not in values: continue + env=os.environ.copy() + for key in ['DISPLAY','XAUTHORITY']: + if key in values: env[key]=values[key] + capture=subprocess.run(['import','-display',values['DISPLAY'],'-window','root',str(profile/'results-desktop.png')],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10) + if capture.returncode==0: captured=True;break + except (OSError,ValueError,subprocess.TimeoutExpired): pass + time.sleep(1) +print(json.dumps({'exit':result.returncode,'source':str(source),'profile':str(profile)})) +print('\n'.join((profile/'runner-output.log').read_text().splitlines()[-45:])) +raise SystemExit(result.returncode) diff --git a/docs/evidence/community-statistics-2026-10-30/native-fixture.c b/docs/evidence/community-statistics-2026-10-30/native-fixture.c new file mode 100644 index 0000000..1bd6a56 --- /dev/null +++ b/docs/evidence/community-statistics-2026-10-30/native-fixture.c @@ -0,0 +1,66 @@ +#ifdef WORKBENCH +// Injected only into a frozen private fixture copy. No public telemetry requests. +modded class ERR_GameMode +{ + protected int m_ERRCommunityTestStage; + protected float m_ERRCommunityTestWait; + protected ref ERR_CommunityStatistics m_ERRCommunityTestResult; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + if (Replication.IsServer() && !SCR_Global.IsEditMode()) GetGame().GetCallqueue().CallLater(ERR_CommunityTest, 500, true); + } + protected bool ERR_CommunityCheck(bool ok, string detail) + { + if (ok) { Print("ERR SMOKE PASS: community " + detail); return true; } + Print("ERR SMOKE FAIL: community " + detail, LogLevel.ERROR); + GetGame().GetCallqueue().Remove(ERR_CommunityTest); return false; + } + protected void ERR_CommunityTest() + { + if (!PreparationError().IsEmpty()) { ERR_CommunityCheck(false, PreparationError()); return; } + if (GetWorld().GetWorldTime() < m_ERRCommunityTestWait) return; + if (m_ERRCommunityTestStage == 0) + { + if (!Active()) return; + SCR_PlayerController pc = SCR_PlayerController.Cast(GetGame().GetPlayerController()); + if (!pc || !pc.GetControlledEntity()) return; + string account = UUID.GenV4(); string first; string second; + if (!ERR_CommunityCheck(ERR_TelemetryCredentials.Resolve("https://fixture.invalid/api/mission-events", account, first), "private credential saved")) return; + if (!ERR_CommunityCheck(ERR_TelemetryCredentials.Resolve("https://fixture.invalid/api/mission-events", account, second) && first == second && first.Length() == 72, "native credential serialization preserves installation secret")) return; + ERR_CommunityStatistics result = new ERR_CommunityStatistics(); + result.schemaVersion = 1; result.serverId = account; result.missionId = UUID.GenV4(); result.eventId = UUID.GenV4(); + result.terrain = GetGame().GetWorldFile(); result.outcome = "TEST_OUTCOME"; + result.server = new ERR_CommunityMapRecord(); result.server.wins = 2; result.server.losses = 1; result.server.completed = 3; result.server.partialMissions = 0; + result.allServers = new ERR_CommunityMapRecord(); result.allServers.wins = 7; result.allServers.losses = 3; result.allServers.completed = 10; result.allServers.partialMissions = 1; + JsonSaveContext saved = new JsonSaveContext(); saved.EnableTypeDiscriminator(false); saved.WriteValue("community", result); + JsonLoadContext loaded = new JsonLoadContext(); + if (!ERR_CommunityCheck(loaded.LoadFromString(saved.SaveToString()) && loaded.ReadValue("community", m_ERRCommunityTestResult) && m_ERRCommunityTestResult.Matches(result.serverId, result.missionId, result.eventId, result.terrain, result.outcome), "native JSON roundtrip and current map/result validation")) return; + if (!ERR_CommunityCheck(!m_ERRCommunityTestResult.Matches(result.serverId, result.missionId, result.eventId, "another-map", result.outcome), "another map is rejected")) return; + ERR_PublishCommunityStatistics("Waiting for community map records..."); + // Native end-screen fixture: this does not prove an organic escape route. + m_Phase = ERR_Phase.EXTRACTION; + ERR_Phase terminal = ERR_Phase.DEFEAT; + if (result.outcome == "victory") terminal = ERR_Phase.VICTORY; + if (!ERR_CommunityCheck(Transition(terminal), "native TEST_OUTCOME screen requested")) return; + m_ERRCommunityTestStage = 1; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 2000; return; + } + if (m_ERRCommunityTestStage == 1) + { + ERR_PublishCommunityStatistics(m_ERRCommunityTestResult.Summary()); + m_ERRCommunityTestStage = 2; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 1000; return; + } + if (m_ERRCommunityTestStage == 2) + { + MenuBase menu = GetGame().GetMenuManager().GetTopMenu(); if (!menu) return; + TextWidget text = TextWidget.Cast(menu.GetRootWidget().FindAnyWidget("RoundStatistics")); + if (!ERR_CommunityCheck(text && text.IsVisible() && text.GetText() == ERR_RoundSummary() && text.GetText().Contains("All reporting servers: 7 wins | 3 failures | 70% win rate"), "open TEST_OUTCOME screen refreshes after late community result")) return; + Print("ERR STATS UI READY: community TEST_OUTCOME screen"); + System.MakeScreenshot("$logs:community-TEST_OUTCOME"); + m_ERRCommunityTestStage = 3; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 5000; return; + } + Print("ERR SMOKE COMPLETE: community TEST_OUTCOME native credentials, JSON validation and result screen refresh"); + GetGame().GetCallqueue().Remove(ERR_CommunityTest); + } +} +#endif diff --git a/docs/evidence/community-statistics-2026-10-30/results.json b/docs/evidence/community-statistics-2026-10-30/results.json new file mode 100644 index 0000000..363034b --- /dev/null +++ b/docs/evidence/community-statistics-2026-10-30/results.json @@ -0,0 +1,16 @@ +{ + "date": "2026-10-30", + "worker": {"miniflare_tests_passed": 14, "deploy_dry_run": "passed", "production_deployment": "not performed for map-summary changes"}, + "native": { + "victory": {"run": "mapwin02", "assertions_passed": 6, "result": "smoke-completed"}, + "defeat": {"run": "maploss01", "assertions_passed": 6, "result": "smoke-completed"}, + "script_or_runtime_failures": 0, + "engine_errors_per_run": 13, + "engine_errors": ["4 external localization resource GUID/name errors", "6 unknown Parent properties", "SlidingTrackMaterial", "m_HardLandingVelocity", "m_DeathLandingVelocity"], + "runner_exit_code": 1, + "scope": "Scripted local Workbench transitions and actual end-screen widget text updates. No organic escape, multiplayer replication or live Worker round trip tested. Desktop screenshot capture did not produce UI evidence.", + "revision_note": "The subsequent guard preserving accepted records when older queued uploads fail was verified by the production-method policy test; native fixtures preceded that small guard change." + }, + "game_policy_tests": ["test_telemetry.py", "test_telemetry_credentials.py", "test_community_statistics.py", "test_round_statistics.py", "test_failure_result_refresh.py"], + "publication": "Not published to Workshop" +} diff --git a/docs/telemetry.md b/docs/telemetry.md index 1e1fd7d..81cb544 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -8,15 +8,34 @@ save-before-apply behavior also apply to this setting. Telemetry runs only on the mission authority, never independently on joining clients. Workbench runs do not collect unless explicitly launched with the -`errTelemetryTest` diagnostic flag. There is no statistics website or production -receiver included in this change. +`errTelemetryTest` diagnostic flag. A deployable Cloudflare Worker/D1 receiver is +provided in [services/telemetry-worker](../services/telemetry-worker/README.md). +It includes automatic server enrollment and protected mission/player query APIs. +The Worker must be deployed separately. There is no statistics website. ## Receiving URL and configuration -The bundled receiving URL belongs in `addons/core/Configs/ERR/Telemetry.conf`. -It is currently empty pending the receiver's actual URL. Until configured, no -telemetry is collected or sent, even with the switch enabled. The menu reports -this condition rather than claiming that statistics are being delivered. +The bundled receiving URL in `addons/core/Configs/ERR/Telemetry.conf` is +`https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events`. +Any host running the updated mission can report without contacting the receiver +administrator or editing its configuration. Mission → Statistics remains the +administrator opt-out. This source change requires a new Workshop release; +0.0.125 does not contain automatic enrollment. + +Before the first request, the host saves its own randomly generated credential +in `$profile:EscapistsTelemetryCredentials.json`. The Worker enrolls an unknown +server ID on its first valid event and stores only the credential hash. Later +requests must present the same credential. Enrollment and the initial event are +committed together. Disabling telemetry prevents enrollment and delivery and +discards pending events; the credential remains for continuity when re-enabled. +No shared secret or receiver administrator credential is bundled in the mod. + +Keep the credentials file with the profile state when moving a server. Damaged +credentials suspend delivery without silently rotating them. The file retains +up to 16 server/endpoint pairs, so changing receiving URLs does not send one +receiver's secret to another. Explicit operator-issued tokens still override +automatic credentials. The default receiver accepts community reports, not +independently verified gameplay or identities. An operator can override the URL and optionally name/authenticate their instance with `$profile:EscapistsTelemetry.json`: @@ -31,8 +50,9 @@ with `$profile:EscapistsTelemetry.json`: Use the full POST endpoint, with its exact trailing-slash convention. HTTPS is required. URL credentials, fragments and query parameters are rejected. The -optional token goes in the HTTPS JSON body, never in a URL, menu snapshot, log, -Workshop resource, or persisted event queue. Configuration loads when the mission +explicit or automatically generated token goes in the HTTPS JSON body, never in +a URL, menu snapshot, log, Workshop resource, or persisted event queue. +Configuration loads when the mission starts. An empty endpoint overrides the bundled default and prevents sending. `serverId` is a generated UUID persisted in @@ -128,6 +148,23 @@ precision), not survival time. Reconnects reuse the authenticated identity. ## Reliable ingestion and aggregation +The default receiver also returns public map aggregates from `GET /api/summary` +(and `GET /api/mission-events`). `terrain` filters the actual world resource; +`serverId` adds the installation's record for that map. Summaries expose counts +only; detailed player and mission records still require a read/admin token. + +After durably accepting a victory or defeat, the Worker returns a `community` +summary containing matching map/server/mission/event/outcome identifiers plus +`server` and `allServers` records. Each record has completed rounds, wins, +failures, win rate and partial-round coverage. The mission validates these +identifiers and counts before displaying the current map's record on its result +screen. Retries never increase the totals twice. Startup failures, interrupted +sessions and empty restarts are excluded from win/failure totals. If a summary +cannot be delivered before the next mission starts, the screen reports it as +unavailable and keeps local statistics visible. +Once a confirmed record is displayed, failures uploading older queued events +cannot replace it with the unavailable message. + Return any **2xx only after durable storage**, including for an already accepted event. Other responses/timeouts retry the identical event with the same event ID. The receiver must enforce uniqueness on `(serverId, eventId)` and aggregate by @@ -154,9 +191,11 @@ Useful queries from terminal mission snapshots: - Milestone times: phase-event elapsed time differences for prison, radio and extraction. Treat missing phases, dropped events and partial runs as unknown. -Validate schema and limits and authenticate operator-issued tokens if statistics -must be trusted. The bundled addon contains no shared secret. These are reports -from community-hosted missions, not independent anti-cheat attestations. +The Worker validates schema and limits and binds each server to its private +credential after the first accepted event. Automatic enrollment proves continuity +of a claimed installation, not its operator's identity or gameplay honesty. +Operator-created server records are labelled separately. These are reports from +community-hosted missions, not independent anti-cheat attestations. ## Buffering, disabling and limits diff --git a/services/telemetry-worker/.dev.vars.example b/services/telemetry-worker/.dev.vars.example new file mode 100644 index 0000000..627514c --- /dev/null +++ b/services/telemetry-worker/.dev.vars.example @@ -0,0 +1,4 @@ +# Local development only. Replace this value before running wrangler dev. +ADMIN_TOKEN=replace-with-a-random-secret-at-least-32-characters-long +# Optional independent read-only credential; admin credentials can also read. +# READ_TOKEN=another-random-secret-at-least-32-characters-long diff --git a/services/telemetry-worker/.gitignore b/services/telemetry-worker/.gitignore new file mode 100644 index 0000000..f13dc77 --- /dev/null +++ b/services/telemetry-worker/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +dist/ +.dev.vars* +.env* +!.dev.vars.example diff --git a/services/telemetry-worker/README.md b/services/telemetry-worker/README.md new file mode 100644 index 0000000..b2a79a9 --- /dev/null +++ b/services/telemetry-worker/README.md @@ -0,0 +1,252 @@ +# Co10 Escape telemetry receiver + +Cloudflare Worker and D1 REST API for the existing [mission telemetry protocol](../../docs/telemetry.md). +The game sends one JSON event per POST to `/api/mission-events`. Any community +server running the updated mission enrolls automatically on its first valid +event. Operators do not need an account, a manually issued token, or a profile +override. Mission → Statistics remains the administrator opt-out. + +The Worker name is `co10escape-telemetry`; the bundled mod endpoint is +`https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events`. +Automatic enrollment requires a new Workshop release after 0.0.125 and the +updated Worker below. These source files alone do not change either deployment. + +## Local verification + +Requires Node.js 22 or newer and npm. From this directory: + +```sh +npm ci +npm test +npm run check +cp .dev.vars.example .dev.vars +# Replace the example ADMIN_TOKEN with a random value (32–256 characters). +npm run migrate:local +npm run dev +``` + +The tests run the Worker in Miniflare/workerd with real local D1 SQL. They cover +authentication, duplicate/concurrent retries, payload conflicts, out-of-order +snapshots, final-result preservation, actual chunked HTTP requests, limits, +legacy counters, storage rollback, server revocation, credential rotation, +automatic enrollment races, ingestion rate limits, public-summary privacy and +per-map win/failure totals across servers. +They use disposable databases and synthetic identities. `npm run check` bundles +the deployable Worker without publishing it. Miniflare is pinned to the version +bundled with Wrangler so development and test runtimes match. + +## Deploy + +The existing account already has D1 database `erri-telemetry`, bound as `DB` in +`wrangler.jsonc`. The renamed Worker reuses this database and its history. From +this directory, with Wrangler already logged in: + +```sh +npm run migrate:remote +npm run deploy +npx wrangler secret put ADMIN_TOKEN +``` + +The migration adds the automatic/managed registration label. Deploying the new +name creates `co10escape-telemetry`; secrets on `erri-telemetry` do not transfer. +Enter the privately saved admin credential at the prompt for the new Worker. +The old Worker is not deleted by these commands. After the new endpoint is +verified, the old Worker can be retired separately. + +For a fresh Cloudflare account, run `npx wrangler login`, then +`npx wrangler d1 create co10escape-telemetry`. Update the database ID and name in +`wrangler.jsonc` and run the migration/deploy commands above. Choose unused rate +limiter namespace IDs if 1001–1003 already serve another application in the account. +An independent read-only credential is optional: + +```sh +npx wrangler secret put READ_TOKEN +``` + +Use independently generated random secrets of 32–256 characters. Enter them at +Wrangler's prompt; do not put them in Git or in the addon. Before ADMIN_TOKEN is +set, administration and detailed reads fail closed. Public aggregate summaries +remain available. Public ingestion enrolls servers +independently of this admin credential. Wrangler prints the Worker URL, for example +`https://co10escape-telemetry.YOUR-SUBDOMAIN.workers.dev`. `/health` is a public liveness +check, not a database readiness test. The service does not enable CORS; a future +browser dashboard should use a backend to keep API credentials private. + +Deployment uses Cloudflare's documented [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/), +[Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/), +and [transactional D1 batches](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch). + +## Automatic community-server enrollment + +The updated addon saves a random server ID in its telemetry state and generates +an independent secret from two random UUIDs. It verifies the secret on disk in +`$profile:EscapistsTelemetryCredentials.json` before sending any request. No +receiver admin secret is included in the mod. The first valid event atomically +creates the server record and stores the event. Only the credential hash is stored +by D1. Following events and retries require that same installation credential. + +Automatic records are labelled `registration: "automatic"`; operator-created +records are labelled `"managed"`. Neither label is an anti-cheat attestation. +An automatic claim cannot replace an existing server's credential or reactivate +a revoked server. Concurrent claims with different credentials yield one owner. +Preserve the profile's state and credentials together when moving a server. +Corrupted credential storage suspends delivery; it is never silently reset. + +The Worker bounds request bodies and uses three Cloudflare rate limiters: +300 requests/minute per source, 10 new enrollment attempts/minute per source, +and 30 valid requests/minute per server credential. Source keys are hashes of +Cloudflare's connecting-IP header, used only in ephemeral limiter state. Limits +are approximate and local to each Cloudflare location, not a global storage +quota. Shared hosting addresses share source quotas; a 429 makes the game retry. +See [Cloudflare rate limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/). + +## Optional managed server credentials + +Each game server has its own `serverId` in +`$profile:EscapistsTelemetryState.json`, inside `state.serverId`. Register this +existing ID; do not replace the state file, mission IDs or queued events. + +The normal automatic path needs no operator action. The administrative API also +supports pre-registering an existing profile ID with a manually issued token. +For an already enrolled server, use token rotation instead of registering again. + +Make an authenticated request (substitute the ID read from the profile): + +```http +POST /api/servers +Authorization: Bearer ADMIN_TOKEN +Content-Type: application/json + +{"serverId":"11111111-1111-4111-8111-111111111111","name":"My Escape server"} +``` + +The 201 response contains `serverId`, `endpoint`, and a new `token`. The raw token +is returned only on registration or rotation; D1 stores its SHA-256 hash. Save the +token directly in the server's private profile configuration: + +```json +{ + "endpoint": "https://co10escape-telemetry.YOUR-SUBDOMAIN.workers.dev/api/mission-events", + "serverName": "My Escape server", + "token": "TOKEN_FROM_REGISTRATION" +} +``` + +Restrict access to that file. Restart the mission to load it. The in-game +Statistics page should report delivered events. Confirm the mission with +`GET /api/missions?serverId=SERVER_ID`. The game token is sent in the JSON envelope +as required by the existing native HTTP sender; it cannot read data, register +servers or submit events for another server ID. + +`POST /api/servers/SERVER_ID/token` rotates a credential and reactivates a revoked +server. Update the private profile with the returned token and restart the +mission. `DELETE /api/servers/SERVER_ID` revokes ingestion without deleting history. +Never distribute the administrator or read credential to game servers. + +## Map records and the results screen + +Opening `/`, `/api/summary`, or `/api/mission-events` in a browser returns public +JSON with collected win/failure totals. No token is required for these summaries. +Use `?terrain=URL_ENCODED_WORLD_RESOURCE` to restrict totals to one map, and add +`&serverId=SERVER_UUID` to include that installation's record for the same map. +Without a terrain filter, the response includes overall totals and up to 100 maps +with separate win/failure counts. The response contains counts and map resources, +not player/server names, account IDs, raw events or credentials. Full records +remain protected by admin/read credentials. + +Each summary has `schemaVersion: 1`, `terrain`, `allServers` and optional `server`. +Each record contains `completed`, `wins`, `losses`, `partialMissions`, and +`winRate` (percentage; null when there are no completed rounds). Map identity is +the actual loaded world resource, compared case-insensitively. Different worlds +are kept separate even when their generic scenario title is the same. + +The acknowledgement for an accepted victory/defeat event also includes a +`community` summary for that event's map, server, mission and event ID. It is +computed after storage and includes the just-finished round exactly once. +An equivalent retry returns updated totals without counting the round again. +A conflicting second terminal snapshot cannot claim inclusion. A failure to +read totals does not undo a successful event acknowledgement. + +The updated mission prioritizes its final result, validates the response against +the current map/server/mission/event/outcome, and replicates a short summary to +the win/failure screen. The screen refreshes if totals arrive after it opens. +It shows map-specific wins, failures and rounded win rates for this server and +all reporting servers. Local round statistics remain available when reporting is +disabled, storage/network fails, the receiver lacks summary support, or the +mission ends before delivery completes. Community totals then show unavailable; +they are not invented as zero. This feature requires the updated Workshop build. + +## API + +All responses are JSON with `Cache-Control: no-store`. Administrative endpoints +require `Authorization: Bearer ADMIN_TOKEN`. Read endpoints accept ADMIN_TOKEN or +the optional READ_TOKEN. Public summaries and `/health` do not require a token; +ingestion uses each installation's own credential. + +| Method and path | Purpose | +| --- | --- | +| `GET /health` | Public liveness check | +| `GET /`, `GET /api/summary`, `GET /api/mission-events` | Public map win/failure summaries; optional `terrain` and `serverId` filters | +| `POST /api/mission-events` | Ingest `{ "token": "...", "event": {...} }`; first valid event enrolls an unknown server | +| `POST /api/servers` | Register `{serverId, name}` and return a new token | +| `GET /api/servers` | List server labels, IDs, registration origin, creation times and active state; never token hashes | +| `POST /api/servers/:serverId/token` | Rotate token and reactivate server | +| `DELETE /api/servers/:serverId` | Revoke ingestion; retain history | +| `GET /api/missions` | Mission summaries, newest-created first | +| `GET /api/missions/:serverId/:missionId` | Authoritative selected snapshot with players, mods, settings and result | +| `GET /api/missions/:serverId/:missionId/events` | Raw event history, newest-received first | +| `GET /api/stats` | Distinct mission counts grouped by outcome, with partial-mission counts | +| `GET /api/players` | Authenticated participants' attempts, personal escapes and combat totals | + +`/api/missions`, `/api/stats` and `/api/players` accept an optional `serverId` filter. +`/api/missions` also accepts `outcome` (`victory`, `defeat`, `preparation_failed`, +`restarted_empty`, `interrupted`, or `unfinished`). Mission, server and event lists +accept `limit` (1–100, default 50) and `before` from the preceding `nextBefore`. +Player lists sort by stable account ID and use `after` from `nextAfter`, plus +`limit`. A non-null cursor means another page may exist; the last page can be empty. +Read results use integer 0/1 for SQLite-derived flags; raw events retain booleans. + +Ingestion returns 201 after a new durable event, or 200 with `duplicate: true` +after an equivalent retry. Victory/defeat acknowledgements include current +map records when available. An already used server/event key with different +content receives 409. Invalid payloads receive 400, unauthorized requests 401, +oversized bodies 413, unsupported media types 415, rate limits 429, and storage failures 503. +The current game client retries every non-2xx, including permanent errors; fix +invalid credentials/schema or inspect the queue rather than treating 4xx as +successful delivery. Request bodies are bounded while streaming, including when +there is no Content-Length. Event JSON is limited to 65,536 UTF-16 code units; +the HTTP envelope permits 264 KiB for UTF-8 expansion and the credential. + +## Storage and counting rules + +`events` retains validated event objects, keyed uniquely by `(serverId, eventId)`. +Envelope tokens, request headers and IP addresses are not stored by this code. +An SQL trigger updates `missions` in the same transaction. D1 must commit both +before the Worker returns 2xx. A failed projection rolls back the event too. + +For each `(serverId, missionId)`, the first accepted terminal snapshot wins. +Subsequent terminal events remain in the history but cannot change its result or +count again. Before completion, snapshots advance by elapsed mission time, then +event wall-clock time; late starts/restarts cannot clear a terminal result. +Reported `previousOutcome` is retained as evidence in events, never converted +into an invented result for a missing mission. Unknown endings stay unfinished. + +Player totals include authenticated, participating players from victory/defeat +snapshots only. Spectators and temporary connection IDs are excluded. A mission +victory does not imply a personal escape. Startup failures, empty restarts and +interrupted sessions do not count as gameplay wins/losses or player attempts. +An interrupted mission can still have raw combat data in its detail snapshot. + +Legacy missing counters remain null, with `missionsWithKills`, +`missionsWithFriendlyKills`, `missionsWithDeaths`, `missionsWithDowned` and +`missionsWithRecoveries` showing each total's coverage. Partial and truncated +mission counts remain visible; missing players cannot be reconstructed. Results +are community reports bound to installation credentials, not independent anti-cheat attestations. + +Raw events and final snapshots are retained indefinitely by default. Monitor D1 +storage as usage grows. Deleting raw events also deletes their deduplication keys; +do not prune them while a server may still retry old queued events. A retention +policy should preserve deduplication tombstones if raw payloads are removed. +Keep read credentials private: mission details contain player names and account +IDs. Worker request logging is disabled in the supplied configuration. Cloudflare +platform logging/settings are managed separately in the account. diff --git a/services/telemetry-worker/migrations/0001_telemetry.sql b/services/telemetry-worker/migrations/0001_telemetry.sql new file mode 100644 index 0000000..0ecdea7 --- /dev/null +++ b/services/telemetry-worker/migrations/0001_telemetry.sql @@ -0,0 +1,87 @@ +CREATE TABLE servers ( + server_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + created_at INTEGER NOT NULL +); + +CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id TEXT NOT NULL REFERENCES servers(server_id), + event_id TEXT NOT NULL, + mission_id TEXT NOT NULL, + received_at INTEGER NOT NULL, + payload_hash TEXT NOT NULL, + payload TEXT NOT NULL CHECK (json_valid(payload)), + UNIQUE (server_id, event_id) +); +CREATE INDEX events_mission ON events(server_id, mission_id, id); + +CREATE TABLE missions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id TEXT NOT NULL REFERENCES servers(server_id), + mission_id TEXT NOT NULL, + event_id TEXT NOT NULL, + terminal INTEGER NOT NULL, + outcome TEXT NOT NULL, + elapsed_seconds INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + received_at INTEGER NOT NULL, + partial INTEGER NOT NULL, + peak_players INTEGER NOT NULL, + dropped_events INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (server_id, mission_id) +); +CREATE INDEX missions_server_page ON missions(server_id, id DESC); +CREATE INDEX missions_outcome ON missions(outcome, server_id); + +-- An event and its mission projection commit together. Duplicate inserts never +-- fire this trigger. First terminal snapshot wins; late starts/heartbeats cannot +-- reopen a mission. Elapsed mission time takes precedence over wall-clock skew. +CREATE TRIGGER project_mission AFTER INSERT ON events BEGIN + INSERT INTO missions ( + server_id, mission_id, event_id, terminal, outcome, elapsed_seconds, + occurred_at, received_at, partial, peak_players, dropped_events, payload + ) VALUES ( + NEW.server_id, NEW.mission_id, NEW.event_id, + json_extract(NEW.payload, '$.type') = 'mission_finished', + json_extract(NEW.payload, '$.outcome'), + json_extract(NEW.payload, '$.elapsedSeconds'), + json_extract(NEW.payload, '$.occurredAt'), NEW.received_at, + json_extract(NEW.payload, '$.partial'), json_extract(NEW.payload, '$.peakPlayers'), + json_extract(NEW.payload, '$.droppedEvents'), NEW.payload + ) ON CONFLICT(server_id, mission_id) DO UPDATE SET + event_id = excluded.event_id, terminal = excluded.terminal, + outcome = excluded.outcome, elapsed_seconds = excluded.elapsed_seconds, + occurred_at = excluded.occurred_at, received_at = excluded.received_at, + partial = MAX(missions.partial, excluded.partial), + peak_players = MAX(missions.peak_players, excluded.peak_players), + dropped_events = MAX(missions.dropped_events, excluded.dropped_events), + payload = excluded.payload + WHERE missions.terminal = 0 AND ( + excluded.terminal = 1 OR excluded.elapsed_seconds > missions.elapsed_seconds OR + (excluded.elapsed_seconds = missions.elapsed_seconds AND excluded.occurred_at >= missions.occurred_at) + ); +END; + +-- Final snapshots alone feed rankings: spectator and temporary connection IDs +-- never become global player records. Missing legacy counters remain NULL. +CREATE VIEW final_players AS +SELECT m.server_id, m.mission_id, m.outcome, m.elapsed_seconds, + m.partial, json_extract(m.payload, '$.playersTruncated') AS players_truncated, + json_extract(p.value, '$.id') AS player_id, + json_extract(p.value, '$.name') AS name, + json_extract(p.value, '$.escaped') AS escaped, + json_extract(p.value, '$.connected') AS connected, + json_extract(p.value, '$.connectedSeconds') AS connected_seconds, + json_extract(p.value, '$.kills') AS kills, + json_extract(p.value, '$.friendlyKills') AS friendly_kills, + json_extract(p.value, '$.deaths') AS deaths, + json_extract(p.value, '$.downed') AS downed, + json_extract(p.value, '$.recoveries') AS recoveries +FROM missions m, json_each(m.payload, '$.players') p +WHERE m.terminal = 1 AND m.outcome IN ('victory', 'defeat') + AND json_extract(p.value, '$.authenticated') = 1 + AND json_extract(p.value, '$.participated') = 1; diff --git a/services/telemetry-worker/migrations/0002_automatic_enrollment.sql b/services/telemetry-worker/migrations/0002_automatic_enrollment.sql new file mode 100644 index 0000000..0f1c1db --- /dev/null +++ b/services/telemetry-worker/migrations/0002_automatic_enrollment.sql @@ -0,0 +1,3 @@ +-- Preserve existing operator-managed credentials and label public enrollment. +ALTER TABLE servers ADD COLUMN registration TEXT NOT NULL DEFAULT 'managed' + CHECK (registration IN ('managed', 'automatic')); diff --git a/services/telemetry-worker/package-lock.json b/services/telemetry-worker/package-lock.json new file mode 100644 index 0000000..eb40b19 --- /dev/null +++ b/services/telemetry-worker/package-lock.json @@ -0,0 +1,1596 @@ +{ + "name": "co10escape-telemetry", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "co10escape-telemetry", + "version": "1.0.0", + "devDependencies": { + "miniflare": "5.20261023.3-beta", + "wrangler": "4.156.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261023.1.tgz", + "integrity": "sha512-lsEx0i7nR0PNG/erUFFHmQmiidEhLfDE5uQmAL9421/y4/ycuxDDOSaxu3VGGYFFPK5sduyUeMmjIlX4CwOBFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261023.1.tgz", + "integrity": "sha512-5e79fHgabdDD8EgmjOhJ1S+xMsf6DbC7fL7IgTBizMnzOp0P2isDn7pYmUxI9AJZE9FVQ4RUilH/I36YNg+GQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261023.1.tgz", + "integrity": "sha512-VHQ6jKFSihjR2+x1buby8XTdBLLJztVnYiB7OLOTYO8aDXB+c1Rd2410PdgFuFYc4AgFEk6PCJLyCLL5L1KBnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261023.1.tgz", + "integrity": "sha512-ilpRHzfDN8YFRM5CLRKuFw40x/LJBS89yVdhdQmxehlVDuBqjKpJB++zPFbTgKRRFaNupgnXc8RHktXAIhgCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261023.1.tgz", + "integrity": "sha512-279WLVjNsMycAwr0EZNiNEf0l2SY6NUtsN80NwZzbTHzakmiN+WSx4w38muxPm+PAU0oSPPAPXKVD+H6Hya8xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20261023.3-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261023.3-beta.tgz", + "integrity": "sha512-thdXzlktu5GE1o/BlmMAPeJ0Cu6Tu5LpMbRlyfRrpVvMRrhd6p52TMqcITCZZLVzt1UO92VGBkLDhLOpryUAyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.30.1", + "workerd": "1.20261023.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20261023.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261023.1.tgz", + "integrity": "sha512-NeofJFyaYNpqKpFRJW3ApuonaerPd6/JWlfTtLDYf2A6ygqrDEFnYgEXmc0hhOxqnJsSM+kXJN6VebPojzRrsg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261023.1", + "@cloudflare/workerd-darwin-arm64": "1.20261023.1", + "@cloudflare/workerd-linux-64": "1.20261023.1", + "@cloudflare/workerd-linux-arm64": "1.20261023.1", + "@cloudflare/workerd-windows-64": "1.20261023.1" + } + }, + "node_modules/wrangler": { + "version": "4.156.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.156.0.tgz", + "integrity": "sha512-kVLAShuLiNgE3jcrmXcymDf/iPKDPrSxACe5ueugW/33HeNB9E9CkKYchudCeBwgmsnl4k+1zt4NO7vqb/fRuQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261023.3-beta", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261023.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261023.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/services/telemetry-worker/package.json b/services/telemetry-worker/package.json new file mode 100644 index 0000000..a7e4b42 --- /dev/null +++ b/services/telemetry-worker/package.json @@ -0,0 +1,21 @@ +{ + "name": "co10escape-telemetry", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test test/*.test.js", + "dev": "wrangler dev", + "check": "wrangler deploy --dry-run --outdir dist", + "deploy": "wrangler deploy", + "migrate:local": "wrangler d1 migrations apply DB --local", + "migrate:remote": "wrangler d1 migrations apply DB --remote" + }, + "devDependencies": { + "miniflare": "5.20261023.3-beta", + "wrangler": "4.156.0" + } +} diff --git a/services/telemetry-worker/src/index.js b/services/telemetry-worker/src/index.js new file mode 100644 index 0000000..fca6cd8 --- /dev/null +++ b/services/telemetry-worker/src/index.js @@ -0,0 +1,264 @@ +import {HttpError, requireValue, object, string, uuid, outcomes, validateEvent, canonical, readJson} from './validation.js'; + +const json = (value, status = 200) => new Response(JSON.stringify(value), { + status, headers: {'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}, +}); +const now = () => Math.floor(Date.now() / 1000); +async function hash(value) { + const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return [...new Uint8Array(bytes)].map(x => x.toString(16).padStart(2, '0')).join(''); +} +function equal(a, b) { + if (a.length !== b.length) return false; + let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} +function newToken() { + return [...crypto.getRandomValues(new Uint8Array(32))].map(x => x.toString(16).padStart(2, '0')).join(''); +} +async function authorize(request, env, readOnly = false) { + if (!string(env.ADMIN_TOKEN, 256, 32)) throw new HttpError(503, 'Service credentials are not configured'); + const token = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1]; + if (!string(token, 256, 32)) throw new HttpError(401, 'Unauthorized'); + const digest = await hash(token); + const admin = equal(digest, await hash(env.ADMIN_TOKEN)); + const reader = readOnly && string(env.READ_TOKEN, 256, 32) && equal(digest, await hash(env.READ_TOKEN)); + if (!admin && !reader) throw new HttpError(401, 'Unauthorized'); +} +function method(request, expected) { + if (request.method !== expected) throw new HttpError(405, `Use ${expected}`); +} +function id(value) { + requireValue(uuid(value), 'Invalid UUID'); return value.toLowerCase(); +} +function paging(url) { + const limit = url.searchParams.get('limit') ?? '50'; + const before = url.searchParams.get('before') ?? String(Number.MAX_SAFE_INTEGER); + requireValue(/^\d+$/.test(limit) && +limit >= 1 && +limit <= 100, 'limit must be 1–100'); + requireValue(/^\d+$/.test(before) && Number.isSafeInteger(+before) && +before > 0, 'Invalid before cursor'); + return {limit: +limit, before: +before}; +} +function serverFilter(url) { + const value = url.searchParams.get('serverId'); return value === null ? null : id(value); +} +async function rateLimit(binding, key) { + if (!binding) throw new HttpError(503, 'Ingestion rate limits are not configured'); + if (!(await binding.limit({key})).success) throw new HttpError(429, 'Too many requests; retry later'); +} + +// Count terminal gameplay results once per mission, scoped to the actual loaded +// terrain resource. Startup failures, interrupted sessions and empty restarts +// never become map losses. No identities or arbitrary player text leave this API. +async function mapRecords(db, terrain, serverId = null) { + const scope = terrain === null ? '' : " AND lower(json_extract(payload, '$.terrain')) = lower(?)"; + const terrainValues = terrain === null ? [] : [terrain]; + const select = `SELECT COUNT(*) AS completed, + COALESCE(SUM(outcome = 'victory'), 0) AS wins, + COALESCE(SUM(outcome = 'defeat'), 0) AS losses, + COALESCE(SUM(partial), 0) AS partialMissions + FROM missions WHERE terminal = 1 AND outcome IN ('victory', 'defeat')${scope}`; + const statements = [db.prepare(select).bind(...terrainValues)]; + if (serverId) statements.push(db.prepare(`${select} AND server_id = ?`).bind(...terrainValues, serverId)); + const results = await db.batch(statements); + const record = (row) => ({...row, winRate: row.completed ? Math.round(1000 * row.wins / row.completed) / 10 : null}); + return {schemaVersion: 1, terrain, allServers: record(results[0].results[0]), + server: serverId ? record(results[1].results[0]) : null}; +} + +async function publicSummary(request, env, url) { + method(request, 'GET'); + await rateLimit(env.REQUEST_LIMITER, 'summary:' + await hash(request.headers.get('cf-connecting-ip') ?? 'local')); + const terrain = url.searchParams.get('terrain'); + requireValue(terrain === null || string(terrain, 1024, 1), 'Invalid terrain'); + const summary = await mapRecords(env.DB, terrain, serverFilter(url)); + if (terrain === null) { + // A bounded alphabetical map list makes the browser response useful without + // knowing a resource ID. The terrain filter retrieves any map individually. + const rows = await env.DB.prepare(`SELECT lower(json_extract(payload, '$.terrain')) AS terrain, + COUNT(*) AS completed, SUM(outcome = 'victory') AS wins, SUM(outcome = 'defeat') AS losses + FROM missions WHERE terminal = 1 AND outcome IN ('victory', 'defeat') + GROUP BY lower(json_extract(payload, '$.terrain')) ORDER BY terrain LIMIT 101`).all(); + summary.maps = rows.results.slice(0, 100); + summary.mapsTruncated = rows.results.length > 100; + } + return json(summary); +} + +async function ingest(request, env) { + method(request, 'POST'); + // Cloudflare supplies this header. Hash it for ephemeral rate-limit keys; + // neither the original IP nor its hash is persisted in the telemetry database. + const source = await hash(request.headers.get('cf-connecting-ip') ?? 'local'); + await rateLimit(env.REQUEST_LIMITER, source); + const envelope = await readJson(request); + requireValue(object(envelope) && Object.keys(envelope).every(k => ['token', 'event'].includes(k)), 'Invalid envelope'); + if (!string(envelope.token, 256, 32)) throw new HttpError(401, 'Unauthorized'); + requireValue(object(envelope.event), 'Invalid event'); + const serverId = id(envelope.event.serverId); + const server = await env.DB.prepare('SELECT token_hash, active FROM servers WHERE server_id = ?').bind(serverId).first(); + // Always hash before comparison; never persist/log the incoming token. + const digest = await hash(envelope.token); + if (server && (!equal(digest, server.token_hash) || !server.active)) throw new HttpError(401, 'Unauthorized'); + const event = validateEvent(envelope.event); + const payload = canonical(event); + // Native queue bounds strings, so permit UTF-8 expansion at the HTTP boundary. + if (payload.length > 65536) throw new HttpError(413, 'Event too large'); + if (!server) await rateLimit(env.ENROLLMENT_LIMITER, source); + // Include the credential hash so guesses cannot exhaust another server's quota. + await rateLimit(env.SERVER_LIMITER, `${serverId}:${digest}`); + const payloadHash = await hash(payload); + const results = await env.DB.batch([ + env.DB.prepare(`INSERT INTO servers(server_id, name, token_hash, created_at, registration) + VALUES (?, ?, ?, ?, 'automatic') ON CONFLICT(server_id) DO NOTHING`) + .bind(serverId, event.serverName, digest, now()), + env.DB.prepare(`INSERT INTO events(server_id, event_id, mission_id, received_at, payload_hash, payload) + SELECT ?, ?, ?, ?, ?, ? FROM servers WHERE server_id = ? AND token_hash = ? AND active = 1 + ON CONFLICT(server_id, event_id) DO NOTHING RETURNING event_id`) + .bind(serverId, event.eventId, event.missionId, now(), payloadHash, payload, serverId, digest), + env.DB.prepare('SELECT payload_hash FROM events WHERE server_id = ? AND event_id = ?').bind(serverId, event.eventId), + env.DB.prepare('SELECT token_hash, active FROM servers WHERE server_id = ?').bind(serverId), + ]); + // Resolve concurrent first claims and revocations inside the same transaction. + const owner = results[3].results[0]; + if (!owner?.active || !equal(owner.token_hash, digest)) throw new HttpError(401, 'Unauthorized'); + if (results[2].results[0]?.payload_hash !== payloadHash) throw new HttpError(409, 'eventId already exists with different content'); + const duplicate = results[1].results.length === 0; + const reply = {accepted: true, duplicate, eventId: event.eventId}; + if (event.type === 'mission_finished' && ['victory', 'defeat'].includes(event.outcome)) { + // Stats are a post-commit convenience. Failure to read them must not turn a + // durably accepted event into a false storage failure or cause double credit. + try { + const selected = await env.DB.prepare('SELECT event_id FROM missions WHERE server_id = ? AND mission_id = ?') + .bind(serverId, event.missionId).first(); + if (selected?.event_id === event.eventId) { + reply.community = {...await mapRecords(env.DB, event.terrain, serverId), serverId, + missionId: event.missionId, eventId: event.eventId, outcome: event.outcome}; + } + } catch { /* Acknowledgement remains valid; the game shows unavailable totals. */ } + } + return json(reply, duplicate ? 200 : 201); +} + +async function servers(request, env, url) { + await authorize(request, env); + if (request.method === 'GET') { + const {limit, before} = paging(url); + const result = await env.DB.prepare(`SELECT rowid AS cursor, server_id AS serverId, name, active, registration, created_at AS createdAt + FROM servers WHERE rowid < ? ORDER BY rowid DESC LIMIT ?`).bind(before, limit).all(); + return json({servers: result.results, nextBefore: result.results.length === limit ? result.results.at(-1).cursor : null}); + } + method(request, 'POST'); + const body = await readJson(request, 4096); + requireValue(object(body) && Object.keys(body).every(k => ['serverId', 'name'].includes(k)), 'Invalid server'); + const serverId = id(body.serverId); + requireValue(string(body.name, 120, 1), 'name must be 1–120 characters'); + const token = newToken(); + const created = await env.DB.prepare(`INSERT INTO servers(server_id, name, token_hash, created_at) + VALUES (?, ?, ?, ?) ON CONFLICT(server_id) DO NOTHING RETURNING server_id`) + .bind(serverId, body.name, await hash(token), now()).first(); + if (!created) throw new HttpError(409, 'Server already exists; rotate its token instead'); + return json({serverId, token, endpoint: `${url.origin}/api/mission-events`}, 201); +} + +async function serverCredential(request, env, serverId, rotate) { + await authorize(request, env); + method(request, rotate ? 'POST' : 'DELETE'); + const token = rotate ? newToken() : null; + const statement = rotate + ? env.DB.prepare('UPDATE servers SET token_hash = ?, active = 1 WHERE server_id = ? RETURNING server_id').bind(await hash(token), serverId) + : env.DB.prepare('UPDATE servers SET active = 0 WHERE server_id = ? RETURNING server_id').bind(serverId); + if (!await statement.first()) throw new HttpError(404, 'Server not found'); + return json(rotate ? {serverId, token} : {serverId, active: false}); +} + +async function missions(request, env, url) { + method(request, 'GET'); await authorize(request, env, true); + const {limit, before} = paging(url), serverId = serverFilter(url); + const outcome = url.searchParams.get('outcome'); + requireValue(outcome === null || [...outcomes, 'unfinished'].includes(outcome), 'Invalid outcome filter'); + const conditions = ['id < ?'], values = [before]; + if (serverId) { conditions.push('server_id = ?'); values.push(serverId); } + if (outcome) { conditions.push('outcome = ?'); values.push(outcome === 'unfinished' ? '' : outcome); } + const rows = await env.DB.prepare(`SELECT id AS cursor, server_id AS serverId, mission_id AS missionId, + CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome, + elapsed_seconds AS elapsedSeconds, occurred_at AS occurredAt, received_at AS receivedAt, + partial, peak_players AS peakPlayers, dropped_events AS droppedEvents, + json_extract(payload, '$.terrain') AS terrain, json_extract(payload, '$.playersTruncated') AS playersTruncated + FROM missions WHERE ${conditions.join(' AND ')} ORDER BY id DESC LIMIT ?`).bind(...values, limit).all(); + return json({missions: rows.results, nextBefore: rows.results.length === limit ? rows.results.at(-1).cursor : null}); +} + +async function missionDetails(request, env, serverId, missionId, events, url) { + method(request, 'GET'); await authorize(request, env, true); + if (events) { + const {limit, before} = paging(url); + const rows = await env.DB.prepare(`SELECT id, received_at, payload FROM events + WHERE server_id = ? AND mission_id = ? AND id < ? ORDER BY id DESC LIMIT ?`) + .bind(serverId, missionId, before, limit).all(); + return json({events: rows.results.map(row => ({cursor: row.id, receivedAt: row.received_at, event: JSON.parse(row.payload)})), + nextBefore: rows.results.length === limit ? rows.results.at(-1).id : null}); + } + const row = await env.DB.prepare('SELECT received_at, payload FROM missions WHERE server_id = ? AND mission_id = ?') + .bind(serverId, missionId).first(); + if (!row) throw new HttpError(404, 'Mission not found'); + return json({receivedAt: row.received_at, event: JSON.parse(row.payload)}); +} + +async function stats(request, env, url) { + method(request, 'GET'); await authorize(request, env, true); + const serverId = serverFilter(url); + const result = await env.DB.prepare(`SELECT CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome, + COUNT(*) AS missions, SUM(partial) AS partialMissions FROM missions + ${serverId ? 'WHERE server_id = ?' : ''} GROUP BY terminal, outcome`) + .bind(...(serverId ? [serverId] : [])).all(); + return json({serverId, outcomes: result.results}); +} + +async function players(request, env, url) { + method(request, 'GET'); await authorize(request, env, true); + const serverId = serverFilter(url), {limit} = paging(url); + const after = url.searchParams.get('after'); + if (after !== null) id(after); + const conditions = [], values = []; + if (serverId) { conditions.push('server_id = ?'); values.push(serverId); } + if (after) { conditions.push('player_id > ?'); values.push(after.toLowerCase()); } + const rows = await env.DB.prepare(`SELECT player_id AS playerId, COUNT(*) AS attempts, + SUM(escaped) AS escapes, SUM(1 - escaped) AS didNotEscape, + SUM(partial) AS partialMissions, SUM(players_truncated) AS truncatedMissions, + MIN(CASE WHEN escaped = 1 THEN elapsed_seconds END) AS bestEscapeSeconds, + SUM(kills) AS kills, COUNT(kills) AS missionsWithKills, + SUM(friendly_kills) AS friendlyKills, COUNT(friendly_kills) AS missionsWithFriendlyKills, + SUM(deaths) AS deaths, COUNT(deaths) AS missionsWithDeaths, + SUM(downed) AS downed, COUNT(downed) AS missionsWithDowned, + SUM(recoveries) AS recoveries, COUNT(recoveries) AS missionsWithRecoveries + FROM final_players ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''} + GROUP BY player_id ORDER BY player_id LIMIT ?`).bind(...values, limit).all(); + return json({players: rows.results, nextAfter: rows.results.length === limit ? rows.results.at(-1).playerId : null}); +} + +export default { + async fetch(request, env) { + try { + const url = new URL(request.url), path = url.pathname; + if (path === '/health') { method(request, 'GET'); return json({ok: true, service: 'co10escape-telemetry'}); } + if (path === '/' || path === '/api/summary' || (path === '/api/mission-events' && request.method === 'GET')) { + return await publicSummary(request, env, url); + } + if (path === '/api/mission-events') return await ingest(request, env); + if (path === '/api/servers') return await servers(request, env, url); + const credential = path.match(/^\/api\/servers\/([^/]+)(\/token)?$/); + if (credential) return await serverCredential(request, env, id(credential[1]), !!credential[2]); + if (path === '/api/missions') return await missions(request, env, url); + const mission = path.match(/^\/api\/missions\/([^/]+)\/([^/]+)(\/events)?$/); + if (mission) return await missionDetails(request, env, id(mission[1]), id(mission[2]), !!mission[3], url); + if (path === '/api/stats') return await stats(request, env, url); + if (path === '/api/players') return await players(request, env, url); + throw new HttpError(404, 'Not found'); + } catch (error) { + // Database failures must never acknowledge an event. Do not log request + // bodies, player identities, SQL parameters or credentials on failure. + if (error instanceof HttpError) return json({error: error.message}, error.status); + return json({error: 'Storage temporarily unavailable'}, 503); + } + }, +}; diff --git a/services/telemetry-worker/src/validation.js b/services/telemetry-worker/src/validation.js new file mode 100644 index 0000000..1995987 --- /dev/null +++ b/services/telemetry-worker/src/validation.js @@ -0,0 +1,117 @@ +export class HttpError extends Error { + constructor(status, message) { super(message); this.status = status; } +} +export function requireValue(ok, message) { + if (!ok) throw new HttpError(400, message); +} +export function object(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +export function string(value, max, min = 0) { + return typeof value === 'string' && value.length >= min && value.length <= max; +} +export function integer(value, max = 2147483647) { + return Number.isInteger(value) && value >= 0 && value <= max; +} +export function uuid(value) { + return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) + && value !== '00000000-0000-0000-0000-000000000000'; +} +function keys(value, allowed) { + requireValue(object(value) && Object.keys(value).every(k => allowed.includes(k)), 'Unexpected object fields'); +} +const types = ['mission_started', 'mission_restarted', 'phase_changed', 'heartbeat', 'telemetry_resumed', 'mission_finished']; +export const outcomes = ['victory', 'defeat', 'preparation_failed', 'restarted_empty', 'interrupted']; +const counters = ['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries']; + +export function validateEvent(event) { + keys(event, ['schemaVersion', 'eventId', 'missionId', 'serverId', 'serverName', 'type', + 'occurredAt', 'elapsedSeconds', 'partial', 'phase', 'outcome', 'previousMissionId', + 'previousOutcome', 'terrain', 'scenario', 'gameVersion', 'mods', 'modsTruncated', + 'playersTruncated', 'connectedPlayers', 'peakPlayers', 'players', 'reason', + 'preparationError', 'settings', 'droppedEvents']); + requireValue(event.schemaVersion === 1, 'Unsupported schemaVersion'); + for (const key of ['serverId', 'missionId', 'eventId']) { + requireValue(uuid(event[key]), `Invalid ${key}`); + event[key] = event[key].toLowerCase(); + } + requireValue(types.includes(event.type), 'Invalid event type'); + requireValue(event.type === 'mission_finished' ? outcomes.includes(event.outcome) : event.outcome === '', 'Invalid event outcome'); + requireValue(integer(event.phase, 6), 'Invalid phase'); + requireValue(event.outcome !== 'victory' || event.phase === 5, 'Victory requires phase 5'); + requireValue(event.outcome !== 'defeat' || event.phase === 6, 'Defeat requires phase 6'); + requireValue(event.previousMissionId === '' || uuid(event.previousMissionId), 'Invalid previousMissionId'); + event.previousMissionId = event.previousMissionId.toLowerCase(); + requireValue(['', 'unfinished', ...outcomes].includes(event.previousOutcome), 'Invalid previousOutcome'); + for (const [key, limit] of Object.entries({serverName: 120, terrain: 1024, scenario: 512, + gameVersion: 128, reason: 4096, preparationError: 4096})) { + requireValue(string(event[key], limit), `Invalid ${key}`); + } + for (const key of ['partial', 'modsTruncated', 'playersTruncated']) { + requireValue(typeof event[key] === 'boolean', `Invalid ${key}`); + } + for (const key of ['elapsedSeconds', 'connectedPlayers', 'peakPlayers', 'droppedEvents']) { + requireValue(integer(event[key]), `Invalid ${key}`); + } + requireValue(integer(event.occurredAt, 253402300799), 'Invalid occurredAt'); + requireValue(event.peakPlayers >= event.connectedPlayers, 'peakPlayers is below connectedPlayers'); + requireValue(Array.isArray(event.players) && event.players.length <= 128, 'Invalid players'); + const ids = new Set(); + for (const p of event.players) { + keys(p, ['id', 'name', 'authenticated', 'connected', 'participated', 'escaped', 'connectedSeconds', ...counters]); + requireValue(string(p.id, 128, 1) && string(p.name, 128), 'Invalid player identity'); + for (const key of ['authenticated', 'connected', 'participated', 'escaped']) { + requireValue(typeof p[key] === 'boolean', `Invalid player ${key}`); + } + requireValue(!p.authenticated || uuid(p.id), 'Authenticated player ID must be a UUID'); + if (p.authenticated) p.id = p.id.toLowerCase(); + requireValue(!ids.has(p.id), 'Duplicate player identity'); ids.add(p.id); + requireValue(!p.escaped || (p.participated && event.outcome === 'victory'), 'Invalid escaped player'); + requireValue(integer(p.connectedSeconds), 'Invalid connectedSeconds'); + for (const key of counters) { + requireValue(p[key] === undefined || integer(p[key]), `Invalid player ${key}`); + } + } + requireValue(Array.isArray(event.mods) && event.mods.length <= 128, 'Invalid mods'); + for (const mod of event.mods) { + keys(mod, ['id', 'name', 'version']); + requireValue(string(mod.id, 128, 1) && string(mod.name, 512) && string(mod.version, 128), 'Invalid mod'); + } + requireValue(object(event.settings) && Object.keys(event.settings).length <= 512, 'Invalid settings'); + for (const [key, value] of Object.entries(event.settings)) { + requireValue(/^[a-zA-Z][a-zA-Z0-9_]{0,127}$/.test(key) && Number.isInteger(value) + && value >= -2147483648 && value <= 2147483647, 'Invalid setting'); + } + return event; +} + +// Sort object keys for idempotency even if a proxy reformats JSON. Array order +// remains meaningful. Only the event is persisted; the envelope token is not. +export function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (object(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`; + return JSON.stringify(value); +} + +export async function readJson(request, limit = 270336) { + if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') { + throw new HttpError(415, 'Content-Type must be application/json'); + } + if (Number(request.headers.get('content-length')) > limit) throw new HttpError(413, 'Request too large'); + if (!request.body) throw new HttpError(400, 'JSON body required'); + const reader = request.body.getReader(); + const chunks = []; let size = 0; + try { + while (true) { + const {done, value} = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { await reader.cancel(); throw new HttpError(413, 'Request too large'); } + chunks.push(value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + try { return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)); } + catch { throw new HttpError(400, 'Invalid JSON'); } +} diff --git a/services/telemetry-worker/test/worker.test.js b/services/telemetry-worker/test/worker.test.js new file mode 100644 index 0000000..c193a0b --- /dev/null +++ b/services/telemetry-worker/test/worker.test.js @@ -0,0 +1,286 @@ +import {after, before, test} from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile, readdir} from 'node:fs/promises'; +import {request as httpRequest} from 'node:http'; +import {Miniflare, convertV4MiniflareOptions} from 'miniflare'; +import worker from '../src/index.js'; + +const ADMIN = 'test-admin-credential-0123456789abcdef'; +const READ = 'test-read-only-credential-0123456789abcdef'; +const serverId = '11111111-1111-4111-8111-111111111111'; +const otherServer = '22222222-2222-4222-8222-222222222222'; +const playerId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +let mf, db, token, otherToken; +const auth = {authorization: `Bearer ${ADMIN}`}; +const readAuth = {authorization: `Bearer ${READ}`}; +function event(overrides = {}) { + return { + schemaVersion: 1, eventId: crypto.randomUUID(), missionId: crypto.randomUUID(), + serverId, serverName: 'Test server', type: 'mission_finished', occurredAt: 1793360000, + elapsedSeconds: 120, partial: false, phase: 5, outcome: 'victory', previousMissionId: '', + previousOutcome: '', terrain: '{1234567890ABCDEF}Worlds/Test.ent', scenario: 'Escape', + gameVersion: '1.6', mods: [{id: '2528466DE220D91C', name: 'Co10 Escape Reintroduced', version: '0.0.125'}], + modsTruncated: false, playersTruncated: false, connectedPlayers: 1, peakPlayers: 1, + players: [{id: playerId, name: 'Player', authenticated: true, connected: true, + participated: true, escaped: true, connectedSeconds: 120, kills: 2, friendlyKills: 0, + deaths: 0, downed: 1, recoveries: 1}], + reason: 'Escape complete', preparationError: '', settings: {telemetryEnabled: 1, random: -1}, + droppedEvents: 0, ...overrides, + }; +} +async function call(path, options = {}) { + return mf.dispatchFetch(`https://telemetry.example${path}`, options); +} +async function post(path, body, headers = {}) { + return call(path, {method: 'POST', headers: {'content-type': 'application/json', ...headers}, body: JSON.stringify(body)}); +} +const ingest = (e, credential = token) => post('/api/mission-events', {token: credential, event: e}); +async function detail(e) { + const response = await call(`/api/missions/${e.serverId}/${e.missionId}`, {headers: readAuth}); + assert.equal(response.status, 200); return (await response.json()).event; +} +before(async () => { + mf = new Miniflare(convertV4MiniflareOptions({host: '127.0.0.1', port: 0, workers: [{name: 'telemetry', + modules: ['index.js', 'validation.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})), + compatibilityDate: '2026-10-23', d1Databases: ['DB'], + ratelimits: { + REQUEST_LIMITER: {namespace_id: '1001', simple: {limit: 300, period: 60}}, + ENROLLMENT_LIMITER: {namespace_id: '1002', simple: {limit: 10, period: 60}}, + SERVER_LIMITER: {namespace_id: '1003', simple: {limit: 30, period: 60}}, + }, + bindings: {ADMIN_TOKEN: ADMIN, READ_TOKEN: READ}}]})); + db = await mf.getD1Database('DB'); + const migrations = new URL('../migrations/', import.meta.url); + for (const name of (await readdir(migrations)).filter(n => n.endsWith('.sql')).sort()) { + const migration = await readFile(new URL(name, migrations), 'utf8'); + // Blank-line-separated statements preserve the trigger's internal semicolons. + for (const sql of migration.split(/;\n\n/)) if (sql.trim()) await db.prepare(sql).run(); + } + for (const sid of [serverId, otherServer]) { + const response = await post('/api/servers', {serverId: sid, name: 'Test'}, auth); + assert.equal(response.status, 201, await response.clone().text()); + const body = await response.json(); + assert.equal(body.token.length, 64); + if (sid === serverId) token = body.token; else otherToken = body.token; + } +}); +after(async () => { await mf?.dispose(); }); + +test('credentials bind writes to a registered server; read-only credentials cannot administer', async () => { + assert.equal((await ingest(event(), 'x'.repeat(64))).status, 401); + assert.equal((await ingest(event({serverId: otherServer}))).status, 401); + assert.equal((await call('/api/stats')).status, 401); + assert.equal((await post('/api/servers', {serverId: crypto.randomUUID(), name: 'No'}, readAuth)).status, 401); + assert.equal((await call('/api/stats', {headers: readAuth})).status, 200); + const stored = await db.prepare('SELECT token_hash FROM servers WHERE server_id = ?').bind(serverId).first(); + assert.notEqual(stored.token_hash, token); + assert.equal((await post('/api/servers', {serverId, name: 'Duplicate'}, auth)).status, 409); +}); + +test('concurrent retries store once; reordered JSON is equivalent; conflicting content is rejected', async () => { + const e = event(); + const responses = await Promise.all(Array.from({length: 6}, () => ingest(e))); + assert.deepEqual(responses.map(r => r.status).sort(), [200, 200, 200, 200, 200, 201]); + const reordered = Object.fromEntries(Object.entries(e).reverse()); + assert.equal((await ingest(reordered)).status, 200); + assert.equal((await ingest({...e, elapsedSeconds: 999})).status, 409); + const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE event_id = ?').bind(e.eventId).first(); + assert.equal(count.n, 1); + assert.equal((await detail(e)).elapsedSeconds, 120); + const row = await db.prepare('SELECT payload FROM events WHERE event_id = ?').bind(e.eventId).first(); + assert.ok(!row.payload.includes(token)); assert.ok(!row.payload.includes('"token"')); + assert.equal((await ingest({...e, serverId: otherServer}, otherToken)).status, 201); +}); + +test('out-of-order snapshots and second terminal reports cannot reopen or double count a mission', async () => { + const e = event({players: []}); + const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100}; + assert.equal((await ingest(heartbeat)).status, 201); + assert.equal((await ingest({...heartbeat, eventId: crypto.randomUUID(), elapsedSeconds: 50, occurredAt: e.occurredAt + 500})).status, 201); + assert.equal((await detail(e)).elapsedSeconds, 100); + assert.equal((await ingest({...e, eventId: crypto.randomUUID(), occurredAt: e.occurredAt - 100})).status, 201); + for (const type of ['mission_started', 'mission_restarted', 'heartbeat', 'telemetry_resumed']) { + assert.equal((await ingest({...heartbeat, type, eventId: crypto.randomUUID(), elapsedSeconds: 1000})).status, 201); + } + assert.equal((await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6})).status, 201); + assert.equal((await detail(e)).outcome, 'victory'); + const rows = await db.prepare('SELECT COUNT(*) AS n FROM missions WHERE server_id = ? AND mission_id = ?').bind(serverId, e.missionId).first(); + assert.equal(rows.n, 1); + const log = await call(`/api/missions/${serverId}/${e.missionId}/events?limit=2`, {headers: readAuth}); + const data = await log.json(); assert.equal(data.events.length, 2); assert.ok(data.nextBefore); +}); + +test('rankings use actual authenticated participant escapes and preserve absent legacy counters', async () => { + const isolated = crypto.randomUUID(); + const credentials = await (await post('/api/servers', {serverId: isolated, name: 'Rankings'}, auth)).json(); + const e = event({serverId: isolated}); + const legacy = {...e.players[0], escaped: false}; + for (const key of ['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries']) delete legacy[key]; + const spectator = {...legacy, id: crypto.randomUUID(), participated: false}; + const anonymous = {...legacy, id: `${e.missionId}:connection:1`, authenticated: false}; + assert.equal((await ingest({...e, players: [legacy, spectator, anonymous]}, credentials.token)).status, 201); + for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) { + assert.equal((await ingest(event({serverId: isolated, outcome, phase: 0, players: [legacy]}), credentials.token)).status, 201); + } + const url = `/api/players?serverId=${isolated}`; + let result = await (await call(url, {headers: readAuth})).json(); + assert.equal(result.players.length, 1); + assert.equal(result.players[0].attempts, 1); assert.equal(result.players[0].escapes, 0); + assert.equal(result.players[0].kills, null); assert.equal(result.players[0].missionsWithKills, 0); + assert.equal((await ingest(event({serverId: isolated}), credentials.token)).status, 201); + result = await (await call(url, {headers: readAuth})).json(); + assert.equal(result.players[0].attempts, 2); assert.equal(result.players[0].escapes, 1); + assert.equal(result.players[0].kills, 2); assert.equal(result.players[0].missionsWithKills, 1); + const totals = await (await call(`/api/stats?serverId=${isolated}`, {headers: readAuth})).json(); + assert.deepEqual(Object.fromEntries(totals.outcomes.map(r => [r.outcome, r.missions])), + {victory: 2, preparation_failed: 1, restarted_empty: 1, interrupted: 1}); +}); + +test('real chunked HTTP request is accepted without Content-Length', async () => { + const origin = await mf.ready; + const body = JSON.stringify({token, event: event()}); + const response = await new Promise((resolve, reject) => { + const req = httpRequest(new URL('/api/mission-events', origin), {method: 'POST', headers: {'content-type': 'application/json'}}, res => { + let text = ''; res.on('data', chunk => { text += chunk; }); + res.on('end', () => resolve({status: res.statusCode, body: JSON.parse(text)})); + }); + req.on('error', reject); req.write(body.slice(0, 80)); req.end(body.slice(80)); + }); + assert.equal(response.status, 201); assert.equal(response.body.accepted, true); +}); + +test('schema validation and streaming limits reject malformed input without durable rows', async () => { + const e = event(); + for (const malformed of [ + {...e, schemaVersion: 2}, {...e, phase: 4}, {...e, players: [...e.players, ...e.players]}, + {...e, players: [{...e.players[0], kills: -1}]}, {...e, extraSecret: token}, + {...e, players: [{...e.players[0], participated: false}]}, + {...e, players: Array.from({length: 129}, () => e.players[0])}, + ]) assert.equal((await ingest(malformed)).status, 400); + assert.equal((await call('/api/mission-events', {method: 'POST', body: '{}'})).status, 415); + assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: '{'})).status, 400); + const stream = new ReadableStream({start(controller) { + controller.enqueue(new TextEncoder().encode(' '.repeat(270337))); controller.close(); + }}); + assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: stream, duplex: 'half'})).status, 413); + assert.equal((await call('/api/missions?limit=1000000', {headers: readAuth})).status, 400); + assert.equal((await call('/api/missions?serverId=invalid', {headers: readAuth})).status, 400); +}); + +test('failed projection rolls back the event and returns non-2xx; retry succeeds after repair', async () => { + const e = event(); + await db.prepare(`CREATE TRIGGER simulate_storage_failure BEFORE INSERT ON missions + WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run(); + assert.equal((await ingest(e)).status, 503); + assert.equal(await db.prepare('SELECT event_id FROM events WHERE event_id = ?').bind(e.eventId).first(), null); + await db.prepare('DROP TRIGGER simulate_storage_failure').run(); + assert.equal((await ingest(e)).status, 201); + const result = await worker.fetch(new Request('https://example/api/stats', {headers: auth}), {}); + assert.equal(result.status, 503); // Missing admin configuration fails closed. +}); + +test('server revocation retains history; rotation invalidates old credential and restores ingestion', async () => { + const sid = crypto.randomUUID(); + const registered = await (await post('/api/servers', {serverId: sid, name: 'Rotation'}, auth)).json(); + const e = event({serverId: sid}); + assert.equal((await ingest(e, registered.token)).status, 201); + assert.equal((await call(`/api/servers/${sid}`, {method: 'DELETE', headers: auth})).status, 200); + assert.equal((await ingest(e, registered.token)).status, 401); + assert.equal((await detail(e)).outcome, 'victory'); + const rotated = await (await call(`/api/servers/${sid}/token`, {method: 'POST', headers: auth})).json(); + assert.equal((await ingest(e, registered.token)).status, 401); + assert.equal((await ingest(e, rotated.token)).status, 200); +}); + +test('unmanaged servers enroll on first valid event and keep their own private write credential', async () => { + const e = event({serverId: crypto.randomUUID()}), credential = crypto.randomUUID() + crypto.randomUUID(); + assert.equal((await ingest({...e, schemaVersion: 99}, credential)).status, 400); + assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null); + assert.equal((await ingest(e, credential)).status, 201); + const row = await db.prepare('SELECT registration, token_hash FROM servers WHERE server_id = ?').bind(e.serverId).first(); + assert.equal(row.registration, 'automatic'); assert.notEqual(row.token_hash, credential); + assert.equal((await ingest(e, credential)).status, 200); // lost acknowledgement / restart + assert.equal((await ingest({...e, eventId: crypto.randomUUID()}, 'x'.repeat(64))).status, 401); + await call(`/api/servers/${e.serverId}`, {method: 'DELETE', headers: auth}); + assert.equal((await ingest(e, credential)).status, 401); // no automatic reactivation + assert.equal((await call('/api/servers', {headers: {authorization: `Bearer ${credential}`}})).status, 401); +}); + +test('concurrent automatic claims never let two credentials own the same server', async () => { + const e = event({serverId: crypto.randomUUID()}), a = 'a'.repeat(64), b = 'b'.repeat(64); + const replies = await Promise.all([ingest(e, a), ingest({...e, eventId: crypto.randomUUID()}, b)]); + assert.deepEqual(replies.map(r => r.status).sort(), [201, 401]); + const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE server_id = ?').bind(e.serverId).first(); + assert.equal(count.n, 1); +}); + +test('automatic claim and event roll back together when projection fails', async () => { + const e = event({serverId: crypto.randomUUID()}), credential = 'r'.repeat(64); + await db.prepare(`CREATE TRIGGER fail_new_server BEFORE INSERT ON missions + WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run(); + assert.equal((await ingest(e, credential)).status, 503); + assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null); + await db.prepare('DROP TRIGGER fail_new_server').run(); + assert.equal((await ingest(e, credential)).status, 201); +}); + +test('public rate limits reject excess traffic without storing more events', async () => { + const e = event({serverId: crypto.randomUUID()}), credential = 'q'.repeat(64); + assert.equal((await ingest(e, credential)).status, 201); + let rejected = false; + for (let i = 0; i < 35; i++) { + if ((await ingest(e, credential)).status === 429) { rejected = true; break; } + } + assert.ok(rejected, 'per-server binding must enforce its quota'); + const input = () => new Request('https://example/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, + body: JSON.stringify({token: credential, event: event({serverId: crypto.randomUUID()})})}); + const allowed = {limit: async () => ({success: true})}; + const denied = {limit: async () => ({success: false})}; + assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: allowed, ENROLLMENT_LIMITER: denied})).status, 429); + assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: denied})).status, 429); + assert.equal((await worker.fetch(input(), {DB: db})).status, 503); +}); + +test('public map summary is usable in a browser and empty maps have zero results, not an invented win rate', async () => { + const terrain = '{ABCD000000000000}Worlds/NoReports.ent'; + for (const path of ['/', '/api/summary', '/api/mission-events']) { + const response = await call(path + '?terrain=' + encodeURIComponent(terrain)); + assert.equal(response.status, 200); + const data = await response.json(); + assert.deepEqual(data.allServers, {completed: 0, wins: 0, losses: 0, partialMissions: 0, winRate: null}); + assert.equal(data.server, null); + } + assert.equal((await call('/api/players')).status, 401); + assert.equal((await call('/api/missions')).status, 401); +}); + +test('map records count victories and defeats across servers, excluding other maps and non-gameplay endings', async () => { + const terrain = '{ABCD123456789012}Worlds/MapRecord.ent'; + const e = event({terrain, serverId: otherServer}); + const first = await (await ingest(e, otherToken)).json(); + assert.equal(first.community.eventId, e.eventId); + assert.equal(first.community.terrain, terrain); + assert.deepEqual(first.community.allServers, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100}); + const defeat = event({terrain, serverId, outcome: 'defeat', phase: 6, players: [], partial: true}); + assert.equal((await ingest(defeat)).status, 201); + for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) { + assert.equal((await ingest(event({terrain, serverId: otherServer, outcome, phase: 0, players: []}), otherToken)).status, 201); + } + assert.equal((await ingest(event({terrain: 'Different-map', serverId: otherServer}), otherToken)).status, 201); + // Case differences in resource spelling do not split the same loaded map. + const params = new URLSearchParams({terrain: terrain.toLowerCase(), serverId: otherServer}); + const response = await call('/api/summary?' + params); + const summary = await response.json(); + assert.deepEqual(summary.allServers, {completed: 2, wins: 1, losses: 1, partialMissions: 1, winRate: 50}); + assert.deepEqual(summary.server, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100}); + const retry = await (await ingest(e, otherToken)).json(); + assert.equal(retry.duplicate, true); assert.equal(retry.community.allServers.completed, 2); + // A conflicting second terminal event must not claim its result was included. + const later = await (await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6, players: []}, otherToken)).json(); + assert.equal(later.community, undefined); + const text = JSON.stringify(summary); + for (const sensitive of [playerId, token, otherToken, 'Player', 'Test server', e.missionId]) assert.ok(!text.includes(sensitive)); + const maps = await (await call('/')).json(); + const row = maps.maps.find(m => m.terrain === terrain.toLowerCase()); + assert.deepEqual(row, {terrain: terrain.toLowerCase(), completed: 2, wins: 1, losses: 1}); +}); diff --git a/services/telemetry-worker/wrangler.jsonc b/services/telemetry-worker/wrangler.jsonc new file mode 100644 index 0000000..5347e85 --- /dev/null +++ b/services/telemetry-worker/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "co10escape-telemetry", + "main": "src/index.js", + "compatibility_date": "2026-10-23", + "workers_dev": true, + "d1_databases": [{ + "binding": "DB", + // Reuse the database already created in this account. + "database_name": "erri-telemetry", + "database_id": "70afec97-cb05-4024-ad57-2256c7089dc1", + "migrations_dir": "migrations" + }], + "ratelimits": [ + { "name": "REQUEST_LIMITER", "namespace_id": "1001", "simple": { "limit": 300, "period": 60 } }, + { "name": "ENROLLMENT_LIMITER", "namespace_id": "1002", "simple": { "limit": 10, "period": 60 } }, + { "name": "SERVER_LIMITER", "namespace_id": "1003", "simple": { "limit": 30, "period": 60 } } + ], + "observability": { "enabled": false } +} diff --git a/tests/test_community_statistics.py b/tests/test_community_statistics.py new file mode 100644 index 0000000..803bebf --- /dev/null +++ b/tests/test_community_statistics.py @@ -0,0 +1,78 @@ +"""Production map-response validation and delayed delivery, with engine JSON/UI doubled.""" +from policy_harness import ROOT, body, run + +base = ROOT/'addons/core/Scripts/Game/ERR/00_Core' +p = base/'ERR_CommunityStatistics.c' +valid = body(p, 'bool Valid(') +matches = body(p, 'bool Matches(').replace('server.', 'server->').replace('allServers.', 'allServers->') +callbacks = '\n'.join(body(base/'ERR_Telemetry.c', name) for name in ['protected void CommunityUnavailable(', 'protected void UpdateCommunity(']) +for a,b in [ + ('protected ', ''), ('RestCallback callback','RestCallback* callback'), ('callback.', 'callback->'), + ('JsonLoadContext sent = new JsonLoadContext()', 'Json sent'), ('JsonLoadContext reply = new JsonLoadContext()', 'Json reply'), + ('ERR_CommunityStatistics community;', 'Community* community = nullptr;'), ('community.', 'community->'), + ('m_Mode.ERR_PublishCommunityStatistics(', 'mode.Publish('), ('m_State.serverId','serverId'), + ('GetGame().GetWorldFile()', 'world'), +]: callbacks=callbacks.replace(a,b) +run(r''' +#include +#include +struct string:std::string {using std::string::string;using std::string::operator=;bool IsEmpty()const{return empty();}int Length()const{return size();}}; +struct Record {int wins=2,losses=1,completed=3,partialMissions=0; +'''+valid+r''' +}; +struct Community {int schemaVersion=1;string serverId="server",missionId="mission",eventId="final",terrain="map",outcome="victory";Record local,global;Record* server=&local;Record* allServers=&global; +'''+matches+r''' + string Summary(){return "map record";} +} result; +struct Json { + string input; + bool LoadFromString(string v){input=v;return v=="old"||v=="final"||v=="response";} + bool ReadValue(string,string& value){value=input;return true;} + bool ReadValue(string,Community*& value){value=&result;return true;} +}; +struct RestCallback {string data="response";string GetData(){return data;}}; +struct Service { + bool m_Ended=true,current=true,m_CommunityReady=false;string m_FinalEventId="final",m_FinalOutcome="victory",m_InFlight="final",serverId="server",m_MissionId="mission",world="map"; + struct Mode {int writes=0;string text;void Publish(string v){writes++;text=v;}} mode; + bool Current(){return current;} +'''+callbacks+r''' +}; +int main(){ + assert(result.Matches("server","mission","final","map","victory")); + assert(!result.Matches("other","mission","final","map","victory")); + assert(!result.Matches("server","old","final","map","victory")); + assert(!result.Matches("server","mission","old-event","map","victory")); + assert(!result.Matches("server","mission","final","another-map","victory")); + assert(!result.Matches("server","mission","final","map","defeat")); + result.schemaVersion=2;assert(!result.Matches("server","mission","final","map","victory"));result.schemaVersion=1; + result.global.wins=1;result.global.completed=2;assert(!result.Matches("server","mission","final","map","victory"));result.global=Record{}; + result.local.wins=-1;assert(!result.local.Valid());result.local=Record{}; + result.local.completed=4;assert(!result.local.Valid());result.local=Record{}; + result.local.partialMissions=4;assert(!result.local.Valid());result.local=Record{}; + Service service;RestCallback cb; + service.m_InFlight="old";service.UpdateCommunity(&cb);assert(service.mode.writes==0); + service.m_InFlight="final";service.current=false;service.UpdateCommunity(&cb);assert(service.mode.writes==0); + service.current=true;service.m_Ended=false;service.UpdateCommunity(&cb);assert(service.mode.writes==0); + service.m_Ended=true;service.UpdateCommunity(&cb);assert(service.mode.text=="map record"); + int receivedWrites=service.mode.writes;service.CommunityUnavailable();assert(service.mode.writes==receivedWrites); // an older queued upload failing cannot erase confirmed totals + service.m_CommunityReady=false; + result.terrain="other-map";service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos);result.terrain="map"; + service.UpdateCommunity(&cb);assert(service.mode.text=="map record"); // later valid retry refreshes the fallback + service.m_CommunityReady=false; + cb.data="invalid-json";service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos); + cb.data=string(8193,'x');service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos); + cb.data="response";service.m_FinalOutcome="interrupted";int before=service.mode.writes;service.UpdateCommunity(&cb);assert(service.mode.writes==before); + service.m_FinalOutcome="defeat";result.outcome="defeat";service.UpdateCommunity(&cb);assert(service.mode.text=="map record"); + service.m_CommunityReady=false; + result.local.losses=0;result.local.completed=2;service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos); +} +''') +mode=(base/'ERR_RoundStatisticsLifecycle.c').read_text() +assert '[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]\n protected string m_ERRCommunitySummary' in mode +publish=body(base/'ERR_RoundStatisticsLifecycle.c','void ERR_PublishCommunityStatistics(') +assert '!Replication.IsServer()' in publish and 'Replication.BumpMe(); ERR_OnRoundStatisticsReplicated();' in publish +assert 'm_ERRCommunitySummary + "\\n\\n" + m_ERRRoundSummary' in mode +sender=(base/'ERR_Telemetry.c').read_text() +assert 'm_State.pending.InsertAt(payload, 0);' in sender +assert 'GetGame().GetCallqueue().CallLater(Pulse, 1, false)' in sender +print('PASS: map/server/mission/event/outcome validation, bounded counters, stale delivery exclusion, current-round refresh and unavailable recovery') diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index be9f57b..65c708e 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -7,6 +7,7 @@ def cpp(v): return v rules='\n'.join(cpp(body(p,x)) for x in ['static bool Acknowledged(', 'static int RetrySeconds(', 'static string Outcome(']) methods='\n'.join(cpp(body(p,x)) for x in ['bool Enabled(', 'void Refresh(', 'protected void Purge(', 'void Finish(', 'void PhaseChanged(', 'protected void Delivered(', 'protected void Failed(', 'void Close(']) +methods=methods.replace('GetGame().GetCallqueue().CallLater(Pulse, 1, false)', 'pulses++') run(r''' #include #include @@ -34,6 +35,7 @@ struct Service { State state;State* m_State=&state;RestCallback* m_Callback=nullptr;void* m_Context=nullptr;void* m_Mode=nullptr; string m_InFlight;int m_Failures=0,m_Delivered=0,m_NextAttempt=0,samples=0,events=0,removed=0,saves=0; string type,result; + int pulses=0;void UpdateCommunity(RestCallback*){}void CommunityUnavailable(){} bool Current(){return current;} void Sample(){samples++;} void Event(string t,string o="",string p="",string prior=""){type=t;result=o;events++;} @@ -86,7 +88,7 @@ assert lifecycle.index('m_ERRTelemetry.Finish(ERR_TelemetryRules.Outcome') < lif assert '#ifdef WORKBENCH' in lifecycle and 'errTelemetryTest' in lifecycle assert 'GetRestApi' not in lifecycle assert 'm_State.pending.Count() >= 64' in s and 'payload.Length() > 65536' in s -assert s.index('if (m_Dirty && !Save()) return;') < s.index('m_Context.POST(') +assert s.index('if (m_Dirty && !Save())') < s.index('m_Context.POST(') assert 'ReadValue("token"' in s and 'WriteValue("token", m_Config.Token)' in s # Persisted event data must not contain authentication material. event=body(p,'protected void Event(') diff --git a/tests/test_telemetry_credentials.py b/tests/test_telemetry_credentials.py new file mode 100644 index 0000000..b0df3ac --- /dev/null +++ b/tests/test_telemetry_credentials.py @@ -0,0 +1,89 @@ +"""Execute production credential persistence with storage/JSON boundary doubles.""" +from policy_harness import ROOT, body, run + +p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_TelemetryCredentials.c' +methods = '\n'.join(body(p, s) for s in ['static bool Valid(', 'static bool Resolve(']) +for a, b in [ + ('out string token', 'string& token'), + ('ref array entries = {};', 'Entries entries;'), + ('ERR_TelemetryCredential entry', 'Credential* entry'), + ('ERR_TelemetryCredential known', 'Credential* known'), + ('ERR_TelemetryCredential created = new ERR_TelemetryCredential()', 'Credential* created = new Credential()'), + ('JsonLoadContext loaded = new JsonLoadContext()', 'JsonLoadContext loaded'), + ('JsonSaveContext saved = new JsonSaveContext()', 'JsonSaveContext saved'), + ('foreach (Credential* known :', 'for (Credential* known :'), + ('entry.', 'entry->'), ('known.', 'known->'), ('created.', 'created->'), + ('FileIO.', 'FileIO::'), ('UUID.', 'UUID::'), ('ERR_ProfileConfig.', 'Profile::'), +]: + methods = methods.replace(a, b) +run(r''' +#include +#include +#include +#include +struct string : std::string { + using std::string::string; using std::string::operator=; + string(std::string s):std::string(s){} bool IsEmpty() const{return empty();} int Length()const{return size();} +}; +struct Credential {string endpoint,serverId,token;}; +struct Entries:std::vector { + int Count(){return size();} void Insert(Credential* v){push_back(v);} bool operator!(){return false;} +}; +std::map files; +std::map documents; +bool failSave=false,failCopy=false,failRead=false;int generated=0,serial=0; +namespace UUID {bool IsUUID(string s){return s=="server"||s=="other";} + string GenV4(){generated++;return string(36, char('a'+generated%20));}} +namespace FileIO { + bool FileExists(string p){return files.count(p);} + bool CopyFile(string a,string b){if(failCopy)return false;files[b]=files[a];return true;} + void DeleteFile(string p){files.erase(p);} +} +namespace Profile {bool Text(string p,string& text){if(failRead||!files.count(p))return false;text=files[p];return true;}} +struct JsonLoadContext { + string raw; + bool LoadFromFile(string p){raw=files[p];return documents.count(raw);} + bool ReadValue(string,Entries& e){e=documents[raw];return true;} +}; +struct JsonSaveContext { + string raw; + void EnableTypeDiscriminator(bool){} + void WriteValue(string,Entries e){raw="document-"+std::to_string(++serial);documents[raw]=e;} + bool SaveToFile(string p){if(failSave)return false;files[p]=raw;return true;} + string SaveToString(){return raw;} +}; +struct Credentials { + static inline string PATH="credentials.json"; +'''+methods+r''' +}; +int main(){ + string token; + assert(Credentials::Resolve("https://one", "server", token)); + assert(token.Length()==72&&generated==2&&files.count(Credentials::PATH)); + string first=token;assert(!files.count(Credentials::PATH+".tmp")); + token="";assert(Credentials::Resolve("https://one", "server", token)); + assert(token==first&&generated==2); // mission restart reuses the durable secret + assert(Credentials::Resolve("https://two", "server", token)&&token!=first); + assert(Credentials::Resolve("https://one", "server", token)&&token==first); // endpoint isolation + assert(Credentials::Resolve("https://one", "other", token)&&token!=first); // identity isolation + failSave=true;assert(!Credentials::Resolve("https://save-fail", "server", token)&&token.IsEmpty());failSave=false; + failCopy=true;assert(!Credentials::Resolve("https://copy-fail", "server", token)&&token.IsEmpty());failCopy=false; + failRead=true;assert(!Credentials::Resolve("https://verify-fail", "server", token)&&token.IsEmpty());failRead=false; + assert(Credentials::Resolve("https://one", "server", token)&&token==first); + auto before=generated;files[Credentials::PATH]="corrupt"; + assert(!Credentials::Resolve("https://one", "server", token)&&token.IsEmpty()&&generated==before); + assert(files[Credentials::PATH]=="corrupt"); // preserve damaged evidence; do not silently rotate + files.clear();documents.clear(); + for(int i=0;i<16;i++)assert(Credentials::Resolve("endpoint-"+std::to_string(i),"server",token)); + assert(!Credentials::Resolve("endpoint-overflow","server",token)&&token.IsEmpty()); +} +''') +sender = p.with_name('ERR_Telemetry.c') +pulse = sender.read_text().split('protected void Pulse(', 1)[1].split('protected void Delivered(', 1)[0] +assert pulse.index('if (!Enabled()) return;') < pulse.index('ERR_TelemetryCredentials.Resolve') +assert pulse.index('if (m_Dirty && !Save())') < pulse.index('ERR_TelemetryCredentials.Resolve') < pulse.index('m_Context.POST(') +assert 'if (m_Config.Token.IsEmpty())' in pulse +assert 'Token' not in body(sender, 'protected void Event(') +assert 'Credentials' not in body(sender, 'protected void Purge(') # opt-out keeps identity, deletes queued personal data +assert 'https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events' in (ROOT/'addons/core/Configs/ERR/Telemetry.conf').read_text() +print('PASS: durable installation credential, restart/endpoint/identity isolation, write failure, corruption, bounded retention and disabled-send gates') diff --git a/tools/check.py b/tools/check.py index 70589e6..e425d0d 100644 --- a/tools/check.py +++ b/tools/check.py @@ -13,6 +13,9 @@ def check(): subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_workshop_changelog.py'],check=True) subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_discord_changelog_ci.py'],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_round_statistics.py')],check=True) + subprocess.run([sys.executable,str(ROOT/'tests/test_telemetry.py')],check=True) + subprocess.run([sys.executable,str(ROOT/'tests/test_telemetry_credentials.py')],check=True) + subprocess.run([sys.executable,str(ROOT/'tests/test_community_statistics.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_controller_init.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_empty_server_restart.py')],check=True)