feat(engagement): the engagement system — cutover 3 of 7 (edge → main)
#180
14
.env.example
14
.env.example
@@ -57,7 +57,7 @@ DB_ROOT_PASSWORD=change-me-root-password
|
||||
# Auth
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
# Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client
|
||||
# secrets, the Discord bot token, the Gmail refresh token, the uo-link auth
|
||||
# secrets, the Discord bot token, the mail transport credentials, the uo-link auth
|
||||
# token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO
|
||||
# START without it (utils/secretBox.js), so a Compose deployment that leaves it
|
||||
# blank crash-loops before it ever listens. Development falls back to a key
|
||||
@@ -98,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
||||
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. Until it's connected, the contact form falls back to
|
||||
# a mailto: link (recipient = the `contact_email` site setting).
|
||||
# Email is configured in Admin → Settings → Email, not via env: pick a mail
|
||||
# transport (SMTP) and enter its host, port and credentials, which are stored
|
||||
# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is
|
||||
# the recommended one, a mailbox provider over SMTP (e.g. smtp.gmail.com:587
|
||||
# with an app password) is the simplest, and an unauthenticated local MTA on
|
||||
# port 25 needs no credentials at all. Until one is configured the contact form
|
||||
# falls back to a mailto: link (recipient = the `contact_email` site setting).
|
||||
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||
|
||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
12
README.md
12
README.md
@@ -151,7 +151,7 @@ flowchart TB
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
||||
| Email | Nodemailer over a configurable mail transport — SMTP (relay, mailbox provider or your own MTA), set up in the admin panel — with a `mailto:` fallback |
|
||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
|
||||
|
||||
@@ -584,7 +584,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (transport + credentials), never via env; recipient = `contact_email` setting. Upgrading from the removed Gmail connect flow: see [`docs/website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md) |
|
||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||
@@ -677,9 +677,11 @@ run this repo as UOMysticmoon.
|
||||
|
||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
||||
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
|
||||
contact form falls back to a `mailto:` link when unconfigured.
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail
|
||||
transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are
|
||||
write-only over the API (never returned, never in env); no transport ships a default host or
|
||||
sender, so an unconfigured deployment sends nowhere. The contact form falls back to a `mailto:`
|
||||
link when unconfigured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
|
||||
# It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. The contact recipient is the `contact_email` site
|
||||
# Email is configured in Admin → Settings → Email, not here: pick a mail
|
||||
# transport (SMTP) and enter its host, port and credentials, stored encrypted in
|
||||
# the DB. A relay is the recommended posture; smtp.gmail.com:587 with an app
|
||||
# password is the simplest. The contact recipient is the `contact_email` site
|
||||
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
||||
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
|
||||
@@ -1029,6 +1029,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"description": "Operator warnings needing action; empty when there is nothing to say",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"example": "EMAIL_TRANSPORT_MIGRATION"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"href": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recent_activity": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -1205,11 +1224,11 @@
|
||||
"tags": [
|
||||
"Admin · Email"
|
||||
],
|
||||
"summary": "Get email delivery config + status (admin only)",
|
||||
"description": "",
|
||||
"summary": "Get email delivery config, status and the transport catalog (admin only)",
|
||||
"description": "Credentials are write-only: secret fields are never returned, only a per-field `secretsSet` flag. `transports` carries each registered transport's declared credential fields, which is what the admin form renders.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Config (refresh token stripped) + status",
|
||||
"description": "Config (secrets stripped) + status + transport catalog",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1257,7 +1276,7 @@
|
||||
"Admin · Email"
|
||||
],
|
||||
"summary": "Update email delivery config (admin only)",
|
||||
"description": "Set the From display name and enabled toggle. Enabling requires a connected Gmail account.",
|
||||
"description": "Set the transport, sender identity, credentials and enabled toggle. `credential` is a patch against the stored blob — a secret field submitted empty keeps its stored value. Enabling requires complete credentials and a sender address.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated config",
|
||||
@@ -1271,7 +1290,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Cannot enable before connecting a mailbox",
|
||||
"description": "Unknown transport, or cannot enable without complete credentials and a sender address",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1318,9 +1337,25 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"transport": {
|
||||
"type": "string",
|
||||
"example": "smtp"
|
||||
},
|
||||
"senderEmail": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"senderName": {
|
||||
"type": "string"
|
||||
},
|
||||
"replyTo": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"credential": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1331,128 +1366,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/email/connect/callback": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Email"
|
||||
],
|
||||
"summary": "OAuth2 callback — stores the refresh token, redirects to Settings",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "Redirect back to /admin/settings"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/email/connect/start": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Email"
|
||||
],
|
||||
"summary": "Begin the Gmail OAuth2 connect flow (admin only)",
|
||||
"description": "Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Authorization URL",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Google OAuth client not configured",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Admin role required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/email/disconnect": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Email"
|
||||
],
|
||||
"summary": "Disconnect Gmail and disable email (admin only)",
|
||||
"summary": "Clear the stored credentials and disable email (admin only)",
|
||||
"description": "",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Disconnected config",
|
||||
"description": "Cleared config",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1502,7 +1425,7 @@
|
||||
"Admin · Email"
|
||||
],
|
||||
"summary": "Send a test email (admin only)",
|
||||
"description": "",
|
||||
"description": "The real verification of the configuration — host, port, TLS mode, credentials, and whether the relay accepts the configured sender. Failures return a specific diagnostic.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Sent",
|
||||
|
||||
Reference in New Issue
Block a user