Skip to content

GoatFlow 0.10.0: The Real Release

In 0.9.0 we made GoatFlow easy to stand up. In 0.10.0 we made it tell the truth once it’s running.

This is the biggest release since the platform/product split: 93 commits and 263 changelog entries. A lot of GoatFlow’s surface area was laid out early as a skeleton, with the routes, screens and settings in place so the shape of the product was clear. 0.10.0 is where much of that skeleton gets its meat: ticket notifications that are evaluated and sent, SLA escalation computed in working time, a reports page with real numbers, search backends that reindex, dashboards that load live data. We call it the real release because it fills in what the earlier releases sketched.

Mountain goats on a rocky alpine slope

Sign-In and Self-Service

  • LDAP / Active Directory login works. The ldap provider used to return “not yet implemented”. It now searches the directory, binds as the user to check the password, and verifies the server certificate on StartTLS/LDAPS.
  • Forgotten-password reset for agents and customers, plus customer self-registration. Reset links and password re-checks are rate limited.
  • Passkey management and recovery codes. Your profile lists each passkey with when it was added and last used, warns when a key was set up on a different host name, and lets you remove it. Passkey-only users get recovery codes.
  • Every login now has a session. OIDC, SAML and POST /api/v1/auth/login used to hand out tokens with no session behind them, so “log out” and Admin → Sessions → kill did not revoke anything. Tokens now carry a session id (sid) and are refused the moment the session is gone.
  before                         after
  ──────                         ─────
  login ─► JWT                   login ─► session row ─► JWT{sid}
  logout ─► cookie cleared       logout ─► session deleted
           JWT still valid                 JWT{sid} refused on every route

That change has one cost you will notice: everyone signs in again once after upgrading. Tokens from 0.9.0 have no sid, so they are refused.

The GoatFlow login page: password, single sign-on, security key and Forgot Password

PostgreSQL, For Real

0.9.0 could not be installed on PostgreSQL at all: golang-migrate stopped at migration 5 with “duplicate migration file”. 0.10.0 runs the full suite against both MariaDB and PostgreSQL, and the SQL portability lint (cmd/gk-lint) now checks every query against the schema of both databases in pre-commit.

0.9.00.10.0
Fresh PostgreSQL installfails at migration 5works
MySQL-only SQL in Go codecaught in review, sometimescaught by gk-lint
Unknown table/column in a queryruntime errorlint error
Upsertshand-written per driverdatabase.ConvertUpsert

If you tried 0.9.0 on PostgreSQL, install 0.10.0 fresh. There is no 0.9.0 PostgreSQL database to upgrade.

OTRS-Compatible Storage

Attachments and raw article bodies can now live where OTRS keeps them, and goatflow-storage is a working CLI for moving between database and filesystem storage. Together with the import tooling, an OTRS or Znuny installation can come across without rewriting its article store first.

The Plugin Platform Checks Its Inputs

0.10.0 is the release where the plugin sandbox stopped trusting plugins to be polite.

Puzzle pieces

  • Permissions are enforced. Every HostAPI call and every SQL statement a plugin runs is checked against the resources.permissions it declared. gRPC plugins used to bypass the sandbox; they now go through it too.
  • Org scoping holds. A plugin query that mentioned org_id could skip the organisation filter. It can’t any more.
  • Plugins run as the caller. Routes, widgets, plugin UIs and MCP calls carry the user’s identity, organisation and language.

Plugins also got new HostAPI capabilities: CreateArticle, article attachments, RenderMarkdownToPdf with branding, and a shared Tiptap editor partial.

A Marketplace That Remembers

The marketplace index used to describe one version per plugin. Publish a release that needs a newer GoatFlow, and every older host was simply locked out.

A wooden library card catalogue

Now each entry can carry its release history:

{
  "name": "goat-kb",
  "latest_version": "0.2.0",
  "min_host_version": "0.10.0",
  "versions": [
    { "version": "0.2.0", "min_host_version": "0.10.0" },
    { "version": "0.1.1", "min_host_version": "0.8.0" },
    { "version": "0.1.0", "min_host_version": "0.8.0" }
  ]
}

GoatFlow picks the newest version it can actually run:

  host 0.9.x  ──► goat-kb 0.1.1   (0.2.0 needs 0.10.0, skipped)
  host 0.10.0 ──► goat-kb 0.2.0

Older GoatFlow builds ignore the versions field and behave exactly as before. The admin marketplace page shows a version picker and a “Requires GoatFlow ≥ X” badge, and gk install [email protected] pins a version from the CLI.

Three gaps closed along the way:

  1. Update checked nothing. Only install looked at min_host_version. Update now checks too.
  2. A failed update deleted your plugin. Update removed the installed plugin before downloading the new one. It now downloads and verifies the signature in a staging folder, and only swaps once that succeeds.
  3. Plugins were told they run on GoatFlow 0.6.4. The gRPC runtime hard-coded it. They now get the real version, and a plugin that needs a newer GoatFlow is refused at load with a clear log line instead of failing later.

On the admin marketplace page, goat-kb on an older install shows the update with a version picker; goat-kanban is up to date:

Admin marketplace: goat-kanban installed, goat-kb update to v0.2.0 with a version picker

Two plugins ship on the new index today: goat-kb 0.2.0 and the new goat-kanban 0.1.0, drag-and-drop boards over real ticket states, scoped to the queues you can see.

goat-kanban: the ticket palette searching for CopperForge tickets next to the board’s state columns

Things That Said Yes

A sample of the 154 entries under Fixed:

