- dashcaddy-api/assets/New Text Document.txt (0 bytes, never referenced)
- dashcaddy-api/assets/test-upload4.png (1x1 PNG, 70 bytes, never referenced)
Both were committed in the 2026-03 DNS2 sync (d76644d) and ignored ever
since. Zero references in any code, frontend, Docker mount, or test
fixture. The dashcaddy-api/assets/ directory is in .gitignore but the
files were still tracked from the pre-ignore era. Safe to remove.
Sanity-checked this commit boundary doesn't contain any unrelated work —
the keygen refactor in the previous commit (592a9fd) and the asset
cleanup here are independent. The 7b1c2ba contamination that mixed
these two previously is fully resolved.
Round-trip cleanup of dashcaddy-api/license-keygen.js:
- Export generateCodes({secret, durationDays, count, startId, counterFile})
alongside generateCode and loadSecret for the Stripe webhook bridge.
- Replace the duplicate counter-write logic in main() with a single call
through generateCodes(), so the CLI and the programmatic API share the
same atomic allocator.
- _atomicWriteCounter() writes a uniquely-named .tmp file (pid+ts+rand
suffix) and renames over the destination. POSIX rename is atomic on the
same filesystem; the .tmp suffix prevents collisions across the event
loop. Stale .tmp files are unlinked if rename fails.
- Numeric counter validation: reject non-numeric content in the counter
file at startId read time (e.g. operator mucked up the file by hand).
- startId range-check: 0..0xFFFFFFFF, non-integer values rejected with a
clear error. Uses Object.prototype.hasOwnProperty.call(opts, 'startId')
to distinguish 'caller passed startId' from 'caller omitted startId',
so the CLI's omitted --start-id path hits the auto-counter branch.
- 32-bit codeId overflow check: startId + count - 1 must fit.
- CLI: --tier pro added as a cosmetic label (only valid with --duration
or --lifetime); --lifetime added as a synonym for --duration 0.
--lifetime and --duration are mutually exclusive. --start-id override
skips the counter write.
- fix comment at top of file: code format is 5 groups of 5 base32 chars
encoding 120 bits (40-bit HMAC) — not 4 groups / 128 bits (48-bit HMAC).
- Add __tests__/license-keygen.test.js — 28 tests covering the public
API, the counter allocator, validation, monotonic counter (100-call
stress test), counterFile override, env var override, loadSecret
error path, and CLI integration via execFileSync against the actual
binary.
Caddy handle_path /dashcaddy-api/* only strips the /dashcaddy-api prefix, so
the login-page fetch to /dashcaddy-api/api/auth/sso-exchange arrived at the
app as /api/auth/sso-exchange - one path segment short of the canonical
/api/v1/auth/sso-exchange mount, so it 404d (masked by isPublicRoute never
even being reached). Add it to the same narrow gate/app-token rewrite case.
Caught by an end-to-end curl replay of the actual handoff flow before
asking for another live retest.
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.
isSessionValid previously checked verifyIPSession() first, falling back to
verifySessionCookie() only if IP miss. Under Caddy --network host forward_auth,
req.ip arrived as 100.121.150.22 (DNS2 tailnet) instead of the user's real IP,
causing every cross-subdomain auto-login (plex/jellyfin/emby/chat) to 401 even
with a valid cookie. Now cookie-only; the IP cache write-back is kept as a
no-op for telemetry compat.
Verification on DNS2: /dashcaddy-login renders in 184ms (was 7s).
app-token/plex with the TOTP-issued cookie returns 200 with a real Plex token.
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).
Sami's explicit decisions:
- Free is completely free — no Pro trial, no time-limited upsells.
Pro is a deliberate paid choice. Update pricing language in
PRODUCT-SPEC-DECISIONS.md so anyone reading it doesn't assume
there's a hidden trial period.
- Lifetime keys are creator-only (Sami runs license-keygen.js
--lifetime on his dev machine; the API rejects LIFETIME codes
at verifyCode time). No paid customer can ever buy or receive
a lifetime key — they get 30/90/180/365-day keys. Update both
PRODUCT-SPEC-DECISIONS.md and DC-052 in BACKLOG.md to spell
this out so the Stripe webhook (DC-054) doesn't accidentally
generate a LIFETIME for a buyer.
Captures the 14 product-spec decisions locked today with Sami:
- Time-based pricing: 30/90/180/365 day tiers at $20/$50/$70/$99
- Free = up to 3 users, Pro = unlimited
- Free = local-only; Pro adds Tailscale-mediated share + public share links
- Stripe Checkout, USD-only, optional dashcaddy.net account
- LIFETIME keys are creator-only (no public exposure)
- Use existing license-keygen, no new auth system
- Invitees MUST use email magic link (email = identity for non-host users)
BACKLOG gets 5 new build tickets:
- DC-052: License-tier enforcement (cap Free at 3 users, gate share on Pro)
- DC-053: Public + Tailscale-mediated share routes (the killer Pro feature)
- DC-054: Stripe webhook bridge for auto-issuing license keys
- DC-055: dashcaddy.net/pricing page + Stripe Checkout
- DC-056: ToS + Privacy Policy pages (GDPR-aware)
No code changes — pure planning artifacts. Code work begins next.
When only TOTP is enabled (today's production state for everyone),
auth-gate.js was falling through to the legacy TOTP overlay with no
visible path to the email provider. The email method was unreachable
from the UI even when configured. Fixed: append a small 'Or sign in
with email instead ->' link to the bottom of the TOTP card. Clicking
swaps the body to the email challenge form.
Why this matters even for the single-totp path: email is the
phone-friendly, no-app-required recovery path. Operator forgets their
TOTP secret at 2am, they can request a link without touching the
authenticator app. The link just wasn't reachable before.
Renders the link only when the methods response includes both totp
and email — preserves the truly-single-provider case unchanged.
New module status/js/auth-gate.js owns the Caddy ?auth=required flow.
On load it queries GET /api/v1/auth/login/methods to discover which
AuthProviders are configured. Three branches:
* 0 providers → legacy TOTP overlay (delegates to window._showTotpOverlay)
* 1 provider (totp only) → legacy TOTP overlay (delegates, no UI change)
* 2+ providers → provider selector with 'Sign in with …' buttons
Email provider challenge is a single email input + 'Send sign-in link'
button. POST to /api/v1/auth/login/email/initiate. On success the UI
shows 'check the server logs' message if deliveredVia == 'dev-console'
(production hosts without SMTP fall back gracefully) or 'check your
inbox' when SMTP is configured.
TOTP button just calls window.location.reload() — simplest path because
totp-auth.js wires the 6-digit input handlers at module-load time, and
a reload re-runs all IIFEs with the original markup. Same behavior as
the legacy single-provider path.
Coordination with totp-auth.js: auth-gate.js sets window.__dc_049_handled
= true at IIFE entry. totp-auth.js's top-level ?auth=required check
reads that flag and skips its own UI when set — eliminates the flicker
in multi-provider installs. Single-provider installs still work because
the legacy code path is unchanged (auth-gate delegates to it).
Bundle order in build.js: auth-gate.js BEFORE totp-auth.js so the flag
is set in time.
Webpack-style bundle markers verified offline: __dc_049_handled,
auth-gate-email-input, provider-btn, _showAuthGate, totp_redirect all
present in dist/core.js (now 20 files, 248KB raw / 153KB min). New SW
cache hash dashcaddy-shell-680e230383 (was 743f9c17b0).
After every rebuild the freshly-baked dashboard bundle lives in
/opt/dashcaddy/status/dist/ + sw.js. DNS2 also serves files from
/var/www/dashcaddy-status/dist/ + sw.js (the original Windows-installer
mirror path). Without an explicit copy step between build and start.sh,
the served bundle stays on whatever hash was there before, while the API
responds with new code. That mismatch is what shows up in the dashboard
as "version unavailable" + "no data" widgets — saw it in the deploy
that followed DC-046/047 (fixed by a manual cp this time, never again).
Sync block runs before docker run:
cp /opt/dashcaddy/status/dist/*.js /var/www/dashcaddy-status/dist/
cp /opt/dashcaddy/status/sw.js /var/www/dashcaddy-status/
cp /opt/dashcaddy/status/index.html /var/www/dashcaddy-status/
All guarded so set -e doesn't kill the container start on a
single per-file failure (e.g. read-only mount, missing dir). Missing
source dir is a WARN + no-op rather than a fatal — fresh installs
without status/dist/ don't get a stale-bundle problem, just a log line.
Test: scripts/test-start-sh-sync.sh — 7 assertions across 4 cases
(fresh-copy, missing-source, idempotent-re-sync, set-e-survives-permission-
denied). All pass.
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.
Three-part fix for the silent data-loss failure mode that survives DC-039:
If SERVICES_FILE env was unset, platformPaths.dataDir resolved to /etc/dashcaddy
(image-layer path), and audit/license/error logs would silently land there and
vanish on every container recreate.
1. platform-paths.assertSafe({mode:'production'}) — throws FATAL on forbidden
zones (/app/src,routes,scripts,utils,managers,security + /etc/* + /usr + /var).
Bypassed with SKIP_DATA_DIR_GUARD=1.
2. server.js calls assertSafe() before any runtime work.
3. start.sh one-time migration: scans 6 known image-layer zombie paths,
copies non-empty content to bind mount with 'migrated-' prefix,
gated by sentinel file. Survives set -e per-file failures.
19/19 platform-paths tests + 5/5 shell migration tests.
Suite: 1066/1067 (1 pre-existing public-routes-drift failure from in-flight
auth refactor, untouched by this commit).
Verified live on DNS2: live audit log at /app/data/audit-log.json (315KB,
active) is unaffected; vestigial 2-byte /app/src/security/audit-log.json +
140KB /app/src/utils/error.log (pre-DC-039 era) will be recovered on next
container recreate.
Symptoms:
- Open 4-5 service tabs (Plex, Torrent, Radarr, etc.) + dashboard polling
- Each page-load fires Caddy forward_auth on every asset (HTML, JS, CSS, XHR)
- /api/v1/auth/gate/<service> counted each call against the 20/15min STRICT budget
- Within a minute or two of normal browsing, every gated service flips to 'down'
with statusCode 429, because Caddy bounces the 429 to a 'auth required' redirect
to status.sami
Fix:
- Split /auth/gate into its own limiter: 600/15min (40/min average) — comfortably
accommodates ~6 service tabs each polling every 15s
- Keep /auth/keys, /auth/jwt, /auth/app-token on the original 20/15min STRICT
(those actually mint credentials — gate just hands Caddy pre-existing auth)
- Same skip clause preserved: req.auth.type in {session, jwt, apikey} bypasses
the limit, so a properly-logged-in user never hits either limit
This is the same class of bug as the DC-044 / P21 health-check probe false
negative (probe chatter exhausting the auth budget). Adding to BACKLOG.
Previously, /api/v1/updates/available silently skipped any image on a
non-Docker-Hub registry (line 154 routing: 'ghcr.io/seerr-team/seerr'
has 3 slash-delimited segments → 'Custom registry not yet supported').
Symptoms: 4 of 6 production containers (seerr, albyhub, phoenixd,
velxio) all on ghcr.io. UpdateManager would log 'Custom registry not
yet supported: ghcr.io/...' and return null. Updates invisible in the
Updates modal, even when newer images existed.
Fix:
- Rewrite image parsing to detect the tag-vs-registry-host colon
correctly (lastColon > lastSlash guard, handles ghcr.io:443/path).
- Add getGhcrDigest() mirroring the DockerHub pattern, against
ghcr.io's OCI distribution endpoint. Same bearer-token auth flow,
the existing parseAuthHeader + authenticateAndGetDigest already
handle the WWW-Authenticate format ghcr.io returns.
- Multiple Accept headers for the response — Docker Hub used
manifest.v2 only; GHCR serves manifest.list.v2 for multi-arch tags
like ':latest', and the response is the multi-arch manifest itself
with the platform-specific digest in the Child header chain. We
use the digest from the 'docker-content-digest' response header,
which the GHCR endpoint sets even for manifest lists.
Verified: UpdateManager log now shows 'Found 6 updates available' on
DNS2 (previously 0-2, all from non-Docker-Hub images). /api/v1/updates/available
returns entries for seerr, albyhub, etc.
Per-tile Update button (core.js:811) + Updates modal Update/Update All
(features.js:1508 + L()) are already wired and now functional for all
registries.
Sami confirmed: the user's email IS their identity. No separate username
field at any point. One field, one identifier, no display-name collection
on first login.
Updated DC-047 ticket to lock this in.
Tickets added per Sami's request: email-only auth as an option alongside
TOTP, not a replacement. Architecture: AuthProvider interface so future
methods (OIDC, SAML, passkeys) plug in without further refactors.
Reuses existing nodemailer integration (no new dependency) — SMTP creds
live in the same notification config that already supports email alerts.
TDC-046 — refactor TOTP into one of N providers (foundation, ~1hr)
- DC-047 — EmailMagicLinkProvider via nodemailer (~3hrs)
- DC-048 — Multi-user bootstrap + admin invites (~2hrs)
- DC-049 — Login UI showing all enabled providers (~1hr)
Sami mentioned he wants to use the SMTP server his website (sami-ahmed.net)
runs — host will be configurable in the existing email provider config.
Documented as done in BACKLOG. Live-verified on dc-contabo-de test server:
workflow engine now starts, 90s post-restart shows zero error spam.
Combined with DC-044, workflows now actually execute end-to-end.
The bundled-workflows.js:310 call site used a non-existent .getState()
method AND forgot to await. The Promise short-circuited via '|| []' to an
empty array, so every health-check-on-interval workflow ran every 5 min
reporting 'Action health-check failed: servicesStateManager.getState is
not a function' while silently iterating over zero services. Visible on
both DNS2 (production) and dc-contabo-de (test server) — same code, same
bug, same log spam.
Fix: 'await servicesStateManager.read().catch(() => []) || []' — uses the
actual async method, returns empty array on read() failure (corrupt or
missing state file shouldn't break the workflow), preserves the original
short-circuit guard.
New regression test __tests__/bundled-workflows-health-check.test.js with
5 cases:
1. uses .read() not the non-existent .getState() — does not throw
2. returns checked/healthy counts from read() output
3. gracefully degrades if read() throws — empty services list, no crash
4. servicesStateManager absent on ctx → no crash, empty result
5. single service (non-template serviceId) path still works
Tests: 1219/1219 pass (1214 baseline + 5 new). ESLint: clean for the new
file. Test fixture note: had to clearInterval the constructor's
scheduledJobs so Jest could exit cleanly — scheduled workflows are not
under test here.
Empirically measured against all 4 release versions + origin/main: every
patch in the old script is a no-op against every current release. v1.14.4
(the version that originally needed patches) doesn't even ship src/ in the
tarball — the old script silently no-op'd on it because it couldn't find
files to patch, then the build crashed with MODULE_NOT_FOUND in production.
Repurposed as a verifier: 5 hard checks (server.js requires, license-manager
path, src/ tree presence, license-keygen.js at root, generic src/ require
path scan) + informational warnings. Exits 1 on ANY failure with a clear
'Build should be ABORTED' message naming the v1.14.4-class bug if relevant.
Old behaviour was 'patch and continue' (silently hid regressions); new
behaviour is 'fail loud' (every regression now produces a build abort).
Files changed:
- scripts/dashcaddy-post-deploy-patches.sh — rewritten as verifier (222→274
lines, header explains the empirical evidence + behaviour change)
- dashcaddy-api/scripts/test-dashcaddy-post-deploy-verifier.sh — new
regression test, 17 assertions across 10 scenarios (clean tree, missing
files, broken requires, empty src/, missing app.js, absolute path, etc.)
Empirical measurements documented:
- origin/main: 5/5 checks pass
- v1.14.9 (latest): 5/5 checks pass (0 patches applied under old script)
- v1.14.8: 5/5 checks pass (0 patches applied under old script)
- v1.14.4: 2/5 checks FAIL under new verifier (src/ missing, license-manager
in wrong location) — old script silently no-op'd on the same input
Tests: 1214/1214 Jest + 31 shell assertions. Lint: 150 warnings, all
pre-existing in untouched files (zero new warnings introduced).