Repository navigation
Releases: humoge7502/VoltHub-CSMS
Release list
v1.8.1
Ships the 2026-10-24 security/money-path review fixes and the console polish pass, plus the
1.8.0 wallet-rail content below, which was built but never tagged. Everything in this
section landed with regression tests; the full chain (16 suites) is green.
Added
- Console motion + honesty layer (UI/UX polish pass). Live-session hero number
glides between polls (useCountUp, snaps underprefers-reduced-motion); charts
draw in once on mount and the live power trace carries a pulsing last point; polled
dashboards keep the last good charts when a poll fails and show areconnecting — showing last good datachip instead of wiping the view; route-level
error.js/global-error.jsboundaries replace Next's default white-screen crash.
Fixed
- Contrast:
--tx3raised #6b7681 → #8a94a0 (3.9:1 → 5.9:1 on the panel surface) —
it was commented "decorative only" but used for real 11px content (kvlabels,
hints, placeholders) and chart axis labels; the heatmap "peak" label (1.9:1) now uses
the same safe tone. Chart/heatmap time labels render in a fixedAsia/Kolkata
zone, so the "IST" suffix is truthful for non-IST visitors (was browser-local time
labelled IST). - Heatmap legend renders the exported
HEAT_RAMP(it had drifted from the actual
render scale), and peak labels sit inside the AA threshold. - Session page: polls sequence-guard every response (no stale overwrites) and stop
once the session ends; thearia-liveregion is scoped to the hero number (the old
whole-page region re-announced the entire view every 5 s). - Deep link
/invoices?id=actually opens the itemized panel (the param was dead);
the itemized panel gained a close button; Pay has a busy state (double-POST
guard); the invoices tab title no longer says "Wallet". - Reservations: "Plug in" sends the active tariff plan from the public
/tariffs/activepreview instead of a hardcodedplanId: 2; reservation windows
render both endpoints with date + zone (midnight-crossing windows read correctly). - History "Load more" guards against double clicks (duplicated rows).
- Analytics / Notifications / Station detail gained real loading and error states
(previously: blank-forever page, empty-grid blink, and an eternal skeleton on fetch
failure respectively). - Wallet signer picker implements the ARIA radio pattern (arrow-key movement,
roving tab index).
Changed
- OCPP gateway: a
MeterValuesframe carrying onlyPower.Active.Importis now
recorded (register carried forward from the start register / last reading) instead of
being ACKed and dropped — the power trace keeps its cadence and billing is unchanged
(BUG-061). ANoErrorStatusNotification now clears open OCPP faults on that
connector; previously only the manual maintenance route ever setcleared_at
(BUG-062). Regression: gateway-close t11. - Store mutex cleanup actually releases keys now (the comparison ran against a
never-stored promise, so every locked key leaked into the map for the process
lifetime) (BUG-046). Regression: run.js drain assertion. - Legacy
$scrypt$password verify runs on the thread pool (crypto.scrypt
async) — a pre-Argon2 user logging in no longer blocks the event loop, closing the
gap the PERF-002 async path was built for. - Release verification is no longer lighter than a push:
release.ymlnow runs the
npm audit gates (both lockfiles), the OpenAPI drift + snapshot checks, and the web
build alongside the hermetic suites (B-59). - Docs drift reconciled: ARCHITECTURE.md stats (72 paths · 35 tables · 8 packages),
the worker README'sCOPY … ON CONFLICTclaim (Postgres COPY has no ON CONFLICT;
the sink is a multi-row INSERT), and the k6 smoke threshold comment (5 s, matching
the assertion).
Security
- OCPP gateway refuses secretless charge points (SEC-041). A CP row without an
auth_secretwas previously accepted with no credentials at all inper-cpmode —
on any seeded/hydrated deployment (identities are guessable), anyone could connect as
any charger and fabricateStartTransaction/MeterValuesbilling input. Such
connections now close with4401. Regression: gateway-close t9. - Control plane is operator station-scoped (BUG-059).
/control/plan/:siteId,
certify,enforcement,decisions(+/:id),certificates(+/:id), grid-asset
reads andcomposite-scheduleenforced the role wall but not the station wall — an
operator assigned to station A could actuate station B's chargers. Foreign sites are
now403 OUT_OF_SCOPE; unfiltered reads return only the operator's own stations.
ADMIN bypasses, mirroring every sibling surface. Regression: run.js scope block. - Unsigned wallet top-up is opt-in (SEC-043).
POST /me/wallet/topupcontradicted
ADR-0016's "the signature is the authorisation" story. It is now disabled in
production (and in the public-demo preset;WALLET_UNSIGNED_TOPUP=1re-enables,
=0force-disables everywhere).scripts/share-demo.shsets the hardening flag.
Regression: wallet W12.
Fixed
- Absolute-register overbilling (BUG-054).
StartTransaction.meterStartwas
ignored andstart_meter_kwhhardcoded 0 whilemeterStopis an absolute register —
every charger whose meter did not start at zero was overbilled by the register
offset. The register base is now captured end-to-end (gateway → local store →
Oraclecharge_session_pkg.start_sessionviaV010__meter_start.sql, default 0 for
existing callers). Regression: gateway-close t10 (500 kWh base, 2.5 kWh delivered,
invoice says 2.5). - Wallet double-credit race (W5 under concurrency).
settleTopupchecked intent
status synchronously, then awaited real mirror I/O before flipping toSETTLED—
two concurrent settles of one intent both credited. Settle now runs under the
intent's mutex and the receipt row is identified by its unique note, not array
position. Regression: wallet W11 (Promise.allsettles → exactly one 201 + one 409,
balance rises by exactly the intent amount). - ADMIN locked out of remote-start (B-16). The station-scope check ran for every
role, so an ADMIN without an operator assignment always got403 OUT_OF_SCOPE.
Scope now applies to OPERATOR only, matching all sibling surfaces. - NaN pagination emptied
/stations(B-17) —?page=abcnow coerces to 1. - Unvalidated
planIdpoisoned sessions (B-18) — malformed or unknown plans are a
422 INVALID_PLANat the door instead of aNO_TARIFF_BANDfailure at bill time. /auth/refresh500 for removed users (B-19) — now a clean401 BAD_REFRESH.- Gateway send-window crashes (SEC-042) — the pre-try rate-limit/FormationViolation
sends and the catch block's ownInternalErrorsend could throw on a dying socket
and reject the async listener (process-fatal). All guarded. - Wallet page toasts never rendered (B-32) — the page called
toast()but never
mounted the<Toasts/>outlet; linked/credited/copied feedback silently vanished.
Changed
.env.exampledocumentsWALLET_UNSIGNED_TOPUPand the strict OCPP secretless-CP
policy;scripts/share-demo.shpublic preset prints the charger-secret caveat.
What's Changed
- fix: close tariff/OCPP/relay gaps and harden public demo deployment by @deveshtiwari1025-debug in #40
- feat(release): v1.8.1 — wallet signature rail, security & money-path fixes, console polish by @deveshtiwari1025-debug in #41
New Contributors
- @deveshtiwari1025-debug made their first contribution in #40
Full Changelog: v1.7.0...v1.8.1
v1.7.0
Added
scripts/share-demo.sh— one command gives the running stack a public, shareable
HTTPS link. Boots the compose stack, runs two cloudflared quick tunnels as Docker
containers (web:3000, API:4000— durable,--restart unless-stopped; plain
background processes die with the session), repoints the web build at the public API
origin (NEXT_PUBLIC_API_BASEis baked at build time), health-checks both URLs and
prints the links. No VM, no DNS, no ports opened;--stoptears the tunnels down.
DEPLOY.md§8 documents the flow and the named-tunnel/VM upgrade path for a permanent
URL. Supporting change: compose interpolatesTRUST_PROXYandWEB_ORIGIN(opt-in,
defaults unchanged) so per-IP throttles keep working behind a proxy/tunnel (BUG-023)
and CORS can be extended for prod origins from.env.
Dependencies
- devDependency
c810.1.3 → 12.0.0 (Dependabot #19; coverage suites unaffected).
Fixed
- BUG-053 (P1, durable): V006 silently reverted BUG-050, so a freshly migrated
database had the unbookable-charger defect back.V006__fk_native.sqlreplaces the
reservation_pkgandcharge_session_pkgbodies wholesale (packages have no partial
replacement), so the expiry-hold release added to V003 never reached the object a
freshmigrate.shends up with — V006 is applied last and wins. Reproduced on live
Oracle: after applying V006,expire_staleflipped the window toEXPIREDand left
the connectorRESERVED(only V003's body released it). The release now exists in
both bodies, V006's header states plainly that its sections 1–2 are the final
definition, and the gate asserts the end state on a fully migrated database — so this
class of "two copies drifted" bug fails the build instead of shipping. - BUG-052 (P1, live): the
trg_connector_guardallow-list was open for the whole
life of a pooled connection, so BR-07 (connector status only via the gateway/packages)
was not actually enforced. The guard accepts a write only whileCLIENT_IDENTIFIER
isocpp-gworpkg:<owner>, and the packages set that identity — but never cleared
it, and it is session-scoped. Measured on live Oracle 23ai: a fresh session was
correctly refused withORA-20801, and the same session allowed a direct
UPDATE connectorimmediately afterreservation_pkg.expire_staleran, because the
identity was stillpkg:reservation_pkg.expire_stale. Every package connector write
now goes through oneguard_pkg.set_status(), which opens the identity for exactly
one statement and closes it on every exit path (including a raise out of the UPDATE),
and the nine directUPDATE connectorstatements plus nineSET_IDENTIFIERcalls are
down to one each. Two consequences:reservation_pkg.cancel_reservationhad been
passing the guard only because of that leak (a fresh session was refused — also
reproduced), and the identity can no longer be inherited by unrelated later DML. - The rate-limit tier and the guard now have executable evidence, not comments.
test/sql/run-invariants.jsgained behavioural probes that drive the real procedures
and assert outcomes (a 0-rows SELECT cannot express either bug), on top of static
source checks:GUARD-META-1/2(the guard identity is set only byguard_pkg, and
connector.statusis written only byguard_pkg.set_status) andGUARD-META-3
(bothV003andV006release the hold on expiry). Red-validated: against the
pre-fix SQL the gate reports the 9 leaked identifier sites, the 9 direct writes, the
missing release in both files,GUARD-ORACLE-4(hold leaked) andINV-12; green
after the fix, idempotent across three consecutive runs with zero residue. - BUG-051 (P1, live): the worker's internal channel was throttled by the public
per-IP ANON tier.throttleis mounted at the app root, so its/internal/*
exclusion — added precisely because "the relay starves on 429s" — tested
req.path, which there is/api/v1/internal/outboxand never matched. The worker
polls three internal endpoints every 2 s (~90 req/min, one IP, no JWT) and was
capped at 60/min: 74 of 120 relays were rejected in a measured probe, the
outbox stopped draining and Timescale telemetry went stale. Observed live on the
compose stack as an endlessoutbox poll 429 — backing offlog with 85 unacked
events.isInternalPath()now matches the full URL and exempts the channel from
every tier (the global throttle and the router barriers). After the fix the same
stack drained the entire backlog on the next 2 s cycle (relayed=85, outbox lag
85 → 0,meter_tick59 → 71, zero 429s). Regression:RL-7
(apps/api/test/ratelimit.js), red against the pre-fix check. - BUG-050 (P1, live):
reservation_pkg.expire_stalenever released the connector
it held. Expiry flipped only the reservation, while the local store also released
the connector — so the two engines disagreed on bookability (ADR-0005) and, because
hydrate()re-reads Oracle on boot, the charge point stayed unbookable forever:
start_sessionon aRESERVEDconnector requires aBOOKEDreservation for it,
and the reservation isEXPIRED. Found live: connectors1:1,2:1and9:1
stuckRESERVEDwith onlyEXPIRED/CANCELLEDwindows on them. The procedure now
releases the hold exactly ascancel_reservationdoes (and sets the V004 guard
identifier explicitly instead of relying on a session that happened to run another
package call). Verified red→green on live Oracle, and gated by the new INV-12
invariant (aRESERVEDconnector must be held by aBOOKEDreservation) in
db/oracle/invariants.sql+ the local-store mirror intest/sql/run-invariants.js. - The web image could not be pointed at a non-localhost API.
next.config.js
inlinesNEXT_PUBLIC_API_BASEat build time, butapps/web/Dockerfilenever
set it and compose only set it at runtime — so every built image shipped pointing at
http://localhost:4000/api/v1and the documented Caddy/TLS deployment
(app.example+api.example) could not be built at all. The Dockerfile now takes
NEXT_PUBLIC_API_BASE/NEXT_PUBLIC_SITE_URLas build args, compose passes them
from the environment, and the value is verified to land in the client bundle
(built with a sentinel origin and grepped out of.next/static). - A root
.envwas silently ignored by every compose command. Compose's project
directory is the compose file's directory (infra/), so thecp .env.example .env
step DEPLOY.md §1 prescribes had no effect: the stack booted with the hardcoded dev
JWT_SECRETand no overrides. Measured:docker compose -f infra/docker-compose.yml configrendersJWT_SECRET: dev-only-32-byte-secret-…
with a root.envpresent, and the real value with--env-file .env. DEPLOY.md
(all commands), README, CONTRIBUTING and a comment on the composeapiservice now
say so. Note--env-filefeeds interpolation only, which is what keeps the
services' explicitORACLE_HOST: oracle/TS_HOST: timescalewiring intact. - The simulator's two most likely failures were unreadable. (1) The gateway
upgrades the socket and then closes it with OCPP4401/4404, which the simulator
never handled — an auth refusal surfaced 8 s later as a bareocpp timeout BootNotification, hiding the cause (the demo DB's admin-provisioned charge points
carry random secrets, not thedev-<identity>the simulator assumes). It now names
the close code, the likely cause and the two remedies. (2) Re-running--provision
against a database that already had the fleet pushed nothing intocpsand died
withCannot read properties of undefined (reading 'connector_ref'); an existing
charge point is now reused (verified: a real sessiontx=118ran on the reused
fleet). - The FastAPI sidecar booted through a deprecated hook.
@app.on_event("startup")
is removed in current FastAPI and emitted aDeprecationWarningon every boot and
every test that imported the app; it is now a lifespan handler (verified by running
the lifespan directly:model_loaded = True). pytest warnings: 3 → 1 (the remaining
one is the environment's own pynvml notice). - The Oracle-backed contract suite failed on any database that was not freshly
seeded.test/run.jslooked for a free connector onstations[0]only, so on a
used compose stack it aborted withneed an AVAILABLE connectorwhile 18 connectors
sat free elsewhere. It now searches every station. Also: the bill/pay assertions
carry the response body in their message, so a rare failure names its cause instead
of printing a bare500 == 201. - The router barriers did not key on the identity they documented. The
barriers introduced by the CodeQL pass are mounted ahead ofauthRequired, so
req.userwas alwaysundefinedwhen the key generator ran: every caller on one
host shared a single…:ip:<addr>bucket while the comment claimed "30/min per
user". Bucket identity now comes fromverifiedClaims()— the signature-verified
sub(the same SEC-006 rule the global throttle uses), falling back to the
IPv6-safeipKeyGenerator— so minting extra tokens no longer buys extra budget.
Pinned red→green byRL-2(apps/api/test/ratelimit.js), which fails against the
pre-fixreq.user?.id ?? ipkey. - The same barrier config was copy-pasted into three routers. It is now one
routerBarrier({ key, limit, message })inmiddleware/security.js, and the
token verification insidethrottle()collapsed into the sameverifiedClaims()
helper — one definition of "who is this caller", which is what the two must agree
on. - Removed the dead
getStore()boot path (db/index.js: never called, 0%
covered, and a duplicate of the hydrate/seed/fallback logicserver.jsnow runs
through `upgradeS...
v1.6.0
Added
- ADR-0015 acceptance-envelope scheduling (E6). The allocator consumes the
same acceptance curve as the certifier and the twin (acceptanceFactor— one
definition, three consumers), so plan-time and realised feasibility cannot
disagree. Measured (100 paired seeds/level): charging shortfall 42.6% →
2.36%, deadline miss 56.6% → 15.7%, plan-honesty gap 1,280 kWh →
0.011 kWh, with bit-identical schedules below the knee (H0). Receipts in
bench/results/e6-taper.json; protocol indocs/perf.md. - Acceptance-envelope contract test (
apps/api/test/control-aes.js, wired
intonpm test): AES is unit-identical to the power-based allocator below
the knee, honest above it, and every certificate promise is deliverable by
an AES schedule. - Grid Current web redesign (all 17 routes): server-rendered editorial home
with a live/healthisland; driver/operator navigation split with a
keyboard-friendly mobile drawer; native<dialog>replaces every
window.confirm/prompt; newPageHead/Section/Statband/EmptyState/
StationPickerprimitives; keyboard-accessible map pins and connector
tiles; responsive table wrappers; per-route metadata titles; expanded design
tokens (spacing, motion, z-index, tinted elevation) with full
prefers-reduced-motioncoverage. Zero new frontend dependencies; API
contracts, CSP, and polling cadences unchanged. Home page is now statically
prerendered (fast LCP). - Submission documentation sets under
docs/: startup product thesis,
research paper, patent invention disclosure, recruitment portfolio.
Fixed
- Worker drain failsafe armed at boot (restart loop). The relay's 10 s
drain failsafe was armed unconditionally at process start, force-exiting the
worker ~10 s after every boot; compose restarted it and each boot relayed a
batch, masking the bug while inflating restart counts. The failsafe is now
armed only when a shutdown signal arrives. - Control-plane rate limiting (CodeQL ×15). The whole control surface now
sits behind a canonicalexpress-rate-limitrouter barrier (30/min per
user, per-IP backstop,RATE_LIMIT_OFFhonored) — every authorizing route,
reads included, carries an explicit recognized limiter. - User-controlled allocation bounds (CodeQL ×2). Untrusted plan/certify
horizon/dtMinvalues are clamped to the documented allow-list
(model.clampHorizon/clampDtMin) before they can size solver allocations;
benchmark direct-call contracts unchanged. - Log injection in the profile probe (CodeQL ×1). The closed-loop probe
logs a boolean shape verdict only — no wire-derived values reach the log. - Screenshot capture hardening. Boot-probes candidates and skips charge
points whose secrets were admin-generated (dev-<identity>no longer
assumed); composeWEB_ORIGINnow also allows the documented capture
console port (:3120).
Security
- CodeQL alert count on
main: 0 (down from 18). All 12 required checks
green; durable control-plane E2E passes on the compose stack.
Docs
- README + docs-site screenshot strip re-recorded against the Grid Current
console (real stack, real OCPP session,home.png+discover.pngadded).
Added (control loop closed end-to-end — the actuate/verify stages are now real)
- The plan cycle now CERTIFIES before it schedules.
control/controller.jsresolves
admission facts from the store instead of test hooks: energy requirement from the
connected vehicle's battery + target SoC (vehicleStateFor), deadline from the
reservation that owns the connector, floors from ACTIVEfeasibility_certificate
rows, and it issues/reuses one certificate per active session via
model.certifyVehicle. Contention is computed over overlapping charging windows,
not over every vehicle ever admitted. New route
POST /control/sessions/{id}/certifyshares the same code path (a promise must not
depend on who asked for it); the plan response carriescertifications. - Certificates are taper-aware.
model.acceptanceFactor(CC-CV knee at 80% SoC,
decaying to 15%) is now part of the worst-case simulation, so a promise made at 20%
SoC is not silently reused at 95%. The flat floor is the rate still guaranteed at the
END of the simulated window — conservative by construction. - The loop's missing edge: CSMS→CP ack correlation. The gateway previously handled
only inbound CALLs and dropped every CALLRESULT (if (msg.kind !== 'CALL') return),
which made actuation fire-and-forget. It now correlates results against a bounded
pending-call registry:SetChargingProfileacks land on thecharging_profile_push
row, CALLERRORs are dead-lettered, and an unanswered call is swept to an explicit
ACK_TIMEOUTinstead of being assumed successful.RemoteStop/RemoteStartshare
the same plumbing. - The verify stage now consumes real telemetry.
MeterValuesrecords an enforcement
tick (scheduled kW from the profile in force vs the metered kW) whenever a profile
is in force — a push is "in force" only after dispatch (markPushSent) and before any
rejection (Rejected/CALLERRORdisqualify the row). No profile in force means no
comparison, never a fabricated zero. New routeGET /control/enforcement. - Solver: feasibility-first, then price-aware, and honest about windows. The shipped
solveSchedulenow runs certified floors → EDF feasibility fill → a cost-shift pass
that moves energy only into cheaper slots inside each vehicle's own
[arrival, deadline) window; it is deterministic (ties break on sessionId). The
previous dead code (rank/pricecomputed and thenvoided) is gone.arrivalAtis
a hard lower bound, which is what makes future-arrival experiments valid. - Digital twin + offline harness (
apps/simulator/src/twin.js,
baselines.js,offline.js): deterministic Poisson-ish arrivals with commute peaks,
battery/SoC/deadlines, ToU tariffs, CC-CV acceptance (imported from the certificate
implementation so promise and experiment cannot disagree), non-compliant chargers, a
shared evaluator, and a percentile bootstrap for CIs. - Experiments E1b and E3 (
bench/e1b-cost.js,bench/e3-deadline.js) with
pre-registered hypotheses, paired seeds, identical populations and 95% CIs, run by
npm run bench:e1b/npm run bench:e3. - OpenAPI snapshot is generated and drift-gated (
scripts/gen-openapi.js,
npm run openapi:gen,--checkin CI):docs/openapi.jsonhad silently fallen 14
paths behind the live spec (52 vs 66) while the README called it a verified snapshot.
Added (durable-path verification — the loop is now proven through the DB engine)
- Control-plane E2E suite (
test/e2e/control-plane.js,npm run test:e2e:control,
wired into the CIe2ejob): drives certify → plan → actuate → typed outcome → durable
audit over HTTP against the compose stack, refusing to run unless the API reports the
durable engine (mode: oracle). It asserts the electrical invariant holds through
Oracle's triggers (child cap above parent →GRID_CAP_MONOTONIC), that ADVISORY never
touches the wire, that every ENFORCED actuation is either dispatched or a typed
error (never a 500), that the promise is explicit (an admission commits real power; a
refusal states why), and that no write-through mirror failure was recorded. Re-runnable:
it reuses an existing site root, walks a ladder of booking offsets to find a genuinely
free reservation window, and stops its own session so no active vehicle is left behind. /health.process_started_at: monitoring needs it to tell "restarted just now" from
"degraded for an hour", and it lets the durability test prove an endpoint serves
evidence that predates the running process.
Fixed
- Experiment receipts were not reproducible, despite the protocol saying they were. The
twin anchored each scenario toDate.now()and read diurnal quantities with
getHours(), so the horizon slid across ToU bands and commute peaks between runs (the
same command produced different numbers minutes apart) and the results depended on the
runner's timezone. Scenarios are now generated at a fixed UTC epoch
(twin.SCENARIO_EPOCH, recorded in every receipt asargs.scenario_epoch),touPrice
andarrivalIntensityuse UTC, and two new twin tests pin the anchor and the timezone
independence.bench/results/e1b-cost.jsonande3-deadline.jsonwere regenerated and
every figure indocs/perf.md,README.mdand ADR-0014 updated to match — including a
softer E3 taper-family claim (at ρ = 0.9 the deadline delta is now indistinguishable
from static-cap rather than significantly worse; the honest statement is the softer one).
Receipts are also compacted (offline.compactRow): the per-slot schedule matrices and
the per-session arrival/deadline slot maps are scenario inputs, not results — dropping
them took the two receipts from 14 MB of debug detail to ~5 MB of per-seed evidence,
with every retained number byte-identical. - Station and charge-point provisioning returned 500 on the durable engine. Both
write-through wrappersreturn await withMirrorRetry(...)— the mirror's return value,
which isundefined— instead of the local method's result, so the routes that
destructure{ station, provisioned }blew up withPROVISION_FAILED: Cannot destructure property 'station' of undefined.POST /admin/stationsandPOST /admin/charge-points
therefore worked on the local store and failed on Oracle, which is why thedb-tests
STORE=oraclesuite was red. (The neighbouringupdateStationwrapper already returned
the right shape — the comment there even says so.) Both now return the local result. - A reservation could be advertised with an id Oracle had never issued. Oracle's
identity sequence caches values, so a container...
v1.5.1
Fixed
- BUG-048 (durable engine, latent crash): interleaved Oracle mirror failures
could GROW an undo array back and crash the ack route. The write-through
adapter's undo paths rolled back appended rows witharr.length = savedLen;
if two mirrors failed around a shared snapshot (A appends 2 → B captures → A
fails and truncates to its snapshot → B fails and sets the length back to its
own later snapshot), the array was left with sparse holes. Every later
outbox.find(...)visitedundefinedand/internal/outbox/ack500'd
forever — observed live on the compose stack (worker acks failing, outbox lag
stuck). All rollback sites now usetruncateTo, which only ever shrinks
(clamps to the saved length, never resurrects a hole; rows appended by other
writers after the snapshot are kept — the correct at-least-once behaviour).
Regression:apps/api/test/run.jsBUG-048 drives two interleaved failing
mirrors through the REAL exportedtruncateToand asserts zero sparse holes
(red against the pre-fix assignment), plus BUG-048b pins/internal/outbox/ack
as 200 + idempotent for unknown/replayed/empty id batches.
Added
- compose forwards
RATE_LIMIT_OFFso DB-backed k6 load runs
(bench/README.mdexperiments 1/2/5) can disable the 60 req/min throttle
without editing compose (the middleware toggle already existed; the env was
not plumbed through theapiservice).
What's Changed
- fix(api): BUG-048 — shrink-only undo truncation (sparse-hole crash) + ack idempotency by @humoge7502 in #16
- docs: v1.5.1 changelog (BUG-048 round) by @humoge7502 in #17
Full Changelog: v1.5.0...v1.5.1
v1.5.0
Added
- AI advisory platform (
apps/ai, ADR-0008) on the 8× A100 host. A FastAPI
sidecar with a GPU-batched fleet demand simulator (15× vs CPU), a trained
per-station demand forecaster evaluated against seasonal-naive + ridge baselines
on a held-out tail (MLP 56.4 < ridge 63.1 < naive 73.8 MAE; the first honest run
shippedbeats_naive: falsebefore a target-normalization fix), a deterministic
smart-charging LP (measured: stays on CPU at CSMS scale), and a measured GPU
allocator (least-loaded pick + file-lock quota + utilization receipts; no K8s by
design). API surface:/ai/forecast,/ai/anomalies,/ai/optimize— RBAC +
operator station scope enforced BEFORE the sidecar,advisory: trueon every
response, audit-logged,503 AI_UNAVAILABLEdegradation (6 JS contract tests +
8 pytest; GPU smoke opt-in). Docs:docs/ai-platform.md.
Fixed
- BUG-039 (B2G-013b, authz): starting WITHOUT reservationId hijacked RESERVED
connectors.startSessionallowedRESERVEDwith no reservation id — omitting
the field converted another driver's window (OCPPStartTransactionand REST
/sessions/startboth). Rule now: a RESERVED connector converts only for the
owner of a BOOKED window on it (explicit id or owner-adoption for the OCPP idTag
flow); everyone else gets 409RESERVATION_MISMATCHand the window is untouched. - BUG-040 (robustness): an oversized OCPP frame crashed the whole API. The
gateway had no socketerrorhandler; a >maxPayload frame emitted an unhandled
errorevent and took the process down. Frames are now capped at 256 KB (ws
default was 100 MiB ⇒ ~1 GB/s parse load per malicious CP) and socket errors
clean up via the close path. Regression:gateway-close.jst4. - BUG-041 (durable engine, latent P1): register published
pub(Promise)under
STORE=oracle.routes.jsnever awaitedstore.createUser, whose Oracle
mirror wrapper (BUG-038) is promise-shaped — the durable register response was a
garbage user + asub: undefinedJWT. AllcreateUsercall sites now await;
regression test 27 pins promise-shape parity. - GAP-002 (provisioning):
POST /admin/charge-pointscreated NO connector. A
standalone-provisioned CP could never charge (reservations 404'd "unknown
connector"), diverging fromprovisionStation's default. Same default now: one
TYPE2/22 kW connector unlessconnectors[]is given. - BUG-043 (contract parity): the Oracle packages raised
ORA_20503-style codes
while the local store throwsOVERLAP/TICK_REJECTED/… — callers, routes and
tests key on the canonical names, so the durable engine and the local profile
disagreed on the error contract.fromDrivernow restores the canonical code
from the package number via an explicitORA_CODE_BY_NUMtable (mirror of the
store's own ORA table; both keyed to theRAISE_APPLICATION_ERRORnumbers in
V003, with a comment demanding they stay in sync). Unknown numbers keep the
ORA_XXXXXfallback; already-normalized errors pass through untouched.
Regression test inapps/api/test/run.js. - BUG-044 (boot race, P1): the Oracle upgrade ran while the socket was ALREADY
accepting. Requests in the hydration window wrote local-only ghost rows
(register → user missing in Oracle, invoice →billing_pkglater 500'd) and the
STORE=oracle CI step attached the adapter MID-SUITE at a nondeterministic test
boundary.server.listennow awaits the store upgrade (attached or
local-fallback) before binding the port — the durable engine is armed before the
first request can arrive, in tests and in compose. Regression test in
apps/api/test/run.js; the ordering itself is exercised by the STORE=oracle
suite against a live Oracle. - BUG-045 (durable engine, exposed by BUG-044): session STARTS were local-only.
REST/sessions/startand OCPPStartTransactionnever reached Oracle — the
first wrapped call on a fresh session 500'dORA-01403(no data found).
db/oracle.jsnow wrapsstartSessionwithcharge_session_pkg.start_session
(local-first validation incl. B2G-013b owner-adoption; the package only sees
validated starts) and remaps the read-cache to the durable id when the counters
diverge (a failed mirror consumes an Oracle IDENTITY value — the remap makes it
harmless). Red→green against a live Oracle. - BUG-046 (durable engine, exposed by BUG-044): the demo pre-seed poisoned the
Oracle-hydrated read-cache.seedStoreran before the upgrade andhydrate()
is additive, so 60 ghost demo sessions + their meter readings survived — every
real tick on a fresh session read{deduped:true}and never flipped
PREPARING→CHARGING. WithORACLE_HOSTset the store now starts empty, hydrates
to exactly what Oracle owns, and seeds only when the DB is genuinely empty (the
rulegetStorealready documented). - BUG-047 (durable engine, exposed by BUG-044): admin hardware writes were
local-only.POST /admin/stations,POST /admin/charge-pointsand
PATCH /admin/stations/:idnever reached Oracle — the first reservation on a
provisioned connector 500'dORA-01403(no connector row). All three are now
store methods mirrored with explicit local ids (BUG-038 pattern); the routes
await them (BUG-041-class promise-shape) and the mirror preserves the local
return shape.station.address_linenow defaults to a non-empty sentinel
(''is NULL in Oracle, violating NOT NULL). - Seed + invariants-runner honesty fixes. The Oracle seed funded two wallets
with nowallet_ledgerrows (INV-2 failed on a fresh DB) — ledger rows added.
test/sql/run-invariants.jssplit its SQL on ALL;— the header comment
carries one, so the oracle gate silently fell back to local checks on every run;
it now strips full-line comments first and resolvesoracledbfrom the hoisted
root orapps/api/node_modules. The real SQL runs and gates CI.
Changed
-
PERF-002: Argon2id moved off the event loop for HTTP auth. Login verifies and
register hashes via the sidecar's async (thread-pool) API; the sync forms remain
for seeds/store-internal use. Motivation: the 100-charger connect-storm soak
showed synchronous ~29 ms KDF burns freezing OCPP frame handling. -
PERF-004: METER_REGRESSION check is O(1) per tick.
recordTickscanned ALL
readings per tick (quadratic across a concurrent fleet); a per-session max-meter
index seeded lazily from hydrated rows replaces it. Measured: ingest 21,739 →
42,553 ticks/s on the same bench. -
PROTO-002/003 (OCPP correctness): the 10 msg/s rate-limit CALLERROR now
carries the offending message's uid (was'0'), andStopTransactionon a
never-charged (PREPARING) session endsCANCELLEDwith a normal CALLRESULT
(was:InternalErrorfrom an ILLEGAL_TRANSITION). Regression:gateway-close.js
t5/t6. -
Simulator fleet mode:
--provisionbuilds N real charge points via the admin
API (a burst of N must mean N connectors — the old flow mapped N flows onto 16
seeded connectors and the one-socket-per-CP guard rightly killed duplicates), and
the sim's OCPP tag is the sim driver's ownTAG-<uid>(hardcodedTAG-1was
user 1).--ramp-msstaggers cold connects. Receipt: 100/100 provisioned
fleet, 0 timeouts, 0 errors (was 7+ timeouts at 100 simultaneous).- RESIL chaos tests (relay): API-down accumulate/backoff, ack-failure replay
dedupe (crash-after-append never duplicates), and recovery drain are pinned by
three new worker tests using injected failure seams (relayOnce({fetchImpl, outFile})); the real mirror is never touched by tests. -
Deps:
pgpromoted to a direct dependency of@volthub/api(was optional /
transitive). The worker's Timescale relay (relay-timescale.js) loadspgand
ships in the api image (apps/api/Dockerfile), sonpm cimust install it — the
compose worker failed on a missingpgafter a cleannpm ci. -
SEC-013: Argon2id password hashing implemented (the documented production target).
hashPasswordnow emits standard Argon2id PHC strings (m=19456 KiB, t=2, p=1 — the
OWASP-recommended baseline) via@node-rs/argon2(prebuilt binaries, no node-gyp,
Node ≥10 so the 20/22 CI matrix stays honest).verifyPasswordaccepts both the new
format and the legacy$scrypt$…shape, so hydrated durable rows and old seeds keep
logging in — no data reset. Malformed/garbage stored hashes fail closed without throwing.
The Oracle seed's demo hashes were regenerated as Argon2id; the SEC-011 dummy pad uses
the same parameters, so unknown-email timing parity holds (re-measured ≈20 ms both paths).
Regression-gated byTEST-SEC-ARGON2-1(16 security tests, all green).
Fixed
- BUG-031 (authz): operator cancel + remote-start ignored station scope.
POST /reservations/:id/cancelaccepted any operator for any booking, and
POST /sessions/remote-startgated on role only — an operator assigned to station A
could cancel bookings and drive chargers on station B. Both now enforce the same
stationScopecontract asPATCH /sessions/:id/stateandrequireOwned(scope
checked in the route, re-asserted incancelReservationfor direct store callers).
Regression tests 20/21 assert 403OUT_OF_SCOPEout of scope and success/CP_OFFLINE
in scope. - BUG-032 (input):
/me/wallet/topupcrashed on non-numeric bodies.
Number(req.body.amount)on"lots"/undefinedisNaN, which slipped past the
store'samount > 0guard as a 500. The route now returns 422INVALID_AMOUNTfor
non-finite/non-positive amounts (per-transaction cap unchanged, still enforced in the
store). Regression test 23. - BUG-033 (input): admin user + charge-point creation accepted garbage.
POST /admin/userstook anyrolestring (Oracle CHECK allows DRIVER/OPERATOR/ADMIN)
and any email;POST /admin/charge-pointstook anyocpp_identity(which is embedde...
v1.4.0
Added
- httpOnly refresh cookie + logout (SEC-012, closes the tracked P2): login/register/
refresh setvh_rtas an httpOnly, SameSite=Lax cookie scoped to/api/v1/auth
(Securein production);POST /auth/logoutrevokes the refresh family server-side
and clears the cookie (audited asLOGOUT). The JSON body field remains for non-browser
clients. CORS upgraded to explicit-origin allow-list +credentials: true. Web logout now
calls the API (best-effort) before dropping the local token. Regression-gated by
TEST-SEC-COOKIE-1..4(13 security tests total, all green). - Flagship cross-link: README footer now points to Q-Trust
— the two projects share one receipts-first discipline. - Benchmarks re-run post-SEC-012 (same hardware): race invariant intact (exactly 1×201 +
19×409), auth-path overhead unchanged. Raw:bench/results-local.json; tables indocs/perf.md.
Full Changelog: v1.3.1...v1.4.0
v1.3.1
Added
- Publishable REST contract:
docs/openapi.json— the full OpenAPI 3.0 spec
(48 paths / 52 operations) captured from the live/api/v1/docsendpoint and
committed as a static artifact, so the API surface is reviewable without
booting the stack; the CI drift gate keeps it honest against routes.js. - Community-health complete:
CODE_OF_CONDUCT.md(precise, honest, useful —
receipts bar applies to conduct too),SUPPORT.md(routing table with honest
response expectations),CITATION.cff(repo is now citable; GitHub renders a
Cite this repository button), and.vscode/extensions.jsonrecommending
the actual toolchain (eslint/prettier/mermaid/Oracle dev tools/rest-client). - README docs map +
docs/README.mdindex the new contract artifact.
What's Changed
- build(deps-dev): bump globals from 16.5.0 to 17.12.0 by @dependabot[bot] in #7
Full Changelog: v1.3.0...v1.3.1
v1.3.0
Added
- Coverage receipts:
npm run coverage(c8, V8 instrumentation,--all
overapps/*/src+packages/*/src) and a 6th CI job publishing line
coverage to Codecov. Informational by policy (codecov.yml) — the badge is
a receipt, not a target; DB-backed suites stay indb-testson purpose.
Local:npm installthennpm run coverage(text + lcov + json-summary). - Pages via Actions:
pages.ymldeploysdocs/as a Pages artifact
(Settings → Pages → Source: GitHub Actions), with a lean-site budget check. - Community kit: full
CONTRIBUTING.md(project map, two dev profiles,
the 10-rung testing ladder, ground rules, ADR-first changes, release path);
greetings.ymlfirst-contact replies routed to the docs that matter;
Discussions templates forideas(trade-off required) andq&a
(what-you-already-read required). - Visual identity refresh (electric indigo): README header banner,
re-renderedarchitecture-hero.png(now shows the 6-job CI pipeline) and
social-preview.png— all real renders, no third-party banner services.
Changed
- oracledb 6.10.0 → 7.0.1 (the last deliberately-deferred upgrade): merged
after a fully-green db-tests run against real Oracle 23ai — the exact gate the
hardening report set (no blind driver bumps without DB-backed CI). ci.ymlpush trigger scoped tomainsov*tag pushes don't re-run the
whole 6-job matrix (the tag-drivenrelease.ymlalready re-verifies).- README: image-first header, Codecov badge, stats row and CI row updated to
6 jobs; Quickstart gains the coverage rung.
What's Changed
- build(deps): bump oracledb from 6.10.0 to 7.0.1 by @dependabot[bot] in #5
New Contributors
- @dependabot[bot] made their first contribution in #5
Full Changelog: v1.2.0...v1.3.0
v1.2.0
Repo / presentation (GitHub pack)
- README v2: badge row (ci · release · OpenSSF Scorecard · Ask DeepWiki ·
node 20|22 · Oracle · TimescaleDB · license · docs site), "New here?" reading
paths, route/engine/package/ADR/CI/CVE stats row, "Stack, and why" trade-off
table, mermaid system diagram, and a docs map. - GitHub Pages one-pager at
docs/index.html(deploys frommain+/docs)
and a 1280×640 social previewdocs/social-preview.png. - YAML issue forms (bug / feature / security-routing
config.yml) replace the
old markdown templates;.gitattributeskeeps the language bar honest. - Tag-driven
release.yml(verify → GitHub Release with CHANGELOG notes) and
OpenSSFscorecard.yml(weekly + on push, SARIF → Code Scanning). - Profile README published at
humoge7502/humoge7502.
Security (HARDEN-2026-09)
- Zero known CVEs across both lockfiles: express 4.19 → 5.2.1 (DoS CVE
chain GHSA-4mjr-xmp4-gh2g closed), Next 14.2.5 → 16.3.4 + React 19 (framework
CVE cluster + postcss chain closed).npm auditis now a CI gate on both
lockfiles (securityjob, 5-job pipeline). - SEC-011: login no longer leaks account existence by timing — unknown emails
are verified against a fixed dummy scrypt hash (measured ≈42 ms both paths,
gap 0 ms across 3 sample pairs). Regression-gated inapps/api/test/security.js. - SEC-010:
x-powered-byframework fingerprint removed; proxy trust is now
opt-in viaTRUST_PROXY(BUG-023) so the documented Caddy deploy profile
cannot collapse the per-IP login throttle into a platform-wide outage.
Changed
- Platform toolchain: eslint 8
.eslintrc→ eslint 10 flat config
(eslint.config.js; stricter gate immediately caught an unused catch binding
and a never-read assignment); pino 9 → 10.3.1; Node 22 LTS images with a
Node 20/22 CI matrix keepingengines >=20honest; checkout/setup-node v7;
concurrencycancel-in-progress. - Web: Next 16 + React 19 — dynamic-route pages migrated from sync
params
props touseParams()(the/stations/:id+/session/:idroutes were
verified in a real browser, zero page errors). scripts/check-openapi.jsuses theapp.routergetter (Express 5 removed
app._router) — the OpenAPI drift gate would otherwise have silently zeroed.
Re-verified: spec=48 routes~52, no missing paths.
Fixed
- BUG-021: a reconnecting charge point's stale socket deregistered its live
successor and marked the charger OFFLINE — the gateway close handler now only
clears the registration it owns. Regression suiteapps/api/test/gateway-close.js
was validated to catch the bug (unguarded handler → red). - BUG-022: worker/relay HTTP calls were unbounded — a hung API could freeze the
telemetry pipeline. Every call now carriesAbortSignal.timeout(5s). - No graceful shutdown anywhere: the API now drains on SIGTERM/SIGINT (stop
accepting, close OCPP sockets, 10 s failsafe) and the worker stops between 2 s
cycles; compose services gainedstop_grace_periodabove the failsafes and
restart: unless-stopped.
Added
- Worker now syncs Timescale
station_mapfromGET /internal/station-mapon
every loop (masterplan §26.5, previously unimplemented): Oracle-owned station
metadata denormalized for query-time cagg enrichment, sov_tick_1m_enriched/
v_tick_1h_enrichedand the Grafana per-station dashboard have data in prod.
Relay unit tests (apps/worker/test/relay.test.js) wired intonpm testand
CI; e2e verifiesstation_mapis populated by the live worker. - The API Dockerfile never copied
apps/worker(compose runs the worker from the
same image), so the worker silently exited in the compose stack — B2G-001's
relay was never live in e2e. The image now shipsapps/worker. - (Superseded in HARDEN-2026-09 below: actions are now at v7 with a Node 20/22
matrix — see the Security/Changed sections above.)
Fixed (migrations were never run end-to-end — “CI partly theater” closed)
db/oracle: V001 now owns thecp_id/connector_noFK pair so V002/V003
compile on a fresh DB (V005's guarded ALTERs no-op for fresh runs, still
repair legacy DBs).db/oracle:audit_log.old_value/new_valuedropped theirCHECK (IS JSON)
—audit_pkg.logwrites plain text/NULL diffs, so the seed's tariff and
package audits raised ORA-02290; V006 drops the constraint on legacy DBs.db/oracle:maintenance_pkgnow sets thepkg:CLIENT_IDENTIFIERthe
V004 connector guard allow-list requires (seed fault story raised ORA-20801).db/oracle/seed: COMMIT per story (autonomous audits self-deadlocked a
single giant transaction with ORA-00060); fixed an illegal scalar subquery
in the fault story's procedure argument.db/oracle/V004: least-privilege role bootstrap now guards for the missing
CREATE ROLEprivilege on the schema-owner migrate account — one note
instead of 90 ORA lines when skipped; full grants when run privileged.db/timescale/T002: continuous aggregates can only query one hypertable —
rewritten join-free (P2V-01) withMODE()replaced by per-stateFILTER
counts +v_state_1m(P2V-02); enrichment moved to query-time views
v_tick_1m_enriched/v_tick_1h_enriched.scripts/migrate.sh: Oracle migrations exec sqlplus inside the running
container (never a silent skip viadocker run), and both Oracle and
Timescale paths now fail loudly when a migration errors.- CI: the local-store suites (contract/race/security/xlayer) blank
ORACLE_HOSTso they can't nondeterministically attach the Oracle adapter
mid-run;db-testsadds a Timescale cagg-refresh smoke (closes P2V-01/02 on
every push); the e2e job provisions DBs viascripts/migrate.sh(initdb
mounts hid the Timescale image's setup scripts and ranqueries.sql) and
waits for stable Oracle connects before migrating.
Full Changelog: v1.1.0...v1.2.0
VoltHub CSMS v1.1.0 — audit remediation complete + evidence kit
VoltHub CSMS v1.1.0
Two-engine EV charging management: Oracle 23ai (ACID reservations/billing/ledger in PL/SQL packages) + TimescaleDB (telemetry hypertables/caggs), driven by an OCPP 1.6J WebSocket gateway + simulator fleet, race-tested on both engines, with a Next.js "Grid Current" console.
Audit Round 2 (B2G) — P0/P1 security + integrity
- Object-level authorization on invoices/sessions (BOLA triad closed, attacker-probe tests)
- Real Oracle write-through adapter (ADR-0005), hydrate of the transactional world, durable outbox acks + idempotency under
STORE=oracle - Pay-parity across engines (failed payment → invoice stays DUE) locked by a cross-layer test
- Reservation-ownership at session start (hijack → 409), JWT alg pin, login throttle, role gates on analytics/faults
- Timescale relay armed in compose +
(session_id, seq_no, ts)sink PK, monotonic gateway sequencing - Docs truth pass (SECURITY/README/ADRs), lint + format CI gates, community kit (dependabot/templates/CODEOWNERS)
Audit Round 3 (B3G) — verification kit
- V006 / ADR-0006: connector linkage is FK-native (
cp_id, connector_nopair, sync triggers retired, D-02 band minutes + unique slot) - OCPP remote commands: operator RemoteStart/RemoteStop contract test (real WS, Basic auth) — 2 tests in
npm test+ CI - Orphaned-invoice guard,
insertBatchrename, V004 wording, ADR-0005 multi-instance note - Bench harness + measured tables (discovery p95 2 ms, 20-race 1×201/19×409, 22,989 ticks/s) — methodology in
docs/perf.md - Real-pixel README evidence: @2x screenshots (dashboard · live session · telemetry · invoice) + live OCPP-driven GIF; re-recordable via
scripts/screenshots/ - Web CSP hydration fix (console now actually hydrates in dev + prod)
- Invariant suite extended to 11 checks (0 rows), OpenAPI drift gate zero-notes
Verification
npm test (API 16 · sim 2 · security 7 · XLAYER 4 · OCPP-remote 2 · invariants 11 · drift OK) · race suite (R1/R4) · e2e 7 steps · lint + prettier clean · Next prod build clean. P2V-01..03 (Timescale/Oracle runtime refresh) remain CI-covered per push — this host's shared disk is full; steps documented in docs/verification.md.