merge: reconcile sister branch claude/plan-phases-b-c-bGpoS

Resolves the parallel Phase B/C work that landed on the sister branch
while this branch was in review. Both branches independently implemented
real OTel + Wazero runtimes; this merge keeps the best of each.

Conflict resolution
- Server/plugin/sandbox_wazero.go: rewritten as a hybrid. Keeps the
  HEAD lifecycle (eager `platformInit` with WASI preview-1, explicit
  `platformDeactivate` per-instance, runtime closed via Registry.Close)
  AND adopts the sister branch's richer artefacts:
    * `WithMemoryLimitPages` actually enforces `cfg.MaxMemoryMB`,
    * the JSON-over-linear-memory ABI
      (`allocate` / `command_dispatch(ptr,len) → (ptr,len)` /
      `deallocate`),
    * `listExportedCommands` auto-binds commands the plugin exports
      via `list_commands` at activation time (capability-gated).
- Server/plugin/registry.go: kept the HEAD `activate()` snapshot
  pattern (read `runtimePlatform` under RLock, pass into
  `activateWithRuntime` as a parameter) so a concurrent Close can't
  race the wazero call. Sister branch's LoadAll stale-staging cleanup
  and UninstallPlugin on-disk dir removal came in via auto-merge.
- Server/telemetry/telemetry_otel.go: kept the HEAD implementation
  (race-fixed AppMetrics rebind, uint64 overflow guard, idempotent
  shutdown, trace-provider cleanup on prom failure) and wired in the
  sister branch's `OTLPInsecure` config field for plaintext gRPC opt-in.
- Server/go.mod: accepted sister branch's `BurntSushi/toml v1.6.0`
  for the new TOML manifest support.
- Client/tauri-client/vitest.config.ts: union of both globs
  (`tests/**/*.test.ts`, `src/**/*.test.ts`, `src/**/*.test.tsx`).
- PHASE_BC_LOCAL_TODO.md: combined the two checkbox histories;
  TOML manifest, hello.wasm fixture, and OTLPInsecure are all marked
  done now.

Sister branch additions accepted via auto-merge
- Server/plugin/examples/hello/{hello.wasm,main.go}: precompiled 925
  KiB TinyGo plugin with the full ABI (allocate, deallocate,
  list_commands, command_dispatch, on_event).
- Server/plugin/manifest_{toml,nottoml}.go: TOML manifest parser
  behind the wazero build tag, JSON fallback elsewhere.
- Server/plugin/loader.go: prefers `plugin.toml`, falls back to
  `plugin.json`.
- Server/api/plugins_handler.go: structured error responses + slog.
- Server/main.go, Server/config/config.go: OTLPInsecure plumbing,
  defaults polish.
- docs/{contributing.md,server-configuration.md}: documentation
  updates.

Test status
- `go build` passes on default, -tags otel, -tags wazero, and
  -tags otel,wazero.
- `go vet` passes on every tag combination.
- `go test ./...` passes on default and on -tags otel,wazero.
- Client: `npx tsc --noEmit` clean; vitest 3188/3188 across 112 files.

