@fulcrum-governance/sdk 0.1.4 → 0.1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +40 -51
  3. package/dist/__tests__/clients.test.js +127 -12
  4. package/dist/clients/agent.js +18 -12
  5. package/dist/clients/approval.js +15 -2
  6. package/dist/clients/budget.js +15 -2
  7. package/dist/clients/checkpoint.js +15 -2
  8. package/dist/clients/envelope.d.ts +5 -4
  9. package/dist/clients/envelope.js +39 -10
  10. package/dist/clients/eventstore.js +15 -2
  11. package/dist/clients/metrics.js +15 -2
  12. package/dist/clients/policy.d.ts +7 -2
  13. package/dist/clients/policy.js +55 -10
  14. package/dist/clients/tenant.js +15 -2
  15. package/dist/index.js +6 -2
  16. package/dist/instrumentation/__tests__/autoGovern.test.js +3 -2
  17. package/dist/instrumentation/__tests__/costReporter.test.d.ts +5 -0
  18. package/dist/instrumentation/__tests__/costReporter.test.js +137 -0
  19. package/dist/instrumentation/autoGovern.js +63 -1
  20. package/dist/instrumentation/costReporter.d.ts +27 -0
  21. package/dist/instrumentation/costReporter.js +69 -0
  22. package/dist/instrumentation/evaluator.d.ts +8 -0
  23. package/dist/instrumentation/evaluator.js +124 -11
  24. package/dist/instrumentation/index.d.ts +3 -1
  25. package/dist/instrumentation/index.js +3 -1
  26. package/dist/instrumentation/types.d.ts +13 -0
  27. package/package.json +11 -4
  28. package/proto/events.proto +1 -1
  29. package/proto/fulcrum/agent/v1/agent_service.proto +1 -1
  30. package/proto/fulcrum/bridge/v1/bridge.proto +1 -1
  31. package/proto/fulcrum/checkpoint/v1/checkpoint_service.proto +1 -1
  32. package/proto/fulcrum/cost/v1/cost_service.proto +1 -1
  33. package/proto/fulcrum/envelope/v1/envelope.proto +1 -1
  34. package/proto/fulcrum/envelope/v1/envelope_service.proto +1 -1
  35. package/proto/fulcrum/eventstore/v1/eventstore.proto +1 -1
  36. package/proto/fulcrum/policy/v1/policy_service.proto +1 -1
  37. package/proto/fulcrum/tenant/v1/tenant_service.proto +1 -1
@@ -20,7 +20,7 @@ class MetricsClient {
20
20
  'Content-Type': 'application/json',
21
21
  };
22
22
  if (this.apiKey) {
23
- headers['Authorization'] = `Bearer ${this.apiKey}`;
23
+ headers['X-API-Key'] = this.apiKey;
24
24
  }
