@littlebearapps/platform-admin-sdk 1.4.2 → 1.5.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 (111) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +121 -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/PatternStats.tsx +60 -0
  9. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  10. package/templates/full/dashboard/src/components/patterns/index.ts +2 -0
  11. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  13. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  17. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  18. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  19. package/templates/full/migrations/008_auditor.sql +99 -0
  20. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  21. package/templates/full/migrations/011_multi_account.sql +51 -0
  22. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  23. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  24. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  25. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  26. package/templates/full/workers/lib/auditor/index.ts +9 -0
  27. package/templates/full/workers/lib/auditor/types.ts +167 -0
  28. package/templates/full/workers/platform-auditor.ts +1071 -0
  29. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  30. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  31. package/templates/shared/config/observability.yaml.hbs +276 -0
  32. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  33. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  34. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  35. package/templates/shared/dashboard/astro.config.mjs +21 -0
  36. package/templates/shared/dashboard/package.json.hbs +29 -0
  37. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  38. package/templates/shared/dashboard/src/components/Nav.astro.hbs +57 -0
  39. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  40. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  41. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  42. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  43. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  44. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  45. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  46. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  47. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  48. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  49. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  50. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  51. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  52. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  53. package/templates/shared/dashboard/src/components/ui/index.ts +3 -0
  54. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  55. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  56. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  57. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  58. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  59. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  60. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  61. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  62. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  63. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  64. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  65. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  66. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  67. package/templates/shared/dashboard/src/styles/global.css +29 -0
  68. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  69. package/templates/shared/dashboard/tsconfig.json +9 -0
  70. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  71. package/templates/shared/package.json.hbs +12 -1
  72. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  73. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  74. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  75. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  76. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  77. package/templates/shared/scripts/validate-schemas.js +61 -0
  78. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  79. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  80. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  81. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  82. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  83. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  84. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  85. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  86. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  87. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  88. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  89. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  90. package/templates/shared/workers/platform-usage.ts +98 -8
  91. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  92. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  93. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  94. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  95. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  96. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  97. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  98. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  99. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  100. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  101. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  102. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  103. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  104. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  105. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  106. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  107. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  108. package/templates/standard/workers/platform-mapper.ts +482 -0
  109. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  110. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  111. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Verify Account Completeness Script
