PhoenixKit.Integrations (phoenix_kit v1.7.105)

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 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:

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.

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

Returns the settings key for a provider connection.

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 | :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(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.

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()}
]

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

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

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

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()}]
}

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(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 | :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(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.

settings_key(provider_key)

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