diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 5cba0335..f00f53c3 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -60,6 +60,26 @@ if printf '%s\n' "$staged" | grep -qE '^(protocol/schema\.json|Server/cmd/genpro fi fi +# Any api/ or admin/ Go file, the migrations, the config or the generator +# changed -> the regenerated docs index blocks must be part of the same commit. +# The route trigger is deliberately the whole of api/ and admin/: routes are +# registered in router.go, in the *_handler.go files, in client_update.go's +# MountClientUpdateRoute, and in the admin package's own mux — naming files +# individually is how this goes stale. +# +# Inlined like the two blocks above, and for the same reason: make is not on +# PATH on a stock Windows box. -tags otel,wazero is the build the route index +# is generated from; the tool refuses to run without it. +if printf '%s\n' "$staged" | grep -qE '^Server/(api|admin)/.*\.go$|^Server/(migrations/|config/config\.go|cmd/gendocs/)'; then + if command -v go >/dev/null 2>&1; then + (cd Server && go run -tags otel,wazero ./cmd/gendocs \ + && git diff --exit-code ../docs/api.md ../docs/schema.md ../docs/server-configuration.md) \ + || fail "generated docs blocks are stale, or a config key is undocumented — if gendocs named keys above, document them in docs/server-configuration.md; otherwise run 'go run -tags otel,wazero ./cmd/gendocs' in Server/ and stage the result" + else + printf 'pre-commit: WARNING: go not installed; skipping the generated-docs check. CI will run it.\n' >&2 + fi +fi + # Findings ledger changed -> it must still be valid. Unlike the two blocks # above there is nothing to diff: FINDINGS.md is not tracked (RL-07), so a # stale rendering cannot be committed. --check is the whole gate here, and it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ee0eea..9953d460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,13 @@ jobs: if: matrix.os == 'ubuntu-latest' run: make protocol-verify + # The route, table and config-key index blocks in docs/ must never drift + # from the mounted router, the migrated schema and config.Config's koanf + # tags. Same one-leg rule as the two checks above. + - name: Verify generated docs (make docs-verify) + if: matrix.os == 'ubuntu-latest' + run: make docs-verify + - name: Run tests with race detection and coverage run: go test -race -timeout 20m ./... -coverprofile=coverage.out -cover diff --git a/CLAUDE.md b/CLAUDE.md index d1786e6b..b762dd95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,11 +11,12 @@ and schema are documented in `docs/protocol.md`, `docs/schema.md`, and CI fails on drift, and the next generator run silently discards your edit. -| Generated | Source of truth | Workflow | -| ---------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- | -| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill | -| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill | -| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` | +| Generated | Source of truth | Workflow | +| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- | +| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill | +| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill | +| `gendocs:*` blocks in `docs/api.md`, `docs/schema.md`, `docs/server-configuration.md` | `Server/api/router.go`, `Server/migrations/`, `Server/config/config.go` | `cd Server && go run -tags otel,wazero ./cmd/gendocs` | +| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` | ## Bug-hunt ledger diff --git a/Server/CLAUDE.md b/Server/CLAUDE.md index cafe7dd7..d259d576 100644 --- a/Server/CLAUDE.md +++ b/Server/CLAUDE.md @@ -14,6 +14,9 @@ prometheus. `cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`), `cmd/dbinventory/` prints the `db`-importer table for `docs/architecture/server-boundaries.md` (exits 1 on an unlisted importer). + `cmd/gendocs/` rewrites the route, table and config-key index blocks in + `docs/` and must be run as `go run -tags otel,wazero ./cmd/gendocs` + (`make docs-verify` fails on drift). `scripts/` holds shell/JS tooling only; no Go entry point lives there - `admin/` web admin panel · `updater/` self-update + signature verification · `plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`) diff --git a/Server/Makefile b/Server/Makefile index f0162450..d3ca1e84 100644 --- a/Server/Makefile +++ b/Server/Makefile @@ -11,13 +11,15 @@ # sqlc-install Install the pinned sqlc version into $GOBIN. # protocol-generate Regenerate WS message-type constants (Go + TS) from ../protocol/schema.json. # protocol-verify Fail if the committed protocol constants are stale (used by CI). +# docs-generate Regenerate the route/table/config index blocks in ../docs. +# docs-verify Fail if those generated blocks are stale (used by CI). # otel-up Start Jaeger + Prometheus for local tracing development. # otel-down Stop and remove the OTel dev containers. SQLC_VERSION := $(shell cat sqlc.version) .PHONY: test test-deadlock fuzz cover cover-all sqlc-install sqlc-generate sqlc-verify \ - protocol-generate protocol-verify otel-up otel-down + protocol-generate protocol-verify docs-generate docs-verify otel-up otel-down test: go test -race -timeout 20m ./... @@ -91,6 +93,23 @@ protocol-verify: exit 1 ; \ ) +# Route, table and config-key indexes in ../docs. Same shape as the two +# generator checks above: regenerate, then fail on any diff. The tool also +# exits non-zero on its own when a config key is documented nowhere. +# +# -tags otel,wazero is not optional: /metrics mounts only when the otel build +# supplies a Prometheus handler, so the default build would generate an index +# missing a production route. The tool refuses to run without it. +docs-generate: + go run -tags otel,wazero ./cmd/gendocs + +docs-verify: + go run -tags otel,wazero ./cmd/gendocs + @git diff --exit-code ../docs/api.md ../docs/schema.md ../docs/server-configuration.md || ( \ + echo "ERROR: generated documentation blocks are stale. Run 'make docs-generate' and commit the result." ; \ + exit 1 ; \ + ) + # Phase B Step 8 — local OTel development stack. # Starts Jaeger (traces) and Prometheus (metrics) in Docker. # Jaeger UI: http://localhost:16686 diff --git a/Server/cmd/gendocs/main.go b/Server/cmd/gendocs/main.go new file mode 100644 index 00000000..d6848753 --- /dev/null +++ b/Server/cmd/gendocs/main.go @@ -0,0 +1,506 @@ +// Command gendocs regenerates the machine-readable index blocks that pin the +// server's three contracts to the documents describing them: +// +// routes -> docs/api.md every route the mounted chi tree serves +// schema -> docs/schema.md every table the migrations create +// config -> docs/server-configuration.md every koanf key, and where it is documented +// +// Each index replaces the text between a pair of HTML-comment markers +// (). Everything +// hand-written around them is left alone. Run it from Server/: +// +// go run ./cmd/gendocs +// +// `make docs-verify` runs that and then `git diff --exit-code` on the three +// documents, so a route, table or config key that changes in code without +// reaching the docs fails the build — the same shape as protocol-verify and +// sqlc-verify. +// +// Two sources are read the way the absence-contract tests read them, because +// no non-test seam exists: the router is rebuilt with every optional family +// switched on and walked with chi.Walk, and the config surface comes from +// reflection over the koanf struct tags. The schema comes from an in-memory +// database with the migrations applied — sqlc exposes no catalog, so the +// migrated database is the catalog. +// +// Output is padded exactly the way Prettier formats a Markdown table, so the +// generated blocks survive the repository's `prettier --check` gate. +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" + "unicode/utf8" + + "github.com/J3vb/OwnCord/Server/api" + "github.com/J3vb/OwnCord/Server/config" + "github.com/J3vb/OwnCord/Server/db" + "github.com/J3vb/OwnCord/Server/telemetry" + "github.com/go-chi/chi/v5" +) + +// Documents rewritten, relative to Server/ (the directory the tool runs in). +const ( + apiDoc = "../docs/api.md" + schemaDoc = "../docs/schema.md" + configDoc = "../docs/server-configuration.md" +) + +// regenCmd is quoted into every generated block so a reader who spots a stale +// row knows what to run without going looking for it. +const regenCmd = "cd Server && go run -tags otel,wazero ./cmd/gendocs" + +// minRoutes is the vacuity floor the route index inherits from +// TestAbsenceContract_NoFederationDirectoryOrListingRoutes: a walk over an +// empty or wrapped mux would otherwise produce a short table that diffs clean +// on the next run. An empty table is drift too. +const minRoutes = 100 + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "gendocs:", err) + os.Exit(1) + } +} + +func run() error { + for _, g := range []struct { + name string + path string + gen func(io.Writer) error + }{ + {"routes", apiDoc, genRoutes}, + {"schema", schemaDoc, genSchema}, + {"config", configDoc, genConfig}, + } { + var buf bytes.Buffer + if err := g.gen(&buf); err != nil { + return fmt.Errorf("%s index: %w", g.name, err) + } + raw, err := os.ReadFile(g.path) + if err != nil { + return err + } + out, err := spliceBlock(string(raw), g.name, buf.String()) + if err != nil { + return fmt.Errorf("%s: %w", g.path, err) + } + if out == string(raw) { + continue + } + //nolint:gosec // G703: g.path is one of the three constants above, never input. + if err := os.WriteFile(g.path, []byte(out), 0o644); err != nil { + return err + } + } + return nil +} + +// spliceBlock replaces the text between the gendocs markers for name with +// body, leaving the markers and everything around them untouched. Missing +// markers are an error rather than an append: where a block lives is a +// hand-made editorial decision, not something a generator should guess. +func spliceBlock(doc, name, body string) (string, error) { + start := "" + end := "" + i := strings.Index(doc, start) + j := strings.Index(doc, end) + if i < 0 || j < 0 { + return "", fmt.Errorf("markers %q … %q not found; add the block by hand once, then this tool fills it", start, end) + } + if j < i+len(start) { + return "", fmt.Errorf("marker %q appears before %q", end, start) + } + return doc[:i+len(start)] + "\n\n" + strings.Trim(body, "\n") + "\n\n" + doc[j:], nil +} + +// writeTable renders a GitHub-flavoured Markdown table padded the way Prettier +// formats one: every cell in a column padded to the widest cell in that +// column (the header included, minimum three), and the separator row filled +// with that many dashes. Emitting anything else means `prettier --check` and +// `git diff --exit-code` fight each other forever, each undoing the other. +func writeTable(w io.Writer, header []string, rows [][]string) { + // ponytail: widths are rune counts, Prettier's are display widths — a + // full-width CJK or emoji cell would pad two columns narrow. No generated + // cell holds one (route paths, SQL identifiers, config keys, section + // headings); switch to a display-width count if that ever changes. + widths := make([]int, len(header)) + cells := make([][]string, 0, len(rows)+1) + for _, r := range append([][]string{header}, rows...) { + row := make([]string, len(header)) + for i := range header { + if i < len(r) { + row[i] = escapeCell(r[i]) + } + widths[i] = max(widths[i], utf8.RuneCountInString(row[i]), 3) + } + cells = append(cells, row) + } + printRow := func(r []string) { + for i, c := range r { + r[i] = c + strings.Repeat(" ", widths[i]-utf8.RuneCountInString(c)) + } + printf(w, "| %s |\n", strings.Join(r, " | ")) + } + printRow(cells[0]) + sep := make([]string, len(header)) + for i := range sep { + sep[i] = strings.Repeat("-", widths[i]) + } + printf(w, "| %s |\n", strings.Join(sep, " | ")) + for _, r := range cells[1:] { + printRow(r) + } +} + +// printf writes formatted output to w. Every w here is an in-memory buffer +// that run() then splices into a document, so a write error is not reachable +// and dropping it once beats five checks that can never fire. +func printf(w io.Writer, format string, a ...any) { + _, _ = fmt.Fprintf(w, format, a...) +} + +// escapeCell hides the one character that would end a cell early. Prettier +// writes the same escape, so an escaped pipe round-trips. +func escapeCell(s string) string { return strings.ReplaceAll(s, "|", `\|`) } + +// code wraps s in a Markdown code span. +func code(s string) string { return "`" + s + "`" } + +// joinCode renders a list of identifiers as comma-separated code spans, or an +// em dash when there are none, matching cmd/dbinventory's table style. +func joinCode(items []string) string { + if len(items) == 0 { + return "—" + } + return code(strings.Join(items, "`, `")) +} + +// openMigrated opens an in-memory database with every migration applied. It is +// the schema catalog and the router's dependency both. +func openMigrated() (*db.DB, func(), error) { + database, err := db.Open(":memory:") + if err != nil { + return nil, nil, fmt.Errorf("db.Open: %w", err) + } + if err := db.Migrate(database); err != nil { + _ = database.Close() + return nil, nil, fmt.Errorf("db.Migrate: %w", err) + } + return database, func() { _ = database.Close() }, nil +} + +// genRoutes walks the production router with every optional family switched +// on — uploads, voice, the GIF proxy and telemetry — so the table is the +// whole tree rather than the bare-config subset. The scaffolding is a copy of +// fullRouter in api/absence_contract_test.go plus the telemetry init main.go +// does; test code stays in the test. +// +// The index is the superset build: /metrics mounts only when +// telemetry.PrometheusHandler() returns non-nil, which needs -tags otel, so +// every invocation of this tool passes -tags otel,wazero. Nothing under +// Server/api or Server/admin is itself tag-gated, so wazero adds and removes +// no route; it rides along so one build serves the whole repository. +func genRoutes(w io.Writer) error { + database, closeDB, err := openMigrated() + if err != nil { + return err + } + defer closeDB() + + dir, err := os.MkdirTemp("", "gendocs") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() + + cfg := &config.Config{ + Server: config.ServerConfig{Name: "gendocs", Port: 8443, DataDir: dir}, + Upload: config.UploadConfig{MaxSizeMB: 1, StorageDir: filepath.Join(dir, "uploads")}, + //nolint:gosec // G101: placeholder values so the optional voice routes mount; the router is walked, never served. + Voice: config.VoiceConfig{ + LiveKitAPIKey: "gendocs-key", + LiveKitAPISecret: "gendocs-secret-at-least-32-chars-long", + LiveKitURL: "ws://127.0.0.1:7880", + }, + GIF: config.GIFConfig{APIKey: "gendocs"}, + // /metrics mounts only when telemetry.PrometheusHandler() is non-nil, + // which needs both the otel build tag and telemetry switched on at + // runtime. Without this the index would silently omit a production + // route. + Telemetry: config.TelemetryConfig{Enabled: true, Exporter: "prometheus", ServiceName: "gendocs"}, + } + + ctx := context.Background() + shutdown, err := telemetry.Init(ctx, cfg.Telemetry) + if err != nil { + return fmt.Errorf("telemetry.Init: %w", err) + } + defer func() { _ = shutdown(ctx) }() + // The default build's Init installs a no-op provider, so this is the + // runtime check that the tool was built the way the index claims. + if telemetry.PrometheusHandler() == nil { + return errors.New("telemetry.Init left no Prometheus handler: run this tool as `go run -tags otel,wazero ./cmd/gendocs`, the build the route index is generated from") + } + + handler, _, cleanup := api.NewRouter(cfg, database, "gendocs", nil, nil) + defer cleanup() + + routes, ok := handler.(chi.Routes) + if !ok { + return fmt.Errorf("api.NewRouter returned %T, want a chi.Routes so the mounted tree can be walked", handler) + } + var rows [][]string + admin := 0 + walk := func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + // A real admin subroute, not the per-method `/admin/*` catch-all chi + // emits for the Mount itself — those appear whether or not the walk + // ever descended into the subrouter. + if strings.HasPrefix(route, "/admin/api/") && !strings.HasSuffix(route, "/*") { + admin++ + } + rows = append(rows, []string{method, route}) + return nil + } + if err := chi.Walk(routes, walk); err != nil { + return fmt.Errorf("chi.Walk: %w", err) + } + if len(rows) < minRoutes { + return fmt.Errorf("walked only %d routes; expected the full production router (>= %d)", len(rows), minRoutes) + } + if admin == 0 { + return errors.New("walk saw no /admin/api/ routes; the mounted admin subrouter was not traversed") + } + // chi hands the methods of one pattern back in map order, so the sort is + // what makes two runs produce the same bytes. Sorted on the bare path, + // before the code span goes on: a trailing backtick would order + // `/x` after `/x/y`. + slices.SortFunc(rows, cmpRoute) + for _, r := range rows { + r[1] = code(r[1]) + } + + printf(w, "Generated from the mounted router by %s — do not edit by hand; `make docs-verify` fails when it drifts. %d routes, from the `otel,wazero` build with every optional family enabled (uploads, voice, the GIF proxy, and telemetry with the Prometheus exporter, which is what mounts `/metrics`).\n\n", + code(regenCmd), len(rows)) + writeTable(w, []string{"Method", "Path"}, rows) + return nil +} + +// cmpRoute orders {method, path} rows by path, then method. +func cmpRoute(a, b []string) int { + if c := strings.Compare(a[1], b[1]); c != 0 { + return c + } + return strings.Compare(a[0], b[0]) +} + +// genSchema reads the catalog of the migrated database. sqlc has no catalog +// export (Server/sqlc.yaml declares no plugins and takes its schema from +// migrations/), so the migrated database is the catalog — the same trick the +// absence contract's fullRouter uses to get a real schema without a file. +// +// sqlite_sequence and the FTS5 shadow tables behind messages_fts are kept: +// they are what the migrations create, and a change to either is a schema +// change worth seeing in the diff. The sqlite_stat* tables are dropped — +// db.Migrate runs ANALYZE after applying migrations, so they hold planner +// statistics rather than schema, and which of them exists is a property of +// the SQLite build (sqlite_stat4 only with STAT4 compiled in). Including them +// would fail this drift check on a modernc.org/sqlite bump. +func genSchema(w io.Writer) error { + database, closeDB, err := openMigrated() + if err != nil { + return err + } + defer closeDB() + + ctx := context.Background() + // GLOB, not LIKE: LIKE's "_" is a wildcard, GLOB's is not. + tables, err := queryStrings(ctx, database, + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_stat*' ORDER BY name`) + if err != nil { + return err + } + if len(tables) == 0 { + return errors.New("the migrated database reports no tables; the catalog read is broken") + } + rows := make([][]string, 0, len(tables)) + for _, t := range tables { + cols, err := tableColumns(ctx, database, t) + if err != nil { + return err + } + idx, err := queryStrings(ctx, database, + `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? ORDER BY name`, t) + if err != nil { + return err + } + rows = append(rows, []string{code(t), joinCode(cols), joinCode(idx)}) + } + + printf(w, "Generated from the migrated schema by %s — do not edit by hand; `make docs-verify` fails when it drifts. %d tables: `sqlite_sequence` and the FTS5 shadow tables behind `messages_fts` are included; the `sqlite_stat*` tables `ANALYZE` writes are not, since they hold planner statistics and which of them exists depends on the SQLite build.\n\n", + code(regenCmd), len(rows)) + writeTable(w, []string{"Table", "Columns", "Indexes"}, rows) + return nil +} + +// tableColumns renders one table's columns in declaration order as +// "name TYPE" with the NOT NULL and PK flags PRAGMA table_info reports. +func tableColumns(ctx context.Context, database *db.DB, table string) ([]string, error) { + rows, err := database.QueryContext(ctx, + `SELECT name, type, "notnull", pk FROM pragma_table_info(?) ORDER BY cid`, table) + if err != nil { + return nil, fmt.Errorf("pragma_table_info(%s): %w", table, err) + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var name, typ string + var notNull, pk int + if err := rows.Scan(&name, &typ, ¬Null, &pk); err != nil { + return nil, err + } + col := name + if typ != "" { + col += " " + typ + } + if notNull != 0 { + col += " NOT NULL" + } + if pk != 0 { + col += " PK" + } + out = append(out, col) + } + return out, rows.Err() +} + +// queryStrings runs a query whose rows are a single string column. +func queryStrings(ctx context.Context, database *db.DB, query string, args ...any) ([]string, error) { + rows, err := database.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("%s: %w", query, err) + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// genConfig lists every dotted koanf key and the reference section that +// documents it. A key documented nowhere fails the run by name: the point of +// the index is that the configuration surface and its reference cannot drift +// apart silently, and a row saying "undocumented" would just record the drift. +func genConfig(w io.Writer) error { + raw, err := os.ReadFile(configDoc) + if err != nil { + return err + } + sections := docSections(string(raw)) + keys := koanfKeys(reflect.TypeFor[config.Config](), "") + slices.Sort(keys) + + var missing []string + rows := make([][]string, 0, len(keys)) + for _, k := range keys { + section, ok := sections[k] + if !ok { + missing = append(missing, k) + continue + } + rows = append(rows, []string{code(k), section}) + } + if len(missing) > 0 { + return fmt.Errorf("%d config key(s) are documented nowhere under \"## Config Key Reference\" in %s — document them, then re-run:\n %s", + len(missing), configDoc, strings.Join(missing, "\n ")) + } + + printf(w, "Generated from the `koanf` tags of `config.Config` by %s — do not edit by hand; `make docs-verify` fails when it drifts, and the tool exits non-zero when a key is documented nowhere above. %d keys.\n\n", + code(regenCmd), len(rows)) + writeTable(w, []string{"Key", "Documented in"}, rows) + return nil +} + +// dottedKey matches a code span holding a dotted lower-case key, which is how +// the reference tables name a setting. +var dottedKey = regexp.MustCompile("`([a-z0-9_]+(?:\\.[a-z0-9_]+)+)`") + +// docSections maps each config key named inside the "## Config Key Reference" +// section to the "### " heading it appears under. The scan stops at the next +// "## " heading, which is where the generated index itself lives — so the +// index can never be its own evidence that a key is documented. +func docSections(doc string) map[string]string { + out := map[string]string{} + inReference, heading := false, "" + for line := range strings.Lines(doc) { + line = strings.TrimRight(line, "\n") + switch { + case strings.HasPrefix(line, "## "): + inReference = line == "## Config Key Reference" + heading = "" + continue + case strings.HasPrefix(line, "### "): + heading = strings.TrimPrefix(line, "### ") + continue + } + if !inReference || heading == "" { + continue + } + for _, m := range dottedKey.FindAllStringSubmatch(line, -1) { + if _, seen := out[m[1]]; !seen { + out[m[1]] = heading + } + } + } + return out +} + +// koanfKeys returns every dotted koanf key reachable from t, recursing into +// nested structs the same way koanf unmarshals them. Copied from +// api/absence_contract_test.go, which walks the same surface for the same +// reason: config.Config's tags are the only enumeration of the keys, and +// config.defaults() is unexported. +func koanfKeys(t reflect.Type, prefix string) []string { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + var keys []string + for f := range t.Fields() { + tag, ok := f.Tag.Lookup("koanf") + if !ok || tag == "" || tag == "-" { + continue + } + key := tag + if prefix != "" { + key = prefix + "." + tag + } + ft := f.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + keys = append(keys, koanfKeys(ft, key)...) + continue + } + keys = append(keys, key) + } + return keys +} diff --git a/Server/cmd/gendocs/main_test.go b/Server/cmd/gendocs/main_test.go new file mode 100644 index 00000000..c12e62ba --- /dev/null +++ b/Server/cmd/gendocs/main_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "slices" + "strings" + "testing" +) + +// TestWriteTablePadsLikePrettier pins the padding rule the whole tool rests +// on: cells and the separator are padded to the widest cell in the column, +// header included, with Prettier's three-dash minimum. Get this wrong and +// `prettier --check` reformats what `git diff --exit-code` then reports as +// drift, forever. +func TestWriteTablePadsLikePrettier(t *testing.T) { + tests := []struct { + name string + header []string + rows [][]string + want string + }{ + { + name: "column widens to its longest cell", + header: []string{"Method", "Path"}, + rows: [][]string{{"GET", "/a"}, {"DELETE", "/longer"}}, + want: strings.Join([]string{ + "| Method | Path |", + "| ------ | ------- |", + "| GET | /a |", + "| DELETE | /longer |", + }, "\n") + "\n", + }, + { + name: "separator never goes below three dashes", + header: []string{"K"}, + rows: [][]string{{"v"}}, + want: "| K |\n| --- |\n| v |\n", + }, + { + name: "a pipe in a cell is escaped, and the escape counts as width", + header: []string{"Type"}, + rows: [][]string{{"a|b"}}, + want: "| Type |\n| ---- |\n| a\\|b |\n", + }, + { + name: "a short row is padded out to the header's column count", + header: []string{"A", "B"}, + rows: [][]string{{"x"}}, + want: "| A | B |\n| --- | --- |\n| x | |\n", + }, + { + name: "width is counted in runes, not bytes", + header: []string{"Note"}, + rows: [][]string{{"—"}}, + want: "| Note |\n| ---- |\n| — |\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + writeTable(&b, tt.header, tt.rows) + if b.String() != tt.want { + t.Errorf("writeTable\n got:\n%s\nwant:\n%s", b.String(), tt.want) + } + }) + } +} + +// TestSpliceBlock pins the marker contract: only the text between a matched +// pair is replaced, and an unmarked document is an error rather than an +// append — where a generated block lives is an editorial decision. +func TestSpliceBlock(t *testing.T) { + const doc = "before\n\n\n\nstale\n\n\n\nafter\n" + tests := []struct { + name string + doc string + body string + want string + wantErr string + }{ + { + name: "replaces the body, keeps the markers and their neighbours", + doc: doc, + body: "fresh", + want: "before\n\n\n\nfresh\n\n\n\nafter\n", + }, + { + name: "surplus newlines around the body are trimmed to one blank line", + doc: doc, + body: "\n\nfresh\n\n\n", + want: "before\n\n\n\nfresh\n\n\n\nafter\n", + }, + { + name: "no markers at all", + doc: "before\nafter\n", + body: "fresh", + wantErr: "not found", + }, + { + name: "start marker without an end marker", + doc: "\nstale\n", + body: "fresh", + wantErr: "not found", + }, + { + name: "end marker before the start marker", + doc: "\nstale\n\n", + body: "fresh", + wantErr: "appears before", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := spliceBlock(tt.doc, "routes", tt.body) + switch { + case tt.wantErr != "": + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("spliceBlock error = %v, want one containing %q", err, tt.wantErr) + } + case err != nil: + t.Fatalf("spliceBlock: %v", err) + case got != tt.want: + t.Errorf("spliceBlock\n got: %q\nwant: %q", got, tt.want) + } + }) + } +} + +// TestCmpRouteOrdersByBarePath pins the sort to the path before its code span +// goes on. Sorting the rendered cell puts `/x` after `/x/y`, because a +// backtick outranks a slash — a stable but nonsensical order. +func TestCmpRouteOrdersByBarePath(t *testing.T) { + rows := [][]string{ + {"POST", "/a/{id}/restore"}, + {"GET", "/a"}, + {"DELETE", "/a/{id}"}, + {"DELETE", "/a"}, + } + slices.SortFunc(rows, cmpRoute) + want := [][]string{ + {"DELETE", "/a"}, + {"GET", "/a"}, + {"DELETE", "/a/{id}"}, + {"POST", "/a/{id}/restore"}, + } + if !slices.EqualFunc(rows, want, slices.Equal) { + t.Errorf("sorted = %v, want %v", rows, want) + } +} + +// TestDocSectionsScansOnlyTheHandWrittenReference keeps the config index from +// being its own evidence: a key is documented when a hand-written subsection +// of "## Config Key Reference" names it, and the generated index sits under +// its own "## " heading, outside the scan. +func TestDocSectionsScansOnlyTheHandWrittenReference(t *testing.T) { + doc := strings.Join([]string{ + "# Title", + "", + "### First-run wizard", + "", + "writes `server.port` for you", + "", + "## Config Key Reference", + "", + "### Server (`server`)", + "", + "| `server.port` | int | `8443` | the port |", + "| `server.name` | string | `\"x\"` | the name |", + "", + "### TLS (`tls`)", + "", + "| `tls.mode` | string | see `server.port` above |", + "", + "## Key index (generated)", + "", + "| `server.orphan` | Server (`server`) |", + "", + }, "\n") + + got := docSections(doc) + want := map[string]string{ + "server.port": "Server (`server`)", + "server.name": "Server (`server`)", + "tls.mode": "TLS (`tls`)", + } + if len(got) != len(want) { + t.Fatalf("docSections = %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("docSections[%q] = %q, want %q", k, got[k], v) + } + } +} diff --git a/Server/invariants/db_import_boundary.go b/Server/invariants/db_import_boundary.go index 60c38a50..8095b17d 100644 --- a/Server/invariants/db_import_boundary.go +++ b/Server/invariants/db_import_boundary.go @@ -73,6 +73,7 @@ var DBImportAllow = map[string]DBImportEntry{ "main.go": {"boundary", "", "process composition root; B3-3 moves it to internal/app"}, "token_cli.go": {"move", "auth", "API-token CLI duplicates admin/handlers_tokens.go"}, "cmd/seed/main.go": {"boundary", "", "developer seeding tool owns its handle"}, + "cmd/gendocs/main.go": {"boundary", "", "docs generator migrates its own in-memory catalog"}, "plugin/pluginstore.go": {"adapter", "", "PluginRow type only; the store is injected"}, // ── ws ──────────────────────────────────────────────────────────────── "ws/client.go": {"adapter", "", "db.User type on the connection"}, diff --git a/docs/api.md b/docs/api.md index bfd1d788..2d41dcd9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,6 +32,140 @@ Note: chi's `middleware.RealIP` is deliberately **not** used -- client IPs are r --- +## Route index (generated) + + + +Generated from the mounted router by `cd Server && go run -tags otel,wazero ./cmd/gendocs` — do not edit by hand; `make docs-verify` fails when it drifts. 121 routes, from the `otel,wazero` build with every optional family enabled (uploads, voice, the GIF proxy, and telemetry with the Prometheus exporter, which is what mounts `/metrics`). + +| Method | Path | +| ------- | -------------------------------------------------------------------- | +| GET | `/admin/` | +| CONNECT | `/admin/*` | +| DELETE | `/admin/*` | +| GET | `/admin/*` | +| HEAD | `/admin/*` | +| OPTIONS | `/admin/*` | +| PATCH | `/admin/*` | +| POST | `/admin/*` | +| PUT | `/admin/*` | +| QUERY | `/admin/*` | +| TRACE | `/admin/*` | +| GET | `/admin/api/audit-log` | +| POST | `/admin/api/backup` | +| GET | `/admin/api/backups` | +| DELETE | `/admin/api/backups/{name}` | +| POST | `/admin/api/backups/{name}/restore` | +| GET | `/admin/api/channels` | +| POST | `/admin/api/channels` | +| DELETE | `/admin/api/channels/{id}` | +| PATCH | `/admin/api/channels/{id}` | +| GET | `/admin/api/channels/{id}/permissions` | +| DELETE | `/admin/api/channels/{id}/permissions/{roleId}` | +| PUT | `/admin/api/channels/{id}/permissions/{roleId}` | +| DELETE | `/admin/api/channels/{id}/user-permissions/{userId}` | +| PUT | `/admin/api/channels/{id}/user-permissions/{userId}` | +| POST | `/admin/api/logs/ticket` | +| GET | `/admin/api/me` | +| GET | `/admin/api/roles` | +| POST | `/admin/api/roles` | +| PATCH | `/admin/api/roles/reorder` | +| DELETE | `/admin/api/roles/{id}` | +| PATCH | `/admin/api/roles/{id}` | +| GET | `/admin/api/settings` | +| PATCH | `/admin/api/settings` | +| POST | `/admin/api/setup` | +| GET | `/admin/api/setup/status` | +| GET | `/admin/api/stats` | +| GET | `/admin/api/tokens` | +| POST | `/admin/api/tokens` | +| DELETE | `/admin/api/tokens/{id}` | +| GET | `/admin/api/updates` | +| POST | `/admin/api/updates/apply` | +| GET | `/admin/api/users` | +| PATCH | `/admin/api/users/{id}` | +| DELETE | `/admin/api/users/{id}/sessions` | +| GET | `/api/v1/admin/plugins/` | +| POST | `/api/v1/admin/plugins/install` | +| DELETE | `/api/v1/admin/plugins/{id}` | +| POST | `/api/v1/admin/plugins/{id}/disable` | +| POST | `/api/v1/admin/plugins/{id}/enable` | +| DELETE | `/api/v1/auth/account` | +| POST | `/api/v1/auth/login` | +| POST | `/api/v1/auth/logout` | +| GET | `/api/v1/auth/me` | +| POST | `/api/v1/auth/register` | +| POST | `/api/v1/auth/verify-totp` | +| GET | `/api/v1/blocks/` | +| DELETE | `/api/v1/blocks/{userId}` | +| PUT | `/api/v1/blocks/{userId}` | +| GET | `/api/v1/channels/` | +| GET | `/api/v1/channels/{id}/messages` | +| GET | `/api/v1/channels/{id}/messages/around/{messageId}` | +| POST | `/api/v1/channels/{id}/messages/purge` | +| GET | `/api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users` | +| GET | `/api/v1/channels/{id}/pins` | +| DELETE | `/api/v1/channels/{id}/pins/{messageId}` | +| POST | `/api/v1/channels/{id}/pins/{messageId}` | +| GET | `/api/v1/client-update/{target}/{current_version}` | +| GET | `/api/v1/diagnostics/connectivity` | +| GET | `/api/v1/dms/` | +| POST | `/api/v1/dms/` | +| POST | `/api/v1/dms/group` | +| DELETE | `/api/v1/dms/{channelId}` | +| PATCH | `/api/v1/dms/{channelId}` | +| GET | `/api/v1/emoji/` | +| POST | `/api/v1/emoji/` | +| DELETE | `/api/v1/emoji/{id}` | +| GET | `/api/v1/emoji/{id}/image` | +| GET | `/api/v1/files/{id}` | +| GET | `/api/v1/gif/search` | +| GET | `/api/v1/gif/trending` | +| GET | `/api/v1/health` | +| GET | `/api/v1/info` | +| GET | `/api/v1/invites/` | +| POST | `/api/v1/invites/` | +| DELETE | `/api/v1/invites/{code}` | +| GET | `/api/v1/livekit/health` | +| POST | `/api/v1/livekit/webhook` | +| GET | `/api/v1/metrics` | +| GET | `/api/v1/search` | +| POST | `/api/v1/uploads` | +| PATCH | `/api/v1/users/me/` | +| POST | `/api/v1/users/me/avatar` | +| PUT | `/api/v1/users/me/password` | +| GET | `/api/v1/users/me/sessions` | +| DELETE | `/api/v1/users/me/sessions/{id}` | +| DELETE | `/api/v1/users/me/totp` | +| POST | `/api/v1/users/me/totp/confirm` | +| POST | `/api/v1/users/me/totp/enable` | +| GET | `/api/v1/ws` | +| GET | `/health` | +| CONNECT | `/livekit/*` | +| DELETE | `/livekit/*` | +| GET | `/livekit/*` | +| HEAD | `/livekit/*` | +| OPTIONS | `/livekit/*` | +| PATCH | `/livekit/*` | +| POST | `/livekit/*` | +| PUT | `/livekit/*` | +| QUERY | `/livekit/*` | +| TRACE | `/livekit/*` | +| CONNECT | `/metrics/*` | +| DELETE | `/metrics/*` | +| GET | `/metrics/*` | +| HEAD | `/metrics/*` | +| OPTIONS | `/metrics/*` | +| PATCH | `/metrics/*` | +| POST | `/metrics/*` | +| PUT | `/metrics/*` | +| QUERY | `/metrics/*` | +| TRACE | `/metrics/*` | + + + +--- + ## Standard Error Response Error responses use this JSON envelope (one exception: the plugin admin diff --git a/docs/architecture/server-boundaries.md b/docs/architecture/server-boundaries.md index ecb9ef4f..accd7090 100644 --- a/docs/architecture/server-boundaries.md +++ b/docs/architecture/server-boundaries.md @@ -88,6 +88,7 @@ which is a row worth reading, and none exists today. | `api/upload_handler.go` | `AttachmentAccess×2` `DB×5` `Role` `User×3` | — | `CreateAttachment` `GetAttachmentWithChannel` `IsAvatarFileURL` `IsDMParticipant` `QueryRowContext` | calls | move | upload | attachment access + a raw QueryRowContext | | `auth/helpers.go` | `User` | — | — | type-only | adapter | — | db.User type in a helper signature | | `auth/resolve.go` | `APIToken` `Role×2` `Session×2` `User×2` | — | — | type-only | adapter | — | Session/APIToken/Role/User types; resolution is injected | +| `cmd/gendocs/main.go` | `DB×3` | `Migrate()` `Open()` | `Close×2` `QueryContext×2` | calls | boundary | — | docs generator migrates its own in-memory catalog | | `cmd/seed/main.go` | `DB×6` | `Migrate()` `Open()` | `Close` `CreateChannel` `CreateMessage×2` `CreateUser` `GetOrCreateDMChannel` `GetUserByUsername` `ListChannels` `QueryRowContext` | calls | boundary | — | developer seeding tool owns its handle | | `main.go` | `AuditWriter×2` `DB×10` | `ErrNotFound` `Migrate()` `NewAuditWriter()` `OpenWithMaxReaders()` | `ClearAllVoiceStates` `Close` `DeleteExpiredSessions` `DeleteOrphanedAttachments` `GetMaxEventSeq` `GetSetting` `ResetAllUserStatuses` `SetAuditWriter` `SetSetting` | calls | boundary | — | process composition root; B3-3 moves it to internal/app | | `plugin/pluginstore.go` | `PluginRow×3` | — | — | type-only | adapter | — | PluginRow type only; the store is injected | @@ -110,8 +111,8 @@ which is a row worth reading, and none exists today. | `ws/voice_join.go` | `Channel×3` `ChannelOverride` `VoiceState×5` | `ErrChannelFull` | `GetChannel×2` `GetChannelOverridesFor` `GetChannelVoiceStates` `GetRoleForUser` `GetVoiceState×6` `JoinVoiceChannel` `JoinVoiceChannelIfCapacity` `LeaveVoiceChannelIfMatch` `SetVoiceServerDeafen` `SetVoiceServerMute` | calls | move | voice | voice state reads and writes | | `ws/voice_moderation.go` | `Role` `VoiceState×3` | `WriteAudit()` | `CountChannelVoiceUsers` `GetChannel×2` `GetRoleForUser` `GetVoiceState×2` `SetVoiceServerDeafen×2` `SetVoiceServerMute×2` | calls | move | voice | mute/deafen/move persist voice state | -49 files import `db` outside `db/` and `service/` (. 2, admin 16, api 10, auth 2, cmd/seed 1, plugin 1, ws 17); 14 are type-only; 0 unlisted. -Dispositions: adapter 17, boundary 6, move 26. Move targets: auth 7, channel 6, connection 1, role 1, settings-ops 4, upload 2, user 2, voice 3. +50 files import `db` outside `db/` and `service/` (. 2, admin 16, api 10, auth 2, cmd/gendocs 1, cmd/seed 1, plugin 1, ws 17); 14 are type-only; 0 unlisted. +Dispositions: adapter 17, boundary 7, move 26. Move targets: auth 7, channel 6, connection 1, role 1, settings-ops 4, upload 2, user 2, voice 3. diff --git a/docs/plans/b3-server-architecture-guardrails-2026-08-29.md b/docs/plans/b3-server-architecture-guardrails-2026-08-29.md index 7636fc77..59cbd3e4 100644 --- a/docs/plans/b3-server-architecture-guardrails-2026-08-29.md +++ b/docs/plans/b3-server-architecture-guardrails-2026-08-29.md @@ -838,6 +838,64 @@ db`). No control file is committed. (OC-0031/OC-0033/OC-0311), not through `LiveKitSession`'s join generations, which need a real LiveKit room. +#### Evidence — item 9 (machine-readable contract drift) + +- Branch `feat/b3-6-contract-drift` from `dev` `75d64dd4`; commits: `f5f467ed` + feat(b3-6): contract drift — generated route, table and config-key indexes + (tool, three blocks, wiring), plus this evidence commit. +- The drift check is `make docs-verify`, which reduces to + `go run ./cmd/gendocs` then `git diff --exit-code ../docs/api.md ../docs/schema.md ../docs/server-configuration.md`, both from `Server/`. `make` is not on PATH on this machine, so the three controls below ran that pair directly; `node scripts/run.mjs --list` shows `check:server` running exactly those two steps. +- RED (a), a stale committed block — one row deleted from the route index and + staged, then the pair above: the diff puts the deleted `GET /api/v1/health` + row back and `git diff --exit-code` exits 1. +- RED (b), a route added without regenerating — a throwaway + `r.Get("/gendocs-red-proof", handleInfo(cfg))` in `api/router.go`, then the + pair: the header line goes from "111 routes." to "112 routes." and a + `GET /api/v1/gendocs-red-proof` row appears; exit 1. +- RED (c), a config key documented nowhere — the hand-written `voice.quality` + row removed from `docs/server-configuration.md`, then `go run ./cmd/gendocs` + alone: `gendocs: config index: 1 config key(s) are documented nowhere under "## Config Key Reference" in ../docs/server-configuration.md … voice.quality`, exit status 1, and no document written. +- GREEN, all three controls reverted (`git status` clean): the same pair exits + 0; `npx prettier --check .` → "All matched files use Prettier code style!"; + `.githooks/pre-commit` run by hand over the staged change → exit 0 with the + generator's own log lines in its output. +- Numbers: **121 routes** (`chi.Walk`; the floor of 100 and the admin-route + guard are carried over from the absence contract, the latter tightened to + require a real `/admin/api/` subroute so the `/admin/*` mount catch-alls + cannot satisfy it alone), **32 tables**, **56 config keys**, **0 undocumented keys at HEAD** — the hand-written reference + already named every koanf tag, so no documentation fixes were needed. + Generated blocks: three, adding 134 + 45 + 67 lines to their documents. + Output is byte-identical across two consecutive runs. +- Verified against HEAD: the plan's pointer to `docs/deployment.md` is wrong — + that document has no configuration table; the reference table is + `docs/server-configuration.md` "## Config Key Reference", and the key index + went there. `sqlc` exposes no catalog at HEAD (`Server/sqlc.yaml` declares no + plugins and no vet rules; its schema source is `migrations/`), so the + migrated in-memory schema is the catalog: `db.Open(":memory:")` plus + `db.Migrate`, then `sqlite_master` and `pragma_table_info`. `schema_versions` + is **included** rather than excluded, along with `sqlite_sequence` and the + FTS5 shadow tables behind `messages_fts` — they are what the migrations + create, and a change to any of them is a schema change worth seeing in the + diff. The route index is generated from the **`otel,wazero` build**, not the + default one: `/metrics` mounts only when `telemetry.PrometheusHandler()` + returns non-nil (`api/router.go:431-437`), which needs `-tags otel` and + telemetry enabled at runtime, so the tool calls `telemetry.Init` the way + `main.go` does with the Prometheus exporter and every invocation passes + `-tags otel,wazero` (Makefile, `run.mjs`, the hook, the block header lines, + the `CLAUDE.md` row; `ci.yml` inherits it through `make docs-verify`). The + tool refuses to run when that handler is absent, so the default build cannot + quietly generate a short index. Nothing under `Server/api` or `Server/admin` + carries a build constraint, so `wazero` adds and removes no route; it rides + along so one build serves the repository. The `sqlite_stat*` tables are + **excluded**: `db.Migrate` runs `ANALYZE` + after applying the migrations, so they carry planner statistics rather than + schema, and `sqlite_stat4` exists only because the current + `modernc.org/sqlite` build has STAT4 — including them would fail this drift + check on an unrelated driver bump. `cmd/gendocs/main.go` imports `db` for that catalog, so it takes a + `boundary` row in `DBImportAllow` and `server-boundaries.md` was regenerated + with it (50 importers, was 49); the dbinventory block is otherwise untouched + and still has no automated drift check of its own. + ## B3-7 — Alpha-shaped test dataset Roadmap workstream 12. Beside the slice. diff --git a/docs/schema.md b/docs/schema.md index f3fd75ab..661feb08 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -84,6 +84,51 @@ CREATE TABLE IF NOT EXISTS schema_versions ( --- +## Table index (generated) + + + +Generated from the migrated schema by `cd Server && go run -tags otel,wazero ./cmd/gendocs` — do not edit by hand; `make docs-verify` fails when it drifts. 32 tables: `sqlite_sequence` and the FTS5 shadow tables behind `messages_fts` are included; the `sqlite_stat*` tables `ANALYZE` writes are not, since they hold planner statistics and which of them exists depends on the SQLite build. + +| Table | Columns | Indexes | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `api_tokens` | `id INTEGER PK`, `user_id INTEGER NOT NULL`, `token_hash TEXT NOT NULL`, `label TEXT NOT NULL`, `created_at TEXT NOT NULL`, `last_used_at TEXT`, `expires_at TEXT`, `revoked_at TEXT` | `idx_api_tokens_user`, `sqlite_autoindex_api_tokens_1` | +| `attachments` | `id TEXT PK`, `message_id INTEGER`, `filename TEXT NOT NULL`, `stored_as TEXT NOT NULL`, `mime_type TEXT NOT NULL`, `size INTEGER NOT NULL`, `uploaded_at TEXT NOT NULL`, `width INTEGER`, `height INTEGER`, `uploader_id INTEGER` | `idx_attachments_message`, `idx_attachments_uploader`, `sqlite_autoindex_attachments_1` | +| `audit_log` | `id INTEGER PK`, `actor_id INTEGER NOT NULL`, `action TEXT NOT NULL`, `target_type TEXT NOT NULL`, `target_id INTEGER NOT NULL`, `detail TEXT NOT NULL`, `created_at TEXT NOT NULL` | `idx_audit_log_actor`, `idx_audit_timestamp` | +| `channel_overrides` | `id INTEGER PK`, `channel_id INTEGER NOT NULL`, `role_id INTEGER NOT NULL`, `allow INTEGER NOT NULL`, `deny INTEGER NOT NULL` | `idx_channel_overrides_role`, `sqlite_autoindex_channel_overrides_1` | +| `channel_user_overrides` | `channel_id INTEGER NOT NULL PK`, `user_id INTEGER NOT NULL PK`, `allow INTEGER NOT NULL`, `deny INTEGER NOT NULL` | `idx_channel_user_overrides_user`, `sqlite_autoindex_channel_user_overrides_1` | +| `channels` | `id INTEGER PK`, `name TEXT NOT NULL`, `type TEXT NOT NULL`, `category TEXT`, `topic TEXT`, `position INTEGER NOT NULL`, `slow_mode INTEGER NOT NULL`, `archived INTEGER NOT NULL`, `created_at TEXT NOT NULL`, `voice_max_users INTEGER NOT NULL`, `voice_quality TEXT`, `mixing_threshold INTEGER`, `voice_max_video INTEGER NOT NULL`, `nsfw INTEGER NOT NULL`, `is_group INTEGER NOT NULL` | `idx_channels_dm_group` | +| `dm_open_state` | `user_id INTEGER NOT NULL PK`, `channel_id INTEGER NOT NULL PK`, `opened_at TEXT NOT NULL` | `sqlite_autoindex_dm_open_state_1` | +| `dm_participants` | `channel_id INTEGER NOT NULL PK`, `user_id INTEGER NOT NULL PK` | `idx_dm_participants_user`, `sqlite_autoindex_dm_participants_1` | +| `emoji` | `id INTEGER PK`, `shortcode TEXT NOT NULL`, `filename TEXT NOT NULL`, `uploaded_by INTEGER NOT NULL`, `created_at TEXT NOT NULL`, `mime_type TEXT NOT NULL` | `sqlite_autoindex_emoji_1` | +| `events` | `seq INTEGER PK`, `event_type TEXT NOT NULL`, `payload BLOB NOT NULL`, `channel_id INTEGER NOT NULL`, `created_at TIMESTAMP NOT NULL` | `idx_events_channel_seq`, `idx_events_created_at` | +| `invites` | `id INTEGER PK`, `code TEXT NOT NULL`, `created_by INTEGER NOT NULL`, `redeemed_by INTEGER`, `max_uses INTEGER`, `use_count INTEGER NOT NULL`, `expires_at TEXT`, `created_at TEXT NOT NULL`, `revoked INTEGER NOT NULL` | `sqlite_autoindex_invites_1` | +| `login_attempts` | `id INTEGER PK`, `ip_address TEXT NOT NULL`, `username TEXT`, `success INTEGER NOT NULL`, `timestamp TEXT NOT NULL` | `idx_login_ip` | +| `message_mentions` | `message_id INTEGER NOT NULL PK`, `mentioned_user_id INTEGER NOT NULL PK` | `idx_message_mentions_user`, `sqlite_autoindex_message_mentions_1` | +| `messages` | `id INTEGER PK`, `channel_id INTEGER NOT NULL`, `user_id INTEGER NOT NULL`, `content TEXT NOT NULL`, `reply_to INTEGER`, `edited_at TEXT`, `deleted INTEGER NOT NULL`, `pinned INTEGER NOT NULL`, `timestamp TEXT NOT NULL`, `mentions_everyone INTEGER NOT NULL` | `idx_messages_channel`, `idx_messages_pinned`, `idx_messages_user` | +| `messages_fts` | `content` | — | +| `messages_fts_config` | `k NOT NULL PK`, `v` | — | +| `messages_fts_data` | `id INTEGER PK`, `block BLOB` | — | +| `messages_fts_docsize` | `id INTEGER PK`, `sz BLOB` | — | +| `messages_fts_idx` | `segid NOT NULL PK`, `term NOT NULL PK`, `pgno` | — | +| `plugin_kv` | `plugin_id INTEGER NOT NULL PK`, `key TEXT NOT NULL PK`, `value BLOB NOT NULL` | `sqlite_autoindex_plugin_kv_1` | +| `plugins` | `id INTEGER PK`, `name TEXT NOT NULL`, `version TEXT NOT NULL`, `enabled INTEGER NOT NULL`, `manifest_json TEXT NOT NULL`, `installed_at TIMESTAMP NOT NULL` | `sqlite_autoindex_plugins_1` | +| `rate_lockouts` | `key TEXT PK`, `expires_at TEXT NOT NULL` | `sqlite_autoindex_rate_lockouts_1` | +| `reactions` | `id INTEGER PK`, `message_id INTEGER NOT NULL`, `user_id INTEGER NOT NULL`, `emoji TEXT NOT NULL` | `sqlite_autoindex_reactions_1` | +| `read_states` | `user_id INTEGER NOT NULL PK`, `channel_id INTEGER NOT NULL PK`, `last_message_id INTEGER NOT NULL`, `mention_count INTEGER NOT NULL` | `sqlite_autoindex_read_states_1` | +| `roles` | `id INTEGER PK`, `name TEXT NOT NULL`, `color TEXT`, `permissions INTEGER NOT NULL`, `position INTEGER NOT NULL`, `is_default INTEGER NOT NULL` | `idx_roles_name_nocase`, `sqlite_autoindex_roles_1` | +| `schema_versions` | `version TEXT PK`, `applied_at TEXT NOT NULL` | `sqlite_autoindex_schema_versions_1` | +| `sessions` | `id INTEGER PK`, `user_id INTEGER NOT NULL`, `token TEXT NOT NULL`, `device TEXT`, `ip_address TEXT`, `created_at TEXT NOT NULL`, `last_used TEXT NOT NULL`, `expires_at TEXT NOT NULL` | `idx_sessions_expires_at`, `idx_sessions_user`, `sqlite_autoindex_sessions_1` | +| `settings` | `key TEXT PK`, `value TEXT NOT NULL` | `sqlite_autoindex_settings_1` | +| `sqlite_sequence` | `name`, `seq` | — | +| `user_blocks` | `blocker_id INTEGER NOT NULL PK`, `blocked_id INTEGER NOT NULL PK`, `created_at TEXT NOT NULL` | `idx_user_blocks_blocked`, `sqlite_autoindex_user_blocks_1` | +| `users` | `id INTEGER PK`, `username TEXT NOT NULL`, `password TEXT NOT NULL`, `avatar TEXT`, `role_id INTEGER NOT NULL`, `totp_secret TEXT`, `status TEXT NOT NULL`, `created_at TEXT NOT NULL`, `last_seen TEXT`, `banned INTEGER NOT NULL`, `ban_reason TEXT`, `ban_expires TEXT`, `identity_public_key TEXT`, `display_name TEXT`, `about TEXT`, `custom_status TEXT` | `idx_users_avatar`, `sqlite_autoindex_users_1` | +| `voice_states` | `user_id INTEGER PK`, `channel_id INTEGER NOT NULL`, `muted INTEGER NOT NULL`, `deafened INTEGER NOT NULL`, `speaking INTEGER NOT NULL`, `joined_at TEXT NOT NULL`, `camera INTEGER NOT NULL`, `screenshare INTEGER NOT NULL`, `server_muted INTEGER NOT NULL`, `server_deafened INTEGER NOT NULL` | `idx_voice_states_channel` | + + + +--- + ## Tables ### roles diff --git a/docs/server-configuration.md b/docs/server-configuration.md index c4929f2c..0c148c63 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -178,6 +178,73 @@ ring buffer that backs the admin panel's live log view. | --------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `logging.level` | string | `"info"` | Minimum level logged: `debug`, `info`, `warn`, `error`. Empty = `info`; an unrecognised value falls back to `info` with a startup warning. | +## Key index (generated) + + + +Generated from the `koanf` tags of `config.Config` by `cd Server && go run -tags otel,wazero ./cmd/gendocs` — do not edit by hand; `make docs-verify` fails when it drifts, and the tool exits non-zero when a key is documented nowhere above. 56 keys. + +| Key | Documented in | +| ------------------------------------------- | --------------------------------------- | +| `backup.dir` | Backups (`backup`) | +| `database.max_readers` | Database (`database`) | +| `database.path` | Database (`database`) | +| `database.type` | Database (`database`) | +| `event_persistence.batch_flush_ms` | Event Persistence (`event_persistence`) | +| `event_persistence.batch_size` | Event Persistence (`event_persistence`) | +| `event_persistence.enabled` | Event Persistence (`event_persistence`) | +| `event_persistence.pruner_interval_minutes` | Event Persistence (`event_persistence`) | +| `event_persistence.replay_cold_limit` | Event Persistence (`event_persistence`) | +| `event_persistence.replay_ring_size` | Event Persistence (`event_persistence`) | +| `event_persistence.retention_hours` | Event Persistence (`event_persistence`) | +| `gif.api_key` | GIF Picker (`gif`) | +| `github.owner` | GitHub / Updates (`github`) | +| `github.repo` | GitHub / Updates (`github`) | +| `github.token` | GitHub / Updates (`github`) | +| `logging.level` | Logging (`logging`) | +| `plugins.cpu_budget_ms` | Plugins (`plugins`) | +| `plugins.directory` | Plugins (`plugins`) | +| `plugins.enabled` | Plugins (`plugins`) | +| `plugins.http_allowlist` | Plugins (`plugins`) | +| `plugins.max_memory_mb` | Plugins (`plugins`) | +| `security.auth_rate_limit_multiplier` | Security (`security`) | +| `server.admin_allowed_cidrs` | Server (`server`) | +| `server.allowed_origins` | Server (`server`) | +| `server.data_dir` | Server (`server`) | +| `server.livekit_webhook_allowed_cidrs` | Server (`server`) | +| `server.max_ws_connections` | Server (`server`) | +| `server.metrics_allowed_cidrs` | Server (`server`) | +| `server.name` | Server (`server`) | +| `server.port` | Server (`server`) | +| `server.restart_mode` | Server (`server`) | +| `server.trusted_proxies` | Server (`server`) | +| `server.waf_crs_mode` | Server (`server`) | +| `server.waf_enabled` | Server (`server`) | +| `server.waf_paranoia_level` | Server (`server`) | +| `telemetry.enabled` | Telemetry / OpenTelemetry (`telemetry`) | +| `telemetry.exporter` | Telemetry / OpenTelemetry (`telemetry`) | +| `telemetry.otlp_endpoint` | Telemetry / OpenTelemetry (`telemetry`) | +| `telemetry.otlp_insecure` | Telemetry / OpenTelemetry (`telemetry`) | +| `telemetry.service_name` | Telemetry / OpenTelemetry (`telemetry`) | +| `tls.acme_cache_dir` | TLS (`tls`) | +| `tls.cert_file` | TLS (`tls`) | +| `tls.domain` | TLS (`tls`) | +| `tls.key_file` | TLS (`tls`) | +| `tls.mode` | TLS (`tls`) | +| `upload.max_size_mb` | Uploads (`upload`) | +| `upload.storage_dir` | Uploads (`upload`) | +| `voice.advertise_internal_ip` | Voice / LiveKit (`voice`) | +| `voice.auto_download_livekit` | Voice / LiveKit (`voice`) | +| `voice.livekit_api_key` | Voice / LiveKit (`voice`) | +| `voice.livekit_api_secret` | Voice / LiveKit (`voice`) | +| `voice.livekit_binary` | Voice / LiveKit (`voice`) | +| `voice.livekit_url` | Voice / LiveKit (`voice`) | +| `voice.livekit_version` | Voice / LiveKit (`voice`) | +| `voice.node_ip` | Voice / LiveKit (`voice`) | +| `voice.quality` | Voice / LiveKit (`voice`) | + + + ## Environment Variable Overrides Every config key can be overridden via environment variables using the prefix `OWNCORD_`. diff --git a/scripts/run.mjs b/scripts/run.mjs index ec879565..98b7303c 100644 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -66,6 +66,25 @@ const PROTOCOL_VERIFY = [ "Server", ), ]; +// The route, table and config-key indexes in docs/. Same shape again: the +// generator rewrites the marked blocks, git reports any drift. cmd/gendocs +// also exits non-zero on its own when a config key is documented nowhere, or +// when it was built without -tags otel,wazero -- the superset build the route +// index is generated from, since /metrics mounts only under otel. +const DOCS_VERIFY = [ + step("go", ["run", "-tags", "otel,wazero", "./cmd/gendocs"], "Server"), + step( + "git", + [ + "diff", + "--exit-code", + "../docs/api.md", + "../docs/schema.md", + "../docs/server-configuration.md", + ], + "Server", + ), +]; const SQLC_VERIFY = [ optional( "sqlc", @@ -94,6 +113,7 @@ const CHECK_SERVER = [ ), ...PROTOCOL_VERIFY, ...SQLC_VERIFY, + ...DOCS_VERIFY, ]; const CHECK_CLIENT = [ @@ -176,6 +196,8 @@ const TASKS = { "Server", "sqlc not on PATH — install the version in Server/sqlc.version", ), + // After sqlc: gendocs compiles the api package, which imports db/dbgen. + step("go", ["run", "-tags", "otel,wazero", "./cmd/gendocs"], "Server"), ], format: [ step("npx", ["prettier", "--write", "."], "."),