25
25
  const response = await fetch(`${this.baseUrl}${path}`, {
26
26
  method,
@@ -30,7 +30,20 @@ class MetricsClient {
30
30
  });
31
31
  if (!response.ok) {
32
32
  const errorData = (await response.json().catch(() => ({})));
33
- throw new MetricsClientError(errorData.error || `Request failed with status ${response.status}`, response.status);
33
+ const serverMessage = errorData.error || errorData.message || errorData.detail || '';
34
+ let errorMessage;
35
+ if (response.status === 401) {
36
+ errorMessage = this.apiKey
37
+ ? `Authentication failed: ${serverMessage || 'invalid API key'}. Verify your API key is correct and active. Check your keys in the Fulcrum dashboard under Settings > API Keys.`
38
+ : 'Authentication required: no API key configured. Pass apiKey when creating the client. Generate a key in the Fulcrum dashboard under Settings > API Keys.';
39
+ }
40
+ else if (response.status === 403) {
41
+ errorMessage = `Insufficient permissions: ${serverMessage || 'access denied'}. Your API key does not have the required scopes. Update key permissions in the Fulcrum dashboard under Settings > API Keys.`;
42
+ }
43
+ else {
44
+ errorMessage = serverMessage || `Request failed with status ${response.status}`;
45
+ }
46
+ throw new MetricsClientError(errorMessage, response.status);
34
47
  }
35
48
  return (await response.json());
36
49
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
- * PolicyClient - REST-based client for policy CRUD operations
2
+ * PolicyClient - REST-based client for policy access
3
3
  *
4
- * Works with the Fulcrum Dashboard REST API for managing policies.
4
+ * Works with the Fulcrum hosted REST API for listing policies. Write methods are
5
+ * retained for compatible admin/self-hosted API surfaces and may not be enabled on
6
+ * the public API-key gateway.
5
7
  * For high-performance policy evaluation, use FulcrumClient.evaluatePolicy() instead.
6
8
  */
7
9
  export interface Policy {
@@ -46,8 +48,11 @@ export declare class PolicyClient {
46
48
  private baseUrl;
47
49
  private apiKey?;
48
50
  private timeoutMs;
51
+ private readonly policiesPath;
49
52
  constructor(options: PolicyClientOptions);
50
53
  private request;
54
+ private normalizeStatus;
55
+ private normalizePolicy;
51
56
  /**
52
57
  * List all policies, optionally filtered
53
58
  */
@@ -1,14 +1,17 @@
1
1
  "use strict";
2
2
  /**
3
- * PolicyClient - REST-based client for policy CRUD operations
3
+ * PolicyClient - REST-based client for policy access
4
4
  *
5
- * Works with the Fulcrum Dashboard REST API for managing policies.
5
+ * Works with the Fulcrum hosted REST API for listing policies. Write methods are
6
+ * retained for compatible admin/self-hosted API surfaces and may not be enabled on
7
+ * the public API-key gateway.
6
8
  * For high-performance policy evaluation, use FulcrumClient.evaluatePolicy() instead.
7
9
  */
8
10
  Object.defineProperty(exports, "__esModule", { value: true });
9
11
  exports.PolicyClientError = exports.PolicyClient = void 0;
10
12
  class PolicyClient {
11
13
  constructor(options) {
14
+ this.policiesPath = '/api/v1/policies';
12
15
  this.baseUrl = options.baseUrl.replace(/\/$/, '');
13
16
  this.apiKey = options.apiKey;
14
17
  this.timeoutMs = options.timeoutMs || 5000;
@@ -21,7 +24,7 @@ class PolicyClient {
21
24
  'Content-Type': 'application/json',
22
25
  };
23
26
  if (this.apiKey) {
24
- headers['Authorization'] = `Bearer ${this.apiKey}`;
27
+ headers['X-API-Key'] = this.apiKey;
25
28
  }
26
29
  const response = await fetch(`${this.baseUrl}${path}`, {
27
30
  method,
@@ -31,7 +34,20 @@ class PolicyClient {
31
34
  });
32
35
  if (!response.ok) {
33
36
  const errorData = (await response.json().catch(() => ({})));
34
- throw new PolicyClientError(errorData.error || `Request failed with status ${response.status}`, response.status);
37
+ const serverMessage = errorData.error || errorData.message || errorData.detail || '';
38
+ let errorMessage;
39
+ if (response.status === 401) {
40
+ errorMessage = this.apiKey
41
+ ? `Authentication failed: ${serverMessage || 'invalid API key'}. Verify your API key is correct and active. Check your keys in the Fulcrum dashboard under Settings > API Keys.`
42
+ : 'Authentication required: no API key configured. Pass apiKey when creating the client. Generate a key in the Fulcrum dashboard under Settings > API Keys.';
43
+ }
44
+ else if (response.status === 403) {
45
+ errorMessage = `Insufficient permissions: ${serverMessage || 'access denied'}. Your API key does not have the required scopes. Update key permissions in the Fulcrum dashboard under Settings > API Keys.`;
46
+ }
47
+ else {
48
+ errorMessage = serverMessage || `Request failed with status ${response.status}`;
49
+ }
50
+ throw new PolicyClientError(errorMessage, response.status);
35
51
  }
36
52
  return (await response.json());
37
53
  }
@@ -39,6 +55,30 @@ class PolicyClient {
39
55
  clearTimeout(timeout);
40
56
  }
41
57
  }
58
+ normalizeStatus(status) {
59
+ return status?.replace(/^POLICY_STATUS_/, '') || 'ACTIVE';
60
+ }
61
+ normalizePolicy(policy) {
62
+ const status = this.normalizeStatus(policy.status);
63
+ const normalized = {
64
+ id: policy.id || policy.policy_id || '',
65
+ name: policy.name || '',
66
+ policy_type: policy.policy_type || 'governance',
67
+ rules: policy.rules || {},
68
+ enabled: policy.enabled ?? status === 'ACTIVE',
69
+ priority: policy.priority ?? 0,
70
+ status,
71
+ created_at: policy.created_at || '',
72
+ updated_at: policy.updated_at || '',
73
+ };
74
+ if (policy.violation_count !== undefined) {
75
+ normalized.violation_count = policy.violation_count;
76
+ }
77
+ if (policy.total_evaluations !== undefined) {
78
+ normalized.total_evaluations = policy.total_evaluations;
79
+ }
80
+ return normalized;
81
+ }
42
82
  /**
43
83
  * List all policies, optionally filtered
44
84
  */
@@ -51,32 +91,37 @@ class PolicyClient {
51
91
  if (filter?.enabled !== undefined)
52
92
  params.append('enabled', String(filter.enabled));
53
93
  const query = params.toString();
54
- const path = query ? `/api/policies?${query}` : '/api/policies';
55
- return this.request('GET', path);
94
+ const path = query ? `${this.policiesPath}?${query}` : this.policiesPath;
95
+ const response = await this.request('GET', path);
96
+ const policies = Array.isArray(response) ? response : response.policies || [];
97
+ return policies.map((policy) => this.normalizePolicy(policy));
56
98
  }
57
99
  /**
58
100
  * Get a single policy by ID
59
101
  */
60
102
  async get(id) {
61
- return this.request('GET', `/api/policies/${id}`);
103
+ const response = await this.request('GET', `${this.policiesPath}/${id}`);
104
+ return this.normalizePolicy(response);
62
105
  }
63
106
  /**
64
107
  * Create a new policy
65
108
  */
66
109
  async create(policy) {
67
- return this.request('POST', '/api/policies', policy);
110
+ const response = await this.request('POST', this.policiesPath, policy);
111
+ return this.normalizePolicy(response);
68
112
  }
69
113
  /**
70
114
  * Update an existing policy
71
115
  */
72
116
  async update(id, updates) {
73
- return this.request('PATCH', `/api/policies/${id}`, updates);
117
+ const response = await this.request('PATCH', `${this.policiesPath}/${id}`, updates);
118
+ return this.normalizePolicy(response);
74
119
  }
75
120
  /**
76
121
  * Delete a policy
77
122
  */
78
123
  async delete(id) {
79
- await this.request('DELETE', `/api/policies/${id}`);
124
+ await this.request('DELETE', `${this.policiesPath}/${id}`);
80
125
  }
81
126
  /**
82
127
  * Toggle policy enabled state
@@ -43,8 +43,21 @@ class TenantClient {
43
43
  signal: controller.signal,
44
44
  });
45
45
  if (!response.ok) {
46
- const error = await response.json().catch(() => ({}));
47
- throw new TenantClientError(error.message || `Request failed with status ${response.status}`, response.status, error);
46
+ const errorData = await response.json().catch(() => ({}));
47
+ const serverMessage = errorData.error || errorData.message || errorData.detail || '';
48
+ let errorMessage;
49
+ if (response.status === 401) {
50
+ errorMessage = this.apiKey
51
+ ? `Authentication failed: ${serverMessage || 'invalid API key'}. Verify your API key is correct and active. Check your keys in the Fulcrum dashboard under Settings > API Keys.`
52
+ : 'Authentication required: no API key configured. Pass apiKey when creating the client. Generate a key in the Fulcrum dashboard under Settings > API Keys.';
53
+ }
54
+ else if (response.status === 403) {
55
+ errorMessage = `Insufficient permissions: ${serverMessage || 'access denied'}. Your API key does not have the required scopes. Update key permissions in the Fulcrum dashboard under Settings > API Keys.`;
56
+ }
57
+ else {
58
+ errorMessage = serverMessage || `Request failed with status ${response.status}`;
59
+ }
60
+ throw new TenantClientError(errorMessage, response.status, errorData);
48
61
  }
49
62
  // For DELETE requests, return empty object if no content
50
63
  if (response.status === 204 || response.headers.get('content-length') === '0') {
package/dist/index.js CHANGED
@@ -57,14 +57,18 @@ class FulcrumClient {
57
57
  oneofs: true,
58
58
  includeDirs: [protoPath]
59
59
  };
60
+ // Use TLS for port 443 (production), insecure for local dev
61
+ const channelCreds = this.host.endsWith(':443')
62
+ ? grpc.credentials.createSsl()
63
+ : grpc.credentials.createInsecure();
60
64
  // Load policy service
61
65
  const policyProto = protoLoader.loadSync(path.join(protoPath, 'fulcrum/policy/v1/policy_service.proto'), protoOptions);
62
66
  const policyPackage = grpc.loadPackageDefinition(policyProto);
63
- this.policyClient = new policyPackage.fulcrum.policy.v1.PolicyService(this.host, grpc.credentials.createInsecure());
67
+ this.policyClient = new policyPackage.fulcrum.policy.v1.PolicyService(this.host, channelCreds);
64
68
  // Load envelope service
65
69
  const envelopeProto = protoLoader.loadSync(path.join(protoPath, 'fulcrum/envelope/v1/envelope_service.proto'), protoOptions);
66
70
  const envelopePackage = grpc.loadPackageDefinition(envelopeProto);
67
- this.envelopeClient = new envelopePackage.fulcrum.envelope.v1.EnvelopeService(this.host, grpc.credentials.createInsecure());
71
+ this.envelopeClient = new envelopePackage.fulcrum.envelope.v1.EnvelopeService(this.host, channelCreds);
68
72
  }
69
73
  getMetadata() {
70
74
  const metadata = new grpc.Metadata();
@@ -394,8 +394,9 @@ describe('autoGovern - fetch patching integration', () => {
394
394
  },
395
395
  ]);
396
396
  await expect(globalThis.fetch('https://example.com/api')).rejects.toThrow(/blocked by Fulcrum policy/);
397
- // Original fetch should not have been called
398
- expect(mockFetch).not.toHaveBeenCalled();
397
+ // Original fetch should not have been called for the blocked URL.
398
+ // (The SDK may call fetch internally for policy sync — that's expected.)
399
+ expect(mockFetch).not.toHaveBeenCalledWith('https://example.com/api', expect.anything());
399
400
  });
400
401
  it('should allow fetch when policy allows', async () => {
401
402
  // Mock fetch
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Unit tests for CostReporter.
3
+ * Tests cover: queueing, flushing, batch limits, no-key behavior, error resilience.
4
+ */
5
+ export {};
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ /**
3
+ * Unit tests for CostReporter.
4
+ * Tests cover: queueing, flushing, batch limits, no-key behavior, error resilience.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const costReporter_1 = require("../costReporter");
8
+ describe('CostReporter', () => {
9
+ let reporter;
10
+ let fetchSpy;
11
+ beforeEach(() => {
12
+ fetchSpy = jest.fn().mockResolvedValue({
13
+ ok: true,
14
+ json: async () => ({ status: 'accepted' }),
15
+ });
16
+ reporter = new costReporter_1.CostReporter({
17
+ serverUrl: 'https://api.fulcrumlayer.io',
18
+ apiKey: 'test-key',
19
+ rawFetch: fetchSpy,
20
+ batchSize: 10,
21
+ flushIntervalMs: 60000, // long interval to avoid auto-flush in tests
22
+ });
23
+ });
24
+ afterEach(() => {
25
+ reporter.stop();
26
+ });
27
+ it('queues cost events', () => {
28
+ reporter.report({
29
+ event_type: 'llm',
30
+ tool_name: 'llm:api.openai.com',
31
+ cost_usd: 0.0023,
32
+ tokens_in: 500,
33
+ tokens_out: 200,
34
+ model_id: 'gpt-4o-mini',
35
+ });
36
+ expect(reporter.queueSize).toBe(1);
37
+ });
38
+ it('flushes cost events to /api/v1/costs/report', async () => {
39
+ reporter.report({
40
+ event_type: 'llm',
41
+ tool_name: 'llm:api.openai.com',
42
+ cost_usd: 0.005,
43
+ tokens_in: 1000,
44
+ tokens_out: 400,
45
+ model_id: 'gpt-4o',
46
+ });
47
+ await reporter.flush();
48
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
49
+ const [url, init] = fetchSpy.mock.calls[0];
50
+ expect(url).toBe('https://api.fulcrumlayer.io/api/v1/costs/report');
51
+ expect(init.method).toBe('POST');
52
+ expect(init.headers['X-API-Key']).toBe('test-key');
53
+ const body = JSON.parse(init.body);
54
+ expect(body.event_type).toBe('llm');
55
+ expect(body.cost_usd).toBe(0.005);
56
+ expect(body.tokens_in).toBe(1000);
57
+ expect(body.model_id).toBe('gpt-4o');
58
+ expect(reporter.queueSize).toBe(0);
59
+ });
60
+ it('respects batch size limit', async () => {
61
+ const smallReporter = new costReporter_1.CostReporter({
62
+ serverUrl: 'https://api.fulcrumlayer.io',
63
+ apiKey: 'test-key',
64
+ rawFetch: fetchSpy,
65
+ batchSize: 2,
66
+ flushIntervalMs: 60000,
67
+ });
68
+ for (let i = 0; i < 5; i++) {
69
+ smallReporter.report({
70
+ event_type: 'tool',
71
+ tool_name: `tool-${i}`,
72
+ cost_usd: 0.01,
73
+ });
74
+ }
75
+ await smallReporter.flush();
76
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
77
+ expect(smallReporter.queueSize).toBe(3);
78
+ smallReporter.stop();
79
+ });
80
+ it('drops events when no API key', () => {
81
+ const noKeyReporter = new costReporter_1.CostReporter({
82
+ serverUrl: 'https://api.fulcrumlayer.io',
83
+ apiKey: '',
84
+ rawFetch: fetchSpy,
85
+ batchSize: 10,
86
+ flushIntervalMs: 60000,
87
+ });
88
+ noKeyReporter.report({
89
+ event_type: 'llm',
90
+ tool_name: 'test',
91
+ cost_usd: 0.001,
92
+ });
93
+ expect(noKeyReporter.queueSize).toBe(0);
94
+ noKeyReporter.stop();
95
+ });
96
+ it('fails silently on network errors', async () => {
97
+ fetchSpy.mockRejectedValueOnce(new Error('connection refused'));
98
+ reporter.report({
99
+ event_type: 'llm',
100
+ tool_name: 'test',
101
+ cost_usd: 0.001,
102
+ });
103
+ await reporter.flush();
104
+ expect(reporter.queueSize).toBe(0);
105
+ });
106
+ it('enforces maximum queue size of 1000', () => {
107
+ for (let i = 0; i < 1005; i++) {
108
+ reporter.report({
109
+ event_type: 'tool',
110
+ tool_name: `tool-${i}`,
111
+ cost_usd: 0.001,
112
+ });
113
+ }
114
+ expect(reporter.queueSize).toBe(1000);
115
+ });
116
+ it('strips trailing slash from server URL', async () => {
117
+ const slashReporter = new costReporter_1.CostReporter({
118
+ serverUrl: 'https://api.fulcrumlayer.io/',
119
+ apiKey: 'test-key',
120
+ rawFetch: fetchSpy,
121
+ flushIntervalMs: 60000,
122
+ });
123
+ slashReporter.report({
124
+ event_type: 'llm',
125
+ tool_name: 'test',
126
+ cost_usd: 0.001,
127
+ });
128
+ await slashReporter.flush();
129
+ const [url] = fetchSpy.mock.calls[0];
130
+ expect(url).toBe('https://api.fulcrumlayer.io/api/v1/costs/report');
131
+ slashReporter.stop();
132
+ });
133
+ it('does nothing on flush when queue is empty', async () => {
134
+ await reporter.flush();
135
+ expect(fetchSpy).not.toHaveBeenCalled();
136
+ });
137
+ });
@@ -16,6 +16,7 @@ exports.getMetrics = getMetrics;
16
16
  exports.evaluateCall = evaluateCall;
17
17
  const types_1 = require("./types");
18
18
  const evaluator_1 = require("./evaluator");
19
+ const costReporter_1 = require("./costReporter");
19
20
  // Global state
20
21
  let evaluator = null;
21
22
  let config = null;
@@ -27,6 +28,7 @@ const metrics = {
27
28
  allowedCalls: 0,
28
29
  patchFailures: 0,
29
30
  };
31
+ let costReporter = null;
30
32
  /**
31
33
  * Activate Fulcrum governance for TypeScript/Node.js.
32
34
  *
@@ -59,6 +61,14 @@ function activate(userConfig = {}) {
59
61
  // Create and start evaluator
60
62
  evaluator = new evaluator_1.PolicyEvaluator(config);
61
63
  evaluator.start();
64
+ // Create cost reporter (capture rawFetch before patching)
65
+ costReporter = new costReporter_1.CostReporter({
66
+ serverUrl: config.serverUrl,
67
+ apiKey: config.apiKey,
68
+ rawFetch: globalThis.fetch,
69
+ batchSize: 10,
70
+ flushIntervalMs: 5000,
71
+ });
62
72
  // Apply patches
63
73
  applyPatches();
64
74
  active = true;
@@ -78,6 +88,11 @@ function deactivate() {
78
88
  evaluator.stop();
79
89
  evaluator = null;
80
90
  }
91
+ // Stop cost reporter
92
+ if (costReporter) {
93
+ costReporter.stop();
94
+ costReporter = null;
95
+ }
81
96
  // Restore originals
82
97
  restoreOriginals();
83
98
  active = false;
@@ -174,7 +189,12 @@ function patchFetch() {
174
189
  if (decision.action === 'deny') {
175
190
  throw new Error(`HTTP request to '${url}' blocked by Fulcrum policy: ${decision.reason}`);
176
191
  }
177
- return originalFetch(input, init);
192
+ const response = await originalFetch(input, init);
193
+ // Extract cost from LLM API responses (best-effort)
194
+ if (costReporter && response.ok && typeof response.clone === 'function') {
195
+ maybeReportCost(url, response.clone());
196
+ }
197
+ return response;
178
198
  };
179
199
  console.debug('[fulcrum] Patched global fetch');
180
200
  }
@@ -282,6 +302,48 @@ function patchChildProcess() {
282
302
  recordMetric('patchFailures');
283
303
  }
284
304
  }
305
+ const LLM_PRICING = {
306
+ 'api.openai.com': {
307
+ 'gpt-4o-mini': [0.15, 0.60],
308
+ 'gpt-4o': [2.50, 10.00],
309
+ 'gpt-4-turbo': [10.00, 30.00],
310
+ },
311
+ 'api.anthropic.com': {
312
+ 'claude-sonnet-4-20250514': [3.00, 15.00],
313
+ 'claude-haiku-4-5-20251001': [0.80, 4.00],
314
+ },
315
+ };
316
+ async function maybeReportCost(url, response) {
317
+ try {
318
+ const host = new URL(url).host;
319
+ const pricing = LLM_PRICING[host];
320
+ if (!pricing)
321
+ return;
322
+ const data = await response.json();
323
+ const usage = data.usage;
324
+ if (!usage)
325
+ return;
326
+ const tokensIn = usage.prompt_tokens ?? usage.input_tokens ?? 0;
327
+ const tokensOut = usage.completion_tokens ?? usage.output_tokens ?? 0;
328
+ if (!tokensIn && !tokensOut)
329
+ return;
330
+ const model = data.model ?? '';
331
+ const defaultRate = host.includes('openai') ? [0.15, 0.60] : [3.00, 15.00];
332
+ const [inRate, outRate] = pricing[model] ?? defaultRate;
333
+ const costUsd = (tokensIn * inRate + tokensOut * outRate) / 1000000;
334
+ costReporter.report({
335
+ event_type: 'llm',
336
+ tool_name: `llm:${host}`,
337
+ cost_usd: costUsd,
338
+ tokens_in: tokensIn,
339
+ tokens_out: tokensOut,
340
+ model_id: model,
341
+ });
342
+ }
343
+ catch {
344
+ // Best-effort — never fail governed calls
345
+ }
346
+ }
285
347
  function restoreOriginals() {
286
348
  // Restore fetch
287
349
  if (originals.has('fetch')) {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Batched cost event reporter for the TypeScript SDK.
3
+ *
4
+ * Queues cost events and periodically POSTs them to /api/v1/costs/report.
5
+ * Uses a captured fetch reference to avoid governance interception.
6
+ */
7
+ import type { CostEvent } from './types';
8
+ export interface CostReporterConfig {
9
+ serverUrl: string;
10
+ apiKey: string;
11
+ rawFetch: typeof globalThis.fetch;
12
+ batchSize?: number;
13
+ flushIntervalMs?: number;
14
+ }
15
+ export declare class CostReporter {
16
+ private readonly serverUrl;
17
+ private readonly apiKey;
18
+ private readonly rawFetch;
19
+ private readonly batchSize;
20
+ private readonly queue;
21
+ private timer;
22
+ constructor(config: CostReporterConfig);
23
+ get queueSize(): number;
24
+ report(event: CostEvent): void;
25
+ flush(): Promise<void>;
26
+ stop(): void;
27
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * Batched cost event reporter for the TypeScript SDK.
4
+ *
5
+ * Queues cost events and periodically POSTs them to /api/v1/costs/report.
6
+ * Uses a captured fetch reference to avoid governance interception.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.CostReporter = void 0;
10
+ class CostReporter {
11
+ constructor(config) {
12
+ this.queue = [];
13
+ this.timer = null;
14
+ this.serverUrl = config.serverUrl.replace(/\/$/, '');
15
+ this.apiKey = config.apiKey;
16
+ this.rawFetch = config.rawFetch;
17
+ this.batchSize = config.batchSize ?? 10;
18
+ if (this.apiKey) {
19
+ this.timer = setInterval(() => this.flush().catch(() => { }), config.flushIntervalMs ?? 5000);
20
+ }
21
+ }
22
+ get queueSize() {
23
+ return this.queue.length;
24
+ }
25
+ report(event) {
26
+ if (!this.apiKey)
27
+ return;
28
+ if (this.queue.length >= 1000) {
29
+ this.queue.shift();
30
+ }
31
+ this.queue.push(event);
32
+ }
33
+ async flush() {
34
+ if (this.queue.length === 0)
35
+ return;
36
+ const batch = this.queue.splice(0, this.batchSize);
37
+ const url = `${this.serverUrl}/api/v1/costs/report`;
38
+ // Send batch concurrently using AbortController (ES2020 compatible)
39
+ const promises = batch.map(async (event) => {
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), 5000);
42
+ try {
43
+ await this.rawFetch(url, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'Content-Type': 'application/json',
47
+ 'X-API-Key': this.apiKey,
48
+ },
49
+ body: JSON.stringify(event),
50
+ signal: controller.signal,
51
+ });
52
+ }
53
+ catch {
54
+ // Best-effort — never propagate errors
55
+ }
56
+ finally {
57
+ clearTimeout(timer);
58
+ }
59
+ });
60
+ await Promise.allSettled(promises);
61
+ }
62
+ stop() {
63
+ if (this.timer) {
64
+ clearInterval(this.timer);
65
+ this.timer = null;
66
+ }
67
+ }
68
+ }
69
+ exports.CostReporter = CostReporter;
@@ -17,6 +17,7 @@ export declare class PolicyEvaluator {
17
17
  private syncError;
18
18
  private syncInterval;
19
19
  private running;
20
+ private readonly rawFetch;
20
21
  constructor(config?: InstrumentationConfig);
21
22
  /**
22
23
  * Start the background policy sync.
@@ -40,6 +41,13 @@ export declare class PolicyEvaluator {
40
41
  private getFieldValue;
41
42
  private getRuleAction;
42
43
  private syncPolicies;
44
+ /**
45
+ * Evaluate a request server-side via the REST gateway.
46
+ *
47
+ * Used as fallback when local cache is empty but credentials are configured.
48
+ * Returns null on any error so the caller can fall back to local evaluation.
49
+ */
50
+ evaluateServerSide(request: EvaluationRequest): Promise<Decision | null>;
43
51
  /**
44
52
  * Manually update the policy cache (for testing).
45
53
  */