Initial commit.

This commit is contained in:
Scott Duensing 2026-06-17 17:40:57 -05:00
commit 9e776ded42
11 changed files with 749 additions and 0 deletions

52
.gitattributes vendored Normal file
View file

@ -0,0 +1,52 @@
# Default line-ending handling for text files: normalize to LF in the repo,
# check out native on each platform.
* text=auto
# Executable shell scripts must stay LF so the shebang works on Unix even when
# checked out on Windows.
*.sh text eol=lf
# Collapse the lockfile in diffs / PR reviews and mark it generated.
package-lock.json linguist-generated=true -diff
# Git LFS. Run `git lfs install` once per clone to activate the filters below.
# This plugin is currently code-only; these patterns are a safety net so any
# binary blob dropped into the tree is tracked correctly without anyone having
# to remember to update this file.
# Archives
*.zip filter=lfs diff=lfs merge=lfs -text
*.tar filter=lfs diff=lfs merge=lfs -text
*.tar.gz filter=lfs diff=lfs merge=lfs -text
*.tgz filter=lfs diff=lfs merge=lfs -text
*.gz filter=lfs diff=lfs merge=lfs -text
*.bz2 filter=lfs diff=lfs merge=lfs -text
*.7z filter=lfs diff=lfs merge=lfs -text
# Databases / snapshots
*.sqlite filter=lfs diff=lfs merge=lfs -text
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
*.db filter=lfs diff=lfs merge=lfs -text
# Images
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text
# Documents
*.pdf filter=lfs diff=lfs merge=lfs -text
# Fonts
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.woff filter=lfs diff=lfs merge=lfs -text
*.woff2 filter=lfs diff=lfs merge=lfs -text
# Audio / video
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.webm filter=lfs diff=lfs merge=lfs -text

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# Installed npm dependencies. Restored from package.json on install; never
# committed. (This plugin has no third-party runtime deps -- it uses Node 20's
# global fetch -- but the entry guards against a future dependency slipping in.)
node_modules/
# npm/yarn diagnostics and `npm pack` output.
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.tgz
# Test coverage output.
coverage/
.nyc_output/
# Local environment overrides / secrets. The Postiz base URL and public API key
# are entered in the Saltcorn plugin config UI; if a local .env is used to drive
# the live test gate it must never be committed.
.env
.env.*
# Editor / OS junk.
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.idea/
.vscode/

104
README.md Normal file
View file

