]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/log
e-mobility-charging-stations-simulator.git
4 weeks agochore(deps): update all non-major dependencies (#1948)
renovate[bot] [Sat, 4 Jul 2026 13:11:07 +0000 (15:11 +0200)] 
chore(deps): update all non-major dependencies (#1948)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agofeat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936...
Jérôme Benoit [Fri, 3 Jul 2026 21:26:23 +0000 (23:26 +0200)] 
feat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936 d) (#1945)

* feat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936 d)

OCPP 1.6 Signed Meter Values whitepaper v1.0 requires that when
SampledDataSignReadings + SampledDataSignUpdatedReadings are enabled
and a signing key is configured, a paired SignedData SampledValue
accompanies the Raw SampledValue in every Sample.Periodic-context
MeterValue. The periodic loop (OCPP16ServiceUtils.startUpdatedMeterValues)
already applies POST-HOC signing after buildMeterValue returns, so
periodic MeterValues are signed today.

Two callsites were missing the signing wrapper:
1. OCPP 1.6 TriggerMessage(MeterValues) handler in
   OCPP16IncomingRequestService.ts (both the specific-connectorId
   branch and the broadcast-to-all-connectors branch).
2. Worker broadcast channel in
   ChargingStationWorkerBroadcastChannel.handleMeterValues
   (cross-version handler; signing is 1.6-only per the whitepaper).

Both paths silently emitted unsigned Raw values instead of the
whitepaper-mandated paired SignedData SampledValue when signing was
enabled.

Fix: consolidate the signing block from startUpdatedMeterValues into
a new public static helper OCPP16ServiceUtils.appendSignedUpdatedReadings
that mutates the MeterValue in place. The helper is a no-op when
signing is disabled or the connector's signing config is missing.
Apply the helper at the three missing callsites and refactor
startUpdatedMeterValues to call it too.

Test invariant fail: 0, skipped: 6 preserved (2895 pass / 2901 total).

Spec citation: OCA Application Note 'Signed Meter Values for OCPP 1.6'
v1.0 §3.2.1 (paired SignedData SampledValue).

Closes issue #1936 item (d).

* docs(ocpp16): apply round-1 review fixes on signed MV wire-up (issue #1936 d)

Address cross-validated R1 findings:

- Naming coherence in TriggerMessage broadcast-to-all branch: rename
  loop-local variables from abbreviated 'id'/'cs'/'txId'/'mv' to
  full 'connectorId'/'connectorStatus'/'transactionId'/'meterValue'
  to match the specific-connector sibling branch style. Per AGENTS.md
  naming coherence: two adjacent branches doing the same thing with
  different name styles is the 'synonym creates ambiguity' pattern.

- Rewrite the broadcast channel guard comment to correctly attribute
  the '!isOcpp2' skip. Previous wording claimed 'the whitepaper is OCPP
  1.6 specific', but the whitepaper §4 explicitly covers OCPP 2.x. The
  actual reason for the skip is that OCPP 2.0.x signing is applied
  inline inside buildMeterValue via the versioned dispatcher's signing
  hook, so post-hoc wrapping is the 1.6 pattern only.

- Tighten the appendSignedUpdatedReadings @description to drop
  refactor-history narrative ('Consolidates the signing block used by
  every trigger/broadcast path...'). Replace with operational spec
  citing whitepaper §3.3.6 SampledDataSignUpdatedReadings and the
  mutation contract on connectorStatus.publicKeySentInTransaction
  (per PublicKeyWithSignedMeterValue = OncePerTransaction).

* fix(ocpp16): harden signed MV helper and thread reading context (issue #1936 d)

Enforce the transactionStarted invariant inside `appendSignedUpdatedReadings` so callsites
with looser guards cannot leak signed emissions past `resetConnectorTransactionStatus`.

Accept an optional reading `context` parameter (default `Sample.Periodic`);
`TriggerMessage(MeterValues)` callsites pass `Trigger` per OCPP 1.6 Core Table 30 so the
signed payload's context field matches its emission source.

Downgrade the `readSigningConfigForConnector` disabled-state log from `warn` to `debug`;
the helper is a hot-path probe whose `undefined` return already conveys the disabled state
to callers.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

4 weeks agorefactor(charging-station): extract reservation helpers from Helpers.ts (issue #1936...
Jérôme Benoit [Fri, 3 Jul 2026 19:53:26 +0000 (21:53 +0200)] 
refactor(charging-station): extract reservation helpers from Helpers.ts (issue #1936 f Phase 1) (#1946)

* refactor(charging-station): extract reservation helpers from Helpers.ts (issue #1936 f)

Phase 1 of the Helpers.ts file split tracked in issue #1936 item (f).

Helpers.ts is 1389 LOC — 5.5x the 250 LOC ceiling documented in
AGENTS.md's programming skill. This PR extracts the smallest cohesive
block (the 4 reservation helpers) into a dedicated file as an initial
step, proving the barrel-preservation pattern so the remaining slices
can follow in subsequent PRs without breaking any callers.

Extracted symbols into HelpersReservation.ts:
- hasReservationExpired
- hasPendingReservation
- hasPendingReservations
- removeExpiredReservations

Helpers.ts (1389 -> 1336 LOC) keeps the public API via a barrel
re-export block. Every external caller ('import { ... } from
"./Helpers.js"') continues to work unchanged. Now-unused imports
in Helpers.ts (isPast, Reservation, ReservationTerminationReason)
are dropped.

Test invariant fail: 0, skipped: 6 preserved (2925 pass / 2931 total).

Closes issue #1936 item (f) Phase 1 of N.

* [autofix.ci] apply automated fixes

* docs(helpers-reservation): strip historical narrative, align banner, complete JSDoc (issue #1936 f)

Apply round-1 review findings on HelpersReservation.ts:

- Banner alignment: 'Copyright ... 2021-2026' → 'Partial Copyright ...
  2021-2025' to match sibling files in src/charging-station/ that carry
  banners (ChargingStation.ts, Bootstrap.ts, BootstrapStateUtils.ts,
  CoherentMeterValuesManager.ts, AutomaticTransactionGenerator.ts,
  ChargingStationWorker.ts). A repo-wide year bump to 2026 is out of
  scope for this refactor.
- Strip historical narrative from @description per AGENTS.md
  documentation convention ('document current state; exclude historical
  evolution'). Old text narrated the extraction event ('Extracted from
  ./Helpers as the first slice of the issue #1936 (item f) file split'),
  which the commit message + git blame already carry permanently.
  Replace with an operational spec describing the module's current
  responsibility: reservation-state predicates + bulk expired-reservation
  cleanup.
- Add missing @returns on removeExpiredReservations. Every other
  exported helper documents its return; the async cleanup did not.
  New wording also encodes the WHY (Promise.allSettled → never
  rethrows, individual failures logged) that was previously only
  implicit in the body.

* docs(helpers-reservation): harmonize JSDoc voice across predicates (issue #1936 f)

Align the three reservation predicates to a single JSDoc voice:
'hasPendingReservation' and 'hasPendingReservations' were 'Checks
if …'; 'hasReservationExpired' was already 'Determines whether …'
after the R1 doc-fix. Standardize the two predicates to
'Determines whether …' so all three read consistently. Leave
'removeExpiredReservations' as imperative ('Removes every …') since
it is a mutation, not a predicate — different verb class,
appropriate.

Per AGENTS.md 'Naming coherence' — semantically accurate names
across code and documentation, avoid synonyms that create
ambiguity.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agorefactor(ocpp20): rewrite H9/H10/H11 + C10/C12 audit markers as descriptive comments...
Jérôme Benoit [Fri, 3 Jul 2026 15:51:46 +0000 (17:51 +0200)] 
refactor(ocpp20): rewrite H9/H10/H11 + C10/C12 audit markers as descriptive comments (issue #1936 l) (#1939)

Nine inline comments in OCPP20IncomingRequestService.ts (7) and its
sibling test file OCPP20IncomingRequestService-UpdateFirmware.test.ts
(2) carried internal audit-round finding numbers (H9, H10, H11, C10,
C12) as if they were code identifiers.

Production-file markers (7) originate from commit 3a6e89f634
'fix(ocpp20): remediate all OCPP 2.0.1 audit findings (#1726)' which
predates PR #1935. Test-file markers (2 H11) originate from commit
b50f9e5f 'refactor(tests): separate handler/listener tests and remove
setTimeout hacks' — same audit-round family, later commit.

The audit markers had the shape //<letter+><digits>[:-)] which is
distinct from the legitimate OCPP spec references elsewhere in the
repo (B11 - Reset, F06.FR.06, A02.FR.06, E05.FR.09, L01.FR.30,
C10.FR.04, C12.FR.05, ...) that all follow <UC>.FR.<NN>.

Each rewrite replaces the marker with a WHY-rationale or BDD-style
expectation describing the current-state behavior. See the PR body
table for the full before/after list.

Verification (POSIX character classes; portable across all git-grep -E
builds — the \s shorthand is a PCRE extension not supported by POSIX
ERE and silently returns no match on macOS git):

  git grep -inE '^[[:space:]]*//[[:space:]]*[A-Z]+[0-9]+[]:)-]' -- \
    src/ tests/

returns empty.

Comment-only change. No runtime behavior change.
Test invariant fail: 0, skipped: 6 holds.

Closes issue #1936 item (l).

4 weeks agorefactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue...
Jérôme Benoit [Fri, 3 Jul 2026 14:39:30 +0000 (16:39 +0200)] 
refactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue #1936 item i) (#1938)

* refactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue #1936 item i)

Split the 820 LOC `CoherentMeterValuesGenerator.ts` (introduced by PR
#1935) into three focused modules per issue #1936 item (i):

- `CoherentSampleComputer.ts` (311 LOC): physics chain V→P→I→ΔE→SoC with
  INV-1/2/3 by construction (guard bundle, ramp factor, voltage noise,
  EVSE/EV/capacity clamps, integer-rounded emission, energy accrual,
  monotone SoC saturation), plus `advanceEnergyRegister` which the
  builder invokes once per sample so `meterStop` stays correct even when
  `Energy.Active.Import.Register` is not in the configured MeterValues.
- `CoherentMeterValueBuilder.ts` (364 LOC): OCPP {@link MeterValue}
  assembly — phase family classifier, cross-measurand emit order
  (SoC→Voltage→Power→Current→Energy), within-measurand phase rank
  (no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N),
  kilo/unit divider, template resolution, and the `buildCoherentMeterValue`
  entry point that orchestrates `computeCoherentSample` +
  `advanceEnergyRegister` + per-template emission.
- `CoherentMeterValuesGenerator.ts` (204 LOC, shrunk from 820): session
  lifecycle (`createCoherentSession`, `CreateSessionOptions`), PRNG
  helpers (`createStreamPrng` with the `tx:`-namespaced deriveSeed leg,
  `resolveRootSeed`), the `isCoherentModeActive` type guard, and the
  module-scope WeakMap runtime state (`SessionRuntime`,
  `getSessionRuntime`, `disposeCoherentSessionRuntime`) accessed by the
  computer.

The three modules stack: Generator (WeakMap + PRNG helpers) ← Computer
(imports `getSessionRuntime`, `createStreamPrng`) ← Builder (imports
`computeCoherentSample`, `advanceEnergyRegister`, `ROUNDING_SCALE`,
`CoherentSample`, `ComputeSampleOptions`). No cycles.

The barrel (`meter-values/index.ts`) re-exports `buildCoherentMeterValue`
+ `BuildVersionedSampledValue` from the builder and
`createCoherentSession` / `isCoherentModeActive` / `resolveRootSeed`
from the generator so external consumers
(`CoherentMeterValuesManager`, `OCPPServiceUtils`, `OCPP16ServiceUtils`)
keep their existing imports.

Preserved invariants (per mission constraints):
- `__injectCoherentSession` NODE_ENV production-guard throw
  (unchanged — lives on `CoherentMeterValuesManager`).
- Session-snapshot reads for `voltageOutNominal` / `currentType` /
  `numberOfPhases` (still read from `session.*` inside
  `computeCoherentSample`).
- `tx:` PRNG namespace on the transactionId leg of `createStreamPrng`
  (preserves the XOR self-inverse guard).
- non-finite-`intervalMs` defensive zero-sample guard
  preserved verbatim in `computeCoherentSample`.

Test file imports updated to point at the new module boundaries; no test
logic changes. Test invariant `fail: 0, skipped: 6` holds.

Closes issue #1936 item (i).

* refactor(meter-values): align banner and tighten JSDoc on new split modules (issue #1936 i)

Revert the copyright banner on the three files introduced by the split
(CoherentSampleComputer.ts, CoherentMeterValueBuilder.ts, index.ts) from
'Copyright ... 2021-2026' to 'Partial Copyright ... 2021-2025' to match
the sibling verbatim convention (AutomaticTransactionGenerator.ts,
ChargingStation.ts, CoherentMeterValuesManager.ts, PerformanceStatistics.ts).
Wider repo-wide banner year sweep to 2021-2026 is deferred to a separate
chore commit.

Tighten two hedge-worded JSDoc lines in CoherentSampleComputer.ts:
- 'Sample timestamp in milliseconds (typically Date.now())' now spells
  out 'callers pass Date.now() in production and a test-controlled clock
  in unit tests'.
- Defensive-guard rationale 'a template override or future dynamic
  supply could return 0' tightened to 'a template override may set
  voltageOut to 0'.

CoherentMeterValuesGenerator.ts is intentionally not touched here to
avoid four-way merge collisions with #1940 (Prng rename), #1941
(physics refinements h), #1942 (RegisterValuesWithoutPhases k), and
#1944 (EVSE-level template fallback g). Design-MAJOR findings (inverted
Computer → Generator dependency, widened intra-package exports,
'Generator' semantic misnomer) are documented in the PR body as a
post-stack-merge follow-up.

* refactor(meter-values): align banner to same-folder sibling convention (issue #1936 i)

Drop the 'Partial' prefix on the three files introduced by the split.
Round-1 aligned to parent-folder siblings (ChargingStation.ts,
AutomaticTransactionGenerator.ts, CoherentMeterValuesManager.ts) which
use 'Partial Copyright ... 2021-2025', but same-folder siblings
(EvProfiles.ts, Prng.ts, types.ts) use 'Copyright ... 2021-2025'
without the prefix. Aligning to same-folder convention restores
banner uniformity inside src/charging-station/meter-values/.

CoherentMeterValuesGenerator.ts (2021-2026 outlier) remains untouched
to avoid the four-way merge collision with #1940 / #1941 / #1942 /
#1944 — repo-wide banner sweep is deferred as documented.

* refactor(meter-values): unidirectional DAG for coherent split (issue #1936 i)

Address the design findings raised by post-split review by relocating
runtime state and PRNG plumbing to their responsibility owners:

- Move SessionRuntime, sessionRuntimes, getSessionRuntime to
  CoherentSampleComputer.ts as module-private (they exist only to cache
  the voltage-noise PRNG closure across samples, which is a physics
  concern, not a session-lifecycle concern).
- Move disposeCoherentSessionRuntime to CoherentSampleComputer.ts
  (exported for CoherentMeterValuesManager). Update the manager's
  direct import path accordingly.
- Move createStreamPrng to Prng.ts alongside deriveSeed / mulberry32 /
  hashLabel. It composes those three helpers and is a PRNG primitive,
  not a session helper.
- Rename CoherentMeterValuesGenerator.ts to CoherentSession.ts. After
  the moves the file owns only session identity (createCoherentSession,
  CreateSessionOptions), the strategy-gate type guard
  (isCoherentModeActive), and the root-seed resolver (resolveRootSeed).
  The 'Generator' name no longer describes the file — the entry point
  buildCoherentMeterValue lives in the builder.
- Align banner on the renamed file to same-folder convention (Copyright
  ... 2021-2025, matching EvProfiles / Prng / types).
- Update barrel and test imports.

Result: strictly unidirectional import DAG within meter-values/:

  types  ← { CoherentSession, CoherentSampleComputer,
             CoherentMeterValueBuilder, EvProfiles, Prng }
  Prng   ← { CoherentSession, CoherentSampleComputer }
  EvProfiles ← { CoherentSession, CoherentSampleComputer }
  CoherentSampleComputer ← CoherentMeterValueBuilder

CoherentSession no longer depends on any coherent sibling; Computer no
longer depends on Session (the pre-refactor Computer → Session inversion
is eliminated). Three previously-widened intra-package exports revert to
module-private (SessionRuntime, sessionRuntimes, getSessionRuntime);
ROUNDING_SCALE stays exported (Computer → Builder is the correct
direction). Barrel public surface unchanged.

Wire behavior and physics chain byte-identical — the move preserves
every function body verbatim, only relocating them. Test invariant
fail:0, skipped:6 preserved (2925 pass / 2931 total).

The four open PRs touching CoherentMeterValuesGenerator.ts (#1940 c,
#1941 h, #1942 k, #1944 g) will need manual rebase resolution on top
of this refactor — accepted trade-off per the maintainer's directive
to do the design work now rather than defer.

* docs(meter-values): apply round-3 review fixes (issue #1936 i)

- Retarget the stale '{@link ./CoherentMeterValuesGenerator}' reference
  in CoherentSampleComputer.ts @file JSDoc to describe the current state
  (physics chain + session-runtime WeakMap + disposeCoherentSessionRuntime
  teardown hook) instead of the pre-split historical evolution, per the
  AGENTS.md 'document current state; exclude historical evolution' rule.
- Restore the getSessionRuntime JSDoc dropped during the Generator →
  Computer move. Documents the load-bearing invariant that the
  voltage-noise PRNG closure must be cached across samples so the
  stream advances rather than restarting from the same seed each draw.
- Rename CoherentMeterValuesGenerator.test.ts to CoherentMeterValues.test.ts.
  Update @file header and describe() label to match the new subject —
  the test file now exercises CoherentSession, CoherentSampleComputer,
  CoherentMeterValueBuilder, and Prng; no single 'generator' module
  remains post-refactor.

* docs(meter-values): apply round-4 review fixes (issue #1936 i)

- Fix dangling '{@link ../../CoherentMeterValuesManager...}' at
  CoherentMeterValueBuilder.ts:287. From src/charging-station/meter-values/,
  '../../' resolves to src/ (target does not exist there); correct
  path is '../CoherentMeterValuesManager' (src/charging-station/). This
  matches the fixes already applied to the sibling occurrences in
  CoherentSampleComputer.ts:9 and CoherentSession.ts:37; Builder was
  missed during the round-3 sweep.
- Add 'satisfies readonly MeterValueMeasurand[]' to MEASURAND_EMIT_ORDER
  in CoherentMeterValueBuilder.ts. Matches the 'as const satisfies
  Record<...>' idiom already used by PHASE_FAMILY and PHASE_RANK.
  Gates against a typo-introduced non-measurand value at compile time.
- Expand the label-style '// EV acceptance from the curve at running
  SoC.' comment in CoherentSampleComputer.ts to document the load-bearing
  physics invariant: the taper MUST interpolate at session.socPercent
  (live state advanced by advanceEnergyRegister) rather than a constant
  or the initial SoC. Without this, a future maintainer could replace
  the live read with a constant, breaking the taper (P would not
  decrease as SoC rises, over-charging past the capacity clamp,
  violating INV-3).

* docs(meter-values): correct socPercent mutator reference in taper comment (issue #1936 i)

The round-4 physics comment at CoherentSampleComputer.ts:299 stated
that session.socPercent is advanced 'by the previous
advanceEnergyRegister tick', but advanceEnergyRegister (line 180)
only mutates connectorStatus energy registers — it does not touch
session.socPercent. The actual mutation happens at the end of
computeCoherentSample itself (line 354).

Retarget the comment's mutation-site reference to computeCoherentSample
and inline-reference the assignment below. This preserves the
load-bearing invariant that round-4 documented (taper must interpolate
at live SoC, not initial) while pointing the future maintainer to the
correct code location — a maintainer following the wrong pointer to
advanceEnergyRegister would find no session mutation and might
delete the 'stale' comment or replace the live read with a constant,
silently breaking the taper (P would not decrease as SoC rises →
over-charging past capacity clamp → violation of INV-3).

* docs(coherent): strip historical narrative from JSDoc + test prose (issue #1936 i)

Apply AGENTS.md 'document current state; exclude historical evolution'
across the coherent-mode surface (both this PR's meter-values files
and the CoherentMeterValuesManager landed in PR #1937):

- CoherentMeterValuesManager.ts:@file — 'Extracted from ChargingStation'
  narrated origin. Rewrite to 'Sits alongside ChargingStation' — same
  design rationale (keeping the strictly opt-in coherent surface off
  the main class body) without the historical framing.
- CoherentMeterValuesManager.ts:injectSession JSDoc — 'mirroring the
  seam previously exposed on ChargingStation' claimed the seam was
  no longer on ChargingStation, but __injectCoherentSession is still
  a live delegator on ChargingStation (points at manager.injectSession).
  Drop 'previously' — the seam mirrors the ChargingStation delegator.
- CoherentMeterValueBuilder.ts:@file — 'Extracted from the original
  single-file generator as part of the issue #1936 (item i) file
  split to keep each module under the 250 LOC ceiling' was pure
  historical narrative; drop the sentence entirely.
- CoherentMeterValueBuilder.ts:resolveTemplates JSDoc — 'tracked as a
  follow-up in issue #1936' was TODO-adjacent but redundant with
  git-blame trail. Keep the current-state limitation description
  (EVSE-level template inheritance needs getEvseStatus on the port).
- meter-values/index.ts:@description — 'Module layout after the
  issue #1936 (item i) split:' → 'Module layout:'.
- CoherentMeterValues.test.ts:@file — 'Exercises the split modules
  together' → 'Cross-module tests spanning'.
- CoherentMeterValues.test.ts:797 assertion — '(moved to module-scope
  runtime state)' → '(owned by module-scope runtime state in
  CoherentSampleComputer)'. Same invariant, current-state phrasing.

4 weeks agochore(deps): update all non-major dependencies (#1947)
renovate[bot] [Fri, 3 Jul 2026 12:52:19 +0000 (14:52 +0200)] 
chore(deps): update all non-major dependencies (#1947)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agorefactor(meter-values): extract CoherentMeterValuesManager, shrink ICoherentContext...
Jérôme Benoit [Thu, 2 Jul 2026 23:38:05 +0000 (01:38 +0200)] 
refactor(meter-values): extract CoherentMeterValuesManager, shrink ICoherentContext, split OCPP16 begin helper (issue #1936 items a, b, e) (#1937)

* refactor(meter-values): extract CoherentMeterValuesManager (issue #1936)

Extract per-station coherent MeterValues lifecycle owner from
ChargingStation into a dedicated singleton-per-station manager,
mirroring the multiton pattern of AutomaticTransactionGenerator /
IdTagsCache / SharedLRUCache (keyed by stationInfo.hashId).

The manager owns the EV profile file, the active-session Map, and
the create/destroy/inject lifecycle. ChargingStation keeps the four
public methods as thin delegators for API stability — external OCPP
handler call sites, the test helper mock, and existing tests are
unchanged.

Behavior is preserved:
- Sessions are still created only when coherentMeterValues=true AND
  a valid EV profile file loads.
- The __injectCoherentSession NODE_ENV production guard is preserved
  on the manager side (BaseError throw).
- Session runtime state is disposed at every reset/stop/disconnect
  path via destroySession, and at station stop/delete via dispose /
  deleteInstance.

Closes issue #1936 item (a).

* refactor(meter-values): thread coherent session, shrink ICoherentContext (issue #1936)

Drop `getCoherentSession` from `ICoherentContext`: the port now
exposes only what the physics chain needs to query about the station
itself. Session lookup moves to the caller (the strategy gate), which
looks up via `ChargingStation.getCoherentSession` — the A1 delegator
that routes through `CoherentMeterValuesManager` in production and
through the test-mock's own Map in tests.

Signature changes:
- `isCoherentModeActive(session): session is CoherentSession` — pure
  type guard replacing the two-tier (stationInfo + session-lookup)
  predicate. The stationInfo gate is implied by session existence
  (sessions are only created via the manager when
  `coherentMeterValues=true`).
- `buildCoherentMeterValue(context, session, ...)` — takes the session
  directly. The internal `context.getCoherentSession` lookup and the
  "missing session" warning branch collapse to a single connector-lookup
  guard.

Strategy gate call sites in `OCPPServiceUtils.buildMeterValue` and
`OCPP16ServiceUtils.buildTransactionBeginMeterValue` are threaded
accordingly.

Behavior preserved: sessions are still only routed to the coherent path
when they exist; random/fixed path is untouched. Test suite invariant
`fail: 0, skipped: 6` holds.

Closes issue #1936 item (b).

* refactor(ocpp16): extract buildCoherentTransactionBeginMeterValue (issue #1936)

Split OCPP16ServiceUtils.buildTransactionBeginMeterValue's dual
responsibility. The coherent short-circuit (route through
buildMeterValue with the vendor StartTxnSampledData override) moves to
a named private static helper; the outer function keeps only the
random/fixed default path plus the strategy gate.

The strategy gate in OCPP 1.6 now lives at a single well-labeled
boundary in this file, mirroring the pattern established by
OCPPServiceUtils.buildMeterValue for the periodic MeterValues path.

Behavior is unchanged: same measurand-key resolution, same
buildMeterValue re-dispatch, same TRANSACTION_BEGIN context.

Closes issue #1936 item (e).

* refactor(meter-values): add peekInstance to avoid phantom manager allocation (issue #1936)

PR-A round-1 Oracle review (lanes A + D converged on this MAJOR
finding): the strategy gate in `OCPPServiceUtils.buildMeterValue`
reaches `ChargingStation.getCoherentSession` unconditionally on every
MeterValue tick, and the delegator was routed through
`CoherentMeterValuesManager.getInstance` — a lazy-create factory.
Result: every station that ever emits a MeterValue allocated a
`CoherentMeterValuesManager` + Map, regardless of the `coherentMeterValues`
opt-in flag. This contradicted the file-header design contract that
'stations with the option off never allocate a manager'.

Fix: add `CoherentMeterValuesManager.peekInstance(cs)` — a lookup-only
sibling of `getInstance` that never constructs. Route the four
read/destroy/dispose sites through `peekInstance`:
- `ChargingStation.createCoherentSession` (opt-in station's manager
  is warmed up at initialize; non-opt-in stations return `undefined`
  without allocating).
- `ChargingStation.destroyCoherentSession`
- `ChargingStation.getCoherentSession` (the strategy-gate hot path).
- `ChargingStation.stop()` finally-block dispose.

Keep `getInstance` (create-if-missing) at:
- `ChargingStation.__injectCoherentSession` — production-guard
  BaseError throw must remain reachable if this test seam is
  accidentally called in prod.
- `ChargingStation.initialize` — the opt-in eager warm-up that surfaces
  EV-profile-file warnings at startup rather than at first transaction.

Also tighten `getInstance` JSDoc to state precisely that `undefined` is
returned iff `stationInfo.hashId` is not yet resolved (the sole failure
mode), and cross-reference `peekInstance` for read paths.

Behavior for opt-in stations is unchanged (warm-up allocates the same
manager, subsequent reads find it in the cache). Non-opt-in stations
no longer allocate. Test invariant `fail: 0, skipped: 6` holds.

Closes issue #1936 item (a) sub-finding from round-1 review.

* docs(meter-values): tighten getInstance/peekInstance JSDoc post round-2 review (issue #1936)

PR-A round-2 Oracle review (lanes A + B converged on JSDoc precision):

- Lane A: `getInstance` JSDoc still listed `createSession` as a caller,
  but the round-1 fix routed `createSession` through `peekInstance`.
  A future reader following the JSDoc could reintroduce the phantom-
  allocation bug at the exact site it was just patched.
- Lane B: JSDoc labeled `destroySession` and `stop()` dispose as
  "read-only paths". Both are actually mutations (Map.delete +
  PRNG-closure disposal). Reword to "paths that must not allocate a
  manager on behalf of non-opt-in stations".
- Lane A NIT: the eager-init invariant is load-bearing — if
  `ChargingStation.initialize` stops warming up the manager for opt-in
  stations, `createCoherentSession` silently no-ops. The invariant was
  implicit; make it explicit in `peekInstance` JSDoc.

Docs-only. No runtime change. Test invariant `fail: 0, skipped: 6` holds.

* fix(meter-values): propagate template reload to coherent manager (issue #1936)

The template file watcher and reset() path already flush sharedLRUCache,
idTagsCache, and OCPPAuthServiceFactory before calling initialize(). The
coherent manager was left behind: getInstance returned the cached
manager for the unchanged hashId, whose evProfiles were snapshotted at
first-construction, so runtime mutations of evProfilesFile or its file
contents did not take effect until process restart.

Wire reloadEvProfiles() into every initialize() invocation so template
mutations propagate. Symmetrically drop the manager entirely when the
coherentMeterValues opt-in flips true to false so cached sessions and
stale profile data do not leak across the config change.

* refactor(meter-values): gate injectSession, harmonize banner and JSDoc (issue #1936)

injectSession test seam now honors the stationInfo.coherentMeterValues
opt-in gate. Pre-refactor, isCoherentModeActive checked the flag on
every tick; post-refactor it trusts session existence. Without the gate,
a test could inject a session on a non-opt-in station and activate the
coherent wire path — contradicting the type-guard precondition.
Production is already protected by the NODE_ENV production throw.

Align copyright banner to the sibling verbatim form
('Partial Copyright Jerome Benoit. 2021-2025').

Harmonize JSDoc: peekInstance MUST-use list now includes createSession
(mirrors getInstance JSDoc); reloadEvProfiles fail-soft description
covers non-opt-in stations and any error; 'cache miss' prose replaced
with 'instances-map miss' to match the field name.

* refactor(meter-values): preserve in-flight coherent sessions across opt-in flag flip (issue #1936)

The previous initialize() logic dropped the CoherentMeterValuesManager
via deleteInstance() when coherentMeterValues flipped true to false.
That immediately disposed every in-flight coherent session — and, in
the template file watcher flow where initialize() runs before
stopAutomaticTransactionGenerator(), that produced mixed-provenance
TransactionEvent frames on OCPP 2.0.x: the Begin frame was coherent,
the Update/End frames after the flip fell through the strategy gate
to random path, and the CSMS saw a discontinuous transaction.

Remove the else branch. New sessions are already blocked by the
coherentMeterValues gate in createSession; existing in-flight
sessions drain naturally via destroyCoherentSession on transaction
end. Provenance is preserved for transactions started before the
flag flip, and the reloadEvProfiles propagation on the true branch
(the round-1 MAJOR fix) is unaffected.

* docs(meter-values): calibrate injectSession JSDoc for mock-helper reality (issue #1936)

The prior JSDoc claimed the opt-in guard means isCoherentModeActive
cannot be tricked into activating the coherent wire path by
injecting on a non-opt-in station. That is true only for the
production-backed injection path (through the real
CoherentMeterValuesManager.injectSession). Tests that mock
ChargingStation write to their own session store bypassing this
seam entirely, so the mock is responsible for enforcing its own
opt-in invariant where relevant. Calibrate the JSDoc scope
accordingly.

4 weeks agofeat(meter-values): coherent MeterValues generator (issue #40) (#1935)
Jérôme Benoit [Thu, 2 Jul 2026 17:26:49 +0000 (19:26 +0200)] 
feat(meter-values): coherent MeterValues generator (issue #40) (#1935)

* feat(meter-values): coherent MeterValues generator (issue #40)

Implements physics-coherent MeterValues (V->P->I->dE->SoC) gated by template
flag coherentMeterValues. Session lifecycle on ChargingStation with txId
snapshotted before resetConnectorStatus. Strategy gate after versioned
sampled-value dispatcher, before legacy random measurand generation.
Deterministic Mulberry32 PRNG with per-label stream splitting. New module
under src/charging-station/meter-values/. Golden invariants harness green.

Refs: #40

* test(meter-values): regression tests for Phase 4 findings (issue #40)

RED phase for M1..M4 findings from /tmp/issue-40/review-consolidated.md:
- M1: voltage PRNG must advance state across samples
- M2: deltaEnergyWh must be clamped to remaining battery capacity at 100% SoC
- M3-OCPP16: coherent session destroyed even if station stops during postTransactionDelay
- M3-OCPP20: same for OCPP 2.0 sibling path
- M4: stopEnergyWh assertion strengthened to remove self-reference tautology

Currently 4 tests fail (expected RED); M4 rewrite passes (strengthening only).

* fix(meter-values): address Phase 4 review findings (issue #40)

- M1: cache voltage PRNG on CoherentSession (was reconstructed each
  sample, producing a stalled seed sequence). PRNG state now advances
  across samples as documented.
- M2: clamp powerW to remaining battery capacity so a sample crossing
  100 % SoC cannot over-charge the register. Everything downstream
  (I, ΔE, register) is recomputed from clamped power, preserving
  INV-1 and INV-3.
- M3-OCPP16 / M3-OCPP20: destroy the coherent session BEFORE awaiting
  postTransactionDelay so an intervening station stop cannot leak the
  session. destroyCoherentSession is idempotent so the post-sleep
  path remains valid.

Regression tests (M1..M4): pass 23/23.
Full suite: pass 2908/0/6 skipped.

* fix(coherent-meter-values): floor reportedPowerW to remaining capacity + tighten M4 register cross-check

Phase 6 verification findings addressed:
- N1 (gpt-5.5 HIGH, opus MED): capacity-clamp fallback risked INV-1 breach.
  Investigation: CURRENT_ROUNDING_SCALE=2 keeps V*I within <=0.1 W of
  reportedPowerW on realistic mains, well under INV-1 tolerance (+/-1 W).
  Simplified: floor reportedPowerW to maxPowerFromCapacityW after utility
  recompute (absorbs float drift without touching currentA).
- V1-M4 secondary (sonnet 'partial'): assertion now reads MV[last] (was
  MV[2], tautological). Independent Sigma(P*dt) primary check unchanged.

* [autofix.ci] apply automated fixes

* fix(meter-values): address PR #1935 review findings (issue #40)

Consolidated fixes for all 30 findings from the multi-agent code review of
PR #1935. Preserves the coherent MeterValues feature scope; adds OCPP 2.0
end-to-end wiring; hardens correctness and idiomaticity.

Blocking (2):
- B1 fix INV-1 breach in capacity-clamp branch: derive current as exact
  fraction (P / (V·phases)), round at emit, then derive emitted P from
  rounded V·I·phases. Prior integer-amp rounding could inflate V·I·phases
  above the capacity-clamped power by up to V·phases·0.5 W. New AC 3-phase
  and DC regression tests lock the invariant.
- B2 wire OCPP 2.0.1 support: add createCoherentSession call in
  OCPP20ResponseService.handleResponseTransactionEvent (Started case),
  mirroring OCPP 1.6. New 5-scenario integration test covering Accepted,
  implicit Accept, rejected idToken, force-override, and non-Started events.

High (4):
- H1 clear coherentSessions in ChargingStation.stop() finally; dispose
  runtime state per session before delete.
- H2 README: add three template-parameter rows (coherentMeterValues,
  evProfilesFile, randomSeed) and a new 'EV profile file format' subsection
  documenting the ev-profiles-template.json schema.
- H3 strip process residue: remove /tmp/issue-40/* references (3 files),
  'Phase 2 merged finding #N' and 'Fix Phase 4 M-N' markers (8+ locations);
  replace with technical rationale.
- H4 label INV-1/INV-2/INV-3 in the class-level JSDoc using the
  PR-body-canonical numbering (INV-1=V·I=P, INV-2=SoC monotone,
  INV-3=ΔE=P·Δt). Remove undefined INV-4 reference.

Medium (13):
- M1 DRY resolveRootSeed via hashLabel (byte-equivalent, test-locked).
- M2 move voltagePrng runtime state off CoherentSession to a module-scope
  WeakMap keyed on session identity (no cross-station coupling); add
  disposeCoherentSessionRuntime wired into destroyCoherentSession + stop().
- M3 collapse five identical rounding scales into a single ROUNDING_SCALE.
- M4 add explanatory comment on the boundary 'as MeterValue' cast
  (OCPP16/20 SampledValue.context enums structurally diverge).
- M5 correct Prng.ts JSDoc: 'SplitMix32-derived' -> 'Mulberry32 + FNV-1a'.
- M6 remove 'byte-identical' over-claims; use 'reproducible' / 'identical'.
- M7 defensive early-return zero-sample when intervalMs <= 0 to prevent
  NaN contamination when SoC has already saturated.
- M8 trim meter-values/index.ts barrel from 22 to 11 externally-consumed
  symbols.
- M9 mark 7 immutable CoherentSession fields readonly.
- M10 add ChargingStation.injectCoherentSession() public method and use it
  in 3 test sites, replacing 'station as unknown as { coherentSessions }'
  private-field injection.
- M11 fix 'transaction id' -> 'transactionId' in Prng.ts comment.
- M12 replace global Date.now monkey-patch with per-iteration
  mock.method(Date, 'now', ...) from node:test.
- M13 remove dead chargingProfileLimitW parameter
  (getConnectorMaximumAvailablePower already folds in charging profiles).

Low / Nit:
- C4 clear-on-stop (covered under H1).
- C5 XOR-commutativity in deriveSeed: deferred with explanatory comment
  (birthday bound ~2^16 well beyond simulator scale; a non-commutative mix
  would desync existing golden tests).
- D-7 rename prng.ts -> Prng.ts (and prng.test.ts -> Prng.test.ts) to
  match repo PascalCase filename convention.
- D-8 move getEvProfilesFile from EvProfiles.ts to Helpers.ts next to
  getIdTagsFile.
- D-9 fix resolveTemplates JSDoc: remove false 'mirrors EVSE lookup' claim.
- D-11 CoherentMeterValuesDefaults now exposes all tunable constants.
- D-18 use AvailabilityType.Operative enum instead of string cast.
- I1 auto-resolved by B1 (ROUNDING_SCALE=2 now semantically meaningful
  for current).
- T2 use getErrorMessage() (repo convention) instead of (error as Error).
- S2 simplify templatesFor test helper — remove 'as unknown as { unit }'
  casts.
- S5 consolidate duplicate BuildVersionedSampledValueFn type into the
  canonical BuildVersionedSampledValue exported from meter-values.

Verification:
- pnpm lint      0 errors, 0 warnings
- pnpm typecheck 0 errors
- pnpm test      2918 pass / 0 fail / 6 skipped
- New regression tests locking B1 (INV-1 clamp AC3+DC), M7 (NaN guard),
  M2 (session hygiene + WeakMap dispose), M1 (hashLabel equivalence),
  B2 (OCPP 2.0 session wiring, 5 scenarios).

Closes #40

* fix(meter-values): address PR #1935 round-2 review findings (issue #40)

Consolidated fixes for all Medium+Low+Nit findings from the second
multi-agent review round of PR #1935. Two Blocking findings (S1 OCPP 1.6
signed-meter-values wrapper, S2 begin MeterValue routing) — S2 implemented,
S1 deferred to a follow-up (post-hoc signing in startUpdatedMeterValues
already covers the OCPP 1.6 periodic path; the remaining gap is only
TriggerMessage/broadcast callsites, best served by a dedicated PR with a
proper signing-key test fixture).

## Blocking (1 of 2 addressed; other deferred)

- **S2 (implemented)** — `Transaction.Started` / `Transaction.Begin`
  MeterValue is now generated by the coherent path. Made
  `ChargingStation.createCoherentSession` idempotent so early
  request-builder creation is safe; reordered OCPP 2.0 flows
  (`OCPP20ServiceUtils.startTransactionOnConnector`,
  `OCPP20IncomingRequestService` RequestStartTransaction handler) to
  create the session BEFORE `buildTransactionStartedMeterValues`;
  reordered OCPP 1.6 flow (`OCPP16ResponseService.handleResponseStartTransaction`)
  and added a coherent-mode branch to
  `OCPP16ServiceUtils.buildTransactionBeginMeterValue` that routes
  through `buildMeterValue` when a session is live.

- **S1 (deferred)** — OCPP 1.6 signed-meter-values wrapper for the
  coherent path. Rationale: `startUpdatedMeterValues` in
  `OCPP16ServiceUtils` applies post-hoc signing after `buildMeterValue`
  returns, so the OCPP 1.6 periodic coherent path IS signed today. The
  actual gap is only the TriggerMessage / worker-broadcast callsites
  where signing is skipped. Fixing it cleanly requires a signing-key
  test fixture that is best set up in a dedicated PR.

## Medium (8)

- **M-01/M-02/M-03/M-07 combined defensive-guard block**
  (`computeCoherentSample`): early-return zero-sample when `intervalMs ≤ 0`
  (existing), `batteryCapacityWh ≤ 0` or non-finite (M-01), or
  `voltageOut ≤ 0` or non-finite (M-02). `rampUpDurationMs` guard now
  requires `> 0 && Number.isFinite(...)` (M-03). Prevents NaN poisoning
  and INV-1/INV-3 incoherence across the four sources.
- **M-04** — Reverted the proposed Zod refinement for sorted `chargingCurve`
  after design analysis showed `loadEvProfilesFile`'s in-place sort is
  the authoritative defense and the refinement would break the loader's
  "accepts unsorted, sorts in place" contract. Documented rationale in
  `EvProfileSchema` JSDoc.
- **M-06** — Renamed `ChargingStation.injectCoherentSession` →
  `__injectCoherentSession` (dunder-prefix test-seam convention);
  tightened mock parameter type from `unknown` to `CoherentSession`;
  updated the 3 test call sites.
- **M-07** — Fixed the determinism claim in README.md and PR body:
  `interval` is a physics parameter, not a PRNG seed input. New prose
  makes this explicit.
- **M-08** — Coherent path now respects OCPP 2.0.1 `SampledDataCtrlr.TxUpdatedMeasurands`
  / `TxEndedMeasurands` / `TxStartedMeasurands` and OCPP 1.6
  `MeterValuesSampledData`. Added `resolveEnabledMeasurands` helper in
  `OCPPServiceUtils.ts` and threaded a `ReadonlySet<MeterValueMeasurand>`
  allow-list into `buildCoherentMeterValue`. Governs J02.FR.11 / E02.FR.09
  / E06.FR.11.

## Deferred to follow-up issue (3)

- **M-05** — Extract `CoherentMeterValuesManager.getInstance(chargingStation)`.
  Sibling per-station concerns (AutomaticTransactionGenerator, IdTagsCache,
  SharedLRUCache) use the multiton pattern; the coherent module currently
  keeps state on `ChargingStation`. Not a correctness issue; architectural
  refactor best done standalone.
- **N-03** — Blocked on M-05 (semantic circularity in `ICoherentContext.getCoherentSession`
  cleaned up naturally when the manager owns the session store).
- **N-04** — `Prng.ts` filename kept as-is; renaming to `PRNG.ts` on a
  case-insensitive macOS FS would require a second two-step rename with
  no functional benefit.

## Low/Nit (10)

- **N-01** — Dropped `disposeCoherentSessionRuntime` from the
  `meter-values/index.ts` barrel; direct sub-module import in
  `ChargingStation.ts` (barrel exposes only externally-consumed symbols).
- **N-02** — Rephrased the `Fix B1:` process-comment in
  `CoherentMeterValuesGenerator.ts` to invariant-only technical rationale
  (H3 miss from the previous round).
- **N-05** — Renamed voltage locals in `computeCoherentSample`:
  `voltageNominal`/`voltageV`/`roundedVoltage` →
  `nominalV`/`sampledV`/`roundedV` (fewer names for the same quantity).
- **N-06** — Added defensive `destroyCoherentSession` in the OCPP 2.0
  `handleResponseTransactionEvent` `Ended` branch when connectorId is
  unknown (symmetric with OCPP 1.6 defensive destroy in
  `handleResponseStopTransaction`).
- **N-07** — Tightened `deriveSeed` JSDoc with quantified birthday
  bound: expected collisions ≈ N²/2^33; at simulator scale (≤ 5×10⁴
  pairs) ≈ 0.3 — negligible.
- **N-08** — Tightened AC1/AC3/DC test tolerances from ≤ 1/3/1 W to
  ≤ 0.01 W (post-Option D tight bound is 0.005 W).
- **N-09** — Uniform `getErrorMessage(error)` in EvProfiles.ts
  ZodError branch (was direct `error.message`, inconsistent with the
  else branch).
- **N-10** — Removed dead exports `CoherentMeterValuesDefaults` and
  `mixSeed` (grep-verified zero external usages).
- **N-11 F-04** — `@file` tag "MeterValue generator" (singular) →
  "MeterValues generator" (plural OCPP term).
- **N-11 F-07** — README schema example id aligned with the actual
  `ev-profiles-template.json` (`compact-ev-40kwh` → `city-ev-40kWh`).
- **N-11 F-08** — README documents the `initialSocPercentMin` ≤
  `initialSocPercentMax` swap-and-warn behavior.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2878 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): address PR #1935 round-3 review findings (issue #40)

Consolidated fixes for all findings from the third multi-agent review round.
No blocking findings remain; two content-lane blocks (barrel count, README
example) plus 4 cross-validated HIGH nits addressed.

## HIGH (4)

- **H1** — Correct false JSDoc claim at `ChargingStation.ts:319`. The
  `__injectCoherentSession` docstring stated the `__` prefix was enforced
  by a `no-restricted-syntax` ESLint rule; no such rule exists. Reworded
  to "convention only — not currently enforced by a lint rule".
- **H2** — Trim 4 dead type re-exports from `src/charging-station/index.ts`
  (`ChargingCurvePoint`, `EvProfile`, `EvProfilesFile`, `ICoherentContext`).
  Grep-verified zero external consumers. `CoherentSession` kept (used by
  4 test files). Barrel is now honest about its "only externally-consumed
  symbols" contract.
- **H3** — Sync README `city-ev-40kWh` schema example to
  `src/assets/ev-profiles-template.json` exactly: `maxPowerW` 7400→11000,
  `weight` 1.0→3, `initialSocPercentMin` 10→15, `initialSocPercentMax`
  80→55, 3-point curve → 4-point taper. First-time readers now see the
  same values in both docs.
- **H4** — Validate CSV entries in `resolveEnabledMeasurands`
  (`OCPPServiceUtils.ts`). `Object.values(MeterValueMeasurand)`-membership
  guard drops unknown entries and logs a per-station-debounced warning.
  Prevents silent typo tolerance (e.g. `Voltege` in config CSV) while
  accepting every OCPP-defined measurand (unlike the narrower
  `isMeasurandSupported` allowlist).

## MED (3)

- **M1** — Thread `TxUpdatedMeasurands` into `buildMeterValue` at both
  OCPP 2.0 sites that previously bypassed it: `OCPP20ServiceUtils.ts`
  `startUpdatedMeterValues` (periodic path) and
  `OCPP20IncomingRequestService.ts` `TriggerMessage(MeterValues)` handler.
  Closes the J02.FR.11 spec gap where CSMS-configured measurand filters
  were ignored on the periodic Updated path.
- **M2** — Debug-only sortedness assertion in `interpolateChargingCurve`
  (`EvProfiles.ts`). Production path (`loadEvProfilesFile`) sorts in
  place; the assertion catches misuse from the `__inject*` test seam
  (unsorted curves silently returned the tail power-fraction — a hidden
  test footgun). `process.env.NODE_ENV !== 'production'` gate keeps
  runtime cost at zero in production.
- **M3** — Session-leak safety net at 2 OCPP 2.0 request-time create sites:
  `OCPP20ServiceUtils.ts` `startTransactionOnConnector` wraps
  `sendTransactionEvent` in `try/catch`; `OCPP20IncomingRequestService.ts`
  `RequestStartTransaction` handler augments the existing `.catch` chain.
  Both call `destroyCoherentSession(txId)` before re-throwing/logging,
  symmetric with the OCPP 1.6 `resetConnectorOnStartTransactionError`
  pattern. Prevents session Map growth on WebSocket-send rejection.

## Content + Low/Nit

- **P-01** — Extend the defensive guard block in `computeCoherentSample`
  with `!Number.isFinite(options.nowMs)`. Matches the pattern for the
  other 4 guarded inputs. Not reachable in production (`Date.now()`
  always finite) but closes the test-injection hole.
- **P-02** — Document `rampUpDurationMs = Number.EPSILON` equivalence
  with 0 (both collapse to immediate full-power). Comment only.
- **D-05** — Add `logger.warn` to the OCPP 2.0
  `handleResponseTransactionEvent` Ended-with-unknown-connector branch,
  mirroring the OCPP 1.6 diagnostic parity at
  `OCPP16ResponseService.ts:534-536`.
- **N-03** — Expand `ComputeSampleOptions.voltageNoise` inline comment
  into a full JSDoc with `@remarks` and `@defaultValue`.
- **N-04** — Document the WHY for `createCoherentSession` idempotency
  (OCPP 2.0.x has two call sites per transaction from S2 fix).
- **N-05** — Document the `<quantity><Unit>` naming convention on
  `CoherentSample` (all fields follow `currentA`/`powerW`/`voltageV`).

## Deferred with rationale (documented decisions)

- **A6** — `buildTransactionBeginMeterValue` reads
  `connectorStatus.transactionId` internally rather than accepting an
  explicit param. Single call site, safe mutation ordering already
  documented; adding a param would add noise for zero safety gain.
- **D-03** — `buildTransactionBeginMeterValue` dual-responsibility
  (coherent short-circuit + legacy path) tracked in follow-up issue
  #1936 as new M-06 item.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2899 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): address PR #1935 round-4 review findings (issue #40)

Consolidated fixes for all findings from the fourth multi-agent review round
(5 parallel reviewers: Oracle elegance/TS · Oracle math/physics · general
OCPP-spec-via-qmd · explore harmonization · general content/terminology).
Design phase cross-validated 5 architecturally-loaded decisions via Oracle.

## HIGH (6)

- **H1 [R4C]** — Thread `OCPP20ReadingContextEnumType.TRIGGER` into the
  `TriggerMessage(MeterValues)` handler at `OCPP20IncomingRequestService.ts:611`.
  Emitted `SampledValue.context` now correctly labels values taken in
  response to `TriggerMessage` per OCPP 2.0.1 §3.66 ReadingContextEnumType;
  previously defaulted to `Sample.Periodic`.

- **H2 [R4C]** — Presence-aware `resolveEnabledMeasurands` semantics in
  `OCPPServiceUtils.ts`. Discriminates key-absent (defaults to
  `{Energy.Active.Import.Register}` for ergonomic parity) from key-present
  (honors the CSV verbatim, including empty ⇒ empty allow-list per
  OCPP 2.0.1 J02.FR.11). Removes the unconditional force-add at line 1115.

- **H3 [R4C]** — Per-phase measurand emission in `buildCoherentMeterValue`.
  Restructured to iterate all templates per measurand (was: first-matching
  only) with phase-aware value resolution:
  `Voltage L-N ⇒ V`; `L-L ⇒ √phases × V`.
  `Power L-N ⇒ P/phases`; L-L unsupported (log-and-skip).
  `Current line ⇒ sample.currentA` (balanced 3-φ Y).
  `SoC / Energy.Active.Import.Register` phase-qualified templates rejected.
  Emit order: `SoC → V → P → I → Energy` across measurands; within a
  measurand: `no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 →
  L3-L1 → N → other`. Closes legacy-parity regression for 3-phase stations
  with phase-qualified templates.

- **H4 [R4D]** — `throw new BaseError(...)` in `EvProfiles.ts`
  `interpolateChargingCurve` (was: bare `Error`), aligning with AGENTS.md
  TypeScript conventions on typed errors.

- **H5 [R4D]** — `assert.ok(cs != null, 'Expected connector 1 to exist')`
  in `OCPP20ServiceUtils-PostTransactionDelay.test.ts` (was: `throw new
  Error`), matching the pattern used elsewhere in the test suite.

- **H6 [R4E]** — README §EV profile file format now documents the
  connector-level-only `MeterValues` template resolution scope under
  coherent mode (EVSE-level inheritance not applied; tracked as follow-up).

## MED (12)

- **M1 [R4A]** — `computeCoherentSample` now takes the resolved
  `session` as a parameter (was: fetched twice via `context.getCoherentSession`).
  Removes 2 unreachable defensive branches (\~25 LOC) and tightens the
  contract into a pure physics function.

- **M2 [R4C]** — OCPP 1.6 `buildTransactionBeginMeterValue` threads
  vendor param `StartTxnSampledData` when configured, falling back to
  `MeterValuesSampledData` when absent — per the OCPP 1.6 Signed Meter
  Values whitepaper.

- **M3 [R4D]** — Move `MS_PER_HOUR` and `UNIT_DIVIDER_KILO` to
  `Constants` class (`src/utils/Constants.ts`). Was duplicated as
  file-scope constants in `OCPPServiceUtils.ts` and
  `CoherentMeterValuesGenerator.ts`. All references now use
  `Constants.MS_PER_HOUR` / `Constants.UNIT_DIVIDER_KILO`.

- **M4 [R4D]** — Move `VOLTAGE_NOISE_PERCENT` to
  `Constants.DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT`. Tunables belong in
  the canonical defaults map per AGENTS.md.

- **M5 [R4D]** — Move `DEFAULT_RAMP_UP_DURATION_MS` to
  `Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS`. Same rationale.

- **M6 [R4D]** — Merge same-specifier `import type` statements in
  `types.ts` and `EvProfiles.ts` (was: 2 statements each for the same
  module). Aligns with the project's `import/no-duplicates` /
  `verbatimModuleSyntax` convention.

- **M8/M11 [R4D]** — `describe('Prng', ...)` and
  `describe('StrategyDispatch', ...)` renames to match the
  module-name-only convention in `tests/TEST_STYLE_GUIDE.md`.

- **M9 [R4E]** — README precision: "emitted measurand list" no longer
  claims `ΔE` is emitted (it is an internal per-sample intermediate);
  INV-1 spelled out per current-type (`P = V·I·phases` for AC, `P = V·I`
  for DC).

- **M10 [R4E]** — Rewrite forward-looking comment at
  `OCPP16ServiceUtils.ts:152-158` to describe the current-branch semantics
  (`StartTxnSampledData` override with fallback to
  `MeterValuesSampledData`) instead of the deferred S1 signing wiring.

## LOW/NIT batch

- **R4A-LOW-01** — `advanceEnergyRegister` rewritten with
  `Math.max(0, ... ?? 0)` — one-expression clamp-and-init (was: two-step
  nullish-then-negative guard, ~14 LOC → 6 LOC).

- **R4A-LOW-02** — 3 near-identical `CoherentSample` zero-literal returns
  in `computeCoherentSample` collapsed to a single `buildZeroSample`
  helper. Two of the three defensive branches also removed by M1.

- **R4A-NIT-01** — `findTemplate` replaced by `groupTemplatesByMeasurand`
  (needed for H3 per-phase iteration anyway); legacy `for..of` scan is now
  gone.

- **R4B-LOW-01/02** — INV-1/INV-3 docstring precision: emit-time rounding
  bound stated as "`ROUNDING_SCALE` half-width (\~0.005 W scalar)"; INV-3
  divergence bound quantified (\~0.12 Wh over 24 h at 1 Hz).

- **R4B-LOW-05** — `EvProfileSchema` JSDoc documents monotone-non-increasing
  `powerFraction` as a caller responsibility (not schema-enforced;
  real curves are flat through CC before tapering).

- **R4B-NIT-06** — AC `numberOfPhases <= 0` now triggers the zero-sample
  defensive branch (was: silently produced `P = 0` via
  `divisor = V × 0 = 0`).

- **R4B-NIT-07** — `interpolateChargingCurve` JSDoc documents the
  closed-closed interval semantic (left segment wins at interior nodes).

- **R4D-LOW-06/07/08** — `StationHelpers.ts` mock: `coherentSessions`,
  `createCoherentSession`, `getCoherentSession` return types tightened
  from `unknown` to `CoherentSession | undefined` (and
  `Map<number | string, CoherentSession>`).

- **R4D-NIT-03/04/05/06** — `meter-values/index.ts` barrel comment now
  explicitly enumerates the intentionally-omitted internal exports
  (`computeCoherentSample`, `advanceEnergyRegister`, `createStreamPrng`,
  `disposeCoherentSessionRuntime`, option-bag types, EvProfiles helpers,
  Prng primitives, Zod schemas).

- **R4E-LOW-05** — `Prng.ts` header no longer claims a specific LOC
  bound ("kept intentionally small").

- **R4E-LOW-06** — Expanded JSDoc on `ComputeSampleOptions` and
  `CreateSessionOptions` interfaces (per-field semantics, units, defaults).

## Deferred to follow-up #1936 (documented rationale)

- **M7 [R4D]** — `StationHelpers.ts` (954 LOC) modular split. Structural
  refactor with 30+ test file consumers; TODO comment added at the top of
  the file linking to #1936. Detailed split sketch documented in the
  design phase.

- **H6 code-side** — Extend `ICoherentContext` with `getEvseStatus` to
  restore EVSE-level template inheritance. Round-4 lands docs-only.

- **R4B-LOW-03/04** — Physics model design notes (`cos φ = 1` assumption,
  linear-vs-S-curve ramp shape). Not blockers.

## Non-findings (verified compliant)

- OCPP 2.0.1 `TxUpdatedInterval` correctly wired for periodic scheduling.
- `Transaction.Begin` / `Transaction.End` enum literals correct.
- Coherent path does not spoof `signedMeterValue`; signing gate intact.
- Coherent path does not bleed into `AlignedDataCtrlr` /
  `ClockAlignedDataInterval` handling.
- Existing hyphenated test file names (`OCPP16-CoherentMeterValues.test.ts`
  etc.) match the `ChargingStation-Configuration.test.ts` sub-domain
  precedent — not a divergence.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2920 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): OCPP-spec-strict emit + per-phase register + polish (issue #40)

## OCPP 2.0.1 spec-strict emit shape

- `TransactionEvent(Updated)` no longer serializes an empty `meterValue`
  wrapper when `TxUpdatedMeasurands` resolves to an empty allow-list.
  Periodic `startUpdatedMeterValues` short-circuits; TriggerMessage
  (MeterValues) active-tx branch sends the event without the `meterValue`
  field. Matches OCPP 2.0.1 J02.FR.11 ("no meter values are sent") and
  fixes the JSON-schema `minItems: 1` violation on empty sampledValue.

## OCPP 1.6 whitepaper composition (StartTxnSampledData × beginEndMeterValues)

- `OCPP16ResponseService` gates the extra `MeterValues.req` sends at
  both the start (`beginEndMeterValues`) and end (`outOfOrderEndMeterValues`)
  branches on `isNotEmptyArray(sampledValue)`. Composes cleanly with the
  Signed Meter Values whitepaper §3.3.4 rule (`StartTxnSampledData`
  present + non-empty) and the legacy `MeterValuesSampledData` fallback:
  both empty ⇒ no send, avoiding a schema-suspect empty sampledValue
  wrapper.

## Coherent path — per-phase physics polish

- **Per-phase Energy.Active.Import.Register**: L-N templates now emit
  `register / phases` (balanced 3-φ Y contribution per phase); L-L and
  `N` phases skipped with a warn. OCPP 2.0.1
  `SampledDataCtrlr.RegisterValuesWithoutPhases` config-driven suppression
  not consulted (documented follow-up).
- **Neutral phase**: new `Neutral` phase-family classifier distinguishes
  bare `N` from `LineToNeutral` (`L1`/`L1-N`/etc.). `N`-qualified
  Voltage and Current templates emit 0 (balanced 3-φ Y: I_N = 0 by
  phasor sum, V_N = 0 by reference-node definition).
- **L-L voltage on 1-phase**: log-and-skip. Previously the `√1 × V`
  degeneracy silently emitted nominal V under an L-L label.
- **L-N power on phases≤0**: log-and-skip (previously fell through to
  aggregate power under a per-phase label — unreachable in practice due
  to the outer defensive guard but semantically inconsistent).
- **Register clamp sync**: `preRegisterWh` in `computeCoherentSample`
  applies `Math.max(0, ... ?? 0)` matching `advanceEnergyRegister`;
  reported `sample.energyRegisterWh` and post-advance persisted state
  now agree even under corrupted negative register state.

## Elegance + TS state-of-the-art

- Extract `resolveUnitDivider(measurand, unit)` +
  `KILO_UNIT_BY_MEASURAND` at module scope, removing the inline
  `(A && B) || (C && D)` boolean at the emit site.
- `PHASE_RANK` converted from `ReadonlyMap` to object literal with
  `as const satisfies Record<MeterValuePhase, number>` for
  compile-time exhaustiveness; `phaseRank`'s runtime fallback dropped.
- `resolvePhasedValue` returns exact fractions (no internal rounding);
  rounding happens once at the emit site after unit-divider scaling.
  Removes double-rounding asymmetry between aggregate and per-phase paths.
- `groupTemplatesByMeasurand` uses `Map.groupBy` (Node 22+) in place of
  the hand-rolled loop; per-bucket phase sort preserved.
- Merged split `import type { ConnectorStatus }` with sibling type-import
  from the same specifier.

## Documentation

- README §Phase-qualified measurands: "nominal V" → "sampled V"
  everywhere (the coherent path emits `sample.voltageV`, i.e.
  post-noise rounded voltage — not the station's nominal); documents
  `N` phase behavior and per-phase Energy register emission.
- `resolvePhasedValue` JSDoc updated to match implementation.
- `ComputeSampleOptions` and `CreateSessionOptions` JSDoc converted
  from bullet-list style to inline per-field JSDoc (matching
  `CoherentSession` and `ICoherentContext` in `types.ts`).
- `types.ts` file header rewritten to state the current interface
  contract instead of deferred tooling-contingency rationale.
- `Constants` docstrings for coherent tunables /
  `MS_PER_HOUR` / `UNIT_DIVIDER_KILO` shortened to one-liners matching
  the concision of sibling entries.
- `CoherentMeterValuesGenerator.ts` header carries a follow-up TODO
  for the 250-LOC ceiling exceedance (split scope documented).
- Explanatory comment on the module-scope `warnedInvalidMeasurands`
  WeakMap in `OCPPServiceUtils.ts` distinguishing its GC-keyed intent
  from a true singleton.
- Internal-only comment on `VersionedSampledValueDispatch` interface.

## Tests

- New per-phase emission integration test: 3-phase AC session with
  L-N/L-L voltage, aggregate + per-phase Power, L1/N Current, and L-N
  Energy templates; asserts V_L1_N=230, V_L1_L2≈√3·230, Σ per-phase P
  ≈ aggregate P, I_N=0, L-L voltage skipped on 1-phase, L-N energy
  register emitted as `register / phases`.
- `describe('OCPP 1.6 coherent MeterValues integration')` renamed to
  `describe('OCPP16CoherentMeterValues')` (module-name convention).
- OCPP 1.6 integration test replaces its local `MS_PER_HOUR = 3_600_000`
  literal with a `Constants.MS_PER_HOUR` alias (canonical source).
- Removed leaked internal-review annotations from 9 describe/it labels
  (`(regression: ...)` suffixes) — these violated documentation
  timeliness by embedding non-behavioral session metadata into the
  behavioral test tree.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2923 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): periodic-event omission + Energy L-N alignment + polish (issue #40)

## OCPP 2.0.1 spec-strict emit shape

- Periodic `TransactionEvent(Updated)` no longer drops the whole message
  when `TxUpdatedMeasurands` resolves empty. Sends the event with the
  `meterValue` field omitted, matching the R5 fix already in place on the
  `TriggerMessage(MeterValues)` active-tx branch. Preserves the periodic
  heartbeat cadence at the OCPP level while honoring J02.FR.11
  ("no meter values are sent") — the message is still delivered, only
  the sampled-value payload is elided.

## Coherent path — phase-degeneracy symmetry

- `Energy.Active.Import.Register` L-N templates on `phases <= 0` now
  log-and-skip (return `undefined`), matching `Power.Active.Import`'s
  behavior for the same misconfiguration. Previously the register branch
  fell through to emit the aggregate under a per-phase template label.

## Elegance + TS state-of-the-art

- `resolvePhasedValue` signature narrowed from
  `(measurand, template, sample, phases, connectorStatus)` to
  `(measurand, phase, sample, phases, connectorStatus)`. The function
  only reads `template.phase`; taking the whole template widened the
  coupling. Caller passes `template.phase` at the loop head.
- `resolveUnitDivider` gains a JSDoc block (sole helper in the file that
  previously lacked one) and reorders the guard to
  `unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit` for
  intent-first reading ("only kilo-divide when a unit is present").
- `LEGACY_EMIT_ORDER` renamed to `MEASURAND_EMIT_ORDER`. The constant
  is the canonical emission order of the coherent path, not a
  compatibility shim; the JSDoc note preserves the "mirrors the legacy
  path" provenance.
- `buildZeroSample` now owns all rounding for the zero-sample path.
  Callers pass raw `socPercent` / `voltageV`; the helper applies
  `roundTo` internally. Rounding-responsibility is unified in one place.

## Documentation

- INV-1 docstring documents both the aggregate residual (≤ 0.005 W) and
  the per-phase L-N residual (≤ 0.01 W = 2 × `ROUNDING_SCALE` half-width),
  quantifying the two-step rounding bound (aggregate emit + per-phase
  division).
- `resolvePhasedValue` JSDoc documents the per-phase Energy register
  conservation bound: Σ across all L-N templates equals the aggregate
  register within emit-unit rounding granularity (Wh: ≤ phases · 0.005 Wh;
  kWh: ≤ phases · 5 Wh).
- `VersionedSampledValueDispatch.signingState` JSDoc: unresolvable
  `{@link buildVersionedSampledValue}` replaced with plain prose
  (the reference is a local closure, not an exported symbol).
- `resolveEnabledMeasurands` `@param measurandsKey` prose clarified:
  `undefined` (or omitted) defaults to `StandardParametersKey.`
  `MeterValuesSampledData` for OCPP 1.6; returns `undefined` (no filter)
  for all other versions.
- `meter-values/index.ts` barrel comment simplified. Dropped the
  enumerated intentionally-omitted list (drifts with internals); kept
  the intent sentence only.
- `ChargingStation.createCoherentSession` JSDoc: dropped the
  request-builder/response-handler call-site narrative; kept the API
  contract ("idempotent; returns `undefined` when coherent mode is
  disabled or no valid EV profile file is loaded").

## README

- `§Template resolution scope`: dropped the internal
  `getSampledValueTemplate` helper name and the backlog-tracking sentence.
  Documents current behavior only (connector-level templates, no
  EVSE-level inheritance under coherent mode).
- `§Phase-qualified measurands`: dropped the "tracked as a follow-up"
  tail on the `RegisterValuesWithoutPhases` note.
- Charging station template configuration table: `three phased` →
  `three-phase`, `line to line voltage` → `line-to-line voltage`
  (hyphenation consistent with the rest of the docs).

## Tests

- Added mandatory `afterEach(() => standardCleanup())` block to
  `CoherentMeterValuesGenerator.test.ts`, `EvProfiles.test.ts`, and
  `Prng.test.ts` per `tests/TEST_STYLE_GUIDE.md` §Mandatory Cleanup.
- `describe('OCPP20ServiceUtils — PostTransactionDelay')` renamed to
  `describe('OCPP20ServiceUtilsPostTransactionDelay')` — module-name
  concatenated form matching the existing coherent-test naming.
- `describe('B2 - OCPP 2.0.1 TransactionEvent Started → coherent session
  wiring')` renamed to `describe('OCPP20ResponseServiceCoherentSession')`
  — module-name-only form; the descriptive/spec-prefix suffix was
  redundant with the inner `it` labels.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2920 pass / 0 fail / 6 skipped

Refs #40

* [autofix.ci] apply automated fixes

* refactor(meter-values): phase-family Record + shared test interval constant (issue #40)

## phaseFamily as an exhaustive Record

- Convert `phaseFamily`'s switch statement to a `PHASE_FAMILY` object
  literal with `as const satisfies Record<MeterValuePhase, ...>`,
  matching the `PHASE_RANK` pattern already established for phase
  ranking. Adding a new `MeterValuePhase` enum value now fails compile
  at the table declaration until the value is classified — the same
  compile-time gate PHASE_RANK enforces.
- The `phaseFamily` function becomes a one-liner:
  `phase == null ? 'Aggregate' : PHASE_FAMILY[phase]`.
  `Aggregate` remains the sentinel for the undefined-phase case.
- Removes the defensive `default: return 'Aggregate'` branch that
  silently absorbed unclassified enum values.

## phaseRank inlined

- Drop the `phaseRank` helper (used at exactly one call site) and
  inline the null-check + `PHASE_RANK` lookup directly into the
  `groupTemplatesByMeasurand` bucket-sort comparator. The
  `PHASE_RANK` table is now the visible source of truth at the sort
  site, matching the `PHASE_FAMILY` pattern above.

## Shared TEST_METER_VALUES_INTERVAL_MS constant

- Add `TEST_METER_VALUES_INTERVAL_MS = 30_000` to
  `tests/charging-station/ChargingStationTestConstants.ts` as the
  canonical test interval.
- Replace 27 inline `30_000` / `30000` literals across
  `CoherentMeterValuesGenerator.test.ts` (20 sites),
  `OCPP16-CoherentMeterValues.test.ts` (4 sites, including the local
  `const INTERVAL_MS = 30_000` that was itself a duplicate), and
  `StrategyDispatch.test.ts` (3 sites). Preserves value; consolidates
  the semantic "meter-values sample interval used across coherent
  tests" into one importable constant.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2923 pass / 0 fail / 6 skipped

Refs #40

* [autofix.ci] apply automated fixes

* chore(meter-values): strip internal-review markers + JSDoc/docstring polish (issue #40)

## Internal-review-marker cleanup

Removed 19 in-code review-round prefixes (`M1:`, `M2:`, `M3:`, `M4:`,
`B1`, `B2`, `Regression M4:`, `Regression B2:`) from test assertion
messages, JSDoc `@description`, and inline comments across 5 test files:
- `CoherentMeterValuesGenerator.test.ts` — 7 sites (voltage-noise,
  capacity-clamp, INV-1 residual assertions).
- `OCPP16-CoherentMeterValues.test.ts` — 6 sites (stop-energy
  reconstruction and coherent-session-leak assertions + comment).
- `OCPP20ServiceUtils-PostTransactionDelay.test.ts` — 1 site
  (coherent-session-leak assertion).
- `OCPP20ResponseService-CoherentSession.test.ts` — 4 sites (`@description`
  + three eventType/idToken assertions).
- `OCPP16ResponseService-ForceTxOnInvalid.test.ts` — 1 comment block
  ("exercises the regression:" process language → behavioral "exercises
  the pre-Start guard").

Assertion messages now describe the invariant being checked in plain
English without carrying review-round bookkeeping. This is a follow-up to
the earlier cleanup that stripped `(regression: XX)` suffixes from
`describe`/`it` labels but missed codes inside assertion messages and
`@description` blocks.

## JSDoc / doc hygiene

- `PHASE_FAMILY` (`CoherentMeterValuesGenerator.ts`) — deleted the stale
  first JSDoc block that documented the pre-refactor `phaseFamily`
  function; kept the block that documents the current Record with
  compile-time exhaustiveness gate.
- `VersionedSampledValueDispatch` (`OCPPServiceUtils.ts`) — converted
  the `//` block comment above the interface to a proper `/** */` JSDoc
  block matching the sibling-interface convention. Body documents the
  internal-only dispatch-bag role with `@link` cross-references.
- `warnedInvalidMeasurands` and `KNOWN_MEASURANDS` (`OCPPServiceUtils.ts`)
  — moved above the `resolveEnabledMeasurands` JSDoc block so the JSDoc
  is now adjacent to the function it documents (was previously separated
  by the two `const` declarations, breaking the visual JSDoc→function
  association).
- `template.unit as MeterValueUnit | undefined` cast (`CoherentMeter`
  `ValuesGenerator.ts`) — added an intent comment documenting the OCPP
  2.0 open-string-branch narrowing at the Map-lookup boundary and the
  fallback semantics (non-enum strings return `undefined` from the Map
  and fall through to `divider = 1`).
- `MEASURAND_EMIT_ORDER` (`CoherentMeterValuesGenerator.ts`) — dropped
  the explicit `readonly MeterValueMeasurand[]` annotation; kept `as const`
  for a narrow tuple type matching the sibling `PHASE_FAMILY`/`PHASE_RANK`
  pattern (immutability via const-assertion, coherent style across the
  three module-scope constants).
- `ChargingStation.__injectCoherentSession` JSDoc — dropped the
  "not currently enforced by a lint rule" sentence (build-tool trivia,
  not API behavior).

## Test constants

- `TEST_METER_VALUES_INTERVAL_MS` rationale documented on the
  `Timer Intervals` block header (`_MS` intervals are intentionally
  half of `Constants.DEFAULT_*_INTERVAL_MS` for bounded test wall-clock).
- `ChargingStationTestConstants.ts` file header rewritten from the
  narrative-prose + `@see` form to standard `@file` / `@description`
  JSDoc matching the rest of the test suite.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2923 pass / 0 fail / 6 skipped

Refs #40

* docs(meter-values): tighten OCPP schema-cardinality citation + test tolerance message (issue #40)

- OCPP20ServiceUtils.ts / OCPP20IncomingRequestService.ts: replace loose
  J02.FR.11 shorthand on the meterValue-field-omission branches with the
  actual grounding — MeterValueType.sampledValue cardinality 1..* combined
  with TransactionEventRequest.meterValue cardinality 0..* — so a reader
  sees why the empty case yields {} rather than { meterValue: [] }.
- CoherentMeterValuesGenerator.test.ts: capacity-clamp failure messages
  now report 2 × ROUNDING_SCALE (0.01 W) to match the assertion tolerance
  (the two rounding steps: aggregate P = V·I·phases plus post-clamp
  re-round each contribute one half-width).

* refactor(meter-values): use session snapshot for voltage, currentType, numberOfPhases (issue #40)

computeCoherentSample now reads voltageOutNominal, currentType, and
numberOfPhases from the session object (immutable snapshot captured at
createCoherentSession) instead of the live context. The three fields
were already declared readonly on CoherentSession but only two were
consulted at runtime, leaving voltageOutNominal dead. Using the snapshot
makes the physics chain deterministic against hypothetical mid-session
station-config drift and reduces per-sample context calls from three to
zero (getConnectorMaximumAvailablePower stays live — the EVSE cap is a
genuinely dynamic quantity that can change during a transaction).

* fix(meter-values): namespace transactionId in createStreamPrng to prevent XOR self-inverse (issue #40)

createStreamPrng chained two XOR-based deriveSeed calls, so
String(transactionId) === label collapsed the derived stream seed back
to the root seed (deriveSeed(deriveSeed(r, X), X) === r). Trigger
required transactionId to match a literal stream label
('VOLTAGE_NOISE', 'POWER_NOISE', 'PROFILE_PICK', 'INITIAL_SOC') —
extremely unlikely with OCPP-numeric transactionIds but reachable via
test seams. Namespace the transactionId leg with a 'tx:' prefix that
labels never carry so the two hash inputs cannot algebraically cancel.
Prng.ts deriveSeed docstring updated to reference the namespacing in
createStreamPrng and drop the 'Known limitation (deferred)' language.

* chore(charging-station): guard __injectCoherentSession against production use (issue #40)

The public __injectCoherentSession method is a test seam whose only
prior protection was the '__' naming convention. Add a runtime guard
that throws a typed BaseError when NODE_ENV === 'production', mirroring
the pattern used by interpolateChargingCurve in EvProfiles.ts for the
unsorted-curve defensive check. Tests run with NODE_ENV unset or 'test'
so the guard is transparent to them; accidental production use now
fails loudly instead of silently corrupting the session store.

* fix(meter-values): tighten defensive guard against non-finite intervalMs (issue #40)

The computeCoherentSample defensive guard applied Number.isFinite to
batteryCapacityWh, nominalV, and nowMs but only tested intervalMs <= 0.
Since NaN <= 0 and +Infinity <= 0 both evaluate to false in JS, either
pathological value escaped the guard, propagated through
maxPowerFromCapacityW = remainingWh * MS_PER_HOUR / intervalMs, and
permanently poisoned session.socPercent to NaN (NaN + x === NaN for
every subsequent sample). Reachability was low in practice — legitimate
callers pass integer intervals and convertToInt throws on NaN — but a
non-compliant CSMS setting MeterValueSampleInterval to a pathological
value via ChangeConfiguration could reach the coherent path.

Add !Number.isFinite(options.intervalMs) to the OR chain, mirroring the
sibling checks. Docstring bullet rewritten to reflect the tightened
coverage. Two new test cases lock the behavior: intervalMs=NaN and
intervalMs=+Infinity both short-circuit to a zero sample with session
state intact, matching the existing intervalMs=0 semantics. The
describe block is renamed from 'intervalMs=0 defensive guard' to
'intervalMs defensive guard' to reflect the broader coverage.

* chore(helpers): drop caller-coordination note on resetConnectorStatus (issue #40)

The 'NOTE: transactionId is deleted here. Callers that need to identify
the just-stopped transaction MUST snapshot ... BEFORE invoking this
function.' comment described a caller-side coordination concern rather
than the WHY of the delete. Every caller that needs the transactionId
already snapshots it before the reset; the note added no local WHY and
duplicated a responsibility that belongs in the caller, not in
resetConnectorStatus.

* docs(meter-values): drop 'legacy' framing for the random/fixed simulation mode (issue #40)

The random/fixed measurand generation is not deprecated — it is one of
two peer simulation modes exposed by the simulator, selected via the
coherentMeterValues station flag. The word 'legacy' throughout the
newly-introduced comments, JSDoc, README, and test descriptions
inaccurately implied a deprecation path.

Neutralize 13 sites across 6 files: 'legacy' either dropped where it
was purely decorative (e.g. 'mirroring the legacy X path' → 'mirroring
the X path') or replaced with 'random/fixed' / 'default' where the
descriptor was substantive. No code changes, no test-logic changes,
purely prose.

* [autofix.ci] apply automated fixes

* docs(meter-values): neutralize residual 'Legacy path' inline comment (issue #40)

F12-A retitled the enclosing it() from 'via the legacy path' to 'via
the random/fixed path' but the inline WHY comment two lines below still
said 'Legacy path' — same author, same commit, same test block.
Missed by the case-sensitive verification grep used at F12-A landing;
this closes the neutralization loop.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agochore(deps): lock file maintenance (#1927)
renovate[bot] [Wed, 1 Jul 2026 21:41:59 +0000 (23:41 +0200)] 
chore(deps): lock file maintenance (#1927)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agofix(deps): update all non-major dependencies (#1933)
renovate[bot] [Wed, 1 Jul 2026 12:23:44 +0000 (14:23 +0200)] 
fix(deps): update all non-major dependencies (#1933)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agochore(deps): update dependency prettier to ^3.8.5 (#1932)
renovate[bot] [Tue, 30 Jun 2026 00:44:22 +0000 (00:44 +0000)] 
chore(deps): update dependency prettier to ^3.8.5 (#1932)

* chore(deps): update dependency prettier to ^3.8.5

* [autofix.ci] apply automated fixes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agochore: release main (#1928) cli@v4.10.1 ocpp-server@v4.10.1 simulator@v4.10.1 ui-common@v4.10.1 v4.10 web@v4.10.1
Jérôme Benoit [Mon, 29 Jun 2026 22:44:06 +0000 (00:44 +0200)] 
chore: release main (#1928)

* chore: release main

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agorefactor: project-wide audit of helper/util usage in src/ + OCPP 2.0.1 §F01.FR.13...
Jérôme Benoit [Mon, 29 Jun 2026 22:34:53 +0000 (00:34 +0200)] 
refactor: project-wide audit of helper/util usage in src/ + OCPP 2.0.1 §F01.FR.13 spec fix (#1931)

* refactor(utils): replace bare structuredClone with clone helper

Adopts the existing `clone` helper from `src/utils/Utils.ts` at 7 sites in 4
files where `structuredClone` was called directly. `clone` is a thin wrapper
around `structuredClone` (`return structuredClone(object)`); the swap is
behaviour-preserving DRY consolidation.

Files touched:
- src/utils/Configuration.ts (1 site)
- src/utils/ConfigurationMigrations.ts (3 sites + import added)
- src/utils/ConfigurationValidation.ts (2 sites + import extended)
- src/charging-station/TemplateValidation.ts (1 site + import extended)

The `as Record<string, unknown>` casts at ConfigurationValidation.ts:103 and
TemplateValidation.ts:62 are preserved on the call sites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp): replace (error as Error).message with getErrorMessage

The `(error as Error).message` cast is unsound — when the thrown value is
not an `Error` instance, `.message` is `undefined` and the log silently
emits 'undefined', obscuring potentially security-relevant failures (notably
on crypto / signing paths). The `getErrorMessage` helper from
`src/utils/ErrorUtils.ts` handles both `Error` instances and non-`Error`
thrown values via `ensureError`, producing a meaningful string in every
case.

Sites converted (3):
- src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts:179
  (buildTransactionStartedMeterValues catch block)
- src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts:1181
  (buildTransactionEndedMeterValues catch block)
- src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts:56
  (deriveSigningMethodFromPublicKeyHex catch block - crypto path)

Imports updated in both files.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp): replace ?.value === 'true' with convertToBoolean

The inline `getConfigurationKey(...)?.value === 'true'` pattern only matches
the literal lowercase string `'true'` and silently treats `'True'`, `'1'`,
`true` (boolean) and any non-string value as false. `convertToBoolean` from
`src/utils/Utils.ts` handles the full set of truthy representations
consistently across the codebase.

Sites converted (7):
- src/charging-station/ocpp/OCPPServiceUtils.ts (3 sites in OCPP 2.0 signed
  meter value generation: SignReadings, SignStartedReadings,
  SignUpdatedReadings flags)
- src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts (3 sites: isSigningEnabled,
  buildStartedMeterValues and buildUpdatedMeterValues signing guards)
- src/charging-station/Bootstrap.ts (1 site: ENV_SIMULATOR_COLD_START env var)

Imports updated in all three files.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt isEmpty/isNotEmptyString helpers for typed collection checks

Consolidates inline emptiness checks on typed strings, arrays and Sets to
use the existing `isEmpty` and `isNotEmptyString` helpers from
`src/utils/Utils.ts`. The helpers cover string, array, Set, Map and plain
object emptiness uniformly; `isNotEmptyString` also trims internally so
`workerScript.trim().length === 0` becomes `!isNotEmptyString(workerScript)`
without behavior change.

isEmpty sites (7):
- src/charging-station/ui-server/UIServerAccessPolicy.ts:230,268,297,407
  (4× .length === 0 on string[] from splitHeaderList / getAllowedHosts)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:508
  (.size === 0 on ReadonlySet<string> trustedProxies)
- src/charging-station/ui-server/AbstractUIServer.ts:1317,1326
  (2× .length === 0 on accessPolicy string[] in UI server constructor warn
  paths)

isNotEmptyString sites (3):
- src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts:76
  (== null || .length === 0 on string | undefined)
- src/worker/WorkerAbstract.ts:29
  (.trim().length === 0 on narrowed string)
- src/charging-station/ui-server/AbstractUIServer.ts:668
  (.length > 0 ternary on rate-limit key)

Imports updated: UIServerAccessPolicy.ts adds isEmpty; OCPPSignedMeterValueUtils.ts
extends to isNotEmptyString; WorkerAbstract.ts adds a fresh utils import.
AbstractUIServer.ts already had both helpers imported.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt convertToInt and has helpers for type conversions and property checks

Consolidates inline `Number.parseInt(_, 10)` / `Number(_)` coercions on string
inputs to use `convertToInt`, and `Object.hasOwn` property checks to use
`has` from `src/utils/Utils.ts`. `has` accepts `(property, object)` argument
order (unlike `Object.hasOwn(object, property)`) and adds a null-guard on the
object.

convertToInt sites (3):
- src/charging-station/ui-server/UIServerNet.ts:97
  (`Number.parseInt(port, 10)` on validated digit-string port)
- src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts:785
  (`Number(value)` on OCPP config integer-key value)
- src/charging-station/TemplateSchema.ts:268
  (`Number(evseKey)` on Object.entries(template.Evses) record key)

has sites (2):
- src/utils/ConfigurationSchema.ts:210
  (`!Object.hasOwn(value as object, 'accessPolicy')` in Zod refine —
  the `as object` cast is no longer needed since `has` accepts `unknown`)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:355
  (`Object.hasOwn(params, key)` in Forwarded header parameter dedup)

Imports updated in UIServerNet.ts, TemplateSchema.ts, ConfigurationSchema.ts
(fresh utils imports), OCPP16IncomingRequestService.ts (already imported),
UIServerAccessPolicy.ts (extended).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt isNotEmptyArray and buildConfigKey for collection checks and log formatting

isNotEmptyArray sites (4):
- src/charging-station/ui-server/AbstractUIServer.ts:1438 (countConnectors
  fallback on data.connectors)
- src/charging-station/ui-server/AbstractUIServer.ts:1451 (iterateConnectors
  guard on data.connectors)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:438 (allowedOrigins
  presence check)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:443 (allowedHosts
  presence check in fallback)

buildConfigKey site (1):
- src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts:518
  (readVariableAsInteger warn log: unify the displayed key format with the
  canonical `buildConfigKey(component, variable)` output instead of inline
  `${component}.${variable}` interpolation)

Imports updated: AbstractUIServer.ts extends to isNotEmptyArray;
UIServerAccessPolicy.ts extends to isNotEmptyArray.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(refactor): avoid TDZ cycles via utils barrel for new helper imports

The previous commits introduced fresh imports from `src/utils/index.js` in
three modules that participate in barrel-induced TDZ cycles via
`ConfigurationSchema.ts`:

- src/charging-station/ui-server/UIServerNet.ts is imported by
  ConfigurationSchema.ts, so importing back through utils/index.js produces
  `ReferenceError: Cannot access 'isHostLiteralWithoutPort' before
  initialization`.
- src/worker/WorkerAbstract.ts is exported via worker/index.js, which is
  consumed by ConfigurationSchema.ts (`WorkerProcessType`), so importing
  back through utils/index.js breaks Zod enum initialization with
  `TypeError: Cannot convert undefined or null to object` in
  `getEnumValues`.
- src/charging-station/TemplateSchema.ts uses the same cycle-prone barrel
  defensively, preempting future Zod-init breakage.

Resolution: import `convertToInt` and `isNotEmptyString` directly from
`./Utils.js` (the leaf module), mirroring the repo-established
cycle-avoidance pattern at `src/utils/ConfigurationMigrations.ts:3-4`
(`import { BaseError } from '../exception/BaseError.js'` with the same kind
of inline note).

Tests: previously failing
- tests/charging-station/ui-server/UIServerNet.test.ts
- tests/worker/WorkerUtils.test.ts
both pass; full suite 2857/2857.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ocpp16): revert handleRequestChangeConfiguration integer-key check to Number()

Reverts the convertToInt swap at OCPP16IncomingRequestService.ts:785 (M4 in
the helpers audit). The downstream check
  `!Number.isInteger(numValue) || numValue < 0 → REJECTED`
intentionally relies on `Number(value)` returning `NaN` for invalid input
to fail `Number.isInteger`. `convertToInt` breaks this contract in four
distinct ways verified empirically:

  value         Number(value)  convertToInt(value)  effect on rejection check
  --------      -------------  -------------------  ----------------------
  undefined     NaN            0                    silently ACCEPTED
  ''            0              throws               uncaught exception
  '1.5'         1.5            1                    silently ACCEPTED
  'abc'         NaN            throws               uncaught exception

Two of these (`''` and `'abc'`) produce uncaught exceptions that propagate
out of the handler instead of returning Rejected. The other two silently
accept invalid values. The audit recommendation to adopt `convertToInt`
here was incorrect; the call site needs the NaN-on-invalid contract that
only `Number()` provides.

Issue flagged by hyperspace-insights review bot on PR #1931 (only the
`undefined → 0` case was highlighted; the full divergence is broader).

Inline note added to prevent re-introduction by future cleanups.

Tests: tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts
and tests/charging-station/ocpp/1.6/OCPP16Integration-Configuration.test.ts —
14/14 pass.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: dedup OCPP signing-flag checks and revert WorkerAbstract utils import

Applies four review nits from the post-audit self-review, plus reverts an
architectural misuse:

F1 — OCPPServiceUtils.ts (OCPP 2.0 signed-meter-value block, 3 sites):
extracts a private `isOCPP20FlagEnabled(chargingStation, component, variable)`
helper consuming the existing `convertToBoolean(getConfigurationKey(_,
buildConfigKey(_, _))?.value)` chain. Reduces 5-level indented blocks at
lines 938, 950, 963 to a single helper call, ~12 lines saved.

F2 — OCPP16ServiceUtils.ts (2 sites): adds `isSigningStartedReadingsEnabled`
and `isSigningUpdatedReadingsEnabled` static methods next to the existing
`isSigningEnabled`, all three following the same shape. Replaces the inline
`convertToBoolean(getConfigurationKey(_, OCPP16VendorParametersKey.X)?.value)`
chains at lines 173 and 826 with named-intent calls. Semantic naming
> generic helper since these are vendor-key-specific.

F3 — UIServerNet.ts, WorkerAbstract.ts, TemplateSchema.ts: compresses the
multi-line TDZ-cycle workaround comments to single-line format, matching
the established repo convention at `ConfigurationMigrations.ts:3`
(`// Direct path: the exception/index.js barrel re-exports OCPPError,
causing a TDZ cycle.`).

F4 — OCPP16IncomingRequestService.ts:786: shortens the 5-line `// NB:`
comment about `Number(value)` preservation to a 1-line concise note while
preserving the regression-prevention intent.

Architectural revert — WorkerAbstract.ts: removes the `isNotEmptyString`
import from `../utils/Utils.js` added in 7fe3e2a10 (M3). `src/worker/` is
a standalone sub-project and must not import from elsewhere in the source
tree. Reverted the call site to inline `workerScript.trim().length === 0`.
Same architectural rule that justifies deferring the worker-side
`mergeDeepRight`/`sleep`/`secureRandom` consolidations.

Tests: 37/37 pass on the affected suites (UIServerNet, WorkerUtils,
WorkerAbstract, OCPP16 Configuration, OCPP16 Configuration integration).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp2): adopt handleIncomingRequestError across OCPP 2.0 handlers

Brings OCPP 2.0 incoming-request handlers to parity with the OCPP 1.6
equivalent (OCPP16IncomingRequestService.ts) by routing all catch-and-return
patterns through the shared `handleIncomingRequestError<T>` helper from
`src/utils/ErrorUtils.ts`.

Sites converted (14 total):

10 clean sites (A — single rejection-response return):
- handleRequestClearCache (line 815) — OCPP_RESPONSE_REJECTED
- handleRequestGetLocalListVersion (line 851) — { versionNumber: 0 }
- handleRequestSendLocalList (line 961) — OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
- handleRequestCertificateSigned (line 1583) — typed Rejected w/ statusInfo
- handleRequestDeleteCertificate (line 1849) — typed Failed w/ statusInfo
- handleRequestGetInstalledCertificateIds (line 1954) — typed NotFound w/ statusInfo
- handleRequestInstallCertificate (line 2109) — typed Failed w/ statusInfo
- handleRequestReset (line 2329) — typed Rejected w/ statusInfo
- handleRequestTriggerMessage (line 2864) — typed Rejected w/ statusInfo
- handleRequestUnlockConnector (line 2949) — typed UnlockFailed w/ statusInfo

4 conditional sites (G — nested + outer catches in handleRequestStartTransaction,
lines 2567/2608/2673/2737): each catch builds a typed
OCPP20RequestStartTransactionResponse local const (status: Rejected with the
appropriate additionalInfo, transactionId: generateUUID()) and passes it both
as errorResponse and as the `??` typing-narrowing fallback. The
`await this.resetConnectorOnStartTransactionError(...)` side-effect on the
outer catch is preserved before the helper call.

Imports added: ensureError, handleIncomingRequestError (alphabetical).

Behavioral note: the log message format changes from
`OCPP20IncomingRequestService.handleRequestX: ...` to
`ErrorUtils.handleIncomingRequestError: Incoming request command 'X' error: ...`
— matches the OCPP 1.6 service. The error itself is still logged (via the
helper's internal logger.error call); no error is swallowed.

Tests: 336/336 pass on `tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-*.test.ts`.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(validation): adopt assertIsJsonObject for JSON-object type guards

Replaces the inline guard 'if (parsed == null || typeof parsed !== object ||
Array.isArray(parsed)) throw new BaseError(...)' with the canonical
'assertIsJsonObject(parsed, new BaseError(...))' helper from
'src/utils/Utils.ts'. The helper:

- Narrows the value type to JsonObject via 'asserts value is JsonObject'
  (type predicate gain).
- Accepts a custom Error second argument that is thrown verbatim, preserving
  the existing BaseError message and stack trace.

Sites converted (2):
- src/charging-station/TemplateValidation.ts:50 (validateTemplate)
- src/utils/ConfigurationValidation.ts:92 (validateConfiguration)

Imports updated: TemplateValidation.ts and ConfigurationValidation.ts both
extended to include assertIsJsonObject.

Tests: 74/74 pass on the validation suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt JSONStringify helper across src/

Replaces 14 bare 'JSON.stringify(_, undefined/null, 2)' call sites with the
shared 'JSONStringify' helper from 'src/utils/Utils.ts'. The helper:

- Serializes Maps and Sets safely. Bare 'JSON.stringify' silently drops Map
  entries (correctness fix for sites with potential Map values).
- Accepts an optional 'MapStringifyFormat' parameter to control how Maps are
  rendered (default: array of pairs; '.object': key-value object).

Sites with Map values (use 'MapStringifyFormat.object' to match the existing
JSON config-schema representation of station data):
- src/charging-station/ChargingStation.ts:2595 (config file write,
  ChargingStationConfiguration)
- src/charging-station/ui-server/mcp/MCPResources.ts:22 (listChargingStationData)
- src/charging-station/ui-server/mcp/MCPResources.ts:44 (station data by id)

Plain-object sites (default 'JSONStringify(_, 2)'):
- src/charging-station/BootstrapStateUtils.ts:150 (state file write)
- src/charging-station/ocpp/OCPPServiceUtils.ts:200 (validate.errors)
- src/charging-station/Bootstrap.ts:647 (worker message error)
- src/charging-station/ui-server/ui-services/AbstractUIService.ts:172
- src/charging-station/ui-server/mcp/MCPResources.ts:64 (templates list)
- src/charging-station/ui-server/mcp/MCPResources.ts:119 (commands list)
- src/charging-station/ocpp/OCPPResponseService.ts:100,112 (error PDU msgs)
- src/charging-station/ocpp/OCPPIncomingRequestService.ts:70,117,129 (error
  PDU msgs)

Also loosens the 'JSONStringify' generic constraint to 'object: unknown'
(matching 'JSON.stringify' standard signature). The previous restrictive
generic (already marked 'no-unnecessary-type-parameters') prevented passing
values like 'ChargingStationConfiguration' that lack TS index signatures
but are JSON-serializable at runtime. Removes unused 'JsonType' import.

Skipped (architectural):
- src/worker/WorkerSet.ts:191 — 'src/worker/' is a standalone sub-project
  and must not import from elsewhere (same rule as the WorkerAbstract revert
  in 2f190a4cf).

Tests: 512/512 pass on Utils + ChargingStation + Bootstrap + OCPP + UI server suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(bootstrap): adopt promiseWithTimeout in waitChargingStationsStopped

Replaces the manual 'new Promise((resolve, reject) => { setTimeout(...);
waitChargingStationEvents(...).then(resolve).catch(reject) })' construct
with the shared 'promiseWithTimeout' helper from 'src/utils/Utils.ts'.

The refactor is behaviour-preserving:
- The timeout BaseError is now constructed once upfront and passed to
  'promiseWithTimeout' (matching the helper's pre-built error contract).
- The 'logger.warn' is emitted only when the caught error is referentially
  the timeoutError (i.e., only on actual timeout, not on errors propagated
  from 'waitChargingStationEvents'). Same semantic as the original which
  only logged inside the setTimeout callback.
- 'clearTimeout' on success path is handled internally by 'promiseWithTimeout'
  via 'promise.finally(() => clearTimeout(timer))'.

Linear try/await/catch flow replaces the nested Promise constructor +
.then/.catch/.finally chain (~5 LOC saved, less indirection).

Imports updated: Bootstrap.ts adds 'promiseWithTimeout' alphabetically to
the existing utils barrel import.

Tests: 11/11 pass on 'tests/charging-station/Bootstrap.test.ts'.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: unify handleIncomingRequestError local-const pattern + doc identity check

F1 — OCPP20IncomingRequestService.ts (2 sites): converts the
constant-inlined-twice pattern at handleRequestClearCache:815 and
handleRequestSendLocalList:961 to the local-const pattern used by the other
11 sites in the same file. The error-response constant is now bound to a
typed local const and referenced once for both the helper's 'errorResponse'
parameter and the '?? errorResponse' TS-narrowing fallback, eliminating the
double reference to the same constant.

F3 — Bootstrap.ts waitChargingStationsStopped: adds a 3-line comment above
the 'if (error === timeoutError)' identity check documenting the contract
relied upon — 'promiseWithTimeout' must reject with the same Error instance
passed in. If a future maintainer wraps the error inside the helper, the
identity check would silently break and the timeout-specific 'logger.warn'
would no longer fire. The comment also clarifies that downstream errors
from 'waitChargingStationEvents' intentionally propagate unlogged
(preserves the original behaviour).

No behavioural change. Tests: 347/347 pass on OCPP20IncomingRequest +
Bootstrap suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: apply v3 cross-validated review fixes

Applies 9 fixes from the 4-agent cross-validation review (2 oracles + 1
librarian + 1 explore agent on the same scope).

HIGH-CONFIDENCE fixes (>=2 agents converged):

CV1 — type-safety: 'isOCPP20FlagEnabled' parameter tightened from
'variable: string' to 'OCPP20OptionalVariableName | OCPP20RequiredVariableName
| OCPP20VendorVariableName'. The merged-runtime 'StandardParametersKey' /
'VendorParametersKey' objects only had OCPP 1.6 typedefs, masking the OCPP
2.0 call-site values; the explicit union closes the gap. (OCPPServiceUtils.ts)

CV2 — semantic preservation: 'convertToInt(evseKey)' in the Zod
'.superRefine' callback would throw on non-numeric keys, where the original
'Number(evseKey)' returned NaN and silently fell through. Reverted to
'convertToIntOrNaN(evseKey)' to preserve the NaN-on-invalid contract the
constraint check downstream relies on. Same class of bug the bot
identified at OCPP16IncomingRequestService:785. (TemplateSchema.ts)

CV3 — discriminator robustness: replaced 'error === timeoutError' identity
check with an 'instanceof TimeoutError' check against a 'BaseError'
subclass. The subclass discriminator survives any future wrapping of the
error inside 'promiseWithTimeout'; the identity check silently broke on
any error-cloning refactor of the helper. (Bootstrap.ts)

CV4 — cross-version parity: aligned the 4 remaining 'handleIncomingRequestError'
sites in OCPP 1.6 (cancelReservation, dataTransfer, getDiagnostics, reserveNow)
to the local-const pattern that OCPP 2.0 adopted in the audit, eliminating
the duplicated constant reference at each call site. (OCPP16IncomingRequestService.ts)

OBSERVABILITY fix:

S1 — reverted the 4 nested + outer catches in OCPP 2.0
'handleRequestStartTransaction' (Authorization / Group authorization /
Charging profile validation / outer 'Error starting transaction') from
'handleIncomingRequestError' adoption back to inline 'catch → logger.error →
return typed-rejection'. The helper's unified log message dropped the
phase-specific context (truncated idToken values, distinct phase names).
OCPP 1.6 'handleRequestRemoteStartTransaction' itself does NOT use
'handleIncomingRequestError', so this revert restores both observability
AND cross-version parity. The other 10 single-catch sites adopted by the
audit remain unchanged. (OCPP20IncomingRequestService.ts)

Doc/style nits:

S4 — README.md: 'SIMULATOR_COLD_START=true' documented as 'a truthy value
(true, True, TRUE, 1, optionally surrounded by whitespace)' to reflect
the actual 'convertToBoolean' semantics adopted in the audit.

S5 — Bootstrap.ts:649: added 'MapStringifyFormat.object' to the
'JSONStringify(data, 2)' call on worker-message error data. Consistent
with the format choice at 'ChargingStation.ts:2597' and 'MCPResources.ts'
since worker message data can carry Maps.

S6 — TemplateSchema.ts:3: TDZ-cycle direct-path comment realigned to the
declarative form used by 'ConfigurationMigrations.ts:3' ('the X barrel
re-exports Y, causing a TDZ cycle.').

S7 — OCPP16ServiceUtils.ts:683,694: JSDoc terminology aligned with the
spec ('signing of meter values at transaction start' / 'during transaction
updates' instead of 'transaction-started readings' / 'transaction-updated
readings').

Tests: 520/520 pass on OCPP 2.0 IncomingRequest, OCPP 1.6 IncomingRequest,
Bootstrap, TemplateSchema, TemplateValidation suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: apply v4 cross-validation review fixes

Applies 10 fixes from the v4 4-agent cross-validation review (2 oracles + 1
librarian + 1 explore) on the post-v3 state of PR #1931.

CORRECTNESS:

BUG — Configuration.ts:293: 'convertToInt(env.PORT ?? "")' would throw an
uncaught exception when PORT is unset (?? '' returns empty string, on which
convertToInt throws). Same class of bug as the CV2 fix in TemplateSchema.
Guards with isNotEmptyString(env.PORT) and only converts when set.

DESIGN:

HC1 + Q2(d) — TimeoutError relocated from Bootstrap.ts to a dedicated
src/exception/TimeoutError.ts file, exported from src/exception/index.js.
Bootstrap.ts now imports TimeoutError alongside BaseError. Matches the
established repo pattern (BaseError, OCPPError each in their own file
under src/exception/). The class declaration is no longer interleaved
with imports in Bootstrap.ts.

S1 — extract rejectedInternalError(additionalInfo) helper in
OCPP20IncomingRequestService.ts. Collapses 4 near-identical 8-line
'{ status: Rejected, statusInfo: { additionalInfo, reasonCode:
InternalError }, transactionId: generateUUID() }' literals at the 4
nested + outer catches of handleRequestStartTransaction. Each catch
becomes a 1-line 'return rejectedInternalError("...")'.

S2 — wrap 'logger.error(..., error)' with 'ensureError(error)' at the
4 reverted G-sites. Aligns with the 10 sibling sites in the same file
that already route through ensureError. Non-Error throwables are
normalized before reaching the logger.

Q3(a) — TemplateSchema.ts: add 'Number.isNaN(evseId)' guard in the
.superRefine loop over Object.entries(template.Evses). Closes the
validation gap where 'Evses: { "abc": {...} }' silently passed
because NaN === 0 / NaN > 0 are both false. The guard pushes a
'ctx.addIssue' for non-numeric keys with a clear message citing
OCPP 2.0.1 §7.2.

POLISH:

S3 — Bootstrap.ts: 'logger.warn(..., error.message)' instead of the
closed-over 'timeoutError.message'. After the instanceof TimeoutError
narrowing, 'error' is correctly typed as TimeoutError. If a future
wrap of promiseWithTimeout passes through a different instance,
error.message still reflects what was actually caught.

S4 — Bootstrap.ts: 'behaviour' (British) → 'behavior' (American)
to match the 30+ occurrences elsewhere in src/. Also resolves the
cspell flag.

S5 — Bootstrap.ts: catch comment compressed from 3 lines to 1 line.
Drops the partial redundancy with the TimeoutError JSDoc and removes
the 'unlogged' cspell flag.

HC2 — README.md: SIMULATOR_COLD_START wording tightened from a
non-exhaustive list ('true, True, TRUE, 1') to the exhaustive rule
'case-insensitive true or 1, optionally surrounded by whitespace'.
Matches the actual convertToBoolean semantics (trim + toLowerCase).

Tests: 2832/2832 pass, 0 fail.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: apply v5 cross-validation review fixes

HC-A TemplateSchema.ts: replace NaN-via-arithmetic guard with explicit
Number.isInteger check for evseId validation (clearer intent, same semantics).

HC-B + HC-C TimeoutError.ts: compress JSDoc to single line; add SPDX
copyright header to match peer OCPPError.ts.

L-A + HC-D OCPP20IncomingRequestService.ts: rename rejectedInternalError
helper to buildRejectedResponse(additionalInfo, reasonCode) and migrate
all 11 RequestStartTransaction rejection sites (4 helper calls + 7 inline
literals) through it. Drop fabricated transactionId: generateUUID() from
rejection responses per OCPP 2.0.1 §E07: that field signals a transaction
already started by the Charging Station before the request arrived
(cable-first scenarios), so fabricating a UUID on a pure rejection
misleads CSMS implementations that map remoteStartId -> transactionId.

V5-6 Bootstrap.ts: relocate catch-block ordering comment below instanceof
check and reword for clarity.

Test: update §E07 regression assertion in
OCPP20IncomingRequestService-RequestStartTransaction.test.ts to expect
response.transactionId === undefined on TxInProgress rejection (was
incorrectly asserting notStrictEqual against the now-fixed bug).

All quality gates pass: 2857/2857 tests, typecheck, lint, format.

* docs(ocpp20): correct spec citation §E07 → §F01.FR.13

The 'transactionId-on-rejection' fix in commit 23beb7ea1 cited OCPP 2.0.1
§E07, which is wrong: §E07 is 'Transaction locally stopped by IdToken'.

The actual clause governing transactionId echo on RequestStartTransaction-
Response is §F01.FR.13: 'When a transaction is created on the Charging
Station, but has not been authorized. AND RequestStartTransactionRequest
is received → The Charging Station SHALL return the transactionId in the
RequestStartTransactionResponse.'

This is the cable-plugin-first scenario. On pure rejections F01.FR.13 does
not apply, so transactionId is correctly omitted. The semantics of the fix
are unchanged; only the spec citation in the comments is corrected.

Verified against OCPP-2-0-1-edition3-part2-specification.md via local
spec collection.

* refactor: apply v6 cross-validation review fixes

V6-S2 + V6-Q1 (HIGH, both Oracle agents): RequestStartTransaction
TxInProgress rejection regressed OCPP 2.0.1 part 2 §F01.FR.13 when the
connector held a transactionPending state. The v5 fix dropped the
fabricated UUID (correct) but also dropped the real connectorStatus.
transactionId (incorrect). Per §F01.FR.13: 'When a transaction is created
on the Charging Station, but has not been authorized AND
RequestStartTransactionRequest is received, the Charging Station SHALL
return the transactionId in the RequestStartTransactionResponse.'

Split the TxInProgress check into two branches:
- transactionPending === true AND typeof connectorStatus.transactionId
  === 'string' → buildRejectedResponse(TxStarted, ..., transactionId).
  The appendix distinguishes TxStarted (cable-plugin-first, not yet
  authorized) from TxInProgress (authorized and running) — TxStarted
  matches the §F01.FR.13 precondition.
- Fallthrough (transactionStarted || transactionPending-without-tx-id ||
  locked) → buildRejectedResponse(TxInProgress, ...) without echo.

Extended buildRejectedResponse signature: added optional transactionId?:
UUIDv4 parameter and swapped to (reasonCode, additionalInfo,
transactionId?) — required-first/optional-last per V6-X3+Q11. All 11
existing callsites updated; the new branch is the 12th call.

V6-S1 (M, Oracle1, qmd-verified): TemplateSchema.ts cited OCPP 2.0.1
§7.2 (Connector numbering) for EVSE-key validation. The correct clause
is §7.1 (EVSE numbering) — qmd against
OCPP-2.0.1_edition3_part1_architecture_topology.md:269 confirms §7.1
covers 'EVSEs MUST be sequentially numbered starting from 1' and 'evseId
0 is reserved'. Two citation occurrences corrected.

V6-S4 (L, Oracle1): F01.FR.11 violation (ChargingProfile.transactionId
set in RequestStartTransaction) used reasonCode InvalidValue. F01.FR.26
hints InvalidProfile/InvalidSchedule for invalid ChargingProfile.
Changed to InvalidProfile for family coherence with the other two
ChargingProfile rejection sites.

V6-X1 (L, Librarian): Helper comment overstated the spec — said
'fabricating a UUID misleads CSMS' as if §F01.FR.13 mandated MUST NOT.
Reworded to distinguish the positive obligation (SHALL echo when
precondition holds) from the practical argument (fabrication misleads).
Documents the new optional transactionId? parameter semantics.

V6-X14 (M, Librarian): TimeoutError extends BaseError {} was an empty
subclass — TypeScript structural typing made BaseError and TimeoutError
indistinguishable in the type system, so the single instanceof check in
Bootstrap.ts relied solely on the runtime prototype chain. Added
'public override readonly name = TimeoutError as const' for nominal
distinction (matches the OCPPError pattern in the same codebase).

V6-Q2 (M, Oracle2): Test coverage gap — only 1 of the 11 v5 rejection
paths asserted transactionId === undefined. Added the assertion to the
6 remaining unprotected Rejected tests (InvalidIdToken, MasterPassGroup,
invalid ChargingProfile purpose, ChargingProfile-with-transactionId,
authorization throw, all-EVSEs-inoperative). The V6-S2 fix test now
asserts transactionId === pendingTransactionId AND reasonCode ===
TxStarted (regression-locks the §F01.FR.13 echo path).

Deferred to follow-up: V6-Q3 (mixed catch pattern — intentional per
v3 S1), V6-E11/E12/E15 (ensureError missing at 18 logger.error sites
in OCPP 2.0 .catch callbacks + 2 OCPP 1.6 catches), V6-E19 (extract
helper for RequestStopTransaction rejection literals — needs different
return type), V6-X3 cosmetic polish items.

Verified via qmd ocpp-specs collection: §F01.FR.13, §7.1 vs §7.2,
§2.10, B09.FR.31 (errata 2025-09 §2.12 — exists as cited), TxStarted
vs TxInProgress appendix distinction. V6-E3 (Explorer's HIGH flag on
B09.FR.31) was a false positive — citation is valid.

All quality gates pass: typecheck, lint, format, 2857/2857 tests.

* refactor: apply v7 cross-validation review fixes

V7 review pass — 4 agents (oracle ×2, librarian, explore) with v6 lessons
integrated as new criteria: regression-chain audit, type-cast scrutiny,
test-gap blast-radius, helper-signature swap audit, false-positive
detection via qmd. Convergent findings reconciled into 11 in-scope fixes.

False positives rejected (lesson from V6-E3/E4):
- V7-E2/E17 (Explore HIGH unique): claimed startTransactionOnConnector
  misses transactionPending=true. REJECTED — ATG path sends
  TransactionEvent(Started) with triggerReason=Authorized, not cable-
  plugin-first. Setting transactionPending=true there would create
  spurious echoes on already-authorized connectors.

Fixes applied:

V7-Q1+X5 (idiom): swap ternary spread '?: {}' to '&& { x }' to match
the dominant codebase pattern (ChargingStation.ts, OCPP16TestUtils.ts,
AbstractUIService.ts).

V7-Q4+S1 (helper docstring): trim from 6 lines to 3 + reword to avoid
implying §F01.FR.13 mandates status=Rejected (spec is silent on status).

V7-Q5 (test comment): trim P4 duplicate of helper docstring to 1-line
anchor — assertions already self-document.

V7-Q7+S2+X6 (UUIDv4 cast safety): add inline justification comment at
the 'as UUIDv4' cast site pointing to the source-of-truth write site
(line ~2740 generateUUID() assignment).

V7-Q2 (reasonCode lock): add statusInfo.reasonCode assertions to 6 v6-Q2
tests so a future bug swapping codes (e.g., InvalidIdToken vs
InvalidValue) cannot pass: InvalidIdToken×2, InvalidProfile×2,
InternalError×1, NotFound×1.

V7-Q3 (residual TxInProgress test): add test exercising the fallthrough
branch via connectorStatus.locked = true — verifies reasonCode=TxInProgress
and transactionId=undefined (the V6-S2 split's other branch).

V7-S6+E4 (MasterPass coverage): add transactionId=undefined +
reasonCode=InvalidIdToken assertions at MasterPass.test.ts:112 (V6-Q2
pattern spillover).

V7-E5 (RequestStopTransaction coverage): add reasonCode assertions
(InvalidValue×3) to the 3 invalid-UUID-format rejection tests.

V7-E7 (test describe label): TemplateSchema.test.ts:235 describe block
updated to reflect §7.1 EVSE numbering + §7.2 connector numbering split
applied at v6-S1.

V7-E8 (citation qualifier): TemplateSchema.ts:285,295 inline error
messages use 'part 1 §7.2' for consistency with v6-S1 correction style.

V7-E9 (empty subclass discriminants): add 'public override readonly
name = ... as const' to 5 error subclasses (OCPPError,
TemplateValidationError, PayloadTooLargeError,
ConfigurationValidationError, AuthenticationError — last had non-readonly
non-as-const override). Extends V6-X14 TimeoutError pattern uniformly.

Deferred (rationale documented):
- V7-Q6+X4 — TimeoutError commit-body typo and 'matches OCPPError'
  inaccuracy — git history immutable, factual note belongs in PR desc.
- V7-E1 — ConnectorStatus.transactionId: number|string union (OCPP 1.6
  numeric IDs share the field). Narrowing requires OCPP 1.6 refactor.
- V7-E3+E6+E10+E12+E13+E14+E15+E16+E18 — verified clean / not in scope
  (defensive code correct, no echo fields, plausible 1.6 citations).

Verified via qmd ocpp-specs (OCA FINAL schema, lorenzodonini/ocpp-go,
mobilityhouse/ocpp, citrineos-core, EVerest/libocpp): TxStarted vs
TxInProgress appendix distinction matches v6 split; transactionId on
status=Rejected is schema-valid; no reference impl contradicts v6;
typeof===string + as UUIDv4 cast is safe given OCPP 2.0 invariant.

All quality gates pass: typecheck, lint, format, 2852/2852 tests.

* refactor: apply v8 cross-validation review fixes

V8 review pass — 4 agents (oracle ×2, librarian, explore) with v7 lessons
elevated to criteria: recursive false-positive detection, V7-E9 blast
radius audit, V7-Q1 idiom equivalence under JSON.stringify, V7-Q3 test
isolation, convergence saturation indicator (<3 substantive = bottom).

3 agents declared SATURATION REACHED (Oracle1, Oracle2, Librarian). 1
agent (Explore) found 3 NEW substantive findings + 1 echo, breaking
saturation.

False positive rejected (lesson from V6-E3, V7-E2):
- V8-E2 (Explore L) — claimed BaseError has no name override and would
  emit name='Error' on direct instantiation. REJECTED: BaseError's
  constructor already does this.name = new.target.name, so direct
  instantiation yields name='BaseError' correctly. Caller-trace
  verification (same v7 criterion) eliminated this.

Fixes applied:

V8-E1 (M) — Extend V7-E9 pattern to ui/common package missed entirely
by v7. ConnectionError and ServerFailureError in ui/common/src/errors.ts
used mutable constructor-assignment 'this.name = ...' instead of the
class-field pattern. Replaced with 'public override readonly name =
"<ClassName>" as const' class fields, removed the constructor
assignments. Now all 8 error subclasses across src/ and ui/common share
the same nominal-discriminant pattern.

V8-E3 (L) — handleRequestStartTransaction had 1 remaining inline
rejection literal at line 2516 (the resolvedEvseId == null branch with
reasonCode=NotFound) bypassing the buildRejectedResponse helper used by
all 11 other rejection returns in the same function. Replaced with
buildRejectedResponse(ReasonCodeEnumType.NotFound, 'No available EVSE
found for remote start') for invariant coherence.

V8-S1 (L) — RequestStopTransaction test 'should reject stop transaction
for non-existent transaction ID' (line 133) used 'non-existent-
transaction-id' as input, which fails UUID format validation at line
2783 and never reaches the TxNotFound branch (line 2796). The test
covered InvalidValue (format-rejection), not TxNotFound. Renamed test
to '... for invalid transaction ID format - non-UUID string' to match
actual coverage + added new test 'should reject stop transaction for
valid UUID with no matching transaction' that uses
'00000000-0000-4000-8000-000000000000' (valid UUIDv4 format, no
matching transaction) and asserts reasonCode=TxNotFound. The real
TxNotFound branch is now covered.

Deferred (rationale documented):
- V8-S2/Q1 — line-number pointer '~2740' in the as UUIDv4 cast comment
  is fragile but acceptable; tilde already signals approximation.
- V8-S3/Q2 — §F01.FR.13 anchor appears at 4 sites (helper docstring,
  branch comment, V6-S2 test comment, V7-Q3 test name). Each lives at a
  distinct cognitive level (helper API vs handler decision vs test
  intent); not collapsible.
- V8-E4 (echo of V7-S7) — handleRequestStopTransaction inline literals
  return a different response type (OCPP20RequestStopTransactionResponse);
  buildRejectedResponse cannot be reused. Helper-extraction follow-up
  out of scope.
- V8-E5 (echo) — 3 ?: {} sites in test-helper files. Already noted.

Saturation declaration:
- v8 found 3 substantive findings (V8-E1, V8-E3, V8-S1). v9 sweep would
  target: (a) ui/common error subclasses (now all readonly as const),
  (b) buildRejectedResponse call sites (now all 13 use the helper), (c)
  RequestStopTransaction test coverage (now exercises both InvalidValue
  and TxNotFound branches). Expected v9 finding count: 0.

Verified via qmd ocpp-specs (5 audits cumulative), empirical Node.js
v24 testing (V7-E9 stack trace + JSON.stringify + util.inspect +
winston format paths), and external references (5 OSS OCPP impls + 5
major TS error-class repos for readonly-name canonical confirmation).

All quality gates pass: typecheck (src + ui/common), lint (src +
ui/common), format, tests (src 2859/2865, ui/common 120/120, 0 fail).

---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
4 weeks agotest: use shared response test helpers (#1930)
Jérôme Benoit [Mon, 29 Jun 2026 18:24:09 +0000 (20:24 +0200)] 
test: use shared response test helpers (#1930)

* test(ui-server): use response drain helpers

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): share response service test helpers

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): use auth factory test instances

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp): move ResponseService testable wrapper to __testable__

Aligns OCPP 2.0 ResponseService test wiring with the established pattern used
by RequestService and VariableManager:

- New src/charging-station/ocpp/2.0/__testable__/OCPP20ResponseServiceTestable.ts
  exporting createTestableResponseService + TestableOCPP20ResponseService;
  mirrors OCPP20RequestServiceTestable.ts structure (function declarations,
  file-level JSDoc with @example, double-cast through unknown).
- __testable__/index.ts re-exports the new module.
- OCPP20ResponseService-TransactionEvent.test.ts and -ForceTxOnInvalid.test.ts
  now import createTestableResponseService from __testable__/index.js;
  call sites renamed (drops redundant OCPP20 prefix, consistent with
  createTestableRequestService).
- OCPP20ResponseServiceTestUtils.ts now contains only the test-only
  buildTransactionEventRequest payload builder; adopts @file header and
  arrow-style export to harmonize with MockFactories.ts neighbour.

Test-only refactor; no production behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): factor auth service injection helpers

Centralizes the two patterns used by OCPP 2.0 IncomingRequest tests to
inject mock OCPPAuthService instances into the factory cache:

- injectMockAuthService(station, service) — wraps the station-id lookup +
  OCPPAuthServiceFactory.setInstanceForTesting plumbing; replaces the
  duplicated boilerplate in ClearCache.test.ts and LocalAuthList.test.ts.
- withThrowingAuthServiceFactory(errorMessage, fn) — scoped monkey-patch +
  try/finally restore for the 3 sites that must exercise a failing
  getInstance (factory unavailable, no Authorization Cache support).
  Required because setInstanceForTesting only injects a returned instance;
  it cannot make getInstance itself throw.

Side effect of the helper adoption:

- LocalAuthList.test.ts drops the suite-scoped originalGetInstance save
  and afterEach restore — no longer needed since every monkey-patch is
  now scoped to its own test via withThrowingAuthServiceFactory.
- ClearCache.test.ts drops the local try/finally on the no-cache-support
  test for the same reason.

Test-only refactor; no production behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): align auth mock helper verbs and drop dead return type

V2-1: harmonize JSDoc verbs across the auth injection helpers — `inject*`
docstrings now start with 'Inject' to match the function name, and the local
`setupMockAuthService` wrappers (which build the mock + inject it) now start
with 'Configure and inject' to reflect their broader role. Avoids the verb
collision between the shared `injectMockAuthService` and the local
`setupMockAuthService` helpers.

V2-2: drop the dead `OCPPAuthService` return type from LocalAuthList's local
`setupMockAuthService` — no call site captured the return value. Also drops
the now-unused `OCPPAuthService` type import.

Test-only refactor; no production behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): align ClearCache test hook ordering with siblings

Moves the `afterEach` block below `beforeEach` in ClearCache.test.ts to match
the before-then-after ordering used by LocalAuthList, TransactionEvent and
ForceTxOnInvalid test files. Cosmetic; no behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
4 weeks agofix(deps): update all non-major dependencies (#1926)
renovate[bot] [Mon, 29 Jun 2026 13:25:43 +0000 (15:25 +0200)] 
fix(deps): update all non-major dependencies (#1926)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agochore: release main (#1911) cli@v4.10.0 ocpp-server@v4.10.0 simulator@v4.10.0 ui-common@v4.10.0 web@v4.10.0
Jérôme Benoit [Sun, 28 Jun 2026 22:29:24 +0000 (00:29 +0200)] 
chore: release main (#1911)

* chore: release main

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
5 weeks agofix(deps): update all non-major dependencies (#1925)
renovate[bot] [Fri, 26 Jun 2026 18:38:57 +0000 (20:38 +0200)] 
fix(deps): update all non-major dependencies (#1925)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agofix(ui-server): classify broadcast responses without handlers (#1924)
Jérôme Benoit [Wed, 24 Jun 2026 20:11:56 +0000 (22:11 +0200)] 
fix(ui-server): classify broadcast responses without handlers (#1924)

* fix(ui-server): classify broadcast responses without handlers

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): preserve internal broadcast origin

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): cleanup broadcast aggregation reliably

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): ensure late response cleanup

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(types): introduce UIRequestOrigin enum and widen ProtocolRequestHandler

- Promote 'internal' | 'transport' string-literal union to enum
  UIRequestOrigin { INTERNAL, TRANSPORT } hosted in src/types/UIProtocol.ts.
  Matches SCREAMING_SNAKE_CASE convention used across the file
  (ProcedureName, ResponseStatus, BroadcastChannelProcedureName).
- Promote UIRequestContext interface to src/types/UIProtocol.ts.
- Widen canonical ProtocolRequestHandler with optional context? parameter
  (backwards-compatible).
- Re-export UIRequestOrigin and UIRequestContext from src/types/index.ts.
- Drop the local UIServiceProtocolRequestHandler shadow in AbstractUIService.ts;
  consume the canonical ProtocolRequestHandler.
- Replace inline 'internal' / 'transport' literals at the two AbstractUIService
  defaults and the AbstractUIServer.sendInternalRequest call site.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): tighten handleProtocolRequest, rename log context, align terminology

- Tighten the canonical ProtocolRequestHandler in src/types/UIProtocol.ts:
  required params (uuid, procedureName, payload) match the runtime invariant
  guaranteed by the ProtocolRequest tuple destructure in requestHandler();
  return union collapses to Promise<ResponsePayload | undefined> | ... | undefined.
- Tighten handleProtocolRequest signature accordingly and drop the dead
  'Invalid protocol request' runtime throw (unreachable: requestHandler only
  calls it after the tuple has been destructured into non-null values).
- Rename interface BroadcastResponseLogContext to
  BroadcastChannelResponseLogContext for symmetry with the
  BroadcastChannel{ProcedureName, RequestContext, ...} family, and export
  it so test helpers can type-check the structured log payload.
- Align log strings in logBroadcastResponseWithoutHandler from
  'transport handler' to 'response handler' to match the public API
  (hasResponseHandler / responseHandlers). Test names and regex matchers
  updated atomically.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* style(ui-server): rename active to stopped and document rollback invariants

- Rename AbstractUIService.active boolean to stopped (default false, flipped
  by stop()). The branch label 'late broadcast response after UI service
  stop' now matches the field name; the read site at
  logBroadcastResponseWithoutHandler reads as 'if (this.stopped) debug,
  else warn' without needing a comment.
- Invert the two log branches under requestContext == null accordingly:
  the stopped branch leads with debug, the active branch with warn.
- Add a 1-line rationale comment on the try/catch in
  sendBroadcastChannelRequest documenting the bookkeeping/dispatch
  atomicity invariant (rollback the just-recorded context if the worker
  dispatch throws).
- Add a 1-line rationale comment on the try/finally in
  UIServiceWorkerBroadcastChannel.responseHandler documenting the
  aggregation-state release invariant under a sendResponse throw.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): extract expectSingleLog, assert log context, regress dispatch rollback

- Add expectSingleLog(mocks, level, pattern, contextShape?) to
  UIServerTestUtils.ts. Collapses the repeated
  'warnMock.length === 0 / debugMock.length === 1 + typeof guard +
  assert.match' block previously duplicated across six broadcast-response
  tests. Accepts an optional structured contextShape that, when provided,
  deep-equals the second logger argument against the
  BroadcastChannelResponseLogContext payload.
- Rewrite the six broadcast-response classification tests to invoke the
  helper. Each test now asserts the full structured log context (origin,
  procedureName, status, uuid, hashIds{Failed,Succeeded}), not just the
  message string — catching regressions that swap origin, drop
  procedureName, or omit hashIds propagation into the log payload.
- Add a new regression test 'should rollback expected responses when
  broadcast dispatch throws' that stubs uiServiceWorkerBroadcastChannel
  .sendRequest to throw, calls service.requestHandler with a broadcast
  procedure, and asserts service.getBroadcastChannelExpectedResponses
  rolled back to 0 — locking the try/catch invariant introduced in this
  PR.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): address post-design-pass nits

- tests: replace Reflect.get + double 'as never' chain in the broadcast
  dispatch rollback regression test with a typed cast to
  UIServiceWorkerBroadcastChannel. Kills both 'as never' casts and the
  '(): never' return annotation. Field rename now fails the test loudly
  via TS instead of silently mis-testing.
- tests: rename 'should warn on untracked broadcast responses while
  service is active' to '... before service stop' so the test name
  matches the post-rename 'stopped' field vocabulary.
- types: add JSDoc on the new public surface introduced by this PR -
  UIRequestOrigin, UIRequestContext, ProtocolRequestHandler widening
  (UIProtocol.ts), and BroadcastChannelResponseLogContext
  (AbstractUIService.ts). Docs describe contract, not mechanism.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
5 weeks agochore(deps): update all non-major dependencies (#1923)
renovate[bot] [Wed, 24 Jun 2026 15:06:27 +0000 (17:06 +0200)] 
chore(deps): update all non-major dependencies (#1923)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agofix(build): externalize prom-client from bundle
Jérôme Benoit [Wed, 24 Jun 2026 00:00:01 +0000 (02:00 +0200)] 
fix(build): externalize prom-client from bundle

5 weeks agodocs(readme): remove uiServer.metrics migration notes
Jérôme Benoit [Tue, 23 Jun 2026 23:06:59 +0000 (01:06 +0200)] 
docs(readme): remove uiServer.metrics migration notes

Drop the `#### Migration notes` block under the worker process model
section that documented the `uiServer.metrics.enabled=true` listener-
inheritance change. Operational guidance lives with the feature
documentation; this stand-alone block was redundant.

5 weeks agofeat(ui-server): expose Prometheus /metrics on ws and mcp transports (#1921)
Jérôme Benoit [Tue, 23 Jun 2026 22:35:14 +0000 (00:35 +0200)] 
feat(ui-server): expose Prometheus /metrics on ws and mcp transports (#1921)

The opt-in Prometheus `/metrics` endpoint introduced by #1912 was wired
only into `UIHttpServer`. With `uiServer.type` set to `ws` or `mcp`, the
endpoint was silently disabled with a startup warning.

Move the metrics registry, the request handler, and the soft-cap
accounting from `UIHttpServer` up to `AbstractUIServer`, then expose
`/metrics` on the shared `httpServer` from `UIWebSocketServer` and
`UIMCPServer` via a single `tryServeMetrics` template method that
encapsulates the `metrics.enabled` gate, the path predicate, the
authentication step, and the scrape handler. The
`uiServer.metrics.{enabled,softSampleCap}` configuration block is
preserved; the gauge registry is re-used unchanged; and `accessPolicy`,
rate-limiting, and `authentication` apply identically on all three
transports.

The lifecycle is bracketed by a public `start()` template-method on
`AbstractUIServer`; subclasses implement the `attachTransport()`
protected hook and may override the symmetric `detachTransport()` hook.
A one-shot `transportAttached` guard is set BEFORE `attachTransport()`
runs so a throwing hook cannot leave a half-attached server admitting a
silent retry; on failure any partially-registered `'request'` /
`'upgrade'` listeners on `httpServer` are stripped and the guard is
rolled back. `metricsScrapeChain` is reseated to `Promise.resolve()`
at every `start()` so cycle N+1 never awaits cycle N's scheduled
registry clear; in `runMetricsScrape`, the per-scrape
`metricsSampleCount` is captured into a local snapshot BEFORE
`await registry.metrics()` yields, so a peer scrape dispatched after
a sync `stop()`/`start()` cannot overwrite the count the soft-cap
branch reads. The corresponding `stop()` is symmetrically defensive:
both `detachTransport()` and each `uiService.stop()` are wrapped in
per-iteration `try/catch` so a throwing override does not abort
downstream teardown (`stopHttpServer`, remaining services, handlers,
caches), and the `transportAttached = false` reset runs in a `finally`
block so a subsequent `start()` is never permanently locked out — any
other unexpected throw still propagates. A shared
`renderNotFoundAndDestroy(req, res)` helper consolidates the 404
fallback used by the WebSocket and MCP request listeners; the helper
delegates socket teardown to `destroyHttp1SocketIfPending(req)` which
skips `req.destroy()` on HTTP/2 streams (lifecycle owned by the
`Http2Stream`) and on already-complete HTTP/1.1 requests (keep-alive
pool).

No new configuration surface, no new listener: `/metrics` is served on
the same `Http2Server | Server` instance that already backs the
WebSocket protocol upgrade in `UIWebSocketServer` and the `/mcp` route
in `UIMCPServer`. HEAD responses emit an explicit `Content-Length`
equal to the GET body byte length (`Buffer.byteLength(body, 'utf8')`)
and an empty body, per RFC 9110 §9.3.2. A frozen
`METRICS_ALLOWED_LABEL_NAMES` tuple pins the canonical PII surface; the
`isMetricsAllowedLabelName` type-guard predicate is the O(1) lookup,
and a guardian spec asserts every gauge label name in the rendered
registry is admitted.

Operator-visible change: `uiServer.metrics.enabled=true` with
`type='ws'` or `'mcp'` now serves `/metrics` whereas the previous
release silently disabled it. Operators relying on the silent disable
must set `metrics.enabled=false` (or omit the block) and audit firewall
posture on the UI server port. `HEAD /metrics` (e.g. `curl -I`) returns
identical headers to `GET` (including `Content-Length`) with an empty
body. README updated.

Closes #1917
Refs #851, #1912

5 weeks agochore(deps): lock file maintenance (#1920)
renovate[bot] [Tue, 23 Jun 2026 19:04:56 +0000 (21:04 +0200)] 
chore(deps): lock file maintenance (#1920)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agochore(deps): update all non-major dependencies (#1922)
renovate[bot] [Tue, 23 Jun 2026 16:42:40 +0000 (18:42 +0200)] 
chore(deps): update all non-major dependencies (#1922)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agochore(deps): update all non-major dependencies to v11.8.0 (#1919)
renovate[bot] [Mon, 22 Jun 2026 14:27:08 +0000 (16:27 +0200)] 
chore(deps): update all non-major dependencies to v11.8.0 (#1919)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agochore(deps): update all non-major dependencies (#1918)
renovate[bot] [Sun, 21 Jun 2026 13:13:05 +0000 (15:13 +0200)] 
chore(deps): update all non-major dependencies (#1918)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agofeat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851) (#1912)
Jérôme Benoit [Sat, 20 Jun 2026 18:47:27 +0000 (20:47 +0200)] 
feat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851) (#1912)

* feat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851)

Expose simulator state on GET /metrics when uiServer.metrics.enabled=true.
The endpoint is grafted into UIHttpServer.requestListener after the
existing runRequestPrologue (access policy + rate limit) and authenticate
calls, so it inherits per-client rate limiting, host/origin/proxy
validation and basic-auth credentials with no additional surface.
Prometheus is HTTP-only by spec; the flag is honoured only on
uiServer.type=http and AbstractUIServer.warnIfMisconfigured logs a
warning otherwise.

Metric families (exhaustive within the data already exposed via
ChargingStationData and Bootstrap.getState()):

* Global: simulator_info{version}, simulator_started,
  simulator_charging_station_templates_total,
  simulator_charging_stations_{configured,provisioned,added,started}_total,
  simulator_ui_server_known_stations_total,
  simulator_template_{added,configured,provisioned,started}{template}.
* Per charge station (label hash_id): simulator_station_info{vendor,
  model, firmware_version, ocpp_version, current_out_type},
  simulator_station_started, simulator_station_ws_state (numeric 0-3),
  simulator_station_connectors_total, simulator_station_evses_total,
  simulator_station_max_power_watts, simulator_station_max_amperage_amperes,
  simulator_station_voltage_out_volts,
  simulator_station_data_timestamp_seconds,
  simulator_station_boot_status_info{status},
  simulator_station_boot_heartbeat_interval_seconds,
  simulator_station_atg_enabled,
  simulator_station_diagnostics_status_info{status},
  simulator_station_firmware_status_info{status},
  simulator_station_ocpp_config_keys_total.
* Per connector (labels hash_id, connector_id):
  simulator_connector_status_info{status} (one-hot over
  ConnectorStatusEnum), simulator_connector_boot_status_info{status},
  simulator_connector_availability_info{availability},
  simulator_connector_error_code_info{error_code},
  simulator_connector_type_info{connector_type},
  simulator_connector_locked,
  simulator_connector_transaction_{started,pending,remote_started},
  simulator_connector_transaction_seq_no,
  simulator_connector_transaction_event_queue_size,
  simulator_connector_transaction_id (numeric value only, never label),
  simulator_connector_transaction_start_seconds,
  simulator_connector_transaction_energy_active_import_register_wh,
  simulator_connector_energy_active_import_register_wh,
  simulator_connector_max_power_watts,
  simulator_connector_charging_profiles_total,
  simulator_connector_evse_id, simulator_connector_reservation_active.

PII reject-list (HARD): supervisionUrl, chargeBoxSerialNumber,
chargePointSerialNumber, meterSerialNumber, iccid, imsi,
authorizeIdTag/localAuthorizeIdTag/transactionIdTag/transactionGroupIdToken
and OCPP customData. transactionId / remoteStartId never used as labels
(unbounded cardinality). Adversarial label values are escaped per
prom-client's exposition-format rules so synthesized # HELP / # TYPE
lines cannot be injected.

Cardinality soft cap: a single warning log per scrape when total
samples exceed METRICS_SOFT_SAMPLE_CAP=5000. The response is still
served in full so operators retain observability while being alerted
to fleet growth.

Adds prom-client@^15.1.3 and 17 new tests under
tests/charging-station/ui-server/UIMetricsEndpoint.test.ts covering
end-to-end exposition, security inheritance (access policy / rate
limit / basic-auth), the PII reject-list, exposition-format escaping
and the soft-cap warning. README.md updated with a "Metrics endpoint
(Prometheus)" subsection (config example, sample output, basic-auth
compatibility note, privacy/cardinality note, scrape_config snippet).

Refs: #851

* fix(ui-server): rebuild metrics registry on start to recover from stop/start cycle (M1)

The metrics registry was built only in the constructor and cleared on
stop(); after Bootstrap.stopUIServer() then startUIServer() (live-reload
path on uiServer.enabled toggle), metricsRegistry was undefined and
/metrics silently fell through to the JSON-RPC parser as a 400
Malformed URL with no log to indicate misconfiguration.

Move the registry build into start() guarded by an idempotency check
so the endpoint recovers across restart cycles. The constructor no
longer touches metricsRegistry.

Refs: #851 (Phase 4 review M1)

* feat(ui-server): expose metrics.softSampleCap in canonical defaults (M3)

The soft cardinality cap was documented as something operators may
'scale' but was not in the canonical defaults map. Add an optional
softSampleCap to UIServerMetricsConfigurationSchema (default 5000 via
METRICS_SOFT_SAMPLE_CAP) so operators can tune the threshold without
patching code, per AGENTS.md options-and-configuration rule.

Refs: #851 (Phase 4 review M3)

* fix(ui-server): serialize concurrent /metrics scrapes (M2 + m6 + m5)

Two concurrent GET /metrics requests interleaved on the shared
metricsSampleCount field and shared Gauge state, making the soft-cap
warning unreliable and the body content racy. Serialize scrapes via a
per-server promise chain; stop() now awaits the in-flight scrape
before clearing the registry, so a request mid-await no longer sees
undefined gauges.

Also adds the headersSent / writableEnded guard on the resolve path
so a client that closes mid-await does not trigger an emit-after-end.
The cap value is now read from uiServerConfiguration.metrics?.softSampleCap
(falls back to METRICS_SOFT_SAMPLE_CAP), wiring up the M3 schema field.

Refs: #851 (Phase 4 review M2 + m6 + m5)

* fix(ui-server): align iterateConnectors with simulator_station_connectors_total either-or (m2)

The per-connector iteration helper unconditionally yielded entries from
both data.connectors and data.evses, while simulator_station_connectors_total
uses an either-or rule. In current production these arrays are mutually
exclusive (buildConnectorEntries returns [] when hasEvses=true), so the
collision was unreachable, but the inconsistency made the helper fragile
to future invariant changes. Align it with the gauge's logic.

Refs: #851 (Phase 4 review m2)

* fix(ui-server): accept HEAD on /metrics for liveness-probe scrapers (n3)

Some Prometheus tooling HEAD-probes the endpoint before scraping.
Treat HEAD identically to GET in isMetricsRequest; Node drops the body
automatically on HEAD responses.

Refs: #851 (Phase 4 review n3)

* style: replace 'honoured' with 'honored' for spelling consistency (n2)

Aligns the new metrics docstring/log with American spelling used
elsewhere in the README and source.

Refs: #851 (Phase 4 review n2)

* docs(readme): fix metrics sample output to match actual exposition (m4)

The previous sample showed simulator_charging_stations_configured_total
with a 'template' label, but that metric is global with no labels.
The per-template counter is the separate simulator_template_configured
gauge. Replace the sample with the actual exposition shape.

Refs: #851 (Phase 4 review m4)

* test(ui-server): add wsState=undefined and EVSE-mode coverage (M4)

The new tests close two regression-detection gaps identified in Phase 4
review: (a) removing the !== undefined guard on data.wsState would
silently emit NaN — now caught by an absence assertion; (b) the OCPP
2.0.x code path (evses populated, connectors empty, evseStatus.connectors
Map iteration) was entirely untested — now exercised end-to-end.

Refs: #851 (Phase 4 review M4)

* test(ui-server): add soft-cap boundary tests for off-by-one detection (m3)

Probe-then-verify pattern: first scrape with a very high cap to count
actual samples produced; then assert no warn fires when cap equals the
sample count (strict-greater-than semantics) and one warn fires when
cap is one below. This regression test would fail if '>' became '>='
on the soft-cap comparison.

Refs: #851 (Phase 4 review m3)

* test(ui-server): retarget rate-limit burst at allowed loopback path (m1)

T9 previously hit a non-loopback denied path, exercising the rate
limiter on a 403 response rather than on /metrics. Switch to a
loopback request that is allowed through the access policy and assert
that the rate limit fires on /metrics directly.

Refs: #851 (Phase 4 review m1)

* style(test): rename tests per TEST_STYLE_GUIDE and drop redundant assertion (n1, n4)

Renamed all tests from 'T<N>: <verb>...' to 'should <verb>...' format
per tests/TEST_STYLE_GUIDE.md. Also dropped the redundant pre-stop
assertion in 'should clear registry on stop()' (the post-stop
assertion already proves the stop() effect).

Refs: #851 (Phase 4 review n1, n4)

* refactor(ui-server): add HEAD to HttpMethod enum (n1)

Avoid the bare 'HEAD' string literal in UIHttpServer.isMetricsRequest by
extending HttpMethod with an explicit HEAD member, matching the AGENTS.md
guidance to prefer enumerations over string literals when one exists.

* refactor(ui-server): adopt prom-client canonical collect pattern (M1+m1+m2+m3+n2+n3+n4+n5+n6)

Phase 6 review feedback consolidated into one coherent surgery on the
metrics path:

- M1: defineGauge now returns Gauge<L> with auto-injected registers and a
  string-literal label-name generic. Every collect() callback is non-arrow
  with typed 'this: Gauge<L>', per the prom-client documentation. Eliminates
  ~20 unsafe 'as Gauge | undefined' casts and ~30 dead null-guards.
- m1: collapse the four global aggregate gauges into a single tuple-loop,
  mirroring the per-template loop pattern.
- m2: append a terminal '.catch(() => undefined)' to the metricsScrapeChain
  reassignment in stop() so the field always points to a handled promise.
- m3: document the metricsSampleCount/metricsScrapeChain concurrency
  invariants; explicitly forbid async collect callbacks.
- n2: factor 'ChargingStationDataProvider' type alias at module scope.
- n3: drop the redundant outer 'async' on handleMetricsRequest.
- n4: replace box-drawing dividers with plain JSDoc section markers.
- n5: rename 'PII whitelist invariant' to 'PII allowlist invariant'.
- n6: document the OCPP either-or rule on iterateConnectors.

The HTTP /metrics contract is unchanged; existing tests pass without
modification.

* docs(readme): align metrics documentation with code (m4, m5)

- m4: replace the two stale '# HELP' lines in the sample exposition block
  with the strings actually emitted by the registry (commit 1bac5c23d
  fixed two of the four; the other two had drifted again).
- m5: add the 'metrics' sub-key to the uiServer row of the configuration
  table: bullet under the Description column documenting metrics.enabled
  and metrics.softSampleCap, and corresponding shape in the Value type
  column. AGENTS.md 'Documentation conventions / Exhaustivity' treats the
  table as the authoritative tunable list.

* test(ui-server): regression for concurrent /metrics scrapes (R1, R2)

Locks the metricsScrapeChain serialization invariant against future drift:
- R1: two concurrent scrapes against a registry whose sample count equals
  the configured softSampleCap; honest serialized execution produces zero
  warns. A regression that removes the chain (or makes any collect()
  callback async) would either spuriously warn (counter shared between
  scrapes) or corrupt the body, both detected by this test.
- R2: same test indirectly guards against async collect callbacks; the
  invariant is also documented as a JSDoc on buildMetricsRegistry.

* refactor(ui-server): apply Phase 7 polish (M1+n2+n3+n4+n5+n6+n8+n9)

- M1: collapse 3 inline per-station gauges (ws_state, connectors_total,
  boot_status_info) into existing helpers (~40 LOC less duplication).
- n2: simulator_station_connectors_total now counts via iterateConnectors,
  making the iterateConnectors JSDoc 'shared with' claim literally true.
- n3: replace 'const self = this' with a typed 'provider:
  IChargingStationDataProvider' built from .bind(this) method captures.
  Drops the @typescript-eslint/no-this-alias suppression and narrows the
  type surface accessed by collect() callbacks.
- n4: justify defineGauge<L extends string = never> default in the JSDoc
  (stricter than prom-client's 'Gauge<T extends string = string>').
- n5: addPerStationInfoLabel hard-codes 'status' (all callers used it);
  addConnectorOneHot narrows labelName to a literal union of valid keys —
  both eliminate the theoretical 'hash_id'/'connector_id' clobber risk.
- n6: rename ChargingStationDataProvider to IChargingStationDataProvider
  (interface form, repo I-prefix convention) and extend with
  getChargingStationsCount.
- n8: tighten handleMetricsRequest and iterateConnectors @param
  descriptions to remove low-signal restatements.
- n9: drop the explanatory '// Explicit return required by
  promise/always-return lint rule' comment; the lint rule speaks for
  itself when the line is removed.

* test(ui-server): tighten concurrent-scrape regression with sample-count assertion (n1)

Replace the weak 'bodyA === bodyB' check (which would pass even if the
metricsScrapeChain serialization were removed, since both scrapes
collect against the same Registry instance) with an exact sample-line
count assertion against the probed value. Locks two distinct invariants
that bodyA===bodyB did not: no truncation, and no double-count from
interleaved 'collect()' calls under prom-client's internal Promise.all.

* docs(readme): add trailing semicolon to metrics value type (n7)

Align the new 'metrics?: { ... }' member of the uiServer Value type
column with the surrounding style (every other nested-object member
in the same type literal terminates with ';').

* [autofix.ci] apply automated fixes

* refactor(ui-server): apply Phase 8 NITs (n2+n3+n4+n5+n7; n1 deferred)

- n2: rename addPerStationInfoLabel to addPerStationStatusInfo to match
  the helper's actual responsibility (it hard-codes the 'status' label
  per Phase 7 n5).
- n3: drop the I-prefix on ChargingStationDataProvider — most interfaces
  in src/ are unprefixed, only IBootstrap uses I.
- n4: extend the ChargingStationDataProvider JSDoc to acknowledge that
  the inline simulator_ui_server_known_stations_total gauge consumes the
  getChargingStationsCount method (not only the helpers).
- n5: extract countConnectors as a single source of truth for the OCPP
  either-or rule and have simulator_station_connectors_total consume it.
  Also restructure the iterateConnectors JSDoc to cross-reference
  countConnectors instead of the gauge.
- n7: tighten @param res on handleMetricsRequest to 'HTTP response to
  end with the exposition body.' (restores the direction Phase 7 n8
  shortened away).
- n1 (addConnectorOneHot generic threading) is deferred: TypeScript
  cannot narrow the dynamic computed property '[labelName]: v' to
  'Record<L, string>' without an 'as' cast, which AGENTS.md 'Type
  safety' forbids. The runtime literal-union on labelName is kept as
  the type-safety contract; a 3-line comment documents the trade-off.

* refactor(ui-server): apply Phase 9 NITs (n1+n2+n3)

- n1: thread Gauge<...> end-to-end on addConnectorOneHot via a
  ConnectorOneHotLabel type + typed-init + property mutation pattern.
  Phase 9 oracle B proved the cast-free pattern compiles cleanly under
  strict TS 6.0.3 (probe with 8 alternatives, only this one passes).
  Replaces the Phase 8 deferral comment, which mis-attributed an 'as'
  ban to AGENTS.md (AGENTS.md only forbids '!' and 'any').
- n2: drop the self-{@link} on countConnectors JSDoc opening — the
  block documents countConnectors itself, so the cross-reference back
  to it reads awkwardly. Asymmetric pattern with iterateConnectors
  preserved.
- n3: drop the duplicate '(see {@link countConnectors} for the
  invariant source)' parenthetical on iterateConnectors JSDoc — the
  leading 'same...rule as {@link countConnectors}' already directs the
  reader, and 'invariant source' was jargon.

* docs(ui-server): harmonize JSDoc prose with repo cadence

Three Phase 9-pass-2 audit findings against the rest of the codebase:

- Drop the coined phrases 'either-or rule' and 'OCPP-version-driven'
  from countConnectors and iterateConnectors JSDoc; the repo-wide
  prose simply names 'OCPP 1.6' / 'OCPP 2.0.x' inline (see ChargingStation.ts
  '$OCPP 2.0.1 §4.2.3', Helpers.ts 'OCPP 2.0 chargingSchedule',
  TemplateSchema.ts 'OCPP 2.0.1 §7.2'). Use 'mutually exclusive' for the
  source-split semantic.
- Drop the bold-stress '**Invariant**:' prose markers from the
  metricsSampleCount, stop() and buildMetricsRegistry JSDoc; `grep -rn '\*\*'
  src/` returns 0 such markers in JSDoc anywhere else. Inline the
  invariant prose into the surrounding sentences without a section
  marker.

* docs(test): harmonize 'soft cap' spelling and @description cadence

Two outliers found in the test file's prose during repo-wide audit:
- 'soft-cap' (3 occurrences: @description, 1 test name, 1 inline comment)
  was hyphenated while the production warning string and ConfigurationSchema
  JSDoc both spell it 'soft cap' / 'soft sample cap' (no hyphen). Tests'
  string-includes('soft cap') matches were already correct; only the prose
  drifted.
- @description was multi-line whereas every other test file in tests/
  uses a single-line @description (verified across ChargingStation*.test.ts,
  ConfigurationKeyUtils.test.ts, TemplateValidation.test.ts, etc.).

* docs(ui-server): harmonize misconfiguration warning style with repo cadence

The warnIfMisconfigured warning used an em-dash + uppercase 'NOT' to
separate the condition from the consequence:

  metrics.enabled=true is only honored when uiServer.type='http';
  current type='X' — the /metrics endpoint will NOT be served.

Sibling warnings in AbstractUIServer (host-not-allowed and tls-required
at lines 441-457) use semicolon-and-period separators with normal-case
prose and a final action sentence. Match that cadence:

  metrics.enabled=true is honored only when uiServer.type='http'; current
  type='X'. The /metrics endpoint will not be served. Set uiServer.type='http'
  to expose metrics.

Test substring matches ('metrics' / 'http') are preserved.

* test(ui-server): rename M-prefix fixtures to T-prefix convention

The fixture station hash IDs 'station-M{evse,boundary,concurrent}' inherited
the 'M' prefix from the Phase 4 review's MAJOR finding IDs (M1..M4) and
were tokenized by cspell as unknown words 'Mevse', 'Mboundary',
'Mconcurrent' — emitting 7 lint warnings.

The rest of the metrics test file already follows the 'station-T<n>'
numeric convention ('station-T5', 'station-T12', 'station-T13',
'station-T16'), where <n> matches the test ordinal. Map the M-prefixed
fixtures to their actual test ordinals:
- 'Mevse'        -> 'T18' (EVSE-mode test, the 18th 'await it()')
- 'Mboundary-*'  -> 'T19-*' (soft-cap boundary, 19th)
- 'Mconcurrent-*'-> 'T20-*' (concurrent-scrape regression, 20th)

Quality gates now report 0 errors AND 0 warnings; previous baseline
was 0 errors / 7 warnings.

* docs(readme): drop redundant Metrics endpoint section, harmonize uiServer.metrics bullet

The dedicated '### Metrics endpoint (Prometheus)' section duplicated
information that is already authoritative elsewhere:
- The /metrics behavior, configuration shape and defaults are documented
  in the uiServer row of the configuration table (single source of
  truth, per AGENTS.md 'No duplication' rule).
- The PII reject-list and softSampleCap semantics are documented in
  UIServerMetricsConfigurationSchema JSDoc.
- The HTTP-only constraint and the warning behavior are documented in
  the AbstractUIServer.warnIfMisconfigured warning string itself.

Drop the section, the TOC entry, and the now-dangling cross-link from
the table bullet. Restructure the _metrics_ bullet to mirror the
_accessPolicy_ cadence in the same row (top-level intro + indented
sub-bullets per sub-key), so the table description is self-sufficient
and harmonized with its sibling.

* chore(gitignore): ignore .codegraph alongside .omo/

.codegraph is a symlink to .omo/codegraph/projects/<hash>/ used by the
oh-my-opencode tooling, on the same lifecycle as .omo/ and .sisyphus/.
Group it under the existing 'oh-my-opencode' section.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
6 weeks agofix(deps): update all non-major dependencies (#1915)
renovate[bot] [Sat, 20 Jun 2026 12:00:45 +0000 (14:00 +0200)] 
fix(deps): update all non-major dependencies (#1915)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agochore(deps): update actions/checkout action to v7 (#1914)
renovate[bot] [Fri, 19 Jun 2026 17:59:00 +0000 (19:59 +0200)] 
chore(deps): update actions/checkout action to v7 (#1914)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agochore(deps): update all non-major dependencies (#1913)
renovate[bot] [Fri, 19 Jun 2026 13:10:46 +0000 (15:10 +0200)] 
chore(deps): update all non-major dependencies (#1913)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agostyle(test): fix lint warnings in OCPP 2.0 ForceTxOnInvalid test
Jérôme Benoit [Wed, 17 Jun 2026 17:22:45 +0000 (19:22 +0200)] 
style(test): fix lint warnings in OCPP 2.0 ForceTxOnInvalid test

Three pre-existing post-merge warnings introduced by the squash-merge
of #1907 on the file:
`tests/charging-station/ocpp/2.0/OCPP20ResponseService-ForceTxOnInvalid.test.ts`

- Lines 67-68: `jsdoc/require-param-description` — fill the empty
  `@param idToken.idToken` and `@param idToken.type` sub-property
  descriptions auto-inserted by eslint-plugin-jsdoc.
- Line 298: `@cspell/spellchecker` — replace "remock" with "re-mock"
  (English-correct hyphenation, matches existing repo idioms like
  "re-entrant", "re-export").

Verification: pnpm lint exit 0 with 0 errors / 0 warnings; focused
`pnpm test` on the file: 16/16 GREEN (substring assertions unaffected).

6 weeks agofeat(simulator): add forceTransactionOnInvalidIdToken template flag (#1907)
Jérôme Benoit [Wed, 17 Jun 2026 16:49:22 +0000 (18:49 +0200)] 
feat(simulator): add forceTransactionOnInvalidIdToken template flag (#1907)

* feat(simulator): add forceTransactionOnInvalidIdToken template flag

Allow station-initiated transactions to continue even when CSMS replies
with a non-Accepted IdToken status (OCPP 1.6 idTagInfo.status, OCPP 2.0.1
idTokenInfo.status). Default false preserves spec-compliant behavior.

The flag is non-spec-compliant by design (violates OCPP 2.0.1 E05.FR.09,
E05.FR.10, E06.FR.04 when enabled) and is intended for testing edge-case
charging station implementations that ignore CSMS rejection. JSDoc on the
field warns explicitly; README entry flags it as a non-spec-compliant test
override.

Scope in OCPP 2.0.1 is limited to TransactionEvent eventType=Started;
mid-transaction revocation (Updated/Ended event types) still triggers
deauthorization regardless of the flag. The OCPP 2.0.1 \`StopTxOnInvalidId\`
device-model variable is left untouched — the template flag short-circuits
ahead of the variable's accounting branch.

Tests: 16 new test cases across both OCPP namespaces covering force-on/off,
mid-tx revocation preservation, override marker log presence, auth cache
non-relaxation, pre-Start guard preservation, and full status-enum parity
(8 OCPP 2.0.1 statuses).

Closes #1826

* fix(types): restore fixedName and pin endedConnector non-null

The forceTransactionOnInvalidIdToken JSDoc rewrite in 73e3358a accidentally
deleted the adjacent fixedName?: boolean field on ChargingStationTemplate,
breaking 30+ TS errors in CI (ChargingStationNameTemplate Pick keyed on
fixedName). Local pnpm test does not run tsc --noEmit, so the regression
was invisible until CI.

Also fix a CI-only error in OCPP20ResponseService-ForceTxOnInvalid.test.ts:
endedConnector is typed as ConnectorStatus | undefined; replace the
optional-chain assertions with an explicit if/fail guard so TS narrows
the type for the subsequent strict-equal assertions.

* test(2.0): document deferred MV-pump fence and ConcurrentTx scope

Phase-4 review-C MINOR-1: the 2.0-T2 test asserts that
\`startUpdatedMeterValues\` is called but stubs the helper, so a wire-level
regression where the interval binds to the wrong connector would not be
caught. Phase 6 golden-set with the live mock CSMS will close that gap;
add a TODO comment to make the deferral explicit.

Phase-4 review-C NIT-1: the T7 enum-parity loop omits ConcurrentTx; this
is correct (ConcurrentTx is not an IdToken rejection in OCPP 2.0.1) but
deserves an inline comment to prevent a future contributor from adding it
naively.

* test(ocpp): tighten forceTransactionOnInvalidIdToken coverage and clarify docs

Follow-up to #1907 applying post-review fixes from a 3-angle audit
(production / types-schema-readme / tests-adversarial).

Production (OCPP 2.0):
- Simplify defensive `else if (overrideRejection && status !== Accepted)`
  to `else if (overrideRejection)` in handleResponseTransactionEvent;
  the second predicate is entailed by the enclosing if/else.
- Trim duplicated 5-line comment block down to a 2-line pointer to the
  canonical JSDoc on ChargingStationTemplate.forceTransactionOnInvalidIdToken.

Documentation:
- ChargingStationTemplate JSDoc: disambiguate from the OCPP variables
  StopTransactionOnInvalidId (1.6) and StopTxOnInvalidId (2.0.1) which
  control mid-transaction stop on revocation and have inverse polarity;
  state independence from ocppStrictCompliance.
- README row: same disambiguation appended to the cell.

Tests (OCPP 1.6 ForceTxOnInvalid):
- T5 (pre-Start guard): change response status from Accepted to Invalid
  so the test exercises the flag-vs-guard interaction it claims to lock.
- T6 (new): status-enum parity loop via Object.values(OCPP16AuthorizationStatus)
  excluding Accepted and ConcurrentTx (3 instances: Blocked, Expired, Invalid).
- T2: replicate the Phase-6 fake-timer-fence TODO from the sibling 2.0 file
  (MV pump observability disclosure).
- Rename test titles to should [verb] per tests/TEST_STYLE_GUIDE.md \xa71.

Tests (OCPP 2.0 ForceTxOnInvalid):
- buildTransactionEventRequest: add optional idToken parameter to exercise
  the auth-cache update path at handleResponseTransactionEvent.
- T8 (new): C10.FR.01/04/05 auth-cache invariant test, parametric over
  [flag=true, flag=false] (2 instances) asserting updateAuthorizationCache
  is called with the supplied idToken and the CSMS-replied idTokenInfo.
- T4 (Ended): add deauth call-count assertion (=== 0); locks the
  cleanup-runs-before-deauth-gate ordering invariant. The deauth path is
  reached but no-ops because cleanupEndedTransaction clears transactionId
  first, so getConnectorIdByTransactionId returns null. A regression that
  reorders cleanup after the gate (or preserves transactionId on Ended)
  flips this to 1.
- T7 (parity): replace static 8-element array with
  Object.values(OCPP20AuthorizationStatusEnumType).filter(...). Same 8
  instances today; future enum additions are auto-covered.
- T6: split into 2 it() blocks (flag-on / flag-off) eliminating the
  mid-test standardCleanup+remock pattern. Each block additionally asserts
  that the override-marker warn-log is NOT emitted on null idTokenInfo,
  locking the invariant against an A6-style regression where the
  override-marker else-if is hoisted outside the outer null guard.
- Rename test titles to should [verb].

Verification:
- pnpm typecheck exit 0
- pnpm lint exit 0
- pnpm test: 0 fail across the full suite
- 23/23 GREEN on the two ForceTxOnInvalid files (was 17/17; +6 new)
- Three mutation experiments confirm regression-detection strength:
  * Drop `|| forceTransactionOnInvalidIdToken` from OCPP16 gate (line 427):
    T2 + 3 parity tests fail with `false !== true` on transactionStarted.
  * Replace `if (requestPayload.idToken != null)` with `if (false)` in
    OCPP20 cache update (line 519): T8 (both flag states) fails `0 !== 1`.
  * Bypass the OCPP16 unauthorized-remote-start guard (lines 315-329):
    T5 only fails (regression-localized to that scenario).

* docs(ocpp): note ocppStrictCompliance independence and clarify Started log

Follow-up to review v2 of #1907 closing the two actionable findings
(B4 Low, N1 nit). Two remaining v2 nits (B5 multi-line JSDoc, R3 test
cast) are intentionally deferred — rationale in
/tmp/pr-1907-review-v2/fixes-design.md.

B4 — ChargingStationTemplate.forceTransactionOnInvalidIdToken JSDoc and
the matching README row both gain a one-clause disambiguation: "Independent
of `ocppStrictCompliance` (operates on response handling, not schema
validation)". Mirrors the pattern used by the sibling `outOfOrderEndMeterValues`
row (which cites ITS `ocppStrictCompliance` coupling), preventing readers
from inferring a non-existent coupling for the new flag.

N1 — OCPP20ResponseService.handleResponseTransactionEvent override warn
log now reads "...on eventType=Started despite..." (was "...on Started
despite..."). The `eventType=` prefix removes ambiguity with OCPP
connector state names (Charging, Available) and aligns the log vocabulary
with the JSDoc, the inline reference comment, and the test fixture
(`requestPayload.eventType === OCPP20TransactionEventEnumType.Started`).
The override-marker substring `forceTransactionOnInvalidIdToken=true` is
preserved verbatim, so the four test assertions that grep for it remain
unaffected.

Verification:
- pnpm typecheck exit 0
- pnpm lint exit 0
- pnpm test: 0 fail across the full suite
- 23/23 GREEN on the two ForceTxOnInvalid files (count unchanged; doc-only changes)
- README table re-aligned by prettier on save (column padding shifted
  421 -> 460 chars to fit the longer description); diff is mechanical
  whitespace, content change is one clause.

6 weeks agorefactor(bootstrap): remove redundant stopping flag (#1909)
Jérôme Benoit [Wed, 17 Jun 2026 16:49:03 +0000 (18:49 +0200)] 
refactor(bootstrap): remove redundant stopping flag (#1909)

6 weeks agochore(deps): update all non-major dependencies to ^3.3.5 (#1910)
renovate[bot] [Wed, 17 Jun 2026 08:56:40 +0000 (10:56 +0200)] 
chore(deps): update all non-major dependencies to ^3.3.5 (#1910)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agochore: release main (#1885) cli@v4.9.0 ocpp-server@v4.9.0 simulator@v4.9.0 ui-common@v4.9.0 v4.9 web@v4.9.0
Jérôme Benoit [Tue, 16 Jun 2026 17:45:59 +0000 (19:45 +0200)] 
chore: release main (#1885)

* chore: release main

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
6 weeks agochore(deps): lock file maintenance (#1900)
renovate[bot] [Tue, 16 Jun 2026 17:32:27 +0000 (19:32 +0200)] 
chore(deps): lock file maintenance (#1900)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agofix(bootstrap): make start/stop idempotent and signal handler re-entrant safe (#1905)
Jérôme Benoit [Tue, 16 Jun 2026 17:02:22 +0000 (19:02 +0200)] 
fix(bootstrap): make start/stop idempotent and signal handler re-entrant safe (#1905)

- Memoize startPromise/stopPromise so concurrent callers await the same
  in-flight transition instead of silently no-op'ing.
- Extract start()/stop() bodies into private doStart()/doStop() so the
  public methods are thin memoization wrappers.
- Demote idempotent guard hits from error to warn (matches sister
  ChargingStation convention) for "Cannot start/stop already ..." paths
  and to debug for "Awaiting in-flight ..." concurrency observations.
- Make gracefulShutdown re-entrant safe via shuttingDown flag --
  multiple SIGTERM/SIGINT/SIGQUIT no longer race the in-flight stop.
- On the no-op stop path with reason=user, ensure
  .simulator-state.json still reflects the authoritative state (writes
  started:false if file says started:true). Fixes UI clients reading
  stale started:true after a UI-driven stop on an already-stopped sim.
- Document Bootstrap.stop coalescing semantics: the FIRST caller's
  reason controls the in-flight transition; later callers' reasons are
  ignored.
- Add 'entrancy' to cspell dictionary (re-entrancy is the standard
  concurrency-engineering spelling).

Tests: tests/charging-station/Bootstrap.test.ts (new file, 11 tests)
- concurrent stop() / start() callers observe the same in-flight
  transition
- gracefulShutdown is re-entrant: 3 synchronous calls invoke stop()
  exactly once (direct unit test on Bootstrap.prototype, runs on every
  platform including Windows)
- multiple SIGTERM produce a single 'Graceful shutdown' log line
  (POSIX-only spawn-based integration smoke; skipped on Windows where
  child.kill('SIGTERM') maps to TerminateProcess and bypasses the
  handler -- coverage for that platform comes from the unit test
  above)
- state-file consistency on the no-op stop path (3 sub-cases:
  reason=user, reason=shutdown, real-stop-then-no-op-stop)
- idempotent guards log at warn or debug, not error (4 sub-cases)

Reproduction: 5x kill -TERM in tight loop, before fix produces 'Cannot
stop an already stopping' error log; after fix produces a single
'Graceful shutdown' info log with no error.

Refs: review feedback on PR #1905 -- 1 BLOCKER, 4 MAJOR, 4 MINOR
findings, all addressed in this amended commit.

6 weeks agotest(ui-common): cover buildStatusNotificationPayload 1.6/2.0.x branches (#1834)...
Jérôme Benoit [Mon, 15 Jun 2026 19:34:42 +0000 (21:34 +0200)] 
test(ui-common): cover buildStatusNotificationPayload 1.6/2.0.x branches (#1834) (#1904)

* test(ui-common): cover buildStatusNotificationPayload 1.6/2.0.x branches (#1834)

Audit pass over the describe('buildStatusNotificationPayload') block
added by PR #1902. Adds the only two production-callsite-tied cases the
existing 9 tests miss. No production code change.

T2.1 — OCPP 1.6 with errorCode and evseId together:
- Tied to ui/cli/src/commands/ocpp.ts:191-203 (1.6 requires --error-code,
  passes evseId) and ui/web/src/skins/classic/components/charging-stations
  /CSConnector.vue:172-173 (selectedErrorCode defaults to NO_ERROR).
- Existing tests cover errorCode-without-evseId and evseId-without-
  errorCode but never both flags together — the actual production path.

T2.2 — undefined ocppVersion with errorCode and evseId:
- Tied to ui/web/src/core/UIClient.ts:141-155 +
  ui/web/src/shared/composables/useConnectorActions.ts:113-122 where
  setConnectorStatus is unconditionally invoked with both options even
  when props.ocppVersion is OCPPVersion | undefined.
- Existing 'undefined version' test omits both options; this asserts the
  1.6 fallthrough preserves them.

3-angle audit found other proposals (cross-enum mismatch guard, marking
existing cases [SYNTHETIC], structural refactor into nested describes)
but they did not survive cross-validation: the first requires a runtime
guard in payloadBuilders.ts (out of scope per task spec), the second is
subjective, the third is cosmetic per AGENTS.md 'small verifiable
changes'.

* test(ui-common): align buildStatusNotificationPayload test names with style guide

Address review feedback on #1904.

Both new test names violated tests/TEST_STYLE_GUIDE.md "should [verb]
in lowercase" describing observable behavior:
- T2.1 carried a callsite traceability annotation
  '(CLI + Web UI 1.6 path)' in the title.
- T2.2 used the implementation-internal term 'fall through' plus a
  '(Web UI undefined-version path)' annotation.

Renamed to mirror the existing sibling tests in the same describe
block:
- T2.1: 'should pass errorCode and evseId through for OCPP 1.6 when
  both are provided' (mirrors 'should pass evseId through for OCPP 1.6
  without errorCode').
- T2.2: 'should default to OCPP 1.6 shape with errorCode and evseId
  when ocppVersion is undefined' (mirrors 'should default to OCPP 1.6
  shape when ocppVersion is undefined').

Callsite traceability lives in the commit message + PR body, not the
test title — matches the convention of all 9 prior cases in the same
describe. No assertion change. 116/116 tests pass.

6 weeks agofix(config): enforce Zod simulator-config validation at boot (#1874) (#1903)
Jérôme Benoit [Mon, 15 Jun 2026 19:20:06 +0000 (21:20 +0200)] 
fix(config): enforce Zod simulator-config validation at boot (#1874) (#1903)

* fix(config): enforce Zod simulator-config validation at boot (#1874)

Audit pass over uiServer.* primitives that PR #1901 left under-constrained.
Each constraint closes a concrete fail-fast gap surfaced by a 3-angle design
review (schema completeness, fail-fast wiring, adversarial risk).

UIServerAuthenticationSchema:
- username: z.string().min(1).optional() — empty username currently passes;
  AbstractUIServer falls back to '' and authenticates against the empty
  configured value, silently bypassing Basic-Auth.
- password: z.string().min(1).optional() — same hole on password.
- username .refine(no ':') (RFC 7617) — lifts the runtime check at
  UIServerFactory.ts:48-54 into the schema layer.
- object-level .refine: enabled === true requires both username and password.
  Closes the silent-bypass when authentication is enabled with no creds.

UIServerAccessPolicySchema:
- object-level .refine: allowLoopbackProxy === true requires
  trustedProxies.length >= 1. With allowLoopbackProxy: true and an empty
  trustedProxies list, the proxy-trust check at runtime always evaluates
  against the empty allowlist, contradicting the operator's intent.

No wiring change: Configuration.ts:160 (boot, process.exit(1)) and
Configuration.ts:386-431 (hot-reload, rollback) already route
ConfigurationValidationError correctly. Schema tightening lands at parse
time without rewiring.

Test fixture update: tests/utils/ConfigurationSchema.test.ts unknown-key
test for uiServer.authentication now includes valid username/password
alongside bogusAuthKey so the unknown-key intent stays unambiguous after
the new object-level superRefine.

9 new test cases (5 reject + 3 accept regression-anchors + 1 cross-field
reject) covering rejection paths and the docker/config.json-shape
acceptance.

Designs that did NOT survive cross-validation are documented as
out-of-scope follow-ups: type×version coupling (coupled to in-place
mutation bug at UIServerFactory.ts:69), type×auth coupling (defense in
depth at runtime is intentional), options.{path,readableAll,writableAll,
ipv6Only} refinements (no shipped config exercises these), and the
TLS-non-loopback wildcard refine (would break docker/config.json:18).

* fix(config): refine auth schema error path and tighten test path checks

Address review feedback on #1903:

src/utils/ConfigurationSchema.ts
- UIServerAuthenticationSchema object-level .refine now carries
  path: ['username'], producing an error path of
  'uiServer.authentication.username' rather than 'uiServer.authentication'.
  Mirrors the path: ['allowLoopbackProxy'] convention already used on
  UIServerAccessPolicySchema and yields a more actionable message.
- JSDoc clarifies that the field-level constraints (min(1), no ':')
  apply unconditionally even when authentication.enabled is false:
  the schema is intentionally stricter than the runtime guard in
  UIServerFactory so a credentials value cannot ship under
  enabled: false and become a bypass the moment enabled flips on
  (e.g. via hot-reload).

tests/utils/ConfigurationSchema.test.ts
- Three .includes('uiServer.authentication') substring checks tightened
  to .startsWith('uiServer.authentication') so they cannot match a
  hypothetical sibling key like 'uiServer.authenticationFoo'. Affects
  the two object-level coupling tests and the unknown-key fixture
  assertion. Exact-element checks at lines 630/651 (specific leaf
  paths) and the leaf-specific .includes at line 671 are left as-is.

No behavior change. 114/114 schema tests pass, typecheck + lint clean.

* docs(config): tighten UIServerAuthenticationSchema JSDoc

Rewrite the JSDoc as a single factual paragraph matching the file
convention (LogConfigurationSchema, WorkerConfigurationSchema,
UIServerListenOptionsObjectSchema, ...) — 12 lines collapsed to 7,
no redundant phrasing, structured as: constraint definitions →
required-when-enabled coupling → unconditional-field-level rationale.
Per AGENTS.md 'Documentation conventions' (concision, structure).

* fix(config): emit per-field error paths on auth-required refine

Address sub-agent review feedback on #1903.

src/utils/ConfigurationSchema.ts
- Replace the .refine() with a fixed path: ['username'] by a
  .superRefine() that emits one issue per actually-missing field with
  the correct path. Configurations supplying only 'username' now
  surface the issue at 'uiServer.authentication.password' rather than
  'uiServer.authentication.username', and vice versa. Configurations
  missing both produce two issues, one per field.

tests/utils/ConfigurationSchema.test.ts
- 'should reject authentication enabled without username and password'
  now asserts both 'uiServer.authentication.username' and
  'uiServer.authentication.password' paths are present, locking the
  per-field issue contract.
- 'should reject authentication enabled with username but no password'
  now asserts 'uiServer.authentication.password' specifically — would
  catch the prior path-misanchoring regression.
- The 'should reject unknown key in uiServer.authentication section'
  regression-anchor now asserts i.code === 'unrecognized_keys' at the
  'uiServer.authentication' path, locking the unknown-key intent so a
  hypothetical future refine cannot satisfy the assertion by accident.

114/114 schema tests pass, typecheck + lint clean. No production-
runtime behavior change beyond per-field error paths.

* fix(ui-common): align authenticationConfigSchema with simulator auth schema

Apply the same defense-in-depth + per-field-error-path pattern shipped
on the simulator-side UIServerAuthenticationSchema in this PR. The Web
UI client schema (consumed by ui/web/src/main.ts and transitively by
ui/cli/src/config/loader.ts) carried the same set of defects:
- password lacked min(1) at the field level
- username regex /^[^:]*$/ accepted the empty string
- a single .refine combined type-check + presence + length, producing
  a generic error at the auth object root with no path
- empty creds shipped under enabled: false would become a Basic-Auth
  bypass the moment enabled is flipped on

ui/common/src/config/schema.ts
- password: z.string().min(1).optional()
- username: z.string().min(1).refine(no ':' RFC 7617).optional()
  (replaces the regex which silently accepted '')
- .refine(...) replaced by .superRefine(...) emitting one issue per
  actually-missing field with the correct path, only when
  enabled === true && type === PROTOCOL_BASIC_AUTH (existing
  type-check short-circuit preserved as future-proofing).
- JSDoc mirrors the simulator-side sibling, documenting the
  intentional stricter-than-runtime semantics.

ui/common/tests/config.test.ts
- 4 new tests: empty password rejected (path 'authentication.password'),
  username with ':' rejected (RFC 7617 message at
  'authentication.username'), password-only and username-only enabled
  configs each rejected at the missing field's path.
- 2 existing tests tightened: 'missing credentials' now asserts both
  username and password paths; 'empty username' now asserts the
  'authentication.username' path.
- 6 redundant 'if (result.success) return' narrowing guards removed —
  assert.strictEqual already narrows the result type.

118/118 ui-common tests pass, typecheck + lint clean. No production
behavior change for the only valid-config consumer ('admin'/'admin'
shape regression-anchored).

* fix(config): point accessPolicy refine error path at the actionable field

Address review feedback on #1903. The .refine() rejecting
'allowLoopbackProxy: true' with empty 'trustedProxies' attached its
issue at path ['allowLoopbackProxy'] — the trigger of the rejection,
but not the field the operator must edit. Move the path to
['trustedProxies'] so structured log scrapers and IDE jump-to-field
tooling steer the operator to the field that needs populating. The
message wording (which already names both fields) is unchanged.

Test 'should reject allowLoopbackProxy=true with empty trustedProxies'
tightened from a permissive substring check
('allowLoopbackProxy or trustedProxies') to an exact-path check on
'uiServer.accessPolicy.trustedProxies' so the path contract is locked.

A second review suggestion (gate the auth credentials-required
superRefine on type === PROTOCOL_BASIC_AUTH) was investigated and
declined: the server-side AuthenticationType enum has BASIC_AUTH AND
PROTOCOL_BASIC_AUTH (unlike the Web UI client's single-member enum),
both runtime paths require username/password
(AbstractUIServer.ts:358-381), and adding the type-gate would re-open
the silent Basic-Auth bypass on the legacy 'basic-auth' path that
this PR is closing on the modern path.

114/114 schema tests pass, typecheck + lint clean.

* docs(config): refine auth-schema JSDoc rationale and tighten RFC 7617 path check

Address sub-agent review feedback on #1903.

src/utils/ConfigurationSchema.ts + ui/common/src/config/schema.ts
- Drop the 'across hot-reload toggles of enabled' phrasing in the
  UIServerAuthenticationSchema and authenticationConfigSchema JSDoc.
  Hot-reload of uiServer.* is documented as a no-op in the PR body
  (R4: Bootstrap.ts:103,161 instantiates the UI server once), so the
  unconditional field-level constraints are an early-detection signal
  rather than a hot-reload mitigation. The accurate rationale is that
  empty placeholders cannot ship under enabled: false and become a
  Basic-Auth bypass on the next boot with enabled: true.
- The asymmetry between the two superRefines (server gates only on
  enabled; Web UI also gates on type === PROTOCOL_BASIC_AUTH) is
  preserved and intentional — server-side AuthenticationType has
  legacy BASIC_AUTH which is also credential-required at runtime
  (AbstractUIServer.ts isBasicAuthEnabled / isValidBasicAuth). The
  rationale is captured in this commit body and the prior
  86a6d4a2 commit body for git blame discoverability.

tests/utils/ConfigurationSchema.test.ts
- RFC 7617 username-rejection test now matches the issue path with
  i.path.join('.') === 'uiServer.authentication.username' (exact)
  instead of String.prototype.includes (substring), aligning with
  every other test in the file and with the ui-common sibling.

114/114 schema tests + 118/118 ui-common tests pass, typecheck +
lint clean. No behavior change.

6 weeks agofix(config): tighten Zod validation on uiServer.options.port (#1874) (#1901)
Jérôme Benoit [Mon, 15 Jun 2026 16:40:57 +0000 (18:40 +0200)] 
fix(config): tighten Zod validation on uiServer.options.port (#1874) (#1901)

* fix(config): tighten Zod validation on uiServer.options.port (#1874)

Replace the permissive z.custom<ListenOptions> bridge under
uiServer.options with a typed object schema validating known primitive
fields, so bad transport-level values (e.g. port: "not-a-number")
fail at boot time in ConfigurationSchema.safeParse instead of later
inside node:net.Server.listen with ERR_SOCKET_BAD_PORT.

The existing accessPolicy refinement is preserved via .pipe(); unknown
keys are still passed through (.loose()) to keep the ListenOptions
extension surface (e.g. signal: AbortSignal) usable.

Validates:
- port: integer in [0, 65535]
- host: non-empty string
- backlog: non-negative integer
- path: non-empty string
- exclusive / ipv6Only / readableAll / writableAll: boolean

Tests: 10 new cases in tests/utils/ConfigurationSchema.test.ts and
1 integration assertion in tests/utils/ConfigurationValidation.test.ts
covering the structured Zod error path uiServer.options.port routed
through ConfigurationValidationError.

* docs(config): align JSDoc with composite uiServer.options schema

Address review comment on #1901: the JSDoc above
UIServerListenOptionsObjectSchema was named after the composite
UIServerListenOptionsSchema, leaving the helper described under the
wrong name and the composite undocumented. UIServerConfigurationSchema
also still referenced the now-removed permissive z.custom<ListenOptions>
bridge.

- UIServerListenOptionsObjectSchema gets its own JSDoc describing the
  typed object layer (.loose() + primitive constraints).
- UIServerListenOptionsSchema gets a JSDoc describing the full chain:
  object guard → accessPolicy refinement → pipe to typed object.
- UIServerConfigurationSchema JSDoc updated to reference the new
  composite schema instead of the obsolete custom bridge.

No behavior change, no test change.

6 weeks agotest(ui-common): cover buildStatusNotificationPayload 1.6/2.0.x branches (#1834)...
Jérôme Benoit [Mon, 15 Jun 2026 16:38:50 +0000 (18:38 +0200)] 
test(ui-common): cover buildStatusNotificationPayload 1.6/2.0.x branches (#1834) (#1902)

* test(ui-common): cover buildStatusNotificationPayload 1.6/2.0.x branches (#1834)

Adds a describe('buildStatusNotificationPayload') block matching the
existing payloadBuilders.test.ts style. Cases (9):

OCPP 1.6
- explicit errorCode: payload carries {connectorId, status, errorCode}
- errorCode omitted: payload omits errorCode (no NoError default)
- evseId pass-through with errorCode
- ocppVersion=undefined falls through to 1.6 shape
- unsupported version (e.g. '3.0') throws via assertOCPP16OrUndefined

OCPP 2.0.x
- VERSION_201 + evseId: {connectorId, connectorStatus, evseId}, no
  status / errorCode fields
- VERSION_20 without evseId: {connectorId, connectorStatus}
- errorCode option ignored (1.6-only field)

Cross-version
- same input, different ocppVersion: shapes differ in the documented
  way (status vs connectorStatus, errorCode/evseId presence)

Uses OCPP enum constants directly (OCPP16ChargePointStatus,
OCPP16ChargePointErrorCode, OCPP20ConnectorStatusEnumType,
OCPPVersion) per the OCPP enumeration-naming convention. Production
code in payloadBuilders.ts is unchanged.

* test(ui-common): exercise evseId-only path for OCPP 1.6 StatusNotification

Address review comment on #1902: the prior test 'should pass evseId
through for OCPP 1.6 when provided' passed both errorCode and evseId
together, so the evseId-only OCPP 1.6 branch was never independently
exercised. The errorCode-only path is already covered by the case
'should build OCPP 1.6 payload with status and explicit errorCode',
and the combined case is trivial composition of the two via the
spread syntax in the function body.

Test renamed to 'should pass evseId through for OCPP 1.6 without
errorCode' and now only passes options.evseId, asserting errorCode is
absent from the resulting payload.

6 weeks agochore(deps): update all non-major dependencies (#1899)
renovate[bot] [Mon, 15 Jun 2026 12:08:35 +0000 (14:08 +0200)] 
chore(deps): update all non-major dependencies (#1899)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agorefactor(simulator): harmonize OCPP sent-message log prefix
Jérôme Benoit [Sun, 14 Jun 2026 23:34:37 +0000 (01:34 +0200)] 
refactor(simulator): harmonize OCPP sent-message log prefix

Add moduleName constant and prefix the SENT command log with
'OCPPRequestService.internalSendMessage:' to match the format of the
3 received-message logs in ChargingStation.ts (handleErrorMessage,
handleIncomingMessage, handleResponseMessage).

6 weeks agofix(ui-server): post-#1891 access policy and WS upgrade hardening (#1898)
Jérôme Benoit [Sun, 14 Jun 2026 20:07:08 +0000 (22:07 +0200)] 
fix(ui-server): post-#1891 access policy and WS upgrade hardening (#1898)

* fix(ui-server): reject ports in allowedHosts, fix host normalization edges

- ConfigurationSchema: reject allowedHosts entries that carry a port
  (e.g., 'gateway.example.com:8080', '[::1]:8080'). Runtime host
  matching strips ports, so a port-bearing entry was silently
  equivalent to its host-only form. Operators are now forced to
  spell out host-only entries at config load time, matching how
  matching actually works.
- UIServerNet: normalizeHost strips a trailing dot after the colon
  split rather than from the whole input, so a Host header like
  'localhost.:80' now resolves to 'localhost' instead of leaving
  the trailing dot intact and failing the allowlist match.
- UIServerAccessPolicy: nonEmpty treats whitespace-only forwarded
  values as absent, and hasForwardedHeaders consumes nonEmpty so
  the two helpers agree. A whitespace-only X-Forwarded-Proto no
  longer flips forwardedHeadersPresent to true and triggers an
  Ambiguous* denial.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): attach socket error listener before WS upgrade rejection writes

The onSocketError listener was previously attached only after the
prologue and bad-header guards had already written their rejection
responses, and was removed before the auth-failure rejection write.
A client TCP-reset between an upgrade event and the destroy callback
could fire 'error' on the Duplex socket with no listener, which Node
escalates to an unhandled exception and crashes the process.

The listener is now attached at the top of the upgrade handler and
only removed once webSocketServer.handleUpgrade takes ownership of
the socket. All three rejection paths (bad upgrade headers, prologue
failure, auth failure) keep the listener until socket.destroy(),
neutralising the DoS vector.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): tighten access-policy schema messages and forwarded parsing

- Schema: split UIServerListenOptions validation so non-object values
  surface 'must be a non-array object' instead of the misleading
  'accessPolicy must be configured under uiServer' message.
- Forwarded parser: unescape RFC 7230 quoted-pair sequences inside
  double-quoted Forwarded parameter values.
- getForwardedClientAddress: propagate forwarded.kind === 'error' before
  the trustedProxy gate, matching getForwardedProtocol and
  getForwardedHost (no behavioral change today; closes a latent
  asymmetry if the upstream untrusted-peer gate is reordered).
- UIServerAccessCache: document identity-based cache invalidation for
  trustedProxies; reload flows must construct a new configuration.
- getForwardedProtocol / getForwardedHost: distinguish length === 0
  (new InvalidForwardedProtocol / InvalidForwardedHost reasons) from
  length > 1 (existing Ambiguous reasons), matching the existing
  InvalidForwardedClient / AmbiguousForwardedClient pattern.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
6 weeks agofeat(ui-server): add source-aware gateway access policy (#1891)
Jérôme Benoit [Sun, 14 Jun 2026 18:24:13 +0000 (20:24 +0200)] 
feat(ui-server): add source-aware gateway access policy (#1891)

* feat(config): add UI server access policy schema

* feat(config): add UI server access policy defaults

* feat(ui-server): add source-aware access evaluation

* feat(ui-server): authorize requests before authentication

* feat(ui-server): gate HTTP requests by access policy

* feat(ui-server): gate MCP requests by access policy

* feat(ui-server): gate WebSocket upgrades by access policy

* chore(docker): align UI access policy for local compose

* docs(ui): document gateway access policy

* [autofix.ci] apply automated fixes

* refactor(ui-server): type access decisions and unify request prologue

- Replace stringly-typed denial reason with UIServerAccessDenialReason enum and
  DENIAL_MESSAGES rendering map; UIServerAccessDecision is a discriminated union
  narrowed by 'allowed'.
- Memoize the per-request access decision via WeakMap so the policy is evaluated
  once per request.
- Add runRequestPrologue template method on AbstractUIServer; rate-limit now
  accounts denied requests, closing pre-auth flood amplification.
- Validate accessPolicy.trustedProxies entries as IPv4/IPv6 literals at the
  schema layer; precompile the normalized set per UIServerConfiguration.
- Drop the dead httpServer.on('connect') listener in UIWebSocketServer; use
  StatusCodes consistently in UIMCPServer.
- README clarifies the binary loopback-vs-non-loopback model, single-hop XFF,
  TLS termination via reverse proxy only, and adds a migration note for the
  default requireTlsForNonLoopback: true.

* refactor(ui-server): split access policy module and tighten its surface

- Split UIServerUtils.ts into UIServerAccessPolicy (decision, forwarded
  parsing, host/origin allowlists) and UIServerNet (IP/host normalization,
  loopback, header tokenization). UIServerUtils retains auth tokens and WS
  subprotocol negotiation only.
- Make evaluateUIServerAccess private; tests and runtime callers go through
  resolveUIServerAccess so the decision cache is always exercised.
- Move accessDecisionCache and trustedProxiesCache from module scope to a
  per-AbstractUIServer instance cache injected into resolveUIServerAccess.
- Quote-aware splitHeaderList honors RFC 7239 / RFC 7230 double-quoted
  values; commas inside "…" are preserved.
- Replace the {error?, value?} dialects in forwarded-header parsers with a
  discriminated ParseOutcome<T>; close the Forwarded params type to
  by/for/host/proto.
- Drop the unreachable isDirectTLSRequest helper and its TLSSocket import.
- Set HTTP server requestTimeout=30s and headersTimeout=5s on
  AbstractUIServer to bound slow-loris exposure on rejected upgrades.
- Ensure UIMCPServer.handleMcpRequest releases the transport and the MCP
  server in every error branch (idempotent cleanup).
- Promote MockUpgradeSocket to UIServerTestUtils.ts; replace the duplicate
  test factory createAccessPolicyConfiguration with the canonical
  createMockUIServerConfiguration.
- Cover the Docker dual-stack case where an IPv4-mapped IPv6 remote address
  matches an IPv4 trustedProxies entry.
- README accessPolicy cell switches to per-field sub-bullets, aligned with
  the log and worker rows.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): drop @file headers from new policy modules

@file JSDoc is the test-suite convention (TEST_STYLE_GUIDE.md) and is
absent from the rest of src/. Align the new UIServerAccessPolicy and
UIServerNet modules with the existing source-tree style.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* docs(ui-server): tighten access policy comments and README

- Drop README migration note and the duplicate access policy paragraph;
  the configuration table cell already specifies the policy.
- Tighten the Docker section access policy paragraph.
- Trim narrative meta-commentary in UIServerAccessPolicy and UIServerNet
  JSDoc; let constant and type names carry the rest.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): address review feedback on access policy

- isHostAllowed: when the immediate peer is a trusted proxy and
  X-Forwarded-Host is present, match it (not the immediate Host) against
  allowedHosts, so reverse proxies that rewrite Host to an internal
  upstream name are not rejected.
- isOriginAllowed: compare allowedOrigins via parsed URL protocol+host
  rather than literal string equality, so entries with a trailing slash or
  with a protocol-default port match the canonical browser-emitted Origin.
- UIMCPServer: destroy the response and request sockets after a denied
  prologue, mirroring UIHttpServer.
- evaluateUIServerAccess: parse the Forwarded header once and pass the
  outcome to getForwardedProtocol and getForwardedClientAddress.
- hasDuplicateHeaders: count rawHeaders in a single pass.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): include prologue headers on WebSocket upgrade denial

The WebSocket upgrade denial path wrote only the HTTP/1.1 status line, so
rate-limit denials reached clients without the Retry-After header that
the HTTP and MCP transports already emit.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): align auth denial across transports

- UIMCPServer: destroy the response and request sockets after a denied
  authentication, mirroring UIHttpServer.
- UIWebSocketServer: include the WWW-Authenticate header on auth-denied
  upgrades so RFC 7235 clients receive the Basic challenge that the HTTP
  and MCP transports already emit.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): close denied connections cleanly and harden allowedOrigins

- AbstractUIServer: also call setTimeout() on the http server so the
  HTTP/2 transport (Http2Server lacks requestTimeout/headersTimeout) gets
  the same idle bound.
- UIHttpServer / UIMCPServer: emit Connection: close on prologue and auth
  denials and drop the explicit res.destroy()/req.destroy() calls; the
  body now flushes before the connection closes.
- UIWebSocketServer: destroy the upgrade socket from the write callback
  so the response bytes are flushed before teardown.
- UIServerAccessPolicySchema: refine allowedOrigins entries to reject
  paths, query strings, and fragments at config load instead of silently
  ignoring them at runtime.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): honor RFC 7239 host parameter and standardize upgrade rejection

- UIServerAccessPolicy: parse the Forwarded host parameter symmetrically
  with for and proto. Both Forwarded host=... and X-Forwarded-Host now
  feed isHostAllowed when the immediate peer is trusted, with a new
  AmbiguousForwardedHost denial reason when they conflict.
- UIWebSocketServer: build upgrade rejection responses through a single
  helper that always emits Connection: close and Content-Length: 0,
  matching the HTTP and MCP transports.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(test): wait on response finish event in gzip tests

Replace the fixed 50 ms waitForStreamFlush delay with await once(res,
'finish') in the UIHttpServer gzip tests. MockServerResponse already
emits 'finish' from end(), so the wait is now event-driven and stops
flaking on slow CI runners (notably Windows / Node 24.x).

Drop the now-unused waitForStreamFlush helper and GZIP_STREAM_FLUSH_DELAY_MS
constant.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): scope HTTP timeouts to slow-loris and tighten Forwarded parser

- AbstractUIServer: drop the connection-wide setTimeout call. requestTimeout
  and headersTimeout already cover slow-loris on HTTP/1.1, and HTTP/2 streams
  are per-request and not vulnerable to the same pattern. The dropped 30 s
  socket-inactivity cap was killing legitimate long-running responses on
  the deprecated HTTP transport.
- UIServerAccessPolicy: split Forwarded pairs with quote awareness so that
  RFC 7239 quoted-string values containing semicolons are not truncated.
  Mirrors the quote handling already in splitHeaderList.
- UIWebSocketServer: place Connection: close after the extraHeaders spread
  in buildUpgradeRejectionResponse to match the HTTP and MCP transports
  and keep the close header non-overridable by callers.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): omit Connection header on HTTP/2 and prioritize trusted-peer denial reason

- AbstractUIServer: add getConnectionCloseHeader() returning an empty
  header set when the underlying server is HTTP/2. HTTP/2 forbids the
  Connection header as a connection-specific header; Node otherwise emits
  an UnsupportedWarning and silently drops the value. Streams are closed
  by res.end() on that path. HTTP/1.1 keeps the explicit Connection: close
  to terminate keep-alive on denial.
- UIHttpServer, UIMCPServer: route prologue-denial and auth-denial
  responses through the helper instead of hardcoding Connection: close.
- UIServerAccessPolicy: hoist the trusted-peer check above the protocol
  and host ambiguity checks. An untrusted peer sending forwarded headers
  is now denied as ForwardedFromUntrustedPeer regardless of duplicate
  proto/host metadata, so operator logs reflect the trust violation
  rather than a parser-level symptom. Duplicate-headers retains absolute
  priority. Tests added for the protocol- and host-ambiguity paths from
  an untrusted peer.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): treat empty forwarded values as absent

Empty forwarded-header values were treated as present-with-empty-string,
which caused two misbehaviors when the immediate peer was a trusted proxy:

- An empty 'host=' parameter inside a Forwarded header (or an empty
  X-Forwarded-Host) shadowed the real Host header. The policy resolved
  the host to '' and denied the request as HostNotAllowed even though
  the actual Host was in allowedHosts.
- An empty X-Forwarded-Proto was rejected as AmbiguousForwardedProtocol
  (via splitHeaderList returning an empty array) instead of being
  treated as missing.

A nonEmpty() helper now normalizes empty/undefined header values to
undefined at parse time. parseSingleForwardedHeader skips empty
parameter values rather than recording them. The two getters fall
through to the next source when the upstream value is empty.

Tests cover the host fallback paths (Forwarded host= and
X-Forwarded-Host) and the protocol absent-vs-ambiguous distinction.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): accept RFC 7239 hidden-identity forms and tighten Forwarded parser

- UIServerAccessPolicy: recognize the RFC 7239 nodename forms 'unknown'
  and obfuscated identifiers ('_' followed by token characters) in the
  Forwarded 'for=' parameter and X-Forwarded-For header. Hidden-identity
  forms now resolve to ABSENT, so the access decision falls back to the
  trusted proxy's own remote address as the client identifier (used as
  the rate-limit bucket and the logged identity). Previously these
  values were rejected as InvalidForwardedClient even though the rest
  of the request (TLS, host, origin, trust) was valid.
- UIServerAccessPolicy: strip Forwarded parameter quotes only when the
  pair is balanced. The previous '/^"|"$/g' replace stripped the
  leading or trailing quote independently and silently accepted
  unbalanced inputs such as 'for="203.0.113.10'. The new
  '/^"(.*)"$/' anchors both ends in a single match.
- README: clarify that allowLoopbackProxy requires the loopback peer
  to also be listed in trustedProxies. allowLoopbackProxy alone has
  no effect because the trusted-peer check denies forwarded headers
  from peers absent from the trustedProxies list.
- Tests: positive path for allowLoopbackProxy=true; identity-hidden
  paths for Forwarded for=unknown, Forwarded for=_obfuscated, and
  X-Forwarded-For: unknown.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): normalize empty X-Forwarded-For and align MCP auth with continuation pattern

- UIServerAccessPolicy: normalize an empty X-Forwarded-For header to
  undefined so the access decision falls back to the trusted proxy's own
  remote address rather than denying the request as InvalidForwardedClient
  or as AmbiguousForwardedClient when a Forwarded for= parameter is also
  present. Restores symmetry with X-Forwarded-Host and X-Forwarded-Proto
  empty-value handling.
- UIMCPServer: rewrite the auth path as a callback continuation. The
  previous capture-then-check pattern relied on authenticate() being
  synchronous and on the last next() call winning. The new shape
  matches UIHttpServer and UIWebSocketServer and continues to handle
  MCP requests only after a successful authentication callback.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): align MCP error responses and treat empty forwarded headers as absent

- UIMCPServer: emit Connection: close on the 404 not-found and on the
  centralized sendErrorResponse helper (BAD_REQUEST, METHOD_NOT_ALLOWED,
  INTERNAL_SERVER_ERROR), aligning these paths with the post-prologue
  and auth denial responses. The connection-close header is omitted on
  HTTP/2 by the existing getConnectionCloseHeader() helper.
- UIServerAccessPolicy: hasForwardedHeaders now ignores empty header
  values, restoring symmetry with the downstream forwarded-header
  parsers that already map empty values to absent. Empty X-Forwarded-*
  headers from an untrusted peer no longer trigger
  ForwardedFromUntrustedPeer; an empty X-Forwarded-Proto from a
  non-loopback untrusted peer now denies as TlsRequired (no proxy
  proof of TLS). Empty headers from a loopback peer no longer block
  the request.
- README: clarify that requireTlsForNonLoopback honors X-Forwarded-Proto
  / Forwarded: proto= from a trusted proxy and does not detect native
  direct TLS; note that a compromised trustedProxies entry can bypass
  per-client rate limiting by varying X-Forwarded-For.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* chore(ui-server): trim redundant access-policy comments and JSDoc

- Drop module-level JSDoc on UIServerAccessPolicy and UIServerNet to
  match the codebase convention (no module-level JSDoc on peer files).
- Drop redundant inline comments whose content is already documented in
  the README or self-evident from the surrounding code: the empty
  X-Forwarded-For malformed branch, the allowedOrigins fallback, and
  the loopback-aliases derivation.
- Trim the verbose 'Discriminated by ...' prose on the UIServerAccessDecision
  and ParseOutcome types and remove the JSDoc on resolveUIServerAccess
  (function name and signature are self-explanatory; memoization is
  visible from the cache lookup).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): extract splitQuoted and pickForwardedValue helpers

- UIServerNet: factor the quote-aware splitter into splitQuoted(value,
  delimiter); splitHeaderList becomes a thin wrapper. The previously
  duplicated state machine in UIServerAccessPolicy (splitForwardedPairs)
  is replaced by a direct splitQuoted call on ';'.
- UIServerAccessPolicy: factor the X-Forwarded-* / Forwarded ambiguity
  pick into pickForwardedValue. The three getForwarded* helpers now
  share a single source of truth for the present/absent/ambiguous
  decision; transport-specific tail logic (multi-hop X-Forwarded-For
  rejection, X-Forwarded-Proto multi-value rejection, lowercasing)
  stays local.
- UIServerAccessPolicy: drop the unreachable string-trim fallback in
  normalizeIPAddress (every input that fails normalizeHost also fails
  the downstream IP checks), narrow isSecureForwardedProtocol's
  parameter to string | undefined (no caller passes null), and skip
  the redundant pre-pass through normalizeHost in isSameHost (the
  IP-address branch already calls normalizeHost internally).
- UIServerAccessPolicy: replace Reflect.get with direct property
  access on IncomingMessage.headersDistinct and rawHeaders (typed
  members on Node 18+).
- Tests: extend the createMockIncomingMessage and the policy test's
  createAccessPolicyRequest factories with headersDistinct and
  rawHeaders defaults so direct property access works against the
  mocks.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): unify denial rendering and switch authenticate to boolean

- AbstractUIServer: introduce renderDenial(res, payload) and
  getUnauthorizedDenial() so HTTP and MCP route every prologue, 401,
  and post-handler error response through the same code path. The
  helper centralizes Content-Type, Connection-close (HTTP/2-aware),
  and 'res.headersSent' guarding.
- AbstractUIServer: change authenticate(req) to return a boolean
  instead of invoking a continuation with an Error. The CPS shape was
  fully synchronous in practice — every caller did
  'if (err != null)' — and the BaseError allocation was dead. The
  three transports collapse to 'if (!this.authenticate(req)) { … }'.
- AbstractUIServer + UIServerUtils: replace hand-typed reason phrases
  with getReasonPhrase() from http-status-codes; the helper functions
  isValidBasicAuth / isValidProtocolBasicAuth and
  getUsernameAndPasswordFromAuthorizationToken drop their continuation
  parameter accordingly.
- UIServerSecurity: replace the chunk-counter createBodySizeLimiter()
  with a shared readLimitedBody(req, maxBytes): Promise<Buffer> helper
  and a typed PayloadTooLargeError. UIHttpServer reads the request
  body via the helper instead of an event-based loop, and UIMCPServer
  detects oversized payloads via 'instanceof PayloadTooLargeError'
  rather than string-matching the error message.
- UIMCPServer: route sendErrorResponse through renderDenial so all
  4xx/5xx error responses share the same headers and Connection
  semantics; preserve the path-filter-before-authenticate ordering
  with a comment so unknown paths return 404 without revealing
  whether authentication would have succeeded.
- UIWebSocketServer: use getUnauthorizedDenial() for the upgrade
  rejection on auth failure instead of inlining the WWW-Authenticate
  string and the 'Unauthorized' literal.
- Tests: align UIServerUtils.test, UIServerSecurity.test, and
  UIMCPServer.test with the new APIs (boolean signatures, new
  PayloadTooLargeError, new readLimitedBody).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): centralize access-policy defaults and validate allowedHosts entries

- ConfigurationSchema: introduce UI_SERVER_ACCESS_POLICY_DEFAULTS as the
  single source of truth for the access-policy field defaults
  (requireTlsForNonLoopback=true, allowLoopbackProxy=false, and the
  empty allowlists). The defaults previously lived inline in
  evaluateUIServerAccess.
- UIServerAccessPolicy: consume UI_SERVER_ACCESS_POLICY_DEFAULTS instead
  of the hardcoded literals so a default change propagates uniformly
  across the policy code, the schema, and the README.
- ConfigurationSchema: validate allowedHosts entries via normalizeHost
  so paths, queries, fragments, and other malformed Host values are
  rejected at config load. The previous z.string().min(1) accepted
  any non-empty string.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): factor access-policy test fixtures and address constants

- UIServerTestUtils: introduce TRUSTED_PROXY_IP, EXTERNAL_CLIENT_IP, and
  GATEWAY_HOST constants from RFC 5737 / RFC 3849 reserved ranges;
  factor the recurrent gateway access-policy configurations into
  createGatewayConfigWithTrustedProxy() and
  createGatewayConfigWithoutTrustedProxies() so subsequent test
  additions inherit a single source of truth for the gateway shape.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): cover missing denial reasons and tighten weak assertions

- UIServerAccessPolicy.test: cover the previously untested denial
  reasons AmbiguousForwardedProtocol (multi-value X-Forwarded-Proto),
  AmbiguousForwardedHeader (multi-entry Forwarded), and
  AmbiguousForwardedParameter (duplicate parameter inside one entry);
  cover the InvalidForwardedClient branch for non-IP, non-hidden 'for='
  values; cover AmbiguousForwardedClient when 'Forwarded: for=unknown'
  collides with X-Forwarded-For; cover the headersDistinct path of
  hasDuplicateHeaders.
- UIServerAccessPolicy.test: cover the malformed Origin URL branch in
  isOriginAllowed and the allowedHosts-fallback branch when
  allowedOrigins is empty; pin the design choices for whitespace-padded
  Host normalization, uppercase X-Forwarded-Proto values, IPv4-mapped
  IPv6 loopback proxies under allowLoopbackProxy, and the policy's
  intentional non-detection of req.socket.encrypted without forwarded
  protocol headers.
- UIServerAccessPolicy.test: tighten the previously weak assertions on
  the accessPolicy-undefined paths (now check the denial reason and
  the resolved client address); rename the misleading
  'should reject empty-but-present X-Forwarded-For' test to reflect
  what it actually exercises (no parseable addresses); replace the
  trivial cache-isolation assertion with a real
  cacheA.decisions.has(req) / cacheB.decisions.has(req) check.
- UIMCPServer.test: drop the two malformed-host tests that re-tested
  policy logic already covered by UIServerAccessPolicy.test; the
  Connection: close tests in the same describe still anchor the
  transport-level rendering contract.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): close coverage gaps and tighten weak assertions identified in second-pass audit

- ConfigurationSchema.test: validate the allowedHosts schema refine
  rejects malformed entries (excess colons, non-numeric port, port out
  of range, empty input) and accepts canonical hostnames and IP
  literals; lock the canonical UI_SERVER_ACCESS_POLICY_DEFAULTS map
  shape so a default change cannot drift silently.
- UIServerNet.test: cover normalizeHost directly (rejection branch is
  the boundary of the allowedHosts schema refine; previously only
  exercised indirectly through transport tests).
- UIServerSecurity.test: assert readLimitedBody propagates upstream
  stream errors (security-relevant fail-closed behavior).
- UIServerAccessPolicy.test: rename the misleading
  for=unknown collision test to reflect the actual code path
  (collision is generic, not unknown-specific); drop the duplicate
  encrypted-proxy plaintext rejection (the encrypted flag is ignored
  by the policy by design and a dedicated test already pins it);
  tighten the whitespace-padded Host test to use an explicit
  allowedHosts list so the trim path is genuinely exercised rather
  than the loopback alias fallback.
- UIServerTestUtils: drop the dead EXTERNAL_CLIENT_IP export and
  correct the RFC reference comment (RFC 5737 IPv4 / RFC 2606
  example.com — RFC 3849 covers IPv6 and is irrelevant here).
- UIWebSocketServer: add a brief comment documenting why
  buildUpgradeRejectionResponse stays separate from
  AbstractUIServer.renderDenial — pre-handshake WS rejections write
  raw HTTP/1.1 to a Duplex socket while renderDenial targets
  ServerResponse.
- src/utils/index: re-export UI_SERVER_ACCESS_POLICY_DEFAULTS so the
  defaults can be asserted from the schema test layer.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): tighten X-Forwarded-Host validation and host charset

- UIServerAccessPolicy: getForwardedHost rejects multi-value
  X-Forwarded-Host as AmbiguousForwardedHost, mirroring the symmetric
  guards in getForwardedProtocol and getForwardedClientAddress. The PR
  description's claim that 'multi-value X-Forwarded-* are rejected'
  now applies uniformly across the three forwarded dimensions.
- UIServerNet: normalizeHost validates the hostname charset against a
  conservative [a-z0-9._-]+ allowlist on the dot path. Inputs with
  commas, spaces, or unbalanced brackets are rejected at config-load
  time when used as allowedHosts entries, and at runtime as a
  defense-in-depth check on the Host header.
- UIServerNet: isValidPort rejects port 0 (RFC 6335 reserved). Port 0
  in a client-side Host header has no useful meaning; tightening to
  1..65535 matches common practice and the prior config-bind path is
  unaffected.
- README: requireTlsForNonLoopback wording corrected — non-loopback
  requests without forwarded protocol headers are denied as
  tls-required, not merely 'not detected', even when the underlying
  socket is encrypted.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): empty Forwarded header, 405 Allow, wildcard misconfig warn

- UIServerAccessPolicy: parseSingleForwardedHeader normalises an empty
  Forwarded header value (e.g., 'Forwarded: ') to ABSENT instead of
  rejecting it as AmbiguousForwardedHeader. The PR description claim
  that 'empty forwarded values are treated as absent' now applies to
  the header value itself, not only to parameters inside the header.
- UIMCPServer: sendErrorResponse accepts an optional headers argument
  so 405 Method Not Allowed responses advertise 'Allow: GET, POST,
  DELETE' per RFC 9110 §15.5.6.
- AbstractUIServer: warn at startup when the UI server is bound to a
  wildcard host ('', '0.0.0.0', '::') with an empty
  accessPolicy.allowedHosts; the previously silent lockout (every
  request denied as host-not-allowed) now produces an actionable log
  line pointing at the configuration to adjust.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): broaden misconfig warning and log rate-limit denials

- AbstractUIServer: warnIfMisconfigured now also fires when the UI
  server is bound to a non-loopback specific host with
  requireTlsForNonLoopback=true and no accessPolicy.trustedProxies;
  the previously silent path (every plaintext request denied as
  tls-required) now produces an actionable startup log line. Wildcard
  binding with empty allowedHosts retains its dedicated warning.
- AbstractUIServer: rate-limit denials now emit a warn log symmetric
  to the access-policy 403 path so operators can detect probing.
  Previously the 429 branch returned silently, which made abusive
  clients invisible from the logs.
- README: drop the 'even when the underlying socket is encrypted'
  caveat from the requireTlsForNonLoopback description; the simulator
  only listens via plaintext or h2c, so the scenario described was
  unreachable. The runtime behaviour is unchanged and the test that
  pins the design choice stays as defense in depth.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
6 weeks agofix(deps): update all non-major dependencies (#1896)
renovate[bot] [Sun, 14 Jun 2026 11:50:12 +0000 (13:50 +0200)] 
fix(deps): update all non-major dependencies (#1896)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7 weeks agofix(ui-web): skip dev-only diagnostics under Vitest (#1897)
Jérôme Benoit [Sun, 14 Jun 2026 01:29:02 +0000 (03:29 +0200)] 
fix(ui-web): skip dev-only diagnostics under Vitest (#1897)

Extract isDev() helper in core/env.ts that returns true only in real
DEV (excludes production build and Vitest). Migrate the 7 existing
import.meta.env.DEV check sites to the helper.

Primary motivation: validateTokenContract schedules a
requestAnimationFrame whose body calls console.warn for missing CSS
tokens. jsdom does not resolve --color-* tokens, so every theme/skin
switch under Vitest 4 queued a console.warn that fired after
environment teardown — surfacing as EnvironmentTeardownError on
Linux/Node 22; passes on macOS/Node 24.

Side effect: silences console.debug noise from storage.ts,
providers.ts, ToggleButton.vue under Vitest. dev/build behaviour
unchanged.

Adds regression test in tests/unit/shared/tokens/contract.test.ts.

7 weeks agotest(ui-web): drain dynamic imports globally for async components (#1893)
Jérôme Benoit [Sat, 13 Jun 2026 23:46:21 +0000 (01:46 +0200)] 
test(ui-web): drain dynamic imports globally for async components (#1893)

Add vi.dynamicImportSettled() to the global afterEach hook so pending
dynamic imports (defineAsyncComponent loaders, lazy routes) settle
before Vitest tears down the test environment. Prevents
EnvironmentTeardownError on transitive .vue import chains observed
under filtered runs (e.g. -t "should open authorize dialog").

Covers App.vue, ModernLayout.vue, and any future component using
defineAsyncComponent or lazy-loaded routes.

7 weeks agofix(deps): update all non-major dependencies (#1892)
renovate[bot] [Sat, 13 Jun 2026 19:51:35 +0000 (21:51 +0200)] 
fix(deps): update all non-major dependencies (#1892)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7 weeks agobuild(deps-dev): bump esbuild (#1894)
dependabot[bot] [Sat, 13 Jun 2026 19:51:13 +0000 (21:51 +0200)] 
build(deps-dev): bump esbuild (#1894)

Bumps the npm_and_yarn group with 1 update in the / directory: [esbuild](https://github.com/evanw/esbuild).

Updates `esbuild` from 0.28.0 to 0.28.1
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7 weeks agochore(skills): sync qmd skill to upstream tobi/qmd@main
Jérôme Benoit [Wed, 10 Jun 2026 22:24:31 +0000 (00:24 +0200)] 
chore(skills): sync qmd skill to upstream tobi/qmd@main

Update .agents/skills/qmd/SKILL.md to upstream version 2.2.0
(blob 0d4b04882506d86d36eca291f1d91627d195dadb).

Replaces the Nix OpenClaw downstream variant (v2.0.0) bundled by
nix-openclaw-tools with the canonical upstream skill content.

Refs: https://github.com/tobi/qmd/issues/722

7 weeks agochore(deps): lock file maintenance (#1889)
renovate[bot] [Tue, 9 Jun 2026 23:11:36 +0000 (01:11 +0200)] 
chore(deps): lock file maintenance (#1889)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7 weeks agochore(deps): update all non-major dependencies (#1890)
renovate[bot] [Tue, 9 Jun 2026 14:38:45 +0000 (16:38 +0200)] 
chore(deps): update all non-major dependencies (#1890)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7 weeks agochore(deps): update all non-major dependencies (#1888)
renovate[bot] [Mon, 8 Jun 2026 11:57:44 +0000 (13:57 +0200)] 
chore(deps): update all non-major dependencies (#1888)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7 weeks agochore(deps): update all non-major dependencies (#1887)
renovate[bot] [Sun, 7 Jun 2026 13:44:51 +0000 (15:44 +0200)] 
chore(deps): update all non-major dependencies (#1887)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8 weeks agochore(deps): update all non-major dependencies (#1886)
renovate[bot] [Sat, 6 Jun 2026 08:36:14 +0000 (10:36 +0200)] 
chore(deps): update all non-major dependencies (#1886)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8 weeks agochore(deps): update actions/checkout digest to df4cb1c (#1882)
renovate[bot] [Thu, 4 Jun 2026 17:23:04 +0000 (19:23 +0200)] 
chore(deps): update actions/checkout digest to df4cb1c (#1882)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8 weeks agofix(deps): update all non-major dependencies (#1883)
renovate[bot] [Thu, 4 Jun 2026 17:22:22 +0000 (19:22 +0200)] 
fix(deps): update all non-major dependencies (#1883)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8 weeks agotest: remove uneeded comment
Jérôme Benoit [Thu, 4 Jun 2026 17:21:06 +0000 (19:21 +0200)] 
test: remove uneeded comment

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
8 weeks agotest(ui-web): mock useTheme/useSkin in SimulatorBar.test.ts to fix vitest 4.x teardow...
Jérôme Benoit [Thu, 4 Jun 2026 17:17:35 +0000 (19:17 +0200)] 
test(ui-web): mock useTheme/useSkin in SimulatorBar.test.ts to fix vitest 4.x teardown RPC leak (#1884)

* test(ui-web): mock useTheme/useSkin in SimulatorBar.test.ts to fix vitest 4.x teardown leak

The two final tests ('should call switchTheme/switchSkin when … select changes')
mounted SimulatorBar with the real useTheme/useSkin composables. Both code paths
schedule console.warn output AFTER the synchronous test body resolves:

1. validateTokenContract() (src/shared/tokens/contract.ts) wraps work in
   requestAnimationFrame and warns when CSS variables are missing. jsdom does
   not resolve --color-* CSS variables, so the contract check logs ~24 warnings
   via rAF on every theme/skin switch.
2. switchSkin() returns Promise<boolean>, but the @change handler discards the
   promise and trigger('change') only awaits Vue's nextTick. The unawaited
   loadStyles() dynamic import resolves later and may also call console.warn.

Vitest 4.x tightened teardown to fail rather than swallow pending RPC calls,
surfacing this latent bug as:

  EnvironmentTeardownError: [vitest-worker]: Closing rpc while
  "onUserConsoleLog" was pending

Reproducibly fails on Linux/Node 22 in CI; passes on macOS/Node 24 because rAF
+ dynamic-import timing differs.

Fix mirrors the existing precedent in tests/unit/router.test.ts: vi.mock both
composables at the top of the test file, returning shapes that match the real
contract (THEME_IDS / SKIN_IDS imported from ui-common rather than hand-rolled
subsets). The mocked switchSkin resolves immediately so no teardown leak occurs.

Also tightens the two affected tests to actually assert what their names claim
(switchTheme/switchSkin called with the selected value), instead of merely
checking the <select> exists.

* test(ui-web): use THEME_IDS/SKIN_IDS indices instead of hardcoded values

Address PR review: hardcoding 'dracula' and 'classic' as the target select
values would silently break the assertions if THEME_IDS or SKIN_IDS are
reordered or renamed in ui-common (the DOM $lt;select$gt; would keep its
current option, switchTheme/switchSkin would be called with that stale
value, and the toHaveBeenCalledWith assertion would compare against a
constant that no longer matches the dispatched event).

Pick targets that are guaranteed to differ from the mocked active values:
THEME_IDS[1] (≠ activeThemeId = THEME_IDS[0]) and the first SKIN_IDS entry
that is not 'modern' (≠ activeSkinId). The assertions then reference the
same constants, keeping the test coupled to the actual contract surface.

Note: the third reviewer comment about adding beforeEach(mockClear) is not
needed — vitest.config.ts already sets clearMocks: true and
restoreMocks: true, which run before each test.

* Revert "test(ui-web): use THEME_IDS/SKIN_IDS indices instead of hardcoded values"

This reverts commit 0941e5324d4501ca89094925654261ea1eda3fac.

* test(ui-web): add explicit afterEach mock cleanup in SimulatorBar test

Aligns SimulatorBar.test.ts with the dominant convention in ui/web's test
suite (Dialogs, StationCard, ConnectorRow, ClassicLayout, Actions, …),
which all add an explicit `afterEach(() => { vi.clearAllMocks() })`
alongside vitest.config.ts's global `clearMocks: true` / `restoreMocks: true`.
Belt-and-braces: the explicit reset keeps mock lifecycle visible next to
the mock declarations, even though it is technically redundant with the
global flags.

8 weeks agochore(deps): update all non-major dependencies (#1881)
renovate[bot] [Wed, 3 Jun 2026 12:54:21 +0000 (14:54 +0200)] 
chore(deps): update all non-major dependencies (#1881)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8 weeks ago[autofix.ci] apply automated fixes
autofix-ci[bot] [Tue, 2 Jun 2026 19:56:20 +0000 (19:56 +0000)] 
[autofix.ci] apply automated fixes

8 weeks agochore(release-please): remove release-as override after 4.8.0 release
Jérôme Benoit [Tue, 2 Jun 2026 19:53:05 +0000 (21:53 +0200)] 
chore(release-please): remove release-as override after 4.8.0 release

The 4.8.0 override has shipped via PR #1880 (merge commit 44ab6cf8).
Remove the 'release-as' field so that subsequent release-please runs
resume normal Conventional Commits version computation.

Without this cleanup, subsequent runs would keep proposing 4.8.0,
producing no-op release PRs or duplicate-tag errors.

8 weeks agochore: release main (#1880) cli@v4.8.0 ocpp-server@v4.8.0 simulator@v4.8.0 ui-common@v4.8.0 v4.8 web@v4.8.0
Jérôme Benoit [Tue, 2 Jun 2026 19:50:16 +0000 (21:50 +0200)] 
chore: release main (#1880)

8 weeks agochore(release-please): override next release to 4.8.0
Jérôme Benoit [Tue, 2 Jun 2026 19:48:39 +0000 (21:48 +0200)] 
chore(release-please): override next release to 4.8.0

The BREAKING CHANGE footer in 8bb806d describes a log message wording
change consumed by external monitors, not a SemVer-breaking API change.
This override forces release-please to ship 4.8.0 (minor) instead of 5.0.0.

Cleanup: this 'release-as' field MUST be removed in a follow-up commit
once the 4.8.0 release PR merges, otherwise subsequent runs will
re-propose 4.8.0.

8 weeks agochore(deps): lock file maintenance (#1870)
renovate[bot] [Tue, 2 Jun 2026 19:00:23 +0000 (21:00 +0200)] 
chore(deps): lock file maintenance (#1870)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8 weeks agofix(deps): update dependency commander to v15 (#1879)
renovate[bot] [Tue, 2 Jun 2026 12:57:10 +0000 (14:57 +0200)] 
fix(deps): update dependency commander to v15 (#1879)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8 weeks agofix(deps): update all non-major dependencies (#1878)
renovate[bot] [Tue, 2 Jun 2026 12:12:21 +0000 (14:12 +0200)] 
fix(deps): update all non-major dependencies (#1878)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agofix(deps): update dependency vue-router to ^5.1.0 (#1877)
renovate[bot] [Mon, 1 Jun 2026 12:29:29 +0000 (14:29 +0200)] 
fix(deps): update dependency vue-router to ^5.1.0 (#1877)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agofix(deps): update all non-major dependencies (#1876)
renovate[bot] [Sun, 31 May 2026 13:50:55 +0000 (15:50 +0200)] 
fix(deps): update all non-major dependencies (#1876)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agochore(sandcastle): install codex CLI in docker image
Jérôme Benoit [Fri, 29 May 2026 18:10:41 +0000 (20:10 +0200)] 
chore(sandcastle): install codex CLI in docker image

2 months agochore(deps): update @ai-hero/sandcastle to 0.6.6 and drop PiOptions.thinking patch
Jérôme Benoit [Fri, 29 May 2026 17:36:04 +0000 (19:36 +0200)] 
chore(deps): update @ai-hero/sandcastle to 0.6.6 and drop PiOptions.thinking patch

2 months agochore(deps): update all non-major dependencies (#1875)
renovate[bot] [Thu, 28 May 2026 17:36:36 +0000 (19:36 +0200)] 
chore(deps): update all non-major dependencies (#1875)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agochore(deps): update @ai-hero/sandcastle to 0.6.5 and regenerate patch
Jérôme Benoit [Thu, 28 May 2026 17:02:56 +0000 (19:02 +0200)] 
chore(deps): update @ai-hero/sandcastle to 0.6.5 and regenerate patch

2 months agodocs(cli-skill): align with CLI flags, conflict rules, and NO_COLOR support
Jérôme Benoit [Wed, 27 May 2026 23:11:15 +0000 (01:11 +0200)] 
docs(cli-skill): align with CLI flags, conflict rules, and NO_COLOR support

2 months agodocs: align project_overview with empirical component boundaries and import discipline
Jérôme Benoit [Wed, 27 May 2026 22:50:19 +0000 (00:50 +0200)] 
docs: align project_overview with empirical component boundaries and import discipline

2 months agorefactor: relocate simulator config to src/utils and enforce barrel discipline
Jérôme Benoit [Wed, 27 May 2026 22:33:50 +0000 (00:33 +0200)] 
refactor: relocate simulator config to src/utils and enforce barrel discipline

- Move ConfigurationSchema/Migrations/Validation from charging-station to utils
- Rename ConfigurationUtils.logPrefix to configurationLogPrefix (barrel anti-collision)
- Expose public APIs through component barrels (charging-station, ocpp, utils, worker)
- Migrate 38 test imports from deep paths to barrels
- Document TDZ-cycle exceptions in OCPPError and ConfigurationMigrations

2 months agofeat(config): add Zod-based simulator configuration syntax validation (#1874)
Jérôme Benoit [Wed, 27 May 2026 21:30:34 +0000 (23:30 +0200)] 
feat(config): add Zod-based simulator configuration syntax validation (#1874)

* feat(config): scaffold simulator configuration schema and validator skeleton

- Add ConfigurationMigrations.ts: CURRENT_CONFIGURATION_SCHEMA_VERSION=1,
  coerceConfigurationVersion, applyConfigurationMigration, migrateV0ToV1
  with DEPRECATED_KEY_REMAPPINGS (~25 legacy keys)
- Add ConfigurationSchema.ts: strict Zod v4 schema for all config sections
  (log, worker, performanceStorage, uiServer, stationTemplateUrls) with
  deprecated keys as .optional()
- Add ConfigurationValidation.ts: validateConfiguration pipeline
  (guard→clone→coerce→migrate→safeParse→transform) + ConfigurationValidationError
  extends BaseError with structured fieldErrors
- Add $schemaVersion: 1 to config-template.json
- Add ConfigurationFixtures.ts test helpers
- Add ConfigurationSchema.test.ts (63 tests), ConfigurationMigrations.test.ts (44 tests)
- Wire validateConfiguration into Configuration.getConfigurationData() with
  hard-throw at boot (console.error+chalk+process.exit(1))
- Hot-reload snapshot rollback: pre-clear snapshot, restore on failure
- Replace ConfigurationData interface with z.infer<typeof ConfigurationSchema>
- Migrate all consumers atomically (SimulatorState, Configuration, types barrel)
- Delete checkWorkerProcessType and checkWorkerElementsPerWorker (subsumed by schema)
- Delete ConfigurationMigration.ts (logic moved to ConfigurationMigrations.ts)
- Add ConfigurationValidation.test.ts (39 tests), Configuration-hot-reload.test.ts
- Add ConfigurationValidation-perf.test.ts (p99 < 50ms budget)
- Add error message snapshot test
- Update README.md with $schemaVersion documentation

* feat(config): remove legacy ConfigurationMigration.ts and stale call sites

- Delete src/utils/ConfigurationMigration.ts (deprecated-key logic now
  owned by ConfigurationMigrations.ts v0→v1 migration step)
- Remove checkDeprecatedConfigurationKeys import and call site from
  Configuration.getStationTemplateUrls() — validation pipeline in
  getConfigurationData() already handles deprecated key remapping

* chore(config): silence lint warnings on configuration migrations

- Add JSDoc descriptions for setAtPath() params
- Add JSDoc descriptions and @returns for migrateV0ToV1
- Whitelist 'emerg' (syslog level) and 'REMAPPINGS' in cspell dictionary

* refactor(config): rebuild configuration validation pipeline

Address audit findings on PR #1874:

- Extract deprecated-key sweep into pure remapDeprecatedKeys() that
  reports warnings and field errors via return value instead of side
  effects. Runs unconditionally regardless of $schemaVersion so v1
  configs still containing deprecated keys never silently drop user
  values (B3).
- Replace silent-drop setAtPath with collision- and intermediate-aware
  variant: equal-value writes are idempotent no-ops, unequal values
  produce a typed field error (B4), non-object intermediates are
  reported and stop traversal instead of overwriting user data (N7).
- Type DEPRECATED_KEY_REMAPPINGS as Record<string, string | null>;
  null marks deprecated keys with no canonical destination, replacing
  the autoReconnectMaxRetries self-mapping that silently dropped the
  user value (B2). Add 'worker.elementStartDelay' as a dotted source
  key so nested deprecations live in the same single source of truth.
- Refactor ConfigurationValidationError: primary constructor takes
  FieldError[] + a context with explicit phase ('migration' | 'schema');
  static fromZodError() factory wraps Zod failures. Error messages now
  carry the phase in their tag for clearer diagnostics.
- Switch deprecation-warning channel from logger.warn to console.warn
  to break a re-entrant boot path where the Logger proxy lazily
  resolved Configuration.getConfigurationSection('log'), recursing into
  the validation pipeline (B1). transformConfiguration is now a pure
  deep clone — its previous warning loop moved upstream to the sweep.
- Delete dead post-migration remapping in Configuration.ts:
  deprecatedLogKeyMap, deprecatedWorkerKeyMap, the
  delete configurationData.workerPoolStrategy mutation, the cast hiding
  the legacy 'supervisionURLs' read, and the worker.elementStartDelay
  fallback (B6). All deprecation handling now flows through the
  migration's single source of truth.
- Make hot-reload reload loop async end-to-end. Awaits the change
  callback inside the same lock so subsequent reloads cannot interleave
  with an in-flight callback. Adds configurationFileReloadPending so
  events arriving during a reload coalesce into exactly one drain
  reload after the current one completes (N8).
- Polish schema: use z.enum(NativeEnum) directly for ApplicationProtocol
  and ApplicationProtocolVersion to preserve literal-type narrowing on
  UIServerConfiguration (N4); drop the BaseConfigurationSchema alias
  (N5).

* test(config): cover migration edge cases and hot-reload rollback

Tests aligned with the rebuilt validation pipeline:

- Split migration tests into a remapDeprecatedKeys block (per-key sweep,
  null-destination removal for autoReconnectMaxRetries, equal-value
  collision idempotency, unequal-value collision field error, non-object
  intermediate field error, nested worker.elementStartDelay) and a lean
  applyConfigurationMigration block (version-bump only, immutability).
- Replace the empty self-mapping branch with explicit hasOwnProperty
  removal assertions to defeat the previously vacuous test.
- ConfigurationValidation tests: switch deprecation-warning channel
  spies from logger.warn to console.warn (B1 regression also asserts
  logger.warn is never called from the pipeline), tolerate
  schema-incompatible remap targets so the warning fires before the
  downstream throw, add B3 sweep / B6 SSOT / future-version pipeline /
  ConfigurationValidationError shape (FieldError[] + phase, fromZodError
  factory, schema-phase aggregation) tests, and update the error-message
  snapshot to include the new [phase] tag.
- transformConfiguration immutability test verifies a fresh validation
  is unaffected by mutating a prior return value.
- ConfigurationSchema tests: strict parity for performanceStorage and
  uiServer.authentication; StationTemplateUrl entry constraints
  (empty file, negative numberOfStations, deprecated numberOfStation,
  unknown key); worker.elementsPerWorker and poolMaxSize/MinSize
  positive-integer constraints; log.statisticsInterval non-negative
  integer constraints; bidirectional schema/DEPRECATED_KEY_REMAPPINGS
  sync meta-test that walks both top-level and nested @deprecated
  describe markers.
- Hot-reload tests rebuilt around the async runReloadLoop: validation
  failure asserts the logged error is a ConfigurationValidationError;
  JSON parse error path asserts configurationData and section cache are
  fully restored (Gap 7); a sentinel watcher survives a failed reload;
  N8 rapid double-save coalesces into exactly one drain reload reflecting
  the latest content; flag reset is exercised on both paths.
- Configuration validation perf test: relative p99 budget (20× median
  with a 1ms floor) plus an absolute 500ms catastrophic ceiling,
  replacing the flaky absolute 50ms threshold (N1).
- Fixtures: new buildInvalidJsonString, buildV1WithDeprecatedKey
  (handles top-level and dotted source keys), and
  buildV0WithDeprecatedKeyCollision builders for the new test cases.

* docs(config): trim verbose JSDocs and harmonize with Template* conventions

Polish pass after PR audit: bring comments and docstrings in line with
the existing TemplateMigrations / TemplateValidation / TemplateSchema
patterns, drop ceremonial AI-generated narration, and fix two precision
defects.

- ConfigurationMigrations.ts: trim getAtPath / setAtPath / remapDeprecatedKeys /
  migrateV0ToV1 / applyConfigurationMigration JSDocs to match Template*
  density; drop the historical "now handled unconditionally upstream"
  paragraph (AGENTS.md "exclude historical evolution").
- ConfigurationValidation.ts: collapse the duplicated pipeline narration
  (the function-level JSDoc already enumerates stages, the inline
  // Stage N — markers were removing them); shorten the
  ConfigurationValidationError class docstring; tighten transformConfiguration
  JSDoc and drop the aspirational "future cross-field invariants" sentence;
  collapse the 5-line re-entrancy comment to a 2-line rationale.
- ConfigurationSchema.ts: fix two precision defects in JSDoc — stale
  module name (ConfigurationMigration → ConfigurationMigrations) and wrong
  subject (numberOfStation is not in the remap table; nothing auto-migrates
  it). Disambiguate WorkerConfigurationSchema description (resourceLimits
  is bridged, not deprecated; elementStartDelay is the deprecated alias).
- Configuration.ts: shrink runReloadLoop and performReload JSDocs to two-
  line summaries matching the surrounding private-method conventions in
  the same file; collapse the 4-line ESLint-disable rationale to a single
  -- suffix on the disable directive.
- Test fixtures and headers: drop internal review codes (B1/B3/B4/B6/N7/N8/
  Gap-7/RG-4) from JSDocs and test names — they had no lookup table and
  would be opaque to future maintainers; trim test-file headers to the
  one-line Template* shape.

* refactor(config): address post-audit findings on validation pipeline

* fix(config): drop incompatible v0 remaps and tighten validation invariants

Addresses 4 PR review findings cross-validated by 2 oracles.

- ConfigurationMigrations: drop `useWorkerPool` (boolean→enum),
  `distributeStationToTenantEqually` and `distributeStationsToTenantsEqually`
  (boolean→enum), and `uiWebSocketServer` (legacy shape→strict object) to
  `null` (warn-and-delete) — same pattern as `workerPoolStrategy` and
  `autoReconnectMaxRetries`. v0 configurations carrying any of these
  legacy keys would otherwise auto-write a value the strict schema
  rejects, killing the simulator at boot. Migration now warns and asks
  the user to set the canonical key explicitly. Fixes the BLOCKING
  finding from the PR review (regression on 'v0 configs remain valid').
- ConfigurationMigrations: remove redundant `out.$schemaVersion = 1`
  from `migrateV0ToV1`. The `applyConfigurationMigration` loop is the
  single owner of version stamping; per-step writes are silently
  overwritten and become misleading once a second migration is added.
- ConfigurationSchema: tighten `StationTemplateUrlSchema.numberOfStations`
  from `.nonnegative()` to `.positive()` — `0` was schema-valid but
  semantically meaningless (a template entry that spawns no station)
  and conflicts with `isValidNumberOfStations()` which rejects `<= 0`.
  Deprecated `numberOfStation` and `provisionedNumberOfStations` keep
  `.nonnegative()` (back-compat / 'none provisioned' is meaningful).
- ConfigurationValidation: import `isEmpty` directly from
  `utils/Utils.js` instead of the `utils/index.js` barrel, which
  re-exports `Configuration` and creates the cycle
  `ConfigurationValidation → utils/index → Configuration → ConfigurationValidation`.
  Mirrors the existing direct-path `BaseError` import in
  `ConfigurationMigrations.ts` for the same TDZ-cycle-avoidance reason.

* docs(config): fix precision defects in schema JSDocs and README

- ConfigurationSchema.ts: correct StorageConfigurationSchema JSDoc —
  'URI' is accepted but not auto-migrated (not in DEPRECATED_KEY_REMAPPINGS);
  narrow ConfigurationSchema JSDoc meta-test scope claim to 'top-level
  and worker.* keys' to match actual test coverage.
- README.md: clarify that deprecated-key remapping is unconditional
  (runs on every load, not only on v0 migration).

* fix(config): keep numberOfStations as .nonnegative() to preserve disabled-entry convention

Revert the .positive() tightening from 95cd26dd: docker/config.json ships
5 stationTemplateUrls entries with `numberOfStations: 0` (used as a
'keep on disk but disabled' convention), and this file is copied to
src/assets/config.json by docker/Dockerfile at image build time. With
.positive(), Docker images built from this branch would fail to start
on validateConfiguration's strict parse → process.exit(1).

Bootstrap loops already tolerate 0 (no-op, no crash). The original
.nonnegative() correctly models the on-disk convention; isValidNumberOfStations()
in UIServerSecurity.ts is for runtime UI add-station requests, a
separate concern from config-file parsing.

* docs(config): trim re-entrancy and clone rationales to one-liners

2 months agochore(deps): update dependency pytest-asyncio to v1.4.0 (#1872)
renovate[bot] [Tue, 26 May 2026 17:59:49 +0000 (19:59 +0200)] 
chore(deps): update dependency pytest-asyncio to v1.4.0 (#1872)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agoci(renovate): enforce 3-day minimum release age for npm packages
Jérôme Benoit [Tue, 26 May 2026 17:40:45 +0000 (19:40 +0200)] 
ci(renovate): enforce 3-day minimum release age for npm packages

Extend the Renovate config with the official 'security:minimumReleaseAgeNpm'
preset so that Renovate waits 3 days after publication before creating PRs
for any npm/pnpm dependency. This adds a buffer against unpublished or
freshly-broken releases (e.g. malicious packages, npm unpublish window,
transient registry/lockfile resolution issues).

2 months agofix(utils): make file persistence atomic across writers (#1871)
Jérôme Benoit [Tue, 26 May 2026 00:19:25 +0000 (02:19 +0200)] 
fix(utils): make file persistence atomic across writers (#1871)

* feat(utils): add atomic file write primitive and migrate call sites

Add `atomicWriteFile` and `atomicWriteFileSync` to `src/utils/FileUtils.ts`,
implementing the canonical write-then-rename pattern with optional fsync
durability and best-effort temp file cleanup on failure. Errors are funnelled
through the existing `handleFileException` helper so callers stay aligned
with the project-wide file error reporting contract.

Migrate the five non-atomic disk writes uncovered by the audit:

- `BootstrapStateUtils.writeStateFile` replaces its inline tmp+rename with
  the new primitive (single source of truth, gains fsync durability).
- `ChargingStation.saveConfiguration` replaces `writeFileSync` so charging
  station OCPP configuration JSON cannot be torn by a crash mid-write.
- `JsonFileStorage.storePerformanceStatistics` drops the persistent
  `openSync('w')` file descriptor design (which truncated the file at byte
  zero on every sample) and uses the atomic primitive instead. Also fixes
  the previous fire-and-forget `runExclusive(...).catch()` pattern by
  awaiting the lock.
- `OCPP20CertificateManager` writes installed PEM certificates atomically.

Add `FileType.Certificate` so PEM writes can flow through
`handleFileException` with an accurate file type label.

Concurrent writers to the same path must still be serialized externally
(typically via `AsyncLock`); the primitive does not implement an internal
queue, matching how every existing call site already locks. The
`createWriteStream` diagnostics archive in OCPP 1.6 `GetDiagnostics` is
intentionally left as-is since the file is ephemeral (FTP-uploaded then
discarded).

Targets Node >= 22 so `writeFile`/`writeFileSync` natively expose the
`flush: true` option used for the fsync step.

* fix(utils): align atomic write call sites on a single error path

Address review feedback on PR #1871. The atomic write primitive logs and
re-throws by default via `handleFileException`. Three call sites kept
their pre-migration outer error wrappers, producing double handling:

- `ChargingStation.saveConfiguration`: the `.catch` handler attached to
  the fire-and-forget `AsyncLock.runExclusive(...).catch(...)` chain
  re-threw inside the catch callback, leaking an unhandled promise
  rejection on every config write failure (disk full, EACCES, EROFS,
  ...). Pass `{ throwError: false }` to that `handleFileException`
  call so the rejection is fully absorbed. The retry semantics are
  preserved: when `atomicWriteFileSync` throws, the `endMeasure` and
  `configurationFileHash` updates inside the lock callback are skipped,
  so the next `saveConfiguration` invocation will retry the write.

- `JsonFileStorage.storePerformanceStatistics`: drop the redundant
  outer `try/catch` and pass `{ throwError: false }` to the primitive,
  matching the `BootstrapStateUtils.writeStateFile` template. Failures
  now produce a single error log instead of one error log followed by
  a second warn-level log.

- `OCPP20CertificateManager.storeCertificate`: replace the empty
  `logPrefix` with a string carrying `stationHashId` and the module
  origin so the new error log carries actionable context. The outer
  `try/catch` in `storeCertificate` only stringifies the error into
  the structured `{ success: false, error }` result and does not call
  `handleFileException`, so there is no double handling here — only
  the missing context to fix.

* refactor(utils): consolidate atomic write options and address audit findings

- FileUtils: include threadId in temp filename for cross-worker uniqueness;
  fold errorParams into AtomicWriteOptions (single trailing options bag);
  document SIGKILL leak, durability scope, and per-field defaults; drop
  redundant 'utf8' as BufferEncoding cast.
- BootstrapStateUtils.writeStateFile: drop the undefined placeholder.
- ChargingStation.saveConfiguration: route fs failures (already logged at
  error by handleFileException) to debug, and surface non-fs failures from
  the lock body at error level via a typeof-guarded ErrnoException check.
- OCPP20CertificateManager.storeCertificate: take a required logPrefix from
  the caller (replaces the synthetic prefix); drop the redundant manual
  mkdir+pathExists block (atomicWriteFile's default ensureDir handles it);
  write certificates with mode 0o600; document the per-path serialization
  rationale (paths keyed by certificate serial number). Both
  OCPP20IncomingRequestService callers updated to pass
  chargingStation.logPrefix(); the manager unit tests are migrated.
- JsonFileStorage.storePerformanceStatistics: migrate to AtomicWriteOptions
  with errorParams; set flush: false on this hot telemetry path (durability
  across crashes is not required for performance records); add 5 focused
  unit tests covering happy path, overwrite, Map serialization, runtime
  storage directory removal, and parallel writes.
- FileUtils tests: tighten assert.throws/rejects with { code: 'ENOENT' } and
  add a deterministic test that asserts the destination remains intact and
  the temp file is cleaned up when the rename step fails.

* fix(performance-storage): decode file URIs into native filesystem paths

JsonFileStorage parsed the storage URI via new URL(uri) and used
.pathname directly as a filesystem path. On Windows, file: URLs yield
WHATWG-formatted pathnames such as '/C:/Users/...' which mkdirSync
interprets relative to the current drive, producing corrupt paths like
'D:\\C:\\Users\\...' and breaking JsonFileStorage on windows-latest CI.

Use fileURLToPath() (the standard Node.js inverse of pathToFileURL) to
decode file: URIs into the native path on every platform. Non-file
schemes (typically jsonfile:./relative-path) keep .pathname semantics
for backward compatibility with existing user configurations.

The new tests in tests/performance/storage/JsonFileStorage.test.ts
exercise this code path with pathToFileURL(absolutePath), which now
round-trips correctly across POSIX and Windows.

2 months agofix(deps): update all non-major dependencies (#1869)
renovate[bot] [Mon, 25 May 2026 14:44:24 +0000 (16:44 +0200)] 
fix(deps): update all non-major dependencies (#1869)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agochore(deps): lock file maintenance (#1863)
renovate[bot] [Sat, 23 May 2026 16:44:56 +0000 (18:44 +0200)] 
chore(deps): lock file maintenance (#1863)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agochore(deps): update dependency eslint-plugin-jsdoc to v63 (#1867)
renovate[bot] [Sat, 23 May 2026 16:33:50 +0000 (18:33 +0200)] 
chore(deps): update dependency eslint-plugin-jsdoc to v63 (#1867)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agobuild(deps): bump js-cookie in the npm_and_yarn group across 1 directory (#1868)
dependabot[bot] [Fri, 22 May 2026 18:37:49 +0000 (20:37 +0200)] 
build(deps): bump js-cookie in the npm_and_yarn group across 1 directory (#1868)

Bumps the npm_and_yarn group with 1 update in the / directory: [js-cookie](https://github.com/js-cookie/js-cookie).

Updates `js-cookie` from 3.0.5 to 3.0.7
- [Release notes](https://github.com/js-cookie/js-cookie/releases)
- [Commits](https://github.com/js-cookie/js-cookie/compare/v3.0.5...v3.0.7)

---
updated-dependencies:
- dependency-name: js-cookie
  dependency-version: 3.0.7
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2 months agochore(deps): update all non-major dependencies (#1865)
renovate[bot] [Fri, 22 May 2026 01:44:34 +0000 (03:44 +0200)] 
chore(deps): update all non-major dependencies (#1865)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agochore(deps): update @ai-hero/sandcastle to 0.5.11
Jérôme Benoit [Fri, 22 May 2026 01:07:50 +0000 (03:07 +0200)] 
chore(deps): update @ai-hero/sandcastle to 0.5.11

Refresh the patch via 'pnpm patch'/'pnpm patch-commit' and pin the
patch key to the exact version so context drift on future bumps
fails loudly instead of applying silently.

Patch semantics are unchanged: it still adds the 'thinking' option
to PiOptions and threads '--thinking <level>' into the pi agent
provider's print command. Only context lines and blob hashes are
refreshed for 0.5.11 (DEFAULT_MODEL bumped to claude-opus-4-7
upstream).

2 months agofix(deps): update all non-major dependencies (#1864)
renovate[bot] [Tue, 19 May 2026 13:11:31 +0000 (15:11 +0200)] 
fix(deps): update all non-major dependencies (#1864)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agochore: ignore .omo/ directory
Jérôme Benoit [Tue, 19 May 2026 12:26:14 +0000 (14:26 +0200)] 
chore: ignore .omo/ directory

2 months agorefactor(bootstrap): factorize UI server start/stop logic
Jérôme Benoit [Mon, 18 May 2026 21:49:47 +0000 (23:49 +0200)] 
refactor(bootstrap): factorize UI server start/stop logic

Extract idempotent helpers and remove duplication across the four
UI server lifecycle call sites:

- public startUIServer() now contains the actual logic instead of
  delegating to a private wrapper; start() calls it directly.
- new private stopUIServer() helper replaces the two inline stop
  blocks in gracefulShutdown() and restart().
- restart() guards syncUIServerTemplates() on uiServerStarted to
  avoid redundant template sync (twice when re-enabling, wasted
  call when the UI server is disabled or stopped). startUIServer()
  performs the sync itself when it actually starts the server.

No public API change. Behavior preserved.

2 months agochore(sandcastle): drop "(Prettier handles it)" parenthetical from critic prompt
Jérôme Benoit [Mon, 18 May 2026 10:02:18 +0000 (12:02 +0200)] 
chore(sandcastle): drop "(Prettier handles it)" parenthetical from critic prompt

2 months agochore(ci): remove obsolete sandcastle cutover note
Jérôme Benoit [Mon, 18 May 2026 00:58:21 +0000 (02:58 +0200)] 
chore(ci): remove obsolete sandcastle cutover note

2 months agofix(sandcastle): resolve TDZ in strategies/index.ts module init
Jérôme Benoit [Mon, 18 May 2026 00:54:16 +0000 (02:54 +0200)] 
fix(sandcastle): resolve TDZ in strategies/index.ts module init

Move the file-private validation regexes `STRATEGY_KEY_PATTERN` and
`CONTROL_TAG_PATTERN` (with their JSDoc verbatim) above the eager call
`STRATEGY_BY_KEY = indexByKey(STRATEGY_REGISTRY)`, so they are
initialized before `indexByKey` reads them at module evaluation.

2 months agofeat(sandcastle): make strategy dispatch registry-driven (#1862)
Jérôme Benoit [Sun, 17 May 2026 22:45:10 +0000 (00:45 +0200)] 
feat(sandcastle): make strategy dispatch registry-driven (#1862)

* feat(sandcastle): make strategy dispatch registry-driven

Replace the hardcoded `sandcastle` label / single-strategy implementation
with a registry mapping each strategy key to its actor/critic loop and
finalization config. Labels (`sandcastle-<key>`) and branch prefixes
(`agent/<key>`) are derived from the key, so adding a strategy is one
folder + one registry line.

- New `.sandcastle/strategies/index.ts` (registry, indexed view with
  load-time uniqueness assertion, `labelOf`/`branchPrefixOf` helpers).
- `TaskSpec.strategyKey` propagates the chosen strategy through the pipeline.
- `GithubIssueSource` fetches issues per registered label, deduplicates
  with a multi-label warning, validates planner output (strategy echo and
  branch number bound to issue id), and builds its prompt sanitizer once
  from the registry (universal core + per-strategy `controlTags`).
- `GITHUB_ISSUE_LABEL` and `GIT_BRANCH_PREFIX` constants removed; no
  back-compat shim.

Cutover (one-time, also noted in `.github/workflows/sandcastle.yml`):
1. `gh label edit sandcastle --name sandcastle-implement`
2. Close or merge open PRs whose head branches start with `agent/issue-`.

* fix(sandcastle): validate strategy key and controlTags at registry load

Extend `indexByKey` to enforce the kebab-case contract on `StrategyEntry.key`
(documented but unchecked) and an XML-name-safe contract on `controlTags`.
Failures throw at module load with a precise message, matching the existing
duplicate-key behaviour, so registry mistakes (typos, wrong casing, empty
tag) cannot silently produce undiscoverable GitHub labels, invalid git
branches or empty regex alternatives in the prompt sanitizer.

* fix(sandcastle): tighten control-tag sanitizer against prefix matches

The deny-list regex `</?(?:tag1|tag2)[^>]*>` matched tag-name prefixes
because `[^>]*` swallowed the trailing characters: `<plant>` matched
alternative `plan`, `<reviewer>` matched `review`, and so on. Insert a
zero-width lookahead `(?=[\\s/>])` after the alternation so only XML
tag-name terminators (whitespace, `/`, or `>`) are accepted. The
deny-list remains entirely registry-driven.

* refactor(sandcastle): parallelise issue fetch and clarify multi-label warning

- Fan out per-strategy `gh issue list` calls with `Promise.all`. The
  dedup pass still iterates the resolved array in registry order, so the
  "first registered wins" semantics is preserved. Failure semantics are
  unchanged: any rejected fetch propagates as before.
- Render labels via `labelOf` (no more hard-coded `sandcastle-*` glob),
  name both winner and dropped GitHub labels, and append a ready-to-paste
  `gh issue edit --remove-label` remediation hint.

* fix(sandcastle): reject prefix-overlapping strategy keys at registry load

Two registered keys related by a kebab-prefix (e.g. 'foo' and 'foo-1') yield
overlapping branch-detection regexes: a branch 'agent/foo-1-42-slug' is matched
by both '^agent/foo-(\d+)-' (capturing '1' as the issue id) and
'^agent/foo-1-(\d+)-' (capturing '42'), and the registry-order tie-break in
`fetchIssuesWithOpenPRs` then attributes the open PR to the wrong issue.

Extend `indexByKey` with a pairwise prefix-incomparable check that throws at
module load with a precise message, in line with the existing
`STRATEGY_KEY_PATTERN`, `CONTROL_TAG_PATTERN`, and duplicate-key checks
introduced in 89be4bd3. Today's single-entry registry passes unchanged.

* refactor(sandcastle): unify prompt taxonomy and decouple planner from orchestrator

Adopt the Inputs/Task/Output/Rules/Done section structure across the
plan, actor and critic prompts; drop `Agent` from the role headers;
deduplicate intra-prompt boilerplate (verbatim instructions, repeated
quality-gate blocks, multiply-explained confidence levels). Total prompt
size: 217 \u2192 142 lines.

Decouple the planner output from registry-known data: the planner now
emits a kebab-case `slug` only; the orchestrator builds
`branch = \`\${source.branchPrefix}-\${id}-\${slug}\`` and copies
`strategyKey` from the registry-resolved source. The validator drops the
`strategyKey` echo check and the full-branch regex, validates the slug
shape via `SLUG_PATTERN` and a `MAX_SLUG_CHARS` cap. The planner JSON
view is also projected to `{ body, labels, number, title }` so
`branchPrefix`/`strategyKey` cannot leak back into the prompt.

Rename actor variable `TASK_ID` \u2192 `ISSUE_NUMBER` for precision; drop the
`## Planner Analysis` section header from `buildPlanContext` so
`{{PLAN_CONTEXT}}` interpolates cleanly inside the actor's Inputs
section.

* fix(sandcastle): de-duplicate multi-line prompt placeholders

The sandcastle library's PromptArgumentSubstitution
(`@ai-hero/sandcastle/dist/PromptArgumentSubstitution.js`) applies the
regex \`/\\{\\{\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}/g\` globally with no
Markdown awareness, so a placeholder appearing both inside an
`## Inputs` bullet (`\`{{KEY}}\` \u2014 description.`) and on a bare line
below was substituted twice. For multi-line values (`ISSUE_BODY`,
`PLAN_CONTEXT`, `FINDINGS`, `ISSUES_JSON`, `ACCEPTANCE_CRITERIA`)
this corrupted the inline-code span and doubled the prompt token cost
on every actor and critic round.

Reference variables by bare name (`\`KEY\``) in the `## Inputs` bullets
and in narratives that mention multi-line values; keep the bare-line
`{{KEY}}` injections downstream as the single substitution sites.
Scalar variables (`ISSUE_NUMBER`, `ISSUE_TITLE`, `BRANCH`,
`BASE_BRANCH`, `NONCE`) remain interpolated where their single-line
value belongs in the rendered prose or commands.

* refactor(sandcastle): centralize MAX_SLUG_CHARS in constants module

`MAX_SLUG_CHARS` is a tunable peer of `MAX_TITLE_CHARS`,
`CONTEXT_HASH_RADIUS`, and `HASH_PREFIX_LENGTH`, all already in
`constants.ts`. Move it there to honour the AGENTS.md "single source of
truth: canonical defaults" rule. `SLUG_PATTERN` stays local in
`task-source.ts`: it is a structural validator regex, not a tunable.

* fix(sandcastle): align PR-coverage branch regex with slug grammar

The PR-coverage regex used to dedup open PRs in `fetchIssuesWithOpenPRs`
omitted the trailing slug, while `validatePlanEntry` enforces a strict
kebab-case slug shape via `SLUG_PATTERN`. A stale or malformed branch
like `agent/implement-42-` would match the dedup regex and suppress
retries forever.

Lift the slug body into a shared `SLUG_PATTERN_BODY` constant reused by
both `SLUG_PATTERN` (the plan-entry validator) and the per-strategy
`branchPatterns` so the two definitions stay in lock-step.

* fix(sandcastle): sanitize planner-supplied issue title at trust boundary

`validatePlanEntry` validated typeof, length and control characters on
`item.title` but stored the raw value verbatim in `TaskSpec`, exposing
both the actor prompt (`{{ISSUE_TITLE}}`) and the GitHub PR title (via
`finalizer.ts`) to control tags such as `<system>` or `<plan>` that a
prompt-injected planner could echo from an issue body.

Route `item.title` through the same registry-derived `sanitizeForPrompt`
already used for `body`, `rootCauseHypothesis` and `acceptanceCriteria`,
trim whitespace, and reject the entry if the sanitised title is empty.
Length and control-character checks remain on the raw input as a fast
fail-fast path before sanitisation.

* fix(sandcastle): retry on all-invalid plan and dedupe planner ids

`validatePlan` returned `[]` both for legitimate empty plans and for
plans where every entry failed validation. The caller treats `[]`
identically ("No actionable issues. Exiting."), so a systemic planner
mistake silently terminated the nightly run as if there were genuinely
no work, wasting the entire retry budget without diagnostic.

It also accepted duplicate ids: a planner emitting two entries for the
same issue number produced two `TaskSpec`s sharing one branch, so
`main.ts` would spawn two concurrent sandboxes and create duplicate PRs.

Restructure `validatePlan` to:
- Track `seenIds: Set<string>`; on duplicate, warn and drop the second
  occurrence (first registered wins, mirrors the multi-label dedup
  pattern in `fetchAndSanitizeIssues`).
- Detect `parsed.issues.length > 0 && validated.length === 0`: log an
  error and return `null` so the existing retry loop at lines 119-177
  re-invokes the planner instead of exiting silently.

2 months agofeat: resolve #314 — Add charging station template Zod validation with schema version...
github-actions[bot] [Sun, 17 May 2026 22:38:50 +0000 (00:38 +0200)] 
feat: resolve #314 — Add charging station template Zod validation with schema versioning (#1860)

* feat: add charging station template Zod validation with schema versioning

Add Zod v4 runtime validation for charging station templates with
schema versioning support. This replaces scattered imperative checks
with a declarative schema pipeline that validates, migrates, and
transforms templates at parse time.

New files:
- TemplateMigrations.ts: CURRENT_SCHEMA_VERSION, coerceVersion(),
  migration registry with migrateV0ToV1()
- TemplateSchema.ts: Zod schemas with topology union, loose + strict
  variants, MeterValues value coercion (number -> string)
- TemplateValidation.ts: validateTemplate() pipeline,
  transformTemplate(), TemplateValidationError

Integration:
- getTemplateFromFile() now calls validateTemplate() instead of bare
  JSON.parse(...) as ChargingStationTemplate
- Cache key updated to hash:vN format incorporating schema version
- Removed checkTemplate(), checkConnectorsConfiguration(),
  checkEvsesConfiguration(), warnTemplateKeysDeprecation() from
  Helpers.ts (logic absorbed into schema/pipeline)
- Exported getConfiguredMaxNumberOfConnectors and
  getMaxNumberOfConnectors from Helpers.ts for connector setup

Template changes:
- Added "$schemaVersion": 1 to all 15 template JSON files

Tests:
- TemplateMigrations.test.ts: version coercion, migration, error cases
- TemplateSchema.test.ts: required fields, topology, EVSE validation,
  MeterValues coercion, all 15 templates pass
- TemplateValidation.test.ts: pipeline, transforms, error class, round-trip

Closes #314

* fix(template): address PR #1860 review findings

- coerceVersion(null|undefined) now returns 0 so legacy templates
  without $schemaVersion go through v0->v1 migration; deprecated keys
  (supervisionUrl, authorizationFile, payloadSchemaValidation,
  mustAuthorizeAtRemoteStart) are renamed instead of being silently
  swallowed by looseObject [HIGH]
- transformTemplate uses Math.max(numberOfConnectors[]) as the
  worst-case bound for the randomConnectors auto-trigger; new
  Helpers.ts pair getMaxConfiguredNumberOfConnectors (deterministic
  upper bound) and pickConfiguredNumberOfConnectors (random pick)
  centralize array semantics; getConfiguredMaxNumberOfConnectors now
  delegates to pickConfiguredNumberOfConnectors [HIGH]
- BaseTemplateSchema.$schemaVersion is now z.literal(CURRENT_SCHEMA_VERSION)
  (post-migration assertion); deprecated keys removed from the v1
  schema and explicitly rejected by a superRefine block with a clear
  diagnostic [MEDIUM]
- transformTemplate drops the unreachable templateMaxConnectors < 0
  branch [LOW]
- validateTemplate widens to accept unknown and rejects null,
  non-plain-object and array payloads with a clear BaseError instead
  of crashing later in coerceVersion [LOW]
- SignedMeterValueSchema, UnitOfMeasureSchema and WsOptionsSchema
  replace z.looseObject({}) with typed shapes plus .catchall(z.unknown())
  for forward-compatible vendor extensions [LOW]
- TemplateValidationError now surfaces '(migrated from vX -> vY)' in
  its message so post-migration validation failures are visible to
  operators

Tests: update coerceVersion(null|undefined) expectation to 0; add
end-to-end legacy migration, deprecated-key rejection (Schema and
Validation layers), null/string/array payload guards, array-form
numberOfConnectors auto-trigger regression, dead-branch regression,
migratedFrom message presence, and unit tests for the two new
Helpers.ts helpers.

* fix(template): address second-pass PR #1860 review findings

- normalize $schemaVersion to coerced integer in validateTemplate so the
  no-migration path (when version === CURRENT) also satisfies the strict
  z.literal(CURRENT_SCHEMA_VERSION) check; was failing for user-authored
  templates with string "$schemaVersion": "1" [F1]
- harmonize coerceVersion error wording on "non-negative integer" across
  all rejection branches; the previous "positive integer" message was
  contradictory since 0 is a valid input (legacy/pre-versioning sentinel
  triggering v0 migration) [F2]
- introduce SCHEMA_VERSION_STRING_PATTERN (^\\d+$) gate in coerceVersion
  to reject permissive Number() coercions ('1.0', '0x1', '1e0', ' 1 ',
  '', '+1', '01a'); pattern mirrors the canonical non-negative-integer
  string pattern used in TemplateSchema for connector/EVSE keys [F6]
- replace for...in with Object.entries destructuring in Evses topology
  validation, harmonizing with the prior-art pattern in Helpers.ts and
  removing the redundant template.Evses[evseKey] re-lookup [F3]
- tighten getMaxConfiguredNumberOfConnectors cast to readonly number[]
  matching the helper signature [F5/F7]
- document the cache-bound warning emission frequency in
  transformTemplate's JSDoc: warnings fire once per (templateFile,
  schemaVersion) cache miss rather than per station instance, which is
  template-scoped on purpose [F4]

Tests: assert string "$schemaVersion": "1" is accepted and normalized
to numeric 1; battery of 8 permissive numeric strings rejected with
harmonized wording; coerceVersion rejection branches all use the same
"non-negative integer" diagnostic.

* fix(template): broaden wsOptions.headers and harden template pipeline

- WsOptionsSchema.headers accepts string | number | string[] values,
  matching Node's OutgoingHttpHeader runtime contract; field-names
  enforced non-empty per RFC 9110 §5.1.

- templateHash uses SRI-style `${algorithm}:${schemaVersion}:${contentHash}`
  computed over the validated template, restoring whitespace-insensitive
  cache semantics from pre-PR main while keeping schema-version-bump
  invalidation deterministic.

- Migration registry refactored to sequential n→n+1 chain so future
  schema versions append one function instead of rewriting prior
  migrations (no behavior change at v1).

- validateTemplate clones its input via structuredClone at the
  validation boundary, honoring the repository immutability convention.

BREAKING CHANGE: external log monitors keyed on the literal string
"Failed to read charging station template file" must also match
"Invalid charging station template payload (not a JSON object)" for
JSON-shape errors; file I/O errors retain their previous wording via
handleFileException.

* test: extract mockLoggerWarnDebug helper to dedupe migration logger spies

Replaces 8 occurrences of the warn+debug t.mock.method pair across
TemplateMigrations.test.ts and TemplateValidation.test.ts with a typed
helper sibling to createLoggerMocks.

* chore(deps): deduplicate pnpm-lock entries for @rolldown/pluginutils and ws

---------

Co-authored-by: Coding Agent <agent@e-mobility-simulator.dev>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
2 months agofeat: resolve #1020 — Persist minimal simulator state and reconstruct template indexe...
github-actions[bot] [Sun, 17 May 2026 19:14:56 +0000 (21:14 +0200)] 
feat: resolve #1020 — Persist minimal simulator state and reconstruct template indexes on startup (#1858)

* feat: persist minimal simulator state and reconstruct template indexes on startup

- Add persistState boolean to ConfigurationData (default: true)
- Add SimulatorState to FileType enum
- Add simulatorState to AsyncLockType enum
- Implement BootstrapStateUtils with:
  - readStateFile: reads and validates state.json with schema version check
  - writeStateFile: atomic write via tmp+rename with AsyncLock
  - reconstructTemplateIndexes: scans per-station config files to rebuild indexes
  - deleteStateFile: safe file deletion
- Integrate into Bootstrap:
  - reconstructTemplateIndexes called in start() before worker spawn
  - State file written on start() (started:true) and stop() (started:false)
  - shouldAutoStart() reads state file to control auto-start behavior
  - persistStateEnabled getter checks persistState config and SIMULATOR_COLD_START env
- Update start.ts to conditionally start based on shouldAutoStart()
- Handle edge cases: corrupt files, missing fields, incompatible schema version
- Add comprehensive tests for all state persistence and reconstruction scenarios

Closes #1020

* refactor(bootstrap): harmonize persisted state feature with codebase conventions

Address review findings on PR #1858:

- HIGH #1: Phase split start() lifecycle. Add public Bootstrap.startUIServer()
  that always brings up the UI server (and reconstructs template indexes
  before accepting requests). start.ts unconditionally calls startUIServer()
  and only gates Bootstrap.start() on shouldAutoStart(), so a persisted
  stopped state no longer turns the simulator into a zombie process.

- HIGH #2: Add canonical default. New defaultPersistState constant and
  Configuration.getPersistState() accessor matching the existing
  defaultUIServerConfiguration / defaultWorkerConfiguration pattern.
  Bootstrap.persistStateEnabled now delegates instead of inlining ?? true.

- MEDIUM #3: Document persistState tunable and SIMULATOR_COLD_START
  environment override in README.md.

- MEDIUM #4: writeStateFile and deleteStateFile now swallow filesystem
  errors via handleFileException with throwError:false, mirroring
  watchJsonFile and the storage layer. Persistence failures no longer
  surface as misleading 'Startup error' / 'Shutdown error'.

- MEDIUM #5: reconstructTemplateIndexes runs inside startUIServer() before
  uiServer.start(), closing the race where UI requests could arrive before
  index reconstruction completes.

- LOW #7: Add formatLogPrefix() utility and use it in BootstrapStateUtils,
  removing the leading-space artifact when logPrefixFn is undefined.

- LOW #8: On state file schema mismatch, quarantine to <path>.v<N>.bak
  instead of silently deleting, allowing forensics during partial schema
  rollouts. JSON parse errors still delete (true corruption, unrecoverable).

- LOW #9: Move state.json from dist/assets/configurations/ to dist/assets/.
  Drops the fragile basename-based filter from reconstructTemplateIndexes
  and separates control file from per-station configuration files.

Drop shouldAutoStart() from IBootstrap interface (boot-time concern, no UI
service consumer needs it). The method stays public on the concrete
Bootstrap class for start.ts.

Tests: align fixtures with new state.json location, add quarantine assertion,
add non-fatal writeStateFile assertion, drop obsolete state.json filter test.

* test(bootstrap): align BootstrapStateUtils tests with project style guide

- Rename BootstrapState.test.ts to BootstrapStateUtils.test.ts to match the
  source module name (TEST_STYLE_GUIDE.md §1: 'Files: ModuleName.test.ts').
- Add mandatory standardCleanup() in afterEach (TEST_STYLE_GUIDE.md §3),
  matching the convention used by Configuration.test.ts, FileUtils.test.ts,
  ErrorUtils.test.ts and ConfigurationKeyUtils.test.ts.

* fix(bootstrap): address review findings on persisted simulator state

Distinguish UI-initiated stop from signal/restart via a private
StopReason enum, so SIGINT/SIGTERM/SIGQUIT and config-reload restarts
no longer flip the persisted state to stopped (HIGH-1).

Short-circuit persistStateEnabled when the UI server is disabled, since
persistence has no recovery channel without a UI; warn once on the
inconsistent configuration (HIGH-2).

Reconstruct template indexes via a shared prepareTemplateStatistics
helper called from constructor and restart, not only from startUIServer,
to prevent index collisions on config-reload of UI-added stations
(MED-3).

Move state file from dist/assets/state.json (wiped by pnpm build) to
dist/assets/configurations/.simulator-state.json; filter dot-prefixed
metadata files in reconstructTemplateIndexes so the state file co-located
with charging station configurations is silently skipped (MIN-10).

Clean up the .tmp file on atomic-rename failure in writeStateFile and
guard readStateFile against JSON null/primitive/array content with an
explicit message (MIN-7, MIN-8).

Unify path computations into readonly assetsDir/configurationsDir/
stateFilePath fields, deduplicate setChargingStationTemplates via a
syncUIServerTemplates helper, and route the SIMULATOR_COLD_START env
var name through Constants.ENV_SIMULATOR_COLD_START (MIN-5, MIN-6).

Export DEFAULT_PERSIST_STATE and add the persistState entry to
config-template.json so the tunable is discoverable (MED-4).

Update README to document the new state file path, the UI server
requirement, and the signal-shutdown semantics.

Test coverage added for tmp cleanup, JSON null/primitive/array guards,
and dot-prefixed metadata filtering; the misleading 'read-only
directory' test is renamed to reflect the actual OS-rejected-path
scenario (MIN-9).

* docs(bootstrap): clarify persisted state semantics from review feedback

- README: drop developer-internals sentence on template index reconstruction
  from the persistState row
- TemplateStatistics: document the `added` (process-scoped) vs `indexes`
  (process+disk-scoped) distinction exposed via SimulatorState
- IBootstrap: document the contract scope (UI-server-facing, excluding
  process-lifecycle helpers and the internal StopReason)
- formatLogPrefix: document the trailing-space contract
- reconstructTemplateIndexes: refine the warn message wording for non-station
  configuration files (level unchanged)

* refactor(utils): default formatLogPrefix prefix to logPrefix

Avoids silent loss of the timestamp when callers do not pass a module-specific
prefix function. Aligns with the existing convention where every module-level
logPrefix delegates to Utils.logPrefix.

---------

Co-authored-by: Agent <agent@github.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
2 months agochore(deps): update all non-major dependencies (#1861)
renovate[bot] [Sun, 17 May 2026 16:07:29 +0000 (18:07 +0200)] 
chore(deps): update all non-major dependencies (#1861)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agobuild(vscode): align workspace folders with monorepo structure
Jérôme Benoit [Fri, 15 May 2026 12:28:45 +0000 (14:28 +0200)] 
build(vscode): align workspace folders with monorepo structure

2 months agochore(deps): update all non-major dependencies (#1859)
renovate[bot] [Fri, 15 May 2026 11:44:12 +0000 (11:44 +0000)] 
chore(deps): update all non-major dependencies (#1859)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 months agoci: set explicit GITHUB_TOKEN read-only permissions
Jérôme Benoit [Fri, 15 May 2026 11:27:04 +0000 (13:27 +0200)] 
ci: set explicit GITHUB_TOKEN read-only permissions