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.
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.
- 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
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.