Core-side runtime bridge to the optional phoenix_kit_referrals package.
The referral-codes feature lives in the standalone phoenix_kit_referrals
module. Core has no compile-time dependency on it — this facade resolves
the installed module at runtime by its PhoenixKit.Module key ("referrals")
via PhoenixKit.ModuleRegistry and dispatches through it.
When the package isn't installed (or doesn't export a given function) every
call degrades safely: the system reads as disabled, lookups return nil, and
use_code/2 is a no-op. That lets the registration / OAuth / magic-link flows
treat referrals as optional — with the module absent, the referral field never
appears and nothing is recorded.
The function surface here mirrors exactly what the signup flows call, so those call sites only had to swap their alias to this module.
Invite-only access gate
With referral_codes_required on, a referral code is supposed to be the only
way in. Enforcing that at each creation path does not work: OAuth and
magic-link signups can be started from URLs that never pass through the
registration form, and blocking them there produces dead ends rather than
admission control.
So enforcement is a post-signup access gate. An account may be created by
any means; until it is satisfied it can reach nothing but
/users/referral (where a code admits it) and log-out. access_satisfied?/1
is the single predicate, called from the same choke points that enforce email
confirmation — see PhoenixKitWeb.Users.Auth.
An account is satisfied when any of these holds:
- It has
custom_fields["referral_satisfied_at"]— written bymark_satisfied/2when a code is used at signup, entered on the parked screen, or accepted as part of an organization invitation. - It was created before invite-only took effect and
referral_grandfather_existingis on (the default). See below. - It holds every enabled permission (
Scope.holds_all_enabled_permissions?/1). This is a hard exemption, independent of the grandfather setting — an operator who turns grandfathering off on an install whose codes are all spent would otherwise lock themselves out with no way back in. It is keyed on permissions rather than a role name because that is what the rest of the admin gate uses, and Owner satisfies it by construction. - A pending, unexpired organization invitation is addressed to its email. Invitations are email-bound, so this makes org admins de-facto admission issuers on purpose — without it the "you were invited" banner on the registration page is a dead end under invite-only.
Why not confirmed_at
The satisfied flag is deliberately independent of email confirmation. OAuth
auto-confirms (PhoenixKit.Users.OAuth), so a gate keyed on confirmed_at
would open for exactly the path it most needs to close.
Grandfathering
referral_required_enabled_at records when invite-only took effect, and users
created before it are grandfathered while referral_grandfather_existing
(default true) is on. Core stamps and clears that setting itself, from
access_required?/0, rather than relying on the referrals admin toggle — that
way it stays correct whoever flips the switch, including an older
phoenix_kit_referrals that knows nothing about the gate. Turning invite-only
off clears the stamp, so switching it on again grandfathers everyone admitted
in between.
If the package is uninstalled
The gate requires enabled?/0, which goes through the installed module. With
the package gone there is no way to validate a code, so gating on a
left-behind referral_codes_required row would park every non-exempt user
permanently.
Summary
Functions
Whether invite-only enforcement is currently active.
Whether this account may use the application, or must first enter a code.
Whether a referrals module is installed and enabled.
Whether the given code is expired.
Look up a referral code struct by its string, or nil.
Referral-codes configuration map. Disabled defaults when the module is absent.
Records that an account has satisfied invite-only, and why.
Whether this account has already been marked as satisfying invite-only.
One sweep's worth of rows, before the per-account authority check.
Deactivates accounts that were created under invite-only and never satisfied it, so "registered" keeps meaning "admitted".
Records a signup's use of a referral code and marks the account admitted.
Claims one use of code_string for user and marks the account admitted,
atomically.
The custom_fields key recording that an account satisfied invite-only.
How long an unadmitted account is left alone before the janitor deactivates
it, in days. 0 (the default) means never.
Whether the given code hit its usage limit. Fails closed — see expired?/1.
Record a use of code_string by user_uuid.
Validates a referral code for a signup attempt.
Functions
@spec access_required?() :: boolean()
Whether invite-only enforcement is currently active.
Also keeps referral_required_enabled_at in step with the answer, so the
grandfather boundary always describes the current invite-only period.
@spec access_satisfied?( PhoenixKit.Users.Auth.Scope.t() | PhoenixKit.Users.Auth.User.t() | nil ) :: boolean()
Whether this account may use the application, or must first enter a code.
Accepts a Scope or a User; anything else (including nil) is satisfied,
because deciding admission for a visitor with no account is the
authentication gate's job, not this one.
The User form exists for the older user-based on_mount hook, which has no
scope. It has to build one to evaluate the full-access exemption, so it is
the more expensive form — but only for an account that is not already
satisfied on cheaper grounds.
Whether a referrals module is installed and enabled.
Whether the given code is expired.
⚠️ Answers true when nothing can answer. These predicates gate admission
under invite-only, so "we could not check" has to mean "do not admit" — the
old false default let a code past an expiry check that never ran.
Look up a referral code struct by its string, or nil.
Referral-codes configuration map. Disabled defaults when the module is absent.
@spec mark_satisfied(PhoenixKit.Users.Auth.User.t(), String.t()) :: {:ok, PhoenixKit.Users.Auth.User.t()} | {:error, term()}
Records that an account has satisfied invite-only, and why.
Idempotent: an already-marked account keeps its original timestamp, so a
second call cannot rewrite when admission happened. reason is stored
alongside for operators auditing how an account got in.
@spec marked_satisfied?(PhoenixKit.Users.Auth.User.t() | nil) :: boolean()
Whether this account has already been marked as satisfying invite-only.
@spec prune_candidates(pos_integer()) :: [PhoenixKit.Users.Auth.User.t()]
One sweep's worth of rows, before the per-account authority check.
Separated from prune_unadmitted/1 so the grandfather exclusion can be
asserted directly. Testing it through the sweep would take
500 accounts to demonstrate — the starvation it prevents
only appears once exempt rows can fill the batch — and a suite that registers
five hundred users to prove one WHERE clause is a suite nobody runs.
@spec prune_unadmitted(non_neg_integer()) :: {:ok, non_neg_integer()}
Deactivates accounts that were created under invite-only and never satisfied it, so "registered" keeps meaning "admitted".
Deactivates; never deletes. Users are the foreign-key target of most of the ecosystem, and an account that was merely slow to enter its code is one an operator will want back.
Nothing happens unless invite-only is on AND days is positive — an install
that turned invite-only off should not have the janitor sweeping up the
accounts it just legitimised. Candidates are re-checked one by one through
access_satisfied?/1 rather than trusted from the query, because the
exemptions that matter most (full permissions, a pending invitation) are the
ones SQL cannot see.
Returns {:ok, count}.
@spec record_signup_use(PhoenixKit.Users.Auth.User.t(), String.t() | map() | nil) :: :ok
Records a signup's use of a referral code and marks the account admitted.
The two halves belong together. use_code/2 on its own leaves an account
that supplied a perfectly good code sitting behind the invite-only gate —
the exact opposite of what supplying it meant — and every signup path had
its own copy of the first half only.
Accepts the code as a string or as an already-resolved code struct; nil
(no code supplied) is a no-op. Marking happens even when invite-only is
currently off, so an operator who turns it on later does not park the people
who did bring a code.
@spec redeem(PhoenixKit.Users.Auth.User.t(), String.t()) :: {:ok, PhoenixKit.Users.Auth.User.t()} | {:error, term()}
Claims one use of code_string for user and marks the account admitted,
atomically.
The two writes have to succeed or fail together. Claiming without marking burns a use of a possibly single-use code and leaves the user still parked, with the same code now rejecting them; marking without claiming lets a code past its limit admit everyone who raced for it.
Returns {:ok, user} or {:error, reason}.
The custom_fields key recording that an account satisfied invite-only.
@spec unadmitted_retention_days() :: non_neg_integer()
How long an unadmitted account is left alone before the janitor deactivates
it, in days. 0 (the default) means never.
Deliberately opt-in. The alternative — a default that silently starts deactivating accounts as soon as an operator switches invite-only on — is the kind of destructive default that has to be asked for, not inherited.
Whether the given code hit its usage limit. Fails closed — see expired?/1.
Record a use of code_string by user_uuid.
No-op returning {:error, :referrals_not_installed} when the module is absent.
Validates a referral code for a signup attempt.
Shared by every signup surface so the rules cannot drift apart — the password and magic-link forms previously carried byte-identical private copies.
Options
:enabled?/:required?— fromget_config/0(required):context—:changewhile the user types,:submiton the final attempt:ip_address— used to rate-limit code checking; omit and no limit applies
Why :context matters
On :change, nothing is rejected. Validation runs on every keystroke
anywhere in the form, so treating blank-and-required as invalid made "Referral
code is required" appear while the user was still typing their email — before
they had reached the field. :submit enforces presence.
A typed code is not checked on change either, which reverses an earlier decision that a touched field was fair to complain about. Two reasons pointing the same way: checking per keystroke hands an attacker a far faster oracle than the submit button does, and it spends one rate-limit token per character, so a real person typing an eight-character code and fixing a typo can exhaust their own budget while holding a good code.
Why rejections are indistinguishable
Every failure returns the same message. Separate strings for missing / inactive / expired / limit-reached confirmed which guesses named a real code. The specific reason is logged at debug level for operators.
Returns {:ok, code_or_nil} or {:error, message}.