Live verification of 4125d7a caught a 500 every 60s: the handler called
deps.session.isSessionValid(req), but the production session context
(src/context/session.js) exposes isValid — isSessionValid is only the
middleware-internal name. The round-1 test stub mirrored the wrong
name, so tests passed while prod 500'd (stub-shape-fits-bug).
- routes/auth/index.js: precompute guarded _authed (typeof isValid ===
'function' check); malformed session object can no longer 500 a
60s-polled endpoint. Fallback true: handler runs only after the
session middleware admitted the request.
- routes/auth/admin.js:119: same latent 500 fixed (isSessionValid →
isValid) — pre-existing DC-048 bug, any legacy-session /me call.
- Test stub now carries the real shape {isValid} with isSessionValid
deliberately absent — regression to the wrong name now fails tests.
Judge: GLM-5.3 cold read round 2, grade B ship; stale-comment polish
folded in. Full suite 115/2682 green.
The frontend admin panel polls GET /api/v1/auth/me on every dashboard
load and every 60s per open tab. /me lived only in the DC-048 admin
router, mounted only when email auth is enabled — so every single-user
install (the default) answered 404 and logged a DC-404 ERROR + stack
once per minute per open tab. Verified live in production logs.
- routes/auth/index.js: /auth/me now ALWAYS mounted. Multi-user +
req.user returns the stored profile (mode:'multi'); otherwise the
legacy single-operator response (role:'admin', isAdmin:true,
legacy:true, mode:'single'). Session-gated — NOT added to
PUBLIC_ROUTES, so unauthenticated polls get a clean 401.
- Frontend behavior unchanged: attachTrigger requires me.user.role
=== 'admin', and user stays null in single-user mode, so no Admin
button appears on single-user installs.
- openapi.yaml /api/v1/auth/me (200/401) now matches reality.
- +6 tests (route present both modes, response shapes, PUBLIC_ROUTES
absence, DC-048 admin-mount invariant, HTTP-level dispatch reach).
Judge: GLM-5.3 cold read, grade B ship (verdict URN recorded in
STATE.md); polish note (HTTP-level mount-order test) folded in same
commit. Full suite 115 suites / 2687 tests green.
Two log sites in routes/auth/admin.js wrote raw email PII to the server
log: the SMTP-unconfigured 'auth-invite-send' warn and the 'invite
accepted, user created' info. Both now route through
AuthProvider.maskEmail() with a '[unmaskable-email]' sentinel fallback
(never the raw address). Two regression tests assert the raw address is
absent from log meta and the masked form present. Response contract
unchanged (full email still returned to the authenticated admin).
Judge: GLM-5.3 cold read (deleg_f0896de3), grade B / ship / zero
blockers; polish notes folded in. Verdict URN:
urn:ump:jd2htpwq76ni6bapj3vxjpvypfdnoc4argqbzpcerutmh7u5khea
Full suite: 2610/2610 (109 suites).
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
Domain=.sami cookies are silently rejected by real browsers - .sami is an
unregistered custom TLD, so browsers treat sami itself as the effective
public suffix and refuse to set a cookie scoped to it (the same rule that
stops a site from setting a supercookie for all of .com). Confirmed via
curl verbose (cookie dropped, domain must not set cookies for sami) and
via the Firefox console on the actual device (Cookie rejected for invalid
domain) for the same cookie. The session cookie set on status.sami after
TOTP verify could never reach plex.sami/jellyfin.sami/emby.sami/chat.sami
no matter how the cookie itself was built - prior fixes tonight left this
mechanism untouched, which is why the loop persisted.
Fix: /totp/verify mints a short-lived (60s) single-use opaque token. The
status.sami frontend appends it to the redirect URL when bouncing the
user back to a gated service. That services login page exchanges the
token via the new public GET /api/v1/auth/sso-exchange for a host-only
session cookie (no Domain attribute - always accepted). isSessionValid
only checks the cookies HMAC signature, never its Domain, so the host-only
cookie validates identically to the cross-domain one on every existing
check with zero changes to that logic.
The never-trap fallback commit (fb638f6) left an extra closing brace after
the fail() call in the plex/jellyfin/emby page bodies (JSON.stringify(j))}
followed by another }).catch(...) on the next line - one brace too many,
since unlike the chat body these have no try/catch needing the extra scope).
This threw a SyntaxError parsing the inline <script>, which silently killed
the ENTIRE script - including the 15s failsafe redirect - leaving the page
stuck on "Signing in to ..." forever with zero console output explaining
why. Confirmed via node --check on the actual generated <script> contents
for all four services.
Sami reported plex.sami/dashcaddy-login hangs at 'Signing in to Plex...'
indefinitely. Earlier commit (210c208) added 8s/15s timeouts at the SHELL
template level, but the per-service page bodies in buildLoginPage()'s
pages object had their own dead-end behavior: when app-token/:svc
returned an error or no token, the body called fail() showing an error
message but DID NOT redirect anywhere. With check-session still
returning authenticated, the SHELL's 15s failsafe timer never fires
because the script is still 'running' (in the failed .then chain).
Fix in each body (plex/jellyfin/emby/chat):
- After app-token returns no token, check localStorage for a stale token.
If present, redirect to /web/?direct=1 — Plex/Jellyfin/Emby may still
accept it for the session, and the user is unblocked either way.
- If no stale token, the fail() message now includes a manual link to
/web/?direct=1 (not just status.sami re-auth), so the user always has
an exit. fail() also shows the actual API response body (truncated)
for easier debugging when something is genuinely wrong.
- catch() handlers get the same manual-link treatment.
- Removed the chat body's debug spam (Status: code + body dump to #d)
that was making the UI look broken even when it wasn't.
Verified: 133/133 auth/sso/csrf/session tests pass; served page on
plex.sami/jellyfin.sami/emby.sami/chat.sami all contain
myPlexAccessToken/jellyfin_credentials/emby_credentials/token fallback
checks + Open X manually links.
buildLoginPage() in routes/auth/sso-gate.js shipped with bare fetches
(no signal). When app-token/:serviceId hung in the browser (slow upstream,
no response after 30s+, etc.), the page sat on 'Signing in to Plex...'
indefinitely. Verified on DNS2 2026-07-22: user reported 'still doing the
same thing' even after cookie + XFF fixes were verified working end-to-end.
Hardening:
- check-session fetch: 5s AbortSignal timeout
- app-token/:svc fetch (via ft()): 8s AbortSignal timeout
- 15s hard overall timer: if nothing succeeds, force-redirect to
status.sami?auth=required so the user can re-auth
- try/catch around fail() to prevent DOM exception from breaking flow
Verified live: 133/133 auth/sso/csrf/session tests pass; container
healthy; served page contains 'withTimeout' + 'overallTimer' + '15000'.
Auto-login can no longer hang the page.
First pricing enforcement. Per PRODUCT-SPEC-DECISIONS.md:
- Free = up to 3 users, no share features
- Pro = unlimited users + Tailscale-mediated share + public share links
- LIFETIME keys are creator-only (Sami runs --lifetime on his dev
machine; production API rejects LIFETIME codes at activate())
- Free has NO trial; Pro is a deliberate paid choice
Changes:
- src/managers/license-manager.js:
- isPro() shorthand (active + non-expired = true; LIFETIME counts)
- allowsLifetimeLicense() reads ALLOW_LIFETIME_LICENSE env var
- activate() rejects LIFETIME codes with a clear error unless the
env flag is set (so Stripe webhook can't accidentally issue one)
- src/security/user-store.js: countUsers() helper for the tier gate
- src/utilities/errors.js: PaymentRequiredError (HTTP 402, code DC-402)
- routes/auth/admin.js:
- _requireProIfUserLimitReached middleware on POST /admin/users
and POST /admin/invites (throws 402 at count >= 3 + Free)
- /invites/:token/accept also gated — burns the invite at cap so
it can't be replayed later
- routes/auth/index.js: bridge licenseManager + userStore onto
req.app.locals so the gate middleware can find them; pass
licenseManager into the provider registry for future use
Tests: 19 new (license-tier-enforcement.test.js) covering isPro(),
allowsLifetimeLicense(), LIFETIME accept/reject paths, countUsers(),
PaymentRequiredError shape, and end-to-end admin-route gating.
Full suite: 1317/1317 passing across 50 suites.
Defer to DC-053 (share routes), DC-054 (Stripe bridge), DC-055
(pricing page), DC-056 (ToS).
Pluggable AuthProvider framework for any future auth method (OIDC, SAML,
passkeys) to plug in without touching the auth path again. Two
implementations ship:
* TOTP — refactored from routes/auth/totp.js into src/auth/providers/totp.js
as one AuthProvider impl. Legacy /api/v1/totp/* routes stay mounted for
back-compat; new /api/v1/auth/login/totp/* routes use the new shape.
* EmailMagicLink — src/auth/providers/email.js. Initiate issues a 32-byte
base64url token, stores its SHA-256 hash in data/email-tokens.json
(atomic lockfile-based mutation, automatic TTL cleanup), and delivers via
nodemailer if providers.email.{host,port,username,password} is set OR
falls back to log.info('auth', 'email magic link issued', ...) for dev.
Verify accepts the token, marks it used, creates the same DashCaddy
session cookie that TOTP uses (single global cookie model).
createAuthProviderRegistry() composes both implementations and exposes
them via /api/v1/auth/login/{methods, :provider/initiate, :provider/verify,
recovery-info} and /api/v1/auth/disable/:provider. PUBLIC_ROUTES + CSRF
exemptions updated to use :provider placeholder (parameterized for future
providers).
Test fix: src/utilities/middleware.js PUBLIC_ROUTES and csrf-protection.js
both switched to the :provider form because the prior literal 'totp'
wouldn't match the parameterized mount path Express 4.22 produces.
Test fix: __tests__/public-routes-drift.test.js extractMountPath() was
broken for Express 4.22's new ^/path/?(?=/|$) source format (no \?\?
terminator in the literal-mount case). Rewrote the parser to normalize
escaped slashes + trailing lookaheads instead of relying on regex
matching against the raw source.
New: __tests__/auth-provider-registry.test.js — 9 tests covering registry
composition, getProvider round-trip, listEnabled no-secrets-leak guarantee,
enabled-flag respect, listAll vs listEnabled distinction, email provider
dev-console fallback (token written to JSON store + log.info with
deliveredVia: 'dev-console' + response masked), verify rejects unknown
tokens via AuthenticationError.
Tests: 1241/1241 passing across 46 suites (was 1232; +9 new).
DNS2 deploy: this commit + bump VERSION to 1.16.0 + publish tarball to
get.dashcaddy.net + docker build + bash start.sh. The image-layer migration
from DC-050 also runs on first container recreate post-merge.
The DC-027 rate limiter on /api/v1/auth/* shipped with skip: () => isTest,
which counted every request — including those from a logged-in TOTP session.
Caddy's forward_auth fires /auth/gate/* on every page-load asset (HTML, JS,
CSS, XHR), so a normal browser session exhausted the 20-req/15-min budget
within ~3 page loads and started getting 429 'Too many auth requests' even
with a valid session cookie.
Fix: extend skip to also return true when req.auth.type is 'session',
'jwt', or 'apikey' (set by jwtApiKeyAuthMiddleware, which runs upstream
of the limiter). The unauthenticated path is still rate-limited — DC-027's
credential-scraping defense is preserved.
Also closes the uncommitted working-tree changes for:
- DC-026: routes/auth/sso-gate.js — pre-auth check in buildLoginPage,
redirected error fallbacks to status.sami?auth=required&return=...
- DC-022: dashcaddy-api/VERSION bumped to fef7e07
- status/index.html + status/js/tailscale-devices.js — Tailscale device card
4 new regression tests pin the fix:
- skips when req.auth.type === 'session'
- skips when req.auth.type === 'jwt'
- skips when req.auth.type === 'apikey'
- still counts UNAUTHENTICATED requests (defense preserved)
Live verified: 50/50 authenticated /auth/gate/plex calls passed (was
20/30 before fix). plex.sami/dashcaddy-login returns 200 with no redirect
loop. Plex auto-login token round-trips end-to-end.
[DC-026] routes/auth/sso-gate.js — fix sessionDuration='never' bypass
Both /auth/gate/:serviceId and /auth/app-token/:serviceId had a session
check gated on `sessionDuration !== 'never'`. An admin setting TOTP to
never-expire accidentally created an authentication-free path to credential
injection (Basic Auth, X-Api-Key, Plex/Prowlarr tokens). Patched: session
required whenever TOTP is enabled, period. Added 8 regression tests.
[DC-027] src/utilities/middleware.js — rate limit /auth/*
New authLimiter (20 req / 15 min) on /auth/keys, /auth/jwt, /auth/gate,
/auth/app-token. These endpoints expose credentials and were unmetered.
Without this, an attacker with a guessed session cookie could burn through
every credential-touching endpoint. Added 5 tests.
[DC-028] src/security/audit-logger.js — log credential exposures
/auth/gate and /auth/app-token were in SKIP_PATHS, silently dropping
every credential-exposure event from the audit log. Combined with the
GET-skip rule, NONE of these events were being recorded. Now logged
with named actions: auth.credential-injection, auth.app-token-issue,
auth.api-key-generate, auth.api-key-revoke, auth.jwt-mint. Added 9 tests.
[start.sh] Disable in-container self-updater
DASHCADDY_UPDATE_ENABLED=false. Without this, the container kept writing
trigger.json every 30 min and clobbered my in-progress host edits. The
path unit on the host is still active for manual triggers, but the
container won't auto-update itself — only when an admin clicks the
update button or a new release is manually published.
[package.json] Bump to 1.14.7
Test results: 1066/1066 passing across 39 suites (added 22 new tests).
1. /totp/recovery-info: was PUBLIC, leaking TOTP configuration status
to unauthenticated attackers. Now requires valid session (401 otherwise).
2. /totp/check-session: had an unconditional bypass that returned
authenticated:true whenever totpConfig.enabled was false. This let
anyone reach authenticated endpoints without credentials.
Now throws AuthenticationError instead.
3. /totp/setup: was unmetered despite generating secrets. Added 3/hour
per-IP rate limit in addition to the existing global 10/15min limiter.
All changes verified live via https://status.sami:
- recovery-info unauth → 401 [DC-110] (was 200)
- check-session no cookie → 401 TOTP protection required (was 200)
- 4th setup attempt → 429 [DC-429]
- sso-gate.js: add GET /api/v1/auth/login-page?service= route; auto-login
HTML for chat/plex/jellyfin/emby now served from code instead of inline
Caddyfile respond blobs. Fix merge() try-block syntax error (was missing
closing } before catch, breaking Jellyfin/Emby localStorage merge).
- middleware.js: add /api/v1/auth/login-page to PUBLIC_ROUTES.
- CLAUDE.md: complete rewrite — was describing the old Windows-local
C:/caddy/ layout; now accurately describes DNS2 production (paths,
container, caddy-apply workflow, SSO architecture, common mistakes).
- .gitignore: cover runtime JSON/log/cert files that were sitting untracked
in dev root (audit-log, backup-history, credentials, health-history, etc.),
plus generated-certs/, pki/, assets/.
- Remove tracked dev-root noise: comprehensive-test.js, license-keygen.js,
test-security-fixes.js (scripts that don't belong at repo root).
- Remove stale routes/openclaw.js (leftover from old monolithic structure).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The DC-005 src/ refactor left depth-2 route files (routes/auth/*,
routes/recipes/*, routes/apps/*, routes/arr/*, routes/config/*) with
broken require() paths. A filesystem-resolving scanner found 67 broken
requires across 21 files — three distinct bug classes:
A) '../../../src/...' (3 levels up, above package root) — Bug 7, ~49 occurrences
B) '../src/utils/...' (1 level up, resolves to nonexistent routes/src/) — ~15 occurrences
C) routes/apps/restore.js:5 used utilities/responses (wrong dir) — should be utils/responses
All fixed to '../../src/...' (or '../../src/utils/responses' for class C).
routes/auth/totp.js was already fixed in the DC-006 commit.
Post-fix: 922/922 tests pass, zero new ESLint warnings. No logic changes —
purely mechanical require() path corrections.
Covers the full BACKLOG DC-006 acceptance criteria:
- GET /api/totp/config — read current config
- POST /api/totp/setup — generate / import Base32 secret
- POST /api/totp/verify-setup — activate TOTP after setup
- POST /api/totp/verify — login with TOTP code → session + CSRF
- GET /api/totp/check-session — auth gate (200 / 401)
- POST /api/totp/disable — disable TOTP (requires valid code)
- POST /api/totp/config — update session duration
25 tests, all passing. Uses real otplib for code generation
(so we exercise actual TOTP math) but mocks credentialManager,
session, totpConfig, saveTotpConfig — those own their own state
machines (disk, cookies, file) that don't belong in a routes
test.
Also fixed a latent DC-005 bug: routes/auth/totp.js had wrong
require-path depth after the refactor (../../../src/... went 3
levels up instead of 2, breaking route load). Changed to
../../src/... for the 2-level depth. NOTE: the same depth bug
exists in many other depth-2 route files (auth/keys.js,
auth/sso-gate.js, auth/session-handlers.js, recipes/*, apps/*,
arr/*, config/*) — see BACKLOG.md DC-005 follow-up note. Tests
didn't catch this because no test previously imported the auth
routes; this new test exercises that import path.
Result: 904/904 Jest tests pass (879 baseline + 25 new).
ESLint: this file clean. Pre-existing 134 src/ warnings are
unrelated (DC-005 refactor moved files without re-applying
DC-004 lint cleanup — separate follow-up).
- credential-manager.js: add diagnose(key) method that distinguishes
ok | missing | unreadable | corrupt instead of silently returning null
- crypto-utils.js: silent fallback to .encryption-key.bak when primary
can't decrypt existing credentials; first-run bootstrap writes .bak;
rotateKey() backs up old key before swap
- routes/auth/totp.js: new public /api/v1/totp/recovery-info endpoint
returns {status, isSetUp, hint} so UI can show meaningful errors
- middleware.js: add /totp/recovery-info to PUBLIC_ROUTES so the
locked-out user can read the diagnostic without being logged in
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
Convert ~160 raw res.json()/res.status().json() calls across 32+ files
to use centralized helpers from src/utils/responses.js (ok, errorResponse,
successMessage, notFound, validationError, forbidden, unauthorized, conflict).
No behavior changes — response shapes are identical. Future schema changes
(e.g., requestId envelope) only need to update one module.
Fix error vs errorResponse signature mismatch in routes/health.js CA cert
endpoint where error(res, message, statusCode) was being called with
errorResponse(res, statusCode, message, extras) argument order.
Files changed: middleware.js, csrf-protection.js, error-handler.js,
license-manager.js, src/app.js, and 27 route files.
Test suite: 755 pass / 4 pre-existing failures (services credential tests).
1. WebSocket exec auth bypass (exec.js): Require valid JWT or API key
before accepting WebSocket upgrade. Reject unauthenticated requests
with 401 before the upgrade completes.
2. Shell injection in router auto-login (session-handlers.js): Validate
baseUrl against safe hostname pattern before embedding in wget shell
command. Reject with null session if invalid.
3. Path traversal in credentials routes (services.js): Add explicit
serviceId validation (alphanumeric + dash/underscore/dot, max 100
chars) to all three credential endpoints. Removed redundant
try/catch wrapper.
4. execSync injection in CA CSR generation (ca.js): Add sanitize step
replacing any non-alphanumeric domain chars with underscore before
interpolation into shell subj argument. Redundant with existing
validation but provides defense-in-depth.
5. Auth bypass when TOTP disabled (middleware.js): Split the logic
cleanly — disabled TOTP means no auth (initial setup state), enabled
TOTP means all auth methods checked (session/JWT/API key). Removed
the sessionDuration:never conflating shortcut.
- Set Domain=.sami on session + CSRF cookies so browsers send them to all subdomains
- This fixes Caddy forward_auth returning 401 for radarr/sonarr/prowlarr
- Fix login URL concatenation bug (radarr.samilogin -> radarr.sami/login)
- Fix getSetCookie() missing from _httpsFetch/_httpFetch response objects
- Fix array/string handling for set-cookie header in session-handlers fallback
- Refactor csrf-protection to createCSRFMiddleware() factory with cookieDomain support
- Pass renewCSRFToken through middleware deps chain to TOTP route
- Add missing fetchT dep to session-handlers.js (fixes ctx is not
defined error that broke jellyfin/emby/plex/syncthing SSO)
- Replace all ctx.fetchT calls with direct fetchT usage
- Remove server-old.js (69K monolith backup) from tracking
- Remove AI-generated doc artifacts from repo root
- Update .gitignore to prevent re-adding these files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix service edit double-write bug (was creating duplicate entries)
- Add editable display name field to service edit modal
- Backend update endpoint now accepts name, logo, and recalculates url
- Fix CSRF token regeneration breaking all POST requests (nonce was
being regenerated on every request, invalidating cached tokens)
- CSRF nonce now persists across requests, rotated only on TOTP login
- Frontend secureFetch auto-retries on CSRF failure with fresh token
- Restore lifetime license activation on DNS2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.