@littlebearapps/platform-admin-sdk 1.4.2 → 2.0.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.
Files changed (189) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +232 -2
  3. package/package.json +1 -1
  4. package/templates/full/config/audit-targets.yaml +72 -0
  5. package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
  7. package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
  8. package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
  9. package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
  10. package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
  11. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  12. package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
  13. package/templates/full/dashboard/src/components/patterns/index.ts +5 -0
  14. package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
  15. package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
  16. package/templates/full/dashboard/src/components/reports/index.ts +2 -0
  17. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  18. package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
  19. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  20. package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
  21. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  22. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  23. package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
  24. package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
  25. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  26. package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
  27. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  28. package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
  30. package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
  31. package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
  32. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
  34. package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
  35. package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
  36. package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
  37. package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
  38. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  39. package/templates/full/migrations/008_auditor.sql +99 -0
  40. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  41. package/templates/full/migrations/011_multi_account.sql +51 -0
  42. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  43. package/templates/full/scripts/ops/universal-backfill.ts +147 -0
  44. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  45. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  46. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  47. package/templates/full/workers/lib/auditor/index.ts +9 -0
  48. package/templates/full/workers/lib/auditor/types.ts +167 -0
  49. package/templates/full/workers/platform-auditor.ts +1071 -0
  50. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  51. package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
  52. package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
  53. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  54. package/templates/shared/.github/workflows/security.yml +33 -0
  55. package/templates/shared/config/observability.yaml.hbs +276 -0
  56. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  57. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  58. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  59. package/templates/shared/dashboard/astro.config.mjs +21 -0
  60. package/templates/shared/dashboard/package.json.hbs +29 -0
  61. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  62. package/templates/shared/dashboard/src/components/Nav.astro.hbs +59 -0
  63. package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
  64. package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
  65. package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
  66. package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
  67. package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
  68. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  69. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  70. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  71. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  72. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  73. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  74. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  75. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  76. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  77. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  78. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  79. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  80. package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
  81. package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
  82. package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
  83. package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
  84. package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
  85. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  86. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  87. package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
  88. package/templates/shared/dashboard/src/components/ui/index.ts +9 -0
  89. package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
  90. package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
  91. package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
  92. package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
  93. package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
  94. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  95. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  96. package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
  97. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  98. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  99. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  100. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  101. package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
  102. package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
  103. package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
  104. package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
  105. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
  107. package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
  108. package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
  109. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  110. package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
  111. package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
  112. package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
  113. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  114. package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
  115. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  116. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  117. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  118. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  119. package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
  120. package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
  121. package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
  122. package/templates/shared/dashboard/src/styles/global.css +29 -0
  123. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  124. package/templates/shared/dashboard/tsconfig.json +9 -0
  125. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  126. package/templates/shared/docs/architecture.md +89 -0
  127. package/templates/shared/docs/post-deploy-runbook.md +126 -0
  128. package/templates/shared/docs/troubleshooting.md +91 -0
  129. package/templates/shared/package.json.hbs +17 -1
  130. package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
  131. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  132. package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
  133. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  134. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  135. package/templates/shared/scripts/ops/validate-controls.js +141 -0
  136. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  137. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  138. package/templates/shared/scripts/validate-schemas.js +61 -0
  139. package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
  140. package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
  141. package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
  142. package/templates/shared/tests/helpers/mock-d1.ts +61 -0
  143. package/templates/shared/tests/helpers/mock-kv.ts +37 -0
  144. package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
  145. package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
  146. package/templates/shared/vitest.config.ts +18 -0
  147. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  148. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  149. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  150. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  151. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  152. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  153. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  154. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  155. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  156. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  157. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  158. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  159. package/templates/shared/workers/platform-usage.ts +98 -8
  160. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  161. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  162. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  163. package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
  164. package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
  165. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  166. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  167. package/templates/standard/dashboard/src/components/health/index.ts +4 -0
  168. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  169. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
  170. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
  171. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
  172. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  173. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  174. package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
  175. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  176. package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
  177. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  178. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  179. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  180. package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
  181. package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
  182. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  183. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  184. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  185. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  186. package/templates/standard/workers/platform-mapper.ts +482 -0
  187. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  188. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  189. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Unit Tests for Batch Resource Snapshot Persistence
