diff --git a/status/build.js b/status/build.js
index dc941b6..dfcd8b2 100644
--- a/status/build.js
+++ b/status/build.js
@@ -18,6 +18,12 @@ const bundles = {
JS('globals.js'),
JS('skeleton-loader.js'),
JS('theme.js'),
+ // DC-049: pluggable auth gate — claims ownership of the
+ // ?auth=required flow by setting window.__dc_049_handled BEFORE
+ // totp-auth.js runs, so the legacy TOTP-only overlay doesn't flicker
+ // in for multi-provider installs. Single-provider TOTP-only installs
+ // work because this module delegates back to window._showTotpOverlay().
+ JS('auth-gate.js'),
JS('totp-auth.js'),
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
// calls from showTotpOverlay(). Must come after totp-auth.js.
diff --git a/status/js/auth-gate.js b/status/js/auth-gate.js
new file mode 100644
index 0000000..b1ddd47
--- /dev/null
+++ b/status/js/auth-gate.js
@@ -0,0 +1,239 @@
+// ===== PLUGGABLE AUTH GATE (DC-049) =====
+//
+// On Caddy redirect to ?auth=required, this module queries
+// GET /api/v1/auth/login/methods to discover which AuthProviders are enabled.
+// If only one provider is enabled, jump straight to its challenge UI
+// (TOTP-only installations today). If multiple providers exist, render a
+// selector first so the user picks how to sign in.
+//
+// Today:
+// - totp: challenge is 6-digit code via the existing TOTP overlay
+// - email: challenge is email address → POST /api/v1/auth/login/email/initiate
+// → server emails magic link (or logs it to console in dev) →
+// waiting for verification (no auto-promotion; user reloads via
+// email link, which routes through /api/v1/auth/login/email/verify)
+//
+// This module wires into the existing totp-auth.js submitTotpCode() flow so
+// the legacy TOTP happy-path is unchanged: when methods returns only
+// `totp`, this module just shows the TOTP overlay and exits.
+
+(function() {
+ // ---- DOM refs (created lazily below; existing TOTP markup supplies them) ----
+ let methodsCache = null;
+
+ async function fetchMethods() {
+ if (methodsCache) return methodsCache;
+ try {
+ const res = await fetch('/api/v1/auth/login/methods', { cache: 'no-store' });
+ if (!res.ok) throw new Error(`methods HTTP ${res.status}`);
+ const data = await res.json();
+ methodsCache = Array.isArray(data.providers) ? data.providers : [];
+ return methodsCache;
+ } catch (e) {
+ // Public endpoint — if it fails, fall through to legacy TOTP path.
+ console.warn('[auth-gate] methods fetch failed; falling back to TOTP-only', e);
+ return [];
+ }
+ }
+
+ function showProviderSelector(providers) {
+ const overlay = document.getElementById('totp-overlay');
+ if (!overlay) return;
+ const card = overlay.querySelector('.totp-card');
+ if (!card) return;
+
+ // Save the existing TOTP-challenge body so we can restore it on cancel.
+ const originalBody = card.innerHTML;
+ if (!card.dataset.originalBody) card.dataset.originalBody = originalBody;
+
+ const providerButtons = providers.map(p => {
+ const label = (p.config && (p.config.label || p.name)) || p.name;
+ const btn = ``;
+ return btn;
+ }).join('\n');
+
+ card.innerHTML = `
+
+
+
Choose how to sign in
+
${providerButtons}
+
+ `;
+
+ overlay.classList.add('show');
+
+ // Wire each button
+ card.querySelectorAll('.provider-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const name = btn.dataset.provider;
+ const provider = providers.find(p => p.name === name);
+ renderProviderChallenge(provider);
+ });
+ });
+ }
+
+ function restoreOriginalBody() {
+ const overlay = document.getElementById('totp-overlay');
+ if (!overlay) return;
+ const card = overlay.querySelector('.totp-card');
+ if (!card || !card.dataset.originalBody) return;
+ card.innerHTML = card.dataset.originalBody;
+ // Re-bind the original digit input handlers by re-running the totp-auth
+ // bootstrap. The simplest path: reload the page, which re-runs all IIFEs.
+ window.location.reload();
+ }
+
+ function renderProviderChallenge(provider) {
+ const overlay = document.getElementById('totp-overlay');
+ if (!overlay) return;
+ const card = overlay.querySelector('.totp-card');
+ if (!card) return;
+
+ if (provider.name === 'totp') {
+ // Restore the original TOTP markup → existing totp-auth.js submitTotpCode
+ // path handles the rest. (Reload is the cleanest path because
+ // totp-auth.js wires its input handlers at top-level IIFE time.)
+ window.location.reload();
+ return;
+ }
+
+ if (provider.name === 'email') {
+ card.innerHTML = `
+
+
+