@checkstack/slo-common 0.4.1 → 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 +48 -0
- package/package.json +6 -6
- package/src/index.ts +2 -0
- package/src/rpc-contract.test.ts +44 -0
- package/src/rpc-contract.ts +2 -2
- package/src/schemas.test.ts +56 -0
- package/src/schemas.ts +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
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
|
+
|
|
41
|
+
## 0.4.2
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [e2d6f25]
|
|
46
|
+
- Updated dependencies [6d52276]
|
|
47
|
+
- @checkstack/frontend-api@0.6.0
|
|
48
|
+
- @checkstack/common@0.12.0
|
|
49
|
+
- @checkstack/signal-common@0.2.5
|
|
50
|
+
|
|
3
51
|
## 0.4.1
|
|
4
52
|
|
|
5
53
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/slo-common",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
13
|
-
"@checkstack/frontend-api": "0.
|
|
14
|
-
"@checkstack/signal-common": "0.2.
|
|
15
|
-
"@orpc/contract": "^1.
|
|
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.
|
|
21
|
+
"@checkstack/scripts": "0.3.4"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"typecheck": "tsgo -b",
|
package/src/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/rpc-contract.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
235
|
+
windowDays: SloWindowDaysSchema.optional(),
|
|
219
236
|
dependencyExclusion: DependencyExclusionModeSchema.optional(),
|
|
220
237
|
excludedDependencyIds: z.array(z.string()).optional(),
|
|
221
238
|
burnRateThresholds: BurnRateThresholdsSchema.optional(),
|