AreaBeforeAfter
Ticket notificationssaved, never sentsent, recipients follow OTRS rules
SLA escalationtimes never computedfirst response / update / solution, events raised
PUT /api/tickets/:idanswered 200, saved nothingsaves, records history
Zinc / Elasticsearchreindex answered 501works, honours queue permissions
Dashboard widgetsshowed 0 on errorshow data or say they can’t
make test-unitcould pass after running almost nothingfails when a package fails to load

One page that used to say “under construction” now has real numbers: Admin → Reports & Analytics shows ticket totals, a created-vs-closed trend, per-queue backlog and agent activity, with CSV and JSON exports.

Admin Reports & Analytics with a 12-month created-vs-closed chart

Operations

Server racks in a data centre

  • GET /health performs real checks; Prometheus metrics on /metrics; structured logs controlled by LOG_FORMAT / LOG_OUTPUT.
  • Graceful shutdown with connection draining, sized to fit the stop grace period.
  • Scheduled jobs and startup migrations run once across replicas, under a lock. A failed migration is no longer marked as applied.
  • The Helm chart works out of the box: a runner Deployment, persistent file storage, working probes, Valkey and Ingress. The air-gapped bundle contains every image the chart needs.
  • Upgrades install the new versions of bundled plugins, keeping any file an admin changed.

Compose users: deploy/docker-compose.yml now requires GOATFLOW_SECURE_KEY (64 hex characters, openssl rand -hex 32) and SMTP_HOST in .env. The key must be the same for the app, customer portal and runner, so compose refuses to start without it rather than letting each process invent its own.

The Security Sweep

A brass padlock on a white gate

32 entries under Security, and a vulnerability assessment before the tag. The highlights:

  • SQL injection in the customer ticket list order parameter.
  • Stored XSS in the ticket view’s description card.
  • POST /api/v1/auth/login ignored the second factor.
  • SSO login CSRF: OIDC state and SAML RelayState are now bound to the browser that started the login.
  • Identity-provider and webhook URLs could reach internal addresses.
  • Identity-provider secrets and webhook headers are encrypted at rest.
  • Database error text no longer reaches the client, in GoatFlow and in 13 first-party plugin repos.

By the Numbers

  • 93 commits since 0.9.0
  • 263 changelog entries: 30 added, 22 changed, 154 fixed, 32 security, 22 removed
  • 33 migrations (26 in 0.9.0), identical on MariaDB and PostgreSQL
  • 2 databases the full test suite runs against
  • 2 plugins on the marketplace with per-version compatibility
  • Go 1.25.12, grpc v1.83.2, goldmark v1.7.17, golang-jwt v5.3.1

What’s Next

1.0.0 is the production release, and we’re getting there in small steps rather than one big drop. Each release takes a slice of the 1.0.0 list and ships it properly, so that 1.0.0 itself is just the release candidate once it has held up.

ReleaseTheme
0.11.0Security basics: security policy and CVE process, a general API rate limit, vulnerability scanning in CI, an OWASP Top 10 review, a hardening guide
0.12.0Performance and observability: a load-test harness and baseline, tuning driven by it, OpenTelemetry tracing, circuit breakers
0.13.0Documentation and quality: the full API reference, admin, troubleshooting and plugin guides, measured test coverage
0.14.0A Calendar & Appointments plugin
0.15.0 – 0.18.0Process management in core, on the OTRS data model: the engine and OTRS process import, then activity dialogs, transitions and actions, and process SLAs
0.19.0 – 0.20.0A process designer plugin: forms first, then drag-and-drop
1.0.0-rcOTRS module check, a third-party security audit, 85% test coverage

No dates beyond the next release. The full list is in the ROADMAP.

Bonus Track: Sixteen Characters of Accept

Every release has one bug worth retelling. 0.10.0’s was found by the demo deploy smoke test, and it lived in the code that protects the demo.

On a public demo, GoatFlow blocks non-admins from changing passwords or MFA. JSON clients get a 403, browsers get redirected. To decide which, the guard checked the Accept header:

func wantsJSON(c *gin.Context) bool {
	accept := c.GetHeader("Accept")
	return accept == "application/json" ||
		c.GetHeader("X-Requested-With") == "XMLHttpRequest" ||
		c.ContentType() == "application/json" ||
		len(accept) > 0 && accept[:16] == "application/json"
}

len(accept) > 0 was meant to guard the slice. It guards nothing: accept[:16] needs sixteen bytes, not one. curl sends Accept: */*. Three bytes. Every curl request to a guarded page panicked with slice bounds out of range [:16] with length 3 and came back as a 500.

Browsers never hit it, because their Accept header is long. So the demo looked fine for everyone who used it in a browser, and broke for the first script that asked politely.

The fix is the function the code was trying to be:

func wantsJSON(c *gin.Context) bool {
	return strings.HasPrefix(c.GetHeader("Accept"), "application/json") ||
		c.GetHeader("X-Requested-With") == "XMLHttpRequest" ||
		c.ContentType() == "application/json"
}

The same smoke test found the guard’s second problem: with no Referer, the browser redirect pointed the page back at itself. It now falls back to the dashboard. Both have regression tests that fail on the old code.


  • Source & containers: GoatFlow on GitHub
  • Full changelog: CHANGELOG.md
  • Helm chart: oci://ghcr.io/goatkit/charts/goatflow
  • Photos: CC0 / public domain from Wikimedia Commons — mountain goats by Jonatan Moerman, puzzle pieces by Profpcde, card catalogue by MarkBuckawicki, server racks by Derrick Coetzee, padlock by Bich Tran.

Questions? Feedback? Open a GitHub Discussion and let us know what you think!

Back to Blog