feat(entitlement): IEntitlement seam — Steam DLC answers, everything fails closed

The first half of surge day 1. One question, "may this session emit Pro output?", with
one method on the interface so nothing downstream knows which rail proved it.

SteamEntitlement reads the lifetime SKU the way Valve intends: SteamAPI.Init() then
SteamApps.BIsDlcInstalled(dlcAppId). Four different failure paths all resolve to the
free tier rather than to Pro or to a crash — no DLC id configured, no Steam client,
steam_api64.dll missing (a throw on first touch), and simply not owned. A copied folder
run without Steam is the common case, not an exceptional one.

NotConfigured() is the shipping default until Steamworks creates the DLC app; it answers
false without ever touching the native library, so today's builds behave exactly as they
did before this commit.

HasProOutput is now IsPremium || DeveloperMode || rail, with an internal EntitlementOverride
test seam (internal, because a public setter here is a licence bypass). Four tests: no id
reads false, no client reads false without throwing, the fake reports what a test sets, and
the view model takes its answer from the rail.

Steamworks.NET 2024.8.0 added; the native DLL still comes from scripts/steam-sdk.sh, so the
csproj change is three lines and no formatting churn.
This commit is contained in:
2026-11-01 08:13:25 -08:00
parent 279eb08dea
commit 6d1c982e26
10 changed files with 226 additions and 13 deletions
+7 -3
View File
@@ -97,9 +97,13 @@ version banner** — Steam is the update path, and that policy has not changed.
## 4. Entitlement wiring
- `IEntitlement` has two implementations: **Steam DLC** (lifetime, local) and **Polar** (monthly,
network + 7–14 day lease). Resolution is `Pro = steamDlc || polarValid`; anything unresolved is
the free profile.
- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today
(`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in
beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds
the Debug-only `DeveloperMode`; anything unresolved is the free profile.
- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it
answers false without touching the native library. Set the id when Steamworks creates the DLC.
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use
the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout,
no price and no payment field. Confirm the wording with Steamworks support before shipping it.
+5 -5
View File
@@ -17,11 +17,11 @@
## Next action
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Undo/redo, the rail gear and the
EDIT alignment landed early on 11-01, so day 6 has room. Day 1 unchanged: licensing —
`IEntitlement` + Steam DLC + fake, `HasProOutput` on the seam, greyed tiers, licence UI's four
states, system messages, licence ID in About. Days 2–6 in `TASKS.md` → "The surge".
No scope beyond that list. No polish before day 6.
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time:
`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`,
with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed
early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in
About. Days 2–6 in `TASKS.md` → "The surge".
## Landmines
+13
View File
@@ -0,0 +1,13 @@
namespace ytLive.Services.Entitlement;
/// <summary>
/// Test double for the entitlement rail. Production never uses it — a fake that could be reached
/// in a shipped build is a licence bypass, so it lives here only because tests need to drive both
/// answers without a Steam client or a network.
/// </summary>
public sealed class FakeEntitlement : IEntitlement
{
public FakeEntitlement(bool isPro = false) => IsPro = isPro;
public bool IsPro { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
namespace ytLive.Services.Entitlement;
/// <summary>
/// Where "is this copy licensed for Pro output?" comes from.
///
/// <para><b>One question, two sources.</b> The lifetime SKU is a Steam DLC; the monthly is a Polar
/// lease. Both answer this interface, and everything downstream — the output profile, the greyed
/// tier list, the notices — reads only this. Nothing outside <c>Services/Entitlement</c> may know
/// which rail proved it.</para>
///
/// <para><b>Fail closed.</b> An implementation that cannot reach its source, cannot load its
/// native library, or has no product id configured returns <c>false</c>. Pro is never a default,
/// never a cached "probably", and never the result of an exception being swallowed as true.</para>
/// </summary>
public interface IEntitlement
{
/// <summary>True when this session may emit above the free output budget.</summary>
bool IsPro { get; }
}
+82
View File
@@ -0,0 +1,82 @@
using Steamworks;
namespace ytLive.Services.Entitlement;
/// <summary>
/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
///
/// <para><b>This is the whole purchase story.</b> There is no key, no server and no lease — the
/// customer buys the DLC on the store page and Steam already knows. The app asks once and caches
/// the answer; a refunded DLC stops reporting ownership the next time the app starts.</para>
///
/// <para><b>⛔ Never sell the monthly plan through this.</b> Steamworks requires in-app purchases
/// to use the Steam Wallet, so anything sold from inside the app would be a policy violation. The
/// monthly lives on the website and is billed by Polar (<see cref="PolarEntitlement"/> when it
/// lands); this class only ever reads what Valve already sold.</para>
///
/// <para><b>Steam absent is normal, not exceptional.</b> A copied folder, a shortcut run without
/// the client, or a CI machine has no Steam — <see cref="SteamAPI.Init"/> returns false and the
/// app runs as the free tier. It must never refuse to start over an entitlement lookup.</para>
/// </summary>
public sealed class SteamEntitlement : IEntitlement
{
/// <summary>The DLC's own app id, not the base app's. Zero = not configured yet, which reads
/// as "no entitlement" rather than "try anyway".</summary>
private readonly uint _dlcAppId;
private bool _initAttempted;
private bool _available;
private bool? _cached;
public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId;
/// <summary>Configured with no product id — the state before the Steamworks app exists.</summary>
public static SteamEntitlement NotConfigured() => new(0);
public bool IsPro
{
get
{
if (_dlcAppId == 0) return false;
if (_cached is { } known) return known;
if (!_initAttempted)
{
_initAttempted = true;
try
{
// No steam_appid.txt and no client → false. That is the documented free path.
_available = SteamAPI.Init();
}
catch (Exception)
{
// A missing steam_api64.dll throws on first touch. Free tier, never a crash.
_available = false;
}
}
if (!_available) { _cached = false; return false; }
try
{
var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId));
_cached = owned;
return owned;
}
catch (Exception)
{
return false;
}
}
}
/// <summary>Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
/// would be wrong, so nothing here schedules it automatically.</summary>
public void Shutdown()
{
if (_available) SteamAPI.Shutdown();
_available = false;
_cached = null;
_initAttempted = false;
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
| 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) |
| 53 | ☐ | Output-format contract: pin every tier with a geometry test, prove 9:16 end-to-end, recorded file matches output | [`task-53`](TASKS/task-53-output-formats.md) |
| 54 | 🔶 | Freemium: EULA + privacy shipped; **no licence path built** — needs the entitlement seam, the output-profile cap and the licence UI below; site copy still says free forever | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
| — | ☐ | **Steamworks entitlement** — `steam_api64.dll` + `SteamAPI.Init`, `BIsDlcInstalled(dlcAppId)` behind `IEntitlement`, free profile when Steam is absent | [`commercial`](docs/ai/commercial.md) |
| — | 🔶 | **Steamworks entitlement** — `IEntitlement` + `SteamEntitlement` (Steamworks.NET) + fake **done**; needs the DLC app id from Steamworks, then the ownership path is live | [`commercial`](docs/ai/commercial.md) |
| — | ☐ | **Entitlement seam** — Polar customer-portal path for the monthly (org ID public, org token never in the app), `conditions: {channel_id}`, 7–14 day lease, `--activate` CLI. **No claim page, no server** | [`commercial`](docs/ai/commercial.md) |
| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries in the dropdown, entitlement wiring | [`commercial`](docs/ai/commercial.md) |
| — | ☐ | **Licence UI** — four states, 7/1/0-day notices, "monthly is on the website, not Steam" line, portal link, licence ID in About | [`commercial`](docs/ai/commercial.md) |
+21 -1
View File
@@ -1,4 +1,5 @@
using ytLive.Helpers;
using ytLive.Services.Entitlement;
namespace ytLive.ViewModels;
@@ -23,7 +24,26 @@ public partial class MainViewModel
/// settable inside a development build (see <see cref="DevBuild"/>), so this expression
/// reduces to <c>IsPremium</c> in a Release binary.</para>
/// </summary>
public bool HasProOutput => IsPremium || DeveloperMode;
public bool HasProOutput => IsPremium || DeveloperMode || _entitlement.IsPro;
/// <summary>
/// The entitlement rail for this session. Lifetime = Steam DLC; until the DLC app id exists it
/// is <see cref="SteamEntitlement.NotConfigured"/>, which reads false (fail closed). Tests
/// inject a fake through <see cref="EntitlementOverride"/>.
/// </summary>
private IEntitlement _entitlement = SteamEntitlement.NotConfigured();
/// <summary>Test seam: swap the rail without a Steam client. Internal — the test assembly is
/// the only caller, and a public setter here would be a licence bypass surface.</summary>
internal IEntitlement EntitlementOverride
{
get => _entitlement;
set
{
_entitlement = value;
OnPropertyChanged(nameof(HasProOutput));
}
}
private bool _developerMode;
+8 -3
View File
@@ -56,11 +56,16 @@ touches a feature. Fail closed: anything unresolved is the free tier.
### Steam (lifetime)
- **Steam owns the lifetime SKU.** `ISteamApps::BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`.
It is a local check against Steam's own state — no server, no key, no lease.
- **Steam owns the lifetime SKU.** `BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`, behind
`IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state — no
server, no key, no lease.
- **Fail closed, four ways:** no DLC app id configured, `SteamAPI.Init()` false (no client),
a thrown native load (missing `steam_api64.dll`), and "not owned" all resolve to the free tier.
None of them may crash the app or grant Pro.
- **Steam down or not running** → free profile, with a line explaining why, never a refusal to
start. A Steam build launched outside Steam is the common case for people who pin shortcuts.
- **No licence key exists for the Steam SKU**, so nothing to paste, email or lose.
- **No licence key exists for the Steam SKU**, so nothing to paste, email or lose. A refunded DLC
simply stops reporting ownership on the next start.
### Polar (monthly, sold off-Steam)
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using Xunit;
using ytLive.Services.Entitlement;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The entitlement seam (2026-11-01): one question — "may this session emit Pro output?" — with a
/// Steam DLC rail, a Polar rail to come, and a fail-closed default.
///
/// <para>The rule that must never break: **no configured rail means the free profile**. A missing
/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may
/// crash the app or quietly grant Pro.</para>
/// </summary>
public class EntitlementTests
{
[Fact]
public void No_configured_dlc_id_reads_as_no_entitlement()
{
// The state before the Steamworks app exists: pure false, no native call attempted.
Assert.False(SteamEntitlement.NotConfigured().IsPro);
}
[Fact]
public void A_steam_entitlement_without_a_client_is_the_free_tier_not_a_crash()
{
// No steam_appid.txt in the test host and no client running: Init fails, we report false.
var entitlement = new SteamEntitlement(1234567u);
Assert.False(entitlement.IsPro);
}
[Fact]
public void The_fake_reports_what_a_test_sets()
{
var fake = new FakeEntitlement(true);
Assert.True(fake.IsPro);
fake.IsPro = false;
Assert.False(fake.IsPro);
}
[Fact]
public void The_view_model_takes_its_answer_from_the_rail()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-entitlement-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
var vm = new MainViewModel();
// Default rail is NotConfigured → free output.
Assert.False(vm.HasProOutput);
vm.EntitlementOverride = new FakeEntitlement(true);
Assert.True(vm.HasProOutput);
vm.EntitlementOverride = new FakeEntitlement(false);
Assert.False(vm.HasProOutput);
}
finally
{
MainViewModel.LayoutPathOverride = null;
try { File.Delete(tempDb); } catch (IOException) { }
}
}
}
+3
View File
@@ -175,6 +175,9 @@
<PackageReference Include="NAudio.WinMM" Version="2.2.1" />
<PackageReference Include="Notification.Wpf" Version="11.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
<!-- Steam's managed wrapper. The native steam_api64.dll comes from the SDK via
scripts/steam-sdk.sh; this package only provides the C# surface (SteamAPI, SteamApps). -->
<PackageReference Include="Steamworks.NET" Version="2024.8.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
</ItemGroup>