Wire table gains the search, private-message, webhook and SSO rows; the library surface shows Search/SendMessage calls and a server-side helper section (WebhookHandler, SSOLogin, secret loaders); the CLI reference lists the four new commands including their secret env vars. env.example documents the DISCOURSE_KEY alias and the two commented-out secrets. Part of Redmine 507 (Discourse Go client).
mopac-discourse-go
A 100% Go, stdlib-only client for the Discourse REST API (admin API key auth). One static binary, zero third-party modules, no official SDK — the MOPAC outbound-CLI for Discourse (SPEC-20260829 "supporting cast": Discourse is where briefings, usage reports and agent documentation land). AGPLv3.
Status: 2026-08-29 — v0 complete and green: categories (list/create), topics (create/list/latest/get), posts (create/update/get), current-user identity probe, raw JSON passthrough for everything else, typed error classes, thin CLI. Same-day additions (Redmine 507): full-text search, private-message send, and the webhook + SSO (Discourse Connect) secret helpers. Built and tested entirely against a fake Discourse (no live writes). Live reads verified against community.turnsys.com; category creation 403s with the current key (needs the admin-scoped key) — see "Live verification" below.
What it implements
The wire protocol, in plain REST with stdlib:
| Surface | Wire |
|---|---|
| whoami | GET /session/current.json — cheapest url+key+username check |
| categories list | GET /categories.json |
| categories create | POST /categories.json — name, color, text_color, permissions (group name -> 1/2/3); needs an admin-scoped key |
| topics create | POST /posts.json — first post with title+raw+category; response carries id (first post), topic_id, topic_slug |
| topics list | GET /c/<id>.json, GET /c/<slug>.json, GET /c/<slug>/<id>.json — category topic lists |
| topics latest | GET /latest.json |
| topics get | GET /t/<id>.json |
| posts create | POST /posts.json — topic_id+raw |
| posts update | PUT /posts/<id>.json — {post: {raw, edit_reason}} |
| posts get | GET /posts/<id>.json |
| search | GET /search.json?term=...&page=N — grouped posts/topics/users/categories hits; term operators (@user, #category, order:latest) pass through |
| message send | POST /posts.json — title+raw+target_usernames/target_group_names/target_emails (comma-joined) instead of a category; creates a private-message topic |
| webhook verify | X-Discourse-Event-Signature — sha256-HMAC hex (with sha256= prefix) over the raw body, constant-time compare |
| SSO verify/response | Discourse Connect — sha256-HMAC hex over the base64 sso string; payload = urlencoded fields (nonce, return_sso_url / email, external_id, ...) |
| raw anything | Do(ctx, METHOD, path, body, out) — JSON in, JSON out; unmodeled endpoints never block on a client release |
Auth is the header pair Api-Key + Api-Username on every request.
Library surface (what harness brief calls)
import "git.knownelement.com/ukrrs/mopac-discourse-go"
c, err := discourse.New(baseURL, apiKey, apiUsername) // or NewFromEnv()
cats, _ := c.ListCategories(ctx)
cat, _ := c.FindCategory(ctx, 0, "mopac-briefings") // id OR slug
cat, err := c.CreateCategory(ctx, discourse.CreateCategoryRequest{
Name: "MOPAC Briefings", Color: "3AB54A", TextColor: "FFFFFF",
Permissions: map[string]int{"staff": 3},
}) // ErrForbidden until the admin-scoped key lands
res, _ := c.CreateTopic(ctx, discourse.CreateTopicRequest{
Title: "MOPAC briefing 2026-09-01", Raw: markdown, Category: cat.ID,
})
res.URL(baseURL) // https://forum/t/<slug>/<topic_id>
p, _ := c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: res.TopicID, Raw: "reply"})
_, _ = c.UpdatePost(ctx, p.PostID, "edited", "typo")
hits, _ := c.Search(ctx, discourse.SearchRequest{Term: "fleet briefing", Page: 1})
// hits.Posts[0].TopicID / .Blurb; also .Topics, .Users, .Categories
pm, _ := c.SendMessage(ctx, discourse.SendMessageRequest{
Title: "Briefing landed", Raw: markdown,
TargetUsernames: []string{"ops", "reachableceo"},
})
pm.URL(c.BaseURL()) // private-message topic link
Webhook + SSO helpers (server side)
// webhook receiver: verify before trusting anything
http.Handle("/hooks/discourse", discourse.WebhookHandler(secret, func(evt *discourse.WebhookEvent) {
// evt.Event == "post_created" etc.; evt.Body is the raw JSON
}))
err := discourse.VerifyWebhook(secret, body, sigHeader) // standalone
// Discourse Connect (SSO) login endpoint
redirect, err := discourse.SSOLogin(ctx, secret, r.FormValue("sso"), r.FormValue("sig"),
map[string]string{"email": u.Email, "external_id": u.ID, "username": u.Handle})
http.Redirect(w, r, redirect, http.StatusFound)
// secrets: env-only, trimmed, never logged
secret, _ := discourse.WebhookSecretFromEnv() // DISCOURSE_WEBHOOK_SECRET
secret, _ := discourse.SSOSecretFromEnv() // DISCOURSE_SSO_SECRET
Bad signatures are the typed ErrBadSignature — drop the delivery.
Errors classify via errors.Is: ErrForbidden (403 — key lacks scope),
ErrUnauthorized (401), ErrNotFound, ErrRateLimited (429, with
APIError.RetryAfter), ErrServer, ErrUnreachable,
ErrMalformedResponse, ErrInvalidRequest. Every server failure is
also an *APIError carrying method, path, status and the
Discourse-reported reasons.
The key is constructor/env-only, never a flag, never logged:
Client.String() renders base url + acting username, and error strings
carry only paths and status codes (the redaction sweep in the smoke
greps every captured output for the key).
Quickstart (verified 2026-08-29, all against the fake server)
All dev work happens inside a Docker builder (host stays toolchain-free).
./dev.sh check # = go build + go vet + go test, inside golang:1.26-bookworm
Expected output (tail):
ok git.knownelement.com/ukrrs/mopac-discourse-go
End-to-end smoke — builds the CLI, boots the fake Discourse in a
container on 127.0.0.1:8610, drives the real binary from the host
through a 0600 env file (whoami / category list+create / topic create /
topic list by id and slug / post reply / post update / raw passthrough /
search / private-message send / webhook verify good+bad sig / sso
verify good+bad sig / typed 404 / redaction sweep / bad-key exit code):
./dev.sh smoke
Expected output (tail):
smoke: OK
Configure
Credentials NEVER arrive via flags or arguments:
mkdir -p ~/.config/discourse-go && umask 077
cp env.example ~/.config/discourse-go/env
# fill in DISCOURSE_URL / DISCOURSE_API_KEY (or DISCOURSE_KEY) /
# DISCOURSE_API_USERNAME, and the webhook/SSO secrets if used
Then set -a; . ~/.config/discourse-go/env; set +a before the CLI (or
source it in the service env). For the harness, the key resolves through
its key_ref mechanism (env:/file:/mpk:) exactly like the Redmine key.
CLI reference
discourse-go whoami
discourse-go categories list
discourse-go categories create NAME [-color HEX] [-text-color HEX] [-perm GROUP=LEVEL]...
discourse-go topics list [-category ID] [-slug SLUG] [-latest]
discourse-go topics show ID
discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT]
discourse-go posts create -topic ID [-file PATH | -raw TEXT]
discourse-go posts update ID [-file PATH | -raw TEXT] [-reason TEXT]
discourse-go search TERM [-page N]
discourse-go messages send -title TITLE -to USER[,USER...] [-group NAMES] [-email ADDRS] [-file PATH | -raw TEXT]
discourse-go webhook verify SIG # delivery body on stdin, secret from DISCOURSE_WEBHOOK_SECRET
discourse-go sso verify SSO SIG # secret from DISCOURSE_SSO_SECRET; prints the payload as JSON
discourse-go raw METHOD PATH [-data JSON|@file]
Perm levels: 1 = reply/see, 2 = create posts, 3 = full. Output is JSON.
Exit codes: 0 ok, 1 usage/config, 2 API/transport error (the message
names the class). webhook verify / sso verify need no instance
credentials — only their secret.
Live verification (community.turnsys.com, 2026-08-29)
whoami+categories listwith the current user-scoped key: OK (HTTP 200).categories create: HTTP 403 with the current key — category creation is admin-scoped. The client fully supports it and maps it toErrForbidden; live creation waits for the admin key (Charles's 1900 list). The harness briefing pipeline is unaffected: it posts TOPICS into an existing category, which the current key allows.
License
AGPLv3 (see LICENSE) — maximally viral, per the MOPAC spec.