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

163 lines
9.8 KiB
Markdown

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