Compare commits
69 Commits
feature/br
...
7d09475c53
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
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
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
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
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
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
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
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
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>.
|
||||
18
README.md
18
README.md
@@ -38,3 +38,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
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
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.
|
||||
1013
android/PLAN.md
Normal file
1013
android/PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
android/screenshots/01-login-2fa-trust-device.png
Normal file
BIN
android/screenshots/01-login-2fa-trust-device.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
android/screenshots/02-account-security-section.png
Normal file
BIN
android/screenshots/02-account-security-section.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
android/screenshots/03-trusted-devices.png
Normal file
BIN
android/screenshots/03-trusted-devices.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
android/screenshots/04-recovery-codes-show-once.png
Normal file
BIN
android/screenshots/04-recovery-codes-show-once.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
android/screenshots/05-recovery-code-login.png
Normal file
BIN
android/screenshots/05-recovery-code-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
29
android/screenshots/README.md
Normal file
29
android/screenshots/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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`).
|
||||
98
android/theme-plan.md
Normal file
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
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.
|
||||
114
website/ARCHITECTURE.md
Normal file
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.
|
||||
@@ -142,6 +142,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
|
||||
@@ -152,18 +255,129 @@ accepts `Authorization: Bearer` for API testing).
|
||||
### /auth (auth.routes.js → auth.controller.js)
|
||||
| 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.
|
||||
|
||||
**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.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) |
|
||||
@@ -189,6 +403,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 +436,24 @@ 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 (see `server/src/app.js`):
|
||||
`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'`. `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.
|
||||
- **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.
|
||||
@@ -270,7 +500,13 @@ 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`, **no
|
||||
published host port** — devices reach it via the reverse proxy; the backend publisher reaches it
|
||||
over the private compose network. 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 +528,16 @@ 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
|
||||
```
|
||||
|
||||
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
|
||||
|
||||
128
website/MODERATION_APPEALS.md
Normal file
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.
|
||||
210
website/TRUSTED_DEVICES_MFA.md
Normal file
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.
|
||||
154
website/test-plan.md
Normal file
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 |
|
||||
@@ -331,7 +430,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). |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user