Phase 62 — Premium-claim recognition (consumer migration)
Phase 62 — Premium-claim recognition (consumer migration)
What changes. SDK ships the anonymous-first premium model: IUserClaims server-side seam (GetPremiumStatus / ListPremiumUsers / GrantPremium / RevokePremium) + NoOpUserClaims default + PremiumGate server-side guard helpers (requirePremium / ifPremium) + usePremium Feliz hook + /api/_platform/users/{userId}/premium operator grant/revoke endpoints + /api/_platform/users/me/premium-status open read endpoint + PremiumGranted / PremiumRevoked audit cases + FeatureFlagSourceRegistry with a PremiumOnly source consumed by FlagEvaluator.createWithSources + IUserClaimsContract portable test pack.
Conceptual overview lives in docs/platform/premium.md; this file is the consumer-side migration checklist.
Scope. Opt-in. Default ClientConfig.PremiumModel = AnonymousFirst + the default NoOpUserClaims DI registration preserve today's behaviour byte-for-byte — usePremium returns NotPremium, requirePremium denies, and writes succeed without touching any provider.
Diff to apply
Consumers gating module surfaces on premium status:
// Server-side handler — gate a premium-only endpoint:
open ToolUp.Platform.Premium
let handler =
fun next ctx -> task {
match! PremiumGate.requirePremium ctx |> Async.StartAsTask with
| Error msg ->
ctx.Response.StatusCode <- 402 // or 403, consumer choice
return! ctx.WriteTextAsync msg
| Ok () ->
// ... render premium response
}
// Client-side module — gate UI on premium:
open ToolUp.Platform.Premium.PremiumHook
[<ReactComponent>]
let MyView () =
let status, _refresh = usePremium ()
match status with
| Premium _ -> Html.div [ ... premium content ... ]
| NotPremium -> Html.div [ ... upgrade prompt ... ]
Consumers swapping in a provider-specific IUserClaims (Clerk / OIDC / custom):
// Server.fs composition — DI override:
ServerApp.empty
|> ServerApp.withServiceConfig (fun services ->
services.AddSingleton<IUserClaims>(MyClerkUserClaims(clerkApiKey)) |> ignore)
The provider impl ships as a separate companion package; this phase only delivers the seam + the NoOpUserClaims default.
Consumers composing premium gating into the feature-flag substrate:
// Server.fs composition — register PremiumOnly flag keys + use the
// premium-aware evaluator overload:
open ToolUp.Platform
let premiumGatedKeys = [
"calculator.comparative-analysis"
"viewer.history-mode"
]
let sources = FeatureFlagSourceRegistry.premiumOnly premiumGatedKeys
let evaluator =
FlagEvaluator.createWithSources
flagStore
declaredFlags
sources
userClaims
(Some logger)
Keys not registered fall through to the standard User → Team → Platform → declared default scope walk. createWithSources with FeatureFlagSourceRegistry.empty is byte-for-byte equivalent to FlagEvaluator.create. See docs/platform/premium.md "Composition with feature flags" for the three composable shapes (enabled-for-all / enabled-for-some / enabled-only-for-premium).
External IUserClaims impls (Clerk / OIDC / custom) bind the SDK's portable contract pack from their own test project:
// In your impl's test project:
open ToolUp.Platform.Tests.Contracts
let myImplTests =
let factory () = MyClerkUserClaims(testApiKey) :> IUserClaims
// `persists = true` exercises the grant + read round-trip cases.
IUserClaimsContract.tests "MyClerkUserClaims" true factory
The contract pack ships two binding factories out of the box (NoOpUserClaims non-persisting + InMemoryUserClaims persisting) in ToolUp.Platform.Tests/InProcess/UserClaimsTests.fs. Per-provider impls add a third binding without modifying the contract pack itself.
Verification
dotnet buildclean; Fable transpile clean.GET /api/_platform/users/me/premium-statuswith anonymous caller → 200 withNotPremiumbody.POST /api/_platform/users/{userId}/premiumfrom non-Platform-Admin → 403.POST /api/_platform/users/{userId}/premiumfrom Platform-Admin (defaultNoOpUserClaims) → 204; audit log showsPremiumGrantedevent under_platformscope.usePremium ()Feliz hook → returns(NotPremium, refresh)until a realIUserClaimsis wired + a logged-in user has the claim set.IUserClaimsContracttests —dotnet run --project src/ToolUp.Platform.Tests/ToolUp.Platform.Tests.fsprojruns the portable pack against the SDK-shippedNoOpUserClaims+InMemoryUserClaimsbindings (passing exit code).FlagEvaluator.createWithSourceswithFeatureFlagSourceRegistry.emptyproduces identical results toFlagEvaluator.createfor the existing FlagEvaluatorTests scenarios — premium gating is purely additive.
Rollback
Revert any premium-gated handler additions; restore module surfaces to unconditional renders. The audit cases (PremiumGranted / PremiumRevoked) are additive — no rollback needed; if no consumer invokes the endpoints, no events flow.
Consumers
The substrate is N-A for any consumer that does not layer a premium tier on top of anonymous traffic. Public-utility apps wanting to flip from anonymous-only to anonymous-first-with-operator-granted-premium adopt the seam by composing PremiumGate + usePremium + (optionally) FeatureFlagSourceRegistry.premiumOnly.
Deferred follow-up
- Clerk-specific
IUserClaimscompanion (ToolUp.AuthProviders.ClerkClaims) — writes through to Clerk's admin API. Companion's own test project will bindIUserClaimsContract.tests "ClerkUserClaims" true factoryagainst a mock HttpClient. - OIDC-specific
IUserClaimscompanion — reads atoolup.premiumcustom claim. Same contract-pack binding pattern. usePremiumauth-context-change subscription so the hook re-fetches when sign-in/out happens without a manualrefresh ()call.
These ship as separate phases / companions; this migration covers the substrate seam, the feature-flag composition surface, and the portable contract pack.