@checkstack/slo-backend 0.2.4 → 0.2.5

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,29 @@
1
1
  # @checkstack/slo-backend
2
2
 
3
+ ## 0.2.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 86bab6a: ### GitOps: Fix authentication token handling
8
+
9
+ - Made `authToken` optional in `ReconcileProviderParams` and `ScraperOptions` to support unauthenticated access to public repositories
10
+ - GitHub and GitLab scrapers now conditionally set authentication headers only when a token is provided
11
+ - Sync worker now decrypts the encrypted `authToken` from the database before passing it to scrapers, fixing authentication failures caused by sending encrypted values in HTTP headers
12
+
13
+ ### SLO: Fix premature Nines Club achievement unlock
14
+
15
+ - The "Nines Club" achievement now requires both ≥99.99% availability **and** a 365-day compliance streak, preventing immediate unlock on newly created SLOs with 100% default availability
16
+
17
+ ### SLO: Align frontend achievement descriptions with backend criteria
18
+
19
+ - Fixed mismatched descriptions for Iron Uptime (7-day, not 30), Diamond Uptime (30-day, not 90), Clean Sheet (rolling window, not quarter), Full Coverage (3+ SLOs, not all systems in group), and Nines Club (99.99%)
20
+
21
+ ### SLO: Enrich milestones with system names
22
+
23
+ - The `getRecentMilestones` endpoint now resolves human-readable system names via the Catalog API instead of returning raw system IDs
24
+ - @checkstack/catalog-backend@0.4.1
25
+ - @checkstack/healthcheck-backend@0.14.2
26
+
3
27
  ## 0.2.4
4
28
 
5
29
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/slo-backend",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -16,10 +16,10 @@
16
16
  "@checkstack/backend-api": "0.12.0",
17
17
  "@checkstack/slo-common": "0.2.0",
18
18
  "@checkstack/healthcheck-common": "0.11.0",
19
- "@checkstack/healthcheck-backend": "0.13.1",
19
+ "@checkstack/healthcheck-backend": "0.14.1",
20
20
  "@checkstack/dependency-common": "0.2.1",
21
21
  "@checkstack/catalog-common": "1.3.1",
22
- "@checkstack/catalog-backend": "0.2.24",
22
+ "@checkstack/catalog-backend": "0.4.0",
23
23
  "@checkstack/command-backend": "0.1.19",
24
24
  "@checkstack/signal-common": "0.1.9",
