Compare commits
125 Commits
feature/br
...
18c41231ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c41231ca | ||
| 9f3f014f34 | |||
| 257ed2166c | |||
| bab70a3f6f | |||
| 9c0c8f902c | |||
| a3e3ca817e | |||
| 45fce7cb9f | |||
|
|
2efc32c022 | ||
| e892d80aa8 | |||
| 43d01fde03 | |||
| 9f6ad6888d | |||
| d515d42b7c | |||
|
|
c47c89c023 | ||
| d034c6f673 | |||
| a4d03bd956 | |||
| 7b699301e7 | |||
|
|
bd8adf1c54 | ||
| fec3aa0d5d | |||
| a7186e2fb9 | |||
| b7244a24b0 | |||
| 6cea1c24c4 | |||
| 31c91fb307 | |||
| 8c46af7e54 | |||
| 9a3e1cc1e7 | |||
| 9ecfe610de | |||
| b1a474a7eb | |||
| 40cd9375d7 | |||
| 5de5e19445 | |||
| 81f7d58fbe | |||
| 3eafaef97e | |||
| 4810830c8a | |||
| bfb006c888 | |||
| 2cb6f7a3b9 | |||
| cfd202b23e | |||
| eb19556802 | |||
| c2f4866012 | |||
|
|
daf735f48f | ||
| 57395c51b1 | |||
| a458d4f594 | |||
| 3e98a6c437 | |||
|
|
468c9b5541 | ||
|
|
7f9d63308b | ||
| d10aefa755 | |||
| 3ae4d1c3db | |||
| f773a13c23 | |||
| 43d9782d2e | |||
| 1e6710dd50 | |||
| a743b6000c | |||
| 2686ade632 | |||
| 2d2a68d4a7 | |||
| 7d7df6ac15 | |||
| 0d5c0486dd | |||
| 0251df5bfe | |||
| 30cfa0df1f | |||
| 9fe7b75833 | |||
| 1809f15456 | |||
| 7d09475c53 | |||
| e34284561f | |||
| b64b310d67 | |||
| db983fbb8f | |||
| f495db572a | |||
| 6e7da3acbe | |||
| e82eac6d97 | |||
| 80f75b1b4c | |||
| eba08bc53d | |||
| e50c6dda2c | |||
| c3062f9960 | |||
| 027fe2cbc3 | |||
| f97931f8c3 | |||
| c1d99f943a | |||
| f3fa48c932 | |||
| 2257df09eb | |||
| 17f9207a17 | |||
| 28c5c228f7 | |||
| 6e0ff2a821 | |||
| 0109df6963 | |||
| 752793f6c3 | |||
| 82d88f26ec | |||
| 44544dc3bc | |||
| b3fa93e9c4 | |||
| 1aba1ff93d | |||
| 5a7bbc26fa | |||
| 71cb181152 | |||
| 837b546f49 | |||
| 1dbdeb789e | |||
| 9a8c083a1e | |||
| cb10cee6f1 | |||
| a51017f4c4 | |||
| 6f9632f77b | |||
| f79c2fa5a9 | |||
| 8798a0ff79 | |||
| 4eae12448a | |||
| 17320ac578 | |||
| a3a5985268 | |||
| 3a1d091a71 | |||
| cdc2bf580e | |||
| da3be61c7f | |||
| ed53990679 | |||
| 0d5743ec74 | |||
| 07603691bb | |||
| 90bc9a0dad | |||
| 292cdb3274 | |||
| eda817e6f3 | |||
| 061fbee8fc | |||
| 78a455e3bb | |||
| e78c92850b | |||
| 874fcfd79d | |||
| d05b59316b | |||
| 63bce88bd7 | |||
| a3ede38c5d | |||
| 7fa6eee9a9 | |||
| ee87ce0729 | |||
| d87a45e914 | |||
| 099e5b0af4 | |||
| c59ff9270b | |||
| 033292504e | |||
| 8963269ff0 | |||
| e889700227 | |||
| 50244e5c2b | |||
| 4f0c282f3f | |||
| f1aa65cc17 | |||
| 8a3e37ae73 | |||
| 363eb810da | |||
|
|
f5a65e6f5b | ||
| 67611f727d |
41
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report something that is broken or behaving unexpectedly
|
||||
title: "[bug] "
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- A clear, concise description of the bug. -->
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
<!-- What you expected to happen. -->
|
||||
|
||||
## Actual behavior
|
||||
|
||||
<!-- What actually happened. Include exact error messages and logs if you have them. -->
|
||||
|
||||
## Environment
|
||||
|
||||
- Component / repo:
|
||||
- Version or commit:
|
||||
- OS / runtime (Node, Rust, ServUO, browser…):
|
||||
- Deployment (Docker Compose, local dev, bare metal…):
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Screenshots, config (with secrets redacted), anything else that helps. -->
|
||||
|
||||
<!--
|
||||
Security issue? Do NOT file it here. See SECURITY.md and email
|
||||
whitlocktech@gmail.com instead.
|
||||
-->
|
||||
5
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/SECURITY.md
|
||||
about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md).
|
||||
23
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea, enhancement, or new capability
|
||||
title: "[feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
---
|
||||
|
||||
## Problem / motivation
|
||||
|
||||
<!-- What are you trying to do? What's missing or painful today? -->
|
||||
|
||||
## Proposed solution
|
||||
|
||||
<!-- What you'd like to see happen. -->
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
<!-- Other approaches you thought about, and why you prefer the one above. -->
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Mockups, links, related issues, affected component/repo, etc. -->
|
||||
33
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
Thanks for contributing to Runic Gateway!
|
||||
Please fill out the sections below and check every box before requesting review.
|
||||
-->
|
||||
|
||||
## What & why
|
||||
|
||||
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Commands you ran, manual steps, screenshots. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- [ ] The change builds and existing tests/checks pass locally.
|
||||
- [ ] I have added or updated tests/docs where it makes sense.
|
||||
- [ ] My commits are reasonably scoped with clear messages.
|
||||
|
||||
## AI-assisted contributions (required)
|
||||
|
||||
This project **requires disclosure of AI tool usage**. Please pick one:
|
||||
|
||||
- [ ] No AI tools were used to produce this contribution.
|
||||
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
|
||||
every change, and take responsibility for it. AI-authored commits are
|
||||
marked with a `Co-Authored-By` / `Assisted-By` trailer.
|
||||
|
||||
## License
|
||||
|
||||
- [ ] I agree that my contribution is licensed under this project's license
|
||||
(**GNU GPL v3.0 or later**), and I have the right to contribute it.
|
||||
133
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**whitlocktech@gmail.com**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
73
CONTRIBUTING.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Contributing to Runic Gateway — Documentation
|
||||
|
||||
Thanks for your interest in contributing! This repo is the **central
|
||||
documentation** for the Runic Gateway platform — design docs, the protocol spec,
|
||||
integration guides, and research, extracted from the code repos so they live in
|
||||
one place.
|
||||
|
||||
By participating you agree to abide by our
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- **Report an error** (something wrong, outdated, or unclear) or **request new
|
||||
documentation** through the
|
||||
[issue tracker](https://gitea.whitlocktech.com/RunicGateway/docs/issues)
|
||||
(issue templates are provided).
|
||||
- **Fix or expand the docs** by opening a pull request (see below).
|
||||
- **Never** report a security vulnerability in a public issue — see
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
## Working on the docs
|
||||
|
||||
Everything here is Markdown — no build step. Just edit the relevant file and
|
||||
preview it in any Markdown viewer (or on Gitea).
|
||||
|
||||
```
|
||||
website/ docs for the shard website (Node/Express + MariaDB + React/Vite)
|
||||
link/ docs for the ServUO bridge (C# plugin + Rust sidecar)
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Keep documents in the folder matching their subsystem (`website/` or `link/`).
|
||||
- These docs are the **authoritative** copy of the protocol and design; when you
|
||||
change behavior in a code repo, update the matching doc here in the same or a
|
||||
follow-up PR so the spec and implementation stay in sync.
|
||||
- Use relative links between docs, and check that any links you add resolve.
|
||||
- Prefer clear prose and tables over screenshots where possible.
|
||||
|
||||
## Branch & PR workflow
|
||||
|
||||
1. Branch from `main` with a descriptive name (`docs/…`, `fix/…`, `chore/…`).
|
||||
2. Keep changes focused; small PRs are easier to review.
|
||||
3. Push and open a pull request against `main`. Fill out the PR template,
|
||||
including the **AI-assisted contributions** disclosure.
|
||||
4. A maintainer will review; address feedback with follow-up commits.
|
||||
|
||||
### Commit messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||
`type(scope): summary` (e.g. `docs(link): clarify town-crier caps`).
|
||||
|
||||
## AI-assisted contributions (disclosure required)
|
||||
|
||||
This project is developed openly with AI assistance, and we ask the same
|
||||
transparency of everyone. **If you used an AI tool** (Claude, Copilot, ChatGPT,
|
||||
Cursor, etc.) to help produce a contribution, you must disclose it:
|
||||
|
||||
- Tick the AI-usage box in the pull-request template and name the tool(s).
|
||||
- Mark AI-authored commits with a trailer, e.g.
|
||||
`Co-Authored-By: Claude <noreply@anthropic.com>` or `Assisted-By: <tool>`.
|
||||
- You remain responsible for every line you submit: review it, understand it,
|
||||
and make sure it is accurate and that you have the right to contribute it.
|
||||
|
||||
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are
|
||||
not, and may be closed.
|
||||
|
||||
## License
|
||||
|
||||
Runic Gateway is licensed under the **GNU General Public License v3.0 or later**
|
||||
(see [LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is
|
||||
licensed under the same terms (inbound = outbound) and that you have the right to
|
||||
contribute it.
|
||||
31
CONTRIBUTORS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Contributors
|
||||
|
||||
Runic Gateway is built and maintained by the people and tools listed here.
|
||||
Thank you to everyone who has contributed.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **whitlocktech** <whitlocktech@gmail.com> — project lead and maintainer
|
||||
|
||||
## Contributors
|
||||
|
||||
<!--
|
||||
Add yourself here when your contribution is merged — alphabetical by name or
|
||||
handle. One line each:
|
||||
|
||||
- **Name or handle** (optional link) — what you contributed
|
||||
-->
|
||||
|
||||
- _Your name could be here — see [CONTRIBUTING.md](CONTRIBUTING.md)._
|
||||
|
||||
## AI-assisted development
|
||||
|
||||
Parts of Runic Gateway were developed with the assistance of AI coding tools,
|
||||
including **Claude** (Anthropic) via Claude Code. AI-assisted commits are
|
||||
attributed in their commit trailers (e.g. `Co-Authored-By: Claude ...`).
|
||||
|
||||
In keeping with this project's transparency policy, **all contributors must
|
||||
disclose their use of AI tools** on any contribution — see the
|
||||
"AI-assisted contributions" section of [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
Disclosed AI assistance is welcome; undisclosed AI-generated contributions are
|
||||
not.
|
||||
674
LICENSE.md
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
32
README.md
@@ -9,6 +9,8 @@ so they live in one place, independent of either codebase.
|
||||
```
|
||||
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||
ci/ cross-cutting CI/quality notes
|
||||
```
|
||||
|
||||
### `website/`
|
||||
@@ -18,6 +20,7 @@ link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
||||
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
||||
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
|
||||
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||
|
||||
### `link/`
|
||||
| Doc | What it covers |
|
||||
@@ -29,6 +32,17 @@ link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
||||
| [RESEARCH.md](link/RESEARCH.md) | Research notes |
|
||||
| [link-README.md](link/link-README.md) | Snapshot of the link repo's README |
|
||||
| [PROJECT_TREE.md](link/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||
|
||||
### `android/`
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [PLAN.md](android/PLAN.md) | Android client build plan / milestones |
|
||||
| [COVERAGE_PLAN.md](android/COVERAGE_PLAN.md) | Test-coverage rollout plan |
|
||||
| [APP_LINKS.md](android/APP_LINKS.md) | Android App Links / deep-link setup |
|
||||
| [theme-plan.md](android/theme-plan.md) | Theming plan |
|
||||
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
|
||||
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||
|
||||
## Provenance
|
||||
|
||||
@@ -38,3 +52,21 @@ link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
Commit history and authorship for each doc are preserved. The two source repos
|
||||
retain a short pointer to this repo in their own READMEs; the authoritative copy
|
||||
of each document now lives here.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Runic Gateway's documentation is free: licensed under the **GNU General Public
|
||||
License v3.0 or later** — see [LICENSE.md](LICENSE.md).
|
||||
|
||||
Copyright (C) 2026 Runic Gateway
|
||||
|
||||
This documentation is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY. You may redistribute and/or modify it under the terms of
|
||||
the GNU General Public License as published by the Free Software Foundation,
|
||||
either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
|
||||
the **AI-usage disclosure** requirement) and our
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
50
SECURITY.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Security Policy
|
||||
|
||||
Thank you for helping keep Runic Gateway and its users safe.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public issues, pull
|
||||
requests, or the wiki.** A public report tips off attackers before a fix is
|
||||
available.
|
||||
|
||||
Instead, report privately by email to:
|
||||
|
||||
**whitlocktech@gmail.com**
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
- The repository and component affected.
|
||||
- The type of issue (e.g. authentication bypass, injection, secret exposure,
|
||||
remote code execution, denial of service).
|
||||
- Step-by-step instructions to reproduce, and a proof-of-concept if you have one.
|
||||
- The impact — what an attacker could do with it.
|
||||
- Any suggested remediation.
|
||||
|
||||
You will receive an acknowledgement of your report, typically within a few days.
|
||||
We will keep you informed as we investigate and work toward a fix, and we are
|
||||
happy to credit you in the release notes once the issue is resolved (let us know
|
||||
if you would prefer to remain anonymous).
|
||||
|
||||
## Scope
|
||||
|
||||
Runic Gateway is a self-hosted platform made up of several components:
|
||||
|
||||
| Component | Repo | Network exposure |
|
||||
|---|---|---|
|
||||
| Website (site + admin + API) | `RunicGateway/website` | Internet-facing (behind a reverse proxy) |
|
||||
| uo-link sidecar | `RunicGateway/link` | The only network-facing part of the game bridge |
|
||||
| ServUO plugin | `RunicGateway/servuo-plugins` | Loopback only — dials the sidecar on `127.0.0.1` |
|
||||
| Documentation | `RunicGateway/docs` | Content only |
|
||||
|
||||
Because instances are self-hosted, the security of any given deployment also
|
||||
depends on how it is configured and operated — strong secrets (`JWT_SECRET`,
|
||||
`SECRET_ENC_KEY`, database and admin passwords), a correctly configured reverse
|
||||
proxy and `TRUST_PROXY`, and keeping the shard itself unreachable from the
|
||||
internet (only the sidecar should be exposed). See each repo's README for the
|
||||
security model.
|
||||
|
||||
## Supported versions
|
||||
|
||||
This project is developed continuously and does not maintain long-term release
|
||||
branches. Security fixes land on `main`; please run a recent build.
|
||||
196
android/APP_LINKS.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Android App Links — implementation spec
|
||||
|
||||
Status: **implementation spec (M9 follow-up).** Stacks on the native SSO bridge (M9 Part 2):
|
||||
the app already handles the **custom-scheme** callback `runicgateway://auth/callback`, and that stays
|
||||
the permanent default and universal fallback. App Links are an **opt-in hardening** layered on top —
|
||||
a verified `https://` callback that only the domain's real owner can claim.
|
||||
|
||||
Read alongside: the "Mobile SSO Authorization Bridge" section of
|
||||
[`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (endpoints/tables/allowlist), and
|
||||
[`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone). This spec matches what ships on the
|
||||
`feat/mobile-app-links` (website) and `feat/app-links` (android) branches.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem it solves
|
||||
|
||||
The mobile SSO bridge redirects the browser back to the app with a one-time code:
|
||||
|
||||
```
|
||||
runicgateway://auth/callback?code=…&state=…
|
||||
```
|
||||
|
||||
A **custom URI scheme** is fine for a self-hosted internal client, but it is not *owned* by anyone:
|
||||
any other Android app can register an intent-filter for `runicgateway://auth/callback` and, if chosen
|
||||
by the user, intercept the callback. The code is single-use, PKCE-bound (Layer B), and short-lived —
|
||||
so an interceptor still cannot complete `/exchange` without the app's `code_verifier` — but a hijacked
|
||||
callback is still a denial-of-service and a phishing surface we would rather close.
|
||||
|
||||
**Android App Links** (verified `https://` deep links) close it: the OS only routes an `https://`
|
||||
link to an app that has proven, via a file served from *that domain*, that it owns the app. An
|
||||
attacker cannot serve that file on a domain they do not control.
|
||||
|
||||
## 2. Why this is harder here than in a normal app
|
||||
|
||||
RunicGateway is **self-hosted per shard**. There is no single canonical domain — every shard owner
|
||||
runs the website on **their own** domain (`play.exampleshard.com`, `uo.anothershard.net`, …). App
|
||||
Links verification is **per-domain**: the domain must serve
|
||||
|
||||
```
|
||||
https://<shard-domain>/.well-known/assetlinks.json
|
||||
```
|
||||
|
||||
asserting the Android app's **package name** + **signing-certificate SHA-256 fingerprint**.
|
||||
|
||||
That is only half the problem. The other half is an Android platform constraint that decides the whole
|
||||
shape of the app side:
|
||||
|
||||
> **`android:autoVerify` needs a *literal* host at build time.** An intent-filter's `<data android:host>`
|
||||
> is a static string in the merged manifest; there is no "any host" or runtime host. A **single
|
||||
> published multi-tenant APK therefore cannot autoVerify an open-ended set of shard domains** — the set
|
||||
> is not known when the APK is built.
|
||||
|
||||
So App Links here are **not** a drop-in replacement for the custom scheme. They split into two pieces
|
||||
that ship independently:
|
||||
|
||||
1. **Server (`assetlinks.json`) — shippable now, benefits any App-Links-capable build.** Every shard
|
||||
can auto-serve its Digital Asset Links statement behind an admin toggle. This is a pure add and is
|
||||
implemented on `feat/mobile-app-links`.
|
||||
2. **App (`autoVerify` intent-filter) — a *build-time* opt-in.** Because the host must be baked in,
|
||||
App Links are available to:
|
||||
- a **white-label / first-party build** that bakes one shard's host (`-PappLinkHost=play.myshard.com`);
|
||||
- a future **canonical relay domain** (`runicgateway.app`, PLAN §14 — *not yet secured*) that all
|
||||
shards could bounce their final callback through, autoVerified by the generic build.
|
||||
|
||||
The **generic multi-tenant build bakes no host and stays custom-scheme-only** — correct and safe.
|
||||
|
||||
The custom scheme is never removed. It is the fallback on every build, for every shard, always.
|
||||
|
||||
## 3. Server design — `feat/mobile-app-links`
|
||||
|
||||
### 3.1 Auto-served `assetlinks.json`
|
||||
|
||||
- **Route:** `GET /.well-known/assetlinks.json`, served at the **web root** (outside `/api/v1`, before
|
||||
the SPA catch-all) in `server/src/app.js`.
|
||||
- **Gate:** the admin setting `mobile_app_links_enabled` (default **off**). Off ⇒ the route **404s** and
|
||||
the app stays on the custom scheme for that shard. On ⇒ the shard opts into App Links.
|
||||
- **Body:** the Digital Asset Links statement for the fixed package `com.runicgateway.app` and the
|
||||
release signing cert SHA-256 fingerprint(s):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.runicgateway.app",
|
||||
"sha256_cert_fingerprints": ["AB:CD:…"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- **Fingerprint source:** env `MOBILE_APP_CERT_SHA256` — comma-separated (supports **cert rotation** and
|
||||
a debug + release cert during testing). It is a **constant of the published app**, identical for every
|
||||
shard, so it is a shipped/env default, not something each owner types. The package name is likewise
|
||||
fixed (`MOBILE_APP_PACKAGE`, default `com.runicgateway.app`).
|
||||
- **Enabled but no fingerprint configured ⇒ 404** (+ a one-time warn): serving a statement with no
|
||||
fingerprint asserts nothing and would only mislead the verifier.
|
||||
- Response is `application/json`, `Cache-Control: public, max-age=3600` (the Play verifier and the OS
|
||||
re-fetch it; it changes only on a cert rotation).
|
||||
|
||||
### 3.2 Redirect-URI allowlist extension
|
||||
|
||||
`mobileSso.controller` validates the app's `redirect_uri` by **exact match** against
|
||||
`MOBILE_AUTH_REDIRECT_URIS` (default `runicgateway://auth/callback`). App Links add exactly one more
|
||||
acceptable value, and **only when the toggle is on**:
|
||||
|
||||
- When `mobile_app_links_enabled`, `/start` additionally accepts the **self-origin** HTTPS callback
|
||||
`https://<request-host>/mobile/callback` (derived from the request/`APP_BASE_URL`, never from
|
||||
attacker-controlled input). Still **exact match** — never a prefix match.
|
||||
- The static custom-scheme allowlist is never narrowed; the HTTPS entry is *additive*.
|
||||
- No new table or schema: the check reads the one boolean setting.
|
||||
|
||||
### 3.3 Public settings advertise the capability
|
||||
|
||||
`settings.getPublic()` gains `mobileAppLinks: <bool>` (mirrors the toggle) so a client can tell whether
|
||||
a shard opted in before requesting an HTTPS `redirect_uri` (a white-label build uses it to avoid asking
|
||||
for a callback the server would reject).
|
||||
|
||||
## 4. App design — `feat/app-links`
|
||||
|
||||
### 4.1 Build-time host (`appLinkHost`)
|
||||
|
||||
- Gradle property `appLinkHost` (default empty). Wired in `app/build.gradle.kts` into **both**:
|
||||
- `BuildConfig.APP_LINK_HOST` — read by `SsoAuthManager` to decide the redirect;
|
||||
- `manifestPlaceholders["appLinkHost"]` — substituted into the App Link intent-filter's host.
|
||||
- **Default (generic build):** empty ⇒ `BuildConfig.APP_LINK_HOST = ""` and the placeholder falls back
|
||||
to the reserved sentinel `runic-gateway.invalid` (RFC 6761 — never resolves), so the `autoVerify`
|
||||
filter is **inert**: it matches no real link and verification simply never succeeds. No custom-scheme
|
||||
behaviour changes.
|
||||
- **White-label build:** `./gradlew assembleRelease -PappLinkHost=play.myshard.com` bakes that one host
|
||||
into the filter and enables the HTTPS redirect for that host.
|
||||
|
||||
### 4.2 Manifest
|
||||
|
||||
A second intent-filter on `MainActivity`, alongside the unchanged custom-scheme one:
|
||||
|
||||
```xml
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="${appLinkHost}"
|
||||
android:path="/mobile/callback" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
### 4.3 `SsoAuthManager` (pure Kotlin, unit-tested on the JVM)
|
||||
|
||||
- **Redirect selection in `buildStartUrl`:** request the HTTPS `redirect_uri`
|
||||
`https://<pairedHost>/mobile/callback` **iff** `BuildConfig.APP_LINK_HOST` is non-blank *and* equals
|
||||
the paired base-URL host (case-insensitive); otherwise the fixed custom-scheme `REDIRECT_URI`. A
|
||||
white-label build that bakes the host is responsible for enabling the server toggle too (§3.2).
|
||||
- **Verified-callback matcher + host-trust check:** a new `matchesAppLinkCallback(scheme, host, path)`
|
||||
accepts only `scheme == https`, `path == /mobile/callback`, and **`host == the paired base-URL host`**.
|
||||
The paired-host equality is defense-in-depth: even though `autoVerify` already means only a real,
|
||||
opted-in shard domain can route here, the app still refuses any HTTPS callback whose host isn't the
|
||||
shard it is currently paired to.
|
||||
- The rest is unchanged: both matchers feed the *same* `complete(state, code, error)` → `/exchange` →
|
||||
`SessionManager.onSignedIn`. There is no second auth path.
|
||||
|
||||
### 4.4 `MainActivity`
|
||||
|
||||
`handleSsoCallback` routes a VIEW intent through **`matchesCallback(...) || matchesAppLinkCallback(...)`**;
|
||||
everything downstream (state check, exchange, sign-in) is shared. Custom-scheme and App Link callbacks
|
||||
are indistinguishable past the edge.
|
||||
|
||||
## 5. Turning it on for a shard
|
||||
|
||||
1. Publish/point the app build at the shard host (`-PappLinkHost=<host>`) — or use the generic build and
|
||||
leave App Links off.
|
||||
2. Set `MOBILE_APP_CERT_SHA256` (release cert fingerprint) in the website env.
|
||||
3. Admin → Shard/Settings: enable **App Links** (`mobile_app_links_enabled`).
|
||||
4. Verify `https://<host>/.well-known/assetlinks.json` returns the statement; confirm Android verifies
|
||||
(`adb shell pm get-app-links com.runicgateway.app`).
|
||||
|
||||
If any step is skipped the app transparently keeps using the custom scheme — nothing breaks.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Server (`node --test`):** route 404s when the toggle is off; 404s when on but no fingerprint;
|
||||
returns the correct statement + content-type when on and configured; the redirect allowlist accepts
|
||||
`https://<host>/mobile/callback` only when enabled and rejects it otherwise (custom scheme always
|
||||
accepted).
|
||||
- **App (JVM unit tests):** `matchesAppLinkCallback` accepts only https + `/mobile/callback` + the paired
|
||||
host and rejects a foreign host / http / wrong path; `buildStartUrl` requests the HTTPS redirect only
|
||||
when the baked host matches the paired host, else the custom scheme.
|
||||
|
||||
## 7. What does *not* change
|
||||
|
||||
- The bridge's server design (PKCE Layer A/B, single-use codes, `/start` + `/exchange`) is untouched;
|
||||
App Links are *one more allowlist entry* + *one static file route*. That is the whole point of keeping
|
||||
the allowlist exact-match and configurable from day one.
|
||||
- The custom scheme remains on every build and is the permanent fallback.
|
||||
- No change to `servuo-plugins/` — App Links are entirely a website ↔ app concern.
|
||||
204
android/COVERAGE_PLAN.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Android App — Test Coverage Plan
|
||||
|
||||
**Goal:** clear the SonarQube coverage quality gate (`new_coverage ≥ 50%`) for
|
||||
`Runic-Gateway-Android-app`, and leave a durable unit-test culture behind it. Companion to
|
||||
[`PLAN.md`](./PLAN.md) §12.1 (the JaCoCo wiring that made coverage measurable).
|
||||
|
||||
## 1. Current state (2026-07-22, post `Android-app#26`)
|
||||
|
||||
The JaCoCo→Sonar wiring is live on `main`, so coverage is now real — and the gate is **failing**:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Quality gate | **ERROR** (one condition) |
|
||||
| `new_coverage` | **16.4%** (threshold ≥ 50%) |
|
||||
| overall `coverage` | 16.3% |
|
||||
| lines to cover | 3,188 |
|
||||
| covered | 542 |
|
||||
|
||||
Everything else on the gate is green (reliability/security/maintainability **A**, duplication 0%).
|
||||
Because this is the *first* measured version, Sonar's "new code" window is essentially the whole
|
||||
codebase, so `new_coverage ≈ overall coverage` — to pass we need to roughly **triple** covered
|
||||
lines, from 542 to ~1,600.
|
||||
|
||||
### Where the uncovered lines are
|
||||
|
||||
Bucketed from Sonar's per-file `uncovered_lines` (exclusions from #26 already applied, so `*Screen.kt`
|
||||
is absent):
|
||||
|
||||
| Bucket | Files | Lines to cover | Covered % | Verdict |
|
||||
|---|--:|--:|--:|---|
|
||||
| **ViewModels** | 28 | 1,153 | **0.1%** | **Test** — the dominant lever; no ViewModel has any test |
|
||||
| **DTOs** | 12 | 648 | 26.7% | **Test** — trivial (serialization); a pattern already exists |
|
||||
| **Repositories** | 11 | 274 | 11.3% | **Test** — fake the API interface |
|
||||
| **core/\* (logic)** | 21 | 365 | 56.2% | **Test** — top up the partially-covered ones |
|
||||
| UI composables (`*Components.kt`, `BlockRenderer`, …) | 7 | 255 | 3.9% | **Exclude** — not JVM-unit-testable, and `*Screen.kt`'s exclusion missed them |
|
||||
| core/push (Android services) | 4 | ~191 | ~0% | **Exclude** (or Robolectric later) — foreground service / notifications |
|
||||
| core/auth `Encrypted*` stores | 3 | 76 | 0% | **Exclude** — Android Keystore / EncryptedSharedPreferences |
|
||||
| framework glue (`di/`, `RunicGatewayApp`, `LocalAssetResolver`) | 3 | ~17 | 0% | **Exclude** |
|
||||
|
||||
**Two levers, applied together:** (a) stop *counting* code a JVM unit test physically cannot execute,
|
||||
and (b) actually *test* the logic — ViewModels, DTOs, repositories, core utilities.
|
||||
|
||||
## 2. Strategy & projected math
|
||||
|
||||
Numbers below are line-coverage projections against Sonar's `lines_to_cover`. They are estimates, but
|
||||
grounded in the current per-bucket totals.
|
||||
|
||||
### Phase 0 — Broaden coverage exclusions (no tests; ~½ day)
|
||||
|
||||
Move non-unit-testable code out of the **coverage** denominator (it stays in *analysis* — bugs and
|
||||
smells are still reported). Extend `sonar.coverage.exclusions` in `sonar-project.properties`:
|
||||
|
||||
```properties
|
||||
sonar.coverage.exclusions=\
|
||||
app/src/main/java/**/ui/**/*Screen.kt,\
|
||||
app/src/main/java/**/ui/**/*Screen*.kt,\
|
||||
app/src/main/java/**/ui/**/*Components.kt,\
|
||||
app/src/main/java/**/ui/page/BlockRenderer.kt,\
|
||||
app/src/main/java/**/ui/components/**,\
|
||||
app/src/main/java/**/ui/shard/FrameFields.kt,\
|
||||
app/src/main/java/**/ui/theme/**,\
|
||||
app/src/main/java/**/ui/LocalAssetResolver.kt,\
|
||||
app/src/main/java/**/RunicApp.kt,\
|
||||
app/src/main/java/**/MainActivity.kt,\
|
||||
app/src/main/java/**/*Application.kt,\
|
||||
app/src/main/java/**/RunicGatewayApp.kt,\
|
||||
app/src/main/java/**/di/**,\
|
||||
app/src/main/java/**/core/push/PushService.kt,\
|
||||
app/src/main/java/**/core/push/PushManager.kt,\
|
||||
app/src/main/java/**/core/push/PushNotifier.kt,\
|
||||
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
|
||||
app/src/main/java/**/core/auth/Encrypted*.kt
|
||||
```
|
||||
|
||||
> Verify each glob targets composable-only / framework-only files before committing (e.g. confirm
|
||||
> `FrameFields.kt` holds no testable logic). Keep the *pure-logic* push files in coverage
|
||||
> (`PushPreferences`, `PushTickle`, `NtfyTopic`, `PushStreams`) — they already have tests.
|
||||
|
||||
Effect: denominator ~3,188 → ~2,650; covered ~542 → ~532. **Coverage ≈ 20%.** (Removing ~540 lines
|
||||
that were ~2% covered.)
|
||||
|
||||
### Phase 1 — DTO serialization tests (highest ROI; ~1 day) → ~34%
|
||||
|
||||
DTOs are `@Serializable` data classes; test them with kotlinx-serialization round-trips against
|
||||
representative backend JSON. The pattern already exists (`AccountDtoTest`, `AuthDtoTest`,
|
||||
`NotificationsDtoTest`, `PlayerShardDtoTest`, `ShardDtoTest`). Add/extend:
|
||||
|
||||
- **New:** `AdminDto` (111 uncov — biggest single file), `WikiDto` (51), `PublicDto` (32),
|
||||
`PageDto` (16), `PostDto` (12), `ContactDto` (11), `SsoDto`, `PageDto`.
|
||||
- **Extend to ~85%:** `PlayerShardDto` (30.8%), `ShardDto` (35.6%), `AccountDto` (50.7%),
|
||||
`AuthDto` (47.4%).
|
||||
|
||||
Target DTOs to ~85%: **+~380 covered lines** → covered ~912 / ~2,650 ≈ **34%**.
|
||||
|
||||
### Phase 2 — ViewModel tests (the big one; ~3–4 days) → clears the gate
|
||||
|
||||
28 ViewModels, ~1,153 lines, currently 0%. This is where the gate is won. Requires a small test
|
||||
harness (§3). Each test drives the VM with fake collaborators and asserts `UiState` transitions
|
||||
(loading → success/error, form validation, actions).
|
||||
|
||||
Priority by uncovered lines:
|
||||
|
||||
1. `LoginViewModel` (116), `AccountViewModel` (104), `CharactersViewModel` (77),
|
||||
`AdminContentViewModel` (74), `NotificationsViewModel` (66), `TrustedDevicesViewModel` (63),
|
||||
`ShardViewModel` (59)
|
||||
2. `HousesViewModel` (51), `GovernorsViewModel` (47), `AdminDashboardViewModel` (43),
|
||||
`AdminSupportViewModel` (41), `ChampsViewModel` (40), `AdminModerationViewModel` (40),
|
||||
`GuildsViewModel` (39), `RecoveryCodesViewModel` (38), `ConnectViewModel` (37),
|
||||
`ContactViewModel` (36), `VendorsViewModel` (32), `AppViewModel` (29)
|
||||
3. The small ones (`PostViewModel`, `NewsViewModel`, `WikiViewModel`, `CharacterViewModel`,
|
||||
`MyHousesViewModel`, `WikiPageViewModel`, `PageViewModel`, `HomeViewModel`, `SessionViewModel`)
|
||||
|
||||
Target ViewModels to ~70%: **+~800 covered lines** → covered ~1,712 / ~2,650 ≈ **65%. ✅ Gate passes.**
|
||||
|
||||
> Phases 0 + 2 alone (skipping DTOs) already reach ~50.5% — but DTOs are cheap insurance and Phase 1
|
||||
> lands first because it de-risks the harness work.
|
||||
|
||||
### Phase 3 — Repository tests (~1–2 days) → margin
|
||||
|
||||
Repositories map API `Response`/exceptions to `ApiResult`; test with a fake `*Api` interface (or
|
||||
OkHttp `MockWebServer`). Priority: `AuthRepository` (89), `ConnectionRepository` (43),
|
||||
`ShardRepository` (27), `AdminRepository` (21), then the small content/wiki/player repos. Target ~70%:
|
||||
**+~160 lines** → buffer well above 50% and resilience as the new-code window narrows.
|
||||
|
||||
### Phase 4 — core/net + core/auth top-up (~½–1 day) → durability
|
||||
|
||||
Fill the partially-covered utilities: `TokenAuthenticator` (31), `ShardStreamClient` (36),
|
||||
`HostSelectionInterceptor` (9), `ApiResult` (6), `WebHandoff`, `WebsiteUrls`, `DeviceNameProvider`,
|
||||
`ServerPreferences`, `AppConfig`.
|
||||
|
||||
### Trajectory
|
||||
|
||||
| After | Denominator | Covered | Coverage |
|
||||
|---|--:|--:|--:|
|
||||
| Today | 3,188 | 542 | 16.3% |
|
||||
| Phase 0 (exclusions) | ~2,650 | ~532 | ~20% |
|
||||
| Phase 1 (DTOs) | ~2,650 | ~912 | ~34% |
|
||||
| **Phase 2 (ViewModels)** | ~2,650 | ~1,712 | **~65% ✅** |
|
||||
| Phase 3 (repos) | ~2,650 | ~1,872 | ~71% |
|
||||
| Phase 4 (core) | ~2,650 | ~2,000+ | ~75%+ |
|
||||
|
||||
## 3. Test infrastructure to add
|
||||
|
||||
The existing suite tests pure-logic classes only; ViewModel/coroutine testing needs a little scaffold.
|
||||
`kotlinx-coroutines-test` is already a `testImplementation` dependency.
|
||||
|
||||
**`MainDispatcherRule`** (JUnit4) — swaps `Dispatchers.Main` (used by `viewModelScope`) for a test
|
||||
dispatcher:
|
||||
|
||||
```kotlin
|
||||
// app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
|
||||
) : TestWatcher() {
|
||||
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
|
||||
override fun finished(d: Description) = Dispatchers.resetMain()
|
||||
}
|
||||
```
|
||||
|
||||
**ViewModel test pattern** — hand-written fakes (matches the repo's existing no-mock convention; no new
|
||||
dependency):
|
||||
|
||||
```kotlin
|
||||
class LoginViewModelTest {
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private class FakeAuthRepository(var result: LoginResult) : AuthRepository { /* stub the seam */ }
|
||||
|
||||
@Test fun `blank credentials surface INVALID_CREDENTIALS without a network call`() = runTest {
|
||||
val vm = LoginViewModel(FakeAuthRepository(LoginResult.Success), /* … */)
|
||||
vm.submit()
|
||||
assertEquals(LoginError.INVALID_CREDENTIALS, vm.state.value.error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Assert on `viewModel.state.value` after `advanceUntilIdle()`; or collect the `StateFlow` in a
|
||||
background `launch` when you need to see intermediate (loading) states.
|
||||
- **Optional deps (decide once):** `mockk` would cut fake-writing for wide interfaces, and `turbine`
|
||||
simplifies Flow assertions. Recommendation: **stay with hand fakes** to match convention; revisit
|
||||
only if VM tests get boilerplate-heavy.
|
||||
|
||||
## 4. Execution notes
|
||||
|
||||
- CI already runs `./gradlew testDebugUnitTest jacocoTestReport` before the scan (`sonarqube.yml`),
|
||||
so new tests count automatically on merge to `main`. Locally on this machine: JDK 21 needs
|
||||
`-Pksp.incremental=false`.
|
||||
- Sonar recomputes the gate on the post-merge scan; there's no way to fully confirm the number
|
||||
pre-merge. Land phases as separate PRs (0, 1, 2, …) so coverage climbs visibly and reviews stay
|
||||
small.
|
||||
- `sonar.coverage.exclusions` removes files from **coverage only** — analysis still flags bugs/smells
|
||||
in them, so excluding UI/framework code is safe.
|
||||
- **Android-framework code deferred, not abandoned:** push services and `Encrypted*` stores are
|
||||
excluded now; if we want them covered later, add Robolectric (`testImplementation`) and a
|
||||
`RobolectricTestRunner` suite rather than instrumented tests, to keep it in the fast JVM `test`
|
||||
source set the scan already consumes.
|
||||
|
||||
## 5. Definition of done
|
||||
|
||||
- `new_coverage ≥ 50%` and the SonarQube quality gate is **green**.
|
||||
- `MainDispatcherRule` + a documented ViewModel test pattern exist and are reused.
|
||||
- Coverage exclusions list only genuinely non-unit-testable files (UI composables, Android-framework
|
||||
glue) — no ViewModel, repository, DTO, or pure core-logic file is excluded.
|
||||
1073
android/PLAN.md
Normal file
348
android/PROJECT_TREE.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Android App — Project Tree
|
||||
|
||||
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
|
||||
> the [`RunicGateway/Android-app`](https://gitea.whitlocktech.com/RunicGateway/Android-app) repository, which
|
||||
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
|
||||
> by hand — changes will be overwritten by the next sync.
|
||||
|
||||
A snapshot of the tracked files in the repository (build output, dependencies, and other
|
||||
git-ignored paths are excluded).
|
||||
|
||||
```text
|
||||
android-app/
|
||||
├── .gitea/
|
||||
│ ├── scripts/
|
||||
│ │ └── gen_tree.py
|
||||
│ └── workflows/
|
||||
│ ├── pr-checks.yml
|
||||
│ ├── release.yml
|
||||
│ ├── sonarqube.yml
|
||||
│ └── sync-project-tree.yml
|
||||
├── app/
|
||||
│ ├── licenses/
|
||||
│ │ └── Cinzel-OFL.txt
|
||||
│ ├── src/
|
||||
│ │ ├── debug/
|
||||
│ │ │ └── res/
|
||||
│ │ │ └── xml/
|
||||
│ │ │ └── network_security_config.xml
|
||||
│ │ ├── main/
|
||||
│ │ │ ├── java/
|
||||
│ │ │ │ └── com/
|
||||
│ │ │ │ └── runicgateway/
|
||||
│ │ │ │ └── app/
|
||||
│ │ │ │ ├── core/
|
||||
│ │ │ │ │ ├── auth/
|
||||
│ │ │ │ │ │ ├── sso/
|
||||
│ │ │ │ │ │ │ ├── EncryptedPendingSsoStore.kt
|
||||
│ │ │ │ │ │ │ ├── PendingSsoStore.kt
|
||||
│ │ │ │ │ │ │ ├── Pkce.kt
|
||||
│ │ │ │ │ │ │ └── SsoAuthManager.kt
|
||||
│ │ │ │ │ │ ├── DeviceNameProvider.kt
|
||||
│ │ │ │ │ │ ├── EncryptedTokenStore.kt
|
||||
│ │ │ │ │ │ ├── EncryptedTrustTokenStore.kt
|
||||
│ │ │ │ │ │ ├── Session.kt
|
||||
│ │ │ │ │ │ ├── SessionManager.kt
|
||||
│ │ │ │ │ │ ├── TokenStore.kt
|
||||
│ │ │ │ │ │ └── TrustTokenStore.kt
|
||||
│ │ │ │ │ ├── net/
|
||||
│ │ │ │ │ │ ├── AuthInterceptor.kt
|
||||
│ │ │ │ │ │ ├── BaseUrlHolder.kt
|
||||
│ │ │ │ │ │ ├── HostSelectionInterceptor.kt
|
||||
│ │ │ │ │ │ ├── Http.kt
|
||||
│ │ │ │ │ │ ├── ServerUrl.kt
|
||||
│ │ │ │ │ │ ├── ShardStream.kt
|
||||
│ │ │ │ │ │ ├── ShardStreamClient.kt
|
||||
│ │ │ │ │ │ ├── ShardStreamEvent.kt
|
||||
│ │ │ │ │ │ ├── TokenAuthenticator.kt
|
||||
│ │ │ │ │ │ └── UserAgentInterceptor.kt
|
||||
│ │ │ │ │ ├── prefs/
|
||||
│ │ │ │ │ │ └── ServerPreferences.kt
|
||||
│ │ │ │ │ ├── push/
|
||||
│ │ │ │ │ │ ├── NtfyStreamClient.kt
|
||||
│ │ │ │ │ │ ├── NtfyTopic.kt
|
||||
│ │ │ │ │ │ ├── PushManager.kt
|
||||
│ │ │ │ │ │ ├── PushNotifier.kt
|
||||
│ │ │ │ │ │ ├── PushPreferences.kt
|
||||
│ │ │ │ │ │ ├── PushService.kt
|
||||
│ │ │ │ │ │ ├── PushStreams.kt
|
||||
│ │ │ │ │ │ └── PushTickle.kt
|
||||
│ │ │ │ │ ├── result/
|
||||
│ │ │ │ │ │ └── ApiResult.kt
|
||||
│ │ │ │ │ ├── web/
|
||||
│ │ │ │ │ │ ├── WebHandoff.kt
|
||||
│ │ │ │ │ │ └── WebsiteUrls.kt
|
||||
│ │ │ │ │ └── AppConfig.kt
|
||||
│ │ │ │ ├── data/
|
||||
│ │ │ │ │ ├── api/
|
||||
│ │ │ │ │ │ ├── dto/
|
||||
│ │ │ │ │ │ │ ├── AccountDto.kt
|
||||
│ │ │ │ │ │ │ ├── AdminDto.kt
|
||||
│ │ │ │ │ │ │ ├── AuthDto.kt
|
||||
│ │ │ │ │ │ │ ├── ContactDto.kt
|
||||
│ │ │ │ │ │ │ ├── NotificationsDto.kt
|
||||
│ │ │ │ │ │ │ ├── PageDto.kt
|
||||
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
|
||||
│ │ │ │ │ │ │ ├── PostDto.kt
|
||||
│ │ │ │ │ │ │ ├── PublicDto.kt
|
||||
│ │ │ │ │ │ │ ├── ShardDto.kt
|
||||
│ │ │ │ │ │ │ ├── SsoDto.kt
|
||||
│ │ │ │ │ │ │ └── WikiDto.kt
|
||||
│ │ │ │ │ │ ├── AdminApi.kt
|
||||
│ │ │ │ │ │ ├── AuthApi.kt
|
||||
│ │ │ │ │ │ ├── AuthRefreshApi.kt
|
||||
│ │ │ │ │ │ ├── MeApi.kt
|
||||
│ │ │ │ │ │ ├── NotificationsApi.kt
|
||||
│ │ │ │ │ │ ├── PlayerShardApi.kt
|
||||
│ │ │ │ │ │ ├── PublicApi.kt
|
||||
│ │ │ │ │ │ └── SsoApi.kt
|
||||
│ │ │ │ │ └── repository/
|
||||
│ │ │ │ │ ├── AccountRepository.kt
|
||||
│ │ │ │ │ ├── AdminRepository.kt
|
||||
│ │ │ │ │ ├── AuthRepository.kt
|
||||
│ │ │ │ │ ├── ConnectionRepository.kt
|
||||
│ │ │ │ │ ├── ContactRepository.kt
|
||||
│ │ │ │ │ ├── ContentRepository.kt
|
||||
│ │ │ │ │ ├── NotificationsRepository.kt
|
||||
│ │ │ │ │ ├── PlayerShardRepository.kt
|
||||
│ │ │ │ │ ├── SettingsRepository.kt
|
||||
│ │ │ │ │ ├── ShardRepository.kt
|
||||
│ │ │ │ │ └── WikiRepository.kt
|
||||
│ │ │ │ ├── di/
|
||||
│ │ │ │ │ ├── AppModule.kt
|
||||
│ │ │ │ │ ├── NetworkModule.kt
|
||||
│ │ │ │ │ └── StorageModule.kt
|
||||
│ │ │ │ ├── ui/
|
||||
│ │ │ │ │ ├── admin/
|
||||
│ │ │ │ │ │ ├── AdminContentScreen.kt
|
||||
│ │ │ │ │ │ ├── AdminContentViewModel.kt
|
||||
│ │ │ │ │ │ ├── AdminDashboardScreen.kt
|
||||
│ │ │ │ │ │ ├── AdminDashboardViewModel.kt
|
||||
│ │ │ │ │ │ ├── AdminModerationScreen.kt
|
||||
│ │ │ │ │ │ ├── AdminModerationViewModel.kt
|
||||
│ │ │ │ │ │ ├── AdminSupportScreen.kt
|
||||
│ │ │ │ │ │ └── AdminSupportViewModel.kt
|
||||
│ │ │ │ │ ├── auth/
|
||||
│ │ │ │ │ │ ├── AccountScreen.kt
|
||||
│ │ │ │ │ │ ├── AccountViewModel.kt
|
||||
│ │ │ │ │ │ ├── LoginScreen.kt
|
||||
│ │ │ │ │ │ ├── LoginViewModel.kt
|
||||
│ │ │ │ │ │ ├── RecoveryCodesScreen.kt
|
||||
│ │ │ │ │ │ ├── RecoveryCodesViewModel.kt
|
||||
│ │ │ │ │ │ ├── TrustedDevicesScreen.kt
|
||||
│ │ │ │ │ │ └── TrustedDevicesViewModel.kt
|
||||
│ │ │ │ │ ├── components/
|
||||
│ │ │ │ │ │ ├── HtmlText.kt
|
||||
│ │ │ │ │ │ ├── StateViews.kt
|
||||
│ │ │ │ │ │ └── ThemeComponents.kt
|
||||
│ │ │ │ │ ├── connect/
|
||||
│ │ │ │ │ │ ├── ConnectScreen.kt
|
||||
│ │ │ │ │ │ └── ConnectViewModel.kt
|
||||
│ │ │ │ │ ├── contact/
|
||||
│ │ │ │ │ │ ├── ContactScreen.kt
|
||||
│ │ │ │ │ │ └── ContactViewModel.kt
|
||||
│ │ │ │ │ ├── home/
|
||||
│ │ │ │ │ │ ├── HomeScreen.kt
|
||||
│ │ │ │ │ │ └── HomeViewModel.kt
|
||||
│ │ │ │ │ ├── navigation/
|
||||
│ │ │ │ │ │ ├── Menu.kt
|
||||
│ │ │ │ │ │ └── Routes.kt
|
||||
│ │ │ │ │ ├── news/
|
||||
│ │ │ │ │ │ ├── NewsScreen.kt
|
||||
│ │ │ │ │ │ ├── NewsViewModel.kt
|
||||
│ │ │ │ │ │ ├── PostScreen.kt
|
||||
│ │ │ │ │ │ └── PostViewModel.kt
|
||||
│ │ │ │ │ ├── notifications/
|
||||
│ │ │ │ │ │ ├── NotificationsScreen.kt
|
||||
│ │ │ │ │ │ └── NotificationsViewModel.kt
|
||||
│ │ │ │ │ ├── page/
|
||||
│ │ │ │ │ │ ├── BlockRenderer.kt
|
||||
│ │ │ │ │ │ ├── PageScreen.kt
|
||||
│ │ │ │ │ │ └── PageViewModel.kt
|
||||
│ │ │ │ │ ├── player/
|
||||
│ │ │ │ │ │ ├── CharacterSheetScreen.kt
|
||||
│ │ │ │ │ │ ├── CharactersScreen.kt
|
||||
│ │ │ │ │ │ ├── CharactersViewModel.kt
|
||||
│ │ │ │ │ │ ├── CharacterViewModel.kt
|
||||
│ │ │ │ │ │ ├── MyHousesScreen.kt
|
||||
│ │ │ │ │ │ ├── MyHousesViewModel.kt
|
||||
│ │ │ │ │ │ ├── VendorsScreen.kt
|
||||
│ │ │ │ │ │ └── VendorsViewModel.kt
|
||||
│ │ │ │ │ ├── session/
|
||||
│ │ │ │ │ │ └── SessionViewModel.kt
|
||||
│ │ │ │ │ ├── shard/
|
||||
│ │ │ │ │ │ ├── ChampsScreen.kt
|
||||
│ │ │ │ │ │ ├── ChampsViewModel.kt
|
||||
│ │ │ │ │ │ ├── FrameFields.kt
|
||||
│ │ │ │ │ │ ├── GovernorsScreen.kt
|
||||
│ │ │ │ │ │ ├── GovernorsViewModel.kt
|
||||
│ │ │ │ │ │ ├── GuildsScreen.kt
|
||||
│ │ │ │ │ │ ├── GuildsViewModel.kt
|
||||
│ │ │ │ │ │ ├── HousesScreen.kt
|
||||
│ │ │ │ │ │ ├── HousesViewModel.kt
|
||||
│ │ │ │ │ │ ├── LiveBoard.kt
|
||||
│ │ │ │ │ │ ├── ShardComponents.kt
|
||||
│ │ │ │ │ │ ├── ShardEventText.kt
|
||||
│ │ │ │ │ │ ├── ShardScreen.kt
|
||||
│ │ │ │ │ │ └── ShardViewModel.kt
|
||||
│ │ │ │ │ ├── theme/
|
||||
│ │ │ │ │ │ ├── BrandColor.kt
|
||||
│ │ │ │ │ │ ├── Color.kt
|
||||
│ │ │ │ │ │ ├── Font.kt
|
||||
│ │ │ │ │ │ ├── Theme.kt
|
||||
│ │ │ │ │ │ └── Type.kt
|
||||
│ │ │ │ │ ├── wiki/
|
||||
│ │ │ │ │ │ ├── WikiPageScreen.kt
|
||||
│ │ │ │ │ │ ├── WikiPageViewModel.kt
|
||||
│ │ │ │ │ │ ├── WikiScreen.kt
|
||||
│ │ │ │ │ │ └── WikiViewModel.kt
|
||||
│ │ │ │ │ ├── AppViewModel.kt
|
||||
│ │ │ │ │ ├── LocalAssetResolver.kt
|
||||
│ │ │ │ │ ├── RunicApp.kt
|
||||
│ │ │ │ │ └── UiState.kt
|
||||
│ │ │ │ ├── MainActivity.kt
|
||||
│ │ │ │ └── RunicGatewayApp.kt
|
||||
│ │ │ ├── res/
|
||||
│ │ │ │ ├── drawable/
|
||||
│ │ │ │ │ └── ic_launcher_background.xml
|
||||
│ │ │ │ ├── drawable-anydpi/
|
||||
│ │ │ │ │ └── ic_stat_name.xml
|
||||
│ │ │ │ ├── drawable-hdpi/
|
||||
│ │ │ │ │ └── ic_stat_name.png
|
||||
│ │ │ │ ├── drawable-mdpi/
|
||||
│ │ │ │ │ └── ic_stat_name.png
|
||||
│ │ │ │ ├── drawable-xhdpi/
|
||||
│ │ │ │ │ └── ic_stat_name.png
|
||||
│ │ │ │ ├── drawable-xxhdpi/
|
||||
│ │ │ │ │ └── ic_stat_name.png
|
||||
│ │ │ │ ├── font/
|
||||
│ │ │ │ │ └── cinzel_variable.ttf
|
||||
│ │ │ │ ├── mipmap-anydpi-v26/
|
||||
│ │ │ │ │ ├── ic_launcher.xml
|
||||
│ │ │ │ │ └── ic_launcher_round.xml
|
||||
│ │ │ │ ├── mipmap-hdpi/
|
||||
│ │ │ │ │ ├── ic_launcher.webp
|
||||
│ │ │ │ │ ├── ic_launcher_foreground.webp
|
||||
│ │ │ │ │ └── ic_launcher_round.webp
|
||||
│ │ │ │ ├── mipmap-mdpi/
|
||||
│ │ │ │ │ ├── ic_launcher.webp
|
||||
│ │ │ │ │ ├── ic_launcher_foreground.webp
|
||||
│ │ │ │ │ └── ic_launcher_round.webp
|
||||
│ │ │ │ ├── mipmap-xhdpi/
|
||||
│ │ │ │ │ ├── ic_launcher.webp
|
||||
│ │ │ │ │ ├── ic_launcher_foreground.webp
|
||||
│ │ │ │ │ └── ic_launcher_round.webp
|
||||
│ │ │ │ ├── mipmap-xxhdpi/
|
||||
│ │ │ │ │ ├── ic_launcher.webp
|
||||
│ │ │ │ │ ├── ic_launcher_foreground.webp
|
||||
│ │ │ │ │ └── ic_launcher_round.webp
|
||||
│ │ │ │ ├── mipmap-xxxhdpi/
|
||||
│ │ │ │ │ ├── ic_launcher.webp
|
||||
│ │ │ │ │ ├── ic_launcher_foreground.webp
|
||||
│ │ │ │ │ └── ic_launcher_round.webp
|
||||
│ │ │ │ ├── values/
|
||||
│ │ │ │ │ ├── colors.xml
|
||||
│ │ │ │ │ ├── strings.xml
|
||||
│ │ │ │ │ └── themes.xml
|
||||
│ │ │ │ └── xml/
|
||||
│ │ │ │ ├── backup_rules.xml
|
||||
│ │ │ │ ├── data_extraction_rules.xml
|
||||
│ │ │ │ └── network_security_config.xml
|
||||
│ │ │ ├── AndroidManifest.xml
|
||||
│ │ │ └── ic_launcher-playstore.png
|
||||
│ │ └── test/
|
||||
│ │ └── java/
|
||||
│ │ └── com/
|
||||
│ │ └── runicgateway/
|
||||
│ │ └── app/
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ │ ├── sso/
|
||||
│ │ │ │ │ ├── PkceTest.kt
|
||||
│ │ │ │ │ └── SsoAuthManagerTest.kt
|
||||
│ │ │ │ └── SessionManagerTest.kt
|
||||
│ │ │ ├── net/
|
||||
│ │ │ │ ├── HostRewriteTest.kt
|
||||
│ │ │ │ ├── ServerUrlTest.kt
|
||||
│ │ │ │ └── ShardStreamClientTest.kt
|
||||
│ │ │ ├── push/
|
||||
│ │ │ │ ├── NtfyTopicTest.kt
|
||||
│ │ │ │ └── PushTickleTest.kt
|
||||
│ │ │ └── result/
|
||||
│ │ │ ├── ApiResultExtrasTest.kt
|
||||
│ │ │ └── ApiResultTest.kt
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ │ ├── dto/
|
||||
│ │ │ │ │ ├── AccountDtoTest.kt
|
||||
│ │ │ │ │ ├── AdminDtoTest.kt
|
||||
│ │ │ │ │ ├── AuthDtoTest.kt
|
||||
│ │ │ │ │ ├── AuthRequestDtoTest.kt
|
||||
│ │ │ │ │ ├── ContentDtoTest.kt
|
||||
│ │ │ │ │ ├── NotificationsDtoTest.kt
|
||||
│ │ │ │ │ ├── PlayerGameDataDtoTest.kt
|
||||
│ │ │ │ │ ├── PlayerShardDtoTest.kt
|
||||
│ │ │ │ │ ├── PublicDtoTest.kt
|
||||
│ │ │ │ │ ├── ShardBoardDtoTest.kt
|
||||
│ │ │ │ │ ├── ShardDtoTest.kt
|
||||
│ │ │ │ │ ├── SsoDtoTest.kt
|
||||
│ │ │ │ │ └── WikiDtoTest.kt
|
||||
│ │ │ │ └── fake/
|
||||
│ │ │ │ ├── FakeAdminApi.kt
|
||||
│ │ │ │ ├── FakePlayerShardApi.kt
|
||||
│ │ │ │ ├── FakePublicApi.kt
|
||||
│ │ │ │ └── FakeShardStream.kt
|
||||
│ │ │ └── repository/
|
||||
│ │ │ ├── AccountTrustedDevicesTest.kt
|
||||
│ │ │ └── ConnectionVersionGuardTest.kt
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── admin/
|
||||
│ │ │ │ ├── AdminContentViewModelTest.kt
|
||||
│ │ │ │ ├── AdminDashboardViewModelTest.kt
|
||||
│ │ │ │ ├── AdminModerationViewModelTest.kt
|
||||
│ │ │ │ └── AdminSupportViewModelTest.kt
|
||||
│ │ │ ├── contact/
|
||||
│ │ │ │ └── ContactViewModelTest.kt
|
||||
│ │ │ ├── navigation/
|
||||
│ │ │ │ └── MenuAccessTest.kt
|
||||
│ │ │ ├── notifications/
|
||||
│ │ │ │ └── NotificationRoutingTest.kt
|
||||
│ │ │ ├── player/
|
||||
│ │ │ │ ├── CharacterSheetHelpersTest.kt
|
||||
│ │ │ │ ├── CharactersViewModelTest.kt
|
||||
│ │ │ │ └── PlayerViewModelTest.kt
|
||||
│ │ │ ├── shard/
|
||||
│ │ │ │ ├── FrameFieldsTest.kt
|
||||
│ │ │ │ ├── LiveBoardTest.kt
|
||||
│ │ │ │ ├── ShardBoardViewModelTest.kt
|
||||
│ │ │ │ └── ShardEventTextTest.kt
|
||||
│ │ │ ├── theme/
|
||||
│ │ │ │ └── BrandColorTest.kt
|
||||
│ │ │ ├── ContentViewModelTest.kt
|
||||
│ │ │ └── UiStateTest.kt
|
||||
│ │ ├── util/
|
||||
│ │ │ ├── FakeApiSupport.kt
|
||||
│ │ │ └── MainDispatcherRule.kt
|
||||
│ │ └── ScaffoldSanityTest.kt
|
||||
│ ├── build.gradle.kts
|
||||
│ └── proguard-rules.pro
|
||||
├── gradle/
|
||||
│ ├── wrapper/
|
||||
│ │ ├── gradle-wrapper.jar
|
||||
│ │ └── gradle-wrapper.properties
|
||||
│ └── libs.versions.toml
|
||||
├── .gitattributes
|
||||
├── .gitignore
|
||||
├── build.gradle.kts
|
||||
├── CODE_OF_CONDUCT.md
|
||||
├── CONTRIBUTING.md
|
||||
├── CONTRIBUTORS.md
|
||||
├── gradle.properties
|
||||
├── gradlew
|
||||
├── gradlew.bat
|
||||
├── LICENSE.md
|
||||
├── README.md
|
||||
├── SECURITY.md
|
||||
├── settings.gradle.kts
|
||||
└── sonar-project.properties
|
||||
```
|
||||
BIN
android/screenshots/01-login-2fa-trust-device.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
android/screenshots/02-account-security-section.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
android/screenshots/03-trusted-devices.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
android/screenshots/04-recovery-codes-show-once.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
android/screenshots/05-recovery-code-login.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
49
android/screenshots/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Android app — trusted devices & recovery codes (live smoke test)
|
||||
|
||||
Screenshots from a live end-to-end smoke test of the trusted-device + MFA feature on
|
||||
the Android client (app PR `RunicGateway/Android-app#23`), captured against the local
|
||||
Node server (`127.0.0.1:3000`) and a `uomysticmoon` MariaDB, on an API 36 emulator.
|
||||
See `../PLAN.md §4.1.1` for the design and `../../website/TRUSTED_DEVICES_MFA.md` for
|
||||
the canonical contract.
|
||||
|
||||
| # | Screenshot | Shows |
|
||||
|---|---|---|
|
||||
| 1 | [`01-login-2fa-trust-device.png`](01-login-2fa-trust-device.png) | The `401 { totpRequired }` login step: the **authentication code** field, the **"Use a recovery code instead"** toggle, and the **"Trust this device (skip codes for 30 days)"** checkbox (ticked). |
|
||||
| 2 | [`02-account-security-section.png`](02-account-security-section.png) | The new **Security** section on the account screen linking to Trusted devices and Recovery codes. |
|
||||
| 3 | [`03-trusted-devices.png`](03-trusted-devices.png) | The **Trusted Devices** screen listing this device (`Google sdk_gphone64_x86_64` — the `device_name` sent at login) with revoke / trust-this-device / untrust-all. |
|
||||
| 4 | [`04-recovery-codes-show-once.png`](04-recovery-codes-show-once.png) | The **Recovery Codes** screen after a password-stepped regenerate: the one-time batch shown once with copy / share, and the updated remaining count. |
|
||||
| 5 | [`05-recovery-code-login.png`](05-recovery-code-login.png) | Signing in with a **single-use recovery code** instead of the authenticator code. |
|
||||
|
||||
## Verified flows (all passed)
|
||||
|
||||
1. **2FA login + "Trust this device"** → `200`, trust token stored; server logged `device trusted`.
|
||||
2. **Trust survives logout** — after signing out, a **password-only** sign-in skipped the
|
||||
TOTP step entirely (server: a clean `200` with **no** preceding `401 totpRequired`).
|
||||
This is the headline behaviour: the trust token is *only* consulted at a fresh login,
|
||||
so it must outlive logout (see PLAN §4.1.1).
|
||||
3. **Trusted Devices** — list, and the device's `last_used` stamp advancing after the
|
||||
trust-skip login.
|
||||
4. **Untrust all** — cleared the server rows **and** the local token; the next
|
||||
password-only sign-in correctly required the TOTP step again (server: `401`).
|
||||
5. **Recovery codes** — generate (password step-up, shown once) and a successful
|
||||
**recovery-code login** (server: `mobile login via recovery code` → `200`).
|
||||
|
||||
## Full step-by-step walkthrough
|
||||
|
||||
The five images above are the curated highlights. These `walkthrough-*` frames are the
|
||||
rest of the same smoke-test session, in flow order, for a complete record. (Pure
|
||||
automation-artifact frames — soft-keyboard popups, mid-transition spinners, and
|
||||
duplicate Home landings — are omitted; the raws that the highlights above supersede are
|
||||
not repeated here.)
|
||||
|
||||
| # | Screenshot | Shows |
|
||||
|---|---|---|
|
||||
| 1 | [`walkthrough-01-home-connected.png`](walkthrough-01-home-connected.png) | Home with the shard **Online** (already connected to the local server), signed out. |
|
||||
| 2 | [`walkthrough-02-drawer-signed-out.png`](walkthrough-02-drawer-signed-out.png) | Navigation drawer while signed out — **Sign in** entry. |
|
||||
| 3 | [`walkthrough-03-login-screen.png`](walkthrough-03-login-screen.png) | The native login screen (no SSO providers configured in dev). |
|
||||
| 4 | [`walkthrough-04-login-2fa-step.png`](walkthrough-04-login-2fa-step.png) | The `401 { totpRequired }` step with the fields empty — the **"Use a recovery code instead"** toggle and **"Trust this device"** checkbox before entry (companion to highlight #1, which shows them filled). |
|
||||
| 5 | [`walkthrough-05-drawer-signed-in.png`](walkthrough-05-drawer-signed-in.png) | Drawer once signed in — **My account**, Notifications, player groups, **Sign out**. |
|
||||
| 6 | [`walkthrough-06-account-overview.png`](walkthrough-06-account-overview.png) | Top of the account screen: identity, username/password, **two-factor ENABLED**. |
|
||||
| 7 | [`walkthrough-07-recovery-codes-before-generate.png`](walkthrough-07-recovery-codes-before-generate.png) | Recovery Codes screen before generating — **0 codes remaining** + the password-step-up form. |
|
||||
| 8 | [`walkthrough-08-trusted-devices-empty-after-untrust.png`](walkthrough-08-trusted-devices-empty-after-untrust.png) | Trusted Devices after **Untrust all** — "All devices untrusted." and the empty state. |
|
||||
| 9 | [`walkthrough-09-login-2fa-required-after-untrust.png`](walkthrough-09-login-2fa-required-after-untrust.png) | The next sign-in **re-prompting for the TOTP step** — proof that untrust-all cleared the local trust token. |
|
||||
BIN
android/screenshots/walkthrough-01-home-connected.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
android/screenshots/walkthrough-02-drawer-signed-out.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
android/screenshots/walkthrough-03-login-screen.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
android/screenshots/walkthrough-04-login-2fa-step.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
android/screenshots/walkthrough-05-drawer-signed-in.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
android/screenshots/walkthrough-06-account-overview.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 120 KiB |
98
android/theme-plan.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Android theme plan — mirroring the website frontend
|
||||
|
||||
This is a summary of the **website frontend theme** (source of truth:
|
||||
`website/client/src/styles/theme.css`, applied at runtime by
|
||||
`website/client/src/contexts/SiteContext.jsx`) so the native Android client can
|
||||
present a visually consistent brand. Where the web uses CSS custom properties,
|
||||
the Android equivalent is a Compose `MaterialTheme` `ColorScheme` + `Typography`.
|
||||
|
||||
## Overall character
|
||||
|
||||
A **dark, moody, "arcane fantasy" theme** — deep blue-black backgrounds, muted
|
||||
slate-blue accent, parchment-white text, and an engraved serif display face. It
|
||||
reads like a leather-and-moonlight fantasy ledger, not a bright consumer app.
|
||||
There is **no light mode** on the web; the app should ship dark-only to match.
|
||||
|
||||
## Color tokens
|
||||
|
||||
The web theme is a flat set of CSS variables under `:root`. Map them to Compose
|
||||
as follows (hex is authoritative):
|
||||
|
||||
| Web token | Hex | Role | Compose slot (suggested) |
|
||||
|-------------------|------------|----------------------------------------|-------------------------------|
|
||||
| `--bg` | `#0e1318` | App background | `background` |
|
||||
| `--bg-deep` | `#0b0f14` | Deepest surface / on-accent text | `surfaceDim` / `onPrimary` |
|
||||
| `--panel-a` | `#192231` | Card gradient top | `surface` |
|
||||
| `--panel-b` | `#141a21` | Card gradient bottom | `surfaceContainer` |
|
||||
| `--panel-flat` | `#11161d` | Flat panels, toolbars | `surfaceContainerLow` |
|
||||
| `--line` | `#2a3544` | Borders / dividers | `outline` |
|
||||
| `--line-soft` | `#1d2733` | Subtle row dividers | `outlineVariant` |
|
||||
| `--accent` | `#7f99bd` | **Primary accent** (brand-overridable) | `primary` |
|
||||
| `--accent-bright` | `#cdd9e8` | Primary button fill, active states | `primaryContainer` / bright |
|
||||
| `--ink` | `#eef3f8` | Highest-contrast text | `onBackground` |
|
||||
| `--head` | `#e6edf6` | Headings | heading color |
|
||||
| `--text` | `#c4cdd8` | Body prose | `onSurface` |
|
||||
| `--muted` | `#aeb8c4` | Secondary text | `onSurfaceVariant` |
|
||||
| `--dim` | `#6f7d8e` | Meta / captions / placeholders | dim / disabled text |
|
||||
| `--blue` | `#13243c` | Accent hover/active background | `secondaryContainer` |
|
||||
| `--mode-live` | `#5fb98a` | "Shard live" status (green) | success |
|
||||
| `--mode-maint` | `#e6c26a` | "Maintenance" status (amber) | warning |
|
||||
|
||||
### Semantic / status colors (used in badges, diffs, moderation)
|
||||
|
||||
- **Success / published / live:** green `#5fb98a` (fills at ~16–22% alpha, text `#7fd0a4`).
|
||||
- **Warning / maintenance / moderation (kick/mute/warn):** amber `#e0b070` / `#e6c26a`.
|
||||
- **Danger / ban / red-link / errors:** desaturated red `#d98b84` (borders `#6e3b38`).
|
||||
- **Admin badge:** near-white `#d8e2ef` on `#3a4a5e`.
|
||||
|
||||
## Branding is data, not code
|
||||
|
||||
The `--accent` value is **overridden at runtime** per shard instance. On the web,
|
||||
`SiteContext` reads `brand.accent` from the site settings API and sets the CSS
|
||||
variable, so one build reskins for any shard. **The Android app should do the
|
||||
same:** fetch the brand payload (name, `accent`, colors, logo/hero/favicon) from
|
||||
the website API and derive the `primary` color at runtime rather than hardcoding
|
||||
`#7f99bd`. Default to `#7f99bd` when the brand payload is absent/offline.
|
||||
|
||||
## Typography
|
||||
|
||||
Three font families, by role:
|
||||
|
||||
- **Display** (`--display`): **Cinzel**, falling back to Georgia serif — an
|
||||
engraved Roman capitals face used for the logo, `h1`/`.h1`, and prose
|
||||
`h2`/`h3`. Bundle Cinzel as an app font; this face carries the brand.
|
||||
- **Serif body** (`--serif`): **Georgia / Times New Roman** — default body and
|
||||
prose text; `line-height ≈ 1.6`.
|
||||
- **Sans** (`--sans`): **Helvetica Neue / Arial** — UI chrome: buttons, pills,
|
||||
form labels, table headers, badges, meta. Labels/eyebrows/kickers are
|
||||
UPPERCASE with wide letter-spacing (`0.1–0.18em`) and small (0.68–0.86rem).
|
||||
|
||||
Heading scale is fluid on web (`h1` clamps ~2.4–3.6rem); pick fixed Material type
|
||||
scale equivalents (e.g. display for `h1`, headline for `h2`, title for `h3`).
|
||||
|
||||
## Shape, elevation & motion
|
||||
|
||||
- **Corners:** cards/panels `10–12px` radius; inputs/small elements `8px`;
|
||||
pills and buttons are **fully rounded** (`999px` / capsule).
|
||||
- **Cards:** vertical gradient `--panel-a → --panel-b`, 1px `--line` border, soft
|
||||
drop shadow (`0 14px 34px rgba(0,0,0,0.3)`). On hover the web lifts `-3px` and
|
||||
brightens the border to `--accent` — translate to a pressed/focused accent
|
||||
border on Android.
|
||||
- **Buttons:** primary = bright fill (`--accent-bright`) with dark text;
|
||||
ghost/secondary = translucent dark fill with accent-on-hover border.
|
||||
- **Motion:** short, subtle transitions (0.12–0.18s). Keep animations understated.
|
||||
|
||||
## Signature accents (nice-to-have)
|
||||
|
||||
- The **"moon"** motif: a radial-gradient sphere (`#eef3f8 → #9fb0c6 → #5d6e88`) —
|
||||
a small brand flourish worth reproducing.
|
||||
- Accent-tinted focus rings and left-border "note" callouts
|
||||
(`border-left: 3px solid --accent` over a translucent `--blue` background).
|
||||
|
||||
## Implementation note for Compose
|
||||
|
||||
Define one `darkColorScheme(...)` from the table above, a `Typography` binding the
|
||||
three families, and a `Shapes` set (`small = 8.dp`, `medium = 10.dp`, capsule for
|
||||
buttons). Load `accent` from the brand API into a state holder and rebuild the
|
||||
`primary` (and derived `primaryContainer`) at runtime so a shard's custom accent
|
||||
flows through the whole UI — exactly as `SiteContext` does on the web.
|
||||
59
ci/SONARQUBE.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# SonarQube static analysis
|
||||
|
||||
Each code repo in the Runic Gateway org reports static-analysis results to the
|
||||
self-hosted **SonarQube** server for review. Analysis is **non-blocking**: it
|
||||
runs on push to `main` (i.e. *after* merge), never on pull requests, so it never
|
||||
gates a PR. It complements each repo's PR gate and release pipeline — it only
|
||||
feeds the dashboard.
|
||||
|
||||
## Server
|
||||
|
||||
- **URL:** `https://sonar.whitlocktech.com`
|
||||
- Each repo is a separate SonarQube project, keyed as below.
|
||||
|
||||
## Projects
|
||||
|
||||
| Repo | Project key | Sources analysed | Language |
|
||||
|---|---|---|---|
|
||||
| `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS |
|
||||
| `link` | `runic-gateway-link` | `sidecar/src` | Rust |
|
||||
| `Android-app` | `runic-gateway-android-app` | `app/src/main` | Kotlin |
|
||||
|
||||
## How it's wired
|
||||
|
||||
Each repo carries two files, identical in shape across repos:
|
||||
|
||||
- **`sonar-project.properties`** (repo root) — declares the project key, sources,
|
||||
tests, and exclusions. The Sonar scanner reads this.
|
||||
- **`.gitea/workflows/sonarqube.yml`** — a `SonarQube` workflow that, on push to
|
||||
`main` (and via manual `workflow_dispatch`), checks out with full history
|
||||
(`fetch-depth: 0`, needed for accurate blame + "new code") and runs
|
||||
`sonarsource/sonarqube-scan-action@v4`.
|
||||
|
||||
The scan is **source-based** — it does not build the project or run a language
|
||||
toolchain, so the workflows are lightweight (checkout + scan only). Richer
|
||||
signals (Rust Clippy, Android Lint, JaCoCo coverage) are left as documented,
|
||||
commented-out enrichment in each repo's `sonar-project.properties`; enable them
|
||||
per repo when wanted.
|
||||
|
||||
## One-time setup per repo (Gitea UI → Repo → Settings → Actions)
|
||||
|
||||
Both are consumed by the scan action via `env:` in the workflow:
|
||||
|
||||
- **Secret `SONAR_TOKEN`** — a SonarQube *Analysis* token (My Account →
|
||||
Security in SonarQube; project-scoped or global).
|
||||
- **Variable `SONAR_HOST_URL`** — the SonarQube base URL reachable from the
|
||||
self-hosted runner. Kept as a **variable, not committed**, so the internal
|
||||
address stays out of git.
|
||||
|
||||
The self-hosted `ubuntu-latest` runner must be able to reach `SONAR_HOST_URL` on
|
||||
the network. Nothing waits on the SonarQube Quality Gate, so a failing gate does
|
||||
not fail the job — check the dashboard.
|
||||
|
||||
## Adding a new repo
|
||||
|
||||
1. Create the project in SonarQube; note its key.
|
||||
2. Add `sonar-project.properties` (copy an existing repo's, adjust key + sources).
|
||||
3. Add `.gitea/workflows/sonarqube.yml` (copy verbatim — it's language-agnostic).
|
||||
4. Set the `SONAR_TOKEN` secret and `SONAR_HOST_URL` variable in the repo's
|
||||
Gitea Actions settings.
|
||||
46
link/PROJECT_TREE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# uo-link — Project Tree
|
||||
|
||||
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
|
||||
> the [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) repository, which
|
||||
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
|
||||
> by hand — changes will be overwritten by the next sync.
|
||||
|
||||
A snapshot of the tracked files in the repository (build output, dependencies, and other
|
||||
git-ignored paths are excluded).
|
||||
|
||||
```text
|
||||
link/
|
||||
├── .gitea/
|
||||
│ ├── ISSUE_TEMPLATE/
|
||||
│ │ ├── bug_report.md
|
||||
│ │ ├── config.yaml
|
||||
│ │ └── feature_request.md
|
||||
│ ├── scripts/
|
||||
│ │ └── gen_tree.py
|
||||
│ ├── workflows/
|
||||
│ │ ├── release.yml
|
||||
│ │ ├── sonarqube.yml
|
||||
│ │ └── sync-project-tree.yml
|
||||
│ └── PULL_REQUEST_TEMPLATE.md
|
||||
├── sidecar/
|
||||
│ ├── src/
|
||||
│ │ ├── config.rs
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── rpc.rs
|
||||
│ │ ├── shard.rs
|
||||
│ │ ├── store.rs
|
||||
│ │ └── web.rs
|
||||
│ ├── .gitignore
|
||||
│ ├── Cargo.lock
|
||||
│ ├── Cargo.toml
|
||||
│ ├── README.md
|
||||
│ └── sidecar.toml.example
|
||||
├── .gitignore
|
||||
├── CODE_OF_CONDUCT.md
|
||||
├── CONTRIBUTING.md
|
||||
├── CONTRIBUTORS.md
|
||||
├── LICENSE.md
|
||||
├── README.md
|
||||
├── SECURITY.md
|
||||
└── sonar-project.properties
|
||||
```
|
||||
731
website/API_V2_PLAN.md
Normal file
@@ -0,0 +1,731 @@
|
||||
# Website API — router domain split + CSP hardening
|
||||
|
||||
Status: **domain split complete** — PR 0 (route manifest), CSP report-only and split PRs 1–5 have all
|
||||
landed; only the CSP enforce PR remains, and it is blocked on soak data rather than on code ·
|
||||
Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
|
||||
|
||||
> **This file replaces the earlier "API v2" plan** (auth merge → CSP → domain split, with a parallel
|
||||
> `/api/v2` mount and an `/api/mobile` facade). Three of those four pieces are **not being built**:
|
||||
> the auth merge and the mobile facade are deferred with their reasoning recorded below, and the
|
||||
> parallel-version scaffold in [API_V2_SKELETON.md](./API_V2_SKELETON.md) is superseded. The filename
|
||||
> is kept so existing links resolve. What remains is genuinely useful work:
|
||||
>
|
||||
> 1. **CSP hardening** — small, independent, ships on its own cadence.
|
||||
> 2. **The domain split** — `admin.routes.js` (1552 lines, 110 routes) broken into one router file per
|
||||
> business capability, **in place, with every URL unchanged**. This is the actual driver.
|
||||
|
||||
---
|
||||
|
||||
## Why the auth merge is out
|
||||
|
||||
The original plan replaced httpOnly session cookies with a bearer JWT + rotating refresh token for
|
||||
every client, so web and mobile would share one session model. Reasons that no longer hold up:
|
||||
|
||||
1. **The current model is the more secure one.** httpOnly + SameSite cookies are unreadable from JS
|
||||
and carry CSRF protection by default. Every migration target is a sideways or backwards move:
|
||||
- Refresh token in `localStorage` → any XSS becomes **persistent full account takeover**, not a
|
||||
bounded access-token window. A short access TTL does not help; the attacker mints new pairs.
|
||||
- Refresh token in an httpOnly cookie scoped to the refresh endpoint → safe, but that is
|
||||
*cookies with extra steps*. It concedes the premise.
|
||||
2. **"One session model everywhere" is already true where it matters.** `auth/session.service.js`
|
||||
unifies cookie and bearer into a single session, and `auth/token.js` already extracts from either
|
||||
`Cookie` or `Authorization: Bearer`. That abstraction is written, working, and paid for. The merge
|
||||
would move complexity *out* of `extractToken` and *into* the SPA.
|
||||
3. **The cookie codepath survives the merge anyway.** The SSO / email-connect redirect flow must keep
|
||||
its short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies — the browser leaves for the
|
||||
IdP and returns with no JS context. So the merge never actually delivered "cookies are gone."
|
||||
4. **It carried the plan's most bug-prone work as a dependency.** The admin SSE rewrite
|
||||
(`EventSource` → hand-rolled `fetch` + `ReadableStream` + SSE frame parser + reconnect/backoff +
|
||||
refresh-on-401) existed *only* to serve the bearer model. Without the merge, the admin stream stays
|
||||
on `EventSource` with `withCredentials` and that code is never written.
|
||||
5. **It is a contract change with no user-visible payoff**, competing for the same review attention as
|
||||
the domain split, which is the thing that actually hurts today.
|
||||
|
||||
### Dropped with it
|
||||
|
||||
- v2 bearer auth routes (`/api/v2/auth/{login,refresh,logout,login/totp}`).
|
||||
- Deletion of the `setAuthCookie` / `clearAuthCookie` path.
|
||||
- `rg_trust` cookie → `X-Trust-Token` header migration for web (the header stays available for native
|
||||
clients via `extractTrustToken`, unchanged).
|
||||
- `api/client.js` bearer + silent-refresh rewrite.
|
||||
- `lib/useShardFeed.js` admin-stream fetch rewrite. **The admin SSE stream stays as-is.**
|
||||
|
||||
### Kept from it
|
||||
|
||||
- **CSP hardening** — now its own phase (below). It was justified as a compensating control for a
|
||||
JS-held token; it is worth doing regardless, just no longer urgent.
|
||||
- **The public/admin SSE allowlist split** — unchanged security boundary, unrelated to session model.
|
||||
- **The tx-cookie carve-out reasoning** — recorded here so a future merge attempt doesn't rediscover it.
|
||||
|
||||
---
|
||||
|
||||
## Deferred: the auth merge
|
||||
|
||||
Not cancelled — parked behind trigger conditions. Revisit if **any** of these become true:
|
||||
|
||||
| Trigger | Why it changes the answer |
|
||||
|---|---|
|
||||
| The API becomes genuinely cross-origin (separate API host) | `SameSite` cookies stop being the easy path; bearer becomes the natural model. |
|
||||
| Third-party or OAuth clients are introduced | Cookies don't serve clients you don't control. |
|
||||
| Mobile and web session behavior diverge enough to cause real bugs | The unification argument gets teeth it currently lacks. |
|
||||
|
||||
If it is ever revived, two specs the original plan lacked must be written **first**:
|
||||
|
||||
- **Refresh-token reuse detection.** Rotation is only useful with it: replay of an already-consumed
|
||||
refresh must revoke the entire token family, not just fail the one request.
|
||||
- **Rollback procedure.** Once web clients have discarded their cookies, a bad deploy locks everyone
|
||||
out. Needs a documented path back.
|
||||
|
||||
---
|
||||
|
||||
## Why there is no `/api/v2`
|
||||
|
||||
The domain split reorganizes router *files*. It does not need to move a single URL — because the URL
|
||||
surface is **already grouped by capability**. Inventory taken from the live Express stack — 196
|
||||
`/api/v1` routes, plus three outside it (`GET /api/health`, `GET /api/docs.json`,
|
||||
`GET /.well-known/assetlinks.json`) and 2 on the internal port. Full machine-readable list:
|
||||
[`api-route-inventory.json`](./api-route-inventory.json).
|
||||
|
||||
| Group | Routes | Second segment → capability |
|
||||
|---|---|---|
|
||||
| `/admin` | 110 | `shard` 16 · `moderation` 15 · `users` 15 · `wiki` 14 · `posts` 9 · `pages` 7 · `account` 6 · `email` 6 · `uo-link` 5 · `auth` 4 · `invites` 3 · `bot-activity` 2 · `discord-bot` 2 · `settings` 2 · `activity` 1 · `dashboard` 1 · `site-mode` 1 · `uploads` 1 |
|
||||
| `/auth` | 42 | `me` 23 · `mobile` 5 · `sso` 4 · `password` 3 · `invite` 2 · `login` 2 · `logout` 1 · `providers` 1 · `register` 1 |
|
||||
| `/public` | 24 | `shard` 12 · `wiki` 4 · `pages` 2 · `posts` 2 · `contact` 1 · `settings` 1 · `status` 1 · `version` 1 |
|
||||
| `/player` | 20 | `account` 8 · `shard` 8 · `appeals` 4 |
|
||||
|
||||
Every capability already owns a URL prefix, so each new router file mounts at the prefix it already
|
||||
owns and the emitted paths are **byte-identical**. No URL change means no contract change, and no
|
||||
contract change means no reason to mount a parallel version.
|
||||
|
||||
Consequences of doing it in place:
|
||||
|
||||
- No `/api/v2`, no dual mount, no route-by-route migration, no v1-usage telemetry project, and no
|
||||
v1-retirement sequence.
|
||||
- `BASE = /api/v1` in `client/src/api/client.js` never changes. The Discord bot's `SITE_PUBLIC_URL`
|
||||
never changes. The Android app is untouched.
|
||||
- [API_V2_SKELETON.md](./API_V2_SKELETON.md) (the `router/v2/` scaffold) is **superseded and not
|
||||
scheduled**. It is kept as the concrete recipe if a real contract break ever forces a versioned API.
|
||||
|
||||
**Tripwire:** if any endpoint turns out to *need* a new URL, that is a contract change, not a
|
||||
refactor. List it explicitly, and reopen the versioning question before writing the code — do not
|
||||
smuggle a URL change into a "mechanical" PR.
|
||||
|
||||
---
|
||||
|
||||
## Deferred: the `/api/mobile` facade and the app-version floor
|
||||
|
||||
The earlier plan's Phase 0 stood up a version-agnostic `/api/mobile` namespace and migrated the
|
||||
Android app onto it, plus an app-version header and a server-side min-version floor.
|
||||
|
||||
**Why it was proposed:** the app hardcodes **69** distinct `api/v1/…` paths (`data/api/*.kt`,
|
||||
`core/net/ShardStreamClient.kt`, `core/net/HostSelectionInterceptor.kt`,
|
||||
`core/auth/sso/SsoAuthManager.kt`), has no version negotiation and no force-update, and installs in
|
||||
the wild cannot be forced forward. Under a parallel-`/api/v2` plan that made the app the load-bearing
|
||||
coupling: v1 could not be retired until the fleet aged out.
|
||||
|
||||
**Why it is deferred:** with the split done in place, no URL moves and nothing is being deleted — so
|
||||
there is no fleet to sunset and no coupling to break. A facade would add ~70 permanently maintained
|
||||
delegate routes plus a contract-test suite to solve a problem that does not currently exist. The
|
||||
version floor was scoped to sunsetting the pre-facade fleet, so it goes with it.
|
||||
|
||||
**If it is ever revived** (the trigger is the mobile contract genuinely needing to diverge from web —
|
||||
different response shapes, a mobile-only aggregation endpoint, a real breaking change):
|
||||
|
||||
- **Start with the alias mount, not a delegate layer:** `apiRouter.use('/mobile', v1Router)` gives the
|
||||
app a stable, version-agnostic namespace with identical wiring, identical middleware and zero
|
||||
per-route maintenance. Build hand-written delegates only for the routes that actually diverge.
|
||||
- **A facade is a security surface, not a convenience alias.** Any hand-written route must carry the
|
||||
*same* middleware chain as the route it mirrors (`requireAuth`, `staffOnly`/`adminOnly`, validators,
|
||||
the public/admin SSE allowlist split). A re-exposed admin route missing `adminOnly` is privilege
|
||||
escalation.
|
||||
- **It needs contract tests.** The moment the app pins a namespace, its response shapes are a
|
||||
committed contract; an internal refactor that changes a shape must fail a test before it ships to
|
||||
installed apps.
|
||||
- **The mobile SSE stream stays anonymous** under whatever path it gets — the app sends no
|
||||
`Authorization` header.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — CSP hardening (independent)
|
||||
|
||||
Previously bundled with the auth merge as a compensating control for a JS-held token. With no token in
|
||||
JS, this is **defense in depth on its own merits** — cheap, worth doing, blocking nothing. It has no
|
||||
dependency on the domain split and can ship at any time.
|
||||
|
||||
**Sequencing fix from the original plan:** the old version both "ships with the auth merge" and called
|
||||
for a one-release report-only soak. Those contradict. Correct order is **report-only first, observe one
|
||||
release, then enforce** — now trivially satisfiable since nothing waits on it.
|
||||
|
||||
The app already ships a tuned policy (`server/src/app.js`). Two directives are load-bearing and are
|
||||
**already correct** — the job is to keep them that way:
|
||||
|
||||
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense.
|
||||
- `connect-src 'self'` — the exfiltration channel. Don't widen it unless the API genuinely becomes
|
||||
cross-origin (which would also reopen the auth-merge question — see the trigger table).
|
||||
|
||||
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
|
||||
pervasive `style={{…}}` attributes can't be nonce'd. Not a meaningful hole. The `/api/docs` route keeps
|
||||
its deliberately looser policy (swagger-ui injects an inline bootstrap script); that carve-out is
|
||||
scoped to the one route and stays scoped.
|
||||
|
||||
Target enforced policy:
|
||||
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'self';
|
||||
connect-src 'self';
|
||||
img-src 'self' data: https:;
|
||||
style-src 'self' 'unsafe-inline'; /* + fonts.googleapis.com only until fonts are self-hosted */
|
||||
font-src 'self'; /* + fonts.gstatic.com only until fonts are self-hosted */
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
```
|
||||
|
||||
Delta vs. the policy in `server/src/app.js` today. This was written as two directives; on
|
||||
implementation it turned out to be **one**:
|
||||
|
||||
- ~~**Add `form-action 'self'`** (currently absent)~~ — **it was not absent.** The directives object in
|
||||
`app.js` does not list it, but the middleware is configured `useDefaults: true`, and helmet's default
|
||||
set already supplies `form-action 'self'` — so the header served in production has carried it all
|
||||
along. Verified by capturing the live `Content-Security-Policy` header from the running app rather
|
||||
than reading the config, which is how the plan got this wrong. **No behavioural change here.** It is
|
||||
now written out explicitly in `config/csp.js` anyway: a security directive should not depend on a
|
||||
third-party library's defaults surviving its next major version.
|
||||
- **Tighten `frame-ancestors`** `'self'` → `'none'` — nothing legitimately frames the site. **This is
|
||||
the entire behavioural delta of the phase.**
|
||||
- Unchanged: `default-src`, `script-src`, `connect-src`, `object-src 'none'`, `base-uri 'self'`, and
|
||||
`img-src … https:` (external `BRAND_*` logo/hero and `<img>` in sanitized wiki/news bodies rely on
|
||||
`https:`).
|
||||
|
||||
The soak is still worth running for that one directive, and arguably it is the directive that most
|
||||
needs one: a `frame-ancestors` report is generated by the browser of *whoever framed the site*, so it
|
||||
is the only way to discover that something legitimately embeds us before the enforcing policy breaks
|
||||
it. Nothing else can tell us that.
|
||||
|
||||
**Rollout:** ship via `Content-Security-Policy-Report-Only` with `report-to` for one release, watch for
|
||||
violations, then flip to enforce.
|
||||
|
||||
Before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `<script>`
|
||||
into `dist/index.html`, which that directive blocks (harmless, but throws a violation).
|
||||
**Already handled** — `client/vite.config.js` sets `modulePreload: { polyfill: false }`, so the build
|
||||
emits no inline bootstrap script. (The `renderIndexHtml` branding injection adds only `<meta>`/`<link>`
|
||||
tags — no inline script, no nonce needed.)
|
||||
|
||||
### Where reports go
|
||||
|
||||
`report-to` needs somewhere to point, so the report-only PR stands up a same-origin sink:
|
||||
**`POST /api/csp-report`** (`server/src/router/cspReport.controller.js`, wired in `app.js`). Same-origin
|
||||
on purpose — violation reports describe attacks against this site and are not handed to a third-party
|
||||
collector. It writes to the `csp` log tag and stores nothing.
|
||||
|
||||
It is mounted outside `/api/v1`, alongside `/api/health`: the browser learns the path from the policy
|
||||
header, never from a client build, so it is not part of the versioned client contract. This is the
|
||||
`+1` in the route manifest that made PR 0 go first (see § Sequencing).
|
||||
|
||||
Necessary properties, since it is an unauthenticated public `POST` (browsers send reports with no
|
||||
session, and gating it would silence exactly the anonymous visitors worth hearing about):
|
||||
|
||||
- **Both wire formats.** `report-uri` (Firefox, Safari) sends `application/csp-report` with a single
|
||||
hyphenated-key object; `report-to` (Chrome) sends `application/reports+json` with an array of
|
||||
camelCase envelopes. Handling one silently drops half the browsers. Both directives are emitted, and
|
||||
`report-to` additionally needs a `Reporting-Endpoints` response header or it is inert.
|
||||
- **Always 204, even for junk.** A 4xx would make the global error handler write an ERROR line quoting
|
||||
the attacker-supplied body — turning an open endpoint into a log-flood primitive. A browser cannot
|
||||
act on an error from a report sink anyway.
|
||||
- **Bounded everywhere:** 16 KB body cap, a per-IP rate limit, a fixed field allowlist, and every
|
||||
logged field truncated (`script-sample` is attacker-influenced and can carry a whole inline script).
|
||||
|
||||
**Retiring it:** the sink exists for the soak. When the tightened policy flips to enforced and the
|
||||
report-only twin is deleted, this endpoint goes with it — *unless* a `report-to` group is deliberately
|
||||
kept on the enforced policy, which is a reasonable thing to want. Decide that in the enforce PR rather
|
||||
than leaving an orphan route behind.
|
||||
|
||||
Tracked follow-ups (own PRs):
|
||||
|
||||
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and `fonts.gstatic.com`
|
||||
from `font-src`, removing two third-party origins from the trust surface.
|
||||
- **Trusted Types** — `require-trusted-types-for 'script'` + a `trusted-types` policy, report-only
|
||||
first. Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — The domain split
|
||||
|
||||
**This is the reason the plan exists.** Everything else is supporting work.
|
||||
|
||||
**Rule:** one router file = one business capability; the URL names the domain; related endpoints live
|
||||
together regardless of HTTP method; no generic `admin.routes.js` catch-all. Controllers are **already**
|
||||
domain-split — this re-wires routes, not logic.
|
||||
|
||||
**Invariant:** each capability router mounts at the prefix it already owns, so the emitted URL set does
|
||||
not change. Proved per PR by the route manifest (§ PR 0).
|
||||
|
||||
Target tree — derived from the inventory above, **inside `router/v1/`** (no `v2/` directory):
|
||||
|
||||
```
|
||||
router/v1/
|
||||
admin/
|
||||
index.js # mounts the capability routers below under /admin, keeps the
|
||||
# shared `noindex, isLoggedIn, staffOnly` gate in one place
|
||||
users.router.js account.router.js invites.router.js
|
||||
authProviders.router.js moderation.router.js botActivity.router.js
|
||||
posts.router.js pages.router.js wiki.router.js
|
||||
uploads.router.js shard.router.js uoLink.router.js
|
||||
email.router.js discordBot.router.js settings.router.js
|
||||
activity.router.js # the staff audit log — landed in PR 2, not with dashboard
|
||||
dashboard.router.js # + the /site-mode singleton
|
||||
auth/
|
||||
login.router.js register.router.js password.router.js invite.router.js
|
||||
sso.router.js mobile.router.js me.routes.js (already split, 23 routes)
|
||||
public/
|
||||
news.router.js (posts) pages.router.js wiki.router.js shard.router.js
|
||||
site.router.js # status, settings, version, contact
|
||||
player/
|
||||
account.router.js shard.router.js appeals.router.js
|
||||
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
|
||||
```
|
||||
|
||||
Steps:
|
||||
|
||||
1. **Land PR 0 (route manifest) first** — the mechanical proof that later PRs move no URL.
|
||||
2. Carve `admin.routes.js` into the per-capability files above, each requiring its already-existing
|
||||
controller. `admin/index.js` keeps the shared gate (`noindex, isLoggedIn, staffOnly`) and mounts
|
||||
each capability router at its existing prefix; the `adminOnly` / `modAccess` gates move with the
|
||||
routes that use them.
|
||||
3. Split `public.routes.js`, `player.routes.js`, and the remaining `auth.routes.js` groups the same
|
||||
way. `auth/me.routes.js` is already a separate file and stays.
|
||||
4. Keep `/internal` off the public listener exactly as today (separate `internalApp.js` port).
|
||||
5. Move each route's `#swagger.*` annotations **with** the route, then regenerate
|
||||
(`cd website/server && npm run swagger`).
|
||||
6. Update `BACKEND_DESIGN.md` §2 (folder structure) and §4 (API contract — it names
|
||||
`admin.routes.js → admin.controller.js` and friends) as routers move, plus `PROJECT_TREE.md`.
|
||||
|
||||
Because the auth model is untouched and the URLs are frozen, each PR is a **pure mechanical refactor
|
||||
with a green test suite and a zero-diff route manifest as its acceptance criteria** — which is what
|
||||
makes grouped PRs actually reviewable.
|
||||
|
||||
> **Step 6 correction:** `PROJECT_TREE.md` is no longer hand-edited. Since website#98 it is
|
||||
> auto-generated by the `sync-project-tree` CI workflow, which opens its own docs PR after a merge to
|
||||
> `main`. Leave it alone in split PRs. `BACKEND_DESIGN.md` §2/§4 are still manual.
|
||||
|
||||
### PR 1 — as landed
|
||||
|
||||
`admin/index.js` owns the shared `noindex, isLoggedIn, staffOnly` gate and the mount table, and
|
||||
declares no routes itself. The gate sits **ahead of every mount**, so a capability router extracted in
|
||||
a later PR cannot silently ship without it. Route counts:
|
||||
|
||||
| Router | Routes | Prefix | Extra gate |
|
||||
|---|---|---|---|
|
||||
| `account.router.js` | 6 | `/admin/account` | none — self-service, an editor manages their own 2FA |
|
||||
| `users.router.js` | 15 | `/admin/users` | `adminOnly` at router level |
|
||||
| `invites.router.js` | 3 | `/admin/invites` | `adminOnly` per route |
|
||||
| `authProviders.router.js` | 4 | `/admin/auth` (routes are `/providers[/:id]`) | `adminOnly` per route |
|
||||
| `admin.routes.js` (residual) | 82 | group root, mounted last | unchanged |
|
||||
|
||||
6 + 15 + 3 + 4 + 82 = the 110 inventoried admin routes. None of the four prefixes appears in the
|
||||
residual file, so nothing depends on mount ordering.
|
||||
|
||||
Three findings worth carrying into PRs 2–5:
|
||||
|
||||
- **The self-service `/shard/*` routes stay with `shard` (PR 4), despite their `Admin · Account`
|
||||
swagger tag.** The invariant is *prefix ownership*, not tag agreement: one router owns `/shard`, so
|
||||
splitting six routes off it by capability would mean two routers mounting under the same prefix and
|
||||
an ordering hazard for no gain. Retag them in PR 4 if the tag still grates.
|
||||
- **`usersRouter.use(adminOnly)` is exactly equivalent to the old `adminRouter.use('/users', adminOnly)`**
|
||||
now that the router is mounted at `/users` — but only because it is mounted at a prefix. Under a
|
||||
*pathless* mount, a bare `use(gate)` would run for every request passing through en route to a later
|
||||
mount, 403-ing an editor on `/admin/posts`. Do not "simplify" a prefix mount away.
|
||||
- **`routes.guards.json` came back zero-diff too**, not just the manifest — no route lost or gained a
|
||||
gate. Worth checking both every time; the guards file is the one that would catch a dropped
|
||||
`adminOnly` that the method+path freeze cannot see.
|
||||
|
||||
The OpenAPI spec was also byte-for-byte unchanged, which required a prerequisite fix — see below.
|
||||
|
||||
### PR 2 — as landed
|
||||
|
||||
`moderation`, `bot-activity` and `activity` — 18 routes, leaving 64 in the residual file.
|
||||
|
||||
| Router | Routes | Prefix | Extra gate |
|
||||
|---|---|---|---|
|
||||
| `moderation.router.js` | 15 | `/admin/moderation` | `modAccess` (admin + moderator) at router level |
|
||||
| `botActivity.router.js` | 2 | `/admin/bot-activity` | `adminOnly` per route |
|
||||
| `activity.router.js` | 1 | `/admin/activity` | none — staff-wide audit log |
|
||||
| `admin.routes.js` (residual) | 64 | group root, mounted last | unchanged |
|
||||
|
||||
All four gates came back zero-diff: `routes.manifest.json` (200 public + 2 internal),
|
||||
`routes.guards.json`, `swagger-output.json` (198 operations), and `docs/website/api-route-inventory.json`
|
||||
was already in sync. 434 server tests green.
|
||||
|
||||
Notes:
|
||||
|
||||
- **`/activity` gets its own file, deviating from the target tree above**, which parked it as a
|
||||
singleton inside `dashboard.router.js`. It is folded into PR 2 by the sequencing list, and PR 4 is
|
||||
where `dashboard` lands — so honouring the tree would have meant leaving one route in the residual
|
||||
file for two PRs to satisfy a filename. It is also a genuinely separate capability: `/activity` is
|
||||
the **staff audit log** (`activity.model.js`), while `/dashboard` is a stats overview and
|
||||
`/bot-activity` is the botScore middleware's in-memory ban state. Three different things that read
|
||||
alike. **PR 4 mounts `dashboard` and `site-mode` only.**
|
||||
- **`modAccess` moved to a router-level `use`; `adminOnly` on bot-activity deliberately did not.**
|
||||
Moderation was already gated by a *prefix* mount (`adminRouter.use('/moderation', modAccess)`), so
|
||||
`moderationRouter.use(modAccess)` is the exact equivalent (the PR 1 `users` case). Bot-activity's
|
||||
gate was per-route, and keeping it per-route is what holds the per-route handler count — the one
|
||||
number in `routes.guards.json` that would catch a dropped `adminOnly`, since `requireRole(...)`
|
||||
returns an anonymous arrow and never shows up by name. **Rule for PRs 3–5: move a gate to router
|
||||
level only where it was already a prefix mount; otherwise leave it on the route.**
|
||||
- **`modAccess` stays in the residual file** — the `/shard/*` in-game staff operations still use it
|
||||
and do not move until PR 4. Its comment there was retargeted rather than deleted.
|
||||
|
||||
### PR 3 — as landed
|
||||
|
||||
`posts`, `uploads`, `wiki` and `pages` — 31 routes, leaving 33 in the residual file. The content tier,
|
||||
and the first split PR where no gate moved at all: all four capabilities are editor-tier, so the shared
|
||||
`staffOnly` in `admin/index.js` is their whole gate.
|
||||
|
||||
| Router | Routes | Prefix | Extra gate |
|
||||
|---|---|---|---|
|
||||
| `posts.router.js` | 9 | `/admin/posts` | none — editor tier |
|
||||
| `uploads.router.js` | 1 | `/admin/uploads` | none — editor tier |
|
||||
| `wiki.router.js` | 14 | `/admin/wiki` | none — editor tier |
|
||||
| `pages.router.js` | 7 | `/admin/pages` | none — editor tier |
|
||||
| `admin.routes.js` (residual) | 33 | group root, mounted last | unchanged |
|
||||
|
||||
All four gates zero-diff: `routes.manifest.json` (200 public + 2 internal), `routes.guards.json`,
|
||||
`swagger-output.json` (198 operations), and `docs/website/api-route-inventory.json` was already in
|
||||
sync. 434 server tests green.
|
||||
|
||||
Notes:
|
||||
|
||||
- **The residual 33 is exactly PR 4's list** — `shard` 16, `email` 6, `uo-link` 5, `settings` 2,
|
||||
`discord-bot` 2, `dashboard` 1, `site-mode` 1. So `admin.routes.js` is deleted by PR 4, one PR
|
||||
earlier than the sequencing list implies, and PR 5 touches only `public/*`, `player/*` and `auth/*`.
|
||||
- **A shared module was unavoidable here, and it is the first one in the split.** The multer config
|
||||
(upload dir, mimetype→extension allowlist, 8 MB cap) was defined inline in `admin.routes.js` and used
|
||||
by *two* routes that this PR puts in different files: `POST /posts/upload` (→ `{image_url}`) and
|
||||
`POST /uploads` (→ `{url}`). It moved to `admin/imageUpload.js` rather than being duplicated —
|
||||
duplicating a security allowlist is how the two copies drift. It stays in `admin/` deliberately:
|
||||
`UPLOAD_DIR` is resolved `__dirname`-relative, so relocating the file would silently repoint the
|
||||
upload directory. Guard freshness is unaffected — multer's middleware is named `multerMiddleware`
|
||||
wherever it is constructed, so `routes.guards.json` did not move.
|
||||
- **`POST /uploads` keeps its `Admin · Posts` swagger tag**, which now disagrees with its filename. The
|
||||
acceptance criterion is a byte-identical spec, so retagging is a real OpenAPI diff and does not
|
||||
belong in a route-move PR. Same call as PR 1's `/shard/*` tag mismatch: fix tags in a PR that is
|
||||
*about* tags.
|
||||
- **The wiki router is the first one with load-bearing intra-file route order.** `/categories` and
|
||||
`/tags` are literal paths that must stay ahead of `/:slug`, or `GET /admin/wiki/categories` gets
|
||||
dispatched as a page whose slug is "categories". **The manifest cannot catch this — it sorts its
|
||||
entries, so a reordering is invisible in all three gates.** It was verified separately by
|
||||
introspecting the built router stack and asserting the last literal layer precedes the first `/:slug`
|
||||
layer. Any future PR moving `/:slug`-style routes needs the same explicit check.
|
||||
- **`/admin/pages` (CMS page builder) and `/admin/shard/pages` (in-game help-page queue) are unrelated
|
||||
capabilities that read alike** — the latter stays with `shard` in PR 4. Same trap as PR 2's
|
||||
`activity` / `dashboard` / `bot-activity` trio.
|
||||
|
||||
### PR 4 — as landed
|
||||
|
||||
`shard`, `uo-link`, `email`, `discord-bot`, `settings` and `dashboard`/`site-mode` — the whole residual
|
||||
33. **`admin.routes.js` is deleted**, so the admin group is fully split and every one of its 110 routes
|
||||
is declared in a capability router.
|
||||
|
||||
| Router | Routes | Prefix | Extra gate |
|
||||
|---|---|---|---|
|
||||
| `shard.router.js` | 16 | `/admin/shard` | none on the 7 self-service routes; `modAccess` per route on the 9 staff ops |
|
||||
| `uoLink.router.js` | 5 | `/admin/uo-link` | `adminOnly` per route |
|
||||
| `email.router.js` | 6 | `/admin/email` | `adminOnly` per route |
|
||||
| `discordBot.router.js` | 2 | `/admin/discord-bot` | `adminOnly` per route |
|
||||
| `settings.router.js` | 2 | `/admin/settings` | `adminOnly` per route |
|
||||
| `dashboard.router.js` | 2 | **group root** (`/dashboard`, `/site-mode`) | `adminOnly` per route on `/site-mode` only |
|
||||
| ~~`admin.routes.js`~~ | — | deleted | — |
|
||||
|
||||
16 + 5 + 6 + 2 + 2 + 2 = 33. All four gates zero-diff: `routes.manifest.json` (200 public + 2
|
||||
internal), `routes.guards.json`, `swagger-output.json` (198 operations), and
|
||||
`docs/website/api-route-inventory.json` was already in sync. 434 server tests green.
|
||||
|
||||
Notes:
|
||||
|
||||
- **`dashboard.router.js` is mounted at the group root, not a prefix — the one relaxation of the
|
||||
"always mount at a prefix" rule, and it is deliberate.** `GET /dashboard` and `PUT /site-mode` own no
|
||||
common path segment, so a prefix mount would mean two one-route files instead of the single file the
|
||||
target tree calls for. It is safe **only** because the file declares no router-level middleware: a
|
||||
bare `use(gate)` in a root-mounted router runs for every request passing through toward another
|
||||
mount and would 403 an editor on an unrelated route (the PR 1 finding). The file says so in a
|
||||
comment, because the next person to add a gate there is the one who needs to know.
|
||||
- **`/shard` is the first prefix where two tiers share one router**, and it is why prefix ownership
|
||||
beats swagger-tag grouping. The 7 self-service routes (`link`, `accounts`, `roster/:account`,
|
||||
`vendors/:account`, `char/:serial`, `sales`, `POST account`) are tagged `Admin · Account`, run with
|
||||
no gate beyond the shared `staffOnly`, and are served by the very same `player/shard.controller`
|
||||
handlers as `/player/shard` — staff are a superset of players, and the controller keys off
|
||||
`req.user.id`. The 9 in-game ops are tagged `Admin · Shard` and carry `modAccess`. Splitting them by
|
||||
tag would put two routers under one prefix for no gain; instead one router owns `/shard` and gates
|
||||
per route. The tag mismatch stays, on the PR 1 and PR 3 precedent: retagging is a real spec diff and
|
||||
belongs in a PR that is about tags.
|
||||
- **No gate moved to router level anywhere in this PR.** Every `adminOnly` in the residual file was
|
||||
per-route, and `modAccess` on `/shard` must stay per-route because half that router must *not* have
|
||||
it. This keeps the per-route handler count intact — the one number `routes.guards.json` can actually
|
||||
check, since `requireRole(...)` returns an anonymous arrow.
|
||||
- **The `/:param` shadowing check was run again and is clean**, since the manifest sorts and therefore
|
||||
cannot see declaration order. Introspecting the built stack, all 110 admin routes and all 59 literal
|
||||
admin paths dispatch to their own layer — nothing is captured first by a `:param` sibling. The
|
||||
near-misses worth naming: `GET /shard/pages` (help-page queue) sits alongside
|
||||
`POST /shard/pages/:id/respond|close`, and `POST /shard/towncrier` alongside
|
||||
`DELETE /uo-link/towncrier/:id` — different depths and methods, so neither collides.
|
||||
- **Deleting the file left dangling `see admin.routes.js` pointers**, which were repointed in the same
|
||||
PR: `botActivity.controller.js` → `botActivity.router.js`, `moderation.controller.js` →
|
||||
`moderation.router.js`, `announceJobs.logic.js`'s town-crier cap mirror → `admin/uoLink.router.js`,
|
||||
and the "route paths sit on the line *after* `adminRouter.get(`" rationale in
|
||||
`scripts/routeManifest.js`, `README.md` and `pr-checks.yml` was generalized (it was never about that
|
||||
one file).
|
||||
- **`/admin/shard/pages` vs `/admin/pages` stayed separate**, as PR 3 flagged: the former is the
|
||||
in-game help-page (support) queue and belongs to `shard`; the latter is the CMS page builder.
|
||||
|
||||
### PR 5 — as landed
|
||||
|
||||
`public`, `player` and the residual `auth` — 54 routes across three groups, the last split PR.
|
||||
`public.routes.js`, `player.routes.js` and `auth.routes.js` are all **deleted**, so every one of the
|
||||
200 routes in the manifest is now declared in a capability router and no monolithic route file
|
||||
remains anywhere in `router/v1/`.
|
||||
|
||||
| Group | Router | Routes | Prefix | Extra gate |
|
||||
|---|---|---|---|---|
|
||||
| `public` | `posts.router.js` | 2 | `/public/posts` | none — `siteMode` per route |
|
||||
| | `wiki.router.js` | 4 | `/public/wiki` | none — `siteMode` per route |
|
||||
| | `pages.router.js` | 2 | `/public/pages` | none — `siteMode` per route except the preview |
|
||||
| | `shard.router.js` | 12 | `/public/shard` | none — never `siteMode` gated |
|
||||
| | `site.router.js` | 4 | **group root** (`/settings`, `/status`, `/version`, `/contact`) | none |
|
||||
| `player` | `account.router.js` | 8 | `/player/account` | none beyond the group gate |
|
||||
| | `shard.router.js` | 8 | `/player/shard` | none beyond the group gate |
|
||||
| | `appeals.router.js` | 4 | `/player/appeals` | none beyond the group gate |
|
||||
| `auth` | `login.router.js` | 2 | `/auth/login` | `loginGuards` per route |
|
||||
| | `register.router.js` | 1 | `/auth/register` | `loginGuards` + `registerLimiter` |
|
||||
| | `invite.router.js` | 2 | `/auth/invite` | `loginGuards` + `registerLimiter` on accept |
|
||||
| | `password.router.js` | 3 | `/auth/password` | per-route reset limiters |
|
||||
| | `session.router.js` | 2 | **group root** (`/logout`, `/me`) | per route |
|
||||
|
||||
24 + 20 + 10 = 54. `auth/`'s other 32 routes (`me` 23, `mobile` 5, `sso` 4) were already in their own
|
||||
files and did not move. All four gates zero-diff: `routes.manifest.json` (200 public + 2 internal),
|
||||
`routes.guards.json`, `swagger-output.json` (198 operations), and `docs/website/api-route-inventory.json`
|
||||
was already in sync. 434 server tests green.
|
||||
|
||||
Notes:
|
||||
|
||||
- **Each group is now a directory with an `index.js`**, matching `admin/`: `public/index.js`,
|
||||
`player/index.js`, `auth/index.js` own the group gate (where there is one) and the mount table and
|
||||
declare no routes. `v1.router.js` requires the directories. The four tests that imported the
|
||||
deleted entry files were repointed.
|
||||
- **Two of the three groups have no group gate, and that is the security-relevant fact about them.**
|
||||
`player/index.js` carries `noindex, requireAuth` — authenticated, *any* role, because staff are a
|
||||
superset of players. `public/index.js` and `auth/index.js` carry **nothing**, deliberately: the
|
||||
public surface is anonymous by contract (logged-out SPA, Discord bot, and the Android
|
||||
`ShardStreamClient` on `/public/shard/stream`, none of which send credentials), and `/auth` is where
|
||||
an anonymous caller *becomes* authenticated. Both index files say so, because the obvious "hardening"
|
||||
edit to either one is an outage.
|
||||
- **`GET /auth/me` has a mount-order dependency, and it is the one genuinely non-obvious thing in this
|
||||
PR.** `authRouter.use('/me', meRouter)` matches the bare path `/me`, not just `/me/*` — so a request
|
||||
to `GET /auth/me` runs `meRouter`'s (and `notifRouter`'s) `noindex, requireAuth`, matches no route
|
||||
inside either, and falls through to its own handler. `session.router.js` must therefore stay mounted
|
||||
**last**. Verified by the counterfactual rather than by reading the mount table: moving the mount to
|
||||
the top of `auth/index.js` still answers `401`, but the response loses its `X-Robots-Tag` header. No
|
||||
gate file and neither manifest can see that — only a header assertion can.
|
||||
- **Two root-mounted routers, on the PR 4 `dashboard.router.js` precedent.** `public/site.router.js`
|
||||
(`/settings`, `/status`, `/version`, `/contact`) and `auth/session.router.js` (`/logout`, `/me`) hold
|
||||
the routes that own no path segment. Both are safe at the root **only** because they declare no
|
||||
router-level middleware — a bare `use(gate)` there runs for every request passing through toward
|
||||
another mount. Both files say so.
|
||||
- **`loginGuards` is the PR's one shared module**, the counterpart to PR 3's `imageUpload.js`. The
|
||||
`[backoffGuard, slowLogin, loginLimiter]` array was defined inline in `auth.routes.js` and spread by
|
||||
four routes that this PR puts in three different files — plus a fifth, already-duplicated copy in
|
||||
`sso.routes.js`. It moved to `auth/loginGuards.js` and `sso.routes.js` now imports it too, so there
|
||||
is one definition rather than five: duplicating a throttling stack is how the copies drift, and the
|
||||
copy that drifts is the one that stops throttling. It is exported `Object.freeze`d — it is
|
||||
module-level shared state, and a router that pushed onto it would silently add middleware to every
|
||||
other login surface. Guard freshness is unaffected: the same three named functions, so
|
||||
`routes.guards.json` did not move.
|
||||
- **The `/:param` shadowing check was run again and is clean.** All 86 public/player/auth routes and
|
||||
all 64 literal paths among them dispatch to their own layer. This was checked in *dispatch* order
|
||||
against the built stack, since the manifest sorts and therefore cannot see declaration order. The
|
||||
only ordering-sensitive pair is `GET /public/wiki/{categories,tags}` ahead of `/public/wiki/:slug` —
|
||||
the public twin of the `admin/wiki.router.js` trap PR 3 found, and `wiki.router.js` says so. The
|
||||
`/public/pages` preview route also stays ahead of `/:slug`, though at a different depth.
|
||||
- **Filename deviations from the target tree, both for prefix agreement.** The tree named the public
|
||||
posts router `news.router.js`; it is `posts.router.js`, matching the `/posts` prefix it owns and its
|
||||
`admin/posts.router.js` sibling. The tree also implied `sso.router.js` / `mobile.router.js`; those
|
||||
files already exist as `sso.routes.js` / `mobile.routes.js` and were not renamed — they did not move
|
||||
in this PR, and churning their names would add diff noise to a PR whose value is being reviewable.
|
||||
- **`auth/session.router.js` is a deviation the target tree did not anticipate**, the same shape as
|
||||
PR 2's `activity.router.js`. The tree listed `login.router.js` but had nowhere to put `/logout` and
|
||||
`GET /me`, which own no prefix. Folding them into `login.router.js` would have forced *that* router
|
||||
to the group root and given up prefix ownership for the four login routes; a separate root-mounted
|
||||
singleton file keeps `/login` a real prefix mount.
|
||||
- **Tag mismatches were left alone again**, on the PR 1 / PR 3 / PR 4 precedent: the acceptance
|
||||
criterion is a byte-identical spec, so retagging belongs in a PR that is about tags.
|
||||
- **`public.controller.js` was not split.** Unlike the admin controllers, it is still one file serving
|
||||
settings/status/version/contact *and* posts/wiki/pages. The plan's rule is that these PRs re-wire
|
||||
routes, not logic — splitting a controller is a separate change with a separate risk profile, and
|
||||
bundling it would have cost this PR its "pure mechanical refactor" acceptance criteria.
|
||||
|
||||
### The swagger path-normalization prerequisite (landed before PR 1)
|
||||
|
||||
swagger-autogen builds a path by string-concatenating the mount prefix with the route argument, so a
|
||||
capability router mounted at `/users` whose collection route is `router.get('/')` documents as
|
||||
`/api/v1/admin/users/` — advertising a URL no client calls while dropping the one the SPA, the Android
|
||||
app and the Discord bot all do. Express is indifferent; the published spec is not. It also emits path
|
||||
keys in *router-traversal order*, so moving a route between files rewrote most of the ~5k-line
|
||||
committed artifact even when the API was provably unchanged — burying the one line a reviewer needs.
|
||||
|
||||
Both are fixed once in `server/swagger/swagger.js`, which post-processes the generator's output to
|
||||
strip trailing slashes and sort path keys (throwing on a collision rather than silently dropping an
|
||||
operation). It shipped as its own PR ahead of PR 1, verified inert by the regenerated spec being
|
||||
byte-for-byte the sorted form of the previously committed one — 198 operations, none added or removed.
|
||||
`#swagger.path` was rejected as the fix: it bypasses the mount prefix, so every route would hardcode
|
||||
its absolute path in a comment that silently lies the moment a mount moves.
|
||||
|
||||
**Consequence for PRs 2–5: the swagger diff is now a signal.** With sorting in place, a pure route
|
||||
move produces *no* spec diff at all, so any diff there means an annotation actually changed. Treat
|
||||
`swagger-output.json`, `routes.manifest.json` and `routes.guards.json` as three zero-diff gates.
|
||||
|
||||
### PR 0 — the route manifest (prerequisite of the first split PR) — **landed**
|
||||
|
||||
> **Status: shipped.** `server/scripts/routeManifest.js` + `npm run routes:manifest`,
|
||||
> `server/routes.manifest.json` (199 public + 2 internal), `server/routes.guards.json`,
|
||||
> `server/test/routeManifest.test.js`, and a `routes:manifest -- --check` step in
|
||||
> `.gitea/workflows/pr-checks.yml`. No router file moved. **The generator reproduced
|
||||
> `api-route-inventory.json` byte-for-byte on first run**, so the freeze is in effect and the
|
||||
> committed baseline is confirmed accurate rather than merely asserted.
|
||||
>
|
||||
> Two deviations from the design below, both deliberate:
|
||||
>
|
||||
> - **The unauthenticated-status snapshot was tried and dropped**, exactly as this section allowed.
|
||||
> Firing unauthenticated GETs at every manifest path against the dead-port mariadb pool the tests
|
||||
> use does not fail fast — the pool sits on its acquire timeout, and a partial sweep had not
|
||||
> finished after two minutes. A flaky two-minute gate is worse than none. What replaced it is
|
||||
> cheap and deterministic: the test suite asserts from the introspected stack that every
|
||||
> `/api/v1/admin/**` and `/api/v1/player/**` route still carries `requireAuth`.
|
||||
> - **`routes.guards.json` is committed and staleness-checked**, though a diff in it is explicitly
|
||||
> *not* a contract change. Left ungenerated it would rot into a misleading review aid within a
|
||||
> release. The gate is on freshness; the meaning of a guards diff is still "read this", not
|
||||
> "justify this". The generator drops app-level plumbing (helmet, morgan, the JSON parser, the bot
|
||||
> guard) since it applies uniformly to all 199 routes and would bury the per-route gates.
|
||||
|
||||
|
||||
|
||||
"Every URL is unchanged" must be *proved by a diff*, not asserted in review. PR 0 lands the tool that
|
||||
proves it, with no router file moved.
|
||||
|
||||
- **Generator:** `server/scripts/routeManifest.js`, wired as `npm run routes:manifest`. It requires
|
||||
`src/app.js` (which exports the app and neither listens nor connects to the DB — `server.js` owns
|
||||
those), walks `app._router.stack` recursively through mounted routers, reconstructs each full path
|
||||
from the layer regexps, and writes a **sorted** array of
|
||||
`{ "method": "GET", "path": "/api/v1/admin/users/:id" }` to `server/routes.manifest.json`.
|
||||
`internalApp.js` is walked into a separate `internal` section so the unpublished port is inventoried
|
||||
without being confused for public surface.
|
||||
- **Scope it to the API surface, or it won't be deterministic.** Three mounts are *filesystem*
|
||||
conditional: the SPA catch-all `GET *` (only when `client/dist/index.html` exists), the `/brand`
|
||||
static mount, and `/api/docs*` (only when `swagger-output.json` is present — it is committed, so it
|
||||
is stable). The manifest keeps only `/api/**`, `/.well-known/**`, and the internal app's routes, so
|
||||
it does not change depending on whether CI built the client. Static mounts are not API contract.
|
||||
- **The baseline already exists:** [`api-route-inventory.json`](./api-route-inventory.json) in this
|
||||
directory is the frozen surface — **199 API routes** (110 of them `/api/v1/admin`) plus 2
|
||||
internal at the time PR 0 was written. PR 0's generator must **reproduce this file byte-for-byte**;
|
||||
that is PR 0's own acceptance test, and it means the freeze is already in effect before the first
|
||||
router moves. *(It did, on first run. The file has since moved to **200** — the CSP report-only PR
|
||||
added `POST /api/csp-report`, the first deliberate, reviewed manifest diff.)*
|
||||
- **Runtime introspection, not source parsing.** It is authoritative about mounts, and the route paths
|
||||
in `admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats naive greps.
|
||||
- **Not `swagger-output.json`.** That is annotation-derived (only annotated routes appear) and churns
|
||||
for unrelated reasons; it documents intent, the manifest records reality.
|
||||
- **Frozen key: method + path only.** That is exactly the contract being preserved. Handler names are
|
||||
useless as a guard check here — `requireRole(...)` returns an anonymous arrow, and router-level gates
|
||||
like `adminRouter.use(noindex, isLoggedIn, staffOnly)` never appear in a route's own stack.
|
||||
- **Guard coverage, separately.** The generator also emits a non-gated review aid: per route, the
|
||||
handler count plus any *named* middleware collected along the mount chain. If a stable behavioral
|
||||
check proves cheap, prefer it — a test that fires an **unauthenticated** request at every manifest
|
||||
path and snapshots the status code catches a dropped `adminOnly` (403 → 200) in a way names cannot.
|
||||
Try it in PR 0; if DB-touching public routes make it slow or noisy against the dead-port pool the
|
||||
tests use, drop it rather than ship a flaky gate.
|
||||
- **CI:** `.gitea/workflows/pr-checks.yml` runs `npm run routes:manifest` and
|
||||
`git diff --exit-code server/routes.manifest.json`. A PR that moves a URL fails unless it
|
||||
deliberately commits the new manifest — which puts the URL change in front of a reviewer instead of
|
||||
letting it pass silently.
|
||||
- **Published copy:** `docs/website/api-route-inventory.json` mirrors `server/routes.manifest.json` and
|
||||
is refreshed in each split PR's mandatory docs edit. The markdown table above is orientation for a
|
||||
human reader; **the manifest is the authoritative freeze.**
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & PR breakdown
|
||||
|
||||
CSP and the split are independent; the only hard ordering is PR 0 before the first split PR.
|
||||
|
||||
**Resequenced during implementation: PR 0 ships first, before the CSP pair.** The CSP report-only PR
|
||||
has to stand up a violation collector (`POST /api/csp-report`) for `report-to` to point at — which is
|
||||
a new URL under `/api/**`. Landing it first would mean PR 0's generator emitting 200 routes against a
|
||||
199-route committed baseline, so PR 0 could no longer prove itself by reproducing
|
||||
`api-route-inventory.json` byte-for-byte. With PR 0 first, the collector shows up as a reviewed,
|
||||
deliberate `+1` in the manifest — which is exactly the mechanism working as designed.
|
||||
|
||||
1. **PR 0 — route manifest.** Generator + CI check + committed baseline of today's surface. No routers moved. ✅ landed
|
||||
2. **PR — CSP report-only.** Tightened policy behind `Content-Security-Policy-Report-Only` + `report-to`,
|
||||
plus the report collector (manifest `+1` — the first deliberate, reviewed manifest diff). ✅ landed
|
||||
3. **PR — CSP enforce.** One release later, assuming a clean violation report. **Blocked on real soak
|
||||
data**, not on code: watch the `csp` log tag for `frame-ancestors` reports across one release before
|
||||
flipping. Also decide there whether `/api/csp-report` is retired with the report-only twin or kept
|
||||
as a `report-to` group on the enforced policy.
|
||||
4. **PR 1 — admin:** `users`, `account`, `invites`, `auth` (providers). ✅ landed
|
||||
5. **PR 2 — admin:** `moderation`, `bot-activity`, `activity`. ✅ landed
|
||||
6. **PR 3 — admin (content):** `posts`, `pages`, `wiki`, `uploads`. ✅ landed
|
||||
7. **PR 4 — admin (ops/config):** `shard`, `uo-link`, `email`, `discord-bot`, `settings`, `site-mode`,
|
||||
`dashboard`. (`activity` went with PR 2 — see § PR 2 — as landed.) **This is the whole residual
|
||||
file** — `admin.routes.js` is deleted here, not by PR 5. ✅ landed
|
||||
8. **PR 5 — `public/*` + `player/*`** (and the residual `auth/*` grouping). ✅ landed — **the domain
|
||||
split is complete.** The only remaining item in this plan is the CSP enforce PR (3), which is
|
||||
blocked on soak data, not on code.
|
||||
|
||||
Each PR: **zero-line diff in `routes.manifest.json`**, server tests green
|
||||
(`cd website/server && npm test`), Swagger regenerated, matching `docs/` edit, Conventional Commit,
|
||||
AI-disclosure trailer, branch from a freshly-pulled `main`.
|
||||
|
||||
---
|
||||
|
||||
## Cross-component blast radius
|
||||
|
||||
Three independent clients consume the site's HTTP/SSE API, two of them in separate repos on separate
|
||||
release cadences. **With the auth merge and the version bump both gone, the blast radius is empty** —
|
||||
no client's URLs or authentication change at all.
|
||||
|
||||
| Consumer | Repo (cadence) | Pinning | Impact under this plan |
|
||||
|---|---|---|---|
|
||||
| Browser SPA | `website/client` (lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | **None.** Same URLs, same cookie session. |
|
||||
| Android app | `android-app` (app-store cadence, un-updatable installs in the wild) | 69 hardcoded `api/v1/…` paths; SSE path in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **None.** No repoint, no release required. |
|
||||
| Discord bot | `website/bot` (separate deploy, env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` | **None.** Anonymous public reads on unchanged paths. |
|
||||
|
||||
Standing constraints, unchanged:
|
||||
|
||||
- **The public SSE stream stays anonymous.** Consumed by logged-out browser visitors *and* the Android
|
||||
`ShardStreamClient`, neither of which sends an `Authorization` header. Adding `requireAuth` blacks
|
||||
out the public live boards on web and mobile. The single most likely regression in a careless
|
||||
refactor is reflexively wrapping *both* shard streams in auth.
|
||||
- **The admin SSE stream keeps its current cookie-based gating** (`isLoggedIn`, `EventSource` +
|
||||
`withCredentials`) — the rewrite that would have changed this went out with the auth merge.
|
||||
- **The public/admin allowlist split is a security boundary**, not an implementation detail. Preserve
|
||||
it verbatim in `utils/shardBroadcast.js` / `utils/shardIngest.js` as routes move.
|
||||
- **SSO / email-connect transaction cookies are load-bearing for web *and* native.** The Android SSO
|
||||
flow opens a Custom Tab to the website's `/auth/…/sso/start` and rides the same server-side redirect
|
||||
transaction and the same tx cookies. Nothing in this plan touches them; don't let a future "cookies
|
||||
go away" push delete them.
|
||||
- **`link/` is out of scope.** The sidecar contract (`uoLinkConfig`, `utils/uoLinkClient.js`,
|
||||
`utils/shardIngest.js`, `X-UOLink-Version`) is a separate versioning axis. `PROTOCOL_VERSION` does
|
||||
**not** bump for this work.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: mapping from the previous plan
|
||||
|
||||
| Previous | Now |
|
||||
|---|---|
|
||||
| Phase 0 — `/api/mobile` facade + app-version floor | **Deferred.** See § Deferred: the `/api/mobile` facade |
|
||||
| Phase 1 — auth merge | **Removed.** See § Why the auth merge is out and § Deferred: the auth merge |
|
||||
| Phase 1b — CSP hardening (shipped with the auth merge) | **Phase 1**, standalone; report-only-first ordering fixed |
|
||||
| Phase 2 — domain split under `router/v2/` | **Phase 2**, in place under `router/v1/`; now the primary driver |
|
||||
| PR 1 — `/api/v2` scaffold ([API_V2_SKELETON.md](./API_V2_SKELETON.md)) | **Superseded**, kept as the recipe if a versioned API is ever forced |
|
||||
| PR final — retire v1 | **Not applicable** — v1 is never replaced |
|
||||
131
website/API_V2_SKELETON.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Website API v2 — `/api/v2` Skeleton (PR 1)
|
||||
|
||||
> ## ⚠ Superseded — not scheduled
|
||||
>
|
||||
> The router domain split is being done **in place**, with every URL byte-identical, so there is no
|
||||
> parallel version to stand up and this scaffold will not be built. See
|
||||
> [API_V2_PLAN.md](./API_V2_PLAN.md) § Why there is no `/api/v2`.
|
||||
>
|
||||
> The file is kept, unedited below, as the concrete recipe **if** a real contract break ever forces a
|
||||
> versioned API. Nothing here describes current or planned work.
|
||||
|
||||
Companion to [API_V2_PLAN.md](./API_V2_PLAN.md) — this is the concrete scaffold for **PR 1** in that
|
||||
plan's sequencing. It stands up `/api/v2` **empty but wired**, next to a frozen `/api/v1`, with **no
|
||||
behavior change**. Endpoints are filled in by the later PRs (auth merge, then the domain split).
|
||||
|
||||
## Scope
|
||||
|
||||
- Create the `router/v2/` tree of empty, domain-named routers.
|
||||
- Mount `/v2` alongside `/v1` in `api.router.js`.
|
||||
- Add a single trivial `GET /api/v2/version` so the mount is testable end-to-end.
|
||||
- **Out of scope:** any real endpoint, any auth change, any controller edit. Those are PR 2+.
|
||||
|
||||
## File tree to create
|
||||
|
||||
```
|
||||
website/server/src/router/v2/
|
||||
v2.router.js # mounts the domain sub-routers; adds GET /version
|
||||
admin/
|
||||
index.js # mounts the admin capability routers under /admin
|
||||
dashboard.router.js users.router.js moderation.router.js
|
||||
content.router.js wiki.router.js shard.router.js
|
||||
settings.router.js invites.router.js bot-activity.router.js
|
||||
auth/
|
||||
index.js login.router.js sso.router.js totp.router.js session.router.js
|
||||
public/
|
||||
index.js news.router.js wiki.router.js page.router.js shard.router.js
|
||||
player/
|
||||
index.js profile.router.js appeals.router.js shard.router.js
|
||||
```
|
||||
|
||||
`internal/` is **not** part of v2's public tree — the internal routes stay on the separate,
|
||||
unpublished port (`internalApp.js`), exactly as in v1. See `API_V2_PLAN.md` § Phase 2.
|
||||
|
||||
## Wiring
|
||||
|
||||
`api.router.js` gains the v2 mount next to v1:
|
||||
|
||||
```js
|
||||
const v1Router = require('./v1/v1.router')
|
||||
const v2Router = require('./v2/v2.router')
|
||||
|
||||
apiRouter.use('/v1', v1Router)
|
||||
apiRouter.use('/v2', v2Router) // NEW — parallel version, migrate off v1 route-by-route
|
||||
```
|
||||
|
||||
`v2.router.js` mounts each domain group and exposes the version ping:
|
||||
|
||||
```js
|
||||
const express = require('express')
|
||||
const v2Router = express.Router()
|
||||
|
||||
const adminRouter = require('./admin')
|
||||
const authRouter = require('./auth')
|
||||
const publicRouter = require('./public')
|
||||
const playerRouter = require('./player')
|
||||
|
||||
// Cheap liveness/mount check so the parallel version is testable before any
|
||||
// real endpoint exists. Returns the API major version, nothing sensitive.
|
||||
v2Router.get('/version', (req, res) => res.json({ version: 2 }))
|
||||
|
||||
v2Router.use('/auth', authRouter)
|
||||
v2Router.use('/public', publicRouter)
|
||||
v2Router.use('/admin', adminRouter)
|
||||
v2Router.use('/player', playerRouter)
|
||||
// NOTE: /internal is intentionally NOT mounted here — same reason as v1.
|
||||
|
||||
module.exports = v2Router
|
||||
```
|
||||
|
||||
Each capability router is an empty stub at this stage — a router that mounts cleanly and adds no
|
||||
routes yet, so PR 2+ only has to add handlers, never re-wire:
|
||||
|
||||
```js
|
||||
// router/v2/admin/dashboard.router.js
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
// Routes added in the admin domain-split PR (see API_V2_PLAN.md § Phase 2).
|
||||
|
||||
module.exports = router
|
||||
```
|
||||
|
||||
Each `index.js` mounts its group's capability routers under the URL that names them, e.g.:
|
||||
|
||||
```js
|
||||
// router/v2/admin/index.js
|
||||
const express = require('express')
|
||||
const admin = express.Router()
|
||||
|
||||
admin.use('/dashboard', require('./dashboard.router'))
|
||||
admin.use('/users', require('./users.router'))
|
||||
admin.use('/moderation', require('./moderation.router'))
|
||||
admin.use('/content', require('./content.router'))
|
||||
admin.use('/wiki', require('./wiki.router'))
|
||||
admin.use('/shard', require('./shard.router'))
|
||||
admin.use('/settings', require('./settings.router'))
|
||||
admin.use('/invites', require('./invites.router'))
|
||||
admin.use('/bot-activity', require('./bot-activity.router'))
|
||||
|
||||
module.exports = admin
|
||||
```
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Server boots with no error; every sub-router mounts.
|
||||
- `GET /api/v2/version` → `200 { "version": 2 }`.
|
||||
- `GET /api/v1/**` behavior is **byte-for-byte unchanged** — v1 is untouched.
|
||||
- Existing server tests stay green (`cd website/server && npm test`).
|
||||
- A new test asserts the `/api/v2/version` mount (smallest possible coverage of the wiring).
|
||||
|
||||
## Docs / spec
|
||||
|
||||
- Swagger regeneration is deferred until v2 has real routes (PR 2) — a lone `/version` ping doesn't
|
||||
need an annotation. When PR 2 lands, add `#swagger.*` to the new routes and run `npm run swagger`.
|
||||
- No `BACKEND_DESIGN.md` change here beyond noting the parallel `/api/v2` mount exists; the
|
||||
route-map/security-contract edits land with the PRs that add real endpoints.
|
||||
|
||||
## Next
|
||||
|
||||
PR 2 fills the `auth/` routers with the bearer access + refresh flow and drops the session cookie —
|
||||
see [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 1.
|
||||
114
website/ARCHITECTURE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Website — Architecture
|
||||
|
||||
How the pieces of `RunicGateway/website` fit together. The React SPA and the native mobile app
|
||||
talk to one Express backend (`router → controller → model → db`), which persists to MariaDB and
|
||||
bridges to the live game world **only** through the **uo-link** sidecar. The ServUO shard itself is
|
||||
never internet-facing — it dials out to the sidecar over loopback, and only the sidecar is exposed.
|
||||
|
||||
This is the canonical copy of the diagram; the same diagram is embedded in the website's
|
||||
[`README.md`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/README.md#architecture).
|
||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the full API / schema / security contract, and
|
||||
[`docs/link/`](../link/) for the wire protocol between the sidecar and the shard.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
%% ---------- Clients ----------
|
||||
subgraph clients["Clients"]
|
||||
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
|
||||
mobile["Native mobile app<br/>(bearer tokens)"]
|
||||
end
|
||||
|
||||
idp["SSO providers<br/>Google · Discord · custom OIDC"]
|
||||
discord["Discord"]
|
||||
|
||||
%% ---------- Website (one repo) ----------
|
||||
subgraph website["website/ — Node app (one repo)"]
|
||||
direction TB
|
||||
|
||||
subgraph backend["server/ — Express backend"]
|
||||
direction TB
|
||||
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
|
||||
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
|
||||
ctrl["Controllers"]
|
||||
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
||||
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
||||
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
||||
|
||||
subgraph shardutil["Shard integration (utils/)"]
|
||||
ingest["shardIngest.js<br/>WS ingest dispatcher"]
|
||||
restcli["uoLinkClient.js<br/>REST client (never throws)"]
|
||||
end
|
||||
|
||||
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
||||
end
|
||||
|
||||
bot["bot/<br/>Discord bot"]
|
||||
end
|
||||
|
||||
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
|
||||
|
||||
%% ---------- Shard side ----------
|
||||
subgraph shardside["Game shard (never internet-facing)"]
|
||||
direction TB
|
||||
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
|
||||
servuo["ServUO shard<br/>(C# plugin)"]
|
||||
end
|
||||
|
||||
%% ---------- Edges ----------
|
||||
browser <-->|"same-origin JSON + SSE (cookie)"| mw
|
||||
mobile -->|"REST (bearer access/refresh)"| mw
|
||||
browser -.->|"OAuth redirect + PKCE"| idp
|
||||
auth -.->|"token exchange"| idp
|
||||
|
||||
mw --> router --> ctrl
|
||||
ctrl --> auth
|
||||
ctrl --> model
|
||||
ctrl --> restcli
|
||||
ctrl --> sse
|
||||
auth --> model
|
||||
model <--> db
|
||||
auth -. reads/writes secrets .-> secret
|
||||
restcli -. reads config/token .-> secret
|
||||
ingest --> model
|
||||
ingest --> sse
|
||||
sse -->|"live events"| browser
|
||||
bot -->|"messages"| discord
|
||||
bot <--> db
|
||||
|
||||
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
|
||||
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
|
||||
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
|
||||
|
||||
%% ---------- Styling ----------
|
||||
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
|
||||
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
|
||||
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
|
||||
class idp,discord ext;
|
||||
class db store;
|
||||
class sidecar,servuo bridge;
|
||||
```
|
||||
|
||||
## Notes on the diagram
|
||||
|
||||
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
|
||||
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
|
||||
access tokens plus rotated, hashed, revocable refresh tokens; SSO (Google/Discord/OIDC) is
|
||||
link-only and PKCE-guarded. All three surfaces resolve to the *same* session model via the session
|
||||
layer, and admin routes are re-validated against the DB on every request.
|
||||
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP `127.0.0.1:7788`
|
||||
(newline-delimited JSON) to the uo-link sidecar; only the sidecar is exposed, and only the backend
|
||||
talks to it. Every backend→sidecar call carries `Authorization: Bearer <token>` and an
|
||||
`X-UOLink-Version` header (a protocol mismatch fails fast with `409`). The REST client
|
||||
(`uoLinkClient.js`) never throws — every call returns `{ ok, data, status }` — so the site degrades
|
||||
gracefully when the shard is down.
|
||||
- **Two ways in from the sidecar.** Live game events arrive over an outbound **WebSocket** and are
|
||||
routed by the `shardIngest.js` dispatcher (state-changing kinds update `shard_*` tables, notable
|
||||
kinds append to `shard_events`, high-frequency kinds only update state). Point-in-time reads and
|
||||
commands go over **REST** through `uoLinkClient.js`.
|
||||
- **Sensitive events stay private.** Ingested events fan out to browsers over two **SSE** channels —
|
||||
a public allowlist stream and an admin-only stream that additionally carries staff audit, cheat
|
||||
detection, and login-attempt events. The allowlist split is a security boundary; sensitive kinds
|
||||
can never leak onto the public channel.
|
||||
- **Secrets at rest.** OAuth client secrets, the uo-link token, and the Gmail refresh token are
|
||||
AES-256-GCM encrypted via `secretBox.js` (keyed by `SECRET_ENC_KEY`). The uo-link token is
|
||||
write-only in the API — never returned to any client.
|
||||
@@ -29,6 +29,24 @@ Public contact email: **UOMysticmoon@gmail.com**
|
||||
|
||||
Skeleton from the spec, with a small number of justified additions marked **(+)**.
|
||||
|
||||
> **Complete.** The monolithic route files (`admin.routes.js` especially, originally 1552 lines /
|
||||
> 110 routes) have been split into one router file per business capability — **in place, with every
|
||||
> URL unchanged**. See [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 2.
|
||||
>
|
||||
> `users`, `account`, `invites`, `auth/providers` (PR 1, 28 routes), `moderation`, `bot-activity`,
|
||||
> `activity` (PR 2, 18 routes), `posts`, `uploads`, `wiki`, `pages` (PR 3, 31 routes) and `shard`,
|
||||
> `uo-link`, `email`, `discord-bot`, `settings`, `dashboard`/`site-mode` (PR 4, 33 routes) each live
|
||||
> in their own router under `admin/`, behind `admin/index.js`. PR 5 did the same for `public/` (24),
|
||||
> `player/` (20) and the residual `auth/` (10). **`admin.routes.js`, `public.routes.js`,
|
||||
> `player.routes.js` and `auth.routes.js` are all deleted**; each group is now a directory whose
|
||||
> `index.js` owns the group gate and the mount table and declares no routes of its own.
|
||||
>
|
||||
> "Every URL unchanged" is enforced mechanically, not by review: `server/scripts/routeManifest.js`
|
||||
> (`npm run routes:manifest`) walks the live Express stack and writes the sorted
|
||||
> `{ method, path }` freeze to `server/routes.manifest.json`, mirrored here as
|
||||
> [api-route-inventory.json](./api-route-inventory.json). PR checks regenerate it and fail on any
|
||||
> diff, so a split PR that moves a URL cannot merge silently. See § 4.0.
|
||||
|
||||
```
|
||||
server/
|
||||
.env.example
|
||||
@@ -42,10 +60,104 @@ server/
|
||||
router/
|
||||
api.router.js mounts /v1
|
||||
v1/
|
||||
v1.router.js mounts /auth /public /admin
|
||||
auth/ auth.routes.js + auth.controller.js
|
||||
public/ public.routes.js + public.controller.js
|
||||
admin/ admin.routes.js + admin.controller.js
|
||||
v1.router.js mounts /auth /public /admin /player
|
||||
auth/ index.js mounts the routers below; no group gate — /auth
|
||||
is where an anonymous caller becomes
|
||||
authenticated, so the authenticated parts gate
|
||||
themselves. Mount order is load-bearing (see
|
||||
session.router.js)
|
||||
login.router.js (2) /auth/login + /login/totp — shared
|
||||
loginGuards stack
|
||||
register.router.js (1) /auth/register — honours the
|
||||
player_registration setting
|
||||
invite.router.js (2) /auth/invite/:token[/accept] — the
|
||||
token is its own authority, so it
|
||||
bypasses player_registration
|
||||
password.router.js (3) /auth/password/forgot + reset/:token
|
||||
session.router.js (2) POST /logout and GET /me — the two
|
||||
singletons owning no path segment, so
|
||||
mounted at the group root, LAST: the
|
||||
/me sub-routers below also match the
|
||||
bare /me and supply its noindex header
|
||||
me.routes.js (23) /auth/me/account*, sessions, trusted
|
||||
devices — router-level requireAuth
|
||||
notifications.routes.js (3) /auth/me/devices*, notifications/*
|
||||
mobile.routes.js + /auth/mobile/* — native bearer login
|
||||
mobileSso.routes.js (5)
|
||||
sso.routes.js (4) mounted PATHLESS: owns two prefixes,
|
||||
/auth/providers and /auth/sso/*
|
||||
loginGuards.js shared backoff/slow/limiter stack for
|
||||
every credential-guessing surface
|
||||
(not a router)
|
||||
auth.controller.js + invite/passwordReset/sso/mobile controllers
|
||||
public/ index.js mounts the routers below; **no group gate** —
|
||||
this surface is anonymous by design (SPA
|
||||
logged-out, Discord bot, Android ShardStream)
|
||||
posts.router.js (2) /public/posts/:category[/:idOrSlug]
|
||||
wiki.router.js (4) /public/wiki — /categories and /tags
|
||||
MUST precede /:slug
|
||||
pages.router.js (2) /public/pages — the draft-preview
|
||||
route precedes /:slug and is
|
||||
deliberately not site-mode gated
|
||||
shard.router.js (12) /public/shard/* incl. the anonymous
|
||||
SSE stream; never site-mode gated
|
||||
site.router.js (4) /settings /status /version /contact —
|
||||
the group-root singletons; declares no
|
||||
router-level middleware
|
||||
public.controller.js + shard.controller.js
|
||||
player/ index.js owns the shared `noindex, requireAuth` gate
|
||||
(authenticated, ANY role — staff are a superset
|
||||
of players) and the mount table
|
||||
account.router.js (8) /player/account — credentials, TOTP,
|
||||
linked identities; handlers shared
|
||||
with /admin/account and /auth/me
|
||||
shard.router.js (8) /player/shard — linking + own roster,
|
||||
vendors, chars, sales, houses
|
||||
appeals.router.js (4) /player/appeals
|
||||
shard.controller.js + appeals.controller.js
|
||||
admin/ index.js mounts the capability routers below at their
|
||||
own prefixes; owns the shared
|
||||
`noindex, isLoggedIn, staffOnly` gate and
|
||||
declares no routes itself
|
||||
account.router.js (6) /admin/account — self-service, no adminOnly
|
||||
users.router.js (15) /admin/users — adminOnly
|
||||
invites.router.js (3) /admin/invites — adminOnly
|
||||
authProviders.router.js (4) /admin/auth — adminOnly
|
||||
moderation.router.js (15) /admin/moderation — modAccess
|
||||
(admin+moderator) at router level
|
||||
botActivity.router.js (2) /admin/bot-activity — adminOnly
|
||||
activity.router.js (1) /admin/activity — staff-wide
|
||||
audit log, no extra gate
|
||||
posts.router.js (9) /admin/posts — editor tier, no
|
||||
gate beyond staffOnly
|
||||
uploads.router.js (1) /admin/uploads — rich-text editor
|
||||
image upload
|
||||
wiki.router.js (14) /admin/wiki — pages, revisions,
|
||||
categories, tags
|
||||
pages.router.js (7) /admin/pages — CMS page builder
|
||||
imageUpload.js shared multer config for the two
|
||||
upload routes above (not a router)
|
||||
shard.router.js (16) /admin/shard — 7 self-service
|
||||
account-linking routes (no extra
|
||||
gate, handlers shared with
|
||||
/player/shard) + 9 in-game staff
|
||||
ops on modAccess, per route
|
||||
uoLink.router.js (5) /admin/uo-link — sidecar config,
|
||||
town crier, admin SSE — adminOnly
|
||||
email.router.js (6) /admin/email — Gmail OAuth2
|
||||
delivery — adminOnly
|
||||
discordBot.router.js (2) /admin/discord-bot — adminOnly
|
||||
settings.router.js (2) /admin/settings — adminOnly
|
||||
dashboard.router.js (2) GET /dashboard (staff-wide) and
|
||||
PUT /site-mode (adminOnly) — the
|
||||
two singletons owning no path
|
||||
segment, so mounted at the group
|
||||
root; declares no router-level
|
||||
middleware, which is what makes a
|
||||
root mount safe
|
||||
admin.controller.js + the per-capability controllers
|
||||
(already domain-split; the split PRs re-wire
|
||||
routes, not logic)
|
||||
model/
|
||||
users/ users.model.js + users.db.js
|
||||
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
|
||||
@@ -142,6 +254,109 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
|
||||
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
|
||||
### password_resets — self-service reset links
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; **plaintext never stored** |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets |
|
||||
| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic `markUsed`) |
|
||||
| requested_ip | VARCHAR(64) NULL | who asked (audit only) |
|
||||
| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this |
|
||||
| created_at / used_at | DATETIME | |
|
||||
|
||||
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
|
||||
A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
||||
|
||||
### push_devices — opt-in push endpoints (M7)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
|
||||
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
|
||||
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
|
||||
| platform | VARCHAR(40) NULL | free-form label, e.g. `android` |
|
||||
| created_at / last_seen_at | DATETIME | |
|
||||
|
||||
`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert.
|
||||
|
||||
### notification_subscriptions — which streams a user opted into (M7)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
||||
replaces the whole set. Nothing is pushed unless the user subscribed.
|
||||
|
||||
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
||||
|
||||
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
|
||||
carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP
|
||||
PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for
|
||||
bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by
|
||||
construction, and the authorization code is stored as a **sha256 hash only** (same pattern as
|
||||
`user_invites` / `password_resets` / `mobile_refresh_tokens`).
|
||||
|
||||
`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`:
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row |
|
||||
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` |
|
||||
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` |
|
||||
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) |
|
||||
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
|
||||
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending`→`completed` when the code is minted; `consumed` after a successful exchange |
|
||||
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
|
||||
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
|
||||
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
|
||||
|
||||
`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems):
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
|
||||
| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) |
|
||||
| expires_at | DATETIME NOT NULL | very short (~5 min) |
|
||||
| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing
|
||||
`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron
|
||||
infra is added (same approach as `revoked_sessions`).
|
||||
|
||||
**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device
|
||||
list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME
|
||||
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
|
||||
is otherwise unchanged.
|
||||
|
||||
### trusted_devices — MFA "Trust this device"
|
||||
|
||||
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
|
||||
to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on
|
||||
web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored
|
||||
(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by
|
||||
its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table
|
||||
(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`,
|
||||
`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is
|
||||
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
|
||||
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
|
||||
TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`.
|
||||
|
||||
### recovery_codes — single-use MFA backup codes
|
||||
|
||||
Generated at TOTP enrollment (10 at a time, shown to the user **once**) so a user who loses their
|
||||
authenticator can complete login without an admin reset. `code_hash VARCHAR(72)` is a **bcrypt** hash
|
||||
(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest
|
||||
analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10
|
||||
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
|
||||
marker. Cleared wholesale on TOTP disable / password change / password reset.
|
||||
|
||||
---
|
||||
|
||||
## 4. API contract
|
||||
@@ -149,21 +364,186 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
|
||||
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
||||
accepts `Authorization: Bearer` for API testing).
|
||||
|
||||
### /auth (auth.routes.js → auth.controller.js)
|
||||
### 4.0 The authoritative route list
|
||||
|
||||
The prose tables below are **orientation for a human reader** and can drift. Two generated artifacts
|
||||
are authoritative, and they answer different questions:
|
||||
|
||||
| Artifact | Source of truth for | Generated by |
|
||||
|---|---|---|
|
||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 200 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
||||
|
||||
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
|
||||
it churns whenever a description is reworded — it documents *intent*. The manifest is introspection-
|
||||
derived and records *reality*, which is why it, not Swagger, is the thing PR checks freeze
|
||||
(`npm run routes:manifest -- --check`).
|
||||
|
||||
Both artifacts are emitted with **sorted** keys, so a diff in either is proportional to the change
|
||||
rather than to how the routers happen to be traversed. `swagger.js` additionally strips trailing
|
||||
slashes from generated path keys — see *Regenerating the spec* in the website README for why the
|
||||
domain split makes that necessary.
|
||||
|
||||
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the
|
||||
internal listener. The SPA catch-all, `/uploads` and `/brand` are filesystem-conditional static
|
||||
mounts — not API contract, and including them would make the output depend on whether CI had built
|
||||
the client.
|
||||
|
||||
A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route,
|
||||
the middleware handler count plus the *named* middleware on its mount chain. It exists because a
|
||||
router-level `router.use(noindex, isLoggedIn, staffOnly)` gate never appears in an individual route's
|
||||
own stack, so a capability router extracted without re-applying the gate would otherwise publish
|
||||
authenticated endpoints silently. Names are a hint only — `requireRole(...)` returns an anonymous
|
||||
arrow and cannot be observed — but a *missing* `requireAuth` is unambiguous, and the server test suite
|
||||
asserts every `/admin/**` and `/player/**` route still carries it.
|
||||
|
||||
### /auth (auth/index.js → the capability routers in §2)
|
||||
|
||||
No group gate — `/auth` is where an anonymous caller becomes authenticated. The authenticated parts
|
||||
gate themselves: `me.routes.js` and `notifications.routes.js` each apply `noindex, requireAuth` at
|
||||
their own router level, and `/sso/:provider/link` carries `requireAuth` per route.
|
||||
|
||||
| Method | Path | Auth | Body | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
|
||||
| POST | `/logout` | cookie | — | clear cookie |
|
||||
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
|
||||
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. |
|
||||
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. |
|
||||
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
|
||||
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
|
||||
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
|
||||
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
||||
| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. |
|
||||
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
|
||||
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
|
||||
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
|
||||
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes |
|
||||
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
||||
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
|
||||
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
|
||||
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
|
||||
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
|
||||
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
|
||||
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
||||
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
||||
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
||||
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
|
||||
|
||||
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
|
||||
admins are created under `/admin/users`.
|
||||
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
|
||||
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
|
||||
(no logic duplication) behind `requireAuth` **only** — any active account, never a specific role. This
|
||||
lets a client (the Android app) manage its own account through one surface without ever touching
|
||||
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
|
||||
for web back-compat.
|
||||
|
||||
**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every
|
||||
player ability plus their staff tools on top — so the whole group (`account.router.js`,
|
||||
`shard.router.js`, `appeals.router.js`, mounted by `player/index.js`) sits behind the shared
|
||||
`noindex, requireAuth` gate **only**, never `requireRole('player')`. Every handler is self-scoped to the caller by `req.user.id`, so an admin/editor/
|
||||
moderator using it sees only their **own** linked accounts and characters (with the pre-existing
|
||||
`isAdmin` bypass still letting a genuine admin read *any* character). Staff also reach the identical
|
||||
self-scoped handlers under `/admin/shard/*` (same controller) for the web admin surface; the two are
|
||||
interchangeable. This is why a staff account with linked game characters gets its "My characters" and
|
||||
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
|
||||
|
||||
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
|
||||
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
|
||||
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
|
||||
reset link points at the web front end (`/account/reset/:token`); the Android app hands off here
|
||||
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
|
||||
by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites.
|
||||
|
||||
**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and
|
||||
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
|
||||
**content-free tickle** — `{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
|
||||
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
|
||||
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
|
||||
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
|
||||
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
|
||||
`config/notificationStreams.js`. Security invariants:
|
||||
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
|
||||
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
|
||||
push.
|
||||
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
|
||||
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
|
||||
`/player/shard/*`).
|
||||
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
|
||||
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
|
||||
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
|
||||
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
|
||||
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
|
||||
|
||||
### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9)
|
||||
|
||||
Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the
|
||||
app**. The website stays the identity authority: each shard owner's provider credentials live in
|
||||
`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new
|
||||
consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the
|
||||
`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
|
||||
issues the **same** token pair as `/auth/mobile/login`.
|
||||
|
||||
| Method | Path | Auth | Body / Query | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
|
||||
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
|
||||
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
|
||||
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}` |
|
||||
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
|
||||
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
|
||||
| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) |
|
||||
|
||||
**Two PKCE layers (do not conflate).**
|
||||
- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the
|
||||
httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged.
|
||||
- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the
|
||||
challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`.
|
||||
This is what stops an intercepted callback code from being redeemed by anyone but the real app.
|
||||
|
||||
**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect,
|
||||
and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE
|
||||
layers (a different app instance triggering `/start` cannot complete someone else's flow).
|
||||
|
||||
**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match**
|
||||
against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned
|
||||
callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes
|
||||
is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the
|
||||
short-lived authorization code.
|
||||
|
||||
*App Links (implemented).* When the admin toggle `mobile_app_links_enabled` is **on**, `/start` also
|
||||
accepts the self-origin HTTPS callback `https://<request-host>/mobile/callback` — one *additive*
|
||||
exact-match entry, derived from the request/`APP_BASE_URL` and never from client input; the
|
||||
custom-scheme allowlist is never narrowed. The shard then auto-serves `GET
|
||||
/.well-known/assetlinks.json` (fixed package `com.runicgateway.app` + `MOBILE_APP_CERT_SHA256`
|
||||
fingerprints; 404 when the toggle is off or no fingerprint is configured), and
|
||||
`settings.getPublic()` advertises `mobileAppLinks: <bool>`. These two things — one static file route
|
||||
and one more allowlist entry — are the *entire* server surface App Links require. See
|
||||
docs/android/APP_LINKS.md.
|
||||
|
||||
**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing
|
||||
pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the
|
||||
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
|
||||
the app — it never mints a session cookie for a mobile flow.
|
||||
|
||||
**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops
|
||||
future renewals but does **not** invalidate an already-issued access token until it expires — up to
|
||||
the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an
|
||||
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
|
||||
access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism
|
||||
web sessions already use.
|
||||
|
||||
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
|
||||
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
|
||||
|
||||
### /public (public/index.js → the capability routers in §2) — all GET except `/contact`, no auth
|
||||
|
||||
**No group gate, deliberately.** This surface is anonymous by design: the SPA renders it logged-out,
|
||||
the Discord bot reads it with no credentials, and the Android `ShardStreamClient` consumes
|
||||
`/public/shard/stream` without an `Authorization` header. Content visibility during maintenance comes
|
||||
from the per-route **siteMode** middleware (§5), never from an auth gate.
|
||||
|
||||
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
|
||||
| GET | `/status` | status message + current mode |
|
||||
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
|
||||
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
|
||||
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
|
||||
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
|
||||
| GET | `/posts/:category/:idOrSlug` | single published post |
|
||||
| GET | `/wiki` | list of pages (slug + title) |
|
||||
@@ -172,7 +552,17 @@ admins are created under `/admin/users`.
|
||||
|
||||
Public content GETs pass through the **siteMode** gate (§5).
|
||||
|
||||
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
|
||||
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
|
||||
|
||||
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
|
||||
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
|
||||
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
|
||||
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
|
||||
the whole gate. The ops/config capabilities — `uo-link`, `email`, `discord-bot`, `settings`, and
|
||||
`PUT /site-mode` — are `adminOnly`; `shard` is the one mixed prefix, where self-service account
|
||||
linking carries no extra gate and the in-game staff operations carry `modAccess`. There is no residual
|
||||
file: every admin route is declared in a capability router. The URLs below are unaffected by which
|
||||
file a route sits in — that is the property the route manifest freezes.
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
|
||||
@@ -189,6 +579,9 @@ Public content GETs pass through the **siteMode** gate (§5).
|
||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
||||
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
|
||||
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
|
||||
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
|
||||
|
||||
Every admin write logs to `activity_log`.
|
||||
|
||||
@@ -219,11 +612,47 @@ who"; `activity_log` provides the history feed.
|
||||
## 6. Auth & security
|
||||
|
||||
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`).
|
||||
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
||||
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
|
||||
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in
|
||||
**`server/src/config/csp.js`** (`app.js` only wires them up):
|
||||
`default-src 'self'`; `script-src 'self'` (the Vite build emits only external module chunks — the
|
||||
inline module-preload polyfill is disabled in `client/vite.config.js` to keep this valid);
|
||||
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` (React's pervasive inline
|
||||
`style={{…}}` attributes can't be nonce'd, plus the Google Fonts stylesheet); `font-src 'self'
|
||||
https://fonts.gstatic.com` (Cinzel); `img-src 'self' data: https:` (same-origin uploads, plus
|
||||
external https images embedded in wiki/news bodies or `BRAND_*` logo/hero/favicon); `connect-src
|
||||
'self'` (REST + SSE are same-origin); `frame-ancestors 'self'`; `object-src 'none'`; `base-uri
|
||||
'self'`; `form-action 'self'` (blocks an injected `<form action="https://evil">` from POSTing
|
||||
credentials off-origin — an exfil path `connect-src` does not cover; it was always emitted via
|
||||
helmet's `useDefaults` and is now pinned explicitly so it cannot vanish under a helmet upgrade).
|
||||
`upgrade-insecure-requests` is intentionally **not** set (TLS terminates at the proxy, there
|
||||
are no mixed-content subresources, and it would break a local `npm start` over plain http). The
|
||||
`/api/docs` Swagger UI route gets a **looser** policy that additionally allows inline script/style,
|
||||
since swagger-ui-express injects an inline bootstrap. helmet also strips `X-Powered-By`; the two
|
||||
internal-only listeners (`internalApp.js`, `bot/src/app.js`) disable it explicitly too.
|
||||
- **A second, tightened policy ships alongside on `Content-Security-Policy-Report-Only`** for one
|
||||
release before it replaces the enforced one (`docs/website/API_V2_PLAN.md` § Phase 1). It is derived
|
||||
from the enforced policy so the two cannot drift, and differs by exactly one directive:
|
||||
`frame-ancestors 'self'` → **`'none'`**. Serving both headers at once means the live policy keeps
|
||||
protecting users while anything the tightened version would break arrives as a report rather than as
|
||||
a broken page — and for `frame-ancestors` specifically, a report from the browser of whoever framed
|
||||
the site is the only way to learn that something does.
|
||||
- **`POST /api/csp-report`** is the same-origin violation sink that `report-to` / `report-uri` point at
|
||||
(`report-to` additionally requires the `Reporting-Endpoints` response header, which is set alongside).
|
||||
Same-origin on purpose: reports describe attacks against this site and are not handed to a
|
||||
third-party collector. It parses **both** wire formats (`application/csp-report` from Firefox/Safari,
|
||||
`application/reports+json` from Chrome's Reporting API — handling one drops half the browsers),
|
||||
writes to the `csp` log tag and **stores nothing**. Necessarily unauthenticated (browsers send
|
||||
reports with no session), so it is bounded on every axis: 16 KB body cap, per-IP rate limit, fixed
|
||||
field allowlist, every logged field truncated, and **always 204 — even for malformed input**, since a
|
||||
4xx would make the global error handler log the attacker-supplied body and turn an open endpoint into
|
||||
a log-flood primitive. Mounted outside `/api/v1` next to `/api/health`: the browser learns the path
|
||||
from the policy header, never from a client build, so it is not part of the versioned client
|
||||
contract.
|
||||
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
|
||||
- **No directory browsing** (express.static doesn't list; no `serve-index`).
|
||||
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
|
||||
@@ -248,7 +677,11 @@ instead. Errors never leak credentials.
|
||||
|
||||
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
|
||||
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
|
||||
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
|
||||
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, `[csp]`, …).
|
||||
|
||||
> During the CSP report-only soak, `[csp]` is the tag to watch: a `csp violation` warn line with
|
||||
> `directive: frame-ancestors` means something really does frame the site and the enforce PR would
|
||||
> break it. Silence across one release is the green light to flip.
|
||||
|
||||
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
|
||||
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
|
||||
@@ -270,7 +703,15 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
|
||||
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
|
||||
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
|
||||
`ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
|
||||
- Volumes: `dbdata`, `uploads`.
|
||||
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
|
||||
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`,
|
||||
**publishes `:80` on a host port** (`${NTFY_HOST_PORT:-2586}:80`, binds 0.0.0.0) so Pangolin — which
|
||||
runs outside the compose network — can forward the notification subdomain to it, the same reason
|
||||
`app` publishes `3000`. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to
|
||||
registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable
|
||||
topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack
|
||||
up provisions a working push relay with **zero interactive setup**.
|
||||
- Volumes: `dbdata`, `uploads`, `ntfydata`.
|
||||
|
||||
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
|
||||
|
||||
@@ -292,6 +733,19 @@ ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
|
||||
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
|
||||
NTFY_BASE_URL=https://ntfy.example.com
|
||||
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
|
||||
# (the app registers its topic endpoint here). Defaults to the first
|
||||
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
|
||||
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
|
||||
# shows push as unavailable for the shard.
|
||||
NTFY_PUBLIC_URL=https://ntfy.example.com
|
||||
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
||||
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
|
||||
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
|
||||
NTFY_HOST_PORT=2586
|
||||
```
|
||||
|
||||
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
|
||||
|
||||
128
website/MODERATION_APPEALS.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Runic Gateway Website — Moderation Appeals (Phase 6c/6d)
|
||||
|
||||
> Website feature branch: **`feature/moderation-appeals`**. Builds on the moderation
|
||||
> dashboard (Phase 6a/6b) and the Discord bot's `mod_actions` log. Companion to
|
||||
> [website-README.md](website-README.md) (overview) and
|
||||
> [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (base API contract).
|
||||
|
||||
## 1. Overview
|
||||
|
||||
A player whose linked Discord identity was **banned** or **muted** — an action
|
||||
recorded in the bot's `mod_actions` log — can open an **appeal** from the player
|
||||
portal and track its status. Staff (**admin** or **moderator** role) work the
|
||||
appeal from an **appeals queue** in the admin moderation section: claim it, then
|
||||
resolve it **approved** or **denied** with a written staff response.
|
||||
|
||||
When staff **approve** a ban/mute appeal, the website makes a best-effort call to
|
||||
the Discord bot's internal API to actually lift the ban / clear the timeout in
|
||||
Discord, and the bot posts a mod-log embed ("Appeal approved"). This is
|
||||
**best-effort**: if the bot is unreachable the appeal still resolves as approved,
|
||||
the reversal is recorded as failed, and staff can reverse the sanction manually in
|
||||
Discord.
|
||||
|
||||
Only **ban** and **mute** actions are appealable — the sanctions that have an
|
||||
ongoing effect. Warnings/kicks and similar one-shot actions are not.
|
||||
|
||||
## 2. Ownership & eligibility
|
||||
|
||||
- **`appeals` is a server-owned table** — only the website reads/writes it. It
|
||||
references the bot-owned `mod_actions` log by a plain id column
|
||||
(`mod_action_id`); there is **no hard cross-owner foreign key** between the two
|
||||
databases, so the reference is validated in application code (same pattern as
|
||||
the rest of the uo-link / bot integration, where the two services never share a
|
||||
live FK).
|
||||
- **Eligibility** — the appellant must be a **logged-in player** whose linked
|
||||
Discord identity (`user_identities`, `provider = 'discord'`) matches the
|
||||
`mod_actions` row's target. A player cannot open an appeal for someone else's
|
||||
action, and an unlinked player has nothing eligible to appeal.
|
||||
- **One active appeal per action** — only one `pending` / `under_review` appeal is
|
||||
allowed for a given `mod_action_id` at a time; a second attempt while one is
|
||||
already open is rejected.
|
||||
|
||||
## 3. Appeal lifecycle
|
||||
|
||||
```
|
||||
pending ──▶ under_review ──▶ approved
|
||||
└─▶ denied
|
||||
|
||||
pending ──▶ withdrawn
|
||||
under_review ──▶ withdrawn
|
||||
```
|
||||
|
||||
- **`pending`** — submitted by the player, not yet claimed.
|
||||
- **`under_review`** — claimed by a staffer (the claiming admin/moderator is
|
||||
stamped on the row).
|
||||
- **`approved`** / **`denied`** — resolved by staff with an optional
|
||||
`staff_response`. Approving a ban/mute appeal triggers the Phase 6d reversal
|
||||
(§5).
|
||||
- **`withdrawn`** — the player pulled the appeal back before it was resolved.
|
||||
|
||||
`reversal_status` (only meaningful on an approved ban/mute appeal) is one of
|
||||
`none` (not attempted / not applicable), `done`, or `failed`. No new
|
||||
`mod_actions` row is written for a reversal — it modifies the *original* action's
|
||||
standing rather than logging a new one.
|
||||
|
||||
## 4. API — player (role: `player`)
|
||||
|
||||
Base `/api/v1/player/appeals`.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/player/appeals` | The caller's own appeals. |
|
||||
| GET | `/player/appeals/eligible` | The caller's ban/mute actions with no active appeal (empty if they have no linked Discord identity). |
|
||||
| POST | `/player/appeals` | Open an appeal — `{ mod_action_id, submitted_text }`. `403` if the action isn't the caller's, `400` if the action isn't a ban/mute, `409` if one is already open for it. |
|
||||
| POST | `/player/appeals/:id/withdraw` | Withdraw an appeal that hasn't been resolved yet. |
|
||||
|
||||
## 5. API — staff (role: `admin` or `moderator`)
|
||||
|
||||
Base `/api/v1/admin/moderation/appeals`, alongside the existing moderation
|
||||
section.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/admin/moderation/appeals?status=&limit=&offset=` | The queue. Defaults to `pending` + `under_review`; pass `status=all` or a specific status to filter. |
|
||||
| GET | `/admin/moderation/appeals/:id` | One appeal. |
|
||||
| POST | `/admin/moderation/appeals/:id/claim` | `pending` → `under_review`, stamping the claiming staffer. |
|
||||
| POST | `/admin/moderation/appeals/:id/resolve` | `{ status: 'approved' \| 'denied', staff_response? }`. On an approved ban/mute, triggers the Discord reversal (§6). |
|
||||
| GET | `/admin/moderation/user/:discordId/appeals` | A user's appeals — shown as a tab on the per-user moderation history page. |
|
||||
|
||||
`resolve` returns a `reversal` object describing what happened:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"reversal": {
|
||||
"attempted": true,
|
||||
"ok": true,
|
||||
"reversal_status": "done", // "none" | "done" | "failed"
|
||||
"bot_status": 200,
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Auto-reversal (Phase 6d)
|
||||
|
||||
On `resolve` with `status: 'approved'` against a ban/mute appeal, the website
|
||||
calls the Discord bot's internal API:
|
||||
|
||||
```
|
||||
POST /internal/mod-reverse
|
||||
```
|
||||
|
||||
— gated by the same shared-secret scheme as the existing `/internal/announce`
|
||||
call. The bot lifts the ban / clears the timeout for the target and posts an
|
||||
"Appeal approved" embed to its mod log.
|
||||
|
||||
The call is **best-effort**: the appeal resolution itself always completes
|
||||
(the appeal is marked `approved` and the staff response is saved) regardless of
|
||||
whether the bot answers. If the bot is down or the call otherwise fails,
|
||||
`reversal_status` is recorded as `failed` and staff are expected to reverse the
|
||||
sanction by hand in Discord; the `reversal` object in the `resolve` response
|
||||
surfaces `ok: false` and an `error` so the UI can flag it. Denied appeals never
|
||||
attempt a reversal.
|
||||
|
||||
---
|
||||
|
||||
See [website-README.md](website-README.md) for the moderation dashboard's place
|
||||
in the wider site, and [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the base API
|
||||
conventions (auth, error shapes, response codes) these endpoints follow.
|
||||
574
website/PROJECT_TREE.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Website — Project Tree
|
||||
|
||||
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
|
||||
> the [`RunicGateway/website`](https://gitea.whitlocktech.com/RunicGateway/website) repository, which
|
||||
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
|
||||
> by hand — changes will be overwritten by the next sync.
|
||||
|
||||
A snapshot of the tracked files in the repository (build output, dependencies, and other
|
||||
git-ignored paths are excluded).
|
||||
|
||||
```text
|
||||
website/
|
||||
├── .claude/
|
||||
│ └── launch.json
|
||||
├── .gitea/
|
||||
│ ├── ISSUE_TEMPLATE/
|
||||
│ │ ├── bug_report.md
|
||||
│ │ ├── config.yaml
|
||||
│ │ └── feature_request.md
|
||||
│ ├── scripts/
|
||||
│ │ └── gen_tree.py
|
||||
│ ├── workflows/
|
||||
│ │ ├── build-images.yml
|
||||
│ │ ├── pr-checks.yml
|
||||
│ │ ├── sonarqube.yml
|
||||
│ │ └── sync-project-tree.yml
|
||||
│ └── PULL_REQUEST_TEMPLATE.md
|
||||
├── bot/
|
||||
│ ├── src/
|
||||
│ │ ├── discord/
|
||||
│ │ │ ├── commands/
|
||||
│ │ │ │ ├── announce.command.js
|
||||
│ │ │ │ ├── autorole.command.js
|
||||
│ │ │ │ ├── ban.command.js
|
||||
│ │ │ │ ├── filter.command.js
|
||||
│ │ │ │ ├── filterallow.command.js
|
||||
│ │ │ │ ├── index.js
|
||||
│ │ │ │ ├── invite.command.js
|
||||
│ │ │ │ ├── kick.command.js
|
||||
│ │ │ │ ├── modlog.command.js
|
||||
│ │ │ │ ├── mute.command.js
|
||||
│ │ │ │ ├── news.command.js
|
||||
│ │ │ │ ├── ping.command.js
|
||||
│ │ │ │ ├── role.command.js
|
||||
│ │ │ │ ├── rolemenu.command.js
|
||||
│ │ │ │ ├── roles.command.js
|
||||
│ │ │ │ ├── schedule.command.js
|
||||
│ │ │ │ ├── warn.command.js
|
||||
│ │ │ │ ├── warnings.command.js
|
||||
│ │ │ │ └── wiki.command.js
|
||||
│ │ │ ├── discordManager.js
|
||||
│ │ │ ├── guildMemberAdd.js
|
||||
│ │ │ ├── guildMemberRemove.js
|
||||
│ │ │ ├── inviteTracker.js
|
||||
│ │ │ ├── messageFilter.js
|
||||
│ │ │ ├── modLog.js
|
||||
│ │ │ ├── newsAnnounce.js
|
||||
│ │ │ └── roleMenuHandler.js
|
||||
│ │ ├── filter/
|
||||
│ │ │ ├── filterCache.js
|
||||
│ │ │ ├── inviteFilter.js
|
||||
│ │ │ ├── normalize.js
|
||||
│ │ │ └── spamFilter.js
|
||||
│ │ ├── internal/
|
||||
│ │ │ ├── internal.controller.js
|
||||
│ │ │ ├── internal.routes.js
|
||||
│ │ │ └── requireInternalKey.js
|
||||
│ │ ├── invites/
|
||||
│ │ │ ├── inviteRotator.js
|
||||
│ │ │ └── inviteScheduler.js
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── filterAllowlist.js
|
||||
│ │ │ ├── filterHits.js
|
||||
│ │ │ ├── filterWords.js
|
||||
│ │ │ ├── guildConfig.js
|
||||
│ │ │ ├── inviteLog.js
|
||||
│ │ │ ├── memberEvents.js
|
||||
│ │ │ ├── roleMenus.js
|
||||
│ │ │ ├── scheduledMessages.js
|
||||
│ │ │ ├── spamHits.js
|
||||
│ │ │ ├── tempRoles.js
|
||||
│ │ │ └── warnings.js
|
||||
│ │ ├── roles/
|
||||
│ │ │ └── tempRoleSweeper.js
|
||||
│ │ ├── scheduler/
|
||||
│ │ │ └── scheduler.js
|
||||
│ │ ├── site/
|
||||
│ │ │ └── siteApiClient.js
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── duration.js
|
||||
│ │ │ └── logger.js
|
||||
│ │ ├── app.js
|
||||
│ │ ├── bootstrap.js
|
||||
│ │ ├── brand.js
|
||||
│ │ ├── db.js
|
||||
│ │ └── server.js
|
||||
│ ├── .env.example
|
||||
│ ├── .gitignore
|
||||
│ ├── Dockerfile
|
||||
│ ├── package-lock.json
|
||||
│ └── package.json
|
||||
├── brand/
|
||||
│ └── README.md
|
||||
├── client/
|
||||
│ ├── public/
|
||||
│ │ ├── assets/
|
||||
│ │ │ └── img/
|
||||
│ │ │ ├── favicon.ico
|
||||
│ │ │ ├── hero-moon.png
|
||||
│ │ │ ├── runic-emblem.png
|
||||
│ │ │ └── uomysticmoon-main-hero.png
|
||||
│ │ └── robots.txt
|
||||
│ ├── src/
|
||||
│ │ ├── api/
|
||||
│ │ │ └── client.js
|
||||
│ │ ├── blocks/
|
||||
│ │ │ ├── types/
|
||||
│ │ │ │ ├── cta.jsx
|
||||
│ │ │ │ ├── divider.jsx
|
||||
│ │ │ │ ├── heading.jsx
|
||||
│ │ │ │ ├── image.jsx
|
||||
│ │ │ │ ├── quote.jsx
|
||||
│ │ │ │ ├── richText.jsx
|
||||
│ │ │ │ └── twoColumn.jsx
|
||||
│ │ │ ├── BlockRenderer.jsx
|
||||
│ │ │ ├── editorKit.jsx
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ └── registry.js
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── security/
|
||||
│ │ │ │ ├── RecoveryCodesDisplay.jsx
|
||||
│ │ │ │ ├── RecoveryCodesPanel.jsx
|
||||
│ │ │ │ ├── TrustedDevicesPanel.jsx
|
||||
│ │ │ │ └── TrustLimitModal.jsx
|
||||
│ │ │ ├── CharacterSheet.jsx
|
||||
│ │ │ ├── CharacterStats.jsx
|
||||
│ │ │ ├── CreateGameAccountForm.jsx
|
||||
│ │ │ ├── GameAccounts.jsx
|
||||
│ │ │ ├── HeroElement.jsx
|
||||
│ │ │ ├── MaintenanceGate.jsx
|
||||
│ │ │ ├── Modal.jsx
|
||||
│ │ │ ├── MoonDot.jsx
|
||||
│ │ │ ├── PageHeader.jsx
|
||||
│ │ │ ├── PageState.jsx
|
||||
│ │ │ ├── PlayersOnline.jsx
|
||||
│ │ │ ├── ProviderIcon.jsx
|
||||
│ │ │ ├── PublicLayout.jsx
|
||||
│ │ │ ├── RequireAuth.jsx
|
||||
│ │ │ ├── RequirePlayer.jsx
|
||||
│ │ │ ├── RichTextEditor.jsx
|
||||
│ │ │ ├── RoleGate.jsx
|
||||
│ │ │ ├── ShardAccountActions.jsx
|
||||
│ │ │ ├── SiteFooter.jsx
|
||||
│ │ │ ├── SiteHeader.jsx
|
||||
│ │ │ └── VendorSales.jsx
|
||||
│ │ ├── contexts/
|
||||
│ │ │ ├── AuthContext.jsx
|
||||
│ │ │ └── SiteContext.jsx
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── cityCrests.js
|
||||
│ │ │ └── regionBuckets.js
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── format.js
|
||||
│ │ │ ├── heroLayout.js
|
||||
│ │ │ ├── shardEvents.js
|
||||
│ │ │ ├── useAsync.js
|
||||
│ │ │ └── useShardFeed.js
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin/
|
||||
│ │ │ │ ├── views/
|
||||
│ │ │ │ │ ├── AccountAdmin.jsx
|
||||
│ │ │ │ │ ├── ActivityAdmin.jsx
|
||||
│ │ │ │ │ ├── AdminCharacter.jsx
|
||||
│ │ │ │ │ ├── AdminCharacters.jsx
|
||||
│ │ │ │ │ ├── Appeals.jsx
|
||||
│ │ │ │ │ ├── AuthProvidersAdmin.jsx
|
||||
│ │ │ │ │ ├── BotActivityAdmin.jsx
|
||||
│ │ │ │ │ ├── Dashboard.jsx
|
||||
│ │ │ │ │ ├── DiscordBotAdmin.jsx
|
||||
│ │ │ │ │ ├── EmailDelivery.jsx
|
||||
│ │ │ │ │ ├── HeroEditor.jsx
|
||||
│ │ │ │ │ ├── HousesAdmin.jsx
|
||||
│ │ │ │ │ ├── InvitesAdmin.jsx
|
||||
│ │ │ │ │ ├── Moderation.jsx
|
||||
│ │ │ │ │ ├── ModerationUser.jsx
|
||||
│ │ │ │ │ ├── PageBuilder.jsx
|
||||
│ │ │ │ │ ├── PagesAdmin.jsx
|
||||
│ │ │ │ │ ├── PostEditor.jsx
|
||||
│ │ │ │ │ ├── PostsAdmin.jsx
|
||||
│ │ │ │ │ ├── SettingsAdmin.jsx
|
||||
│ │ │ │ │ ├── ShardAdmin.jsx
|
||||
│ │ │ │ │ ├── ShardOps.jsx
|
||||
│ │ │ │ │ ├── UserDetail.jsx
|
||||
│ │ │ │ │ ├── UserEditor.jsx
|
||||
│ │ │ │ │ ├── UsersAdmin.jsx
|
||||
│ │ │ │ │ ├── WikiAdmin.jsx
|
||||
│ │ │ │ │ ├── WikiCategories.jsx
|
||||
│ │ │ │ │ ├── WikiEditor.jsx
|
||||
│ │ │ │ │ └── WikiHistory.jsx
|
||||
│ │ │ │ ├── AdminLayout.jsx
|
||||
│ │ │ │ └── AdminLogin.jsx
|
||||
│ │ │ ├── player/
|
||||
│ │ │ │ ├── AcceptInvite.jsx
|
||||
│ │ │ │ ├── ForgotPassword.jsx
|
||||
│ │ │ │ ├── PlayerAccount.jsx
|
||||
│ │ │ │ ├── PlayerAppeals.jsx
|
||||
│ │ │ │ ├── PlayerCharacter.jsx
|
||||
│ │ │ │ ├── PlayerCharacters.jsx
|
||||
│ │ │ │ ├── PlayerLogin.jsx
|
||||
│ │ │ │ ├── PlayerPortalLayout.jsx
|
||||
│ │ │ │ ├── PlayerRegister.jsx
|
||||
│ │ │ │ ├── PlayerShell.jsx
|
||||
│ │ │ │ └── ResetPassword.jsx
|
||||
│ │ │ ├── public/
|
||||
│ │ │ │ ├── About.jsx
|
||||
│ │ │ │ ├── ChampSpawns.jsx
|
||||
│ │ │ │ ├── CmsPage.jsx
|
||||
│ │ │ │ ├── FiveOnFriday.jsx
|
||||
│ │ │ │ ├── Governors.jsx
|
||||
│ │ │ │ ├── Guilds.jsx
|
||||
│ │ │ │ ├── Houses.jsx
|
||||
│ │ │ │ ├── Maintenance.jsx
|
||||
│ │ │ │ ├── News.jsx
|
||||
│ │ │ │ ├── Newsletter.jsx
|
||||
│ │ │ │ ├── NewsletterIssue.jsx
|
||||
│ │ │ │ ├── Portal.jsx
|
||||
│ │ │ │ ├── Screenshots.jsx
|
||||
│ │ │ │ ├── Shard.jsx
|
||||
│ │ │ │ ├── ShardActivity.jsx
|
||||
│ │ │ │ ├── Status.jsx
|
||||
│ │ │ │ └── Website.jsx
|
||||
│ │ │ └── wiki/
|
||||
│ │ │ ├── Wiki.jsx
|
||||
│ │ │ └── WikiArticle.jsx
|
||||
│ │ ├── styles/
|
||||
│ │ │ └── theme.css
|
||||
│ │ ├── App.jsx
|
||||
│ │ └── main.jsx
|
||||
│ ├── test/
|
||||
│ │ ├── apiClient.test.js
|
||||
│ │ ├── format.test.js
|
||||
│ │ ├── heroLayout.test.js
|
||||
│ │ ├── regionBuckets.test.js
|
||||
│ │ └── shardEvents.test.js
|
||||
│ ├── index.html
|
||||
│ ├── package-lock.json
|
||||
│ ├── package.json
|
||||
│ └── vite.config.js
|
||||
├── ntfy/
|
||||
│ └── server.yml
|
||||
├── scripts/
|
||||
│ ├── dev/
|
||||
│ │ ├── README.md
|
||||
│ │ ├── seed-sso-provider.js
|
||||
│ │ ├── sso-bridge-smoketest.js
|
||||
│ │ └── stub-idp.js
|
||||
│ └── sonar-test-reporter.mjs
|
||||
├── server/
|
||||
│ ├── db/
|
||||
│ │ ├── schema.sql
|
||||
│ │ └── seed.js
|
||||
│ ├── scripts/
|
||||
│ │ └── routeManifest.js
|
||||
│ ├── src/
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── providers/
|
||||
│ │ │ │ ├── base.provider.js
|
||||
│ │ │ │ ├── discord.provider.js
|
||||
│ │ │ │ ├── genericOidc.provider.js
|
||||
│ │ │ │ ├── google.provider.js
|
||||
│ │ │ │ ├── local.provider.js
|
||||
│ │ │ │ ├── oauth2.provider.js
|
||||
│ │ │ │ └── registry.js
|
||||
│ │ │ ├── session.middleware.js
|
||||
│ │ │ ├── session.service.js
|
||||
│ │ │ ├── ssoState.js
|
||||
│ │ │ ├── token.js
|
||||
│ │ │ └── usernamePolicy.js
|
||||
│ │ ├── blocks/
|
||||
│ │ │ ├── types/
|
||||
│ │ │ │ ├── cta.js
|
||||
│ │ │ │ ├── divider.js
|
||||
│ │ │ │ ├── heading.js
|
||||
│ │ │ │ ├── image.js
|
||||
│ │ │ │ ├── quote.js
|
||||
│ │ │ │ ├── richText.js
|
||||
│ │ │ │ └── twoColumn.js
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ ├── propHelpers.js
|
||||
│ │ │ ├── registry.js
|
||||
│ │ │ ├── sanitizeBlocks.js
|
||||
│ │ │ └── validateBlocks.js
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── brand.js
|
||||
│ │ │ ├── csp.js
|
||||
│ │ │ ├── notificationStreams.js
|
||||
│ │ │ └── version.js
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── botScore.js
|
||||
│ │ │ ├── loginProtection.js
|
||||
│ │ │ ├── noindex.js
|
||||
│ │ │ ├── rateLimit.js
|
||||
│ │ │ ├── requireInternalKey.js
|
||||
│ │ │ ├── siteMode.js
|
||||
│ │ │ └── validate.js
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── activity/
|
||||
│ │ │ │ ├── activity.db.js
|
||||
│ │ │ │ └── activity.model.js
|
||||
│ │ │ ├── announceJobs/
|
||||
│ │ │ │ ├── announceJobs.db.js
|
||||
│ │ │ │ ├── announceJobs.logic.js
|
||||
│ │ │ │ └── announceJobs.model.js
|
||||
│ │ │ ├── appeals/
|
||||
│ │ │ │ ├── appeals.db.js
|
||||
│ │ │ │ ├── appeals.model.js
|
||||
│ │ │ │ └── appeals.pure.js
|
||||
│ │ │ ├── authProviders/
|
||||
│ │ │ │ ├── authProviders.db.js
|
||||
│ │ │ │ └── authProviders.model.js
|
||||
│ │ │ ├── botConfig/
|
||||
│ │ │ │ ├── botConfig.db.js
|
||||
│ │ │ │ └── botConfig.model.js
|
||||
│ │ │ ├── emailConfig/
|
||||
│ │ │ │ ├── emailConfig.db.js
|
||||
│ │ │ │ └── emailConfig.model.js
|
||||
│ │ │ ├── invites/
|
||||
│ │ │ │ ├── invites.db.js
|
||||
│ │ │ │ └── invites.model.js
|
||||
│ │ │ ├── mobileAuthBridge/
|
||||
│ │ │ │ ├── mobileAuthBridge.db.js
|
||||
│ │ │ │ └── mobileAuthBridge.model.js
|
||||
│ │ │ ├── mobileSessions/
|
||||
│ │ │ │ ├── mobileSessions.db.js
|
||||
│ │ │ │ └── mobileSessions.model.js
|
||||
│ │ │ ├── moderation/
|
||||
│ │ │ │ ├── moderation.db.js
|
||||
│ │ │ │ ├── moderation.model.js
|
||||
│ │ │ │ └── moderation.pure.js
|
||||
│ │ │ ├── modNotes/
|
||||
│ │ │ │ ├── modNotes.db.js
|
||||
│ │ │ │ └── modNotes.model.js
|
||||
│ │ │ ├── notificationSubs/
|
||||
│ │ │ │ ├── notificationSubs.db.js
|
||||
│ │ │ │ └── notificationSubs.model.js
|
||||
│ │ │ ├── pages/
|
||||
│ │ │ │ ├── pages.db.js
|
||||
│ │ │ │ ├── pages.model.js
|
||||
│ │ │ │ └── reservedSlugs.js
|
||||
│ │ │ ├── passwordResets/
|
||||
│ │ │ │ ├── passwordResets.db.js
|
||||
│ │ │ │ └── passwordResets.model.js
|
||||
│ │ │ ├── posts/
|
||||
│ │ │ │ ├── posts.db.js
|
||||
│ │ │ │ └── posts.model.js
|
||||
│ │ │ ├── pushDevices/
|
||||
│ │ │ │ ├── pushDevices.db.js
|
||||
│ │ │ │ └── pushDevices.model.js
|
||||
│ │ │ ├── recoveryCodes/
|
||||
│ │ │ │ ├── recoveryCodes.db.js
|
||||
│ │ │ │ └── recoveryCodes.model.js
|
||||
│ │ │ ├── revokedSessions/
|
||||
│ │ │ │ ├── revokedSessions.db.js
|
||||
│ │ │ │ └── revokedSessions.model.js
|
||||
│ │ │ ├── settings/
|
||||
│ │ │ │ ├── settings.db.js
|
||||
│ │ │ │ └── settings.model.js
|
||||
│ │ │ ├── shardEvents/
|
||||
│ │ │ │ ├── shardEvents.db.js
|
||||
│ │ │ │ └── shardEvents.model.js
|
||||
│ │ │ ├── shardLinks/
|
||||
│ │ │ │ ├── shardLinks.db.js
|
||||
│ │ │ │ └── shardLinks.model.js
|
||||
│ │ │ ├── shardState/
|
||||
│ │ │ │ ├── shardState.db.js
|
||||
│ │ │ │ └── shardState.model.js
|
||||
│ │ │ ├── trustedDevices/
|
||||
│ │ │ │ ├── trustedDevices.db.js
|
||||
│ │ │ │ └── trustedDevices.model.js
|
||||
│ │ │ ├── uoLinkConfig/
|
||||
│ │ │ │ ├── uoLinkConfig.db.js
|
||||
│ │ │ │ └── uoLinkConfig.model.js
|
||||
│ │ │ ├── userIdentities/
|
||||
│ │ │ │ ├── userIdentities.db.js
|
||||
│ │ │ │ └── userIdentities.model.js
|
||||
│ │ │ ├── users/
|
||||
│ │ │ │ ├── users.db.js
|
||||
│ │ │ │ └── users.model.js
|
||||
│ │ │ ├── wiki/
|
||||
│ │ │ │ ├── wiki.db.js
|
||||
│ │ │ │ ├── wiki.links.js
|
||||
│ │ │ │ └── wiki.model.js
|
||||
│ │ │ └── singletonConfigDb.js
|
||||
│ │ ├── router/
|
||||
│ │ │ ├── v1/
|
||||
│ │ │ │ ├── admin/
|
||||
│ │ │ │ │ ├── account.controller.js
|
||||
│ │ │ │ │ ├── account.router.js
|
||||
│ │ │ │ │ ├── activity.router.js
|
||||
│ │ │ │ │ ├── admin.controller.js
|
||||
│ │ │ │ │ ├── authProviders.controller.js
|
||||
│ │ │ │ │ ├── authProviders.router.js
|
||||
│ │ │ │ │ ├── botActivity.controller.js
|
||||
│ │ │ │ │ ├── botActivity.router.js
|
||||
│ │ │ │ │ ├── dashboard.router.js
|
||||
│ │ │ │ │ ├── discordBot.controller.js
|
||||
│ │ │ │ │ ├── discordBot.router.js
|
||||
│ │ │ │ │ ├── email.router.js
|
||||
│ │ │ │ │ ├── emailConfig.controller.js
|
||||
│ │ │ │ │ ├── imageUpload.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── invites.controller.js
|
||||
│ │ │ │ │ ├── invites.router.js
|
||||
│ │ │ │ │ ├── moderation.controller.js
|
||||
│ │ │ │ │ ├── moderation.router.js
|
||||
│ │ │ │ │ ├── pages.controller.js
|
||||
│ │ │ │ │ ├── pages.router.js
|
||||
│ │ │ │ │ ├── posts.router.js
|
||||
│ │ │ │ │ ├── settings.router.js
|
||||
│ │ │ │ │ ├── shard.router.js
|
||||
│ │ │ │ │ ├── shardOps.controller.js
|
||||
│ │ │ │ │ ├── uoLink.controller.js
|
||||
│ │ │ │ │ ├── uoLink.router.js
|
||||
│ │ │ │ │ ├── uploads.router.js
|
||||
│ │ │ │ │ ├── users.router.js
|
||||
│ │ │ │ │ ├── usersShard.controller.js
|
||||
│ │ │ │ │ └── wiki.router.js
|
||||
│ │ │ │ ├── auth/
|
||||
│ │ │ │ │ ├── auth.controller.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── invite.controller.js
|
||||
│ │ │ │ │ ├── invite.router.js
|
||||
│ │ │ │ │ ├── login.router.js
|
||||
│ │ │ │ │ ├── loginGuards.js
|
||||
│ │ │ │ │ ├── me.routes.js
|
||||
│ │ │ │ │ ├── mobile.controller.js
|
||||
│ │ │ │ │ ├── mobile.routes.js
|
||||
│ │ │ │ │ ├── mobileSso.controller.js
|
||||
│ │ │ │ │ ├── mobileSso.routes.js
|
||||
│ │ │ │ │ ├── notifications.controller.js
|
||||
│ │ │ │ │ ├── notifications.routes.js
|
||||
│ │ │ │ │ ├── password.router.js
|
||||
│ │ │ │ │ ├── passwordReset.controller.js
|
||||
│ │ │ │ │ ├── register.router.js
|
||||
│ │ │ │ │ ├── session.router.js
|
||||
│ │ │ │ │ ├── sso.controller.js
|
||||
│ │ │ │ │ ├── sso.routes.js
|
||||
│ │ │ │ │ └── trustDevice.helper.js
|
||||
│ │ │ │ ├── internal/
|
||||
│ │ │ │ │ ├── internal.controller.js
|
||||
│ │ │ │ │ └── internal.routes.js
|
||||
│ │ │ │ ├── player/
|
||||
│ │ │ │ │ ├── account.router.js
|
||||
│ │ │ │ │ ├── appeals.controller.js
|
||||
│ │ │ │ │ ├── appeals.router.js
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── shard.controller.js
|
||||
│ │ │ │ │ └── shard.router.js
|
||||
│ │ │ │ ├── public/
|
||||
│ │ │ │ │ ├── index.js
|
||||
│ │ │ │ │ ├── pages.router.js
|
||||
│ │ │ │ │ ├── posts.router.js
|
||||
│ │ │ │ │ ├── public.controller.js
|
||||
│ │ │ │ │ ├── shard.controller.js
|
||||
│ │ │ │ │ ├── shard.router.js
|
||||
│ │ │ │ │ ├── site.router.js
|
||||
│ │ │ │ │ └── wiki.router.js
|
||||
│ │ │ │ └── v1.router.js
|
||||
│ │ │ ├── api.router.js
|
||||
│ │ │ ├── cspReport.controller.js
|
||||
│ │ │ └── wellKnown.controller.js
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── announceWorker.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── botInternalClient.js
|
||||
│ │ │ ├── botInternalKey.js
|
||||
│ │ │ ├── db.js
|
||||
│ │ │ ├── logger.js
|
||||
│ │ │ ├── mailer.js
|
||||
│ │ │ ├── newsGump.js
|
||||
│ │ │ ├── pushDispatch.js
|
||||
│ │ │ ├── sanitizeHtml.js
|
||||
│ │ │ ├── secretBox.js
|
||||
│ │ │ ├── shardBroadcast.js
|
||||
│ │ │ ├── shardIngest.js
|
||||
│ │ │ ├── shardSales.js
|
||||
│ │ │ ├── totp.js
|
||||
│ │ │ ├── trustProxy.js
|
||||
│ │ │ ├── uoLinkClient.js
|
||||
│ │ │ └── uoLinkSocket.js
|
||||
│ │ ├── app.js
|
||||
│ │ ├── internalApp.js
|
||||
│ │ └── server.js
|
||||
│ ├── swagger/
|
||||
│ │ ├── swagger-output.json
|
||||
│ │ └── swagger.js
|
||||
│ ├── test/
|
||||
│ │ ├── _helper.js
|
||||
│ │ ├── adminTrustedDevices.test.js
|
||||
│ │ ├── adminUserShard.test.js
|
||||
│ │ ├── announceJobs.test.js
|
||||
│ │ ├── appeals.pure.test.js
|
||||
│ │ ├── appeals.test.js
|
||||
│ │ ├── appLinks.test.js
|
||||
│ │ ├── authController.test.js
|
||||
│ │ ├── authMe.test.js
|
||||
│ │ ├── authTrustedDevice.test.js
|
||||
│ │ ├── botInternalKey.test.js
|
||||
│ │ ├── botScore.test.js
|
||||
│ │ ├── csp.test.js
|
||||
│ │ ├── emailConfig.model.test.js
|
||||
│ │ ├── honeypot.test.js
|
||||
│ │ ├── inviteController.test.js
|
||||
│ │ ├── invites.test.js
|
||||
│ │ ├── loginProtection.test.js
|
||||
│ │ ├── mailer.test.js
|
||||
│ │ ├── mobileAuthBridge.model.test.js
|
||||
│ │ ├── mobileDeviceSessions.test.js
|
||||
│ │ ├── mobileSession.test.js
|
||||
│ │ ├── mobileSsoBridge.test.js
|
||||
│ │ ├── moderation.model.test.js
|
||||
│ │ ├── moderation.test.js
|
||||
│ │ ├── newsGump.test.js
|
||||
│ │ ├── notificationsRoutes.test.js
|
||||
│ │ ├── pages.model.test.js
|
||||
│ │ ├── passwordResetController.test.js
|
||||
│ │ ├── passwordResets.test.js
|
||||
│ │ ├── playerAccounts.test.js
|
||||
│ │ ├── playerRouteAccess.test.js
|
||||
│ │ ├── providers.test.js
|
||||
│ │ ├── publicBrand.test.js
|
||||
│ │ ├── publicController.test.js
|
||||
│ │ ├── publicShardOnline.test.js
|
||||
│ │ ├── publicVersion.test.js
|
||||
│ │ ├── pushDispatch.test.js
|
||||
│ │ ├── recoveryCodes.test.js
|
||||
│ │ ├── registry.test.js
|
||||
│ │ ├── requireInternalKey.test.js
|
||||
│ │ ├── routeManifest.test.js
|
||||
│ │ ├── secretBox.test.js
|
||||
│ │ ├── selfTrustedDevices.test.js
|
||||
│ │ ├── session.test.js
|
||||
│ │ ├── shardControllerPublic.test.js
|
||||
│ │ ├── shardIngest.champsPages.test.js
|
||||
│ │ ├── shardIngest.protocol2.test.js
|
||||
│ │ ├── shardState.governorTerms.test.js
|
||||
│ │ ├── shardState.model.test.js
|
||||
│ │ ├── ssoCallback.test.js
|
||||
│ │ ├── ssoState.test.js
|
||||
│ │ ├── totp.test.js
|
||||
│ │ ├── trustedDevices.test.js
|
||||
│ │ ├── trustProxy.test.js
|
||||
│ │ └── usernamePolicy.test.js
|
||||
│ ├── .env.example
|
||||
│ ├── package-lock.json
|
||||
│ ├── package.json
|
||||
│ ├── routes.guards.json
|
||||
│ └── routes.manifest.json
|
||||
├── .dockerignore
|
||||
├── .env.example
|
||||
├── .env.uomysticmoon.example
|
||||
├── .gitignore
|
||||
├── CODE_OF_CONDUCT.md
|
||||
├── CONTRIBUTING.md
|
||||
├── CONTRIBUTORS.md
|
||||
├── docker-compose.dev.yml
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── LICENSE.md
|
||||
├── package.json
|
||||
├── README.md
|
||||
├── SECURITY.md
|
||||
└── sonar-project.properties
|
||||
```
|
||||
210
website/TRUSTED_DEVICES_MFA.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Trusted Devices & MFA Improvements — Design & Implementation Plan
|
||||
|
||||
> Reference plan for the trusted-device + MFA hardening work. Approved 2026-07-21.
|
||||
> This document is the contract the implementation builds against; keep it in sync
|
||||
> with `BACKEND_DESIGN.md` (§3 schema, §4 API, §6 security) as code lands.
|
||||
|
||||
## 1. Goal & scope
|
||||
|
||||
Reduce 2FA friction without weakening the second-factor boundary, and close the
|
||||
2FA-lockout gap. Four deliverables:
|
||||
|
||||
1. **Trusted devices** — an opt-in "Trust this device" that lets a browser or the
|
||||
Android app **skip the TOTP step** (never the password) on future logins for a
|
||||
fixed window.
|
||||
2. **Recovery / backup codes** — single-use codes generated at 2FA enrollment so a
|
||||
user who loses their authenticator can self-recover instead of needing an admin
|
||||
reset.
|
||||
3. **Admin-managed revocation** — staff can view and revoke a user's trusted
|
||||
devices and reset their MFA, with full audit logging (**backend endpoints _and_
|
||||
admin front-end screens**).
|
||||
4. **Step-up (password) for sensitive operations** — reusing the existing
|
||||
`currentPassword`-verification pattern; disabling TOTP keeps its stronger
|
||||
current-TOTP-code requirement.
|
||||
|
||||
Touches `website/` (server + client), `docs/`, and `android-app/` (plan only in
|
||||
this pass). **No** `link/` or `servuo-plugins/` change — no wire-protocol impact.
|
||||
|
||||
### Approved decisions
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| Trust duration | **30 days** (matches mobile refresh-token lifetime) |
|
||||
| Roles eligible | **All roles** (no staff carve-out) |
|
||||
| Opt-in model | **Explicit "Trust this device" checkbox, default off** |
|
||||
| Recovery codes | **10 codes**, shown **once**, single-use |
|
||||
| Trusted-device cap | **10 per user, no silent pruning** (see §5) |
|
||||
| Trust-token hashing | **sha256** |
|
||||
| Recovery-code hashing | **bcrypt** (cost 10) |
|
||||
|
||||
## 2. Current state (starting point)
|
||||
|
||||
- **One session service** (`server/src/auth/session.service.js`) backs web (JWT
|
||||
`httpOnly` cookie, 1d) and mobile (15m access JWT + 30d opaque refresh token).
|
||||
`requireAuth` accepts either via `token.extractToken()`.
|
||||
- **TOTP** is opt-in per user (`users.totp_secret` / `totp_enabled`), demanded on
|
||||
**every** login. Web uses a staged 5-min `stage:'totp'` challenge; mobile uses a
|
||||
single-request `401 { totpRequired }`. **No recovery codes** exist today.
|
||||
- **Device tracking exists only on mobile** (`mobile_refresh_tokens` rows with
|
||||
`device_name` / `device_hash` / `user_agent` / `last_used_at`). Web JWTs are
|
||||
stateless with no per-session row.
|
||||
- **Revocation is mature:** `revoked_sessions` (jti denylist) + `tokens_valid_after`
|
||||
(per-user cutoff) for web; per-token rows + `revokeAllForUser` for mobile.
|
||||
- **No trusted-device or step-up concept exists anywhere.**
|
||||
|
||||
## 3. Hashing rationale
|
||||
|
||||
The repo already splits hashing by secret entropy, and this plan follows it:
|
||||
|
||||
- **sha256** — every high-entropy machine-generated opaque token
|
||||
(`mobile_refresh_tokens`, `mobile_auth_codes`, `user_invites`, `password_resets`,
|
||||
SSO PKCE). **Trusted-device tokens use sha256:** they are 256-bit random values
|
||||
(nothing to brute-force) looked up **by a `token_hash UNIQUE` index**, which
|
||||
requires a deterministic hash — bcrypt's per-row salt would break the lookup and
|
||||
truncates input at 72 bytes.
|
||||
- **bcrypt (`bcryptjs`, cost 10)** — the repo uses it only for **passwords**, the
|
||||
one human-chosen low-entropy secret. **Recovery codes use bcrypt:** they are a
|
||||
human-typed, lower-entropy fallback credential that grants a login (the closest
|
||||
analogue to a password), and there is no hash-lookup constraint — we fetch the
|
||||
identified user's ≤10 code rows and `bcrypt.compare` each, exactly like password
|
||||
verification.
|
||||
|
||||
## 4. Database (additive, idempotent — matches `schema.sql` style)
|
||||
|
||||
### `trusted_devices`
|
||||
Pattern-identical to `mobile_refresh_tokens`; stores only the token hash.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
|
||||
| token_hash | CHAR(64) NOT NULL UNIQUE | sha256 hex of the opaque trust token |
|
||||
| platform | ENUM('web','mobile') NOT NULL DEFAULT 'web' | |
|
||||
| device_name | VARCHAR(100) NULL | friendly label |
|
||||
| device_hash | VARCHAR(32) NULL | best-effort UA+IP, **display only** |
|
||||
| user_agent | VARCHAR(255) NULL | |
|
||||
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||||
| last_used_at | DATETIME NULL | stamped when trust is honored at login |
|
||||
| expires_at | DATETIME NOT NULL | created_at + 30d |
|
||||
| revoked_at | DATETIME NULL | |
|
||||
|
||||
Indices: `idx_td_user (user_id)`, `idx_td_expires (expires_at)`.
|
||||
|
||||
### `recovery_codes`
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
|
||||
| code_hash | VARCHAR(72) NOT NULL | **bcrypt** hash of one code |
|
||||
| used_at | DATETIME NULL | single-use marker |
|
||||
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||||
|
||||
Index: `idx_rc_user (user_id)`.
|
||||
|
||||
No new `users` column: password change/reset and TOTP-disable **bulk-revoke**
|
||||
`trusted_devices` rows and **delete** `recovery_codes` (consistent with
|
||||
`revokeAllForUser`), so no "trust epoch" column is needed.
|
||||
|
||||
## 5. Trusted-device cap — no silent pruning
|
||||
|
||||
Cap = **10**. A shared `assertUnderTrustCap(userId)` guards both entry points (the
|
||||
login/TOTP trust path and the authenticated "trust this device" path). On the 11th
|
||||
attempt the backend **refuses to create the row** and returns
|
||||
`409 { error: 'trusted_device_limit', devices: [...] }`. Login itself still
|
||||
succeeds — only the trust marker is withheld. The web client then renders a modal
|
||||
**in the same visual pattern as the TOTP entry flow** that:
|
||||
|
||||
1. shows the existing trusted devices,
|
||||
2. requires revoking ≥1 before continuing,
|
||||
3. completes via `POST /auth/me/trusted-devices` (trust current device), and
|
||||
4. offers **Cancel**, which returns without creating any trust entry.
|
||||
|
||||
## 6. API additions
|
||||
|
||||
### Auth (login paths)
|
||||
- `POST /auth/login` — after password verify, if a valid unrevoked `rg_trust`
|
||||
cookie matches a live `trusted_devices` row for this user → **skip TOTP**, issue
|
||||
the session, log `auth.login.trusted_device`, stamp `last_used_at`. Otherwise
|
||||
unchanged (`{ totpRequired, challenge }`).
|
||||
- `POST /auth/login/totp` — gains optional `trustDevice` + `deviceName`, and
|
||||
accepts a **recovery code** as an alternative to the TOTP code (single-use). On
|
||||
success with `trustDevice`, mint the opaque trust token, set the `rg_trust`
|
||||
cookie, insert the row (subject to the cap → `409` signal).
|
||||
- `POST /auth/mobile/login` — gains `trustDevice` / `recoveryCode`; returns a
|
||||
`trustToken` the app stores in EncryptedSharedPreferences and replays on a later
|
||||
login to skip TOTP. Same cap behavior.
|
||||
|
||||
### Self-service (`/auth/me/*`, `requireAuth`, any role)
|
||||
- `GET /auth/me/trusted-devices` — list active trusted devices (never tokens).
|
||||
- `POST /auth/me/trusted-devices` — trust the current browser/device (cap-checked).
|
||||
- `DELETE /auth/me/trusted-devices/:id` — revoke one (ownership-scoped).
|
||||
- `DELETE /auth/me/trusted-devices` — revoke all ("untrust everywhere").
|
||||
- `POST /auth/me/account/recovery-codes/generate` — **password step-up required**;
|
||||
returns the codes **once**.
|
||||
- `GET /auth/me/account/recovery-codes/status` — remaining count only.
|
||||
|
||||
### Admin (`requireRole('admin')`)
|
||||
- `GET /admin/users/:id/trusted-devices` — list a user's trusted devices.
|
||||
- `DELETE /admin/users/:id/trusted-devices/:deviceId` — revoke one.
|
||||
- `DELETE /admin/users/:id/trusted-devices` — revoke all.
|
||||
- MFA reset control (revoke trust + disable TOTP + clear recovery codes).
|
||||
|
||||
## 7. Cookie / refresh / JWT interaction
|
||||
|
||||
- New **`rg_trust`** cookie: `httpOnly`, `sameSite=Lax`, `secure` per-request
|
||||
(reuse `cookieSecure`), `path=/`, `maxAge` 30d, opaque 256-bit base64url,
|
||||
sha256-hashed server-side. **Separate from the session cookie and deliberately
|
||||
survives logout** (so the next login skips 2FA); only untrust / password-change /
|
||||
TOTP-disable revoke it.
|
||||
- **JWTs stay stateless and unchanged** — trust is a server-side cookie+row, never
|
||||
a JWT claim, so it remains revocable.
|
||||
- **Refresh flow untouched** — trust is consulted only at the login/password step,
|
||||
never at token refresh; the two stores stay independent.
|
||||
|
||||
## 8. Security & invalidation
|
||||
|
||||
- Trust **only ever gates the second factor**; password is always required.
|
||||
- Recovery-code entry reuses the login brute-force stack (backoff + bot scoring +
|
||||
rate limits); recovery codes are single-use.
|
||||
- **Password change/reset and TOTP-disable clear trust and recovery codes.**
|
||||
- **Audit logging** via existing `activity.log` / `activity_log`:
|
||||
`auth.login.trusted_device`, `account.trusted_device.add` / `.revoke` /
|
||||
`.revoke_all`, `account.recovery_codes.generate`, `account.recovery_code.consume`,
|
||||
and admin `admin.trusted_device.revoke` / `.revoke_all`, `admin.user.totp.reset`
|
||||
— each with actor, target user, and device id in `detail`.
|
||||
|
||||
## 9. Backwards compatibility
|
||||
|
||||
Fully additive. With no `rg_trust` cookie the behavior is exactly today's (TOTP
|
||||
every login). Recovery codes exist only for users who generate them. No existing
|
||||
session or login flow changes shape. New tables via `CREATE TABLE IF NOT EXISTS`
|
||||
and columns via `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.
|
||||
|
||||
## 10. Implementation roadmap
|
||||
|
||||
1. **Schema + models** — `trusted_devices` (sha256, cap-checked) + `recovery_codes`
|
||||
(bcrypt); `.db.js` / `.model.js` pairs mirroring `mobileSessions`.
|
||||
2. **Session service** — trust-token mint/sha256/verify + recovery-code
|
||||
generate/bcrypt-verify/consume helpers (pure, DB-free); shared
|
||||
`assertUnderTrustCap()`.
|
||||
3. **Web login** — trust-cookie skip in `/auth/login`; `trustDevice` / recovery
|
||||
handling + cap `409` in `/auth/login/totp`; set/clear `rg_trust`.
|
||||
4. **Mobile login** — `trustDevice` / `trustToken` / `recoveryCode`, same cap.
|
||||
5. **Self-service + admin backend** — `/auth/me/trusted-devices*` + recovery-code
|
||||
endpoints; `/admin/users/:id/trusted-devices*` + MFA reset (all admin-gated).
|
||||
6. **Invalidation wiring** — password change/reset & TOTP-disable revoke trust +
|
||||
delete recovery codes.
|
||||
7. **Web client UI** — "Trust this device" checkbox; cap-reached TOTP-styled modal
|
||||
(revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
|
||||
8. **Admin front-end UI** — admin Trusted Devices management & revocation screens
|
||||
(per-user list, revoke one / revoke all, MFA reset), wired to step 5.
|
||||
9. **Android** — record the app-side trust/recovery flow in
|
||||
`docs/android/PLAN.md`; app implementation sequenced after the backend lands.
|
||||
10. **OpenAPI + docs** — `#swagger.*` on every new/modified route + regenerate
|
||||
`server/swagger/swagger-output.json`; update `BACKEND_DESIGN.md` §3/§4/§6.
|
||||
11. **Automated tests** — trusted-device login skip (valid / missing / expired /
|
||||
revoked), token mint+hash, recovery-code single-use consume + wrong-code
|
||||
backoff, cap `409` behavior, revocation (self + admin), invalidation on
|
||||
password-change / TOTP-disable, and permission checks (admin routes reject
|
||||
non-admins; self routes ownership-scoped); web client pure-logic tests; Android
|
||||
JVM DTO/repository tests.
|
||||
@@ -54,7 +54,7 @@ auth model (admin/editor — no new roles, no public contributions).
|
||||
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
|
||||
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
|
||||
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
|
||||
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
|
||||
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [wiki.router.js](server/src/router/v1/admin/wiki.router.js) |
|
||||
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
|
||||
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
|
||||
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
|
||||
@@ -196,7 +196,7 @@ centralized error handler unchanged. **Every write logs to `activity_log`**
|
||||
|
||||
### 4.4 Image uploads
|
||||
|
||||
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
|
||||
Generalize the existing screenshot upload (multer config now in [admin/imageUpload.js](server/src/router/v1/admin/imageUpload.js))
|
||||
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
|
||||
the post editor and the wiki editor. Same size/mime limits. No new storage —
|
||||
served from the existing `uploads/` volume.
|
||||
|
||||
815
website/api-route-inventory.json
Normal file
@@ -0,0 +1,815 @@
|
||||
{
|
||||
"$comment": "Generated route inventory - the authoritative freeze of the URL surface. Regenerate with `npm run routes:manifest` in website/server; a domain-split PR must produce a zero-line diff here.",
|
||||
"public": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/.well-known/assetlinks.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/csp-report"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/docs.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/health"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/activity"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/auth/providers"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/auth/providers"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/auth/providers/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/auth/providers/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/bot-activity"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/bot-activity/unban"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/dashboard"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/discord-bot/config"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/discord-bot/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/config"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/email/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/callback"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/disconnect"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/test"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/invites"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/invites/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/appeals"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/appeals/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/appeals/:id/claim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/appeals/:id/resolve"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/filter-hits"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/members"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/recent"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/search"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/spam-hits"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/stats/summary"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/actions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/appeals"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/pages"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/pages"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/pages/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/pages/:id"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/pages/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/pages/:id/preview"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/pages/:id/unprotect"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/posts"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/posts"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/posts/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/posts/:id/announce"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/posts/:id/announce/retry"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/posts/:id/publish"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/posts/upload"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/audit"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/ban"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/broadcast"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/char/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/kick"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/link"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/pages"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/pages/:id/close"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/pages/:id/respond"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/roster/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/sales"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/unban"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/vendors/:account"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/site-mode"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/uo-link/config"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/uo-link/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/uo-link/stream"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uo-link/towncrier"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/uo-link/towncrier/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uploads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/users/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/mfa/reset"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/shard/link/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/online"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/sales"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/standing"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/wiki"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/wiki/:slug/publish"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/:slug/revisions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/:slug/revisions/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/wiki/:slug/revisions/:id/restore"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/categories"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/wiki/categories"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/wiki/categories/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/wiki/categories/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/invite/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/invite/:token/accept"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/login"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/login/totp"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/logout"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/password"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/recovery-codes/generate"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/recovery-codes/status"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/devices"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/devices"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/devices/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/streams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/sessions/:id"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/trusted-devices"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/trusted-devices/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/login"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/logout"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/refresh"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/mobile/sso/exchange"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/mobile/sso/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/password/forgot"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/password/reset/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/password/reset/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/providers"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/register"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/sso/:provider/callback"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/sso/:provider/link"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/sso/:provider/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/sso/totp"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/password"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/appeals"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/appeals/:id/withdraw"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals/eligible"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/shard/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/char/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/shard/link"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/roster/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/sales"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/vendors/:account"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/posts/:category"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/posts/:category/:idOrSlug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/settings"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/champs"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/economy"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/feed"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/governors"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/governors/:city/history"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/guilds"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/houses"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/idoc"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/status"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/stream"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/status"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/version"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/categories"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/tags"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/health"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/internal/bot-config"
|
||||
}
|
||||
]
|
||||
}
|
||||
154
website/test-plan.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Runic Gateway Website — Test Plan (Discord bot)
|
||||
|
||||
> Companion to [website-README.md](website-README.md) (overview) and
|
||||
> [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (server API/schema/security contract).
|
||||
> Establishes the test plan for the **`bot/` workspace** (the Discord bot), the
|
||||
> last of the three `website` npm workspaces without a suite. The server and
|
||||
> client suites already exist (website PR #86); this doc closes the bot gap and
|
||||
> records the shared conventions so all three stay consistent.
|
||||
|
||||
## 1. Goal & philosophy
|
||||
|
||||
Cover **meaningful bot behavior** — the moderation/filter decisions a future
|
||||
change could silently break — not a coverage number. The bot's value is in *what
|
||||
it decides to delete, warn, mute, or let through*; a good test reads "given this
|
||||
message + config, what action does the bot take?", never "did this function call
|
||||
that function?".
|
||||
|
||||
The whole `website` repo tests on **Node's built-in runner (`node --test`)** with
|
||||
**zero external test dependencies** — no jest, no vitest, no jsdom. Unit tests
|
||||
run without a database or a live Discord gateway: the DB pool is pointed at a dead
|
||||
port and every collaborator (`db.query`, a model, a Discord `message`/`client`) is
|
||||
replaced with an in-memory fake. This mirrors the server suite exactly (the bot is
|
||||
CommonJS, like the server — `require`/`module.exports` — so the same patterns
|
||||
apply verbatim).
|
||||
|
||||
## 2. Current state
|
||||
|
||||
| Workspace | Runner | Status |
|
||||
|---|---|---|
|
||||
| `server/` | `node --test` | **Exists** — models, controllers, auth/session, shard ingest. |
|
||||
| `client/` | `node --test` (pure-logic ESM) | **Exists** (PR #86) — `lib/`, `api/`, `data/`. |
|
||||
| `bot/` | — | **This plan** — no runner or tests yet. |
|
||||
|
||||
The 46% SonarQube aggregate is dragged down by the bot (and the client's
|
||||
un-unit-testable React components) reading as 0% covered. Standing up the bot
|
||||
suite lifts the real number and, more importantly, locks the filter/moderation
|
||||
rules.
|
||||
|
||||
## 3. Harness
|
||||
|
||||
Add a test script to `bot/package.json` (mirrors server/client):
|
||||
|
||||
```json
|
||||
"scripts": { "test": "node --test" }
|
||||
```
|
||||
|
||||
Conventions, identical to `server/test/`:
|
||||
|
||||
- **No DB.** Set `process.env.DB_HOST = '127.0.0.1'` / `DB_PORT = '59999'` at the
|
||||
top of any test whose module transitively `require`s `../db`, so the pool is
|
||||
built against a dead port and a stray query fails fast instead of hanging.
|
||||
Models are exercised by monkeypatching `db.query` (or the model method the unit
|
||||
under test calls) with an in-memory fake; restore it in `afterEach`.
|
||||
- **No Discord.** The `discord.js` `Client`, `Message`, `GuildMember`, and
|
||||
`Interaction` objects are hand-rolled fakes carrying only the fields the unit
|
||||
reads (e.g. `message.mentions.users.size`, `message.member.roles.cache`,
|
||||
`message.client.fetchInvite`). Never construct a real client or open a gateway
|
||||
connection.
|
||||
- **Tests live in `bot/test/*.test.js`.** One file per module under test.
|
||||
|
||||
## 4. Units to cover
|
||||
|
||||
Ordered high-value first. Each row names the module, the behavior worth locking,
|
||||
and the seam a test drives it through.
|
||||
|
||||
### 4.1 Pure logic (no mocks beyond inputs)
|
||||
|
||||
| Module | Behaviors to lock | Notes |
|
||||
|---|---|---|
|
||||
| `filter/normalize.js` | leetspeak folding (`b4d`→`bad`, `@ss`→`ass`), 3+-repeat collapse (`sooooo`→`so`), case-fold; `matches()` is word-boundary anchored (so `bad` does not fire inside `badminton`); `findMatch()` returns the first `{word, severity}` row or `null`; the *documented gaps* stay gaps (spaced-out `b a d` is **not** caught). | The core obfuscation-resistance contract. Pin the gaps too, so a future tightening is a deliberate, test-visible change. |
|
||||
| `utils/duration.js` | `"30s"/"10m"/"2h"/"1d"` → correct ms; whitespace tolerated; garbage/empty/unknown-unit → `null`; `MAX_TIMEOUT_MS` is 28 days (the Discord cap callers clamp to). | Feeds `/mute` and temp-role expiry — a wrong parse mutes for the wrong span. |
|
||||
| `filter/spamFilter.js` | `isRateLimited` trips on the 6th message inside the 5 s window and is per-`guild:user`; it has a **side effect** (records the timestamp) so it must be called once; `isMassMention` counts users **+** roles against the threshold; `isMassEmoji` counts custom `<:name:id>` and unicode pictographs. | Drive time with a stubbed `Date.now` (or accept the real clock and use tight windows). The module-load `setInterval(...).unref()` sweep must not keep the runner alive — `.unref()` already handles this; assert no leak by letting the suite exit. |
|
||||
|
||||
### 4.2 Logic with a single mocked collaborator
|
||||
|
||||
| Module | Behaviors to lock | Seam / mock |
|
||||
|---|---|---|
|
||||
| `filter/inviteFilter.js` | returns the **code** (not a bool) of the first *foreign* invite; an invite resolving to the current guild is allowed; an invite that **fails to resolve** (expired/invalid) is treated as foreign (fail-closed); no invite in the message → `null`. | Fake `message.client.fetchInvite(code)` — resolve with `{ guild: { id } }` for local/foreign, or throw for unresolvable. Set `message.guildId` + `message.content`. |
|
||||
| `site/siteApiClient.js` | never throws on a network/timeout/HTTP failure — always returns the `{ ok, ... }` shape so a command never breaks when the site is down: `{ ok: true, data }` on success, `{ ok: false, error }` on failure, and the distinct `{ ok: false, maintenance: true, message }` for the site's 503 maintenance response. (Public read client — **no** shared secret; the keyed channel is `botInternalClient.js`.) | Stub `global.fetch` (resolve various statuses/bodies, reject/timeout via `AbortController`). Same non-throwing contract the server's `uoLinkClient` follows. |
|
||||
| `internal/requireInternalKey.js` | `401` on a missing/wrong key; `next()` on the configured key; **fail-closed** when `BOT_INTERNAL_KEY` is empty (never matches); timing-safe compare (equal-length + `crypto.timingSafeEqual`, no early-exit length leak). | Call the middleware with a fake `req` (`req.get('X-Internal-Key')`) + `res`/`next` spies; mirrors `server/test/requireInternalKey.test.js`. |
|
||||
|
||||
### 4.3 The filter pipeline (orchestration)
|
||||
|
||||
`discord/messageFilter.js` is the highest-value integration seam — it composes
|
||||
all of §4.1–4.2. Lock the **decision order and the actions**, with every
|
||||
collaborator faked:
|
||||
|
||||
- **Bypass wins first:** an allow-listed channel, or a member holding an
|
||||
allow-listed role, short-circuits the whole pipeline (`isBypassed`) — no
|
||||
delete, no hit recorded.
|
||||
- **Order:** invite link → banned word → spam (rate-limit → mass-mention →
|
||||
mass-emoji). `detectSpam` must evaluate `isRateLimited` **first and exactly
|
||||
once** (it has the timestamp side effect).
|
||||
- **Actions:** invite/spam always delete + warn (no severity tiers); a
|
||||
word-filter hit applies the action for its severity; filter-triggered mutes use
|
||||
the fixed `FILTER_MUTE_SECONDS` (600 s).
|
||||
- **Best-effort recording never breaks moderation:** `recordFilterHit` /
|
||||
`recordSpamHit` throwing must be swallowed — the delete/warn still happens.
|
||||
|
||||
Mocks: `filterCache` (in-memory `allowChannels`/`allowRoles` Sets + words), a fake
|
||||
`message` (content, author, member roles, `delete()`, `guildId`), and spies on
|
||||
`warnings`/`filterHits`/`spamHits`/`modLog` to assert what was called.
|
||||
|
||||
### 4.4 Models (`bot/model/*.js`)
|
||||
|
||||
Thin `db.query` wrappers (e.g. `warnings.js`, `filterWords.js`, `filterHits.js`,
|
||||
`spamHits.js`, `memberEvents.js`, `guildConfig.js`, `scheduledMessages.js`,
|
||||
`tempRoles.js`). Cover only those with **shaping/branching logic** (a query that
|
||||
maps rows, applies an "active/not-expired" predicate, or upserts) by asserting the
|
||||
**SQL params** passed to a fake `db.query` and the shape returned. Skip pure
|
||||
one-line inserts with no logic — testing those only proves the mock.
|
||||
|
||||
## 5. Explicitly out of scope
|
||||
|
||||
- **`discord.js` internals / the live gateway** — not ours to test; never open a
|
||||
real connection.
|
||||
- **Thin command wrappers** (`discord/commands/*.command.js`) that only validate
|
||||
args and call a Discord API + a model — cover their *logic* (arg parsing,
|
||||
duration clamping) if any, but not the Discord round-trip.
|
||||
- **`node-cron` scheduling itself** — test the job's callback logic, not that cron
|
||||
fires.
|
||||
|
||||
## 6. CI & coverage wiring
|
||||
|
||||
Mirror what PR #86 did for the client:
|
||||
|
||||
- **`.gitea/workflows/pr-checks.yml`** — add a `bot-tests` job (or a
|
||||
`Run bot tests` step) that runs `npm test --prefix bot`, gating PRs into `main`.
|
||||
- **`.gitea/workflows/sonarqube.yml`** — generate a bot LCOV from the repo root so
|
||||
`SF:` paths resolve to `bot/src/...`:
|
||||
|
||||
```
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=lcov --test-reporter-destination=bot/coverage/lcov.info \
|
||||
bot/test/*.test.js
|
||||
```
|
||||
|
||||
- **`sonar-project.properties`** — add `bot/test` to `sonar.tests`, the glob to
|
||||
`sonar.test.inclusions`, `bot/coverage/lcov.info` to
|
||||
`sonar.javascript.lcov.reportPaths`, and `bot/coverage/**` to `sonar.exclusions`.
|
||||
(`bot/src` is already in `sonar.sources`.)
|
||||
|
||||
Bot units need no `npm ci` to run when they import only relative files + Node
|
||||
built-ins; a test that stubs `global.fetch` or fakes `db` needs no `discord.js`
|
||||
either. Keep the pool pointed at a dead port so the run is hermetic.
|
||||
|
||||
## 7. Suggested phasing
|
||||
|
||||
1. **Harness + pure logic** — `test` script, then `normalize`, `duration`,
|
||||
`spamFilter`. Fast, zero mocks, immediate value.
|
||||
2. **Single-collaborator units** — `inviteFilter`, `siteApiClient`,
|
||||
`requireInternalKey`.
|
||||
3. **Pipeline** — `messageFilter` decision order + actions (the payoff test).
|
||||
4. **Models with logic**, then the **CI/Sonar wiring** so it all counts.
|
||||
@@ -10,6 +10,7 @@ A full-stack app in one repo:
|
||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
||||
- **Moderation appeals** — a player whose linked Discord identity was banned or muted (per the bot's `mod_actions` log) can open an appeal from the player portal; staff claim and resolve appeals from an admin queue, and approving a ban/mute appeal best-effort reverses it in Discord automatically. See [MODERATION_APPEALS.md](MODERATION_APPEALS.md).
|
||||
|
||||
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||
|
||||
@@ -17,6 +18,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
|
||||
## Contents
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Project structure](#project-structure)
|
||||
- [Prerequisites](#prerequisites)
|
||||
@@ -36,6 +38,103 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
How the pieces fit together — the React SPA and native app talk to one Express backend
|
||||
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
|
||||
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
%% ---------- Clients ----------
|
||||
subgraph clients["Clients"]
|
||||
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
|
||||
mobile["Native mobile app<br/>(bearer tokens)"]
|
||||
end
|
||||
|
||||
idp["SSO providers<br/>Google · Discord · custom OIDC"]
|
||||
discord["Discord"]
|
||||
|
||||
%% ---------- Website (one repo) ----------
|
||||
subgraph website["website/ — Node app (one repo)"]
|
||||
direction TB
|
||||
|
||||
subgraph backend["server/ — Express backend"]
|
||||
direction TB
|
||||
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
|
||||
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
|
||||
ctrl["Controllers"]
|
||||
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
||||
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
||||
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
||||
|
||||
subgraph shardutil["Shard integration (utils/)"]
|
||||
ingest["shardIngest.js<br/>WS ingest dispatcher"]
|
||||
restcli["uoLinkClient.js<br/>REST client (never throws)"]
|
||||
end
|
||||
|
||||
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
||||
end
|
||||
|
||||
bot["bot/<br/>Discord bot"]
|
||||
end
|
||||
|
||||
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
|
||||
|
||||
%% ---------- Shard side ----------
|
||||
subgraph shardside["Game shard (never internet-facing)"]
|
||||
direction TB
|
||||
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
|
||||
servuo["ServUO shard<br/>(C# plugin)"]
|
||||
end
|
||||
|
||||
%% ---------- Edges ----------
|
||||
browser <-->|"same-origin JSON + SSE (cookie)"| mw
|
||||
mobile -->|"REST (bearer access/refresh)"| mw
|
||||
browser -.->|"OAuth redirect + PKCE"| idp
|
||||
auth -.->|"token exchange"| idp
|
||||
|
||||
mw --> router --> ctrl
|
||||
ctrl --> auth
|
||||
ctrl --> model
|
||||
ctrl --> restcli
|
||||
ctrl --> sse
|
||||
auth --> model
|
||||
model <--> db
|
||||
auth -. reads/writes secrets .-> secret
|
||||
restcli -. reads config/token .-> secret
|
||||
ingest --> model
|
||||
ingest --> sse
|
||||
sse -->|"live events"| browser
|
||||
bot -->|"messages"| discord
|
||||
bot <--> db
|
||||
|
||||
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
|
||||
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
|
||||
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
|
||||
|
||||
%% ---------- Styling ----------
|
||||
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
|
||||
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
|
||||
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
|
||||
class idp,discord ext;
|
||||
class db store;
|
||||
class sidecar,servuo bridge;
|
||||
```
|
||||
|
||||
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
|
||||
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
|
||||
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
|
||||
All three surfaces produce the *same* session via the session layer.
|
||||
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
|
||||
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
|
||||
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
|
||||
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
|
||||
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Tech |
|
||||
@@ -53,7 +152,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
||||
## Project structure
|
||||
|
||||
```
|
||||
website/
|
||||
UOMSITE/
|
||||
├─ server/ Express API
|
||||
│ ├─ src/
|
||||
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||
@@ -268,6 +367,18 @@ cd server
|
||||
npm run swagger # → server/swagger/swagger-output.json
|
||||
```
|
||||
|
||||
`swagger.js` post-processes the generator's output in two ways before writing it:
|
||||
|
||||
- **Trailing slashes are stripped from path keys.** swagger-autogen builds a path by
|
||||
string-concatenating the mount prefix with the route argument, so a capability router mounted at
|
||||
`/users` whose collection route is `router.get('/')` would document as `/api/v1/admin/users/` —
|
||||
a URL no client calls, while dropping the one they all do. Express is indifferent (non-strict
|
||||
routing treats the two as one route), but the published spec is a contract.
|
||||
- **Path keys are sorted.** The generator emits them in router-traversal order, so moving a route
|
||||
between files rewrote most of this ~5k-line committed artifact even when the API was provably
|
||||
unchanged. Sorting keeps the diff proportional to the change. OpenAPI attaches no meaning to path
|
||||
order, and `scripts/routeManifest.js` already sorts for the same reason.
|
||||
|
||||
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||
not crash).
|
||||
|
||||
@@ -331,7 +442,7 @@ character**; players and editor/moderator staff are limited to their own linked
|
||||
|
||||
| Surface | Endpoints | Who | Data |
|
||||
|---|---|---|---|
|
||||
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown by name. Their in-game **map location is only included for admin/moderator viewers** — for players and the public it is stripped from the payload entirely (server-enforced, not just hidden in the UI). Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
||||
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
||||
|
||||
|
||||