diff --git a/AGENTS.md b/AGENTS.md
index e0a14e5..53892c4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ Read and follow [AGENT.md](./AGENT.md) — the canonical guide for the Nimbus ma
pnpm typecheck && pnpm build && pnpm exec nimbus-docs check && pnpm lint:docs
```
-Then confirm `dist/` still serves every public route: `/`, `/server/`, `/reader/`, `/faq/`, `/compare/`, `/architecture/`, `/server/{intro,installation,configuration,webui,search,compatibility,enhancements,limitations,development,migration}/`, `/server/enhancements/{webhooks,history-events,epub2-metadata,natural-sort,komf,thumbnail-storage,komga-riir,next-previous,reading-stats,smart-lists,server-stats,update-check,sse-recovery}/`, `/reader/{intro,reading,offline,library,translation,settings,privacy}/`.
+Then confirm `dist/` still serves every public route: `/`, `/server/`, `/reader/`, `/faq/`, `/compare/`, `/architecture/`, `/server/{intro,installation,configuration,webui,search,compatibility,enhancements,limitations,development,migration}/`, `/server/enhancements/{webhooks,history-events,epub2-metadata,natural-sort,komf,thumbnail-storage,komga-riir,next-previous,reading-stats,smart-lists,server-stats,update-check,sse-recovery}/`, `/reader/{intro,reading,offline,library,translation,settings,privacy}/`, `/oauth/{anilist,mal,bangumi,mangabaka}/callback/`.
## Content conventions
diff --git a/public/_headers b/public/_headers
new file mode 100644
index 0000000..9fab789
--- /dev/null
+++ b/public/_headers
@@ -0,0 +1,7 @@
+/oauth/*
+ Cache-Control: no-store
+ Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
+ Referrer-Policy: no-referrer
+ X-Content-Type-Options: nosniff
+ X-Frame-Options: DENY
+ X-Robots-Tag: noindex, nofollow, noarchive
diff --git a/public/oauth/anilist/callback/index.html b/public/oauth/anilist/callback/index.html
new file mode 100644
index 0000000..6c03f79
--- /dev/null
+++ b/public/oauth/anilist/callback/index.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+ AniList authorization · kmworks
+
+
+
+
+
+ kmworks · OAuth relay
+ AniList authorization
+
+ Returning the authorization result to your KMServer instance.
+
+
+ Processing authorization…
+
+
+ You will be redirected automatically.
+
+
+
+
+
diff --git a/public/oauth/bangumi/callback/index.html b/public/oauth/bangumi/callback/index.html
new file mode 100644
index 0000000..effbd7c
--- /dev/null
+++ b/public/oauth/bangumi/callback/index.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+ Bangumi authorization · kmworks
+
+
+
+
+
+ kmworks · OAuth relay
+ Bangumi authorization
+
+ Returning the authorization result to your KMServer instance.
+
+
+ Processing authorization…
+
+
+ You will be redirected automatically.
+
+
+
+
+
diff --git a/public/oauth/mal/callback/index.html b/public/oauth/mal/callback/index.html
new file mode 100644
index 0000000..a229d63
--- /dev/null
+++ b/public/oauth/mal/callback/index.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+ MyAnimeList authorization · kmworks
+
+
+
+
+
+ kmworks · OAuth relay
+ MyAnimeList authorization
+
+ Returning the authorization result to your KMServer instance.
+
+
+ Processing authorization…
+
+
+ You will be redirected automatically.
+
+
+
+
+
diff --git a/public/oauth/mangabaka/callback/index.html b/public/oauth/mangabaka/callback/index.html
new file mode 100644
index 0000000..8a6060c
--- /dev/null
+++ b/public/oauth/mangabaka/callback/index.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+ MangaBaka authorization · kmworks
+
+
+
+
+
+ kmworks · OAuth relay
+ MangaBaka authorization
+
+ Returning the authorization result to your KMServer instance.
+
+
+ Processing authorization…
+
+
+ You will be redirected automatically.
+
+
+
+
+
diff --git a/public/oauth/relay.css b/public/oauth/relay.css
new file mode 100644
index 0000000..d0d364e
--- /dev/null
+++ b/public/oauth/relay.css
@@ -0,0 +1,122 @@
+:root {
+ color-scheme: light dark;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+ background: #f4f5f7;
+ color: #191c20;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-height: 100vh;
+ margin: 0;
+ padding: 24px;
+ display: grid;
+ place-items: center;
+}
+
+.relay-card {
+ width: min(100%, 32rem);
+ padding: 32px;
+ border: 1px solid #d9dde3;
+ border-radius: 16px;
+ background: #ffffff;
+ box-shadow: 0 16px 48px rgb(25 28 32 / 8%);
+}
+
+.relay-kicker {
+ margin: 0 0 20px;
+ color: #59616d;
+ font-size: 0.75rem;
+ font-weight: 650;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+h1 {
+ margin: 0;
+ font-size: clamp(1.5rem, 5vw, 2rem);
+ line-height: 1.2;
+}
+
+.relay-description,
+.relay-detail,
+.relay-noscript {
+ color: #59616d;
+ line-height: 1.6;
+}
+
+.relay-description {
+ margin: 12px 0 24px;
+}
+
+.relay-status {
+ padding: 12px 14px;
+ border-radius: 8px;
+ background: #edf1f5;
+ font-size: 0.9rem;
+ font-weight: 650;
+}
+
+.relay-status.is-success {
+ background: #e4f4e9;
+ color: #17632d;
+}
+
+.relay-status.is-error {
+ background: #fbe9e7;
+ color: #98261f;
+}
+
+.relay-detail,
+.relay-noscript {
+ margin: 16px 0 0;
+ font-size: 0.875rem;
+ overflow-wrap: anywhere;
+}
+
+.relay-noscript {
+ color: #98261f;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ background: #111418;
+ color: #edf0f3;
+ }
+
+ .relay-card {
+ border-color: #30363e;
+ background: #191d22;
+ box-shadow: none;
+ }
+
+ .relay-kicker,
+ .relay-description,
+ .relay-detail,
+ .relay-noscript {
+ color: #a5adb8;
+ }
+
+ .relay-status {
+ background: #272d35;
+ }
+
+ .relay-status.is-success {
+ background: #173d26;
+ color: #8ce0a6;
+ }
+
+ .relay-status.is-error {
+ background: #48201f;
+ color: #ffaaa3;
+ }
+
+ .relay-noscript {
+ color: #ffaaa3;
+ }
+}
diff --git a/public/oauth/relay.js b/public/oauth/relay.js
new file mode 100644
index 0000000..71bd46c
--- /dev/null
+++ b/public/oauth/relay.js
@@ -0,0 +1,86 @@
+const relayPath = /^\/oauth\/(anilist|mal|bangumi|mangabaka)\/callback\/?$/;
+
+export function buildRelayTarget(url) {
+ const match = relayPath.exec(url.pathname);
+ if (!match) {
+ throw new Error("This OAuth callback address is not supported.");
+ }
+ const provider = match[1];
+ const providerError = url.searchParams.get("error");
+ if (providerError) {
+ const description = url.searchParams.get("error_description");
+ throw new Error(description ? `${providerError}: ${description}` : providerError);
+ }
+
+ const code = url.searchParams.get("code");
+ if (!code) {
+ throw new Error("The authorization response is missing its code.");
+ }
+ const state = url.searchParams.get("state");
+ if (!state) {
+ throw new Error("The authorization response is missing its state.");
+ }
+
+ let payload;
+ try {
+ payload = JSON.parse(state);
+ } catch {
+ throw new Error("The authorization state is invalid.");
+ }
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ throw new Error("The authorization state is invalid.");
+ }
+ if (typeof payload.nonce !== "string" || !payload.nonce) {
+ throw new Error("The authorization state is missing its nonce.");
+ }
+ if (typeof payload.redirectUrl !== "string") {
+ throw new Error("The authorization state is missing its return address.");
+ }
+
+ let target;
+ try {
+ target = new URL(payload.redirectUrl);
+ } catch {
+ throw new Error("The authorization return address is invalid.");
+ }
+ if (target.protocol !== "https:" && target.protocol !== "http:") {
+ throw new Error("The authorization return address must use HTTP or HTTPS.");
+ }
+ if (target.username || target.password || target.search || target.hash) {
+ throw new Error("The authorization return address contains unsupported parts.");
+ }
+ const targetPath = new RegExp(
+ `^/(?:[A-Za-z0-9_-]+/)*api/oauth/${provider}/callback$`,
+ );
+ if (!targetPath.test(target.pathname)) {
+ throw new Error("The authorization return address does not match this provider.");
+ }
+
+ target.searchParams.set("code", code);
+ target.searchParams.set("state", state);
+ return target.toString();
+}
+
+function runRelay() {
+ const status = document.getElementById("relay-status");
+ const detail = document.getElementById("relay-detail");
+ if (!status || !detail) {
+ return;
+ }
+
+ try {
+ const target = buildRelayTarget(new URL(window.location.href));
+ status.textContent = "Authorization received";
+ status.classList.add("is-success");
+ detail.textContent = "Returning to KMServer…";
+ window.location.replace(target);
+ } catch (error) {
+ status.textContent = "Authorization could not be completed";
+ status.classList.add("is-error");
+ detail.textContent = error instanceof Error ? error.message : "Invalid authorization response.";
+ }
+}
+
+if (typeof window !== "undefined" && typeof document !== "undefined") {
+ runRelay();
+}