@littlebearapps/platform-admin-sdk 1.5.0 → 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 (86) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +112 -1
  3. package/package.json +1 -1
  4. package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
  5. package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
  6. package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
  7. package/templates/full/dashboard/src/components/patterns/index.ts +3 -0
  8. package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
  9. package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
  10. package/templates/full/dashboard/src/components/reports/index.ts +2 -0
  11. package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
  13. package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
  17. package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
  18. package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
  19. package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
  20. package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
  21. package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
  22. package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
  23. package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
  24. package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
  25. package/templates/full/scripts/ops/universal-backfill.ts +147 -0
  26. package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
  27. package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
  28. package/templates/shared/.github/workflows/security.yml +33 -0
  29. package/templates/shared/dashboard/src/components/Nav.astro.hbs +2 -0
  30. package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
  31. package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
  32. package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
  33. package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
  34. package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
  35. package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
  36. package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
  37. package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
  38. package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
  39. package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
  40. package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
  41. package/templates/shared/dashboard/src/components/ui/index.ts +6 -0
  42. package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
  43. package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
  44. package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
  45. package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
  46. package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
  47. package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
  48. package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
  49. package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
  50. package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
  51. package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
  52. package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
  53. package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
  54. package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
  55. package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
  56. package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
  57. package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
  58. package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
  59. package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
  60. package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
  61. package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
  62. package/templates/shared/docs/architecture.md +89 -0
  63. package/templates/shared/docs/post-deploy-runbook.md +126 -0
  64. package/templates/shared/docs/troubleshooting.md +91 -0
  65. package/templates/shared/package.json.hbs +5 -0
  66. package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
  67. package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
  68. package/templates/shared/scripts/ops/validate-controls.js +141 -0
  69. package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
  70. package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
  71. package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
  72. package/templates/shared/tests/helpers/mock-d1.ts +61 -0
  73. package/templates/shared/tests/helpers/mock-kv.ts +37 -0
  74. package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
  75. package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
  76. package/templates/shared/vitest.config.ts +18 -0
  77. package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
  78. package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
  79. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  80. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
  81. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
  82. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
  83. package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
  84. package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
  85. package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
  86. package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
