@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,55 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ export const GET: APIRoute = async ({ locals }) => {
4
+ const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
5
+
6
+ if (!db) {
7
+ return new Response(JSON.stringify({ byPriority: {}, byWorker: [], total: 0 }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const byPriority = await db
14
+ .prepare(
15
+ `SELECT priority, COUNT(*) as count
16
+ FROM error_occurrences WHERE status = 'open'
17
+ GROUP BY priority
18
+ LIMIT 10`
19
+ )
20
+ .all<{ priority: string; count: number }>();
21
+
22
+ const byWorker = await db
23
+ .prepare(
24
+ `SELECT script_name, COUNT(*) as count
25
+ FROM error_occurrences WHERE status = 'open'
26
+ GROUP BY script_name
27
+ ORDER BY count DESC
28
+ LIMIT 10`
29
+ )
30
+ .all<{ script_name: string; count: number }>();
31
+
32
+ const total = await db
33
+ .prepare(`SELECT COUNT(*) as count FROM error_occurrences WHERE status = 'open' LIMIT 1`)
34
+ .first<{ count: number }>();
35
+
36
+ const priorityMap: Record<string, number> = {};
37
+ for (const row of byPriority.results ?? []) {
38
+ priorityMap[row.priority] = row.count;
39
+ }
40
+
41
+ return new Response(
42
+ JSON.stringify({
43
+ byPriority: priorityMap,
44
+ byWorker: byWorker.results ?? [],
45
+ total: total?.count ?? 0,
46
+ }),
47
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
48
+ );
49
+ } catch {
50
+ return new Response(JSON.stringify({ byPriority: {}, byWorker: [], total: 0 }), {
51
+ status: 500,
52
+ headers: { 'Content-Type': 'application/json' },
53
+ });
54
+ }
55
+ };
@@ -0,0 +1,37 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ export const GET: APIRoute = async ({ locals, url }) => {
4
+ const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
5
+
6
+ if (!db) {
7
+ return new Response(JSON.stringify({ history: [] }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20'), 100);
13
+
14
+ try {
15
+ const history = await db
16
+ .prepare(
17
+ `SELECT id, project, scan_type, ai_judge_score, sdk_score,
18
+ observability_score, cost_protection_score, security_score,
19
+ scan_date
20
+ FROM audit_results
21
+ ORDER BY scan_date DESC
22
+ LIMIT ?`
23
+ )
24
+ .bind(limit)
25
+ .all();
26
+
27
+ return new Response(
28
+ JSON.stringify({ history: history.results ?? [] }),
29
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' } }
30
+ );
31
+ } catch {
32
+ return new Response(JSON.stringify({ history: [] }), {
33
+ status: 500,
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
37
+ };
@@ -0,0 +1,43 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ export const GET: APIRoute = async ({ locals }) => {
4
+ const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
5
+
6
+ if (!db) {
7
+ return new Response(JSON.stringify({ depth: 0, oldestAge: null, status: 'unknown' }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const depth = await db
14
+ .prepare(`SELECT COUNT(*) as count FROM dlq_messages WHERE status = 'pending' LIMIT 1`)
15
+ .first<{ count: number }>();
16
+
17
+ const oldest = await db
18
+ .prepare(
19
+ `SELECT MIN(received_at) as oldest
20
+ FROM dlq_messages WHERE status = 'pending'
21
+ LIMIT 1`
22
+ )
23
+ .first<{ oldest: string | null }>();
24
+
25
+ let oldestAgeMinutes: number | null = null;
26
+ if (oldest?.oldest) {
27
+ oldestAgeMinutes = Math.round((Date.now() - new Date(oldest.oldest).getTime()) / 60000);
28
+ }
29
+
30
+ const dlqDepth = depth?.count ?? 0;
31
+ const status = dlqDepth === 0 ? 'healthy' : dlqDepth < 100 ? 'warning' : 'critical';
32
+
33
+ return new Response(
34
+ JSON.stringify({ depth: dlqDepth, oldestAgeMinutes, status }),
35
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
36
+ );
37
+ } catch {
38
+ return new Response(JSON.stringify({ depth: 0, oldestAgeMinutes: null, status: 'unknown' }), {
39
+ status: 500,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ }
43
+ };
@@ -0,0 +1,13 @@
1
+ ---
2
+ import DashboardLayout from '../layouts/DashboardLayout.astro';
3
+ import { CircuitBreakerPanel } from '../components/health/CircuitBreakerPanel';
4
+ import { CircuitBreakerEvents } from '../components/health/CircuitBreakerEvents';
5
+ ---
6
+
7
+ <DashboardLayout title="Circuit Breakers">
8
+ <div class="max-w-5xl mx-auto space-y-4">
9
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Circuit Breakers</h2>
10
+ <CircuitBreakerPanel client:load />
11
+ <CircuitBreakerEvents client:load />
12
+ </div>
13
+ </DashboardLayout>
@@ -0,0 +1,13 @@
1
+ ---
2
+ import DashboardLayout from '../../layouts/DashboardLayout.astro';
3
+ import { ErrorsTable } from '../../components/errors/ErrorsTable';
4
+ import { ErrorStats } from '../../components/errors/ErrorStats';
5
+ ---
6
+
7
+ <DashboardLayout title="Errors">
8
+ <div class="max-w-7xl mx-auto space-y-6">
9
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Error Management</h2>
10
+ <ErrorStats client:load />
11
+ <ErrorsTable client:load />
12
+ </div>
13
+ </DashboardLayout>
@@ -0,0 +1,11 @@
1
+ ---
2
+ import DashboardLayout from '../../layouts/DashboardLayout.astro';
3
+ import { HealthTabs } from '../../components/health/HealthTabs';
4
+ ---
5
+
6
+ <DashboardLayout title="Health">
7
+ <div class="max-w-7xl mx-auto">
8
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">System Health</h2>
9
+ <HealthTabs client:load />
10
+ </div>
11
+ </DashboardLayout>
@@ -0,0 +1,65 @@
1
+ -- Migration 009: Topology Mapper Tables
2
+ --
3
+ -- Purpose: Infrastructure topology tracking for the platform-mapper worker.
4
+ -- Stores discovered Cloudflare resources, service connections, and historical snapshots.
5
+ --
6
+ -- Tables:
7
+ -- services — Discovered/defined infrastructure services
8
+ -- connections — Relationships between services
9
+ -- topology_snapshots — Historical topology snapshots for trend analysis
10
+ --
11
+ -- Tier: Standard+
12
+ -- Worker: platform-mapper (15-minute cron)
13
+
14
+ -- Services discovered or defined
15
+ CREATE TABLE IF NOT EXISTS services (
16
+ id TEXT PRIMARY KEY,
17
+ name TEXT NOT NULL,
18
+ type TEXT NOT NULL, -- 'cloudflare-worker', 'cloudflare-pages', etc.
19
+ tier INTEGER NOT NULL, -- 0 (critical), 1 (high), 2 (medium)
20
+ status TEXT NOT NULL, -- 'deployed', 'partial', 'not-deployed', 'unknown'
21
+ version TEXT,
22
+ health_endpoint TEXT,
23
+ health_status TEXT DEFAULT 'unknown', -- 'up', 'degraded', 'down', 'unknown'
24
+ last_seen TIMESTAMP,
25
+ metadata TEXT, -- JSON: Type-specific data
26
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
27
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
28
+ );
29
+
30
+ CREATE INDEX IF NOT EXISTS idx_services_type ON services(type);
31
+ CREATE INDEX IF NOT EXISTS idx_services_tier ON services(tier);
32
+ CREATE INDEX IF NOT EXISTS idx_services_status ON services(status);
33
+ CREATE INDEX IF NOT EXISTS idx_services_health ON services(health_status);
34
+
35
+ -- Connections between services
36
+ CREATE TABLE IF NOT EXISTS connections (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ from_service TEXT NOT NULL,
39
+ to_service TEXT NOT NULL,
40
+ connection_type TEXT NOT NULL, -- 'webhook', 'api', 'data-flow', 'deployment', 'service-binding'
41
+ protocol TEXT, -- 'https', 'rest', 'queue', etc.
42
+ status TEXT NOT NULL, -- 'active', 'planned', 'not-integrated', 'broken'
43
+ metadata TEXT, -- JSON: Connection-specific data
44
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
45
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
+ UNIQUE (from_service, to_service, connection_type),
47
+ FOREIGN KEY (from_service) REFERENCES services(id),
48
+ FOREIGN KEY (to_service) REFERENCES services(id)
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_connections_from ON connections(from_service);
52
+ CREATE INDEX IF NOT EXISTS idx_connections_to ON connections(to_service);
53
+ CREATE INDEX IF NOT EXISTS idx_connections_status ON connections(status);
54
+
55
+ -- Historical snapshots (for topology change tracking)
56
+ CREATE TABLE IF NOT EXISTS topology_snapshots (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
59
+ data TEXT NOT NULL, -- JSON: Full topology snapshot
60
+ change_summary TEXT, -- What changed since last snapshot
61
+ service_count INTEGER,
62
+ connection_count INTEGER
63
+ );
64
+
65
+ CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON topology_snapshots(timestamp);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Unit Tests for Error Capture Logic
3
+ *
4
+ * Covers:
5
+ * - shouldCapture() filtering logic
6
+ * - Outcome handling for all 7 non-OK outcomes
7
+ * - Deduplication via KV fingerprint keys
8
+ */
9
+
10
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
11
+ import { createMockKV } from '../../../tests/helpers/mock-kv';
12
+ import { MockD1Database } from '../../../tests/helpers/mock-d1';
13
+
14
+ vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return {
17
+ ...actual,
18
+ createLoggerFromEnv: () => ({
19
+ info: vi.fn(),
20
+ warn: vi.fn(),
21
+ error: vi.fn(),
22
+ debug: vi.fn(),
23
+ }),
24
+ };
25
+ });
26
+
27
+ import { shouldCapture } from '../../../workers/lib/error-collector/capture';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function createTailEvent(overrides: Record<string, unknown> = {}) {
34
+ return {
35
+ scriptName: 'test-worker',
36
+ outcome: 'exception',
37
+ exceptions: [{ name: 'Error', message: 'Test error' }],
38
+ logs: [],
39
+ eventTimestamp: Date.now(),
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ // ===========================================================================
45
+ // shouldCapture
46
+ // ===========================================================================
47
+
48
+ describe('shouldCapture', () => {
49
+ it('captures exception outcomes', () => {
50
+ const event = createTailEvent({ outcome: 'exception' });
51
+ expect(shouldCapture(event as never)).toBe(true);
52
+ });
53
+
54
+ it('captures exceededCpu outcomes', () => {
55
+ const event = createTailEvent({ outcome: 'exceededCpu' });
56
+ expect(shouldCapture(event as never)).toBe(true);
57
+ });
58
+
59
+ it('captures exceededMemory outcomes', () => {
60
+ const event = createTailEvent({ outcome: 'exceededMemory' });
61
+ expect(shouldCapture(event as never)).toBe(true);
62
+ });
63
+
64
+ it('captures canceled outcomes', () => {
65
+ const event = createTailEvent({ outcome: 'canceled' });
66
+ expect(shouldCapture(event as never)).toBe(true);
67
+ });
68
+
69
+ it('captures responseStreamDisconnected outcomes', () => {
70
+ const event = createTailEvent({ outcome: 'responseStreamDisconnected' });
71
+ expect(shouldCapture(event as never)).toBe(true);
72
+ });
73
+
74
+ it('captures scriptNotFound outcomes', () => {
75
+ const event = createTailEvent({ outcome: 'scriptNotFound' });
76
+ expect(shouldCapture(event as never)).toBe(true);
77
+ });
78
+
79
+ it('does not capture ok outcomes without error logs', () => {
80
+ const event = createTailEvent({ outcome: 'ok', exceptions: [], logs: [] });
81
+ expect(shouldCapture(event as never)).toBe(false);
82
+ });
83
+
84
+ it('captures ok outcomes with error-level logs (soft errors)', () => {
85
+ const event = createTailEvent({
86
+ outcome: 'ok',
87
+ exceptions: [],
88
+ logs: [{ level: 'error', message: ['Something failed'], timestamp: Date.now() }],
89
+ });
90
+ expect(shouldCapture(event as never)).toBe(true);
91
+ });
92
+
93
+ it('captures ok outcomes with warning-level logs', () => {
94
+ const event = createTailEvent({
95
+ outcome: 'ok',
96
+ exceptions: [],
97
+ logs: [{ level: 'warn', message: ['Deprecation warning'], timestamp: Date.now() }],
98
+ });
99
+ expect(shouldCapture(event as never)).toBe(true);
100
+ });
101
+
102
+ it('ignores events without scriptName', () => {
103
+ const event = createTailEvent({ scriptName: undefined, outcome: 'exception' });
104
+ expect(shouldCapture(event as never)).toBe(false);
105
+ });
106
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Unit Tests for Error Fingerprinting
3
+ *
4
+ * Covers:
5
+ * - Error classification by outcome type
6
+ * - Transient error detection (quota, rate-limit, timeout)
7
+ * - Message normalisation (variable stripping)
8
+ * - Fingerprint stability (same error = same fingerprint)
9
+ */
10
+
11
+ import { describe, expect, it, vi } from 'vitest';
12
+
13
+ vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
14
+ const actual = await importOriginal();
15
+ return {
16
+ ...actual,
17
+ createLoggerFromEnv: () => ({
18
+ info: vi.fn(),
19
+ warn: vi.fn(),
20
+ error: vi.fn(),
21
+ debug: vi.fn(),
22
+ }),
23
+ };
24
+ });
25
+
26
+ import {
27
+ classifyError,
28
+ generateFingerprint,
29
+ normalizeMessage,
30
+ isTransientError,
31
+ } from '../../../workers/lib/error-collector/fingerprint';
32
+
33
+ // ===========================================================================
34
+ // Classification
35
+ // ===========================================================================
36
+
37
+ describe('classifyError', () => {
38
+ it('classifies exception as P0-P2 based on severity', () => {
39
+ const result = classifyError({
40
+ outcome: 'exception',
41
+ scriptName: 'test-worker',
42
+ message: 'Uncaught ReferenceError: foo is not defined',
43
+ logs: [],
44
+ } as never);
45
+
46
+ expect(result).toBeDefined();
47
+ expect(['P0', 'P1', 'P2']).toContain(result.priority);
48
+ });
49
+
50
+ it('classifies exceededCpu as P0', () => {
51
+ const result = classifyError({
52
+ outcome: 'exceededCpu',
53
+ scriptName: 'test-worker',
54
+ message: 'Worker exceeded CPU time limit',
55
+ logs: [],
56
+ } as never);
57
+
58
+ expect(result.priority).toBe('P0');
59
+ });
60
+
61
+ it('classifies canceled as P2', () => {
62
+ const result = classifyError({
63
+ outcome: 'canceled',
64
+ scriptName: 'test-worker',
65
+ message: 'Request was canceled',
66
+ logs: [],
67
+ } as never);
68
+
69
+ expect(result.priority).toBe('P2');
70
+ });
71
+
72
+ it('classifies soft_error from logs as P2-P3', () => {
73
+ const result = classifyError({
74
+ outcome: 'ok',
75
+ scriptName: 'test-worker',
76
+ message: 'Soft error captured via console.error',
77
+ logs: [{ level: 'error', message: ['Something failed'] }],
78
+ } as never);
79
+
80
+ expect(['P2', 'P3']).toContain(result.priority);
81
+ });
82
+ });
83
+
84
+ // ===========================================================================
85
+ // Transient Detection
86
+ // ===========================================================================
87
+
88
+ describe('isTransientError', () => {
89
+ it('detects quota-exhausted as transient', () => {
90
+ expect(isTransientError('D1_ERROR: too many requests (quota exhausted)')).toBe(true);
91
+ });
92
+
93
+ it('detects rate-limited as transient', () => {
94
+ expect(isTransientError('Rate limited: try again in 30 seconds')).toBe(true);
95
+ });
96
+
97
+ it('detects timeout as transient', () => {
98
+ expect(isTransientError('Network request timed out after 30000ms')).toBe(true);
99
+ });
100
+
101
+ it('does not flag regular errors as transient', () => {
102
+ expect(isTransientError('TypeError: Cannot read property of null')).toBe(false);
103
+ });
104
+ });
105
+
106
+ // ===========================================================================
107
+ // Message Normalisation
108
+ // ===========================================================================
109
+
110
+ describe('normalizeMessage', () => {
111
+ it('strips UUIDs from messages', () => {
112
+ const result = normalizeMessage('Error for user a1b2c3d4-e5f6-7890-abcd-ef1234567890');
113
+ expect(result).not.toContain('a1b2c3d4');
114
+ expect(result).toContain('<UUID>');
115
+ });
116
+
117
+ it('strips numeric IDs from messages', () => {
118
+ const result = normalizeMessage('Failed to process request 1234567890');
119
+ expect(result).not.toContain('1234567890');
120
+ });
121
+
122
+ it('strips timestamps from messages', () => {
123
+ const result = normalizeMessage('Error at 2026-03-05T12:00:00Z in handler');
124
+ expect(result).not.toContain('2026-03-05');
125
+ });
126
+
127
+ it('preserves error type prefixes', () => {
128
+ const result = normalizeMessage('TypeError: Cannot read property of null');
129
+ expect(result).toContain('TypeError');
130
+ });
131
+ });
132
+
133
+ // ===========================================================================
134
+ // Fingerprint Stability
135
+ // ===========================================================================
136
+
137
+ describe('generateFingerprint', () => {
138
+ it('produces consistent fingerprint for same error', () => {
139
+ const fp1 = generateFingerprint('test-worker', 'TypeError: Cannot read property of null');
140
+ const fp2 = generateFingerprint('test-worker', 'TypeError: Cannot read property of null');
141
+ expect(fp1).toBe(fp2);
142
+ });
143
+
144
+ it('produces different fingerprints for different errors', () => {
145
+ const fp1 = generateFingerprint('test-worker', 'TypeError: Cannot read property of null');
146
+ const fp2 = generateFingerprint('test-worker', 'RangeError: Maximum call stack exceeded');
147
+ expect(fp1).not.toBe(fp2);
148
+ });
149
+
150
+ it('produces different fingerprints for different workers', () => {
151
+ const fp1 = generateFingerprint('worker-a', 'Error: timeout');
152
+ const fp2 = generateFingerprint('worker-b', 'Error: timeout');
153
+ expect(fp1).not.toBe(fp2);
154
+ });
155
+ });
@@ -198,14 +198,48 @@ export async function processEmailHealthAlerts(
198
198
  }
199
199
 
200
200
  try {
201
- // Create a focused issue for this specific check failure
201
+ const issueTitle = `Email Health: ${brandName} ${failure.check_type} failing`;
202
+
203
+ // Search for an existing OPEN issue before creating a new one.
204
+ // This prevents daily duplicate issues when a problem persists across days.
205
+ const openIssues = await github.searchIssues(
206
+ owner,
207
+ repo,
208
+ `"${issueTitle}" is:open label:cf:email-health`
209
+ );
210
+
211
+ if (openIssues.length > 0) {
212
+ const existing = openIssues[0];
213
+ await github.addComment(
214
+ owner,
215
+ repo,
216
+ existing.number,
217
+ `### Still failing (${getDateKey()})\n\n` +
218
+ `Check \`${failure.check_type}\` continues to fail: ${failure.error_msg}\n\n` +
219
+ `Run ID: \`${event.run_id}\``
220
+ );
221
+ await setDedup(env.PLATFORM_CACHE, event.brand_id, failure.check_type, existing.number);
222
+
223
+ console.log(
224
+ `Commented on existing email health issue #${existing.number} for ${event.brand_id}:${failure.check_type}`
225
+ );
226
+
227
+ results.skipped++;
228
+ results.skippedChecks.push({
229
+ check_type: failure.check_type,
230
+ reason: `Commented on existing issue #${existing.number}`,
231
+ });
232
+ continue;
233
+ }
234
+
235
+ // No existing open issue — create a new one
202
236
  const issue = await github.createIssue({
203
237
  owner,
204
238
  repo,
205
- title: `Email Health: ${brandName} ${failure.check_type} failing`,
239
+ title: issueTitle,
206
240
  body: formatIssueBody({
207
241
  ...event,
208
- failures: [failure], // Only include this specific failure
242
+ failures: [failure],
209
243
  }),
210
244
  labels: EMAIL_HEALTH_LABELS,
211
245
  });
@@ -237,7 +237,38 @@ export async function processGapAlert(
237
237
  const github = new GitHubClient(env);
238
238
 
239
239
  try {
240
- // Create the issue
240
+ // Search for an existing OPEN gap alert issue before creating a new one.
241
+ // This prevents daily duplicate issues when coverage stays below threshold.
242
+ const openIssues = await github.searchIssues(
243
+ owner,
244
+ repo,
245
+ `"Data Coverage Gap: ${event.project}" is:open label:cf:gap-alert`
246
+ );
247
+
248
+ if (openIssues.length > 0) {
249
+ const existing = openIssues[0];
250
+ await github.addComment(
251
+ owner,
252
+ repo,
253
+ existing.number,
254
+ `### Coverage still below threshold (${getDateKey()})\n\n` +
255
+ `Coverage: **${event.coveragePct}%** (threshold: 90%)\n` +
256
+ `Hours with data: ${event.hoursWithData}/${event.expectedHours}\n` +
257
+ `Missing hours: ${event.missingHours.length}`
258
+ );
259
+ await setGapAlertDedup(env.PLATFORM_CACHE, event.project, existing.number);
260
+
261
+ console.log(
262
+ `Commented on existing gap alert issue #${existing.number} for ${event.project}`
263
+ );
264
+
265
+ return {
266
+ processed: false,
267
+ skipped: `Commented on existing issue #${existing.number}`,
268
+ };
269
+ }
270
+
271
+ // No existing open issue — create a new one
241
272
  const issue = await github.createIssue({
242
273
  owner,
243
274
  repo,