Module: Persona::AccountLink

Defined in:
lib/persona/account_link.rb

Overview

Binds a verified OIDC identity (provider + subject) to a persona.

Structurally the same event as redeeming a recovery code: the subject identifies a "holder" — the persona already bound to it, if any — and the acting browser is either adopted into that holder or has the subject linked to it. When the browser is already a different joined persona with contributions, the two are folded together via the store's merge, gated by a confirmation preview.

Callers pass the browser's agent_uid so an anonymous browser can be bound; current_user (resolved from the X-Agent-Id header or the session cookie, depending on transport) is the persona the browser is currently acting as, or nil when still anonymous.

All persistence goes through a storage port so the decision logic stays host-agnostic. The port is an object implementing:

holder_for(provider:, subject:)            -> user | nil
within_transaction { ... }                 atomic scope; yields
create_guest!(agent_uid:, user_agent:)     -> user, already bound to
                                         agent_uid, with the app's
                                         usual join side effects run
add_account_binding!(user, provider:, subject:)
agent_binding?(user, agent_uid:)           -> boolean
add_agent_binding!(user, agent_uid:, user_agent:)
merge_preview(source:, target:)            -> opaque preview object,
                                         MUST NOT change data
merge!(source:, target:)                   move all bindings and domain
                                         records from source to
                                         target, then retire source

User objects are the host's own; the only requirement is a stable #id. Consumers register their implementation via Persona.config.account_link_store (an ActiveRecord example lives in examples/ at the repository root).

Defined Under Namespace

Classes: Result

Class Method Summary collapse

Class Method Details

.holder_for(identity, store: Persona.config.account_link_store) ⇒ Object



94
95
96
# File 'lib/persona/account_link.rb', line 94

def holder_for(identity, store: Persona.config.)
  store.holder_for(provider: identity.provider, subject: identity.subject)
end

.perform(identity:, current_user:, agent_uid:, user_agent: nil, confirm_merge: false, store: Persona.config.account_link_store) ⇒ Object

Raises:



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/persona/account_link.rb', line 45

def perform(identity:, current_user:, agent_uid:, user_agent: nil, confirm_merge: false,
            store: Persona.config.)
  raise ConfigurationError, 'Persona.config.account_link_store is not configured — ' \
                            'provide an object implementing the AccountLink storage port' if store.nil?

  holder = holder_for(identity, store: store)

  # Conflict peek: the browser is joined as someone other than the
  # subject's current holder. Return the preview without touching data so
  # the user can still cancel; the client re-sends with confirm_merge.
  if current_user && holder && current_user.id != holder.id && !confirm_merge
    return Result.new(
      user: current_user,
      agent_uid: nil,
      merged: false,
      merge_preview: store.merge_preview(source: current_user, target: holder),
    )
  end

  store.within_transaction do
    # Subject not yet linked to any persona: bind it to the acting
    # persona, creating a fresh guest first if the browser is anonymous.
    if holder.nil?
      holder = current_user || store.create_guest!(agent_uid: agent_uid, user_agent: user_agent)
      store.(holder, provider: identity.provider, subject: identity.subject)
    end

    merged = false
    if current_user && current_user.id != holder.id
      store.merge!(source: current_user, target: holder)
      merged = true
    end

    # Ensure this browser's agent_uid resolves to the holder. For a joined
    # browser that was just merged, merge! already moved its binding; for
    # an anonymous browser adopting an existing holder, add it now.
    unless store.agent_binding?(holder, agent_uid: agent_uid)
      store.add_agent_binding!(holder, agent_uid: agent_uid, user_agent: user_agent)
    end

    Result.new(
      user: holder,
      agent_uid: current_user ? nil : agent_uid,
      merged: merged,
      merge_preview: nil,
    )
  end
end