Accrue.Live.Entitlements (accrue v1.2.0)

Copy Markdown View Source

on_mount/4 entitlement guard for host LiveViews (ENT-07).

Gate a LiveView (or a whole live_session) on a feature or plan by adding this hook to the on_mount: list:

live_session :paid, on_mount: [
  {MyAppWeb.UserAuth, :ensure_authenticated},
  {Accrue.Live.Entitlements, {:require_feature, :reports}}
] do
  live "/reports", ReportsLive
end

Or gate on an active plan:

{Accrue.Live.Entitlements, {:require_plan, :pro}}

On allow it returns {:cont, socket} (the resolved billable is stashed once on the socket under :accrue_billable). On deny it returns {:halt, socket} where the socket has been redirected (and, for the :forbidden path, given a generic flash error).

All decision logic — billable resolution, the fail-closed gate call, and the tiered on_deny resolution — lives in Accrue.Entitlements.Guard. This module only surface-translates the Guard's deny form into LiveView terms.

Ordering (REQUIRED — read this)

The host's authentication on_mount hook MUST run BEFORE this guard in the on_mount: list (D-20). This guard gates entitlement only, never authentication: it resolves the billable from server-side socket assigns (the default scope/user probe, or your configured billable fn). If it runs before auth has populated those assigns, the billable resolves to nil and EVERY user is denied (fail-closed, but spuriously). List your auth hook first.

Deny destination (avoid redirect loops)

The deny destination — the per-guard/config on_deny {:redirect, path} target, or the config :accrue, :entitlements, deny_path: fallback (default "/") — MUST live OUTSIDE the gated live_session (D-13). A deny target that is itself behind this guard produces an infinite redirect / a LiveView that never mounts.

Deny surface-translation (D-21)

  • {:redirect, path}redirect(socket, to: path).
  • :forbiddenput_flash(:error, …) + redirect(to: deny_path()).
  • {status, body} → degrades to the :forbidden path. A raw HTTP status + body is meaningless on a socket, so the one irreducible plug-vs-socket asymmetry collapses to the flash+redirect deny. Hosts never see this plumbing.

The deny flash is intentionally generic ("You don't have access to this page.") and NEVER names the required feature or plan (D-10).

Conditional compilation

This module is wrapped in Code.ensure_loaded?(Phoenix.LiveView) and is the ONLY always-shipped core file allowed to reference the LiveView socket runtime (D-03/D-04). Because :phoenix_live_view is a hard core dep the branch is never actually elided, but the wrapper keeps the surface refs confined to one auditable location for the Plan 06 merge gate.

Summary

Functions

on_mount(arg, params, session, socket)

@spec on_mount(
  {:require_feature, atom()} | {:require_plan, atom() | String.t()},
  map(),
  map(),
  Phoenix.LiveView.Socket.t()
) :: {:cont, Phoenix.LiveView.Socket.t()} | {:halt, Phoenix.LiveView.Socket.t()}