From cbcd86688dbd60f39432341080973f91c9e51c78 Mon Sep 17 00:00:00 2001 From: xeladev4 Date: Thu, 30 Jul 2026 08:07:18 +0100 Subject: [PATCH 1/4] feat(iot): cache deterministic sensor readings for the clock hour getSolarData and getSatelliteData recomputed the seeded-random simulation on every request even though their output is fixed per (projectId, hour). Route them through the existing withIotCache helper, keyed solar:: and satellite:: so entries expire at the hour boundary. Only the deterministic fields are cached; timestamp is still stamped per call, so responses are indistinguishable from the uncached path and callers never get a reference to the shared cache entry. Cap retained entries with IOT_CACHE_MAX_SIZE (default 1000), evicting expired entries first and then oldest-first. An unusable value falls back to the default instead of throwing. IOT_CACHE_DISABLED continues to bypass the cache. routes/iot.ts kept a second copy of the simulation, which the HTTP endpoints used while the cron used lib/iot.ts. It now re-exports lib/iot.ts so both read through one implementation, and therefore one cache. Closes #221 --- src/__tests__/iot-cache.test.ts | 126 ++++++++++++++++++++++++++++++- src/lib/iot.ts | 130 ++++++++++++++++++++++++++------ src/routes/iot.ts | 125 ++---------------------------- 3 files changed, 236 insertions(+), 145 deletions(-) diff --git a/src/__tests__/iot-cache.test.ts b/src/__tests__/iot-cache.test.ts index 68ea190..0fdebb9 100644 --- a/src/__tests__/iot-cache.test.ts +++ b/src/__tests__/iot-cache.test.ts @@ -1,12 +1,21 @@ -import { withIotCache, clearIotCache } from "../lib/iot"; +import { + withIotCache, + clearIotCache, + getIotCacheStats, + getHourSeed, + getSolarData, + getSatelliteData, +} from "../lib/iot"; beforeEach(() => { clearIotCache(); delete process.env.IOT_CACHE_DISABLED; + delete process.env.IOT_CACHE_MAX_SIZE; }); afterEach(() => { delete process.env.IOT_CACHE_DISABLED; + delete process.env.IOT_CACHE_MAX_SIZE; }); describe("withIotCache — cache miss", () => { @@ -50,8 +59,8 @@ describe("withIotCache — TTL expiry", () => { const fn = jest.fn().mockReturnValueOnce("first").mockReturnValueOnce("second"); - withIotCache("solar:5:99999", fn, 1_000); // prime cache - jest.advanceTimersByTime(1_001); // expire TTL + withIotCache("solar:5:99999", fn, 1_000); // prime cache + jest.advanceTimersByTime(1_001); // expire TTL const result = withIotCache("solar:5:99999", fn, 1_000); expect(fn).toHaveBeenCalledTimes(2); @@ -99,3 +108,114 @@ describe("withIotCache — IOT_CACHE_DISABLED", () => { expect(fn).toHaveBeenCalledTimes(2); }); }); + +describe("withIotCache — IOT_CACHE_MAX_SIZE", () => { + it("never grows past the configured cap", () => { + process.env.IOT_CACHE_MAX_SIZE = "3"; + + for (let i = 0; i < 10; i++) { + withIotCache(`solar:${i}:99999`, () => i, 60_000); + } + + expect(getIotCacheStats().entries).toBeLessThanOrEqual(3); + }); + + it("evicts the oldest entry first, keeping the newest", () => { + process.env.IOT_CACHE_MAX_SIZE = "2"; + + withIotCache("solar:100:99999", () => "oldest", 60_000); + withIotCache("solar:101:99999", () => "middle", 60_000); + withIotCache("solar:102:99999", () => "newest", 60_000); + + // The newest key survives and is served from cache... + expect(withIotCache("solar:102:99999", () => "recomputed", 60_000)).toBe("newest"); + // ...while the oldest was evicted and has to be recomputed. + expect(withIotCache("solar:100:99999", () => "recomputed", 60_000)).toBe("recomputed"); + }); + + it("falls back to the default cap when the value is not a usable number", () => { + process.env.IOT_CACHE_MAX_SIZE = "not-a-number"; + expect(getIotCacheStats().maxSize).toBe(1000); + + process.env.IOT_CACHE_MAX_SIZE = "0"; + expect(getIotCacheStats().maxSize).toBe(1000); + }); + + it("reports the configured cap through getIotCacheStats", () => { + process.env.IOT_CACHE_MAX_SIZE = "42"; + expect(getIotCacheStats()).toMatchObject({ maxSize: 42, enabled: true }); + }); +}); + +describe("getSolarData / getSatelliteData caching", () => { + it("caches solar readings under solar::", () => { + getSolarData(7); + expect(getIotCacheStats().entries).toBe(1); + + getSolarData(7); + expect(getIotCacheStats().entries).toBe(1); + }); + + it("caches satellite readings under a separate key from solar", () => { + getSolarData(7); + getSatelliteData(7); + expect(getIotCacheStats().entries).toBe(2); + }); + + it("keys cache entries per project id", () => { + getSolarData(1); + getSolarData(2); + expect(getIotCacheStats().entries).toBe(2); + }); + + it("returns identical readings within the hour (deterministic as before)", () => { + const first = getSolarData(9); + const second = getSolarData(9); + + expect(second.power_output_kw).toBe(first.power_output_kw); + expect(second.efficiency_pct).toBe(first.efficiency_pct); + expect(second.max_power_kw).toBe(first.max_power_kw); + }); + + it("produces the same readings whether or not the cache is enabled", () => { + const cached = getSolarData(11); + + process.env.IOT_CACHE_DISABLED = "true"; + const uncached = getSolarData(11); + + expect(uncached.efficiency_pct).toBe(cached.efficiency_pct); + expect(uncached.power_output_kw).toBe(cached.power_output_kw); + }); + + it("does not cache anything when the cache is disabled", () => { + process.env.IOT_CACHE_DISABLED = "true"; + getSolarData(12); + getSatelliteData(12); + expect(getIotCacheStats().entries).toBe(0); + }); + + it("stamps a fresh timestamp on a cache hit rather than replaying the cached one", () => { + const nowSpy = jest.spyOn(Date, "now"); + nowSpy.mockReturnValue(1_000); + const first = getSolarData(13); + + nowSpy.mockReturnValue(2_000); + const second = getSolarData(13); + + expect(first.timestamp).toBe(1_000); + expect(second.timestamp).toBe(2_000); + nowSpy.mockRestore(); + }); + + it("does not let a caller mutate the shared cache entry", () => { + const first = getSolarData(14); + first.efficiency_pct = -999; + + expect(getSolarData(14).efficiency_pct).not.toBe(-999); + }); + + it("exposes a numeric hour seed for cache keys", () => { + expect(typeof getHourSeed()).toBe("number"); + expect(Number.isNaN(getHourSeed())).toBe(false); + }); +}); diff --git a/src/lib/iot.ts b/src/lib/iot.ts index 76722b3..be3af42 100644 --- a/src/lib/iot.ts +++ b/src/lib/iot.ts @@ -1,6 +1,7 @@ import { logger } from "./logger"; +import { config } from "../config"; -const MAX_POWER_KW = 1000; +const MAX_POWER_KW = config.MAX_POWER_KW; const DEFAULT_EFFICIENCY_PCT = 60; const DEFAULT_FOREST_DENSITY_PCT = 50; @@ -12,8 +13,11 @@ const CRON_TIMEZONE = process.env.CRON_TIMEZONE ?? "UTC"; /** * Returns a stable hour metric respecting CRON_TIMEZONE. * Defends aggressively against NaN parsing issues. + * + * Exported because it is also the cache-key component that gives IoT cache + * entries their hourly expiry (see `withIotCache`). */ -function getHourSeed(): number { +export function getHourSeed(): number { try { const now = new Date(); const formatter = new Intl.DateTimeFormat("en-US", { @@ -46,7 +50,7 @@ function getHourSeed(): number { * Generates a deterministic pseudo-random number in [0, 1) for a given seed. * Uses MurmurHash3 avalanche properties to avoid adjacent collision. */ -function seededRandom(seed: number): number { +export function seededRandom(seed: number): number { const hourSeed = getHourSeed(); // Ensure the inputs aren't NaN before bitwise operations const safeSeed = Number.isNaN(seed) ? 0 : seed; @@ -67,23 +71,74 @@ interface CacheEntry { const _cache = new Map>(); +/** Default cap on cached entries; overridable via IOT_CACHE_MAX_SIZE. */ +const DEFAULT_CACHE_MAX_SIZE = 1000; + +/** + * The cache knobs are read on every call rather than captured at import time. + * The cache is a pure performance layer over deterministic data, so operators + * can flip `IOT_CACHE_DISABLED` or resize the cache without a restart, and + * tests can exercise both paths without re-importing the module. + */ +function isCacheDisabled(): boolean { + return process.env.IOT_CACHE_DISABLED === "true"; +} + +/** + * Resolved entry cap. A missing, non-numeric or non-positive value falls back + * to the default rather than throwing — a bad cache size must never be able to + * take the IoT endpoints down. + */ +function cacheMaxSize(): number { + const raw = process.env.IOT_CACHE_MAX_SIZE; + if (!raw) return DEFAULT_CACHE_MAX_SIZE; + const parsed = parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed < 1) { + logger.warn("Invalid IOT_CACHE_MAX_SIZE, falling back to default", { + IOT_CACHE_MAX_SIZE: raw, + default: DEFAULT_CACHE_MAX_SIZE, + }); + return DEFAULT_CACHE_MAX_SIZE; + } + return parsed; +} + /** Returns milliseconds until the top of the next hour (minimum 1 ms). */ function msUntilNextHour(): number { const now = new Date(); - const ms = - (60 - now.getMinutes()) * 60_000 - now.getSeconds() * 1_000 - now.getMilliseconds(); + const ms = (60 - now.getMinutes()) * 60_000 - now.getSeconds() * 1_000 - now.getMilliseconds(); return ms > 0 ? ms : 3_600_000; } +/** + * Drop expired entries, then evict oldest-first until the cache fits `max`. + * + * `Map` iterates in insertion order and `withIotCache` re-inserts on every + * write, so the first keys returned are the least recently written — which for + * hour-keyed entries is also the least recently used. + */ +function evictTo(max: number): void { + const now = Date.now(); + for (const [key, entry] of _cache) { + if (entry.expiresAt <= now) _cache.delete(key); + } + + for (const key of _cache.keys()) { + if (_cache.size <= max) break; + _cache.delete(key); + } +} + /** * Wraps a synchronous data-fetch function with an in-memory TTL cache. * * Cache key format: `solar:${projectId}:${hourSeed}` (or any caller-chosen key). * TTL defaults to the remainder of the current hour so cached values never - * cross an hour boundary. Set `IOT_CACHE_DISABLED=true` to bypass entirely. + * cross an hour boundary. Set `IOT_CACHE_DISABLED=true` to bypass entirely and + * `IOT_CACHE_MAX_SIZE` to cap retained entries (default 1000). */ export function withIotCache(key: string, fn: () => T, ttlMs?: number): T { - if (process.env.IOT_CACHE_DISABLED === "true") { + if (isCacheDisabled()) { return fn(); } @@ -96,6 +151,10 @@ export function withIotCache(key: string, fn: () => T, ttlMs?: number): T { const value = fn(); _cache.set(key, { value, expiresAt: now + (ttlMs ?? msUntilNextHour()) }); + + const max = cacheMaxSize(); + if (_cache.size > max) evictTo(max); + return value; } @@ -104,17 +163,13 @@ export function clearIotCache(): void { _cache.clear(); } -export function getSolarData(projectId: number) { - if (projectId == null || Number.isNaN(projectId)) { - logger.warn("getSolarData called with null/NaN projectId, using defaults", { projectId }); - return { - power_output_kw: (DEFAULT_EFFICIENCY_PCT / 100) * MAX_POWER_KW, - efficiency_pct: DEFAULT_EFFICIENCY_PCT, - max_power_kw: MAX_POWER_KW, - timestamp: Date.now(), - }; - } +/** Cache introspection for tests and health/metrics surfaces. */ +export function getIotCacheStats(): { entries: number; maxSize: number; enabled: boolean } { + return { entries: _cache.size, maxSize: cacheMaxSize(), enabled: !isCacheDisabled() }; +} +/** The deterministic part of a solar reading — everything except `timestamp`. */ +function computeSolarReading(projectId: number) { const base = seededRandom(projectId); const drift = seededRandom(projectId * 7 + 1); @@ -133,20 +188,32 @@ export function getSolarData(projectId: number) { power_output_kw: Math.round(power_output_kw * 100) / 100, efficiency_pct: Math.round(efficiency_pct * 100) / 100, max_power_kw: MAX_POWER_KW, - timestamp: Date.now(), }; } -export function getSatelliteData(projectId: number) { +export function getSolarData(projectId: number) { if (projectId == null || Number.isNaN(projectId)) { - logger.warn("getSatelliteData called with null/NaN projectId, using defaults", { projectId }); + logger.warn("getSolarData called with null/NaN projectId, using defaults", { projectId }); return { - forest_density_pct: DEFAULT_FOREST_DENSITY_PCT, - ndvi_score: Math.round(Math.min(1, DEFAULT_FOREST_DENSITY_PCT / 100) * 1000) / 1000, + power_output_kw: (DEFAULT_EFFICIENCY_PCT / 100) * MAX_POWER_KW, + efficiency_pct: DEFAULT_EFFICIENCY_PCT, + max_power_kw: MAX_POWER_KW, timestamp: Date.now(), }; } + // Only the deterministic fields are cached; `timestamp` stays the time of the + // request so the response shape is indistinguishable from the uncached path, + // and callers never receive a reference to the shared cache entry. + const reading = withIotCache(`solar:${projectId}:${getHourSeed()}`, () => + computeSolarReading(projectId), + ); + + return { ...reading, timestamp: Date.now() }; +} + +/** The deterministic part of a satellite reading — everything except `timestamp`. */ +function computeSatelliteReading(projectId: number) { const base = seededRandom(projectId * 3 + 5); const drift = seededRandom(projectId * 11 + 2); @@ -162,6 +229,23 @@ export function getSatelliteData(projectId: number) { return { forest_density_pct: Math.round(forest_density_pct * 100) / 100, ndvi_score: Math.round(Math.min(1, forest_density_pct / 100) * 1000) / 1000, - timestamp: Date.now(), }; } + +export function getSatelliteData(projectId: number) { + if (projectId == null || Number.isNaN(projectId)) { + logger.warn("getSatelliteData called with null/NaN projectId, using defaults", { projectId }); + return { + forest_density_pct: DEFAULT_FOREST_DENSITY_PCT, + ndvi_score: Math.round(Math.min(1, DEFAULT_FOREST_DENSITY_PCT / 100) * 1000) / 1000, + timestamp: Date.now(), + }; + } + + // See getSolarData: deterministic fields cached, `timestamp` always fresh. + const reading = withIotCache(`satellite:${projectId}:${getHourSeed()}`, () => + computeSatelliteReading(projectId), + ); + + return { ...reading, timestamp: Date.now() }; +} diff --git a/src/routes/iot.ts b/src/routes/iot.ts index ef6f6a6..2f847c7 100644 --- a/src/routes/iot.ts +++ b/src/routes/iot.ts @@ -1,128 +1,15 @@ import { Router, Request, Response, NextFunction } from "express"; import { parseProjectId } from "../middleware/errors"; import { fetchSatelliteWithFallback } from "../lib/satellite-sources"; -import { logger } from "../lib/logger"; -import { config } from "../config"; - -const MAX_POWER_KW = config.MAX_POWER_KW; -const DEFAULT_EFFICIENCY_PCT = 60; -const DEFAULT_FOREST_DENSITY_PCT = 50; - -// Configurable timezone for seeded-random hour boundaries. -// Defaults to UTC so results are identical across servers regardless of OS locale. -// Set CRON_TIMEZONE=America/New_York to align hourly boundaries with a local clock. -const CRON_TIMEZONE = process.env.CRON_TIMEZONE ?? "UTC"; - -/** - * Returns a stable hour metric respecting CRON_TIMEZONE. - * Defends aggressively against NaN parsing issues. - */ -function getHourSeed(): number { - try { - const now = new Date(); - const formatter = new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - hour12: false, - timeZone: CRON_TIMEZONE, - }); - const parts = formatter.formatToParts(now); - const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10); - return (get("year") * 10000 + get("month") * 100 + get("day")) * 24 + get("hour"); - } catch (error) { - logger.error("Invalid CRON_TIMEZONE configuration, falling back to UTC epoch hours", { - CRON_TIMEZONE, - error, - }); - return Math.floor(Date.now() / 3_600_000); - } -} +import { getSolarData } from "../lib/iot"; /** - * Generates a deterministic pseudo-random number in [0, 1) for a given seed. - * Uses MurmurHash3 avalanche properties to avoid adjacent collision. + * The simulation itself lives in `../lib/iot`, which owns the seeded-random + * generator and the hourly in-memory cache in front of it. These re-exports + * keep the historical `routes/iot` import path working for existing callers + * while there is only one implementation — and therefore one cache — behind it. */ -export function seededRandom(seed: number): number { - const hourSeed = getHourSeed(); - // Ensure the inputs aren't NaN before bitwise operations - const safeSeed = Number.isNaN(seed) ? 0 : seed; - - let h = (safeSeed * 2654435761) ^ (hourSeed * 40503) ^ 0x9e3779b9; - h = Math.imul(h ^ (h >>> 16), 0x85ebca6b); - h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); - h = (h ^ (h >>> 16)) >>> 0; - return h / 0xffffffff; -} - -export function getSolarData(projectId: number) { - if (projectId == null || Number.isNaN(projectId)) { - logger.warn("getSolarData called with null/NaN projectId, using defaults", { projectId }); - return { - power_output_kw: (DEFAULT_EFFICIENCY_PCT / 100) * MAX_POWER_KW, - efficiency_pct: DEFAULT_EFFICIENCY_PCT, - max_power_kw: MAX_POWER_KW, - timestamp: Date.now(), - }; - } - - const base = seededRandom(projectId); - const drift = seededRandom(projectId * 7 + 1); - - if (Number.isNaN(base) || Number.isNaN(drift)) { - logger.warn("getSolarData: seededRandom returned NaN, using fallback", { - projectId, - base, - drift, - }); - } - - const safeBase = Number.isNaN(base) ? 0 : base; - const safeDrift = Number.isNaN(drift) ? 0 : drift; - - const efficiency_pct = Math.min(98, Math.max(40, 40 + safeBase * 58 + safeDrift * 2 - 1)); - const power_output_kw = (efficiency_pct / 100) * MAX_POWER_KW; - - return { - power_output_kw: Math.round(power_output_kw * 100) / 100, - efficiency_pct: Math.round(efficiency_pct * 100) / 100, - max_power_kw: MAX_POWER_KW, - timestamp: Date.now(), - }; -} - -export function getSatelliteData(projectId: number) { - if (projectId == null || Number.isNaN(projectId)) { - logger.warn("getSatelliteData called with null/NaN projectId, using defaults", { projectId }); - return { - forest_density_pct: DEFAULT_FOREST_DENSITY_PCT, - ndvi_score: Math.round(Math.min(1, DEFAULT_FOREST_DENSITY_PCT / 100) * 1000) / 1000, - timestamp: Date.now(), - }; - } - - const base = seededRandom(projectId * 3 + 5); - const drift = seededRandom(projectId * 11 + 2); - - if (Number.isNaN(base) || Number.isNaN(drift)) { - logger.warn("getSatelliteData: seededRandom returned NaN, using fallback", { - projectId, - base, - drift, - }); - } - - const safeBase = Number.isNaN(base) ? 0 : base; - const safeDrift = Number.isNaN(drift) ? 0 : drift; - - const forest_density_pct = Math.min(100, Math.max(0, 30 + safeBase * 65 + safeDrift * 5 - 2.5)); - return { - forest_density_pct: Math.round(forest_density_pct * 100) / 100, - ndvi_score: Math.round(Math.min(1, forest_density_pct / 100) * 1000) / 1000, - timestamp: Date.now(), - }; -} +export { seededRandom, getSolarData, getSatelliteData, getHourSeed } from "../lib/iot"; const router = Router(); From 60fed0e1a2fccd7294fdb72b31c05f82efe7b6af Mon Sep 17 00:00:00 2001 From: xeladev4 Date: Thu, 30 Jul 2026 08:07:19 +0100 Subject: [PATCH 2/4] feat(validation): bound the project id path parameter parseProjectId already rejected floats, signs and non-numeric values via its digits-only regex, but had no upper bound, so ids like 999999999999 were accepted and passed on to downstream lookups. Reject anything above MAX_PROJECT_ID (default 1000000) with a 400. The bound is read per call so it can be raised as the registry grows; an unusable value falls back to the default rather than turning every project lookup into a 500. Closes #223 --- src/__tests__/validation.test.ts | 70 +++++++++++++++++++++++++++++++- src/middleware/errors.ts | 34 +++++++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/__tests__/validation.test.ts b/src/__tests__/validation.test.ts index d8b8c87..743e799 100644 --- a/src/__tests__/validation.test.ts +++ b/src/__tests__/validation.test.ts @@ -1,7 +1,13 @@ import request from "supertest"; import express, { Express } from "express"; import iotRouter from "../routes/iot"; -import { errorHandler, notFoundHandler } from "../middleware/errors"; +import { + errorHandler, + notFoundHandler, + parseProjectId, + maxProjectId, + DEFAULT_MAX_PROJECT_ID, +} from "../middleware/errors"; function buildApp(): Express { const app = express(); @@ -64,3 +70,65 @@ describe("request validation + structured errors", () => { }); }); }); + +describe("project id bounds", () => { + const app = buildApp(); + + afterEach(() => { + delete process.env.MAX_PROJECT_ID; + }); + + it("defaults the upper bound to 1000000", () => { + expect(maxProjectId()).toBe(DEFAULT_MAX_PROJECT_ID); + expect(DEFAULT_MAX_PROJECT_ID).toBe(1_000_000); + }); + + it("accepts the highest allowed id", () => { + expect(parseProjectId("1000000", "project id")).toBe(1_000_000); + }); + + it("rejects an id one past the upper bound", () => { + expect(() => parseProjectId("1000001", "project id")).toThrow( + /project id must be between 1 and 1000000/, + ); + }); + + it.each(["1.5", "0.9", "-5", "+5", "1e6", " 7", "7 ", "0x10", "Infinity", "NaN"])( + "rejects %p as a project id", + (raw) => { + expect(() => parseProjectId(raw, "project id")).toThrow(/positive integer/); + }, + ); + + it("honours a raised MAX_PROJECT_ID", () => { + process.env.MAX_PROJECT_ID = "5"; + expect(parseProjectId("5", "project id")).toBe(5); + expect(() => parseProjectId("6", "project id")).toThrow(/between 1 and 5/); + }); + + it("ignores an unusable MAX_PROJECT_ID and keeps the default", () => { + process.env.MAX_PROJECT_ID = "abc"; + expect(maxProjectId()).toBe(DEFAULT_MAX_PROJECT_ID); + + process.env.MAX_PROJECT_ID = "0"; + expect(maxProjectId()).toBe(DEFAULT_MAX_PROJECT_ID); + }); + + it("returns 400 over HTTP for a very large id", async () => { + const res = await request(app).get("/api/iot/solar/999999999999").expect(400); + expect(res.body).toEqual({ + error: { code: "bad_request", message: expect.stringContaining("between 1 and") }, + }); + }); + + it("returns 400 over HTTP for a float id", async () => { + const res = await request(app).get("/api/iot/solar/1.5").expect(400); + expect(res.body).toEqual({ + error: { code: "bad_request", message: expect.stringContaining("positive integer") }, + }); + }); + + it("still serves an id inside the valid range", async () => { + await request(app).get("/api/iot/solar/1000000").expect(200); + }); +}); diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 942ce68..bcaf13b 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -35,9 +35,33 @@ export function badRequest(message: string): ApiError { return new ApiError(400, "bad_request", message); } +/** Default inclusive upper bound for project IDs; overridable via MAX_PROJECT_ID. */ +export const DEFAULT_MAX_PROJECT_ID = 1_000_000; + +/** + * Resolved inclusive upper bound for project IDs. + * + * Read per call so the bound can be raised without a rebuild as the registry + * grows. A missing, non-numeric or non-positive value falls back to the default + * rather than throwing: a typo in this variable must not turn every project + * lookup into a 500. + */ +export function maxProjectId(): number { + const raw = process.env.MAX_PROJECT_ID; + if (!raw) return DEFAULT_MAX_PROJECT_ID; + const parsed = parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed < 1) return DEFAULT_MAX_PROJECT_ID; + return parsed; +} + /** - * Parse and validate a `:id` style path/route param as a positive integer. - * Throws `ApiError` (400) on anything that isn't a whole number >= 1. + * Parse and validate a `:id` style path/route param as a project ID. + * + * Accepts only a whole number in `[1, MAX_PROJECT_ID]` (default 1..1000000). + * The digits-only regex rejects floats (`1.5`), signs (`-5`, `+5`), whitespace + * and exponent notation (`1e6`) before any numeric coercion happens, and the + * upper bound keeps absurd IDs from reaching downstream lookups. Anything else + * throws `ApiError` (400). */ export function parseProjectId(raw: string | string[] | undefined, field = "id"): number { const value = Array.isArray(raw) ? raw[0] : raw; @@ -48,6 +72,12 @@ export function parseProjectId(raw: string | string[] | undefined, field = "id") if (!Number.isInteger(id) || id < 1) { throw badRequest(`${field} must be a positive integer`); } + + const max = maxProjectId(); + if (id > max) { + throw badRequest(`${field} must be between 1 and ${max}`); + } + return id; } From 08916367414d8f3aee915bc6c9919d7665e431ad Mon Sep 17 00:00:00 2001 From: xeladev4 Date: Thu, 30 Jul 2026 08:07:19 +0100 Subject: [PATCH 3/4] chore(dev): run the dev server under node --watch bun run dev ran ts-node without a watcher, so every code change needed a manual restart. Wrap it in node --watch, which needs no new dependency: ts-node is loaded via -r and Node watches the files it requires. The previous non-watching behaviour stays available as bun run dev:no-watch. Closes #222 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c117c9..26e8534 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "private": true, "scripts": { - "dev": "ts-node src/index.ts", + "dev": "node --watch -r ts-node/register src/index.ts", + "dev:no-watch": "ts-node src/index.ts", "build": "tsc", "start": "node dist/index.js", "test": "jest", From 2bdc334c283a1bdcaf31cb79607533eeeac89662 Mon Sep 17 00:00:00 2001 From: xeladev4 Date: Thu, 30 Jul 2026 08:07:19 +0100 Subject: [PATCH 4/4] docs: version the changelog and document the new configuration CHANGELOG.md had every shipped feature sitting under [Unreleased] with no released section, no issue links and no guidance for adding entries. Move that list to a dated [1.0.0] section matching package.json, note that released sections are generated by semantic-release from Conventional Commits, and add a copyable category template plus compare links. Also document what the preceding commits added: - README: the CHANGELOG link, the valid 1..1000000 project id range and what is rejected, IoT response caching, the dev/dev:no-watch scripts, and the IOT_CACHE_DISABLED, IOT_CACHE_MAX_SIZE and MAX_PROJECT_ID variables - API.md: the id range on the three endpoints that documented only 'positive integer' - .env.example and src/types/env.d.ts: the three new variables Closes #224 --- .env.example | 12 ++++++++++ API.md | 6 ++--- CHANGELOG.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 27 ++++++++++++++++++++++ src/types/env.d.ts | 4 ++++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index b360c9d..a7d72a3 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,18 @@ TX_TIMEOUT_SECONDS=30 # --- IoT simulation --- # Optional: Maximum simulated solar power output in kW. Default: 1000 MAX_POWER_KW=1000 +# Optional: Set to "true" to disable the in-memory IoT reading cache. +# Readings are deterministic per (project_id, hour), so the cache only skips +# redundant recomputation — disabling it changes performance, not responses. +IOT_CACHE_DISABLED= +# Optional: Max entries retained by the IoT reading cache. Default: 1000 +# Oldest entries are evicted first once the cap is reached. +IOT_CACHE_MAX_SIZE=1000 + +# --- Input validation --- +# Optional: Inclusive upper bound accepted for a :id project parameter. +# Requests outside 1..MAX_PROJECT_ID are rejected with HTTP 400. Default: 1000000 +MAX_PROJECT_ID=1000000 # --- Secrets Management --- # Provider: env | aws | vault | azure diff --git a/API.md b/API.md index f9111e1..d8c334c 100644 --- a/API.md +++ b/API.md @@ -78,7 +78,7 @@ Simulated solar-panel reading for project `id`. Readings are deterministic per } ``` -**Errors:** `400` if `id` is not a positive integer. +**Errors:** `400` if `id` is not a whole number in `1..1000000` (bound configurable via `MAX_PROJECT_ID`). --- @@ -100,7 +100,7 @@ Simulated satellite / vegetation reading for project `id`. } ``` -**Errors:** `400` if `id` is not a positive integer. +**Errors:** `400` if `id` is not a whole number in `1..1000000` (bound configurable via `MAX_PROJECT_ID`). --- @@ -164,7 +164,7 @@ Detail for a single project. } ``` -**Errors:** `400` if `id` is not a positive integer. +**Errors:** `400` if `id` is not a whole number in `1..1000000` (bound configurable via `MAX_PROJECT_ID`). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d48964..b927c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Released sections are generated by [semantic-release](https://github.com/semantic-release/semantic-release) +from Conventional Commit messages on `main` (see `.releaserc.json`), so they do not +need to be written by hand. Anything merged but not yet released belongs under +[Unreleased](#unreleased); copy the [template](#template) at the bottom of this +file when starting a new section. + ## [Unreleased] ### Added +- In-memory TTL cache for the deterministic IoT readings. Entries are keyed + `solar::` and `satellite::` and + expire at the next hour boundary, so repeated reads within an hour no longer + recompute the simulation. Disable with `IOT_CACHE_DISABLED=true`; cap retained + entries with `IOT_CACHE_MAX_SIZE` (default `1000`, oldest evicted first) + ([#221](https://github.com/heliobond/backend/issues/221)) +- Upper bound on the `:id` project parameter. IDs outside `1..MAX_PROJECT_ID` + (default `1000000`) are rejected with a `400`, alongside the existing rejection + of floats, signed and non-numeric values + ([#223](https://github.com/heliobond/backend/issues/223)) +- `bun run dev:no-watch`, which keeps the previous non-watching development server + ([#222](https://github.com/heliobond/backend/issues/222)) +- Changelog conventions and a copyable entry template, linked from the README + ([#224](https://github.com/heliobond/backend/issues/224)) + +### Changed + +- `bun run dev` now runs under `node --watch`, so the server restarts on save + ([#222](https://github.com/heliobond/backend/issues/222)) +- `src/routes/iot.ts` re-exports the simulation from `src/lib/iot.ts` instead of + keeping a second copy of it, so the HTTP routes and the hourly cron read + through one implementation and therefore one cache + ([#221](https://github.com/heliobond/backend/issues/221)) + +## [1.0.0] - 2026-07-29 + +Initial version. + +### Added + - Health check endpoint with uptime and last cron run status - IoT simulation endpoints for solar panel and satellite readings - Soroban ProjectRegistry contract integration for impact score updates @@ -37,3 +73,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bearer token authentication for admin endpoints - Rate limiting to prevent abuse - Security policy documentation + +## Template + +Copy this into a new `## [Unreleased]` section and drop the categories that do not +apply. Keep them in this order, and write entries for the person upgrading — what +changed and what they need to do about it, rather than which files moved. Link the +issue or PR at the end of each entry. + +```markdown +## [Unreleased] + +### Added +### Changed +### Deprecated +### Removed +### Fixed +### Security +``` + +[unreleased]: https://github.com/heliobond/backend/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/heliobond/backend/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 3f44e21..a1b0a79 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,15 @@ exceeded. { "status": "ok" } ``` +### Project IDs + +Every `:id` path parameter is a project ID: a whole number in `1..1000000` +inclusive. The upper bound is configurable via `MAX_PROJECT_ID`. + +Anything else is rejected with `400 bad_request` before the route runs — floats +(`1.5`), signed values (`-5`, `+5`), exponent notation (`1e6`), surrounding +whitespace, non-numeric strings, and IDs above the bound. + ### `GET /v1/iot/solar/:id` ```json @@ -112,6 +121,11 @@ exceeded. Readings are deterministic per `(project_id, hour)` — the same id returns the same values within a given clock hour. +Because they are deterministic, readings are cached in memory for the remainder +of the clock hour instead of being recomputed per request; `timestamp` is still +the time of the request. Set `IOT_CACHE_DISABLED=true` to recompute every time, +and `IOT_CACHE_MAX_SIZE` to cap retained entries. + ### `GET /v1/iot/satellite/:id` ```json @@ -224,6 +238,9 @@ Create a `.env` file (see `.env.example`): | `POLL_MAX_ATTEMPTS` | No | `20` | Max polling attempts before timing out | | `TX_TIMEOUT_SECONDS` | No | `30` | Soroban transaction timeout (seconds) | | `MAX_POWER_KW` | No | `1000` | Maximum simulated solar power output (kW) | +| `IOT_CACHE_DISABLED` | No | — | `true` bypasses the in-memory IoT reading cache | +| `IOT_CACHE_MAX_SIZE` | No | `1000` | Max cached IoT readings; oldest are evicted first | +| `MAX_PROJECT_ID` | No | `1000000` | Inclusive upper bound accepted for a `:id` project param | --- @@ -242,6 +259,8 @@ cp .env.example .env # 3. Development (ts-node + hourly cron + 5-min indexer) bun run dev # -> Heliobond backend listening on port 3001 + # watches src/ and restarts on save +bun run dev:no-watch # same, without file watching # Verify it's up curl http://localhost:3001/health @@ -351,6 +370,14 @@ The full OpenAPI specification is available at `http://localhost:3001/api-docs.j ### API.md Reference Detailed API reference with examples and error codes is available in [API.md](./API.md). +## Changelog + +Notable changes for each version are recorded in [CHANGELOG.md](./CHANGELOG.md), +which follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Released +sections are generated by semantic-release from Conventional Commits; add +unreleased work under `## [Unreleased]` using the template at the bottom of the +file. + ## Contributing Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 447f291..01f2154 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -59,6 +59,8 @@ declare namespace NodeJS { COMPRESSION_LEVEL?: string; /** Integer ms to wait for in-flight work on shutdown. Default: 30000 */ SHUTDOWN_TIMEOUT_MS?: string; + /** Integer inclusive upper bound accepted for a `:id` project param. Default: 1000000 */ + MAX_PROJECT_ID?: string; // ── Database ──────────────────────────────────────────────────────── DB_HOST?: string; @@ -95,6 +97,8 @@ declare namespace NodeJS { CRON_FAILURE_THRESHOLD?: string; /** "true" disables the in-memory IoT reading cache. */ IOT_CACHE_DISABLED?: string; + /** Integer max entries retained by the IoT reading cache. Default: 1000 */ + IOT_CACHE_MAX_SIZE?: string; /** Integer ms satellite readings stay cached. Default: 7200000 */ SATELLITE_CACHE_TTL_MS?: string; /** Integer consecutive source failures before alerting. Default: 3 */