@checkstack/slo-common 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # @checkstack/slo-common
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9dcc848: Align workspace dependency versions and migrate React Router to v7.
8
+
9
+ BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
10
+
11
+ Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
12
+
13
+ Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
14
+
15
+ Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
16
+
17
+ ### Patch Changes
18
+
19
+ - 9dcc848: Input-validation and error-mapping hardening found by a fuzzing pass against the built container.
20
+
21
+ - backend: a Postgres driver error caused by bad client input no longer surfaces as a `500`. The `/api` and `/rest` dispatchers now map the relevant SQLSTATE classes to the correct status - `22P02`/`22003`/`22001`/`22007` (malformed/out-of-range/over-long/bad-date value), `23502`/`23503`/`23514` (missing/dangling/check-failed) to `400`, and `23505` (unique violation) to `409` - and log them at `warn` (client mistake), not `error`. The client-facing message is generic so column/constraint names are never leaked; genuine unknown faults still log at `error` and 500. Previously a `where id = $1` with a non-uuid `$1` (or an over-long string, or a foreign-key miss in `addSystemToGroup`) reached the driver and 500'd, making routine probing look like a server outage and burying real 500s.
22
+ - slo-common: **fixes a stored cluster-wide DoS.** `windowDays` was accepted up to `2^53`, but the SLO engine derives window boundaries with `Date(now - windowDays * 86_400_000)` - a large value overflows past the max representable `Date` and yields `Invalid Date`. That objective committed fine, then every subsequent read of the system's objectives threw `RangeError: Invalid time value` during serialization (a 500 readable by anyone with SLO read access, on any pod). `windowDays` is now bounded to 1..3650 days at the contract, the GitOps `kind: SLO` spec, and the update path via a single shared `SloWindowDaysSchema`, so the poison row can never be created.
23
+ - slo-common + healthcheck-common: SLO `getDailySnapshots` and the healthcheck history endpoints (`getHistory`, `getDetailedHistory`, `getAggregatedHistory`, `getDetailedAggregatedHistory`, `getRunsForAnalysis`) declared their `startDate`/`endDate` params as `z.date()`, which a `/rest/...` string param can never satisfy - so those endpoints 400'd on the entire REST surface. They now use `z.coerce.date()`, accepting both the REST string shape and the native RPC `Date`.
24
+ - healthcheck-common: `intervalSeconds` was `z.number().min(1)` with no `.int()` and no upper bound, so a fractional or out-of-range value reached the DB and failed at insert (the column is a 32-bit int). It is now `.int().min(1).max(2_592_000)` (1 second .. 30 days), applied to both create and update (the update schema is the create partial).
25
+ - catalog-common: system/group/environment names were bare `z.string()` (environment was `.min(1)` only), so empty, whitespace-only, and 100KB+ names reached the DB - the huge ones surfaced as 500s when parameter binding blew up. Names are now `trim().min(1).max(200)` via a shared schema.
26
+
27
+ **BREAKING:** `getSystemContacts` is now `userType: "authenticated"` (was `"public"`). System contacts carry PII (user id, name, email); the public read leaked them to anonymous status-page visitors. Anonymous callers now receive `401` for this one endpoint; the system detail page already renders "No contacts assigned" for anonymous viewers, so the UI degrades gracefully. All other catalog reads remain public.
28
+
29
+ - catalog-frontend: the system detail page skips the `getSystemContacts` request entirely for anonymous viewers (it would now `401`) and falls back to the empty state.
30
+
31
+ This is a beta release: the breaking contact-visibility change ships as a minor bump per the beta versioning policy, not a major.
32
+
33
+ - Updated dependencies [9dcc848]
34
+ - Updated dependencies [9dcc848]
35
+ - Updated dependencies [9dcc848]
36
+ - Updated dependencies [9dcc848]
37
+ - @checkstack/common@0.13.0
38
+ - @checkstack/frontend-api@0.7.0
39
+ - @checkstack/signal-common@0.2.6
40
+
3
41
  ## 0.4.2
4
42
 
5
43
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/slo-common",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,16 +9,16 @@
9
9
  }
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/common": "0.11.0",
13
- "@checkstack/frontend-api": "0.5.2",
14
- "@checkstack/signal-common": "0.2.4",
15
- "@orpc/contract": "^1.13.14",
12
+ "@checkstack/common": "0.12.0",
13
+ "@checkstack/frontend-api": "0.6.0",
14
+ "@checkstack/signal-common": "0.2.5",
15
+ "@orpc/contract": "^1.14.4",
16
16
  "zod": "^4.2.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "typescript": "^5.7.2",
