PhoenixKit.Integrations (phoenix_kit v1.7.118)

Copy Markdown View Source

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 row's key column is just its UUID; the module column is stamped "integrations" and the JSONB body holds {"provider", "name", "auth_type", "status", ...}. There is no composite key shape — provider and name are pure JSONB.

Connections are referenced by the storage row's UUID. Names are pure user-chosen labels with no system semantics — any string is allowed (including spaces and duplicates within a provider); 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:

The key column is the row's UUIDv7 — collisions are structurally impossible. JSONB provider/name are pure data; corruption there affects only this row's content, never routing.

Summary

Functions

Adds a new named connection for a provider.

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

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

Check if an integration is connected and has valid credentials.

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

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

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

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

Get the full integration data for a provider.

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

Lists all connections for a provider.

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

List all known providers.

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

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

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

Removes a connection by uuid.

Renames a connection identified by uuid.

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

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

Validate that a provider's credentials are working.

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.

Functions

add_connection(provider_key, name, actor_uuid \\ nil)

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

Adds a new named connection for a provider.

This is the row-birth path. The row's UUIDv7 is generated up front and used as both the uuid and the key column; provider and name live in JSONB. Names are pure user-chosen labels with no character or uniqueness restrictions — any non-empty string (after trim) is valid, duplicates within a provider are allowed.

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

authenticated_request(uuid, method, url, opts \\ [])

@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.

Security — url must come from a trusted source

The integration's Bearer token is attached to every request this function dispatches. If a caller passes a URL that came from unvalidated user input, the token leaks to whatever host that URL points at. Callers MUST validate the URL before invoking — pin to a domain allowlist (the provider's own host, typically), enforce https, and reject RFC1918 / loopback / link-local ranges.

Internal usage (OpenRouterClient.fetch_models/2, OAuth refresh, userinfo lookups) builds URLs from the Providers registry, which is hardcoded and therefore safe. The schema-level validate_base_url/1 guard in PhoenixKitAI.Endpoint covers the AI module's operator-supplied base_url case. New callsites that take URLs from anywhere else need their own guard before reaching this function — there's no allowlist enforcement here.

authorization_url(uuid, redirect_uri, extra_scopes \\ nil, state \\ nil)

@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?(provider_key)

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

Check if an integration is connected and has valid credentials.

disconnect(uuid, actor_uuid \\ nil)

@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(uuid, code, redirect_uri, actor_uuid \\ nil)

@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(input)

@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(provider_key)

@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(provider_key)

@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(uuid)

@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(provider_key)

@spec list_connections(String.t()) :: [
  %{
    uuid: String.t(),
    name: String.t(),
    data: map(),
    date_added: DateTime.t() | nil
  }
]

Lists all connections for a provider.

Returns a list of %{uuid, name, data, date_added} maps, sorted by name (case-insensitive). Filters by module = "integrations" and JSONB provider — provider and name both live in JSONB; the row's uuid is the stable identifier. date_added is the row's creation timestamp (UTC, second precision), useful for "Created N days ago" display in UI pickers.

list_integrations()

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

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

Order is determined by load_all_connections/1's map iteration, which is alphabetical by provider key (Erlang Map order over string keys) — NOT the registration order from Providers.all/0. Within a provider, connections are sorted alphabetically by name (case-insensitive). Callers that need a specific provider order should walk Providers.all/0 themselves.

list_providers()

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

List all known providers.

load_all_connections(provider_keys)

@spec load_all_connections([String.t()]) :: %{
  required(String.t()) => [
    %{
      uuid: String.t(),
      name: String.t(),
      data: map(),
      date_added: DateTime.t() | nil
    }
  ]
}

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, date_added}], with every requested provider key present (empty list when no connections exist).

record_validation(uuid, result)

@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(uuid)

@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(uuid, actor_uuid \\ nil)

@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(uuid, new_name, actor_uuid \\ nil)

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

Renames a connection identified by uuid.

Updates only the JSONB name field; the storage key (= row uuid) is untouched, so consumers that pinned to the uuid keep working across the rename. Names are pure user-chosen labels; any non-empty string is valid, duplicates within a provider are allowed.

No-ops when new_name (after trim) matches the current name.

Returns {:ok, new_data} on success.

resolve_to_uuid(input)

@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.
@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(uuid, attrs, actor_uuid \\ nil)

@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.

validate_connection(uuid, actor_uuid \\ nil)

@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(provider_key, attrs)

@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.