Compare commits
3 Commits
v1.0.183
...
docs/scaff
| Author | SHA1 | Date | |
|---|---|---|---|
| 47beed01ca | |||
| d5ef0b84d8 | |||
| b19c39208c |
150
.claude/skills/document-features/SKILL.md
Normal file
150
.claude/skills/document-features/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: document-features
|
||||
description: Populate `<docs-dir>/features/<slug>.md` for one, several, or every undocumented feature area by dispatching up to 10 parallel subagents — one per feature. The agent docs directory is discovered from `AGENTS.md` — typically `agents-docs/` (the `setup-agentic-repository` default) but may be elsewhere if `--docs-dir` was used. Use whenever the user wants to document features, fill out feature docs, write up specific features (e.g. "document auth and billing"), document all undocumented features, or follow up on `find-features` discovery. This is the natural sequel to `find-features` — that skill identifies what is missing, this skill writes the docs in parallel.
|
||||
metadata:
|
||||
author: Olof Brogeby
|
||||
url: https://github.com/brogeby
|
||||
---
|
||||
|
||||
# document-features
|
||||
|
||||
Write feature documentation for the repository — one populated `<docs-dir>/features/<slug>.md` per requested feature — by dispatching subagents in parallel so an "all" run does not serialize. The agent docs directory (`<docs-dir>`) is discovered in Phase 1; it's typically `agents-docs/` but `setup-agentic-repository` may have written it somewhere else.
|
||||
|
||||
This skill is the documentation counterpart to `find-features`. `find-features` discovers what is missing; `document-features` is the focused worker that actually fills the template, in parallel, for the features the user names.
|
||||
|
||||
If the user asks "find and document everything", you can start with `find-features` to build the candidate list, then hand that list to this skill. If the user already knows which features they want (e.g. "document auth and billing"), come straight here.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Verify prerequisites and locate the docs directory
|
||||
|
||||
Confirm the repo has been initialized with the Mimas template, and discover where its agent docs actually live. `setup-agentic-repository` writes the agent doc tree to **`agents-docs/`** by default (a sibling of any human-maintained `docs/`), but `--docs-dir <dir>` can override that (e.g. `docs/agents`). Don't assume the path; discover it.
|
||||
|
||||
1. Read `AGENTS.md` at the repo root. Every Mimas-generated `AGENTS.md` lists its docs paths in the first block (`<docs-dir>/AGENT_WORKFLOW.md`, `<docs-dir>/AGENTS_FEATURES.md`, etc.) — the directory in those paths is the docs dir for this repo.
|
||||
2. If `AGENTS.md` doesn't exist or doesn't reference the contract files, fall back to searching for `AGENTS_FEATURES.md` directly (`find . -maxdepth 3 -name AGENTS_FEATURES.md`). The directory containing it is the docs dir.
|
||||
3. Confirm the files this skill needs are there:
|
||||
- `<docs-dir>/AGENTS_FEATURES.md` — feature documentation contract
|
||||
- `<docs-dir>/features/feature-template.md` — canonical template
|
||||
- `<docs-dir>/FEATURES.md` — feature index
|
||||
|
||||
If any are missing, tell the user this skill is designed to run after `setup-agentic-repository` and stop. Do not scaffold them yourself.
|
||||
|
||||
For the rest of this skill, treat the discovered directory as `<docs-dir>` and use it wherever paths appear. **Critical:** the subagent prompt in Phase 3 is a template — substitute the actual discovered value into it before dispatching each subagent. The subagents do not run this Phase 1 themselves and will not discover the dir on their own.
|
||||
|
||||
Read `<docs-dir>/AGENTS_FEATURES.md` and the root `AGENTS.md` (plus any subdomain `CONTEXT.md` files) so you know the contract — what counts as an area-level doc vs. a per-service doc, and which subdomains exist. These files are authoritative; if anything in this skill drifts from them, the files win.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Decide what to document
|
||||
|
||||
The user's request usually contains the answer. Parse what they said:
|
||||
|
||||
- **Named features** ("document auth and billing", "write up the search feature") → take that list of slugs
|
||||
- **A number** ("document 3 features", "write up the top 5") → take a numeric count of the most significant undocumented features
|
||||
- **"all" / "every" / "everything"** → every undocumented feature with meaningful implementation
|
||||
- **Ambiguous or empty** → use `AskUserQuestion` with the same options find-features uses (Top 5 / All / Number / Specific names) plus an "Other" free-text fallback
|
||||
|
||||
If the user is continuing from a recent `find-features` session, prefer the candidate list that already exists in the conversation over re-asking.
|
||||
|
||||
Then quickly inventory `<docs-dir>/features/`:
|
||||
|
||||
- Top-level `.md` files (one per documented area, ignoring `feature-template.md`)
|
||||
- Subdirectories (areas with per-service docs)
|
||||
- Entries in `<docs-dir>/FEATURES.md`
|
||||
|
||||
Filter the requested list against the inventory:
|
||||
|
||||
- Drop slugs that already have `<docs-dir>/features/<slug>.md`. Tell the user which ones you skipped and why
|
||||
- For named features that you cannot locate in the codebase, surface them and ask whether to skip or scaffold a TODO doc. Do not invent feature areas
|
||||
|
||||
If the user asked for a number or "all" and you need to identify candidates yourself, scan the subdomains declared in `AGENTS.md` looking for named concepts with dedicated logic — dedicated service layer, non-trivial handler, dedicated tables/migrations, multiple endpoints, real business rules. A single empty route stub is not enough. Rank by significance and trim to the requested count.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Dispatch one subagent per feature, in parallel
|
||||
|
||||
This is the whole point of the skill. Documentation per feature is independent work — one subagent can read the code for `auth` while another reads the code for `billing`. Serializing them throws away the parallelism the harness gives you.
|
||||
|
||||
**Cap concurrency at 10 subagents.** If the final list has more than 10 features, dispatch the first batch of 10 in one message, wait for it to complete, then dispatch the next batch. Do not exceed 10 concurrent agents — it overwhelms tooling and the user's review can't keep up.
|
||||
|
||||
For each feature in the current batch, spawn one `Agent` call in the **same message** (this is what makes them run in parallel). Use the `general-purpose` subagent unless something more specific fits.
|
||||
|
||||
Each subagent gets a self-contained prompt — it has not seen this conversation, so include everything it needs. **Before dispatching, substitute `<docs-dir>` with the value you discovered in Phase 1** (typically `agents-docs`). The subagents do not discover the dir on their own.
|
||||
|
||||
```
|
||||
You are documenting a single feature area for this repository. The output is one populated markdown file at `<docs-dir>/features/<slug>.md` that follows the project's feature documentation contract.
|
||||
|
||||
Feature slug: <slug>
|
||||
One-line concept (from discovery, may be rough): <concept>
|
||||
|
||||
Authoritative reading order (read these first, in order):
|
||||
1. `<docs-dir>/AGENTS_FEATURES.md` — the contract that governs how feature docs are written
|
||||
2. `<docs-dir>/features/feature-template.md` — the canonical template. Mirror its section order and headings; do not invent your own structure
|
||||
3. `AGENTS.md` and any subdomain `CONTEXT.md` files — for vocabulary and where the code lives
|
||||
|
||||
Your task:
|
||||
- Locate this feature in the codebase. Confirm it has meaningful implementation (service layer, handler, endpoints, tables, real business rules). If it is only a stub, stop and report that back — do not write a doc for a stub
|
||||
- Create `<docs-dir>/features/<slug>.md` from the template
|
||||
- Fill in what you can verify from the code: overview, responsibilities, key concepts, endpoint(s), service/handler/repository paths, key types, tests location, related features
|
||||
- Set `Area:` to the slug, `Status:` to `Active` for production code or `In Progress` for half-built features, and `Last updated:` to today's date in `YYYY-MM-DD`
|
||||
- For sections you cannot confidently fill (performance, security review, undocumented edge cases), keep the template's prompt and add a clear `TODO:` marker. Honest gaps beat invented content
|
||||
- If the feature has multiple distinct services or endpoints worth separating, also create `<docs-dir>/features/<slug>/<service>.md` files from the same template and link them from the area doc — but only when complexity actually warrants it. Do not duplicate large sections between area and per-service docs
|
||||
- Do not edit `<docs-dir>/FEATURES.md` — the dispatcher will update the index once all features are written, to avoid concurrent edits to the same file
|
||||
- Do not commit anything
|
||||
|
||||
Report back when done with:
|
||||
- Path(s) of the file(s) you created
|
||||
- One-line description of the feature (this will be used for the FEATURES.md entry)
|
||||
- Which template sections you left as TODO
|
||||
- Anything you noticed that needs human judgment
|
||||
```
|
||||
|
||||
Substitute `<slug>`, `<concept>`, and `<docs-dir>` per feature. Keep the rest verbatim — the subagent depends on having the contract in its own context.
|
||||
|
||||
If features turn out to be unrelated to one another (different subdomains, different layers), there's no need to coordinate beyond avoiding the shared `<docs-dir>/FEATURES.md` file. The dispatcher updates the index after the batch returns.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Update the index and report back
|
||||
|
||||
Once a batch completes, collect each subagent's reported one-liner and add entries to `<docs-dir>/FEATURES.md` in alphabetical order, in the form:
|
||||
|
||||
```
|
||||
- [<slug>](./features/<slug>.md) — one-line description
|
||||
```
|
||||
|
||||
If `<docs-dir>/FEATURES.md` still contains the placeholder `_No feature areas documented yet. Add entries as you build out the system._`, remove that line as you add the first real entry.
|
||||
|
||||
Then, if more features remain (because the original list was >10), dispatch the next batch of up to 10 and repeat.
|
||||
|
||||
When everything is written, tell the user:
|
||||
|
||||
- Which feature docs were created (full paths), grouped by batch if there were multiple
|
||||
- Which requested features were skipped, and why ("already documented at `<docs-dir>/features/<slug>.md`", "could not locate in codebase", "implementation too thin to earn an entry yet")
|
||||
- Which sections in the new docs are TODOs that still need human judgment
|
||||
- That `<docs-dir>/FEATURES.md` was updated and how many entries were added
|
||||
- Any discrepancies you noticed between `<docs-dir>/FEATURES.md` and the filesystem
|
||||
|
||||
Do not commit the changes. The user reviews before committing.
|
||||
|
||||
---
|
||||
|
||||
## Why this is structured around subagents
|
||||
|
||||
Documenting one feature is read-heavy and largely independent of documenting another — different files, different services, different endpoints. The slow part is the reading, and parallelizing the reading is the entire reason this skill exists separately from `find-features`. Spawning ten subagents in one message and letting them work concurrently turns a 10-minute sequential run into something closer to a 1–2 minute parallel one, with each subagent producing a focused, faithful doc because its whole context is one feature.
|
||||
|
||||
The 10-agent cap is pragmatic, not theoretical: more than that and you risk rate limits, output you can't usefully review at once, and the dispatcher running out of room to track which agent owns which slug. Two batches of ten is fine; ten batches of one is the failure mode this skill is designed to avoid.
|
||||
|
||||
---
|
||||
|
||||
## What makes a good output
|
||||
|
||||
**Faithful to the code.** Endpoint paths, file locations, method names, table names must match what is actually in the repo. If a subagent can't find something, the right move is a `TODO:` marker, not a guess.
|
||||
|
||||
**Concept-first for area docs.** The area-level doc explains what the feature is *for* — responsibilities, boundaries, vocabulary. Deep implementation detail belongs in per-service docs once they exist.
|
||||
|
||||
**Honest about gaps.** A clear `TODO: describe rate limiting` is more useful than a fabricated rate limit policy. Future sessions can fill these in from real information.
|
||||
|
||||
**Proportionate.** A small CRUD endpoint with one handler does not need every section of the template. Trim or skip sections that genuinely do not apply. The template is a checklist of what *might* be relevant, not a contract that every section must be populated.
|
||||
|
||||
**Honors the contract.** The structure, headings, and rules in `<docs-dir>/AGENTS_FEATURES.md` and `<docs-dir>/features/feature-template.md` win every time. If this skill's instructions ever drift from those files, the files are authoritative — they live with the project and are what other agents read.
|
||||
35
.claude/skills/document-features/evals/evals.json
Normal file
35
.claude/skills/document-features/evals/evals.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"skill_name": "document-features",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "find-features just listed auth, billing, and search as undocumented. Please go document all three.",
|
||||
"expected_output": "Skill verifies prerequisites by discovering the agent docs directory (typically agents-docs/, but may be elsewhere — read from AGENTS.md), takes the three named features, inventories <docs-dir>/features/ to confirm none are already documented, dispatches one subagent per feature in a single message (so they run in parallel), substitutes the discovered <docs-dir> value into each subagent prompt before dispatching, then writes <docs-dir>/features/auth.md, <docs-dir>/features/billing.md, <docs-dir>/features/search.md from the template, and appends three entries to <docs-dir>/FEATURES.md (alphabetical). Leaves TODO markers where content cannot be derived from the code. Does not commit.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "document every undocumented feature in this repo",
|
||||
"expected_output": "Skill discovers the docs dir, parses 'every' as 'all', identifies undocumented feature areas with meaningful implementation, dispatches subagents in parallel batches capped at 10 per batch (with the discovered <docs-dir> substituted into each subagent prompt), writes one doc per feature, and updates <docs-dir>/FEATURES.md. If there are more than 10 features, runs a second batch after the first finishes. Reports back which features were skipped and why.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "write the docs for billing",
|
||||
"expected_output": "Skill discovers the docs dir, treats 'billing' as a single named feature, checks <docs-dir>/features/billing.md does not already exist, locates billing in the codebase, dispatches one subagent to document it (with <docs-dir> substituted into the prompt), writes <docs-dir>/features/billing.md from the template, and adds an entry to <docs-dir>/FEATURES.md. If billing already has a doc or cannot be located, the skill surfaces that instead of silently creating something.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"prompt": "document the top 5 undocumented features",
|
||||
"expected_output": "Skill discovers the docs dir, parses '5' as a numeric limit, identifies undocumented feature areas, ranks by significance, takes the top 5, dispatches 5 subagents in parallel in a single message (each with <docs-dir> substituted), writes 5 docs from the template, and updates <docs-dir>/FEATURES.md with 5 alphabetical entries. Other candidates are listed in the final report as 'not selected this round'.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"prompt": "this repo uses --docs-dir docs/agents for its agent docs. document the auth feature.",
|
||||
"expected_output": "Skill discovers the docs dir is docs/agents/ (from AGENTS.md path references), dispatches a single subagent with docs/agents substituted into the subagent prompt template, writes docs/agents/features/auth.md, and updates docs/agents/FEATURES.md. Does not hardcode agents-docs/ or docs/features/ — uses the discovered path everywhere.",
|
||||
"files": []
|
||||
}
|
||||
]
|
||||
}
|
||||
149
.claude/skills/find-features/SKILL.md
Normal file
149
.claude/skills/find-features/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: find-features
|
||||
description: Discover feature areas in the current repository that are not yet documented under the agent docs `features/` tree (scaffolded by `setup-agentic-repository` — `agents-docs/features/` by default, or wherever `--docs-dir` put it), then create populated feature docs from the canonical template. Use whenever the user wants to find undocumented features, fill out `features/`, catch up on missing feature documentation, document feature X/Y/Z, or mentions "find features". This is the natural follow-up to `setup-agentic-repository`, which scaffolds the empty `features/` tree this skill populates.
|
||||
metadata:
|
||||
author: Olof Brogeby
|
||||
url: https://github.com/brogeby
|
||||
---
|
||||
|
||||
# find-features
|
||||
|
||||
Discover feature areas in this repository that are missing from `<docs-dir>/features/`, then create a populated markdown file for each one — following the contract in `<docs-dir>/AGENTS_FEATURES.md` and the template at `<docs-dir>/features/feature-template.md`. The agent docs directory (`<docs-dir>`) is discovered in Phase 1 — it's typically `agents-docs/` but `setup-agentic-repository` may have written it somewhere else.
|
||||
|
||||
The Mimas template (`setup-agentic-repository`) scaffolds an empty `<docs-dir>/features/` tree. This skill is the next step — it fills it in.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Verify prerequisites and locate the docs directory
|
||||
|
||||
Confirm the repo has been initialized with the Mimas template, and discover where its agent docs actually live. `setup-agentic-repository` writes the agent doc tree to **`agents-docs/`** by default (a sibling of any human-maintained `docs/`), but `--docs-dir <dir>` can override that (e.g. `docs/agents`). Don't assume the path; discover it.
|
||||
|
||||
1. Read `AGENTS.md` at the repo root. Every Mimas-generated `AGENTS.md` lists its docs paths in the first block (`<docs-dir>/AGENT_WORKFLOW.md`, `<docs-dir>/AGENTS_FEATURES.md`, etc.) — the directory in those paths is the docs dir for this repo.
|
||||
2. If `AGENTS.md` doesn't exist or doesn't reference the contract files, fall back to searching for `AGENTS_FEATURES.md` directly (`find . -maxdepth 3 -name AGENTS_FEATURES.md`). The directory containing it is the docs dir.
|
||||
3. Confirm the files this skill needs are there:
|
||||
- `<docs-dir>/AGENTS_FEATURES.md` — feature documentation contract
|
||||
- `<docs-dir>/features/feature-template.md` — the canonical template
|
||||
- `<docs-dir>/FEATURES.md` — the feature index
|
||||
|
||||
If any of those are missing, tell the user this skill is designed to run after `setup-agentic-repository` and stop. Don't try to scaffold them yourself — that is the other skill's job.
|
||||
|
||||
For the rest of this skill, treat the discovered directory as `<docs-dir>` and use it wherever paths appear. Don't hardcode `docs/` or `agents-docs/` in your reasoning, prompts, or report.
|
||||
|
||||
Read `<docs-dir>/AGENTS_FEATURES.md` and the root `AGENTS.md` (plus any subdomain `CONTEXT.md` files) so you know:
|
||||
|
||||
- what counts as a feature area — a named concept with dedicated logic in the codebase, identified by naming and behavior, not folder structure alone
|
||||
- the split between area-level docs (`<docs-dir>/features/<area>.md`) and per-service docs (`<docs-dir>/features/<area>/<service>.md`)
|
||||
- which subdomains exist and where their code lives
|
||||
|
||||
These files define the contract you must satisfy. The whole point of the skill is to produce docs that look like a senior engineer on this project wrote them, following the rules already agreed in `AGENTS_FEATURES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Inventory what is already documented
|
||||
|
||||
List `<docs-dir>/features/` and capture, before asking the user anything:
|
||||
|
||||
- Top-level `.md` files (one per documented area) — ignore `feature-template.md`, it is the template
|
||||
- Subdirectories under `<docs-dir>/features/` (areas with per-service docs)
|
||||
- The entries listed in `<docs-dir>/FEATURES.md`
|
||||
|
||||
You will use this list to filter out features that already have an area doc. Discrepancies between the filesystem and `<docs-dir>/FEATURES.md` are worth flagging in your final report, but don't block on them.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Ask the user what to discover
|
||||
|
||||
Use `AskUserQuestion` to ask how many features (or which ones) to discover. Phrase the question so it accepts a number, "all", or a free-text list of names. The tool always offers an "Other" free-text fallback, so give a few sensible presets and let the user type a specific answer when none fit.
|
||||
|
||||
```
|
||||
questions:
|
||||
- question: "How many features would you like to discover, or which ones specifically?"
|
||||
header: "Scope"
|
||||
multiSelect: false
|
||||
options:
|
||||
- label: "Top 5 (Recommended)"
|
||||
description: "Discover up to 5 of the most significant undocumented feature areas."
|
||||
- label: "All"
|
||||
description: "Find every undocumented feature area in the codebase."
|
||||
- label: "A specific number (1–10)"
|
||||
description: "I'll tell you a number from 1 to 10."
|
||||
- label: "Specific names"
|
||||
description: "I'll list the feature names I want documented (e.g. 'auth, billing, search')."
|
||||
```
|
||||
|
||||
Parse whatever they answer with — accept a bare number, the word "all" (any case), or a comma- or space-separated list. Phrases like "find feature x, y, z" or "auth and billing" should yield `["x", "y", "z"]` and `["auth", "billing"]` respectively. Don't be strict about format; pull out the names.
|
||||
|
||||
If the user names features you can't locate in the codebase, surface that and ask whether to skip them or create scaffolded TODO docs for them. Do not silently invent them.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Discover undocumented feature areas
|
||||
|
||||
Scan the codebase for feature areas using the definition from `<docs-dir>/AGENTS_FEATURES.md`. Start from the subdomains declared in the root `AGENTS.md` — if there are several large subdomains, **launch one subagent per subdomain in parallel** so discovery doesn't serialize.
|
||||
|
||||
For each candidate feature, capture:
|
||||
|
||||
- **Slug** — kebab-case, ideally matching how the area is referenced in code or routes (e.g. `auth`, `billing`, `recruitment-content`)
|
||||
- **One-sentence concept** — what the feature does, in user-facing terms
|
||||
- **Where the code lives** — routes, services, handlers, components, modules
|
||||
- **Significance signal** — at least one of: dedicated service layer, non-trivial handler, dedicated DB tables/migrations, multiple endpoints, real business rules. A single empty route stub is not enough
|
||||
- **Whether it is already documented** — does `<docs-dir>/features/<slug>.md` exist already?
|
||||
|
||||
Then filter:
|
||||
|
||||
- Drop anything that already has an area-level doc
|
||||
- Drop stubs and scaffolding — `<docs-dir>/AGENTS_FEATURES.md` says an entry must have meaningful implementation. Err on the side of leaving thin features out and noting them as "not yet earning an entry"
|
||||
|
||||
Rank what is left by significance (surface area, number of endpoints, depth of business logic), then trim to what the user asked for:
|
||||
|
||||
- **Number** → take the top N from the ranked list
|
||||
- **"all"** → take everything that survived the filter
|
||||
- **Named features** → take only those, matched by slug or close fuzzy match, warning about any that didn't match
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Create the feature docs
|
||||
|
||||
For each chosen feature:
|
||||
|
||||
1. Read `<docs-dir>/features/feature-template.md` once and use it as the structural template — section order, headings, prompts. Do not invent your own structure.
|
||||
2. Create `<docs-dir>/features/<slug>.md` populated from the template:
|
||||
- Fill in what you can verify from the code: overview, responsibilities, key concepts, API endpoint(s), service/handler/repository paths, key types, tests location, related features
|
||||
- Set **`Area:`** to the slug, **`Status:`** to `Active` for production code or `In Progress` for half-built features, and **`Last updated:`** to today's date in `YYYY-MM-DD`
|
||||
- For sections you cannot confidently fill (performance characteristics, security review, edge cases nobody has documented), keep the template's prompt and add a clear TODO marker. Leaving an honest gap is better than inventing content
|
||||
3. If the feature has multiple distinct services or endpoints worth separating, also create `<docs-dir>/features/<slug>/` and seed per-service docs (`<docs-dir>/features/<slug>/<service>.md`) from the same template, then link them from the area doc. Only do this when complexity actually warrants it — the contract says don't duplicate large sections between area and per-service docs
|
||||
|
||||
After writing each file, add an entry to `<docs-dir>/FEATURES.md` in alphabetical order, in the form:
|
||||
|
||||
```
|
||||
- [<slug>](./features/<slug>.md) — one-line description
|
||||
```
|
||||
|
||||
If `<docs-dir>/FEATURES.md` still contains the placeholder `_No feature areas documented yet. Add entries as you build out the system._`, remove that line as you add the first real entry.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Report back
|
||||
|
||||
Tell the user:
|
||||
|
||||
- Which feature docs were created (full paths)
|
||||
- Which candidates were considered but rejected, and why ("too thin to earn an entry yet", "matches existing doc <name>", etc.)
|
||||
- Which sections in the new docs are TODOs that still need human judgment (performance, security, business rules, error scenarios)
|
||||
- Whether `<docs-dir>/FEATURES.md` was updated, and any discrepancies you noticed between it and the filesystem
|
||||
|
||||
Do not commit the changes. The user reviews before committing.
|
||||
|
||||
---
|
||||
|
||||
## What makes a good output
|
||||
|
||||
**Faithful to the code.** Endpoint paths, file locations, method names, table names must match what is actually in the repo. If you cannot find something, leave the template prompt in place with a TODO — do not guess.
|
||||
|
||||
**Concept-first for area docs.** The area-level doc explains what the feature is *for* — responsibilities, boundaries, vocabulary. Implementation detail belongs in per-service docs once they exist.
|
||||
|
||||
**Honest about gaps.** A clear `TODO: describe rate limiting` is more useful than a fabricated rate limit policy. Future sessions can fill these in from real information.
|
||||
|
||||
**Proportionate.** A small CRUD endpoint with one handler does not need every section of the template. Trim or skip sections that genuinely do not apply (no auth, no caching, no migrations, no feature flags). The template is a checklist of what *might* be relevant, not a contract that every section must be populated.
|
||||
|
||||
**Honors the contract.** The structure, headings, and rules in `<docs-dir>/AGENTS_FEATURES.md` and `<docs-dir>/features/feature-template.md` win every time. If this skill's instructions ever drift from those files, the files are authoritative — they live with the project and are what other agents read.
|
||||
35
.claude/skills/find-features/evals/evals.json
Normal file
35
.claude/skills/find-features/evals/evals.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"skill_name": "find-features",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "I just ran /setup-agentic-repository on this repo and the features folder is empty. Can you find the most important features and write up docs for them?",
|
||||
"expected_output": "Skill verifies prerequisites by discovering the agent docs directory (reading AGENTS.md or searching for AGENTS_FEATURES.md — typically agents-docs/ but may be elsewhere), asks how many/which features (with Top 5 / All / Number / Names options), scans the codebase, creates <docs-dir>/features/<slug>.md files from the template for the chosen features, and appends entries to <docs-dir>/FEATURES.md (alphabetical). Honestly leaves TODO markers where it cannot derive content from the code.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "we have some feature docs already but I think auth and billing are missing — can you check and add what's missing?",
|
||||
"expected_output": "Skill discovers the docs dir, inventories the existing <docs-dir>/features/, treats the user's free-text answer 'auth and billing' as the named list, verifies both exist in the codebase (skipping or flagging any that don't), and creates only the missing area docs while leaving already-documented features alone.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "find every undocumented feature in this repo and document them all",
|
||||
"expected_output": "Skill interprets 'every' as 'all', discovers every feature area with meaningful implementation that lacks an area doc in the discovered <docs-dir>/features/, filters out stubs, ranks by significance, and creates one doc per area plus <docs-dir>/FEATURES.md entries. Reports back rejected candidates with reasons.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"prompt": "find 3 features that aren't documented yet",
|
||||
"expected_output": "Skill parses '3' as a numeric limit, discovers undocumented features in the discovered docs dir, takes the top 3 by significance, and creates docs and <docs-dir>/FEATURES.md entries for exactly those three. Other candidates are listed in the final report as 'not selected this round'.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"prompt": "the docs are under agents-docs/ in this repo, find any feature areas we haven't written up yet",
|
||||
"expected_output": "Skill reads AGENTS.md (or falls back to searching for AGENTS_FEATURES.md), confirms the docs dir is agents-docs/, uses agents-docs/AGENTS_FEATURES.md as the contract and agents-docs/features/feature-template.md as the template, and writes new docs into agents-docs/features/. Does not hardcode 'docs/' anywhere in the output.",
|
||||
"files": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,16 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
||||
|
||||
## Feature list (alphabetical)
|
||||
|
||||
_No cross-context feature docs have been written yet._
|
||||
- [access-control](./features/access-control.md) — Roles, role assignments, channel permission overrides, memberships, invites, bans, and slowmode.
|
||||
- [attachments](./features/attachments.md) — P2P chunked file-transfer protocol over the WebRTC chat data channel, storage decisions, auto-download rules.
|
||||
- [authentication](./features/authentication.md) — User account REST surface, WebSocket `identify` handshake, heartbeat sweep, and Electron Local API tokens.
|
||||
- [ipc-bridge](./features/ipc-bridge.md) — Electron preload `window.electronAPI` surface, IPC channels, and CQRS dispatch.
|
||||
- [messaging](./features/messaging.md) — Server-channel chat, direct messages, inventory-sync protocol, delivery state machine.
|
||||
- [plugin-system](./features/plugin-system.md) — Plugin manifest contract, renderer runtime, capability grants, and server `plugin-support` API.
|
||||
- [presence](./features/presence.md) — Connection lifecycle, availability status, profile metadata propagation, voice membership, and game activity.
|
||||
- [server-directory](./features/server-directory.md) — REST surface for server catalog, invites, join requests, and moderation.
|
||||
- [voice-signaling](./features/voice-signaling.md) — WebRTC mesh signaling, RNNoise pipeline, and voice / direct-call / screen-share orchestration.
|
||||
- [websocket-envelopes](./features/websocket-envelopes.md) — Wire-format contract for every realtime envelope between server and clients.
|
||||
|
||||
The product client already documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
|
||||
|
||||
|
||||
232
agents-docs/features/access-control.md
Normal file
232
agents-docs/features/access-control.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Access Control
|
||||
|
||||
> **Area:** access-control
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
Access control is the **permission engine** under every Toju server: roles and their assignments, per-channel permission overrides, server membership, invites, bans, and slowmode. It runs in two places at once. The signaling server enforces it as the source of truth on REST mutations and on the `join_server` WebSocket gate. The Angular product client maintains a parallel resolution path so the UI can disable controls the user is not allowed to use, but those client-side guards are advisory — the server is authoritative.
|
||||
|
||||
Most concepts here were introduced over two migrations: `1000000000001-ServerAccessControl.ts` added memberships, bans, and invites; `1000000000005-ServerRoleAccessControl.ts` introduced first-class roles, role assignments, and per-channel overrides. The client carries a back-compat path that re-derives the legacy `RoomPermissions` booleans from the new role state for older UI code paths.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Define and persist roles, role assignments, and per-channel permission overrides.
|
||||
- Decide whether an identity is allowed to enter a server (`authorizeWebSocketJoin`) and, if not yet a member, what step is required (open join, password, invite).
|
||||
- Maintain memberships, bans, and invites with their lifecycles (invite expiry, ban expiry).
|
||||
- Resolve the effective permission state for a (user, channel) pair via role-position precedence and channel overrides.
|
||||
- Hydrate the room model on join so the client can apply the same resolution locally.
|
||||
- Guard moderation actions against privilege escalation via role-position checks.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- Server catalog, discoverability, search, or moderation reports → [server-directory](./server-directory.md).
|
||||
- Authentication or identity binding → [authentication](./authentication.md).
|
||||
- The wire format of `join_server`, `access_denied`, role/ban update envelopes → [websocket-envelopes](./websocket-envelopes.md).
|
||||
- Online status, voice presence, game activity → [presence](./presence.md).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Role** — named row in `server_role` with a `position` (higher number = higher precedence) and a `PermissionStatePayload` of per-key overrides (`allow | deny | inherit`).
|
||||
- **System role** — bootstrap roles seeded by `1000000000005-ServerRoleAccessControl.ts`: `system-everyone`, `system-moderator`, `system-admin`. `system-everyone` applies to all members; the others are assignable.
|
||||
- **RoleAssignment** — (server, user, role) row in `server_user_role`.
|
||||
- **ChannelPermissionOverride** — (server, channel, role) row in `server_channel_permission` carrying allow / deny / inherit per permission key on top of the role's base state.
|
||||
- **Membership** — (server, user) row in `server_membership` with `createdAt` and optional metadata.
|
||||
- **Invite** — single-use or multi-use server invite in `server_invite`; default expiry **10 days** from creation.
|
||||
- **Ban** — (server, user) entry in `server_ban` with optional `expiresAt` and `reason`. Persists across reconnects.
|
||||
- **Slowmode** — per-channel send interval (`slowModeInterval` on the channel) — currently a client-rendered hint, not enforced server-side (see TODOs).
|
||||
|
||||
---
|
||||
|
||||
## Permission keys
|
||||
|
||||
Defined as a `PermissionStatePayload` in `server/src/cqrs/types.ts`. ~11 keys gate distinct capabilities:
|
||||
|
||||
- `manageServer` — edit server metadata (name, icon, settings).
|
||||
- `manageChannels` — create / edit / delete channels.
|
||||
- `manageRoles` — create / edit / delete roles (subject to role-position guard, see below).
|
||||
- `moderateMember` — kick / ban / mute / change-nick on lower-positioned members.
|
||||
- `inviteMember` — create invites.
|
||||
- `viewChannel` — see a channel exists.
|
||||
- `readMessages` — read message history in a channel.
|
||||
- `writeMessages` — send messages.
|
||||
- `manageMessages` — delete / pin others' messages.
|
||||
- `connectVoice` — join a voice room.
|
||||
- `speakVoice` — un-mute in a voice room.
|
||||
|
||||
Each key may be `allow`, `deny`, or `inherit` on a given role. The default state on `system-everyone` is permissive for "read / view / write" and restrictive for "manage / moderate"; see the migration for the seed values.
|
||||
|
||||
### Resolution algorithm
|
||||
|
||||
For a (user, channel) lookup:
|
||||
|
||||
1. Collect the user's role assignments for the server, plus the implicit `system-everyone`.
|
||||
2. Order roles by `position` ascending — **lowest position resolved first**, highest position resolved last (last-writer-wins).
|
||||
3. For each role, apply its base `PermissionStatePayload` to a running accumulator: `allow` and `deny` overwrite; `inherit` leaves the prior value intact.
|
||||
4. Apply the channel's per-role overrides (`server_channel_permission`) on top, in the same position order.
|
||||
5. The accumulator's final value per key is the effective permission.
|
||||
|
||||
Because there is no inherent `deny > allow` priority, a higher-positioned role's `allow` will *override* a lower-positioned role's `deny` on the same key. This is the intentional Discord-style "promote to override" model. Document any deviation from this when introducing a new key.
|
||||
|
||||
---
|
||||
|
||||
## Membership state machine
|
||||
|
||||
Entry into a server runs through `joinServerWithAccess` in `server/src/services/server-access.service.ts`:
|
||||
|
||||
1. Look up the server. If missing → reject.
|
||||
2. Look up the user's membership. If active → fast-path success.
|
||||
3. Look up an active ban for `(server, user)`. If present and not expired → reject with `banned`.
|
||||
4. If the server is **public** (`isPublic = true`, no password, no invite required) → create membership, return success.
|
||||
5. If the server is **password-protected** (`hasPassword = true`) → require the supplied password to hash-match. If mismatch → reject with `password_required` / `bad_password`.
|
||||
6. If the server is **invite-only** → require a valid (`server_invite.code`) and non-expired invite. Consume the invite if single-use.
|
||||
7. On success → insert a `server_membership` row, return ok.
|
||||
|
||||
`authorizeWebSocketJoin` is the lighter gate used on the `join_server` WebSocket envelope: it short-circuits to "allowed" if a membership already exists, otherwise reports the access mode needed. Unlike `joinServerWithAccess`, it does **not** consume invites or process passwords — those flow through dedicated REST routes on the server before the client retries the WebSocket join.
|
||||
|
||||
`handleJoinServer` (`server/src/websocket/handler.ts:155`) is the call site: on rejection the server sends `access_denied` with a reason (`banned`, `password_required`, `invite_required`, ...); on success it broadcasts presence (`user_joined`) per the rules documented in [presence](./presence.md).
|
||||
|
||||
---
|
||||
|
||||
## Moderation actions
|
||||
|
||||
Moderation is gated by two helpers in `server/src/services/server-permissions.service.ts`:
|
||||
|
||||
- `canManageServerUpdate(actorRoles, requested)` — maps the requested change (rename, icon change, permission edit, role create, invite, etc.) to the permission key it needs, then resolves the actor's effective state.
|
||||
- `canModerateServerMember(actorHighestRole, targetHighestRole)` — privilege-escalation guard: the actor's highest-position role must be **strictly greater** than the target's highest-position role. Two moderators at the same position cannot ban each other.
|
||||
|
||||
Bans are written via `banServerUser`. The ban entity supports an optional `expiresAt` for time-limited bans and a `reason` string for moderator-facing UI.
|
||||
|
||||
Kick is implemented as "delete membership"; the connection is dropped via a subsequent WebSocket close on the next envelope.
|
||||
|
||||
---
|
||||
|
||||
## Client-side hydration
|
||||
|
||||
When the client joins a server, the server sends the room model with normalized access-control fields:
|
||||
|
||||
```
|
||||
roles: ServerRolePayload[]
|
||||
roleAssignments: RoleAssignmentPayload[]
|
||||
channelPermissions: ChannelPermissionPayload[]
|
||||
permissions: legacy RoomPermissions bools (back-compat)
|
||||
slowModeInterval: number | undefined (per-channel)
|
||||
```
|
||||
|
||||
`normalizeRoomAccessControl` in `toju-app/src/app/shared-kernel/room.models.ts` is the single normalization helper. It:
|
||||
|
||||
- Backfills the legacy `permissions` booleans from the role state so older NgRx selectors keep working.
|
||||
- Sorts roles by `position` ascending.
|
||||
- De-duplicates assignments by `(userId, roleId)`.
|
||||
|
||||
Client-side resolution mirrors the server algorithm and lives under `toju-app/src/app/domains/access-control/domain/rules/`. Selectors expose `canSendMessage(channelId)`, `canManageRole(roleId)`, etc., which UI components consume to disable controls.
|
||||
|
||||
`canManageRole` enforces the same privilege-escalation guard as the server: a user cannot edit a role at or above their own highest position.
|
||||
|
||||
---
|
||||
|
||||
## Invites
|
||||
|
||||
`server_invite` rows have:
|
||||
|
||||
- `code` — opaque token used in invite URLs.
|
||||
- `serverId`, `createdById`.
|
||||
- `expiresAt` — default 10 days from creation.
|
||||
- `maxUses` / `uses` — single-use or multi-use semantics.
|
||||
|
||||
The REST surface for invite creation, lookup, and consumption is part of [server-directory](./server-directory.md). Invite consumption is **transactional**: a single-use invite is decremented before the membership row is created so a race cannot create two memberships off one invite.
|
||||
|
||||
---
|
||||
|
||||
## Bans
|
||||
|
||||
`server_ban` rows carry `userId`, `serverId`, optional `expiresAt`, optional `reason`, and `createdById`. Active bans are matched on `(serverId, userId)` with the expiry filter applied at read time. A ban broadcast envelope notifies connected peers so the client can drop the banned user from the local room model — TODO: confirm whether such an envelope exists today or whether clients only learn of bans on the next `server_users` snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Slowmode
|
||||
|
||||
`slowModeInterval` is a per-channel hint expressed in seconds. The client renders the cooldown UI and is expected to gate the send button locally. The server does **not** enforce slowmode today — a non-cooperating client can ignore the interval. This is a known gap; see TODOs.
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- **Server is authoritative.** Client-side `canX` selectors are advisory and exist to prevent UI confusion, not to gate security.
|
||||
- **Last-writer-wins by position** — there is no inherent allow/deny priority; a higher-positioned role can override a lower-positioned role's deny.
|
||||
- **Privilege escalation is blocked** by requiring strict position-greater on the moderator. Same-position moderation is rejected on both sides.
|
||||
- **Invite consumption is atomic** for single-use invites.
|
||||
- **Bans persist independently of memberships** — a banned user without a membership row is still banned.
|
||||
- **`system-everyone` always applies** at position 0 (or whatever the migration seeds); it cannot be removed.
|
||||
- **Channel overrides resolve last** after role base state.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
### Server
|
||||
- Services — `server/src/services/server-access.service.ts` (`authorizeWebSocketJoin`, `joinServerWithAccess`, `ensureServerMembership`, `banServerUser`, `leaveServerUser`); `server/src/services/server-permissions.service.ts` (`getServerRoles`, `getServerAssignments`, `resolveRolePermissionState`, `resolveHighestRole`, `canManageServerUpdate`, `canModerateServerMember`).
|
||||
- Entities — `server/src/entities/ServerMembershipEntity.ts`, `ServerBanEntity.ts`, `ServerInviteEntity.ts`, `ServerRoleEntity.ts`, `ServerUserRoleEntity.ts`, `ServerChannelPermissionEntity.ts`.
|
||||
- Migrations — `1000000000001-ServerAccessControl.ts`, `1000000000005-ServerRoleAccessControl.ts`.
|
||||
- CQRS — payload types in `server/src/cqrs/types.ts` (`AccessRolePayload`, `PermissionStatePayload`, `RoleAssignmentPayload`, `ChannelPermissionPayload`); normalization in `server/src/cqrs/relations.ts` (`normalizeServerRoles`, `normalizeServerRoleAssignments`).
|
||||
- REST — server / role / invite / ban routes are mounted under `server/src/routes/servers.ts` (catalog endpoints are documented in [server-directory](./server-directory.md)).
|
||||
- WS — `server/src/websocket/handler.ts::handleJoinServer` (line 155), `access_denied` emission.
|
||||
|
||||
### Product client
|
||||
- Domain — `toju-app/src/app/domains/access-control/`.
|
||||
- Shared kernel — `toju-app/src/app/shared-kernel/access-control.models.ts`, `moderation.models.ts`, `room.models.ts` (`normalizeRoomAccessControl`).
|
||||
- NgRx — access-control reducers / selectors under `toju-app/src/app/store/access-control/`.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- TODO: no spec was located for `server-access.service.ts` or `server-permissions.service.ts`.
|
||||
- TODO: no spec was located for the `authorizeWebSocketJoin` rejection paths.
|
||||
- Client-side rule specs likely exist under `toju-app/src/app/domains/access-control/domain/rules/*.spec.ts` — confirm and list when filling in.
|
||||
- TODO: E2E coverage for invite consumption, ban enforcement, and role-position escalation.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Slowmode is not server-enforced.** A modified client can spam. Treat slowmode as an anti-accident measure, not abuse mitigation.
|
||||
- **No server-side channel-permission enforcement on message send** — only role-state is checked at the join gate. TODO: verify whether per-channel overrides are applied on the message-send path.
|
||||
- **No audit log** of moderation actions. TODO.
|
||||
- **Password-protected servers** rely on the same SHA-256 hashing used for AuthUser passwords — see Security in [authentication](./authentication.md); the same caveats apply.
|
||||
- **Position-based escalation guard** works only if positions are well-ordered. Position assignment is on `manageRoles`-bearing users; misconfiguration can produce moderators who can demote each other arbitrarily.
|
||||
- **Bans are not always broadcast in real time** — clients may discover an ongoing ban only on the next `server_users` snapshot. TODO: confirm the live ban envelope.
|
||||
|
||||
---
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- Role and assignment lookups are point queries on `server_role` / `server_user_role` indexed by `serverId`.
|
||||
- Per-channel override resolution is O(roles × channels) at hydration time; happens once on `join_server` and is cached client-side.
|
||||
- `authorizeWebSocketJoin` is O(1) for existing members (single membership lookup), O(1)–O(invites) for invite consumption.
|
||||
|
||||
---
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **No server-side slowmode enforcement.**
|
||||
- **No dedicated ban-broadcast envelope** (or unconfirmed).
|
||||
- **No audit log** for moderation actions.
|
||||
- **Channel-permission overrides may not be applied on the message-send path** server-side — TODO.
|
||||
- **Unsalted SHA-256** for password-protected server passwords — same gap as user passwords.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- **[server-directory](./server-directory.md)** — owns server catalog, discoverability, and the REST surface for invites/bans/roles.
|
||||
- **[authentication](./authentication.md)** — provides the `oderId` identity that access-control authorizes.
|
||||
- **[presence](./presence.md)** — `user_joined` / `user_left` are emitted only after `authorizeWebSocketJoin` succeeds.
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `join_server`, `access_denied`, role/ban update envelopes.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
196
agents-docs/features/attachments.md
Normal file
196
agents-docs/features/attachments.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Attachments
|
||||
|
||||
> **Area:** attachments
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
Attachments are pure peer-to-peer in Toju. The signaling server never sees a file byte. A sender announces an attachment on the WebRTC chat data channel; a receiver requests it; the sender streams base64-encoded 64 KiB chunks back; the receiver reassembles and (on Electron) writes the result to disk under a per-conversation folder. If the original sender goes offline mid-transfer, the receiver can re-request from another peer that previously announced the same attachment. There is no inventory protocol, no integrity signature, and no server-side fallback — attachments live entirely on the participants' machines.
|
||||
|
||||
This area is the closest sibling of [voice-signaling](./voice-signaling.md): both are P2P protocols that ride the same RTCPeerConnection. The chat events that drive attachments are members of the `ChatEvent` union; they share the data channel with chat messages but are conceptually distinct.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Define the file-transfer envelope set (announce / request / chunk / cancel / not-found) and its sequencing rules.
|
||||
- Maintain per-transfer state on both sides — chunk index, in-flight chunk, retry/failover bookkeeping.
|
||||
- Decide whether to auto-download (size + media-type heuristic).
|
||||
- Decide where to persist (Electron disk vs browser memory).
|
||||
- Estimate transfer speed via EWMA so the UI can render a progress bar that doesn't jitter.
|
||||
- Pick a failover peer when the current sender disappears.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- The chat message that references the attachment → [messaging](./messaging.md).
|
||||
- The peer connection or data channel itself → [voice-signaling](./voice-signaling.md).
|
||||
- The IPC channels used to read / write the file on Electron → [ipc-bridge](./ipc-bridge.md).
|
||||
- Permission to upload — there is no formal upload gate today; access-control's `writeMessages` is the proxy. See [access-control](./access-control.md).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Attachment** — a file announced and referenced by a chat message. Persisted independently of the message body.
|
||||
- **Transfer** — the per-receiver state for a single in-flight attachment.
|
||||
- **Bucket** — storage subfolder: `image | video | audio | files`. Determined by MIME type.
|
||||
- **Tried-peer set** — the set of peers a receiver has already attempted for a given `${messageId}:${fileId}`; used to drive failover without re-trying the same peer in a loop.
|
||||
- **`uploaderPeerId`** — the original announcer; the receiver prefers it over the tried-peer set when (re-)issuing a `file-request`.
|
||||
|
||||
---
|
||||
|
||||
## Protocol
|
||||
|
||||
The five events live in the `ChatEvent` union (`toju-app/src/app/shared-kernel/chat-events.ts`) and ride the WebRTC `chat` data channel. They do **not** flow through the WebSocket signaling server.
|
||||
|
||||
- `file-announce` — sender announces an attachment alongside a chat message. Carries `messageId`, `fileId`, `name`, `size`, `mimeType`, optional preview metadata.
|
||||
- `file-request` — receiver requests the attachment from a specific peer.
|
||||
- `file-chunk` — sender streams `index`, base64-encoded chunk payload, and `total` chunk count.
|
||||
- `file-cancel` — either side aborts the in-flight transfer.
|
||||
- `file-not-found` — sender responds when asked for an unknown `fileId`.
|
||||
|
||||
### Constants
|
||||
|
||||
Defined in the attachment domain (`toju-app/src/app/domains/attachment/`):
|
||||
|
||||
- `P2P_BASE64_CHUNK_SIZE_BYTES = 64 * 1024` — re-exported as `FILE_CHUNK_SIZE_BYTES`. Shared with the avatar P2P sync path.
|
||||
- `MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024` — files at or under 10 MiB are auto-downloaded on receipt.
|
||||
- `MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES = 50 * 1024 * 1024` — browser-mode cap on inlined media.
|
||||
- **EWMA weights** — previous-weight `0.7`, current-weight `0.3` for transfer-rate smoothing.
|
||||
- **Data-channel water marks** — `highWaterMark = 4 MiB`, `lowWaterMark = 1 MiB` for backpressure pacing.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Sender computes attachment metadata and emits `file-announce` referencing the chat message.
|
||||
2. Receiver opens a transfer state. Auto-download triggers if `size ≤ MAX_AUTO_SAVE_SIZE_BYTES` and the MIME type is in the allow-list for the bucket. Larger files require an explicit user click.
|
||||
3. Receiver sends `file-request` to `uploaderPeerId`.
|
||||
4. Sender streams `file-chunk` events sequentially. **Exactly one chunk is in flight per receiver at a time** — the sender awaits the per-chunk write/ack before queueing the next one. On Electron the receiver writes each chunk to disk; the protocol requires `index === receivedCount` for the next chunk or the transfer aborts.
|
||||
5. Receiver reassembles. On Electron, the file lands at:
|
||||
- `{appData}/server/{room}/{bucket}/{id}{.ext}` for server-channel attachments.
|
||||
- `{appData}/direct-messages/{conv}/{bucket}/{id}{.ext}` for DM attachments.
|
||||
- Browser mode keeps the file as a Blob in memory — lost on reload.
|
||||
6. Either side may `file-cancel`; the sender returns `file-not-found` if the requested `fileId` is unknown.
|
||||
|
||||
### Failover
|
||||
|
||||
- Receiver-driven. No inventory protocol.
|
||||
- Sequential — tries one peer at a time.
|
||||
- The tried-peer set is keyed by `${messageId}:${fileId}`.
|
||||
- `uploaderPeerId` is always preferred when reachable; the tried-peer set ensures it isn't re-attempted in a busy loop after a failure.
|
||||
- If every available peer is in the tried set, the transfer ends in a not-found state and surfaces a UI prompt.
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
### Electron
|
||||
|
||||
- `AttachmentEntity` — TypeORM row in the per-user local database. Carries `id`, `messageId`, `roomId` / `conversationId`, `name`, `size`, `mimeType`, `bucket`, `relativePath`, `createdAt`.
|
||||
- CQRS commands — `save-attachment`, `delete-attachments-for-message`.
|
||||
- CQRS queries — `get-all-attachments`, `get-attachments-for-message`.
|
||||
- Filesystem IPC — `read-file-chunk`, `get-file-size`, `write-file`, `append-file`, `get-file-url`, `file-exists`, `delete-file`, `ensure-dir`, `get-app-data-path`.
|
||||
|
||||
The renderer never touches Node.js filesystem APIs directly; every read/write is brokered through [ipc-bridge](./ipc-bridge.md).
|
||||
|
||||
### Browser
|
||||
|
||||
When the desktop shell is not present, attachments stay in-memory as Blob URLs. Reloading the renderer loses them; this is documented behavior, not a bug.
|
||||
|
||||
---
|
||||
|
||||
## Auto-download heuristic
|
||||
|
||||
- Any file with `size ≤ 10 MiB` and a media MIME type (`image/*`, `video/*`, `audio/*`) is auto-downloaded on receipt so the chat UI can render it inline.
|
||||
- Files above the cap or in the `files` bucket require an explicit click. The chat UI shows a "Download" affordance with the file size.
|
||||
|
||||
---
|
||||
|
||||
## Speed estimation (EWMA)
|
||||
|
||||
Transfer rate is exposed to the UI via an exponentially-weighted moving average:
|
||||
|
||||
```
|
||||
rate_t = 0.7 · rate_{t-1} + 0.3 · instantaneous_t
|
||||
```
|
||||
|
||||
Smooth enough for a stable progress display; responsive enough to surface a stalled transfer within a few seconds.
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- Attachments are **pure P2P** — the signaling server never sees an attachment byte.
|
||||
- **One chunk in flight per sender → receiver** (`await` per chunk). No parallelism within a single transfer.
|
||||
- **Sequential chunk indices on Electron disk receive** — `index === receivedCount` is enforced; mismatches abort.
|
||||
- **`PeerDeliveryService` is not on the attachment path.** Attachments use `RealtimeSessionFacade.broadcastMessage` / `sendToPeer` / `sendToPeerBuffered` directly.
|
||||
- **Browser mode loses everything on reload** — no IndexedDB persistence today for attachments.
|
||||
- **No integrity / signature check** on chunks; no encryption at rest beyond OS file permissions.
|
||||
- **Failover is receiver-driven** and tried-peer-set deduplicated.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
### Product client
|
||||
- Domain — `toju-app/src/app/domains/attachment/`: manager, transfer state, persistence selection.
|
||||
- Contracts — `toju-app/src/app/shared-kernel/attachment-contracts.ts`, `chat-events.ts` (the five envelope types).
|
||||
- Realtime send paths — `RealtimeSessionFacade.broadcastMessage` / `sendToPeer` / `sendToPeerBuffered` in the realtime infrastructure tree.
|
||||
|
||||
### Electron
|
||||
- Entity — `AttachmentEntity` in `electron/entities/`.
|
||||
- CQRS handlers — under `electron/src/cqrs/` (or equivalent) for `save-attachment`, `delete-attachments-for-message`, `get-all-attachments`, `get-attachments-for-message`.
|
||||
- Filesystem IPC handlers — `electron/ipc/`: `read-file-chunk`, `get-file-size`, `write-file`, `append-file`, `get-file-url`, `file-exists`, `delete-file`, `ensure-dir`, `get-app-data-path`.
|
||||
|
||||
### Key types
|
||||
- `AttachmentEntity` — local persistence row.
|
||||
- `FileChunkEvent`, `FileAnnounceEvent`, `FileRequestEvent`, `FileCancelEvent`, `FileNotFoundEvent` — member shapes of the `ChatEvent` union.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- TODO: no dedicated `*.spec.ts` files under `toju-app/src/app/domains/attachment/` at time of writing.
|
||||
- E2E: `e2e/tests/chat/chat-message-features.spec.ts` includes `test('syncs image and file attachments between users', ...)` which covers happy-path attachment sync.
|
||||
- TODO: no E2E coverage for multi-peer failover.
|
||||
- TODO: no E2E coverage for `file-cancel`.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **No integrity signature.** A malicious sender can corrupt a chunk; the receiver assembles whatever arrives.
|
||||
- **No encryption at rest** beyond OS-level file permissions on the per-user app-data folder.
|
||||
- **No MIME-type sanitation.** The receiver trusts the announced `mimeType` for bucket routing; a misleading MIME does not change the on-disk contents but does affect inline rendering. Browser-side renderers must defend against this.
|
||||
- **No size cap server-side.** Caps are receiver-side and advisory: `MAX_AUTO_SAVE_SIZE_BYTES` for auto-download, `MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES` for in-memory media. A sender can announce arbitrarily large files; the receiver simply refuses them.
|
||||
- **Receivers expose disk write paths** indirectly: a misbehaving peer cannot escape `{appData}/server/...` or `{appData}/direct-messages/...` because the relative path is computed by the receiver, not transmitted by the sender — but this property must be preserved in any future protocol change.
|
||||
|
||||
---
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- **Base64 overhead.** ~33 % inflation on the wire; a 64 KiB binary chunk is ~86 KiB on the wire.
|
||||
- **Single chunk in flight** per (sender, receiver) — caps single-receiver throughput at one round-trip per chunk.
|
||||
- **Data-channel water marks** (4 MiB high, 1 MiB low) provide back-pressure pacing without tuning per-NIC.
|
||||
- **No FEC, no parallel chunks, no resumption across browser reloads.**
|
||||
|
||||
---
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **No dedicated unit specs** for the attachment domain.
|
||||
- **No resume across browser reloads** (Electron writes to disk and survives; browser does not).
|
||||
- **No checksum / signed integrity** on chunks.
|
||||
- **No encryption at rest** beyond OS file permissions.
|
||||
- **No server-side fallback** if every peer is offline — attachments are unreachable until at least one peer with the file returns.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- **[messaging](./messaging.md)** — chat messages reference attachments; attachments are persisted separately from message bodies.
|
||||
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel that attachments ride on.
|
||||
- **[ipc-bridge](./ipc-bridge.md)** — exposes the filesystem and CQRS APIs the Electron persistence path uses.
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — for context only; attachments do not flow through the signaling server.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
194
agents-docs/features/authentication.md
Normal file
194
agents-docs/features/authentication.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Authentication
|
||||
|
||||
> **Area:** authentication
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
User identity in Toju is split into two surfaces. A small **HTTP credential surface** on the signaling server (`/api/users/register`, `/api/users/login`) registers and verifies user accounts persisted in TypeORM. A separate **WebSocket `identify` handshake** binds a *self-asserted* identity (`oderId` + display name + optional description) to a live WebSocket connection so the server can route envelopes. There is no server-issued session token: the client re-asserts identity on every reconnect, and other peers trust the claim as far as the signaling fabric does — i.e. only to the extent that subsequent authorization checks (see [access-control](./access-control.md)) accept it.
|
||||
|
||||
The Electron desktop shell adds a third surface — the **Local API token store** in `electron/api/auth-store.ts` — which issues short-lived bearer tokens for the in-process HTTP server that hosts the docs site and OpenAPI bundle. That surface is internal to the desktop process and is documented here only because it shares the "authentication" name.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Register and authenticate user accounts against the signaling server's `users` table.
|
||||
- Bind a connection-scoped identity to a WebSocket connection via the `identify` envelope, including profile metadata propagation.
|
||||
- Detect dead WebSocket connections via ping/pong sweeps and reap stale `ConnectedUser` rows.
|
||||
- Mint and validate short-lived bearer tokens for the Electron Local API server.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- Permissions, roles, bans, or membership state → [access-control](./access-control.md).
|
||||
- Online / away / busy status, voice presence, game activity → [presence](./presence.md).
|
||||
- The shape of WebSocket envelopes carrying identity claims → [websocket-envelopes](./websocket-envelopes.md).
|
||||
- Profile avatar bytes or per-user assets → product-client `profile-avatar` domain.
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **AuthUser** — server-persisted account: `id` (uuid), `username` (unique), `passwordHash`, `displayName`, `createdAt`. Defined in `server/src/entities/AuthUserEntity.ts`.
|
||||
- **oderId** — client-asserted user identifier sent on `identify`. Used as the broadcast routing key. The server **trusts** it; there is no cryptographic binding between an AuthUser row and the `oderId` claimed over WebSocket.
|
||||
- **Identify handshake** — first message a client sends on a WebSocket. Carries `oderId`, `displayName`, optional `description`, optional `profileUpdatedAt`, optional `connectionScope`.
|
||||
- **Connection scope** — opaque string (typically the signal URL the client connected through). Used together with `oderId` to disambiguate multiple sockets per identity so stale-connection eviction does not loop across signal URLs.
|
||||
- **Local API token** — bearer token issued by the Electron desktop process, 24 h TTL, kept in-memory and pruned on access.
|
||||
|
||||
---
|
||||
|
||||
## HTTP credential surface
|
||||
|
||||
Mounted at `/api/users` (see `server/src/routes/index.ts:20`). All payloads are JSON.
|
||||
|
||||
### `POST /api/users/register`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"username": "string (required, unique)",
|
||||
"password": "string (required)",
|
||||
"displayName": "string (optional, defaults to username)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{ "id": "uuid", "username": "string", "displayName": "string" }
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- **400** `Missing username/password` — either field absent.
|
||||
- **409** `Username taken` — username already exists.
|
||||
|
||||
### `POST /api/users/login`
|
||||
|
||||
**Request:** `{ "username": "string", "password": "string" }`
|
||||
|
||||
**Response (200):** `{ "id": "uuid", "username": "string", "displayName": "string" }`
|
||||
|
||||
**Errors:**
|
||||
- **401** `Invalid credentials` — no row matches, or stored hash differs.
|
||||
|
||||
No session cookie, JWT, or bearer token is issued — the response is purely informational. The client is expected to remember the username and re-present it via the WebSocket `identify` handshake on every reconnect.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket `identify` handshake
|
||||
|
||||
`handleIdentify` (`server/src/websocket/handler.ts:112`) processes the first envelope a client sends. It:
|
||||
|
||||
1. Reads `oderId` (falls back to `connectionId` when absent).
|
||||
2. Reads `connectionScope` (opaque routing key).
|
||||
3. Reads / normalizes `displayName`, `description`, `profileUpdatedAt`.
|
||||
4. Mutates the `ConnectedUser` row in `connectedUsers`.
|
||||
5. If any of `displayName` / `description` / `profileUpdatedAt` changed, rebroadcasts `user_joined` to every server the user is currently in.
|
||||
|
||||
`identify` itself is unauthenticated — the server does not consult `AuthUserEntity` here. Authorization happens later, at `join_server` time, via `authorizeWebSocketJoin` (documented in [access-control](./access-control.md)).
|
||||
|
||||
`identify` is the canonical channel for **profile updates**. Renaming yourself or updating your description means resending `identify`; the rebroadcast pushes the new profile to peers without disconnect/reconnect.
|
||||
|
||||
---
|
||||
|
||||
## Heartbeat and dead-connection sweep
|
||||
|
||||
Defined in `server/src/websocket/index.ts:19`–`75`:
|
||||
|
||||
- `PING_INTERVAL_MS = 30_000` — server pings every connection every 30 s.
|
||||
- `PONG_TIMEOUT_MS = 45_000` — a connection whose `lastPong` is older than 45 s is closed and removed from `connectedUsers`.
|
||||
|
||||
`lastPong` is bumped on any inbound frame (not just pong), so an active client cannot be reaped while sending traffic. Eviction triggers `handleLeaveServer` for every server the connection had joined, which in turn emits `user_left` if no other connection of the same `oderId` still holds the server.
|
||||
|
||||
---
|
||||
|
||||
## Electron Local API token store
|
||||
|
||||
`electron/api/auth-store.ts` mints opaque bearer tokens used by the Local API server (`electron/api/router.ts`) to gate calls to `/api/auth/login` and other authenticated routes. Tokens have a 24 h TTL and are kept in-memory only — they do not persist across desktop restarts. Pruning happens lazily on lookup.
|
||||
|
||||
This surface is **not** the same identity as the signaling server's AuthUser. It is a desktop-local affordance for the in-process HTTP server that serves docs and plugin APIs; the renderer never sees these tokens.
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- Usernames are **unique** at the database level (`@Column('text', { unique: true })` on `AuthUserEntity.username`) AND pre-checked in the route handler. Comparison is **case-sensitive**.
|
||||
- Passwords are hashed with **unsalted SHA-256** (`crypto.createHash('sha256')`, `routes/users.ts:8`). There is no salting, no peppering, no iteration count, no Argon2/bcrypt. This is a known weakness — see Security below.
|
||||
- The signaling server never issues a session token. Identity is re-asserted on every reconnect via `identify`.
|
||||
- The `identify` claim is **not verified** against `AuthUserEntity`. Two clients can claim the same `oderId`; only `(oderId, connectionScope)` is used to deduplicate eviction.
|
||||
- `user_joined` is only re-broadcast on `identify` when at least one of `displayName` / `description` / `profileUpdatedAt` actually changed — duplicate identifies are silent.
|
||||
- Dead-connection sweep runs continuously. A client that goes silent for ≥ 45 s is treated as disconnected.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
### Server (signaling)
|
||||
- HTTP routes: `server/src/routes/users.ts`, mounted at `/api/users` in `server/src/routes/index.ts`.
|
||||
- CQRS handlers: `server/src/cqrs/commands/handlers/registerUser.ts`, `server/src/cqrs/queries/handlers/getUserByUsername.ts`, `server/src/cqrs/queries/handlers/getUserById.ts`.
|
||||
- Entity: `server/src/entities/AuthUserEntity.ts` (`users` table).
|
||||
- Migration: `server/src/migrations/1000000000000-InitialSchema.ts`.
|
||||
- WebSocket handshake: `server/src/websocket/handler.ts::handleIdentify` (line 112).
|
||||
- `ConnectedUser` shape: `server/src/websocket/types.ts`.
|
||||
- Heartbeat sweep: `server/src/websocket/index.ts`.
|
||||
|
||||
### Product client
|
||||
- Domain: `toju-app/src/app/domains/authentication/`.
|
||||
- Service: `authentication.service.ts` (login / register HTTP calls).
|
||||
- Components: `login.component.ts`, `register.component.ts`.
|
||||
- Model: `authentication.model.ts`.
|
||||
|
||||
### Electron
|
||||
- Local API token store: `electron/api/auth-store.ts`.
|
||||
- Local API router: `electron/api/router.ts` (`/api/auth/login` endpoint).
|
||||
|
||||
### Key types
|
||||
- `AuthUserEntity` — server account row.
|
||||
- `ConnectedUser` — live WebSocket connection state, including `oderId`, `connectionScope`, `lastPong`.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- E2E: `e2e/tests/auth/user-session-data-isolation.spec.ts` — verifies session-level data isolation between users.
|
||||
- TODO: no unit specs were located for `server/src/routes/users.ts`, `handleRegisterUser`, `getUserByUsername`/`getUserById`, `handleIdentify`, the Electron `/api/auth/login` proxy, or the `toju-app` authentication services.
|
||||
- TODO: no happy-path login/register E2E exists today.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Password hashing is unsalted SHA-256.** Vulnerable to rainbow-table and parallel GPU attacks. Replacing with Argon2id or bcrypt is the obvious upgrade path and is currently a TODO.
|
||||
- **No rate limiting on `/login`.** A `users` table with weak hashes is exposed to credential stuffing and online brute-force.
|
||||
- **`identify` is unauthenticated.** Any WebSocket can claim any `oderId`. The real authorization gate is `authorizeWebSocketJoin` on `join_server`, which checks membership / invite / password against the access-control tables — until that gate is crossed, an unverified `oderId` cannot do anything meaningful beyond joining the public lobby.
|
||||
- **No reuse-prevention on `displayName`.** Two distinct accounts may carry the same display name. UI must therefore disambiguate by `oderId` where identity actually matters.
|
||||
- **Local API tokens** never leave the desktop process and have a 24 h TTL — they are not a credential primitive for the signaling server.
|
||||
|
||||
---
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- `/register` and `/login` are O(1) lookups against a `UNIQUE` index on `username`. No caching layer.
|
||||
- `identify` is O(serversJoinedByThisConnection) on profile change because of the rebroadcast loop; profile updates are rare so this is negligible.
|
||||
- Dead-connection sweep is O(connections) per `PING_INTERVAL_MS`; trivially scalable for a single-process signaling server.
|
||||
|
||||
---
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **Unsalted SHA-256 password hashing.** Highest-priority hardening target.
|
||||
- **No password reset, email confirmation, MFA, or account recovery.**
|
||||
- **No audit log** of register/login events.
|
||||
- **No binding between `AuthUserEntity.id` and the claimed `oderId`.** A future hardening pass should require the client to prove possession of an `AuthUser` credential before the server accepts an `identify` payload that names that user — likely via a signed challenge.
|
||||
- **No spec coverage** for the HTTP credential surface or the identify handshake.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- **[access-control](./access-control.md)** — consumes the `oderId` claimed via `identify` to authorize server joins, role lookups, and moderation actions.
|
||||
- **[presence](./presence.md)** — `identify` is the canonical channel for profile-metadata updates that presence broadcasts forward.
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `identify`, `user_joined`, and `access_denied`.
|
||||
- **[ipc-bridge](./ipc-bridge.md)** — the Electron Local API token store lives behind the same IPC boundary as other privileged operations.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
178
agents-docs/features/ipc-bridge.md
Normal file
178
agents-docs/features/ipc-bridge.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Electron IPC Bridge
|
||||
|
||||
> **Area:** ipc-bridge
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
The Electron IPC bridge is the only path through which the Angular renderer can reach the desktop runtime — the filesystem, the local SQLite database, OS APIs, the update flow, plugin loading, and the in-process Local API server. The renderer cannot import `electron`, `node:fs`, TypeORM, or any other privileged module directly; every privileged operation crosses the preload `contextBridge` boundary as a typed IPC call. This area documents the surface itself: how it is registered, how it is consumed, and what guarantees do (or do not) hold.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Expose a frozen, allow-listed set of methods on the renderer's global window object via the preload bridge.
|
||||
- Register one `ipcMain` handler per exposed method, grouped by concern (`system`, `window-controls`, `cqrs`).
|
||||
- Provide a CQRS abstraction over the local database (commands + queries dispatched through two generic channels).
|
||||
- Translate main-process operations into renderer-safe values (file paths → URLs, native errors → structured responses where appropriate).
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- The schema or business logic behind any specific command/query (those live in `electron/cqrs/handlers/` and the affected product-client domains).
|
||||
- The plugin manifest contract (see [plugin-system](./plugin-system.md)) — only the IPC methods that surface it.
|
||||
- WebSocket signaling (see [websocket-envelopes](./websocket-envelopes.md)) — that bypasses Electron entirely.
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Preload bridge** — `electron/preload.ts`. The sole place `contextBridge.exposeInMainWorld` runs. Adding a method here requires a matching `ipcMain.handle` / `ipcMain.on` on the main side.
|
||||
- **Window surface** — exposed as `window.electronAPI` on the renderer. (Note: `electron/CONTEXT.md` refers to this as `window.api.*` — the documented intent. The literal global today is `electronAPI`. TODO: pick one and align.)
|
||||
- **CQRS channel** — two reserved channels `cqrs:command` and `cqrs:query` route every typed `Command`/`Query` through a single dispatch step, instead of one channel per operation.
|
||||
- **Handler setup function** — registered once at app boot from `electron/ipc/index.ts`: `setupCqrsHandlers`, `setupSystemHandlers`, `setupWindowControlHandlers`.
|
||||
- **Renderer bridge service** — `ElectronBridgeService` in `toju-app/src/app/core/platform/electron/electron-bridge.service.ts` is the Angular-side wrapper; domain services inject it rather than reaching for `window.electronAPI` directly.
|
||||
|
||||
---
|
||||
|
||||
## Surface catalogue
|
||||
|
||||
Defined in `electron/preload.ts`. Approximately 50 methods, grouped below by concern. For exact signatures see the file.
|
||||
|
||||
### Window controls (fire-and-forget)
|
||||
|
||||
- `minimizeWindow`, `maximizeWindow`, `closeWindow` — channels `window-minimize`, `window-maximize`, `window-close`. Implementation: `electron/ipc/window-controls.ts`. Uses `ipcMain.on` (no return value).
|
||||
|
||||
### Screen share & media
|
||||
|
||||
- `getSources` — DesktopCapturer source enumeration.
|
||||
- Linux audio routing for screen-share monitor capture: `prepareLinuxScreenShareAudioRouting`, `activateLinuxScreenShareAudioRouting`, `deactivateLinuxScreenShareAudioRouting`, `startLinuxScreenShareMonitorCapture`, `stopLinuxScreenShareMonitorCapture`.
|
||||
- Event listeners: `onLinuxScreenShareMonitorAudioChunk`, `onLinuxScreenShareMonitorAudioEnded`.
|
||||
|
||||
### Process & game detection
|
||||
|
||||
- `getRunningProcessNames` (via `electron/process-list.ts`).
|
||||
- `getActiveGameCandidate` (via `electron/game-detection/`).
|
||||
- `getIgnoredGameProcesses`, `setIgnoredGameProcesses`.
|
||||
|
||||
### File system
|
||||
|
||||
- `readFile`, `readFileChunk`, `getFileSize`, `writeFile`, `appendFile`, `deleteFile`, `fileExists`, `getFileUrl`, `ensureDir`, `saveFileAs`, `saveExistingFileAs`, `openFilePath`, `readClipboardFiles`.
|
||||
- `getFileUrl` is the canonical way for the renderer to display a local file via `file://` — direct path access is forbidden.
|
||||
|
||||
### Theme & plugins (filesystem-backed)
|
||||
|
||||
- `getSavedThemesPath`, `listSavedThemes`, `readSavedTheme`, `writeSavedTheme`, `deleteSavedTheme`.
|
||||
- `getLocalPluginsPath`, `listLocalPluginManifests`. See [plugin-system](./plugin-system.md) for the manifest contract.
|
||||
|
||||
### Settings & notifications
|
||||
|
||||
- `getDesktopSettings`, `setDesktopSettings`.
|
||||
- `showDesktopNotification`, `requestWindowAttention`, `clearWindowAttention`, `onWindowStateChanged`.
|
||||
|
||||
### Auto-update
|
||||
|
||||
- `getAutoUpdateState`, `getAutoUpdateServerHealth`, `configureAutoUpdateContext`, `checkForAppUpdates`, `restartToApplyUpdate`, `onAutoUpdateStateChanged`.
|
||||
|
||||
### Local API & docs
|
||||
|
||||
- `getLocalApiStatus`, `openLocalApiDocs`, `openDocusaurusDocs`. The Local API server hosts the prebuilt Docusaurus bundle inside the desktop app — see `electron/api/local-api-server.ts`.
|
||||
|
||||
### App & deep links
|
||||
|
||||
- `relaunchApp`, `consumePendingDeepLink`, `onDeepLinkReceived`, `getAppDataPath`, `openCurrentDataFolder`.
|
||||
|
||||
### Data management
|
||||
|
||||
- `exportUserData`, `importUserData`, `eraseUserData`. Backed by `electron/data-archive.ts`.
|
||||
|
||||
### Clipboard & context menu
|
||||
|
||||
- `copyImageToClipboard`, `onContextMenu`, `contextMenuCommand`.
|
||||
|
||||
### Idle state
|
||||
|
||||
- `getIdleState`, `onIdleStateChanged`. Backed by `electron/idle/`.
|
||||
|
||||
### CQRS (typed database access)
|
||||
|
||||
- `command<T>(command: Command) => Promise<T>` → channel `cqrs:command`.
|
||||
- `query<T>(query: Query) => Promise<T>` → channel `cqrs:query`.
|
||||
- Command and query union types live in `electron/cqrs/types.ts`. Handlers are built dynamically per `DataSource` via `buildCommandHandlers(dataSource)` and `buildQueryHandlers(dataSource)` in `electron/ipc/cqrs.ts`.
|
||||
- Current commands: `SaveMessage`, `DeleteMessage`, `UpdateMessage`, `ClearRoomMessages`, `SaveReaction`, `RemoveReaction`, `SaveUser`, `SetCurrentUserId`, `UpdateUser`, `SaveRoom`, `DeleteRoom`, `UpdateRoom`, `SaveBan`, `RemoveBan`, `SaveAttachment`, `DeleteAttachmentsForMessage`, `SavePluginData`, `DeletePluginData`, `SaveMeta`, `ClearAllData`.
|
||||
- Current queries: `GetMessages`, `GetMessagesSince`, `GetMessageById`, `GetReactionsForMessage`, `GetUser`, `GetCurrentUser`, `GetCurrentUserId`, `GetUsersByRoom`, `GetRoom`, `GetAllRooms`, `GetBansForRoom`, `IsUserBanned`, `GetAttachmentsForMessage`, `GetAllAttachments`, `GetPluginData`, `GetMeta`.
|
||||
- Unknown `type` raises `Error("No command/query handler for type: ${type}")`.
|
||||
|
||||
---
|
||||
|
||||
## Renderer consumption
|
||||
|
||||
- **`ElectronBridgeService`** (`toju-app/src/app/core/platform/electron/electron-bridge.service.ts`) — provides `getApi(): ElectronApi | null` and `requireApi(): ElectronApi`. Domain services inject the bridge service, never `window` directly. This also makes the bridge mockable for spec runs and the website preview (where `window.electronAPI` is absent).
|
||||
- **CQRS wrapper**: `toju-app/src/app/infrastructure/persistence/electron-database.service.ts` wraps `api.command()` / `api.query()` with typed helpers; product-client domains use this rather than calling CQRS directly.
|
||||
- **Per-domain consumers**: file I/O (`attachment`), theme (`theme`), profile-avatar, notifications, idle (used by presence), and game-activity domains each inject the bridge to reach their respective IPC slice.
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
`electron/CONTEXT.md` says:
|
||||
|
||||
> IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
|
||||
|
||||
In practice today:
|
||||
|
||||
- The CQRS layer throws raw `Error` objects on unknown `type` (caller sees the serialized message).
|
||||
- Most `electron/ipc/system.ts` handlers catch errors and return structured response objects (e.g. `{ opened: false, reason: string }`), but the shape is per-handler, not centralised.
|
||||
- There is no global error-envelope wrapper around `ipcMain.handle`.
|
||||
|
||||
**TODO**: reconcile the CONTEXT.md invariant with reality — either introduce a shared error-envelope wrapper or update the invariant to match the per-handler convention. Until then, treat error shapes as a per-method concern.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
- **Preload**: `electron/preload.ts` (single source of truth for the exposed surface).
|
||||
- **Registration**: `electron/ipc/index.ts` calls three setup functions at app boot — `setupCqrsHandlers`, `setupSystemHandlers`, `setupWindowControlHandlers`.
|
||||
- **System handlers**: `electron/ipc/system.ts` (~40 channels, ~780 lines).
|
||||
- **Window controls**: `electron/ipc/window-controls.ts` (3 channels, fire-and-forget).
|
||||
- **CQRS handlers**: `electron/ipc/cqrs.ts` plus typed command/query unions in `electron/cqrs/types.ts` and per-handler implementations under `electron/cqrs/handlers/`.
|
||||
- **Local SQLite access** is gated behind CQRS — no other channel exposes the database directly. See `electron/data-source.ts` and `electron/entities/`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The renderer never imports `electron`, Node APIs, or TypeORM directly. (Enforced by Electron's `contextIsolation` + no `nodeIntegration`.)
|
||||
- Every method on `window.electronAPI` has exactly one IPC channel and exactly one main-process handler.
|
||||
- Schema mutations go through a TypeORM migration in `electron/migrations/`; raw SQL never crosses the IPC bridge.
|
||||
- All file access is path-based on the main side, URL-based on the renderer side (`getFileUrl`).
|
||||
|
||||
## Testing
|
||||
|
||||
- `electron/plugin-library.spec.ts` — plugin discovery (touches the same IPC path but tests the library, not the channel).
|
||||
- `electron/idle/idle-monitor.spec.ts` — idle source unit test.
|
||||
- **TODO**: no spec covers `preload.ts` exposure, the system handler set, the CQRS dispatcher, or the error path. Renderer-side `ElectronBridgeService` spec status not verified.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- `contextIsolation: true` + no `nodeIntegration` in the renderer; `electron/preload.ts` is the only crossing.
|
||||
- Adding a channel requires touching both `preload.ts` and `electron/ipc/`. There is no dynamic channel registration.
|
||||
- File-system handlers should validate paths against user-data scope — TODO: audit `system.ts` for path-traversal protections beyond what the plugin loader does.
|
||||
- Deep-link handling: `consumePendingDeepLink` returns a queued URL; validation lives in renderer routing. TODO: confirm allow-list / scheme filtering on the main side.
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- IPC traffic is per-call serialization; large payloads (file chunks, attachment imports) go via `readFileChunk` + offsets instead of single `readFile` to avoid blocking the main process.
|
||||
- CQRS calls hit the local SQLite database synchronously inside the main process. There is no batching layer.
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **Documented vs. actual API name** — the `window` global is `electronAPI`, not `api`. CONTEXT.md uses `window.api.*`. Reconcile in a future cleanup.
|
||||
- **No typed error envelope** despite the CONTEXT.md invariant.
|
||||
- **No preload-surface test** — additions are caught only at runtime / lint.
|
||||
|
||||
## Related features
|
||||
|
||||
- **[plugin-system](./plugin-system.md)** — surfaces `getLocalPluginsPath`, `listLocalPluginManifests`, and plugin data CQRS commands through this bridge.
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — the realtime path that bypasses the bridge; included here only to delineate the two surfaces.
|
||||
- **[voice-signaling](./voice-signaling.md)** — uses `getSources` and the Linux audio routing methods for screen-share media capture.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
195
agents-docs/features/messaging.md
Normal file
195
agents-docs/features/messaging.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Messaging
|
||||
|
||||
> **Area:** messaging
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
Messaging in Toju covers two transports that share a single domain model. Server-channel chat is broadcast by the signaling server over WebSocket — fire-and-forget, no server-side persistence today. Direct messages (1:1 and group DMs) are peer-to-peer over the WebRTC chat data channel, with a signaling-server fallback when no data channel is open and an offline queue for when neither path is available. On both transports the client maintains a **monotonic delivery state machine** per message and a **chunked inventory-sync protocol** that lets two peers reconcile missing history without flooding the link.
|
||||
|
||||
This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage decisions. The product-client domain READMEs at `toju-app/src/app/domains/chat/README.md` and `toju-app/src/app/domains/direct-message/README.md` cover internal NgRx state and effects; the wire shapes live in [websocket-envelopes](./websocket-envelopes.md).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Send, edit, and delete server-channel chat messages over WebSocket (`chat_message`, `edit_message`, `delete_message`).
|
||||
- Send, edit, and delete direct messages over the WebRTC data channel with signaling fallback.
|
||||
- Carry typing indicators on server-channel chat (`user_typing`).
|
||||
- Reconcile peer history via the inventory-sync protocol (chunked, capped backfill).
|
||||
- Drive a monotonic delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`.
|
||||
- Persist direct messages locally; the server keeps no message store.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- Attachment payloads or the chunked file-transfer protocol → [attachments](./attachments.md).
|
||||
- RTC negotiation that brings the data channel up → [voice-signaling](./voice-signaling.md).
|
||||
- Permission to send a message (`writeMessages`, `manageMessages`, channel overrides, slowmode hint) → [access-control](./access-control.md).
|
||||
- The wire shape of every envelope used here → [websocket-envelopes](./websocket-envelopes.md).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Server-channel message** — broadcast through the signaling server to every connected peer of a server. No server-side persistence in the current build.
|
||||
- **Direct message** — point-to-point or group P2P message, persisted locally per-user via Electron CQRS (and via `localStorage` on browser fallback today — TODO confirm).
|
||||
- **Conversation** — 1:1 or group DM thread. Group DMs can be created by adding a third participant to a 1:1, which spawns a new conversation while preserving the original.
|
||||
- **Inventory event** — peer-to-peer announcement of "here is what I have for this conversation/channel"; the receiver replies with a request for missing pieces.
|
||||
- **Sync batch** — chunked response carrying up to 200 messages per envelope, capped at 1000 messages of backfill per inventory exchange.
|
||||
- **Delivery state** — monotonic enum on a direct message: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Defined in the chat-events shared kernel; advanced via `advanceDirectMessageStatus`.
|
||||
- **Peer-delivery service** — `PeerDeliveryService` (`toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts`) — the dispatcher that tries the data channel first, then the signaling forward, then the offline queue.
|
||||
|
||||
---
|
||||
|
||||
## Transports
|
||||
|
||||
### Server-channel chat (WebSocket)
|
||||
|
||||
- Client sends `chat_message` to the signaling server; `handleChatMessage` (`server/src/websocket/handler.ts:274`) validates the user is in the target server and broadcasts to every other connection.
|
||||
- `edit_message` and `delete_message` follow the same fan-out path.
|
||||
- `user_typing` (`handleTyping`, `handler.ts:309`) is broadcast as a transient signal — no persistence, no sync, no delivery state.
|
||||
- The server **does not persist** these envelopes. Late joiners do not see chat history older than their join — they can request it from peers via the inventory protocol if any peer present has it stored locally.
|
||||
|
||||
### Direct messages (WebRTC data channel)
|
||||
|
||||
- DMs ride the `chat`-labelled data channel established alongside each voice peer connection (see [voice-signaling](./voice-signaling.md)).
|
||||
- `PeerDeliveryService` is the dispatcher. For each outgoing event it:
|
||||
1. Tries every open data channel to a `recipients`-listed peer.
|
||||
2. If no data channel is available to a recipient, falls back to a signaling-server `forwardPeerMessage` envelope so the server forwards it to that peer's connection.
|
||||
3. If neither path is open, enqueues the event in `OfflineMessageQueueService` and replays on `peerConnected$` / `networkRestored$`.
|
||||
- The server **forwards** DM envelopes opaquely — no inspection, no persistence.
|
||||
|
||||
### Storage
|
||||
|
||||
- **Direct messages** persist via Electron CQRS — see [ipc-bridge](./ipc-bridge.md). Each user has their own local TypeORM database; messages are written via `save-direct-message` / equivalent commands.
|
||||
- **Server-channel chat** persists via `DatabaseService` (Electron CQRS) when running on desktop, or in IndexedDB when running purely in browser. TODO: confirm the IndexedDB code path.
|
||||
- The signaling server holds **zero** message bytes at rest today. Re-deploys lose nothing because there is nothing to lose.
|
||||
|
||||
---
|
||||
|
||||
## Inventory / sync protocol
|
||||
|
||||
Both transports share the same inventory shape, defined in `toju-app/src/app/shared-kernel/chat-events.ts`:
|
||||
|
||||
- `ChatInventoryEvent` — sender broadcasts "for conversation X I have messages with these ids and last-modified timestamps" (capped at 1000 entries).
|
||||
- `ChatSyncBatchEvent` — receiver replies with a chunked batch of full message payloads, **up to 200 per envelope**, repeated until the requested set is satisfied or 1000 messages have been returned.
|
||||
|
||||
Rules:
|
||||
|
||||
- Inventory is **additive** — `mergeIncomingMessage` / `upsertDirectMessage` only insert or update; a sparser peer never wipes a richer peer's history.
|
||||
- Reactions and attachment-link changes are reconciled by comparing per-message `lastModifiedAt`; the higher wins.
|
||||
- The 1000-message ceiling is per inventory exchange, not per conversation lifetime; an older history can be filled in piecewise across multiple inventory cycles.
|
||||
|
||||
The same protocol is reused for the chat domain (server channels) and the direct-message domain. The implementation lives in `toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts` and is invoked from the direct-message effects via `DirectMessageService.requestSync()`.
|
||||
|
||||
---
|
||||
|
||||
## Delivery state machine
|
||||
|
||||
For DMs, every outgoing message has a `status`:
|
||||
|
||||
| Value | Numeric | Meaning |
|
||||
|-------|---------|---------|
|
||||
| `QUEUED` | 0 | Composed locally; no transport attempt has succeeded yet. |
|
||||
| `SENT` | 1 | At least one transport (data channel or signaling forward) has accepted the payload. |
|
||||
| `DELIVERED` | 2 | At least one recipient has acknowledged receipt at the application layer. |
|
||||
| `ACKNOWLEDGED` | 3 | The full recipient set has acknowledged (1:1: the one recipient; group: every participant). |
|
||||
|
||||
Transitions are advanced via `advanceDirectMessageStatus`, which **only advances** — a higher value is never replaced by a lower one. A retried message that succeeds after a queue replay can therefore move `QUEUED → SENT` but never `DELIVERED → SENT`.
|
||||
|
||||
Server-channel messages do not carry an application-level delivery state today (the server broadcast is fire-and-forget); the UI treats them as `SENT` once the WebSocket accepts the frame.
|
||||
|
||||
---
|
||||
|
||||
## Edit and delete
|
||||
|
||||
- `edit-message` / `delete-message` events carry the original `messageId`. On both transports, the receiver locates the existing row (by id) and applies the mutation via `applyMutation`.
|
||||
- DMs use the same envelope types but ride the data channel / signaling-forward fabric.
|
||||
- Edits are last-writer-wins by `editedAt`. A delete removes the message body but keeps a tombstone with `deletedAt` so peers that haven't yet seen the delete can converge on the next inventory sync.
|
||||
|
||||
TODO: `applyMutation` does not currently verify the mutation originated from the original author. A non-cooperating client could send `edit-message` for someone else's message and a receiver would accept it. Confirm and either harden client-side or document the trust model.
|
||||
|
||||
---
|
||||
|
||||
## Typing indicators
|
||||
|
||||
- Server-channel only. DMs do not have a typing indicator today.
|
||||
- Sent as `user_typing`; broadcast to a server scope.
|
||||
- Transient: no persistence, no sync replay, no delivery state.
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- The signaling server is **not authoritative** for messaging. `handleChatMessage` only broadcasts; there is no server-side message log.
|
||||
- The delivery state machine is **monotonic** — `advanceDirectMessageStatus` never moves status backwards.
|
||||
- DM envelopes are **ignored** unless the local user appears in `participants` / `recipients` (or has an existing local conversation matching the id).
|
||||
- Inventory merges are **additive** — `mergeIncomingMessage` / `upsertDirectMessage` never delete or downgrade a richer local row.
|
||||
- A 1:1 → group upgrade **preserves** the original 1:1 history; the group is a new conversation.
|
||||
- Edits / deletes are reconciled by `lastModifiedAt` / `editedAt` / `deletedAt`.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
### Server
|
||||
- WS handlers — `server/src/websocket/handler.ts`: `handleChatMessage` (line 274), `handleTyping` (309); DM forwarding via `forwardPeerMessage` / `forwardRtcMessage`.
|
||||
- No CQRS, no entities, no migrations: server messaging is broadcast-only.
|
||||
|
||||
### Product client
|
||||
- Chat domain — `toju-app/src/app/domains/chat/`: services, effects, sync rules at `domain/rules/message-sync.rules.ts`.
|
||||
- Direct-message domain — `toju-app/src/app/domains/direct-message/`: `DirectMessageService`, `PeerDeliveryService` (`application/services/peer-delivery.service.ts`), offline queue.
|
||||
- Shared kernel — `toju-app/src/app/shared-kernel/chat-events.ts` (`ChatInventoryEvent`, `ChatSyncBatchEvent`, `chat_message`, `edit-message`, `delete-message`, `direct-message-sync`, `direct-message-sync-request`).
|
||||
- Persistence — Electron CQRS commands for DMs (see [ipc-bridge](./ipc-bridge.md)); `DatabaseService` for server-channel chat.
|
||||
|
||||
### Electron
|
||||
- DM persistence — TypeORM entity (likely `MessageEntity` and a DM-specific row) + CQRS handlers. Backup / restore is part of the Electron data-management surface.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- TODO: chat domain has zero `*.spec.ts` files at time of writing.
|
||||
- TODO: no dedicated server-side spec for `handleChatMessage`, `handleTyping`, or `forwardRtcMessage`.
|
||||
- TODO: confirm specs for `PeerDeliveryService` and `OfflineMessageQueueService`.
|
||||
- E2E: `e2e/tests/chat/chat-message-features.spec.ts` covers happy-path chat and attachment sync between users.
|
||||
|
||||
---
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- Inventory batch cap: **200 messages per envelope**.
|
||||
- Inventory backfill cap: **1000 messages per inventory exchange**.
|
||||
- `chat_message` broadcast is O(N) over connected peers of the server; no fan-out batching.
|
||||
- DMs incur O(recipients) data-channel writes (or signaling forwards) per send; large group DMs amplify per-message cost linearly.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **No end-to-end encryption.** P2P traffic over the data channel is DTLS-encrypted by WebRTC; signaling-forwarded fallback is plain WebSocket; either way the local TypeORM database stores plaintext.
|
||||
- **`applyMutation` does not verify authorship** on incoming `edit-message` / `delete-message` events. TODO above.
|
||||
- **No server-side rate limiting** on `chat_message`. A non-cooperating client can flood a server's broadcast.
|
||||
|
||||
---
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **No server-side chat history.** Late joiners depend on peers having local history to replay via inventory sync.
|
||||
- **No spec coverage** for the chat domain.
|
||||
- **DM authorship is not verified** by `applyMutation`.
|
||||
- **No DM typing indicator.**
|
||||
- **`OfflineMessageQueueService` retry policy** is currently driven by `peerConnected$` / `networkRestored$` events only — there is no scheduled retry; a stuck queue requires one of those events to fire. TODO: confirm behavior across reconnects.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope here.
|
||||
- **[attachments](./attachments.md)** — file payloads ride alongside chat events on the data channel.
|
||||
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel DMs ride on.
|
||||
- **[ipc-bridge](./ipc-bridge.md)** — exposes the CQRS persistence DMs and server chat use.
|
||||
- **[access-control](./access-control.md)** — gates write permissions and slowmode.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
180
agents-docs/features/plugin-system.md
Normal file
180
agents-docs/features/plugin-system.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Plugin System
|
||||
|
||||
> **Area:** plugin-system
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
Plugins extend Toju's renderer with bundled or local ES modules that can publish events into a server, register UI contributions (pages, panels, actions, channel sections, embed renderers), store per-user or per-server data, and exchange messages over P2P. They are described by a typed manifest, gated by an explicit capability grant model, and coordinated across three subdomains: the Electron plugin loader (`electron/plugin-library.ts`), the renderer plugin runtime (`toju-app/src/app/domains/plugins/`), and the server's plugin-support surface (`server/src/routes/plugin-support.ts`). This area documents the contract those three sides share.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Define the canonical plugin manifest shape (`TojuPluginManifest`) and its semantic versioning, dependency, and capability requirements.
|
||||
- Discover local plugin manifests from disk in Electron and surface them to the renderer.
|
||||
- Load plugin modules — local file://, http(s)://, or bundled — into the renderer and run their lifecycle hooks.
|
||||
- Gate every host API call by an explicit capability grant.
|
||||
- Persist server-scoped plugin requirements and event definitions on the signaling server, broadcasting changes to connected clients.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- The wire format of `plugin_event` envelopes themselves — those live with [websocket-envelopes](./websocket-envelopes.md); this area defines the **validation rules** applied to them.
|
||||
- IPC plumbing for plugin manifest discovery — that's the [ipc-bridge](./ipc-bridge.md) surface.
|
||||
- Per-plugin business logic — that lives in the plugin's own code.
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Manifest** — `TojuPluginManifest` (`toju-app/src/app/shared-kernel/plugin-system.contracts.ts`). Required: `id`, `title`, `description`, `version`, `apiVersion`, `kind`, `schemaVersion` (fixed `1`), `compatibility.minimumTojuVersion`. Optional: `entrypoint`, `bundle`, `capabilities[]`, `events[]`, `data[]`, `ui`, `settings`, `relationships`, `authors`, `pluginUser`, `scope`, `homepage`, `changelog`, `license`, `readme`, `load.priority`.
|
||||
- **Capability** — a string ID (e.g. `messages.send`, `events.server.publish`, `ui.pages`, `storage.local`). Plugins declare what they need in `capabilities[]`; the host enforces grants per plugin.
|
||||
- **Plugin event** — a declared `{ eventName, direction, scope, schema?, maxPayloadBytes? }` tuple. `direction` is `clientToServer | serverRelay | p2pHint`; `scope` is `server | channel | user | plugin`.
|
||||
- **Capability grant** — an entry in `metoyou_plugin_capability_grants` (localStorage + desktop file) recording user consent for a `(pluginId, capability)` pair.
|
||||
- **Activation context** — `TojuPluginActivationContext` — what a plugin module receives in its `activate(context)` hook: `pluginId`, `manifest`, `api` (the capability-gated `TojuClientPluginApi`), and a `subscriptions[]` cleanup list.
|
||||
- **Local plugin** — a folder under `${app.getPath('userData')}/plugins/<id>/` containing `toju-plugin.json` (preferred) or `plugin.json` and resolved relative assets.
|
||||
|
||||
---
|
||||
|
||||
## Manifest contract
|
||||
|
||||
Declared in `toju-app/src/app/shared-kernel/plugin-system.contracts.ts` (the source of truth on the renderer side; the Electron loader treats the manifest as `unknown` until the renderer validates it).
|
||||
|
||||
Highlights:
|
||||
|
||||
- **`schemaVersion: 1`** — fixed; bump only with a coordinated migration.
|
||||
- **`apiVersion`** — declares which host API surface the plugin expects.
|
||||
- **`kind`** — distinguishes plugin types (currently a string union; see file for exact members).
|
||||
- **`bundle.url`** + optional `bundle.entrypointUrl` — for remote / store-installed plugins.
|
||||
- **`entrypoint`** — relative path within the local plugin folder; rejected if it escapes `pluginRoot`.
|
||||
- **`relationships.{requires,optional,conflicts,before,after}`** — `pluginId` + optional `versionRange`. Resolved at activation; missing required dependencies block activation.
|
||||
- **`events[]`** — registered with the server via `plugin-support` when the plugin is required by a server. The server uses these definitions to validate inbound `plugin_event` envelopes.
|
||||
- **`data[]`** — `{ key, scope: server|channel|user|plugin, storage: local|serverData, schema? }`.
|
||||
- **`load.priority`** — `bootstrap | high | default | low`; controls ordering when several plugins are activated together.
|
||||
- **`pluginUser`** — synthetic user identity for messages a plugin posts on its own behalf.
|
||||
|
||||
---
|
||||
|
||||
## Discovery & loading
|
||||
|
||||
### Local discovery (Electron main)
|
||||
|
||||
`electron/plugin-library.ts`:
|
||||
|
||||
- Scans `${app.getPath('userData')}/plugins/` **one level deep** for plugin folders.
|
||||
- For each folder, reads `toju-plugin.json` (preferred) or `plugin.json` as raw JSON. No schema validation at this layer.
|
||||
- Resolves relative paths (`entrypoint`, `readme`) against the plugin root, rejecting `..`, absolute paths, or anything that escapes the root via `isPathInside()` realpath check.
|
||||
- Returns `LocalPluginDiscoveryResult { plugins, errors, pluginsPath }` where each `plugins[i]` is a `LocalPluginManifestDescriptor` carrying the raw manifest, manifest path, plugin root, plugin-root `file://` URL, optional entrypoint and readme paths, and a `discoveredAt` timestamp.
|
||||
|
||||
Exposed to the renderer through the [ipc-bridge](./ipc-bridge.md) as `listLocalPluginManifests` and `getLocalPluginsPath`.
|
||||
|
||||
### Renderer runtime
|
||||
|
||||
`toju-app/src/app/domains/plugins/`:
|
||||
|
||||
- **`PluginHostService`** — orchestrates lifecycle: `discoverLocalPlugins`, `registerLocalManifest`, `activate`, `deactivate`, `reload`, `loadPluginModule`.
|
||||
- **`PluginClientApiService`** — constructs the capability-gated facade `TojuClientPluginApi` per plugin. Subsystems: `channels`, `events`, `messages`, `messageBus`, `p2p`, `profile`, `users`, `roles`, `server`, `attachments`, `media`, `storage`, `serverData`, `clientData`, `ui`, `logger`, `context`.
|
||||
- **`PluginCapabilityService`** — `grant(pluginId, capability)`, `revoke()`, `grantAll(manifest)`, `assert(pluginId, capability)`. Storage in `metoyou_plugin_capability_grants` (localStorage + desktop file).
|
||||
- **`PluginMessageBusService`** — plugin-scoped pub/sub with topic, optional channel/peer targeting, optional message replay.
|
||||
- **`PluginStorageService`** — split storage paths for `local` and `serverData` scopes.
|
||||
- **`PluginUiRegistryService`** — central registry of UI contributions consumed by `plugin-render-host`, `plugin-page-host`, `plugin-action-menu`.
|
||||
- **`PluginRequirementStateService`**, **`PluginDesktopStateService`** — state slices.
|
||||
|
||||
### Module loading
|
||||
|
||||
- Entrypoint URL is `file://` for local plugins, `http(s)://` for remote, or the `bundle.url` for bundled.
|
||||
- Bytes are fetched, wrapped in a `Blob`-backed object URL, then imported via dynamic `import()` so devtools and stack traces resolve.
|
||||
- `GuardedPluginMutationObserver` wraps observer callbacks to catch plugin errors and break infinite redispatch loops.
|
||||
|
||||
### Lifecycle states
|
||||
|
||||
From `plugin-runtime.models.ts`: `discovered → validated → blocked | loading → ready → loaded → failed | unloading → unloaded → disabled`.
|
||||
|
||||
### Module contract
|
||||
|
||||
```ts
|
||||
export interface TojuClientPluginModule {
|
||||
activate?(context: TojuPluginActivationContext): void | Promise<void>;
|
||||
deactivate?(context: TojuPluginActivationContext): void | Promise<void>;
|
||||
ready?(context: TojuPluginActivationContext): void | Promise<void>;
|
||||
onServerRequirementsChanged?(context, snapshot): void | Promise<void>;
|
||||
onPluginDataChanged?(context, event): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server surface (`server/src/routes/plugin-support.ts`)
|
||||
|
||||
Each endpoint scoped to `:serverId`. Permission `manageServer` enforced for mutations via `server-permissions.service.ts`.
|
||||
|
||||
- `GET /:serverId/plugins` → `PluginRequirementsSnapshot` — full set of required/optional plugins + event definitions for the server.
|
||||
- `PUT /:serverId/plugins/:pluginId/requirement` — upsert a requirement: `status: required | optional | recommended | blocked | incompatible`, `installUrl?`, `sourceUrl?`, `manifest?`, `versionRange?`.
|
||||
- `DELETE /:serverId/plugins/:pluginId/requirement` — remove.
|
||||
- `PUT /:serverId/plugins/:pluginId/events/:eventName` — register or update an event definition (`direction`, `scope`, `schema?`, `maxPayloadBytes?`).
|
||||
- `DELETE /:serverId/plugins/:pluginId/events/:eventName` — delete.
|
||||
- `GET|PUT|DELETE /:serverId/plugins/:pluginId/data/:key` — **disabled** on the signaling server (returns HTTP 410). Plugin data on the server is intentionally out of scope.
|
||||
|
||||
Identifier patterns:
|
||||
|
||||
- `pluginId`: `/^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/`
|
||||
- `eventName`: `/^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/`
|
||||
|
||||
Changes broadcast to connected clients via the [websocket-envelopes](./websocket-envelopes.md) — the matching `PluginRequirementsSnapshot` is also delivered as part of the `join_server` and `view_server` responses.
|
||||
|
||||
---
|
||||
|
||||
## Plugin event validation
|
||||
|
||||
`server/src/services/plugin-support.service.ts` exposes `validatePluginEventEnvelope()`. The `plugin_event` envelope handler (`server/src/websocket/handler.ts`) calls it before broadcasting. Validation checks:
|
||||
|
||||
- `eventName` is registered for `pluginId` on `serverId`.
|
||||
- `direction` permits the source (clientToServer vs p2pHint vs serverRelay).
|
||||
- `payload` size ≤ `maxPayloadBytes` (default 64 KB).
|
||||
- If `schema` was declared in the manifest, the payload conforms — TODO: confirm the schema dialect (looks like JSON Schema subset).
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Plugins run in the renderer's JS context.** There is no true sandbox: plugins have DOM access, can read/write `localStorage`, and can issue `fetch` requests subject to the renderer's CSP.
|
||||
- **Capability model is the primary security boundary.** Every method on `TojuClientPluginApi` calls `PluginCapabilityService.assert(pluginId, capability)`. Missing grant → `PluginCapabilityError`.
|
||||
- **Path traversal** in the local plugin loader is blocked by `isPathInside()` realpath checks (`electron/plugin-library.ts`).
|
||||
- **Payload bounds** on plugin events: default 64 KB, configurable per event definition.
|
||||
- **No code signing or integrity verification.** Plugins are trusted to the extent the user granted capabilities. The plugin store flow is documented in `docs-site/docs/plugin-development/`.
|
||||
- **TODO**: review whether `bundle.url` fetches go through a CSP / allow-list or are unbounded.
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Local plugins path**: `${app.getPath('userData')}/plugins/` (Electron). Exposed as `getLocalPluginsPath`.
|
||||
- **Capability grants**: `metoyou_plugin_capability_grants` in localStorage; mirrored to a desktop file via `PluginDesktopStateService`.
|
||||
- **Server-side persistence**: plugin requirements and event definitions are stored in the server's database; entities and migrations live alongside other server entities (TODO: enumerate the specific entities).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Electron**: `electron/plugin-library.spec.ts` — covers valid/invalid JSON, path traversal rejection, escaping entrypoints. Uses fixture `TEST_PLUGIN_FIXTURE_DIR`.
|
||||
- **Renderer**: `plugin-host.service.spec.ts`, `plugin-store.service.spec.ts`, `local-plugin-discovery.service.spec.ts`.
|
||||
- **Server**: `server/src/websocket/handler-plugin.spec.ts` — `plugin_event` validation flow.
|
||||
- **E2E**: `e2e/tests/.../plugin-support-api.spec.ts`, `plugin-manager-ui.spec.ts`, `plugin-api-two-users.spec.ts`.
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **Server-side plugin data is intentionally disabled** (`410 Gone` on `*/data/:key` routes). Plugins must currently treat `serverData` storage as not yet implemented on the signaling server. TODO: clarify whether this is a permanent boundary or scheduled work.
|
||||
- **No true sandbox** for plugin execution. The capability model is the only restraint between a plugin and the renderer's globals.
|
||||
- **Manifest validation is renderer-side.** The Electron loader treats manifests as raw JSON; malformed or hostile manifests are caught when the renderer registers them.
|
||||
- **No host-side plugin allow-list** beyond per-server requirements — a user can install any local plugin.
|
||||
|
||||
## Related features
|
||||
|
||||
- **[ipc-bridge](./ipc-bridge.md)** — surfaces `listLocalPluginManifests`, `getLocalPluginsPath`, and the plugin CQRS commands (`SavePluginData`, `DeletePluginData`, `GetPluginData`).
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — defines the `plugin_event` envelope this area validates.
|
||||
- **[server-directory](./server-directory.md)** — `join_server` / `view_server` responses include `PluginRequirementsSnapshot` for the joined server.
|
||||
|
||||
## Documentation for plugin authors
|
||||
|
||||
Author-facing docs ship with the desktop app via Docusaurus:
|
||||
|
||||
- `docs-site/docs/plugin-development/create-a-plugin.md`
|
||||
- `docs-site/docs/user-guide/plugins.md`
|
||||
- `docs-site/docs/developer/llm-plugin-builder-guide.md`
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
196
agents-docs/features/presence.md
Normal file
196
agents-docs/features/presence.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Presence
|
||||
|
||||
> **Area:** presence
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
Presence in Toju covers everything that signals "who is here, where, and doing what" — connection lifecycle (`join_server`, `leave_server`), availability status (`online / away / busy / offline`), profile metadata propagation, voice-room membership, and the current-game indicator. The signaling server **forwards and deduplicates** presence over WebSocket but never persists it: every restart of the server resets all presence state to nothing, and every reconnect of a client re-derives the world from scratch via a `server_users` snapshot.
|
||||
|
||||
Several signals contribute. Status comes from idle detection in the renderer (Electron `powerMonitor` or browser fallback) and from explicit user choices. Voice membership is **client-derived** from observed `voice_state` broadcasts — the server never tracks who is in a voice room. Game activity is scanned in the renderer using Electron-IPC process inspection, resolved via the server's RAWG-backed `/games/match` API, and broadcast directly to peers over the WebRTC data channel — it never touches the signaling server.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Track which users are connected to which server and broadcast joins/leaves with multi-device dedup.
|
||||
- Carry profile metadata (`displayName`, `description`, `profileUpdatedAt`) so peers can render rich identity without a separate lookup.
|
||||
- Propagate availability status (`online / away / busy / offline`) as users choose, idle, or wake.
|
||||
- Surface voice-room membership to peers via the `voice_state` envelope.
|
||||
- Surface current-game activity to peers via the chat data channel.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- The WebSocket envelope shape — see [websocket-envelopes](./websocket-envelopes.md).
|
||||
- RTC negotiation, peer connection lifecycle, RNNoise — see [voice-signaling](./voice-signaling.md).
|
||||
- Server-side account records, credentials, or the `identify` handshake's authentication semantics — see [authentication](./authentication.md).
|
||||
- Authorization for joining a server (membership / bans / invites) — see [access-control](./access-control.md).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **`oderId`** — client-asserted identity. The deduplication key across multiple sockets and devices.
|
||||
- **`connectionScope`** — opaque string (typically the signal URL). Used together with `oderId` to coexist multi-URL sockets without an eviction loop.
|
||||
- **Status** — `'online' | 'away' | 'busy' | 'offline'`. `VALID_STATUSES` lives in `server/src/websocket/handler.ts`. Client maps incoming `offline` to a local `disconnected` rendering tag.
|
||||
- **Manual vs automatic status** — manual user choices override the idle-detector.
|
||||
- **Voice presence** — derived client-side from `voice_state` broadcasts; the signaling server has no voice-room model.
|
||||
- **Game activity** — currently-detected game; resolved via `/games/match` and announced to peers over the data channel as a `game-activity` chat event.
|
||||
- **`ConnectedUser`** — server-side per-connection row (`server/src/websocket/types.ts`); see [authentication](./authentication.md) for the full shape.
|
||||
|
||||
---
|
||||
|
||||
## Envelopes (consumed and emitted)
|
||||
|
||||
Schemas live in [websocket-envelopes](./websocket-envelopes.md). Presence-relevant types:
|
||||
|
||||
- `identify` — first envelope a client sends; updates profile metadata. Re-broadcasts `user_joined` per joined server when profile fields change.
|
||||
- `join_server` / `leave_server` — connection joins/leaves a specific server scope; gated by `authorizeWebSocketJoin` (see [access-control](./access-control.md)).
|
||||
- `server_users` — full peer roster sent to a connection when it joins a server; the only "snapshot" envelope.
|
||||
- `user_joined` — broadcast to peers on a server when a new *identity* arrives (i.e. no other connection of the same `oderId` already had this server).
|
||||
- `user_left` — broadcast to peers when an identity fully releases a server; payload includes `serverIds` listing the servers the user is still in elsewhere.
|
||||
- `status_update` — availability status change (`online / away / busy / offline`).
|
||||
- `voice_state` — broadcast to a server when the user enters/leaves voice or toggles mute/deafen. Carries `roomId`, `voiceGateway`, mute/deafen flags. Voice membership is reconstructed from these events client-side.
|
||||
- `keepalive` — bumps `lastPong` on the server to keep the connection from being reaped.
|
||||
- `access_denied` — server response when authorization for `join_server` fails.
|
||||
|
||||
Game activity events ride the WebRTC **chat data channel** as `game-activity` chat events — not as WebSocket envelopes.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
A connection moves through these stages:
|
||||
|
||||
1. **WebSocket connect** — server allocates a `ConnectedUser` row keyed by `connectionId`.
|
||||
2. **`identify`** — client claims `oderId`, profile metadata, and an optional `connectionScope`. See [authentication](./authentication.md) for the handshake details.
|
||||
3. **`join_server`** — gated by `authorizeWebSocketJoin`. On success, the server adds the server to `user.serverIds`, sends a private `server_users` snapshot to the joiner, and broadcasts `user_joined` to other peers on that server *only if* this is a new identity for the server (multi-device dedup).
|
||||
4. **Steady state** — `status_update`, `voice_state`, profile-bearing `identify` updates, and chat / RTC envelopes flow. The server bumps `lastPong` on every inbound frame.
|
||||
5. **`leave_server` / disconnect** — the server removes the server from `user.serverIds`. `user_left` is broadcast to peers *only* once every connection of the same `oderId` has released the server. The payload's `serverIds` field reports which servers the identity is still in, so clients can distinguish "moved tabs" from "fully left."
|
||||
6. **Dead-connection sweep** — every `PING_INTERVAL_MS = 30 000` the server sweeps; any connection with `lastPong` older than `PONG_TIMEOUT_MS = 45 000` is closed and processed as a disconnect (`server/src/websocket/index.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Multi-device deduplication
|
||||
|
||||
`broadcastToServer` (`server/src/websocket/broadcast.ts`) deduplicates fan-out by `oderId`, so a user logged in on two devices sees each peer event once.
|
||||
|
||||
`handleJoinServer` (`handler.ts:155`) only emits `user_joined` when the join is a **new identity membership** — i.e. no other connection of the same `oderId` already held the server. Renaming a tab or opening a second window does not produce spurious join notifications.
|
||||
|
||||
Symmetrically, `handleLeaveServer` only emits `user_left` when no other connection of the same `oderId` still holds the server. The `serverIds` field on the payload lets clients see "the identity is still in these other servers" rather than treating a tab close as a full logout.
|
||||
|
||||
`connectionScope` keeps this stable across multi-signal-URL deployments: an identity that opens connections to two signal URLs is still one identity from the broadcast layer's perspective, but the per-scope stale-eviction does not loop.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Two writers update status:
|
||||
|
||||
- **Manual** — the user picks `online / away / busy / offline` from the UI. The chosen value is sent as a `status_update` envelope and persists locally.
|
||||
- **Automatic** — `UserStatusService` (`toju-app/src/app/core/services/user-status.service.ts`) listens to Electron `powerMonitor` events (suspend, resume, lock, unlock) when running on desktop, or falls back to a 15-minute idle timer in the browser. It emits `status_update` via `RealtimeSessionFacade.sendRawMessage`.
|
||||
|
||||
Manual overrides automatic for the session — explicit user input prevents the idle detector from overwriting `away` back to `online`.
|
||||
|
||||
Server-side, `handleStatusUpdate` (`handler.ts:337`) validates the value against `VALID_STATUSES`, mutates `user.status`, and broadcasts the event to every server the user is in.
|
||||
|
||||
---
|
||||
|
||||
## Game activity
|
||||
|
||||
`GameActivityService` (`toju-app/src/app/domains/game-activity/application/game-activity.service.ts`) is the renderer-side scanner:
|
||||
|
||||
- Polls every 5–60 seconds (default 10 s).
|
||||
- Asks Electron for active and running process names via the IPC bridge (`getActiveGameCandidate`, `getRunningProcessNames` — see [ipc-bridge](./ipc-bridge.md)).
|
||||
- Resolves candidates to RAWG metadata via `POST /games/match` on the signaling server.
|
||||
- Broadcasts the result to peers as a `game-activity` chat event on the WebRTC chat data channel.
|
||||
|
||||
The signaling server is **not** in the broadcast path for game activity — it only matches process names to RAWG entries. Once a peer connection exists, the game-activity envelope flows P2P.
|
||||
|
||||
---
|
||||
|
||||
## Business rules and invariants
|
||||
|
||||
- The signaling server **forwards** presence but **never persists** it. Server restart = full reset; clients rederive via `server_users`.
|
||||
- Presence is **per-connection**; identity is reconstructed at broadcast time by collapsing connections that share `oderId`.
|
||||
- `user_joined` is emitted **only** on a new-identity membership; `user_left` **only** on full release of a server by an `oderId`.
|
||||
- `identify` is the canonical channel for profile-metadata updates. Profile-bearing `identify` envelopes rebroadcast `user_joined` only if at least one of `displayName` / `description` / `profileUpdatedAt` actually changed.
|
||||
- **Voice room membership is never server-tracked.** It is client-derived from `voice_state` broadcasts.
|
||||
- Manual status overrides automatic status for the session.
|
||||
- Reconnection resets all presence state; the joiner's first `server_users` snapshot is authoritative for that server.
|
||||
- Dead connections are reaped after 45 s of silence.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
### Server
|
||||
- WebSocket envelope handlers — `server/src/websocket/handler.ts`: `handleStatusUpdate` (line 337), `handleIdentify` (112), `handleJoinServer` (155), `handleLeaveServer` (224).
|
||||
- Broadcast / dedup — `server/src/websocket/broadcast.ts`: `broadcastToServer`, `sendServerUsers`.
|
||||
- Sweep / heartbeat — `server/src/websocket/index.ts` (`PING_INTERVAL_MS`, `PONG_TIMEOUT_MS`).
|
||||
- `ConnectedUser` row — `server/src/websocket/types.ts`.
|
||||
- Game-match HTTP — `server/src/routes/games.ts`, `server/src/services/game-matching.service.ts`.
|
||||
|
||||
### Product client
|
||||
- Status writer — `toju-app/src/app/core/services/user-status.service.ts`.
|
||||
- Game-activity scanner — `toju-app/src/app/domains/game-activity/application/game-activity.service.ts`.
|
||||
- Voice presence — `toju-app/src/app/domains/voice-session/` (see [voice-signaling](./voice-signaling.md)).
|
||||
- Presence sync into NgRx — `toju-app/src/app/store/rooms/room-state-sync.effects.ts`, `room-members-sync.effects.ts`.
|
||||
- Presence reducers — `toju-app/src/app/store/users/`.
|
||||
|
||||
### Electron
|
||||
- Process inspection IPC — `electron/preload.ts` (`getActiveGameCandidate`, `getRunningProcessNames`). See [ipc-bridge](./ipc-bridge.md).
|
||||
- `powerMonitor` events — wired in `electron/` and surfaced to renderer via IPC events.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- `server/src/websocket/handler-status.spec.ts` — status validation and broadcast.
|
||||
- `toju-app/src/app/store/users/users-status.reducer.spec.ts` — status reducer.
|
||||
- `toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts` — room-level status aggregation.
|
||||
- `toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts` — scanner + RAWG match.
|
||||
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — presence-relevant handling.
|
||||
- TODO: no spec for multi-device `user_left` suppression.
|
||||
- TODO: no spec for `identify`-triggered profile rebroadcast.
|
||||
- TODO: no spec for pong-timeout reaping.
|
||||
- TODO: no spec for client `offline → disconnected` rendering mapping.
|
||||
- TODO: no E2E for cross-device presence dedup.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- The `identify` claim is **not verified** — see [authentication](./authentication.md). Presence trust is inherited from that gap: a connection can claim any `oderId` and be broadcast as that user.
|
||||
- Game-activity scanning surfaces local process names to the signaling server (via `/games/match`) and to peers (via the data channel). This is privacy-sensitive — there is no per-user opt-out documented today; if a user does not want process names leaving their machine, they need a `closeToTraySetting`-style toggle. TODO: confirm the current opt-out path.
|
||||
- Status is self-asserted. `busy` does not enforce anything; it is purely a presence hint.
|
||||
|
||||
---
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- `broadcastToServer` is O(N) per envelope, where N is the number of connections subscribed to the server. There is no fan-out batching.
|
||||
- The 30 s ping cadence keeps idle connections cheap; the 45 s reaper keeps `connectedUsers` from leaking on TCP half-closes.
|
||||
- `GameActivityService` defaults to a 10 s poll; user-configurable, clamped 5–60 s.
|
||||
- `identify` rebroadcast is O(serversJoinedByThisConnection); negligible in practice.
|
||||
|
||||
---
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- No server-side voice-room membership model means the only way to enumerate a room's occupants is to replay `voice_state` events client-side.
|
||||
- Status idle detection in the browser falls back to a coarse 15-minute timer (no platform idle API).
|
||||
- Game-activity opt-out and granularity (per-game blocklist) are not centrally documented; the `gameIgnoreList` setting lives in `electron/desktop-settings.ts` but is undocumented in renderer UX terms — TODO.
|
||||
|
||||
---
|
||||
|
||||
## Related features
|
||||
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope this area uses.
|
||||
- **[voice-signaling](./voice-signaling.md)** — consumes the `voice_state` broadcasts to drive RTC mesh.
|
||||
- **[authentication](./authentication.md)** — owns the `identify` handshake and the heartbeat / reaping policy.
|
||||
- **[access-control](./access-control.md)** — gates `join_server`, which precedes any presence broadcast for that server.
|
||||
- **[ipc-bridge](./ipc-bridge.md)** — exposes `powerMonitor` events and process-list inspection used by status and game-activity.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
174
agents-docs/features/server-directory.md
Normal file
174
agents-docs/features/server-directory.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Server Directory
|
||||
|
||||
> **Area:** server-directory
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
The Server Directory is the public REST surface that lists joinable Toju chat servers, manages invites and join requests, gates membership (passwords, bans, ownership), and exposes moderation actions (kick / ban / unban). It is the only feature where the signaling server holds non-ephemeral, multi-user state: the persistent catalog of servers, their access rules, their memberships, and their pending join requests. The renderer's `server-directory` domain consumes this surface to render the "find a server" experience and to drive the join flow that eventually opens a WebSocket (see [websocket-envelopes](./websocket-envelopes.md)).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Persist the catalog of servers, their access policy (public/private, password, max users), and ownership.
|
||||
- Mint invites and accept invite redemptions.
|
||||
- Track join requests on private servers and route owner decisions back to the requester.
|
||||
- Track memberships and bans; enforce them on join attempts.
|
||||
- Provide moderation primitives: kick, ban, unban — gated by role/owner permissions.
|
||||
- Emit user-targeted notifications when a join request changes state.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- Realtime presence, chat, or voice — those flow over the WebSocket once a user has joined (see [websocket-envelopes](./websocket-envelopes.md), [voice-signaling](./voice-signaling.md)).
|
||||
- Per-channel permissions logic (lives in `server/src/services/server-permissions.service.ts` and is consumed by this area, but is reused beyond it).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Server** — a joinable chat server. Persisted as `ServerEntity` (`servers` table).
|
||||
- **Public / private** — `isPrivate` flag. Public servers appear in directory listings; private servers do not.
|
||||
- **Invite** — an opaque token (`ServerInviteEntity`) that grants short-lived access to a specific server. Expires after `SERVER_INVITE_EXPIRY_MS` (10 days).
|
||||
- **Join request** — a pending request on a private server (`JoinRequestEntity`), `pending → approved | denied`.
|
||||
- **Membership** — a `ServerMembershipEntity` row, indexed by `serverId` + `userId`.
|
||||
- **Ban** — a `ServerBanEntity` row, optionally `expiresAt`. Auto-pruned on the next join attempt for the banned user.
|
||||
- **Heartbeat** — periodic `POST /:id/heartbeat` from the server owner's client that updates `lastSeen` and `currentUsers` on the directory entry.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All HTTP routes; no auth header — caller identity is supplied per-request in the body (`ownerId`, `actorUserId`, `userId`, `requesterUserId`). Identity is whatever the client claims; authorization is enforced against persisted state. Request body validation is **manual / defensive** (no zod or class-validator).
|
||||
|
||||
### `server/src/routes/servers.ts`
|
||||
|
||||
| Method | Path | Purpose | Auth |
|
||||
|--------|------|---------|------|
|
||||
| `GET` | `/` | List public servers. Query: `q`, `tags`, `limit`, `offset`. | None |
|
||||
| `POST` | `/` | Create a server. Required body: `name`, `ownerId`, `ownerPublicKey`. | Self-asserted |
|
||||
| `GET` | `/:id` | Fetch a single server. 404 if missing. | None |
|
||||
| `PUT` | `/:id` | Update a server. Required body: `currentOwnerId`. Permission check via `canManageServerUpdate`. | Owner / role |
|
||||
| `POST` | `/:id/join` | Join a server. Required body: `userId`. Optional: `password`, `inviteId`. Returns `signalingUrl`. | Self-asserted + access rules |
|
||||
| `POST` | `/:id/invites` | Create an invite. Required body: `requesterUserId`. Delegates to `createServerInvite`. | Role permission |
|
||||
| `POST` | `/:id/moderation/kick` | Kick a user. Required: `actorUserId`, `targetUserId`. Permission: `canModerateServerMember`. | Role permission |
|
||||
| `POST` | `/:id/moderation/ban` | Ban a user. Required: `actorUserId`, `targetUserId`. Optional: `banId`, `reason`, `expiresAt`. | Role permission |
|
||||
| `POST` | `/:id/moderation/unban` | Unban a user. Required: `actorUserId`. Permission: `manageBans`. | Role permission |
|
||||
| `POST` | `/:id/leave` | Leave a server. Required body: `userId`. | Self-asserted |
|
||||
| `POST` | `/:id/heartbeat` | Update `lastSeen` and `currentUsers`. Optional body: `currentUsers`. | None (TODO: confirm) |
|
||||
| `DELETE` | `/:id` | Delete a server. Required body: `ownerId` (must match `server.ownerId`). | Owner |
|
||||
| `GET` | `/:id/requests` | List pending join requests. Query: `ownerId`. | Owner |
|
||||
|
||||
### `server/src/routes/invites.ts`
|
||||
|
||||
- `GET /invites/:id` (API) — fetch invite metadata; `404` for expired or unknown invite.
|
||||
- `GET /invites/:id` (page router) — server-rendered HTML preview of the invite (server info, owner, expiry); renders an offline state when the server is unreachable.
|
||||
|
||||
### `server/src/routes/join-requests.ts`
|
||||
|
||||
- `PUT /requests/:id` — update join-request status. Body: `ownerId`, `status`. Permission: `manageServer`. On success, calls `notifyUser` (WebSocket fan-out, see below).
|
||||
|
||||
### Standard error codes
|
||||
|
||||
`SERVER_NOT_FOUND`, `MISSING_USER`, `NOT_AUTHORIZED`, `BANNED`, `PASSWORD_REQUIRED`, `INVITE_EXPIRED`, plus 400 for missing required fields.
|
||||
|
||||
---
|
||||
|
||||
## CQRS handlers
|
||||
|
||||
`server/src/cqrs/` backs every mutation; routes are thin adapters around CQRS dispatch.
|
||||
|
||||
**Queries** (`server/src/cqrs/queries/handlers/`):
|
||||
|
||||
- `getAllPublicServers` — filtered by `isPrivate = 0`, loads relations.
|
||||
- `getServerById`
|
||||
- `getJoinRequestById`
|
||||
- `getPendingRequestsForServer`
|
||||
|
||||
**Commands** (`server/src/cqrs/commands/handlers/`):
|
||||
|
||||
- `upsertServer` — also calls `replaceServerRelations` to sync `tags`, `channels`, `roles`, `roleAssignments`, `channelPermissions` atomically.
|
||||
- `deleteServer`
|
||||
- `createJoinRequest`
|
||||
- `updateJoinRequestStatus` — emits a `notifyUser` event so the requesting user's client learns the outcome over WebSocket.
|
||||
|
||||
All handlers run inside TypeORM transactions where multi-table changes are involved.
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
### Entities (`server/src/entities/`)
|
||||
|
||||
- `ServerEntity` (table `servers`) — `id`, `name`, `description`, `ownerId`, `ownerPublicKey`, `passwordHash`, `isPrivate`, `maxUsers`, `currentUsers`, `icon`, `iconUpdatedAt`, `slowModeInterval`, `createdAt`, `lastSeen`.
|
||||
- `ServerInviteEntity` (`server_invites`) — `id`, `serverId` (indexed), `createdBy`, `createdByDisplayName`, `createdAt`, `expiresAt` (indexed).
|
||||
- `JoinRequestEntity` (`join_requests`) — `id`, `serverId` (indexed), `userId`, `userPublicKey`, `displayName`, `status` (default `pending`), `createdAt`.
|
||||
- `ServerMembershipEntity` (`server_memberships`) — `id`, `serverId` (indexed), `userId` (indexed), `joinedAt`, `lastAccessAt`.
|
||||
- `ServerBanEntity` (`server_bans`) — `id`, `serverId` (indexed), `userId` (indexed), `bannedBy`, `displayName`, `reason`, `expiresAt` (nullable), `createdAt`.
|
||||
|
||||
Related (referenced by `replaceServerRelations`): `ServerChannelEntity`, `ServerRoleEntity`, `ServerUserRoleEntity`, `ServerTagEntity`, `ServerChannelPermissionEntity`.
|
||||
|
||||
### Migrations (`server/src/migrations/`)
|
||||
|
||||
- `1000000000000-InitialSchema.ts` — `servers`, `users`.
|
||||
- `1000000000001-ServerAccessControl.ts` — adds `passwordHash` to `servers`; creates `server_memberships`, `server_invites`, `server_bans` with indices.
|
||||
- `1000000000002-ServerChannels.ts` — `server_channels`.
|
||||
- `1000000000005-ServerRoleAccessControl.ts` — role/permission tables.
|
||||
- TODO: locate the migration that created `join_requests` (not obvious from filenames; likely folded into an earlier migration).
|
||||
|
||||
---
|
||||
|
||||
## Renderer side
|
||||
|
||||
`toju-app/src/app/domains/server-directory/`:
|
||||
|
||||
- **API client**: `infrastructure/services/server-directory-api.service.ts` — `ServerDirectoryApiService` exposes `searchServers`, `getServers`, `getServer`, `findServerAcrossActiveEndpoints`, `registerServer`, `updateServer`, `requestJoin`, `createInvite`, `getInvite`, `kickServerMember`, `banServerMember`, `unbanServerMember`, `notifyLeave`, `sendHeartbeat`. Defensive coercion (`getNumberValue` / `getStringValue` / `getBooleanValue`) is used instead of schema validation.
|
||||
- **State**: signal-based via `ServerEndpointStateService` (servers, active server) — not NgRx for this slice.
|
||||
- **Facade**: `application/services/server-directory.service.ts` plus `application/facades/`.
|
||||
- **Multi-endpoint awareness**: Toju supports several federated signaling endpoints; `findServerAcrossActiveEndpoints` queries each and merges results.
|
||||
|
||||
---
|
||||
|
||||
## Business rules
|
||||
|
||||
- **Public-only listing**: `GET /` only returns servers with `isPrivate = 0`. Private servers must be reached by ID + invite.
|
||||
- **Owner immutability**: only `currentOwnerId` matching `server.ownerId` may update; only `ownerId` matching `server.ownerId` may delete.
|
||||
- **Join order of checks** (on `POST /:id/join`): existence → ban check (auto-prune expired bans) → password check (if `passwordHash`) → invite check (if private and no invite) → membership upsert → return `signalingUrl`.
|
||||
- **Invite expiry**: 10 days (`SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000`). Expired invites are pruned on access via `pruneExpiredServerAccessArtifacts()`.
|
||||
- **Ban expiry**: optional `expiresAt`; auto-deleted on next join attempt for that user.
|
||||
- **Join request notifications**: on `PUT /requests/:id`, after CQRS dispatch, `notifyUser` pushes the new status over WebSocket to any open connection for `userPublicKey` / `userId`.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **No authentication header.** All identity is self-asserted in the request body. Authorization is enforced by checking the claimed identity against persisted role/owner state.
|
||||
- **Password storage**: `passwordHash` only; never the cleartext. TODO: confirm the hashing algorithm (likely bcrypt / scrypt — verify in `server/src/services/`).
|
||||
- **SSRF**: routes in this area do not fetch user-supplied URLs, so the SSRF guard does not apply here (it applies to link-metadata, klipy, proxy).
|
||||
- **No rate limiting** on directory or moderation routes — TODO: add brute-force protection on `POST /:id/join` for password attempts.
|
||||
- **No CSRF** (REST + JSON body, no cookies in scope), but spam protection on `POST /` (server creation) is also TODO.
|
||||
|
||||
## Configuration
|
||||
|
||||
- `SERVER_INVITE_EXPIRY_MS` — currently hardcoded at 10 days. Not exposed via `data/variables.json`.
|
||||
- Per-server `maxUsers`, `slowModeInterval`, `isPrivate`, `passwordHash` are operator-configurable via `PUT /:id`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Server-side**: no direct route specs for `servers.ts`, `invites.ts`, `join-requests.ts`. WebSocket-side handlers (`handler-status.spec.ts`, `handler-plugin.spec.ts`) cover adjacent concerns.
|
||||
- **Renderer-side**: `application/services/server-endpoint-state.service.spec.ts`.
|
||||
- **E2E**: TODO — verify whether the Playwright suite covers join / invite / moderation end-to-end.
|
||||
- **Gap**: routes that mutate persistent state and accept self-asserted identity should ideally have integration tests against a real DB.
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **OpenAPI coverage is incomplete.** `server/src/routes/openapi-docs.ts` currently documents plugin-support endpoints only; server-directory endpoints are not listed.
|
||||
- **No structured request validation library.** Inline manual checks are error-prone; consider zod once the team is ready.
|
||||
- **No rate limiting / spam protection** on server creation or join attempts.
|
||||
- **`join_requests` migration is undocumented** (file not located by inspection); confirm during the next schema change.
|
||||
|
||||
## Related features
|
||||
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — `join_server` envelope re-uses this area's access rules via `authorizeWebSocketJoin`. `notifyUser` fan-out for join-request decisions is delivered over the same WebSocket.
|
||||
- **[plugin-system](./plugin-system.md)** — `join_server` responses include the joined server's `PluginRequirementsSnapshot`.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
177
agents-docs/features/voice-signaling.md
Normal file
177
agents-docs/features/voice-signaling.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Voice & WebRTC Signaling
|
||||
|
||||
> **Area:** voice-signaling
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
Voice and screen-share in Toju are pure WebRTC mesh: peers establish RTCPeerConnections directly, while the signaling server only forwards SDP and ICE messages. This area covers the end-to-end flow — envelope routing, peer election, RTCPeerConnection lifecycle, RNNoise denoising, and the relationships between the three product-client domains involved: `voice-session`, `voice-connection`, and `direct-call`. Screen-share rides on the same peer connection; its UI orchestration is its own domain but the signaling path is shared.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Negotiate WebRTC sessions between peers using `offer` / `answer` / `ice_candidate` envelopes forwarded by the signaling server.
|
||||
- Elect an initiator deterministically when multiple peers arrive simultaneously, with a non-initiator fallback timer.
|
||||
- Maintain the local audio pipeline: mic capture → optional RNNoise denoising → RTCPeerConnection sender.
|
||||
- Track per-peer playback gain, mute, deafen, and speaking-activity state on the receive side.
|
||||
- Mirror voice presence (`voice_state`) and direct-call signalling (`direct-call`) to other peers via the WebSocket.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- The WebSocket envelope shape (see [websocket-envelopes](./websocket-envelopes.md)).
|
||||
- Screen-share UI orchestration (its own domain at `toju-app/src/app/domains/screen-share/`); only the peer connection plumbing is shared.
|
||||
- Persistent user settings beyond `voiceSettingsStorage` (audio device IDs, volumes, bitrate, latency profile, noise-reduction toggle, persisted to localStorage).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Mesh** — every participant holds an `RTCPeerConnection` per other participant. No SFU / MCU.
|
||||
- **Voice session** — high-level "user is currently in voice room X" state. Owned by `voice-session` domain.
|
||||
- **Voice connection** — low-level transport/peer concerns: speaking detection, per-peer gain, mute / deafen state. Owned by `voice-connection` domain.
|
||||
- **Direct call** — 1:1 voice/video call with an optional group-upgrade path. Owned by `direct-call` domain.
|
||||
- **Initiator** — the peer responsible for sending the first `offer`. Elected first-peer-wins; non-initiators wait `NON_INITIATOR_GIVE_UP_MS` (≈5 s) before generating their own offer.
|
||||
- **Data channel** — `chat`-labelled data channel established alongside each peer connection for P2P chat fallback and direct-message delivery.
|
||||
- **Noise suppressor worklet** — RNNoise WASM running in an `AudioWorkletNode` (`NoiseSuppressorWorklet`), loaded from `rnnoise-worklet.js` at the app root.
|
||||
|
||||
---
|
||||
|
||||
## Signaling envelopes (consumed)
|
||||
|
||||
Defined in [websocket-envelopes](./websocket-envelopes.md). Voice-relevant types:
|
||||
|
||||
- `offer`, `answer`, `ice_candidate` — forwarded by the server to `targetUserId` without inspection.
|
||||
- `direct-call` — forwarded; payload carries call-scoped events (ring, participant join/leave, call end).
|
||||
- `voice_state` — broadcast to a server. Payload includes `roomId`, `voiceGateway`, mute/deafen flags.
|
||||
- `server_users` — full peer roster on join; seeds the initial offer fan-out.
|
||||
- `user_joined` — schedules a fallback offer after a grace delay (`USER_JOINED_FALLBACK_OFFER_DELAY_MS`, ≈1 s).
|
||||
- `user_left` — peer teardown, with special handling that preserves peers still under an active voice session.
|
||||
- `connected` / `access_denied` — connection lifecycle (server bootstrap and authorization).
|
||||
|
||||
The server is **purely signaling**: it does not track which `oderId` is in which voice room. Voice membership is derived client-side from the `voice_state` broadcasts observed on the server.
|
||||
|
||||
---
|
||||
|
||||
## Session establishment flow
|
||||
|
||||
A new participant joining a voice room produces this exchange (initiator perspective; symmetrical when both arrive at once):
|
||||
|
||||
1. Local user clicks "Join voice" → `VoiceSessionFacade.startSession()` populates the session model and asks `voice-connection` to ready peer transport.
|
||||
2. Server broadcasts `user_joined` to existing peers.
|
||||
3. Each existing peer evaluates: am I the elected initiator for the (me, new-peer) pair? If yes, the peer-connection manager calls `doCreateAndSendOffer()`.
|
||||
4. Initiator constructs `new RTCPeerConnection({ iceServers })` (`infrastructure/realtime/peer-connection-manager/.../create-peer-connection.ts`), adds local tracks, creates the data channel `chat`, generates an SDP offer, and sends it via the signaling transport.
|
||||
5. Responder receives `offer` → `doHandleOffer()` sets remote description, generates SDP answer, sends `answer`.
|
||||
6. Initiator receives `answer` → `doHandleAnswer()` sets remote description.
|
||||
7. Both sides emit `ice_candidate` as they gather candidates via `onicecandidate`.
|
||||
8. `iceConnectionState` reaches `connected` / `completed` → media flows.
|
||||
9. Either side may open the `chat` data channel for P2P text payloads (direct messages, etc.).
|
||||
|
||||
If the elected initiator never sends an offer within `NON_INITIATOR_GIVE_UP_MS`, the non-initiator promotes itself and initiates instead — preserves liveness across asymmetric drop-outs.
|
||||
|
||||
`user_left` is treated carefully: the `signaling-message-handler.spec.ts` covers the case where a peer is still required by an active voice session and must not be torn down, even if other parts of the system think the peer has disconnected.
|
||||
|
||||
---
|
||||
|
||||
## Domain responsibilities
|
||||
|
||||
### `voice-session` (`toju-app/src/app/domains/voice-session/`)
|
||||
|
||||
- `VoiceSessionFacade` (`application/facades/voice-session.facade.ts`) — owns the active session metadata (`serverId`, `roomId`, `participantIds`); drives a `showFloatingControls` signal when the user navigates away from the room.
|
||||
- `VoiceWorkspaceService` (`application/services/voice-workspace.service.ts`) — UI state for the workspace (hidden / expanded / minimized), focused stream ID, mini-window position.
|
||||
- `voiceSettingsStorage` (`infrastructure/util/voice-settings-storage.util.ts`) — localStorage persistence: input/output device IDs, output volume (0–100), bitrate (32–256 kbps), latency profile (`low | balanced | high`), noise-reduction toggle.
|
||||
- Joining a new voice target first calls `endSession()` so transitions cannot leak peer connections.
|
||||
|
||||
### `voice-connection` (`toju-app/src/app/domains/voice-connection/`)
|
||||
|
||||
Bridges the application layer to the low-level WebRTC infrastructure under `toju-app/src/app/infrastructure/realtime/`.
|
||||
|
||||
- **`VoiceActivityService`** — RMS-based speaking detection via `AnalyserNode` (fftSize 256, RMS ≥ 0.015, 8-frame grace period).
|
||||
- **`VoicePlaybackService`** — per-peer `GainNode` chains (0–200% range), localStorage-persisted; deafen sets all gains to 0.
|
||||
- **`VoiceConnectionFacade`** — exposes signals like `isVoiceConnected`, `isMuted`; methods like `toggleMute()`, `toggleNoiseReduction()`, `setOutputVolume()`.
|
||||
|
||||
Per the domain README, voice-connection does **not** own RTCPeerConnection construction or signaling — those live in `infrastructure/realtime/peer-connection-manager`.
|
||||
|
||||
### `direct-call` (`toju-app/src/app/domains/direct-call/`)
|
||||
|
||||
- Initiator flow (`DirectCallService.startCall()`): create/reuse the 1:1 DM, start a call-scoped voice session, send a `direct-call` "ring" envelope via `PeerDeliveryService`.
|
||||
- Recipient flow: store incoming session, ring `assets/audio/call.wav` (unless DND), show in-app modal + desktop notification.
|
||||
- Group upgrade: adding a third participant spawns a new group conversation; the active call swaps its chat panel to the new conversation but original DM history is preserved.
|
||||
- Invariant: incoming `direct-call` events are ignored unless the local user is in `participantIds`.
|
||||
|
||||
### Screen share (`toju-app/src/app/domains/screen-share/`)
|
||||
|
||||
- Adds dedicated `MediaStreamTrack` senders to the existing peer connection (does not open a new one).
|
||||
- Request / response model: a receiver sends `screen-share-request`; the sender attaches the share track; `screen-share-stop` tears it down.
|
||||
- Quality presets: `low` / `balanced` / `high` (resolution + FPS).
|
||||
- On Electron, `ScreenShareSourcePickerService` drives a Promise-based picker over `getSources` (see [ipc-bridge](./ipc-bridge.md)).
|
||||
|
||||
---
|
||||
|
||||
## RNNoise pipeline
|
||||
|
||||
Manager: `infrastructure/realtime/media/noise-reduction.manager.ts`.
|
||||
|
||||
```
|
||||
Raw mic → MediaStreamAudioSourceNode → NoiseSuppressorWorklet (AudioWorkletNode) → MediaStreamAudioDestinationNode → clean stream → RTCPeerConnection sender
|
||||
```
|
||||
|
||||
- AudioContext at 48 kHz.
|
||||
- Worklet loaded from `rnnoise-worklet.js` (built from `@timephy/rnnoise-wasm`, output written to `toju-app/public/`).
|
||||
- If worklet load fails, the raw stream is passed through unchanged.
|
||||
- Mute takes priority — when muted, noise reduction is also disabled.
|
||||
|
||||
## Technical implementation
|
||||
|
||||
- **Envelope types**: see [websocket-envelopes](./websocket-envelopes.md).
|
||||
- **Signaling adapter (renderer)**: `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts` (and `signaling-transport-handler.ts`).
|
||||
- **Peer-connection manager**: `toju-app/src/app/infrastructure/realtime/peer-connection-manager/` — `create-peer-connection.ts`, recovery (grace timers, reconnect), data-channel plumbing.
|
||||
- **Voice settings**: `domains/voice-session/infrastructure/util/voice-settings-storage.util.ts`.
|
||||
- **Noise reduction**: `infrastructure/realtime/media/noise-reduction.manager.ts`.
|
||||
- **Worklet asset**: `toju-app/public/rnnoise-worklet.js`.
|
||||
- **Server side**: signaling only — `server/src/websocket/handler.ts::forwardRtcMessage`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The server forwards `offer` / `answer` / `ice_candidate` / `direct-call` envelopes opaquely and never persists media or call state.
|
||||
- Switching voice rooms always tears down the prior session before starting the new one.
|
||||
- Mute overrides noise reduction (the manager disables the worklet path when muted).
|
||||
- Direct-call events with the local user absent from `participantIds` are ignored.
|
||||
|
||||
## Testing
|
||||
|
||||
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — `user_left` peer preservation under active voice.
|
||||
- `toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts` — reconnect, grace timers, exponential backoff.
|
||||
- `toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.spec.ts`.
|
||||
- `toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts`.
|
||||
- E2E: `e2e/tests/voice/multi-signal-eight-user-voice.spec.ts`, `e2e/tests/voice/direct-call.spec.ts` (verify exact filenames in the suite — TODO).
|
||||
|
||||
## Security considerations
|
||||
|
||||
- WebRTC bypasses the server entirely once connected — peer IPs may be exposed to other participants via ICE candidates. Standard WebRTC privacy caveat.
|
||||
- Signaling envelopes are forwarded without verifying that source and target share a server — TODO: confirm whether `forwardRtcMessage` enforces membership.
|
||||
- The data channel `chat` carries P2P text payloads; integrity / authentication of those payloads is owned by the chat/direct-message domains, not by this area.
|
||||
- RNNoise runs entirely client-side; mic audio never leaves the local AudioContext until it enters the encrypted RTCPeerConnection.
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- Mesh topology — N×(N-1)/2 peer connections per voice room. Practical ceiling is bound by client CPU and uplink; no documented soft cap.
|
||||
- Bitrate is client-controlled (32–256 kbps); no server-enforced QoS.
|
||||
- Voice activity detection runs at fftSize 256 with an 8-frame grace period — chosen to minimise CPU while staying responsive to natural speech.
|
||||
- The signaling server's only cost is envelope forwarding (O(1) per envelope).
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **No SFU / MCU.** Large rooms scale linearly with participant count on each client.
|
||||
- **No recording or server-side mixing** for voice or screen.
|
||||
- **Bitrate is not enforced server-side** — adversarial clients could ignore the suggested range.
|
||||
- **No documented call-quality telemetry pipeline.**
|
||||
|
||||
## Related features
|
||||
|
||||
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire types this area consumes.
|
||||
- **[ipc-bridge](./ipc-bridge.md)** — `getSources` and the Linux audio-routing methods are used by screen-share.
|
||||
- **[plugin-system](./plugin-system.md)** — plugins may participate as observers via `voice_state` broadcasts (subject to capability grants); no direct call control surface today.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
164
agents-docs/features/websocket-envelopes.md
Normal file
164
agents-docs/features/websocket-envelopes.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# WebSocket Envelopes
|
||||
|
||||
> **Area:** websocket-envelopes
|
||||
> **Status:** Active
|
||||
> **Last updated:** 2026-05-25
|
||||
|
||||
## Overview
|
||||
|
||||
The WebSocket envelope contract is the realtime wire-format boundary between the signaling server and every connected client. Every realtime concern in Toju — presence, chat broadcasts, typing indicators, voice state, WebRTC offer/answer/ICE forwarding, direct messages, server icon P2P sync, and plugin events — travels as a typed envelope over a single WebSocket connection per client. Drift between the server definition and the client-side mirror is treated as a wire-protocol break.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Define the canonical shape of every realtime message exchanged between `toju-app` (renderer) and `server`.
|
||||
- Route incoming envelopes to a single dedicated handler on the server.
|
||||
- Provide a stable identity for the connection (`connectionId`, `oderId`, `connectionScope`) and a lazy authorization model on `join_server`.
|
||||
- Forward peer-targeted envelopes (WebRTC signaling, direct messages, server-icon peer transfers) without inspecting their payload.
|
||||
|
||||
This area does **not** own:
|
||||
|
||||
- The HTTP/REST surface (see [server-directory](./server-directory.md)).
|
||||
- WebRTC media transport or session orchestration (see [voice-signaling](./voice-signaling.md) — the envelope contract is shared, but session lifecycle lives there).
|
||||
- Persistence (server entities are owned by the server subdomain; the envelope is the contract, not the entity).
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **Envelope** — a `{ type, ...payload }` message routed by `type`. Defined in `server/src/websocket/types.ts`.
|
||||
- **ConnectedUser** — server-side state record per WebSocket: `connectionId`, `oderId`, `connectionScope`, `displayName`, `description`, `status`, `serverIds`, `lastPong`.
|
||||
- **`oderId`** — opaque user identity. Set by the client in `identify`; falls back to a UUID if absent. Multiple connections may share an `oderId` (e.g. multiple devices) — broadcasts are deduplicated per `oderId`.
|
||||
- **`connectionScope`** — typically the signal URL; disambiguates several connections from the same `oderId`.
|
||||
- **Handler** — server-side function mapped to one envelope `type` in `server/src/websocket/handler.ts`.
|
||||
- **Forwarded envelope** — peer-to-peer envelopes the server relays untouched to a specific `targetUserId` (offer / answer / ice_candidate / direct-call / direct-message family / server_icon_peer_*).
|
||||
|
||||
---
|
||||
|
||||
## Envelope catalogue
|
||||
|
||||
Defined on the server in `server/src/websocket/types.ts` and dispatched by the switch in `server/src/websocket/handler.ts`. Groups below match the dispatch shape, not a literal grouping in code.
|
||||
|
||||
### Connection & presence
|
||||
|
||||
- `identify` — client → server. Profile + `connectionScope`. Required before any other envelope is meaningful.
|
||||
- `connected` — server → client. Sent automatically on connect: `{ connectionId, serverTime }`.
|
||||
- `keepalive` — client ↔ server. Resets `lastPong`. See lifecycle below.
|
||||
- `status_update` — broadcast presence: `online | away | busy | offline`.
|
||||
- `access_denied` — server → client when `join_server` authorization fails.
|
||||
|
||||
### Server membership
|
||||
|
||||
- `join_server` — client requests membership for a `serverId`. Authorization checked via `authorizeWebSocketJoin` (`server/src/services/server-access.service.ts`). Response includes `server_users` + `plugin_requirements`.
|
||||
- `view_server` — client marks a server as viewed (fetch roster + plugin requirements without joining).
|
||||
- `leave_server` — client leaves; broadcasts `user_left` to remaining members.
|
||||
- `server_users` — server → client. Full peer roster for a joined server (used as the seed for P2P offers).
|
||||
- `user_joined` / `user_left` — broadcast presence changes.
|
||||
|
||||
### Chat & typing
|
||||
|
||||
- `chat_message` — broadcast to a server. Payload: `{ message, senderId, senderName, timestamp }`.
|
||||
- `typing` — broadcast: `{ isTyping, channelId, oderId, displayName }`.
|
||||
|
||||
### Voice presence
|
||||
|
||||
- `voice_state` — broadcast user voice state (mute/deafen/room metadata). Pure signaling — the server does not store voice room membership.
|
||||
|
||||
### WebRTC signaling (forwarded)
|
||||
|
||||
- `offer` / `answer` / `ice_candidate` — forwarded to `targetUserId` via `forwardRtcMessage()`.
|
||||
- `direct-call` — forwarded; semantic call lifecycle lives in the `direct-call` product-client domain.
|
||||
|
||||
### Direct messages (forwarded)
|
||||
|
||||
- `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-sync`, `direct-message-sync-request` — forwarded to `targetUserId`.
|
||||
|
||||
### Server icon P2P sync
|
||||
|
||||
- `server_icon_available` — client announces it has an icon at version `iconUpdatedAt`.
|
||||
- `server_icon_sync_request` — client asks the server which peers have a newer icon.
|
||||
- `server_icon_sync_peers` — server → client. Peer list offering newer icons.
|
||||
- `server_icon_peer_request` / `server_icon_peer_data` — P2P transfer, forwarded.
|
||||
|
||||
### Plugins
|
||||
|
||||
- `plugin_event` — validated against the plugin's registered event schema (see [plugin-system](./plugin-system.md) and `server/src/services/plugin-support.service.ts`), then broadcast within the server scope. Payload: `{ serverId, pluginId, eventName, payload, sourcePluginUserId, sourceUserId, emittedAt }`.
|
||||
|
||||
---
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
Implemented in `server/src/websocket/index.ts`.
|
||||
|
||||
1. Client opens WebSocket → server generates `connectionId` (UUID), creates the `ConnectedUser` record, sends `{ type: 'connected', connectionId, serverTime }`.
|
||||
2. Client sends `identify` with `oderId`, `displayName`, `connectionScope`, optional `description` / `profileUpdatedAt`. Server normalizes and stores.
|
||||
3. Client sends `join_server` (or `view_server`) per server they care about. Each `join_server` is authorized independently.
|
||||
4. Heartbeat: server pings every **30 s** (`PING_INTERVAL_MS`). Any incoming message also refreshes `lastPong`. Connections without a pong for **45 s** (`PONG_TIMEOUT_MS`) are terminated.
|
||||
5. On close: server emits `user_left` to every server the connection had joined. Broadcasts are **deduplicated by `oderId`**, so multi-device users only generate one departure event per logical identity.
|
||||
|
||||
---
|
||||
|
||||
## Authentication model
|
||||
|
||||
There is no bearer token or signed envelope. Identity is whatever the client claims in `identify`. Authorization is **per-`join_server`**, evaluated by `authorizeWebSocketJoin` against persisted server access rules (private flag, password hash, bans, invite/join-request state). `access_denied` is returned when authorization fails; the connection itself stays open.
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation
|
||||
|
||||
### Server
|
||||
|
||||
- **Types**: `server/src/websocket/types.ts` — `WsMessage` (union over `type`), `ConnectedUser`, `ConnectionScope`.
|
||||
- **Dispatcher**: `server/src/websocket/handler.ts` — `handleWebSocketMessage(connectionId, message)`. Single switch (~16 dedicated handler functions plus `forwardRtcMessage`).
|
||||
- **Lifecycle**: `server/src/websocket/index.ts` — `ws` server, ping/pong, connection registry, dead-connection reaping.
|
||||
- **Plugin event validation**: `server/src/services/plugin-support.service.ts` — async `validatePluginEventEnvelope()` (runtime schema check).
|
||||
|
||||
### Client (renderer)
|
||||
|
||||
- **Shared types**: `toju-app/src/app/shared-kernel/signaling-contracts.ts` — **stale**, only declares a generic `SignalingMessage` and an obsolete `SignalingMessageType` enum. Not the active wire-format definition.
|
||||
- **Active envelope shapes** are defined inline as `IncomingSignalingMessage` in `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts`.
|
||||
- **Constants**: `toju-app/src/app/infrastructure/realtime/realtime.constants.ts` — every envelope `type` string lives here as `SIGNALING_TYPE_*`.
|
||||
- **Transport**: `toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts` — socket lifecycle, sends `identify`, `join_server`, raw envelopes.
|
||||
- **Coordinator**: `toju-app/src/app/infrastructure/realtime/signaling/server-signaling-coordinator.ts` — maps `serverId` to signal URL (Toju supports multiple federated signaling endpoints).
|
||||
- **Inbound dispatch**: `signaling-message-handler.ts` — `handleConnectedSignalingMessage`, `handleServerUsersSignalingMessage`, `handleUserJoinedSignalingMessage`, `handleUserLeftSignalingMessage`, `handleOfferSignalingMessage`, `handleAnswerSignalingMessage`, `handleIceCandidateSignalingMessage`, `handleAccessDeniedSignalingMessage`. Domain envelopes (chat/typing/direct-message/etc.) are consumed in the respective product-client domains, not in this central adapter — TODO: enumerate exact subscription points.
|
||||
|
||||
### Versioning
|
||||
|
||||
No `version` field on envelopes. No `Accept-Version` header. Drift between server and client is enforced only by code review (per `server/CONTEXT.md` invariants).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- `server/src/websocket/handler-status.spec.ts` — `status_update` broadcast and profile metadata in `user_joined` / `server_users`.
|
||||
- `server/src/websocket/handler-plugin.spec.ts` — `plugin_event` validation and broadcast.
|
||||
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — inbound handler unit tests (notably `user_left` preserving peers under voice).
|
||||
- **TODO**: no round-trip envelope-shape test between server `WsMessage` and client `IncomingSignalingMessage`. Drift can only be caught by E2E or manual review today.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- No transport-level auth — identity is self-asserted via `identify`. The server trusts `oderId` for routing but checks authorization on every `join_server`.
|
||||
- WebRTC signaling envelopes (`offer` / `answer` / `ice_candidate`) are forwarded **without inspection**. The server does not verify that the sender is a member of the same server as the target — TODO: confirm whether `forwardRtcMessage` enforces server-membership before forwarding.
|
||||
- `plugin_event` payloads are bounded by the plugin's declared `maxPayloadBytes` (default 64 KB) and validated against the plugin's declared event schema. See [plugin-system](./plugin-system.md).
|
||||
- Multi-connection identities: a single `oderId` may have many open sockets. Broadcasts dedupe by `oderId`, but per-connection state (e.g. `voice_state`) does not — TODO: document the cross-connection invariants.
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- Single WebSocket per client. No fan-out worker; broadcast is in-process via the in-memory connection map.
|
||||
- Ping cadence 30 s / pong timeout 45 s. Reaping is per-connection on next tick.
|
||||
- TODO: no documented soft cap on connected users per signaling server.
|
||||
|
||||
## Known issues and limitations
|
||||
|
||||
- **Stale shared-kernel contract.** `toju-app/src/app/shared-kernel/signaling-contracts.ts` does not enumerate the live envelope set; client code uses `IncomingSignalingMessage` in `signaling-message-handler.ts` instead. Update or replace this file when adjacent work touches the wire format.
|
||||
- **No envelope versioning.** Any field rename is an immediate break for older clients.
|
||||
- **TODO — operator concerns**: rate limits, max-message-size, and backpressure are not documented.
|
||||
|
||||
## Related features
|
||||
|
||||
- **[voice-signaling](./voice-signaling.md)** — consumes `offer` / `answer` / `ice_candidate` / `voice_state` / `direct-call`.
|
||||
- **[plugin-system](./plugin-system.md)** — defines and validates `plugin_event`.
|
||||
- **[server-directory](./server-directory.md)** — REST counterpart for server discovery, joining, and moderation; `join_server` envelope authorization reuses the same access rules.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-05-25 | Initial documentation |
|
||||
@@ -5,6 +5,18 @@
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"computedHash": "4d486dd6f9fbb27ce1c51c972c9a5eb25a53236ae05eabf4d076ac1e293f4b7a"
|
||||
},
|
||||
"document-features": {
|
||||
"source": "Grade-AI-Labs/Mimas",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/document-features/SKILL.md",
|
||||
"computedHash": "e4df8d900aab6698d2ae65c575ca006a05d13ff99a17d3a7273124858d57051d"
|
||||
},
|
||||
"find-features": {
|
||||
"source": "Grade-AI-Labs/Mimas",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/find-features/SKILL.md",
|
||||
"computedHash": "2581a85c49fff668c838068c06611723e233890be7e3cdeaab6c7545ecd85e89"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user