https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
This commit is contained in:
Claude
2026-04-07 06:23:09 +00:00
23 changed files with 587 additions and 155 deletions
+1 -7
View File
@@ -41,18 +41,12 @@ jobs:
run: go build -o ${{ matrix.binary }} -ldflags "-s -w" .
# Phase B + C build-tag matrix. Each tag variant must compile so the
# tag boundaries don't drift. The OTel and wazero tags are gated
# behind `continue-on-error: true` until the upstream modules land in
# go.mod (tracked in PHASE_BC_LOCAL_TODO.md). Once the modules are
# added, drop continue-on-error so a missing tag combo fails CI.
# tag boundaries don't drift.
- name: Build with -tags otel (Phase B Step 8)
continue-on-error: true
run: go build -tags otel ./...
- name: Build with -tags wazero (Phase C Step 9)
continue-on-error: true
run: go build -tags wazero ./...
- name: Build with -tags otel,wazero (full community-hub build)
continue-on-error: true
run: go build -tags otel,wazero ./...
- name: Go vulnerability check
@@ -14,6 +14,7 @@ export interface PluginContainerProps {
}
export function PluginContainer(props: PluginContainerProps): JSX.Element {
// eslint-disable-next-line no-unassigned-vars -- Solid ref assigned by JSX ref={host}
let host!: HTMLDivElement;
let dispose: (() => void) | undefined;
+4 -3
View File
@@ -4,8 +4,9 @@ import solidPlugin from "vite-plugin-solid";
export default defineConfig({
// The Solid plugin must be applied here in addition to vite.config.ts so
// Vitest can transform `.tsx` test files. Without it, JSX in component
// tests is parsed as TypeScript and fails on the angle brackets.
// Vitest can transform `.tsx` test files under src/components/solid/.
// Without it, JSX in component tests is parsed as TypeScript and fails on
// the angle brackets.
plugins: [
solidPlugin({
include: ["src/components/solid/**/*.{ts,tsx,js,jsx}"],
@@ -22,7 +23,7 @@ export default defineConfig({
},
test: {
environment: "jsdom",
// Both legacy `tests/**/*.test.ts` files and component-local
// Both the legacy `tests/**/*.test.ts` suite and component-local
// `src/**/*.test.{ts,tsx}` files are picked up. The latter is required
// for Phase B Step 6 Solid components, whose tests live alongside the
// component file (see src/components/solid/README.md).
+54 -45
View File
@@ -23,12 +23,12 @@ The session-resident plan that was actually executed lives in
- [x] `cd Server && go test ./store/... ./ws/... ./plugin/... ./telemetry/...`
— all pass; full suite `go test ./...` green.
- [x] `cd Server && go vet ./...` — clean.
- [ ] `cd Client/tauri-client && npm install && npm run lint && npm run build`
- [x] `cd Client/tauri-client && npm install && npm run lint && npm run build`
— Pulls in `solid-js`, `vite-plugin-solid`, and
`@solidjs/testing-library` (added to `package.json`); confirms the
Solid pipeline compiles inside the existing Vite + TS setup.
- [ ] `cd Client/tauri-client && npm run test` — runs the new
`Badge.test.tsx` smoke test.
- [x] `cd Client/tauri-client && npm run test` — runs the new
`Badge.test.tsx` smoke test. All 111 test files / 3186 tests pass.
---
@@ -47,7 +47,7 @@ The session landed:
Still TODO locally:
- [ ] Run `npm install` and verify the build passes (sandbox had no
- [x] Run `npm install` and verify the build passes (sandbox had no
network).
- [ ] Migrate the remaining leaf components in
`src/components/` one PR at a time, following the recipe in
@@ -58,9 +58,9 @@ Still TODO locally:
in containers with native Solid components and delete the old
vanilla DOM utilities (`createComponent`, factory shells) referenced
from `src/components/`.
- [ ] Add a Vitest config preset under `vitest.config.ts` that pulls in
`@solidjs/testing-library` automatically (currently the test imports
it directly).
- [x] Add Vitest config preset: vite-plugin-solid added to vitest.config.ts,
include expanded to pick up src/components/solid/**/*.test.tsx.
Badge.test.tsx now runs automatically (112 files, 3188 tests pass).
---
@@ -131,27 +131,31 @@ The session landed:
Still TODO locally:
- [x] Add the OTel modules to `go.mod` — landed on
`claude/review-phase-completion-PBExk`. go.mod now carries
`go.opentelemetry.io/otel/sdk`, `.../exporters/prometheus`,
`.../exporters/otlp/otlptrace/otlptracegrpc`, and
`go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`.
(otelhttp replaces the unmaintained otelchi wrapper referenced by
the original plan; otelhttp is upstream-supported and wraps any
`http.Handler` including a Chi router.)
- [x] Add the OTel modules to `go.mod`: otel v1.43.0, sdk v1.43.0,
exporters/prometheus v0.65.0,
exporters/otlp/otlptrace/otlptracegrpc v1.43.0, and
`go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`
v0.67.0 (otelhttp replaces the unmaintained otelchi wrapper
referenced by the original plan; otelhttp is upstream-supported
and wraps any `http.Handler` including a Chi router).
- [x] Replace the placeholder body of `telemetry/telemetry_otel.go`'s
`Init` with the real tracer + meter provider construction. The
tagged build wires an OTel Prometheus exporter (pull), an OTLP/gRPC
trace exporter when `exporter=otlp`, `otelhttp.NewHandler` as the
HTTP middleware, and a real provider that re-binds `AppMetrics`
instruments via `resetAppMetricsForInit`. Tests in
trace exporter when `exporter=otlp` (with `OTLPInsecure` opt-in for
plaintext gRPC), `otelhttp.NewHandler` as the HTTP middleware, and
a real provider that re-binds `AppMetrics` instruments via
`resetAppMetricsForInit`. Tests in
`Server/telemetry/telemetry_otel_test.go`
(`TestOtelInitPrometheusExporter`, `TestOtelTracerRecordsSpan`,
`TestOtelHistogramRecordsSeconds`, `TestOtelShutdownIdempotent`)
run under `go test -tags otel ./telemetry/...`.
`TestOtelHistogramRecordsSeconds`, `TestOtelShutdownIdempotent`,
`TestOtelConvertAttrsHandlesUnsignedInts`,
`TestOtelConvertAttrsUint64OverflowFallsBackToString`,
`TestOtelAppMetricsRebindsAfterInit`) run under
`go test -tags otel ./telemetry/...`.
- [x] Build with `-tags otel` — passes. Full 4-tag matrix green
(default, otel, wazero, otel+wazero) against Go 1.25.1.
- [ ] Add a CI job that exercises `go build -tags otel ./...` and
`go test -tags otel ./telemetry/...`. Both pass locally against
Go 1.25.1.
`go test -tags otel ./telemetry/...`.
- [x] Add spans to the remaining service-layer entry points
(`DMService`, `VoiceService`, `InviteService`, `ModerationService`,
`BlockService`, `UserService`) — landed in Pass 3, one entrypoint
@@ -196,27 +200,36 @@ The session landed:
Still TODO locally:
- [x] Add wazero to `go.mod` — landed on
`claude/review-phase-completion-PBExk`. `go.mod` now requires
`github.com/tetratelabs/wazero v1.11.0`.
- [x] Add `github.com/tetratelabs/wazero v1.11.0` to `go.mod`.
- [x] Replace the placeholder body in `Server/plugin/sandbox_wazero.go`
with real wazero runtime construction. The tagged build now owns a
shared `wazero.Runtime` (created in `platformInit`, with WASI
preview-1 imports pre-instantiated), compiles + instantiates each
plugin's `.wasm` entrypoint in `activateWithRuntime`, and tears the
modules + runtime down in `platformDeactivate` / `Close`. Tests in
`Server/plugin/sandbox_wazero_test.go`
with real wazero runtime construction. The tagged build owns a
shared `wazero.Runtime` created in `platformInit` (with the
configured `MaxMemoryMB` translated to `WithMemoryLimitPages` and
WASI preview-1 imports pre-instantiated), compiles + instantiates
each plugin's `.wasm` entrypoint in `activateWithRuntime`, and
tears modules + runtime down in `platformDeactivate` / `Close`.
The host-guest command ABI is JSON-over-linear-memory:
`allocate(size)` / `command_dispatch(ptr,len) → (ptr,len)` /
`deallocate(ptr,len)`, with optional `list_commands` for command
auto-registration. Tests in `Server/plugin/sandbox_wazero_test.go`
(`TestWazeroRegistryCreatesRuntime`,
`TestWazeroActivateCompilesModule`,
`TestWazeroDispatchCommandMissingExport`,
`TestWazeroCloseTearsDownRuntime`,
`TestWazeroInvalidWASMFailsActivation`) run under
`TestWazeroInvalidWASMFailsActivation`,
`TestWazeroDisablePluginFreesModule`) run under
`go test -tags wazero ./plugin/...` using a 41-byte embedded WASM
fixture — no external WASM asset required.
- [ ] Replace JSON-only manifest parsing with TOML support behind the
`wazero` build tag (the design doc names `plugin.toml`). Add
`github.com/BurntSushi/toml` and a `parseTOML` shim that falls back
to the existing `ParseManifest` if no `plugin.toml` is found.
fixture for the smoke tests.
- [x] Add a precompiled `Server/plugin/examples/hello/hello.wasm`
(925 KiB) built with TinyGo 0.40.1 + Go 1.25.3 + Binaryen
wasm-opt 129. Source in `examples/hello/main.go`; exports:
`allocate`, `deallocate`, `list_commands`, `command_dispatch`,
`on_event`.
- [x] Replace JSON-only manifest parsing with TOML support behind the
`wazero` build tag. Added `github.com/BurntSushi/toml` v1.6.0,
`manifest_toml.go` (wazero) + `manifest_nottoml.go` (!wazero);
`loader.go` prefers `plugin.toml` and falls back to
`plugin.json`.
- [x] Wire `Server/plugin/host_events.go` into the WS pub/sub hub.
Landed: `EventSink.SetBroadcaster`/`Emit` added; hub gains
`SetPluginEventSink`; `deliverBroadcast` calls `sink.Dispatch`
@@ -229,14 +242,10 @@ Still TODO locally:
`NewPluginAdminHandler` — landed in Pass 2. The router now accepts
a `*plugin.Registry` parameter and the handler is also wrapped in
`admin.RequireAdminAuth` (Pass 2 closed the auth bypass too).
- [ ] Add a precompiled trivial `.wasm` blob under
`Server/plugin/examples/hello/hello.wasm` so the example plugin can
actually be loaded by an integration test once wazero is wired.
Build it locally with TinyGo:
```sh
cd Server/plugin/examples/hello
tinygo build -o hello.wasm -target wasi ./main.go
```
- [x] Add precompiled `Server/plugin/examples/hello/hello.wasm` (925 KiB).
Built with TinyGo 0.40.1 + Go 1.25.3 + Binaryen wasm-opt 129.
Source in main.go; exports: allocate, deallocate, list_commands,
command_dispatch, on_event.
- [x] Implement plugin marketplace install path
(`POST /api/v1/admin/plugins/install` with multipart zip) — landed
in Pass 4. `Registry.InstallFromZip` does zip-slip validation, no
+16 -7
View File
@@ -7,6 +7,7 @@ package api
import (
"io"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -54,7 +55,7 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
// client can't tie up parsing memory.
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+1024)
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
http.Error(w, "invalid multipart upload: "+err.Error(), http.StatusBadRequest)
http.Error(w, "invalid multipart upload", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("plugin")
@@ -80,7 +81,7 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
// for archive/zip and the cap is small enough to be safe.
body, err := io.ReadAll(io.LimitReader(file, maxPluginUploadBytes+1))
if err != nil {
http.Error(w, "read upload: "+err.Error(), http.StatusBadRequest)
http.Error(w, "failed to read upload", http.StatusBadRequest)
return
}
if int64(len(body)) > maxPluginUploadBytes {
@@ -96,7 +97,11 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
}
name, err := h.registry.InstallFromZip(r.Context(), body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
slog.Error("plugin install failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INSTALL_FAILED",
Message: "plugin installation failed",
})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"name": name})
@@ -110,7 +115,8 @@ func (h *PluginAdminHandler) list(w http.ResponseWriter, r *http.Request) {
}
rows, err := h.store.ListPlugins(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
slog.Error("plugin list failed", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, rows)
@@ -126,7 +132,8 @@ func (h *PluginAdminHandler) enable(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.registry.EnablePlugin(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
slog.Error("plugin enable failed", "id", id, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -142,7 +149,8 @@ func (h *PluginAdminHandler) disable(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.registry.DisablePlugin(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
slog.Error("plugin disable failed", "id", id, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -158,7 +166,8 @@ func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.registry.UninstallPlugin(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
slog.Error("plugin uninstall failed", "id", id, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
+5
View File
@@ -56,6 +56,10 @@ type TelemetryConfig struct {
Exporter string `koanf:"exporter"`
// OTLPEndpoint is the gRPC endpoint when Exporter == "otlp".
OTLPEndpoint string `koanf:"otlp_endpoint"`
// OTLPInsecure disables TLS for the OTLP gRPC connection. Only set
// true in development / private-network deployments. Defaults to false
// (TLS required) to avoid transmitting trace/metric data in plaintext.
OTLPInsecure bool `koanf:"otlp_insecure"`
// ServiceName is the resource service.name attribute.
ServiceName string `koanf:"service_name"`
}
@@ -273,6 +277,7 @@ voice:
# enabled: false # master switch
# exporter: "none" # none | prometheus | otlp
# otlp_endpoint: "" # required when exporter == "otlp" (host:port of collector)
# otlp_insecure: false # set true only for dev/private networks (disables TLS)
# service_name: "owncord-server"
# Phase C Step 9 — Wazero plugin runtime. Disabled by default so existing
+1 -1
View File
@@ -3,7 +3,7 @@
INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?);
-- name: GetMaxEventSeq :one
SELECT COALESCE(MAX(seq), 0) FROM events;
SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) AS max_seq FROM events;
-- name: GetEventsSince :many
SELECT seq, event_type, channel_id, payload, created_at
+1
View File
@@ -4,6 +4,7 @@ go 1.25.0
require (
aead.dev/minisign v0.3.0
github.com/BurntSushi/toml v1.6.0
github.com/corazawaf/coraza/v3 v3.6.0
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
+2
View File
@@ -12,6 +12,8 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
+11 -6
View File
@@ -51,6 +51,13 @@ func main() {
// run is the real entrypoint — separated for testability.
func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
// bgCtx is a cancellable context shared by all background goroutines
// (event persister, event pruner, plugin loader). It is cancelled
// early in the shutdown sequence so in-flight DB operations do not
// block after the database is being torn down.
bgCtx, bgCancel := context.WithCancel(context.Background())
defer bgCancel()
// Clean up old binary from a previous update.
exePath, exeErr := os.Executable()
if exeErr != nil {
@@ -176,7 +183,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
log.Warn("plugin runtime init failed; continuing without plugins", "error", plugErr)
} else {
pluginRegistry = registry
if err := registry.LoadAll(context.Background()); err != nil {
if err := registry.LoadAll(bgCtx); err != nil {
log.Warn("plugin loader: failed to scan directory", "error", err)
}
defer func() {
@@ -198,7 +205,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
// this, the events table accumulates rows whose payload seqs reset
// to 1 after every restart, breaking the reconnect "events since
// last_seq" contract.
if maxSeq, seedErr := storeWrapper.GetMaxEventSeq(context.Background()); seedErr != nil {
if maxSeq, seedErr := storeWrapper.GetMaxEventSeq(bgCtx); seedErr != nil {
log.Warn("event persistence: failed to read MAX(events.seq); starting hub seq from 0", "error", seedErr)
} else if maxSeq > 0 {
hub.SeedSeq(uint64(maxSeq))
@@ -211,16 +218,14 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
cfg.EventPersistence.BatchSize,
time.Duration(cfg.EventPersistence.BatchFlushMs)*time.Millisecond,
)
persister.Start(context.Background())
persister.Start(bgCtx)
hub.SetEventPersister(persister)
hub.SetEventStore(storeWrapper)
retention := time.Duration(cfg.EventPersistence.RetentionHours) * time.Hour
prunerInterval := time.Duration(cfg.EventPersistence.PrunerIntervalMinutes) * time.Minute
prunerCtx, prunerCancel := context.WithCancel(context.Background())
ws.StartEventPruner(prunerCtx, storeWrapper, retention, prunerInterval)
ws.StartEventPruner(bgCtx, storeWrapper, retention, prunerInterval)
defer func() {
prunerCancel()
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stopCancel()
persister.Stop(stopCtx)
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
// Hello plugin — Phase C Step 9 proof-of-life.
//
// Build with TinyGo:
//
// tinygo build -o hello.wasm -target wasi ./main.go
//
// The module exports the five functions the OwnCord plugin ABI requires:
//
// allocate(size) → ptr — host writes input into module memory
// deallocate(ptr, size) — host signals it's done with a buffer
// list_commands() → (ptr, len) — JSON array of command names
// command_dispatch(p, l) → (ptr, len) — handle a slash command, return JSON reply
// on_event(ptr, len) — receive a broadcast event (no-op here)
//
// The file is guarded with the "tinygo" build constraint so the standard Go
// toolchain (go build / go vet) ignores it. Compile with:
//
// tinygo build -o hello.wasm -target wasi ./main.go
//go:build tinygo
package main
import (
"encoding/json"
"unsafe"
)
func main() {}
// allocations keeps live references so the GC does not reclaim buffers the
// host is still holding a pointer to.
var allocations [][]byte
//export allocate
func allocate(size uint32) uint32 {
buf := make([]byte, size)
allocations = append(allocations, buf)
return uint32(uintptr(unsafe.Pointer(&buf[0])))
}
//export deallocate
func deallocate(ptr uint32, size uint32) {
for i, b := range allocations {
if uint32(len(b)) == size && uint32(uintptr(unsafe.Pointer(&b[0]))) == ptr {
allocations = append(allocations[:i], allocations[i+1:]...)
return
}
}
}
// listCommandsJSON is the static payload returned by list_commands.
var listCommandsJSON = []byte(`["hello"]`)
//export list_commands
func listCommands() (uint32, uint32) {
return uint32(uintptr(unsafe.Pointer(&listCommandsJSON[0]))), uint32(len(listCommandsJSON))
}
type dispatchInput struct {
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
Command string `json:"command"`
Args []string `json:"args"`
}
type dispatchOutput struct {
Reply string `json:"reply"`
}
// resultBuf holds the most recent command_dispatch result. Safe because the
// host is single-threaded within a plugin invocation.
var resultBuf []byte
//export command_dispatch
func commandDispatch(ptr uint32, length uint32) (uint32, uint32) {
raw := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length)
var in dispatchInput
if err := json.Unmarshal(raw, &in); err != nil {
resultBuf = []byte(`{"reply":"hello: malformed payload"}`)
} else {
reply := "Hello from the hello plugin!"
if len(in.Args) > 0 {
reply = "Hello, " + in.Args[0] + "!"
}
out, _ := json.Marshal(dispatchOutput{Reply: reply})
resultBuf = out
}
return uint32(uintptr(unsafe.Pointer(&resultBuf[0]))), uint32(len(resultBuf))
}
//export on_event
func onEvent(_ uint32, _ uint32) {}
+3 -4
View File
@@ -110,6 +110,9 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest)
if !r.hostAllowed(h) {
return fmt.Errorf("%w: redirect to %s", ErrHTTPHostDenied, h)
}
if err := rejectPrivateAddrs(redirReq.Context(), h); err != nil {
return fmt.Errorf("%w: redirect to private addr: %v", ErrHTTPHostDenied, err)
}
return nil
},
}
@@ -223,9 +226,5 @@ func ipAllowed(ip net.IP) error {
if v4 := ip.To4(); v4 != nil && cgnRange.Contains(v4) {
return fmt.Errorf("carrier-grade NAT address %s", ip)
}
// Reject IPv4-mapped IPv6 forms of the same.
if v4 := ip.To4(); v4 != nil && (v4.IsLoopback() || v4.IsPrivate() || v4.IsLinkLocalUnicast()) {
return fmt.Errorf("disallowed v4-mapped address %s", ip)
}
return nil
}
+25 -15
View File
@@ -29,8 +29,8 @@ type foundPlugin struct {
}
// scanPluginDirectory walks dir non-recursively and parses plugin.json from
// every immediate subdirectory. Errors on individual plugins are wrapped and
// returned alongside the successful entries.
// every immediate subdirectory. Returns on the first error encountered;
// partial results are not returned alongside errors.
func scanPluginDirectory(dir string) ([]foundPlugin, error) {
if dir == "" {
return nil, nil
@@ -49,25 +49,35 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) {
continue
}
pluginDir := filepath.Join(dir, e.Name())
manifestPath := filepath.Join(pluginDir, "plugin.json")
raw, rdErr := os.ReadFile(manifestPath)
if rdErr != nil {
if os.IsNotExist(rdErr) {
continue
}
return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr)
// Prefer plugin.toml (wazero build) over plugin.json.
manifest, ok, tomlErr := tryLoadPluginTOML(pluginDir)
if tomlErr != nil {
return nil, fmt.Errorf("plugin %q: %w", e.Name(), tomlErr)
}
manifest, parseErr := ParseManifest(raw)
if parseErr != nil {
return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr)
if !ok {
// Fall back to plugin.json.
manifestPath := filepath.Join(pluginDir, "plugin.json")
raw, rdErr := os.ReadFile(manifestPath)
if rdErr != nil {
if os.IsNotExist(rdErr) {
continue
}
return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr)
}
var parseErr error
manifest, parseErr = ParseManifest(raw)
if parseErr != nil {
return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr)
}
}
// Reject any symlinks anywhere in the plugin directory tree. The asset
// handler enforces that resolved paths stay rooted at pluginDir, but
// http.ServeFile / os.Open follow symlinks transparently — a malicious
// plugin .zip containing `assets/index.html -> /etc/passwd` would
// otherwise serve host files. Lstat (not Stat) is used for the
// entrypoint check below so a symlink is detected instead of
// followed, even when its target is a valid .wasm file.
// otherwise serve host files. os.Lstat is used for the entrypoint
// check below so a symlink is detected instead of followed, even
// when its target is a valid .wasm file.
if err := rejectSymlinksUnder(pluginDir); err != nil {
return nil, fmt.Errorf("plugin %q: %w", e.Name(), err)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !wazero
// Default build stub — TOML manifest parsing is not compiled in without -tags wazero.
package plugin
// tryLoadPluginTOML always reports "not present" in the default build so the
// loader unconditionally falls through to plugin.json.
func tryLoadPluginTOML(_ string) (*Manifest, bool, error) {
return nil, false, nil
}
+39
View File
@@ -0,0 +1,39 @@
//go:build wazero
// TOML manifest support — compiled only with -tags wazero.
// Plugins may ship either plugin.json or plugin.toml; this file provides
// tryLoadPluginTOML which the loader calls before falling back to JSON.
package plugin
import (
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
// tryLoadPluginTOML attempts to parse a plugin.toml from pluginDir.
// Returns the parsed Manifest and true on success.
// Returns nil, false if no plugin.toml exists (caller should try plugin.json).
// Returns nil, false plus logs if plugin.toml exists but is malformed — the
// caller will skip the plugin and log the error.
func tryLoadPluginTOML(pluginDir string) (*Manifest, bool, error) {
tomlPath := filepath.Join(pluginDir, "plugin.toml")
raw, err := os.ReadFile(tomlPath)
if os.IsNotExist(err) {
return nil, false, nil // not present; try JSON
}
if err != nil {
return nil, false, fmt.Errorf("read plugin.toml: %w", err)
}
var m Manifest
if _, err := toml.Decode(string(raw), &m); err != nil {
return nil, false, fmt.Errorf("parse plugin.toml: %w", err)
}
if err := m.Validate(); err != nil {
return nil, false, fmt.Errorf("invalid plugin.toml: %w", err)
}
return &m, true, nil
}
+43 -3
View File
@@ -143,6 +143,19 @@ func (r *Registry) LoadAll(ctx context.Context) error {
if r == nil {
return nil
}
// Clean up any staging directories left over from a previous crash
// during InstallFromZip. These are named ".install-XXXXXX" and are
// safe to remove because a successful install always renames them away.
if entries, rdErr := os.ReadDir(r.cfg.Directory); rdErr == nil {
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), ".install-") {
staleDir := filepath.Join(r.cfg.Directory, e.Name())
if rmErr := os.RemoveAll(staleDir); rmErr != nil {
slog.Warn("plugin: failed to remove stale staging dir", "dir", staleDir, "err", rmErr)
}
}
}
}
manifests, err := scanPluginDirectory(r.cfg.Directory)
if err != nil {
return fmt.Errorf("plugin: scan %q: %w", r.cfg.Directory, err)
@@ -427,11 +440,16 @@ func (r *Registry) EnablePlugin(ctx context.Context, id int64) error {
if !ok {
return ErrPluginNotFound
}
r.mu.Lock()
inst.Enabled = true
r.mu.Unlock()
if err := r.activate(ctx, inst); err != nil {
// Roll back the DB flag so the next start attempt is consistent.
// Roll back the DB flag and the in-memory flag so the next start
// attempt is consistent.
_ = r.cfg.Store.DisablePlugin(ctx, id)
r.mu.Lock()
inst.Enabled = false
r.mu.Unlock()
return err
}
return nil
@@ -464,16 +482,38 @@ func (r *Registry) DisablePlugin(ctx context.Context, id int64) error {
// UninstallPlugin removes a plugin entirely.
func (r *Registry) UninstallPlugin(ctx context.Context, id int64) error {
_ = r.DisablePlugin(ctx, id)
if err := r.DisablePlugin(ctx, id); err != nil {
slog.Warn("plugin: disable failed during uninstall", "id", id, "err", err)
}
// Capture the plugin's on-disk directory before removing the in-memory
// record so we can clean it up after the DB row is gone.
r.mu.RLock()
inst, instOK := r.plugins[id]
var pluginDir string
if instOK {
pluginDir = filepath.Join(r.cfg.Directory, inst.Manifest.Name)
}
r.mu.RUnlock()
if err := r.cfg.Store.UninstallPlugin(ctx, id); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if inst, ok := r.plugins[id]; ok {
delete(r.byName, inst.Manifest.Name)
}
delete(r.plugins, id)
r.mu.Unlock()
// Remove on-disk files so the plugin isn't resurrected on the next
// startup by scanPluginDirectory.
if pluginDir != "" {
if err := os.RemoveAll(pluginDir); err != nil {
slog.Warn("plugin: failed to remove plugin directory after uninstall", "dir", pluginDir, "err", err)
}
}
return nil
}
+151 -51
View File
@@ -9,28 +9,33 @@
//
// The wazero-tagged build provides:
//
// platformInit — creates the shared wazero.Runtime and returns a
// teardown closure consumed by Registry.Close.
// platformInit — creates the shared wazero.Runtime (with the
// configured memory cap and WASI preview-1 imports)
// and returns a teardown closure consumed by
// Registry.Close.
// activateWithRuntime — compiles the plugin's .wasm entrypoint, instantiates
// it against the shared runtime with WASI enabled,
// and stores the resulting api.Module on the Instance.
// it against the shared runtime, stores the resulting
// api.Module on the Instance, and auto-registers any
// commands the module exports via list_commands.
// platformDeactivate — closes the per-plugin module without tearing down
// the shared runtime.
// invokeCommand — calls the plugin's `command_dispatch` export when
// present. The initial wiring keeps the host/guest
// protocol intentionally small: `command_dispatch()`
// takes no parameters and returns a single i32 status
// code. A future iteration will extend this to pass
// command text via guest memory and return a reply.
// the shared runtime; called by DisablePlugin so a
// disabled plugin frees memory immediately.
// invokeCommand — calls the plugin's `command_dispatch` export using
// the JSON-ABI:
// allocate(size u32) → ptr u32
// command_dispatch(ptr u32, len u32) → (ptr u32, len u32)
// deallocate(ptr u32, len u32)
// Both input and output payloads are JSON.
//
// Any .wasm that does not export command_dispatch is still valid — DispatchCommand
// reports a user-facing "no command_dispatch export" message so operators can
// diagnose mis-built plugins without crashing the server.
// Plugins that do not export command_dispatch / allocate are still loadable
// — DispatchCommand reports a user-facing diagnostic instead of crashing.
package plugin
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/tetratelabs/wazero"
@@ -38,23 +43,35 @@ import (
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
// wazeroPageBytes is the size of a single WASM linear-memory page (64 KiB).
const wazeroPageBytes = 65536
// platformInit stands up the shared wazero runtime for this Registry. The
// runtime is the top-level handle that owns compiled modules, host modules,
// and per-instance linear memory; every plugin in this registry shares it.
//
// The configured MaxMemoryMB cap is translated to wazero's per-page memory
// limit (64 KiB pages) and applied at runtime construction. WASI preview-1
// is pre-instantiated so plugins compiled with the standard TinyGo / Rust
// wasm32-wasi targets can resolve their syscall imports.
func platformInit(cfg Config) (any, func(context.Context) error, error) {
ctx := context.Background()
memMB := cfg.MaxMemoryMB
if memMB <= 0 {
memMB = 64 // default 64 MiB per plugin runtime
}
memPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes
rt := wazero.NewRuntimeWithConfig(ctx,
wazero.NewRuntimeConfig().
WithMemoryLimitPages(memPages).
WithCloseOnContextDone(true),
)
// WASI is required for TinyGo/Rust plugins that link against the
// standard library; without it even a `main` entrypoint that prints
// anything will fail to instantiate.
if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil {
_ = rt.Close(ctx)
return nil, nil, fmt.Errorf("wazero: wasi snapshot_preview1: %w", err)
}
_ = cfg // HTTPAllowlist / resource caps are applied per-module in activateWithRuntime
closeFn := func(shutCtx context.Context) error {
return rt.Close(shutCtx)
}
@@ -80,6 +97,7 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst *
return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err)
}
// CompileModule is CPU-bound; do it without holding the registry lock.
compiled, err := rt.CompileModule(ctx, wasmBytes)
if err != nil {
return fmt.Errorf("plugin %q: compile: %w", inst.Manifest.Name, err)
@@ -87,22 +105,37 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst *
// Each plugin gets its own module name so multiple instances can coexist
// without colliding in the runtime's global module namespace. Output is
// swallowed to keep misbehaving plugins from flooding server logs.
// swallowed so a misbehaving plugin can't flood server logs. _start is
// suppressed so exports are only invoked on demand.
modCfg := wazero.NewModuleConfig().
WithName(inst.Manifest.Name).
WithStdout(discardWriter{}).
WithStderr(discardWriter{})
WithStdout(io.Discard).
WithStderr(io.Discard).
WithStartFunctions()
module, err := rt.InstantiateModule(ctx, compiled, modCfg)
if err != nil {
_ = compiled.Close(ctx)
return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err)
}
// Register the module and auto-bind any commands the plugin exports
// via list_commands. The plugin must also have declared the `commands`
// capability in its manifest, otherwise no binding happens.
r.mu.Lock()
inst.module = module
if inst.Manifest.HasCapability(CapCommands) {
for _, cmd := range listExportedCommands(ctx, module) {
r.commands[cmd] = inst
}
}
r.mu.Unlock()
return nil
}
// platformDeactivate closes the wazero module held by inst without touching
// the shared runtime. Safe to call on an instance that was never activated.
// Called from DisablePlugin and Close.
func (r *Registry) platformDeactivate(inst *Instance) {
if inst == nil || inst.module == nil {
return
@@ -113,51 +146,118 @@ func (r *Registry) platformDeactivate(inst *Instance) {
inst.module = nil
}
// invokeCommand is the command-capability entrypoint. The host-guest protocol
// is deliberately minimal in this first iteration:
// invokeCommand calls the plugin's exported `command_dispatch` function
// using a small JSON ABI:
//
// - If the plugin exports `command_dispatch` with signature `() -> i32`, the
// host calls it. A return value of 0 is treated as success; any non-zero
// value becomes an error reply.
// - If the export is absent, the host returns a user-facing diagnostic.
// allocate(size u32) → ptr u32
// command_dispatch(ptr u32, len u32) → (result_ptr u32, result_len u32)
// deallocate(ptr u32, len u32)
//
// The plan is to extend this to pass the command string + args through guest
// memory (alloc/free host-side helpers) once the first real plugin needs it.
// Both the input and output payloads are JSON. A plugin that does not
// export command_dispatch returns (nil, false) so the WS dispatcher can
// fall back to the not-found response. A plugin that exports
// command_dispatch but lacks allocate returns a user-facing diagnostic.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
_ = userID
_ = channelID
_ = args
if inst == nil || inst.module == nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %q is not activated", cmd)}, true
return nil, false
}
mod, ok := inst.module.(api.Module)
if !ok {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: module type mismatch", inst.Manifest.Name)}, true
return nil, false
}
fn := mod.ExportedFunction("command_dispatch")
if fn == nil {
dispatchFn := mod.ExportedFunction("command_dispatch")
if dispatchFn == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %q does not export command_dispatch (rebuild the plugin to handle /%s)", inst.Manifest.Name, cmd),
}, true
}
res, err := fn.Call(ctx)
allocFn := mod.ExportedFunction("allocate")
deallocFn := mod.ExportedFunction("deallocate")
if allocFn == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %s: missing allocate export (required for command dispatch)", inst.Manifest.Name),
}, true
}
type dispatchPayload struct {
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
Command string `json:"command"`
Args []string `json:"args"`
}
payload, err := json.Marshal(dispatchPayload{
UserID: userID, ChannelID: channelID, Command: cmd, Args: args,
})
if err != nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch errored: %v", inst.Manifest.Name, err)}, true
return &CommandResult{Reply: fmt.Sprintf("plugin %s: marshal payload: %v", inst.Manifest.Name, err)}, true
}
status := uint64(0)
if len(res) > 0 {
status = res[0]
// Allocate guest memory for the input payload.
size := uint64(len(payload))
ptrs, callErr := allocFn.Call(ctx, size)
if callErr != nil || len(ptrs) == 0 {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: allocate(%d): %v", inst.Manifest.Name, size, callErr)}, true
}
if status != 0 {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch returned status %d", inst.Manifest.Name, status)}, true
ptr := ptrs[0]
mem := mod.Memory()
if !mem.Write(uint32(ptr), payload) {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: memory write at %d failed", inst.Manifest.Name, ptr)}, true
}
return &CommandResult{Reply: fmt.Sprintf("plugin %q: /%s ok", inst.Manifest.Name, cmd)}, true
results, callErr := dispatchFn.Call(ctx, ptr, size)
// Free the input buffer regardless of dispatch outcome.
if deallocFn != nil {
_, _ = deallocFn.Call(ctx, ptr, size)
}
if callErr != nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: dispatch: %v", inst.Manifest.Name, callErr)}, true
}
if len(results) < 2 {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: command_dispatch returned %d values, want 2", inst.Manifest.Name, len(results))}, true
}
resPtr, resLen := uint32(results[0]), uint32(results[1])
resBytes, ok2 := mem.Read(resPtr, resLen)
if !ok2 {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: cannot read result at %d+%d", inst.Manifest.Name, resPtr, resLen)}, true
}
type dispatchResult struct {
Reply string `json:"reply"`
}
var dr dispatchResult
if err := json.Unmarshal(resBytes, &dr); err != nil {
// Fall back to raw bytes if the JSON is malformed.
return &CommandResult{Reply: string(resBytes)}, true
}
return &CommandResult{Reply: dr.Reply}, true
}
// discardWriter is a tiny io.Writer that throws everything away. Wazero's
// ModuleConfig accepts any io.Writer for stdout/stderr; using io.Discard would
// pull the extra import just to satisfy two calls.
type discardWriter struct{}
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
// listExportedCommands calls the plugin's optional `list_commands` export
// which returns (ptr u32, len u32) pointing to a JSON array of command name
// strings. If the export is absent or returns invalid JSON, an empty slice
// is returned and no command bindings are created.
func listExportedCommands(ctx context.Context, mod api.Module) []string {
fn := mod.ExportedFunction("list_commands")
if fn == nil {
return nil
}
results, err := fn.Call(ctx)
if err != nil || len(results) < 2 {
return nil
}
ptr, length := uint32(results[0]), uint32(results[1])
raw, ok := mod.Memory().Read(ptr, length)
if !ok {
return nil
}
var cmds []string
if err := json.Unmarshal(raw, &cmds); err != nil {
return nil
}
return cmds
}
+6 -2
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
@@ -50,7 +51,7 @@ func (s *UserService) UpdateProfile(userID int64, username string, avatar *strin
return user, nil
}
// ChangePassword verifies the old password hash matches, then updates.
// ChangePassword updates the user's password and revokes other sessions.
// Returns the number of other sessions revoked.
func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (int64, error) {
if err := s.st.UpdateUserPassword(userID, newPasswordHash); err != nil {
@@ -77,7 +78,10 @@ func (s *UserService) ListSessions(userID int64) ([]db.Session, error) {
// RevokeSession deletes a specific session owned by the user.
func (s *UserService) RevokeSession(userID, sessionID int64) error {
if err := s.st.DeleteSessionByID(sessionID, userID); err != nil {
return fmt.Errorf("%w: session not found", ErrNotFound)
if errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("%w: session not found", ErrNotFound)
}
return fmt.Errorf("%w: failed to revoke session", ErrInternal)
}
_ = s.st.LogAudit(userID, "session_revoke", "session", sessionID, "session revoked")
slog.Info("session revoked", "user_id", userID, "session_id", sessionID)
+17 -10
View File
@@ -9,9 +9,10 @@
//
// "none" — no-op provider; same as the default build.
// "prometheus" — pull-based metrics via a /metrics handler, spans are still
// processed by a batching tracer provider with no exporter.
// processed by a tracer provider with no exporter.
// "otlp" — push-based traces over OTLP/gRPC to cfg.OTLPEndpoint AND
// pull-based metrics via the Prometheus exporter.
// pull-based metrics via the Prometheus exporter (operators
// typically want both).
//
// The concrete Provider adapts our tiny telemetry.* API onto the upstream
// OTel SDK so the rest of the codebase never depends on the SDK directly.
@@ -84,10 +85,16 @@ func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error)
if endpoint == "" {
endpoint = "localhost:4317"
}
traceExp, err := otlptracegrpc.New(ctx,
otlpOpts := []otlptracegrpc.Option{
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
)
}
// OTLPInsecure honours the explicit operator opt-in for plaintext
// gRPC; production deployments should leave it false and provide
// a TLS endpoint.
if cfg.OTLPInsecure {
otlpOpts = append(otlpOpts, otlptracegrpc.WithInsecure())
}
traceExp, err := otlptracegrpc.New(ctx, otlpOpts...)
if err != nil {
return nil, fmt.Errorf("telemetry: otlp trace exporter: %w", err)
}
@@ -159,8 +166,8 @@ func (p *otelProvider) Meter(name string) Meter {
return &otelMeter{inner: p.mp.Meter(name)}
}
// HTTPMiddleware wraps next with otelhttp so every REST request becomes a span
// named after its route pattern.
// HTTPMiddleware wraps next with otelhttp so every REST request becomes a
// span named after its route pattern.
func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, "http.server",
otelhttp.WithServerName(p.serviceName),
@@ -243,9 +250,9 @@ func (g *otelGauge) Set(ctx context.Context, value float64, attrs ...Attr) {
// convertAttrs maps telemetry.Attr values to attribute.KeyValue. Unknown
// types are rendered via fmt.Sprint so callers never panic on exotic values.
// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields in
// OwnCord are unsigned, and routing them through the default fmt.Sprint path
// would encode them as string attributes and break metric aggregation.
// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields
// in OwnCord are unsigned, and routing them through the default fmt.Sprint
// path would encode them as string attributes and break metric aggregation.
func convertAttrs(in []Attr) []attribute.KeyValue {
if len(in) == 0 {
return nil
+20 -1
View File
@@ -3,7 +3,8 @@
// chat_command routes a slash command from a WS client to a registered plugin.
// If no plugin owns the command, an error is returned to the sender. If the
// plugin returns a Reply, it is sent only to the invoking client (ephemeral).
// If the plugin returns a Broadcast string, it is broadcast to the channel.
// If the plugin returns a Broadcast string, it is broadcast to the channel
// only after verifying the invoking client holds SEND_MESSAGES permission.
package ws
import (
@@ -12,10 +13,17 @@ import (
"fmt"
"log/slog"
"strings"
"github.com/owncord/server/permissions"
)
const MsgTypeChatCommand = "chat_command"
// maxCommandArgs is the maximum number of arguments accepted in a
// chat_command payload. This prevents a malicious client from flooding
// the plugin's allocate/dispatch ABI with thousands of strings.
const maxCommandArgs = 64
// chatCommandPayload is the client-supplied payload for a chat_command message.
type chatCommandPayload struct {
ChannelID int64 `json:"channel_id"`
@@ -32,6 +40,7 @@ func registerPluginCommandHandler(r *HandlerRegistry) {
// hub.pluginRegistry. Returns an error to the client when:
// - the payload is malformed,
// - the command name is empty,
// - too many arguments are supplied,
// - no plugin owns the command (unknown command),
// - the plugin returns an error reply.
func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
@@ -47,6 +56,11 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p
return
}
if len(p.Args) > maxCommandArgs {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("too many command arguments (max %d)", maxCommandArgs)))
return
}
if h.pluginRegistry == nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s (no plugins loaded)", cmd)))
return
@@ -69,6 +83,11 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p
}
if result.Broadcast != "" && p.ChannelID != 0 {
// Verify the invoking client has permission to send to this channel
// before broadcasting the plugin result to all channel members.
if !h.requireChannelPerm(c, p.ChannelID, permissions.SendMessages, "SEND_MESSAGES") {
return
}
// Channel broadcast — visible to everyone in the channel.
msg := buildCommandBroadcast(p.ChannelID, c.userID, cmd, result.Broadcast)
h.BroadcastToChannel(p.ChannelID, msg)
+13
View File
@@ -25,10 +25,23 @@ How to set up the development environment and contribute to OwnCord.
|---------|-------------|
| `go build -o chatserver.exe -ldflags "-s -w" .` | Build server binary (Windows) |
| `CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w" .` | Build server binary (Linux) |
| `go build -tags otel .` | Build with OpenTelemetry SDK (requires `go get` first — see Phase B) |
| `go build -tags wazero .` | Build with Wazero plugin runtime (requires `go get` first — see Phase C) |
| `go build -tags postgres .` | Build with PostgreSQL backend (requires pgx in go.mod) |
| `go test ./...` | Run all server tests |
| `go test ./... -cover` | Run server tests with coverage |
| `go test -race ./...` | Run server tests with race detection |
**Make targets** (run from `Server/`):
| Command | Description |
|---------|-------------|
| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` |
| `make sqlc-generate` | Regenerate type-safe Go for both SQLite (`db/dbgen/`) and PostgreSQL (`db/pgdbgen/`) engines |
| `make sqlc-verify` | Fail if committed `dbgen` / `pgdbgen` output is stale (used by CI) |
| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
| `make otel-down` | Stop and remove the OTel dev containers |
#### Client (Tauri v2)
**Build & dev**
+69
View File
@@ -67,6 +67,44 @@ Configuration is loaded in three layers (later layers override earlier ones):
|-----|------|---------|-------------|
| `github.token` | string | `""` | Optional GitHub API token for higher rate limits on update checks (5000 req/hr vs 60) |
### Event Persistence (`event_persistence`)
Controls the tiered event log used for WebSocket reconnection replay. When enabled, missed events are stored in the database so clients that reconnect after the in-memory ring buffer window (1 000 events) can still replay missed events from the DB tier.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `event_persistence.enabled` | bool | `true` | Enable cold-storage event persistence. When `false`, only the in-memory ring buffer is used (lower durability). |
| `event_persistence.retention_hours` | int | `24` | How long persisted events are kept before the pruner deletes them |
| `event_persistence.batch_size` | int | `50` | Maximum events per database flush |
| `event_persistence.batch_flush_ms` | int | `100` | Maximum delay between flushes (milliseconds) |
| `event_persistence.pruner_interval_minutes` | int | `60` | How often the pruner goroutine wakes up to delete expired events |
### Telemetry / OpenTelemetry (`telemetry`)
Controls the OpenTelemetry SDK. Requires building with `-tags otel` (see [Contributing](contributing.md)). When disabled, the server uses no-op tracer/meter providers; the legacy JSON `/api/v1/metrics` endpoint is always available regardless of this setting.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `telemetry.enabled` | bool | `false` | Enable the OTel SDK |
| `telemetry.exporter` | string | `"none"` | Exporter backend: `none`, `prometheus`, `otlp` |
| `telemetry.otlp_endpoint` | string | `""` | gRPC endpoint for the OTLP exporter (e.g. `localhost:4317`). Only used when `exporter: otlp`. |
| `telemetry.service_name` | string | `"owncord-server"` | OTel `service.name` resource attribute |
> **Local development:** Run `make otel-up` (from `Server/`) to start Jaeger + Prometheus via Docker.
> Jaeger UI: `http://localhost:16686` — Prometheus UI: `http://localhost:9090`
### Plugins (`plugins`)
Controls the Wazero WASM plugin runtime. Requires building with `-tags wazero`. When disabled, no plugins are loaded and the plugin admin endpoints return `501 Not Implemented`.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `plugins.enabled` | bool | `false` | Enable plugin loading at startup |
| `plugins.directory` | string | `"data/plugins"` | Directory scanned for plugin packages on startup |
| `plugins.max_memory_mb` | int | `64` | Maximum WASM linear memory per plugin (megabytes) |
| `plugins.cpu_budget_ms` | int | `100` | Maximum CPU time per plugin invocation (milliseconds) |
| `plugins.http_allowlist` | string[] | `[]` | Host suffixes plugins may reach via the `host_http` capability (e.g. `["api.steampowered.com"]`). Empty = no outbound HTTP. |
## Environment Variable Overrides
Every config key can be overridden via environment variables using the prefix `OWNCORD_`.
@@ -90,6 +128,14 @@ Every config key can be overridden via environment variables using the prefix `O
| `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` |
| `OWNCORD_VOICE_QUALITY` | `voice.quality` |
| `OWNCORD_GITHUB_TOKEN` | `github.token` |
| `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` |
| `OWNCORD_EVENT_PERSISTENCE_RETENTION_HOURS` | `event_persistence.retention_hours` |
| `OWNCORD_TELEMETRY_ENABLED` | `telemetry.enabled` |
| `OWNCORD_TELEMETRY_EXPORTER` | `telemetry.exporter` |
| `OWNCORD_TELEMETRY_OTLP_ENDPOINT` | `telemetry.otlp_endpoint` |
| `OWNCORD_TELEMETRY_SERVICE_NAME` | `telemetry.service_name` |
| `OWNCORD_PLUGINS_ENABLED` | `plugins.enabled` |
| `OWNCORD_PLUGINS_DIRECTORY` | `plugins.directory` |
## Example config.yaml
@@ -132,6 +178,29 @@ voice:
github:
token: "" # optional GitHub PAT for update check rate limits
# Event persistence (tiered reconnect replay)
event_persistence:
enabled: true
retention_hours: 24
batch_size: 50
batch_flush_ms: 100
pruner_interval_minutes: 60
# OpenTelemetry (requires build tag: -tags otel)
telemetry:
enabled: false
exporter: "none" # none | prometheus | otlp
otlp_endpoint: "" # e.g. "localhost:4317" for OTLP gRPC
service_name: "owncord-server"
# Plugin runtime (requires build tag: -tags wazero)
plugins:
enabled: false
directory: "data/plugins"
max_memory_mb: 64
cpu_budget_ms: 100
http_allowlist: [] # host suffixes plugins may reach, e.g. ["api.steampowered.com"]
```
## See Also