20
20
  "@checkstack/tsconfig": "0.0.7",
21
- "@checkstack/scripts": "0.3.3"
21
+ "@checkstack/scripts": "0.3.4"
22
22
  },
23
23
  "scripts": {
24
24
  "typecheck": "tsgo -b",
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@ export {
14
14
  SloStatusSchema,
15
15
  CreateSloObjectiveInputSchema,
16
16
  UpdateSloObjectiveInputSchema,
17
+ SloWindowDaysSchema,
18
+ SLO_MAX_WINDOW_DAYS,
17
19
  type DependencyExclusionMode,
18
20
  type AttributionType,
19
21
  type AchievementType,
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ZodType } from "zod";
3
+ import { sloContract } from "./rpc-contract";
4
+
5
+ /**
6
+ * The REST/OpenAPI surface (`/rest/...`) sends query params as strings. With the
7
+ * date params declared as `z.date()` they were unsatisfiable over REST (a string
8
+ * never validates as a native Date), so `getDailySnapshots` 400'd on every REST
9
+ * call. `z.coerce.date()` accepts both an ISO string (REST) and a Date (native
10
+ * RPC), which is what this guards.
11
+ */
12
+ function inputSchemaFor(procName: keyof typeof sloContract): ZodType {
13
+ const proc = sloContract[procName] as unknown as Record<string, unknown>;
14
+ const orpc = proc["~orpc"] as { inputSchema?: ZodType };
15
+ if (!orpc.inputSchema) throw new Error(`${String(procName)} has no input`);
16
+ return orpc.inputSchema;
17
+ }
18
+
19
+ describe("getDailySnapshots coerces string date params (REST compatibility)", () => {
20
+ const schema = inputSchemaFor("getDailySnapshots");
21
+
22
+ test("accepts ISO date strings (the REST shape)", () => {
23
+ const parsed = schema.safeParse({
24
+ objectiveId: "obj-1",
25
+ startDate: "2026-01-01T00:00:00.000Z",
26
+ endDate: "2026-02-01T00:00:00.000Z",
27
+ });
28
+ expect(parsed.success).toBe(true);
29
+ if (parsed.success) {
30
+ const data = parsed.data as { startDate: Date; endDate: Date };
31
+ expect(data.startDate).toBeInstanceOf(Date);
32
+ expect(data.endDate).toBeInstanceOf(Date);
33
+ }
34
+ });
35
+
36
+ test("still accepts native Date objects (the RPC shape)", () => {
37
+ const parsed = schema.safeParse({
38
+ objectiveId: "obj-1",
39
+ startDate: new Date("2026-01-01"),
40
+ endDate: new Date("2026-02-01"),
41
+ });
42
+ expect(parsed.success).toBe(true);
43
+ });
44
+ });
@@ -145,8 +145,8 @@ export const sloContract = {
145
145
  .input(
146
146
  z.object({
147
147
  objectiveId: z.string(),
148
- startDate: z.date(),
149
- endDate: z.date(),
148
+ startDate: z.coerce.date(),
149
+ endDate: z.coerce.date(),
150
150
  }),
151
151
  )
152
152
  .output(z.object({ snapshots: z.array(SloDailySnapshotSchema) })),
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ CreateSloObjectiveInputSchema,
4
+ SLO_MAX_WINDOW_DAYS,
5
+ SloWindowDaysSchema,
6
+ } from "./schemas";
7
+
8
+ /**
9
+ * `windowDays` is the load-bearing bound: the engine derives window boundaries
10
+ * with `Date(now - windowDays * 86_400_000)`, so an unbounded value overflows
11
+ * past the max representable Date and produces `Invalid Date`. That row commits
12
+ * but then poisons EVERY read of the system's objectives (the serializer throws
13
+ * `RangeError: Invalid time value`) - a stored cluster-wide DoS the fuzzing pass
14
+ * found. The schema bound is what prevents the poison row from ever existing.
15
+ */
16
+ describe("SloWindowDaysSchema", () => {
17
+ test("accepts a normal window", () => {
18
+ expect(SloWindowDaysSchema.parse(30)).toBe(30);
19
+ expect(SloWindowDaysSchema.parse(SLO_MAX_WINDOW_DAYS)).toBe(
20
+ SLO_MAX_WINDOW_DAYS,
21
+ );
22
+ });
23
+
24
+ test("rejects zero and negative windows", () => {
25
+ expect(SloWindowDaysSchema.safeParse(0).success).toBe(false);
26
+ expect(SloWindowDaysSchema.safeParse(-1).success).toBe(false);
27
+ });
28
+
29
+ test("rejects non-integer windows", () => {
30
+ expect(SloWindowDaysSchema.safeParse(1.5).success).toBe(false);
31
+ });
32
+
33
+ test("rejects the date-poisoning value above the max", () => {
34
+ expect(SloWindowDaysSchema.safeParse(SLO_MAX_WINDOW_DAYS + 1).success).toBe(
35
+ false,
36
+ );
37
+ // The exact value the fuzzer used to poison the SLO read path.
38
+ expect(SloWindowDaysSchema.safeParse(100_000_000).success).toBe(false);
39
+ });
40
+
41
+ test("a max-window objective produces a valid Date in the engine arithmetic", () => {
42
+ const windowStart = new Date(
43
+ Date.now() - SLO_MAX_WINDOW_DAYS * 24 * 60 * 60 * 1000,
44
+ );
45
+ expect(Number.isNaN(windowStart.getTime())).toBe(false);
46
+ });
47
+
48
+ test("CreateSloObjectiveInputSchema rejects an out-of-range window", () => {
49
+ const result = CreateSloObjectiveInputSchema.safeParse({
50
+ systemId: "sys-1",
51
+ target: 99.9,
52
+ windowDays: 100_000_000,
53
+ });
54
+ expect(result.success).toBe(false);
55
+ });
56
+ });
package/src/schemas.ts CHANGED
@@ -184,6 +184,23 @@ export type SloStatus = z.infer<typeof SloStatusSchema>;
184
184
  // INPUT SCHEMAS
185
185
  // =============================================================================
186
186
 
187
+ /**
188
+ * SLO rolling-window length in days. Bounded on BOTH ends: at least 1 day, and
189
+ * at most 10 years. The upper bound is load-bearing - the engine derives window
190
+ * boundaries with `Date(now - windowDays * 86_400_000)`, so an unbounded value
191
+ * (the API previously accepted any positive int up to 2^53) overflows past the
192
+ * max representable Date and produces `Invalid Date`. That row commits fine but
193
+ * then poisons EVERY read of the system's objectives (the serializer throws
194
+ * `RangeError: Invalid time value`), a stored cluster-wide DoS. 3650 days keeps
195
+ * all arithmetic well inside Date and int32 range.
196
+ */
197
+ export const SLO_MAX_WINDOW_DAYS = 3650;
198
+ export const SloWindowDaysSchema = z
199
+ .number()
200
+ .int()
201
+ .positive("Window must be at least 1 day")
202
+ .max(SLO_MAX_WINDOW_DAYS, `Window must be at most ${SLO_MAX_WINDOW_DAYS} days`);
203
+
187
204
  /**
188
205
  * Input for creating a new SLO objective.
189
206
  */
@@ -194,7 +211,7 @@ export const CreateSloObjectiveInputSchema = z.object({
194
211
  .number()
195
212
  .min(0, "Target must be >= 0")
196
213
  .max(100, "Target must be <= 100"),
197
- windowDays: z.number().int().positive("Window must be at least 1 day"),
214
+ windowDays: SloWindowDaysSchema,
198
215
  dependencyExclusion: DependencyExclusionModeSchema.optional().default(
199
216
  "strict",
200
217
  ),
@@ -215,7 +232,7 @@ export type CreateSloObjectiveInput = z.infer<
215
232
  export const UpdateSloObjectiveInputSchema = z.object({
216
233
  id: z.string(),
217
234
  target: z.number().min(0).max(100).optional(),
218
- windowDays: z.number().int().positive().optional(),
235
+ windowDays: SloWindowDaysSchema.optional(),
219
236
  dependencyExclusion: DependencyExclusionModeSchema.optional(),
220
237
  excludedDependencyIds: z.array(z.string()).optional(),
221
238
  burnRateThresholds: BurnRateThresholdsSchema.optional(),