Why things are like this: DECISIONS.md (D-xx). Message definitions: proto/. Rules and plugin contract: CONTRACTS.md. Build order: ROADMAP.md.
Browser ──HTTPS (proxy) / HTTP localhost──► web container (React SPA + own Fastify BFF) nets: public, backend
│ gRPC / TCP / mTLS — the ONLY thing web talks to
┌─────────────────────────────── server container ───────────────────────────────────┐ nets: backend, dbnet, egress
│ Controller (PID 1) ── starts, assigns rights, restarts ──┐ │
│ ▼ │
│ Watchdog ──health via Gateway──► IPC Gateway ◄──── every message passes here ────│
│ ▲ ▲ ▲ ▲ │
│ Executioner ───────────────┘ │ │ └── File-System ── media volume │
│ │ spawns + checks │ └───── Plugin-Mgmt (PMP) ── plugins vol │
│ ▼ private channel │ │
│ Plugin A / Plugin B (own UID) └── Gateway ──TCP, SCRAM──► db (PostgreSQL)│ net: dbnet
└──────────────────────────────────────────────────────────────┬──────────────────────┘
▼ outbound only (egress)
Provider APIs (e.g. Meta)
| Container | Contents | Networks | Published |
|---|---|---|---|
web |
frontend + own BFF | public, backend |
yes, 127.0.0.1:8080 by default |
server |
Controller + Watchdog, Gateway, Executioner, PMP, File-System, plugin sub-processes | backend, dbnet, egress |
no |
db |
PostgreSQL 18 | dbnet only |
no |
proxy (profile tls) |
Caddy TLS | public |
80/443 |
backend and dbnet are internal (no internet). egress is attached to server only. Web cannot reach the DB; the DB cannot be reached from anything but the server container. No Docker socket anywhere. Hardening everywhere: read-only rootfs, cap_drop: ALL, no-new-privileges, pids/memory limits.
| Process | UID | Capabilities | Secrets | DB | Role |
|---|---|---|---|---|---|
| Controller (PID 1) | root | SETUID, SETGID, CHOWN, KILL, DAC_READ_SEARCH |
reads all, re-homes | no | spawn, assign rights, restart crashed, signals, ordered shutdown |
| Watchdog | own | none | none | no | health checks via Gateway, status, restart requests |
| IPC Gateway | own | none | DB password, master key, TLS server key + CA | yes (only client) | door, router, authn/authz, ownership, audit, DB ops, credential crypto |
| Executioner | own | ambient SETUID, SETGID only |
none | no | loads plugins as sub-processes, verifies their rights, rights-checks + approvals, job scheduler |
| Plugin-Mgmt (PMP) | own | none | none | no | writes/stores plugin packages, pins hashes, registry ops |
| File-System | own | none | none | no | stores files on the media volume |
| Plugin n | 20000+ each | none | none (leases per call) | no | provider integration |
/run/postflowx/ (tmpfs, created by the Controller)
gateway/<process>.sock one socket dir per process: only that process UID + Gateway UID (= identity)
gateway/health.sock Gateway health, status only (probed directly by the Watchdog)
controller-watchdog private socketpair (inherited at spawn): restart/stop/status + heartbeat
secrets/<process>/ re-homed secrets, 0400, owner = that process UID
Executioner ↔ plugin private inherited socketpair per plugin (never visible to others)
Rule: every message between processes passes through the Gateway. (Process-lifecycle control and the two private channels below carry no application data.)
| From → To | Transport | Caller identified by |
|---|---|---|
| Browser → BFF | HTTP(S) | session cookie (validated at the Gateway) |
| BFF → Gateway | gRPC / TCP / mTLS | client cert CN=postflowx-web |
| Process → Gateway → process | gRPC / UDS | per-process socket dir (+ UID); Gateway assigns sender, checks route ACL |
| Plugin ↔ Executioner | private channel | the channel itself (one per plugin); Executioner attaches actor from the call context |
| Controller ↔ Watchdog | private socketpair | inherited at spawn; only restart/stop/status + heartbeat |
| Controller → children | fork/exec, waitpid, signals |
parent/child |
| Gateway → PostgreSQL | TCP on dbnet |
SCRAM-SHA-256, password only the Gateway can read |
| Plugin → provider | HTTPS via egress |
credential leased through Executioner → Gateway |
Forbidden: browser → anything but the BFF; web → DB or any process except the Gateway; plugin → Gateway, DB, Docker socket, volumes, other plugins; any process-to-process socket other than via the Gateway; raw SQL from web or any process; a client- or plugin-supplied user/plugin ID treated as identity; anything except web published.
- Web: serve SPA; cookie transport, CSRF/Origin; validation; error mapping; typed calls to the Gateway only; generic routes
GET /api/connect/{plugin}/callbackandGET /media/public/{token}; renders plugin descriptors as forms (escaped); shows the approval queue to the admin (SSE). No DB, no domain logic, no authority. - Controller: see D-07. Static process list, config table of rights per process. Listens to nothing but the Watchdog channel. No network.
- Watchdog: health request to every process through the Gateway every 5 s; probes the Gateway directly; states
STARTING, HEALTHY, DEGRADED, UNRESPONSIVE, CRASH_LOOP, STOPPED, UNKNOWN; unhealthy → asks the Controller to restart; publishes status for the admin page. Never starts/kills itself. - Gateway: terminate mTLS; per-process identity; route table (deny by default); limits and deadlines; correlation IDs; authentication (sessions), role checks, ownership scoping, audit; typed DB operations (users, sessions, plugins registry, accounts, credentials, posts, jobs, plugin data, approvals, audit); credential encryption; signed media tokens.
- Executioner: reads the registry; spawns and supervises plugin sub-processes (verify pinned hash → distinct UID → verify rights in
/proc); plugin ↔ Executioner channel; per-request rights check (trusted/untrusted, grants, approvals); call contexts (call_id); job scheduler and attempts; manifest tasks; reports plugin states. - PMP: only writer of the
pluginsvolume; package validation (strictmain.json), path safety, SHA-256 pinning; registry rows via the Gateway; install/enable/disable/trust/uninstall operations. - File-System: only process touching the
mediavolume (0700);put/get/stat/delete/listwith chunked streaming; ID-based paths, atomic writes, magic-byte + size validation, owner scoping and quotas. - PostgreSQL: state only; on
dbnet; application role DML only. - Plugin: implements
PluginService, uses the Host API over its private channel to the Executioner (CONTRACTS.md).
Request: Browser → BFF (CSRF/validate) → Gateway (mTLS, session → actor, route ACL) → typed DB operation or routed call to a process → result → BFF → browser.
Plugin request with approval (local): plugin → (private channel) Executioner checks the plugin's trust level and grants. trusted + granted → forward to the Gateway. untrusted → Executioner holds the request and creates an approval entry through the Gateway (local DB). The BFF follows pending entries through the Gateway and pushes them to the admin's browser; the admin answers deny / allow once / allow always — or uses postflowx-admin approvals … on the CLI. The decision is stored and audited; the Executioner releases or rejects (PERMISSION_DENIED); no answer within 60 s = deny. Nothing leaves the stack.
Connect account (OAuth): user picks plugin → Gateway op accounts.begin_connect creates a single-use state bound to user/session (TTL 10 min) → Executioner creates a call context and calls plugin BeginConnect → provider URL → browser redirect → provider redirects to /api/connect/{plugin}/callback → BFF forwards raw params + session → Gateway validates and consumes state → Executioner calls HandleCallback → plugin exchanges the code → UpsertAccount + StoreCredential (via Executioner → Gateway; encrypted there).
Publish: composer (caption, media, account, provider options from the descriptor, schedule + IANA tz) → Gateway validates (schema) → Executioner calls plugin ValidatePost → job SCHEDULED (UTC) → Executioner's scheduler claims (SKIP LOCKED, lease) → attempt CLAIMED → call context → mark DISPATCHED before calling plugin Publish (deadline) → plugin leases credential, gets media (via File-System ops or signed URL), calls the provider, CheckpointAttempt before irreversible steps → outcome class → state update with backoff for retryable. Timeout/crash after dispatch → OUTCOME_UNKNOWN → Reconcile with checkpoint; only NOT_PUBLISHED auto-retries. After an Executioner or Gateway restart: expired lease while CLAIMED → safe retry; while DISPATCHED → OUTCOME_UNKNOWN.
Plugin lifecycle: DISCOVERED → VALIDATING → INSTALLED → ENABLED → STARTING → READY. CLI installs via PMP; admin sets trust level, grants and enables; Executioner verifies hash, spawns, verifies rights, health check + Describe with compatible API → READY. Failures: INVALID, INCOMPATIBLE, START_FAILED (incl. rights deviation), UNHEALTHY, CRASH_LOOP, DISABLED, UNINSTALL_PENDING.
Schema core (Gateway only): users, sessions, user_roles, plugins, plugin_versions, plugin_grants, plugin_approvals, social_accounts, credentials (ciphertext, key_id, expires_at, state), media, posts, publishing_jobs, job_attempts (state, lease, dispatched_at, checkpoint, external ids), plugin_data, connect_states, public_media_tokens (hashed), audit_events, schema_migrations.
Admins have no implicit access to other users' posts, media or credentials.
| Volume / path | Written by | Read by |
|---|---|---|
pgdata |
db |
db only |
media → /var/lib/postflowx/media |
File-System only (0700) | File-System only |
plugins → /var/lib/postflowx/plugins |
PMP only | Executioner (exec/read), PMP |
/run/postflowx, /tmp (tmpfs) |
Controller / per process | sockets, re-homed secrets, per-plugin temp (0700) |
The plugins volume is mounted read-write in the server container (PMP writes at runtime); only the PMP UID has write permission.
Compose waits for db health → server: Controller starts Gateway → File-System → PMP → Executioner → Watchdog. web starts independently and shows a degraded screen if the server is down. Docker healthcheck of server = Controller's view of the five platform processes (plugin failures only degrade). Crash → Controller restarts (backoff); hung → Watchdog asks the Controller; Gateway/Controller crash loop → container unhealthy → Docker restarts. Shutdown: Watchdog stops first (no restart storms) → Executioner drains jobs and plugins (≤ 30 s) → PMP → File-System → Gateway (stop_grace_period 45 s).