@littlebearapps/platform-admin-sdk 1.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 (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.ts +16 -0
  3. package/dist/index.js +89 -0
  4. package/dist/prompts.d.ts +27 -0
  5. package/dist/prompts.js +80 -0
  6. package/dist/scaffold.d.ts +5 -0
  7. package/dist/scaffold.js +65 -0
  8. package/dist/templates.d.ts +16 -0
  9. package/dist/templates.js +131 -0
  10. package/package.json +46 -0
  11. package/templates/full/migrations/006_pattern_discovery.sql +199 -0
  12. package/templates/full/migrations/007_notifications_search.sql +127 -0
  13. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  14. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  15. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  16. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  17. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  18. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  19. package/templates/full/workers/pattern-discovery.ts +661 -0
  20. package/templates/full/workers/platform-alert-router.ts +1809 -0
  21. package/templates/full/workers/platform-notifications.ts +424 -0
  22. package/templates/full/workers/platform-search.ts +480 -0
  23. package/templates/full/workers/platform-settings.ts +436 -0
  24. package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
  25. package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
  26. package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
  27. package/templates/full/wrangler.search.jsonc.hbs +16 -0
  28. package/templates/full/wrangler.settings.jsonc.hbs +23 -0
  29. package/templates/shared/README.md.hbs +69 -0
  30. package/templates/shared/config/budgets.yaml.hbs +72 -0
  31. package/templates/shared/config/services.yaml.hbs +45 -0
  32. package/templates/shared/migrations/001_core_tables.sql +117 -0
  33. package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
  34. package/templates/shared/migrations/003_feature_tracking.sql +250 -0
  35. package/templates/shared/migrations/004_settings_alerts.sql +452 -0
  36. package/templates/shared/migrations/seed.sql.hbs +4 -0
  37. package/templates/shared/package.json.hbs +21 -0
  38. package/templates/shared/scripts/sync-config.ts +242 -0
  39. package/templates/shared/tsconfig.json +12 -0
  40. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  41. package/templates/shared/workers/lib/billing.ts +293 -0
  42. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  43. package/templates/shared/workers/lib/control.ts +292 -0
  44. package/templates/shared/workers/lib/economics.ts +368 -0
  45. package/templates/shared/workers/lib/metrics.ts +103 -0
  46. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  47. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  48. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  49. package/templates/shared/workers/lib/shared/types.ts +58 -0
  50. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  51. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  52. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  53. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  54. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  55. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  56. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  57. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  58. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  59. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  60. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  61. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  62. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  63. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  64. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  65. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  66. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  67. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  68. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  69. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  70. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  71. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  72. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  73. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  74. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  75. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  76. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  77. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  78. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  79. package/templates/shared/workers/platform-usage.ts +1915 -0
  80. package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
  81. package/templates/standard/migrations/005_error_collection.sql +162 -0
  82. package/templates/standard/workers/error-collector.ts +2670 -0
  83. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  84. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  85. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  86. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  87. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  88. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  89. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  90. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  91. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  92. package/templates/standard/workers/platform-sentinel.ts +1744 -0
  93. package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
  94. package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Platform Search Worker
3
+ *
4
+ * Platform-wide full-text search using SQLite FTS5.
5
+ * Searches across errors, patterns, settings, pages, and services.
6
+ *
7
+ * Storage:
8
+ * - D1: search_index table with FTS5 virtual table
9
+ *
10
+ * @module workers/platform-search
11
+ * @created 2026-02-03
12
+ * @task task-303.1
13
+ */
14
+
15
+ import type {
16
+ KVNamespace,
17
+ ExecutionContext,
18
+ D1Database,
19
+ } from '@cloudflare/workers-types';
20
+ import {
21
+ withFeatureBudget,
22
+ CircuitBreakerError,
23
+ createLoggerFromRequest,
24
+ } from '@littlebearapps/platform-consumer-sdk';
25
+
26
+ // =============================================================================
27
+ // TYPES
28
+ // =============================================================================
29
+
30
+ interface Env {
31
+ PLATFORM_DB: D1Database;
32
+ PLATFORM_CACHE: KVNamespace;
33
+ CLOUDFLARE_ACCOUNT_ID: string;
34
+ }
35
+
36
+ interface SearchDocument {
37
+ id: string;
38
+ content_type: string;
39
+ project: string | null;
40
+ title: string;
41
+ content: string;
42
+ url: string;
43
+ metadata: string | null;
44
+ indexed_at: number;
45
+ source_updated_at: number | null;
46
+ }
47
+
48
+ interface SearchResult extends SearchDocument {
49
+ rank: number;
50
+ snippet: string;
51
+ parsed_metadata?: Record<string, unknown>;
52
+ }
53
+
54
+ interface IndexDocumentRequest {
55
+ id: string;
56
+ content_type: string;
57
+ project?: string;
58
+ title: string;
59
+ content: string;
60
+ url: string;
61
+ metadata?: Record<string, unknown>;
62
+ source_updated_at?: number;
63
+ }
64
+
65
+ // =============================================================================
66
+ // CONSTANTS
67
+ // =============================================================================
68
+
69
+ const FEATURE_ID = 'platform:search:api';
70
+ const VALID_CONTENT_TYPES = ['error', 'pattern', 'setting', 'page', 'service', 'opportunity', 'draft', 'project'];
71
+ const MAX_RESULTS = 100;
72
+ const DEFAULT_LIMIT = 20;
73
+
74
+ // =============================================================================
75
+ // HELPERS
76
+ // =============================================================================
77
+
78
+ function sanitizeQuery(query: string): string {
79
+ // Escape special FTS5 characters and prepare for MATCH
80
+ // Remove potentially dangerous characters while preserving search intent
81
+ return query
82
+ .replace(/['"]/g, '') // Remove quotes
83
+ .replace(/[-+*()]/g, ' ') // Replace operators with spaces
84
+ .trim()
85
+ .split(/\s+/)
86
+ .filter((term) => term.length > 0)
87
+ .map((term) => `"${term}"*`) // Prefix match each term
88
+ .join(' ');
89
+ }
90
+
91
+ function extractSnippet(content: string, query: string, maxLength: number = 200): string {
92
+ const lowerContent = content.toLowerCase();
93
+ const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 2);
94
+
95
+ // Find the first matching term
96
+ let startIndex = 0;
97
+ for (const term of terms) {
98
+ const index = lowerContent.indexOf(term);
99
+ if (index !== -1) {
100
+ startIndex = Math.max(0, index - 50);
101
+ break;
102
+ }
103
+ }
104
+
105
+ // Extract snippet around the match
106
+ let snippet = content.substring(startIndex, startIndex + maxLength);
107
+
108
+ // Add ellipsis if truncated
109
+ if (startIndex > 0) {
110
+ snippet = '...' + snippet;
111
+ }
112
+ if (startIndex + maxLength < content.length) {
113
+ snippet = snippet + '...';
114
+ }
115
+
116
+ return snippet;
117
+ }
118
+
119
+ // =============================================================================
120
+ // API HANDLERS
121
+ // =============================================================================
122
+
123
+ async function handleSearch(env: Env, url: URL): Promise<Response> {
124
+ const query = url.searchParams.get('q');
125
+ if (!query || query.trim().length === 0) {
126
+ return Response.json({ error: 'Query parameter q is required' }, { status: 400 });
127
+ }
128
+
129
+ const contentType = url.searchParams.get('type');
130
+ const project = url.searchParams.get('project');
131
+ const limit = Math.min(
132
+ parseInt(url.searchParams.get('limit') || String(DEFAULT_LIMIT), 10),
133
+ MAX_RESULTS
134
+ );
135
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
136
+
137
+ // Sanitize query for FTS5
138
+ const sanitizedQuery = sanitizeQuery(query);
139
+ if (sanitizedQuery.length === 0) {
140
+ return Response.json({ results: [], count: 0, query });
141
+ }
142
+
143
+ // Build the search query
144
+ // FTS5 MATCH query with ranking by bm25
145
+ let sql = `
146
+ SELECT
147
+ search_index.*,
148
+ bm25(search_fts) as rank
149
+ FROM search_fts
150
+ JOIN search_index ON search_fts.rowid = search_index.rowid
151
+ WHERE search_fts MATCH ?
152
+ `;
153
+ const params: (string | number)[] = [sanitizedQuery];
154
+
155
+ if (contentType && VALID_CONTENT_TYPES.includes(contentType)) {
156
+ sql += ' AND search_index.content_type = ?';
157
+ params.push(contentType);
158
+ }
159
+
160
+ if (project) {
161
+ sql += ' AND (search_index.project = ? OR search_index.project IS NULL)';
162
+ params.push(project);
163
+ }
164
+
165
+ sql += ' ORDER BY rank LIMIT ? OFFSET ?';
166
+ params.push(limit, offset);
167
+
168
+ try {
169
+ const result = await env.PLATFORM_DB.prepare(sql).bind(...params).all<SearchDocument & { rank: number }>();
170
+ const documents = result.results || [];
171
+
172
+ // Enrich results with snippets and parsed metadata
173
+ const results: SearchResult[] = documents.map((doc) => ({
174
+ ...doc,
175
+ snippet: extractSnippet(doc.content, query),
176
+ parsed_metadata: doc.metadata ? JSON.parse(doc.metadata) : undefined,
177
+ }));
178
+
179
+ // Group by content type for UI
180
+ const grouped: Record<string, SearchResult[]> = {};
181
+ for (const result of results) {
182
+ if (!grouped[result.content_type]) {
183
+ grouped[result.content_type] = [];
184
+ }
185
+ grouped[result.content_type].push(result);
186
+ }
187
+
188
+ return Response.json({
189
+ results,
190
+ grouped,
191
+ count: results.length,
192
+ query,
193
+ filters: {
194
+ type: contentType,
195
+ project,
196
+ },
197
+ });
198
+ } catch (error) {
199
+ // FTS5 query errors are common with malformed input
200
+ console.error('Search error:', error);
201
+ return Response.json({
202
+ results: [],
203
+ count: 0,
204
+ query,
205
+ error: 'Search query could not be processed',
206
+ });
207
+ }
208
+ }
209
+
210
+ async function handleIndex(request: Request, env: Env): Promise<Response> {
211
+ const body = (await request.json()) as IndexDocumentRequest;
212
+
213
+ if (!body.id || !body.content_type || !body.title || !body.content || !body.url) {
214
+ return Response.json(
215
+ { error: 'Missing required fields: id, content_type, title, content, url' },
216
+ { status: 400 }
217
+ );
218
+ }
219
+
220
+ if (!VALID_CONTENT_TYPES.includes(body.content_type)) {
221
+ return Response.json(
222
+ { error: `Invalid content_type. Must be one of: ${VALID_CONTENT_TYPES.join(', ')}` },
223
+ { status: 400 }
224
+ );
225
+ }
226
+
227
+ const now = Math.floor(Date.now() / 1000);
228
+ const metadataJson = body.metadata ? JSON.stringify(body.metadata) : null;
229
+
230
+ // Upsert the document (triggers will handle FTS sync)
231
+ await env.PLATFORM_DB.prepare(
232
+ `INSERT INTO search_index (id, content_type, project, title, content, url, metadata, indexed_at, source_updated_at)
233
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
234
+ ON CONFLICT(id) DO UPDATE SET
235
+ content_type = excluded.content_type,
236
+ project = excluded.project,
237
+ title = excluded.title,
238
+ content = excluded.content,
239
+ url = excluded.url,
240
+ metadata = excluded.metadata,
241
+ indexed_at = excluded.indexed_at,
242
+ source_updated_at = excluded.source_updated_at`
243
+ )
244
+ .bind(
245
+ body.id,
246
+ body.content_type,
247
+ body.project || null,
248
+ body.title,
249
+ body.content,
250
+ body.url,
251
+ metadataJson,
252
+ now,
253
+ body.source_updated_at || null
254
+ )
255
+ .run();
256
+
257
+ return Response.json({ success: true, id: body.id }, { status: 201 });
258
+ }
259
+
260
+ async function handleBulkIndex(request: Request, env: Env): Promise<Response> {
261
+ const body = (await request.json()) as { documents: IndexDocumentRequest[] };
262
+
263
+ if (!body.documents || !Array.isArray(body.documents)) {
264
+ return Response.json({ error: 'Missing documents array' }, { status: 400 });
265
+ }
266
+
267
+ const now = Math.floor(Date.now() / 1000);
268
+ let indexed = 0;
269
+ let failed = 0;
270
+
271
+ for (const doc of body.documents) {
272
+ if (!doc.id || !doc.content_type || !doc.title || !doc.content || !doc.url) {
273
+ failed++;
274
+ continue;
275
+ }
276
+ if (!VALID_CONTENT_TYPES.includes(doc.content_type)) {
277
+ failed++;
278
+ continue;
279
+ }
280
+
281
+ try {
282
+ const metadataJson = doc.metadata ? JSON.stringify(doc.metadata) : null;
283
+ await env.PLATFORM_DB.prepare(
284
+ `INSERT INTO search_index (id, content_type, project, title, content, url, metadata, indexed_at, source_updated_at)
285
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
286
+ ON CONFLICT(id) DO UPDATE SET
287
+ content_type = excluded.content_type,
288
+ project = excluded.project,
289
+ title = excluded.title,
290
+ content = excluded.content,
291
+ url = excluded.url,
292
+ metadata = excluded.metadata,
293
+ indexed_at = excluded.indexed_at,
294
+ source_updated_at = excluded.source_updated_at`
295
+ )
296
+ .bind(
297
+ doc.id,
298
+ doc.content_type,
299
+ doc.project || null,
300
+ doc.title,
301
+ doc.content,
302
+ doc.url,
303
+ metadataJson,
304
+ now,
305
+ doc.source_updated_at || null
306
+ )
307
+ .run();
308
+ indexed++;
309
+ } catch {
310
+ failed++;
311
+ }
312
+ }
313
+
314
+ return Response.json({
315
+ success: failed === 0,
316
+ indexed,
317
+ failed,
318
+ total: body.documents.length,
319
+ });
320
+ }
321
+
322
+ async function handleReindex(env: Env, contentType: string): Promise<Response> {
323
+ if (!VALID_CONTENT_TYPES.includes(contentType)) {
324
+ return Response.json(
325
+ { error: `Invalid content_type. Must be one of: ${VALID_CONTENT_TYPES.join(', ')}` },
326
+ { status: 400 }
327
+ );
328
+ }
329
+
330
+ // Delete all documents of this type (triggers will clean up FTS)
331
+ const result = await env.PLATFORM_DB.prepare(
332
+ 'DELETE FROM search_index WHERE content_type = ?'
333
+ )
334
+ .bind(contentType)
335
+ .run();
336
+
337
+ return Response.json({
338
+ success: true,
339
+ content_type: contentType,
340
+ deleted: result.meta.changes,
341
+ message: 'Index cleared. Documents must be re-indexed by their source workers.',
342
+ });
343
+ }
344
+
345
+ async function handleDelete(env: Env, id: string): Promise<Response> {
346
+ const result = await env.PLATFORM_DB.prepare('DELETE FROM search_index WHERE id = ?')
347
+ .bind(id)
348
+ .run();
349
+
350
+ if (result.meta.changes === 0) {
351
+ return Response.json({ error: 'Document not found' }, { status: 404 });
352
+ }
353
+
354
+ return Response.json({ success: true, deleted: id });
355
+ }
356
+
357
+ async function handleStats(env: Env): Promise<Response> {
358
+ // Get counts by content type
359
+ const typeStats = await env.PLATFORM_DB.prepare(
360
+ `SELECT content_type, COUNT(*) as count
361
+ FROM search_index
362
+ GROUP BY content_type
363
+ ORDER BY count DESC`
364
+ ).all<{ content_type: string; count: number }>();
365
+
366
+ // Get counts by project
367
+ const projectStats = await env.PLATFORM_DB.prepare(
368
+ `SELECT COALESCE(project, 'global') as project, COUNT(*) as count
369
+ FROM search_index
370
+ GROUP BY project
371
+ ORDER BY count DESC`
372
+ ).all<{ project: string; count: number }>();
373
+
374
+ // Get total count
375
+ const totalResult = await env.PLATFORM_DB.prepare(
376
+ 'SELECT COUNT(*) as count FROM search_index'
377
+ ).first<{ count: number }>();
378
+
379
+ // Get oldest and newest indexed
380
+ const rangeResult = await env.PLATFORM_DB.prepare(
381
+ 'SELECT MIN(indexed_at) as oldest, MAX(indexed_at) as newest FROM search_index'
382
+ ).first<{ oldest: number; newest: number }>();
383
+
384
+ return Response.json({
385
+ total: totalResult?.count || 0,
386
+ by_type: typeStats.results || [],
387
+ by_project: projectStats.results || [],
388
+ index_range: {
389
+ oldest: rangeResult?.oldest ? new Date(rangeResult.oldest * 1000).toISOString() : null,
390
+ newest: rangeResult?.newest ? new Date(rangeResult.newest * 1000).toISOString() : null,
391
+ },
392
+ });
393
+ }
394
+
395
+ // =============================================================================
396
+ // MAIN WORKER
397
+ // =============================================================================
398
+
399
+ export default {
400
+ async fetch(
401
+ request: Request,
402
+ env: Env,
403
+ ctx: ExecutionContext
404
+ ): Promise<Response> {
405
+ const url = new URL(request.url);
406
+
407
+ // Health check (lightweight)
408
+ if (url.pathname === '/health') {
409
+ return Response.json({
410
+ status: 'ok',
411
+ service: 'platform-search',
412
+ timestamp: new Date().toISOString(),
413
+ });
414
+ }
415
+
416
+ const log = createLoggerFromRequest(request, env, 'platform-search', FEATURE_ID);
417
+
418
+ try {
419
+ const trackedEnv = withFeatureBudget(env, FEATURE_ID, { ctx });
420
+
421
+ // GET /search - Perform search
422
+ if (url.pathname === '/search' && request.method === 'GET') {
423
+ return await handleSearch(trackedEnv, url);
424
+ }
425
+
426
+ // POST /search/index - Index a document
427
+ if (url.pathname === '/search/index' && request.method === 'POST') {
428
+ return await handleIndex(request, trackedEnv);
429
+ }
430
+
431
+ // POST /search/index/bulk - Bulk index documents
432
+ if (url.pathname === '/search/index/bulk' && request.method === 'POST') {
433
+ return await handleBulkIndex(request, trackedEnv);
434
+ }
435
+
436
+ // POST /search/reindex/:type - Clear and reindex a content type
437
+ const reindexMatch = url.pathname.match(/^\/search\/reindex\/([^/]+)$/);
438
+ if (reindexMatch && request.method === 'POST') {
439
+ return await handleReindex(trackedEnv, reindexMatch[1]);
440
+ }
441
+
442
+ // DELETE /search/index/:id - Delete a document
443
+ const deleteMatch = url.pathname.match(/^\/search\/index\/([^/]+)$/);
444
+ if (deleteMatch && request.method === 'DELETE') {
445
+ return await handleDelete(trackedEnv, deleteMatch[1]);
446
+ }
447
+
448
+ // GET /search/stats - Get index statistics
449
+ if (url.pathname === '/search/stats' && request.method === 'GET') {
450
+ return await handleStats(trackedEnv);
451
+ }
452
+
453
+ // API index
454
+ return Response.json({
455
+ service: 'platform-search',
456
+ version: '1.0.0',
457
+ endpoints: [
458
+ 'GET /health - Health check',
459
+ 'GET /search?q=<query> - Search (with optional type, project, limit, offset)',
460
+ 'POST /search/index - Index a document',
461
+ 'POST /search/index/bulk - Bulk index documents',
462
+ 'POST /search/reindex/:type - Clear index for content type',
463
+ 'DELETE /search/index/:id - Delete a document',
464
+ 'GET /search/stats - Index statistics',
465
+ ],
466
+ valid_content_types: VALID_CONTENT_TYPES,
467
+ });
468
+ } catch (error) {
469
+ if (error instanceof CircuitBreakerError) {
470
+ log.warn('Circuit breaker tripped', error);
471
+ return Response.json(
472
+ { error: 'Service temporarily unavailable' },
473
+ { status: 503, headers: { 'Retry-After': '60' } }
474
+ );
475
+ }
476
+ log.error('Request failed', error);
477
+ return Response.json({ error: 'Internal server error' }, { status: 500 });
478
+ }
479
+ },
480
+ };