nigig-org/nimanyatta/PLAN.md
andodeki 13ae5017af
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nimanyatta: align the WS wire format with nimanyatta-protocol (postcard)
The remote's protocol-crate commits pinned the client wire format, and the
pre-existing chat_load_tester_consistency suite already decoded
ServerToClientMsg with postcard — but the reconstructed nigig-common was
encoding with serde_json. Switch to_bytes/from_bytes (both message enums)
to postcard to match, and rebuild decode_all on postcard::take_from_bytes
so a fragmented frame re-buffers and bytes trailing a complete message are
preserved instead of dropped.

AuthMethod (13 variants) is shape- and order-aligned with the client
library. The remaining server/client shape divergences (missing
reaction/upload/history variants, LoginSuccess/MessageDeleted/MessageContent
field differences) are pre-existing in the original design — the gateway
itself destructures MessageContent::Text { body } while the client emits
Text { text } — and are now documented with a remediation path in
KNOWN_ISSUES ("WS wire parity" section).

nigig-common: 21/21 tests green with the postcard round-trips.
2026-09-26 16:31:04 +00:00

119 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# nimanyatta — Execution Plan
## Completed Phases
### Phase 0 — Baseline Hygiene (commit `3908dbc`)
- `.gitignore` for target, logs, `.env`, concatenated dumps, `.bak`
- Deleted: `src/database.rs`, `.bak`/`.new` files, 11 `concatenated_code.txt`, `old/`, `instruments_output_*`, runtime logs, stray binaries
- `broadcast_server.rs`: removed test modules, ~470 lines dead code, dead traits
- Wrote real README with API reference, env vars, cargo aliases
- `KNOWN_ISSUES.md` created from prior review findings
### Phase 1 — Bug Fixes (commit `39401c1`)
- **500-vs-409 fix**: `is_duplicate_error()` shared by all three error mappers in `db.rs`
- **Session eviction fix**: `register_token_conn` called after password login + token restore
- **In-memory DB safety**: `DATABASE_PATH` required when `ENVIRONMENT=production`
### Phase 1 completion + Phase 2 (commit `73fa54a`)
- **Device revocation fix**: `is_token_revoked` now checks `device:<device_id>` markers; all 4 callers updated
- **Dead code removal** (-1,513 lines):
- `chat_state.rs` (orphaned, not a module)
- `connection_multiplexer.rs` + `connection_pool.rs` (never instantiated)
- `io_backend/` directory (not a module, references nonexistent `ServerError`)
- `error.rs`: removed `NigigError` parent, 7 dead sub-sets, 5 dead `From` impls — kept only `IggyError`
- Dead `pub use` exports from `optimized/mod.rs`
### Phase 3 — Architecture Cleanup (commit `75b2b41`, -1,058 lines)
- **Deleted distributor infrastructure**: `BroadcastDistributor`, `AdaptiveBroadcaster`, `ShardedBroadcast`, `BackpressureConfig` — all vestigial (hot-path `SendGroup` already bypassed via `get_sender()` → `try_send()`)
- Removed all distributor fields, construction, register/unregister calls, broadcast_rx select arm, is_under_pressure gate
- Replaced `log_direct_drop` with local static counter + inline function
### Phase 4 — Request-Path Improvements (commit `32d237f`, -38 lines)
- **Registration rate limiting**: added IP+username rate limit to `POST /auth/register` (was unprotected)
- **Custom roles ownership check**: replaced 4x full-table `get_room_custom_roles().iter().any()` scans with bounded `role_belongs_to_room()` query (LIMIT 1)
- **Dead code**: deleted `get_active_devices_for_user()` + `ActiveDevice`/`ActiveDeviceRead` structs (zero callers)
- **Skipped token validation caching**: in-memory `PasetoManager` cache is only populated at startup; runtime revocations write to DB but don't update the HashMap. Query is fast (in-memory SurrealDB, LIMIT 1).
### Phase 5 — Security Hardening (commit `ee115ae`, +59 lines)
- **X-Forwarded-For IP filtering**: added `PROXY_TRUSTED_IPS` env var; middleware now checks TCP peer IP against trusted set before reading headers
- **WebSocket Origin validation**: added `WS_ALLOWED_ORIGINS` env var; middleware validates Origin header on WS upgrade, rejects with 403 if not in allowed list
- **UsernameOnly auth**: already properly gated behind `#[cfg(debug_assertions)]`, rejected in release builds — no change needed
### Phase 6 — Observability & Deployment (commit `b08ad8c`, +33 lines)
- **OTEL gating**: added `ENABLE_OTEL` env var (default false); when disabled, skip OTLP exporter entirely — prevents startup hang when no collector is available
- **Env var fix**: read `OTEL_EXPORTER_OTLP_ENDPOINT` first, fall back to `OTLP_ENDPOINT`
- **`/ready` endpoint**: Kubernetes readiness probe returning 200 when running, 503 during shutdown; added to PUBLIC_PATHS
- **Cleanup**: removed dead `health()` function and commented-out route from `start_xitca.rs`
### Phase 7 — Request-Path Perf + Shutdown (commit `fcaec35`, `8cc54e8`)
- **Token validation caching** (commit `fcaec35`): removed the per-request `is_token_revoked` DB query from all 4 call sites (auth middleware, WS handshake, refresh endpoint, gateway token restore). Revocation is fully covered by the in-memory `PasetoManager` cache, which is populated at startup and updated by every `revoke_token` / `revoke_all_user_tokens` call (`revoke_tokens_for_device` has zero callers). Deleted dead `Database::is_token_revoked`.
- **Unbounded query caps**: added safety caps + warnings to `get_room_member_ids` (LIMIT 2,000) and `get_user_joined_rooms` (LIMIT 1,000) so huge rooms/users degrade with a logged warning instead of unbounded memory use.
- **Stale bin config**: removed `[[bin]]` entries for deleted sources (`server`, `websocket_gateway_iggy`, `load_tester`, `simple_ws_server`) and their broken cargo aliases — the full `--features b_server` build was failing.
- **Graceful shutdown** (commit `8cc54e8`): disabled xitca's built-in signal handling (SIGINT mapped to an immediate force stop that defeated our sequencing), configured `shutdown_timeout`, drive a graceful `stop(true)` from `broadcast_server`, wait for SIGINT or SIGTERM, and only force-abort if the drain exceeds `SHUTDOWN_TIMEOUT_SECS`.
## Remaining / Future Work
### Test Coverage Plan
1. Install `cargo-llvm-cov` and run with `--features b_server` — ✅ installed (v0.9.0)
2. Added integration tests (commit `b9e5178`):
- DB write path: `revoke_token`, `revoke_all_user_tokens`, `revoke_tokens_for_device` — each asserts idempotence of the `UPSERT` (single row) plus marker/user fields
- `cleanup_expired_revocations` removes only expired rows
- `load_revoked_tokens` excludes expired rows
- `create_duplicate_user_returns_conflict` exercises the `409` mapping with a real in-memory SurrealDB (`Database::connect_fresh`)
- Config guard: production without `DATABASE_PATH` and without `PASETO_KEY` rejected; production with both accepted (env-mutating tests serialized under a lock)
- `is_duplicate_error` edge cases + rate limiter — already present
3. Exclusions — no Makepad/generated code is part of the `b_server` build (it lives in separate crates), so nothing to configure there. Recommended command (excludes the stray `<WORKSPACE>` debuginfo path emitted by the `../xitca-web` path-dependency macros):
```
cargo llvm-cov --features b_server --bin broadcast_server \
--html --ignore-filename-regex '<WORKSPACE>|/target/'
```
Baseline (46 tests): total 11.82% lines — pure-logic modules run 22–92% (`sms.rs` 92%, `fingerprint.rs` 87%, `session_limit.rs` 43%, `shared.rs` 40%, `db.rs` 23%); HTTP/WS handlers show 0% because they need a live server and are exercised by the `load`-feature stress/instrumentation suites.
#### Phase 7 coverage run (live, instrumented)
Ran against `127.0.0.1:8080` under `cargo llvm-cov run --no-report` so unit tests + live traffic merge into one profile:
- Executed `chat_load_tester_consistency` (5×45s, 1000 users) and `chat_load_tester` (6 profiles, up to 3000 users) plus a REST/curl pass (register, login, refresh, SMS request/verify, auth failures, `/admin/reset`, `/stats`, `/metrics`, `/ready`).
- **Total: 45.86% lines** (functions 43.79%, regions 45.64%) — up from 11.82%. Auth (`auth.rs` 63%), SMS (`sms_auth.rs` 43%), WS/real-time (`realtime.rs` 59%, `hybrid_gateway_optimized.rs` 55%), `db.rs` 32%, `middleware.rs` 65%, monitoring/observability 20–80%, pure logic (sms 97%, fingerprint 90%, session_limit 84%).
- **Validated graceful shutdown** (commit `8cc54e8`) live: SIGTERM on the app → `Graceful stopped xitca-server-worker-N` for every worker, `Shutting down observability`, `Shutdown complete.`, port released, exit in ~2–4s (timeout backstop not hit).
- **Stale load suites — repaired in Phase 8** (see below): all seven suites (`fast_integration_suite`, `chat_load_tester_realistic`, `security_suite`, `regression_suite`, `session_limit_test`, `enhanced_security_tests`, `enhanced_security`) now compile under `cargo check --features load --bins`.
- **Instrumented-debug caveat**: the `-C instrument-coverage` build is ~10–50× slower than release (register/login 2–4 s, REST keep-alive connections hit `EOF while parsing…` timeouts after a burst). On this build the REST success paths for `routes/rooms.rs`, `routes/roles.rs`, `routes/sync.rs` remain at 0% — the two working load testers drive those operations over WS instead and reach their handlers (`create/join/send` messaging paths covered via WS). To close the REST 0% gaps deterministically, add in‑process route tests using the existing `Database::connect_fresh` pattern (build `AppState` + handlers against embedded SurrealDB, bind an ephemeral port) rather than timing windows against a live instrumented binary. Re-run the stress profiles against a **release** build for trustworthy pass/fail numbers.
#### Phase 7.5 — In-process route tests (closes REST 0% gaps)
Added `src/routes/tests.rs` (`#[cfg(test)]`, wired via `mod tests`): a single `rest_routes_in_process` test builds the **exact production service chain** (`App` → `/ws` + auth/room/role/sync/monitoring routes → `nimanyatta_middleware` → `HttpServiceBuilder::h1()`), serves it on an ephemeral port over `xitca_server::Builder` (worker_threads 1), and drives it with `reqwest` + JSON bodies — no fixed port, no timing windows, no external processes.
- Dev-deps: `reqwest` (plain HTTP, `default-features = false`, features `["json"]`), `serde`, `serde_json` (alongside existing xitca-web/tokio/tokio-tungstenite/futures-util). `xitca-test` was tried first but its build.rs needs `protoc` (grpc), so the harness reimplements `test_h1_server`'s ~15 lines locally and forces a graceful stop via `ServerHandle::stop(true)` + `join.await` (dropping `ServerFuture` from async context panics — "Cannot drop a runtime").
- Flow covered: public `/health`,`/ready`; loopback monitoring `/stats`,`/metrics`; unauthenticated `/nowhere`+`/api/v1/sync` → 401; register (weak password → 400, duplicate → 409, wrong pw/unknown user → 401); login token extraction (tokens are nested `{token, expiry}` objects); refresh rotation (asserts token changed) + garbage → 401; SMS request/verify (Console provider); single-device logout (access **and** refresh → 401, device is deactivated); re-login → all-devices logout (both tokens → 401); group + direct room create, join + duplicate 409, send `MessageContent::Text` (externally-tagged), messages pagination, room roles list/create/assign/update/remove/delete, set room role, set server role (owner seeded `ServerRole::ServerAdmin` via `Database::set_server_role` since granting requires an existing admin), sync with full filter.
- **Real bug found & fixed**: `Database::role_belongs_to_room` compared `id = $role_id` (Surreal record id vs bare ULID string) → always false → `assign`/`update`/`remove` custom role 404'd. Now `id = type::record('custom_role', $role_id)`.
- **Test-run coverage now 49.01% lines** (functions 43.54%, regions 46.78%) on 47 tests. The previously-0% REST handlers are now exercised: `routes/roles.rs` 92.86% (lines), `routes/rooms.rs` 50.00%, `routes/sync.rs` 36.09%. Combined with the Phase 7 live-traffic profile, total ≥ the 45.86% live number (test binary excludes the `broadcast_server` main/run path, which the live run covered at 70%).
### Phase 8 — Protocol crate reconstruction, WS hardening, REST coverage, suite repairs
**Context**: the `nigig-common` dependency (`../nigig-lite/crates/common`) was reconstructed as a faithful, fully-tested crate (21 unit tests) mirroring the wire contract defined by `crates/nimanyatta-protocol` and the server's own call sites. The previously-lost shapes were recovered from the consumer code: `UserProfile`, `DeliveryReceipt { message_id, state, timestamp, by_user }` (+ `sent`/`delivered`/`read` constructors), `DeliveryState` five-state enum, `MessageAck` + `message_ack()`/`group_message_with_ack()` constructors, `MessageDeleted { …, deleted_by }`, `AuthMethod::SsoCallback { access_token, user_id }`, `client_msg_id: String` on `SendGroup`/`SendPrivate`, `JoinRoomRequest { reason }`, `Room.created_at: DateTime<Utc>` (non-optional), `AppError::{DatabaseConnection{source}, db_connection(op, err), SmsSendFailed{reason}, OtpMaxAttemptsExceeded (unit), SessionLimitExceeded{limit: usize}}`, `From<Box<dyn Error>>` for `AppError`, and enum helpers `RoomRole/ServerRole/MembershipState::{as_str, from_str}`, `ServerRole: Default`, `PermissionSet::{merge, defaults_for_server_role, defaults_for_room_role}`.
- **WS origin fail-closed (production)**: `Config::from_env` now **rejects an empty `WS_ALLOWED_ORIGINS` when `ENVIRONMENT=production`** (startup fails with a clear message); `*` remains an explicit opt-out. Non-production keeps the permissive default for local dev. Tests: `production_requires_ws_allowed_origins`, `production_with_database_path_and_key_ok` (now also sets origins).
- **PROXY_TRUSTED_IPS documented**: README env table expanded (PROXY_TRUSTED_IPS, WS_ALLOWED_ORIGINS, CORS_ALLOWED_ORIGINS, ENABLE_OTEL, token durations) plus two explainer sections — the proxy trust model (headers only honoured for TCP peers in the trusted set) and the WS origin fail-closed model.
- **Four unwired REST routes registered** (`src/routes/mod.rs`): `rooms/{room_id}/invite` POST, `/typing` PUT, `/read_receipt` POST, `/redact/{event_id}` POST — the handlers existed and were documented in the README but never reachable (root cause of the rooms 50 % coverage ceiling).
- **`POST /rooms/{room_id}/read_receipt` now returns 501** (`AppError::HttpError`) instead of 500 — REST read receipts are not persisted yet; the WebSocket `MarkRead` path *does* record receipts. README documents this explicitly.
- **README API tables corrected** to the real paths/methods (rooms path-param form, roles, SMS).
- **Gateway KNOWN_ISSUES fixes**: #1 centralised + idempotent close-path cleanup via `cleanup_connection`, #2 `LoggedOut` now sent **before** `remove_session`, #5 DRY'd eviction loop onto `cleanup_connection`; #3 verified already enforced (`session_manager.authenticate` evicts prior same-username connections on both handshake and in-band login paths, including token restore).
- **Gateway receipt bug fixed**: `DeliveryReceipt::read/delivered` call sites passed `Option<UserId>` where the (reconstructed) protocol wants `by_user: Option<String>` — now `Some(username.clone())`.
- **Load suites repaired** (`cargo check --features load --bins` → 0 errors):
- `fast_integration_suite`, `session_limit_test`, `regression_suite` are `[[bin]]` targets whose bodies are `#[tokio::test]` functions — `#[test]` items are `cfg(test)`-gated and vanish in normal builds, so the bins had no `main`. Added small `main`s (manual multi-thread runtime / `#[tokio::main]`) and stripped the test attributes from the orchestrator functions.
- All other compile breaks (enum shapes, missing `JoinRoomRequest.reason`, removed imports) resolved via the nigig-common reconstruction above; `cargo check --features b_server --bin broadcast_server` is clean except the intentional `RocksDb` import that requires the `kv-rocksdb` feature (restored for the final full build).
- **Coverage tests added** (`src/routes/tests.rs`): invite → join → 409-on-reinvite → 404-unknown-user, typing PUT (member + 403 non-member), redact (happy path, 404 unknown event, 403 non-owner), read_receipt 501, custom-role creation denial (403 without `ManagePermissions`), non-member send 403, messages pagination (`limit`/`direction`/invalid `from` → 400), sync filtered (room filter, `timeline_limit`, `include_state`), sync invalid `since` → 400, empty room filter → zero rooms.
- **Real bug found & fixed — OTP attempt counter never incremented** (`db.rs::increment_otp_attempts_if_under_limit`): the `BEGIN TRANSACTION; … IF … THEN ( UPDATE …; true ) …` SurrealQL block is a **parse error** (`;` is not a valid statement separator inside an IF block), so every wrong OTP made the query fail — and the handler's fail-closed `.unwrap_or(false)` masked the DB error as `OTP_MAX_ATTEMPTS_EXCEEDED` (429) on the *first* wrong attempt. Rewritten as a single atomic conditional update: `UPDATE type::record('otp', $id) SET attempts = attempts + 1 WHERE attempts < $max RETURN VALUE attempts` — empty result set ⇒ at-limit, and the read-check-write race is gone entirely. The handler now logs the underlying error before failing closed (so a broken query can never hide as a 429 again). Regression test: `db::tests::otp_attempts_increment_round_trip` (store → fetch → increment → counter persisted).
- **Server-role permission baseline**: `PermissionSet::defaults_for_server_role(User)` grants `CreateRooms`/`SendMessages`/`ReadMessages`/`InviteUsers` — room-scoped powers still come from room roles; membership checks precede permission checks in every handler.
- **WS wire format aligned to postcard** (follow-up to the remote's protocol-crate wire pinning): `nigig-common`'s `to_bytes`/`from_bytes` now use **postcard** (they had been reconstructed with serde_json), matching `nimanyatta-protocol` and the pre-existing `chat_load_tester_consistency` suite, which already called `postcard::from_bytes` on `ServerToClientMsg`. `decode_all` uses `postcard::take_from_bytes` so a fragmented frame re-buffers and bytes trailing a complete message are preserved. Remaining server/client **shape** divergences (variant sets, `LoginSuccess`/`MessageDeleted`/`MessageContent` fields) are pre-existing and documented with a remediation path in KNOWN_ISSUES ("WS wire parity").
- **Final verification**: `cargo test --features b_server` → **51 passed, 0 failed** (47 prior + OTP round-trip + WS-origin fail-closed config tests + 2 new route-coverage suites); `cargo test -p nigig-common` → **21 passed**; `cargo check --features load --bins` → clean (suite mains repaired); full `kv-rocksdb` compile check re-verified after restoring the production feature set.
### Remaining / Future Work (post-Phase-8 roadmap)
Scope docs (`crates/apps/nigig-site/construction-site-app-scope.md`, `construction-site-app-scope2.md`) Phase-3 chat gaps **documented as open** (not yet implemented — see KNOWN_ISSUES "Scope gaps"):
1. **Channel structure** per construction site (site channels auto-provisioned per project; management channel; DMs) — currently all rooms are flat, user-created.
2. **Pinned document library** per room/site (pins exist in the permission model; no pin storage/REST yet).
3. **Read receipts on documents** (message receipts land via WS `MarkRead`; document-level receipts need schema work).
4. **Mentions / search / broadcast / moderation (kick/ban enforcement) / retention policies** — permission bits exist (`PinMessages`, `KickUsers`, `BanUsers`); no handlers.
5. **Offline queue** — inbox structures exist in nigig-common (`InboxError`); server-side offline inbox not wired.
Non-scope-gap follow-ups:
- **X-Forwarded-For without trusted proxy**: requests from untrusted peers keep the raw TCP peer IP (headers ignored) — documented in README; a real reverse proxy must set `PROXY_TRUSTED_IPS`.
- **`cargo llvm-cov` re-run** after Phase 8 to refresh the coverage numbers (rooms/sync percentages will rise with the newly registered routes + tests).