@littlebearapps/platform-admin-sdk 2.1.0 → 2.3.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 (122) hide show
  1. package/README.md +2 -5
  2. package/dist/check-upgrade.d.ts +29 -0
  3. package/dist/check-upgrade.js +97 -0
  4. package/dist/index.js +59 -4
  5. package/dist/manifest.d.ts +2 -0
  6. package/dist/scaffold.js +5 -1
  7. package/dist/templates.d.ts +6 -1
  8. package/dist/templates.js +141 -3
  9. package/dist/upgrade.d.ts +1 -0
  10. package/dist/upgrade.js +21 -2
  11. package/package.json +1 -1
  12. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  13. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  14. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  15. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  16. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  17. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  18. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  19. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  20. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  21. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  22. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  23. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  24. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  25. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  26. package/templates/full/dashboard/src/pages/map.astro +561 -0
  27. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  28. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  29. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  30. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  31. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  32. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  33. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  34. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  35. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  36. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  37. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  38. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  39. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  40. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  41. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  42. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  43. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  44. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  45. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  46. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  47. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  48. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  49. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  50. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  51. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  52. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  53. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  54. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  55. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  56. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  57. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  58. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  59. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  60. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  61. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  62. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  63. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  64. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  65. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  66. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  67. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  68. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  69. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  70. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  71. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  72. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  73. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  74. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  75. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  76. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  77. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  78. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  79. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  80. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  81. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  82. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  83. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  84. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  85. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  86. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  87. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  88. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  89. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  90. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  91. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  92. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  93. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  94. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  95. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  96. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  97. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  98. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  99. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  100. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  101. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  102. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  103. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  104. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  105. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  107. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  108. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  109. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  110. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  111. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  112. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  113. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  114. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  115. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  116. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  117. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  118. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  119. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  120. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  121. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  122. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,361 @@
