# `PhoenixKit.Integrations`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v1.7.105/lib/phoenix_kit/integrations/integrations.ex#L1)

Centralized management of external service integrations.

Stores credentials (OAuth tokens, API keys, bot tokens, etc.) using the
existing `PhoenixKit.Settings` system with `value_json` JSONB storage.
Each integration is a JSON blob under a key like
`"integration:google:default"` (`integration:{provider}:{name}`).

Connections are referenced by the storage row's UUID. Names are pure
user-chosen labels with no system semantics — they can be renamed or
removed freely; consumer modules pin to UUIDs that survive renames.

## Auth types supported

- `:oauth2` — Google, Microsoft, Slack, etc. (client_id/secret + access/refresh tokens)
- `:api_key` — OpenRouter, Stripe, SendGrid, etc. (single API key)
- `:key_secret` — AWS, Twilio, etc. (access key + secret key)
- `:bot_token` — Telegram, Discord, etc. (single bot token)
- `:credentials` — SMTP, databases, etc. (freeform credential map)

## Usage

Consumer modules (AI endpoints, document creator, etc.) store an
integration's UUID on their own records and resolve credentials by UUID:

    # Look up the row by uuid (the stable reference consumers store)
    {:ok, %{provider: "openrouter", name: "default", data: data}} =
      PhoenixKit.Integrations.get_integration_by_uuid(integration_uuid)

    # Get credentials for API calls — accepts either a uuid or a
    # `provider:name` shape
    {:ok, creds} = PhoenixKit.Integrations.get_credentials(integration_uuid)
    # => %{"access_token" => "ya29...", "token_type" => "Bearer", ...}

    # Make an authenticated request with auto-refresh on 401
    {:ok, response} =
      PhoenixKit.Integrations.authenticated_request(integration_uuid, :get, url)

## Renaming and removing

Any connection can be renamed or removed — there's no privileged
`"default"` name. The storage row's UUID stays stable across renames,
so consumer references don't break:

    {:ok, _} = PhoenixKit.Integrations.rename_connection(uuid, "work")
    :ok = PhoenixKit.Integrations.remove_connection(uuid)

## API shape (uuid-strict)

Every operation past row creation takes the row's `uuid`. The only
exceptions are:

- `add_connection/3` — row birth, no uuid exists yet
- `get_integration/1`, `find_uuid_by_provider_name/1` — read shims for
  legacy `migrate_legacy/0` callbacks that walk pre-uuid data shapes

The structural rule: `"integration:{provider}:{name}"` storage-key
construction happens only inside `add_connection/3` (creation) and
module-side `migrate_legacy/0` migrators (translation). Every other
caller routes by uuid, so a corrupted JSONB `provider`/`name` field
cannot leak into a new storage key.

# `add_connection`

```elixir
@spec add_connection(String.t(), String.t(), String.t() | nil) ::
  {:ok, %{uuid: String.t(), data: map()}}
  | {:error, :empty_name | :invalid_name | :already_exists | term()}
```

Adds a new named connection for a provider.

This is the row-birth path — the only place a new
`integration:{provider}:{name}` storage key is constructed. Every
other public API takes the row's uuid; callers can find it via the
returned `:uuid` or by listing the provider's connections.

The name can be any string alphanumeric with hyphens / underscores
(e.g., "company-drive"), starting with an alphanumeric character.

Returns `{:ok, %{uuid: uuid, data: data}}` on success.

# `authenticated_request`

```elixir
@spec authenticated_request(String.t(), atom(), String.t(), keyword()) ::
  {:ok, Req.Response.t()} | {:error, term()}
```

Make an authenticated HTTP request with automatic token refresh on 401.

For OAuth providers: adds Bearer token, retries with refreshed token on 401.
For API key providers: adds Bearer token from the api_key.
For bot token providers: returns credentials for the caller to use directly.

`opts` are passed through to `Req.request/1`.

# `authorization_url`

```elixir
@spec authorization_url(String.t(), String.t(), String.t() | nil, String.t() | nil) ::
  {:ok, String.t()} | {:error, term()}
```

Build the OAuth authorization URL for a connection (by uuid).

Accepts an optional `state` parameter for CSRF protection. Use
`PhoenixKit.Integrations.OAuth.generate_state/0` to generate one,
store it in the session or socket assigns, and verify it when the
callback arrives.

# `connected?`

```elixir
@spec connected?(String.t()) :: boolean()
```

Check if an integration is connected and has valid credentials.

# `disconnect`

```elixir
@spec disconnect(String.t(), String.t() | nil) :: :ok
```

Disconnect a connection (remove tokens, keep setup credentials).

For OAuth: removes access_token, refresh_token, keeps client_id/client_secret.
For API key/bot token: removes the key entirely.

No-op when the uuid doesn't resolve (already gone).

# `exchange_code`

```elixir
@spec exchange_code(String.t(), String.t(), String.t(), String.t() | nil) ::
  {:ok, map()} | {:error, term()}
```

