@checkstack/gitops-backend 0.1.0 → 0.1.1

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,30 @@
1
1
  # @checkstack/gitops-backend
2
2
 
3
+ ## 0.1.1
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
+
25
+ - Updated dependencies [86bab6a]
26
+ - @checkstack/gitops-common@0.1.1
27
+
3
28
  ## 0.1.0
4
29
 
5
30
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/gitops-backend",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@checkstack/backend-api": "0.12.0",
17
- "@checkstack/gitops-common": "0.0.1",
17
+ "@checkstack/gitops-common": "0.1.0",
18
18
  "@checkstack/common": "0.6.5",
19
19
  "@checkstack/command-backend": "0.1.19",
20
20
  "@checkstack/queue-api": "0.2.13",
package/src/router.ts CHANGED
@@ -112,8 +112,11 @@ export const createGitOpsRouter = ({
112
112
  if (input.data.pathPattern !== undefined)
113
113
  updates.pathPattern = input.data.pathPattern;
114
114
  if (input.data.baseUrl !== undefined) updates.baseUrl = input.data.baseUrl;
115
- if (input.data.authToken !== undefined)
116
- updates.authToken = encrypt(input.data.authToken);
115
+ if (input.data.authToken !== undefined) {
116
+ // null = explicitly clear token, string = encrypt and store
117
+ // eslint-disable-next-line unicorn/no-null
118
+ updates.authToken = input.data.authToken ? encrypt(input.data.authToken) : null;
119
+ }
117
120
  if (input.data.syncInterval !== undefined)
118
121
  updates.syncInterval = input.data.syncInterval;
119
122
  if (input.data.deletionPolicy !== undefined)
@@ -352,4 +352,55 @@ describe("githubScraper", () => {
352
352
  expect(url).toStartWith(enterpriseUrl);
353
353
  }
354
354
  });
355
+ it("works without auth token and does not send Authorization header", async () => {
356
+ const capturedHeaders: Record<string, string>[] = [];
357
+
358
+ const mockFetch: FetchFn = async (input, init) => {
359
+ const url = typeof input === "string" ? input : input.toString();
360
+ const headers = init?.headers as Record<string, string> | undefined;
361
+ if (headers) {
362
+ capturedHeaders.push(headers);
363
+ }
364
+
365
+ if (url.includes("git/trees")) {
366
+ return new Response(
367
+ JSON.stringify({
368
+ sha: "abc",
369
+ tree: [{ path: ".checkstack/sys.yaml", type: "blob" }],
370
+ truncated: false,
371
+ }),
372
+ { headers: { "Content-Type": "application/json" } },
373
+ );
374
+ }
375
+
376
+ if (url.includes("contents/")) {
377
+ return new Response(
378
+ JSON.stringify({ content: btoa("yaml"), encoding: "base64" }),
379
+ { headers: { "Content-Type": "application/json" } },
380
+ );
381
+ }
382
+
383
+ if (url.includes("repos/public-org/repo")) {
384
+ return new Response(
385
+ JSON.stringify({ full_name: "public-org/repo", default_branch: "main" }),
386
+ { headers: { "Content-Type": "application/json" } },
387
+ );
388
+ }
389
+
390
+ return new Response("Not Found", { status: 404 });
391
+ };
392
+
393
+ const files = await githubScraper.discoverFiles({
394
+ target: "public-org/repo",
395
+ pathPattern: ".checkstack/**/*.yaml",
396
+ logger: mockLogger,
397
+ fetch: mockFetch,
398
+ });
399
+
400
+ expect(files).toHaveLength(1);
401
+ // Verify no Authorization header was sent
402
+ for (const headers of capturedHeaders) {
403
+ expect(headers.Authorization).toBeUndefined();
404
+ }
405
+ });
355
406
  });