@ -0,0 +1,104 @@
# 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 any trigger (row Insert /
Update, or a button). When it fires it reads the post text (and an optional
media URL) from the row, optionally uploads the media to Postiz, and creates
the post against the channels you selected.
- The channel picker in the action config is **populated from the live Postiz
instance** (`GET /public/v1/integrations`), so admins choose real connected
accounts instead of pasting ids. If Postiz is unreachable at config time the
field degrades to a free-text id list.
## Layout
```
postiz/
index.js plugin entry: wires config workflow + the action
lib/
constants.js plugin name/version, Postiz endpoints, post types
postizClient.js the only module that speaks HTTP to Postiz
config.js configuration_workflow (base URL + API key)
action.js post_to_postiz: configFields, payload builder, run
secretsAtRest.js AES-256-GCM seal/open for the API key (shared helper)
test/
e2e.js payload + secrets gates, plus optional live gate
```
## 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/secretsAtRest.js` seals the key with AES-256-GCM under a key derived
(HKDF-SHA256) from Saltcorn's `session_secret` -- which lives in the env /
config file, not the database. Ciphertext in the DB is useless without it.
- Because Saltcorn's config form autosaves raw values, `onLoad` re-seals any
plaintext key the moment the plugin (re)loads after a save; 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 env /
config file. **Rotating `session_secret` invalidates the sealed key** -- 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** -- self-hosted `https://postiz.example.com`, or cloud
`https://api.postiz.com`.
- **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 field, target
channels, and timing (now / schedule / draft).
## Testing
```
node test/e2e.js # pure payload gate only
POSTIZ_BASE_URL=http://localhost:5000 \
POSTIZ_API_KEY=xxxxxxxx node test/e2e.js # + live gate vs a real Postiz
```
The live gate lists integrations and creates a **draft** (safe to delete) using
the first connected channel. With no env vars it is skipped, not failed.
## To confirm before production
These are sketched against the documented Postiz API and isolated to one or two
spots each, but verify against [docs.postiz.com/public-api](https://docs.postiz.com/public-api),
which has changed across Postiz's weekly releases:
- **Create-post body shape** (`type` / `date` / `posts[].integration` /
`value[].content` / `value[].image`) -- see `buildPostPayload` in
`lib/action.js`.
- **Auth header format** -- bare key vs `Bearer` (`AUTH_HEADER` in
`lib/constants.js`).
- **Media upload** -- whether `/upload` accepts a URL fetch or wants multipart,
and the id/path field it returns (`uploadMedia` in `lib/postizClient.js`).
## 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.

35
index.js Normal file
View file

@ -0,0 +1,35 @@
// postiz-poster: Saltcorn plugin that publishes table rows to social networks
// through a Postiz instance (self-hosted or cloud). Exposes one trigger action,
// post_to_postiz, configured per-trigger. The Postiz connection itself lives in
// the plugin configuration (see lib/config.js); the API key is sealed at rest
// (see lib/secretsAtRest.js).
const { PLUGIN_NAME } = require("./lib/constants");
const { configurationWorkflow } = require("./lib/config");
const { makePostAction, sealStoredApiKey } = require("./lib/action");
// Runs each time the plugin loads -- including immediately after a config save
// (Saltcorn calls Plugin.loadPlugin after upsert). Seals any plaintext apiKey
// the autoSave form left in the database.
const onLoad = async () => {
try {
await sealStoredApiKey();
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] sealing stored secrets failed:`, err);
}
};
module.exports = {
sc_plugin_api_version: 1,
plugin_name: PLUGIN_NAME,
configuration_workflow: configurationWorkflow,
onLoad: onLoad,
// Saltcorn calls capability keys with the saved plugin config when a
// configuration_workflow is present (see saltcorn-data db/state.ts).
actions: (pluginCfg) => ({
post_to_postiz: makePostAction(pluginCfg || {})
})
};

139
lib/action.js Normal file
View file

@ -0,0 +1,139 @@
// The post_to_postiz action. Attach it to any Saltcorn trigger (row Insert /
// Update, or a button): it maps the triggering row into a Postiz post and
// publishes through the configured channels.
//
// The Postiz API key is stored sealed in the plugin config (see
// lib/secretsAtRest.js); it is decrypted only at the point of use here.
const { makePostizClient } = require("./postizClient");
const { encryptSecret, decryptSecret, isEncryptedBlob } = require("./secretsAtRest");
const { PLUGIN_NAME, POST_TYPES } = require("./constants");
// Domain label that ties the sealed apiKey to this plugin + field (key
// derivation). Changing it would orphan already-sealed keys.
const API_KEY_INFO = `${PLUGIN_NAME}:apiKey`;
// Resolve the usable plaintext API key from saved config -- whether it is
// sealed (normal) or still plaintext (transiently, right after a config save
// and before onLoad re-seals it). Throws a clear, actionable error if a sealed
// key cannot be opened (e.g. session_secret was rotated).
const resolveApiKey = (pluginCfg) => {
const raw = pluginCfg && pluginCfg.apiKey;
if (!raw) {
return "";
}
if (!isEncryptedBlob(raw)) {
return String(raw);
}
const opened = decryptSecret(raw, API_KEY_INFO);
if (opened === null) {
throw new Error(`${PLUGIN_NAME}: stored Postiz API key could not be decrypted (was SALTCORN_SESSION_SECRET rotated?). Re-enter it in the plugin config.`);
}
return opened;
};
// Build a Postiz client from saved config with the key decrypted for use.
const clientFor = (pluginCfg) => {
return makePostizClient({
baseUrl: pluginCfg && pluginCfg.baseUrl,
apiKey: resolveApiKey(pluginCfg)
});
};
// onLoad migration: Saltcorn's autoSave writes raw form values, so a freshly
// entered apiKey lands in the DB as plaintext. Re-seal it in place so the
// database never retains the plaintext beyond the load that follows the save.
const sealStoredApiKey = async () => {
const Plugin = require("@saltcorn/data/models/plugin");
const plugin = await Plugin.findOne({ name: PLUGIN_NAME });
if (!plugin || !plugin.configuration) {
return;
}
const key = plugin.configuration.apiKey;
if (!key || isEncryptedBlob(key)) {
return;
}
plugin.configuration = { ...plugin.configuration, apiKey: encryptSecret(key, API_KEY_INFO) };
await plugin.upsert();
};
// Pure: (action config, row, media ids, now) -> Postiz create-post body. Kept
// side-effect free so the test suite can assert the shape without a live
// server. NOTE: verify this body against docs.postiz.com/public-api -- the
// schema has churned across Postiz's weekly releases.
const buildPostPayload = (configuration, row, mediaIds, nowIso) => {
const content = row[configuration.contentField];
const integrationIds = String(configuration.integrationIds || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const value = [{ content, image: mediaIds.map((id) => ({ id })) }];
const date = configuration.when === POST_TYPES.SCHEDULE && configuration.scheduleField
? new Date(row[configuration.scheduleField]).toISOString()
: nowIso;
return {
type: configuration.when,
date: date,
tags: [],
posts: integrationIds.map((id) => ({
integration: { id },
value: value,
settings: {}
}))
};
};
// Builds the per-action config form. Populates the channel picker from the live
// Postiz instance so the admin selects real connected accounts; if Postiz is
// unreachable at config time the field falls back to a free-text id list.
const buildConfigFields = (pluginCfg) => async ({ table }) => {
const strFields = (table && table.fields ? table.fields : [])
.filter((f) => f.type && f.type.name === "String")
.map((f) => f.name);
let channelOptions = [];
try {
const integrations = await clientFor(pluginCfg).listIntegrations();
channelOptions = (integrations || []).map((i) => ({
label: `${i.name} (${i.identifier || i.providerIdentifier || "?"})`,
value: i.id
}));
} catch (e) {
// Postiz unreachable / key unset at config time -> admin types ids by hand.
}
return [
{ name: "contentField", label: "Content field", type: "String", required: true, attributes: { options: strFields } },
{ name: "mediaUrlField", label: "Media URL field (optional)", type: "String", attributes: { options: strFields } },
{ name: "integrationIds", label: "Post to channels", type: "String", required: true, attributes: { options: channelOptions } },
{ name: "when", label: "Timing", type: "String", required: true, attributes: { options: Object.values(POST_TYPES) } },
{ name: "scheduleField", label: "Schedule date field", type: "String", attributes: { options: strFields }, showIf: { when: POST_TYPES.SCHEDULE } }
];
};
const makePostAction = (pluginCfg) => ({
configFields: buildConfigFields(pluginCfg),
run: async ({ row, configuration }) => {
const client = clientFor(pluginCfg);
// Postiz wants media uploaded first, then referenced by id.
let mediaIds = [];
if (configuration.mediaUrlField && row[configuration.mediaUrlField]) {
const uploaded = await client.uploadMedia({ url: row[configuration.mediaUrlField] });
mediaIds = uploaded && uploaded.id ? [uploaded.id] : [];
}
const payload = buildPostPayload(configuration, row, mediaIds, new Date().toISOString());
return await client.createPost(payload);
}
});
module.exports = { makePostAction, buildPostPayload, buildConfigFields, resolveApiKey, sealStoredApiKey, API_KEY_INFO };

39
lib/config.js Normal file
View file

@ -0,0 +1,39 @@
// Plugin-level configuration: the Postiz connection (base URL + API key). Set
// once per Saltcorn site under Settings -> Plugins -> postiz-poster. Every
// trigger action reuses this saved connection.
const Workflow = require("@saltcorn/data/models/workflow");
const Form = require("@saltcorn/data/models/form");
const configurationWorkflow = () =>
new Workflow({
steps: [
{
name: "Postiz connection",
form: async () =>
new Form({
fields: [
{
name: "baseUrl",
label: "Postiz base URL",
type: "String",
required: true,
sublabel: "Self-hosted: https://postiz.example.com | Cloud: https://api.postiz.com"
},
{
name: "apiKey",
label: "Public API key",
type: "String",
required: true,
sublabel: "Postiz -> Settings -> Developers -> Public API",
attributes: { input_type: "password" }
}
]
})
}
]
});
module.exports = { configurationWorkflow };

36
lib/constants.js Normal file
View file

@ -0,0 +1,36 @@
// Compile-time constants for the postiz-poster plugin.
const PLUGIN_NAME = "postiz-poster";
const PLUGIN_VERSION = "0.0.1";
// Postiz public REST API. The surface is identical for self-hosted and cloud;
// only the base URL (plugin config) differs. Endpoint paths below are appended
// to <baseUrl><PATH_BASE>.
const PATH_BASE = "/public/v1";
const ENDPOINTS = {
INTEGRATIONS: "/integrations", // GET -> list connected channels
UPLOAD: "/upload", // POST -> media, returns { id, path }
POSTS: "/posts" // POST -> create / schedule a post
};
// Postiz post "type": publish now, schedule for a date, or save as a draft.
const POST_TYPES = {
NOW: "now",
SCHEDULE: "schedule",
DRAFT: "draft"
};
// Header carrying the Postiz public API key. Some versions expect a bare key
// rather than a Bearer prefix; isolated here so a change is one edit, not many.
// Confirm against docs.postiz.com/public-api before shipping.
const AUTH_HEADER = "Authorization";
module.exports = {
PLUGIN_NAME,
PLUGIN_VERSION,
PATH_BASE,
ENDPOINTS,
POST_TYPES,
AUTH_HEADER
};

36
lib/postizClient.js Normal file
View file

@ -0,0 +1,36 @@
// Single source of truth for talking to the Postiz public API. One factory,
// built from the plugin's saved { baseUrl, apiKey }. Self-hosted vs cloud is
// purely the baseUrl -- no other code in the plugin branches on deployment.
const { PATH_BASE, ENDPOINTS, AUTH_HEADER } = require("./constants");
const makePostizClient = (pluginCfg) => {
const baseUrl = String((pluginCfg && pluginCfg.baseUrl) || "").replace(/\/+$/, "");
const apiKey = String((pluginCfg && pluginCfg.apiKey) || "");
const request = async (method, endpoint, body) => {
const url = `${baseUrl}${PATH_BASE}${endpoint}`;
const headers = { [AUTH_HEADER]: apiKey };
const init = { method, headers };
if (body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
const res = await fetch(url, init);
if (!res.ok) {
const text = await res.text();
throw new Error(`Postiz ${method} ${endpoint} -> ${res.status} ${text}`);
}
return res.status === 204 ? null : res.json();
};
return {
createPost: (payload) => request("POST", ENDPOINTS.POSTS, payload),
listIntegrations: () => request("GET", ENDPOINTS.INTEGRATIONS),
uploadMedia: (payload) => request("POST", ENDPOINTS.UPLOAD, payload)
};
};
module.exports = { makePostizClient };

96
lib/secretsAtRest.js Normal file
View file

@ -0,0 +1,96 @@
// Encryption-at-rest for plugin secrets kept in _sc_plugins.configuration.
//
// Saltcorn persists plugin configuration PLAINTEXT in the database, so a DB
// dump, a backup, or a dev-deploy journal sync would otherwise expose any API
// key or signing secret stored there. This module seals such fields with
// AES-256-GCM under a key derived (HKDF-SHA256) from Saltcorn's session_secret,
// which lives in the env / config file -- NOT the database. Ciphertext in the
// DB is therefore useless without the separately-held key.
//
// Threat model: this protects DB-only exposure (backups, peer sync, an admin
// reading the config table). It does NOT protect an attacker who also holds the
// host env / config file -- they have the key. Rotating session_secret
// invalidates every sealed value (the secret must be re-entered in the UI).
//
// Shareable verbatim across plugins: callers pass a domain label (info) so each
// plugin/field derives a distinct key.
const crypto = require("crypto");
const BLOB_PREFIX = "scs1:"; // saltcorn-secret, format v1
const HASH = "sha256";
const GCM = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
const KEY_BYTES = 32;
const kekCache = new Map(); // info label -> 32-byte derived key
const getSessionSecret = () => {
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
if (fromEnv && fromEnv.length > 0) {
return fromEnv;
}
// Fall back to the value Saltcorn loaded into its config state.
try {
const { getState } = require("@saltcorn/data/db/state");
const v = getState().getConfig("session_secret");
if (v) {
return v;
}
} catch (e) {
// state not available (e.g. unit tests) -> fall through to throw
}
throw new Error("secretsAtRest: SALTCORN_SESSION_SECRET unavailable; cannot derive key");
};
const getKek = (info) => {
const cached = kekCache.get(info);
if (cached) {
return cached;
}
const ikm = Buffer.from(getSessionSecret(), "utf8");
const salt = Buffer.from(`${BLOB_PREFIX}${info}`, "utf8");
const kek = Buffer.from(crypto.hkdfSync(HASH, ikm, salt, Buffer.from(info, "utf8"), KEY_BYTES));
kekCache.set(info, kek);
return kek;
};
const isEncryptedBlob = (value) => {
return typeof value === "string" && value.startsWith(BLOB_PREFIX);
};
const encryptSecret = (plaintext, info) => {
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(GCM, getKek(info), iv);
const ct = Buffer.concat([cipher.update(Buffer.from(String(plaintext), "utf8")), cipher.final()]);
const tag = cipher.getAuthTag();
return BLOB_PREFIX + Buffer.concat([iv, tag, ct]).toString("base64");
};
// Returns the plaintext, or null if the blob can't be opened (wrong/rotated
// key, tampering, corruption). Callers treat null as "re-enter the secret".
const decryptSecret = (blob, info) => {
if (!isEncryptedBlob(blob)) {
return null;
}
try {
const raw = Buffer.from(blob.slice(BLOB_PREFIX.length), "base64");
const iv = raw.subarray(0, IV_BYTES);
const tag = raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
const ct = raw.subarray(IV_BYTES + TAG_BYTES);
const decipher = crypto.createDecipheriv(GCM, getKek(info), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
} catch (e) {
return null;
}
};
module.exports = { encryptSecret, decryptSecret, isEncryptedBlob };

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "postiz-poster",
"version": "0.0.1",
"description": "Saltcorn plugin: publish table rows to social networks (Mastodon, Bluesky, X, LinkedIn, Facebook, Instagram, Threads, and more) through a Postiz instance -- self-hosted or cloud -- via a single per-trigger action. Channels are chosen from the live Postiz integration list; post content and optional media come from row fields.",
"main": "index.js",
"scripts": {
"test": "node test/e2e.js"
},
"keywords": [
"saltcorn",
"postiz",
"social-media",
"publishing",
"scheduling"
],
"engines": {
"node": ">=20"
},
"author": "Scott Duensing",
"license": "MIT"
}

162
test/e2e.js Normal file
View file

@ -0,0 +1,162 @@
// postiz-poster integration test suite.
//
// Two gates:
// 1. payloadGate -- pure assertions on buildPostPayload (always runs, no net).
// 2. liveGate -- drives a real Postiz instance; runs only when both
// POSTIZ_BASE_URL and POSTIZ_API_KEY are set.
//
// Run from the postiz/ directory:
//
// node test/e2e.js # payloadGate only
// POSTIZ_BASE_URL=http://localhost:5000 \
// POSTIZ_API_KEY=xxxxxxxx node test/e2e.js # + liveGate
//
// Without the env vars the live gate is skipped (not failed), so the pure gate
// still runs anywhere with no Postiz available. Assertion failures are caught
// and counted; one failure does not stop the suite.
const assert = require("node:assert/strict");
const { buildPostPayload } = require("../lib/action");
const { makePostizClient } = require("../lib/postizClient");
const { encryptSecret, decryptSecret, isEncryptedBlob } = require("../lib/secretsAtRest");
const { POST_TYPES } = require("../lib/constants");
let passed = 0;
let failed = 0;
const check = (name, fn) => {
try {
fn();
passed += 1;
console.log(`ok - ${name}`);
} catch (err) {
failed += 1;
console.log(`FAIL - ${name}: ${err && err.message ? err.message : err}`);
}
};
const checkAsync = async (name, fn) => {
try {
await fn();
passed += 1;
console.log(`ok - ${name}`);
} catch (err) {
failed += 1;
console.log(`FAIL - ${name}: ${err && err.message ? err.message : err}`);
}
};
const payloadGate = () => {
const nowIso = "2026-06-08T20:00:00.000Z";
check("now post targets every channel id, content carried through", () => {
const cfg = { contentField: "body", integrationIds: "a, b ,c", when: POST_TYPES.NOW };
const out = buildPostPayload(cfg, { body: "hi" }, [], nowIso);
assert.equal(out.type, POST_TYPES.NOW);
assert.equal(out.date, nowIso);
assert.equal(out.posts.length, 3);
assert.deepEqual(out.posts.map((p) => p.integration.id), ["a", "b", "c"]);
assert.equal(out.posts[0].value[0].content, "hi");
});
check("media ids attach as image refs", () => {
const cfg = { contentField: "body", integrationIds: "a", when: POST_TYPES.NOW };
const out = buildPostPayload(cfg, { body: "pic" }, ["m1", "m2"], nowIso);
assert.deepEqual(out.posts[0].value[0].image, [{ id: "m1" }, { id: "m2" }]);
});
check("scheduled post reads the date field, not now", () => {
const cfg = { contentField: "body", integrationIds: "a", when: POST_TYPES.SCHEDULE, scheduleField: "at" };
const out = buildPostPayload(cfg, { body: "later", at: "2026-12-25T09:00:00Z" }, [], nowIso);
assert.equal(out.type, POST_TYPES.SCHEDULE);
assert.equal(out.date, "2026-12-25T09:00:00.000Z");
});
check("blank / whitespace channel ids are dropped", () => {
const cfg = { contentField: "body", integrationIds: " , ,", when: POST_TYPES.DRAFT };
const out = buildPostPayload(cfg, { body: "x" }, [], nowIso);
assert.equal(out.posts.length, 0);
});
};
const secretsGate = () => {
// secretsAtRest reads the key root from this env var (see getSessionSecret).
process.env.SALTCORN_SESSION_SECRET = process.env.SALTCORN_SESSION_SECRET || "test-session-secret-do-not-use";
const info = "postiz-poster:apiKey";
check("seal -> open round-trips the plaintext", () => {
const blob = encryptSecret("super-secret-key", info);
assert.ok(isEncryptedBlob(blob), "blob should carry the scs1: prefix");
assert.notEqual(blob, "super-secret-key");
assert.equal(decryptSecret(blob, info), "super-secret-key");
});
check("ciphertext differs each call (random IV)", () => {
assert.notEqual(encryptSecret("x", info), encryptSecret("x", info));
});
check("wrong domain label cannot open the blob", () => {
const blob = encryptSecret("k", info);
assert.equal(decryptSecret(blob, "postiz-poster:other"), null);
});
check("tampered blob fails GCM auth -> null", () => {
const blob = encryptSecret("k", info);
const i = "scs1:".length + 2;
const flipped = blob[i] === "A" ? "B" : "A";
const tampered = blob.slice(0, i) + flipped + blob.slice(i + 1);
assert.equal(decryptSecret(tampered, info), null);
});
check("plaintext is not mistaken for an encrypted blob", () => {
assert.equal(isEncryptedBlob("plain"), false);
assert.equal(decryptSecret("plain", info), null);
});
};
const liveGate = async () => {
const baseUrl = process.env.POSTIZ_BASE_URL;
const apiKey = process.env.POSTIZ_API_KEY;
if (!baseUrl || !apiKey) {
console.log("skip - liveGate (set POSTIZ_BASE_URL + POSTIZ_API_KEY to enable)");
return;
}
const client = makePostizClient({ baseUrl, apiKey });
let firstChannel = null;
await checkAsync("listIntegrations returns an array", async () => {
const integrations = await client.listIntegrations();
assert.ok(Array.isArray(integrations), "expected an array of integrations");
firstChannel = integrations[0] || null;
});
await checkAsync("createPost (draft) is accepted", async () => {
if (!firstChannel) {
console.log(" (no channels connected -- connect one in Postiz to exercise createPost)");
return;
}
const cfg = { contentField: "body", integrationIds: String(firstChannel.id), when: POST_TYPES.DRAFT };
const payload = buildPostPayload(cfg, { body: "postiz-poster e2e draft -- safe to delete" }, [], new Date().toISOString());
const res = await client.createPost(payload);
assert.ok(res, "expected a create-post response");
});
};
const main = async () => {
payloadGate();
secretsGate();
await liveGate();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
};
main();