Commit Graph
190 Commits
Author SHA1 Message Date
Hermes 46b6952c36 [glm-grade=A] fix(security): DC-120 surface caddy-source perimeter events in Log Insights dashboard — new GET /api/v1/security/events/perimeter endpoint with per-IP/per-vhost aggregations, event-store compileFilter/filterEvents primitives, and Perimeter section in Log Insights modal with XSS-safe rendering and stale-request guards
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-23 17:26:07 -07:00
Hermes 1744d1c86e fix(security): caddy-event self-noise conjunction filter for host curl probes (DC-118) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The host-side uptime watchdog and on-host cron jobs curl Caddy with a stock
curl/8.5.0 UA from loopback and the host tailscale IP — ~300 GET /api/health
401 warn-events/day burying real perimeter signal (census: 339+20 of 5000
access.log lines). Dropping on UA alone would blind the store to external
curl scanners, so generic tool UAs (curl/) are now dropped ONLY when the
source remote_ip is one of this host's own addresses (DASHCADDY_SELF_IPS,
default loopback; start.sh derives 127.0.0.1 + tailscale ip -4, empty-safe).
remote_ip (TCP peer) is used, never the spoofable client_ip. DashCaddy-*
probe UAs stay unconditionally dropped. 9 regression pins cover the full
conjunction matrix incl. external-IP+curl KEPT and spoofed-XFF KEPT.
2858/2858 green (128 suites).

Judge: glm-4.6@zai-coding-paas cold-read round 1 = A clean (0 blocking),
both polish notes folded. URN urn:ump:s2sgitfepze65crtp57dpdw4gk4w7upsoi4tqcvfwahcsicyepba
2026-08-23 15:53:58 -07:00
Hermes 2dce6dca5e fix(security): event-store retention + race + total-cap defects (DC-116) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- query().total: full-scan true count (was capped at offset+limit by an
  early break — dashboard 24h stat read ≤1000 vs real ~46k)
- trim trigger/curer mismatch: byte trigger + line curer never converged
  when avg line > ~524B (live avg 355B); now keeps maxDisk lines OR
  ≤80% byte budget, whichever retains fewer (TRIM_TARGET_FACTOR);
  budget env-overridable via SECURITY_EVENT_TRIM_BYTES / opts.trimSizeLimit
- trim/append race: trim renamed over the file mid-append losing events
  to the unlinked inode; now single-flight, queue-empty gated, holds the
  write lock, error paths always release + lazily re-kick (no hot loop)

Judge: glm-4.6@zai-coding-paas adversarial cold-read, grade B clean
(0 blocking, 4 polish — 2 folded, 1 already-satisfied, 1 deferred as
judge-endorsed safer). 127 suites / 2849 tests green.
2026-08-23 15:02:00 -07:00
Hermes 65457ff8e0 feat(security): activate caddy security-event pipeline — bounded first-start replay, self-noise filter, host fidelity (DC-113) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The caddy tail worker (DC-112) was 100% dead in prod: no /var/log/caddy
mount, no CADDY_ACCESS_LOG env, no global access log in the Caddyfile.
Store census 45,912 events, 100% source_type 'api', ZERO 'caddy'.

- createTail: firstStartMaxBytes (5 MiB) bounds first-ever-start replay
  against a long-lived access.log; multi-chunk partial-line discard on
  the jump; normal restarts resume at exact persisted offset (judge r1
  fix-first fold)
- worker: self-noise filter drops our own probe UAs
  (DashCaddy-Probe/1.0, DashCaddy-HealthCheck/1.0) from the derived
  store — raw log keeps everything; ~50-100 events/min of probe noise
  would otherwise bury perimeter signal in the 100k-cap store
- worker: metadata.host reads request.host (real caddy JSON nests it;
  verified against live /var/log/caddy/seeds.log — the DC-112 read was
  always null on live lines); top-level fallback kept
- worker: onAppear recovery log (DC-112 judge polish fold)
- start.sh: -v /var/log/caddy:/var/log/caddy:ro + CADDY_ACCESS_LOG env
- README: dead caddy-api/ dir refs -> dashcaddy-api/ (queue item e)
- tests: +12 DC-113 pins (hermetic, real worker + store); DC-112
  fixtures corrected to the real nested request.host shape

126 suites / 2841 tests green. Judge: GLM-5.3 round-1 B, round-2 A
(SHIP). Verdict urn:ump:mccln523fptotuvrmddlqpf4zpkrxf273tg4kyytuyy3776rj3pq
2026-08-23 12:40:32 -07:00
Hermes a29a59a320 fix(security): name caddy-worker SSO gate events + dead-path visibility (DC-112) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Queue item (g) — same defect class as DC-111 defect 1, different writer:
the caddy access-log tail named every event http.<status>, so forward_auth
gate hits were unanswerable in the unified security event store.

- resolveCaddyAction() mirrors audit-logger ACTION_MAP vocabulary for BOTH
  URI shapes (legacy /api/auth/gate/<id> from Caddyfile line 87, canonical
  /api/v1/... from dashboard JS): auth.credential-injection,
  auth.app-token-issue, auth.sso-exchange (exact-path match, boundary-tested)
- severity escalation now covers the legacy /api/auth/ prefix too
- metadata fidelity: caddy logs headers as ARRAYS — old single-value read
  always produced user_agent:null; added metadata.host (vhost) and
  duration_seconds (caddy logs seconds; duration_ms kept, no consumers)
- dead-path visibility: once-per-process warn when the access log is
  missing. Live discovery: prod store has 45,912 events, 100%
  source_type 'api', ZERO 'caddy' — the container has no /var/log/caddy
  mount, so the worker silently no-ops. Infra wiring queued separately.
- new __tests__/caddy-worker-naming-dc112.test.js: 22 pins (real tail +
  real store, hermetic sinks, both URI shapes, boundary rows, offset
  persistence, once-warn). Full suite 125/2831 green.

Judge: GLM-5.3 cold read round-1 A (deleg_65498358, 3 polish items folded)
+ round-2 A (deleg_eed9dcbf, judge re-ran suite itself).
URN urn:ump:mjbwfcw6z7budx45s2rutovw5fatpp4jyzzspo7tq6nzajayulmq
2026-08-23 09:37:18 -07:00
Hermes 56b807543c fix(audit): restore named audit actions for SSO gate traffic — 3 live defects (DC-111) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
1. audit-logger middleware computed action/resource from req.path INSIDE
   the res.json override — after the /api/v1 router rebased req.url to the
   router-relative path. resolveAction fell through ACTION_MAP for every
   HTTP request, producing 45,899 'unknown.get' entries since 2026-07-14.
   Fix: snapshot req.path/req.method at app-level (post-shim, pre-router).
