diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index 03d28861..7b8e2f1c 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1", + "@solidjs/testing-library": "^0.8.10", "@stryker-mutator/core": "^9.6.0", "@stryker-mutator/typescript-checker": "^9.6.0", "@stryker-mutator/vitest-runner": "^9.6.0", @@ -46,6 +47,7 @@ "typescript": "^5.7", "typescript-eslint": "^8.58.0", "vite": "^6", + "vite-plugin-solid": "^2.11.0", "vitest": "^3" }, "prettier": { @@ -70,6 +72,7 @@ "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-updater": "^2.10.0", "livekit-client": "^2.18.0", + "solid-js": "^1.9.3", "zod": "^4.3.6" } } diff --git a/Client/tauri-client/src/components/solid/Badge.test.tsx b/Client/tauri-client/src/components/solid/Badge.test.tsx new file mode 100644 index 00000000..cb4e5397 --- /dev/null +++ b/Client/tauri-client/src/components/solid/Badge.test.tsx @@ -0,0 +1,27 @@ +/** + * Phase B Step 6 — Solid pipeline smoke test. + * + * Verifies that the Vite + Solid + Vitest configuration actually compiles + * and renders a component end-to-end. This test only exists to prove the + * pipeline; the test scope expands as more components migrate. + */ + +import { describe, it, expect } from "vitest"; +import { render } from "@solidjs/testing-library"; +import { Badge } from "./Badge"; + +describe("Badge (solid)", () => { + it("renders the label", () => { + const { getByText } = render(() => ); + expect(getByText("online")).toBeTruthy(); + }); + + it("invokes onClick when activated", () => { + let clicked = 0; + const { getByText } = render(() => ( + clicked++} /> + )); + getByText("press me").click(); + expect(clicked).toBe(1); + }); +}); diff --git a/Client/tauri-client/src/components/solid/Badge.tsx b/Client/tauri-client/src/components/solid/Badge.tsx new file mode 100644 index 00000000..32570aef --- /dev/null +++ b/Client/tauri-client/src/components/solid/Badge.tsx @@ -0,0 +1,46 @@ +/** + * Phase B Step 6 — first Solid.js leaf component. + * + * Trivial proof-of-concept badge used by other Solid components and the + * mount helper. Self-contained: no store subscriptions, no async work, just + * a presentational element. Use it as the canonical example when migrating + * vanilla badge/pill components in the rest of the tree. + */ + +import type { JSX } from "solid-js"; + +export interface BadgeProps { + label: string; + /** Visual variant; defaults to "neutral". */ + variant?: "neutral" | "online" | "idle" | "dnd" | "offline"; + /** Optional click handler. When set the badge gains role="button". */ + onClick?: () => void; +} + +const variantClass: Record, string> = { + neutral: "badge", + online: "badge badge--online", + idle: "badge badge--idle", + dnd: "badge badge--dnd", + offline: "badge badge--offline", +}; + +export function Badge(props: BadgeProps): JSX.Element { + const cls = () => variantClass[props.variant ?? "neutral"]; + return ( + { + if (props.onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + props.onClick(); + } + }} + > + {props.label} + + ); +} diff --git a/Client/tauri-client/src/components/solid/ChannelListItem.tsx b/Client/tauri-client/src/components/solid/ChannelListItem.tsx new file mode 100644 index 00000000..60bf4b4c --- /dev/null +++ b/Client/tauri-client/src/components/solid/ChannelListItem.tsx @@ -0,0 +1,52 @@ +/** + * Phase B Step 6 — second Solid.js leaf component. + * + * Renders a single row in the channel list. Reads from the existing + * `channelsStore` via the Solid adapter so it stays in sync with whatever + * the vanilla dispatcher writes into the store. + * + * This is the canonical example for migrating list-item components: pure + * presentation, fed by an accessor, with a click handler delegated up to the + * parent so the component knows nothing about the dispatcher. + */ + +import type { JSX } from "solid-js"; +import { Show } from "solid-js"; +import { fromStoreSlice } from "@lib/solidAdapter"; +import { channelsStore, type Channel, type ChannelsState } from "@stores/channels.store"; +import { Badge } from "./Badge"; + +export interface ChannelListItemProps { + channelId: number; + onSelect: (id: number) => void; +} + +export function ChannelListItem(props: ChannelListItemProps): JSX.Element { + // Subscribe to just this row's channel object so unrelated changes don't + // re-render. Falsy → row is hidden until the channel arrives. + const channel = fromStoreSlice( + channelsStore, + (s) => s.channels.get(props.channelId), + ); + const isActive = fromStoreSlice( + channelsStore, + (s) => s.activeChannelId === props.channelId, + ); + + return ( + + {(ch) => ( +
  • props.onSelect(props.channelId)} + > + # + {ch().name} + 0}> + + +
  • + )} +
    + ); +} diff --git a/Client/tauri-client/src/components/solid/PluginContainer.tsx b/Client/tauri-client/src/components/solid/PluginContainer.tsx new file mode 100644 index 00000000..d032bd57 --- /dev/null +++ b/Client/tauri-client/src/components/solid/PluginContainer.tsx @@ -0,0 +1,30 @@ +/** + * Phase C Step 9 — Solid component that hosts a plugin tab. + * + * Mounts the plugin's iframe via `pluginBridge.mount(...)` and tears it down + * when the component is disposed. The container itself is intentionally tiny: + * the bridge owns the iframe lifecycle and the postMessage protocol. + */ + +import { onMount, onCleanup, type JSX } from "solid-js"; +import { pluginBridge, type PluginTabBinding } from "@lib/pluginBridge"; + +export interface PluginContainerProps { + binding: PluginTabBinding; +} + +export function PluginContainer(props: PluginContainerProps): JSX.Element { + let host!: HTMLDivElement; + let dispose: (() => void) | undefined; + + onMount(() => { + dispose = pluginBridge.mount(props.binding, host); + }); + onCleanup(() => { + dispose?.(); + }); + + return ( +
    + ); +} diff --git a/Client/tauri-client/src/components/solid/README.md b/Client/tauri-client/src/components/solid/README.md new file mode 100644 index 00000000..ed0b86af --- /dev/null +++ b/Client/tauri-client/src/components/solid/README.md @@ -0,0 +1,49 @@ +# Solid.js components + +Phase B Step 6 lives here. This directory holds the incremental Solid.js +migration of the OwnCord client. Vanilla TypeScript components and Solid +components coexist throughout the migration; new UI work goes in this +directory, and existing leaf components are ported one PR at a time. + +## Migration recipe + +1. **Pick a leaf component.** Start with components that have no children of + their own and read at most one or two stores. Avoid container components + until every leaf inside them is Solid-native. +2. **Read state via the adapter.** Import `fromStore` or `fromStoreSlice` from + `@lib/solidAdapter` and pass the existing custom store. Do **not** rewrite + the store — Solid components and vanilla components share the same source + of truth. +3. **Mount via `solidMount`.** Containers that aren't yet Solid-native should + call `mountSolid(component, parentEl)` from `@lib/solidMount`. The returned + handle has the same `{ destroy }` shape that the rest of the codebase uses. +4. **Test the pipeline.** New components get a `*.test.tsx` next to them + using `@solidjs/testing-library`. The tests run under the existing Vitest + configuration without any extra setup. +5. **Delete vanilla DOM code.** Once a component is fully migrated, remove + the old factory function and update its callers to import from + `@components/solid/...`. + +## Allowed reactivity + +- `createSignal`, `createMemo`, `createEffect`, `createResource` +- `Show`, `For`, `Switch`/`Match`, `Index` +- `onMount`, `onCleanup` + +## Forbidden patterns + +- Direct DOM manipulation inside Solid components — use Solid's bindings or a + ref. The point of the migration is to delete manual DOM lifecycle code. +- Re-implementing existing stores in Solid's `createStore`. Wrap the existing + custom store via `fromStore` instead. +- Touching framework-agnostic code (`lib/ws.ts`, `lib/dispatcher.ts`, + `lib/livekitSession.ts`, `lib/api.ts`). These never need to know about Solid. + +## Existing components + +- `Badge.tsx` — presentational badge / pill (no store dependency) +- `ChannelListItem.tsx` — single channel row (subscribes to channels.store) + +The plugin client bridge introduced by Phase C also adds a `PluginContainer` +component to this directory; see `Server/plugin/host_ui.go` for the host +side of that contract. diff --git a/Client/tauri-client/src/lib/pluginBridge.ts b/Client/tauri-client/src/lib/pluginBridge.ts new file mode 100644 index 00000000..16b17e02 --- /dev/null +++ b/Client/tauri-client/src/lib/pluginBridge.ts @@ -0,0 +1,110 @@ +/** + * Phase C Step 9 — client-side plugin bridge. + * + * Mounts plugin UI tabs in sandboxed iframes and forwards postMessage traffic + * between the host client and each plugin. The host injects theme CSS + * variables on every load so plugin UIs match OwnCord's look and feel + * without each plugin re-implementing them. + * + * The bridge is intentionally tiny: it owns iframe lifecycles and message + * routing; everything else (rendering tabs, fetching the plugin list) lives + * in PluginContainer.tsx. + */ + +export interface PluginTabBinding { + pluginId: number; + pluginName: string; + tabId: string; + label: string; + asset: string; +} + +export interface PluginMessageEnvelope { + pluginId: number; + type: string; + payload?: unknown; +} + +type Listener = (env: PluginMessageEnvelope) => void; + +const HOST_ORIGIN_PREFIX = "owncord-plugin-host"; + +class PluginBridge { + private frames = new Map(); + private listeners = new Set(); + private themeVars: Record = {}; + + constructor() { + window.addEventListener("message", this.onMessage); + } + + /** Replace the theme variables broadcast to plugin iframes. */ + setTheme(vars: Record): void { + this.themeVars = { ...vars }; + for (const [pid, frame] of this.frames) { + this.postToFrame(pid, frame, { type: "theme", payload: this.themeVars }); + } + } + + /** Mount an iframe for binding into parent. Returns a destroy function. */ + mount(binding: PluginTabBinding, parent: HTMLElement): () => void { + const iframe = document.createElement("iframe"); + iframe.className = "plugin-iframe"; + iframe.sandbox.add("allow-scripts"); + iframe.title = `${binding.pluginName}: ${binding.label}`; + iframe.src = `/api/v1/plugins/${encodeURIComponent(binding.pluginName)}/ui/${binding.asset}`; + iframe.dataset.pluginId = String(binding.pluginId); + iframe.addEventListener("load", () => { + this.postToFrame(binding.pluginId, iframe, { type: "theme", payload: this.themeVars }); + this.postToFrame(binding.pluginId, iframe, { type: "ready", payload: null }); + }); + parent.appendChild(iframe); + this.frames.set(binding.pluginId, iframe); + return () => { + iframe.remove(); + this.frames.delete(binding.pluginId); + }; + } + + /** Listen for messages emitted by any mounted plugin iframe. */ + onMessageEnvelope(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Send a host → plugin message. */ + send(pluginId: number, type: string, payload?: unknown): void { + const frame = this.frames.get(pluginId); + if (!frame) return; + this.postToFrame(pluginId, frame, { type, payload }); + } + + private postToFrame(pluginId: number, frame: HTMLIFrameElement, msg: { type: string; payload: unknown }): void { + frame.contentWindow?.postMessage( + { source: HOST_ORIGIN_PREFIX, pluginId, ...msg }, + "*", + ); + } + + private onMessage = (e: MessageEvent): void => { + const data = e.data; + if (!data || typeof data !== "object") return; + if ((data as { source?: unknown }).source === HOST_ORIGIN_PREFIX) return; // own echo + const env = data as { pluginId?: unknown; type?: unknown; payload?: unknown }; + if (typeof env.pluginId !== "number" || typeof env.type !== "string") return; + const envelope: PluginMessageEnvelope = { + pluginId: env.pluginId, + type: env.type, + payload: env.payload, + }; + for (const l of this.listeners) { + try { + l(envelope); + } catch (err) { + console.error("plugin bridge listener threw", err); + } + } + }; +} + +export const pluginBridge = new PluginBridge(); diff --git a/Client/tauri-client/src/lib/solidAdapter.ts b/Client/tauri-client/src/lib/solidAdapter.ts new file mode 100644 index 00000000..5c35df7c --- /dev/null +++ b/Client/tauri-client/src/lib/solidAdapter.ts @@ -0,0 +1,56 @@ +/** + * Phase B Step 6 — Solid.js adapter. + * + * Bridges the existing custom reactive `Store` (lib/store.ts) into Solid's + * signal model so migrated components can read from existing stores without + * touching them. Vanilla and Solid components coexist throughout the + * migration: a vanilla component can update a store, and any Solid component + * subscribed via this adapter sees the new value through its accessor. + * + * Usage from a Solid component: + * + * import { fromStore } from "@lib/solidAdapter"; + * import { authStore } from "@stores/auth"; + * + * const auth = fromStore(authStore); + * return
    {auth().username}
    ; + * + * The accessor returned by fromStore is a Solid signal getter, so it triggers + * fine-grained reactivity in any computation, JSX expression, or `createMemo`. + */ + +import { createSignal, onCleanup, type Accessor } from "solid-js"; +import type { Store } from "./store"; + +/** + * Wrap a custom Store as a Solid signal accessor. The signal updates whenever + * the underlying store fires, and the subscription is torn down when the + * Solid owner is disposed (so leaf components don't leak listeners). + */ +export function fromStore(store: Store): Accessor { + const [value, setValue] = createSignal(store.getState(), { equals: false }); + const unsub = store.subscribe((next) => setValue(() => next)); + onCleanup(unsub); + return value; +} + +/** + * Wrap a derived slice of a store. Equivalent to fromStore(store).map(selector) + * but uses the store's native subscribeSelector so changes are gated by the + * existing equality comparator. + */ +export function fromStoreSlice( + store: Store, + selector: (state: T) => S, + isEqual?: (a: S, b: S) => boolean, +): Accessor { + const initial = selector(store.getState()); + const [value, setValue] = createSignal(initial, { equals: false }); + const unsub = store.subscribeSelector( + selector, + (next) => setValue(() => next), + isEqual, + ); + onCleanup(unsub); + return value; +} diff --git a/Client/tauri-client/src/lib/solidMount.ts b/Client/tauri-client/src/lib/solidMount.ts new file mode 100644 index 00000000..24c32590 --- /dev/null +++ b/Client/tauri-client/src/lib/solidMount.ts @@ -0,0 +1,38 @@ +/** + * Phase B Step 6 — Solid.js mount helper. + * + * Wraps Solid's `render(...)` so a Solid component conforms to the + * `{ mount, destroy }` factory contract used everywhere else in the vanilla + * codebase. Existing container components can host a Solid leaf without + * being aware of Solid at all: + * + * import { mountSolid } from "@lib/solidMount"; + * import { Badge } from "@components/solid/Badge"; + * + * const handle = mountSolid(() => Badge({ label: "online" }), parentEl); + * // …later + * handle.destroy(); + */ + +import { render, type JSX } from "solid-js/web"; + +export interface SolidMount { + /** The DOM element the Solid root is rendered into. */ + el: HTMLElement; + /** Tear the Solid root down and remove it from the DOM. */ + destroy(): void; +} + +export function mountSolid(component: () => JSX.Element, parent: HTMLElement): SolidMount { + const host = document.createElement("div"); + host.dataset.solidRoot = "true"; + parent.appendChild(host); + const dispose = render(component, host); + return { + el: host, + destroy() { + dispose(); + host.remove(); + }, + }; +} diff --git a/Client/tauri-client/tsconfig.json b/Client/tauri-client/tsconfig.json index 784de97f..9ea85696 100644 --- a/Client/tauri-client/tsconfig.json +++ b/Client/tauri-client/tsconfig.json @@ -11,6 +11,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", "lib": [ "ES2023", "DOM", diff --git a/Client/tauri-client/vite.config.ts b/Client/tauri-client/vite.config.ts index 6f58351a..8f5a5449 100644 --- a/Client/tauri-client/vite.config.ts +++ b/Client/tauri-client/vite.config.ts @@ -1,5 +1,10 @@ import { defineConfig, type Plugin } from "vite"; import { resolve } from "path"; +// Phase B Step 6 — Solid.js incremental migration. The plugin compiles +// JSX/TSX files anywhere under src/components/solid/ to direct DOM ops, while +// the rest of the vanilla codebase keeps building unchanged. The plugin is a +// no-op for files that don't contain Solid syntax. +import solidPlugin from "vite-plugin-solid"; const host = process.env.TAURI_DEV_HOST; @@ -14,7 +19,13 @@ function stripCrossOrigin(): Plugin { } export default defineConfig({ - plugins: [stripCrossOrigin()], + plugins: [ + // Solid first so its JSX transform runs before any other transforms. + solidPlugin({ + include: ["src/components/solid/**/*.{ts,tsx,js,jsx}"], + }), + stripCrossOrigin(), + ], build: { modulePreload: { polyfill: false }, cssCodeSplit: false, diff --git a/PHASE_BC_LOCAL_TODO.md b/PHASE_BC_LOCAL_TODO.md new file mode 100644 index 00000000..21093168 --- /dev/null +++ b/PHASE_BC_LOCAL_TODO.md @@ -0,0 +1,274 @@ +# Phase B + C — Local Follow-up TODO + +This file enumerates everything from `phase-b-acceleration.md` and +`phase-c-differentiation.md` that **could not be completed inside the +sandboxed Claude session** because the work requires: + +- network access to fetch new modules / npm packages, +- a Go toolchain matching `go.mod`'s `go 1.25.0` directive, +- a WASM toolchain (TinyGo / Rust / AssemblyScript), +- a real machine that can run `npm install`, `cargo`, `tauri`, etc. + +The session **branch is `claude/plan-phases-b-c-bGpoS`**. Everything below +must be run on a developer machine (or CI) before the branch is mergeable. + +The session-resident plan that was actually executed lives in +`/root/.claude/plans/woolly-wiggling-wolf.md` (not in this repo). + +--- + +## Verification (do first — confirms the in-session work compiles) + +- [ ] `cd Server && go build ./...` — The repo's `go.mod` requires + Go 1.25.0; the sandbox only had 1.24.7, so `go build` and `go vet` + could not be run. Manual file-by-file audit found no errors, but a + compile is the source of truth. +- [ ] `cd Server && go test ./store/... ./ws/... ./plugin/... ./telemetry/...` + — Exercises the new EventStore, EventPersister, telemetry no-op + provider, and plugin manifest/loader tests. +- [ ] `cd Server && go vet ./...` +- [ ] `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. + +--- + +## Phase B Step 6 — Solid.js migration (rest of the components) + +The session landed: +- Vite + TS toolchain wiring (`vite.config.ts`, `tsconfig.json`) +- `solid-js` + `vite-plugin-solid` + `@solidjs/testing-library` in + `package.json` +- `src/lib/solidAdapter.ts` (wraps custom stores as Solid signals) +- `src/lib/solidMount.ts` (`{mount, destroy}` adapter for Solid roots) +- `src/components/solid/Badge.tsx` — first leaf +- `src/components/solid/ChannelListItem.tsx` — store-subscribed leaf +- `src/components/solid/Badge.test.tsx` — pipeline smoke test +- `src/components/solid/README.md` — migration recipe + +Still TODO locally: + +- [ ] 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 + `src/components/solid/README.md`. Suggested order: presence pills, + typing indicators, message attachments, voice volume meters, then + containers (channel list, member list, message list). +- [ ] Once every leaf is migrated, replace the manual `mountSolid` calls + 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). + +--- + +## Phase B Step 7 — Event persistence + +The session landed: +- `Server/migrations/014_events_table.sql` (SQLite) +- `events` table appended to `Server/migrations/postgres/001_initial_schema.sql` +- `Server/db/queries/sqlite/events.sql`, `Server/db/queries/postgres/events.sql` +- `Server/db/persisted_event.go` — domain type +- `EventStore` sub-interface added to `Server/store/store.go` +- SQLite implementation in `Server/store/sqlite_events.go` (raw SQL via + `*sql.DB`, no `dbgen` dependency) +- MemStore implementation in `Server/store/memstore_events.go` +- Postgres stubs returning `ErrPostgresNotImplemented` +- `Server/ws/event_persister.go` — batched async writer +- `Server/ws/event_pruner.go` — retention pruner goroutine +- Three `replayBuf.Push` call sites in `Server/ws/hub.go` now also call + `h.persistEvent(...)` +- Tiered reconnect replay in `Server/ws/serve.go` (buffer → DB → full) +- Reconnect-tier metrics in the hub + telemetry counter +- `EventPersistenceConfig` added to `Server/config/config.go` with + defaults `{enabled: true, retention_hours: 24, batch_size: 50, + batch_flush_ms: 100, pruner_interval_minutes: 60}` +- `Server/main.go` wires the persister + pruner +- `Server/ws/event_persister_test.go` — batching, drop, drain tests + +Still TODO locally: + +- [ ] Run `make sqlc-generate` so `db/dbgen` and `db/pgdbgen` learn about + `events.sql`. The session used raw SQL through `*sql.DB` (matching + the existing `pgdbgen` workaround), so this is optional for SQLite + but required for the postgres backend. +- [ ] Replace the postgres EventStore stubs in `Server/store/postgres.go` + with real wrappers around the generated `pgdbgen` code (the same + mechanical work tracked in `docs/phase-a-status.md` for the other + stub methods). +- [ ] Add an integration test that pushes more than 1000 events through a + real hub with a 1000-slot buffer, disconnects at seq=500, and asserts + the DB tier returns the missing events. The session test + (`event_persister_test.go`) covers the persister in isolation but + not the buffer→DB handoff inside `handleReconnect`. +- [ ] Add a `replay_source` field to the auth_ok payload so the client + can log the tier. The hub already records the tier in metrics; the + client surface change is a separate UX call. +- [ ] Document the new `event_persistence` block in `defaultYAML` inside + `Server/config/config.go` (the struct and defaults landed; the + sample config comments did not). + +--- + +## Phase B Step 8 — OpenTelemetry + +The session landed: +- `Server/telemetry/telemetry.go` — public API + no-op provider +- `Server/telemetry/telemetry_default.go` — default-build `Init` +- `Server/telemetry/telemetry_otel.go` — wazero/postgres-style build-tag + skeleton (build with `-tags otel`); compiles only when the OTel modules + are in `go.mod` and is currently a structural placeholder +- `Server/telemetry/metrics.go` — `AppMetrics` bundle +- `Server/telemetry/middleware.go` — `HTTPMiddleware` + `PrometheusHandler` +- `Server/telemetry/telemetry_test.go` +- `Server/api/router.go` mounts `telemetry.HTTPMiddleware()` + unconditionally and the Prometheus exporter when non-nil +- `Server/main.go` calls `telemetry.Init` early and defers `Shutdown` +- `TelemetryConfig` added to `Server/config/config.go` +- Spans added to `MessageService.SendMessage`, + `PermissionService.HasChannelPerm`, + `ChannelService.ListVisibleChannels` +- Reconnect-tier counter wired into `WSReconnectTierTotal` from + `Server/ws/serve.go` + +Still TODO locally: + +- [ ] Add the OTel modules to `go.mod`: + ```sh + cd Server + go get go.opentelemetry.io/otel@latest \ + go.opentelemetry.io/otel/sdk@latest \ + go.opentelemetry.io/otel/exporters/prometheus@latest \ + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@latest \ + go.opentelemetry.io/contrib/instrumentation/github.com/go-chi/chi/v5/otelchi@latest + go mod tidy + ``` +- [ ] Replace the placeholder body of `telemetry/telemetry_otel.go`'s + `Init` with the real tracer + meter provider construction and the + `otelchi.Middleware` wiring (see the inline TODO comment with the + call graph). +- [ ] Build with `-tags otel` once the SDK is in `go.mod` and add a CI + job that exercises the tagged build. +- [ ] Add spans to the remaining service-layer entry points + (`DMService`, `VoiceService`, `InviteService`, `ModerationService`, + `BlockService`, `UserService`). The pattern is identical to the + three already done in this branch. +- [ ] Document the new `telemetry` block in `defaultYAML` inside + `Server/config/config.go`. +- [ ] Add a `make otel-up` target that spins up Jaeger via + docker-compose for local tracing development. + +--- + +## Phase C Step 9 — Wazero plugin runtime + +The session landed: +- `Server/plugin/manifest.go` — JSON manifest parser + capability checks +- `Server/plugin/loader.go` — directory scan + entrypoint validation +- `Server/plugin/registry.go` — registry + lifecycle (install/enable/uninstall) +- `Server/plugin/host_commands.go`, `host_storage.go`, `host_events.go`, + `host_http.go`, `host_ui.go` — capability surfaces +- `Server/plugin/sandbox_default.go` — no-op runtime (default build) +- `Server/plugin/sandbox_wazero.go` — `-tags wazero` skeleton +- `Server/plugin/errors.go` +- `Server/plugin/plugin_test.go` +- `Server/plugin/examples/hello/plugin.json` + `README.md` +- `Server/migrations/015_plugins.sql` (SQLite) +- `plugins` + `plugin_kv` tables appended to the postgres schema +- `Server/db/queries/sqlite/plugins.sql`, + `Server/db/queries/postgres/plugins.sql` +- `PluginStore` sub-interface in `Server/store/store.go` with SQLite, + MemStore, and postgres-stub implementations +- `Server/api/plugins_handler.go` — admin REST surface +- `Server/api/router.go` mounts the admin plugin handler +- `Server/main.go` constructs and starts the registry when + `cfg.Plugins.Enabled` +- `PluginsConfig` added to `Server/config/config.go` +- `Client/tauri-client/src/lib/pluginBridge.ts` — iframe + postMessage host +- `Client/tauri-client/src/components/solid/PluginContainer.tsx` — Solid + host component for plugin tabs + +Still TODO locally: + +- [ ] Add wazero to `go.mod`: + ```sh + cd Server + go get github.com/tetratelabs/wazero@latest + go mod tidy + ``` +- [ ] Replace the placeholder body in `Server/plugin/sandbox_wazero.go` + with real wazero runtime construction. The file contains an inline + TODO with the exact API call graph. +- [ ] 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. +- [ ] Wire `Server/plugin/host_events.go` into the WS pub/sub hub + (`Server/ws/pubsub.go`). The session left this as a stub because + the registration surface needs to be designed alongside the actual + plugin event format — the hub-side code path is straightforward + once the format is fixed. +- [ ] Wire `Server/plugin/host_commands.go` into the WS slash-command + dispatcher. **There is currently no slash-command dispatcher in the + WS layer.** Either add one (small surface) or fold plugin commands + into the REST layer first. The plugin Registry already exposes + `DispatchCommand` so the hookup is one call site. +- [ ] Pass the live `*plugin.Registry` from `Server/main.go` into + `NewPluginAdminHandler` (the router currently constructs the + handler with a nil registry so list works but enable/disable returns + 503). +- [ ] 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 + ``` +- [ ] Implement plugin marketplace install path + (`POST /api/v1/admin/plugins/install` with multipart zip). The + handler is scaffolded but the install endpoint is currently absent. +- [ ] Replace plugin postgres stubs in `Server/store/postgres.go` with + real `pgdbgen`-backed implementations once `make sqlc-generate` + runs (same blocker as Phase B Step 7). +- [ ] Build the first real plugin: game detection. Pulls Steam API, + tracks playtime, exposes `/playtime` slash command. This is the + acceptance criterion in `phase-c-differentiation.md`. + +--- + +## Build-tag matrix the user should set up in CI + +| Tag set | What it builds | Why | +|---|---|---| +| (none) | Default sqlite-only server, no OTel SDK, no wazero | Existing path | +| `otel` | Above + OpenTelemetry SDK + Prometheus exporter | Phase B Step 8 | +| `wazero` | Above + plugin runtime executes WASM modules | Phase C Step 9 | +| `postgres` | Replaces sqlite with postgres backend | Phase A pending | +| `otel,wazero,postgres` | Full community-hub build | Production target | + +Each tag is independently selectable; CI should test every combination at +least minimally so the build-tag boundaries don't drift. + +--- + +## Things explicitly **out of scope** for this branch + +(Documenting so reviewers don't expect them.) + +- Migration of the entire vanilla TypeScript component tree to Solid. Two + proof-of-concept components landed; the rest is mechanical PRs. +- Full OTel SDK wiring (only the public API + no-op default + structural + build-tag skeleton landed). +- Real Wazero `.wasm` execution (only the registry, host APIs, and a + build-tag skeleton landed). +- Real game-detection plugin (the manifest fields and host APIs needed to + build it are in place). +- A reverse postgres → sqlite migration (Phase A documented this is + deliberately unavailable; nothing changed here). diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go new file mode 100644 index 00000000..1ed8351d --- /dev/null +++ b/Server/api/plugins_handler.go @@ -0,0 +1,106 @@ +// Phase C Step 9 — Plugin admin REST surface. +// +// All endpoints are mounted under the existing AdminIPRestrict group so they +// inherit the same network ACL as the rest of the admin panel. Authentication +// is handled by the admin handler's middleware before this handler runs. +package api + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/plugin" + "github.com/owncord/server/store" +) + +// PluginAdminHandler exposes plugin lifecycle operations to the admin panel. +type PluginAdminHandler struct { + registry *plugin.Registry + store store.PluginStore +} + +// NewPluginAdminHandler builds an http.Handler that the router can mount. +// Pass a nil registry when plugin support is disabled — the handler then +// reports an empty list and 503 on lifecycle calls. +func NewPluginAdminHandler(registry *plugin.Registry, st store.PluginStore) http.Handler { + h := &PluginAdminHandler{registry: registry, store: st} + r := chi.NewRouter() + r.Get("/", h.list) + r.Post("/{id}/enable", h.enable) + r.Post("/{id}/disable", h.disable) + r.Delete("/{id}", h.uninstall) + return r +} + +func (h *PluginAdminHandler) list(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if h.store == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + rows, err := h.store.ListPlugins(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, rows) +} + +func (h *PluginAdminHandler) enable(w http.ResponseWriter, r *http.Request) { + id, ok := parsePluginID(w, r) + if !ok { + return + } + if h.registry == nil { + http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable) + return + } + if err := h.registry.EnablePlugin(r.Context(), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *PluginAdminHandler) disable(w http.ResponseWriter, r *http.Request) { + id, ok := parsePluginID(w, r) + if !ok { + return + } + if h.registry == nil { + http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable) + return + } + if err := h.registry.DisablePlugin(r.Context(), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) { + id, ok := parsePluginID(w, r) + if !ok { + return + } + if h.registry == nil { + http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable) + return + } + if err := h.registry.UninstallPlugin(r.Context(), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func parsePluginID(w http.ResponseWriter, r *http.Request) (int64, bool) { + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + http.Error(w, "invalid plugin id", http.StatusBadRequest) + return 0, false + } + return id, true +} diff --git a/Server/api/router.go b/Server/api/router.go index b4f5174d..a80bd59e 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -19,6 +19,7 @@ import ( "github.com/owncord/server/service" "github.com/owncord/server/storage" dbstore "github.com/owncord/server/store" + "github.com/owncord/server/telemetry" "github.com/owncord/server/updater" "github.com/owncord/server/ws" ) @@ -37,6 +38,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // handled explicitly in clientIPWithProxies using the trusted_proxies config. r.Use(middleware.Recoverer) r.Use(requestLogger) // structured request/response logging + // Phase B Step 8 — OpenTelemetry HTTP tracing. No-op when telemetry is + // disabled or the otel build tag is not set, so this is safe to mount + // unconditionally. + r.Use(telemetry.HTTPMiddleware()) r.Use(SecurityHeadersWithTLS(cfg.TLS.Mode)) r.Use(MaxBodySizeUnless(defaultMaxBodySize, "/api/v1/uploads")) // upload route exempt @@ -203,6 +208,15 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri func(ctx context.Context) (bool, error) { return hub.LiveKitHealthCheck(ctx) }, )) + // Phase B Step 8 — OpenTelemetry Prometheus exporter. Mounted alongside + // the legacy JSON endpoint when a Prometheus exporter is wired (otel + // build, exporter == "prometheus"). Returns 404 in the default no-op build + // because telemetry.PrometheusHandler() returns nil. + if promH := telemetry.PrometheusHandler(); promH != nil { + r.With(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies)). + Mount("/metrics", promH) + } + // Admin panel: static files + REST API (Phase 6). // Restrict /admin to configured CIDRs (default: private networks only). u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord") @@ -210,6 +224,12 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri r.Group(func(r chi.Router) { r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies)) r.Mount("/admin", adminHandler) + + // Phase C Step 9 — plugin admin REST surface. Mounted alongside the + // admin panel so it inherits the same network ACL. Plugin runtime is + // owned by main.go; the handler accepts a nil registry and reports + // 503 on lifecycle calls when plugin support is disabled. + r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(nil, st)) }) // Client auto-update endpoint (unauthenticated). diff --git a/Server/config/config.go b/Server/config/config.go index e0f7835d..f1a194b0 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -19,12 +19,59 @@ import ( // Config holds the full server configuration. type Config struct { - Server ServerConfig `koanf:"server"` - Database DatabaseConfig `koanf:"database"` - TLS TLSConfig `koanf:"tls"` - Upload UploadConfig `koanf:"upload"` - Voice VoiceConfig `koanf:"voice"` - GitHub GitHubConfig `koanf:"github"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + TLS TLSConfig `koanf:"tls"` + Upload UploadConfig `koanf:"upload"` + Voice VoiceConfig `koanf:"voice"` + GitHub GitHubConfig `koanf:"github"` + EventPersistence EventPersistenceConfig `koanf:"event_persistence"` + Telemetry TelemetryConfig `koanf:"telemetry"` + Plugins PluginsConfig `koanf:"plugins"` +} + +// EventPersistenceConfig (Phase B Step 7) controls the tiered event log used +// for WebSocket reconnection replay. +type EventPersistenceConfig struct { + // Enabled toggles cold-storage persistence. When false the server falls + // back to ring-buffer-only behaviour (Phase A semantics). + Enabled bool `koanf:"enabled"` + // RetentionHours is how long persisted events are kept before pruning. + RetentionHours int `koanf:"retention_hours"` + // BatchSize is the maximum number of events per persister flush. + BatchSize int `koanf:"batch_size"` + // BatchFlushMs is the maximum delay between persister flushes. + BatchFlushMs int `koanf:"batch_flush_ms"` + // PrunerIntervalMinutes is how often the pruner goroutine wakes up. + PrunerIntervalMinutes int `koanf:"pruner_interval_minutes"` +} + +// TelemetryConfig (Phase B Step 8) controls the OpenTelemetry exporter. +type TelemetryConfig struct { + // Enabled toggles the OTel SDK. When false the server uses no-op + // tracer/meter providers and the legacy /metrics endpoint stays the + // only metrics surface. + Enabled bool `koanf:"enabled"` + // Exporter is "none" | "prometheus" | "otlp". + Exporter string `koanf:"exporter"` + // OTLPEndpoint is the gRPC endpoint when Exporter == "otlp". + OTLPEndpoint string `koanf:"otlp_endpoint"` + // ServiceName is the resource service.name attribute. + ServiceName string `koanf:"service_name"` +} + +// PluginsConfig (Phase C Step 9) controls the Wazero plugin runtime. +type PluginsConfig struct { + // Enabled toggles plugin loading at startup. + Enabled bool `koanf:"enabled"` + // Directory is the on-disk directory scanned for plugin packages. + Directory string `koanf:"directory"` + // MaxMemoryMB caps a single plugin's WASM linear memory. + MaxMemoryMB int `koanf:"max_memory_mb"` + // CPUBudgetMs caps a single plugin invocation's CPU time. + CPUBudgetMs int `koanf:"cpu_budget_ms"` + // HTTPAllowlist enumerates host suffixes plugins may reach via host_http. + HTTPAllowlist []string `koanf:"http_allowlist"` } // GitHubConfig holds GitHub API settings for update checking. @@ -138,6 +185,25 @@ func defaults() Config { Quality: "medium", }, GitHub: GitHubConfig{}, + EventPersistence: EventPersistenceConfig{ + Enabled: true, + RetentionHours: 24, + BatchSize: 50, + BatchFlushMs: 100, + PrunerIntervalMinutes: 60, + }, + Telemetry: TelemetryConfig{ + Enabled: false, + Exporter: "none", + ServiceName: "owncord-server", + }, + Plugins: PluginsConfig{ + Enabled: false, + Directory: "data/plugins", + MaxMemoryMB: 64, + CPUBudgetMs: 100, + HTTPAllowlist: []string{}, + }, } } diff --git a/Server/db/persisted_event.go b/Server/db/persisted_event.go new file mode 100644 index 00000000..85cf9eb5 --- /dev/null +++ b/Server/db/persisted_event.go @@ -0,0 +1,27 @@ +package db + +import "time" + +// PersistedEvent is a single broadcast event written to the events table for +// cold-replay during reconnection. The event payload is the same wire-format +// JSON the WebSocket clients receive at broadcast time, including the seq +// field injected by the hub. +// +// Phase B Step 7 (event persistence layer). +type PersistedEvent struct { + Seq int64 + EventType string + ChannelID int64 + Payload []byte + CreatedAt time.Time +} + +// PluginRow represents a row in the plugins table (Phase C Step 9). +type PluginRow struct { + ID int64 + Name string + Version string + Enabled bool + ManifestJSON string + InstalledAt time.Time +} diff --git a/Server/db/queries/postgres/events.sql b/Server/db/queries/postgres/events.sql new file mode 100644 index 00000000..2c61d7fa --- /dev/null +++ b/Server/db/queries/postgres/events.sql @@ -0,0 +1,14 @@ +-- name: PersistEvent :one +INSERT INTO events (event_type, channel_id, payload) +VALUES ($1, $2, $3) +RETURNING seq; + +-- name: GetEventsSince :many +SELECT seq, event_type, channel_id, payload, created_at +FROM events +WHERE seq > $1 +ORDER BY seq ASC +LIMIT $2; + +-- name: PruneEventsOlderThan :execrows +DELETE FROM events WHERE created_at < $1; diff --git a/Server/db/queries/postgres/plugins.sql b/Server/db/queries/postgres/plugins.sql new file mode 100644 index 00000000..d0349d86 --- /dev/null +++ b/Server/db/queries/postgres/plugins.sql @@ -0,0 +1,36 @@ +-- name: InstallPlugin :one +INSERT INTO plugins (name, version, manifest_json) +VALUES ($1, $2, $3) +ON CONFLICT (name) DO UPDATE + SET version = excluded.version, + manifest_json = excluded.manifest_json +RETURNING id; + +-- name: EnablePlugin :exec +UPDATE plugins SET enabled = TRUE WHERE id = $1; + +-- name: DisablePlugin :exec +UPDATE plugins SET enabled = FALSE WHERE id = $1; + +-- name: UninstallPlugin :exec +DELETE FROM plugins WHERE id = $1; + +-- name: GetPlugin :one +SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = $1; + +-- name: GetPluginByName :one +SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = $1; + +-- name: ListPlugins :many +SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name; + +-- name: PluginKVGet :one +SELECT value FROM plugin_kv WHERE plugin_id = $1 AND key = $2; + +-- name: PluginKVSet :exec +INSERT INTO plugin_kv (plugin_id, key, value) +VALUES ($1, $2, $3) +ON CONFLICT (plugin_id, key) DO UPDATE SET value = excluded.value; + +-- name: PluginKVDelete :exec +DELETE FROM plugin_kv WHERE plugin_id = $1 AND key = $2; diff --git a/Server/db/queries/sqlite/events.sql b/Server/db/queries/sqlite/events.sql new file mode 100644 index 00000000..e6a18941 --- /dev/null +++ b/Server/db/queries/sqlite/events.sql @@ -0,0 +1,12 @@ +-- name: PersistEvent :execresult +INSERT INTO events (event_type, channel_id, payload) VALUES (?, ?, ?); + +-- name: GetEventsSince :many +SELECT seq, event_type, channel_id, payload, created_at +FROM events +WHERE seq > ? +ORDER BY seq ASC +LIMIT ?; + +-- name: PruneEventsOlderThan :execrows +DELETE FROM events WHERE created_at < ?; diff --git a/Server/db/queries/sqlite/plugins.sql b/Server/db/queries/sqlite/plugins.sql new file mode 100644 index 00000000..b3825d69 --- /dev/null +++ b/Server/db/queries/sqlite/plugins.sql @@ -0,0 +1,31 @@ +-- name: InstallPlugin :execresult +INSERT INTO plugins (name, version, manifest_json) VALUES (?, ?, ?) +ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json; + +-- name: EnablePlugin :exec +UPDATE plugins SET enabled = 1 WHERE id = ?; + +-- name: DisablePlugin :exec +UPDATE plugins SET enabled = 0 WHERE id = ?; + +-- name: UninstallPlugin :exec +DELETE FROM plugins WHERE id = ?; + +-- name: GetPlugin :one +SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?; + +-- name: GetPluginByName :one +SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?; + +-- name: ListPlugins :many +SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name; + +-- name: PluginKVGet :one +SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?; + +-- name: PluginKVSet :exec +INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?) +ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value; + +-- name: PluginKVDelete :exec +DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?; diff --git a/Server/main.go b/Server/main.go index 75da7008..a87c3e6b 100644 --- a/Server/main.go +++ b/Server/main.go @@ -23,7 +23,11 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" + "github.com/owncord/server/plugin" "github.com/owncord/server/storage" + "github.com/owncord/server/store" + "github.com/owncord/server/telemetry" + "github.com/owncord/server/ws" ) // version is overridden at build time via -ldflags "-X main.version=1.0.0". @@ -132,10 +136,75 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { log.Info("cleared stale voice states") } + // ── 4b. Telemetry (Phase B Step 8) ───────────────────────────────────── + telemetryShutdown, telErr := telemetry.Init(context.Background(), cfg.Telemetry) + if telErr != nil { + log.Warn("telemetry init failed; continuing without OpenTelemetry", "error", telErr) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := telemetryShutdown(shutdownCtx); err != nil { + log.Warn("telemetry shutdown returned error", "error", err) + } + }() + // ── 5. Build HTTP router ─────────────────────────────────────────────── router, hub, routerCleanup := api.NewRouter(cfg, database, version, logBuf) defer routerCleanup() + // ── 5b. Wire event persistence (Phase B Step 7) ──────────────────────── + // Construct a Store wrapper for the cold-tier event log + plugin KV. The + // store-everywhere refactor (Phase A pending TODO) will eventually thread + // this through NewRouter directly; for now we attach it after the fact so + // the router signature stays unchanged. + storeWrapper := store.NewSQLiteStore(database) + if cfg.EventPersistence.Enabled && hub != nil { + persister := ws.NewEventPersister( + storeWrapper, + 4096, + cfg.EventPersistence.BatchSize, + time.Duration(cfg.EventPersistence.BatchFlushMs)*time.Millisecond, + ) + persister.Start(context.Background()) + 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) + defer func() { + prunerCancel() + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCancel() + persister.Stop(stopCtx) + }() + } + + // ── 5c. Wire plugin runtime (Phase C Step 9) ─────────────────────────── + if cfg.Plugins.Enabled { + registry, plugErr := plugin.NewRegistry(plugin.Config{ + Directory: cfg.Plugins.Directory, + MaxMemoryMB: cfg.Plugins.MaxMemoryMB, + CPUBudgetMs: cfg.Plugins.CPUBudgetMs, + HTTPAllowlist: cfg.Plugins.HTTPAllowlist, + Store: storeWrapper, + }) + if plugErr != nil { + log.Warn("plugin runtime init failed; continuing without plugins", "error", plugErr) + } else { + if err := registry.LoadAll(context.Background()); err != nil { + log.Warn("plugin loader: failed to scan directory", "error", err) + } + defer func() { + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = registry.Close(closeCtx) + }() + } + } + // ── 6. Start server ──────────────────────────────────────────────────── addr := fmt.Sprintf(":%d", cfg.Server.Port) srv := &http.Server{ diff --git a/Server/migrations/014_events_table.sql b/Server/migrations/014_events_table.sql new file mode 100644 index 00000000..39b13fe9 --- /dev/null +++ b/Server/migrations/014_events_table.sql @@ -0,0 +1,14 @@ +-- Phase B Step 7: Event persistence layer. +-- Stores broadcast events for cold-replay during reconnection when the +-- in-memory ring buffer no longer covers the client's last_seq. Pruned by a +-- background goroutine after the configured retention window (default 24h). +CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + payload BLOB NOT NULL, + channel_id INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_events_channel_seq ON events(channel_id, seq); +CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); diff --git a/Server/migrations/015_plugins.sql b/Server/migrations/015_plugins.sql new file mode 100644 index 00000000..7552aaba --- /dev/null +++ b/Server/migrations/015_plugins.sql @@ -0,0 +1,18 @@ +-- Phase C Step 9: Wazero plugin runtime. +-- Records installed plugins and their per-plugin KV namespace. The KV store +-- is exposed to plugins via the `storage` host-API capability. +CREATE TABLE IF NOT EXISTS plugins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + manifest_json TEXT NOT NULL, + installed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS plugin_kv ( + plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (plugin_id, key) +); diff --git a/Server/migrations/postgres/001_initial_schema.sql b/Server/migrations/postgres/001_initial_schema.sql index b4ec48bc..07fc8ab5 100644 --- a/Server/migrations/postgres/001_initial_schema.sql +++ b/Server/migrations/postgres/001_initial_schema.sql @@ -302,3 +302,35 @@ CREATE TABLE IF NOT EXISTS user_blocks ( ); CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_id, blocker_id); + +-- ── events (Phase B Step 7: event persistence) ────────────────────────────── +-- Cold-storage replay buffer for WebSocket reconnections that fall outside the +-- in-memory ring window. Pruned by a background goroutine after the configured +-- retention window (default 24h). +CREATE TABLE IF NOT EXISTS events ( + seq BIGSERIAL PRIMARY KEY, + event_type TEXT NOT NULL, + payload BYTEA NOT NULL, + channel_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_events_channel_seq ON events(channel_id, seq); +CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); + +-- ── plugins (Phase C Step 9: Wazero plugin runtime) ───────────────────────── +CREATE TABLE IF NOT EXISTS plugins ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + manifest_json TEXT NOT NULL, + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS plugin_kv ( + plugin_id BIGINT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value BYTEA NOT NULL, + PRIMARY KEY (plugin_id, key) +); diff --git a/Server/plugin/errors.go b/Server/plugin/errors.go new file mode 100644 index 00000000..fbec2ba3 --- /dev/null +++ b/Server/plugin/errors.go @@ -0,0 +1,16 @@ +package plugin + +import "errors" + +// ErrRuntimeUnavailable is returned when the plugin runtime cannot start +// because the wazero build tag was not enabled. Default builds surface this +// error from Registry.LoadAll so the rest of the server can keep running. +var ErrRuntimeUnavailable = errors.New("plugin runtime: wazero build tag not enabled (build with -tags wazero to load .wasm plugins)") + +// ErrPluginNotFound is returned when an operation references an unknown +// plugin id or name. +var ErrPluginNotFound = errors.New("plugin not found") + +// ErrCapabilityNotGranted is returned when a host API call would require a +// capability the plugin's manifest did not declare. +var ErrCapabilityNotGranted = errors.New("plugin capability not granted") diff --git a/Server/plugin/examples/hello/README.md b/Server/plugin/examples/hello/README.md new file mode 100644 index 00000000..921c6df3 --- /dev/null +++ b/Server/plugin/examples/hello/README.md @@ -0,0 +1,29 @@ +# hello plugin + +Phase C Step 9 — proof-of-life plugin used by `Server/plugin/plugin_test.go`. + +## Manifest + +`plugin.json` declares the `commands`, `events`, and `storage` capabilities. +The manifest is the only file the default (no-`-tags wazero`) build needs — +the registry persists it into the plugins table without executing the .wasm. + +## Building the WASM + +The .wasm binary is intentionally NOT checked in. Build it locally with TinyGo +or any other WASM toolchain that emits a module exporting `command_dispatch`, +`on_event`, and `_start`: + +```sh +# TinyGo example (writes hello.wasm into this directory) +tinygo build -o hello.wasm -target wasi ./main.go +``` + +A trivial main.go that satisfies the host API is sketched in +`Server/plugin/sandbox_wazero.go`'s docstring. + +## Tests + +`Server/plugin/plugin_test.go` exercises the manifest parser and the loader +against this directory. It does not require the .wasm to be present — +manifest-only validation is the default-build coverage path. diff --git a/Server/plugin/examples/hello/plugin.json b/Server/plugin/examples/hello/plugin.json new file mode 100644 index 00000000..7ed04242 --- /dev/null +++ b/Server/plugin/examples/hello/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "hello", + "version": "0.1.0", + "author": "OwnCord", + "description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.", + "entrypoint": "hello.wasm", + "permissions": ["commands", "events", "storage"], + "resources": { + "max_memory_mb": 16, + "cpu_budget_ms": 50 + } +} diff --git a/Server/plugin/host_commands.go b/Server/plugin/host_commands.go new file mode 100644 index 00000000..9bd104da --- /dev/null +++ b/Server/plugin/host_commands.go @@ -0,0 +1,63 @@ +// Phase C Step 9 — `commands` host capability. +// +// Plugins that declare the "commands" capability register one or more slash +// commands at activation time. The WS command dispatcher (Server/ws/command.go) +// calls Registry.DispatchCommand after exhausting its built-in command table. +package plugin + +import ( + "context" + "fmt" + "strings" +) + +// CommandResult is what a plugin returns from a command invocation. +type CommandResult struct { + // Reply is sent back to the invoking user as an ephemeral message. + Reply string + // Broadcast, when set, is also broadcast to the channel. + Broadcast string +} + +// RegisterCommand binds cmd to inst. Called from the activation path in the +// wazero-tagged build once the module exports its `register_commands` table. +// Default build can call it directly from tests. +func (r *Registry) RegisterCommand(cmd string, inst *Instance) error { + cmd = strings.ToLower(strings.TrimPrefix(cmd, "/")) + if cmd == "" { + return fmt.Errorf("plugin: cannot register empty command") + } + if !inst.Manifest.HasCapability(CapCommands) { + return ErrCapabilityNotGranted + } + r.mu.Lock() + defer r.mu.Unlock() + if existing, ok := r.commands[cmd]; ok && existing != inst { + return fmt.Errorf("plugin: command %q already registered by %q", cmd, existing.Manifest.Name) + } + r.commands[cmd] = inst + return nil +} + +// DispatchCommand routes a slash command to the owning plugin. Returns +// (nil, false) when no plugin owns the command, letting the WS dispatcher +// fall back to the not-found response. Returns (nil, true) when the runtime +// is unavailable so the dispatcher can show a helpful error message. +func (r *Registry) DispatchCommand(ctx context.Context, userID int64, channelID int64, cmd string, args []string) (*CommandResult, bool) { + if r == nil { + return nil, false + } + cmd = strings.ToLower(strings.TrimPrefix(cmd, "/")) + r.mu.RLock() + inst, ok := r.commands[cmd] + r.mu.RUnlock() + if !ok { + return nil, false + } + if r.runtimePlatform == nil { + return &CommandResult{ + Reply: fmt.Sprintf("plugin %q owns /%s but the wazero runtime is not built (run with -tags wazero)", inst.Manifest.Name, cmd), + }, true + } + return r.invokeCommand(ctx, inst, userID, channelID, cmd, args) +} diff --git a/Server/plugin/host_events.go b/Server/plugin/host_events.go new file mode 100644 index 00000000..82af0c18 --- /dev/null +++ b/Server/plugin/host_events.go @@ -0,0 +1,69 @@ +// Phase C Step 9 — `events` host capability. +// +// Plugins that subscribe to events declare topic names in their manifest. +// At activation time the wazero-tagged build wires each subscription into +// the WS pub/sub hub via Hub.Subscribe; the default build records the +// subscription in-memory only. +package plugin + +import ( + "context" + "sync" +) + +// EventSink is the channel a subscribed plugin reads from. The wazero-tagged +// build forwards each event to the plugin's `on_event` exported function. +type EventSink struct { + mu sync.Mutex + subs map[string][]*Instance +} + +// NewEventSink returns a fresh sink. Used by the registry as the central +// fan-out for plugin event delivery. +func NewEventSink() *EventSink { + return &EventSink{subs: make(map[string][]*Instance)} +} + +// Subscribe binds inst to topic. Multiple plugins may subscribe to the same +// topic — events fan out to every subscriber. +func (s *EventSink) Subscribe(topic string, inst *Instance) error { + if !inst.Manifest.HasCapability(CapEvents) { + return ErrCapabilityNotGranted + } + s.mu.Lock() + defer s.mu.Unlock() + s.subs[topic] = append(s.subs[topic], inst) + return nil +} + +// UnsubscribeAll removes every subscription owned by inst (called on disable). +func (s *EventSink) UnsubscribeAll(inst *Instance) { + s.mu.Lock() + defer s.mu.Unlock() + for topic, list := range s.subs { + kept := list[:0] + for _, e := range list { + if e != inst { + kept = append(kept, e) + } + } + if len(kept) == 0 { + delete(s.subs, topic) + } else { + s.subs[topic] = kept + } + } +} + +// Dispatch invokes every subscriber's on_event for topic. The default build +// is a no-op; the wazero-tagged build calls into the WASM module. +func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) { + s.mu.Lock() + subs := append([]*Instance(nil), s.subs[topic]...) + s.mu.Unlock() + for _, inst := range subs { + _ = inst // wazero-tagged build calls inst.module.invoke("on_event", payload) + _ = ctx + _ = payload + } +} diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go new file mode 100644 index 00000000..39fbd0ee --- /dev/null +++ b/Server/plugin/host_http.go @@ -0,0 +1,94 @@ +// Phase C Step 9 — `http` host capability. +// +// Outbound HTTP requests proxied through the server. Each request is matched +// against PluginsConfig.HTTPAllowlist (host suffix match) before being sent. +// The wazero-tagged build invokes this from the plugin's `host_http_request` +// import; the default build exposes it for testing. +package plugin + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// HTTPRequest is the plugin → host request envelope. +type HTTPRequest struct { + Method string + URL string + Body []byte + Header map[string]string +} + +// HTTPResponse is the host → plugin response envelope. +type HTTPResponse struct { + StatusCode int + Body []byte + Header map[string]string +} + +const httpTimeout = 10 * time.Second + +// HTTPDo executes a plugin-initiated HTTP request after enforcing the host +// allowlist declared in PluginsConfig. +func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) (*HTTPResponse, error) { + if !inst.Manifest.HasCapability(CapHTTP) { + return nil, ErrCapabilityNotGranted + } + if !r.hostAllowed(req.URL) { + return nil, fmt.Errorf("plugin http: host not in allowlist: %s", req.URL) + } + httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, strings.NewReader(string(req.Body))) + if err != nil { + return nil, fmt.Errorf("plugin http: build request: %w", err) + } + for k, v := range req.Header { + httpReq.Header.Set(k, v) + } + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("plugin http: do: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("plugin http: read body: %w", err) + } + hdr := make(map[string]string, len(resp.Header)) + for k, v := range resp.Header { + if len(v) > 0 { + hdr[k] = v[0] + } + } + return &HTTPResponse{ + StatusCode: resp.StatusCode, + Body: body, + Header: hdr, + }, nil +} + +// hostAllowed reports whether url's host matches any suffix in the allowlist. +func (r *Registry) hostAllowed(url string) bool { + // Trivial host extraction — full URL parsing would be overkill since the + // allowlist match is suffix-based. + rest := url + for _, prefix := range []string{"https://", "http://"} { + if strings.HasPrefix(rest, prefix) { + rest = rest[len(prefix):] + break + } + } + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + rest = rest[:i] + } + for _, suffix := range r.cfg.HTTPAllowlist { + if strings.HasSuffix(rest, suffix) { + return true + } + } + return false +} diff --git a/Server/plugin/host_storage.go b/Server/plugin/host_storage.go new file mode 100644 index 00000000..812bbed9 --- /dev/null +++ b/Server/plugin/host_storage.go @@ -0,0 +1,54 @@ +// Phase C Step 9 — `storage` host capability. +// +// Plugins get a per-plugin namespaced KV store backed by the PluginStore +// rows in the events/plugin schema. Capacity caps and value-size caps are +// enforced here so a misbehaving plugin can't fill the database. +package plugin + +import ( + "context" + "fmt" +) + +const ( + maxPluginValueBytes = 64 * 1024 // 64 KB per value + maxPluginScanLimit = 1000 // hard cap on PluginKVScan results +) + +// StoragePut writes a single key/value pair on behalf of inst. +func (r *Registry) StoragePut(ctx context.Context, inst *Instance, key string, value []byte) error { + if !inst.Manifest.HasCapability(CapStorage) { + return ErrCapabilityNotGranted + } + if len(value) > maxPluginValueBytes { + return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes) + } + return r.cfg.Store.PluginKVSet(ctx, inst.ID, key, value) +} + +// StorageGet returns the value for key, or (nil, error) when missing. +func (r *Registry) StorageGet(ctx context.Context, inst *Instance, key string) ([]byte, error) { + if !inst.Manifest.HasCapability(CapStorage) { + return nil, ErrCapabilityNotGranted + } + return r.cfg.Store.PluginKVGet(ctx, inst.ID, key) +} + +// StorageDelete removes a key. +func (r *Registry) StorageDelete(ctx context.Context, inst *Instance, key string) error { + if !inst.Manifest.HasCapability(CapStorage) { + return ErrCapabilityNotGranted + } + return r.cfg.Store.PluginKVDelete(ctx, inst.ID, key) +} + +// StorageScan returns all keys with the given prefix, capped at maxPluginScanLimit. +func (r *Registry) StorageScan(ctx context.Context, inst *Instance, prefix string, limit int) (map[string][]byte, error) { + if !inst.Manifest.HasCapability(CapStorage) { + return nil, ErrCapabilityNotGranted + } + if limit <= 0 || limit > maxPluginScanLimit { + limit = maxPluginScanLimit + } + return r.cfg.Store.PluginKVScan(ctx, inst.ID, prefix, limit) +} diff --git a/Server/plugin/host_ui.go b/Server/plugin/host_ui.go new file mode 100644 index 00000000..73929853 --- /dev/null +++ b/Server/plugin/host_ui.go @@ -0,0 +1,62 @@ +// Phase C Step 9 — `ui` host capability. +// +// A plugin that declares the `ui` capability ships HTML/CSS/JS assets and a +// list of tabs. The host serves those assets at /api/v1/plugins//ui/... +// and the Solid.js client bridge renders each tab inside a sandboxed iframe. +package plugin + +import ( + "net/http" + "path/filepath" + "strings" +) + +// RegisterUI binds inst's declared tabs into the registry. Called from the +// activation path; safe to call multiple times (idempotent on inst). +func (r *Registry) RegisterUI(inst *Instance) error { + if !inst.Manifest.HasCapability(CapUI) { + return ErrCapabilityNotGranted + } + r.mu.Lock() + defer r.mu.Unlock() + // Drop any existing bindings for this instance, then re-add. + kept := r.uiTabs[:0] + for _, b := range r.uiTabs { + if b.PluginID != inst.ID { + kept = append(kept, b) + } + } + r.uiTabs = kept + for _, t := range inst.Manifest.UI.Tabs { + r.uiTabs = append(r.uiTabs, UITabBinding{ + PluginID: inst.ID, + PluginName: inst.Manifest.Name, + Tab: t, + }) + } + return nil +} + +// AssetHandler returns an http.Handler that serves the on-disk assets for +// inst, rooted at the plugin's directory. The handler refuses path traversal +// attempts and only serves files declared by manifest tabs. +func (r *Registry) AssetHandler(inst *Instance) http.Handler { + allowed := make(map[string]bool, len(inst.Manifest.UI.Tabs)) + for _, t := range inst.Manifest.UI.Tabs { + allowed[t.Asset] = true + } + pluginDir := filepath.Dir(inst.WASMPath) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + rel := strings.TrimPrefix(req.URL.Path, "/") + if !allowed[rel] { + http.NotFound(w, req) + return + } + full := filepath.Join(pluginDir, rel) + if !strings.HasPrefix(full, pluginDir) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + http.ServeFile(w, req, full) + }) +} diff --git a/Server/plugin/loader.go b/Server/plugin/loader.go new file mode 100644 index 00000000..1d6bc1d3 --- /dev/null +++ b/Server/plugin/loader.go @@ -0,0 +1,85 @@ +// Phase C Step 9 — On-disk plugin discovery. +// +// Each plugin lives in its own subdirectory under PluginsConfig.Directory: +// +// plugins/ +// hello/ +// plugin.json +// hello.wasm +// game-detection/ +// plugin.json +// detector.wasm +// assets/... +// +// Loader walks the directory, parses every plugin.json, and returns a slice +// of foundPlugin records. The Registry then persists each into the store. +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type foundPlugin struct { + Manifest *Manifest + Dir string + WASMPath string +} + +// 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. +func scanPluginDirectory(dir string) ([]foundPlugin, error) { + if dir == "" { + return nil, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + // Directory absent is fine — operators may not have created it yet. + return nil, nil + } + return nil, err + } + var found []foundPlugin + for _, e := range entries { + if !e.IsDir() { + 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) + } + manifest, parseErr := ParseManifest(raw) + if parseErr != nil { + return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr) + } + wasmPath := filepath.Join(pluginDir, manifest.Entrypoint) + if _, statErr := os.Stat(wasmPath); statErr != nil { + return nil, fmt.Errorf("plugin %q: missing entrypoint %s: %w", e.Name(), manifest.Entrypoint, statErr) + } + found = append(found, foundPlugin{ + Manifest: manifest, + Dir: pluginDir, + WASMPath: wasmPath, + }) + } + return found, nil +} + +// serialize returns a canonical JSON encoding of the manifest, used as the +// manifest_json column value in the plugins table. +func (m *Manifest) serialize() (string, error) { + b, err := json.Marshal(m) + if err != nil { + return "", fmt.Errorf("manifest serialize: %w", err) + } + return string(b), nil +} diff --git a/Server/plugin/manifest.go b/Server/plugin/manifest.go new file mode 100644 index 00000000..2f6be965 --- /dev/null +++ b/Server/plugin/manifest.go @@ -0,0 +1,129 @@ +// Package plugin implements the OwnCord plugin runtime. +// +// Phase C Step 9 — Wazero Plugin Runtime. +// +// The package is split into: +// +// - manifest.go : declarative plugin metadata + permission checks +// - registry.go : in-memory registry + lifecycle (install/enable/load) +// - loader.go : on-disk discovery and package validation +// - sandbox.go : Wazero runtime configuration (build tag `wazero`) +// - host_*.go : capability-scoped host API surfaces +// - errors.go +// +// The default `go build ./...` ships a stub runtime that satisfies every call +// site without pulling Wazero into go.mod. To compile the real runtime: +// +// go get github.com/tetratelabs/wazero +// go build -tags wazero ./... +// +// This mirrors the postgres / otel build-tag approach used elsewhere in the +// repo so the default build stays self-contained. +package plugin + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Manifest is the parsed plugin metadata declared in plugin.json (or +// plugin.toml in the wazero-tagged build). The on-disk schema is intentionally +// flat so the default JSON parser handles it without a TOML dependency. +type Manifest struct { + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Entrypoint string `json:"entrypoint"` // relative .wasm path + Permissions []string `json:"permissions"` + Resources Resources `json:"resources"` + UI UISpec `json:"ui"` +} + +// Resources caps the plugin's runtime budget. Zero means "use the runtime +// default from PluginsConfig". +type Resources struct { + MaxMemoryMB int `json:"max_memory_mb"` + CPUBudgetMs int `json:"cpu_budget_ms"` +} + +// UISpec describes the optional client-side rendering surface. +type UISpec struct { + Tabs []UITab `json:"tabs"` +} + +// UITab is a single iframe-rendered plugin tab. +type UITab struct { + ID string `json:"id"` + Label string `json:"label"` + Asset string `json:"asset"` // relative html path +} + +// Capability is a permission name a plugin may request. +type Capability string + +const ( + CapCommands Capability = "commands" + CapEvents Capability = "events" + CapStorage Capability = "storage" + CapHTTP Capability = "http" + CapUI Capability = "ui" +) + +// validCapabilities is the closed set of capability names a manifest may +// declare. Anything else is rejected at load time. +var validCapabilities = map[Capability]bool{ + CapCommands: true, + CapEvents: true, + CapStorage: true, + CapHTTP: true, + CapUI: true, +} + +// ParseManifest decodes a plugin.json byte slice and validates required fields. +func ParseManifest(raw []byte) (*Manifest, error) { + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("plugin manifest: invalid JSON: %w", err) + } + if err := m.Validate(); err != nil { + return nil, err + } + return &m, nil +} + +// Validate enforces the manifest schema. +func (m *Manifest) Validate() error { + if strings.TrimSpace(m.Name) == "" { + return fmt.Errorf("plugin manifest: name is required") + } + if strings.TrimSpace(m.Version) == "" { + return fmt.Errorf("plugin manifest: version is required") + } + if strings.TrimSpace(m.Entrypoint) == "" { + return fmt.Errorf("plugin manifest: entrypoint is required") + } + if !strings.HasSuffix(m.Entrypoint, ".wasm") { + return fmt.Errorf("plugin manifest: entrypoint %q must end in .wasm", m.Entrypoint) + } + for _, p := range m.Permissions { + if !validCapabilities[Capability(p)] { + return fmt.Errorf("plugin manifest: unknown permission %q", p) + } + } + if m.Resources.MaxMemoryMB < 0 || m.Resources.CPUBudgetMs < 0 { + return fmt.Errorf("plugin manifest: resources must be non-negative") + } + return nil +} + +// HasCapability reports whether the manifest declared cap. +func (m *Manifest) HasCapability(cap Capability) bool { + for _, p := range m.Permissions { + if Capability(p) == cap { + return true + } + } + return false +} diff --git a/Server/plugin/plugin_test.go b/Server/plugin/plugin_test.go new file mode 100644 index 00000000..8f9cc216 --- /dev/null +++ b/Server/plugin/plugin_test.go @@ -0,0 +1,148 @@ +// Phase C Step 9 — manifest + loader tests. +// +// These tests cover the default-build code path (no wazero). They confirm: +// - the JSON manifest parses and validates, +// - the loader walks a directory and surfaces well-formed plugins, +// - the registry persists discovered plugins into a PluginStore, +// - per-capability gating refuses calls when the manifest didn't grant them. +// +// Wazero-specific tests live in sandbox_wazero_test.go and only run with the +// `wazero` build tag. +package plugin + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/owncord/server/store" +) + +func TestParseManifestRoundTrip(t *testing.T) { + raw := []byte(`{ + "name": "hello", + "version": "0.1.0", + "entrypoint": "hello.wasm", + "permissions": ["commands", "storage"], + "resources": {"max_memory_mb": 16, "cpu_budget_ms": 50} + }`) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("ParseManifest: %v", err) + } + if m.Name != "hello" || m.Version != "0.1.0" { + t.Fatalf("unexpected manifest fields: %+v", m) + } + if !m.HasCapability(CapCommands) { + t.Fatal("expected commands capability") + } + if m.HasCapability(CapHTTP) { + t.Fatal("did not expect http capability") + } +} + +func TestParseManifestRejectsBadEntrypoint(t *testing.T) { + cases := map[string]string{ + "missing entrypoint": `{"name":"x","version":"1","entrypoint":""}`, + "non-wasm entrypoint": `{"name":"x","version":"1","entrypoint":"x.so"}`, + "unknown capability": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["badperm"]}`, + "missing version": `{"name":"x","entrypoint":"x.wasm"}`, + "missing name": `{"version":"1","entrypoint":"x.wasm"}`, + } + for label, body := range cases { + t.Run(label, func(t *testing.T) { + if _, err := ParseManifest([]byte(body)); err == nil { + t.Fatalf("expected error for %s", label) + } + }) + } +} + +func TestScanPluginDirectoryHandlesMissing(t *testing.T) { + got, err := scanPluginDirectory(filepath.Join(t.TempDir(), "does-not-exist")) + if err != nil { + t.Fatalf("scanPluginDirectory: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty result, got %d", len(got)) + } +} + +func TestScanPluginDirectoryParsesValidPlugin(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "hello") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["storage"]}` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644); err != nil { + t.Fatal(err) + } + got, err := scanPluginDirectory(dir) + if err != nil { + t.Fatalf("scanPluginDirectory: %v", err) + } + if len(got) != 1 || got[0].Manifest.Name != "hello" { + t.Fatalf("unexpected scan result: %+v", got) + } +} + +func TestRegistryInstallFromDisk(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "hello") + _ = os.MkdirAll(pluginDir, 0o755) + _ = os.WriteFile(filepath.Join(pluginDir, "plugin.json"), + []byte(`{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["storage"]}`), + 0o644) + _ = os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644) + + mem := store.NewMemStore() + reg, err := NewRegistry(Config{Directory: dir, Store: mem}) + if err != nil { + t.Fatal(err) + } + if err := reg.LoadAll(context.Background()); err != nil { + t.Fatalf("LoadAll: %v", err) + } + rows, err := mem.ListPlugins(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Name != "hello" { + t.Fatalf("expected hello plugin row, got %+v", rows) + } +} + +func TestStorageGatedByCapability(t *testing.T) { + mem := store.NewMemStore() + reg, err := NewRegistry(Config{Store: mem}) + if err != nil { + t.Fatal(err) + } + inst := &Instance{ + ID: 1, + Manifest: &Manifest{Name: "x", Permissions: []string{}}, + } + if err := reg.StoragePut(context.Background(), inst, "k", []byte("v")); err == nil { + t.Fatal("expected ErrCapabilityNotGranted") + } + inst.Manifest.Permissions = []string{string(CapStorage)} + // Pre-create the plugin row so the KV foreign-key-equivalent succeeds. + if _, err := mem.InstallPlugin(context.Background(), "x", "0.1", "{}"); err != nil { + t.Fatal(err) + } + if err := reg.StoragePut(context.Background(), inst, "k", []byte("v")); err != nil { + t.Fatalf("StoragePut: %v", err) + } + got, err := reg.StorageGet(context.Background(), inst, "k") + if err != nil { + t.Fatalf("StorageGet: %v", err) + } + if string(got) != "v" { + t.Fatalf("expected v, got %q", got) + } +} diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go new file mode 100644 index 00000000..df8cdaab --- /dev/null +++ b/Server/plugin/registry.go @@ -0,0 +1,254 @@ +// Phase C Step 9 — Plugin registry, lifecycle, and host-API plumbing. +// +// The Registry is the long-lived handle the rest of the server holds onto. It +// owns the Wazero runtime (in the wazero-tagged build), the loaded plugin +// instances, and the dispatch tables for host-API capabilities (commands, +// events, storage, http, ui). +// +// In the default build the runtime is a stub: LoadAll walks the plugins +// directory and persists each manifest into the PluginStore so admins can see +// what is "installed", but the .wasm files are NOT executed. Calling +// Dispatch() in the default build returns ErrRuntimeUnavailable. +package plugin + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/owncord/server/store" +) + +// Config is the runtime configuration sourced from PluginsConfig. +type Config struct { + Directory string + MaxMemoryMB int + CPUBudgetMs int + HTTPAllowlist []string + Store store.PluginStore +} + +// Registry is the central plugin coordinator. +type Registry struct { + cfg Config + + mu sync.RWMutex + plugins map[int64]*Instance // by plugin row id + byName map[string]*Instance // by manifest name + commands map[string]*Instance // command name → owning plugin + uiTabs []UITabBinding // declared by `ui` capability plugins + + // runtimePlatform is set by the wazero-tagged build's NewRegistry to a + // concrete *wazero.Runtime. The default build leaves it nil and falls + // back to manifest-only behaviour. + runtimePlatform any +} + +// Instance is a single loaded plugin. +type Instance struct { + ID int64 + Manifest *Manifest + WASMPath string + Enabled bool + + // module is the wazero compiled module in the wazero-tagged build, or + // nil in the default build. + module any +} + +// UITabBinding is the public projection of a plugin's declared UI tab, +// served to the client bridge so it can render iframe tabs. +type UITabBinding struct { + PluginID int64 + PluginName string + Tab UITab +} + +// NewRegistry constructs a registry. In the default build it is a thin +// holder; the wazero-tagged build replaces this constructor with one that +// stands up a real Wazero runtime. +func NewRegistry(cfg Config) (*Registry, error) { + if cfg.Store == nil { + return nil, fmt.Errorf("plugin: NewRegistry requires a non-nil PluginStore") + } + return &Registry{ + cfg: cfg, + plugins: make(map[int64]*Instance), + byName: make(map[string]*Instance), + commands: make(map[string]*Instance), + }, nil +} + +// Close shuts the registry down. In the wazero-tagged build it tears the +// runtime down and frees module memory. +func (r *Registry) Close(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + for id := range r.plugins { + delete(r.plugins, id) + } + for n := range r.byName { + delete(r.byName, n) + } + for c := range r.commands { + delete(r.commands, c) + } + r.uiTabs = nil + return nil +} + +// LoadAll scans cfg.Directory and persists every plugin.json found into the +// PluginStore. In the wazero-tagged build it then compiles each entrypoint +// into a runnable module; the default build stops at the persistence step. +func (r *Registry) LoadAll(ctx context.Context) error { + if r == nil { + return nil + } + manifests, err := scanPluginDirectory(r.cfg.Directory) + if err != nil { + return fmt.Errorf("plugin: scan %q: %w", r.cfg.Directory, err) + } + for _, found := range manifests { + if err := r.installFromDisk(ctx, found); err != nil { + slog.Warn("plugin: failed to install from disk", "name", found.Manifest.Name, "err", err) + continue + } + } + return r.activateAll(ctx) +} + +// installFromDisk persists a manifest discovered on disk into the PluginStore +// and registers it in the in-memory registry. +func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error { + manifestJSON, err := found.Manifest.serialize() + if err != nil { + return err + } + id, err := r.cfg.Store.InstallPlugin(ctx, found.Manifest.Name, found.Manifest.Version, manifestJSON) + if err != nil { + return fmt.Errorf("InstallPlugin: %w", err) + } + r.mu.Lock() + defer r.mu.Unlock() + inst := &Instance{ + ID: id, + Manifest: found.Manifest, + WASMPath: found.WASMPath, + Enabled: false, + } + r.plugins[id] = inst + r.byName[found.Manifest.Name] = inst + return nil +} + +// activateAll attempts to compile + register host-API hooks for every plugin +// row in the PluginStore that is marked enabled. The default build is a +// no-op (no Wazero modules to compile). +func (r *Registry) activateAll(ctx context.Context) error { + rows, err := r.cfg.Store.ListPlugins(ctx) + if err != nil { + return fmt.Errorf("ListPlugins: %w", err) + } + for _, row := range rows { + if !row.Enabled { + continue + } + r.mu.Lock() + inst, ok := r.byName[row.Name] + r.mu.Unlock() + if !ok { + slog.Warn("plugin: enabled row has no on-disk manifest, skipping", "name", row.Name) + continue + } + if err := r.activate(ctx, inst); err != nil { + slog.Warn("plugin: activation failed", "name", row.Name, "err", err) + } + } + return nil +} + +// activate compiles and starts a single plugin module. Default build returns +// ErrRuntimeUnavailable; the wazero-tagged build replaces this with the real +// implementation via the runtimePlatform field. +func (r *Registry) activate(ctx context.Context, inst *Instance) error { + if r.runtimePlatform == nil { + return ErrRuntimeUnavailable + } + return r.activateWithRuntime(ctx, inst) +} + +// EnablePlugin marks a plugin enabled in the store, then attempts to load it. +func (r *Registry) EnablePlugin(ctx context.Context, id int64) error { + if err := r.cfg.Store.EnablePlugin(ctx, id); err != nil { + return err + } + r.mu.RLock() + inst, ok := r.plugins[id] + r.mu.RUnlock() + if !ok { + return ErrPluginNotFound + } + inst.Enabled = true + if err := r.activate(ctx, inst); err != nil { + // Roll back the DB flag so the next start attempt is consistent. + _ = r.cfg.Store.DisablePlugin(ctx, id) + inst.Enabled = false + return err + } + return nil +} + +// DisablePlugin marks a plugin disabled and tears its module down. +func (r *Registry) DisablePlugin(ctx context.Context, id int64) error { + if err := r.cfg.Store.DisablePlugin(ctx, id); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if inst, ok := r.plugins[id]; ok { + inst.Enabled = false + // Drop command bindings owned by this plugin. + for cmd, owner := range r.commands { + if owner == inst { + delete(r.commands, cmd) + } + } + } + return nil +} + +// UninstallPlugin removes a plugin entirely. +func (r *Registry) UninstallPlugin(ctx context.Context, id int64) error { + _ = r.DisablePlugin(ctx, id) + 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) + return nil +} + +// List returns the currently registered plugins. Read-only snapshot. +func (r *Registry) List() []*Instance { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*Instance, 0, len(r.plugins)) + for _, p := range r.plugins { + out = append(out, p) + } + return out +} + +// UITabBindings returns the declared UI tabs across enabled plugins. +func (r *Registry) UITabBindings() []UITabBinding { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]UITabBinding, len(r.uiTabs)) + copy(out, r.uiTabs) + return out +} diff --git a/Server/plugin/sandbox_default.go b/Server/plugin/sandbox_default.go new file mode 100644 index 00000000..1ae343d9 --- /dev/null +++ b/Server/plugin/sandbox_default.go @@ -0,0 +1,24 @@ +//go:build !wazero + +// Default plugin runtime: no Wazero. Plugin manifests are still discovered, +// persisted, and surfaced through the admin API, but `.wasm` modules are not +// executed. To enable real WASM execution build with `-tags wazero`. +package plugin + +import ( + "context" +) + +// activateWithRuntime is a no-op in the default build. It is only called from +// Registry.activate when runtimePlatform is non-nil, which never happens here. +func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error { + return ErrRuntimeUnavailable +} + +// invokeCommand returns an error result instructing the operator to enable +// the wazero build tag. Default build only. +func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) { + return &CommandResult{ + Reply: "plugin runtime disabled — rebuild server with -tags wazero to execute plugin commands", + }, true +} diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go new file mode 100644 index 00000000..0811e483 --- /dev/null +++ b/Server/plugin/sandbox_wazero.go @@ -0,0 +1,79 @@ +//go:build wazero + +// Real Wazero-backed plugin runtime. Compiled only with `-tags wazero`, +// matching the postgres / otel build-tag pattern used elsewhere in the repo. +// +// IMPORTANT: This file is a structural skeleton — it will fail to compile +// until github.com/tetratelabs/wazero is added to go.mod. To finish wiring it +// on a machine with network access: +// +// cd Server +// go get github.com/tetratelabs/wazero@latest +// go mod tidy +// go build -tags wazero ./... +// +// The skeleton documents the intended call graph so the implementation work +// is mechanical: each TODO marker maps to a wazero API call. +package plugin + +import ( + "context" + "fmt" + "os" +) + +// activateWithRuntime compiles inst.WASMPath into a wazero module, applies +// the per-plugin resource caps, and registers exported functions for each +// declared capability. +func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error { + wasmBytes, err := os.ReadFile(inst.WASMPath) + if err != nil { + return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err) + } + _ = wasmBytes + _ = ctx + + // TODO(wazero): replace with the real wiring once go.mod has wazero: + // + // runtime := r.runtimePlatform.(wazero.Runtime) + // compiled, err := runtime.CompileModule(ctx, wasmBytes) + // if err != nil { return fmt.Errorf("compile: %w", err) } + // + // modCfg := wazero.NewModuleConfig(). + // WithName(inst.Manifest.Name). + // WithStdout(io.Discard). + // WithStderr(io.Discard) + // + // memBytes := uint32(inst.Manifest.Resources.MaxMemoryMB) + // if memBytes == 0 { memBytes = uint32(r.cfg.MaxMemoryMB) } + // // wazero pages are 64 KiB; the runtime config caps via WithMemoryLimitPages. + // + // module, err := runtime.InstantiateModule(ctx, compiled, modCfg) + // if err != nil { return fmt.Errorf("instantiate: %w", err) } + // + // inst.module = module + // + // // Walk inst.Manifest.Permissions and call host_*.Register* for each + // // capability so the runtime knows what exports to look for. + + return fmt.Errorf("plugin %q: wazero runtime skeleton incomplete (see sandbox_wazero.go)", inst.Manifest.Name) +} + +// invokeCommand calls the plugin's `command_dispatch` exported function with +// the marshalled command + args, and decodes the response into a CommandResult. +func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) { + _ = ctx + _ = inst + _ = userID + _ = channelID + _ = cmd + _ = args + // TODO(wazero): real call: + // fn := module.ExportedFunction("command_dispatch") + // payload := encodeCommand(userID, channelID, cmd, args) + // result, err := fn.Call(ctx, ...) + // ... + return &CommandResult{ + Reply: "plugin runtime: command dispatch not yet implemented in skeleton", + }, true +} diff --git a/Server/service/channel.go b/Server/service/channel.go index c6e84f93..f77c2d49 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "log/slog" "time" @@ -8,6 +9,7 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/store" + "github.com/owncord/server/telemetry" ) // ChannelService handles channel-related business logic including @@ -28,6 +30,17 @@ func NewChannelService(st store.Store, perms *PermissionService) *ChannelService // ListVisibleChannels returns channels the user has ReadMessages permission for. // DM channels are excluded (they are accessed via DMService). func (s *ChannelService) ListVisibleChannels(userID int64) ([]db.Channel, error) { + // Phase B Step 8 — span the public service entrypoint. + ctx, span := telemetry.GlobalTracer("service/channel").Start(context.Background(), + "ChannelService.ListVisibleChannels", + telemetry.Int64("user_id", userID), + ) + start := time.Now() + defer func() { + telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationMs, start, + telemetry.String("method", "ListVisibleChannels")) + span.End() + }() all, err := s.st.ListChannels() if err != nil { slog.Error("ChannelService.ListVisibleChannels", "err", err) diff --git a/Server/service/message.go b/Server/service/message.go index 0a78ee15..d6bc6df3 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "log/slog" @@ -12,6 +13,7 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/store" + "github.com/owncord/server/telemetry" ) // sanitizer is the shared HTML sanitization policy (strips all tags). @@ -115,6 +117,19 @@ func NewMessageService(st store.Store, perms *PermissionService, limiter *auth.R // SendMessage validates, persists, and prepares broadcast data for a new message. // Callers are responsible for emitting the appropriate events. func (s *MessageService) SendMessage(p SendMessageParams) (*SendMessageResult, error) { + // Phase B Step 8 — wrap the public service entrypoint in a tracing span + // and a duration histogram. Both are no-ops in the default build. + ctx, span := telemetry.GlobalTracer("service/message").Start(context.Background(), "MessageService.SendMessage", + telemetry.Int64("user_id", p.UserID), + telemetry.Int64("channel_id", p.ChannelID), + ) + start := time.Now() + defer func() { + telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationMs, start, + telemetry.String("method", "SendMessage")) + span.End() + }() + // Rate limit. ratKey := fmt.Sprintf("chat:%d", p.UserID) if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { diff --git a/Server/service/permission.go b/Server/service/permission.go index 018d3c22..2671ba7d 100644 --- a/Server/service/permission.go +++ b/Server/service/permission.go @@ -1,12 +1,14 @@ package service import ( + "context" "sync" "time" "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/store" + "github.com/owncord/server/telemetry" ) // cachedPerms holds a snapshot of a user's role and channel overrides. @@ -44,6 +46,15 @@ func NewPermissionService(st store.Store, checker *permissions.Checker) *Permiss // HasChannelPerm reports whether the user has the required permission bits // on the given channel. Uses cached role/override data when available. func (s *PermissionService) HasChannelPerm(userID, channelID, perm int64) bool { + // Phase B Step 8 — span the perm check so traces show how many permission + // lookups a single REST/WS request triggers. The cache hit path is fast, + // but knowing how often it misses is the whole point of having metrics. + _, span := telemetry.GlobalTracer("service/permission").Start(context.Background(), + "PermissionService.HasChannelPerm", + telemetry.Int64("user_id", userID), + telemetry.Int64("channel_id", channelID), + ) + defer span.End() cp := s.getOrPopulate(userID) if cp == nil { return false diff --git a/Server/store/memstore.go b/Server/store/memstore.go index 109c0940..549ed96e 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -39,6 +39,12 @@ type MemStore struct { blocks map[int64]map[int64]bool // userID -> channelID -> lastReadMessageID readStates map[int64]map[int64]int64 + + // Phase B Step 7 / Phase C Step 9 — events + plugin KV. Lazily initialised + // via ensureEvents() so existing tests that constructed a bare MemStore + // without these fields keep working. + eventsOnce sync.Once + eventStore *memEventStore } // NewMemStore creates an empty MemStore ready for use. diff --git a/Server/store/memstore_events.go b/Server/store/memstore_events.go new file mode 100644 index 00000000..3213cd5d --- /dev/null +++ b/Server/store/memstore_events.go @@ -0,0 +1,279 @@ +package store + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/owncord/server/db" +) + +// memEventStore is an in-memory EventStore + PluginStore implementation +// embedded into MemStore via the field below. +type memEventStore struct { + mu sync.Mutex + nextSeq atomic.Int64 + events []db.PersistedEvent + plugins map[int64]*db.PluginRow + nextPID int64 + pluginKV map[int64]map[string][]byte +} + +func newMemEventStore() *memEventStore { + return &memEventStore{ + plugins: make(map[int64]*db.PluginRow), + pluginKV: make(map[int64]map[string][]byte), + } +} + +// ---------- EventStore ---------- + +func (m *MemStore) ensureEvents() *memEventStore { + m.eventsOnce.Do(func() { + m.eventStore = newMemEventStore() + }) + return m.eventStore +} + +func (m *MemStore) PersistEvent(_ context.Context, eventType string, channelID int64, payload []byte) (int64, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + seq := es.nextSeq.Add(1) + cp := make([]byte, len(payload)) + copy(cp, payload) + es.events = append(es.events, db.PersistedEvent{ + Seq: seq, + EventType: eventType, + ChannelID: channelID, + Payload: cp, + CreatedAt: time.Now().UTC(), + }) + return seq, nil +} + +func (m *MemStore) GetEventsSince(_ context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + out := make([]db.PersistedEvent, 0) + for _, e := range es.events { + if e.Seq > afterSeq { + out = append(out, e) + if len(out) >= limit { + break + } + } + } + return out, nil +} + +func (m *MemStore) GetEventsSinceForChannels(_ context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + allowed := make(map[int64]bool, len(channelIDs)) + for _, cid := range channelIDs { + allowed[cid] = true + } + out := make([]db.PersistedEvent, 0) + for _, e := range es.events { + if e.Seq <= afterSeq { + continue + } + if e.ChannelID == 0 || allowed[e.ChannelID] { + out = append(out, e) + if len(out) >= limit { + break + } + } + } + return out, nil +} + +func (m *MemStore) PruneEventsOlderThan(_ context.Context, cutoff time.Time) (int64, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + kept := es.events[:0] + var deleted int64 + for _, e := range es.events { + if e.CreatedAt.Before(cutoff) { + deleted++ + continue + } + kept = append(kept, e) + } + es.events = kept + return deleted, nil +} + +// ---------- PluginStore ---------- + +func (m *MemStore) InstallPlugin(_ context.Context, name, version, manifestJSON string) (int64, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + for _, p := range es.plugins { + if p.Name == name { + p.Version = version + p.ManifestJSON = manifestJSON + return p.ID, nil + } + } + es.nextPID++ + id := es.nextPID + es.plugins[id] = &db.PluginRow{ + ID: id, + Name: name, + Version: version, + Enabled: false, + ManifestJSON: manifestJSON, + InstalledAt: time.Now().UTC(), + } + return id, nil +} + +func (m *MemStore) EnablePlugin(_ context.Context, id int64) error { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + p, ok := es.plugins[id] + if !ok { + return fmt.Errorf("plugin %d not found", id) + } + p.Enabled = true + return nil +} + +func (m *MemStore) DisablePlugin(_ context.Context, id int64) error { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + p, ok := es.plugins[id] + if !ok { + return fmt.Errorf("plugin %d not found", id) + } + p.Enabled = false + return nil +} + +func (m *MemStore) UninstallPlugin(_ context.Context, id int64) error { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + delete(es.plugins, id) + delete(es.pluginKV, id) + return nil +} + +func (m *MemStore) GetPlugin(_ context.Context, id int64) (*db.PluginRow, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + p, ok := es.plugins[id] + if !ok { + return nil, fmt.Errorf("plugin %d not found", id) + } + cp := *p + return &cp, nil +} + +func (m *MemStore) GetPluginByName(_ context.Context, name string) (*db.PluginRow, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + for _, p := range es.plugins { + if p.Name == name { + cp := *p + return &cp, nil + } + } + return nil, fmt.Errorf("plugin %q not found", name) +} + +func (m *MemStore) ListPlugins(_ context.Context) ([]db.PluginRow, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + out := make([]db.PluginRow, 0, len(es.plugins)) + for _, p := range es.plugins { + out = append(out, *p) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (m *MemStore) PluginKVGet(_ context.Context, pluginID int64, key string) ([]byte, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + bucket, ok := es.pluginKV[pluginID] + if !ok { + return nil, fmt.Errorf("kv: plugin %d not found", pluginID) + } + v, ok := bucket[key] + if !ok { + return nil, fmt.Errorf("kv: key %q not found", key) + } + cp := make([]byte, len(v)) + copy(cp, v) + return cp, nil +} + +func (m *MemStore) PluginKVSet(_ context.Context, pluginID int64, key string, value []byte) error { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + bucket, ok := es.pluginKV[pluginID] + if !ok { + bucket = make(map[string][]byte) + es.pluginKV[pluginID] = bucket + } + cp := make([]byte, len(value)) + copy(cp, value) + bucket[key] = cp + return nil +} + +func (m *MemStore) PluginKVDelete(_ context.Context, pluginID int64, key string) error { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + if bucket, ok := es.pluginKV[pluginID]; ok { + delete(bucket, key) + } + return nil +} + +func (m *MemStore) PluginKVScan(_ context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { + es := m.ensureEvents() + es.mu.Lock() + defer es.mu.Unlock() + out := make(map[string][]byte) + bucket, ok := es.pluginKV[pluginID] + if !ok { + return out, nil + } + keys := make([]string, 0, len(bucket)) + for k := range bucket { + if strings.HasPrefix(k, prefix) { + keys = append(keys, k) + } + } + sort.Strings(keys) + if limit > 0 && len(keys) > limit { + keys = keys[:limit] + } + for _, k := range keys { + v := bucket[k] + cp := make([]byte, len(v)) + copy(cp, v) + out[k] = cp + } + return out, nil +} diff --git a/Server/store/postgres.go b/Server/store/postgres.go index 74ff78a4..a1dead5f 100644 --- a/Server/store/postgres.go +++ b/Server/store/postgres.go @@ -574,6 +574,70 @@ func (s *PostgresStore) GetAllSettings() (map[string]string, error) { return nil, ErrPostgresNotImplemented } +// ── EventStore (stubs — Phase B Step 7) ───────────────────────────────────── + +func (s *PostgresStore) PersistEvent(ctx context.Context, eventType string, channelID int64, payload []byte) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +// ── PluginStore (stubs — Phase C Step 9) ──────────────────────────────────── + +func (s *PostgresStore) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) EnablePlugin(ctx context.Context, id int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) DisablePlugin(ctx context.Context, id int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UninstallPlugin(ctx context.Context, id int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) ListPlugins(ctx context.Context) ([]db.PluginRow, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) PluginKVDelete(ctx context.Context, pluginID int64, key string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { + return nil, ErrPostgresNotImplemented +} + // Compile-time interface check — fails to compile if any Store method is // missing a PostgresStore receiver. var _ Store = (*PostgresStore)(nil) diff --git a/Server/store/sqlite_events.go b/Server/store/sqlite_events.go new file mode 100644 index 00000000..6b54ff97 --- /dev/null +++ b/Server/store/sqlite_events.go @@ -0,0 +1,290 @@ +package store + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/owncord/server/db" +) + +// ── EventStore (Phase B Step 7) ───────────────────────────────────────────── + +// PersistEvent appends a single event to the events table and returns the +// auto-assigned seq. +func (s *SQLiteStore) PersistEvent(ctx context.Context, eventType string, channelID int64, payload []byte) (int64, error) { + res, err := s.db.SQLDb().ExecContext(ctx, + `INSERT INTO events (event_type, channel_id, payload) VALUES (?, ?, ?)`, + eventType, channelID, payload, + ) + if err != nil { + return 0, fmt.Errorf("PersistEvent: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("PersistEvent LastInsertId: %w", err) + } + return id, nil +} + +// GetEventsSince returns events with seq > afterSeq up to limit, ordered ASC. +func (s *SQLiteStore) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) { + rows, err := s.db.SQLDb().QueryContext(ctx, + `SELECT seq, event_type, channel_id, payload, created_at + FROM events + WHERE seq > ? + ORDER BY seq ASC + LIMIT ?`, + afterSeq, limit, + ) + if err != nil { + return nil, fmt.Errorf("GetEventsSince: %w", err) + } + defer rows.Close() + return scanEventRows(rows) +} + +// GetEventsSinceForChannels filters events to those whose channel_id is 0 +// (global broadcast) or in channelIDs. +func (s *SQLiteStore) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) { + // Build IN clause manually since database/sql does not expand slices. + if len(channelIDs) == 0 { + // Only global broadcasts. + rows, err := s.db.SQLDb().QueryContext(ctx, + `SELECT seq, event_type, channel_id, payload, created_at + FROM events + WHERE seq > ? AND channel_id = 0 + ORDER BY seq ASC + LIMIT ?`, + afterSeq, limit, + ) + if err != nil { + return nil, fmt.Errorf("GetEventsSinceForChannels (global only): %w", err) + } + defer rows.Close() + return scanEventRows(rows) + } + + placeholders := make([]string, len(channelIDs)) + args := make([]any, 0, len(channelIDs)+2) + args = append(args, afterSeq) + for i, cid := range channelIDs { + placeholders[i] = "?" + args = append(args, cid) + } + args = append(args, limit) + + query := fmt.Sprintf( + `SELECT seq, event_type, channel_id, payload, created_at + FROM events + WHERE seq > ? + AND (channel_id = 0 OR channel_id IN (%s)) + ORDER BY seq ASC + LIMIT ?`, + strings.Join(placeholders, ","), + ) + rows, err := s.db.SQLDb().QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err) + } + defer rows.Close() + return scanEventRows(rows) +} + +// PruneEventsOlderThan deletes events older than cutoff. Returns rows deleted. +func (s *SQLiteStore) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { + res, err := s.db.SQLDb().ExecContext(ctx, + `DELETE FROM events WHERE created_at < ?`, + cutoff.UTC().Format("2006-01-02 15:04:05"), + ) + if err != nil { + return 0, fmt.Errorf("PruneEventsOlderThan: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("PruneEventsOlderThan RowsAffected: %w", err) + } + return n, nil +} + +// ── PluginStore (Phase C Step 9) ──────────────────────────────────────────── + +func (s *SQLiteStore) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { + res, err := s.db.SQLDb().ExecContext(ctx, + `INSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?) + ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json`, + name, version, manifestJSON, + ) + if err != nil { + return 0, fmt.Errorf("InstallPlugin: %w", err) + } + id, err := res.LastInsertId() + if err != nil || id == 0 { + // On conflict path LastInsertId may be 0; look up by name. + row := s.db.SQLDb().QueryRowContext(ctx, `SELECT id FROM plugins WHERE name = ?`, name) + if scanErr := row.Scan(&id); scanErr != nil { + return 0, fmt.Errorf("InstallPlugin lookup: %w", scanErr) + } + } + return id, nil +} + +func (s *SQLiteStore) EnablePlugin(ctx context.Context, id int64) error { + _, err := s.db.SQLDb().ExecContext(ctx, `UPDATE plugins SET enabled = 1 WHERE id = ?`, id) + return err +} + +func (s *SQLiteStore) DisablePlugin(ctx context.Context, id int64) error { + _, err := s.db.SQLDb().ExecContext(ctx, `UPDATE plugins SET enabled = 0 WHERE id = ?`, id) + return err +} + +func (s *SQLiteStore) UninstallPlugin(ctx context.Context, id int64) error { + _, err := s.db.SQLDb().ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id) + return err +} + +func (s *SQLiteStore) GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) { + row := s.db.SQLDb().QueryRowContext(ctx, + `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?`, + id, + ) + return scanPluginRow(row) +} + +func (s *SQLiteStore) GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) { + row := s.db.SQLDb().QueryRowContext(ctx, + `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?`, + name, + ) + return scanPluginRow(row) +} + +func (s *SQLiteStore) ListPlugins(ctx context.Context) ([]db.PluginRow, error) { + rows, err := s.db.SQLDb().QueryContext(ctx, + `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name`, + ) + if err != nil { + return nil, fmt.Errorf("ListPlugins: %w", err) + } + defer rows.Close() + var out []db.PluginRow + for rows.Next() { + var p db.PluginRow + var enabledInt int64 + var installedAt string + if err := rows.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil { + return nil, fmt.Errorf("ListPlugins scan: %w", err) + } + p.Enabled = enabledInt != 0 + p.InstalledAt = parseSQLiteTime(installedAt) + out = append(out, p) + } + return out, rows.Err() +} + +func (s *SQLiteStore) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) { + row := s.db.SQLDb().QueryRowContext(ctx, + `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`, + pluginID, key, + ) + var v []byte + if err := row.Scan(&v); err != nil { + return nil, err + } + return v, nil +} + +func (s *SQLiteStore) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error { + _, err := s.db.SQLDb().ExecContext(ctx, + `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?) + ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value`, + pluginID, key, value, + ) + return err +} + +func (s *SQLiteStore) PluginKVDelete(ctx context.Context, pluginID int64, key string) error { + _, err := s.db.SQLDb().ExecContext(ctx, + `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`, + pluginID, key, + ) + return err +} + +func (s *SQLiteStore) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { + rows, err := s.db.SQLDb().QueryContext(ctx, + `SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`, + pluginID, prefix+"%", limit, + ) + if err != nil { + return nil, fmt.Errorf("PluginKVScan: %w", err) + } + defer rows.Close() + out := make(map[string][]byte) + for rows.Next() { + var k string + var v []byte + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + out[k] = v + } + return out, rows.Err() +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanPluginRow(row rowScanner) (*db.PluginRow, error) { + var p db.PluginRow + var enabledInt int64 + var installedAt string + if err := row.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil { + return nil, err + } + p.Enabled = enabledInt != 0 + p.InstalledAt = parseSQLiteTime(installedAt) + return &p, nil +} + +type rowsScanner interface { + Next() bool + Scan(dest ...any) error + Err() error +} + +func scanEventRows(rows rowsScanner) ([]db.PersistedEvent, error) { + var out []db.PersistedEvent + for rows.Next() { + var e db.PersistedEvent + var createdAt string + if err := rows.Scan(&e.Seq, &e.EventType, &e.ChannelID, &e.Payload, &createdAt); err != nil { + return nil, fmt.Errorf("scanEventRows: %w", err) + } + e.CreatedAt = parseSQLiteTime(createdAt) + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func parseSQLiteTime(s string) time.Time { + // SQLite CURRENT_TIMESTAMP returns "YYYY-MM-DD HH:MM:SS" in UTC. + for _, layout := range []string{ + "2006-01-02 15:04:05", + time.RFC3339, + time.RFC3339Nano, + } { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC() + } + } + return time.Time{} +} diff --git a/Server/store/store.go b/Server/store/store.go index 0e77d0c9..1f968bdc 100644 --- a/Server/store/store.go +++ b/Server/store/store.go @@ -26,6 +26,8 @@ type Store interface { AttachmentStore AdminStore SettingsStore + EventStore + PluginStore // Close releases the underlying database connection. Close() error @@ -194,3 +196,44 @@ type SettingsStore interface { SetSetting(key, value string) error GetAllSettings() (map[string]string, error) } + +// EventStore persists broadcast events for cold-replay during reconnection +// when the in-memory ring buffer no longer covers the client's last_seq. +// +// Phase B Step 7 — Event Persistence Layer. +type EventStore interface { + // PersistEvent appends an event to the persistent log and returns the + // auto-assigned seq. channelID == 0 means the event was a global broadcast. + PersistEvent(ctx context.Context, eventType string, channelID int64, payload []byte) (int64, error) + + // GetEventsSince returns up to limit events with seq > afterSeq, ordered + // by seq ascending. Used as a fallback after the ring buffer misses. + GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) + + // GetEventsSinceForChannels returns up to limit events with seq > afterSeq + // whose channel_id is in channelIDs OR is 0 (global broadcasts), ordered by + // seq ascending. Mirrors EventRingBuffer.EventsSinceFiltered. + GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) + + // PruneEventsOlderThan deletes events with created_at < cutoff. Returns the + // number of deleted rows. Called periodically by the pruner goroutine. + PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) +} + +// PluginStore manages installed plugins and per-plugin KV namespaces. +// +// Phase C Step 9 — Wazero Plugin Runtime. +type PluginStore interface { + InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) + EnablePlugin(ctx context.Context, id int64) error + DisablePlugin(ctx context.Context, id int64) error + UninstallPlugin(ctx context.Context, id int64) error + GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) + GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) + ListPlugins(ctx context.Context) ([]db.PluginRow, error) + + PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) + PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error + PluginKVDelete(ctx context.Context, pluginID int64, key string) error + PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) +} diff --git a/Server/telemetry/metrics.go b/Server/telemetry/metrics.go new file mode 100644 index 00000000..47f3968f --- /dev/null +++ b/Server/telemetry/metrics.go @@ -0,0 +1,64 @@ +// Phase B Step 8 — declared application metrics. +// +// Instruments are constructed lazily against the global Provider so callers +// don't need to thread a Meter through every constructor. Hot-path callers +// should cache the returned instrument in a struct field rather than calling +// these helpers per request — they take a sync.RWMutex to read the global +// provider and the cost adds up at high throughput. +package telemetry + +import "sync" + +const ( + scopeWS = "github.com/owncord/server/ws" + scopeService = "github.com/owncord/server/service" + scopeDB = "github.com/owncord/server/db" + scopeVoice = "github.com/owncord/server/voice" +) + +// AppMetrics is the canonical bundle of meters used across the server. Build +// it once at startup with NewAppMetrics() and stash it on the relevant +// long-lived structs (Hub, services, etc). +type AppMetrics struct { + WSMessagesTotal Counter + WSActiveConnections Gauge + WSBroadcastLatency Histogram + WSReconnectTierTotal Counter + WSEventsPersisted Counter + WSEventsDropped Counter + DBQueryDurationSec Histogram + VoiceActiveSessions Gauge + VoiceParticipants Gauge + ServiceCallDurationMs Histogram +} + +var ( + appMetricsOnce sync.Once + appMetricsInst *AppMetrics +) + +// NewAppMetrics returns a process-wide AppMetrics, lazily constructed against +// the current global provider. Calling it multiple times returns the same +// instance — the metrics are tied to the global provider, not to a specific +// caller. +func NewAppMetrics() *AppMetrics { + appMetricsOnce.Do(func() { + ws := GlobalMeter(scopeWS) + svc := GlobalMeter(scopeService) + db := GlobalMeter(scopeDB) + voice := GlobalMeter(scopeVoice) + appMetricsInst = &AppMetrics{ + WSMessagesTotal: ws.Counter("ws_messages_total", "WebSocket messages broadcast"), + WSActiveConnections: ws.Gauge("ws_active_connections", "Currently connected WebSocket clients"), + WSBroadcastLatency: ws.Histogram("ws_broadcast_latency_seconds", "Wall-clock seconds from enqueue to fanout completion", "s"), + WSReconnectTierTotal: ws.Counter("ws_reconnect_tier_total", "Reconnection replay tier hits, attribute tier=buffer|db|full"), + WSEventsPersisted: ws.Counter("ws_events_persisted_total", "Events written to the cold-tier event log"), + WSEventsDropped: ws.Counter("ws_events_dropped_total", "Events dropped because the persister queue was full"), + DBQueryDurationSec: db.Histogram("db_query_duration_seconds", "Per-query wall time", "s"), + VoiceActiveSessions: voice.Gauge("voice_active_sessions", "Active LiveKit rooms"), + VoiceParticipants: voice.Gauge("voice_participants", "Connected LiveKit participants across all rooms"), + ServiceCallDurationMs: svc.Histogram("service_call_duration_seconds", "Service-layer method execution time", "s"), + } + }) + return appMetricsInst +} diff --git a/Server/telemetry/middleware.go b/Server/telemetry/middleware.go new file mode 100644 index 00000000..e20dca7d --- /dev/null +++ b/Server/telemetry/middleware.go @@ -0,0 +1,21 @@ +// Phase B Step 8 — HTTP middleware that delegates tracing to the global +// provider. The default no-op build returns next unchanged; the otel-tagged +// build wraps next with otelchi.Middleware. Mount it from the Chi router so +// every REST request becomes a span automatically. +package telemetry + +import "net/http" + +// HTTPMiddleware returns an HTTP middleware that traces every request via +// the currently installed Provider. Safe to mount unconditionally — it is a +// pass-through when telemetry is disabled. +func HTTPMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return Global().HTTPMiddleware(next) + } +} + +// PrometheusHandler returns the active Prometheus exporter handler, or nil if +// no Prometheus exporter is wired. Mount it from the API router only when +// non-nil so the legacy /metrics endpoint stays untouched in the no-op build. +func PrometheusHandler() http.Handler { return Global().PrometheusHandler() } diff --git a/Server/telemetry/telemetry.go b/Server/telemetry/telemetry.go new file mode 100644 index 00000000..d7c84a69 --- /dev/null +++ b/Server/telemetry/telemetry.go @@ -0,0 +1,172 @@ +// Package telemetry wires the OpenTelemetry SDK for OwnCord. +// +// Phase B Step 8 — Add OpenTelemetry for Observability. +// +// This file provides the public API surface and a no-op default implementation +// that compiles without any external dependencies. The real OpenTelemetry +// providers live in telemetry_otel.go behind the `otel` build tag, so the +// default build (`go build ./...`) ships a stub that satisfies every call site +// at zero binary cost. To enable real OTel: +// +// go get go.opentelemetry.io/otel \ +// go.opentelemetry.io/otel/sdk \ +// go.opentelemetry.io/otel/exporters/prometheus \ +// go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \ +// go.opentelemetry.io/contrib/instrumentation/github.com/go-chi/chi/otelchi +// go build -tags otel ./... +// +// The fallback API is intentionally tiny: it lets the rest of the codebase +// reference Tracer / Meter / Counter / Histogram without caring whether the +// real SDK is compiled in. +package telemetry + +import ( + "context" + "net/http" + "sync" + "time" +) + +// ShutdownFunc is returned by Init and must be called on server shutdown to +// flush exporters. The no-op implementation returns nil. +type ShutdownFunc func(context.Context) error + +// Counter is a monotonically increasing integer metric. +type Counter interface { + Add(ctx context.Context, delta int64, attrs ...Attr) +} + +// Histogram records distributions of float64 observations (e.g. durations). +type Histogram interface { + Record(ctx context.Context, value float64, attrs ...Attr) +} + +// Gauge records the current value of a measurement. +type Gauge interface { + Set(ctx context.Context, value float64, attrs ...Attr) +} + +// Attr is a single key/value attribute attached to a metric or span. +type Attr struct { + Key string + Value any +} + +// String constructs a string attribute. +func String(k, v string) Attr { return Attr{Key: k, Value: v} } + +// Int64 constructs an int64 attribute. +func Int64(k string, v int64) Attr { return Attr{Key: k, Value: v} } + +// Float64 constructs a float64 attribute. +func Float64(k string, v float64) Attr { return Attr{Key: k, Value: v} } + +// Span is a single tracing span. +type Span interface { + End() + SetAttributes(attrs ...Attr) + RecordError(err error) +} + +// Tracer creates spans within a single instrumentation library. +type Tracer interface { + Start(ctx context.Context, name string, attrs ...Attr) (context.Context, Span) +} + +// Meter creates instruments within a single instrumentation library. +type Meter interface { + Counter(name, description string) Counter + Histogram(name, description, unit string) Histogram + Gauge(name, description string) Gauge +} + +// Provider is the top-level handle returned by Init. +type Provider interface { + Tracer(name string) Tracer + Meter(name string) Meter + HTTPMiddleware(next http.Handler) http.Handler + PrometheusHandler() http.Handler // returns nil when no Prometheus exporter is wired +} + +var ( + globalMu sync.RWMutex + globalProvider Provider = noopProvider{} +) + +// SetGlobal stores p as the package-level provider returned by Global. +func SetGlobal(p Provider) { + globalMu.Lock() + defer globalMu.Unlock() + if p == nil { + globalProvider = noopProvider{} + return + } + globalProvider = p +} + +// Global returns the currently installed provider, or a no-op when none. +func Global() Provider { + globalMu.RLock() + defer globalMu.RUnlock() + return globalProvider +} + +// Tracer is a convenience that fetches a tracer from the global provider. +func GlobalTracer(name string) Tracer { return Global().Tracer(name) } + +// Meter is a convenience that fetches a meter from the global provider. +func GlobalMeter(name string) Meter { return Global().Meter(name) } + +// ── No-op implementation ──────────────────────────────────────────────────── + +type noopProvider struct{} + +func (noopProvider) Tracer(string) Tracer { return noopTracer{} } +func (noopProvider) Meter(string) Meter { return noopMeter{} } +func (noopProvider) HTTPMiddleware(next http.Handler) http.Handler { return next } +func (noopProvider) PrometheusHandler() http.Handler { return nil } + +type noopTracer struct{} + +func (noopTracer) Start(ctx context.Context, _ string, _ ...Attr) (context.Context, Span) { + return ctx, noopSpan{} +} + +type noopSpan struct{} + +func (noopSpan) End() {} +func (noopSpan) SetAttributes(...Attr) {} +func (noopSpan) RecordError(error) {} + +type noopMeter struct{} + +func (noopMeter) Counter(string, string) Counter { return noopCounter{} } +func (noopMeter) Histogram(string, string, string) Histogram { return noopHistogram{} } +func (noopMeter) Gauge(string, string) Gauge { return noopGauge{} } + +type noopCounter struct{} + +func (noopCounter) Add(context.Context, int64, ...Attr) {} + +type noopHistogram struct{} + +func (noopHistogram) Record(context.Context, float64, ...Attr) {} + +type noopGauge struct{} + +func (noopGauge) Set(context.Context, float64, ...Attr) {} + +// TimeSince records the elapsed time since start as seconds on h. Convenience +// shim used by Hub / service-layer instrumentation. +func TimeSince(ctx context.Context, h Histogram, start time.Time, attrs ...Attr) { + if h == nil { + return + } + h.Record(ctx, time.Since(start).Seconds(), attrs...) +} + +// init installs a no-op provider as the package default. The real +// telemetry_otel.go (build tag `otel`) overrides this via Init. +func init() { + SetGlobal(noopProvider{}) +} diff --git a/Server/telemetry/telemetry_default.go b/Server/telemetry/telemetry_default.go new file mode 100644 index 00000000..6e632aa3 --- /dev/null +++ b/Server/telemetry/telemetry_default.go @@ -0,0 +1,21 @@ +//go:build !otel + +// Default no-op build of the telemetry package — see telemetry.go for the +// public API. The real OpenTelemetry SDK wiring lives in telemetry_otel.go and +// is selected by `go build -tags otel ./...`. +package telemetry + +import ( + "context" + + "github.com/owncord/server/config" +) + +// Init configures and installs the OpenTelemetry SDK based on cfg. In the +// default build this installs a no-op provider so call sites have a usable +// global handle without pulling in any external dependencies. +func Init(_ context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) { + _ = cfg // honoured by the otel-tagged build only + SetGlobal(noopProvider{}) + return func(context.Context) error { return nil }, nil +} diff --git a/Server/telemetry/telemetry_otel.go b/Server/telemetry/telemetry_otel.go new file mode 100644 index 00000000..87b3a7ea --- /dev/null +++ b/Server/telemetry/telemetry_otel.go @@ -0,0 +1,75 @@ +//go:build otel + +// Real OpenTelemetry-backed implementation. Compiled only with `-tags otel`, +// which keeps the OTel SDK out of the default sqlite-only build (matching the +// pattern used by Server/store/postgres.go). +// +// IMPORTANT: This file currently contains a real-API skeleton that will fail +// to compile until the OTel modules are added to go.mod. To finish wiring it, +// run on a machine with network access: +// +// cd Server +// go get go.opentelemetry.io/otel@latest \ +// go.opentelemetry.io/otel/sdk@latest \ +// go.opentelemetry.io/otel/exporters/prometheus@latest \ +// go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@latest \ +// go.opentelemetry.io/contrib/instrumentation/github.com/go-chi/chi/v5/otelchi@latest +// go mod tidy +// go build -tags otel ./... +// +// Until that runs, the default build (no `-tags otel`) uses telemetry_default.go. +package telemetry + +import ( + "context" + "fmt" + "net/http" + + "github.com/owncord/server/config" +) + +// otelProvider is the placeholder for the real OTel-backed Provider. The +// fields and methods will be filled in once the OTel modules are in go.mod; +// for now this file exists so reviewers can see the intended shape and the +// `otel` build tag has a target. +type otelProvider struct { + cfg config.TelemetryConfig + promHandler http.Handler + httpMiddleware func(http.Handler) http.Handler + shutdown ShutdownFunc +} + +// Init wires the OTel SDK exporters according to cfg.Exporter: +// +// "none" — no-op (matches the default build) +// "prometheus" — pull-based Prometheus exporter mounted at /metrics +// "otlp" — push-based OTLP/gRPC exporter to cfg.OTLPEndpoint +// +// All exporters share the same resource (service.name = cfg.ServiceName). +func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) { + if !cfg.Enabled || cfg.Exporter == "" || cfg.Exporter == "none" { + SetGlobal(noopProvider{}) + return func(context.Context) error { return nil }, nil + } + + // TODO(otel-build-tag): replace the panic below with the real OTel + // initialisation once go.mod has the otel modules. The structural call + // graph is: + // + // resource = sdkresource.NewWithAttributes(...) + // tp = sdktrace.NewTracerProvider(WithBatcher(otlptracegrpc...)) + // mp = sdkmetric.NewMeterProvider(WithReader(prometheus.New())) + // otel.SetTracerProvider(tp); otel.SetMeterProvider(mp) + // handler = promhttp.HandlerFor(prometheusReg, promhttp.HandlerOpts{}) + // mw = otelchi.Middleware(serviceName, otelchi.WithChiRoutes(...)) + // + // then wrap them in a Provider implementation and SetGlobal it. + _ = ctx + return nil, fmt.Errorf("telemetry: otel build tag is set but the SDK skeleton in telemetry_otel.go is incomplete; finish wiring after `go get go.opentelemetry.io/otel...`") +} + +// otelProvider satisfies Provider once the SDK is wired. +func (p *otelProvider) Tracer(name string) Tracer { _ = name; return noopTracer{} } +func (p *otelProvider) Meter(name string) Meter { _ = name; return noopMeter{} } +func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler { return p.httpMiddleware(next) } +func (p *otelProvider) PrometheusHandler() http.Handler { return p.promHandler } diff --git a/Server/telemetry/telemetry_test.go b/Server/telemetry/telemetry_test.go new file mode 100644 index 00000000..97625bfd --- /dev/null +++ b/Server/telemetry/telemetry_test.go @@ -0,0 +1,67 @@ +// Phase B Step 8 — telemetry default-build smoke test. +// +// In the default build (no -tags otel) the package installs a no-op provider +// that satisfies every API surface. The test confirms Init returns a non-nil +// shutdown closer, the Global() helper returns a usable provider, and that a +// pass-through HTTP middleware leaves the wrapped handler intact. +package telemetry + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/owncord/server/config" +) + +func TestInitNoOpReturnsShutdown(t *testing.T) { + shutdown, err := Init(context.Background(), config.TelemetryConfig{Enabled: false}) + if err != nil { + t.Fatalf("Init: %v", err) + } + if shutdown == nil { + t.Fatal("expected non-nil shutdown") + } + if err := shutdown(context.Background()); err != nil { + t.Fatalf("shutdown: %v", err) + } +} + +func TestGlobalReturnsUsableProvider(t *testing.T) { + p := Global() + if p == nil { + t.Fatal("Global returned nil") + } + tracer := p.Tracer("test") + _, span := tracer.Start(context.Background(), "noop") + span.SetAttributes(String("k", "v")) + span.End() + meter := p.Meter("test") + meter.Counter("c", "").Add(context.Background(), 1) + meter.Histogram("h", "", "s").Record(context.Background(), 1.0) + meter.Gauge("g", "").Set(context.Background(), 0.5) +} + +func TestHTTPMiddlewareIsPassThrough(t *testing.T) { + called := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + }) + wrapped := HTTPMiddleware()(handler) + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if !called { + t.Fatal("inner handler not called") + } + if rec.Code != http.StatusTeapot { + t.Fatalf("status: got %d want %d", rec.Code, http.StatusTeapot) + } +} + +func TestPrometheusHandlerNilByDefault(t *testing.T) { + if PrometheusHandler() != nil { + t.Fatal("expected nil Prometheus handler in no-op build") + } +} diff --git a/Server/ws/event_persister.go b/Server/ws/event_persister.go new file mode 100644 index 00000000..726896e4 --- /dev/null +++ b/Server/ws/event_persister.go @@ -0,0 +1,157 @@ +// Phase B Step 7 — Event Persistence Layer. +// +// EventPersister is an asynchronous batched writer that drains broadcast +// events from an in-memory channel into the EventStore. It must never block +// the broadcast hot path: when the queue is full, events are dropped and a +// counter is incremented. The reconnection handler tolerates gaps because the +// in-memory ring buffer remains the primary cold-start source for clients +// whose last_seq is recent. +package ws + +import ( + "context" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/owncord/server/store" +) + +// pendingEvent is a single event waiting to be flushed to the EventStore. +type pendingEvent struct { + eventType string + channelID int64 + payload []byte +} + +// EventPersister batches broadcast events and writes them to an EventStore. +type EventPersister struct { + store store.EventStore + queue chan pendingEvent + batchSize int + flushEvy time.Duration + + stopOnce sync.Once + stop chan struct{} + done chan struct{} + + persisted atomic.Uint64 + dropped atomic.Uint64 + flushes atomic.Uint64 + errors atomic.Uint64 +} + +// NewEventPersister returns a persister wired to s. queueSize sets the +// channel buffer; once full, Enqueue increments the dropped counter without +// blocking. batchSize and flushEvery control the flush triggers. +func NewEventPersister(s store.EventStore, queueSize, batchSize int, flushEvery time.Duration) *EventPersister { + if queueSize <= 0 { + queueSize = 1024 + } + if batchSize <= 0 { + batchSize = 50 + } + if flushEvery <= 0 { + flushEvery = 100 * time.Millisecond + } + return &EventPersister{ + store: s, + queue: make(chan pendingEvent, queueSize), + batchSize: batchSize, + flushEvy: flushEvery, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +// Start launches the background flusher goroutine. +func (p *EventPersister) Start(ctx context.Context) { + go p.run(ctx) +} + +// Enqueue queues an event for persistence. Non-blocking; drops on full queue. +func (p *EventPersister) Enqueue(eventType string, channelID int64, payload []byte) { + if p == nil { + return + } + // Defensive copy: callers may reuse buffers. + cp := make([]byte, len(payload)) + copy(cp, payload) + select { + case p.queue <- pendingEvent{eventType: eventType, channelID: channelID, payload: cp}: + default: + p.dropped.Add(1) + } +} + +// Stop signals the persister to drain remaining events and exit. Blocks until +// the goroutine exits or ctx is cancelled. +func (p *EventPersister) Stop(ctx context.Context) { + if p == nil { + return + } + p.stopOnce.Do(func() { close(p.stop) }) + select { + case <-p.done: + case <-ctx.Done(): + } +} + +// Stats returns lifetime counters. +func (p *EventPersister) Stats() (persisted, dropped, flushes, errs uint64) { + return p.persisted.Load(), p.dropped.Load(), p.flushes.Load(), p.errors.Load() +} + +func (p *EventPersister) run(ctx context.Context) { + defer close(p.done) + tick := time.NewTicker(p.flushEvy) + defer tick.Stop() + + batch := make([]pendingEvent, 0, p.batchSize) + flush := func() { + if len(batch) == 0 { + return + } + p.flushes.Add(1) + for _, evt := range batch { + if _, err := p.store.PersistEvent(ctx, evt.eventType, evt.channelID, evt.payload); err != nil { + p.errors.Add(1) + slog.Warn("event persister: PersistEvent failed", + "event_type", evt.eventType, "channel_id", evt.channelID, "err", err) + continue + } + p.persisted.Add(1) + } + batch = batch[:0] + } + + for { + select { + case <-p.stop: + // Drain anything still in the channel before exiting. + for { + select { + case evt := <-p.queue: + batch = append(batch, evt) + if len(batch) >= p.batchSize { + flush() + } + default: + flush() + return + } + } + case <-ctx.Done(): + flush() + return + case evt := <-p.queue: + batch = append(batch, evt) + if len(batch) >= p.batchSize { + flush() + } + case <-tick.C: + flush() + } + } +} diff --git a/Server/ws/event_persister_test.go b/Server/ws/event_persister_test.go new file mode 100644 index 00000000..2e54dc07 --- /dev/null +++ b/Server/ws/event_persister_test.go @@ -0,0 +1,90 @@ +// Phase B Step 7 — EventPersister behavioural test. +// +// Confirms that: +// - events flow into the configured store via the batched flusher, +// - dropped events are counted on full queue, +// - Stop() drains pending events before exiting. +package ws + +import ( + "context" + "testing" + "time" + + "github.com/owncord/server/store" +) + +func TestEventPersisterFlushesBatch(t *testing.T) { + mem := store.NewMemStore() + p := NewEventPersister(mem, 1024, 4, 50*time.Millisecond) + ctx := context.Background() + p.Start(ctx) + t.Cleanup(func() { p.Stop(ctx) }) + + for i := 0; i < 10; i++ { + p.Enqueue("broadcast", 0, []byte(`{"type":"x"}`)) + } + + // Wait for at least one flush tick. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + persisted, _, _, _ := p.Stats() + if persisted >= 10 { + break + } + time.Sleep(10 * time.Millisecond) + } + persisted, dropped, _, errs := p.Stats() + if persisted != 10 { + t.Fatalf("expected 10 persisted, got %d", persisted) + } + if dropped != 0 { + t.Fatalf("expected 0 dropped, got %d", dropped) + } + if errs != 0 { + t.Fatalf("expected 0 errors, got %d", errs) + } + + // Verify the events landed in the store. + rows, err := mem.GetEventsSince(ctx, 0, 100) + if err != nil { + t.Fatal(err) + } + if len(rows) != 10 { + t.Fatalf("expected 10 rows in mem store, got %d", len(rows)) + } +} + +func TestEventPersisterDropsOnFullQueue(t *testing.T) { + mem := store.NewMemStore() + // Tiny queue, very long flush interval — guarantees drops because the + // flusher won't drain fast enough. + p := NewEventPersister(mem, 2, 1024, time.Hour) + // NB: Start is intentionally NOT called so the queue stays full. + for i := 0; i < 50; i++ { + p.Enqueue("broadcast", 0, []byte(`{}`)) + } + _, dropped, _, _ := p.Stats() + if dropped == 0 { + t.Fatal("expected drops with full queue and no consumer") + } +} + +func TestEventPersisterStopDrains(t *testing.T) { + mem := store.NewMemStore() + p := NewEventPersister(mem, 256, 100, time.Hour) + p.Start(context.Background()) + + for i := 0; i < 5; i++ { + p.Enqueue("broadcast", 0, []byte(`{}`)) + } + + stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + p.Stop(stopCtx) + + persisted, _, _, _ := p.Stats() + if persisted != 5 { + t.Fatalf("expected 5 persisted after Stop drain, got %d", persisted) + } +} diff --git a/Server/ws/event_pruner.go b/Server/ws/event_pruner.go new file mode 100644 index 00000000..73d032b4 --- /dev/null +++ b/Server/ws/event_pruner.go @@ -0,0 +1,62 @@ +// Phase B Step 7 — Event Persistence Layer (pruner). +// +// StartEventPruner runs a background goroutine that deletes events older than +// the configured retention window. It is the bounded-storage half of the +// event persistence design: the persister appends, the pruner trims. +package ws + +import ( + "context" + "log/slog" + "time" + + "github.com/owncord/server/store" +) + +// StartEventPruner launches a goroutine that wakes every interval and deletes +// events older than retention. The goroutine exits when ctx is cancelled. +func StartEventPruner(ctx context.Context, s store.EventStore, retention, interval time.Duration) { + if s == nil { + return + } + if retention <= 0 { + retention = 24 * time.Hour + } + if interval <= 0 { + interval = time.Hour + } + go func() { + // Run once shortly after startup so a tiny dataset stays small. + startupDelay := time.NewTimer(time.Minute) + defer startupDelay.Stop() + select { + case <-ctx.Done(): + return + case <-startupDelay.C: + } + runPrune(ctx, s, retention) + + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + runPrune(ctx, s, retention) + } + } + }() +} + +func runPrune(ctx context.Context, s store.EventStore, retention time.Duration) { + cutoff := time.Now().Add(-retention) + deleted, err := s.PruneEventsOlderThan(ctx, cutoff) + if err != nil { + slog.Warn("event pruner: PruneEventsOlderThan failed", "err", err) + return + } + if deleted > 0 { + slog.Info("event pruner: pruned old events", "deleted", deleted, "cutoff", cutoff.Format(time.RFC3339)) + } +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 1bfc65a4..0856a2b5 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -14,6 +14,7 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/service" + "github.com/owncord/server/store" "github.com/owncord/server/syncutil" ) @@ -49,6 +50,15 @@ type Hub struct { replayBuf *EventRingBuffer // recent broadcast events for reconnection replay broadcastDrops atomic.Uint64 // counts messages dropped due to full broadcast channel + // Phase B Step 7 — event persistence. nil = ring buffer only. + eventPersister *EventPersister + eventStore store.EventStore // read path for cold-tier replay + + // Phase B Step 7 — reconnection tier metrics. Incremented per resume. + reconnectTierBuf atomic.Uint64 + reconnectTierDB atomic.Uint64 + reconnectTierFull atomic.Uint64 + // Settings cache — avoids per-connection DB queries for server_name/motd. settingsMu syncutil.RWMutex settingsName string @@ -538,6 +548,7 @@ func (h *Hub) sendSequencedToUsers(channelID int64, userIDs []int64, msg []byte) // Store DM event for reconnect replay; filtering is channel-based and uses // allowed channel IDs computed at auth time (including open DMs). h.replayBuf.Push(seq, channelID, wrapped) + h.persistEvent(channelID, wrapped) for _, userID := range userIDs { h.SendToUser(userID, wrapped) @@ -552,6 +563,7 @@ func (h *Hub) sendSequencedToUsersHigh(channelID int64, userIDs []int64, msg []b seq := h.nextSeq() wrapped := wrapWithSeq(msg, seq) h.replayBuf.Push(seq, channelID, wrapped) + h.persistEvent(channelID, wrapped) for _, userID := range userIDs { h.SendToUserHigh(userID, wrapped) @@ -607,6 +619,38 @@ func (h *Hub) ReplayBuffer() *EventRingBuffer { return h.replayBuf } +// SetEventPersister attaches a persister so subsequent broadcasts are also +// written to the persistent EventStore. Pass nil to disable. +func (h *Hub) SetEventPersister(p *EventPersister) { + h.eventPersister = p +} + +// SetEventStore attaches a read-side EventStore used by the cold-tier +// reconnect replay path. Typically the same store backing SetEventPersister. +func (h *Hub) SetEventStore(s store.EventStore) { + h.eventStore = s +} + +// ReconnectTierStats returns the per-tier resume hit counters in the order +// (buffer, db, full). Phase B Step 7 metrics surface; OpenTelemetry meters +// (Step 8) read from the same atomics. +func (h *Hub) ReconnectTierStats() (buffer, db, full uint64) { + return h.reconnectTierBuf.Load(), h.reconnectTierDB.Load(), h.reconnectTierFull.Load() +} + +// persistEvent enqueues a broadcast event for cold-storage persistence. Safe +// to call with a nil persister; never blocks the broadcast hot path. +func (h *Hub) persistEvent(channelID int64, payload []byte) { + if h.eventPersister == nil { + return + } + eventType := "broadcast" + if channelID != 0 { + eventType = "channel_broadcast" + } + h.eventPersister.Enqueue(eventType, channelID, payload) +} + // wrapWithSeq injects a "seq" field into a JSON message without re-serializing. func wrapWithSeq(msg []byte, seq uint64) []byte { // Fast path: inject seq after the opening brace. @@ -756,6 +800,7 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) { // Store in replay buffer for reconnection recovery. h.replayBuf.Push(seq, bm.channelID, msg) + h.persistEvent(bm.channelID, msg) if bm.channelID == 0 { // Global broadcast — deliver to every connected client. diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 6f022542..546baa6f 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -15,6 +15,7 @@ import ( "github.com/owncord/server/config" "github.com/owncord/server/db" "github.com/owncord/server/permissions" + "github.com/owncord/server/telemetry" ) const ( @@ -121,9 +122,41 @@ func (h *Hub) handleReconnect( } events := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs) + replaySource := "buffer" if events == nil { - return false + // Phase B Step 7 — try cold-tier replay from the EventStore before + // giving up and forcing a full ready re-sync. + if h.eventStore != nil { + channelIDs := make([]int64, 0, len(allowedChannelIDs)) + for cid := range allowedChannelIDs { + channelIDs = append(channelIDs, cid) + } + const maxColdReplay = 5000 + persisted, dbErr := h.eventStore.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, maxColdReplay) + if dbErr != nil { + slog.Warn("ws handleReconnect: cold-tier replay query failed", + "user_id", c.userID, "err", dbErr) + } else if len(persisted) > 0 { + events = make([][]byte, 0, len(persisted)) + for _, p := range persisted { + events = append(events, p.Payload) + } + replaySource = "db" + } + } + if events == nil { + h.reconnectTierFull.Add(1) + telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full")) + return false + } } + switch replaySource { + case "buffer": + h.reconnectTierBuf.Add(1) + case "db": + h.reconnectTierDB.Add(1) + } + telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource)) // Register BEFORE writing replay data so broadcasts that arrive during // the write window are queued in the client's send buffer instead of @@ -147,7 +180,7 @@ func (h *Hub) handleReconnect( return true } } - slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq) + slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource) // Update presence but skip member_join — user was already known. if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil {