@danielblomma/cortex-mcp 1.7.1 → 2.0.2

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.
Files changed (79) hide show
  1. package/bin/cortex.mjs +679 -32
  2. package/bin/style.mjs +349 -0
  3. package/package.json +4 -3
  4. package/scaffold/mcp/package-lock.json +834 -671
  5. package/scaffold/mcp/package.json +1 -1
  6. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  7. package/scaffold/mcp/src/cli/govern.ts +987 -0
  8. package/scaffold/mcp/src/cli/run.ts +306 -0
  9. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  10. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  11. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  12. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  13. package/scaffold/mcp/src/core/config.ts +329 -0
  14. package/scaffold/mcp/src/core/index.ts +34 -0
  15. package/scaffold/mcp/src/core/license.ts +202 -0
  16. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  17. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  18. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  19. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  20. package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
  21. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  22. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  23. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  25. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  26. package/scaffold/mcp/src/daemon/client.ts +155 -0
  27. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  29. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  30. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  31. package/scaffold/mcp/src/daemon/main.ts +300 -0
  32. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  33. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  34. package/scaffold/mcp/src/daemon/server.ts +227 -0
  35. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  36. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  37. package/scaffold/mcp/src/embed.ts +1 -1
  38. package/scaffold/mcp/src/embeddings.ts +1 -1
  39. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  40. package/scaffold/mcp/src/enterprise/index.ts +415 -0
  41. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  42. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  43. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
  44. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
  46. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  47. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  48. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  49. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  50. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  51. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  52. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  53. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  54. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  55. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  56. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  57. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  58. package/scaffold/mcp/src/plugin.ts +150 -0
  59. package/scaffold/mcp/src/server.ts +218 -7
  60. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  61. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  62. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  63. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  64. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  65. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  66. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  67. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  68. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  69. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  70. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  71. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  72. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  73. package/scaffold/mcp/tests/run.test.mjs +109 -0
  74. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  75. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  76. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  77. package/scaffold/scripts/bootstrap.sh +0 -11
  78. package/scaffold/scripts/doctor.sh +24 -4
  79. package/types.js +5 -0
@@ -0,0 +1,68 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ export type AuditEvidenceLevel = "required" | "diagnostic";
5
+ export type AuditEventType =
6
+ | "tool_call"
7
+ | "workflow_transition"
8
+ | "review_result"
9
+ | "policy_sync"
10
+ | "approval"
11
+ | "session"
12
+ | "security_scan";
13
+
14
+ export type AuditEntry = {
15
+ timestamp: string;
16
+ tool: string;
17
+ input: Record<string, unknown>;
18
+ result_count: number;
19
+ entities_returned: string[];
20
+ rules_applied: string[];
21
+ duration_ms: number;
22
+ status?: "success" | "error";
23
+ error?: string;
24
+ event_type?: AuditEventType;
25
+ evidence_level?: AuditEvidenceLevel;
26
+ resource_type?: string;
27
+ resource_id?: string;
28
+ repo?: string;
29
+ instance_id?: string;
30
+ session_id?: string;
31
+ metadata?: Record<string, unknown>;
32
+ };
33
+
34
+ type AuditWriterOptions = {
35
+ onEntry?: (entry: AuditEntry) => void;
36
+ };
37
+
38
+ export class AuditWriter {
39
+ private readonly auditDir: string;
40
+ private readonly onEntry: ((entry: AuditEntry) => void) | null;
41
+ private initialized = false;
42
+
43
+ constructor(contextDir: string, options: AuditWriterOptions = {}) {
44
+ this.auditDir = join(contextDir, "audit");
45
+ this.onEntry = options.onEntry ?? null;
46
+ }
47
+
48
+ log(entry: AuditEntry): void {
49
+ const date = entry.timestamp.slice(0, 10); // YYYY-MM-DD
50
+ const filePath = join(this.auditDir, `${date}.jsonl`);
51
+ const line = JSON.stringify(entry) + "\n";
52
+
53
+ this.onEntry?.(entry);
54
+
55
+ // Fire-and-forget async write — don't block the tool handler
56
+ this.writeAsync(filePath, line).catch(() => {
57
+ process.stderr.write("[cortex-enterprise] Failed to write audit entry\n");
58
+ });
59
+ }
60
+
61
+ private async writeAsync(filePath: string, line: string): Promise<void> {
62
+ if (!this.initialized) {
63
+ await mkdir(this.auditDir, { recursive: true });
64
+ this.initialized = true;
65
+ }
66
+ await appendFile(filePath, line);
67
+ }
68
+ }
@@ -0,0 +1,329 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Role, RBACConfig } from "./rbac/check.js";
4
+ import { parseValidatorsConfig, type ValidatorsConfig } from "./validators/config.js";
5
+
6
+ export type TelemetryConfig = {
7
+ enabled: boolean;
8
+ endpoint: string;
9
+ api_key: string;
10
+ interval_minutes: number;
11
+ };
12
+
13
+ export type EnterpriseServiceConfig = {
14
+ endpoint: string;
15
+ api_key: string;
16
+ base_url: string;
17
+ };
18
+
19
+ export type EnterpriseActivation =
20
+ | { active: true; reason: "active"; endpoint: string; api_key: string }
21
+ | {
22
+ active: false;
23
+ reason:
24
+ | "missing_api_key"
25
+ | "missing_endpoint"
26
+ | "invalid_api_key_format"
27
+ | "invalid_endpoint_format";
28
+ endpoint: string | null;
29
+ api_key: string | null;
30
+ };
31
+
32
+ export type AuditConfig = {
33
+ enabled: boolean;
34
+ retention_days: number;
35
+ };
36
+
37
+ export type PolicyConfig = {
38
+ enabled: boolean;
39
+ endpoint: string;
40
+ api_key: string;
41
+ sync_interval_minutes: number;
42
+ };
43
+
44
+ export type ComplianceFramework =
45
+ | "iso27001"
46
+ | "iso42001"
47
+ | "soc2"
48
+ | "gdpr"
49
+ | "ai_act"
50
+ | "nis2";
51
+
52
+ export type ComplianceConfig = {
53
+ frameworks: ComplianceFramework[];
54
+ eu_addons: boolean;
55
+ };
56
+
57
+ export type GovernMode = "off" | "advisory" | "enforced";
58
+ export type GovernTier = "prevent" | "wrap" | "detect" | "off";
59
+
60
+ export type GovernConfig = {
61
+ mode: GovernMode;
62
+ sync_on_startup: boolean;
63
+ sync_interval_minutes: number;
64
+ tier_claude: GovernTier;
65
+ tier_codex: GovernTier;
66
+ tier_copilot: GovernTier;
67
+ detect_ungoverned: boolean;
68
+ govern_endpoint: string;
69
+ };
70
+
71
+ export type EnterpriseConfig = {
72
+ enterprise: EnterpriseServiceConfig;
73
+ telemetry: TelemetryConfig;
74
+ audit: AuditConfig;
75
+ policy: PolicyConfig;
76
+ rbac: RBACConfig;
77
+ validators: ValidatorsConfig;
78
+ compliance: ComplianceConfig;
79
+ govern: GovernConfig;
80
+ };
81
+
82
+ const DEFAULT_FRAMEWORKS: ComplianceFramework[] = ["iso27001", "iso42001", "soc2"];
83
+ const EU_ADDON_FRAMEWORKS: ComplianceFramework[] = ["gdpr", "ai_act", "nis2"];
84
+ const VALID_FRAMEWORKS = new Set<ComplianceFramework>([
85
+ ...DEFAULT_FRAMEWORKS,
86
+ ...EU_ADDON_FRAMEWORKS,
87
+ ]);
88
+ const VALID_MODES = new Set<GovernMode>(["off", "advisory", "enforced"]);
89
+ const VALID_TIERS = new Set<GovernTier>(["prevent", "wrap", "detect", "off"]);
90
+
91
+ const DEFAULTS: EnterpriseConfig = {
92
+ enterprise: {
93
+ endpoint: "",
94
+ api_key: "",
95
+ base_url: "",
96
+ },
97
+ telemetry: {
98
+ enabled: false,
99
+ endpoint: "",
100
+ api_key: "",
101
+ interval_minutes: 10,
102
+ },
103
+ audit: {
104
+ enabled: true,
105
+ retention_days: 90,
106
+ },
107
+ policy: {
108
+ enabled: true,
109
+ endpoint: "",
110
+ api_key: "",
111
+ sync_interval_minutes: 240,
112
+ },
113
+ rbac: {
114
+ enabled: false,
115
+ default_role: "developer",
116
+ },
117
+ validators: {},
118
+ compliance: {
119
+ frameworks: [],
120
+ eu_addons: false,
121
+ },
122
+ govern: {
123
+ mode: "off",
124
+ sync_on_startup: true,
125
+ sync_interval_minutes: 60,
126
+ tier_claude: "prevent",
127
+ tier_codex: "prevent",
128
+ tier_copilot: "wrap",
129
+ detect_ungoverned: true,
130
+ govern_endpoint: "",
131
+ },
132
+ };
133
+
134
+ const VALID_ROLES: Role[] = ["admin", "developer", "readonly"];
135
+
136
+ function isValidRole(value: string | undefined): value is Role {
137
+ return VALID_ROLES.includes(value as Role);
138
+ }
139
+
140
+ function stripInlineComment(value: string): string {
141
+ // Strip # comments that aren't inside quotes
142
+ const singleMatch = value.match(/^'([^']*)'(\s*#.*)?$/);
143
+ if (singleMatch) return singleMatch[1];
144
+ const doubleMatch = value.match(/^"([^"]*)"(\s*#.*)?$/);
145
+ if (doubleMatch) return doubleMatch[1];
146
+ // Unquoted: strip from first # preceded by whitespace
147
+ const commentIdx = value.search(/\s+#/);
148
+ return commentIdx >= 0 ? value.slice(0, commentIdx).trimEnd() : value;
149
+ }
150
+
151
+ function parseInlineList(value: string): string[] | null {
152
+ const trimmed = value.trim();
153
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
154
+ const inner = trimmed.slice(1, -1).trim();
155
+ if (!inner) return [];
156
+ return inner
157
+ .split(",")
158
+ .map((part) => part.trim().replace(/^["']|["']$/g, ""))
159
+ .filter(Boolean);
160
+ }
161
+
162
+ function parseSimpleYaml(text: string): Record<string, string> {
163
+ const result: Record<string, string> = {};
164
+ let section = "";
165
+ for (const line of text.split("\n")) {
166
+ const trimmed = line.trimEnd();
167
+ if (!trimmed || trimmed.startsWith("#")) continue;
168
+
169
+ const sectionMatch = trimmed.match(/^(\w+):\s*$/);
170
+ if (sectionMatch) {
171
+ section = sectionMatch[1];
172
+ continue;
173
+ }
174
+
175
+ const kvMatch = trimmed.match(/^\s+(\w+):\s*(.+?)\s*$/);
176
+ if (kvMatch && section) {
177
+ result[`${section}.${kvMatch[1]}`] = stripInlineComment(kvMatch[2]);
178
+ continue;
179
+ }
180
+
181
+ const topMatch = trimmed.match(/^(\w+):\s*(.+?)\s*$/);
182
+ if (topMatch) {
183
+ result[topMatch[1]] = stripInlineComment(topMatch[2]);
184
+ }
185
+ }
186
+ return result;
187
+ }
188
+
189
+ function isLikelyApiKey(value: string): boolean {
190
+ return /^(?:ctx|ent)_[A-Za-z0-9._-]{8,}$/.test(value);
191
+ }
192
+
193
+ function isLikelyHttpUrl(value: string): boolean {
194
+ try {
195
+ const parsed = new URL(value);
196
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ export function resolveEnterpriseActivation(config: EnterpriseConfig): EnterpriseActivation {
203
+ const apiKey = config.enterprise.api_key.trim();
204
+ const endpoint = config.enterprise.endpoint.trim();
205
+
206
+ if (!apiKey) {
207
+ return { active: false, reason: "missing_api_key", api_key: null, endpoint: endpoint || null };
208
+ }
209
+
210
+ if (!endpoint) {
211
+ return { active: false, reason: "missing_endpoint", api_key: apiKey, endpoint: null };
212
+ }
213
+
214
+ if (!isLikelyApiKey(apiKey)) {
215
+ return { active: false, reason: "invalid_api_key_format", api_key: apiKey, endpoint };
216
+ }
217
+
218
+ if (!isLikelyHttpUrl(endpoint)) {
219
+ return { active: false, reason: "invalid_endpoint_format", api_key: apiKey, endpoint };
220
+ }
221
+
222
+ return { active: true, reason: "active", api_key: apiKey, endpoint };
223
+ }
224
+
225
+ function deriveEndpoint(baseUrl: string, suffix: string): string {
226
+ if (!baseUrl) return "";
227
+ return baseUrl.replace(/\/$/, "") + suffix;
228
+ }
229
+
230
+ function isValidTier(value: string | undefined): value is GovernTier {
231
+ return value !== undefined && VALID_TIERS.has(value as GovernTier);
232
+ }
233
+
234
+ function isValidMode(value: string | undefined): value is GovernMode {
235
+ return value !== undefined && VALID_MODES.has(value as GovernMode);
236
+ }
237
+
238
+ function resolveFrameworks(rawList: string | undefined, euAddons: boolean): ComplianceFramework[] {
239
+ const parsed = rawList ? parseInlineList(rawList) : null;
240
+ const base = parsed && parsed.length > 0
241
+ ? parsed.filter((f): f is ComplianceFramework => VALID_FRAMEWORKS.has(f as ComplianceFramework))
242
+ : DEFAULT_FRAMEWORKS.slice();
243
+ if (!euAddons) return base;
244
+ const merged = new Set<ComplianceFramework>(base);
245
+ for (const f of EU_ADDON_FRAMEWORKS) merged.add(f);
246
+ return Array.from(merged);
247
+ }
248
+
249
+ export function loadEnterpriseConfig(contextDir: string): EnterpriseConfig {
250
+ let raw: string;
251
+ try {
252
+ raw = readFileSync(join(contextDir, "enterprise.yml"), "utf8");
253
+ } catch {
254
+ try {
255
+ raw = readFileSync(join(contextDir, "enterprise.yaml"), "utf8");
256
+ } catch {
257
+ return DEFAULTS;
258
+ }
259
+ }
260
+
261
+ const fields = parseSimpleYaml(raw);
262
+ const enterpriseApiKey = fields["enterprise.api_key"] ?? DEFAULTS.enterprise.api_key;
263
+ const baseUrl = (fields["enterprise.base_url"] ?? fields["enterprise.endpoint"] ?? "").replace(/\/$/, "");
264
+ const enterpriseEndpoint = fields["enterprise.endpoint"] ?? baseUrl;
265
+
266
+ const telemetryEndpoint =
267
+ fields["enterprise.endpoint_telemetry"] ??
268
+ fields["telemetry.endpoint"] ??
269
+ deriveEndpoint(baseUrl, "/api/v1/telemetry/push");
270
+ const telemetryApiKey = fields["telemetry.api_key"] ?? enterpriseApiKey;
271
+ const policyEndpoint =
272
+ fields["enterprise.endpoint_policy"] ??
273
+ fields["policy.endpoint"] ??
274
+ deriveEndpoint(baseUrl, "/api/v1/policies/sync");
275
+ const policyApiKey = fields["policy.api_key"] ?? enterpriseApiKey;
276
+ const governEndpoint =
277
+ fields["enterprise.endpoint_govern"] ??
278
+ deriveEndpoint(baseUrl, "/api/v1/govern");
279
+
280
+ const euAddons = fields["compliance.eu_addons"] === "true";
281
+ const frameworks = resolveFrameworks(fields["compliance.frameworks"], euAddons);
282
+
283
+ const governMode = isValidMode(fields["govern.mode"]) ? fields["govern.mode"] : DEFAULTS.govern.mode;
284
+
285
+ return {
286
+ enterprise: {
287
+ endpoint: enterpriseEndpoint,
288
+ api_key: enterpriseApiKey,
289
+ base_url: baseUrl,
290
+ },
291
+ telemetry: {
292
+ endpoint: telemetryEndpoint,
293
+ api_key: telemetryApiKey,
294
+ enabled: fields["telemetry.enabled"] !== undefined
295
+ ? fields["telemetry.enabled"] === "true"
296
+ : !!(telemetryEndpoint && telemetryApiKey),
297
+ interval_minutes: parseInt(fields["telemetry.interval_minutes"] ?? "", 10) || DEFAULTS.telemetry.interval_minutes,
298
+ },
299
+ audit: {
300
+ enabled: fields["audit.enabled"] !== "false",
301
+ retention_days: parseInt(fields["audit.retention_days"] ?? "", 10) || DEFAULTS.audit.retention_days,
302
+ },
303
+ policy: {
304
+ enabled: fields["policy.enabled"] !== "false",
305
+ endpoint: policyEndpoint,
306
+ api_key: policyApiKey,
307
+ sync_interval_minutes: parseInt(fields["policy.sync_interval_minutes"] ?? "", 10) || DEFAULTS.policy.sync_interval_minutes,
308
+ },
309
+ rbac: {
310
+ enabled: fields["rbac.enabled"] === "true",
311
+ default_role: isValidRole(fields["rbac.default_role"]) ? fields["rbac.default_role"] : DEFAULTS.rbac.default_role,
312
+ },
313
+ validators: parseValidatorsConfig(fields),
314
+ compliance: {
315
+ frameworks,
316
+ eu_addons: euAddons,
317
+ },
318
+ govern: {
319
+ mode: governMode,
320
+ sync_on_startup: fields["govern.sync_on_startup"] !== "false",
321
+ sync_interval_minutes: parseInt(fields["govern.sync_interval_minutes"] ?? "", 10) || DEFAULTS.govern.sync_interval_minutes,
322
+ tier_claude: isValidTier(fields["govern.tier_claude"]) ? fields["govern.tier_claude"] : DEFAULTS.govern.tier_claude,
323
+ tier_codex: isValidTier(fields["govern.tier_codex"]) ? fields["govern.tier_codex"] : DEFAULTS.govern.tier_codex,
324
+ tier_copilot: isValidTier(fields["govern.tier_copilot"]) ? fields["govern.tier_copilot"] : DEFAULTS.govern.tier_copilot,
325
+ detect_ungoverned: fields["govern.detect_ungoverned"] !== "false",
326
+ govern_endpoint: governEndpoint,
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,34 @@
1
+ // Config
2
+ export { loadEnterpriseConfig, resolveEnterpriseActivation } from "./config.js";
3
+ export type {
4
+ TelemetryConfig,
5
+ AuditConfig,
6
+ PolicyConfig,
7
+ EnterpriseConfig,
8
+ EnterpriseServiceConfig,
9
+ EnterpriseActivation
10
+ } from "./config.js";
11
+
12
+ // Telemetry
13
+ export { TelemetryCollector } from "./telemetry/collector.js";
14
+ export type { TelemetryMetrics } from "./telemetry/collector.js";
15
+
16
+ // Audit
17
+ export { AuditWriter } from "./audit/writer.js";
18
+ export type { AuditEntry } from "./audit/writer.js";
19
+ export { queryAuditLog } from "./audit/query.js";
20
+ export type { AuditQuery } from "./audit/query.js";
21
+
22
+ // RBAC
23
+ export { checkAccess, getAccessDeniedMessage } from "./rbac/check.js";
24
+ export type { Role, RBACConfig } from "./rbac/check.js";
25
+
26
+ // Policy
27
+ export { PolicyStore } from "./policy/store.js";
28
+ export type { OrgPolicy } from "./policy/store.js";
29
+
30
+ // Prompt injection
31
+ export { scanForInjection, sanitizeContent } from "./policy/injection.js";
32
+ export type { ScanResult, InjectionMatch, InjectionCategory } from "./policy/injection.js";
33
+ export { enforceInjectionPolicy, isInjectionDefenseActive, buildViolationPayload } from "./policy/enforce.js";
34
+ export type { EnforcementResult } from "./policy/enforce.js";
@@ -0,0 +1,202 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export type LicenseVerification =
5
+ | {
6
+ valid: true;
7
+ edition: string;
8
+ features: string[];
9
+ expires_at: string;
10
+ max_repos: number;
11
+ verified_at: string;
12
+ source: "remote" | "cache";
13
+ }
14
+ | {
15
+ valid: false;
16
+ reason: string;
17
+ verified_at: string;
18
+ source: "remote" | "cache" | "grace_expired";
19
+ };
20
+
21
+ type CacheEntry = {
22
+ result: LicenseVerification;
23
+ // ISO timestamp for cache freshness window
24
+ cached_at: string;
25
+ };
26
+
27
+ const CACHE_FILE = "license_cache.json";
28
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h fresh
29
+ const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7d grace if endpoint unreachable
30
+ const REQUEST_TIMEOUT_MS = 5000;
31
+
32
+ function cachePath(contextDir: string): string {
33
+ return join(contextDir, "telemetry", CACHE_FILE);
34
+ }
35
+
36
+ function readCache(contextDir: string): CacheEntry | null {
37
+ const path = cachePath(contextDir);
38
+ if (!existsSync(path)) return null;
39
+ try {
40
+ const raw = readFileSync(path, "utf8");
41
+ return JSON.parse(raw) as CacheEntry;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function writeCache(contextDir: string, entry: CacheEntry): void {
48
+ const path = cachePath(contextDir);
49
+ try {
50
+ mkdirSync(join(contextDir, "telemetry"), { recursive: true });
51
+ writeFileSync(path, JSON.stringify(entry, null, 2), "utf8");
52
+ } catch {
53
+ // Cache failures are non-fatal — license check just won't be cached.
54
+ }
55
+ }
56
+
57
+ function deleteCache(contextDir: string): void {
58
+ const path = cachePath(contextDir);
59
+ if (!existsSync(path)) return;
60
+ try {
61
+ unlinkSync(path);
62
+ } catch {
63
+ // ignore — best-effort
64
+ }
65
+ }
66
+
67
+ function ageMs(isoTimestamp: string): number {
68
+ return Date.now() - new Date(isoTimestamp).getTime();
69
+ }
70
+
71
+ async function fetchLicense(
72
+ endpoint: string,
73
+ apiKey: string,
74
+ instanceId: string | undefined,
75
+ clientVersion: string | undefined,
76
+ ): Promise<LicenseVerification | null> {
77
+ const url = `${endpoint.replace(/\/$/, "")}/api/v1/license/verify`;
78
+ const controller = new AbortController();
79
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
80
+
81
+ try {
82
+ const res = await fetch(url, {
83
+ method: "POST",
84
+ headers: {
85
+ "content-type": "application/json",
86
+ authorization: `Bearer ${apiKey}`,
87
+ },
88
+ body: JSON.stringify({
89
+ instance_id: instanceId,
90
+ client_version: clientVersion,
91
+ }),
92
+ signal: controller.signal,
93
+ });
94
+
95
+ if (!res.ok) {
96
+ // 401/429/5xx — treat as transient, surface null so caller can fall back.
97
+ return null;
98
+ }
99
+
100
+ const json = (await res.json()) as Record<string, unknown>;
101
+ const verifiedAt = new Date().toISOString();
102
+
103
+ if (json.valid === true) {
104
+ return {
105
+ valid: true,
106
+ edition: String(json.edition ?? "unknown"),
107
+ features: Array.isArray(json.features) ? json.features.map(String) : [],
108
+ expires_at: String(json.expires_at ?? ""),
109
+ max_repos: typeof json.max_repos === "number" ? json.max_repos : 0,
110
+ verified_at: verifiedAt,
111
+ source: "remote",
112
+ };
113
+ }
114
+
115
+ return {
116
+ valid: false,
117
+ reason: String(json.reason ?? "unknown"),
118
+ verified_at: verifiedAt,
119
+ source: "remote",
120
+ };
121
+ } catch {
122
+ // Network error, timeout, JSON parse error — treat as transient.
123
+ return null;
124
+ } finally {
125
+ clearTimeout(timeout);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Verify the license for the given api_key. Layered fallback:
131
+ * 1. If a positive cache (valid:true) is fresh (<24h) → use cache
132
+ * Negative cache entries are never trusted; if one is encountered
133
+ * it's deleted on the spot so a since-fixed remote can heal.
134
+ * 2. Otherwise try remote endpoint
135
+ * - On valid:true → write positive cache, return result
136
+ * - On valid:false (authoritative fail) → DELETE any positive
137
+ * cache (so a revoked/expired key doesn't keep masquerading as
138
+ * valid past its remote-side fail), return result, do NOT
139
+ * cache the negative.
140
+ * - On transient failure → fall back to positive cache if within
141
+ * grace period (7d). If only a negative cache exists, ignore it.
142
+ * 3. If no usable cache and endpoint unreachable → return invalid
143
+ * (grace_expired).
144
+ *
145
+ * The caller decides what to do based on the result. Typically:
146
+ * - valid:true → activate enterprise hooks
147
+ * - valid:false → community mode (no enterprise)
148
+ */
149
+ export async function verifyLicense(
150
+ contextDir: string,
151
+ endpoint: string,
152
+ apiKey: string,
153
+ options: { instance_id?: string; client_version?: string } = {},
154
+ ): Promise<LicenseVerification> {
155
+ let cached = readCache(contextDir);
156
+
157
+ // Defensive: a previous version of this code wrote negative results
158
+ // into the cache. Refuse to honour them and clean them up so a
159
+ // since-deployed fix on the remote can be observed.
160
+ if (cached && cached.result.valid === false) {
161
+ deleteCache(contextDir);
162
+ cached = null;
163
+ }
164
+
165
+ // Fresh positive cache: skip remote.
166
+ if (cached && cached.result.valid === true && ageMs(cached.cached_at) < CACHE_TTL_MS) {
167
+ return { ...cached.result, source: "cache" };
168
+ }
169
+
170
+ const remote = await fetchLicense(
171
+ endpoint,
172
+ apiKey,
173
+ options.instance_id,
174
+ options.client_version,
175
+ );
176
+
177
+ if (remote) {
178
+ if (remote.valid) {
179
+ writeCache(contextDir, {
180
+ result: remote,
181
+ cached_at: new Date().toISOString(),
182
+ });
183
+ } else {
184
+ // Authoritative fail from remote — drop any stale positive cache
185
+ // so we don't bounce back to "valid" on the next call.
186
+ deleteCache(contextDir);
187
+ }
188
+ return remote;
189
+ }
190
+
191
+ // Remote unreachable. Fall back to positive cache if within grace.
192
+ if (cached && cached.result.valid === true && ageMs(cached.cached_at) < GRACE_PERIOD_MS) {
193
+ return { ...cached.result, source: "cache" };
194
+ }
195
+
196
+ return {
197
+ valid: false,
198
+ reason: "endpoint_unreachable_grace_expired",
199
+ verified_at: new Date().toISOString(),
200
+ source: "grace_expired",
201
+ };
202
+ }