Exchange an OAuth authorization code for tokens and save them on the
connection identified by `uuid`.

# `find_uuid_by_provider_name`

```elixir
@spec find_uuid_by_provider_name(String.t() | {String.t(), String.t()}) ::
  {:ok, String.t()} | {:error, :not_found | :invalid}
```

Resolve a `provider:name`-style reference to the storage row's uuid.

Used by consumer modules' `migrate_legacy/0` implementations to walk
legacy name-string references and rewrite them to uuid references.
Accepts a few input shapes for convenience:

- `"openrouter:work"` — full provider:name pair
- `"openrouter"` — bare provider, treated as `provider:default`
- `{"openrouter", "work"}` — explicit tuple

Returns `{:ok, uuid}` if a matching row exists, `{:error, :not_found}`
if not, `{:error, :invalid}` for malformed input. Does NOT auto-pick
an arbitrary connection when multiple match — that's not the
caller's intent here.

# `get_credentials`

```elixir
@spec get_credentials(String.t()) ::
  {:ok, map()} | {:error, :not_configured | :deleted}
```

Get credentials for a provider, suitable for making API calls.

Returns the full integration data map. The caller extracts what it needs
based on the auth type (e.g., `"access_token"` for OAuth, `"api_key"` for API key).

# `get_integration`

```elixir
@spec get_integration(String.t()) ::
  {:ok, map()} | {:error, :not_configured | :invalid_provider_key}
```

Get the full integration data for a provider.

Returns the entire JSON blob including credentials, status, and metadata.
Misses return `:not_configured` (or `:deleted` for uuid input) — there's
no on-read legacy-shape migration in core anymore. Modules with legacy
data own their own migration via the `migrate_legacy/0` callback on
`PhoenixKit.Module` (orchestrated by
`PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0`).

# `get_integration_by_uuid`

```elixir
@spec get_integration_by_uuid(String.t()) ::
  {:ok,
   %{uuid: String.t(), provider: String.t(), name: String.t(), data: map()}}
  | {:error, :not_configured | :invalid_uuid}
```

Look up an integration row by its settings UUID and return a normalized
shape with `provider`, `name`, `data`, and the original `uuid`.

Used by the integration form LV (route `/admin/settings/integrations/:uuid`)
so the URL is stable across renames — the human-readable `name` lives in
the JSONB blob, the URL stays pinned to the row's storage UUID.

# `list_connections`

```elixir
@spec list_connections(String.t()) :: [
  %{uuid: String.t(), name: String.t(), data: map()}
]
```

Lists all connections for a provider.

Returns a list of `%{uuid: uuid, name: name, data: data}` maps, with "default" first.
The `uuid` is the stable identifier for the settings row.

# `list_integrations`

```elixir
@spec list_integrations() :: [map()]
```

List all configured integrations (those that have saved data).

# `list_providers`

```elixir
@spec list_providers() :: [map()]
```

List all known providers.

# `load_all_connections`

```elixir
@spec load_all_connections([String.t()]) :: %{
  required(String.t()) =&gt; [%{uuid: String.t(), name: String.t(), data: map()}]
}
```

Loads all connections for multiple providers in a single database query.

More efficient than calling `list_connections/1` in a loop.
Returns a map of `provider_key => [%{uuid, name, data}]`.

# `record_validation`

```elixir
@spec record_validation(String.t(), :ok | {:error, term()}) :: :ok
```

Persist the outcome of a connection check (manual or automatic) onto the
integration record and broadcast a PubSub event when status changes.

`last_validated_at` is always rewritten — it is the canonical
"moment of the last validation attempt" timestamp, and a manual
Test-Connection click that returns the same result must still
advance the field (otherwise the form's "Last tested N ago" reading
goes stale). Status and `validation_status` are merged in
unconditionally too — usually the same value as before, so it's a
no-op write at the JSONB level. The PubSub broadcast is gated on an
actual state change so high-frequency automatic paths (e.g. token
refresh failing on every API call) don't spam listing-LV reloads.

# `refresh_access_token`

```elixir
@spec refresh_access_token(String.t()) :: {:ok, String.t()} | {:error, term()}
```

Refresh an expired OAuth access token and save the new one.

On failure, stamps the integration record with `status: "error"` and a
human-readable `validation_status` so the UI reflects the broken state
without waiting for an admin to click "Test Connection".
On success following a previously-errored state, auto-recovers the status
back to `"connected"`.

# `remove_connection`

```elixir
@spec remove_connection(String.t(), String.t() | nil) :: :ok | {:error, term()}
```

Removes a connection by uuid.

Names are pure user-chosen labels — no privileged values. The user is
free to delete any connection; consumer modules that referenced the
deleted integration row will surface a `:not_configured` (or similar)
error on next use, which is the correct loud failure.

# `rename_connection`

```elixir
@spec rename_connection(String.t(), String.t(), String.t() | nil) ::
  {:ok, map()}
  | {:error,
     :empty_name | :invalid_name | :already_exists | :not_configured | term()}
```

