sc-postiz-poster/README.md
2026-10-19 01:20:42 -05:00

9.8 KiB

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