3
+ *
4
+ * Tests the batch D1 insert logic in persistResourceUsageSnapshots():
5
+ * - Rows are batched in groups of 25
6
+ * - Fallback to individual inserts on batch failure
7
+ * - SQL uses ON CONFLICT DO UPDATE
8
+ */
9
+
10
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
11
+ import { MockD1Database } from '../../helpers/mock-d1';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Module mocks
15
+ // ---------------------------------------------------------------------------
16
+
17
+ vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
18
+ const actual = await importOriginal();
19
+ return {
20
+ ...actual,
21
+ createLoggerFromEnv: () => ({
22
+ info: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ debug: vi.fn(),
26
+ }),
27
+ };
28
+ });
29
+
30
+ import { persistResourceUsageSnapshots } from '../../../workers/lib/usage/scheduled/data-collection';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // BatchCapturingD1 — extends MockD1Database to track batch() calls
34
+ // ---------------------------------------------------------------------------
35
+
36
+ class BatchCapturingD1 extends MockD1Database {
37
+ batchCalls: unknown[][] = [];
38
+ batchShouldFail = false;
39
+ private batchFailCount = 0;
40
+ private batchFailMax = Infinity;
41
+
42
+ failBatchTimes(n: number): void {
43
+ this.batchShouldFail = true;
44
+ this.batchFailMax = n;
45
+ this.batchFailCount = 0;
46
+ }
47
+
48
+ async batch(statements: unknown[]): Promise<unknown[]> {
49
+ this.batchCalls.push([...statements]);
50
+ if (this.batchShouldFail && this.batchFailCount < this.batchFailMax) {
51
+ this.batchFailCount++;
52
+ throw new Error('Simulated batch failure');
53
+ }
54
+ return statements.map(() => ({ success: true, results: [] }));
55
+ }
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Fixture factory
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function makeWorkerEntry(name: string) {
63
+ return {
64
+ scriptName: name,
65
+ requests: 100,
66
+ errors: 0,
67
+ cpuTimeMs: 50,
68
+ duration50thMs: 1,
69
+ duration99thMs: 5,
70
+ };
71
+ }
72
+
73
+ function makeUsageData(workerCount: number) {
74
+ return {
75
+ workers: Array.from({ length: workerCount }, (_, i) => makeWorkerEntry(`worker-${i}`)),
76
+ d1: { reads: 1000, writes: 100 },
77
+ kv: { reads: 500, writes: 50 },
78
+ r2: { reads: 10, writes: 2, storage: 1024 },
79
+ };
80
+ }
81
+
82
+ // ===========================================================================
83
+ // Tests
84
+ // ===========================================================================
85
+
86
+ describe('persistResourceUsageSnapshots', () => {
87
+ it('batches rows in groups of 25', async () => {
88
+ const db = new BatchCapturingD1();
89
+ const usageData = makeUsageData(30);
90
+
91
+ await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
92
+
93
+ // Should have called batch at least once
94
+ expect(db.batchCalls.length).toBeGreaterThanOrEqual(1);
95
+ });
96
+
97
+ it('falls back to individual inserts on batch failure', async () => {
98
+ const db = new BatchCapturingD1();
99
+ db.failBatchTimes(1);
100
+ // Queue individual run results for fallback
101
+ for (let i = 0; i < 30; i++) {
102
+ db.queueRunResult({ success: true });
103
+ }
104
+
105
+ const usageData = makeUsageData(5);
106
+
107
+ await expect(
108
+ persistResourceUsageSnapshots(db as never, usageData as never, 'all')
109
+ ).resolves.not.toThrow();
110
+ });
111
+
112
+ it('handles empty usage data', async () => {
113
+ const db = new BatchCapturingD1();
114
+ const usageData = makeUsageData(0);
115
+
116
+ await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
117
+
118
+ // Should not fail with empty data
119
+ expect(db.batchCalls.length).toBe(0);
120
+ });
121
+
122
+ it('uses ON CONFLICT in SQL statements', async () => {
123
+ const db = new BatchCapturingD1();
124
+ const usageData = makeUsageData(1);
125
+
126
+ await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
127
+
128
+ const hasConflict = db.statements.some((s: string) =>
129
+ s.toUpperCase().includes('ON CONFLICT')
130
+ );
131
+ expect(hasConflict).toBe(true);
132
+ });
133
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Unit Tests for Budget Enforcement
3
+ *
4
+ * Covers:
5
+ * - Feature-level budget warnings at 70%/90% thresholds
6
+ * - Circuit breaker trip at 100%
7
+ * - Monthly budget tracking
8
+ * - Dedup via KV BUDGET_WARN: prefix
9
+ */
10
+
11
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
12
+ import { createMockKV } from '../../helpers/mock-kv';
13
+ import { MockD1Database } from '../../helpers/mock-d1';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Module mocks — must be before imports that resolve these modules
17
+ // ---------------------------------------------------------------------------
18
+
19
+ vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
20
+ const actual = await importOriginal();
21
+ return {
22
+ ...actual,
23
+ createLoggerFromEnv: () => ({
24
+ info: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn(),
27
+ debug: vi.fn(),
28
+ }),
29
+ };
30
+ });
31
+
32
+ vi.mock('../../../workers/lib/circuit-breaker-middleware', () => ({
33
+ CB_STATUS: { CLOSED: 'active', WARNING: 'warning', OPEN: 'paused' },
34
+ }));
35
+
36
+ vi.mock('../../../workers/lib/platform-settings', () => ({
37
+ getPlatformSettings: vi.fn().mockResolvedValue({}),
38
+ getProjectSetting: vi.fn().mockResolvedValue(null),
39
+ DEFAULT_PLATFORM_SETTINGS: {},
40
+ }));
41
+
42
+ const mockFetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));
43
+
44
+ import {
45
+ checkAndUpdateBudgetStatus,
46
+ checkMonthlyBudgets,
47
+ } from '../../../workers/lib/usage/queue/budget-enforcement';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Helpers
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function createTestEnv(kvData: Record<string, string> = {}, slackUrl?: string) {
54
+ const db = new MockD1Database();
55
+ db.queueRunResult({ success: true });
56
+ db.queueRunResult({ success: true });
57
+
58
+ return {
59
+ env: {
60
+ PLATFORM_CACHE: createMockKV(kvData),
61
+ PLATFORM_DB: db,
62
+ SLACK_WEBHOOK_URL: slackUrl,
63
+ NOTIFICATIONS_API: undefined,
64
+ PLATFORM_TELEMETRY: undefined,
65
+ TELEMETRY_QUEUE: undefined,
66
+ } as unknown,
67
+ db,
68
+ };
69
+ }
70
+
71
+ // ===========================================================================
72
+ // Section A: Feature-Level Budget Warnings
73
+ // ===========================================================================
74
+
75
+ describe('checkAndUpdateBudgetStatus', () => {
76
+ beforeEach(() => {
77
+ vi.stubGlobal('fetch', mockFetch);
78
+ mockFetch.mockClear();
79
+ });
80
+
81
+ afterEach(() => {
82
+ vi.unstubAllGlobals();
83
+ });
84
+
85
+ it('fires 70% warning when metric is between 70-89% of budget', async () => {
86
+ const { env } = createTestEnv(
87
+ {
88
+ 'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
89
+ d1_rows_written: 100_000,
90
+ }),
91
+ },
92
+ 'https://hooks.slack.com/test'
93
+ );
94
+
95
+ await checkAndUpdateBudgetStatus(
96
+ 'test:api:endpoint',
97
+ { d1RowsWritten: 72_000 } as never,
98
+ env as never
99
+ );
100
+
101
+ const kv = (env as Record<string, unknown>).PLATFORM_CACHE as ReturnType<typeof createMockKV>;
102
+ expect(kv.put).toHaveBeenCalledWith(
103
+ 'BUDGET_WARN:test:api:endpoint:d1RowsWritten:70',
104
+ '1',
105
+ { expirationTtl: 3600 }
106
+ );
107
+ });
108
+
109
+ it('fires 90% critical warning when metric >= 90% of budget', async () => {
110
+ const { env } = createTestEnv(
111
+ {
112
+ 'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
113
+ d1_rows_written: 100_000,
114
+ }),
115
+ },
116
+ 'https://hooks.slack.com/test'
117
+ );
118
+
119
+ await checkAndUpdateBudgetStatus(
120
+ 'test:api:endpoint',
121
+ { d1RowsWritten: 92_000 } as never,
122
+ env as never
123
+ );
124
+
125
+ const kv = (env as Record<string, unknown>).PLATFORM_CACHE as ReturnType<typeof createMockKV>;
126
+ expect(kv.put).toHaveBeenCalledWith(
127
+ 'BUDGET_WARN:test:api:endpoint:d1RowsWritten:90',
128
+ '1',
129
+ { expirationTtl: 3600 }
130
+ );
131
+ });
132
+
133
+ it('does not fire warning when metric is below 70%', async () => {
134
+ const { env } = createTestEnv(
135
+ {
136
+ 'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
137
+ d1_rows_written: 100_000,
138
+ }),
139
+ },
140
+ 'https://hooks.slack.com/test'
141
+ );
142
+
143
+ await checkAndUpdateBudgetStatus(
144
+ 'test:api:endpoint',
145
+ { d1RowsWritten: 50_000 } as never,
146
+ env as never
147
+ );
148
+
149
+ expect(mockFetch).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it('skips warning when dedup key already exists', async () => {
153
+ const { env } = createTestEnv(
154
+ {
155
+ 'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
156
+ d1_rows_written: 100_000,
157
+ }),
158
+ 'BUDGET_WARN:test:api:endpoint:d1RowsWritten:70': '1',
159
+ },
160
+ 'https://hooks.slack.com/test'
161
+ );
162
+
163
+ await checkAndUpdateBudgetStatus(
164
+ 'test:api:endpoint',
165
+ { d1RowsWritten: 75_000 } as never,
166
+ env as never
167
+ );
168
+
169
+ expect(mockFetch).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('does nothing when no budget config exists', async () => {
173
+ const { env } = createTestEnv({}, 'https://hooks.slack.com/test');
174
+
175
+ await checkAndUpdateBudgetStatus(
176
+ 'test:api:endpoint',
177
+ { d1RowsWritten: 99_000 } as never,
178
+ env as never
179
+ );
180
+
181
+ expect(mockFetch).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+
185
+ // ===========================================================================
186
+ // Section B: Monthly Budget Tracking
187
+ // ===========================================================================
188
+
189
+ describe('checkMonthlyBudgets', () => {
190
+ beforeEach(() => {
191
+ vi.stubGlobal('fetch', mockFetch);
192
+ mockFetch.mockClear();
193
+ });
194
+
195
+ afterEach(() => {
196
+ vi.unstubAllGlobals();
197
+ });
198
+
199
+ it('queries daily_usage_rollups for current month', async () => {
200
+ const { env, db } = createTestEnv({});
201
+ db.queueAllResult([]);
202
+
203
+ await checkMonthlyBudgets(env as never);
204
+
205
+ expect(db.statements.some((s: string) => s.includes('daily_usage_rollups'))).toBe(true);
206
+ });
207
+
208
+ it('handles missing tables gracefully', async () => {
209
+ const { env } = createTestEnv({});
210
+
211
+ // Should not throw even with empty DB
212
+ await expect(checkMonthlyBudgets(env as never)).resolves.not.toThrow();
213
+ });
214
+ });
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ include: ['workers/**/*.ts'],
11
+ },
12
+ },
13
+ resolve: {
14
+ alias: {
15
+ '@': './src',
16
+ },
17
+ },
18
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Anthropic (Claude) Usage Collector
3
+ *
4
+ * Collects API usage from the Anthropic Admin Usage API.
5
+ * Requires an Admin API key with organization:usage:read scope.
6
+ *
7
+ * NOTE: This only tracks API usage (developer/organization consumption).
8
+ * Claude Max subscriptions (claude.ai consumer product) are NOT tracked here -
9
+ * there is no API to retrieve Claude Max conversation/usage data.
10
+ *
11
+ * This is a FLOW metric (usage that accumulates) - safe to SUM.
12
+ *
13
+ * @see https://docs.anthropic.com/en/api/admin-api
14
+ */
15
+
16
+ import type { Env, AnthropicUsageData } from '../shared';
17
+ import { fetchWithRetry } from '../shared';
18
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
19
+ import type { ExternalCollector } from './index';
20
+
21
+ /**
22
+ * Collect Anthropic (Claude) API usage from the Admin Usage API.
23
+ * Requires an Admin API key with organization:usage:read scope.
24
+ */
25
+ export async function collectAnthropicUsage(env: Env): Promise<AnthropicUsageData | null> {
26
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:anthropic');
27
+ if (!env.ANTHROPIC_ADMIN_API_KEY) {
28
+ log.info('No ANTHROPIC_ADMIN_API_KEY configured, skipping usage collection');
29
+ return null;
30
+ }
31
+
32
+ try {
33
+ // Get usage for the last 24 hours
34
+ const now = new Date();
35
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
36
+ const startTime = yesterday.toISOString().replace(/\.\d{3}Z$/, 'Z');
37
+ const endTime = now.toISOString().replace(/\.\d{3}Z$/, 'Z');
38
+
39
+ const url =
40
+ `https://api.anthropic.com/v1/organizations/usage?` +
41
+ `start_time=${startTime}&end_time=${endTime}&group_by=model`;
42
+
43
+ const response = await fetchWithRetry(url, {
44
+ headers: {
45
+ 'anthropic-version': '2023-06-01',
46
+ 'x-api-key': env.ANTHROPIC_ADMIN_API_KEY,
47
+ },
48
+ });
49
+
50
+ if (!response.ok) {
51
+ const errorText = await response.text();
52
+ log.error(`Usage API returned ${response.status}: ${errorText}`);
53
+ return null;
54
+ }
55
+
56
+ const data = (await response.json()) as {
57
+ data?: Array<{
58
+ model?: string;
59
+ input_tokens?: number;
60
+ output_tokens?: number;
61
+ }>;
62
+ };
63
+
64
+ if (!data.data || !Array.isArray(data.data)) {
65
+ log.info('No usage data returned');
66
+ return { inputTokens: 0, outputTokens: 0, totalCost: 0, modelBreakdown: {} };
67
+ }
68
+
69
+ const result: AnthropicUsageData = {
70
+ inputTokens: 0,
71
+ outputTokens: 0,
72
+ totalCost: 0,
73
+ modelBreakdown: {},
74
+ };
75
+
76
+ // Aggregate usage by model
77
+ for (const item of data.data) {
78
+ const inputTokens = item.input_tokens || 0;
79
+ const outputTokens = item.output_tokens || 0;
80
+ result.inputTokens += inputTokens;
81
+ result.outputTokens += outputTokens;
82
+
83
+ if (item.model) {
84
+ if (!result.modelBreakdown[item.model]) {
85
+ result.modelBreakdown[item.model] = { inputTokens: 0, outputTokens: 0 };
86
+ }
87
+ result.modelBreakdown[item.model].inputTokens += inputTokens;
88
+ result.modelBreakdown[item.model].outputTokens += outputTokens;
89
+ }
90
+ }
91
+
92
+ // Estimate cost (rough approximation - actual pricing varies by model)
93
+ // Claude Sonnet: $3/$15 per MTok, Claude Opus: $15/$75 per MTok
94
+ // Using average estimate of $5/$20 per MTok
95
+ result.totalCost = (result.inputTokens * 5 + result.outputTokens * 20) / 1_000_000;
96
+
97
+ log.info('Collected usage', {
98
+ inputTokens: result.inputTokens,
99
+ outputTokens: result.outputTokens,
100
+ estimatedCost: result.totalCost,
101
+ });
102
+ return result;
103
+ } catch (error) {
104
+ log.error('Failed to collect usage', error);
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /** Collector registration for use in collectors/index.ts COLLECTORS array */
110
+ export const anthropicCollector: ExternalCollector<AnthropicUsageData | null> = {
111
+ name: 'anthropic',
112
+ collect: collectAnthropicUsage,
113
+ defaultValue: null,
114
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Apify Usage Collector
3
+ *
4
+ * Collects platform usage from the Apify Monthly Usage API.
5
+ *
6
+ * This is a FLOW metric (usage that accumulates) - safe to SUM.
7
+ *
8
+ * @see https://docs.apify.com/api/v2/users-me-usage-monthly-get
9
+ */
10
+
11
+ import type { Env, ApifyUsageData } from '../shared';
12
+ import { fetchWithRetry } from '../shared';
13
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
14
+ import type { ExternalCollector } from './index';
15
+
16
+ /**
17
+ * Collect Apify platform usage from the Monthly Usage API.
18
+ */
19
+ export async function collectApifyUsage(env: Env): Promise<ApifyUsageData | null> {
20
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:apify');
21
+ if (!env.APIFY_API_KEY) {
22
+ log.info('No APIFY_API_KEY configured, skipping usage collection');
23
+ return null;
24
+ }
25
+
26
+ try {
27
+ const response = await fetchWithRetry('https://api.apify.com/v2/users/me/usage/monthly', {
28
+ headers: {
29
+ Authorization: `Bearer ${env.APIFY_API_KEY}`,
30
+ Accept: 'application/json',
31
+ },
32
+ });
33
+
34
+ if (!response.ok) {
35
+ const errorText = await response.text();
36
+ if (response.status === 401) {
37
+ log.info('API key invalid or expired');
38
+ return null;
39
+ }
40
+ log.error(`Usage API returned ${response.status}: ${errorText}`);
41
+ return null;
42
+ }
43
+
44
+ const data = (await response.json()) as {
45
+ data?: {
46
+ monthlyServiceUsage?: Record<
47
+ string,
48
+ {
49
+ quantity?: number;
50
+ baseAmountUsd?: number;
51
+ }
52
+ >;
53
+ totalUsageCreditsUsdAfterVolumeDiscount?: number;
54
+ };
55
+ };
56
+
57
+ const result: ApifyUsageData = {
58
+ totalUsageCreditsUsd: data.data?.totalUsageCreditsUsdAfterVolumeDiscount || 0,
59
+ actorComputeUnits: 0,
60
+ dataTransferGb: 0,
61
+ storageGb: 0,
62
+ };
63
+
64
+ const services = data.data?.monthlyServiceUsage;
65
+ if (services) {
66
+ if (services.ACTOR_COMPUTE_UNITS) {
67
+ result.actorComputeUnits = services.ACTOR_COMPUTE_UNITS.quantity || 0;
68
+ }
69
+ if (services.DATA_TRANSFER_EXTERNAL_GBYTES) {
70
+ result.dataTransferGb = services.DATA_TRANSFER_EXTERNAL_GBYTES.quantity || 0;
71
+ }
72
+ if (services.DATASET_STORAGE_GBYTES) {
73
+ result.storageGb += services.DATASET_STORAGE_GBYTES.quantity || 0;
74
+ }
75
+ if (services.KEY_VALUE_STORE_STORAGE_GBYTES) {
76
+ result.storageGb += services.KEY_VALUE_STORE_STORAGE_GBYTES.quantity || 0;
77
+ }
78
+ }
79
+
80
+ log.info('Collected usage', {
81
+ totalUsageCreditsUsd: result.totalUsageCreditsUsd,
82
+ actorComputeUnits: result.actorComputeUnits,
83
+ });
84
+ return result;
85
+ } catch (error) {
86
+ log.error('Failed to collect usage', error);
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** Collector registration for use in collectors/index.ts COLLECTORS array */
92
+ export const apifyCollector: ExternalCollector<ApifyUsageData | null> = {
93
+ name: 'apify',
94
+ collect: collectApifyUsage,
95
+ defaultValue: null,
96
+ };