4
+ *
5
+ * Queries all Cloudflare GraphQL Analytics endpoints to calculate the true
6
+ * account-wide bill and compare against D1 records for the current month.
7
+ *
8
+ * Services audited:
9
+ * - Workers (requests + CPU)
10
+ * - D1 (rows read/written)
11
+ * - R2 (Class A/B operations)
12
+ * - KV (reads/writes)
13
+ * - Vectorize (queries)
14
+ * - Durable Objects (requests)
15
+ * - Workers AI (neurons)
16
+ *
17
+ * Usage:
18
+ * npx tsx scripts/ops/verify-account-completeness.ts
19
+ * npx tsx scripts/ops/verify-account-completeness.ts --start 2026-02-01 --end 2026-02-28
20
+ *
21
+ * Environment Variables:
22
+ * CLOUDFLARE_API_TOKEN — API token with Analytics:Read permissions
23
+ * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
24
+ * D1_DATABASE_ID — Your platform-metrics D1 database ID
25
+ */
26
+
27
+ const GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql';
28
+ const REST_API_BASE = 'https://api.cloudflare.com/client/v4';
29
+
30
+ // Pricing constants (Workers Paid Plan)
31
+ const PRICING = {
32
+ workers: { includedRequests: 10_000_000, requestCostPerMillion: 0.3, cpuCostPerMillionMs: 0.02 },
33
+ d1: { includedReads: 25_000_000_000, includedWrites: 50_000_000, readCostPerMillion: 0.001, writeCostPerMillion: 1.0 },
34
+ kv: { includedReads: 10_000_000, includedWrites: 1_000_000, readCostPerMillion: 0.5, writeCostPerMillion: 5.0 },
35
+ r2: { includedClassA: 1_000_000, includedClassB: 10_000_000, classACostPerMillion: 4.5, classBCostPerMillion: 0.36 },
36
+ vectorize: { includedDimensions: 30_000_000, queryCostPerMillionDimensions: 0.01 },
37
+ durableObjects: { includedRequests: 1_000_000, requestCostPerMillion: 0.15 },
38
+ workersAI: { neuronCostPer1000: 0.011 },
39
+ };
40
+
41
+ interface ServiceUsage {
42
+ name: string;
43
+ usage: string;
44
+ includedAllowance: string;
45
+ billableUsage: string;
46
+ estimatedCost: number;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // GraphQL helper
51
+ // ---------------------------------------------------------------------------
52
+
53
+ async function queryGraphQL(query: string, variables: Record<string, unknown>): Promise<unknown> {
54
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
55
+ if (!apiToken) throw new Error('CLOUDFLARE_API_TOKEN required');
56
+
57
+ const response = await fetch(GRAPHQL_ENDPOINT, {
58
+ method: 'POST',
59
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ query, variables }),
61
+ });
62
+
63
+ const result = (await response.json()) as { data?: unknown; errors?: Array<{ message: string }> };
64
+ if (result.errors?.length) throw new Error(`GraphQL error: ${result.errors[0].message}`);
65
+ return result.data;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Service queries
70
+ // ---------------------------------------------------------------------------
71
+
72
+ async function getWorkersUsage(accountId: string, startDate: string, endDate: string): Promise<ServiceUsage> {
73
+ const query = `query($accountTag:String!,$startDate:Date!,$endDate:Date!){viewer{accounts(filter:{accountTag:$accountTag}){workersInvocationsAdaptive(filter:{date_geq:$startDate,date_leq:$endDate},limit:1000){sum{requests,errors}quantiles{cpuTimeP50}}}}}`;
74
+ const data = (await queryGraphQL(query, { accountTag: accountId, startDate, endDate })) as Record<string, unknown>;
75
+ const results = ((data as { viewer: { accounts: Array<{ workersInvocationsAdaptive: Array<{ sum: { requests: number }; quantiles: { cpuTimeP50: number } }> }> } }).viewer.accounts[0]?.workersInvocationsAdaptive) ?? [];
76
+ let totalRequests = 0;
77
+ let totalCpuMs = 0;
78
+ for (const row of results) {
79
+ totalRequests += row.sum.requests;
80
+ totalCpuMs += row.sum.requests * row.quantiles.cpuTimeP50;
81
+ }
82
+ const billable = Math.max(0, totalRequests - PRICING.workers.includedRequests);
83
+ const cost = (billable / 1_000_000) * PRICING.workers.requestCostPerMillion + (totalCpuMs / 1_000_000) * PRICING.workers.cpuCostPerMillionMs;
84
+ return { name: 'Workers', usage: `${(totalRequests / 1_000_000).toFixed(2)}M requests`, includedAllowance: '10M requests', billableUsage: `${(billable / 1_000_000).toFixed(2)}M`, estimatedCost: cost };
85
+ }
86
+
87
+ async function getD1Usage(accountId: string, startDate: string, endDate: string): Promise<ServiceUsage> {
88
+ const query = `query($accountTag:String!,$startDate:Date!,$endDate:Date!){viewer{accounts(filter:{accountTag:$accountTag}){d1AnalyticsAdaptiveGroups(filter:{date_geq:$startDate,date_leq:$endDate},limit:1000){sum{rowsRead,rowsWritten}}}}}`;
89
+ const data = (await queryGraphQL(query, { accountTag: accountId, startDate, endDate })) as Record<string, unknown>;
90
+ const results = ((data as { viewer: { accounts: Array<{ d1AnalyticsAdaptiveGroups: Array<{ sum: { rowsRead: number; rowsWritten: number } }> }> } }).viewer.accounts[0]?.d1AnalyticsAdaptiveGroups) ?? [];
91
+ let totalReads = 0, totalWrites = 0;
92
+ for (const row of results) { totalReads += row.sum.rowsRead; totalWrites += row.sum.rowsWritten; }
93
+ const billReads = Math.max(0, totalReads - PRICING.d1.includedReads);
94
+ const billWrites = Math.max(0, totalWrites - PRICING.d1.includedWrites);
95
+ const cost = (billReads / 1_000_000) * PRICING.d1.readCostPerMillion + (billWrites / 1_000_000) * PRICING.d1.writeCostPerMillion;
96
+ return { name: 'D1', usage: `${(totalWrites / 1_000_000).toFixed(1)}M writes, ${(totalReads / 1_000_000_000).toFixed(3)}B reads`, includedAllowance: '50M writes, 25B reads', billableUsage: `${(billWrites / 1_000_000).toFixed(1)}M writes`, estimatedCost: cost };
97
+ }
98
+
99
+ async function getKVUsage(accountId: string, startDate: string, endDate: string): Promise<ServiceUsage> {
100
+ const query = `query($accountTag:String!,$startDate:Date!,$endDate:Date!){viewer{accounts(filter:{accountTag:$accountTag}){kvOperationsAdaptiveGroups(filter:{date_geq:$startDate,date_leq:$endDate},limit:1000){sum{requests}dimensions{actionType}}}}}`;
101
+ const data = (await queryGraphQL(query, { accountTag: accountId, startDate, endDate })) as Record<string, unknown>;
102
+ const results = ((data as { viewer: { accounts: Array<{ kvOperationsAdaptiveGroups: Array<{ sum: { requests: number }; dimensions: { actionType: string } }> }> } }).viewer.accounts[0]?.kvOperationsAdaptiveGroups) ?? [];
103
+ let reads = 0, writes = 0;
104
+ for (const row of results) {
105
+ const action = row.dimensions.actionType.toLowerCase();
106
+ if (action === 'read' || action === 'get') reads += row.sum.requests;
107
+ else if (action === 'write' || action === 'put') writes += row.sum.requests;
108
+ }
109
+ const billReads = Math.max(0, reads - PRICING.kv.includedReads);
110
+ const cost = (billReads / 1_000_000) * PRICING.kv.readCostPerMillion + (Math.max(0, writes - PRICING.kv.includedWrites) / 1_000_000) * PRICING.kv.writeCostPerMillion;
111
+ return { name: 'KV', usage: `${(reads / 1_000_000).toFixed(2)}M reads, ${(writes / 1_000).toFixed(1)}K writes`, includedAllowance: '10M reads, 1M writes', billableUsage: `${(billReads / 1_000_000).toFixed(2)}M reads`, estimatedCost: cost };
112
+ }
113
+
114
+ async function getR2Usage(accountId: string, startDate: string, endDate: string): Promise<ServiceUsage> {
115
+ const query = `query($accountTag:String!,$startDate:Date!,$endDate:Date!){viewer{accounts(filter:{accountTag:$accountTag}){r2OperationsAdaptiveGroups(filter:{date_geq:$startDate,date_leq:$endDate},limit:1000){sum{requests}dimensions{actionType}}}}}`;
116
+ const data = (await queryGraphQL(query, { accountTag: accountId, startDate, endDate })) as Record<string, unknown>;
117
+ const results = ((data as { viewer: { accounts: Array<{ r2OperationsAdaptiveGroups: Array<{ sum: { requests: number }; dimensions: { actionType: string } }> }> } }).viewer.accounts[0]?.r2OperationsAdaptiveGroups) ?? [];
118
+ const classBActions = new Set(['GetObject', 'HeadObject', 'HeadBucket']);
119
+ let classA = 0, classB = 0;
120
+ for (const row of results) {
121
+ if (classBActions.has(row.dimensions.actionType)) classB += row.sum.requests;
122
+ else classA += row.sum.requests;
123
+ }
124
+ const billA = Math.max(0, classA - PRICING.r2.includedClassA);
125
+ const billB = Math.max(0, classB - PRICING.r2.includedClassB);
126
+ const cost = (billA / 1_000_000) * PRICING.r2.classACostPerMillion + (billB / 1_000_000) * PRICING.r2.classBCostPerMillion;
127
+ return { name: 'R2', usage: `${(classA / 1_000).toFixed(1)}K A, ${(classB / 1_000).toFixed(1)}K B`, includedAllowance: '1M A, 10M B', billableUsage: `${billA.toLocaleString()} A`, estimatedCost: cost };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // D1 comparison
132
+ // ---------------------------------------------------------------------------
133
+
134
+ async function getD1RecordTotal(
135
+ accountId: string,
136
+ apiToken: string,
137
+ databaseId: string,
138
+ startDate: string,
139
+ endDate: string
140
+ ): Promise<number> {
141
+ const sql = `SELECT SUM(total_cost_usd) as total FROM daily_usage_rollups WHERE snapshot_date BETWEEN '${startDate}' AND '${endDate}'`;
142
+ const url = `${REST_API_BASE}/accounts/${accountId}/d1/database/${databaseId}/query`;
143
+ const response = await fetch(url, {
144
+ method: 'POST',
145
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({ sql }),
147
+ });
148
+ if (!response.ok) return 0;
149
+ const data = (await response.json()) as { result?: Array<{ results?: Array<{ total: number }> }> };
150
+ return data?.result?.[0]?.results?.[0]?.total ?? 0;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Main
155
+ // ---------------------------------------------------------------------------
156
+
157
+ async function main(): Promise<void> {
158
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
159
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
160
+ const databaseId = process.env.D1_DATABASE_ID;
161
+
162
+ if (!apiToken || !accountId || !databaseId) {
163
+ console.error('Error: Required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID');
164
+ process.exit(1);
165
+ }
166
+
167
+ // Parse date range
168
+ const args = process.argv.slice(2);
169
+ const now = new Date();
170
+ let startDate = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-01`;
171
+ let endDate = now.toISOString().split('T')[0];
172
+ for (let i = 0; i < args.length; i++) {
173
+ if (args[i] === '--start' && args[i + 1]) startDate = args[++i];
174
+ else if (args[i] === '--end' && args[i + 1]) endDate = args[++i];
175
+ }
176
+
177
+ console.log('='.repeat(80));
178
+ console.log(`ACCOUNT COMPLETENESS AUDIT (${startDate} to ${endDate})`);
179
+ console.log('='.repeat(80));
180
+ console.log('');
181
+
182
+ const services: ServiceUsage[] = [];
183
+
184
+ console.log('Querying Cloudflare GraphQL…');
185
+ for (const [name, fn] of [
186
+ ['Workers', () => getWorkersUsage(accountId, startDate, endDate)],
187
+ ['D1', () => getD1Usage(accountId, startDate, endDate)],
188
+ ['KV', () => getKVUsage(accountId, startDate, endDate)],
189
+ ['R2', () => getR2Usage(accountId, startDate, endDate)],
190
+ ] as const) {
191
+ console.log(` ${name}…`);
192
+ services.push(await fn());
193
+ }
194
+
195
+ console.log('');
196
+ console.log('Service | Usage | Billable | Est. Cost');
197
+ console.log('-'.repeat(80));
198
+
199
+ let totalCF = 0;
200
+ for (const svc of services) {
201
+ totalCF += svc.estimatedCost;
202
+ console.log(`${svc.name.padEnd(16)} | ${svc.usage.padEnd(31)} | ${svc.billableUsage.padEnd(22)} | $${svc.estimatedCost.toFixed(2).padStart(8)}`);
203
+ }
204
+ console.log('-'.repeat(80));
205
+ console.log(`${'CF TOTAL'.padEnd(16)} | ${' '.repeat(31)} | ${' '.repeat(22)} | $${totalCF.toFixed(2).padStart(8)}`);
206
+ console.log('');
207
+
208
+ // Compare against D1
209
+ console.log('Querying D1 records…');
210
+ const d1Total = await getD1RecordTotal(accountId, apiToken, databaseId, startDate, endDate);
211
+
212
+ console.log('');
213
+ console.log('-'.repeat(80));
214
+ console.log('COMPARISON');
215
+ console.log('-'.repeat(80));
216
+ console.log(` Cloudflare Calculated: $${totalCF.toFixed(2)}`);
217
+ console.log(` Our D1 Records: $${d1Total.toFixed(2)}`);
218
+ console.log(` Variance: $${(totalCF - d1Total).toFixed(2)}`);
219
+ console.log('');
220
+
221
+ const variance = Math.abs(totalCF - d1Total);
222
+ const variancePct = (variance / Math.max(totalCF, d1Total, 0.01)) * 100;
223
+
224
+ if (variancePct < 5) {
225
+ console.log(`VERDICT: ACCURATE (${variancePct.toFixed(1)}% variance — within acceptable margin)`);
226
+ } else if (d1Total > totalCF) {
227
+ console.log(`VERDICT: OVER-RECORDED (D1 records exceed Cloudflare by $${(d1Total - totalCF).toFixed(2)})`);
228
+ } else {
229
+ console.log(`VERDICT: MISSING DATA (Cloudflare exceeds D1 by $${(totalCF - d1Total).toFixed(2)})`);
230
+ }
231
+ }
232
+
233
+ main().catch((error) => {
234
+ console.error('Fatal error:', error);
235
+ process.exit(1);
236
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * JSON Schema Validation Script
3
+ *
4
+ * Validates all JSON schemas in contracts/schemas/ are syntactically correct
5
+ * using the Ajv JSON Schema validator.
6
+ *
7
+ * Usage:
8
+ * node scripts/validate-schemas.js
9
+ * npm run validate:schemas
10
+ *
11
+ * Prerequisites:
12
+ * npm install ajv ajv-formats --save-dev
13
+ */
14
+
15
+ import Ajv from 'ajv';
16
+ import addFormats from 'ajv-formats';
17
+ import { readFileSync, readdirSync } from 'fs';
18
+ import { join } from 'path';
19
+
20
+ const ajv = new Ajv({ allErrors: true, strict: true });
21
+ addFormats(ajv);
22
+
23
+ const schemasDir = join(process.cwd(), 'contracts', 'schemas');
24
+
25
+ let schemaFiles;
26
+ try {
27
+ schemaFiles = readdirSync(schemasDir).filter((file) => file.endsWith('.schema.json'));
28
+ } catch {
29
+ console.error(`No schemas directory found at ${schemasDir}`);
30
+ console.error('Create contracts/schemas/ with your JSON schema files.');
31
+ process.exit(1);
32
+ }
33
+
34
+ if (schemaFiles.length === 0) {
35
+ console.log('No schema files found in contracts/schemas/');
36
+ process.exit(0);
37
+ }
38
+
39
+ let allValid = true;
40
+
41
+ for (const file of schemaFiles) {
42
+ const path = join(schemasDir, file);
43
+ const schema = JSON.parse(readFileSync(path, 'utf-8'));
44
+
45
+ try {
46
+ ajv.compile(schema);
47
+ console.log(` PASS ${file}`);
48
+ } catch (error) {
49
+ console.error(` FAIL ${file}: ${error.message}`);
50
+ allValid = false;
51
+ }
52
+ }
53
+
54
+ console.log('');
55
+ if (allValid) {
56
+ console.log(`All ${schemaFiles.length} schemas are valid.`);
57
+ process.exit(0);
58
+ }
59
+
60
+ console.error('Some schemas are invalid.');
61
+ process.exit(1);
@@ -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
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Custom HTTP Collector Factory
3
+ *
4
+ * Factory function for creating collectors that fetch metrics from arbitrary
5
+ * REST APIs. Use when no dedicated collector exists for your provider.
6
+ *
7
+ * Supports bearer token, basic auth, and API key header authentication.
8
+ * Extracts multiple metrics from a single JSON response using dot-notation paths.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { createCustomCollector } from './custom-http';
13
+ *
14
+ * const myCollector = createCustomCollector({
15
+ * name: 'my-api',
16
+ * url: 'https://api.example.com/v1/usage',
17
+ * auth: { type: 'bearer', envKey: 'MY_API_TOKEN' },
18
+ * metrics: {
19
+ * requests: { jsonPath: 'data.total_requests', unit: 'count' },
20
+ * cost: { jsonPath: 'data.billing.amount', unit: 'usd', isCost: true },
21
+ * },
22
+ * });
23
+ *
24
+ * // Register in COLLECTORS array:
25
+ * // const COLLECTORS = [myCollector];
26
+ * ```
27
+ */
28
+
29
+ import type { Env, CustomHttpCollectorConfig } from '../shared';
30
+ import { fetchWithRetry } from '../shared';
31
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
32
+ import type { ExternalCollector } from './index';
33
+
34
+ /** Extracted metrics result from a custom collector */
35
+ export interface CustomMetrics {
36
+ /** Metric values keyed by name */
37
+ values: Record<string, number>;
38
+ /** Metadata about each metric */
39
+ meta: Record<string, { unit: string; isCost: boolean }>;
40
+ /** Source URL that was fetched */
41
+ source: string;
42
+ }
43
+
44
+ /**
45
+ * Extract a value from a nested object using dot-notation path.
46
+ * Returns undefined if the path doesn't exist.
47
+ */
48
+ function extractJsonPath(obj: unknown, path: string): unknown {
49
+ const parts = path.split('.');
50
+ let current: unknown = obj;
51
+
52
+ for (const part of parts) {
53
+ if (current === null || current === undefined || typeof current !== 'object') {
54
+ return undefined;
55
+ }
56
+ current = (current as Record<string, unknown>)[part];
57
+ }
58
+
59
+ return current;
60
+ }
61
+
62
+ /**
63
+ * Build request headers for the configured auth type.
64
+ */
65
+ function buildAuthHeaders(
66
+ config: CustomHttpCollectorConfig,
67
+ env: Env
68
+ ): Record<string, string> | null {
69
+ const headers: Record<string, string> = { ...config.headers };
70
+
71
+ if (config.auth.type === 'bearer') {
72
+ const token = (env as Record<string, unknown>)[config.auth.envKey] as string | undefined;
73
+ if (!token) return null;
74
+ headers['Authorization'] = `Bearer ${token}`;
75
+ } else if (config.auth.type === 'basic') {
76
+ const username = (env as Record<string, unknown>)[config.auth.usernameEnvKey] as
77
+ | string
78
+ | undefined;
79
+ const password = (env as Record<string, unknown>)[config.auth.passwordEnvKey] as
80
+ | string
81
+ | undefined;
82
+ if (!username || !password) return null;
83
+ headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
84
+ } else if (config.auth.type === 'api-key') {
85
+ const key = (env as Record<string, unknown>)[config.auth.envKey] as string | undefined;
86
+ if (!key) return null;
87
+ headers[config.auth.headerName] = key;
88
+ }
89
+
90
+ return headers;
91
+ }
92
+
93
+ /**
94
+ * Create a custom HTTP collector from a configuration object.
95
+ *
96
+ * The collector fetches the configured URL, parses the JSON response,
97
+ * and extracts metrics using the provided JSON paths.
98
+ */
99
+ export function createCustomCollector(
100
+ config: CustomHttpCollectorConfig
101
+ ): ExternalCollector<CustomMetrics | null> {
102
+ return {
103
+ name: config.name,
104
+ defaultValue: null,
105
+ collect: async (env: Env): Promise<CustomMetrics | null> => {
106
+ const log = createLoggerFromEnv(env, 'platform-usage', `platform:usage:${config.name}`);
107
+
108
+ const headers = buildAuthHeaders(config, env);
109
+ if (!headers) {
110
+ log.info(`No credentials configured for ${config.name}, skipping collection`);
111
+ return null;
112
+ }
113
+
114
+ try {
115
+ const response = await fetchWithRetry(config.url, { headers });
116
+
117
+ if (!response.ok) {
118
+ const errorText = await response.text();
119
+ if (response.status === 401 || response.status === 403) {
120
+ log.info(`${config.name} API access denied`, { status: response.status });
121
+ return null;
122
+ }
123
+ log.error(`${config.name} API returned ${response.status}: ${errorText}`);
124
+ return null;
125
+ }
126
+
127
+ const data = (await response.json()) as unknown;
128
+
129
+ const values: Record<string, number> = {};
130
+ const meta: Record<string, { unit: string; isCost: boolean }> = {};
131
+
132
+ for (const [metricName, metricConfig] of Object.entries(config.metrics)) {
133
+ const raw = extractJsonPath(data, metricConfig.jsonPath);
134
+ const value = typeof raw === 'number' ? raw : parseFloat(String(raw || '0'));
135
+ values[metricName] = isNaN(value) ? 0 : value;
136
+ meta[metricName] = { unit: metricConfig.unit, isCost: metricConfig.isCost ?? false };
137
+ }
138
+
139
+ log.info(`Collected ${config.name} metrics`, {
140
+ metricCount: Object.keys(values).length,
141
+ values,
142
+ });
143
+
144
+ return { values, meta, source: config.url };
145
+ } catch (error) {
146
+ log.error(`Failed to collect ${config.name} metrics`, error);
147
+ return null;
148
+ }
149
+ },
150
+ };
151
+ }