PhoenixKitSync
Peer-to-peer data sync module for PhoenixKit.
Provides bidirectional data synchronization between PhoenixKit instances — sync between dev and prod, dev and dev, or different websites entirely.
Installation
Add to your parent app's mix.exs:
{:phoenix_kit_sync, "~> 0.2"}
The module is auto-discovered via PhoenixKit's beam scanning — no additional configuration needed. Enable it from the admin dashboard under Modules.
Architecture
Automatic Cross-Site Registration
The module uses permanent connections with automatic cross-site registration:
- Sender creates a connection pointing to a remote site's URL
- System automatically notifies the remote site via API
- Remote site registers the connection based on their incoming settings:
- Auto Accept: Connection activates immediately
- Require Approval (the default): Connection is created
pendingand does nothing until an admin of that site approves it - Require Password: Only accepts with the correct password, then activates
- Deny All: Rejects the connection request
- Both sites have matching connection records for data sync
- All transfers are tracked in the history with full audit trail
Connection Types
- Sender: "I allow this remote site to pull data from me"
- Receiver: "I can pull data from this remote site" (created via API)
When you create a sender connection, the remote site receives a corresponding receiver connection. Only a sender connection grants access: its token is what the remote site presents to read your tables. A receiver connection is this site's own pass to the other site's data and gives that site nothing here.
Features
- Ephemeral Code-Based Transfers: One-time manual sync with 8-character secure codes
- Permanent Token-Based Connections: Recurring sync with full access controls
- Token-Based Authentication: Secure tokens, hashed in database, shown only once
- Access Controls: Allowed/excluded tables, download limits, record limits
- Security Features: IP whitelist, time-of-day restrictions, expiration dates, download password
- Conflict Resolution: skip, overwrite, merge, append strategies
- Transfer Tracking: Full history with statistics and approval workflow
- Audit Trail: Track who created, approved, suspended, or revoked connections
- Real-Time Progress: Live tracking of sync operations
- Background Import: Async processing via Oban workers
- Cross-Site Protocol: HTTP API + WebSocket for data transfer
- Activity Audit: Every connection mutation (create, approve, suspend, revoke, reactivate, delete, token rotate) and every transfer mutation (create, complete, fail, cancel, approve, deny) writes a
sync.<resource>.<verb>row tophoenix_kit_activitieswith the actor's UUID — visible in the core admin Activity feed - Centralised error translation: Context functions return
{:error, :atom}tuples; UI / API layers translate viaPhoenixKitSync.Errors.message/1, which dispatches to gettext-wrapped strings - Translations: The module has its own Gettext backend (
PhoenixKitSync.Gettext,priv/gettext) with English, Estonian and Russian
Connection Settings
Sender-Side Controls
These settings are checked on every request the remote site makes with the
connection's token: each REST call (list-tables, pull-data,
table-schema, table-records), the WebSocket upgrade, and every message
on an open socket. One function decides
(PhoenixKitSync.Connections.authorize_request/3), and it reads the
connection as it is in the database at that moment, so a change made while a
socket is open takes effect on its next message.
| Setting | Enforced | Description |
|---|---|---|
| Allowed Tables | Yes | Whitelist of tables the receiver can access |
| Excluded Tables | Yes | Blacklist of tables to hide from receiver |
| Max Downloads | Yes | Total pulls. A table read page by page is one pull, over REST (with the sender's cursor) and on one WebSocket (a request for exactly the position, or the offset, the page before ended on, within ten minutes). Every nonempty table-records answer, the 10-row preview of the receiving site's sync page included, is a pull of its own |
| Max Records Total | Yes | Total records that can be downloaded, by pull-data, table-records and the WebSocket alike; a page is cut where the allowance ends |
| Max Records Per Request | Yes | Records per page (default: 10,000), set as "Records per request" in the connection form. A larger table is sent in pages |
| Download Password | Yes | The request must carry it (password in the REST body, ?password= on the socket). The receiving site's sync page asks its admin for it when the sender refuses a request without one, keeps it for as long as that page is open, and stores it nowhere |
| IP Whitelist | Yes | Only the listed client addresses (see "Client address" below) |
| Allowed Hours | Yes | Time-of-day window, in UTC (e.g., only 2am-5am) |
| Expiration Date | Yes | Requests after expires_at are refused |
| Approval Mode | No | auto_approve / require_approval / per_table and Auto-Approve Tables are stored, and Connections.requires_approval?/2 answers for them, but no pull is held for approval: every mode behaves as auto_approve |
| Rate Limit | No | rate_limit_requests_per_minute is stored but not applied |
An answer with no rows returns its reservation and consumes no download.
Both limits are taken with one conditional UPDATE before any row is read
(Connections.reserve_transfer/2), so requests arriving together cannot
both take the last download or the last records: the rows served under a
connection never exceed Max Records Total, and its pulls never exceed
Max Downloads. A request holds its whole page against the record limit
until it has answered, so a second request arriving in that moment can be
refused with record_limit_reached although rows are left; it is given
them on its next try.
The connection form has the name, the URL and "Records per request". The
download password, the other limits and the approval and rate-limit fields
are not in it; set them with Connections.update_connection/2.
Connection Statuses
| Status | Description |
|---|---|
| Pending | Created, awaiting approval. Grants and uses nothing |
| Active | In use |
| Suspended | Temporarily disabled (can be reactivated) |
| Revoked | Permanently disabled |
| Expired | Set by Connections.expire_connections/0, which nothing in this module schedules; call it from a job of your own if you want the status to follow. A connection past its expiry date or limits is refused either way |
A status changes only on the site that owns the connection (asking for it
with get-connection-status is a read and changes nothing), with one
exception: the sender reports its suspensions and revocations
(update-status), and the receiving site mirrors them onto its receiver
connection. The remote site can suspend or revoke an active receiver
connection and lift a suspension it reported itself. It cannot activate a
pending, revoked or locally suspended connection, and it cannot touch a
sender connection at all.
Incoming Connection Settings
Control how your site handles connection requests from other sites:
| Mode | Behavior |
|---|---|
| Auto Accept | Incoming connections activate immediately |
| Require Approval | The default. Connections are created pending and need an admin's approval (the Approve button in the Incoming list) |
| Require Password | Sender must provide the correct password; the connection then activates |
| Deny All | Reject all incoming connection requests |
The registration request is unauthenticated unless the mode asks for a
password. At most 50 registrations wait for approval at a time
(config :phoenix_kit_sync, max_pending_incoming: 50); further ones are
answered 429 until some are approved or deleted. A pending connection's site
is never contacted. A sender site has one connection here however its URL is
spelled: https://Example.com, https://example.com/ and
https://example.com:443 are the same site (scheme and host compared in
lower case, the default port and trailing slashes left out), and a second
registration under any of them is answered 409. The duplicate check, the cap
and the insert are one step, so registrations arriving together cannot get
past either.
Workflow
Setting Up a Sender Connection
- Navigate to the Sync connections page in the admin dashboard
- Click "New Connection"
- Enter a name and the remote site's URL
- Configure access controls (approval mode, tables, limits)
- Save — the connection is created
pendingand its token generated. Approve it (the Approve button on its row) when the remote site may start pulling: until then it grants nothing, and nothing the remote site does activates it - The remote site is notified automatically!
- If successful, the connection appears in their list
- Based on their settings, it may be auto-approved or pending
- If notification fails, share the token manually as a fallback
What Happens on the Remote Site
When you create a sender connection:
- Your site calls
POST {remote_url}/{prefix}/sync/api/register-connection - The remote site creates a matching receiver connection
- Based on their incoming mode:
- Auto Accept: Ready to use immediately
- Require Approval: Admin must approve in their connections list
- Require Password: You need to provide their password
- Deny All: Connection is rejected
Choosing Tables to Pull
- Primary keys decide how a table is imported. With a single-column
key, rows are matched against local ones by key, then by unique columns.
A row matched by unique columns under another key is remapped, so tables
that reference it point at the local row (uuid, integer and text keys).
A composite key such as
(lang, value)is imported as is: conflicts resolve on the whole key. A table with no primary key is not pulled at all — the pull reports it as an error, because without a key every repeat pull would insert the same rows again. - Conflict strategy.
skipleaves a matched row alone;overwritewrites the sender's values onto it;mergedoes the same but keeps the local value wherever the sender's is NULL. On the bulk pull,appendinserts unmatched rows under a new key and leaves matched rows alone. Unique columns other than the key: on the bulk pull, a row with a new single-column key whose unique columns match a local row is that row (remapped, then left alone or updated per the strategy). A row that would clash with another local row's unique value (a composite key, the precise pull, or an update of an existing key) is skipped under skip, overwrite and merge. - Large tables come in pages. The sender answers at most its
connection's
max_records_per_requestrows and says where the rest starts; the pull follows until the table ends, for integer, uuid, text,character, numeric, float, boolean,bytea, date, time and timestamp keys and composites of them. A sender from before cursors, or a key of another type (time with time zone,interval, an enum, citext), answers its first page only: those rows are imported and the table is reported as only partly pulled, naming the key type when that is the reason. A key value whose text is longer than 8192 bytes is cut the same way, and named as such. Such a table is pulled whole once the sender's "Records per request" is at least its row count. - Array columns travel as lists whose elements are written and read by
the array's element type, on the bulk pull as on the WebSocket path: a
uuid[],numeric[],date[],timestamptz[]orbytea[]column comes back as it was, NULL elements and further dimensions included. A JSONnullelement of ajsonb[]comes back as NULL, and a multi-dimensionaljsonb[]as one dimension. - Column types beyond the basic ones.
interval,inet,cidrandmacaddrvalues (and arrays of them) come back as they were. Ranges, the geometric types (point,box,circle, ...) andtsvectorare not carried: a record holding one is counted as an error on the receiving site, with the type named, and the rest of the table is imported. - Some tables are never synced, whatever a connection allows: this
module's own tables,
phoenix_kit_settings, and the tables that hold session tokens, OAuth tokens, invitations and provider keys. See "Security Considerations". - The table must exist on this site. A table missing here is reported as an error rather than pulled; create it first (Precise Transfer > Create Table). Both checks run before anything is requested from the sender, so they do not use up its download or record limits.
- Leave out tables that a database trigger maintains. Some tables are
projections that a trigger keeps in step with another table (for example
a slug lookup table filled by an
AFTER INSERT OR UPDATEtrigger on the table that owns the slugs). Pull the source table only: importing it runs the trigger on this site and fills the projection here. Pulling the projection as well can add rows the source does not have, and the trigger may then reject a later write to the source. Sync cannot tell such tables apart; leaving them out is the admin's call. - A table that fails as a whole (sender offline, table missing here, no primary key, an import that raised) does not stop the run: its row on the results screen shows the reason and the run ends as "completed with errors".
Programmatic API
Connection Management
alias PhoenixKitSync.Connections
# Create a sender connection. The raw token is returned in the third
# tuple element — it's only available at creation time; afterwards only
# the SHA-256 hash is stored.
{:ok, connection, token} =
Connections.create_connection(%{
"name" => "Production Backup",
"direction" => "sender",
"site_url" => "https://backup.example.com",
"approval_mode" => "auto_approve",
"allowed_tables" => ["users", "posts"],
"max_downloads" => 100,
"created_by_uuid" => current_user.uuid
})
# Approve a pending connection. Every status change takes `actor_uuid:`,
# recorded as the actor on its activity-log row
# (`sync.connection.approved`, ...).
{:ok, connection} = Connections.approve_connection(connection, actor_uuid: admin_user_uuid)
# Suspend a connection
{:ok, connection} =
Connections.suspend_connection(connection, actor_uuid: admin_user_uuid, reason: "Security audit")
# Reactivate a suspended connection
{:ok, connection} = Connections.reactivate_connection(connection, actor_uuid: admin_user_uuid)
# Revoke permanently
{:ok, connection} =
Connections.revoke_connection(connection, actor_uuid: admin_user_uuid, reason: "No longer needed")
# Rotate the auth token (returns the new raw token, hash stored in DB).
# Logged as `sync.connection.token_regenerated`.
# This is a local, low-level operation: the caller must also replace the
# credential on the peer. The admin UI uses delete + create to reconnect.
{:ok, connection, new_token} = Connections.regenerate_token(connection, actor_uuid: admin_user_uuid)
# Validate a token (a sender connection's; what the socket upgrade does)
case Connections.validate_connection(token, client_ip, password: password) do
{:ok, connection} -> # Token is valid and the connection may be used now
{:error, :invalid_token} -> # No sender connection has this token
{:error, :connection_not_active} -> # Pending, suspended, revoked or expired
{:error, :connection_expired} -> # Past expires_at
{:error, :download_limit_reached} -> # Max downloads reached
{:error, :record_limit_reached} -> # Max records reached
{:error, :ip_not_allowed} -> # IP not in whitelist
{:error, :outside_allowed_hours} -> # Outside time window
{:error, :invalid_password} -> # Download password missing or wrong
end
# The same decision for a connection already loaded (what every REST action
# and every socket message calls)
:ok = Connections.authorize_request(connection, client_ip, password: password)
Transfer Tracking
alias PhoenixKitSync.Transfers
# Record a transfer that is already over, in one write
{:ok, transfer} =
Transfers.record_finished(
%{
direction: "send",
connection_uuid: connection.uuid,
table_name: "users",
records_transferred: 150,
bytes_transferred: 45000
},
:completed
)
# Get transfer history
transfers = Transfers.list_transfers(
connection_uuid: connection.uuid,
direction: "send",
status: "completed"
)
# Get statistics for a connection
stats = Transfers.connection_stats(connection.uuid)
# => %{total_transfers: 25, total_records: 5000, total_bytes: 1500000}
System Control
# Enable/disable
PhoenixKitSync.enabled?()
PhoenixKitSync.enable_system()
PhoenixKitSync.disable_system()
PhoenixKitSync.get_config()
# Local database inspection
{:ok, tables} = PhoenixKitSync.list_tables()
{:ok, schema} = PhoenixKitSync.get_schema("users")
{:ok, records} = PhoenixKitSync.export_records("users", limit: 100)
# Import with conflict strategy
{:ok, result} = PhoenixKitSync.import_records("users", records, :skip)
Remote Client
alias PhoenixKitSync.Client
{:ok, client} = Client.connect("https://sender.com", "ABC12345")
{:ok, tables} = Client.list_tables(client)
{:ok, result} = Client.transfer(client, "users", strategy: :skip)
Client.disconnect(client)
API Endpoints
Cross-site communication endpoints (under the configured URL prefix):
POST /sync/api/register-connection— Register incoming connection. Unauthenticated; the answer'sconnection_statusis"active"or"pending"(see "Incoming Connection Settings")POST /sync/api/delete-connection— Delete a connectionPOST /sync/api/verify-connection— Verify connection tokenPOST /sync/api/update-status— The sender reports a status for the receiving site's connection. 409 with"error_code": "status_change_not_allowed"for a change the remote site may not make (see "Connection Statuses"); 404 when no receiver connection matchesPOST /sync/api/get-connection-status— Query a sender connection's status by its token hash. A read: it changes nothing, a pending connection includedPOST /sync/api/list-tables— List available tables."include_checksums": falseleaves the per-table content checksum outPOST /sync/api/pull-data— Pull table data. Optional record filter:ids(up to 1000 key values) orid_start/id_end(an integer range; either bound may be left out), on a table with a single-column key. A sender that applied the filter adds"filtered": trueto the answer; a receiver that sent a filter and gets an answer without it (a sender from before filters) imports nothing and reports it. Such a sender still counts the whole table in itsdownloads_used/records_downloadedand records the send as completed, although nothing was kept. The filter is compared in the key's own type (integer, uuid or text keys; a range needs an integer key); a filter the sender cannot apply is a 400 whoseerror_codesays why (invalid_filter,unsupported_key_type,filter_needs_single_key).table-recordstakes the same filter. This narrows whattable-recordsaccepted for previews: a filter on a table with a composite key or none (a view included), an emptyidslist, oridstogether with a range is now a 400, where it used to filter on the first key column or onid. Paging. An answer holds at most the connection'smax_records_per_requestrows, in primary-key order, and always says"has_more". When rows are left it also carries"truncated": true(what a receiver from before paging reads),"next_after"(the last row's key values, as text by column) and"cursor". Send both back as"after"and"cursor"for the next page: the rows strictly after that key. With the cursor, signed by the sender for that connection, table and position, the page belongs to the pull that produced it;"after"alone (or with any other cursor) also works but counts as a new download. A cursor is good for its own position only and for ten minutes; within them the same page can be asked for again without a download being counted, and its rows are added torecords_downloadedeach time. A receiver that pages sends"paged": true, so the sender records each page as a completed send rather than a cut one. A table without a primary key has no order to page in and is refused ("error_code": "no_primary_key"); a badafterisinvalid_cursor. An answer cut on a key whose type has no cursor carries"unsupported_key_type"(the type's name) instead of"next_after"; so does one cut on a key value whose text is over 8192 bytes ("text value over 8192 bytes"), the longest position a sender issues and accepts.POST /sync/api/table-schema— Get table schema. Each column carries itselement_type(for an array) beside theinformation_schemanamesPOST /sync/api/table-records— Get table records (limitup to 100,offset, thepull-datafilter). Each answer counts as one download and its rows as records, and is cut where the record limit ends
The four table endpoints take the connection's download password as
"password" and answer 403 with an error_code when the connection may not
be used (connection_not_active, connection_expired,
download_limit_reached, record_limit_reached, ip_not_allowed,
outside_allowed_hours, invalid_password).
GET /sync/api/status— Check module status
When notifying a remote site, ConnectionNotifier prepends the remote's URL
prefix to these paths. By default it mirrors the local site's
PhoenixKit.Config.get_url_prefix/0, which is correct when both deployments
share the same routing config. If a remote mounts PhoenixKit under a different
prefix than the local site, override it:
config :phoenix_kit_sync, remote_url_prefix: "/custom"
Use "" (or "/") when the remote serves the sync API at the root (e.g.
https://remote.example.com/sync/api/...).
Database
Table migrations are currently managed by PhoenixKit's core migration system.
If the tables don't already exist, this package can create them automatically
via PhoenixKitSync.Migration.up/0.
See docs/table_structure.md in the source repository for full schema documentation.
Configuration
config :phoenix_kit_sync,
# Let a connection's site_url point at localhost / private addresses
allow_internal_urls: false,
# URL prefix of the remote site's sync API (default: this site's own)
remote_url_prefix: "/phoenix_kit",
# Tables never synced, on top of the built-in list
excluded_tables: ["my_app_api_clients"],
# Registrations that may wait for approval at once
max_pending_incoming: 50,
# Largest answer accepted from a remote site for a page of records
# (pull-data, table-records), in bytes. A table list or a table's schema
# is capped at 16 MiB (or at this value when it is lower), every other
# answer at 1 MiB
max_response_bytes: 268_435_456
Not Implemented
- Auto-sync scheduling. Connections have
auto_sync_enabled,auto_sync_tablesandauto_sync_interval_minutescolumns; nothing reads them, and the form does not offer them. - Per-transfer approval and request rate limiting (see "Sender-Side Controls").
Security Considerations
- Token Security: Tokens are SHA-256 hashed in the database and only shown once on creation; comparisons use
Plug.Crypto.secure_compare/2to defeat timing attacks. The hash is the credential of the REST API, so the connections table is never synced - One authorization for every entry point:
Connections.authorize_request/3decides for the REST actions, the WebSocket upgrade and each message on an open socket, against the connection as stored at that moment. Only asenderconnection authorizes anything - Per-connection table authorization: Even with a valid token, every API endpoint that returns or accepts table data (
list_tables,pull_data,table_schema,table_records) filters through the connection'sexcluded_tablesblocklist andallowed_tablesallowlist - Tables that are never synced:
schema_migrations,oban_*,pg_*,phoenix_kit_sync_connections,phoenix_kit_sync_transfers,phoenix_kit_settings,phoenix_kit_users_tokens,phoenix_kit_user_oauth_providers,phoenix_kit_organization_invitations,phoenix_kit_ai_accounts,phoenix_kit_ai_endpoints,phoenix_kit_buckets,phoenix_kit_payment_provider_configs; any table whose name containstoken,secret,credential,password,api_key,oauthorsession; and the host's ownexcluded_tables. Names are compared without regard to case, so a quoted"PHOENIX_KIT_SETTINGS"is excluded as well. They are never created from a sender's schema, and are refused on export and import, over REST and WebSocket, for token connections and code sessions alike, even when a connection lists them as allowed.phoenix_kit_usersis synced, password hashes (hashed_password) included, to any connection whose tables allow it: create connections only to sites you fully trust, and restrict the table with a connection's allowed or excluded tables where that matters - Parameterised SQL with identifier validation: Every dynamic table / column name interpolated into raw SQL passes through
SchemaInspector.valid_identifier?/1first; values always go through$Nparameterised binds. A table whose name is outside that rule ("customer-data") is not listed, read or written on any path - Incoming registrations: created pending unless the mode is
auto_acceptor the incoming password matched; refused outright whenincoming_mode = "require_password"but no password is configured (no silent bypass) - Client address: the IP whitelist compares against the address core's
PhoenixKit.Utils.IpAddress.client_address/1reports:conn.remote_ip, and a forwarded header only when that address is a proxy on the same box or network (the lastX-Forwarded-Forhop, which the proxy appended). A client-suppliedX-Forwarded-Foris never believed. Behind a proxy on another network, setremote_ipwith your proxy plug, or the whitelist sees the proxy - Session codes: eight characters from the OS's CSPRNG, never written to the log, to a job or to the activity log (an import job keeps a tag derived from the code,
session_ref). A code is claimed once for the life of its session, whatever status the session is given afterwards, and only the code of a session opened to send (create_session(:send)) opens a socket - Credentials in request logs: the module's routes are declared with
log: false, so the router's line for a request, which prints its parameters (auth_token_hash,code,token), is not written. Your repo's query log, at debug level, still prints the bind values of every query, the token hash a connection is looked up by among them - Outbound requests:
site_urlis checked against localhost,.local, private, loopback and link-local addresses (in any case, with or without a trailing dot, IPv4-mapped IPv6 included) unlessallow_internal_urlsis set; a name that resolves to an internal address only at request time is not caught. The size of every answer is capped (seemax_response_bytes), and an answer whose table list or records are not a list of objects is refused as an invalid response - Audit Trail: Every state-changing operation lands a row in
phoenix_kit_activitieswithactor_uuid,resource_type,resource_uuid, and a PII-safe metadata subset (nosite_url, noauth_token_hash): connection changes (a status the remote site reported is logged with no actor andreason: "remote_site"), transfers, and tables created from a sender's schema (sync.table.created)
Troubleshooting
Connection Issues
- Verify the token is correct and hasn't been regenerated
- Check connection status is "active"
- Verify IP is in whitelist (if configured); behind a reverse proxy, check which address the site sees
- Check time-of-day restrictions
- Verify download/record limits haven't been exceeded
Transfer Failures
- Check transfer history for error messages
- Verify table is in allowed tables (if configured)
- "Only partly pulled": the sender has no cursors (update its sync module), or the table's key type has none; the message then names the type, and the fix is a higher "Records per request" on the sender's connection
- Review server logs for detailed errors