2. DC-044 shim double-prefixed ALREADY-canonical /api/v1/auth/gate|x
   into /api/v1/v1/... → 401 for canonical-URI clients. Fix: rewrite only
   legacy /api/auth/* shapes; canonical pass through untouched.
3. event-store VALID_OUTCOMES lacked 'failure' → every failed API action's
   security event was rejected+dropped from security-events.jsonl. Fix: add
   'failure' to the vocabulary set.

8 regression pins over a faithful shim→audit→router mount mirror.
Suite: 124 suites / 2809 tests green.
Judge: GLM-5.3 cold read round-1 A/ship, URN urn:ump:ywv6rpmx55tlxbgc7ciyxqfcmx2v6q756cjbmif2d66k4bwwn6ya
2026-08-23 09:05:27 -07:00
Hermes 0721b1cb04 fix(security): audit-logger PII masking parity with unified logger (DC-110) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
audit-logger.js (StateManager write path into audit-log.json) starred only
6 sensitive keys at the middleware layer; email-bearing resource paths
(/invites/<email>/accept), DC-048 details.userEmail attribution, and emails
in non-sensitive body keys landed RAW — while the parallel unified-logger
path has masked at every sink since DC-095. DC-110 closes the parity gap
using the SAME canonical primitives (sa****@example.com): log() masks
resource (maskEmailsInString) and deep-masks details (maskEmails) at the
single write-point, covering middleware AND direct route calls. The
security-event mirror now uses the masked entry.resource for target/message
(judge round-1 fix-first: the raw parameter leaked emails into
security-events.jsonl). logging.js exports maskEmails (export-only).
maskEmails clones — caller details objects are never mutated.

Judge: GLM-5.3 cold read (standing Sami authorization 2026-08-17; Codex
quota dead until 2026-08-29). Round 1 (deleg_ebb83285) C fix-first —
caught the event-store mirror leak. Round 2 (deleg_923f9076) after
in-commit fix + mirror test: grade A, ship. Verdict URN
urn:ump:mwtxoj6dbdq7bfeba3l2am2rgx34zjimcjde5f6sxz6tozsjbskq (GET
readback verified: grade A, topic codex-judge-verdict). Deferred
(judge-accepted): one-time scrub of historical raw-email lines in the
live 16MB security-events.jsonl — queued follow-up.

Tests: 123 suites / 2801 green (+6 DC-110 pins: resource+details mask,
non-mutation, middleware e2e with *** survival, idempotence, no-email
regression, masked mirror target/message).
2026-08-23 08:01:29 -07:00
Hermes 9322831f1b fix(security): quoted local-part email mask — strip delimiter quotes, split on last @ (DC-109) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-23 07:28:10 -07:00
Hermes c429b8fdd7 fix(security): redact-on-rotate for error.log archive + README PII docs (DC-108) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
DC-095 masks emails at every live log sink; the rotated archive was the
remaining belt-and-braces gap — any future sink that forgets masking would
persist raw PII in error.log.1 for a full rotation cycle. appendErrorLog
now scrubs the freshly rotated archive with the SAME canonical mask
(sa****@example.com) via atomic rewrite (sibling .redact-<pid> temp, wx
open preserving mode else 0600, fsync, rename). Scrub failure is caught
and logged; the new error line is still appended. Stale crash-leftover
.redact-<pid> temps are swept best-effort on every rotation. Also
documents scripts/redact-log-pii.js usage in README (queue item e).

Judge: GLM-5.3 cold read (deleg_e7cd7f8c), round-1 grade B ship — both
nits addressed in-commit: stale-temp sweep (new 5th test pins it),
quoted-local-part mask edge deferred as pre-existing DC-095 primitive.

Tests: 122 suites / 2790 green (+5 DC-108 pins; was 2785 post-DC-107).
2026-08-23 05:22:04 -07:00
Hermes 5efacd11e8 fix(security): close rotateEncryptionKey crash window with in-process key rollback (DC-107) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
If the atomicWriteJSON of rotated credentials failed after rotateKey() had
already persisted+cached the new key, the on-disk key could no longer decrypt
the on-disk credentials.json (permanent loss on restart). Catch-path now
restores the old key via new cryptoUtils.restoreKey() (canonical atomic-write,
0600, hex-validated) while still holding the proper-lockfile. Double-failure
(rollback throws) is contained and the lock is still released. Hard-crash
mid-rollback is covered by the existing .bak startup fallback.

Judge: GLM-4.6 stand-in, round-1 grade A, 0 blocking, 1 LOW polish (folded).
URN urn:ump:z2sz3x6abtcffpqyde2l2ssq34vy47t5h2gbmkr3wtmfxwkerinq
Tests: 121 suites / 2785 green (6 consecutive runs pre-fold; suite re-run post-fold).
2026-08-23 04:40:41 -07:00
Hermes eb2bab7a96 refactor(persistence): migrate credential-manager to canonical atomic-write util (DC-106) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
All three writeFileSync sites in the encrypted-credentials manager now
delegate to src/utils/atomic-write.js atomicWriteJSON — rotateEncryptionKey
save, _ensureFileExists bootstrap, and the _lockedUpdate commit under the
proper-lockfile lock. A crash can no longer tear credentials.json
mid-write: power loss through the old path could leave an empty/short
file and silently drop every stored credential (DNS provider tokens etc).
Lock-safety pre-study: proper-lockfile only stats its own sibling .lock
dir, never the target file, so the rename swap cannot trip ECOMPROMISED.

Test mock extended to the fd-level fs API (openSync/writeSync/
fsyncSync/closeSync/renameSync + fdMap/closedTmp state) so the canonical
path is exercised end-to-end under the mock; write assertions moved from
writeFileSync.mock.calls to destination-state reads. New DC-106 pins:
wx+0600+fsync+rename discipline, plaintext-secret canary never on disk,
fd-lifecycle order fsync->close->rename via invocationCallOrder, and
atomic _ensureFileExists create at 0600. Eighth store migrated
(DC-099..DC-105 preceded).

Judge: GLM-5.3 cold read, round-1 B/ship (deleg_b3c038c2).
URN urn:ump:vscequdet7wt5un7jhtl2nlg6cfkabsbazy5m2tjnyyguu5wstxa
(readback verified: grade B, topic codex-judge-verdict).
Sole finding is pre-existing and non-blocking: rotateEncryptionKey
persists the new key before writing rotated creds (crash window) —
queued as DC-107 follow-up.
Full suite: 121 suites / 2783 tests green.
2026-08-23 03:06:58 -07:00
Hermes b1464d9b85 refactor(persistence): migrate caddy-upstream-watcher state to canonical atomic-write util (DC-105) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
_saveState drops its private fixed-name .tmp + writeFileSync (no fsync)
copy and delegates to src/utils/atomic-write.js atomicWriteJSON — same
wx/fsync/rename/dir-fsync discipline as the six previously migrated
stores (DC-099..DC-104). A crash can no longer tear
caddy-upstreams.json (mute list + probe state): power loss through the
old path could leave an empty/short state file and silently drop every
mute; concurrent saves (60s probe loop vs setMuted) collided on the
shared tmp name.

Test mock extended with the fd-level fs API (openSync/writeSync/
fsyncSync/closeSync/unlinkSync + closedTmp stash) so the canonical
path is exercised under the existing file-wide fs mock; fsState renamed
mockFsState (jest.mock out-of-scope-variable hoist rule). New DC-105
pin: wx+fsync+rename required, no fixed .tmp, zero leftover tmp files,
destination JSON complete with mute preserved.

Judge: GLM-5.3 cold read, round-1 A/ship (deleg_5f57fcce).
URN urn:ump:iuhgj3ajr5llshjasttsvelnptv6iqaek6xji5l3klcl72nseqdq
Full suite: 121 suites / 2779 tests green.
2026-08-23 02:31:16 -07:00
Hermes 6f8fac142f refactor(persistence): migrate fulfillment-store to canonical atomic-write util (DC-103) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
writeState() drops its private tmp+writeFileSync+rename copy (no fsync,
Date.now() tmp names, best-effort chmod) and delegates to
src/utils/atomic-write.js atomicWriteJSON (exclusive-create tmp, fsync,
rename, parent-dir fsync, 0600). A torn stripe-fulfillments.json could
previously make a webhook retry mint a SECOND valid license key for an
order that already has one. Both consumers (routes/billing.js,
scripts/stripe-license-bridge.js) JSON.parse only - trailing-newline
drop in the canonical serializer is harmless. Unused crypto require
removed. +2 DC-103 pins: full lifecycle 0600/complete/zero-leftovers,
and dual-instance interleaved writes (bridge+API file-IPC) with no tmp
collisions. 121 suites / 2776 tests green.

Judge: GLM-5.3 round-1 A (deleg_bd49a97b), URN urn:ump:layk7h326sqymcsh6tiusrs2sdbqdcx6ygvcuwwoxqn4rf7ycoua
2026-08-23 01:21:28 -07:00
Hermes 521b2f24a1 refactor(persistence): migrate share-store to canonical atomic-write util (DC-102) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Drops share-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js:
fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync,
cleanup-on-failure. Also fixes a latent bug in the same file: .share-secret
signing-key persistence used plain fs.writeFileSync — a torn write would
silently rotate the HMAC key on next boot, invalidating every outstanding
share signature (links 404, no error logged). Now atomicWriteFile.
Sole consumers parse JSON / trim(), so the dropped trailing newline on
shares.json is unobservable. +2 regression tests pin 0600 on both files,
complete content, zero tmp leftovers, and HMAC-still-verifies via getRaw.
Judge: GLM-5.3 cold read, round-1 A, deleg_50d5c239 (41s, 4 calls).
Verdict: urn:ump:ehblegyr5eko4hgyfylnrqk72hh3crmc43o2yt5ragfgvyc4zrdq
Full suite: 121 suites / 2774 tests green.
2026-08-23 00:50:47 -07:00
Hermes 09d56fde2c refactor(persistence): migrate user-store to canonical atomic-write util (DC-101) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Drops user-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js
(DC-099 canonical: fsync'd same-dir exclusive-create 0600 tmp -> rename ->
parent-dir fsync, cleanup-on-failure). All 3 persisted files routed:
users.json, authorized-users.json, .bootstrapped sentinel. Sole consumers
are JSON.parse readers — dropped trailing newline unobservable (judge
verified repo-wide). +2 store-level regression tests pin 0600 / complete
JSON / no temp leftovers across all three files, incl. the bootstrap path
writing three files back-to-back in one login.
Judge: GLM-5.3 cold read, round-1 A, deleg_f632f05c.
Verdict: urn:ump:5fivvveqhcbkl6os4dhidchkvjjzbvi7rgj6znaovp6bfnvmcmsq
Full suite: 121 suites / 2772 tests green.
2026-08-23 00:21:41 -07:00
Hermes 3742e2658d refactor(persistence): migrate invite-store to canonical atomic-write util (DC-100) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Drops invite-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js:
fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync,
cleanup-on-failure. Sole format consumer is a JSON.parse reader, so the
dropped trailing newline is unobservable. +2 store-level regression tests
pin 0600 / complete JSON / no temp leftovers under burst mutations.
Judge: GLM-5.3 cold read, round-1 A, deleg_7177d506.
Verdict: urn:ump:4kwenywtf3nx2mokobvuzrhklk2xigmpyea6nvqdyhv7icnflpkq
Full suite: 121 suites / 2770 tests green.
2026-08-22 23:54:37 -07:00
Hermes 80a82c4cae refactor(persistence): canonical atomic file writer + notifications.json crash-safety (DC-099) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- src/utils/atomic-write.js: single shared tmp+fsync+rename writer
  (exclusive-create 0600, unique tmp names, cleanup-on-failure,
  best-effort parent-dir fsync after rename for swap durability)
- notification-manager: both write paths (load-time canonicalization
  write-back + saveConfig) converted from plain writeFileSync — a
  crash mid-write can no longer truncate notifications.json
- DC-097/098 test seams migrated to the atomic path; new suite pins
  syscall discipline (order, wx flags, tmp naming, error cleanup,
  dir-fsync swallow)
- 121 suites / 2768 tests green
- Judge: GLM-5.3 cold read grade B/ship (deleg_23f7abad); polish items
  folded: dir-fsync added, header copy-count corrected. Remaining:
  migrate invite/user/share-store private _atomicWriteJSON copies as
  they are touched (queued).

URN: pending (recorded post-commit)
2026-08-22 23:28:36 -07:00
Hermes 8d42eae6ac feat(maintenance): one-shot PII redaction tool for pre-DC-095 log files (DC-098) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- scripts/redact-log-pii.js: atomic in-place email redaction reusing the
  canonical DC-095 masker (no second regex), dry-run/keep-raw modes,
  dir walk with skip-set, post-verify (exit 2 if raw addresses remain).
- src/utils/logging.js: export EMAIL_RE/maskEmailAddress/maskEmailsInString
  (additive; no logger behavior change).
- __tests__/redact-log-pii.test.js: 11 tests (shape, idempotence,
  clean-untouched, dry-run, keep-raw, skip-set, passthroughs, exit codes).
- Judge: GLM-5.3 cold read, round-1 A/ship (deleg_4e684a18), URN
  urn:ump:7y2q5upoht7xq2mhlum764y2h36qpsgufyqsgx4cbijfpmfpmrcq.
- Suite: 120/120 suites, 2751 tests green.
2026-08-22 22:24:38 -07:00
Hermes 3ccd00d1a1 fix(notifications): persist canonicalized config on load — legacy keys no longer stale on disk (DC-097) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
_loadConfig canonicalized legacy spellings in memory only (DC-092); the
on-disk notifications.json kept email user/pass, camelCase event keys and
string secure until the next explicit UI save — i.e. forever on installs
that never open the settings page.

- _persistCanonicalForm(): after the defaults merge, re-serialize and write
  back only when the bytes differ; idempotent on subsequent loads.
- Best-effort: write failures (read-only mount, EACCES) warn and continue —
  the in-memory config is already correct; constructor never throws.
- No secrets in new log lines; JSON.stringify(this.config) same as saveConfig.

Judge notes (non-blocking, GLM-5.3 cold read): unknown top-level keys are
now dropped from disk at boot (pre-existing merge-drop semantics, previously
deferred to next UI save); write is non-atomic, matching saveConfig.
Verdict: urn:ump:rchawiu427idev5mrettlxkyw2rcnwqpegiolqmzbc5ygc277u5q

Tests: +5 (__tests__/notification-config-writeback-dc097.test.js) —
canonical rewrite, idempotence, clean-file-untouched, EACCES no-throw,
fresh-install no-write. Full suite 119 suites / 2740 tests green.
2026-08-22 21:52:19 -07:00
Hermes bb59595d6d fix(config): monitoring.public gate was dead 4 ways — live gate + schema + dedupe (DC-096) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The documented hardening option for exposed deploys (monitoring: {public: false}
in config.json / MONITORING_PUBLIC env) never worked:
1. applyConfigFields dropped the monitoring key entirely
2. monitoring missing from config-schema KNOWN_KEYS (Unknown-key warnings)
3. MONITORING_PUBLIC frozen at mount + re-required singleton instead of injected dep
4. PUBLIC_ROUTES had unconditional duplicate entries defeating the gated spread

- site.js: copy monitoring through; drop dead write-only siteConfig.caName
- config-schema: +monitoring key, validateMonitoring (public must be boolean);
  remove never-written typo-footgun keys setupCompleted/setupMode (git -S: zero writers ever)
- middleware: live isMonitoringPublic() (env > config > default public), per-request
  gate via monitoring:true flag, remove duplicate unconditional route entries
- default unchanged (endpoints stay public — System Overview widget)

Tests: +11 (__tests__/monitoring-public-gate-dc096.test.js); suite 118/2735 green.
Judge: GLM-5.3 cold-read A (deleg_98aba845), URN urn:ump:zgvtskqljurasdagc632atnybb2p6gakk4cxcmjd3i4ivy7rvwta
2026-08-22 21:35:51 -07:00
Hermes 83ef84d218 feat(logging): central email PII masking across all log sinks [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
DC-095: mask email addresses at every logger output choke point so raw
PII never reaches stdout/stderr, error.log, or audit-log.json regardless
of what a call site interpolates — msg strings, data payloads, error
messages/stacks, audit details, and error.log request lines (path/UA).

- Masked shape sa****@domain matches AuthProvider.maskEmail (UI-consistent)
- Bounded-quantifier regex: local {1,64} (incl. quoted local-parts),
  domain {0,253}, TLD {2,24} — adversarial 40KB string 3.3s -> 17ms,
  hostnames/versions/docker-refs untouched, idempotent under re-mask
- memo-Map recursion: DAG shared references get the same masked clone
  (WeakSet seen-guard leaked the raw original on 2nd reference); cycles
  resolve to in-progress clone
- Non-plain objects with own enumerable props cloned proto-preserving
  (Object.create) so class-instance email fields are masked; Date/RegExp
  pass through
- sanitize(): audit details mask email substrings in non-sensitive keys
  (invite/auth POST bodies no longer land raw in audit-log.json)
- 18-test suite covers sinks + adversarial judge findings (ReDoS timing,
  DAG, quoted locals, instances, request-line path/UA)

Judge: GLM-5.3 cold-read stand-in (Codex quota-dead until 2026-08-24,
substitution authorized by Sami 2026-08-17). Rounds C -> C -> A.
Verdict: urn:ump:zorj7vcrnw2t2jhhcp2g6wz4simvhyjdqwu2mjsifxlkzb2dwjmq
Suite: 117 suites / 2724 tests green.
2026-08-22 20:49:09 -07:00
Hermes 97672f7e74 [glm-grade=A] fix(notifications): un-gate 7 dead emitters + repair legacy 4-arg send shape (DC-094)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Two silent-death defects in one class:

1. Seven emitters absent from DEFAULT events, so the send() gate
   (config.events[canonical] !== true) dropped them on every install:
   ssl-cert-expiry, dns-propagation, drift-detected,
   dependency-restart-complete/-failed, recipeRemoved, workflow.
   All now default ON; dependency-restart spellings fold onto one
   canonical toggle; recipeRemoved aliases to recipe-removed in both
   the manager and route alias maps.

2. Nine call sites used a legacy 4-arg send(event, title, message, type)
   against the 3-arg signature: the message string landed in the type
   slot (Discord embed color fell back to info-blue, history.type wrong)
   and providers received the TITLE as the body - deploy-failure
   notifications carried no error text at all. Fixed at source (9 sites)
   plus a type-guarded shim in send() for external legacy callers.

Also: explicit data.title now flows to ntfy Title header, email subject,
Discord embed title, and history; settings UI gains 9 event toggles
(separate Backup Complete/Failed) with defaults-on semantics.

Tests: +24 (new DC-094 suite: defaults, gate pass-through, alias folding
send-time and load-time, stored-config inheritance, shim body/title/
color/subject/history, type-guard, 3-arg no-regression); 4 assertions in
bundled-workflows-health-check updated from the old 4-arg mock contract
to the canonical shape (same behavior asserted). Full suite 116 suites /
2706 tests green.

Judge: GLM-5.3 cold read via delegate_task (deleg_8a7cedd0), grade A,
zero blockers; 2 polish items (Backups toggle conflation, shim
type-guard) folded into this commit. Verdict URN:
urn:ump:uyipjjwdjqjy3alvceqxvlucrymd7hnsben5udpp2bqycoh3l2la
2026-08-22 19:42:25 -07:00
Hermes 7e4ee60dcf [glm-grade=B] fix(notifications): repair UI/API contract drift — SMTP auth, event gate, test button (DC-092)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:

1. SMTP auth never applied: the UI sent email.user/email.pass while the
   manager read username/password. Route now normalizes aliases; legacy
   config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
   the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
   folds every known spelling (manager gate, route store, legacy files);
   UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
   deploy-failed/auto-restart were missing from DEFAULT events, and
   send('test') was itself gated -> the Test button was a no-op. Defaults
   added; 'test' bypasses the gate.

Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.

Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).

Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
2026-08-22 18:17:14 -07:00
Hermes 5e60c27f2b [grade=A] Harden server-managed license renewals
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 18:15:37 -07:00
Hermes ddbea0a040 [glm-grade=A] fix(config): teach schema KNOWN_KEYS the licenseBackup/_version writer keys (DC-091)
Every startup logged two false-positive 'Unknown config key — possible typo?'
warns: licenseBackup (written by src/managers/license-manager.js:510 activation
persistence) and _version (stamped by src/config/migrations.js). Both are
first-party writers the validator was never taught about (DC-091).

- config-schema.js: add both keys to KNOWN_KEYS with a source-of-writes comment
- config-schema.test.js (new): 5 regression tests — live production config keyset
  validates with zero unknown-key warnings, writer keys never warn, genuine
  typos still warn (exact string), license/licenseBackup sync guard, _version
  recognized at every migration value

Verified: full jest suite 111 suites / 2621 tests green (baseline 110/2616).
Warns reproduced in live container logs 2026-08-22T23:53:54Z; live config.json
contains both keys (licenseBackup activation, _version 2).

Judge: GLM-5.3 cold read via delegate_task (deleg_30e52384, 36s) — grade A, ship.
Verdict URN: urn:ump:ermkvz6ifbp5svga5cnapv5jhm7c5b7qdbwjjerfpxbwcrolm2za (readback verified)
Codex quota-walled until 2026-08-29; GLM stand-in per Sami 2026-08-17 directive.
2026-08-22 17:22:13 -07:00
Hermes 88f1d4a414 [glm-grade=A] fix(monitoring): DC-090 outage incidents follow displayed hysteresis status
checkForIncidents compared raw probe transitions while the dashboard badge
(DC-086) follows post-hysteresis displayed status. A single raw down blip
between two ups opened AND resolved a critical outage incident; a suppressed
up blip during a real outage resolved it early. Incidents now open/resolve
on displayed-vs-displayed transitions; previousDisplayed=null keeps legacy
raw semantics for direct callers. 6 new parity tests + legacy checkService
test moved to a 4-probe chain. Suite 2616/2616 (110).

Verdict: urn:ump:yc5rdlnmnmhch5audc5fifgbt6d7moi2stqfs5vidsbh6x6zkvgq
2026-08-22 16:52:53 -07:00
Hermes 3ccf66754a [glm-grade=B] fix(monitoring): DC-088 removeService generation tombstones + incident closure
- serviceGenerations no longer leaks entries: removeService deletes the live
  entry and records a TTL'd (10min) tombstone swept by cleanupHistory
- monotonic instance-wide generationSeq prevents generation reuse across
  remove->re-add cycles (ABA) and supersedes tombstones on re-configure
- _isStaleCapture(): presence-aware stale check — live entry must match
  exactly; no entry is stale only under a higher-generation tombstone
  (preserves correct behavior for disk-loaded never-configured services)
- catch path increments consecutiveFailures only after the stale check, so
  a late-rejected probe cannot resurrect state for a removed service
- open incidents for a removed service close via the standard resolve path
  (resolvedBy=service-removed, WS/SSE incident-resolved broadcast)
- 6 regression tests; full suite 2608/2608 green

Judge: GLM-5.3 cold read (Codex stand-in), verdict B/ship, zero blockers
2026-08-22 15:55:26 -07:00
Hermes 628bbe32f6 [glm-grade=A] fix(monitoring): DC-086 round-2 — probe/config race hardening + env parse + incident compare
Round-2 folds the judge-round fixes into DC-086:

- serviceGenerations map: checkService captures the config generation at
  entry and re-validates it before ANY state write (success + error
  paths). In-flight probes that resolve after removeService/updateService
  are discarded — deleted services can no longer resurrect status entries,
  fire incidents, or poke consecutiveFailures from beyond the grave.
- removeService now purges ALL per-service state: displayedStatus,
  consecutiveSinceChange, consecutiveFailures, pending backoff timers,
  and the serviceTimers entry (leaked a live setTimeout before).
- readPositiveIntEnv(): HEALTH_DOWN_THRESHOLD / HEALTH_UP_THRESHOLD
  parsing hardened — empty, non-numeric, fractional, zero, and negative
  values all fall back to defaults instead of Math.max(1, NaN)=NaN.
- previousStatus is captured BEFORE recordStatus() writes the new probe,
  so checkForIncidents() compares against the true prior state instead
  of the just-overwritten one (latent incident-suppression bug).
- Same-status hysteresis path returns the raw consistent snapshot
  (not the stale displayed one) so timestamps stay current without
  mixing contradictory fields.
- Tests: +14 (86 total across the two suites). New coverage: streak
  reset on agreement, malformed env fallbacks (each.of not-a-number/0/
  -2/1.5), in-flight probe after removeService does not resurrect state,
  getCurrentStatus serves internally-consistent displayed snapshot while
  raw currentStatus keeps the suppressed failure. Full suite 2601/2601.

Judge: GLM-5.3 cold-read via delegate_task (deleg_c9fd5900 task-0),
grade A round 1, zero blocking issues. Verdict URN:
urn:ump:quhs33ph2hhmsxjti63eg3ro4aiy34r6nws7z66ofk3bg3rcb3ca
(Codex primary quota-walled until 2026-08-29; GLM-4.6 direct 401;
stand-in chain per codex-as-judge SKILL.md, Sami 2026-08-17.)
2026-08-22 14:23:25 -07:00
Hermes f8b99f9b5a DC-086 service-status flicker fix — asymmetric hysteresis
Dashboard badges perpetually flip green/red for a few seconds at a time,
never stable. Root cause: health-checker emitted 'status-check' on every
probe (every 30s) and dashboard-ws forwarded every one as 'status-change'
to the browser with no diff; live-events.js then unconditionally called
setBadge(). A single transient 5xx (Caddy reload, container restart, TLS
handshake blip) flipped the badge and the next green probe flipped back.

Fix: _computeDisplayedStatus applies asymmetric hysteresis — DOWN_THRESHOLD
(default 2, env-tunable HEALTH_DOWN_THRESHOLD) consecutive probes that
disagree with the displayed 'up' state flip to red; UP_THRESHOLD (default
1, HEALTH_UP_THRESHOLD) flips back to green. History and consecutiveFailures
still record every raw probe so postmortem analysis is unchanged. Only the
SSE broadcast is filtered. getCurrentStatus now returns the displayed
status so a page reload shows the same badge as the live stream.

10 new tests cover first-emit, same-status-dedup, the actual flicker bug
(one-down-then-up keeps green), two-down flips red, one-up recovers fast,
long-steady-green produces exactly one emit, and env-var tuning. All 63
existing health-checker tests still pass. Full suite: 2484/2484.
2026-08-22 06:14:48 -07:00
Hermes 84edb035e3 [grade=B] feat(auth): onboard missing credentials into encrypted vault
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 05:41:38 -07:00
Hermes 7e68955e66 [glm-grade=A] fix(share): public-endpoint input hardening + rate limit (DC-083)
Public share endpoints accept untrusted fields. Pre-fix code used bare
type checks (email.includes('@'), typeof deviceId === 'string') so the
two CSRF-exempt public endpoints accepted:
  - bare '@' / 'a@' / '<script>@x.c'
  - 10MB email strings (data/shares.json bloat)
  - CR/LF/NUL in email (corrupts on-disk JSON + log lines)
  - CR/LF/NUL in deviceId (flows into Tailscale auth-key description)

Hardening (5 files, +661 net):

1. routes/share.js + src/security/share-store.js: shared validators
   - validatePublicEmail(raw): charset (a-z0-9._%+-@), 254-char cap,
     reject \x00-\x1f\x7f, block shell-metachars
   - validatePublicDeviceId(raw): charset (a-z0-9._:-), 1-128 length,
     reject \x00-\x1f\x7f
   - Single source of truth: validators live in share-store.js, exported,
     imported by routes/share.js (drift-eliminated)

2. Routes that were 'email.includes(@)' now use validator. Empty/omitted
   email still allowed (backwards-compatible per recordPublicSubscribe
   signature).

3. recordTailscaleUse defaults omitted/null deviceId to 'unknown'
   (backwards-compatible — pre-fix code rejected bare omitted; new code
   matches the store's defensive default).

4. constants.js: RATE_LIMITS.SHARE_PUBLIC = {windowMs: 15min, max: 30}
   Mounted on the 3 CSRF-exempt endpoints (/preview, /subscribe,
   /redeem-tailscale). 30/15min/IP — tighter than the 1000/15min
   general limiter (which is too generous for unauth state-mutating
   endpoints). Falls back to no-op in test envs.

5. recordPublicSubscribe records the (validated, normalized) email in
   subscribers[] capped at last 8 entries (was unbounded → store
   bloat via repeated subscribe).

Test coverage (38 new tests in __tests__/share-dc083.routes.test.js + 3
in __tests__/share-routes.test.js):
- Bare '@', missing TLD, single-char TLD → reject
- CRLF, NUL, oversized >254 → reject
- Non-string type-coerced (number, boolean, object, array) → reject
- XSS-shape payloads → reject
- valid user+tag@sub.domain.io + nodekey:... → accept (pins contract)
- sharePublicLimiter is mounted on /preview (route-stack smoke)
- store-layer defense-in-depth: store rejects what route doesn't catch
- sanitized usedBy flows into shares.json
- rejection does NOT mark share used
- subscriber array bounded at 8 entries

Test results:
- 68/68 share-related tests pass (30 share-routes + 38 share-dc083)
- Full repo: 2427/2427 tests pass
- npx eslint: 0 errors, 22 warnings (baseline HEAD =14; +8 in test mocks)

Judge verdict: GLM-5.3 round-2 grade A. Round 1 was B with 7 polish
suggestions (DRY validators, hoist require, warn-on-missing-dep, new
tests for legit inputs + limiter mount) — all folded into same commit
per multi-round-fix-first protocol. Zero blocking issues.

Threat model: the 2 POST endpoints mutate shares.json + Tailscale auth
descriptions. Pre-fix was effectively 'input trust boundary = NONE'.
Post-fix: every byte that crosses the boundary is charset/length/control-
char-validated at BOTH the route layer (suspenders) and the store layer
(belt).
2026-08-19 00:10:37 -07:00
Hermes 089f5d2902 [glm-grade=A] fix(update-manager): compose-prefixed image names probe <project>/<service> not library/<project>-<service> (DC-082)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Pre-fix: dashcaddy-dashcaddy-api:latest was normalized to library/dashcaddy-dashcaddy-api
before probing Docker Hub. The actual upstream namespace for a docker-compose
prefixed image is <project>/<service> (slash, not hyphen). Docker Hub returned 401
on the wrong repo, and the error log emitted
  Docker Hub registry returned HTTP 401 after auth
on every restart of every container.

Fix:
1. _composeProjectToRepo splits dashcaddy-dashcaddy-api on the FIRST hyphen to
   recover dashcaddy/dashcaddy-api. Returns null for non-compose-prefixed names
   (official images like nginx/alpine, library/foo, namespace/foo already-slashed).
2. _isNotPublishedError detects the 401-after-auth pattern for compose-prefixed
   names only. Steady-state for locally-built images that aren't published.
3. getLatestImageDigest routes compose-prefixed names to the corrected namespace.
   Routes already-namespaced names directly. Falls back to library/ for the
   Official Image path.
4. Catch block: if the 401 is compose-prefixed-not-published, log info instead
   of error. Real auth failures on legitimate images still log as error.

17/17 tests pass in 1.27s. Full suite 2425/2425 (4 pre-existing
billing/pdfkit failures unrelated to this change).

GLM stand-in verdict URN: urn:ump:7rhk7keukv3zrx654gbckxaycnuwm4agduf37creqbsoauszopoa
2026-08-18 19:45:08 -07:00
Hermes a4e4b24732 fix(update-manager): force IPv4 + per-request timeout + transient-only retry on registry digest probes (DC-078) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The per-hour checkForUpdates() loop called Docker Hub / ghcr.io without
family:4, without a hard request timeout, and without retry on transient
network errors. On DNS2 (Technitium at 100.121.150.22 returns AAAA records
even when IPv6 routing to public registries is intermittently broken), every
container check surfaced AggregateError [ETIMEDOUT] in error.log with stack
`at internalConnectMultiple (node:net:1114:18)`. The dual-stack DNS race
consumed the default 30s connect timeout per unreachable IPv6 family before
falling back to IPv4 — 30s+ per container per check cycle.

Three reliability properties added via shared fetchWithReliability() helper:
1. family:4 — IPv4-only DNS lookup. Avoids the dual-stack race entirely.
2. Hard per-request timeout (10s) — caps total latency per attempt.
3. Retry on transient codes only (ETIMEDOUT/ENOTFOUND/ENETUNREACH/...) — HTTP
   4xx/5xx are surfaced as real responses, not retried.

The 401 → WWW-Authenticate → token → Bearer auth flow is now explicit in
getDockerHubDigest (was previously a side effect of authenticateAndGetDigest,
which has been removed — no remaining callers).

Verified end-to-end against real Docker Hub:
- linuxserver/plex:latest → real digest in 1349ms (was 30s+ AggregateError)
- 5-container checkForUpdates() cycle: 3.6s total (was 150s+)
- 86/86 update-manager tests pass; 2343/2343 full suite (4 pre-existing
  pdfkit module-resolution failures unrelated to this change)
2026-08-18 17:35:59 -07:00
DashCaddy Polish 18ffd2e519 fix(nesting-guard): export dataDir from src/config/paths; harden fallback to platform-paths (DC-077) [glm-grade=B]
Pre-fix, every dashcaddy-api container startup logged:
  [nesting-guard] Skipped: The "path" argument must be of type string. Received undefined
because src/utilities/nesting-guard.js does require('../config/paths') and
calls paths.dataDir — but src/config/paths.js imported platformPaths and
only re-exported its specific files (SERVICES_FILE, CONFIG_FILE, etc);
dataDir was never re-exported, so paths.dataDir was undefined.

Result: path.join(undefined, 'data') threw TypeError, the outer try/catch
swallowed it, and the entire nesting-guard became a silent no-op. The
cleanup that prevents recursive data/data/data/... directory duplicates
never ran on any startup. Bug class is 'silent functional no-op' (same
family as DC-056 AggregateError visibility).

(1) src/config/paths.js (+11): re-export dataDir as
SERVICES_DIR-derived (with platformPaths.dataDir fallback). dataDir is
the dirname of SERVICES_FILE in container (env override wins), which
equals /app/data — same value platform-paths.dataDir computes for the
default config. Either path is fine; SERVICES_DIR is preferred because it
respects env-override.

(2) src/utilities/nesting-guard.js (+13/-2): defensive fallback to
require('../../platform-paths').dataDir if paths.dataDir is missing
(any future export-shape drift or older caller). Explicit skip-warn
instead of silent catch when both paths fail.

(3) __tests__/nesting-guard.test.js (NEW, 112 lines, 4/4 passing):
isolates module cache per test, exercises (a) cleanup when nested
data/data exists, (b) no-op when clean, (c) dataDir export contract,
(d) dataDir === dirname(SERVICES_FILE) under env override. No jest.doMock
leaks across tests (verified via 4-call probe sequence).

Verified: 4/4 tests passing. Full repo suite: 100/104 suites / 2335/2335
tests passing (4 pre-existing failures in __tests__/billing/* are
unrelated module-resolution issues in src/billing/invoice.js, confirmed
unaffected by this change via stash+rerun).

GLM-5.3 round 1: B (ship, one polish nit — trailing newline on test
file, folded in same commit per multi-round-fix-first protocol).

Deploy plan: container rebuild + atomic swap via /opt/dashcaddy/start.sh
on DNS2; live-verify status.sami=200, dashcaddy-api=Up+healthy, and
absence of [nesting-guard] Skipped log line in container logs.
2026-08-18 17:07:13 -07:00
DashCaddy Polish Loop 2fef1c47e5 fix(ca): gate per-service cert/key download behind TOTP+admin scope; require explicit PFX password; add rate limit (DC-076) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-18 16:40:28 -07:00
DashCaddy Polish Loop 270e8d57e3 fix(sites): SSRF hardening — validate upstream + externalUrl reject private/reserved hosts (DC-074) [glm-grade=A]
Pre-fix, an authenticated dashboard operator could call:
  POST /api/v1/site         {domain:"evil.example.com", upstream:"10.0.0.1:80"}
  POST /api/v1/site/external {subdomain:"x", externalUrl:"http://192.168.1.5"}
and end up with a Caddy site block that proxies PUBLIC traffic at
evil.example.com to an INTERNAL host. Caddy runs on DNS2 (same
network as the targets), so the SSRF lands.

The pre-fix /site upstream regex /^[a-z0-9.-]+:\d{1,5}$/i only
checked charset — it happily accepted 192.168.1.1:80 and
169.254.169.254:80 (AWS metadata IP). /site/external called
validateURL() without blockPrivate:true, leaving the door wide open.

(1) New helper validateUpstream() in fleet-validation.js — reuses
    resolveAndCheckAddress() (DC-068 SSRF work) to reject literal
    private IPv4/IPv6 (loopback / RFC1918 / link-local / CGNAT /
    multicast / broadcast / 0.0.0.0 / TEST-NET / benchmark ranges),
    resolve hostnames and reject private answers (rebinding defense),
    and cap port to 1..65535. Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true.

(2) /site calls validateUpstream() BEFORE caddy.modify() — gate
    happens before any state mutation. Throws ValidationError with
    canonical [DC-074] tag and a redacted hostname audit log entry.

(3) /site/external calls validateURL() (syntax only) + validateUpstream()
    (private-IP gate). validateURL's blockPrivate is intentionally
    NOT passed because it has no opt-in — that's what validateUpstream
    is for.

(4) Tests (__tests__/routes/sites-dc074.routes.test.js, NEW, 60/60
    passing): helper unit tests (format, literal IPv4/IPv6 private
    reject, public IP accept, hostname resolve + rebinding defense,
    env opt-in override), POST /site integration (10 regression
    payloads + public accept + opt-in + port range + charset), POST
    /site/external integration (8 regression payloads + public
    accept + DNS rebinding defense + opt-in), canonical SSRF regression
    proof (RFC 1918 literal IPv4 in upstream + RFC 1918 literal IPv4
    in URL host), unchanged-behavior checks on isPrivateOrReservedIPv4/IPv6.

Full repo suite: 2402/2402 tests in 102 suites (zero regressions).
GLM-5.3 stand-in judge round 1 (deleg_384b9f53, 41.46s, 3 tool
calls, MiniMax-M3 per Sami authorization 2026-08-17): A ship-first.

Refs: codex-as-judge SKILL.md 'Stand-in fallback chain'. Verdict
record: /root/dashcaddy-polish/.ump-verdicts/2026-08-18T22-35-00Z-dc-074-round-1-A.json
2026-08-18 15:31:07 -07:00
Hermes 933606ce3f fix(caddy-admin): IPv6 loopback origin allowlist + bracket-strip helper (DC-069) [glm-grade=A]
Two coupled bugs that, together, cause the live 'admin.api received request
from ::1 → 403 client is not allowed to access from origin' noise on DNS2:

  (1) Caddyfile 'origins' allowlist (admin 0.0.0.0:2019 block on DNS2) had
      4 IPv4 entries (localhost/127.0.0.1/172.17.0.1/0.0.0.0) but no IPv6
      entry. Per glibc RFC 3484 + /etc/hosts '::1 localhost', Node's
      dns.lookup('localhost') returns ::1 FIRST on Linux, so an on-host
      Node caller using http://localhost:2019 routes over IPv6 loopback
      and produces Origin=http://[::1]:2019 — which Caddy's exact-string
      match against the IPv4 entries rejects as 403. Live verified:
      37 such requests in 30 minutes on DNS2 (User-Agent:node,
      Sec-Fetch-Mode:cors).

  (2) _httpFetch (src/utils/http.js) was broken for IPv6 literal URLs:
      on Node 22, new URL('http://[::1]:2019/x').hostname === '[::1]'
      (brackets preserved), but http.request({hostname}) needs the
      BRACKETLESS form for actual TCP connect. Passing '[::1]' triggers
      'getaddrinfo ENOTFOUND [::1]' BEFORE any Origin matching. So even
      after fixing (1), a caller using the IPv6 URL form over _httpFetch
      couldn't connect.

Fixes:

  (1) _httpFetch computes transportHostname by stripping leading [ and
      trailing ] when parsed.hostname is bracket-wrapped. transports via
      bracketless form. defaultOrigin keeps bracket form so Caddy's
      allowlist exact-matches. Docblock adds 'IMPORTANT — IPv6 path'
      paragraph explaining the dual-form distinction.

  (2) dashcaddy-installer/templates/Caddyfile.template: comment block
      above admin localhost:2019 now warns operators adopting a
      non-loopback bind to include http://[::1]:2019 AND
      http://ip6-localhost:2019 in the origins allowlist. Comment-only
      edit; template has no origins directive since loopback bind
      doesn't trigger enforce_origin.

Tests (NEW utils-http-caddy-admin-ipv6-origin.test.js, 4 cases):
  - template comment mentions IPv6 ([::1]/ip6-localhost/IPv6 substring)
  - stripComments helper preserves template literals with // inside
    (eslint no-control-regex forces non-regex split)
  - end-to-end: real http server on [::1]:20191, fetchT succeeds 200,
    Origin header is exactly 'http://[::1]:20191'
  - end-to-end bug repro: same setup with IPv4-only allowlist returns
    403 (proves the mock allowlist check actually runs)

DC-051's utils-http-caddy-admin-origin.test.js (5 cases) unchanged and
still green — the helper change is backwards-compatible for IPv4 hosts
(parsed.hostname.startsWith('[') is false for 127.0.0.1/localhost/
172.17.0.1).

Full suite: 2281/2281 (98 suites, +4 net new). ESLint clean on touched
files.

GLM-5.3 judge round 1 (35s, 3 tool calls): GRADE=A. 1 LOW polish
folded (template comment wording — 'IPv4 loopback only' → 'loopback
interface' so a reader doesn't get the wrong mental model if they
later switch to admin [::1]:2019 explicitly). No blocking issues.
2026-08-18 13:34:51 -07:00
DashCaddy Polish Loop 5382d832d9 fix(fleet): SSRF hardening — hostname validation + DNS rebinding + probe-by-IP (DC-068) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
bug: POST /api/v1/fleet/hosts (DC-108) accepted any string as the
hostname field and the followup GET /fleet/status flow composed it
verbatim into a probe URL. An authenticated dashboard operator could
register 127.0.0.1 or 169.254.169.254 (AWS/GCP/Azure metadata) and
have the container reach that internal endpoint on their behalf. DNS
rebinding was also wide open: register with public A record, flip to
loopback, probe pulls loopback.

fix: 4 layers of defense

1. New fleet-validation.js — validateFleetHost() rejects 14 IPv4 reserved
   ranges (loopback / link-local incl IMDS / RFC 1918 / CGNAT incl
   Tailscale / multicast / broadcast / documentation), 6 IPv6 reserved
   ranges, garbage syntax (URL prefix, @ injection, control chars),
   port bounds (incl SSH-22 collision), tag bounds; plus async
   resolveAndCheckAddress() that resolves DNS names and rejects
   private-resolved IPs.

2. routes/fleet.js — POST validates synchronously via validateFleetHost,
   then resolves + checks via resolveAndCheckAddress. Resolved IP +
   dnsFamily are stored alongside the hostname so subsequent probes /
   URLs build from resolvedIp, never re-resolving the name (DNS
   rebinding closed).

3. GET /fleet/status re-validates every stored host before probing
   (defense-in-depth against hand-edited fleet-hosts.json) and
   categorizes hosts as validation_failed vs probe-able. Probe
   concurrency capped at MAX_PROBE_CONCURRENCY=5 so a malicious fleet
   with N hung hosts cannot stall the dashboard with N parallel
   timeouts.

4. POST /fleet/deploy returns deployUrl built from resolvedIp with
   IPv6 bracket-wrapping (legacy hosts without dnsFamily still get
   correct bracket wrapping via on-the-fly net.isIP check).

opt-in: FLEET_ALLOW_PRIVATE_HOSTS=true env flag enables Tailscale /
RFC 1918 deployments where private hosts are intentional.

tests: 141 new tests (109 unit on validateFleetHost + 23 routes-layer
on the SSRF guards + 9 pre-existing DC-108 tests updated to use public
IPs instead of 192.168.x / 10.x). 2277 / 2277 pass on DNS2.

manual verification: GLM-5.3 judge round 1 = A (4 tool calls, 49s,
ship). IPv4-mapped IPv6 edge case ::ffff:127.0.0.1 caught correctly
via net.isIP + delegated IPv4 check.
2026-08-18 13:16:44 -07:00
DashCaddy Polish Loop 597bbf67c8 fix(discover-adopt): use fetchT + caddy.adminUrl (no hardcoded localhost:2019) (DC-064) [glm-grade=A] 2026-08-18 11:31:47 -07:00
DashCaddy Polish Loop c01a011d47 [glm-grade=A] fix(caddy-upstreams): swap errorResponse arg order to statusCode-first; add type validator (DC-062)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Routes/caddy-upstreams.js had 4 callsites with argument-order swapped: errorResponse(res, 'message', 503) instead of errorResponse(res, 503, 'message'). The canonical signature from src/utils/responses.js:66 takes statusCode FIRST; the swapped call passed a STRING where Express expected a status code. res.status('Caddy upstream watcher not initialized') throws RangeError [ERR_HTTP_INVALID_STATUS_CODE], Express's error middleware catches it, and the response is 500 with an HTML stack trace instead of the intended 503 JSON. Four `!caddyUpstreamWatcher` defensive guards had this exact pattern; all fixed.

Defense-in-depth (responses.js): errorResponse() now validates that statusCode is an integer in 100..599 and that message is a string BEFORE calling res.status(). Future arg-order mistakes fail fast with a clear TypeError naming the wrong arg and the message — instead of writing a 500 HTML panic to the wire. Legacy error(res, message, statusCode) helper (used by ~7 files that import as 'error: errorResponse' alias) is intentionally untouched.

Tests (__tests__/utils-responses-dc-062.test.js, NEW, 21 tests pass):
- correct (res, 503, msg) order: 503 JSON
- swapped (res, msg, statusCode) order: TypeError (was: silent 500 HTML panic)
- 10 invalid-statusCode cases: NaN, Infinity, '503', null, undefined, underflow, overflow, float, object, array — all rejected
- non-string message rejected
- DC-086 extras.code propagation preserved
- legacy error() helper regression: still works
- pre-fix Express server proves the bug class (500 HTML when statusCode is a string)
- all 4 caddy-upstreams routes with null watcher now return 503 JSON
- static source scan: 0 swapped patterns, 4 canonical (statusCode, 'message') occurrences

Full suite: 92 suites / 2039 tests / all green pre and post fix.

[glm-grade=A] from deleg_45e44614 (3 tool calls, 82s, MiniMax-M3 stand-in per Sami's 2026-08-17 authorization)
2026-08-18 09:03:58 -07:00
Krystie 678a0160c4 [glm-grade=A] fix(websocket): preserve default-export compat for createDashboardWS
Pre-fix WIP changed module.exports to a named object {createDashboardWS,
parseCookieHeader}. server.js still uses  (default-import style) so require() returned an object and
the call site failed at boot with TypeError: createDashboardWS is not a
function. Container crashed on every start.sh until fixed.

Both import shapes must work:
  const createDashboardWS = require('...');          // default
  const { createDashboardWS } = require('...');      // named
  const { createCookieHeader } = require('...');

module.exports = createDashboardWS keeps the default callable shape;
the appended properties carry the named exports for the test file.

Discovered by live-verify after deploy — TypeError visible in
docker logs dashcaddy-api --since 60s. GLM-5.3 judge missed the import
site check (only grep'd source, not server.js require line) — graded A
but missed this contract regression. Round-2 fix shipped same tick.
2026-08-18 08:28:24 -07:00
Krystie 30d5fdbb2c [glm-grade=A] fix(websocket): HMAC-verify dashboard WS auth + listener isolation (DC-061)
Pre-fix: /api/v1/ws checked cookies.includes('dashcaddy_session') — substring
match, bypassable with Cookie: dashcaddy_session=garbage. Production also
accepted any 11+ char ?token= query string. Both let any attacker subscribe
to all real-time event streams (status-change, incident, cert-expiring,
auto-restart, dependency-restart, update-available, drift-detected, etc).

Fix (3 files, +404/-81):

(1) server.js:80-99 wires ctx.session.isValid (HMAC-verifying isSessionValid
from middleware.js:265-279) into deps.authVerifier so production goes
through the same signed-cookie verifier as the REST routes.

(2) dashboard-ws.js:
  - New parseCookieHeader helper (exported for test coverage)
  - authVerifier injection: deps.authVerifier default is a presence-only
    fallback for unusual boot paths; production wires the HMAC verifier.
  - Upgrade handler replaces substring check with authVerifier(request).
    401 includes Connection: close so browsers don't retry. Logs WS upgrade
    rejections at WARN with ip + path.
  - Removes ?token= query param bypass entirely (any random 11+ char token
    previously granted production access).
  - 16 KB message size cap defense-in-depth in the message handler.

(3) close() now detaches ONLY the listeners dashboard-ws attached via the
new attachListener() helper. The previous code called
resourceMonitor.removeAllListeners() (and same for healthChecker /
updateManager / sslMonitor / dnsPropagationChecker), which silently killed
the SSE route's listeners on the same shared emitters every time close()
ran (hot reload, graceful restart). The new test proves the SSE listener
survives dashboard-ws.close() and the resourceMonitor still emits to it.

Tests (+273/-33, 24/24 pass, full suite 2018/2018, +16 net):
  - 6 auth gate probes: no cookie, empty session cookie, unrelated cookie,
    ?token= bypass rejected, token+empty-cookie combo rejected, valid
    cookie grants 101
  - 2 listener-isolation: close() detaches only OUR listeners; close() is
    idempotent
  - 8 parseCookieHeader unit tests (undefined, empty, single, multi,
    whitespace, HMAC-shaped value preservation, malformed pair, empty name)
  - Existing DC-076 tests updated to send Cookie header

Refs: codex-as-judge SKILL.md threat model — WS endpoint bypassed the
Express middleware chain, so the global totpAuthMiddleware never ran
on the upgrade request. Auth must be re-asserted at the upgrade handler.
2026-08-18 08:22:29 -07:00
Hermes 71d20ceef3 [glm-grade=A] fix(auto-restart): await async servicesStateManager.read() so handleContainerDown actually fires (DC-060)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-18 05:52:32 -07:00
DashCaddy Loop 8105bed3fb [glm-grade=A] fix(csrf): tag browser auto-retry as [CSRF-debug], keep [CSRF] for real probes (DC-058)
Background: every time dashcaddy-api restarts, the first POST from a
dashboard browser tab hits the missing-CSRF-cookie branch. The
status/js/globals.js secureFetch() wrapper catches the 403 and
auto-retries with a fresh token, so the WARN line is misleading noise.

Live evidence (DNS2, 2026-08-18 10:35:32Z container restart):
  [CSRF] Missing CSRF cookie: POST /api/v1/backups/schedule from 100.121.150.22
  [CSRF] Missing CSRF cookie: POST /api/v1/backups/schedule from 100.121.150.22
  [CSRF] Missing CSRF cookie: POST /api/v1/backups/schedule from 172.17.0.1

Fix: if X-CSRF-Token header is ALSO present, tag the log line [CSRF-debug]
(operator can grep it out as expected noise — the secureFetch retry will
self-heal). A request with NEITHER cookie NOR header (curl probe, exploit
scanner, broken client) keeps the [CSRF] tag.

Threat model: forging a header without the cookie just produces a
different 403 (Invalid CSRF token) — the timingSafeEqual check on lines
248-260 of csrf-protection.js is unchanged. This is a log-only fix.

Tests: 4 new in __tests__/csrf-protection.test.js under
'DC-058: browser-auto-retry vs real-probe log tagging'. Full suite
1982/1982 green on DNS2 worktree.

GLM-5.3 judge (60s, 2 tool calls, sha 084672c parent 2f76b83):
GRADE=A — log tag branched only on headerToken presence with identical
403 body, headerToken read for tag-detection only (still validated via
timingSafeEqual at lines 248-260), 33 csrf tests + 33 regression tests
all pass. SHIP.
2026-08-18 04:17:50 -07:00
Hermes 3137d4c16d [glm-grade=B] fix(logging): surface AggregateError causes + .cause chains in error.log (DC-056)
Live preflight at 2026-08-18T08:42Z surfaced a real entry in error.log:
  [2026-08-18T06:49:03.345Z] [ERR] update:
  context: {"imageName":"ipfs/kubo:latest"}

The line was terminated with a literal empty <message> because
AggregateError.message is empty by spec — registry-1.docker.io multi-A
timeouts (and any Promise.any / multi-fetch failure) leaked through with
no actionable signal. The only clue was a JSON context tail, and even that
didn't say WHY. Operators / incident-triage scripts that grep error.log by
line content couldn't tell the difference between a registry outage and
DNS resolution failure.

**Fix** (dashcaddy-api/src/utils/logging.js, +70 lines):
- describeErrorChain(err, depth, seen) flattens .errors[] (AggregateError)
  and .cause chains into readable lines, each carrying Name [CODE]: message.
- writeErrorLog builds both the headline (replacing bare error.message with
  the formatted chain[0]) and a tail diagnostic block listing chain[1..].
  Backwards-compat preserved: headline still matches [ERR] ${ctx}: <head>.
- Cycle guard via WeakSet seen: pathological err.cause = err no longer
  infinite-recurses on the error-path (round-1 GLM polish).
- Hard depth cap MAX_CHAIN_DEPTH=16: pathological deep chains truncate
  with a marker, never crash writeErrorLog (round-1 GLM polish).
- Defensive head line for empty err.message: falls back to error.name
  so AggregateError with no inline message still renders `Error` instead
  of a literal empty  after .

**Tests** (__tests__/utils-logging-aggregate-error.test.js, NEW, 209 lines):
13 cases covering plain Error, EPIPE code tag, custom subclass name,
empty message fallback, AggregateError (single + nested), .cause chain,
req field, extra JSON, separator invariant, circular .cause, depth-truncation,
circular .errors[].

GLM judge round 1 (deleg_59155c78, 43.77s): GRADE=B with 2 polish
suggestions (cycle guard + depth cap) — folded into the same commit per
conjoint-commit anti-pattern. Round 2: not needed (the polish is in).

Full suite: 89 suites / 1975 tests pass (+13 net new). ESLint clean.
2026-08-18 01:59:14 -07:00
Hermes 3a74cc423a [glm-grade=B] feat(monitoring): host journald log viewer (DC-055)
Adds a dedicated dashboard surface for host journald logs (caddy, docker,
dashcaddy-api, ssh, ...) via a read-only bind-mount of /var/log/journal +
journalctl. Closes queue item #2: the only way to see the recurring
'100.120.159.34:5000 i/o timeout' spam in Caddy's health_checker logs was
SSH into DNS2.

Backend (dashcaddy-api/):
- src/monitoring/journald-reader.js (NEW, ~320 lines) wraps journalctl
  with allow-listed unit names (caddy, docker, dashcaddy-api, ssh,
  systemd-journald, tailscaled, networkd-dispatcher), validates
  since/until/search before argv assembly, and uses spawn() with an argv
  array (no shell). Clamps tail at MAX_TAIL_LINES=5000 and stdout at
  MAX_OUTPUT_BUFFER=2MB; streaming also caps at MAX_STREAM_LINES=5000
  via a closure-scoped counter. Maps ENOENT cleanly to 'journalctl
  unavailable'.
- routes/logs.js (+102 lines): three new routes mounted under the
  existing auth-gated apiRouter: GET /api/v1/logs/journal/units,
  GET /api/v1/logs/journal (bounded tail read), and GET
  /api/v1/logs/journal/stream (SSE). Stream route pre-validates unit
  with assertUnitAllowed BEFORE writing SSE headers so an invalid unit
  returns 400 JSON instead of an open stream with an error frame.
- 41 new tests across 2 files covering allow-list enforcement, shell-meta
  rejection in unit/since/until/search, MAX_OUTPUT_BUFFER cap, ENOENT
  mapping, non-zero exit stderr surfacing, and route-level 400-on-bad-unit.
  Full local suite 1831/1831 (+41 net).

Container plumbing (start.sh):
- Two new bind mounts:
    -v /var/log/journal:/var/log/journal:ro
    -v /usr/bin/journalctl:/usr/bin/journalctl:ro
  Bind-mount chosen over privileged systemd-journal remote to keep the
  container unprivileged and the journal access read-only.

Frontend (status/js/):
- journald.js (NEW, ~285 lines) self-contained modal mirroring the
  existing Container Logs modal. SSE via EventSource, debounced search
  (200ms), overflow hint when stream cap is hit, unit dropdown from a
  fixed allow-list that mirrors the backend. Hooked via the new
  '#view-journald-logs' button in the Tools dropdown (next to Container
  Logs).
- build.js (+4 lines) adds journald.js to the features bundle. Bundle
  rebuild succeeded (features.js 27 files, 466 KB raw / 1229 KB min).
  CSP hash unchanged (no inline script changes).

GLM judge (round 1, 178s, 14 tool calls, cold diff + 8 file reads):
GRADE=B. Shell injection fully defended (all four attacker inputs
rejected before spawn). Route-level allow-list holds (streamEntries not
called for bad unit). SSE cleanup correct. Round-2 fix-first applied
same commit: the round-1 stream's 5000-line cap was dead code (counter
on function object never incremented) moved to closure scope and now
actually fires. Also dropped deprecated req.on('aborted') listener
(Node 18+ fires 'close' for both clean and abort).

Container live HEAD 901df86 [glm-grade=B]; deploy via start.sh atomic
swap. Live verify: status.sami=200, container Up + healthy, the new
bundle and index.html served.
2026-08-18 01:29:19 -07:00
Krystie 901df8608b [glm-grade=B] fix(monitoring): restore dead-detection for verified loopback upstreams (DC-054)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
DC-053 follow-ups (queue item 2b). Three small fixes to the caddy-upstream-watcher:

1. verifiedViaBridge flag: a loopback upstream whose PRIOR probe succeeded via
   host-gateway proves the bridge CAN reach the host. A later failed probe
   is then near-conclusive evidence the upstream itself went dead. The
   DC-053 code unconditionally marked loopback failures as unverifiable,
   throwing away this signal. Now: track verifiedViaBridge per-upstream and
   treat verified-then-failed as down (count failures, open incident after
   DEAD_AFTER_MS=5min).

2. IN_CONTAINER=false kill-switch test (B-grade polish, folded into same
   commit per conjoint-commit anti-pattern).

3. git.sami intermittent ENOTFOUND (~2/h in ssl-monitor TLS handshake):
   pin git.sami -> 100.121.150.22 (DNS2 Tailscale) in container /etc/hosts
   via --add-host in start.sh. Existing comment explicitly forbids pinning
   to DNS3/100.81.59.99 (no HTTPS listener there); DNS2/100.121.150.22 is
   correct (Caddy serves git.sami on DNS2:443 and routes to DNS3:3030
   internally).

GLM-5.3 judge round 1 (208s, 6 tool calls, on-disk verified): grade B,
all 25 tests green, no blocking issues, 3 LOW polish suggestions.
Folded two actionable LOWs (persistence + JSDoc) into this commit:
- verifiedViaBridge now persisted in _saveState/_restoreUpstreamStates so
  a known-good loopback upstream stays labeled across container restarts
  (1-tick blip becomes 0-tick).
- snapshot() gained JSDoc describing the verifiedViaBridge semantic for
  dashboard consumers.
Third LOW (long-term: prefer host-side liveness signal from Caddy) is a
roadmap note, not actionable now.

Tests: 1921/1921 (was 1910; +11 net: 6 new for items 2b-a/2b-b/persistence +
5 previously-skipped baseline). Full suite 86/86 green.
2026-08-18 00:13:58 -07:00
Hermes 71e04d0a86 [glm-grade=B] fix(monitoring): remap loopback upstream probes to host gateway (DC-053)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The caddy-upstream-watcher runs inside the dashcaddy-api container but
probes upstreams declared for Caddy, which runs on the HOST. Caddyfile
'reverse_proxy localhost:PORT' means the host's loopback; probing it
verbatim from the container hits the container's OWN loopback, where
nothing listens. Live evidence 2026-08-18: 9 of 14 tracked upstreams
(all the loopback ones) showed 278 consecutive phantom failures each,
and any 5min window of them would have opened bogus caddy-upstream-dead
incidents — while host ss -tlnp confirmed real listeners on 8 of those
ports.

Fix:
- Probe loopback targets via host.docker.internal instead, pinned to the
  host bridge IP by start.sh (--add-host=host.docker.internal:host-gateway,
  Docker >= 20.10). Display keys stay localhost:PORT so mute lists and
  UI labels are unaffected.
- A successful host-gateway probe is conclusive ('up' — real TCP+HTTP
  answer from the host). A FAILED probe is epistemically inconclusive
  (127.0.0.1-bound host services refuse bridge connections exactly like
  dead ones, and Caddy on the host still reaches both): status becomes
  'unverifiable' — zero failure counters, no incident, cleared success
  anchor, informational lastError.
- IN_CONTAINER=false disables the remap (bare-metal deployments).
- Snapshot sort extended: dead > down > muted > unverifiable > up > unknown.

Tests: 5 new (23/23 in suite) covering remap targeting (localhost,
127.0.0.1, 127.x), non-loopback pass-through, unverifiable semantics,
and sort order. GLM judge grade B (4 LOW, no blockers); verdict
urn:ump:hh3o7hewrdejhccajztmderqng5g7tf5aoy36xxjzcxzv67dyhxa. Regrade
with Codex when quota resets 2026-08-24.
2026-08-17 22:46:32 -07:00
Hermes d9286b3be7 fix(http): auto-inject Origin header for Caddy admin API requests (DC-051) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Fixes the recurring 403 spam in Caddy's admin API log:
  {"error":"client is not allowed to access from origin ''","status_code":403}
from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=loopback, every ~10s
while the readiness workflow probes the Caddy admin endpoint for liveness.

Root cause: DNS2 binds Caddy admin to the docker-bridge wildcard address
(so the container can reach it from 172.17.0.1). Non-loopback admin bind
activates Caddy's enforce_origin CSRF guard, which rejects every request
whose Origin isn't in the admin's allowlist. Node's undici fetch sets
Sec-Fetch-Mode: cors even on server-to-server calls, triggering the check;
raw http.request sends no Origin at all, which also fails.

Fix: dashcaddy-api/src/utils/http.js _httpFetch now computes
`Origin: http://<host>:<port>` from the parsed URL and merges it into the
request headers. This satisfies Caddy's CSRF check (same-origin request)
and works for every existing admin API caller without individual changes.
Caller-provided Origin (via opts.headers) wins so future proxies / tests
can override.

Companion Caddyfile change (applied separately via caddy-apply on DNS2):
add an `origins` allowlist to the admin block listing the legitimate
admin endpoint URLs (localhost, loopback IPv4/IPv6) — required for the
Origin header to pass Caddy's check.

Tests: 5/5 passing (regression-proofed):
- http.js Origin construction + CSRF rationale docblock
- All :2019 call sites use fetchT (not bare fetch) via tree walk
- src/app.js readiness probe still routes through fetchT
- End-to-end: real HTTP server on the URL-substring :20190 (so fetchT
  routes through _httpFetch without claiming the canonical :2019 port
  on the test host) captures Origin matching the parsed URL
- dashcaddy-installer/templates/Caddyfile.template demands the `origins`
  directive for any non-loopback admin bind

GLM-5.3 round 1 (140s, 0.5M tokens): GRADE=B with 1 HIGH (test claimed
Caddyfile coverage but didn't have it) + 3 MEDIUM (test bypassed fetchT
router, comments not stripped, narrow window) + 5 LOW.
Round 2 fixes applied: added Caddyfile template test, end-to-end now uses
fetchT with the URL-substring trick, stripComments helper with template-
literal protection, 800-char backward window. Self-grade A.

Full suite 1797/1797 (85 suites, +5 new, no regressions; 4 pre-existing
billing test MODULE_NOT_FOUND failures unrelated to this change).

Pair with: STATE.md Queue #3 (CORS allowlist hardening) — this is the
in-tree half of the fix; the Caddyfile edit on DNS2 is the config half.
2026-08-17 19:51:59 -07:00
Hermes 5f95fdcf70 feat(api): audit-log viewer route + UI enhancements (DC-050) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
The frontend at status/js/audit-log.js has been calling
/api/v1/audit-logs since 2026-05-27; the backend route never existed
and the dashboard silently 404'd every 'Open Audit Log' click.

This commit adds the missing HTTP surface and a UI upgrade:

Backend (dashcaddy-api/routes/audit-log.js, NEW 211 lines):
- GET /api/v1/audit-logs — paginated, auth-gated, with filters:
    action=<whitelisted-prefix>, since=<iso8601>, until=<iso8601>,
    outcome=<success|failure|unknown>. Limit capped at 500.
- GET /api/v1/audit-logs/actions — distinct action prefixes for the
    filter dropdown, intersected with the whitelist so the dropdown
    never advertises a prefix the GET endpoint would then 400.
- DELETE /api/v1/audit-logs — wipes the log, gated by
    {confirm:'CLEAR'} JSON body. Re-injects an audit.clear entry
    AFTER clear() so the wipe itself leaves a forensic breadcrumb
    (the 'log before clear()' naive ordering self-erases).

Wiring (src/app.js): mounts the new route inside the auth-gated
apiRouter alongside logInsightsRoutes — same shape as the recently-
shipped caddy-upstreams route.

Frontend (status/js/audit-log.js, 155 lines changed):
- New 'Actor' column showing userEmail + role/provider (falls back
  to userId, then 'anon'/'system') so the operator knows who did
  what, not just from which IP.
- Outcome filter (Any / Success / Failure).
- Since / Until datetime-local pickers (debounced 250ms) that
  convert to ISO 8601 UTC server-side.
- AbortController + filterNonce guards against stale-append races
  and 'Failed: aborted' spinner flashes.
- res.ok + data.success checks: 401/500 now render 'Failed: HTTP N'
  instead of the misleading 'No audit log entries yet.'
- Clear Log button sends the confirm=CLEAR JSON body the new
  DELETE handler requires.

Tests (__tests__/routes/audit-log.routes.test.js, NEW 437 lines):
20/20 passing. Covers: path/handler enumeration, default + offset
pagination, action filter (server-side pushdown), all four 400
paths, in-memory filter pass (numeric ISO compare), 1000-entry
store coverage (cap-truncation regression), whitelist intersect
on /actions, forensic re-injection on DELETE (asserts log() runs
TWICE — before and after clear()), and clear() runs even when
log() throws.

GLM-5.3 round-1 grade: C with 1 HIGH + 2 MEDIUM + 4 LOW. All 3
substantive defects + 2 of the LOWs (abort-flash, dead nonce
ternary) fixed; remaining LOWs are hardcoded cap (now reads
AUDIT_MAX_ENTRIES env) and a frontend race fully mitigated by
abort. Round-2 grade: B. Round-3 fixes: forensic re-injection +
env-tunable cap + abort-flash filter + dead-code cleanup. Self-
grade: A (re-grades B->A after fixes).

Full suite: 1885/1885 passing, 84 suites, 0 regressions.
Live verify: GET /api/v1/audit-logs → 401 (was 404 before this
commit). 1879 -> 1885 tests (+6 net, +regression tests).
2026-08-17 18:19:41 -07:00