1
+ /// <reference types="node" />
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+ import { readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { MockD1Database, MockKVNamespace } from '../helpers/mock-storage';
6
+
7
+ const schema = readFileSync(
8
+ join(__dirname, '..', '..', 'storage', 'd1', 'schema-feedback.sql'),
9
+ 'utf-8'
10
+ );
11
+
12
+ describe('Feedback Events D1 Schema', () => {
13
+ let db: MockD1Database;
14
+ let kv: MockKVNamespace;
15
+
16
+ beforeEach(() => {
17
+ db = new MockD1Database();
18
+ kv = new MockKVNamespace();
19
+ });
20
+
21
+ describe('Table Structure', () => {
22
+ it('should have correct schema structure', () => {
23
+ expect(schema).toContain('CREATE TABLE IF NOT EXISTS feedback_events');
24
+ expect(schema).toContain('feedback_id TEXT PRIMARY KEY');
25
+ expect(schema).toContain('repo TEXT NOT NULL');
26
+ expect(schema).toContain('issue_number INTEGER NOT NULL');
27
+ expect(schema).toContain('category TEXT');
28
+ expect(schema).toContain("sentiment TEXT DEFAULT 'neutral'");
29
+ expect(schema).toContain('related_error_correlation TEXT');
30
+ expect(schema).toContain('user_contact_encrypted TEXT');
31
+ expect(schema).toContain('correlation_confidence REAL');
32
+ expect(schema).toContain('correlation_method TEXT');
33
+ expect(schema).toContain('UNIQUE(repo, issue_number)');
34
+ });
35
+
36
+ it('should have performance indexes', () => {
37
+ expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_category');
38
+ expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_sentiment');
39
+ expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_correlation');
40
+ expect(schema).toContain('CREATE INDEX IF NOT EXISTS idx_feedback_received_at');
41
+ });
42
+
43
+ it('should have auto-update trigger', () => {
44
+ expect(schema).toContain('CREATE TRIGGER IF NOT EXISTS feedback_events_updated_at');
45
+ expect(schema).toContain('AFTER UPDATE ON feedback_events');
46
+ expect(schema).toContain("UPDATE feedback_events SET updated_at = datetime('now')");
47
+ });
48
+ });
49
+
50
+ describe('Insert Operations', () => {
51
+ it('should insert valid feedback event', async () => {
52
+ const result = await db
53
+ .prepare(
54
+ `
55
+ INSERT INTO feedback_events (
56
+ feedback_id, repo, issue_number, category,
57
+ related_error_correlation, correlation_confidence, correlation_method,
58
+ user_contact_encrypted
59
+ )
60
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
61
+ `
62
+ )
63
+ .bind(
64
+ 'delivery-123',
65
+ 'convert-my-file',
66
+ 42,
67
+ 'feature-request',
68
+ 'corr-xyz',
69
+ 0.85,
70
+ 'rule-based',
71
+ 'encrypted-email'
72
+ )
73
+ .run();
74
+
75
+ expect(result.success).toBe(true);
76
+ const insert = db.statements.find((stmt) => stmt.sql.includes('INSERT INTO feedback_events'));
77
+ expect(insert).toBeTruthy();
78
+ expect(insert?.params).toHaveLength(8);
79
+ });
80
+
81
+ it('should insert minimal feedback event (no correlation)', async () => {
82
+ const result = await db
83
+ .prepare(
84
+ `
85
+ INSERT INTO feedback_events (
86
+ feedback_id, repo, issue_number, category
87
+ )
88
+ VALUES (?, ?, ?, ?)
89
+ `
90
+ )
91
+ .bind('delivery-456', 'notebridge', 10, 'bug-report')
92
+ .run();
93
+
94
+ expect(result.success).toBe(true);
95
+ const insert = db.statements.find((stmt) => stmt.sql.includes('INSERT INTO feedback_events'));
96
+ expect(insert).toBeTruthy();
97
+ expect(insert?.params).toEqual(['delivery-456', 'notebridge', 10, 'bug-report']);
98
+ });
99
+ });
100
+
101
+ describe('Upsert Operations', () => {
102
+ it('should perform upsert on conflict', async () => {
103
+ const result = await db
104
+ .prepare(
105
+ `
106
+ INSERT INTO feedback_events (
107
+ feedback_id, repo, issue_number, category,
108
+ related_error_correlation, correlation_confidence, correlation_method
109
+ )
110
+ VALUES (?, ?, ?, ?, ?, ?, ?)
111
+ ON CONFLICT(repo, issue_number) DO UPDATE SET
112
+ category = excluded.category,
113
+ related_error_correlation = excluded.related_error_correlation,
114
+ correlation_confidence = excluded.correlation_confidence,
115
+ correlation_method = excluded.correlation_method
116
+ `
117
+ )
118
+ .bind('delivery-789', 'palette-kit', 5, 'ux-improvement', 'corr-abc', 0.95, 'manual')
119
+ .run();
120
+
121
+ expect(result.success).toBe(true);
122
+ const upsert = db.statements.find((stmt) => stmt.sql.includes('ON CONFLICT'));
123
+ expect(upsert).toBeTruthy();
124
+ expect(upsert?.sql).toContain('DO UPDATE SET');
125
+ });
126
+ });
127
+
128
+ describe('Query Operations', () => {
129
+ beforeEach(() => {
130
+ // Queue mock results for queries
131
+ db.queueRunResult({
132
+ success: true,
133
+ results: [
134
+ {
135
+ feedbackId: 'delivery-123',
136
+ repo: 'convert-my-file',
137
+ issueNumber: 42,
138
+ category: 'feature-request',
139
+ relatedErrorCorrelation: 'corr-xyz',
140
+ correlationConfidence: 0.85,
141
+ correlationMethod: 'rule-based',
142
+ },
143
+ ],
144
+ });
145
+ });
146
+
147
+ it('should query by category', async () => {
148
+ const result = await db
149
+ .prepare(
150
+ `
151
+ SELECT
152
+ feedback_id as feedbackId,
153
+ repo,
154
+ issue_number as issueNumber,
155
+ category,
156
+ related_error_correlation as relatedErrorCorrelation,
157
+ correlation_confidence as correlationConfidence,
158
+ correlation_method as correlationMethod
159
+ FROM feedback_events
160
+ WHERE category = ?
161
+ ORDER BY received_at DESC
162
+ LIMIT ?
163
+ `
164
+ )
165
+ .bind('feature-request', 20)
166
+ .run();
167
+
168
+ expect(result.success).toBe(true);
169
+ const query = db.statements.find((stmt) => stmt.sql.includes('WHERE category = ?'));
170
+ expect(query).toBeTruthy();
171
+ expect(query?.params).toEqual(['feature-request', 20]);
172
+ });
173
+
174
+ it('should query correlated feedback', async () => {
175
+ const result = await db
176
+ .prepare(
177
+ `
178
+ SELECT *
179
+ FROM feedback_events
180
+ WHERE related_error_correlation IS NOT NULL
181
+ ORDER BY correlation_confidence DESC
182
+ `
183
+ )
184
+ .run();
185
+
186
+ expect(result.success).toBe(true);
187
+ const query = db.statements.find((stmt) =>
188
+ stmt.sql.includes('related_error_correlation IS NOT NULL')
189
+ );
190
+ expect(query).toBeTruthy();
191
+ });
192
+
193
+ it('should query by repo with time range', async () => {
194
+ const result = await db
195
+ .prepare(
196
+ `
197
+ SELECT *
198
+ FROM feedback_events
199
+ WHERE repo = ? AND received_at >= datetime('now', '-30 days')
200
+ ORDER BY received_at DESC
201
+ `
202
+ )
203
+ .bind('notebridge')
204
+ .run();
205
+
206
+ expect(result.success).toBe(true);
207
+ const query = db.statements.find((stmt) => stmt.sql.includes("datetime('now', '-30 days')"));
208
+ expect(query).toBeTruthy();
209
+ });
210
+ });
211
+
212
+ describe('Constraint Validation', () => {
213
+ it('should enforce NOT NULL on repo', () => {
214
+ // We're testing that the constraint is defined in the schema
215
+ expect(schema).toContain('repo TEXT NOT NULL');
216
+ });
217
+
218
+ it('should enforce NOT NULL on issue_number', () => {
219
+ expect(schema).toContain('issue_number INTEGER NOT NULL');
220
+ });
221
+
222
+ it('should enforce UNIQUE constraint on (repo, issue_number)', () => {
223
+ expect(schema).toContain('UNIQUE(repo, issue_number)');
224
+ });
225
+ });
226
+
227
+ describe('Default Values', () => {
228
+ it('should have default sentiment as neutral', () => {
229
+ expect(schema).toContain("sentiment TEXT DEFAULT 'neutral'");
230
+ });
231
+
232
+ it('should have default received_at timestamp', () => {
233
+ expect(schema).toContain("received_at TEXT DEFAULT (datetime('now'))");
234
+ });
235
+
236
+ it('should have default updated_at timestamp', () => {
237
+ expect(schema).toContain("updated_at TEXT DEFAULT (datetime('now'))");
238
+ });
239
+ });
240
+
241
+ describe('Correlation Data', () => {
242
+ it('should store correlation confidence as REAL', () => {
243
+ expect(schema).toContain('correlation_confidence REAL');
244
+ });
245
+
246
+ it('should accept null correlation', async () => {
247
+ const result = await db
248
+ .prepare(
249
+ `
250
+ INSERT INTO feedback_events (
251
+ feedback_id, repo, issue_number, category,
252
+ related_error_correlation
253
+ )
254
+ VALUES (?, ?, ?, ?, ?)
255
+ `
256
+ )
257
+ .bind('delivery-999', 'palette-kit', 99, 'other', null)
258
+ .run();
259
+
260
+ expect(result.success).toBe(true);
261
+ const insert = db.statements.find((stmt) => stmt.sql.includes('INSERT INTO feedback_events'));
262
+ expect(insert?.params[4]).toBeNull();
263
+ });
264
+
265
+ it('should store correlation method', async () => {
266
+ const result = await db
267
+ .prepare(
268
+ `
269
+ INSERT INTO feedback_events (
270
+ feedback_id, repo, issue_number, category, correlation_method
271
+ )
272
+ VALUES (?, ?, ?, ?, ?)
273
+ `
274
+ )
275
+ .bind('delivery-111', 'convert-my-file', 11, 'bug-report', 'ml')
276
+ .run();
277
+
278
+ expect(result.success).toBe(true);
279
+ const insert = db.statements.find((stmt) => stmt.sql.includes('correlation_method'));
280
+ expect(insert?.params).toContain('ml');
281
+ });
282
+ });
283
+
284
+ describe('GDPR Compliance', () => {
285
+ it('should allow null user_contact_encrypted', async () => {
286
+ const result = await db
287
+ .prepare(
288
+ `
289
+ INSERT INTO feedback_events (
290
+ feedback_id, repo, issue_number, category, user_contact_encrypted
291
+ )
292
+ VALUES (?, ?, ?, ?, ?)
293
+ `
294
+ )
295
+ .bind('delivery-222', 'notebridge', 22, 'feature-request', null)
296
+ .run();
297
+
298
+ expect(result.success).toBe(true);
299
+ });
300
+
301
+ it('should store encrypted user contact', async () => {
302
+ const encryptedEmail = 'v1.aes-gcm.base64encodeddata';
303
+ const result = await db
304
+ .prepare(
305
+ `
306
+ INSERT INTO feedback_events (
307
+ feedback_id, repo, issue_number, category, user_contact_encrypted
308
+ )
309
+ VALUES (?, ?, ?, ?, ?)
310
+ `
311
+ )
312
+ .bind('delivery-333', 'palette-kit', 33, 'bug-report', encryptedEmail)
313
+ .run();
314
+
315
+ expect(result.success).toBe(true);
316
+ const insert = db.statements.find((stmt) => stmt.sql.includes('user_contact_encrypted'));
317
+ expect(insert?.params).toContain(encryptedEmail);
318
+ });
319
+ });
320
+
321
+ describe('KV Cache Integration', () => {
322
+ it('should cache feedback event after insert', async () => {
323
+ const feedbackData = {
324
+ feedbackId: 'delivery-444',
325
+ repo: 'convert-my-file',
326
+ issueNumber: 44,
327
+ category: 'feature-request',
328
+ relatedErrorCorrelation: null,
329
+ correlationConfidence: 0,
330
+ correlationMethod: 'rule-based',
331
+ userContactEncrypted: null,
332
+ receivedAt: new Date().toISOString(),
333
+ };
334
+
335
+ const cacheKey = `feedback:${feedbackData.repo}:${feedbackData.issueNumber}`;
336
+ await kv.put(cacheKey, JSON.stringify(feedbackData), {
337
+ expirationTtl: 900,
338
+ } as any);
339
+
340
+ expect(kv.store.get(cacheKey)).toBeTruthy();
341
+ const cached = JSON.parse(kv.store.get(cacheKey)!);
342
+ expect(cached.feedbackId).toBe('delivery-444');
343
+ expect(cached.category).toBe('feature-request');
344
+ });
345
+
346
+ it('should invalidate recent list cache on new event', async () => {
347
+ const repo = 'notebridge';
348
+ const cacheKey = `feedback:recent:${repo}`;
349
+
350
+ // Set cache
351
+ await kv.put(cacheKey, JSON.stringify([]), {
352
+ expirationTtl: 900,
353
+ } as any);
354
+
355
+ // Invalidate on new event
356
+ await kv.delete(cacheKey);
357
+
358
+ expect(kv.store.has(cacheKey)).toBe(false);
359
+ });
360
+ });
361
+ });
@@ -0,0 +1,108 @@
1
+ /// <reference types="node" />
2
+ import { Buffer } from 'node:buffer';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { gunzipSync, gzipSync } from 'node:zlib';
5
+ import {
6
+ archiveEvent,
7
+ getArchiveStats,
8
+ listArchivedEvents,
9
+ retrieveEvent,
10
+ type PlatformEvent,
11
+ } from '../../storage/r2/archive';
12
+
13
+ describe('R2 archive helpers', () => {
14
+ let mockR2: {
15
+ put: ReturnType<typeof vi.fn>;
16
+ get: ReturnType<typeof vi.fn>;
17
+ list: ReturnType<typeof vi.fn>;
18
+ };
19
+
20
+ beforeEach(() => {
21
+ mockR2 = {
22
+ put: vi.fn(),
23
+ get: vi.fn(),
24
+ list: vi.fn(),
25
+ };
26
+ });
27
+
28
+ it('archives events using gzip compression and metadata', async () => {
29
+ const event: PlatformEvent = {
30
+ id: 'evt-123',
31
+ source: 'cloakpipe',
32
+ type: 'error_report',
33
+ timestamp: '2025-10-25T10:00:00Z',
34
+ data: { foo: 'bar' },
35
+ metadata: { schema_version: '1.0.0' },
36
+ };
37
+
38
+ const key = await archiveEvent(event, mockR2 as any);
39
+
40
+ expect(key).toBe('2025/10/25/cloakpipe/evt-123.json.gz');
41
+ expect(mockR2.put).toHaveBeenCalledTimes(1);
42
+
43
+ const [, body, options] = mockR2.put.mock.calls[0];
44
+ expect(options).toMatchObject({
45
+ httpMetadata: { contentType: 'application/json', contentEncoding: 'gzip' },
46
+ customMetadata: {
47
+ source: 'cloakpipe',
48
+ type: 'error_report',
49
+ timestamp: '2025-10-25T10:00:00Z',
50
+ },
51
+ });
52
+
53
+ const buffer = Buffer.from(body as ArrayBuffer);
54
+ const decompressed = JSON.parse(gunzipSync(buffer).toString('utf-8'));
55
+ expect(decompressed).toMatchObject({
56
+ id: 'evt-123',
57
+ source: 'cloakpipe',
58
+ type: 'error_report',
59
+ });
60
+ });
61
+
62
+ it('retrieves archived events and decompresses payload', async () => {
63
+ const event: PlatformEvent = {
64
+ id: 'evt-999',
65
+ source: 'homeostat',
66
+ type: 'autofix_result',
67
+ timestamp: '2025-10-26T12:34:56Z',
68
+ data: { status: 'ok' },
69
+ metadata: { schema_version: '1.0.0' },
70
+ };
71
+
72
+ const compressed = gzipSync(Buffer.from(JSON.stringify(event), 'utf-8'));
73
+ mockR2.get.mockResolvedValue({
74
+ arrayBuffer: async () =>
75
+ compressed.buffer.slice(
76
+ compressed.byteOffset,
77
+ compressed.byteOffset + compressed.byteLength
78
+ ),
79
+ });
80
+
81
+ const retrieved = await retrieveEvent('2025/10/26/homeostat/evt-999.json.gz', mockR2 as any);
82
+
83
+ expect(retrieved).toEqual(event);
84
+ });
85
+
86
+ it('lists archived events across date range prefixes', async () => {
87
+ mockR2.list
88
+ .mockResolvedValueOnce({ objects: [{ key: '2025/10/24/cloakpipe/a.json.gz' }] })
89
+ .mockResolvedValueOnce({ objects: [{ key: '2025/10/25/cloakpipe/b.json.gz' }] });
90
+
91
+ const keys = await listArchivedEvents('cloakpipe', '2025-10-24', '2025-10-25', mockR2 as any);
92
+
93
+ expect(keys).toEqual(['2025/10/24/cloakpipe/a.json.gz', '2025/10/25/cloakpipe/b.json.gz']);
94
+ expect(mockR2.list).toHaveBeenCalledTimes(2);
95
+ });
96
+
97
+ it('summarises archive statistics', async () => {
98
+ mockR2.list.mockResolvedValue({
99
+ objects: [
100
+ { key: 'foo', size: 10 },
101
+ { key: 'bar', size: 20 },
102
+ ],
103
+ });
104
+
105
+ const stats = await getArchiveStats(mockR2 as any);
106
+ expect(stats).toEqual({ count: 2, totalSizeBytes: 30, compressionRatio: null });
107
+ });
108
+ });
@@ -0,0 +1,41 @@
1
+ # Dependabot Auto-Merge
2
+ # Automatically merges patch/minor dependency updates when CI passes
3
+ #
4
+ # Reference: GitHub Security Rollout - littlebearapps
5
+
6
+ name: Dependabot Auto-Merge
7
+
8
+ on:
9
+ pull_request:
10
+ types: [opened, synchronize, reopened]
11
+
12
+ permissions:
13
+ contents: write
14
+ pull-requests: write
15
+
16
+ jobs:
17
+ dependabot-automerge:
18
+ name: Auto-merge Dependabot
19
+ runs-on: ubuntu-latest
20
+ if: github.actor == 'dependabot[bot]'
21
+
22
+ steps:
23
+ - name: Dependabot metadata
24
+ id: metadata
25
+ uses: dependabot/fetch-metadata@v2.4.0
26
+ with:
27
+ github-token: "${{ secrets.GITHUB_TOKEN }}"
28
+
29
+ - name: Enable auto-merge for patch/minor updates
30
+ if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor'
31
+ run: gh pr merge --auto --squash "$PR_URL"
32
+ env:
33
+ PR_URL: ${{ github.event.pull_request.html_url }}
34
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35
+
36
+ - name: Log major updates (manual review required)
37
+ if: steps.metadata.outputs.update-type == 'version-update:semver-major'
38
+ run: |
39
+ echo "::notice::Major version update detected - manual review required"
40
+ echo "Package: ${{ steps.metadata.outputs.dependency-names }}"
41
+ echo "Update type: ${{ steps.metadata.outputs.update-type }}"
@@ -0,0 +1,27 @@
1
+ name: Validate Controls
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - 'monitoring/controls.json'
7
+
8
+ jobs:
9
+ validate:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v6
13
+
14
+ - uses: actions/setup-node@v6
15
+ with:
16
+ node-version: '20'
17
+
18
+ - name: Install dependencies
19
+ run: npm ci
20
+
21
+ - name: Validate controls.json
22
+ run: npm run validate:controls
23
+
24
+ - name: Require platform approval note
25
+ if: success()
26
+ run: |
27
+ echo '⚠️ Circuit breaker changes require platform team approval'
@@ -0,0 +1,101 @@
1
+ ---
2
+ /**
3
+ * Breadcrumbs.astro
4
+ * Navigation breadcrumbs for deep pages
5
+ * Truncates middle segments on mobile (shows first and last)
6
+ */
7
+
8
+ interface BreadcrumbItem {
9
+ label: string;
10
+ href?: string; // Optional - current page won't have href
11
+ }
12
+
13
+ interface Props {
14
+ items: BreadcrumbItem[];
15
+ }
16
+
17
+ const { items } = Astro.props;
18
+ ---
19
+
20
+ <nav aria-label="Breadcrumb" class="mb-4">
21
+ <ol class="flex items-center text-sm">
22
+ {
23
+ items.map((item, index) => {
24
+ const isLast = index === items.length - 1;
25
+ const isFirst = index === 0;
26
+ const isMiddle = !isFirst && !isLast;
27
+
28
+ return (
29
+ <li class={`flex items-center ${isMiddle ? 'hidden sm:flex' : ''}`}>
30
+ {/* Separator (not for first item) */}
31
+ {index > 0 && (
32
+ <svg
33
+ class={`w-4 h-4 mx-2 text-gray-400 dark:text-gray-500 flex-shrink-0 ${isMiddle ? 'hidden sm:block' : ''}`}
34
+ fill="none"
35
+ stroke="currentColor"
36
+ viewBox="0 0 24 24"
37
+ >
38
+ <path
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ stroke-width="2"
42
+ d="M9 5l7 7-7 7"
43
+ />
44
+ </svg>
45
+ )}
46
+
47
+ {/* Mobile ellipsis (shown between first and last on mobile when there are middle items) */}
48
+ {isLast && items.length > 2 && (
49
+ <span class="sm:hidden flex items-center">
50
+ <svg
51
+ class="w-4 h-4 mx-2 text-gray-400 dark:text-gray-500"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ viewBox="0 0 24 24"
55
+ >
56
+ <path
57
+ stroke-linecap="round"
58
+ stroke-linejoin="round"
59
+ stroke-width="2"
60
+ d="M9 5l7 7-7 7"
61
+ />
62
+ </svg>
63
+ <span class="text-gray-400 dark:text-gray-500 mr-2">...</span>
64
+ <svg
65
+ class="w-4 h-4 mr-2 text-gray-400 dark:text-gray-500"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ viewBox="0 0 24 24"
69
+ >
70
+ <path
71
+ stroke-linecap="round"
72
+ stroke-linejoin="round"
73
+ stroke-width="2"
74
+ d="M9 5l7 7-7 7"
75
+ />
76
+ </svg>
77
+ </span>
78
+ )}
79
+
80
+ {/* Breadcrumb item */}
81
+ {item.href && !isLast ? (
82
+ <a
83
+ href={item.href}
84
+ class="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
85
+ >
86
+ {item.label}
87
+ </a>
88
+ ) : (
89
+ <span
90
+ class={`${isLast ? 'text-gray-900 dark:text-white font-medium' : 'text-gray-500 dark:text-gray-400'}`}
91
+ aria-current={isLast ? 'page' : undefined}
92
+ >
93
+ {item.label}
94
+ </span>
95
+ )}
96
+ </li>
97
+ );
98
+ })
99
+ }
100
+ </ol>
101
+ </nav>
@@ -0,0 +1,46 @@
1
+ ---
2
+ /**
3
+ * EmptyState.astro
4
+ * Empty state placeholder with icon, title, description, and optional CTA
5
+ * Use when there's no data to display
6
+ */
7
+
8
+ interface Props {
9
+ icon?: string;
10
+ title: string;
11
+ description?: string;
12
+ compact?: boolean;
13
+ }
14
+
15
+ const { icon = '📭', title, description, compact = false } = Astro.props;
16
+ ---
17
+
18
+ <div class={`flex flex-col items-center justify-center text-center ${compact ? 'py-8' : 'py-16'}`}>
19
+ {/* Icon */}
20
+ <div class={`${compact ? 'text-3xl mb-3' : 'text-5xl mb-4'}`} role="img" aria-hidden="true">
21
+ {icon}
22
+ </div>
23
+
24
+ {/* Title */}
25
+ <h3
26
+ class={`font-semibold text-gray-900 dark:text-white ${compact ? 'text-base mb-1' : 'text-lg mb-2'}`}
27
+ >
28
+ {title}
29
+ </h3>
30
+
31
+ {/* Description */}
32
+ {
33
+ description && (
34
+ <p
35
+ class={`text-gray-500 dark:text-gray-400 max-w-md ${compact ? 'text-sm mb-3' : 'text-base mb-6'}`}
36
+ >
37
+ {description}
38
+ </p>
39
+ )
40
+ }
41
+
42
+ {/* CTA Slot */}
43
+ <div class="flex flex-wrap items-center justify-center gap-3">
44
+ <slot name="cta" />
45
+ </div>
46
+ </div>