Renames a connection identified by uuid.

Updates the row's `key` column in place (preserving the uuid) and
rewrites the JSONB `name` field. Consumers that pinned to the uuid
keep working across the rename — that's the whole point of uuid-based
references. Names are pure user-chosen labels; any name (including
the literal string `"default"`) is valid.

No-ops when `new_name` matches the current name. Refuses if the new
name already exists for this provider, or if it doesn't match the
connection-name pattern.

Returns `{:ok, new_data}` on success, with the same JSONB body as
before but `"name"` rewritten.

# `resolve_to_uuid`

```elixir
@spec resolve_to_uuid(String.t()) ::
  {:ok, String.t()} | {:error, :not_found | :invalid}
```

Resolves a binary that may be EITHER an integration row's uuid OR a
`provider:name` string into the canonical row uuid.

This is the dual-input lookup that consumer modules' lazy-promotion
paths and migration sweeps converge on — code that reads a legacy
string from a column where the operator might have stuffed a uuid
pre-V107, or a `provider:name` shape pre-uuid-strict, or a bare
provider key. Each consumer used to copy the same regex + dispatch
pair into its own helper; this primitive centralises it so a future
provider doesn't tempt a third copy.

Returns `{:ok, uuid}` if the input resolves to a current row,
`{:error, :not_found}` if it parses cleanly but no matching row
exists, `{:error, :invalid}` for malformed input (empty string, nil,
non-binary).

## Examples

    iex> resolve_to_uuid("019b669c-3c9d-7256-8ed1-edbc6ae29703")
    {:ok, "019b669c-3c9d-7256-8ed1-edbc6ae29703"}  # already-uuid path

    iex> resolve_to_uuid("openrouter:default")
    {:ok, "..."}  # provider:name path → find_uuid_by_provider_name

    iex> resolve_to_uuid("openrouter")
    {:ok, "..."}  # bare provider, treated as provider:default

See `find_uuid_by_provider_name/1` for the provider:name half of the
lookup. The split exists because that primitive doesn't handle the
"input is already a uuid" case — it'd treat `"019b669c-..."` as a
provider name and search `integration:019b669c-...:default`.

# `run_legacy_migrations`

> This function is deprecated. Use PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0 instead.

```elixir
@spec run_legacy_migrations() :: :ok
```

Deprecated. Use `PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0`
from your host app's `Application.start/2` instead.

Each module that has legacy data now implements its own
`migrate_legacy/0` callback. The orchestrator walks every registered
module and runs them all — same single entry point as before, but
modules own their own data shape.

Calling this delegates to the orchestrator for backwards compat.
Returns `:ok` regardless of per-module outcome (matches the previous
semantics of "best-effort, never crash boot").

# `save_setup`

```elixir
@spec save_setup(String.t(), map(), String.t() | nil) ::
  {:ok, map()} | {:error, :not_configured | :invalid_uuid | term()}
```

Save setup credentials for an existing connection (referenced by uuid).

For OAuth providers, this saves client_id/client_secret.
For API key providers, this saves the api_key.
For bot token providers, this saves the bot_token.

Merges with existing data to preserve any previously obtained tokens.
Sets status to "disconnected" if no runtime credentials exist yet.

The connection must exist (`add_connection/3` is the row-birth path).
Returns `{:error, :not_configured}` if the uuid doesn't resolve.

# `settings_key`

```elixir
@spec settings_key(String.t()) :: String.t()
```

Returns the settings key for a provider connection.

Accepts `"google"` (returns default connection key) or
`"google:personal"` (returns named connection key).

## Examples

    iex> PhoenixKit.Integrations.settings_key("google")
    "integration:google:default"

    iex> PhoenixKit.Integrations.settings_key("google:personal")
    "integration:google:personal"

# `validate_connection`

```elixir
@spec validate_connection(String.t(), String.t() | nil) :: :ok | {:error, String.t()}
```

Validate that a provider's credentials are working.

For OAuth: calls the provider's userinfo endpoint.
For API key / bot token: calls the provider's validation endpoint if defined.
Returns `:ok` or `{:error, reason}`.

# `validate_credentials`

```elixir
@spec validate_credentials(String.t(), map()) :: :ok | {:error, String.t()}
```

Probe a provider's API with in-memory credentials, without
persisting anything. Used by the integration form to let
operators test what they typed before committing — same HTTP
validation as `validate_connection/2`, but no storage row, no
`last_validated_at` stamp, no PubSub broadcast.

`attrs` is the same shape `save_setup/3` accepts (e.g.
`%{"api_key" => "..."}` for api_key providers,
`%{"client_id" => "...", "client_secret" => "..."}` for OAuth).

OAuth providers without a saved `access_token` will return
`{:error, "No access token"}` — pre-save validation is most
useful for api_key / bot_token providers where the secret the
user just typed IS the credential.

---

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