feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)

Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
This commit is contained in:
daniel-c-harvey
2026-06-19 12:59:00 -04:00
parent 1931574ad4
commit dbd90ee52a
35 changed files with 2460 additions and 2 deletions
+34
View File
@@ -8,8 +8,10 @@ using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using NetBlocks.Utilities.Environment;
using System.Threading.RateLimiting;
// Required credential files — must exist before the app will start.
// Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
@@ -64,6 +66,14 @@ builder.Services
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
builder.Services.AddScoped<UnifiedTrackService>();
// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
// release-resolution + counter-bump transaction.
builder.Services
.AddScoped<EventRepository>()
.AddScoped<EventManager>()
.AddScoped<IEventService>(sp => sp.GetRequiredService<EventManager>());
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
builder.Services
@@ -118,6 +128,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
options.KnownProxies.Clear();
});
// Per-IP rate limiting for the anonymous telemetry intake (Phase 16 §2.5). Coarse and stateless —
// a fixed window keyed by the (forwarded) remote IP. The substrate sits behind nginx, so the real
// client IP is the X-Forwarded-For value UseForwardedHeaders resolves into Connection.RemoteIpAddress.
// On limit, reject with 429 (the beacon ignores it; this only blunts casual inflation). The 30-window
// budget is generous for a real listening session and only bites on scripted spam.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("events", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
var app = builder.Build();
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
@@ -136,6 +165,11 @@ if (app.Environment.IsDevelopment())
app.UseCors("ContentApiPolicy");
// Rate limiter must sit in the pipeline for the [EnableRateLimiting("events")] attribute on
// EventController to take effect. Only the telemetry endpoints carry the policy; everything else is
// unaffected (no global limiter is set).
app.UseRateLimiter();
// ApiKey middleware only enforces on endpoints tagged [ApiKeyAuthorize] (the track surface); it
// passes all other endpoints through. JWT auth/authorization gate the AuthBlocks endpoints, which
// carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.