# `AttestoPhoenix.Store.EctoRefreshStore`
[🔗](https://github.com/XukuLLC/attesto_phoenix/blob/v0.19.0/lib/attesto_phoenix/store/ecto_refresh_store.ex#L1)

Ecto implementation of the `Attesto.RefreshStore` behaviour.

The protocol core (`Attesto.RefreshToken`) owns all rotation logic and reuse
detection; this module is purely the storage seam. It persists refresh-token
records over `AttestoPhoenix.Schema.RefreshToken` and provides the atomic
single-use claim on which reuse detection depends (RFC 6749 §10.4, OAuth 2.0
Security BCP §4.13).

## Why the claim must be atomic

Rotation requires detecting when an already-rotated (consumed) token is
presented again: that is the captured-token signal, and the whole family
must then be revoked. Reliable detection needs a compare-and-set that, in
one indivisible step, checks the token is unconsumed and marks it consumed.
Here that is a single conditional `UPDATE ... RETURNING`:

    UPDATE attesto_refresh_tokens
       SET consumed = true, consumed_at = now()
     WHERE token_hash = $1 AND consumed = false
    RETURNING ...

Zero rows updated *with a row still present* means the token was already
consumed: reuse. A non-atomic read-then-write would let two concurrent
rotations both observe "unconsumed" and both succeed, defeating detection.
This holds across all nodes sharing the database, which the single-node ETS
store cannot offer. `consume/1` returns `{:ok, entry}` to the single winner,
`{:reuse, entry}` on a replay, and `:error` for an unknown token.

## Sticky revocation

`revoke_family/1` marks every row in the family revoked (it does not delete
them) so the revocation persists. A subsequent `insert/1` checks the family
before writing and refuses with `{:error, :family_revoked}`, so a successor
whose claim won before the revocation landed cannot be added to a revoked
family. Revocation therefore rejects later inserts, not only the rows present
when it ran. The check and the insert run in one transaction so no concurrent
revocation can interleave between them.

The repo is resolved from the application environment (`:repo` under the
`:attesto_phoenix` app) so the host owns the connection; nothing here
hardcodes an OTP app's repo, and a missing repo fails closed.

# `consume`

```elixir
@spec consume(
  Attesto.RefreshStore.token_hash(),
  keyword()
) ::
  {:ok, Attesto.RefreshStore.entry()}
  | {:reuse, Attesto.RefreshStore.entry()}
  | :error
```

Atomically marks the token consumed if it was not already.

Returns `{:ok, entry}` to the single caller that wins the claim (the record
is reported as it stood, unconsumed, since the successor is minted from it),
`{:reuse, entry}` when the token was already consumed (the caller MUST then
`revoke_family/1`; the entry carries the `:family_id`), or `:error` for an
unknown token. The conditional `UPDATE ... WHERE consumed = false RETURNING`
is one indivisible statement, so concurrent rotations cannot both win.

# `get`

```elixir
@spec get(Attesto.RefreshStore.token_hash()) ::
  {:ok, Attesto.RefreshStore.entry()} | :error
```

Non-consuming read of the record for `token_hash`, or `:error` if absent or
family-revoked.

Returns the record in the `Attesto.RefreshStore` contract shape (opaque
`:data` context, `:expires_at` as absolute unix seconds). Used by
`Attesto.RefreshToken` to validate a rotation (expiry, client and DPoP
binding) and to detect an already-consumed replay before the atomic claim,
so a recoverable validation failure does not burn the token.

# `insert`

```elixir
@spec insert(Attesto.RefreshStore.entry()) :: :ok | {:error, :family_revoked}
```

Persists a new (unconsumed) refresh-token record.

Returns `{:error, :family_revoked}` when the record's `:family_id` has
already been revoked, and the row is NOT written. The revocation check and
the insert run in one transaction holding a per-family advisory lock (shared
with `revoke_family/1`), so a concurrent revocation cannot interleave and
leave a live successor in a revoked family (sticky revocation, RFC 6749
§10.4). A plain `FOR UPDATE` on the existing rows would not suffice: under
`READ COMMITTED` a revoking `UPDATE` that began before this insert committed
would not see the just-inserted successor (a phantom), leaving it live. The
advisory lock serializes the two operations outright, so a revocation that
loses the race still runs its `UPDATE` on a fresh snapshot that includes the
new row. The opaque store record is flattened onto the schema columns by
`AttestoPhoenix.Schema.RefreshToken.from_store_record/2`.

# `remember_successor`

```elixir
@spec remember_successor(Attesto.RefreshStore.token_hash(), map(), keyword()) ::
  :ok | :error
```

Records the successor minted by a consumed parent token.

The core uses this for refresh-rotation idempotency: an immediate retry of a
just-rotated token by the same client can receive the same successor rather
than revoking the family. Only consumed parents accept a successor marker.
The marker is encrypted before it is written to the database; if no
`:refresh_successor_secret` is configured, the store fails closed by returning
`:error`.

# `revoke_family`

```elixir
@spec revoke_family(Attesto.RefreshStore.family_id()) :: :ok
```

Revokes a token family: marks every token in `family_id` revoked.

The rows are kept (their `:family_revoked` flag is set) rather than deleted,
so the revocation is sticky: a successor `insert/1` serialized after this call
is refused (see `insert/1`). The revocation runs in a transaction holding the
same per-family advisory lock as `insert/1`, so a concurrent successor insert
cannot slip a live row past it: a revocation that wins the lock is seen by the
later insert (refused); one that loses runs its `UPDATE` after the insert has
committed, on a fresh snapshot that includes the new row. Idempotent:
re-revoking is a no-op re-set, and revoking an unknown family updates nothing
and returns `:ok`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