@@ -43,17 +43,18 @@ function parseNextPageUrl(linkHeader: string | null): string | undefined {
43
43
  */
44
44
  async function githubFetch(params: {
45
45
  url: string;
46
- authToken: string;
46
+ authToken?: string;
47
47
  fetchFn: FetchFn;
48
48
  }): Promise<Response> {
49
49
  const { url, authToken, fetchFn } = params;
50
- return fetchFn(url, {
51
- headers: {
52
- Authorization: `Bearer ${authToken}`,
53
- Accept: "application/vnd.github+json",
54
- "X-GitHub-Api-Version": "2022-11-28",
55
- },
56
- });
50
+ const headers: Record<string, string> = {
51
+ Accept: "application/vnd.github+json",
52
+ "X-GitHub-Api-Version": "2022-11-28",
53
+ };
54
+ if (authToken) {
55
+ headers.Authorization = `Bearer ${authToken}`;
56
+ }
57
+ return fetchFn(url, { headers });
57
58
  }
58
59
 
59
60
  // ─── Core Logic ────────────────────────────────────────────────────────────
@@ -64,7 +65,7 @@ async function githubFetch(params: {
64
65
  */
65
66
  async function enumerateRepos(params: {
66
67
  target: string;
67
- authToken: string;
68
+ authToken?: string;
68
69
  fetchFn: FetchFn;
69
70
  apiUrl: string;
70
71
  }): Promise<GitHubRepo[]> {
@@ -113,7 +114,7 @@ async function enumerateRepos(params: {
113
114
  async function getMatchingFiles(params: {
114
115
  repo: GitHubRepo;
115
116
  pathPattern: string;
116
- authToken: string;
117
+ authToken?: string;
117
118
  fetchFn: FetchFn;
118
119
  apiUrl: string;
119
120
  }): Promise<string[]> {
@@ -143,7 +144,7 @@ async function fetchFileContent(params: {
143
144
  repoFullName: string;
144
145
  filePath: string;
145
146
  branch: string;
146
- authToken: string;
147
+ authToken?: string;
147
148
  fetchFn: FetchFn;
148
149
  apiUrl: string;
149
150
  }): Promise<string> {
@@ -293,4 +293,56 @@ describe("gitlabScraper", () => {
293
293
  expect(url).toStartWith(selfHostedUrl);
294
294
  }
295
295
  });
296
+ it("works without auth token and does not send PRIVATE-TOKEN header", async () => {
297
+ const capturedHeaders: Record<string, string>[] = [];
298
+
299
+ const mockFetch: FetchFn = async (input, init) => {
300
+ const url = typeof input === "string" ? input : input.toString();
301
+ const headers = init?.headers as Record<string, string> | undefined;
302
+ if (headers) {
303
+ capturedHeaders.push(headers);
304
+ }
305
+
306
+ if (url.includes("/repository/tree")) {
307
+ return new Response(
308
+ JSON.stringify([
309
+ { id: "a1", name: "sys.yaml", type: "blob", path: ".checkstack/sys.yaml" },
310
+ ]),
311
+ { headers: { "Content-Type": "application/json" } },
312
+ );
313
+ }
314
+
315
+ if (url.includes("/repository/files/") && url.includes("/raw")) {
316
+ return new Response("yaml-content", {
317
+ headers: { "Content-Type": "text/plain" },
318
+ });
319
+ }
320
+
321
+ if (url.includes("/projects/")) {
322
+ return new Response(
323
+ JSON.stringify({
324
+ id: 99,
325
+ path_with_namespace: "public/repo",
326
+ default_branch: "main",
327
+ }),
328
+ { headers: { "Content-Type": "application/json" } },
329
+ );
330
+ }
331
+
332
+ return new Response("Not Found", { status: 404 });
333
+ };
334
+
335
+ const files = await gitlabScraper.discoverFiles({
336
+ target: "public/repo",
337
+ pathPattern: ".checkstack/**/*.yaml",
338
+ logger: mockLogger,
339
+ fetch: mockFetch,
340
+ });
341
+
342
+ expect(files).toHaveLength(1);
343
+ // Verify no PRIVATE-TOKEN header was sent
344
+ for (const headers of capturedHeaders) {
345
+ expect(headers["PRIVATE-TOKEN"]).toBeUndefined();
346
+ }
347
+ });
296
348
  });
@@ -25,15 +25,15 @@ interface GitLabTreeItem {
25
25
  */
26
26
  async function gitlabFetch(params: {
27
27
  url: string;
28
- authToken: string;
28
+ authToken?: string;
29
29
  fetchFn: FetchFn;
30
30
  }): Promise<Response> {
31
31
  const { url, authToken, fetchFn } = params;
32
- return fetchFn(url, {
33
- headers: {
34
- "PRIVATE-TOKEN": authToken,
35
- },
36
- });
32
+ const headers: Record<string, string> = {};
33
+ if (authToken) {
34
+ headers["PRIVATE-TOKEN"] = authToken;
35
+ }
36
+ return fetchFn(url, { headers });
37
37
  }
38
38
 
39
39
  /**
@@ -41,7 +41,7 @@ async function gitlabFetch(params: {
41
41
  */
42
42
  async function paginateGitLab<T>(params: {
43
43
  initialUrl: string;
44
- authToken: string;
44
+ authToken?: string;
45
45
  fetchFn: FetchFn;
46
46
  }): Promise<T[]> {
47
47
  const { initialUrl, authToken, fetchFn } = params;
@@ -80,7 +80,7 @@ async function paginateGitLab<T>(params: {
80
80
  */
81
81
  async function enumerateProjects(params: {
82
82
  target: string;
83
- authToken: string;
83
+ authToken?: string;
84
84
  fetchFn: FetchFn;
85
85
  apiUrl: string;
86
86
  }): Promise<GitLabProject[]> {
@@ -96,7 +96,7 @@ async function enumerateProjects(params: {
96
96
  async function getMatchingFiles(params: {
97
97
  project: GitLabProject;
98
98
  pathPattern: string;
99
- authToken: string;
99
+ authToken?: string;
100
100
  fetchFn: FetchFn;
101
101
  apiUrl: string;
102
102
  }): Promise<string[]> {
@@ -122,7 +122,7 @@ async function fetchFileContent(params: {
122
122
  projectId: number;
123
123
  filePath: string;
124
124
  branch: string;
125
- authToken: string;
125
+ authToken?: string;
126
126
  fetchFn: FetchFn;
127
127
  apiUrl: string;
128
128
  }): Promise<string> {
@@ -32,8 +32,8 @@ export interface ScraperOptions {
32
32
  target: string;
33
33
  /** Glob pattern for matching file paths (e.g., ".checkstack/**\/*.yaml"). */
34
34
  pathPattern: string;
35
- /** Decrypted auth token for the Git provider API. */
36
- authToken: string;
35
+ /** Decrypted auth token for the Git provider API. Optional for public repos. */
36
+ authToken?: string;
37
37
  /** Custom API base URL for enterprise/on-prem installations. */
38
38
  baseUrl?: string;
39
39
  /** Logger for diagnostic output. */
@@ -19,7 +19,7 @@ interface ReconcileProviderParams {
19
19
  providerType: "github" | "gitlab";
20
20
  target: string;
21
21
  pathPattern: string;
22
- authToken: string;
22
+ authToken?: string;
23
23
  baseUrl?: string;
24
24
  deletionPolicy: "orphan" | "auto";
25
25
  db: Db;
@@ -1,4 +1,5 @@
1
1
  import type { Logger, SafeDatabase } from "@checkstack/backend-api";
2
+ import { decrypt } from "@checkstack/backend-api";
2
3
  import type { QueueManager } from "@checkstack/queue-api";
3
4
  import type { InternalEntityKindRegistry } from "../kind-registry";
4
5
  import type { SecretStore } from "../secret-resolver";
@@ -104,12 +105,25 @@ async function runSyncForProvider(params: {
104
105
  const scraper =
105
106
  provider.type === "github" ? githubScraper : gitlabScraper;
106
107
 
108
+ // Decrypt authToken from database (stored encrypted via DynamicForm secret field)
109
+ let authToken: string | undefined;
110
+ if (provider.authToken) {
111
+ try {
112
+ authToken = decrypt(provider.authToken);
113
+ } catch (error) {
114
+ logger.error(
115
+ `GitOps sync: failed to decrypt authToken for provider ${providerId}: ${error}`,
116
+ );
117
+ throw new Error(`Failed to decrypt auth token for provider ${providerId}`);
118
+ }
119
+ }
120
+
107
121
  return reconcileProvider({
108
122
  providerId: provider.id,
109
123
  providerType: provider.type,
110
124
  target: provider.target,
111
125
  pathPattern: provider.pathPattern,
112
- authToken: provider.authToken ?? "",
126
+ authToken,
113
127
  baseUrl: provider.baseUrl ?? undefined,
114
128
  deletionPolicy: provider.deletionPolicy,
115
129
  db,