Class: Studio::Link
- Inherits:
-
ApplicationRecord
- Object
- ApplicationRecord
- Studio::Link
- Defined in:
- app/models/studio/link.rb
Overview
One table, one /l/kind selects the behavior; metadata (jsonb) carries
the kind-specific payload (email, return_to, target, age_attested) OFF the
wire so the URL is just the short random token.
Generalizes turf-monster's app-local MagicLink model: adds a polymorphic
linkable owner (the inviting User for referrals; left nil for a magic link
to a not-yet-existent email — that email rides in metadata) and the kind
discriminator. Replaces the engine's stateless MessageVerifier MagicLink
service for mcritchie-studio so both apps share one short-token scheme.
Like Studio::EmailDelivery, the table lives in each consumer app — installed
by bin/rails studio_engine:install:migrations, never hand-copied (a hand
copy collides with the task's own copy on class CreateStudioLinks). This
model is shipped by the gem.
Defined Under Namespace
Classes: InvalidToken, MissingTable
Class Method Summary collapse
-
.consume!(token) ⇒ Object
Find by token + burn-if-single-use in one call.
-
.create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil) ⇒ Object
A single-use sign-in/sign-up link.
-
.referral_for(linkable, target: nil) ⇒ Object
A user's referral link is stable + reusable, keyed by its landing target so sharing contest A vs B yields distinct (but each stable) links — both crediting the same inviter.
Instance Method Summary collapse
- #age_attested ⇒ Object (also: #age_attested?)
-
#burn ⇒ Object
The non-raising sibling of #consume!, and the one the click flow uses.
-
#consume! ⇒ Object
Single-use kinds (magic_link) are atomically burned: only the first caller flips consumed_at, so a replay / double-submit loses the race and is rejected.
- #consumed? ⇒ Boolean
-
#dead_status ⇒ Object
How a failed burn should be described.
-
#email ⇒ Object
--- metadata readers (sanitized on the way out) -------------------------.
- #expired? ⇒ Boolean
- #live? ⇒ Boolean
- #return_to ⇒ Object
- #single_use? ⇒ Boolean
- #target ⇒ Object
Class Method Details
.consume!(token) ⇒ Object
Find by token + burn-if-single-use in one call. Raises InvalidToken for unknown / expired / already-used links. Returns the live Link.
75 76 77 78 79 80 |
# File 'app/models/studio/link.rb', line 75 def consume!(token) link = find_by(token: token.to_s) raise InvalidToken, "unknown link" unless link link.consume! end |
.create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil) ⇒ Object
A single-use sign-in/sign-up link. The email rides in metadata (not the
URL, not a column) per the create-or-login flow — the account may not
exist yet. ttl defaults to the app's Studio.magic_link_ttl.
44 45 46 47 48 49 50 51 52 53 54 55 56 |
# File 'app/models/studio/link.rb', line 44 def create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil) ttl ||= Studio.magic_link_ttl mint!( kind: "magic_link", linkable: linkable, expires_at: ttl.from_now, metadata: { "email" => Studio::LinkToken.normalize_email(email), "return_to" => Studio::LinkToken.sanitize_path(return_to), "age_attested" => !!age_attested }.compact ) end |
.referral_for(linkable, target: nil) ⇒ Object
A user's referral link is stable + reusable, keyed by its landing target
so sharing contest A vs B yields distinct (but each stable) links — both
crediting the same inviter. target is an optional same-origin path the
referral redirects to (e.g. a specific contest).
62 63 64 65 66 67 68 69 70 71 |
# File 'app/models/studio/link.rb', line 62 def referral_for(linkable, target: nil) wanted = Studio::LinkToken.sanitize_path(target) referrals.where(linkable: linkable).live.detect { |link| link.target == wanted } || mint!( kind: "referral", linkable: linkable, expires_at: nil, metadata: { "target" => wanted }.compact ) end |
Instance Method Details
#age_attested ⇒ Object Also known as: age_attested?
192 193 194 |
# File 'app/models/studio/link.rb', line 192 def age_attested !!( && ["age_attested"]) end |
#burn ⇒ Object
The non-raising sibling of #consume!, and the one the click flow uses. Returns whether THIS caller won the burn — false for a link that was already used, has expired, or lost the atomic race to a concurrent click.
Why a boolean and not the exception: "the link was dead" is not an error here, it is one of the two normal outcomes, and the branch it feeds (Studio::LinkResolution) needs the answer as data. Reloads on a loss so the caller reads the row's settled state (consumed_at / expires_at) rather than the copy it held before the race.
143 144 145 146 147 148 149 |
# File 'app/models/studio/link.rb', line 143 def burn consume! true rescue InvalidToken reload false end |
#consume! ⇒ Object
Single-use kinds (magic_link) are atomically burned: only the first caller flips consumed_at, so a replay / double-submit loses the race and is rejected. Reusable kinds (referral) only check expiry. Returns self.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'app/models/studio/link.rb', line 119 def consume! if single_use? burned = self.class.unconsumed .where(id: id) .where("expires_at IS NULL OR expires_at > ?", Time.current) .update_all(consumed_at: Time.current) raise InvalidToken, "link already used or expired" if burned.zero? self.consumed_at = Time.current elsif expired? raise InvalidToken, "link expired" end self end |
#consumed? ⇒ Boolean
170 171 172 |
# File 'app/models/studio/link.rb', line 170 def consumed? consumed_at.present? end |
#dead_status ⇒ Object
How a failed burn should be described. Only meaningful once #burn has returned false (or on a link that was never burned at all).
153 154 155 156 157 158 159 160 |
# File 'app/models/studio/link.rb', line 153 def dead_status return :used if single_use? && consumed? return :expired if expired? # Neither flag is set but the burn did not land: a concurrent click won # it between our read and our write. Same story for the reader. :used end |
#email ⇒ Object
--- metadata readers (sanitized on the way out) -------------------------
180 181 182 |
# File 'app/models/studio/link.rb', line 180 def email && ["email"] end |
#expired? ⇒ Boolean
166 167 168 |
# File 'app/models/studio/link.rb', line 166 def expired? expires_at.present? && expires_at <= Time.current end |
#live? ⇒ Boolean
174 175 176 |
# File 'app/models/studio/link.rb', line 174 def live? !expired? && !(single_use? && consumed?) end |
#return_to ⇒ Object
184 185 186 |
# File 'app/models/studio/link.rb', line 184 def return_to Studio::LinkToken.sanitize_path( && ["return_to"]) end |
#single_use? ⇒ Boolean
162 163 164 |
# File 'app/models/studio/link.rb', line 162 def single_use? Studio::LinkToken.single_use?(kind) end |
#target ⇒ Object
188 189 190 |
# File 'app/models/studio/link.rb', line 188 def target Studio::LinkToken.sanitize_path( && ["target"]) end |