@@ -0,0 +1,38 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ export const POST: APIRoute = async ({ locals }) => {
4
+ const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
5
+ const kv = (locals.runtime?.env as { PLATFORM_CACHE?: KVNamespace } | undefined)?.PLATFORM_CACHE;
6
+
7
+ if (!db || !kv) {
8
+ return new Response(JSON.stringify({ error: 'Bindings not available' }), {
9
+ status: 503,
10
+ headers: { 'Content-Type': 'application/json' },
11
+ });
12
+ }
13
+
14
+ try {
15
+ const approved = await db
16
+ .prepare(
17
+ `SELECT id, pattern_type, pattern_value, error_type, priority
18
+ FROM transient_pattern_suggestions
19
+ WHERE status = 'approved'
20
+ ORDER BY match_count DESC
21
+ LIMIT 500`
22
+ )
23
+ .all();
24
+
25
+ const patterns = approved.results ?? [];
26
+ await kv.put('PATTERNS:DYNAMIC:APPROVED', JSON.stringify(patterns), { expirationTtl: 86400 });
27
+
28
+ return new Response(
29
+ JSON.stringify({ refreshed: true, count: patterns.length }),
30
+ { headers: { 'Content-Type': 'application/json' } }
31
+ );
32
+ } catch {
33
+ return new Response(JSON.stringify({ error: 'Cache refresh failed' }), {
34
+ status: 500,
35
+ headers: { 'Content-Type': 'application/json' },
36
+ });
37
+ }
38
+ };
@@ -0,0 +1,36 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ export const POST: 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({ error: 'Database not available' }), {
8
+ status: 503,
9
+ headers: { 'Content-Type': 'application/json' },
10
+ });
11
+ }
12
+
13
+ try {
14
+ const unclassified = await db
15
+ .prepare(
16
+ `SELECT fingerprint, script_name, error_message, priority, occurrence_count
17
+ FROM error_occurrences
18
+ WHERE fingerprint NOT IN (
19
+ SELECT pattern_value FROM transient_pattern_suggestions WHERE status = 'approved'
20
+ )
21
+ ORDER BY occurrence_count DESC
22
+ LIMIT 50`
23
+ )
24
+ .all();
25
+
26
+ return new Response(
27
+ JSON.stringify({ discovered: unclassified.results?.length ?? 0, errors: unclassified.results ?? [] }),
28
+ { headers: { 'Content-Type': 'application/json' } }
29
+ );
30
+ } catch {
31
+ return new Response(JSON.stringify({ error: 'Discovery failed' }), {
32
+ status: 500,
33
+ headers: { 'Content-Type': 'application/json' },
34
+ });
35
+ }
36
+ };
@@ -0,0 +1,39 @@
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({ patterns: [], count: 0 }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const patterns = await db
14
+ .prepare(
15
+ `SELECT s.id, s.pattern_type, s.pattern_value, s.error_type, s.priority,
16
+ s.match_count, s.source, s.created_at,
17
+ COUNT(e.id) as evidence_count
18
+ FROM transient_pattern_suggestions s
19
+ LEFT JOIN pattern_match_evidence e ON e.suggestion_id = s.id
20
+ WHERE s.status = 'shadow'
21
+ AND s.match_count >= 3
22
+ AND s.created_at < datetime('now', '-3 days')
23
+ GROUP BY s.id
24
+ ORDER BY s.match_count DESC
25
+ LIMIT 20`
26
+ )
27
+ .all();
28
+
29
+ return new Response(
30
+ JSON.stringify({ patterns: patterns.results ?? [], count: patterns.results?.length ?? 0 }),
31
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' } }
32
+ );
33
+ } catch {
34
+ return new Response(JSON.stringify({ patterns: [], count: 0 }), {
35
+ status: 500,
36
+ headers: { 'Content-Type': 'application/json' },
37
+ });
38
+ }
39
+ };
@@ -0,0 +1,39 @@
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({ byStatus: {}, total: 0 }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const counts = await db
14
+ .prepare(
15
+ `SELECT status, COUNT(*) as count
16
+ FROM transient_pattern_suggestions
17
+ GROUP BY status
18
+ LIMIT 10`
19
+ )
20
+ .all<{ status: string; count: number }>();
21
+
22
+ const byStatus: Record<string, number> = {};
23
+ let total = 0;
24
+ for (const row of counts.results ?? []) {
25
+ byStatus[row.status] = row.count;
26
+ total += row.count;
27
+ }
28
+
29
+ return new Response(
30
+ JSON.stringify({ byStatus, total }),
31
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' } }
32
+ );
33
+ } catch {
34
+ return new Response(JSON.stringify({ byStatus: {}, total: 0 }), {
35
+ status: 500,
36
+ headers: { 'Content-Type': 'application/json' },
37
+ });
38
+ }
39
+ };
@@ -0,0 +1,43 @@
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({ suggestions: [], total: 0 }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ const status = url.searchParams.get('status') ?? 'pending';
13
+ const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50'), 100);
14
+
15
+ try {
16
+ const suggestions = await db
17
+ .prepare(
18
+ `SELECT id, pattern_type, pattern_value, error_type, priority, status,
19
+ match_count, source, created_at, reviewed_at, reviewer_notes, is_protected
20
+ FROM transient_pattern_suggestions
21
+ WHERE status = ?
22
+ ORDER BY match_count DESC, created_at DESC
23
+ LIMIT ?`
24
+ )
25
+ .bind(status, limit)
26
+ .all();
27
+
28
+ const total = await db
29
+ .prepare(`SELECT COUNT(*) as count FROM transient_pattern_suggestions WHERE status = ? LIMIT 1`)
30
+ .bind(status)
31
+ .first<{ count: number }>();
32
+
33
+ return new Response(
34
+ JSON.stringify({ suggestions: suggestions.results ?? [], total: total?.count ?? 0 }),
35
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
36
+ );
37
+ } catch {
38
+ return new Response(JSON.stringify({ suggestions: [], total: 0 }), {
39
+ status: 500,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ }
43
+ };
@@ -0,0 +1,45 @@
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({ reports: [] }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ const project = url.searchParams.get('project');
13
+ const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 50);
14
+
15
+ try {
16
+ const query = project
17
+ ? `SELECT id, project, scan_type, ai_judge_score, sdk_score, observability_score,
18
+ cost_protection_score, security_score, scan_date, focused_dimensions
19
+ FROM audit_results
20
+ WHERE project = ?
21
+ ORDER BY scan_date DESC
22
+ LIMIT ?`
23
+ : `SELECT id, project, scan_type, ai_judge_score, sdk_score, observability_score,
24
+ cost_protection_score, security_score, scan_date, focused_dimensions
25
+ FROM audit_results
26
+ ORDER BY scan_date DESC
27
+ LIMIT ?`;
28
+
29
+ const stmt = project
30
+ ? db.prepare(query).bind(project, limit)
31
+ : db.prepare(query).bind(limit);
32
+
33
+ const reports = await stmt.all();
34
+
35
+ return new Response(
36
+ JSON.stringify({ reports: reports.results ?? [] }),
37
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' } }
38
+ );
39
+ } catch {
40
+ return new Response(JSON.stringify({ reports: [] }), {
41
+ status: 500,
42
+ headers: { 'Content-Type': 'application/json' },
43
+ });
44
+ }
45
+ };
@@ -0,0 +1,52 @@
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({ daily: [], monthly: [] }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ const days = Math.min(parseInt(url.searchParams.get('days') ?? '30'), 90);
13
+ const startDate = new Date();
14
+ startDate.setDate(startDate.getDate() - days);
15
+ const startStr = startDate.toISOString().slice(0, 10);
16
+
17
+ try {
18
+ const [daily, monthly] = await Promise.all([
19
+ db
20
+ .prepare(
21
+ `SELECT snapshot_date, d1_reads, d1_writes, kv_reads, kv_writes,
22
+ r2_reads, r2_writes, worker_requests, total_cost_usd
23
+ FROM daily_usage_rollups
24
+ WHERE project = 'all' AND snapshot_date >= ?
25
+ ORDER BY snapshot_date ASC
26
+ LIMIT 90`
27
+ )
28
+ .bind(startStr)
29
+ .all(),
30
+ db
31
+ .prepare(
32
+ `SELECT snapshot_month, d1_reads, d1_writes, kv_reads, kv_writes,
33
+ r2_reads, r2_writes, worker_requests, total_cost_usd
34
+ FROM monthly_usage_rollups
35
+ WHERE project = 'all'
36
+ ORDER BY snapshot_month DESC
37
+ LIMIT 12`
38
+ )
39
+ .all(),
40
+ ]);
41
+
42
+ return new Response(
43
+ JSON.stringify({ daily: daily.results ?? [], monthly: monthly.results ?? [] }),
44
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' } }
45
+ );
46
+ } catch {
47
+ return new Response(JSON.stringify({ daily: [], monthly: [] }), {
48
+ status: 500,
49
+ headers: { 'Content-Type': 'application/json' },
50
+ });
51
+ }
52
+ };
@@ -0,0 +1,28 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ export const POST: 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({ error: 'Database not available' }), {
8
+ status: 503,
9
+ headers: { 'Content-Type': 'application/json' },
10
+ });
11
+ }
12
+
13
+ try {
14
+ await db
15
+ .prepare(`INSERT INTO search_index(search_index) VALUES('rebuild')`)
16
+ .run();
17
+
18
+ return new Response(
19
+ JSON.stringify({ ok: true, message: 'FTS5 reindex started' }),
20
+ { headers: { 'Content-Type': 'application/json' } }
21
+ );
22
+ } catch {
23
+ return new Response(JSON.stringify({ error: 'Reindex failed' }), {
24
+ status: 500,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ });
27
+ }
28
+ };
@@ -0,0 +1,27 @@
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({ indexed: 0, lastUpdated: null }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const count = await db
14
+ .prepare(`SELECT COUNT(*) as count FROM search_index LIMIT 1`)
15
+ .first<{ count: number }>();
16
+
17
+ return new Response(
18
+ JSON.stringify({ indexed: count?.count ?? 0 }),
19
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' } }
20
+ );
21
+ } catch {
22
+ return new Response(JSON.stringify({ indexed: 0 }), {
23
+ status: 500,
24
+ headers: { 'Content-Type': 'application/json' },
25
+ });
26
+ }
27
+ };
@@ -0,0 +1,37 @@
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({ settings: {} }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const rows = await db
14
+ .prepare(
15
+ `SELECT key, value, updated_at
16
+ FROM platform_settings
17
+ ORDER BY key ASC
18
+ LIMIT 200`
19
+ )
20
+ .all<{ key: string; value: string; updated_at: string }>();
21
+
22
+ const settings: Record<string, { value: string; updatedAt: string }> = {};
23
+ for (const row of rows.results ?? []) {
24
+ settings[row.key] = { value: row.value, updatedAt: row.updated_at };
25
+ }
26
+
27
+ return new Response(
28
+ JSON.stringify({ settings }),
29
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' } }
30
+ );
31
+ } catch {
32
+ return new Response(JSON.stringify({ settings: {} }), {
33
+ status: 500,
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
37
+ };
@@ -0,0 +1,41 @@
1
+ import type { APIRoute } from 'astro';
2
+
3
+ export const POST: APIRoute = async ({ request, locals }) => {
4
+ const db = (locals.runtime?.env as { PLATFORM_DB?: D1Database } | undefined)?.PLATFORM_DB;
5
+
6
+ if (!db) {
7
+ return new Response(JSON.stringify({ error: 'Database not available' }), {
8
+ status: 503,
9
+ headers: { 'Content-Type': 'application/json' },
10
+ });
11
+ }
12
+
13
+ try {
14
+ const body = (await request.json()) as { key?: string; value?: string };
15
+ if (!body.key || body.value === undefined) {
16
+ return new Response(JSON.stringify({ error: 'key and value are required' }), {
17
+ status: 400,
18
+ headers: { 'Content-Type': 'application/json' },
19
+ });
20
+ }
21
+
22
+ await db
23
+ .prepare(
24
+ `INSERT INTO platform_settings (key, value, updated_at)
25
+ VALUES (?, ?, datetime('now'))
26
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
27
+ )
28
+ .bind(body.key, body.value)
29
+ .run();
30
+
31
+ return new Response(
32
+ JSON.stringify({ ok: true, key: body.key }),
33
+ { headers: { 'Content-Type': 'application/json' } }
34
+ );
35
+ } catch {
36
+ return new Response(JSON.stringify({ error: 'Update failed' }), {
37
+ status: 500,
38
+ headers: { 'Content-Type': 'application/json' },
39
+ });
40
+ }
41
+ };
@@ -0,0 +1,56 @@
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({ nodes: [], edges: [] }), {
8
+ headers: { 'Content-Type': 'application/json' },
9
+ });
10
+ }
11
+
12
+ try {
13
+ const [workers, bindings] = await Promise.all([
14
+ db
15
+ .prepare(
16
+ `SELECT script_name, project, worker_type, last_seen_at
17
+ FROM resource_project_mapping
18
+ WHERE resource_type = 'worker'
19
+ ORDER BY script_name ASC
20
+ LIMIT 100`
21
+ )
22
+ .all(),
23
+ db
24
+ .prepare(
25
+ `SELECT source_worker, target_worker, binding_type
26
+ FROM service_bindings
27
+ ORDER BY source_worker ASC
28
+ LIMIT 200`
29
+ )
30
+ .all(),
31
+ ]);
32
+
33
+ const nodes = (workers.results ?? []).map((w: Record<string, unknown>) => ({
34
+ id: w.script_name as string,
35
+ project: w.project as string,
36
+ type: w.worker_type as string,
37
+ lastSeen: w.last_seen_at as string,
38
+ }));
39
+
40
+ const edges = (bindings.results ?? []).map((b: Record<string, unknown>) => ({
41
+ from: b.source_worker as string,
42
+ to: b.target_worker as string,
43
+ type: b.binding_type as string,
44
+ }));
45
+
46
+ return new Response(
47
+ JSON.stringify({ nodes, edges }),
48
+ { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' } }
49
+ );
50
+ } catch {
51
+ return new Response(JSON.stringify({ nodes: [], edges: [] }), {
52
+ status: 500,
53
+ headers: { 'Content-Type': 'application/json' },
54
+ });
55
+ }
56
+ };
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Universal Backfill Script (Full Tier)
4
+ *
5
+ * Orchestrates the hourly → daily → monthly cascade with progress reporting.
6
+ * Runs each step in sequence, passing date ranges through the pipeline.
7
+ *
8
+ * Prerequisites:
9
+ * CLOUDFLARE_API_TOKEN — API token with Analytics:Read + D1:Write permissions
10
+ * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
11
+ * D1_DATABASE_ID — Your platform-metrics D1 database ID
12
+ *
13
+ * Usage:
14
+ * npx tsx scripts/ops/universal-backfill.ts
15
+ * npx tsx scripts/ops/universal-backfill.ts --dry-run
16
+ * npx tsx scripts/ops/universal-backfill.ts --start 2026-02-01 --end 2026-02-28
17
+ * npx tsx scripts/ops/universal-backfill.ts --skip-hourly
18
+ */
19
+
20
+ import { execSync } from 'node:child_process';
21
+ import { resolve } from 'node:path';
22
+
23
+ const SCRIPTS_DIR = resolve(import.meta.dirname);
24
+
25
+ interface Args {
26
+ start?: string;
27
+ end?: string;
28
+ dryRun: boolean;
29
+ skipHourly: boolean;
30
+ }
31
+
32
+ function parseArgs(): Args {
33
+ const args = process.argv.slice(2);
34
+ const result: Args = { dryRun: false, skipHourly: false };
35
+
36
+ for (let i = 0; i < args.length; i++) {
37
+ if (args[i] === '--start' && args[i + 1]) result.start = args[++i];
38
+ else if (args[i] === '--end' && args[i + 1]) result.end = args[++i];
39
+ else if (args[i] === '--dry-run') result.dryRun = true;
40
+ else if (args[i] === '--skip-hourly') result.skipHourly = true;
41
+ }
42
+
43
+ if (!result.start) {
44
+ const d = new Date();
45
+ d.setDate(d.getDate() - 7);
46
+ result.start = d.toISOString().slice(0, 10);
47
+ }
48
+ if (!result.end) {
49
+ result.end = new Date().toISOString().slice(0, 10);
50
+ }
51
+
52
+ return result;
53
+ }
54
+
55
+ function runStep(name: string, script: string, extraArgs: string[]) {
56
+ console.log(`\n${'='.repeat(60)}`);
57
+ console.log(`Step: ${name}`);
58
+ console.log('='.repeat(60));
59
+
60
+ const cmd = `npx tsx ${script} ${extraArgs.join(' ')}`;
61
+ console.log(`Running: ${cmd}\n`);
62
+
63
+ try {
64
+ execSync(cmd, { stdio: 'inherit', env: process.env });
65
+ console.log(`\n${name}: COMPLETE`);
66
+ return true;
67
+ } catch (err) {
68
+ console.error(`\n${name}: FAILED`);
69
+ return false;
70
+ }
71
+ }
72
+
73
+ async function main() {
74
+ const args = parseArgs();
75
+ const dateArgs = [];
76
+ if (args.start) dateArgs.push('--start', args.start);
77
+ if (args.end) dateArgs.push('--end', args.end);
78
+ if (args.dryRun) dateArgs.push('--dry-run');
79
+
80
+ console.log('Universal Backfill Pipeline');
81
+ console.log(`Range: ${args.start} → ${args.end}`);
82
+ console.log(`Dry run: ${args.dryRun}`);
83
+ console.log(`Skip hourly: ${args.skipHourly}`);
84
+
85
+ const startTime = Date.now();
86
+ const results: Array<{ step: string; success: boolean }> = [];
87
+
88
+ // Step 1: Hourly backfill (optional — requires GraphQL API, rate-limited)
89
+ if (!args.skipHourly) {
90
+ const success = runStep(
91
+ '1/3: Hourly Backfill',
92
+ resolve(SCRIPTS_DIR, 'backfill-cloudflare-hourly.ts'),
93
+ dateArgs
94
+ );
95
+ results.push({ step: 'Hourly', success });
96
+ if (!success && !args.dryRun) {
97
+ console.warn('\nHourly backfill failed — continuing with daily rollups using existing hourly data.');
98
+ }
99
+ } else {
100
+ console.log('\nSkipping hourly backfill (--skip-hourly)');
101
+ results.push({ step: 'Hourly', success: true });
102
+ }
103
+
104
+ // Step 2: Daily rollups
105
+ const dailySuccess = runStep(
106
+ '2/3: Daily Rollups',
107
+ resolve(SCRIPTS_DIR, 'backfill-cloudflare-daily.ts'),
108
+ dateArgs
109
+ );
110
+ results.push({ step: 'Daily', success: dailySuccess });
111
+
112
+ // Step 3: Monthly rollups
113
+ const monthStart = args.start?.slice(0, 7) ?? '';
114
+ const monthEnd = args.end?.slice(0, 7) ?? '';
115
+ const monthlyArgs = [];
116
+ if (monthStart) monthlyArgs.push('--start', monthStart);
117
+ if (monthEnd) monthlyArgs.push('--end', monthEnd);
118
+ if (args.dryRun) monthlyArgs.push('--dry-run');
119
+
120
+ const monthlySuccess = runStep(
121
+ '3/3: Monthly Rollups',
122
+ resolve(SCRIPTS_DIR, 'backfill-monthly-rollups.ts'),
123
+ monthlyArgs
124
+ );
125
+ results.push({ step: 'Monthly', success: monthlySuccess });
126
+
127
+ // Summary
128
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
129
+ console.log(`\n${'='.repeat(60)}`);
130
+ console.log('Pipeline Summary');
131
+ console.log('='.repeat(60));
132
+ for (const r of results) {
133
+ console.log(` ${r.success ? 'PASS' : 'FAIL'} ${r.step}`);
134
+ }
135
+ console.log(`\nTotal time: ${elapsed}s`);
136
+
137
+ const failed = results.filter((r) => !r.success);
138
+ if (failed.length > 0) {
139
+ console.error(`\n${failed.length} step(s) failed.`);
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ main().catch((err) => {
145
+ console.error('Fatal error:', err);
146
+ process.exit(1);
147
+ });
@@ -0,0 +1,42 @@
1
+ # Contract Schema Validation
2
+ #
3
+ # Validates JSON schemas and TypeScript types match expected contracts.
4
+ # Runs schema validation against fixture data.
5
+ #
6
+ # Checks performed:
7
+ # - JSON schema validation (envelope, error_report)
8
+ # - TypeScript type compilation
9
+ # - Contract fixture validation
10
+
11
+ name: Contract Check
12
+
13
+ on:
14
+ push:
15
+ branches: [main]
16
+ paths:
17
+ - 'contracts/**'
18
+ - 'tests/contract/**'
19
+ pull_request:
20
+ branches: [main]
21
+ paths:
22
+ - 'contracts/**'
23
+ - 'tests/contract/**'
24
+
25
+ jobs:
26
+ validate:
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+
31
+ - uses: actions/setup-node@v4
32
+ with:
33
+ node-version: '22'
34
+ cache: 'npm'
35
+
36
+ - run: npm ci
37
+
38
+ - name: Validate schemas
39
+ run: npm run validate:schemas
40
+
41
+ - name: Run contract tests
42
+ run: npx vitest run tests/contract/