25
25
  "@checkstack/integration-backend": "0.1.19",
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ ACHIEVEMENT_DEFINITIONS,
4
+ type AchievementContext,
5
+ } from "./achievement-evaluator";
6
+
7
+ /**
8
+ * Creates a base context with sensible defaults for testing.
9
+ * All achievements should be OFF with these defaults.
10
+ */
11
+ function baseContext(
12
+ overrides?: Partial<AchievementContext>,
13
+ ): AchievementContext {
14
+ return {
15
+ objectiveCount: 0,
16
+ streakDays: 0,
17
+ bestAvailability: undefined,
18
+ budgetRemainingPercent: undefined,
19
+ hasZeroDowntime: false,
20
+ hasUpstreamOnlyDowntime: false,
21
+ fastestRecoverySeconds: undefined,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function findDefinition(type: string) {
27
+ const def = ACHIEVEMENT_DEFINITIONS.find((d) => d.type === type);
28
+ if (!def) throw new Error(`Achievement definition not found: ${type}`);
29
+ return def;
30
+ }
31
+
32
+ describe("achievement definitions", () => {
33
+ describe("nines_club", () => {
34
+ const ninesClub = findDefinition("nines_club");
35
+
36
+ it("requires both 99.99% availability AND 365-day streak", () => {
37
+ const ctx = baseContext({
38
+ bestAvailability: 99.995,
39
+ streakDays: 365,
40
+ });
41
+ expect(ninesClub.evaluate(ctx)).toBe(true);
42
+ });
43
+
44
+ it("does NOT unlock with high availability but insufficient streak", () => {
45
+ const ctx = baseContext({
46
+ bestAvailability: 99.999,
47
+ streakDays: 30,
48
+ });
49
+ expect(ninesClub.evaluate(ctx)).toBe(false);
50
+ });
51
+
52
+ it("does NOT unlock with 365-day streak but low availability", () => {
53
+ const ctx = baseContext({
54
+ bestAvailability: 99.98,
55
+ streakDays: 400,
56
+ });
57
+ expect(ninesClub.evaluate(ctx)).toBe(false);
58
+ });
59
+
60
+ it("does NOT unlock with undefined availability even with long streak", () => {
61
+ const ctx = baseContext({
62
+ bestAvailability: undefined,
63
+ streakDays: 500,
64
+ });
65
+ expect(ninesClub.evaluate(ctx)).toBe(false);
66
+ });
67
+
68
+ it("does NOT unlock on a freshly created SLO (zero streak, 100% availability)", () => {
69
+ const ctx = baseContext({
70
+ bestAvailability: 100.0,
71
+ streakDays: 0,
72
+ });
73
+ expect(ninesClub.evaluate(ctx)).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("iron_uptime", () => {
78
+ const ironUptime = findDefinition("iron_uptime");
79
+
80
+ it("unlocks at 7-day streak", () => {
81
+ expect(ironUptime.evaluate(baseContext({ streakDays: 7 }))).toBe(true);
82
+ });
83
+
84
+ it("does NOT unlock below 7 days", () => {
85
+ expect(ironUptime.evaluate(baseContext({ streakDays: 6 }))).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("diamond_uptime", () => {
90
+ const diamondUptime = findDefinition("diamond_uptime");
91
+
92
+ it("unlocks at 30-day streak", () => {
93
+ expect(diamondUptime.evaluate(baseContext({ streakDays: 30 }))).toBe(
94
+ true,
95
+ );
96
+ });
97
+
98
+ it("does NOT unlock below 30 days", () => {
99
+ expect(diamondUptime.evaluate(baseContext({ streakDays: 29 }))).toBe(
100
+ false,
101
+ );
102
+ });
103
+ });
104
+
105
+ describe("first_steps", () => {
106
+ const firstSteps = findDefinition("first_steps");
107
+
108
+ it("unlocks with 1 objective", () => {
109
+ expect(firstSteps.evaluate(baseContext({ objectiveCount: 1 }))).toBe(
110
+ true,
111
+ );
112
+ });
113
+
114
+ it("does NOT unlock with 0 objectives", () => {
115
+ expect(firstSteps.evaluate(baseContext({ objectiveCount: 0 }))).toBe(
116
+ false,
117
+ );
118
+ });
119
+ });
120
+
121
+ describe("full_coverage", () => {
122
+ const fullCoverage = findDefinition("full_coverage");
123
+
124
+ it("unlocks with 3 or more objectives", () => {
125
+ expect(fullCoverage.evaluate(baseContext({ objectiveCount: 3 }))).toBe(
126
+ true,
127
+ );
128
+ expect(fullCoverage.evaluate(baseContext({ objectiveCount: 5 }))).toBe(
129
+ true,
130
+ );
131
+ });
132
+
133
+ it("does NOT unlock with fewer than 3 objectives", () => {
134
+ expect(fullCoverage.evaluate(baseContext({ objectiveCount: 2 }))).toBe(
135
+ false,
136
+ );
137
+ });
138
+ });
139
+
140
+ describe("budget_miser", () => {
141
+ const budgetMiser = findDefinition("budget_miser");
142
+
143
+ it("unlocks when 90% or more budget remaining", () => {
144
+ expect(
145
+ budgetMiser.evaluate(baseContext({ budgetRemainingPercent: 90 })),
146
+ ).toBe(true);
147
+ expect(
148
+ budgetMiser.evaluate(baseContext({ budgetRemainingPercent: 99 })),
149
+ ).toBe(true);
150
+ });
151
+
152
+ it("does NOT unlock below 90% remaining", () => {
153
+ expect(
154
+ budgetMiser.evaluate(baseContext({ budgetRemainingPercent: 89.9 })),
155
+ ).toBe(false);
156
+ });
157
+
158
+ it("does NOT unlock with undefined budget", () => {
159
+ expect(
160
+ budgetMiser.evaluate(baseContext({ budgetRemainingPercent: undefined })),
161
+ ).toBe(false);
162
+ });
163
+ });
164
+
165
+ describe("clean_sheet", () => {
166
+ const cleanSheet = findDefinition("clean_sheet");
167
+
168
+ it("unlocks with zero downtime", () => {
169
+ expect(cleanSheet.evaluate(baseContext({ hasZeroDowntime: true }))).toBe(
170
+ true,
171
+ );
172
+ });
173
+
174
+ it("does NOT unlock when downtime has occurred", () => {
175
+ expect(cleanSheet.evaluate(baseContext({ hasZeroDowntime: false }))).toBe(
176
+ false,
177
+ );
178
+ });
179
+ });
180
+
181
+ describe("cascade_breaker", () => {
182
+ const cascadeBreaker = findDefinition("cascade_breaker");
183
+
184
+ it("unlocks when all downtime is upstream-attributed", () => {
185
+ expect(
186
+ cascadeBreaker.evaluate(
187
+ baseContext({ hasUpstreamOnlyDowntime: true }),
188
+ ),
189
+ ).toBe(true);
190
+ });
191
+
192
+ it("does NOT unlock without upstream-only downtime", () => {
193
+ expect(
194
+ cascadeBreaker.evaluate(
195
+ baseContext({ hasUpstreamOnlyDowntime: false }),
196
+ ),
197
+ ).toBe(false);
198
+ });
199
+ });
200
+
201
+ describe("rapid_recovery", () => {
202
+ const rapidRecovery = findDefinition("rapid_recovery");
203
+
204
+ it("unlocks with 5-minute or under recovery", () => {
205
+ expect(
206
+ rapidRecovery.evaluate(baseContext({ fastestRecoverySeconds: 300 })),
207
+ ).toBe(true);
208
+ expect(
209
+ rapidRecovery.evaluate(baseContext({ fastestRecoverySeconds: 60 })),
210
+ ).toBe(true);
211
+ });
212
+
213
+ it("does NOT unlock over 5 minutes", () => {
214
+ expect(
215
+ rapidRecovery.evaluate(baseContext({ fastestRecoverySeconds: 301 })),
216
+ ).toBe(false);
217
+ });
218
+ });
219
+ });
@@ -34,9 +34,9 @@ const ACHIEVEMENT_DEFINITIONS: Array<{
34
34
  },
35
35
  {
36
36
  type: "nines_club",
37
- description: "Achieved 99.9% or higher availability in a rolling window",
38
- evaluate: ({ bestAvailability }) =>
39
- bestAvailability !== undefined && bestAvailability >= 99.9,
37
+ description: "Maintained 99.99% or higher availability for 365 days",
38
+ evaluate: ({ bestAvailability, streakDays }) =>
39
+ bestAvailability !== undefined && bestAvailability >= 99.99 && streakDays >= 365,
40
40
  },
41
41
  {
42
42
  type: "budget_miser",
@@ -199,3 +199,7 @@ export function getAchievementDefinitions() {
199
199
  description: d.description,
200
200
  }));
201
201
  }
202
+
203
+ // Export for testing
204
+ export { ACHIEVEMENT_DEFINITIONS };
205
+ export type { AchievementContext };
package/src/index.ts CHANGED
@@ -177,7 +177,7 @@ export default createBackendPlugin({
177
177
  rpcClient: coreServices.rpcClient,
178
178
  queueManager: coreServices.queueManager,
179
179
  },
180
- init: async ({ logger, database, rpc, signalService }) => {
180
+ init: async ({ logger, database, rpc, signalService, rpcClient }) => {
181
181
  logger.debug("🔧 Initializing SLO Backend...");
182
182
 
183
183
  const service = new SloService(database as SafeDatabase<typeof schema>);
@@ -190,7 +190,7 @@ export default createBackendPlugin({
190
190
  // Store for afterPluginsReady
191
191
  sharedEngine = engine;
192
192
 
193
- const router = createRouter({ service, engine, signalService });
193
+ const router = createRouter({ service, engine, signalService, rpcClient });
194
194
  rpc.registerRouter(router, sloContract);
195
195
 
196
196
  // Register command palette entries
package/src/router.ts CHANGED
@@ -6,8 +6,10 @@ import {
6
6
  import {
7
7
  autoAuthMiddleware,
8
8
  type RpcContext,
9
+ type RpcClient,
9
10
  } from "@checkstack/backend-api";
10
11
  import type { SignalService } from "@checkstack/signal-common";
12
+ import { CatalogApi } from "@checkstack/catalog-common";
11
13
  import type { SloService } from "./service";
12
14
  import type { SloEngine } from "./slo-engine";
13
15
 
@@ -15,10 +17,12 @@ export function createRouter({
15
17
  service,
16
18
  engine,
17
19
  signalService,
20
+ rpcClient,
18
21
  }: {
19
22
  service: SloService;
20
23
  engine: SloEngine;
21
24
  signalService: SignalService;
25
+ rpcClient: RpcClient;
22
26
  }) {
23
27
  const os = implement(sloContract)
24
28
  .$context<RpcContext>()
@@ -180,9 +184,30 @@ export function createRouter({
180
184
  const achievements = await service.getRecentMilestones({
181
185
  limit: input.limit ?? 20,
182
186
  });
187
+
188
+ // Collect unique system IDs for batch lookup
189
+ const uniqueSystemIds = [...new Set(achievements.map((a) => a.systemId))];
190
+ const systemNameMap = new Map<string, string>();
191
+
192
+ // Resolve system names via catalog RPC
193
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
194
+ await Promise.all(
195
+ uniqueSystemIds.map(async (systemId) => {
196
+ try {
197
+ const system = await catalogClient.getSystem({ systemId });
198
+ if (system?.name) {
199
+ systemNameMap.set(systemId, system.name);
200
+ }
201
+ } catch {
202
+ // Silently skip — system may have been deleted
203
+ }
204
+ }),
205
+ );
206
+
183
207
  return {
184
208
  milestones: achievements.map((a) => ({
185
209
  systemId: a.systemId,
210
+ systemName: systemNameMap.get(a.systemId),
186
211
  achievement: a.achievement,
187
212
  unlockedAt: a.unlockedAt,
188
213
  })),