Aug 172026v0.6.6featurefixsecurity27 updatesCopy link
Security
Job writes are authorized against the job's own target servers
Jobs are instance-wide, so the
job:writepermission (which checks organization membership) is not by itself authority over the servers a command job runs on. Editing and deleting a job now go through the same per-target server check that creating and running one already did, and a denial is indistinguishable from a job that doesn't exist. Reported externally; regression tests added. Self-hosted instances with more than one trust level should upgrade; Openship Cloud was never affected (the Jobs API islocalOnly).
Credentials
One store for third-party secrets
A provider registry (container registries, Cloudflare, and room for what comes next) behind one table, with every secret sealed in a single
enc1:envelope rather than a column per field. A credential is verified against its provider before it is stored, so a bad token is rejected where you paste it instead of where a deploy needs it.Private images pull on every host
Registry auth is resolved per image from that store (#581). On a remote host the config goes to a temporary
DOCKER_CONFIGdirectory (0700, file0600) removed whether the pull succeeds or fails, so a pull never edits the operator's own~/.docker/config.json. Local pulls still go through the daemon's own credential store, which is what keeps a Docker DesktopcredsStoreworking.DNS credentials fold into it
Existing Cloudflare tokens are re-wrapped at boot. A token that cannot be decrypted (a rotated
BETTER_AUTH_SECRET) is left exactly where it is, and the legacy row is deleted only once the new one exists — a crash mid-move leaves a duplicate, never zero credentials.Git credentials get their own tab
Clone credentials were filed under API tokens, which is neither where you look for them nor somewhere a second Git provider could go. "API tokens" now means only that.
Inbound mail
A mailbox can be read
Inbound capture, read and watch, so mail that arrives is stored and retrievable rather than merely delivered.
Inbound filtering fails closed
Loop guards, a spam gate, and scope matching that drops a message it cannot confidently attribute instead of guessing an owner for it.
mail.inbound_receivedis a notificationInbound mail can drive a channel, in a mail group that appears only on self-hosted instances.
Openship Mail
Mailbox creation runs in the engine, not on the host
doveadmandchownran on the host, where they don't exist, so creating a mailbox returned a 500 (GH-562).The database bootstrap fails loudly
The script had no
set -e, so a failed step still reported success and left a half-provisioned engine looking healthy (GH-562).The mail screens look like the product
The channel picker is now the same component the notification and job screens use, with real brand marks in place of a native checkbox that renders in the browser's colours rather than the theme's, and the admin tabs drop the borders and status dots that matched no pattern here.
App catalog
Every app installs
Each catalog app now ships a UI and a connection, rather than installing into something you couldn't open.
verifiedmeans upstream-publishedThe badge is reserved for bundles published upstream, so it stops implying a review it never represented. Neon is no longer marked verified (neond is a community control plane), and the experimental badge is off both Neon and PostHog.
Neon survives real install timing
Its bootstrap assumed a readiness it never waited for.
Migration
A selection resolves by identity, not by name
The wizard sent names and the server matched on them (#584), so two containers sharing a name collapsed into one pick, and a name matching something outside the selection could be adopted. A selection now carries container ids, falling back to names only for a service that has none.
Openship's own stack is excluded, not a blocker
The containers running Openship are skipped with the reason named, instead of the whole migration refusing to start.
Two fixes to what a vhost scan imports
Nginx's own default vhost is skipped rather than adopted as a site, and prefix-relative document roots resolve against their prefix.
Edge
Uploads default to 50 MB
Nginx's built-in limit is 1 MB, and a project that never opened the proxy panel inherited it, so any real upload died with a 413 the app never saw.
client_max_body_size 50mnow ships athttpscope in every edge config, which leaves a project's own value winning wherever one is set. Hosts installed before this are healed in place, and a value an operator tuned themselves is never overwritten.
Audit log
Its own page
Nothing on it is a setting: you never change anything there, you read what already happened. It sat three clicks inside Settings, which is how a review surface goes unread.
Every filter is in the URL
Filters and pagination are query parameters, so a view can be linked, shared and reloaded.
The uncatalogued events are catalogued
Events emitted without a taxonomy entry now have one.
Fixes
openship reset-admin-passwordworks on a Compose installIt authenticated with
~/.openship/internal-token, a file the Compose path never writes: the api container is booted with theINTERNAL_TOKENfrom~/.openship/compose/.env. So on a Compose box the command minted a brand-new random token, sent that, and reportedUnauthorized— the lockout-recovery command was unusable on exactly the install that needed it. Which token this box is running with is now resolved in one place, readers never mint, and a root-owned.envthis user can't open says so (re-run with sudo) instead of reporting an authorization failure. Same fix reaches the control panel's "Reset admin password",openship doctor(whose health readout came back empty on every Compose stack), and a bare box's:80/:443takeover, which looked for the Compose token and skipped importing the migrated sites after stopping the operator's proxy.A password reset uses a 6-digit code
Rather than an emailed link.
A cancelled deployment keeps its reason
The failure message was gated on
failedalone, which blanked the reason on every cancelled row. But a cancel is not always your Stop: the boot sweep cancels with "Interrupted by a server restart", and a superseded partial failure records why. With the reason discarded, the install wizard had only its generic "Install failed" left to print over a row that saidcancelled.An install's verdict and its reason agree
The wizard decided "was this a cancel?" and "what do I show?" independently, so a stopped install could print "Install failed" underneath the heading "Install cancelled".
Verify appears only once a domain row exists
The optimistic row fell back to the bare hostname as its id, and every guard downstream reads that id as proof the server row exists. So a hostname with no row yet rendered a live Verify button that 404'd, along with a DNS-records panel that couldn't load.
An abbreviated commit is not a new commit
POST /deploymentstakescommitShaas whatever the caller sends (openship deploy --commit 1eeaf76, the MCP deploy tool, a CI script), and git checks an abbreviation out happily: the right code shipped while the row recorded a name no comparison could match. The drift check compared it against the 40-char branch HEAD, and since both sides render seven characters, the project page advertised "New commit available 1eeaf76 … you're deployed on 1eeaf76" — permanently, with a Redeploy that could never clear it. Two shas now name the same commit when one is a prefix of the other at git's own abbreviation floor, a ref that is not a sha at all (a tag,HEAD) reads as "can't tell" rather than as drift, and a caller's ref is resolved to the full sha before anything stores or compares it — which also unbreaks the per-service commit checks GitHub rejects a short sha for, and the webhook's already-deploying dedupe.