# postiz-poster A Saltcorn plugin that publishes table rows to social networks through a [Postiz](https://github.com/gitroomhq/postiz-app) 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/v1` API, 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 short `notify` message; 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.js` seals the key (via the shared `lib/common/aesGcm.js` + `sessionKey.js`) with AES-256-GCM (full 16-byte tags enforced) under a key derived (HKDF-SHA256) from Saltcorn's `session_secret`, resolved with Saltcorn's own precedence and ONLY from: the `SALTCORN_SESSION_SECRET` env var, then the `session_secret` in the Saltcorn config file (`~/.config/.saltcorn`). When neither is set, Saltcorn falls back to a secret it generates per process (or the constant `deadbeef`); 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. `onLoad` seals 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). **Rotating `session_secret` invalidates 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 1. In Postiz, connect your social accounts, then create a key under **Settings -> Developers -> Public API**. 2. 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**. 3. 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](../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](https://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.