| lib | ||
| test | ||
| .gitattributes | ||
| .gitignore | ||
| index.js | ||
| package.json | ||
| README.md | ||
postiz-poster
A Saltcorn plugin that publishes table rows to social networks through a Postiz instance -- self-hosted or Postiz Cloud. Postiz handles the per-platform fan-out (Mastodon, Bluesky, X, LinkedIn, Facebook, Instagram, Threads, Telegram, and more); this plugin just maps a Saltcorn row into a Postiz post and calls Postiz's public API.
How it works
- Plugin config holds the Postiz connection: a base URL and a public API
key. Self-hosted and cloud use the same
/public/v1API, so switching between them is only a base-URL change -- no code change. - One action,
post_to_postiz, is attached to a table trigger (row Insert / Update) or a row button (it needs a row). When it fires it reads the post text (and an optional media URL) from the row, has Postiz fetch the media (POST /public/v1/upload-from-url), and creates the post on the configured channels (POST /public/v1/posts). It reports a shortnotifymessage; the Postiz response itself is not passed back into Saltcorn. - Channels are a comma-separated list of Postiz integration ids. The action
config lists the live connected channels (name, provider, id) under the field,
from
GET /public/v1/integrations(fetched with a short timeout and cached for a minute, because Saltcorn rebuilds action config forms on every view-builder load). It is free text rather than a picker on purpose: Saltcorn's select is single-choice, and a required select with no options (Postiz unreachable) renders no input, which would leave the trigger unsavable.
Layout
postiz/
index.js plugin entry: wires config workflow + the action
lib/
constants.js plugin name, Postiz endpoints, post types, timeouts/limits
postizClient.js the only module that speaks HTTP to Postiz
config.js configuration_workflow (base URL + API key, sealed on save)
action.js post_to_postiz: configFields, payload builder, publish, run
apiKey.js seal / open / migrate the stored API key
mcpTools.js saltcorn-mcp tools (postiz_*), adapters over publish + the client
common/ aesGcm + sessionKey (at-rest sealing), cappedBody,
htmlEscape
test/
e2e.js offline gates (stub servers, stubbed Saltcorn) + optional live gate
fakePostiz.js the local Postiz stand-in the offline gates use
compatVectors.js recorded vectors (compatVectors.json): a key sealed by
earlier versions still opens
Secrets at rest
The Postiz API key is configured through the normal plugin UI but is not
left plaintext in the database. Saltcorn stores plugin configuration unencrypted
in _sc_plugins.configuration, which would otherwise expose the key to DB
backups, admin reads, and dev-deploy's journal sync. Instead:
lib/apiKey.jsseals the key (via the sharedlib/common/aesGcm.js+sessionKey.js) with AES-256-GCM (full 16-byte tags enforced) under a key derived (HKDF-SHA256) from Saltcorn'ssession_secret, resolved with Saltcorn's own precedence and ONLY from: theSALTCORN_SESSION_SECRETenv var, then thesession_secretin the Saltcorn config file (~/.config/.saltcorn). When neither is set, Saltcorn falls back to a secret it generates per process (or the constantdeadbeef); a key derived from that would not open after a restart or in another worker, or would be derivable by anyone, so the plugin refuses it: the API key then stays plaintext and a warning is logged. The derived KEK is cached for the resolved secret, so changing the secret re-derives it.- The configuration workflow seals a typed key before Saltcorn saves it (in
both the full save and the autosave path), so the plaintext never reaches the
database.
onLoadseals a plaintext key left by versions before 0.0.3. The action decrypts only at the point of use. - Threat model: this protects DB-only exposure (backups, sync, config-table
reads). It does not protect a host that leaks both the database and the
session_secret(env var or config file). Rotatingsession_secretinvalidates the sealed key (the KEK cache re-derives) -- you must re-enter it in the plugin config (the action raises a clear error saying so). - For the same reason, the sealed key is environment-specific: it will not
decrypt on a peer with a different
session_secret, so exclude it from any dev-deploy sync and set it per environment.
Configuring
- In Postiz, connect your social accounts, then create a key under Settings -> Developers -> Public API.
- In Saltcorn: Settings -> Plugins -> postiz-poster, set:
- Postiz base URL -- cloud
https://api.postiz.com; self-hosted, the backend URL (NEXT_PUBLIC_BACKEND_URL), normally your Postiz site plus/api, e.g.https://postiz.example.com/api. (The bare site URL reaches the web frontend; the action then reports a "response is not JSON" error.) A user name or password in the URL is refused (fetch cannot send them). - Public API key.
- Postiz base URL -- cloud
- Create a trigger on the table you want to publish from, choose action
post_to_postiz, then pick the content field, optional media URL field, the channel ids (listed under the field), and timing (now / schedule / draft). A schedule date comes from a Date field, or a text field holding an ISO date with a time zone (e.g.2026-12-25T09:00:00Z); zone-less text is rejected rather than read in the server's local time zone.
MCP tools (saltcorn-mcp)
With the saltcorn-mcp plugin installed, an AI client can drive this
plugin through these tools (contract: ../mcp/docs/pluginTools.md). They are
adapters over the same publish() the action's run uses, read the plugin
configuration as it is at call time, and never return the API key: results and
errors are scrubbed of it, only allow-listed fields of Postiz responses pass,
and quoted Postiz response text is marked as untrusted data.
| tool | scope | what it does |
|---|---|---|
postiz_status |
read | configured?, base URL (user-info removed) and why it would be refused, API key state (unset / sealed / sealed_unopenable / plaintext), session secret ok; probe: true also tests the connection |
postiz_list_channels |
read | the connected channels, fetched now: id, name, identifier, disabled, unsupported (providers this plugin cannot post to); include_disabled |
postiz_preview_post |
read | dry run for a post_to_postiz trigger or a configuration, and a stored row (table + row_id, read as the token's user may read it) or a row object: the exact payload (media placeholder), media URL, warnings, the notify text. Lists channels only: no upload, no post. Secret-named row fields are masked |
postiz_post_row |
admin | publishes like the action (same inputs as the preview, optional when override); returns the notify text and the created post ids |
postiz_post_text |
admin | publishes free text: content, integration_ids, when, date (schedule), media_url |
postiz_check_connection |
admin | tests a candidate base_url and/or api_key before configure_plugin; a base URL other than the configured one needs api_key too (the stored key is only sent to the configured URL) |
Posting is admin because it is public and irreversible; checking candidate
values is admin because it sends a key to a URL the caller chooses. The action
also declares mcp_admin_reason, so saltcorn-mcp requires an admin token to
create, change or run a post_to_postiz trigger as well.
Testing
node test/e2e.js # offline gates
POSTIZ_BASE_URL=https://postiz.example.com/api \
POSTIZ_API_KEY=xxxxxxxx node test/e2e.js # + live gate vs a real Postiz
The offline gates include mcpGate: every tool definition checked against
saltcorn-mcp's schema subset (../mcp/lib/schemaCheck.js, when present) and
every tool driven against the fake Postiz with a ctx stub. The suite starts with
compatGate: test/compatVectors.js proves an API key sealed by earlier
versions (recorded in test/compatVectors.json) still opens. The offline gates need no Postiz and no Saltcorn: they stub the
@saltcorn/data modules and run the HTTP client and the action against local
stub servers. The live gate lists integrations and creates a draft (safe to
delete) on the first enabled channel; with no env vars it is skipped, and with
no enabled channel it fails.
Postiz API conformance
The request shapes follow the published public API
(docs.postiz.com/public-api): bare key in
Authorization; upload-from-url with { url } returning { id, path }; the
create-post body with type, date, shortLink: false, tags: [] and, per
channel, integration.id, value[{ content, image[{ id, path }] }] and
settings.__type (the integration's identifier). They have not yet been run
against a live Postiz. Providers that require settings beyond __type: X gets
who_can_reply_post: "everyone" and Instagram post_type: "post"
(PROVIDER_DEFAULT_SETTINGS in lib/constants.js); YouTube, TikTok, Reddit
and Pinterest need per-post choices this plugin does not collect
(UNSUPPORTED_PROVIDERS), so Postiz rejects those posts with a 400 that the
action reports (the MCP channel listing flags them and the preview warns).
Notes
- Postiz removes the subscription cost (self-hosted) but not the gatekeeping: you still register your own developer apps with each walled-garden platform (Meta App Review, LinkedIn Community Management API, separate Threads app) and own the OAuth token upkeep. Mastodon / Bluesky / X are straightforward.
- Underlying platform fees still pass through (e.g. X per-post pricing). Postiz brokers the call; it does not make any platform's API free.