The guest's linear memory was taken from mod.Memory() and used unchecked, so
an untrusted plugin wasm with no memory section nil-dereferenced on the
unrecovered startup path and crashed the server. All guest-memory access now
goes through one guestMemory() helper that detects wazero's non-nil interface
wrapping a nil *MemoryInstance, binding no commands at activation and
returning the existing missing-export diagnostic on dispatch.
Verified by a panel of agents; the added regression test panics with the
finding's exact stack against the unpatched tree.
Note: TestRegistry_Activate_WithoutRuntime and
TestRegistry_EnablePlugin_RollsBackWhenActivationFails fail under
-tags wazero, confirmed here to fail identically on the base tree. They are
pre-existing and unrelated; CI builds the wazero variant but does not test it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(auth): add revocable API tokens (bot/service auth)
Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.
- migration 018 + dedicated api_tokens table (kept separate from sessions so
bulk logout and the per-user session cap never touch these); only the
SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
and adminAuthMiddleware now call. Sessions are matched first so existing
login behavior is unchanged; API tokens are a fallback only on session miss.
A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
api middleware integration (valid + revoked token)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): add owncord-introspect MCP server
A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.
- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
exposes api_request (full read-write passthrough), server_logs (admin SSE
ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(dependencies): update and add various crate versions in Cargo.lock
* feat(admin): manage API tokens from the admin panel
Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.
- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
backups/updates): an HTTP token-mint endpoint is a network-reachable
credential-minting surface, and API tokens deliberately survive password
change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
from request context (audits who clicked, not the bound user); the raw
token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: modernize to Go 1.26 idioms + enable modernize linter
Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).
Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.
- plugin/manifest.go parent-traversal check applied by hand: modernize
skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.
No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audits what actually has tests, then closes the gaps it found. Full write-up
with before/after numbers in docs/audit-test-coverage-2026-07-25.md.
Measurement first: `go test ./... -coverprofile` (what CI runs) instruments
each package only for itself, so code exercised through another package's
tests reads as uncovered — `service` reported 36.7% against a real 85%. All
analysis here uses -coverpkg=./..., and both views now have Makefile targets.
Features that had zero coverage at every layer:
- user blocking (db + service + the /api/v1/blocks routes)
- auth lockout persistence — the DB round-trip that survives a restart
- plugin install/enable/disable/uninstall and the plugin KV namespace
- event replay bounds (GetMaxEventSeq, PruneEventsOlderThan)
- LiveKit participant_joined webhook (replayed-token guard), the room-service
client, and proxyWebSocket/copyWS
- ws_proxy.rs and livekit_proxy.rs — pure helpers extracted, matching the
existing tofu.rs pattern, so cert-pin and header-injection checks are testable
Gaps that were hidden rather than absent:
- Server/admin reported 0.3% coverage with 307 tests passing. TestSpawnDetached_*
re-execs the test binary; the child inherited GOCOVERDIR and the parent's
stdout, clobbering the profile and printing "[no tests to run]". Now 71.4%,
and CI's uploaded artifact is correct.
- vitest.config.ts excluded 2.2k LOC unexplained, including two files that
already had tests. Trimmed to three entries, each justified inline.
- api.HandleLiveKitHealthForTest re-implemented the handler it claimed to
expose, so eight call sites tested a copy. Added a hook to the real one.
Two bugs found and pinned rather than silently patched: logctx.WithGroup nests
req_id under the group, and drag-reorder.ts takes one listener ref per channel
but releases one per sidebar, so the count never reaches zero.
Coverage: client 92.93% -> 94.87% statements (3371 -> 3572 tests) even after
un-excluding hidden files; Rust 47 -> 74 tests; Go zero-coverage functions
~70 -> 21, with plugin 61->77%, admin 67->86%, db 76->84%, service 85->91%.
Verified: go vet, all four build-tag variants, go test -race, -tags deadlock,
vitest --coverage, cargo test --lib, cargo clippy --all-targets, playwright.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEETs3Vh6sAHHb1jMBL75g
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.
- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
Context variants; the four redundant ctx-less passthroughs removed.
db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
RequireChannelAccess) and the service.Store interface mirror the new
signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
the connection ctx via DispatchV2; hub loops and startup wiring use
context.Background(); service methods thread ctx where they have one
and Background where no ctx exists. Public service surface reached by
ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
RequireChannelAccess, message/dm/block/invite/profile methods) is now
ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
invariant, found by a 3-lens adversarial review of the diff:
* voice-leave background retries (a dead webhook/connection ctx killed
retry 2 before it ran, leaving ghost capacity-holding voice rows)
* rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
* post-2FA-change DeleteOtherSessions and logout DeleteSession (the
security tail of a committed change must not die with the request)
* all api/ws audit writes (a banned user could suppress their own
login_blocked_banned row by aborting the request mid-bcrypt)
* admin backup VACUUM INTO (an interrupt left a truncated .db that
the backup list presented as restorable)
* post-commit message/edit refetches (a committed message must still
fan out when the sender disconnects)
* hub settings-cache refresh (one dead connection could pin stale
values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
config with justification (generated source must stay world-readable)
instead of flipping genprotocol output to 0o600.
Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
invokeCommand drove a shared wazero module (allocate/mem.Write/command_dispatch/mem.Read) with no per-instance lock, so concurrent invocations of the same plugin command raced the module's linear-memory buffer. Add a per-Instance mutex around the guest-call sequence. Confirmed under -race. (Security scan F2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The closure rationale for audit finding #4 claimed in five places that
nothing in the server calls EventSink.Dispatch. That is disprovable by
grep: ws/hub.go:1034 calls Dispatch on every broadcast message, and
api/router.go:134-139 wires h.pluginSink whenever plugins are enabled.
The call site is pre-existing on main, not introduced by this branch.
Restate the closure on the claim the evidence actually supports:
Dispatch has exactly one caller outside the plugin package's tests
(ws/hub.go, on the hub's broadcast goroutine under seqMu), but its loop
body invokes no guest code and no production code calls Subscribe, so
the subscriber set is always empty and no guest code executes on the
event path. Finding #4 stays closed; the reason changes.
Also warn on Subscribe that adding the first production caller turns
Dispatch's loop live on the hub's hot path, and note in the SECURITY
GATE that the call site already exists so wiring delivery is not a new
integration.
Corrected in: plugin/host_events.go (Dispatch + Subscribe comments),
plugin/audit_closure_test.go, docs/audit-2026-04-07.md (row 4 and the
structural-mitigation paragraph), docs/audit-2026-07-19.md §1 row,
docs/plans/audit-2026-07-19-decisions.md D11.
Comments and docs only — no behaviour change.
Closes audit-2026-04-07 CRITICAL #3. Holding the `commands` capability used
to bind whatever names the guest module returned from `list_commands`, so an
admin enabling a plugin could not know which commands it would claim and a
plugin could widen its own command surface after review.
The manifest is now the authority. `plugin.json` gains a `commands` block
(`[{"name": "hello"}]`) and `RegisterCommand` refuses any undeclared name —
the single choke point both auto-registration and direct registration route
through, so no caller can bypass it. Declared names are validated to the
dispatcher's canonical lowercase form, deduplicated, and capped at 64.
The object shape matches docs/plans/slash-commands.md so the richer
per-command schema can land later without a manifest migration.
Also pins the two neighbouring CRITICALs that verification found already
closed, and adds the storage key cap host_storage.go's doc comment already
promised:
- #2 (storage key isolation): TestStorageKeysIsolatedPerPlugin — the KV
namespace is the caller's Instance.ID with no parameter to override it,
and plugin_kv PRIMARY KEY (plugin_id, key) makes the split structural.
- #4 (event rate limit): TestEventDeliveryHasNoGuestPath — EventSink.Dispatch
invokes no guest code and has no callers, so there is nothing to limit yet;
a SECURITY GATE comment requires the limiter in whatever change wires
delivery.
- #5 mitigation: TestEmptyAllowlistDeniesEveryHost — the shipped empty
http_allowlist must fail closed.
BREAKING CHANGE: a plugin declaring the `commands` capability must now list
its commands in the manifest's `commands` block; undeclared names no longer
bind. Only the in-repo `hello` example is affected and is updated here.
The client held the Klipy key in VITE_KLIPY_API_KEY, which Vite inlines into
the shipped bundle by design — a build variable can never hold a secret. Move
the integration behind the server:
- New authenticated GET /api/v1/gif/search and /api/v1/gif/trending. The key
comes from the new `gif.api_key` config section (koanf,
OWNCORD_GIF_API_KEY) and never leaves the server.
- Default-off: with no key, both endpoints return 503 GIF_DISABLED so clients
can hide the picker instead of showing a broken one. Auth is checked first,
so anonymous callers cannot probe whether a key is configured.
- Outbound call reuses the existing SSRF-guarded dialer (exported as
plugin.GuardedDialContext) rather than a bare http.Get: resolve once,
reject private/loopback/link-local/CGN, dial only vetted IPs. Redirects are
not followed and the response body is size-capped.
- Only id/title/media_formats.{tinygif,gif}.url are forwarded — decoding into
the narrow struct is the allowlist, so an upstream that echoed the key
could not leak it. Upstream errors become a generic 502 and the key is
redacted from anything that reaches the logs.
- Dedicated `gif:` rate-limit bucket (30/min per IP) so debounced search
traffic cannot exhaust the shared bucket used by password/TOTP endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deletes Server/store (SQLiteStore, MemStore, the composed Store
interface) and collapses to a single sqlc-backed db package, executing
the prior audit's P4 "single data layer" direction (finding #6).
SQLiteStore was a pure pass-through to *db.DB, so consumers now depend
on narrow interfaces that *db.DB satisfies directly:
- service.Store (service/datastore.go, renamed from store/store.go)
- ws.EventStore (ws/eventstore.go)
- plugin.PluginStore (plugin/pluginstore.go)
The event- and plugin-KV methods that lived in the store's SQLite
implementation move into the db package (db/event_queries.go,
db/plugin_queries.go), keeping their raw-SQL form.
Tests: the MemStore-based unit tests now run against a real in-memory
SQLite db opened per-test with migrations applied, via package-local
seed helpers. Fault-injection tests embed a real *db.DB and override the
single method under test, preserving error-path coverage. Full server
suite and sqlc-verify are green.
Docs: audit finding #6 and A-2026-07-06 marked resolved; decisions D3
updated; architecture server.md / data-model.md diagrams and prose
updated to the api -> service -> db layering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
After validating every resolved IP, the guarded dial connected only to
ips[0] — an allowlisted dual-stack or round-robin host whose first record
was down hard-failed despite reachable vetted alternatives. The dial now
tries each vetted address in order (all records still validated before
any dial: one poisoned private record refuses the whole request).
Also removes the redundant rejectPrivateAddrs pre-resolves (initial
request + redirect hop): the guarded dial is the authoritative check and
every path flows through it, so the pre-resolve only cost an extra DNS
round trip while re-opening the rebinding TOCTOU it was meant to close.
Folds the W3-2-adjacent double-resolve cleanup from the plan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
installFromDisk replaced r.plugins/r.byName with a fresh *Instance but
left r.commands keyed to the old pointer and the old module running:
re-installing an enabled plugin blocked its own command re-registration
(RegisterCommand compared ownership by pointer) and kept dispatch routing
into the orphaned module until restart. Reinstall now deactivates the old
instance and clears its bindings, and RegisterCommand compares ownership
by plugin identity (manifest name) — the same plugin re-binds freely, a
different plugin still cannot hijack an owned command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hand-assembled wasm fixture whose command_dispatch spins forever only for
payloads over 100 bytes: baseline dispatch succeeds, an over-budget dispatch
surfaces the budget error, and the next dispatch on the same plugin succeeds
again via lazy re-instantiation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WithCloseOnContextDone(true) closes the module when the per-call budget
deadline fires, and nothing ever re-instantiated it — one over-budget
command bricked the plugin for every user until an admin disable/enable
cycle or a server restart. Now any guest-call failure that closed the
module (deadline, trap, parent-context cancellation) releases inst.module,
and the next dispatch lazily re-activates the same instance; a concurrent-
activation guard keeps double dispatches from leaking modules. Re-
instantiation resets guest in-memory state — documented at the budget site.
Host-call time exclusion from the budget is documented as a requirement but
not implemented: no host imports are wired into the runtime yet, so there
is no host-call time to exclude today.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PostgresStore was 86% stubs behind a build tag nothing enables, pgdbgen
carried hand-added build tags that fought sqlc-verify, and the runtime never
threaded store.Store through the handler boundary. Single-engine reality
shrinks the W1-3 attachment-ownership fix and ends the pgdbgen churn.
Removed: store/postgres.go, db/pgdbgen/, db/queries/postgres/,
migrations/postgres/, the sqlc postgres block, pgx from go.mod, the
startup-refusal branch, and the dead Postgres config surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
golangci-lint had been failing invisibly behind the earlier CI gate
failures. Default-build lint is now clean:
- Delete the unused pre-topic-limiter rate-limit constants, the unused
bluemonday sanitizer, and the dead broadcast variants superseded by
their Low/High counterparts (broadcastExclude,
broadcastToDMParticipants(+Exclude), sendSequencedToUsers,
PubSub.debugDump). Test references were comments only; updated to
name the live variants.
- Separate 'Phase X Step Y' file headers from the package clause with a
blank line so staticcheck ST1000 no longer reads them as malformed
package comments (proper package docs exist in hub.go/manifest.go).
- Add .gitattributes normalizing line endings to LF on checkout —
the Windows CI runner materialized CRLF, which made every
prettier-formatted file fail the format gate.
Known remainder (pre-existing, out of P0 scope): golangci-lint with
-tags wazero reports 3 gosec + 2 staticcheck and -tags otel 1+1; CI
lints the default build. Tracked for the P1 plugin pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies fixes for 20 adversarially-verified findings from a whole-codebase
security review (server side). All Go build-tag variants build, `go vet` is
clean, and the suite passes (the sole failing test, ws TestEmitEvents, is a
pre-existing nil-harness failure unrelated to these changes).
High severity:
- auth: close TOCTOU in TOTP verify rate-limit by recording each attempt
atomically up-front (was Check-then-Allow), restoring the per-user
brute-force cap.
- plugin: enforce the CPU/time budget on every WASM guest call via a
WithTimeout context (WithCloseOnContextDone interrupts runaways); the
configured budget was previously parsed but never applied.
- api/waf: inspect request bodies for chunked (ContentLength==-1) requests
so the SQLi/XSS/RCE body rules can no longer be bypassed.
- ws: rate-limit voice_join/voice_leave and voice_e2ee announce/offer, which
fan out to every participant and could force mass disconnects.
Medium severity:
- api: run bcrypt on the unknown-user login path (no || short-circuit) to
remove the timing-based username-enumeration oracle.
- ws: verify LiveKit webhooks via the SDK receiver so the signature is bound
to the body hash (kills forgery/replay).
- authz: require READ_MESSAGES for reactions and for plugin-command
broadcasts; route the latter through RequireChannelAccess.
- api: cache the client-update signature fetch and rate-limit the endpoint.
- service: propagate DeleteOtherSessions failure from ChangePassword instead
of silently reporting success.
- api: trust the rightmost non-proxy X-Forwarded-For entry, not the
client-controllable leftmost one.
- plugin: route auto-registered commands through the conflict-checked
RegisterCommand; pin the DNS-validated IP for host_http dials
(DNS-rebinding TOCTOU).
- api: mark access-controlled downloads private/no-cache + Vary: Origin.
Low severity:
- auth: fail closed when a fully-shaped TOTP ciphertext fails GCM auth
(was returning the ciphertext as plaintext).
- api: apply the livekit-proxy path allowlist to WebSocket upgrades too.
- service: verify attachment ownership before linking (IDOR).
- admin: bound the bootstrap setup invite (5 uses / 24h); re-verify the
update binary hash immediately before rename+spawn (TOCTOU).
- service: require BanMembers + role hierarchy for moderation ban/unban.
chore: stop tracking the stray Server/owncord-server.exe build artifact.
Test infra: add uploader_id to the hand-rolled ws test attachment schemas and
make MemStore.GetAttachmentByID a no-op lookup, matching production/DB behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
contextcheck: add ctx context.Context as first param to ListVisibleChannels,
BlockUser, CreateDM, CreateInvite, UpdateProfile, SendMessage; pass r.Context()
from HTTP handlers and ctx from WS handler; replace context.Background() in
telemetry spans with the propagated ctx.
errcheck: suppress justified Close() errors — defer func(){ _ = rows.Close() }()
in sqlite_events.go (idiomatic; rows.Err() checked), _ = resp.Body.Close() in
host_http.go (body fully consumed), _ = f.Close() in host_ui.go (read-only fd).
gocritic/rangeValCopy: rewrite for _, ch := range all (line 60) to indexed loop
in ChannelService.ListVisibleChannels to avoid 144-byte per-iteration copy.
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
- sandbox_wazero.go: explicitly discard plugin stdin via WithStdin to
prevent WASM modules from reading the server process's stdin fd
- channel_handler.go: replace ErrInternal message pass-through with
generic 'an internal error occurred' — full error stays server-side
in slog.Error only
- invite_handler_test.go: update assertions to expect generic message
- service/user.go: fix ChangePassword docstring (no old-password verification)
- service/user.go: RevokeSession now maps db.ErrNotFound→ErrNotFound and
all other store errors→ErrInternal, preventing internal failures from
masquerading as 404s
- plugin/loader.go: update scanPluginDirectory comment to reflect fail-fast
behavior; fix Lstat comment wording
- db/queries/sqlite/events.sql: CAST COALESCE result to INTEGER so sqlc
generates int64 instead of interface{}
- api/plugins_handler.go: log install error server-side and return sanitized
structured JSON response instead of raw err.Error()
- .github/workflows/ci.yml: remove continue-on-error from tag build steps
so tag boundary drift fails CI"
Agent-Logs-Url: https://github.com/J3vb/OwnCord/sessions/6635420c-af26-4cc0-9397-5e5b37887437
Co-authored-by: J3vb <192430104+J3vb@users.noreply.github.com>
Phase B Step 8 (OpenTelemetry) and Phase C Step 9 (Wazero plugin runtime)
were structurally scaffolded but the tagged builds were placeholders that
errored at runtime. This commit lands the real implementations behind the
existing build tags, plus three review passes worth of fixes across the
plugin admin handler, plugin registry, telemetry adapter, and Solid client.
Telemetry (Phase B Step 8)
- Add real go.opentelemetry.io/otel{,/sdk,/exporters/{prometheus,otlp...}}
modules to go.mod plus contrib/instrumentation/net/http/otelhttp.
- Replace the telemetry_otel.go skeleton with a working Provider that
wires Prometheus + OTLP/gRPC exporters, otelhttp middleware, span and
meter adapters, and an idempotent Shutdown.
- AppMetrics cache is now reset *before* SetGlobal to close a race where
a concurrent NewAppMetrics() could observe a swapped provider but read
stale no-op instruments.
- Init releases the trace provider on a later prometheus exporter
failure so Init never leaks gRPC connections.
- convertAttrs handles int32/uint/uint32/uint64/float32 explicitly;
uint64 values that exceed math.MaxInt64 fall back to a STRING attr
rather than wrapping into a negative int64 and corrupting metrics.
- Tests under -tags otel cover the prometheus scrape, span lifecycle,
histogram recording, shutdown idempotency, AppMetrics rebind, and
the uint64 overflow fallback.
Plugin runtime (Phase C Step 9)
- Add github.com/tetratelabs/wazero v1.11.0 to go.mod.
- platformInit creates a shared wazero.Runtime with WASI preview1
pre-instantiated; activateWithRuntime compiles + instantiates each
plugin module under that runtime; platformDeactivate closes per-
plugin modules without tearing down the runtime.
- DisablePlugin now calls platformDeactivate so the wazero module is
freed immediately instead of leaking until registry Close.
- activate() captures runtimePlatform under r.mu.RLock and passes it as
a parameter to activateWithRuntime; the call no longer re-reads the
field, closing a race with concurrent Close.
- invokeCommand calls the plugin's command_dispatch export when
present; missing/broken exports return a user-facing diagnostic
instead of crashing the dispatcher.
- Tests under -tags wazero cover registry creation, module compilation,
re-enable after disable (verifies the leak fix), close-twice safety,
invalid wasm rejection, and DispatchCommand with a missing export.
Fixture is a 41-byte embedded add.wasm; no external asset required.
Plugin admin handler hardening
- /api/v1/admin/plugins/install now rejects uploads whose multipart
Content-Type is not application/zip|x-zip-compressed|octet-stream
(415) and uploads whose body lacks the PK\\x03\\x04 / PK\\x05\\x06
zip magic (400). The 16 MiB cap and registry-side zip-slip / symlink
/ size-bomb defences are still applied as before.
- New plugins_handler_test.go covers list-empty, install-503-when-nil,
content-type rejection, magic rejection, happy path, lifecycle 503,
invalid id, and isZipContentType / hasZipMagic helpers.
Solid client (Phase B Step 6) cleanup
- vitest.config.ts now wires vite-plugin-solid and broadens the test
glob to include src/**/*.test.tsx so Badge.test.tsx is actually
discovered (it was silently skipped).
- pluginBridge.ts targets postMessage at window.location.origin
instead of "*", and exposes a destroy() that detaches the message
listener and clears mounted frames.
- solidMount.ts imports the JSX type from "solid-js" instead of
"solid-js/web" (the latter does not re-export it), unblocking
npx tsc --noEmit.
Build/test status
- go build succeeds on default, -tags otel, -tags wazero, and
-tags otel,wazero.
- go test passes on every tag combination across telemetry, plugin,
api, ws, service, store, and the rest of the tree.
- Client: npx tsc --noEmit clean; vitest 3188/3188 across 112 files.
PHASE_BC_LOCAL_TODO.md is updated to mark the OTel modules + real Init,
the wazero module + real platformInit, and the test coverage that
landed in this commit as completed.
https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
- main.go: TinyGo WASI plugin implementing the OwnCord plugin ABI
(allocate/deallocate, list_commands, command_dispatch, on_event)
- hello.wasm: precompiled binary (TinyGo 0.40.1, Go 1.25.3, wasm-opt 129)
Responds to /hello [name] with a greeting; proof-of-life for wazero runtime
Plugin TOML support (-tags wazero):
- manifest_toml.go: tryLoadPluginTOML using BurntSushi/toml v1.6.0
- manifest_nottoml.go: no-op stub for default build
- loader.go: prefers plugin.toml, falls back to plugin.json
Solid vitest preset:
- vitest.config.ts: add vite-plugin-solid, expand include to
src/components/solid/**/*.test.tsx — Badge.test.tsx now runs
automatically as part of npm test (112 files / 3188 tests)
Clean sweep of every actionable item from the two Copilot review passes
on head 59ae4d8. Grouped by severity:
─── Crash / security (must-fix) ─────────────────────────────────────
1. main.go:140 — telemetryShutdown nil panic.
telemetry.Init can return (nil, err) on the -tags otel skeleton
path; the deferred closure would then call a nil function. Normalise
to a no-op shutdown when Init errors so the defer is always safe.
2. api/upload_handler.go — permSvc nil deref.
MountUploadRoutes + handleServeFile dereference permSvc on every
authenticated file request. Add a fail-fast panic at mount time so
the misconfiguration surfaces at wiring, not on the first 500.
Update upload_handler_test.go to pass a real PermissionService built
on the test DB (the existing tests were missing the argument entirely,
which meant the package wouldn't compile — this fixes the real bug
Copilot flagged).
3. ws/event_persister.go — NewEventPersister nil EventStore panic.
run() dereferences p.store on every flush. Panic at constructor
time instead so the crash happens once at startup rather than
minutes later in a background goroutine.
4. plugin/host_ui.go — serve-time symlink check.
rejectSymlinksUnder only runs at install time, so a symlink created
post-install (accidental or malicious) would be followed by
http.ServeFile and leak host files. Add an os.Lstat + ModeSymlink
check + IsRegular check to AssetHandler on every request. Cheap
relative to the file read and closes the TOCTOU window.
─── Correctness / observability (should-fix) ───────────────────────
5. ws/deps.go:77 — requirePerm hides misconfig as FORBIDDEN.
Previously, nil database, nil perms, or a GetRoleForUser error all
returned ErrCodeForbidden with the same message, making operator
failures indistinguishable from legitimate permission denials.
Split the branches: misconfig + DB error now return ErrCodeInternal
with a server-side slog.Error so operators see the real problem;
FORBIDDEN is reserved for the actual permission-bit check.
6. telemetry/metrics.go — ServiceCallDurationMs renamed to Sec.
Field name said "Ms" but the instrument name was
`service_call_duration_seconds` with unit "s". Renamed the field
and updated all 8 service-layer callers so the struct field and
metric semantics match.
7. ws/event_persister.go — flushEvy typo → flushEvery.
Renamed the field and the one call site in run().
─── Comments out of sync with code ──────────────────────────────────
8. plugin/loader.go — Stat vs Lstat comment.
The comment claimed "Stat (not Lstat)" but the code correctly uses
os.Lstat to detect symlinks. Updated the comment to match the code;
the code was already right.
9. telemetry/telemetry_otel.go — compile claim wrong.
Comment said the file would fail to compile without the upstream
OTel modules, but the skeleton deliberately avoids importing them
and Init returns a runtime error instead. Updated the comment to
reflect actual CI behaviour (the -tags otel build step passes
today but doesn't exercise real telemetry).
─── Nit / polish ────────────────────────────────────────────────────
10. ws/event_pruner.go — startup delay magic constant.
Hard-coded time.Minute made the "run shortly after startup"
behaviour untestable (a test with a 100ms interval would still
wait a full minute). Cap the startup delay by the interval:
min(interval, time.Minute). Documented via a new `maxStartupDelay`
constant.
11. ws/event_pruner_test.go — new file.
Unit coverage for runPrune cutoff correctness, error swallowing,
StartEventPruner nil-store short-circuit, ctx cancellation, and
the interval-bounded startup delay from fix#10. Uses a fakeEventStore
stub that records every prune call and signals the first one so
tests don't sleep.
─── Verification ────────────────────────────────────────────────────
gofmt -l clean. No network access in sandbox so `go vet` and `go test`
could not run; the changes are local and surgical and every touched
file compiles in isolation against the existing signatures.
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
Final in-sandbox completeness pass. Five focused pieces; the remaining
items in PHASE_BC_LOCAL_TODO.md after this commit are all genuinely
local-only (toolchain, network, native deps).
Test coverage (the biggest gap from prior reviews)
- Server/plugin/manifest_test.go — pluginNameRegexp accept/reject table,
validateRelativePath table, oversized version, unknown permission.
- Server/plugin/host_http_test.go — hostAllowed dot-boundary suffix,
empty-entry rejection, case insensitivity, FQDN trailing dot. ipAllowed
table over loopback, RFC1918, RFC4193 (ULA), RFC6598 (CGN), link-local,
multicast, unspecified — both v4 and v6 — plus public-IP accept cases.
- Server/plugin/loader_test.go — rejectSymlinksUnder catches direct and
nested symlinks; scanPluginDirectory rejects a plugin whose entrypoint
is a symlink. Skipped on Windows where symlink creation needs elevation.
- Server/plugin/host_ui_test.go — AssetHandler serves declared files,
rejects undeclared files (404), rejects path traversal, supports nested
asset paths.
- Server/ws/hub_seedseq_test.go — SeedSeq monotonic, never-backwards,
concurrent CAS safety, integration with nextSeq.
- Server/ws/extract_event_type_test.go — table covering happy paths,
control char rejection, escaped quote rejection, length cap (64),
empty/missing/non-JSON inputs.
Plugin install endpoint (closes a real feature gap)
- Server/plugin/registry.go — InstallFromZip extracts a plugin .zip into
a staging directory under cfg.Directory, validates it zip-slip safe
(cleaned-path Rel check), refuses non-regular entries, refuses
symlinks, caps compressed at 16 MiB and uncompressed total at 64 MiB
(each file gated by io.CopyN against the remaining budget). Manifest
is parsed at the staged root, then atomically renamed into the
canonical plugin directory and registered via the existing
installFromDisk path.
- Server/api/plugins_handler.go — POST /install accepts multipart with
one "plugin" file part, http.MaxBytesReader caps the request body,
io.LimitReader caps the in-memory buffer, calls Registry.InstallFromZip,
returns 201 with the new plugin name. The endpoint inherits the Pass 2
admin auth + IP gate (mounted under r.Use(admin.RequireAdminAuth)).
Protocol surface
- Server/ws/serve.go — buildAuthOK now takes replaySource and includes
it in the auth_ok payload as "replay_source": "none" | "buffer" | "db".
Two call sites updated: reconnect path passes the existing local,
fresh-connect path passes "none". Test export updated to pass "none".
CI build-tag matrix
- .github/workflows/ci.yml — three new steps inside server-build-test
build the server with -tags otel, -tags wazero, and -tags otel,wazero.
All three are continue-on-error: true until the upstream OTel and
wazero modules land in go.mod (tracked in PHASE_BC_LOCAL_TODO.md).
Once they do, dropping continue-on-error converts the steps into
hard CI gates against tag-boundary drift.
Documentation
- CHANGELOG.md — new root-level file with curated entries for Phase B,
Phase C, security, and behavioural changes operators must know about
(notably event_persistence.enabled = true by default).
- PHASE_BC_LOCAL_TODO.md — ticks off the install endpoint, the
replay_source field, and the existing event_persistence defaultYAML
entry. The remaining items are toolchain-bound.
After this pass, the in-sandbox completeness ceiling is reached.
Everything still pending requires Go 1.25 toolchain, npm install,
real OTel SDK + wazero modules, sqlc, postgres backend impl, or
tinygo.
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
Eight focused follow-ups from the medium-severity review bucket. All
in-sandbox tractable; no module changes, no new dependencies.
Performance
- Drop the defensive memcpy in EventPersister.Enqueue. The hub already
passes a fresh slice from wrapWithSeq and the copy was happening under
seqMu, serializing broadcast throughput. Documented the no-mutate
contract on the call site.
Observability
- AppMetrics gains WSEventsPersistErrors counter; the persister run loop
bumps both it and the existing WSEventsPersisted counter via cached
metrics handle.
- Hub.persistEvent now extracts the real event type ("chat_message",
"voice_join", ...) from the wrapped JSON envelope via a small
no-allocation byte scan instead of recording the generic
"broadcast"/"channel_broadcast" label.
- Added OTel spans + ServiceCallDurationMs histogram entries on one
public method per remaining service: DMService.CreateDM,
VoiceService.JoinChannel, InviteService.CreateInvite,
ModerationService.BanUser, BlockService.BlockUser,
UserService.UpdateProfile. Mirrors the existing pattern from
MessageService.SendMessage.
Hardening
- plugin/loader now Lstat-walks each plugin directory and rejects any
symlink, plus refuses an entrypoint that is itself a symlink. The
asset handler's prefix check stays as defense in depth.
- ipAllowed (plugin HTTP capability) now rejects RFC6598 carrier-grade
NAT (100.64.0.0/10), closing a gap in net.IP.IsPrivate which only
covers RFC1918 + RFC4193.
- Registry.activateAll syncs Instance.Enabled := true after a successful
activate so callers reading the in-memory flag see the live state.
Documentation
- defaultYAML now documents the new event_persistence, telemetry, and
plugins config blocks with their defaults and one-line descriptions.
- PHASE_BC_LOCAL_TODO.md ticks off five items (defaultYAML docs ×2,
remaining service spans, registry wiring already-fixed in Pass 2).
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
Phase B + C review pass: critical security and correctness fixes.
Security
- S1: plugin admin endpoints now require admin.RequireAdminAuth in addition
to AdminIPRestrict. Previously a LAN attacker on the allowed CIDR could
list/enable/disable/uninstall plugins without a session.
- S2: rewrite plugin HTTPDo allowlist with proper net/url parsing. Empty
entries are ignored, suffix matches require a dot boundary, and a custom
Dialer rejects loopback / RFC1918 / link-local addresses to close the
DNS-rebinding TOCTOU window. Redirects re-validated.
- S3 + #9: manifest Name pinned to ^[a-z0-9][a-z0-9_-]{0,63}$, Entrypoint
and UI tab assets validated against absolute / "..", NUL byte, backslash
and non-canonical paths. Asset handler hardened with filepath.Rel check
for symlink and prefix-without-separator escapes.
- S5: pluginBridge postMessage handler ignores the pluginId in the message
body and uses an e.source -> contentWindow lookup instead, defeating
spoofed messages from same-origin scripts.
- S8: HTTPDo body capped at 5 MiB via io.LimitReader, redirects bounded
to 5 hops.
Correctness
- Critical seq alignment: PersistEvent now takes the hub-assigned seq as a
required parameter so the events table row seq always matches the wrapped
payload seq. Hub seeds its in-memory atomic counter from MAX(events.seq)
on startup. Drops in the persister queue no longer mis-align row vs
payload seq.
- #1: live plugin.Registry constructed in main.go BEFORE NewRouter and
threaded through; admin handler is no longer wired with nil.
- #3: EventPersister.Stop is now safe to call without a prior Start by
tracking a started flag — previously deadlocked waiting on done.
Wiring
- NewRouter signature gains *plugin.Registry; two test callers updated.
- admin.RequireAdminAuth exported as a thin wrapper over the existing
package-private adminAuthMiddleware.
- sqlc query templates updated for the new PersistEvent + GetMaxEventSeq
contracts (sqlite + postgres).
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk