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
@@ -0,0 +1,82 @@
using DeepDrftData;
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DeepDrftAPI.Controllers;
/// <summary>
/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
/// validated to make casual inflation annoying (§2.5). Both endpoints return <c>202 Accepted</c>: these
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) never reads the
/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
/// The controller is a thin HTTP boundary; all write logic lives in <see cref="IEventService"/>.
/// </summary>
[ApiController]
[Route("api/event")]
[EnableRateLimiting("events")]
public class EventController : ControllerBase
{
// Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
// payloads are a track key + an enum, well under 1 KB.
private const int MaxBodyBytes = 1024;
private readonly IEventService _eventService;
private readonly ILogger<EventController> _logger;
public EventController(IEventService eventService, ILogger<EventController> logger)
{
_eventService = eventService;
_logger = logger;
}
// POST api/event/play (unauthenticated, rate-limited)
[HttpPost("play")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
{
// Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
// already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
// keep the contract obvious and cover the empty-string key the model binder lets through.
if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
return BadRequest("trackEntryKey is required");
if (!Enum.IsDefined(payload.Bucket))
return BadRequest("bucket is invalid");
// Wave 16.1 writes no anonId — defend the substrate by dropping any the client sends early.
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId: null, ct);
if (!result.Success)
{
// A telemetry failure must never surface to the listener as an error they can act on, but
// we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
// beacon ignores the status either way.
_logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
// POST api/event/share (unauthenticated, rate-limited)
[HttpPost("share")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(payload.TargetKey))
return BadRequest("targetKey is required");
if (!Enum.IsDefined(payload.TargetType))
return BadRequest("targetType is invalid");
if (!Enum.IsDefined(payload.Channel))
return BadRequest("channel is invalid");
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId: null, ct);
if (!result.Success)
{
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
}
+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.