# AshOpenFeed

An [Ash](https://ash-hq.org) extension for [OpenFeed](https://openfeed.au) —
Australian Consumer Data Right banking and energy data.

Built on the `openfeed` package, which handles the FAPI 2.0 Security Profile
(PAR, PKCE, DPoP) and key management. This package adds the Ash half: grant
persistence, token refresh, and an installer that generates the consent flow.

## Requirements

- **Elixir 1.17 or later**
- **OTP 27 or later** — this is a hard floor, inherited from
  [`oidcc`](https://hex.pm/packages/oidcc), whose Erlang source does not compile
  on OTP 26. Both packages check for it and fail with a clear message rather than
  letting you hit an unexplained dependency compile error.

Tested in CI against Elixir 1.17, 1.18, 1.19 and 1.20.

## Installation

```sh
mix igniter.install ash_openfeed
```

That generates an Ash domain, a grant resource, a signing-key resource, a
consent controller and its routes, then tells you the remaining steps.

Prefer to wire it up yourself? Add the dep and read on.

```elixir
def deps do
  [{:ash_openfeed, "~> 0.1"}]
end
```

## The extension goes on *your* resource

```elixir
defmodule MyApp.OpenFeed.Grant do
  use Ash.Resource,
    domain: MyApp.OpenFeed,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshOpenFeed.Grant]

  openfeed do
    otp_app :my_app
    key_store {AshOpenFeed.KeyStore.Ash, resource: MyApp.OpenFeed.Key}
    grant_management? true
    extra_accept [:user_id]
  end

  postgres do
    table "openfeed_grants"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    timestamps()
  end

  relationships do
    belongs_to :user, MyApp.Accounts.User
  end
end
```

The extension injects the token attributes, a unique identity on `grant_id`, and
the lifecycle actions. It does **not** ship its own resource, so that you can
add your own relationships, policies, multitenancy and encryption. Everything is
added with Ash's `add_new_*` builders — anything you declare yourself wins.

Credentials go in `runtime.exs`, where secrets belong:

```elixir
config :my_app, :openfeed,
  client_id: System.fetch_env!("OPENFEED_CLIENT_ID"),
  redirect_uri: "https://my.app/openfeed/callback"
```

If credentials vary per tenant, implement `AshOpenFeed.ConfigProvider` and set
`config_provider` instead.

## Using it

```elixir
# 1. Start a consent flow. Keep `flow` in the session.
flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.authorize_url(MyApp.OpenFeed.Grant, flow)

# 2. At the callback, having checked `state` matches.
{:ok, grant} =
  AshOpenFeed.complete_authorization(MyApp.OpenFeed.Grant, code, flow,
    attributes: %{user_id: current_user.id}
  )

# 3. Read data. The token is refreshed first if it is near expiry.
{:ok, accounts} =
  AshOpenFeed.with_token(grant, &OpenFeed.Sharing.banking_accounts(&1, &2))

# 4. Let the consumer disconnect.
{:ok, grant} = AshOpenFeed.revoke(grant)
```

`with_token/3` also reconciles local state from what the API said: a revoked
grant gets marked revoked, exhausted credit gets marked suspended. The next
caller does not have to repeat a request that is guaranteed to fail.

## Collecting data into your own resources

Step 3 above gets you one endpoint. A real sync walks the tree and stores it.
This library deliberately does not model CDR data for you — how much history to
keep and how to shape it is an application decision — so the loop is yours:

```elixir
def sync(grant) do
  AshOpenFeed.with_token(grant, fn config, tokens ->
    {:ok, accounts} = OpenFeed.Sharing.banking_accounts(config, tokens)

    for account <- accounts do
      {:ok, stored} =
        MyApp.Banking.upsert_account(%{
          grant_id: grant.id,
          account_id: account["accountId"],
          display_name: account["displayName"],
          provider_name: account["providerName"]
        })

      {:ok, transactions} =
        OpenFeed.Sharing.banking_transactions(config, tokens, account["accountId"],
          oldest_date: oldest_date(grant)
        )

      for tx <- transactions do
        MyApp.Banking.upsert_transaction(%{
          account_id: stored.id,
          transaction_id: tx["transactionId"],
          # Banking amounts are ISO 20022 strings, energy amounts are JSON
          # numbers. OpenFeed.Amount takes either.
          amount: OpenFeed.Amount.to_decimal(tx["amount"]),
          description: tx["description"],
          transaction_date: Date.from_iso8601!(tx["transactionDate"])
        })
      end
    end
  end)
end

# Re-read a week before the last sync, so late-posted transactions land. Upserting
# on the provider's transactionId makes the overlap free of duplicates.
defp oldest_date(%{last_synced_at: nil}), do: Date.add(Date.utc_today(), -365)
defp oldest_date(%{last_synced_at: at}), do: at |> DateTime.to_date() |> Date.add(-7)
```

Two things worth doing properly rather than by hand — see the
[Collecting data guide](https://hexdocs.pm/openfeed/collecting-data.html):

- **Money has two wire formats.** Use `OpenFeed.Amount.to_decimal/1`.
- **Energy usage reads carry a discriminator.** Use
  `OpenFeed.Energy.net_usage/1`, and record
  `OpenFeed.Energy.has_reads?/1` so "no data" stays distinguishable from
  "zero usage".

For a collection that could be large — a year of transactions ran to nearly
4,000 records on one real account — stream instead of materialising it:

```elixir
OpenFeed.Client.stream(config, tokens, "/v1/banking/accounts/#{id}/transactions")
|> Stream.chunk_every(500)
|> Enum.each(&bulk_insert!/1)
```

### Collect errors, do not abandon the run

One unavailable balance should not cost you a year of transactions. Gather
failures and decide once at the end, branching on `OpenFeed.Error`'s `:kind`:

```elixir
cond do
  Enum.any?(errors, &OpenFeed.Error.grant_revoked?/1) ->
    # Terminal. The consumer withdrew consent.
    Ash.update!(grant, %{}, action: :mark_revoked)

  Enum.any?(errors, &OpenFeed.Error.credit_exhausted?/1) ->
    # Your credit, not their consent. The grant is still good.
    Ash.update!(grant, %{}, action: :mark_metering_suspended)

  true ->
    Ash.update!(grant, %{}, action: :touch_synced)
end
```

Note that a 403 `subject_mismatch` is **not** a revocation, despite sharing the
status code. `grant_revoked?/1` gets that right; matching on `403` does not.

### Scheduling it

[`ash_oban`](https://hex.pm/packages/ash_oban) is the natural fit. OpenFeed
refreshes its own mirror roughly every 4 hours for banking and 6 for energy, so
that is the practical ceiling on useful polling:

```elixir
config :my_app, Oban,
  plugins: [
    {Oban.Plugins.Cron, crontab: [{"0 */4 * * *", MyApp.OpenFeed.SyncAllWorker}]}
  ]
```

Polling more often is not billed differently — **OpenFeed charges per grant per
month, not per request** — but it cannot surface data OpenFeed has not fetched
yet. See [Cost and cadence](https://hexdocs.pm/openfeed/cost-and-cadence.html).

Because refresh tokens are not rotated, concurrent refreshes are wasteful rather
than destructive. If you fan out across many grants, give the worker a
uniqueness constraint rather than a database lock.

### A worked example

[`examples/openfeed_demo`](https://github.com/benkolera/ash-openfeed/tree/main/examples/openfeed_demo)
is a Phoenix + Ash app doing all of this against production OpenFeed;
`lib/openfeed_demo/open_feed/sync.ex` is the file to read.

## Grant management

A grant is the consumer's consent, and it is not a one-shot thing. OpenFeed
supports the CDR Grant Management surface, so consent can be amended, queried and
revoked — and it can disappear from under you.

A consumer has **at most one active grant** per app, so a fresh
`authorize_url/3` reuses the one they already have rather than creating a second.

### Letting a consumer change what they share

```elixir
flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.amend_url(grant, flow)
redirect(conn, external: url)
```

OpenFeed keeps the **same `grant_id`** and bumps its revision, so handle the
callback exactly as you handle a first connection — the upsert lands on the same
row.

### Noticing revocations

There are **no webhooks**. Two things will tell you a consumer disconnected:

**A failing call.** A data call returns 403 → `:grant_revoked`. More often, a
token refresh is rejected: OpenFeed sweeps the refresh tokens bound to a revoked
grant, so a sync usually finds out while refreshing. `AshOpenFeed` marks the grant
revoked when it sees either, so a disconnected consumer stops costing you a doomed
refresh on every run.

**Polling.** The proactive route:

```elixir
{:ok, summary} = AshOpenFeed.reconcile_grants(MyApp.OpenFeed.Grant)
#=> %{checked: 12, revoked: 1, updated: 2, unchanged: 9, unknown: 0}
```

This diffs your grants against OpenFeed's app-level index. A grant missing from it
was revoked upstream; a higher revision means consent was amended, so the full
state is fetched and applied. It costs nothing — app-level calls are not metered.

Usefully, phase one needs only the app-level scope from a client-credentials
token, so revocation detection works even without the per-grant management
scopes. Without them an amendment is still *noticed* and its revision recorded —
you just cannot read the new authorised id lists.

### `revision` is the signal worth acting on

It increments only when the consumer changes which accounts they share — not on a
status or metering change. So a bump means exactly one thing, and it is the cheap
trigger for re-reading accounts and pruning what has gone:

```elixir
if reloaded.revision > grant.revision do
  # Consent scope changed — re-read accounts and prune locally.
end
```

### The account id lists are opaque

`refresh_status/2` stores `banking_account_ids` and `energy_account_ids`, but
**you cannot match them against your stored accounts** — they are
`account_identity` ids from a different identifier space to the `accountId` values
the data endpoints return, with no mapping available to a third-party client.

Use them for change detection and audit. To work out what to stop syncing, do it
the straightforward way: after a revision bump, re-read the account lists and
prune whatever is no longer returned.

See the [Grant management guide](https://hexdocs.pm/openfeed/grant-management.html)
for the full picture.

## Two things that are easy to get wrong

**Not every 403 means the consumer withdrew consent.** OpenFeed returns 403 for
both `disclosure_grant_required` (consent gone) and `subject_mismatch` (a bug on
your side). Treating them alike marks healthy grants dead. `OpenFeed.Error`
classifies them as `:grant_revoked` and `:subject_mismatch`, and only the first
is terminal.

**Keys must not be generated per node.** A key generated lazily on first use
gives each node in a cluster a *different* key, so DPoP proofs get signed with
keys absent from your registered JWKS — an intermittent, load-balancer-dependent
failure. `AshOpenFeed.KeyStore.Ash` holds one key in the database, shared by
every node. Key creation is always an explicit, operator-driven step.

## A naming clash to watch for

The installer names your domain `MyApp.OpenFeed` by default, which **shadows
this library's top-level `OpenFeed` module** if you alias it bare:

```elixir
alias MyApp.OpenFeed            # now OpenFeed.Sharing means MyApp.OpenFeed.Sharing
```

Alias it explicitly instead:

```elixir
alias MyApp.OpenFeed, as: Grants
alias OpenFeed.{Error, Sharing}
```

Or pass `--domain MyApp.Banking` to the installer to avoid the collision
entirely.

## Token types are not guessable

`AshOpenFeed.with_token/3` hands your callback an `OpenFeed.Tokens` struct, not
a bare string, and you should pass that struct straight to `OpenFeed.Sharing`.
It carries the `token_type` OpenFeed actually issued, which is the only reliable
source of the authorization scheme.

This matters more than it sounds: OpenFeed issues **client-credentials tokens as
`Bearer`** even to a `private_key_jwt` client, so inferring `DPoP` from the
client profile sends `Authorization: DPoP <bearer token>` and earns a bare 401
with no error code to explain it.

## Encrypt your tokens

The generated resource stores tokens as plaintext columns. A refresh token here
is unusually valuable: OpenFeed does not rotate them, and Recommended-profile
refresh tokens live as long as the grant does, so a leaked row is durable access
to somebody's banking data rather than a short window.

```elixir
use Ash.Resource, extensions: [AshOpenFeed.Grant, AshCloak.Resource]

cloak do
  vault MyApp.Vault
  attributes [:access_token, :refresh_token]
end
```

A compile-time verifier warns if it cannot see encryption configured. It warns
rather than errors — this is a deployment judgement — but it should be a
conscious one. Silence it with `config :ash_openfeed, warn_unencrypted?: false`.

Note also that Ash's `sensitive?` keeps tokens out of `inspect/1` and error
messages, but does not reach a data layer's own debug logging.

## Refresh concurrency

`AshOpenFeed.access_token/2` refreshes without taking a lock, deliberately.
OpenFeed does not rotate refresh tokens — FAPI 2.0 permits sender-constraint
(DPoP) as the alternative — so a refresh token stays valid after use and two
concurrent refreshes both succeed. Concurrent refresh is wasteful, not
destructive. If you want to avoid the waste when fanning out across many grants,
give your worker a uniqueness constraint rather than reaching for a database
lock.

## What is not here

**A CDR data model.** There are no Banking or Energy resources, and no sync
engine. How much history to keep, how to shape it, and how often to refresh are
application decisions, and shipping opinions about them would make this a far
larger and more brittle package.

What you get instead is the hard part — FAPI 2.0, key management, grant and
token lifecycle — plus a client that returns decoded maps, helpers for the two
payload shapes that are easy to get wrong, and a
[worked example](https://github.com/benkolera/ash-openfeed/tree/main/examples/openfeed_demo)
of the loop to copy from. See
[Collecting data into your own resources](#collecting-data-into-your-own-resources)
above.

## License

Apache-2.0
