## 📈 Activity
-
+
## 👥 Contributors
Thanks to everyone who has contributed to making Claude better for everyone!
+
+
+
+
+
+
diff --git a/__mocks__/@prisma/client.ts b/__mocks__/@prisma/client.ts
new file mode 100644
index 000000000..ec7eaf212
--- /dev/null
+++ b/__mocks__/@prisma/client.ts
@@ -0,0 +1,1048 @@
+/**
+ * TEST: Prismocker approach (our custom Prisma mock)
+ *
+ * This mock replaces PrismaClient with PrismockerClient for all tests.
+ *
+ * IMPORTANT: Jest automatically uses __mocks__ directory (no explicit registration needed).
+ *
+ * Prismocker is our custom, standalone Prisma mock that:
+ * - Works perfectly with pnpm (no module resolution issues)
+ * - Supports all Prisma operations we use
+ * - Is type-safe using Prisma's generated types
+ * - Is simpler and faster than Prismock
+ *
+ * TypeScript types still come from the real @prisma/client module for type checking.
+ *
+ * This is the SINGLE mock file for Prisma.
+ */
+
+// Import from built dist files
+// CRITICAL: Use require to load CommonJS dist file directly
+// The dist/index.js file is pre-compiled and doesn't need transformation
+// Using CommonJS for Jest compatibility (Jest doesn't fully support ESM in mocks)
+const path = require('path');
+
+// Use require() to load pre-compiled CommonJS dist file
+// This file is already compiled and doesn't need transformation
+const prismockerPath = path.resolve(__dirname, '../../packages/prismocker/dist/index.cjs');
+const prismockerModule = require(prismockerPath);
+
+const createPrismocker = prismockerModule.createPrismocker || prismockerModule.default;
+
+// Create Prisma.Decimal fallback class
+// NOTE: We do NOT try to require the real @prisma/client/runtime/library here
+// because that would trigger loading the real Prisma client, which requires
+// .prisma/client/default to exist (which doesn't in tests).
+// Instead, we use a minimal Decimal class that matches Prisma's Decimal API.
+class Decimal {
+ value: any;
+ constructor(value: any) {
+ this.value = value;
+ }
+ toString() {
+ return String(this.value);
+ }
+ toNumber() {
+ return Number(this.value);
+ }
+ toFixed(decimalPlaces?: number) {
+ return Number(this.value).toFixed(decimalPlaces);
+ }
+ toJSON() {
+ return this.value;
+ }
+}
+const PrismaDecimal = Decimal;
+
+// Create Prisma namespace with Decimal
+// This matches what the real @prisma/client exports
+const Prisma = {
+ Decimal: PrismaDecimal,
+};
+
+// Keep Prisma for export
+const PrismaExport = Prisma;
+
+// Export PrismaClient as a function constructor that returns a PrismockerClient instance
+// When someone does `new PrismaClient()`, they get a PrismockerClient instance
+// This matches the real PrismaClient's usage pattern
+// Using a function instead of a class for compatibility
+function PrismaClient() {
+ const instance = createPrismocker();
+ return instance;
+}
+
+// module.exports will be at the end of the file, after all enum constants are defined
+
+// Prisma and PrismaExport are now defined above, before module.exports
+
+// Export Prisma enum stubs to prevent Vitest from trying to load the actual @prisma/client module
+// These are stub objects that match the structure of Prisma-generated enums
+// When code imports `import { job_status } from '@prisma/client'`, Vitest will use these stubs
+// instead of trying to load the actual module (which requires .prisma/client/default to exist)
+//
+// These enums are defined in prisma/schema.prisma and generated by Prisma
+// The stub values match the enum keys from the schema
+// NOTE: This section is auto-generated by packages/generators/src/scripts/generate-prismocker-enums.ts
+// Run `pnpm generate:prismocker-enums` to regenerate after schema changes
+
+const aal_level = {
+ aal1: 'aal1',
+ aal2: 'aal2',
+ aal3: 'aal3',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const code_challenge_method = {
+ s256: 's256',
+ plain: 'plain',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const factor_status = {
+ unverified: 'unverified',
+ verified: 'verified',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const factor_type = {
+ totp: 'totp',
+ webauthn: 'webauthn',
+ phone: 'phone',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const oauth_authorization_status = {
+ pending: 'pending',
+ approved: 'approved',
+ denied: 'denied',
+ expired: 'expired',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const oauth_client_type = {
+ public: 'public',
+ confidential: 'confidential',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const oauth_registration_type = {
+ dynamic: 'dynamic',
+ manual: 'manual',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const oauth_response_type = {
+ code: 'code',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const one_time_token_type = {
+ confirmation_token: 'confirmation_token',
+ reauthentication_token: 'reauthentication_token',
+ recovery_token: 'recovery_token',
+ email_change_token_new: 'email_change_token_new',
+ email_change_token_current: 'email_change_token_current',
+ phone_change_token: 'phone_change_token',
+ schema: 'schema',
+ auth: 'auth',
+} as const;
+
+const announcement_icon = {
+ ArrowUpRight: 'ArrowUpRight',
+ ArrowRight: 'ArrowRight',
+ AlertTriangle: 'AlertTriangle',
+ Calendar: 'Calendar',
+ BookOpen: 'BookOpen',
+ Sparkles: 'Sparkles',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const announcement_priority = {
+ high: 'high',
+ medium: 'medium',
+ low: 'low',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const announcement_tag = {
+ Feature: 'Feature',
+ New: 'New',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const announcement_variant = {
+ default: 'default',
+ outline: 'outline',
+ secondary: 'secondary',
+ destructive: 'destructive',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const app_setting_category = {
+ feature_flag: 'feature_flag',
+ config: 'config',
+ secret: 'secret',
+ experimental: 'experimental',
+ maintenance: 'maintenance',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const category_icon = {
+ BookOpen: 'BookOpen',
+ Briefcase: 'Briefcase',
+ FileText: 'FileText',
+ Layers: 'Layers',
+ Server: 'Server',
+ Sparkles: 'Sparkles',
+ Terminal: 'Terminal',
+ Webhook: 'Webhook',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const changelog_category = {
+ Added: 'Added',
+ Changed: 'Changed',
+ Fixed: 'Fixed',
+ Security: 'Security',
+ Deprecated: 'Deprecated',
+ Removed: 'Removed',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const changelog_source = {
+ manual: 'manual',
+ jsonbored: 'jsonbored',
+ automation: 'automation',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const company_size = {
+ just_me: 'just_me',
+ two_to_ten: '2-10',
+ eleven_to_fifty: '11-50',
+ fifty_one_to_two_hundred: '51-200',
+ two_hundred_one_to_five_hundred: '201-500',
+ five_hundred_plus: '500+',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const confetti_variant = {
+ success: 'success',
+ celebration: 'celebration',
+ milestone: 'milestone',
+ subtle: 'subtle',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const config_format = {
+ json: 'json',
+ multi: 'multi',
+ hook: 'hook',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const contact_action_type = {
+ internal: 'internal',
+ external: 'external',
+ route: 'route',
+ sheet: 'sheet',
+ easter_egg: 'easter-egg',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const contact_category = {
+ bug: 'bug',
+ feature: 'feature',
+ partnership: 'partnership',
+ general: 'general',
+ other: 'other',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const contact_command_category = {
+ hidden: 'hidden',
+ info: 'info',
+ social: 'social',
+ support: 'support',
+ utility: 'utility',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const contact_command_icon = {
+ Bug: 'Bug',
+ Clock: 'Clock',
+ HelpCircle: 'HelpCircle',
+ Lightbulb: 'Lightbulb',
+ Mail: 'Mail',
+ MessageSquare: 'MessageSquare',
+ Sparkles: 'Sparkles',
+ Trash: 'Trash',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const content_category = {
+ agents: 'agents',
+ mcp: 'mcp',
+ rules: 'rules',
+ commands: 'commands',
+ hooks: 'hooks',
+ statuslines: 'statuslines',
+ skills: 'skills',
+ collections: 'collections',
+ guides: 'guides',
+ jobs: 'jobs',
+ changelog: 'changelog',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const content_field_type = {
+ installation: 'installation',
+ use_cases: 'use_cases',
+ troubleshooting: 'troubleshooting',
+ requirements: 'requirements',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const content_source = {
+ claudepro: 'claudepro',
+ community: 'community',
+ official: 'official',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const copy_type = {
+ llmstxt: 'llmstxt',
+ markdown: 'markdown',
+ code: 'code',
+ link: 'link',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const crud_action = {
+ create: 'create',
+ update: 'update',
+ delete: 'delete',
+ add_item: 'add_item',
+ remove_item: 'remove_item',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const educational_level = {
+ Beginner: 'Beginner',
+ Intermediate: 'Intermediate',
+ Advanced: 'Advanced',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const email_blocklist_reason = {
+ spam_complaint: 'spam_complaint',
+ hard_bounce: 'hard_bounce',
+ repeated_soft_bounce: 'repeated_soft_bounce',
+ manual: 'manual',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const email_frequency = {
+ weekly: 'weekly',
+ biweekly: 'biweekly',
+ monthly: 'monthly',
+ paused: 'paused',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const email_sequence_id = {
+ onboarding: 'onboarding',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const email_sequence_status = {
+ active: 'active',
+ completed: 'completed',
+ cancelled: 'cancelled',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const environment = {
+ development: 'development',
+ preview: 'preview',
+ production: 'production',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const experience_level = {
+ beginner: 'beginner',
+ intermediate: 'intermediate',
+ advanced: 'advanced',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const field_scope = {
+ common: 'common',
+ type_specific: 'type_specific',
+ tags: 'tags',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const field_type = {
+ text: 'text',
+ textarea: 'textarea',
+ number: 'number',
+ select: 'select',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const focus_area_type = {
+ security: 'security',
+ performance: 'performance',
+ documentation: 'documentation',
+ testing: 'testing',
+ code_quality: 'code-quality',
+ automation: 'automation',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const follow_action = {
+ follow: 'follow',
+ unfollow: 'unfollow',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const form_field_icon = {
+ Github: 'Github',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const form_field_type = {
+ text: 'text',
+ textarea: 'textarea',
+ number: 'number',
+ select: 'select',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const form_grid_column = {
+ full: 'full',
+ half: 'half',
+ third: 'third',
+ two_thirds: 'two-thirds',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const form_icon_position = {
+ left: 'left',
+ right: 'right',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const generation_source = {
+ ai: 'ai',
+ manual: 'manual',
+ import: 'import',
+ migration: 'migration',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const grid_column = {
+ full: 'full',
+ half: 'half',
+ third: 'third',
+ two_thirds: 'two-thirds',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const guide_subcategory = {
+ tutorials: 'tutorials',
+ comparisons: 'comparisons',
+ workflows: 'workflows',
+ use_cases: 'use-cases',
+ troubleshooting: 'troubleshooting',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const icon_position = {
+ left: 'left',
+ right: 'right',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const integration_type = {
+ github: 'github',
+ database: 'database',
+ cloud_aws: 'cloud-aws',
+ cloud_gcp: 'cloud-gcp',
+ cloud_azure: 'cloud-azure',
+ communication: 'communication',
+ none: 'none',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const interaction_type = {
+ view: 'view',
+ copy: 'copy',
+ bookmark: 'bookmark',
+ click: 'click',
+ time_spent: 'time_spent',
+ search: 'search',
+ filter: 'filter',
+ screenshot: 'screenshot',
+ share: 'share',
+ download: 'download',
+ pwa_installed: 'pwa_installed',
+ pwa_launched: 'pwa_launched',
+ newsletter_subscribe: 'newsletter_subscribe',
+ contact_interact: 'contact_interact',
+ contact_submit: 'contact_submit',
+ form_started: 'form_started',
+ form_step_completed: 'form_step_completed',
+ form_field_focused: 'form_field_focused',
+ form_template_selected: 'form_template_selected',
+ form_abandoned: 'form_abandoned',
+ form_submitted: 'form_submitted',
+ sponsored_impression: 'sponsored_impression',
+ sponsored_click: 'sponsored_click',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const job_category = {
+ engineering: 'engineering',
+ design: 'design',
+ product: 'product',
+ marketing: 'marketing',
+ sales: 'sales',
+ support: 'support',
+ research: 'research',
+ data: 'data',
+ operations: 'operations',
+ leadership: 'leadership',
+ consulting: 'consulting',
+ education: 'education',
+ other: 'other',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const job_plan = {
+ one_time: 'one-time',
+ subscription: 'subscription',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const job_run_status = {
+ queued: 'queued',
+ running: 'running',
+ retrying: 'retrying',
+ succeeded: 'succeeded',
+ failed: 'failed',
+ cancelled: 'cancelled',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const job_status = {
+ draft: 'draft',
+ pending_payment: 'pending_payment',
+ pending_review: 'pending_review',
+ active: 'active',
+ expired: 'expired',
+ rejected: 'rejected',
+ deleted: 'deleted',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const job_tier = {
+ standard: 'standard',
+ featured: 'featured',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const job_type = {
+ full_time: 'full-time',
+ part_time: 'part-time',
+ contract: 'contract',
+ freelance: 'freelance',
+ internship: 'internship',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const newsletter_interest = {
+ general: 'general',
+ agents: 'agents',
+ mcp: 'mcp',
+ rules: 'rules',
+ commands: 'commands',
+ hooks: 'hooks',
+ statuslines: 'statuslines',
+ skills: 'skills',
+ collections: 'collections',
+ guides: 'guides',
+ jobs: 'jobs',
+ changelog: 'changelog',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const newsletter_source = {
+ footer: 'footer',
+ homepage: 'homepage',
+ modal: 'modal',
+ content_page: 'content_page',
+ inline: 'inline',
+ post_copy: 'post_copy',
+ resend_import: 'resend_import',
+ oauth_signup: 'oauth_signup',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const newsletter_subscription_status = {
+ active: 'active',
+ unsubscribed: 'unsubscribed',
+ bounced: 'bounced',
+ complained: 'complained',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const newsletter_sync_status = {
+ pending: 'pending',
+ synced: 'synced',
+ failed: 'failed',
+ skipped: 'skipped',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const notification_priority = {
+ high: 'high',
+ medium: 'medium',
+ low: 'low',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const notification_type = {
+ announcement: 'announcement',
+ feedback: 'feedback',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const oauth_provider = {
+ discord: 'discord',
+ github: 'github',
+ google: 'google',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const open_graph_type = {
+ profile: 'profile',
+ website: 'website',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const payment_product_type = {
+ job_listing: 'job_listing',
+ mcp_listing: 'mcp_listing',
+ user_content: 'user_content',
+ subscription: 'subscription',
+ premium_membership: 'premium_membership',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const payment_transaction_status = {
+ pending: 'pending',
+ completed: 'completed',
+ failed: 'failed',
+ refunded: 'refunded',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const primary_action_type = {
+ notification: 'notification',
+ copy_command: 'copy_command',
+ copy_script: 'copy_script',
+ scroll: 'scroll',
+ download: 'download',
+ github_link: 'github_link',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const route_group = {
+ primary: 'primary',
+ secondary: 'secondary',
+ actions: 'actions',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const setting_type = {
+ boolean: 'boolean',
+ string: 'string',
+ number: 'number',
+ json: 'json',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const sort_direction = {
+ asc: 'asc',
+ desc: 'desc',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const sort_option = {
+ relevance: 'relevance',
+ date: 'date',
+ popularity: 'popularity',
+ name: 'name',
+ updated: 'updated',
+ created: 'created',
+ views: 'views',
+ trending: 'trending',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const sponsorship_tier = {
+ featured: 'featured',
+ promoted: 'promoted',
+ spotlight: 'spotlight',
+ sponsored: 'sponsored',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const static_route_icon = {
+ Activity: 'Activity',
+ Bookmark: 'Bookmark',
+ Briefcase: 'Briefcase',
+ Building2: 'Building2',
+ Cookie: 'Cookie',
+ FileText: 'FileText',
+ Handshake: 'Handshake',
+ HelpCircle: 'HelpCircle',
+ Home: 'Home',
+ Library: 'Library',
+ Link: 'Link',
+ Mail: 'Mail',
+ Plus: 'Plus',
+ PlusCircle: 'PlusCircle',
+ Search: 'Search',
+ Settings: 'Settings',
+ Shield: 'Shield',
+ Star: 'Star',
+ TrendingUp: 'TrendingUp',
+ User: 'User',
+ Users: 'Users',
+ Wand2: 'Wand2',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const submission_status = {
+ pending: 'pending',
+ approved: 'approved',
+ rejected: 'rejected',
+ spam: 'spam',
+ merged: 'merged',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const submission_type = {
+ agents: 'agents',
+ mcp: 'mcp',
+ rules: 'rules',
+ commands: 'commands',
+ hooks: 'hooks',
+ statuslines: 'statuslines',
+ skills: 'skills',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const subscription_status = {
+ active: 'active',
+ cancelled: 'cancelled',
+ past_due: 'past_due',
+ paused: 'paused',
+ revoked: 'revoked',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const trending_metric = {
+ views: 'views',
+ likes: 'likes',
+ shares: 'shares',
+ downloads: 'downloads',
+ all: 'all',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const trending_period = {
+ today: 'today',
+ week: 'week',
+ month: 'month',
+ year: 'year',
+ all: 'all',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const twitter_card_type = {
+ summary_large_image: 'summary_large_image',
+ summary: 'summary',
+ app: 'app',
+ player: 'player',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const use_case_type = {
+ code_review: 'code-review',
+ api_development: 'api-development',
+ frontend_development: 'frontend-development',
+ data_science: 'data-science',
+ content_creation: 'content-creation',
+ devops_infrastructure: 'devops-infrastructure',
+ general_development: 'general-development',
+ testing_qa: 'testing-qa',
+ security_audit: 'security-audit',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const user_role = {
+ user: 'user',
+ admin: 'admin',
+ moderator: 'moderator',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const user_tier = {
+ free: 'free',
+ pro: 'pro',
+ enterprise: 'enterprise',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const webhook_delivery_status = {
+ running: 'running',
+ succeeded: 'succeeded',
+ failed: 'failed',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const webhook_direction = {
+ inbound: 'inbound',
+ outbound: 'outbound',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const webhook_event_type = {
+ changelog_announcement: 'changelog_announcement',
+ changelog_notification: 'changelog_notification',
+ content_announcement_create: 'content_announcement_create',
+ content_announcement_update: 'content_announcement_update',
+ deployment_succeeded: 'deployment.succeeded',
+ email_bounced: 'email.bounced',
+ email_clicked: 'email.clicked',
+ email_delivery_delayed: 'email.delivery_delayed',
+ job_deleted: 'job_deleted',
+ job_expired: 'job_expired',
+ job_notification_create: 'job_notification_create',
+ job_notification_update: 'job_notification_update',
+ job_status_change: 'job_status_change',
+ job_submission_new: 'job_submission_new',
+ jobs_expired: 'jobs_expired',
+ submission_notification: 'submission_notification',
+ submission_notification_update: 'submission_notification_update',
+ email_complained: 'email.complained',
+ content_announcement: 'content_announcement',
+ error_notification: 'error_notification',
+ order_paid: 'order.paid',
+ order_refunded: 'order.refunded',
+ subscription_canceled: 'subscription.canceled',
+ subscription_renewal: 'subscription.renewal',
+ subscription_revoked: 'subscription.revoked',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const webhook_source = {
+ resend: 'resend',
+ vercel: 'vercel',
+ discord: 'discord',
+ supabase_db: 'supabase_db',
+ custom: 'custom',
+ polar: 'polar',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+const workplace_type = {
+ Remote: 'Remote',
+ On_site: 'On site',
+ Hybrid: 'Hybrid',
+ schema: 'schema',
+ public: 'public',
+} as const;
+
+// Export everything at the end, after all constants are defined
+module.exports = {
+ PrismaClient,
+ Prisma: PrismaExport,
+ aal_level,
+ code_challenge_method,
+ factor_status,
+ factor_type,
+ oauth_authorization_status,
+ oauth_client_type,
+ oauth_registration_type,
+ oauth_response_type,
+ one_time_token_type,
+ announcement_icon,
+ announcement_priority,
+ announcement_tag,
+ announcement_variant,
+ app_setting_category,
+ category_icon,
+ changelog_category,
+ changelog_source,
+ company_size,
+ confetti_variant,
+ config_format,
+ contact_action_type,
+ contact_category,
+ contact_command_category,
+ contact_command_icon,
+ content_category,
+ content_field_type,
+ content_source,
+ copy_type,
+ crud_action,
+ educational_level,
+ email_blocklist_reason,
+ email_frequency,
+ email_sequence_id,
+ email_sequence_status,
+ environment,
+ experience_level,
+ field_scope,
+ field_type,
+ focus_area_type,
+ follow_action,
+ form_field_icon,
+ form_field_type,
+ form_grid_column,
+ form_icon_position,
+ generation_source,
+ grid_column,
+ guide_subcategory,
+ icon_position,
+ integration_type,
+ interaction_type,
+ job_category,
+ job_plan,
+ job_run_status,
+ job_status,
+ job_tier,
+ job_type,
+ newsletter_interest,
+ newsletter_source,
+ newsletter_subscription_status,
+ newsletter_sync_status,
+ notification_priority,
+ notification_type,
+ oauth_provider,
+ open_graph_type,
+ payment_product_type,
+ payment_transaction_status,
+ primary_action_type,
+ route_group,
+ setting_type,
+ sort_direction,
+ sort_option,
+ sponsorship_tier,
+ static_route_icon,
+ submission_status,
+ submission_type,
+ subscription_status,
+ trending_metric,
+ trending_period,
+ twitter_card_type,
+ use_case_type,
+ user_role,
+ user_tier,
+ webhook_delivery_status,
+ webhook_direction,
+ webhook_event_type,
+ webhook_source,
+ workplace_type,
+};
diff --git a/apps/edge/README.md b/apps/edge/README.md
deleted file mode 100644
index 88fc40ca8..000000000
--- a/apps/edge/README.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Supabase Edge Functions
-
-This workspace contains Supabase Edge Functions running on Deno.
-
-## Architecture
-
-- **Runtime**: Deno
-- **Framework**: Supabase Edge Runtime
-- **Location**: `functions/`
-
-## React Version Policy
-
-⚠️ **IMPORTANT**: This workspace uses **React 18** (via Deno imports) because Supabase Edge Runtime and `react-email` / `satori` are optimized for it.
-
-- **Edge**: React 18 (pinned in `functions/deno.json`)
-- **Web**: React 19 (Node.js/Next.js)
-
-Do not attempt to force React 19 here unless you have verified compatibility with Deno and `react-email`.
-
-## Commands
-
-- `pnpm lint`: Lint Deno functions
-- `pnpm type-check`: Check types
-- `pnpm deploy`: Deploy functions
diff --git a/apps/edge/package.json b/apps/edge/package.json
deleted file mode 100644
index a6ca3e967..000000000
--- a/apps/edge/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "edge",
- "version": "1.1.0",
- "private": true,
- "scripts": {
- "lint": "cd supabase/functions && deno lint",
- "lint:fix": "cd supabase/functions && deno lint --fix",
- "type-check": "cd supabase && for dir in functions/*/; do if [ -d \"$dir\" ]; then echo \"Type-checking $(basename $dir)...\"; (cd \"$dir\" && deno check --config ../../deno.json '**/*.ts' '**/*.tsx') || exit 1; fi; done",
- "deploy:functions": "tsx ../../packages/generators/src/bin/deploy-functions.ts"
- }
-}
diff --git a/apps/edge/supabase/config.toml b/apps/edge/supabase/config.toml
deleted file mode 100644
index 781be926d..000000000
--- a/apps/edge/supabase/config.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-# Supabase Edge Functions Configuration
-# This file persists JWT verification settings across redeployments
-
-# =============================================================================
-# Unified Architecture (ARCH-003)
-# =============================================================================
-
-# 1. PUBLIC API MONOLITH
-# Read-only, high-traffic, synchronous endpoints.
-[functions.public-api]
-verify_jwt = false
-import_map = "./functions/public-api/deno.json"
-
-[functions.heyclaude-mcp]
-verify_jwt = true
-import_map = "./functions/heyclaude-mcp/deno.json"
diff --git a/apps/edge/supabase/deno-imports.d.ts b/apps/edge/supabase/deno-imports.d.ts
deleted file mode 100644
index b31abf33f..000000000
--- a/apps/edge/supabase/deno-imports.d.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Deno Import Scheme Declarations
- *
- * TypeScript doesn't natively understand Deno's import schemes (jsr:, npm:, https:, etc.)
- * This file provides ambient module declarations so TypeScript recognizes these imports
- * as valid, allowing IDEs to properly type-check code that uses Deno imports.
- *
- * These declarations don't provide actual types - they just tell TypeScript the modules exist.
- * Actual types come from the packages themselves at runtime via Deno's type resolution.
- *
- * This file intentionally uses `any` types and `export *` for Deno module compatibility.
- */
-
-// JSR (JavaScript Registry) imports
-declare module 'jsr:@supabase/supabase-js@*' {
- export * from '@supabase/supabase-js';
-}
-
-// npm: imports - declare as any to allow imports without type errors
-declare module 'npm:resend@*' {
- const content: any;
- export default content;
- export * from 'resend';
-}
-
-declare module 'npm:react@*' {
- import * as React from 'react';
- export default React;
- export = React;
- export * from 'react';
-
- // Export namespace for type imports like `import type * as React from 'npm:react@*'`
- namespace React {
- export type CSSProperties = import('react').CSSProperties;
- export type ReactElement = import('react').ReactElement;
- export type ComponentType> = import('react').ComponentType
;
- export type FC
> = import('react').FC
;
- export type ReactNode = import('react').ReactNode;
- }
-}
-
-declare module 'npm:@react-email/components@*' {
- const content: any;
- export default content;
- export * from '@react-email/components';
-}
-
-// HTTPS imports - declare as any to allow imports
-// Supports both default and named exports
-// Note: TypeScript doesn't support index signatures in module declarations,
-// so we only declare the specific exports we use. Other exports will work at runtime
-// but won't have types in the IDE.
-declare module 'https://*' {
- const content: any;
- export default content;
- export const __esModule: boolean;
- // Common named exports from HTTPS modules
- export const Webhook: any;
- export const ImageResponse: any;
- export const React: any;
-}
-
-declare module 'http://*' {
- const content: any;
- export default content;
- export const __esModule: boolean;
-}
-
-// Specific declaration for zod-to-json-schema
-declare module 'npm:zod-to-json-schema@*' {
- export function zodToJsonSchema(schema: any): any;
- export default zodToJsonSchema;
-}
-
-// Generic npm: pattern for any npm package
-// NOTE: These catch-all declarations trade away type-safety for pragmatism.
-// As the edge workspace stabilizes, consider:
-// 1. Adding explicit module declarations for high-value packages (similar to npm:react@*, npm:zod-to-json-schema@*)
-// 2. Narrowing or removing these wildcards once core dependencies are covered
-// This prevents accidental 'any' creep while maintaining good DX now.
-declare module 'npm:*' {
- const content: any;
- export default content;
- export const __esModule: boolean;
- // Note: Named exports are handled by specific module declarations above
-}
-
-// Generic jsr: pattern for any JSR package
-// NOTE: Same considerations as npm:* above - consider tightening over time.
-declare module 'jsr:*' {
- const content: any;
- export default content;
- export const __esModule: boolean;
-}
diff --git a/apps/edge/supabase/deno.json b/apps/edge/supabase/deno.json
deleted file mode 100644
index e943fafc3..000000000
--- a/apps/edge/supabase/deno.json
+++ /dev/null
@@ -1,54 +0,0 @@
-{
- "nodeModulesDir": "auto",
- "imports": {
- "@heyclaude/database-types": "../../../packages/database-types/src/index.ts",
- "@heyclaude/shared-runtime/": "../../../packages/shared-runtime/src/",
- "@heyclaude/edge-runtime/": "../../../packages/edge-runtime/src/",
- "@heyclaude/data-layer/": "../../../packages/data-layer/src/",
- "@supabase/supabase-js": "npm:@supabase/supabase-js@2.86.0",
- "@supabase/supabase-js/": "npm:@supabase/supabase-js@2.86.0/",
- "zod": "npm:zod@3.24.1",
- "zod-to-json-schema": "npm:zod-to-json-schema@3.24.1",
- "mcp-lite": "npm:mcp-lite@0.8.2",
- "hono": "npm:hono@4.6.14",
- "hono/": "npm:hono@4.6.14/",
- // sanitize-html@2.17.0 is the latest version (verified 2025-01-27)
- // Security audit shows no known vulnerabilities (pnpm audit verified)
- "sanitize-html": "npm:sanitize-html@2.17.0",
- "react": "npm:react@18.3.1",
- "yoga-layout": "npm:yoga-layout@3.2.1",
- "https://esm.sh/yoga-layout@3.2.1": "npm:yoga-layout@3.2.1",
- "https://esm.sh/yoga-layout@3.2.1/": "npm:yoga-layout@3.2.1/",
- "@imagemagick/magick-wasm": "npm:@imagemagick/magick-wasm@0.0.30",
- "pino": "npm:pino@10.1.0"
- },
- "lint": {
- "include": ["**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules/**", "deno.lock"],
- "rules": {
- "tags": ["recommended"],
- "exclude": ["no-var", "no-explicit-any"]
- }
- },
- "compilerOptions": {
- "lib": ["deno.ns", "deno.unstable", "dom"],
- "types": ["@heyclaude/edge-runtime/deno-globals.d.ts", "@heyclaude/edge-runtime/jsx-types.d.ts", "./tsconfig-setup.d.ts"],
- "strict": true,
- "noImplicitAny": true,
- "strictNullChecks": true,
- "strictFunctionTypes": true,
- "strictPropertyInitialization": true,
- "noImplicitThis": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noImplicitReturns": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
- "exactOptionalPropertyTypes": true,
- "noPropertyAccessFromIndexSignature": true,
- "noImplicitOverride": true,
- "allowUnusedLabels": false,
- "allowUnreachableCode": false,
- "skipLibCheck": true
- }
-}
diff --git a/apps/edge/supabase/deno.lock b/apps/edge/supabase/deno.lock
deleted file mode 100644
index 9bfba13b9..000000000
--- a/apps/edge/supabase/deno.lock
+++ /dev/null
@@ -1,974 +0,0 @@
-{
- "version": "5",
- "specifiers": {
- "jsr:@supabase/supabase-js@2": "2.58.0",
- "npm:@imagemagick/magick-wasm@0.0.30": "0.0.30",
- "npm:@react-email/components@0.0.22": "0.0.22_react@18.3.1",
- "npm:@supabase/auth-js@2.72.0": "2.72.0",
- "npm:@supabase/functions-js@2.5.0": "2.5.0",
- "npm:@supabase/node-fetch@2.6.15": "2.6.15",
- "npm:@supabase/postgrest-js@1.21.4": "1.21.4",
- "npm:@supabase/realtime-js@2.15.5": "2.15.5",
- "npm:@supabase/storage-js@2.12.2": "2.12.2",
- "npm:@supabase/supabase-js@2.86.0": "2.86.0",
- "npm:@types/node@*": "24.2.0",
- "npm:hono@4.6.14": "4.6.14",
- "npm:mcp-lite@0.8.2": "0.8.2",
- "npm:pino@10.1.0": "10.1.0",
- "npm:react@18.3.1": "18.3.1",
- "npm:resend@4.0.0": "4.0.0_react@18.3.1",
- "npm:resend@4.8.0": "4.8.0_react@18.3.1",
- "npm:resend@6.4.2": "6.4.2",
- "npm:resend@6.5.2": "6.5.2",
- "npm:sanitize-html@2.17.0": "2.17.0",
- "npm:sugar-high@0.9.5": "0.9.5",
- "npm:yoga-layout@3.2.1": "3.2.1",
- "npm:zod-to-json-schema@3": "3.24.1_zod@3.24.1",
- "npm:zod-to-json-schema@3.24.1": "3.24.1_zod@3.24.1",
- "npm:zod@3.24.1": "3.24.1"
- },
- "jsr": {
- "@supabase/supabase-js@2.58.0": {
- "integrity": "4d04e72e9f632b451ac7d1a84de0b85249c0097fdf06253f371c1f0a23e62c87",
- "dependencies": [
- "npm:@supabase/auth-js",
- "npm:@supabase/functions-js",
- "npm:@supabase/node-fetch",
- "npm:@supabase/postgrest-js",
- "npm:@supabase/realtime-js",
- "npm:@supabase/storage-js"
- ]
- }
- },
- "npm": {
- "@imagemagick/magick-wasm@0.0.30": {
- "integrity": "sha512-l5pTepsyrM9O3zMdmDHGbncP2Uf4858nylU5tw6GE7QA/Qh99aEFr4eNkEan3q1PT6mV/1Ev0gOgthuK7/kNFQ=="
- },
- "@isaacs/cliui@8.0.2": {
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dependencies": [
- "string-width@5.1.2",
- "string-width-cjs@npm:string-width@4.2.3",
- "strip-ansi@7.1.2",
- "strip-ansi-cjs@npm:strip-ansi@6.0.1",
- "wrap-ansi@8.1.0",
- "wrap-ansi-cjs@npm:wrap-ansi@7.0.0"
- ]
- },
- "@one-ini/wasm@0.1.1": {
- "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
- },
- "@pinojs/redact@0.4.0": {
- "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
- },
- "@pkgjs/parseargs@0.11.0": {
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="
- },
- "@radix-ui/react-compose-refs@1.1.0_react@18.3.1": {
- "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
- "dependencies": [
- "react"
- ]
- },
- "@radix-ui/react-slot@1.1.0_react@18.3.1": {
- "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
- "dependencies": [
- "@radix-ui/react-compose-refs",
- "react"
- ]
- },
- "@react-email/body@0.0.9_react@18.3.1": {
- "integrity": "sha512-bSGF6j+MbfQKYnnN+Kf57lGp/J+ci+435OMIv/BKAtfmNzHL+ptRrsINJELiO8QzwnZmQjTGKSMAMMJiQS+xwQ==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/button@0.0.16_react@18.3.1": {
- "integrity": "sha512-paptUerzDhKHEUmBuT0UecCoqo3N6ZQSyDKC1hFALTwKReGW2xQATisinho9Ybh9ZGw6IZ3n1nGtmX5k2sX70Q==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/code-block@0.0.6_react@18.3.1": {
- "integrity": "sha512-i+TEeI7AyG1pmtO2Mr+TblV08zQnOtTlYB/v45kFMlDWWKTkvIV33oLRqLYOFhCIvoO5fDZA9T+4m6PvhmcNwQ==",
- "dependencies": [
- "prismjs",
- "react"
- ]
- },
- "@react-email/code-inline@0.0.3_react@18.3.1": {
- "integrity": "sha512-SY5Nn4KhjcqqEBHvUwFlOLNmUT78elIGR+Y14eg02LrVKQJ38mFCfXNGDLk4wbP/2dnidkLYq9+60nf7mFMhnQ==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/column@0.0.11_react@18.3.1": {
- "integrity": "sha512-KvrPuQFn0hlItRRL3vmRuOJgKG+8I0oO9HM5ReLMi5Ns313JSEQogCJaXuOEFkOVeuu5YyY6zy/+5Esccc1AxQ==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/components@0.0.22_react@18.3.1": {
- "integrity": "sha512-GO6F+fS3c3aQ6OnqL8esQ/KqtrPGwz80U6uQ8Nd/ETpgFt7y1PXvSGfr8v12wyLffAagdowc/JjoThfIr0L6aA==",
- "dependencies": [
- "@react-email/body",
- "@react-email/button",
- "@react-email/code-block",
- "@react-email/code-inline",
- "@react-email/column",
- "@react-email/container",
- "@react-email/font",
- "@react-email/head",
- "@react-email/heading",
- "@react-email/hr",
- "@react-email/html",
- "@react-email/img",
- "@react-email/link",
- "@react-email/markdown",
- "@react-email/preview",
- "@react-email/render@0.0.17_react@18.3.1_react-dom@18.3.1__react@18.3.1",
- "@react-email/row",
- "@react-email/section",
- "@react-email/tailwind",
- "@react-email/text",
- "react"
- ]
- },
- "@react-email/container@0.0.13_react@18.3.1": {
- "integrity": "sha512-ftke0N1FZl8MX3XXxXiiOaiJOnrQz7ZXUyqNj81K+BK+DePWIVaSmgK6Bu8fFnsgwdKuBdqjZTEtF4sIkU3FuQ==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/font@0.0.7_react@18.3.1": {
- "integrity": "sha512-R0/mfUV/XcUQIALjZUFT9GP+XGmIP1KPz20h9rpS5e4ji6VkQ3ENWlisxrdK5U+KA9iZQrlan+/6tUoTJ9bFsg==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/head@0.0.10_react@18.3.1": {
- "integrity": "sha512-VoH399w0/i3dJFnwH0Ixf9BTuiWhSA/y8PpsCJ7CPw8Mv8WNBqMAAsw0rmrITYI8uPd15LZ2zk2uwRDvqasMRw==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/heading@0.0.13_react@18.3.1": {
- "integrity": "sha512-MYDzjJwljKHBLueLuyqkaHxu6N4aGOL1ms2NNyJ9WXC9mmBnLs4Y/QEf9SjE4Df3AW4iT9uyfVHuaNUb7uq5QA==",
- "dependencies": [
- "@radix-ui/react-slot",
- "react"
- ]
- },
- "@react-email/hr@0.0.9_react@18.3.1": {
- "integrity": "sha512-Rte+EZL3ptH3rkVU3a7fh8/06mZ6Q679tDaWDjsw3878RQC9afWqUPp5lwgA/1pTouLmJlDs2BjRnV6H84O7iw==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/html@0.0.9_react@18.3.1": {
- "integrity": "sha512-NB74xwWaOJZxhpiy6pzkhHvugBa2vvmUa0KKnSwOEIX+WEQH8wj5UUhRN4F+Pmkiqz3QBTETUJiSsNWWFtrHgA==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/img@0.0.9_react@18.3.1": {
- "integrity": "sha512-zDlQWmlSANb2dBYhDaKD12Z4xaGD5mEf3peawBYHGxYySzMLwRT2ANGvFqpDNd7iT0C5po+/9EWR8fS1dLy0QQ==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/link@0.0.9_react@18.3.1": {
- "integrity": "sha512-rRqWGPUTGFwwtMCtsdCHNh0ewOsd4UBG/D12UcwJYFKRb0U6hUG/6VJZE3tB1QYZpLIESdvOLL6ztznh+D749g==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/markdown@0.0.11_react@18.3.1": {
- "integrity": "sha512-KeDTS0bAvvtgavYAIAmxKpRxWUSr1/jufckDzu9g4QsQtth8wYaSR5wCPXuTPmhFgJMIlNSlOiBnVp+oRbDtKA==",
- "dependencies": [
- "md-to-react-email",
- "react"
- ]
- },
- "@react-email/preview@0.0.10_react@18.3.1": {
- "integrity": "sha512-bRrv8teMMBlF7ttLp1zZUejkPUzrwMQXrigdagtEBOqsB8HxvJU2MR6Yyb3XOqBYldaIDOQJ1z61zyD2wRlKAw==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/render@0.0.17_react@18.3.1_react-dom@18.3.1__react@18.3.1": {
- "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==",
- "dependencies": [
- "html-to-text",
- "js-beautify",
- "react",
- "react-dom",
- "react-promise-suspense"
- ]
- },
- "@react-email/render@1.1.2_react@18.3.1_react-dom@18.3.1__react@18.3.1": {
- "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==",
- "dependencies": [
- "html-to-text",
- "prettier",
- "react",
- "react-dom",
- "react-promise-suspense"
- ]
- },
- "@react-email/row@0.0.9_react@18.3.1": {
- "integrity": "sha512-ZDASHVvyKrWBS00o5pSH5khfMf46UtZhrHcSAfPSiC4nj7R8A0bf+3Wmbk8YmsaV+qWXUCUSHWwIAAlMRnJoAA==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/section@0.0.13_react@18.3.1": {
- "integrity": "sha512-McsCQ5NQlNWEMEAR3EtCxHgRhxGmLD+jPvj7A3FD7y2X3fXG0hbmUGX12B63rIywSWjJoQi6tojx/8RpzbyeTA==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/tailwind@0.0.19_react@18.3.1": {
- "integrity": "sha512-bA0w4D7mSNowxWhcO0jBJauFIPf2Ok7QuKlrHwCcxyX35L2pb5D6ZmXYOrD9C6ADQuVz5oEX+oed3zpSLROgPg==",
- "dependencies": [
- "react"
- ]
- },
- "@react-email/text@0.0.9_react@18.3.1": {
- "integrity": "sha512-UNFPGerER3zywpb1ODOS2VgHP7rgOmiTxMHn75pjvQf/gi3/jN9edEQLYvRgPv/mNn4IpJFkOrlP8jcammLeew==",
- "dependencies": [
- "react"
- ]
- },
- "@selderee/plugin-htmlparser2@0.11.0": {
- "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
- "dependencies": [
- "domhandler",
- "selderee"
- ]
- },
- "@stablelib/base64@1.0.1": {
- "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="
- },
- "@standard-schema/spec@1.0.0": {
- "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
- },
- "@supabase/auth-js@2.72.0": {
- "integrity": "sha512-4+bnUrtTDK1YD0/FCx2YtMiQH5FGu9Jlf4IQi5kcqRwRwqp2ey39V61nHNdH86jm3DIzz0aZKiWfTW8qXk1swQ==",
- "dependencies": [
- "@supabase/node-fetch"
- ]
- },
- "@supabase/auth-js@2.86.0": {
- "integrity": "sha512-3xPqMvBWC6Haqpr6hEWmSUqDq+6SA1BAEdbiaHdAZM9QjZ5uiQJ+6iD9pZOzOa6MVXZh4GmwjhC9ObIG0K1NcA==",
- "dependencies": [
- "tslib"
- ]
- },
- "@supabase/functions-js@2.5.0": {
- "integrity": "sha512-SXBx6Jvp+MOBekeKFu+G11YLYPeVeGQl23eYyAG9+Ro0pQ1aIP0UZNIBxHKNHqxzR0L0n6gysNr2KT3841NATw==",
- "dependencies": [
- "@supabase/node-fetch"
- ]
- },
- "@supabase/functions-js@2.86.0": {
- "integrity": "sha512-AlOoVfeaq9XGlBFIyXTmb+y+CZzxNO4wWbfgRM6iPpNU5WCXKawtQYSnhivi3UVxS7GA0rWovY4d6cIAxZAojA==",
- "dependencies": [
- "tslib"
- ]
- },
- "@supabase/node-fetch@2.6.15": {
- "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
- "dependencies": [
- "whatwg-url"
- ]
- },
- "@supabase/postgrest-js@1.21.4": {
- "integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==",
- "dependencies": [
- "@supabase/node-fetch"
- ]
- },
- "@supabase/postgrest-js@2.86.0": {
- "integrity": "sha512-QVf+wIXILcZJ7IhWhWn+ozdf8B+oO0Ulizh2AAPxD/6nQL+x3r9lJ47a+fpc/jvAOGXMbkeW534Kw6jz7e8iIA==",
- "dependencies": [
- "tslib"
- ]
- },
- "@supabase/realtime-js@2.15.5": {
- "integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==",
- "dependencies": [
- "@supabase/node-fetch",
- "@types/phoenix",
- "@types/ws",
- "ws"
- ]
- },
- "@supabase/realtime-js@2.86.0": {
- "integrity": "sha512-dyS8bFoP29R/sj5zLi0AP3JfgG8ar1nuImcz5jxSx7UIW7fbFsXhUCVrSY2Ofo0+Ev6wiATiSdBOzBfWaiFyPA==",
- "dependencies": [
- "@types/phoenix",
- "@types/ws",
- "tslib",
- "ws"
- ]
- },
- "@supabase/storage-js@2.12.2": {
- "integrity": "sha512-SiySHxi3q7gia7NBYpsYRu8gyI0NhFwSORMxbZIxJ/zAVkN6QpwDRan158CJ+UdzD4WB/rQMAGRqIJQP+7ccAQ==",
- "dependencies": [
- "@supabase/node-fetch"
- ]
- },
- "@supabase/storage-js@2.86.0": {
- "integrity": "sha512-PM47jX/Mfobdtx7NNpoj9EvlrkapAVTQBZgGGslEXD6NS70EcGjhgRPBItwHdxZPM5GwqQ0cGMN06uhjeY2mHQ==",
- "dependencies": [
- "iceberg-js",
- "tslib"
- ]
- },
- "@supabase/supabase-js@2.86.0": {
- "integrity": "sha512-BaC9sv5+HGNy1ulZwY8/Ev7EjfYYmWD4fOMw9bDBqTawEj6JHAiOHeTwXLRzVaeSay4p17xYLN2NSCoGgXMQnw==",
- "dependencies": [
- "@supabase/auth-js@2.86.0",
- "@supabase/functions-js@2.86.0",
- "@supabase/postgrest-js@2.86.0",
- "@supabase/realtime-js@2.86.0",
- "@supabase/storage-js@2.86.0"
- ]
- },
- "@types/node@22.19.1": {
- "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
- "dependencies": [
- "undici-types@6.21.0"
- ]
- },
- "@types/node@24.2.0": {
- "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
- "dependencies": [
- "undici-types@7.10.0"
- ]
- },
- "@types/phoenix@1.6.6": {
- "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="
- },
- "@types/ws@8.18.1": {
- "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
- "dependencies": [
- "@types/node@24.2.0"
- ]
- },
- "abbrev@2.0.0": {
- "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="
- },
- "ansi-regex@5.0.1": {
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
- },
- "ansi-regex@6.2.2": {
- "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="
- },
- "ansi-styles@4.3.0": {
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dependencies": [
- "color-convert"
- ]
- },
- "ansi-styles@6.2.3": {
- "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="
- },
- "atomic-sleep@1.0.0": {
- "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="
- },
- "balanced-match@1.0.2": {
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
- },
- "brace-expansion@2.0.2": {
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dependencies": [
- "balanced-match"
- ]
- },
- "color-convert@2.0.1": {
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dependencies": [
- "color-name"
- ]
- },
- "color-name@1.1.4": {
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
- },
- "commander@10.0.1": {
- "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="
- },
- "config-chain@1.1.13": {
- "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
- "dependencies": [
- "ini",
- "proto-list"
- ]
- },
- "cross-spawn@7.0.6": {
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dependencies": [
- "path-key",
- "shebang-command",
- "which"
- ]
- },
- "deepmerge@4.3.1": {
- "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
- },
- "dom-serializer@2.0.0": {
- "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
- "dependencies": [
- "domelementtype",
- "domhandler",
- "entities"
- ]
- },
- "domelementtype@2.3.0": {
- "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
- },
- "domhandler@5.0.3": {
- "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
- "dependencies": [
- "domelementtype"
- ]
- },
- "domutils@3.2.2": {
- "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
- "dependencies": [
- "dom-serializer",
- "domelementtype",
- "domhandler"
- ]
- },
- "eastasianwidth@0.2.0": {
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
- },
- "editorconfig@1.0.4": {
- "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
- "dependencies": [
- "@one-ini/wasm",
- "commander",
- "minimatch@9.0.1",
- "semver"
- ],
- "bin": true
- },
- "emoji-regex@8.0.0": {
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
- },
- "emoji-regex@9.2.2": {
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
- },
- "entities@4.5.0": {
- "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
- },
- "es6-promise@4.2.8": {
- "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
- },
- "escape-string-regexp@4.0.0": {
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
- },
- "fast-deep-equal@2.0.1": {
- "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="
- },
- "fast-sha256@1.3.0": {
- "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="
- },
- "foreground-child@3.3.1": {
- "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
- "dependencies": [
- "cross-spawn",
- "signal-exit"
- ]
- },
- "glob@10.4.5": {
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
- "dependencies": [
- "foreground-child",
- "jackspeak",
- "minimatch@9.0.5",
- "minipass",
- "package-json-from-dist",
- "path-scurry"
- ],
- "bin": true
- },
- "hono@4.6.14": {
- "integrity": "sha512-j4VkyUp2xazGJ8eCCLN1Vm/bxdvm/j5ZuU9AIjLu9vapn2M44p9L3Ktr9Vnb2RN2QtcR/wVjZVMlT5k7GJQgPw=="
- },
- "html-to-text@9.0.5": {
- "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
- "dependencies": [
- "@selderee/plugin-htmlparser2",
- "deepmerge",
- "dom-serializer",
- "htmlparser2",
- "selderee"
- ]
- },
- "htmlparser2@8.0.2": {
- "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
- "dependencies": [
- "domelementtype",
- "domhandler",
- "domutils",
- "entities"
- ]
- },
- "iceberg-js@0.8.1": {
- "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
- },
- "ini@1.3.8": {
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
- },
- "is-fullwidth-code-point@3.0.0": {
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
- },
- "is-plain-object@5.0.0": {
- "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
- },
- "isexe@2.0.0": {
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
- },
- "jackspeak@3.4.3": {
- "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
- "dependencies": [
- "@isaacs/cliui"
- ],
- "optionalDependencies": [
- "@pkgjs/parseargs"
- ]
- },
- "js-beautify@1.15.4": {
- "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
- "dependencies": [
- "config-chain",
- "editorconfig",
- "glob",
- "js-cookie",
- "nopt"
- ],
- "bin": true
- },
- "js-cookie@3.0.5": {
- "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="
- },
- "js-tokens@4.0.0": {
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
- },
- "leac@0.6.0": {
- "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="
- },
- "loose-envify@1.4.0": {
- "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "dependencies": [
- "js-tokens"
- ],
- "bin": true
- },
- "lru-cache@10.4.3": {
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
- },
- "marked@7.0.4": {
- "integrity": "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==",
- "bin": true
- },
- "mcp-lite@0.8.2": {
- "integrity": "sha512-v78gCsmOI9cGhUsu8xRZ8NEZ6AOFPaAxm4xarP6Dhro3l9HY6Voo43MNuX+CO5oT8gpRfgJ5bMJLTSCFsp+4Kg==",
- "dependencies": [
- "@standard-schema/spec"
- ]
- },
- "md-to-react-email@5.0.2_react@18.3.1": {
- "integrity": "sha512-x6kkpdzIzUhecda/yahltfEl53mH26QdWu4abUF9+S0Jgam8P//Ciro8cdhyMHnT5MQUJYrIbO6ORM2UxPiNNA==",
- "dependencies": [
- "marked",
- "react"
- ]
- },
- "minimatch@9.0.1": {
- "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
- "dependencies": [
- "brace-expansion"
- ]
- },
- "minimatch@9.0.5": {
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dependencies": [
- "brace-expansion"
- ]
- },
- "minipass@7.1.2": {
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
- },
- "nanoid@3.3.11": {
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "bin": true
- },
- "nopt@7.2.1": {
- "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
- "dependencies": [
- "abbrev"
- ],
- "bin": true
- },
- "on-exit-leak-free@2.1.2": {
- "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="
- },
- "package-json-from-dist@1.0.1": {
- "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
- },
- "parse-srcset@1.0.2": {
- "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
- },
- "parseley@0.12.1": {
- "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
- "dependencies": [
- "leac",
- "peberminta"
- ]
- },
- "path-key@3.1.1": {
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
- },
- "path-scurry@1.11.1": {
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
- "dependencies": [
- "lru-cache",
- "minipass"
- ]
- },
- "peberminta@0.9.0": {
- "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="
- },
- "picocolors@1.1.1": {
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
- },
- "pino-abstract-transport@2.0.0": {
- "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
- "dependencies": [
- "split2"
- ]
- },
- "pino-std-serializers@7.0.0": {
- "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="
- },
- "pino@10.1.0": {
- "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
- "dependencies": [
- "@pinojs/redact",
- "atomic-sleep",
- "on-exit-leak-free",
- "pino-abstract-transport",
- "pino-std-serializers",
- "process-warning",
- "quick-format-unescaped",
- "real-require",
- "safe-stable-stringify",
- "sonic-boom",
- "thread-stream"
- ],
- "bin": true
- },
- "postcss@8.5.6": {
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "dependencies": [
- "nanoid",
- "picocolors",
- "source-map-js"
- ]
- },
- "prettier@3.6.2": {
- "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
- "bin": true
- },
- "prismjs@1.29.0": {
- "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q=="
- },
- "process-warning@5.0.0": {
- "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="
- },
- "proto-list@1.2.4": {
- "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="
- },
- "querystringify@2.2.0": {
- "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
- },
- "quick-format-unescaped@4.0.4": {
- "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
- },
- "react-dom@18.3.1_react@18.3.1": {
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
- "dependencies": [
- "loose-envify",
- "react",
- "scheduler"
- ]
- },
- "react-promise-suspense@0.3.4": {
- "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
- "dependencies": [
- "fast-deep-equal"
- ]
- },
- "react@18.3.1": {
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
- "dependencies": [
- "loose-envify"
- ]
- },
- "real-require@0.2.0": {
- "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="
- },
- "requires-port@1.0.0": {
- "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
- },
- "resend@4.0.0_react@18.3.1": {
- "integrity": "sha512-rDX0rspl/XcmC2JV2V5obQvRX2arzxXUvNFUDMOv5ObBLR68+7kigCOysb7+dlkb0JE3erhQG0nHrbBt/ZCWIg==",
- "dependencies": [
- "@react-email/render@0.0.17_react@18.3.1_react-dom@18.3.1__react@18.3.1"
- ]
- },
- "resend@4.8.0_react@18.3.1": {
- "integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==",
- "dependencies": [
- "@react-email/render@1.1.2_react@18.3.1_react-dom@18.3.1__react@18.3.1"
- ]
- },
- "resend@6.4.2": {
- "integrity": "sha512-YnxmwneltZtjc7Xff+8ZjG1/xPLdstCiqsedgO/JxWTf7vKRAPCx6CkhQ3ZXskG0mrmf8+I5wr/wNRd8PQMUfw==",
- "dependencies": [
- "svix"
- ]
- },
- "resend@6.5.2": {
- "integrity": "sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==",
- "dependencies": [
- "svix"
- ]
- },
- "safe-stable-stringify@2.5.0": {
- "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
- },
- "sanitize-html@2.17.0": {
- "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
- "dependencies": [
- "deepmerge",
- "escape-string-regexp",
- "htmlparser2",
- "is-plain-object",
- "parse-srcset",
- "postcss"
- ]
- },
- "scheduler@0.23.2": {
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
- "dependencies": [
- "loose-envify"
- ]
- },
- "selderee@0.11.0": {
- "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
- "dependencies": [
- "parseley"
- ]
- },
- "semver@7.7.3": {
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "bin": true
- },
- "shebang-command@2.0.0": {
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dependencies": [
- "shebang-regex"
- ]
- },
- "shebang-regex@3.0.0": {
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
- },
- "signal-exit@4.1.0": {
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
- },
- "sonic-boom@4.2.0": {
- "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
- "dependencies": [
- "atomic-sleep"
- ]
- },
- "source-map-js@1.2.1": {
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
- },
- "split2@4.2.0": {
- "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
- },
- "string-width@4.2.3": {
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dependencies": [
- "emoji-regex@8.0.0",
- "is-fullwidth-code-point",
- "strip-ansi@6.0.1"
- ]
- },
- "string-width@5.1.2": {
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dependencies": [
- "eastasianwidth",
- "emoji-regex@9.2.2",
- "strip-ansi@7.1.2"
- ]
- },
- "strip-ansi@6.0.1": {
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dependencies": [
- "ansi-regex@5.0.1"
- ]
- },
- "strip-ansi@7.1.2": {
- "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
- "dependencies": [
- "ansi-regex@6.2.2"
- ]
- },
- "sugar-high@0.9.5": {
- "integrity": "sha512-eirwp9p7QcMg6EFCD6zrGh4H30uFx2YtfiMJUavagceP6/YUUjLeiQmis7QuwqKB3nXrWXlLaRumCqOd9AKpSA=="
- },
- "svix@1.76.1": {
- "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==",
- "dependencies": [
- "@stablelib/base64",
- "@types/node@22.19.1",
- "es6-promise",
- "fast-sha256",
- "url-parse",
- "uuid"
- ]
- },
- "thread-stream@3.1.0": {
- "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
- "dependencies": [
- "real-require"
- ]
- },
- "tr46@0.0.3": {
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
- },
- "tslib@2.8.1": {
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
- },
- "undici-types@6.21.0": {
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
- },
- "undici-types@7.10.0": {
- "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
- },
- "url-parse@1.5.10": {
- "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
- "dependencies": [
- "querystringify",
- "requires-port"
- ]
- },
- "uuid@10.0.0": {
- "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
- "bin": true
- },
- "webidl-conversions@3.0.1": {
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
- },
- "whatwg-url@5.0.0": {
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "dependencies": [
- "tr46",
- "webidl-conversions"
- ]
- },
- "which@2.0.2": {
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dependencies": [
- "isexe"
- ],
- "bin": true
- },
- "wrap-ansi@7.0.0": {
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dependencies": [
- "ansi-styles@4.3.0",
- "string-width@4.2.3",
- "strip-ansi@6.0.1"
- ]
- },
- "wrap-ansi@8.1.0": {
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dependencies": [
- "ansi-styles@6.2.3",
- "string-width@5.1.2",
- "strip-ansi@7.1.2"
- ]
- },
- "ws@8.18.3": {
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
- },
- "yoga-layout@3.2.1": {
- "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="
- },
- "zod-to-json-schema@3.24.1_zod@3.24.1": {
- "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==",
- "dependencies": [
- "zod"
- ]
- },
- "zod@3.24.1": {
- "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="
- }
- },
- "redirects": {
- "https://esm.sh/@stablelib/base64@^1.0.0?target=denonext": "https://esm.sh/@stablelib/base64@1.0.1?target=denonext",
- "https://esm.sh/@types/react@~18.2.79/index.d.ts": "https://esm.sh/@types/react@18.2.79/index.d.ts",
- "https://esm.sh/@types/react@~19.0.8/index.d.ts": "https://esm.sh/@types/react@19.0.14/index.d.ts",
- "https://esm.sh/css-background-parser@^0.1.0?target=denonext": "https://esm.sh/css-background-parser@0.1.0?target=denonext",
- "https://esm.sh/css-to-react-native@^3.0.0?target=denonext": "https://esm.sh/css-to-react-native@3.2.0?target=denonext",
- "https://esm.sh/fast-sha256@^1.3.0?target=denonext": "https://esm.sh/fast-sha256@1.3.0?target=denonext",
- "https://esm.sh/postcss-value-parser@^4.2.0?target=denonext": "https://esm.sh/postcss-value-parser@4.2.0?target=denonext"
- },
- "remote": {
- "https://deno.land/x/og_edge@0.0.4/emoji.ts": "f14b3b9fbf52fdc389ee474a67f8e112ff6b17c70a7ee4dd5b242c4243abca98",
- "https://deno.land/x/og_edge@0.0.4/mod.ts": "987426bda16fb886bbc708eb064318f6648e34aaca46798a55b79732325693b9",
- "https://esm.sh/@resvg/resvg-wasm@2.0.0-alpha.4": "d371b4611f91d15ce11dcac327c51cd82afeb22ab1d8fbf4a288233d2a099536",
- "https://esm.sh/@shuding/opentype.js@1.4.0-beta.0/denonext/opentype.mjs": "018430ec9d39bad59482266911124a6ea28188f6b6f0e5bc3be63eb378fd2e9e",
- "https://esm.sh/@stablelib/base64@1.0.1/denonext/base64.mjs": "34390feb4ceca61c968c8b802f0da0c3e47b55979dfcad2f0b19724264b026ee",
- "https://esm.sh/@stablelib/base64@1.0.1?target=denonext": "d6a21d947314ef3a453f3e0dc95c17b7c5d4ca4a29937f10eea783360a736c51",
- "https://esm.sh/css-background-parser@0.1.0/denonext/css-background-parser.mjs": "40166a0d2c5ad007f7117098899b734b533962a0b56fe75df01f9fa50ea1e230",
- "https://esm.sh/css-background-parser@0.1.0?target=denonext": "51a9834cd6ece8955d562351954d05e5927af96f1ff1a8323b937a3be01c4c0c",
- "https://esm.sh/css-box-shadow@1.0.0-3/denonext/css-box-shadow.mjs": "2cc6d39ea4de5d510796d35157bce82471702718450823b5bca8b9b31f0763ee",
- "https://esm.sh/css-to-react-native@3.2.0?target=denonext": "8a1e9a0a89f453e1be791e1f660af19bfc7e2ef44f3d843a021c9e4d61355fb8",
- "https://esm.sh/fast-sha256@1.3.0/denonext/fast-sha256.mjs": "7a13f9dac67165a69a042f7670635f248e14130113d3f35b8b5b92524ae3c009",
- "https://esm.sh/fast-sha256@1.3.0?target=denonext": "e1af1f94f63902ce191180a8ede5eda744b74f6abb885dae5f8b1fd9e13c8575",
- "https://esm.sh/postcss-value-parser@4.2.0?target=denonext": "f11ba11f7fe1a5768433011193e133bf80a697154aab65f582bf653305a571d1",
- "https://esm.sh/react@18.2.0": "fed8aeb7d240bb42a2167c9c7c6e842e128c7630cbcb08c4d61c9c1926f89c75",
- "https://esm.sh/satori@0.0.40": "b5c395e91292bad4b41152ebf9c669d038ae49c8829aeee3ec6daafc5a939656",
- "https://esm.sh/satori@0.0.40/denonext/wasm.mjs": "274eff62aab796f92946cae25793130a17b769127a2d57f9b65750c955385ebd",
- "https://esm.sh/satori@0.0.40/wasm": "76dbd0d73d396e1fbc3cb7c2b3dfdc67db97a9d633d2dafd0b175b63dd117b08",
- "https://esm.sh/standardwebhooks@1.0.0": "b8cafcb7e9a890c14c0eb74cb093f36333000122e2276cc204818f6a680c8ba9",
- "https://esm.sh/standardwebhooks@1.0.0/denonext/standardwebhooks.mjs": "c0e4bbcd5a885307f4466a9c2783319e052ac95aea3daaf5af9e607ba378d1df",
- "https://esm.sh/yoga-wasm-web@0.1.2": "211ce9e3af12ab2d77133510c772b5a34945b5f9ca1dc6031c6218828ca48735",
- "https://esm.sh/yoga-wasm-web@0.1.2/denonext/yoga-wasm-web.mjs": "b27600312517925e6a8a6c4d9d785b677cd5cf5f1e94fe69c25de3c5da0a1556"
- },
- "workspace": {
- "dependencies": [
- "npm:@imagemagick/magick-wasm@0.0.30",
- "npm:@supabase/supabase-js@2.86.0",
- "npm:hono@4.6.14",
- "npm:mcp-lite@0.8.2",
- "npm:pino@10.1.0",
- "npm:react@18.3.1",
- "npm:resend@6.5.2",
- "npm:sanitize-html@2.17.0",
- "npm:sugar-high@0.9.5",
- "npm:yoga-layout@3.2.1",
- "npm:zod-to-json-schema@3.24.1",
- "npm:zod@3.24.1"
- ]
- }
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/README.md b/apps/edge/supabase/functions/heyclaude-mcp/README.md
deleted file mode 100644
index 686db337c..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/README.md
+++ /dev/null
@@ -1,135 +0,0 @@
-# HeyClaude MCP Server
-
-Official MCP server for the Claude Pro Directory, exposing real-time access to prompts, agents, MCP servers, rules, commands, and more through the Model Context Protocol.
-
-## Architecture
-
-This Edge Function uses existing HeyClaude infrastructure:
-
-- **Data Access:** `@heyclaude/data-layer` services (ContentService, TrendingService, SearchService)
-- **HTTP Utilities:** `@heyclaude/edge-runtime` (jsonResponse, errorResponse, CORS, rate limiting)
-- **Database:** Supabase RPCs via service role client
-- **MCP Framework:** `mcp-lite@0.8.2` with Streamable HTTP transport
-
-## Structure
-
-```
-heyclaude-mcp/
-├── index.ts # Main entry point with Hono routing
-├── routes/ # Tool handler implementations
-│ ├── categories.ts # listCategories tool
-│ ├── search.ts # searchContent tool
-│ ├── detail.ts # getContentDetail tool
-│ ├── trending.ts # getTrending tool
-│ ├── featured.ts # getFeatured tool
-│ ├── templates.ts # getTemplates tool
-│ ├── mcp-servers.ts # getMcpServers tool
-│ ├── related.ts # getRelatedContent tool
-│ ├── tags.ts # getContentByTag tool
-│ ├── popular.ts # getPopular tool
-│ ├── recent.ts # getRecent tool
-│ ├── download-platform.ts # downloadContentForPlatform tool
-│ ├── newsletter.ts # subscribeNewsletter tool
-│ ├── account.ts # createAccount tool
-│ ├── submit-content.ts # submitContent tool
-│ ├── oauth-authorize.ts # OAuth authorization proxy
-│ └── auth-metadata.ts # OAuth metadata endpoints
-├── resources/ # MCP resource handlers
-│ └── content.ts # Content resource handlers (LLMs.txt, Markdown, JSON, RSS/Atom)
-└── lib/ # Shared utilities
- ├── types.ts # MCP tool type definitions
- └── platform-formatters.ts # Platform-specific formatting functions
-```
-
-## Tools
-
-### Core Tools (v1.0.0)
-
-1. **listCategories** - List all directory categories with counts
-2. **searchContent** - Search with filters, pagination, tag support
-3. **getContentDetail** - Get complete content metadata by slug
-4. **getTrending** - Get trending content across categories
-5. **getFeatured** - Get featured/highlighted content
-6. **getTemplates** - Get submission templates by category
-
-### Advanced Tools (v1.0.0)
-
-7. **getMcpServers** - List all MCP servers with download URLs
-8. **getRelatedContent** - Find related/similar content
-9. **getContentByTag** - Filter content by tags with AND/OR logic
-10. **getPopular** - Get popular content by views and engagement
-11. **getRecent** - Get recently added content
-
-### Platform Formatting Tools (v1.0.0)
-
-12. **downloadContentForPlatform** - Download content formatted for your platform (Claude Code, Cursor, etc.) with installation instructions
-
-### Growth Tools (v1.0.0)
-
-13. **subscribeNewsletter** - Subscribe an email address to the Claude Pro Directory newsletter via Inngest
-14. **createAccount** - Create a new account using OAuth (GitHub, Google, Discord) with newsletter opt-in support
-15. **submitContent** - Submit content (agents, rules, MCP servers, etc.) for review with step-by-step guidance
-
-### Feature Enhancement Tools (v1.0.0)
-
-16. **getSearchSuggestions** - Get search autocomplete suggestions based on query history
-17. **getSearchFacets** - Get available search facets (categories, tags, authors) for filtering
-18. **getChangelog** - Get changelog of content updates in LLMs.txt format
-19. **getSocialProofStats** - Get community statistics (contributors, submissions, success rate)
-20. **getCategoryConfigs** - Get category-specific configurations and features
-
-## Endpoints
-
-- **Primary:** `https://mcp.claudepro.directory/mcp`
-- **Direct:** `https://hgtjdifxfapoltfflowc.supabase.co/functions/v1/heyclaude-mcp/mcp`
-- **Health:** `https://mcp.claudepro.directory/`
-
-## Development
-
-```bash
-# Start local development
-supabase functions serve --no-verify-jwt heyclaude-mcp
-
-# Test with MCP Inspector
-npx @modelcontextprotocol/inspector
-# Add endpoint: http://localhost:54321/functions/v1/heyclaude-mcp/mcp
-
-# Test with Claude Desktop
-# Add to ~/.claude_desktop_config.json:
-{
- "mcpServers": {
- "heyclaude-mcp-dev": {
- "url": "http://localhost:54321/functions/v1/heyclaude-mcp/mcp"
- }
- }
-}
-```
-
-## Deployment
-
-```bash
-# Deploy to production
-supabase functions deploy --no-verify-jwt heyclaude-mcp
-```
-
-## Environment Variables
-
-The following environment variables are required or recommended:
-
-- **INNGEST_EVENT_KEY** (Required for production): Inngest event key for sending events (newsletter tool)
-- **INNGEST_URL** (Optional): Inngest API URL (defaults to Inngest Cloud or local dev server)
-- **APP_URL** (Optional): Application URL (defaults to `https://claudepro.directory`)
-- **MCP_SERVER_URL** (Optional): MCP server URL (defaults to `https://mcp.claudepro.directory`)
-- **API_BASE_URL** (Optional): API base URL for resource handlers (defaults to `https://claudepro.directory`)
-
-## Version
-
-- **MCP Server:** v1.0.0
-- **Protocol:** 2025-06-18
-- **Transport:** Streamable HTTP
-
-## Links
-
-- **Documentation:** https://claudepro.directory/mcp/heyclaude-mcp
-- **MCP Spec:** https://spec.modelcontextprotocol.io/
-- **mcp-lite:** https://github.com/wong2/mcp-lite
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/deno.json b/apps/edge/supabase/functions/heyclaude-mcp/deno.json
deleted file mode 100644
index c25b0e86d..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/deno.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "nodeModulesDir": "auto",
- "imports": {
- "@heyclaude/database-types": "../../../../../packages/database-types/src/index.ts",
- "@heyclaude/shared-runtime/": "../../../../../packages/shared-runtime/src/",
- "@heyclaude/edge-runtime/": "../../../../../packages/edge-runtime/src/",
- "@heyclaude/data-layer/": "../../../../../packages/data-layer/src/",
- "@supabase/supabase-js": "npm:@supabase/supabase-js@2.86.0",
- "@supabase/supabase-js/": "npm:@supabase/supabase-js@2.86.0/",
- "zod": "npm:zod@3.24.1",
- "zod-to-json-schema": "npm:zod-to-json-schema@3",
- "hono": "npm:hono@4.6.14",
- "hono/": "npm:hono@4.6.14/",
- "mcp-lite": "npm:mcp-lite@0.8.2",
- "pino": "npm:pino@10.1.0",
- "sanitize-html": "npm:sanitize-html@2.17.0"
- },
- "lint": {
- "include": ["**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules/**", "deno.lock"],
- "rules": {
- "tags": ["recommended"],
- "exclude": ["no-var", "no-explicit-any"]
- }
- },
- "compilerOptions": {
- "lib": ["deno.ns", "deno.unstable", "dom"],
- "types": ["@heyclaude/edge-runtime/deno-globals.d.ts", "@heyclaude/edge-runtime/jsx-types.d.ts", "../../tsconfig-setup.d.ts"],
- "strict": true,
- "noImplicitAny": true,
- "strictNullChecks": true,
- "strictFunctionTypes": true,
- "strictPropertyInitialization": true,
- "noImplicitThis": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noImplicitReturns": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
- "exactOptionalPropertyTypes": true,
- "noPropertyAccessFromIndexSignature": true,
- "noImplicitOverride": true,
- "allowUnusedLabels": false,
- "allowUnreachableCode": false,
- "skipLibCheck": true
- }
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/deno.lock b/apps/edge/supabase/functions/heyclaude-mcp/deno.lock
deleted file mode 100644
index 6cd2e3d58..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/deno.lock
+++ /dev/null
@@ -1,257 +0,0 @@
-{
- "version": "5",
- "specifiers": {
- "npm:@supabase/supabase-js@2.86.0": "2.86.0",
- "npm:hono@4.6.14": "4.6.14",
- "npm:mcp-lite@0.8.2": "0.8.2",
- "npm:pino@10.1.0": "10.1.0",
- "npm:sanitize-html@2.17.0": "2.17.0",
- "npm:zod-to-json-schema@3": "3.25.0_zod@3.24.1",
- "npm:zod@3.24.1": "3.24.1"
- },
- "npm": {
- "@pinojs/redact@0.4.0": {
- "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
- },
- "@standard-schema/spec@1.0.0": {
- "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
- },
- "@supabase/auth-js@2.86.0": {
- "integrity": "sha512-3xPqMvBWC6Haqpr6hEWmSUqDq+6SA1BAEdbiaHdAZM9QjZ5uiQJ+6iD9pZOzOa6MVXZh4GmwjhC9ObIG0K1NcA==",
- "dependencies": [
- "tslib"
- ]
- },
- "@supabase/functions-js@2.86.0": {
- "integrity": "sha512-AlOoVfeaq9XGlBFIyXTmb+y+CZzxNO4wWbfgRM6iPpNU5WCXKawtQYSnhivi3UVxS7GA0rWovY4d6cIAxZAojA==",
- "dependencies": [
- "tslib"
- ]
- },
- "@supabase/postgrest-js@2.86.0": {
- "integrity": "sha512-QVf+wIXILcZJ7IhWhWn+ozdf8B+oO0Ulizh2AAPxD/6nQL+x3r9lJ47a+fpc/jvAOGXMbkeW534Kw6jz7e8iIA==",
- "dependencies": [
- "tslib"
- ]
- },
- "@supabase/realtime-js@2.86.0": {
- "integrity": "sha512-dyS8bFoP29R/sj5zLi0AP3JfgG8ar1nuImcz5jxSx7UIW7fbFsXhUCVrSY2Ofo0+Ev6wiATiSdBOzBfWaiFyPA==",
- "dependencies": [
- "@types/phoenix",
- "@types/ws",
- "tslib",
- "ws"
- ]
- },
- "@supabase/storage-js@2.86.0": {
- "integrity": "sha512-PM47jX/Mfobdtx7NNpoj9EvlrkapAVTQBZgGGslEXD6NS70EcGjhgRPBItwHdxZPM5GwqQ0cGMN06uhjeY2mHQ==",
- "dependencies": [
- "iceberg-js",
- "tslib"
- ]
- },
- "@supabase/supabase-js@2.86.0": {
- "integrity": "sha512-BaC9sv5+HGNy1ulZwY8/Ev7EjfYYmWD4fOMw9bDBqTawEj6JHAiOHeTwXLRzVaeSay4p17xYLN2NSCoGgXMQnw==",
- "dependencies": [
- "@supabase/auth-js",
- "@supabase/functions-js",
- "@supabase/postgrest-js",
- "@supabase/realtime-js",
- "@supabase/storage-js"
- ]
- },
- "@types/node@24.2.0": {
- "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
- "dependencies": [
- "undici-types"
- ]
- },
- "@types/phoenix@1.6.6": {
- "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="
- },
- "@types/ws@8.18.1": {
- "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
- "dependencies": [
- "@types/node"
- ]
- },
- "atomic-sleep@1.0.0": {
- "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="
- },
- "deepmerge@4.3.1": {
- "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
- },
- "dom-serializer@2.0.0": {
- "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
- "dependencies": [
- "domelementtype",
- "domhandler",
- "entities"
- ]
- },
- "domelementtype@2.3.0": {
- "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
- },
- "domhandler@5.0.3": {
- "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
- "dependencies": [
- "domelementtype"
- ]
- },
- "domutils@3.2.2": {
- "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
- "dependencies": [
- "dom-serializer",
- "domelementtype",
- "domhandler"
- ]
- },
- "entities@4.5.0": {
- "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
- },
- "escape-string-regexp@4.0.0": {
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
- },
- "hono@4.6.14": {
- "integrity": "sha512-j4VkyUp2xazGJ8eCCLN1Vm/bxdvm/j5ZuU9AIjLu9vapn2M44p9L3Ktr9Vnb2RN2QtcR/wVjZVMlT5k7GJQgPw=="
- },
- "htmlparser2@8.0.2": {
- "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
- "dependencies": [
- "domelementtype",
- "domhandler",
- "domutils",
- "entities"
- ]
- },
- "iceberg-js@0.8.1": {
- "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
- },
- "is-plain-object@5.0.0": {
- "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
- },
- "mcp-lite@0.8.2": {
- "integrity": "sha512-v78gCsmOI9cGhUsu8xRZ8NEZ6AOFPaAxm4xarP6Dhro3l9HY6Voo43MNuX+CO5oT8gpRfgJ5bMJLTSCFsp+4Kg==",
- "dependencies": [
- "@standard-schema/spec"
- ]
- },
- "nanoid@3.3.11": {
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "bin": true
- },
- "on-exit-leak-free@2.1.2": {
- "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="
- },
- "parse-srcset@1.0.2": {
- "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
- },
- "picocolors@1.1.1": {
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
- },
- "pino-abstract-transport@2.0.0": {
- "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
- "dependencies": [
- "split2"
- ]
- },
- "pino-std-serializers@7.0.0": {
- "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="
- },
- "pino@10.1.0": {
- "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
- "dependencies": [
- "@pinojs/redact",
- "atomic-sleep",
- "on-exit-leak-free",
- "pino-abstract-transport",
- "pino-std-serializers",
- "process-warning",
- "quick-format-unescaped",
- "real-require",
- "safe-stable-stringify",
- "sonic-boom",
- "thread-stream"
- ],
- "bin": true
- },
- "postcss@8.5.6": {
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "dependencies": [
- "nanoid",
- "picocolors",
- "source-map-js"
- ]
- },
- "process-warning@5.0.0": {
- "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="
- },
- "quick-format-unescaped@4.0.4": {
- "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
- },
- "real-require@0.2.0": {
- "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="
- },
- "safe-stable-stringify@2.5.0": {
- "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
- },
- "sanitize-html@2.17.0": {
- "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
- "dependencies": [
- "deepmerge",
- "escape-string-regexp",
- "htmlparser2",
- "is-plain-object",
- "parse-srcset",
- "postcss"
- ]
- },
- "sonic-boom@4.2.0": {
- "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
- "dependencies": [
- "atomic-sleep"
- ]
- },
- "source-map-js@1.2.1": {
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
- },
- "split2@4.2.0": {
- "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
- },
- "thread-stream@3.1.0": {
- "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
- "dependencies": [
- "real-require"
- ]
- },
- "tslib@2.8.1": {
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
- },
- "undici-types@7.10.0": {
- "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
- },
- "ws@8.18.3": {
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
- },
- "zod-to-json-schema@3.25.0_zod@3.24.1": {
- "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
- "dependencies": [
- "zod"
- ]
- },
- "zod@3.24.1": {
- "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="
- }
- },
- "workspace": {
- "dependencies": [
- "npm:@supabase/supabase-js@2.86.0",
- "npm:hono@4.6.14",
- "npm:mcp-lite@0.8.2",
- "npm:pino@10.1.0",
- "npm:sanitize-html@2.17.0",
- "npm:zod-to-json-schema@3",
- "npm:zod@3.24.1"
- ]
- }
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/index.ts b/apps/edge/supabase/functions/heyclaude-mcp/index.ts
deleted file mode 100644
index 7801ed609..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/index.ts
+++ /dev/null
@@ -1,893 +0,0 @@
-/**
- * HeyClaude MCP Server
- *
- * Exposes the Claude Pro Directory through the Model Context Protocol (MCP).
- * Provides real-time access to prompts, agents, MCP servers, rules, commands,
- * and more through a standardized MCP interface.
- *
- * @version 1.0.0
- * @transport Streamable HTTP (MCP Protocol 2025-06-18)
- * @endpoints
- * - Primary: https://mcp.claudepro.directory/mcp
- * - Direct: https://hgtjdifxfapoltfflowc.supabase.co/functions/v1/heyclaude-mcp/mcp
- */
-
-import { zodToJsonSchema } from 'zod-to-json-schema';
-import type { Database } from '@heyclaude/database-types';
-import { edgeEnv } from '@heyclaude/edge-runtime/config/env.ts';
-import { initRequestLogging, traceRequestComplete, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { requireAuthUser } from '@heyclaude/edge-runtime/utils/auth.ts';
-import { createDataApiContext, logError, logger } from '@heyclaude/shared-runtime/logging.ts';
-import type { User } from '@supabase/supabase-js';
-import { createClient } from '@supabase/supabase-js';
-import { Hono } from 'hono';
-import { McpServer, StreamableHttpTransport } from 'mcp-lite';
-import type { z } from 'zod';
-
-/**
- * Authentication error for typed error handling
- */
-class AuthenticationError extends Error {
- constructor(message: string) {
- super(message);
- this.name = 'AuthenticationError';
- }
-}
-
-import {
- CreateAccountInputSchema,
- DownloadContentForPlatformInputSchema,
- GetCategoryConfigsInputSchema,
- GetChangelogInputSchema,
- GetContentByTagInputSchema,
- GetContentDetailInputSchema,
- GetFeaturedInputSchema,
- GetMcpServersInputSchema,
- GetPopularInputSchema,
- GetRecentInputSchema,
- GetRelatedContentInputSchema,
- GetSearchFacetsInputSchema,
- GetSearchSuggestionsInputSchema,
- GetSocialProofStatsInputSchema,
- GetTemplatesInputSchema,
- GetTrendingInputSchema,
- ListCategoriesInputSchema,
- MCP_PROTOCOL_VERSION,
- MCP_SERVER_VERSION,
- SearchContentInputSchema,
- SubmitContentInputSchema,
- SubscribeNewsletterInputSchema,
-} from './lib/types.ts';
-import { checkRateLimit } from './lib/rate-limit.ts';
-import { McpErrorCode, createErrorResponse, errorToMcpError } from './lib/errors.ts';
-import { withTimeout } from './lib/utils.ts';
-import {
- handleAuthorizationServerMetadata,
- handleProtectedResourceMetadata,
-} from './routes/auth-metadata.ts';
-// Import tool handlers
-import { handleListCategories } from './routes/categories.ts';
-import { handleCreateAccount } from './routes/account.ts';
-import { handleGetCategoryConfigs } from './routes/category-configs.ts';
-import { handleGetChangelog } from './routes/changelog.ts';
-import { handleGetContentDetail } from './routes/detail.ts';
-import { handleDownloadContentForPlatform } from './routes/download-platform.ts';
-import { handleGetFeatured } from './routes/featured.ts';
-import { handleGetMcpServers } from './routes/mcp-servers.ts';
-import { handleOAuthAuthorize } from './routes/oauth-authorize.ts';
-import { handleSubscribeNewsletter } from './routes/newsletter.ts';
-import { handleSubmitContent } from './routes/submit-content.ts';
-import { handleGetPopular } from './routes/popular.ts';
-import { handleGetRecent } from './routes/recent.ts';
-import { handleGetRelatedContent } from './routes/related.ts';
-import { handleGetSearchFacets } from './routes/search-facets.ts';
-import { handleGetSearchSuggestions } from './routes/search-suggestions.ts';
-import { handleSearchContent } from './routes/search.ts';
-import { handleGetSocialProofStats } from './routes/social-proof.ts';
-import { handleGetContentByTag } from './routes/tags.ts';
-import { handleGetTemplates } from './routes/templates.ts';
-import { handleGetTrending } from './routes/trending.ts';
-// Import resource handlers
-import {
- handleContentResource,
- handleCategoryResource,
- handleSitewideResource,
-} from './resources/content.ts';
-
-/**
- * Outer Hono app - matches function name (/heyclaude-mcp)
- * Required by Supabase routing: all requests go to //*
- */
-const app = new Hono();
-
-/**
- * Inner MCP app - handles actual MCP protocol endpoints
- * Mounted at /heyclaude-mcp, serves /mcp endpoint
- */
-const mcpApp = new Hono();
-
-/**
- * CORS configuration for MCP protocol
- * Must allow Mcp-Session-Id and MCP-Protocol-Version headers
- */
-mcpApp.use('/*', async (c, next) => {
- // Handle preflight
- if (c.req.method === 'OPTIONS') {
- return c.text('', 204, {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id, MCP-Protocol-Version',
- 'Access-Control-Expose-Headers': 'Mcp-Session-Id, MCP-Protocol-Version',
- 'Access-Control-Max-Age': '86400',
- });
- }
-
- // Add CORS headers to response
- await next();
- c.header('Access-Control-Allow-Origin', '*');
- c.header('Access-Control-Expose-Headers', 'Mcp-Session-Id, MCP-Protocol-Version');
- return;
-});
-
-/**
- * MCP server configuration
- * Note: A new McpServer instance is created per request (see requestMcp below)
- * This allows each request to have its own authenticated Supabase client
- */
-
-/**
- * Create a Supabase client that sends the provided user access token with every request.
- *
- * @param token - Access token to include as `Authorization: Bearer ` on each request
- * @returns A Supabase client instance configured to include the provided token on outbound requests
- */
-function getAuthenticatedSupabase(_user: User, token: string) {
- const {
- supabase: { url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY },
- } = edgeEnv;
-
- return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
- global: {
- headers: { Authorization: `Bearer ${token}` },
- },
- });
-}
-
-/**
- * Register Core Tools (Phase 2)
- */
-
-/**
- * Register the HeyClaude directory MCP tools on the provided server using the given per-request Supabase client.
- *
- * Registers the directory toolset and binds each tool's handler to the supplied authenticated Supabase client so
- * all tool operations run in the context of the current request's user/token. Tools registered include listing
- * categories, searching content, retrieving content details, trending/featured/templates, MCP servers listing,
- * related content, content-by-tag, popular, and recent endpoints.
- *
- * All tool handlers are wrapped with timeout protection (60s default) to prevent hanging requests.
- *
- * @param mcpServer - MCP server instance to register tools on
- * @param supabase - Authenticated, per-request Supabase client bound to the request's user/token
- */
-function registerAllTools(
- mcpServer: McpServer,
- supabase: ReturnType
-) {
- // Helper to wrap tool handlers with timeout
- const wrapWithTimeout = (
- handler: (...args: T) => Promise,
- toolName: string,
- timeoutMs: number = 60000
- ) => {
- return async (...args: T): Promise => {
- return withTimeout(
- handler(...args),
- timeoutMs,
- `Tool ${toolName} timed out after ${timeoutMs}ms`
- );
- };
- };
- // 1. listCategories - List all directory categories
- mcpServer.tool('listCategories', {
- description:
- 'List all content categories in the HeyClaude directory with counts and descriptions',
- inputSchema: ListCategoriesInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleListCategories(supabase, args),
- 'listCategories',
- 30000 // 30s timeout
- ),
- });
-
- // 2. searchContent - Search with filters and pagination
- mcpServer.tool('searchContent', {
- description:
- 'Search directory content with filters, pagination, and tag support. Returns matching items with metadata.',
- inputSchema: SearchContentInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleSearchContent(supabase, args),
- 'searchContent',
- 45000 // 45s timeout (can be slower with complex queries)
- ),
- });
-
- // 3. getContentDetail - Get complete content metadata
- mcpServer.tool('getContentDetail', {
- description:
- 'Get complete metadata for a specific content item by slug and category. Includes full description, tags, author info, and stats.',
- inputSchema: GetContentDetailInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetContentDetail(supabase, args),
- 'getContentDetail',
- 30000
- ),
- });
-
- // 4. getTrending - Get trending content
- mcpServer.tool('getTrending', {
- description:
- 'Get trending content across categories or within a specific category. Sorted by popularity and engagement.',
- inputSchema: GetTrendingInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetTrending(supabase, args),
- 'getTrending',
- 30000
- ),
- });
-
- // 5. getFeatured - Get featured/highlighted content
- mcpServer.tool('getFeatured', {
- description:
- 'Get featured and highlighted content from the homepage. Includes hero items, latest additions, and popular content.',
- inputSchema: GetFeaturedInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetFeatured(supabase, args),
- 'getFeatured',
- 45000 // Can be slower due to multiple RPC calls
- ),
- });
-
- // 6. getTemplates - Get submission templates
- mcpServer.tool('getTemplates', {
- description:
- 'Get submission templates for creating new content. Returns required fields and validation rules by category.',
- inputSchema: GetTemplatesInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetTemplates(supabase, args),
- 'getTemplates',
- 30000
- ),
- });
-
- // 7. getMcpServers - List all MCP servers with download URLs
- mcpServer.tool('getMcpServers', {
- description:
- 'List all MCP servers in the directory with download URLs and configuration details',
- inputSchema: GetMcpServersInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetMcpServers(supabase, args),
- 'getMcpServers',
- 30000
- ),
- });
-
- // 8. getRelatedContent - Find related/similar content
- mcpServer.tool('getRelatedContent', {
- description: 'Find related or similar content based on tags, category, and semantic similarity',
- inputSchema: GetRelatedContentInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetRelatedContent(supabase, args),
- 'getRelatedContent',
- 30000
- ),
- });
-
- // 9. getContentByTag - Filter content by tags with AND/OR logic
- mcpServer.tool('getContentByTag', {
- description: 'Get content filtered by specific tags with AND/OR logic support',
- inputSchema: GetContentByTagInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetContentByTag(supabase, args),
- 'getContentByTag',
- 30000
- ),
- });
-
- // 10. getPopular - Get popular content by views and engagement
- mcpServer.tool('getPopular', {
- description: 'Get most popular content by views and engagement metrics',
- inputSchema: GetPopularInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetPopular(supabase, args),
- 'getPopular',
- 30000
- ),
- });
-
- // 11. getRecent - Get recently added content
- mcpServer.tool('getRecent', {
- description: 'Get recently added content sorted by date',
- inputSchema: GetRecentInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetRecent(supabase, args),
- 'getRecent',
- 30000
- ),
- });
-
- // 12. downloadContentForPlatform - Download content formatted for platform
- mcpServer.tool('downloadContentForPlatform', {
- description:
- 'Download content formatted for your platform (Claude Code, Cursor, etc.) with installation instructions. Returns ready-to-use configuration files.',
- inputSchema: DownloadContentForPlatformInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleDownloadContentForPlatform(supabase, args),
- 'downloadContentForPlatform',
- 45000 // Can be slower due to formatting
- ),
- });
-
- // 13. subscribeNewsletter - Subscribe to newsletter
- mcpServer.tool('subscribeNewsletter', {
- description:
- 'Subscribe an email address to the Claude Pro Directory newsletter. Handles email validation, Resend sync, welcome email, and drip campaign enrollment via Inngest.',
- inputSchema: SubscribeNewsletterInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleSubscribeNewsletter(supabase, args),
- 'subscribeNewsletter',
- 30000
- ),
- });
-
- // 14. createAccount - Create account with OAuth
- mcpServer.tool('createAccount', {
- description:
- 'Create a new account on Claude Pro Directory using OAuth (GitHub, Google, or Discord). Returns OAuth authorization URL and step-by-step instructions. Supports newsletter opt-in during account creation.',
- inputSchema: CreateAccountInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleCreateAccount(supabase, args),
- 'createAccount',
- 30000
- ),
- });
-
- // 15. submitContent - Submit content for review
- mcpServer.tool('submitContent', {
- description:
- 'Submit content (agents, rules, MCP servers, etc.) to Claude Pro Directory for review. Collects submission data and provides instructions for completing submission via web interface. Requires authentication - use createAccount tool first if needed.',
- inputSchema: SubmitContentInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleSubmitContent(supabase, args),
- 'submitContent',
- 45000 // Can be slower due to data collection
- ),
- });
-
- // 16. getSearchSuggestions - Get search autocomplete suggestions
- mcpServer.tool('getSearchSuggestions', {
- description:
- 'Get search suggestions based on query history. Helps discover popular searches and provides autocomplete functionality for AI agents. Returns suggestions with search counts and popularity indicators.',
- inputSchema: GetSearchSuggestionsInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetSearchSuggestions(supabase, args),
- 'getSearchSuggestions',
- 30000
- ),
- });
-
- // 17. getSearchFacets - Get available search facets
- mcpServer.tool('getSearchFacets', {
- description:
- 'Get available search facets (categories, tags, authors) for filtering content. Helps AI agents understand what filters are available and enables dynamic filter discovery.',
- inputSchema: GetSearchFacetsInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetSearchFacets(supabase),
- 'getSearchFacets',
- 30000
- ),
- });
-
- // 18. getChangelog - Get content changelog
- mcpServer.tool('getChangelog', {
- description:
- 'Get changelog of content updates in LLMs.txt format. Helps AI agents understand recent changes and stay current with the latest content additions and updates.',
- inputSchema: GetChangelogInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetChangelog(supabase, args),
- 'getChangelog',
- 30000
- ),
- });
-
- // 19. getSocialProofStats - Get community statistics
- mcpServer.tool('getSocialProofStats', {
- description:
- 'Get community statistics including top contributors, recent submissions, success rate, and total user count. Provides social proof data for engagement and helps understand community activity.',
- inputSchema: GetSocialProofStatsInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetSocialProofStats(supabase),
- 'getSocialProofStats',
- 30000
- ),
- });
-
- // 20. getCategoryConfigs - Get category configurations
- mcpServer.tool('getCategoryConfigs', {
- description:
- 'Get category-specific configurations and features. Helps understand category-specific requirements, submission guidelines, and configuration options for each content category.',
- inputSchema: GetCategoryConfigsInputSchema,
- handler: wrapWithTimeout(
- async (args) => await handleGetCategoryConfigs(supabase, args),
- 'getCategoryConfigs',
- 30000
- ),
- });
-}
-
-/**
- * Register MCP Resources (Phase 1: Content Delivery)
- *
- * Registers resource templates for content access in various formats.
- * Resources are accessed via URI templates and handled by onResourceRequest.
- *
- * @param mcpServer - MCP server instance to register resources on
- */
-function registerAllResources(mcpServer: McpServer) {
- // Template 1: Individual content items
- mcpServer.resource({
- uriTemplate: 'claudepro://content/{category}/{slug}/{format}',
- name: 'Content Export',
- description:
- 'Access any content item in LLMs.txt, Markdown, JSON, or download format',
- mimeType: 'text/plain', // Varies by format
- });
-
- // Template 2: Category exports
- mcpServer.resource({
- uriTemplate: 'claudepro://category/{category}/{format}',
- name: 'Category Export',
- description:
- 'Export all content in a category (LLMs.txt, RSS, Atom, JSON)',
- mimeType: 'text/plain',
- });
-
- // Template 3: Sitewide exports
- mcpServer.resource({
- uriTemplate: 'claudepro://sitewide/{format}',
- name: 'Sitewide Export',
- description:
- 'Export all directory content (LLMs.txt, README JSON, complete JSON)',
- mimeType: 'text/plain',
- });
-}
-
-/**
- * Note: MCP transport is created per-request in the /mcp endpoint handler
- * to ensure each request has its own authenticated Supabase client context
- * Tools are registered on each per-request instance with the authenticated Supabase client
- * to enforce Row-Level Security (RLS) policies.
- */
-
-/**
- * Health check endpoint
- * Returns server information and available endpoints
- */
-mcpApp.get('/', (c) => {
- return c.json({
- name: 'heyclaude-mcp',
- version: MCP_SERVER_VERSION,
- protocol: MCP_PROTOCOL_VERSION,
- description: 'HeyClaude MCP Server - Access the Claude Pro Directory via MCP',
- endpoints: {
- mcp: '/heyclaude-mcp/mcp',
- health: '/heyclaude-mcp/',
- protectedResourceMetadata: '/heyclaude-mcp/.well-known/oauth-protected-resource',
- authorizationServerMetadata: '/heyclaude-mcp/.well-known/oauth-authorization-server',
- },
- documentation: 'https://claudepro.directory/mcp/heyclaude-mcp',
- status: 'operational',
- tools: {
- core: 6, // listCategories, searchContent, getContentDetail, getTrending, getFeatured, getTemplates
- advanced: 5, // getMcpServers, getRelatedContent, getContentByTag, getPopular, getRecent
- platform: 1, // downloadContentForPlatform
- growth: 3, // subscribeNewsletter, createAccount, submitContent
- enhancements: 5, // getSearchSuggestions, getSearchFacets, getChangelog, getSocialProofStats, getCategoryConfigs
- total: 20, // All tools implemented
- },
- resources: {
- templates: 3, // Phase 1: Content Delivery
- formats: ['llms', 'markdown', 'json', 'rss', 'atom', 'download'],
- },
- });
-});
-
-/**
- * OAuth Protected Resource Metadata (RFC 9728)
- * Endpoint: GET /.well-known/oauth-protected-resource
- *
- * MCP clients use this to discover the authorization server
- */
-mcpApp.get('/.well-known/oauth-protected-resource', handleProtectedResourceMetadata);
-
-/**
- * OAuth Authorization Server Metadata (RFC 8414)
- * Endpoint: GET /.well-known/oauth-authorization-server
- *
- * Provides metadata about Supabase Auth as the authorization server
- */
-mcpApp.get('/.well-known/oauth-authorization-server', handleAuthorizationServerMetadata);
-
-/**
- * OAuth Authorization Endpoint Proxy
- * Endpoint: GET /oauth/authorize
- *
- * Proxies OAuth authorization requests to Supabase Auth with resource parameter (RFC 8707)
- * This ensures tokens include the MCP server URL in the audience claim.
- */
-mcpApp.get('/oauth/authorize', handleOAuthAuthorize);
-
-/**
- * Gets the MCP server resource URL used to validate token audience.
- *
- * @returns The MCP server resource URL — the value of the `MCP_SERVER_URL` environment variable if set, otherwise `https://mcp.claudepro.directory/mcp`.
- */
-function getMcpServerResourceUrl(): string {
- // Use environment variable if set, otherwise default to production URL
- return Deno.env.get('MCP_SERVER_URL') || 'https://mcp.claudepro.directory/mcp';
-}
-
-/**
- * Determine whether a JWT's `aud` claim includes the MCP resource URL.
- *
- * Inspects the token's `aud` claim (string or array) and returns `true` if it contains `expectedAudience`;
- * returns `false` if `aud` is missing or does not match.
- *
- * Per OAuth 2.1 with resource indicators (RFC 8707), tokens MUST include the resource URL in the audience claim.
- * This prevents token passthrough attacks by ensuring tokens are issued specifically for this MCP server.
- *
- * @param token - The JWT string to inspect (already verified by Supabase)
- * @param expectedAudience - The MCP resource URL that must be present in the token's `aud` claim
- * @returns `true` if the token's `aud` includes `expectedAudience`, `false` otherwise
- */
-function validateTokenAudience(token: string, expectedAudience: string): boolean {
- try {
- // Decode JWT without verification (we already verified via Supabase)
- // We just need to check the audience claim
- const parts = token.split('.');
- if (parts.length !== 3) {
- return false;
- }
-
- // Decode payload (base64url)
- const payloadPart = parts[1];
- if (!payloadPart) {
- return false;
- }
- // Add padding if needed (base64 requires length to be multiple of 4)
- const base64String = payloadPart
- .replace(/-/g, '+')
- .replace(/_/g, '/')
- .padEnd(payloadPart.length + ((4 - (payloadPart.length % 4)) % 4), '=');
- const payload = JSON.parse(
- new TextDecoder().decode(
- Uint8Array.from(atob(base64String), (c) => c.charCodeAt(0))
- )
- );
-
- // Check audience claim
- // Per MCP spec (RFC 8707), tokens MUST include the resource in the audience claim
- const aud = payload.aud;
- if (!aud) {
- // OAuth 2.1 with resource parameter requires audience claim
- // Reject tokens without audience for security
- return false;
- }
-
- // Audience can be string or array
- const audiences = Array.isArray(aud) ? aud : [aud];
-
- // For OAuth 2.1 with resource parameter, the audience MUST match the MCP server URL
- // This prevents token passthrough attacks (RFC 8707 - Resource Indicators)
- // Removed Supabase audience fallback (2025-01-XX) - all tokens must include MCP server URL in audience
- const hasMcpServerAudience = audiences.some((a) => a === expectedAudience);
-
- return hasMcpServerAudience;
- } catch (error) {
- // If we can't decode, reject (shouldn't happen since Supabase already validated)
- // Log for debugging but don't expose error details
- // Fire-and-forget error logging (non-blocking)
- const logContext = createDataApiContext('validate-token-audience', {
- app: 'heyclaude-mcp',
- });
- logError('Failed to decode JWT token for audience validation', logContext, error).catch(() => {
- // Swallow errors from logging itself - best effort
- });
- return false;
- }
-}
-
-/**
- * Builds the value for a WWW-Authenticate header used for MCP Bearer authentication.
- *
- * @param resourceMetadataUrl - URL of the protected-resource metadata to include as `resource_metadata`
- * @param scope - Optional space-delimited scope string to include as `scope`
- * @returns The WWW-Authenticate header value starting with `Bearer ` and containing `realm="mcp"`, `resource_metadata=""`, and optionally `scope=""`
- */
-function createWwwAuthenticateHeader(resourceMetadataUrl: string, scope?: string): string {
- const params = [`realm="mcp"`, `resource_metadata="${resourceMetadataUrl}"`];
-
- if (scope) {
- params.push(`scope="${scope}"`);
- }
-
- return `Bearer ${params.join(', ')}`;
-}
-
-/**
- * MCP protocol endpoint
- * Handles all MCP requests (tool calls, resource requests, etc.)
- * Requires authentication - JWT token must be provided in Authorization header
- *
- * Implements MCP OAuth 2.1 authorization per specification:
- * - Returns 401 with WWW-Authenticate header for unauthenticated requests
- * - Validates token audience (RFC 8707)
- * - Enforces Row-Level Security via authenticated Supabase client
- */
-mcpApp.all('/mcp', async (c) => {
- const logContext = createDataApiContext('mcp-protocol', {
- app: 'heyclaude-mcp',
- method: c.req.method,
- });
-
- // Initialize request logging with trace and bindings (Phase 1 & 2)
- initRequestLogging(logContext);
- traceStep('MCP protocol request received', logContext);
-
- // Set bindings for this request - mixin will automatically inject these into all subsequent logs
- logger.setBindings({
- requestId: typeof logContext['request_id'] === "string" ? logContext['request_id'] : undefined,
- operation: typeof logContext['action'] === "string" ? logContext['action'] : 'mcp-protocol',
- function: typeof logContext['function'] === "string" ? logContext['function'] : "unknown",
- method: c.req.method,
- });
-
- const mcpServerUrl = getMcpServerResourceUrl();
- const resourceMetadataUrl = `${mcpServerUrl.replace('/mcp', '')}/.well-known/oauth-protected-resource`;
-
- try {
- // Require authentication for all MCP requests
- const authResult = await requireAuthUser(c.req.raw, {
- cors: {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
- 'Access-Control-Allow-Headers':
- 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
- },
- errorMessage:
- 'Authentication required. Please provide a valid JWT token in the Authorization header.',
- });
-
- if ('response' in authResult) {
- // Add WWW-Authenticate header per MCP spec (RFC 9728)
- const wwwAuthHeader = createWwwAuthenticateHeader(resourceMetadataUrl, 'mcp:tools');
-
- // Clone response and add WWW-Authenticate header
- const response = authResult.response;
- const newHeaders = new Headers(response.headers);
- newHeaders.set('WWW-Authenticate', wwwAuthHeader);
-
- return new Response(response.body, {
- status: response.status,
- statusText: response.statusText,
- headers: newHeaders,
- });
- }
-
- // Validate token audience (RFC 8707 - Resource Indicators)
- // This ensures tokens were issued specifically for this MCP server
- if (!validateTokenAudience(authResult.token, mcpServerUrl)) {
- await logError(
- 'Token audience validation failed',
- logContext,
- new Error('Token audience mismatch')
- );
-
- const wwwAuthHeader = createWwwAuthenticateHeader(resourceMetadataUrl, 'mcp:tools');
- return c.json(
- {
- jsonrpc: '2.0',
- error: {
- code: -32001, // Invalid token
- message: 'Token audience mismatch. Token was not issued for this resource.',
- },
- id: null,
- },
- 401,
- {
- 'WWW-Authenticate': wwwAuthHeader,
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
- 'Access-Control-Allow-Headers':
- 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
- }
- );
- }
-
- // Create authenticated Supabase client for this request
- const authenticatedSupabase = getAuthenticatedSupabase(authResult.user, authResult.token);
-
- // Check global rate limiting before processing request
- // Note: We can't parse the body here because it's consumed by MCP handler
- // Per-tool rate limiting would require parsing the request body, which conflicts with MCP handler
- // Global rate limit provides protection against abuse
- const rateLimitResult = checkRateLimit(authResult.user.id);
- if (!rateLimitResult.allowed) {
- await logError('Rate limit exceeded', logContext, new Error('Rate limit exceeded'), {
- userId: authResult.user.id,
- retryAfter: rateLimitResult.retryAfter,
- });
-
- const wwwAuthHeader = createWwwAuthenticateHeader(resourceMetadataUrl, 'mcp:tools');
- return c.json(
- {
- jsonrpc: '2.0',
- error: {
- code: -32029, // Rate limit exceeded (custom MCP error code)
- message: `Rate limit exceeded. Retry after ${rateLimitResult.retryAfter} seconds.`,
- data: {
- errorCode: McpErrorCode.RATE_LIMIT_EXCEEDED,
- retryAfter: rateLimitResult.retryAfter,
- requestId: typeof logContext['request_id'] === 'string' ? logContext['request_id'] : undefined,
- },
- },
- id: null,
- },
- 429,
- {
- 'WWW-Authenticate': wwwAuthHeader,
- 'Retry-After': String(rateLimitResult.retryAfter || 60),
- 'X-RateLimit-Remaining': String(rateLimitResult.remaining || 0),
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
- 'Access-Control-Allow-Headers':
- 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
- }
- );
- }
-
- // Create a new MCP server instance with authenticated client for this request
- const requestMcp = new McpServer({
- name: 'heyclaude-mcp',
- version: MCP_SERVER_VERSION,
- schemaAdapter: (schema) => zodToJsonSchema(schema as z.ZodType),
- });
-
- // Register all tools with authenticated client (wrapped with timeout)
- registerAllTools(requestMcp, authenticatedSupabase);
-
- // Register all resources
- registerAllResources(requestMcp);
-
- // Register resource request handler
- requestMcp.onResourceRequest(async (uri) => {
- if (uri.startsWith('claudepro://content/')) {
- return handleContentResource(uri);
- }
- if (uri.startsWith('claudepro://category/')) {
- return handleCategoryResource(uri);
- }
- if (uri.startsWith('claudepro://sitewide/')) {
- return handleSitewideResource(uri);
- }
- throw new Error(`Unknown resource URI: ${uri}`);
- });
-
- // Create handler for this authenticated request
- const requestTransport = new StreamableHttpTransport();
- const requestHandler = requestTransport.bind(requestMcp);
-
- // Get the raw Request object from Hono context
- const request = c.req.raw;
-
- // Pass to MCP handler with timeout protection
- const response = await withTimeout(
- requestHandler(request),
- 60000, // 60s timeout for entire MCP request
- 'MCP request timed out after 60 seconds'
- );
-
- // Trace successful request completion
- traceRequestComplete(logContext);
-
- // Add rate limit and request ID headers to response
- const responseHeaders = new Headers(response.headers);
- const requestId = typeof logContext['request_id'] === 'string' ? logContext['request_id'] : undefined;
-
- if (rateLimitResult.allowed && rateLimitResult.remaining !== undefined) {
- responseHeaders.set('X-RateLimit-Remaining', String(rateLimitResult.remaining));
- responseHeaders.set('X-RateLimit-Reset', String(Math.ceil((Date.now() + 60000) / 1000))); // Approximate
- }
-
- if (requestId) {
- responseHeaders.set('X-Request-ID', requestId);
- }
-
- return new Response(response.body, {
- status: response.status,
- statusText: response.statusText,
- headers: responseHeaders,
- });
- } catch (error) {
- await logError('MCP protocol error handling request', logContext, error);
-
- // Convert error to structured MCP error response
- const requestId = typeof logContext['request_id'] === 'string' ? logContext['request_id'] : undefined;
- const mcpError = errorToMcpError(error, McpErrorCode.INTERNAL_ERROR, requestId);
-
- // Determine status code and error code
- const isAuthError =
- error instanceof AuthenticationError ||
- (error instanceof Error && error.name === 'AuthenticationError') ||
- mcpError.code === McpErrorCode.AUTHENTICATION_REQUIRED ||
- mcpError.code === McpErrorCode.TOKEN_INVALID ||
- mcpError.code === McpErrorCode.TOKEN_AUDIENCE_MISMATCH;
-
- const isTimeoutError =
- error instanceof Error &&
- (error.message.includes('timed out') || error.message.includes('timeout'));
-
- const isRateLimitError = mcpError.code === McpErrorCode.RATE_LIMIT_EXCEEDED;
-
- let statusCode = 500;
- let mcpErrorCode = -32603; // Internal error
-
- if (isAuthError) {
- statusCode = 401;
- mcpErrorCode = -32001; // Invalid token
- } else if (isTimeoutError) {
- statusCode = 504; // Gateway timeout
- mcpErrorCode = -32000; // Server error (timeout)
- } else if (isRateLimitError) {
- statusCode = 429; // Too many requests
- mcpErrorCode = -32029; // Rate limit exceeded
- }
-
- const wwwAuthHeader = isAuthError
- ? createWwwAuthenticateHeader(resourceMetadataUrl, 'mcp:tools')
- : undefined;
-
- return c.json(
- {
- jsonrpc: '2.0',
- error: {
- code: mcpErrorCode,
- message: mcpError.message,
- data: {
- errorCode: mcpError.code,
- ...(mcpError.details && { details: mcpError.details }),
- ...(mcpError.recovery && { recovery: mcpError.recovery }),
- ...(mcpError.requestId && { requestId: mcpError.requestId }),
- },
- },
- id: null,
- },
- statusCode,
- {
- ...(wwwAuthHeader && { 'WWW-Authenticate': wwwAuthHeader }),
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
- 'Access-Control-Allow-Headers':
- 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
- ...(requestId && { 'X-Request-ID': requestId }),
- }
- );
- }
-});
-
-// Mount mcpApp at /heyclaude-mcp path
-app.route('/heyclaude-mcp', mcpApp);
-
-// Export the Deno serve handler
-// This is the required export for Supabase Edge Functions
-Deno.serve(app.fetch);
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/lib/errors.ts b/apps/edge/supabase/functions/heyclaude-mcp/lib/errors.ts
deleted file mode 100644
index 7b356c598..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/lib/errors.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-/**
- * Structured Error Codes and Error Handling
- *
- * Provides standardized error codes, messages, and error response formatting
- * for consistent error handling across all MCP tools.
- */
-
-/**
- * Error codes for different error types
- */
-export enum McpErrorCode {
- // Authentication & Authorization
- AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
- TOKEN_INVALID = 'TOKEN_INVALID',
- TOKEN_AUDIENCE_MISMATCH = 'TOKEN_AUDIENCE_MISMATCH',
- AUTHORIZATION_FAILED = 'AUTHORIZATION_FAILED',
-
- // Content Not Found
- CONTENT_NOT_FOUND = 'CONTENT_NOT_FOUND',
- CATEGORY_NOT_FOUND = 'CATEGORY_NOT_FOUND',
- SLUG_NOT_FOUND = 'SLUG_NOT_FOUND',
-
- // Validation Errors
- INVALID_INPUT = 'INVALID_INPUT',
- INVALID_CATEGORY = 'INVALID_CATEGORY',
- INVALID_SLUG = 'INVALID_SLUG',
- INVALID_EMAIL = 'INVALID_EMAIL',
- INVALID_PROVIDER = 'INVALID_PROVIDER',
- INVALID_SUBMISSION_TYPE = 'INVALID_SUBMISSION_TYPE',
- INVALID_PLATFORM = 'INVALID_PLATFORM',
- INVALID_FORMAT = 'INVALID_FORMAT',
- INVALID_URI = 'INVALID_URI',
-
- // Rate Limiting
- RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
-
- // External Service Errors
- INNGEST_ERROR = 'INNGEST_ERROR',
- API_ROUTE_ERROR = 'API_ROUTE_ERROR',
- DATABASE_ERROR = 'DATABASE_ERROR',
-
- // Timeout Errors
- REQUEST_TIMEOUT = 'REQUEST_TIMEOUT',
- OPERATION_TIMEOUT = 'OPERATION_TIMEOUT',
-
- // Server Errors
- INTERNAL_ERROR = 'INTERNAL_ERROR',
- SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
-}
-
-/**
- * User-friendly error messages
- */
-export const ERROR_MESSAGES: Record = {
- AUTHENTICATION_REQUIRED: 'Authentication required. Please provide a valid JWT token in the Authorization header.',
- TOKEN_INVALID: 'Invalid or expired authentication token.',
- TOKEN_AUDIENCE_MISMATCH: 'Token audience mismatch. Token was not issued for this resource.',
- AUTHORIZATION_FAILED: 'Authorization failed. You do not have permission to perform this action.',
-
- CONTENT_NOT_FOUND: 'The requested content item was not found.',
- CATEGORY_NOT_FOUND: 'The specified category does not exist.',
- SLUG_NOT_FOUND: 'The specified content slug does not exist in this category.',
-
- INVALID_INPUT: 'Invalid input provided. Please check your request parameters.',
- INVALID_CATEGORY: 'Invalid category specified. Please use a valid category name.',
- INVALID_SLUG: 'Invalid slug format. Slugs must be alphanumeric with hyphens.',
- INVALID_EMAIL: 'Invalid email address format.',
- INVALID_PROVIDER: 'Invalid OAuth provider. Supported providers: GitHub, Google, Discord.',
- INVALID_SUBMISSION_TYPE: 'Invalid submission type. Please use a valid submission type.',
- INVALID_PLATFORM: 'Invalid platform specified. Supported platforms: claude-code, cursor, chatgpt-codex, generic.',
- INVALID_FORMAT: 'Invalid format specified. Please use a supported format.',
- INVALID_URI: 'Invalid resource URI format.',
-
- RATE_LIMIT_EXCEEDED: 'Rate limit exceeded. Please try again later.',
-
- INNGEST_ERROR: 'Failed to process newsletter subscription. Please try again later.',
- API_ROUTE_ERROR: 'Failed to fetch content from API. Please try again later.',
- DATABASE_ERROR: 'Database operation failed. Please try again later.',
-
- REQUEST_TIMEOUT: 'Request timed out. The operation took too long to complete.',
- OPERATION_TIMEOUT: 'Operation timed out. Please try again with a simpler request.',
-
- INTERNAL_ERROR: 'An internal server error occurred. Please try again later.',
- SERVICE_UNAVAILABLE: 'Service temporarily unavailable. Please try again later.',
-};
-
-/**
- * Error recovery suggestions with actionable steps
- */
-export const ERROR_RECOVERY: Partial> = {
- CONTENT_NOT_FOUND: [
- 'Try searching for similar content with searchContent tool',
- 'Check if the category is correct with listCategories tool',
- 'Use getRecent to see recently added content',
- 'Try getTrending to see popular content',
- ],
- CATEGORY_NOT_FOUND: [
- 'Use listCategories tool to see available categories',
- 'Check the category name spelling',
- ],
- SLUG_NOT_FOUND: [
- 'Verify the slug is correct',
- 'Search for the content by name using searchContent',
- 'Check the category with listCategories',
- ],
- INVALID_CATEGORY: [
- 'Use listCategories tool to see valid category names',
- 'Check the category spelling',
- ],
- INVALID_SLUG: [
- 'Slugs must be alphanumeric with hyphens, underscores, or dots',
- 'Check the slug format and try again',
- ],
- INVALID_EMAIL: [
- 'Ensure the email address is properly formatted (e.g., user@example.com)',
- 'Check for typos in the email address',
- ],
- INVALID_PROVIDER: [
- 'Use one of the supported providers: github, google, or discord',
- 'Check the provider name spelling',
- ],
- INVALID_PLATFORM: [
- 'Supported platforms: claude-code, cursor, chatgpt-codex, generic',
- 'Check the platform name spelling',
- ],
- INVALID_FORMAT: [
- 'Check the format parameter',
- 'Use a supported format for the requested resource',
- ],
- RATE_LIMIT_EXCEEDED: [
- 'Wait a moment before making another request',
- 'Reduce the frequency of requests',
- ],
- REQUEST_TIMEOUT: [
- 'Try breaking your request into smaller parts',
- 'Use more specific filters to reduce result size',
- 'Try again with a simpler query',
- ],
- API_ROUTE_ERROR: [
- 'The content may be temporarily unavailable',
- 'Try again in a few moments',
- 'Check if the resource URI is correct',
- ],
- DATABASE_ERROR: [
- 'The database may be temporarily unavailable',
- 'Try again in a few moments',
- ],
-};
-
-/**
- * Structured error response
- */
-export interface McpErrorResponse {
- code: McpErrorCode;
- message: string;
- details?: string;
- recovery?: string; // Can be string or array joined with ' | '
- suggestions?: string[]; // Actionable suggestions for fixing the error
- requestId?: string;
-}
-
-/**
- * Create a structured error response with actionable suggestions
- */
-export function createErrorResponse(
- code: McpErrorCode | string,
- details?: string,
- requestId?: string
-): McpErrorResponse {
- // Handle string error codes (for backward compatibility)
- const errorCode = typeof code === 'string'
- ? (Object.values(McpErrorCode).includes(code as McpErrorCode) ? code as McpErrorCode : McpErrorCode.INTERNAL_ERROR)
- : code;
-
- const response: McpErrorResponse = {
- code: errorCode,
- message: ERROR_MESSAGES[errorCode] || 'An error occurred',
- };
-
- if (details) {
- response.details = details;
- }
-
- // Include recovery suggestions (now an array)
- const recovery = ERROR_RECOVERY[errorCode];
- if (recovery) {
- if (Array.isArray(recovery)) {
- response.recovery = recovery.join(' | ');
- response.suggestions = recovery; // Also include as array for structured access
- } else {
- response.recovery = recovery;
- }
- }
-
- if (requestId) {
- response.requestId = requestId;
- }
-
- return response;
-}
-
-/**
- * Convert error to MCP error response
- */
-export function errorToMcpError(
- error: unknown,
- defaultCode: McpErrorCode = McpErrorCode.INTERNAL_ERROR,
- requestId?: string
-): McpErrorResponse {
- if (error instanceof Error) {
- // Check for specific error patterns
- if (error.message.includes('not found') || error.message.includes('does not exist')) {
- return createErrorResponse(McpErrorCode.CONTENT_NOT_FOUND, error.message, requestId);
- }
- if (error.message.includes('invalid') || error.message.includes('Invalid')) {
- return createErrorResponse(McpErrorCode.INVALID_INPUT, error.message, requestId);
- }
- if (error.message.includes('timeout') || error.message.includes('timed out')) {
- return createErrorResponse(McpErrorCode.REQUEST_TIMEOUT, error.message, requestId);
- }
- if (error.message.includes('rate limit')) {
- return createErrorResponse(McpErrorCode.RATE_LIMIT_EXCEEDED, error.message, requestId);
- }
-
- return createErrorResponse(defaultCode, error.message, requestId);
- }
-
- return createErrorResponse(defaultCode, 'An unknown error occurred', requestId);
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/lib/platform-formatters.ts b/apps/edge/supabase/functions/heyclaude-mcp/lib/platform-formatters.ts
deleted file mode 100644
index d03c244af..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/lib/platform-formatters.ts
+++ /dev/null
@@ -1,598 +0,0 @@
-/**
- * Platform-Specific Content Formatters
- *
- * Formats content items for different development platforms:
- * - Claude Code: .claude/CLAUDE.md format
- * - Cursor IDE: .cursor/rules/ directory (old .cursorrules deprecated)
- * - Also supports .claude/CLAUDE.md (Claude Code compatibility)
- * - OpenAI Codex: AGENTS.md format (project root)
- * - Generic: Plain markdown format
- *
- * Research completed via Context7 MCP:
- * - Claude Code: Official docs from anthropics/claude-code, thevibeworks/claude-code-docs
- * - Cursor IDE: Official docs from getcursor/docs, gabimoncha/cursor-rules-cli
- * - OpenAI Codex: Official docs from openai/codex, developers.openai.com/codex
- *
- * Updates:
- * - (2025-01-XX): Cursor IDE has deprecated .cursorrules in favor of .cursor/rules/ directory
- * - (2025-01-XX): Cursor IDE also supports .claude/CLAUDE.md (Claude Code compatibility)
- */
-
-/**
- * Content item structure from getContentDetail
- */
-export interface ContentItem {
- slug: string;
- title: string;
- displayTitle: string;
- category: string;
- description: string;
- content: string;
- tags: string[];
- author: string;
- authorProfileUrl: string | null;
- dateAdded: string | null;
- dateUpdated: string | null;
- createdAt: string;
- metadata: {
- examples?: Array<{
- title?: string;
- description?: string;
- code?: string;
- language?: string;
- }>;
- features?: string[];
- use_cases?: string[];
- requirements?: string[];
- troubleshooting?: Array<{
- issue?: string;
- solution?: string;
- question?: string;
- answer?: string;
- }>;
- configuration?: Record;
- installation?: {
- claudeCode?: {
- steps?: string[];
- };
- claudeDesktop?: {
- steps?: string[];
- configPath?: Record;
- };
- };
- };
- stats: {
- views: number;
- bookmarks: number;
- copies: number;
- };
-}
-
-/**
- * Format content for Claude Code (.claude/CLAUDE.md)
- *
- * Creates a clean markdown file ready to be placed in .claude/CLAUDE.md
- * Includes all relevant sections: content, examples, features, use cases, troubleshooting
- */
-export function formatForClaudeCode(item: ContentItem): string {
- const sections: string[] = [];
-
- // Title and description
- sections.push(`# ${item.title}\n`);
- if (item.description) {
- sections.push(`${item.description}\n`);
- }
-
- // Main content (the rule/agent/command content itself)
- if (item.content) {
- sections.push(item.content);
- }
-
- // Features section
- if (item.metadata.features && item.metadata.features.length > 0) {
- sections.push('\n## Features\n');
- item.metadata.features.forEach((feature) => {
- sections.push(`- ${feature}`);
- });
- }
-
- // Use Cases section
- if (item.metadata.use_cases && item.metadata.use_cases.length > 0) {
- sections.push('\n## Use Cases\n');
- item.metadata.use_cases.forEach((useCase) => {
- sections.push(`- ${useCase}`);
- });
- }
-
- // Examples section
- if (item.metadata.examples && item.metadata.examples.length > 0) {
- sections.push('\n## Examples\n');
- item.metadata.examples.forEach((example, index) => {
- if (example.title) {
- sections.push(`### ${example.title}\n`);
- } else {
- sections.push(`### Example ${index + 1}\n`);
- }
- if (example.description) {
- sections.push(`${example.description}\n`);
- }
- if (example.code) {
- const language = example.language || 'plaintext';
- sections.push(`\`\`\`${language}\n${example.code}\n\`\`\`\n`);
- }
- });
- }
-
- // Requirements section
- if (item.metadata.requirements && item.metadata.requirements.length > 0) {
- sections.push('\n## Requirements\n');
- item.metadata.requirements.forEach((requirement) => {
- sections.push(`- ${requirement}`);
- });
- }
-
- // Configuration section
- if (item.metadata.configuration) {
- sections.push('\n## Configuration\n');
- sections.push('```json');
- sections.push(JSON.stringify(item.metadata.configuration, null, 2));
- sections.push('```\n');
- }
-
- // Troubleshooting section
- if (item.metadata.troubleshooting && item.metadata.troubleshooting.length > 0) {
- sections.push('\n## Troubleshooting\n');
- item.metadata.troubleshooting.forEach((item) => {
- // Handle both issue/solution and question/answer formats
- if (item.issue && item.solution) {
- sections.push(`### ${item.issue}\n`);
- sections.push(`${item.solution}\n`);
- } else if (item.question && item.answer) {
- sections.push(`### ${item.question}\n`);
- sections.push(`${item.answer}\n`);
- }
- });
- }
-
- // Footer with metadata
- sections.push('\n---\n');
- sections.push(`**Source:** ${item.author}`);
- if (item.authorProfileUrl) {
- sections.push(` | [Profile](${item.authorProfileUrl})`);
- }
- sections.push(` | [Claude Pro Directory](https://claudepro.directory/${item.category}/${item.slug})`);
- if (item.tags.length > 0) {
- sections.push(`\n**Tags:** ${item.tags.join(', ')}`);
- }
-
- return sections.join('\n');
-}
-
-/**
- * Format content for Cursor IDE (.cursor/rules/)
- *
- * Cursor IDE uses .cursor/rules/ directory for AI instructions.
- * The old .cursorrules file in project root is deprecated.
- * Format is similar to Claude Code's CLAUDE.md but optimized for Cursor's context.
- * Cursor supports markdown with code blocks, examples, and structured sections.
- * Files in .cursor/rules/ can have any name (typically .md or .mdc extension).
- */
-export function formatForCursor(item: ContentItem): string {
- const sections: string[] = [];
-
- // Title and description
- sections.push(`# ${item.title}\n`);
- if (item.description) {
- sections.push(`${item.description}\n`);
- }
-
- // Main content
- if (item.content) {
- sections.push(item.content);
- }
-
- // Features section
- if (item.metadata.features && item.metadata.features.length > 0) {
- sections.push('\n## Features\n');
- item.metadata.features.forEach((feature) => {
- sections.push(`- ${feature}`);
- });
- sections.push('');
- }
-
- // Use cases section
- if (item.metadata.use_cases && item.metadata.use_cases.length > 0) {
- sections.push('\n## Use Cases\n');
- item.metadata.use_cases.forEach((useCase) => {
- sections.push(`- ${useCase}`);
- });
- sections.push('');
- }
-
- // Examples section (important for Cursor)
- if (item.metadata.examples && item.metadata.examples.length > 0) {
- sections.push('\n## Examples\n');
- item.metadata.examples.forEach((example, index) => {
- if (example.title) {
- sections.push(`### ${example.title}\n`);
- } else {
- sections.push(`### Example ${index + 1}\n`);
- }
- if (example.description) {
- sections.push(`${example.description}\n`);
- }
- if (example.code) {
- const language = example.language || 'typescript';
- sections.push(`\`\`\`${language}\n${example.code}\n\`\`\`\n`);
- }
- });
- }
-
- // Requirements section
- if (item.metadata.requirements && item.metadata.requirements.length > 0) {
- sections.push('\n## Requirements\n');
- item.metadata.requirements.forEach((requirement) => {
- sections.push(`- ${requirement}`);
- });
- sections.push('');
- }
-
- // Configuration section
- if (item.metadata.configuration && Object.keys(item.metadata.configuration).length > 0) {
- sections.push('\n## Configuration\n');
- sections.push('```json');
- sections.push(JSON.stringify(item.metadata.configuration, null, 2));
- sections.push('```\n');
- }
-
- // Troubleshooting section
- if (item.metadata.troubleshooting && item.metadata.troubleshooting.length > 0) {
- sections.push('\n## Troubleshooting\n');
- item.metadata.troubleshooting.forEach((item) => {
- if (item.issue && item.solution) {
- sections.push(`### ${item.issue}\n`);
- sections.push(`${item.solution}\n`);
- } else if (item.question && item.answer) {
- sections.push(`### ${item.question}\n`);
- sections.push(`${item.answer}\n`);
- }
- });
- }
-
- // Footer
- sections.push('\n---\n');
- sections.push(`**Source:** ${item.author}`);
- if (item.authorProfileUrl) {
- sections.push(` | [Profile](${item.authorProfileUrl})`);
- }
- sections.push(`\n**Category:** ${item.category}`);
- sections.push(` | **Added:** ${item.dateAdded}`);
-
- return sections.join('\n');
-}
-
-/**
- * Format content for OpenAI Codex (AGENTS.md)
- *
- * OpenAI Codex uses AGENTS.md file in project root for agent instructions.
- * Format is similar to Claude Code's CLAUDE.md but optimized for Codex's context.
- * Codex supports markdown with code blocks, examples, and structured sections.
- * Supports hierarchical merging from subdirectories.
- */
-export function formatForCodex(item: ContentItem): string {
- const sections: string[] = [];
-
- // Title and description
- sections.push(`# ${item.title}\n`);
- if (item.description) {
- sections.push(`${item.description}\n`);
- }
-
- // Main content
- if (item.content) {
- sections.push(item.content);
- }
-
- // Features section
- if (item.metadata.features && item.metadata.features.length > 0) {
- sections.push('\n## Features\n');
- item.metadata.features.forEach((feature) => {
- sections.push(`- ${feature}`);
- });
- sections.push('');
- }
-
- // Use cases section
- if (item.metadata.use_cases && item.metadata.use_cases.length > 0) {
- sections.push('\n## Use Cases\n');
- item.metadata.use_cases.forEach((useCase) => {
- sections.push(`- ${useCase}`);
- });
- sections.push('');
- }
-
- // Examples section (important for Codex)
- if (item.metadata.examples && item.metadata.examples.length > 0) {
- sections.push('\n## Examples\n');
- item.metadata.examples.forEach((example, index) => {
- if (example.title) {
- sections.push(`### ${example.title}\n`);
- } else {
- sections.push(`### Example ${index + 1}\n`);
- }
- if (example.description) {
- sections.push(`${example.description}\n`);
- }
- if (example.code) {
- const language = example.language || 'typescript';
- sections.push(`\`\`\`${language}\n${example.code}\n\`\`\`\n`);
- }
- });
- }
-
- // Requirements section
- if (item.metadata.requirements && item.metadata.requirements.length > 0) {
- sections.push('\n## Requirements\n');
- item.metadata.requirements.forEach((requirement) => {
- sections.push(`- ${requirement}`);
- });
- sections.push('');
- }
-
- // Configuration section
- if (item.metadata.configuration && Object.keys(item.metadata.configuration).length > 0) {
- sections.push('\n## Configuration\n');
- sections.push('```json');
- sections.push(JSON.stringify(item.metadata.configuration, null, 2));
- sections.push('```\n');
- }
-
- // Troubleshooting section
- if (item.metadata.troubleshooting && item.metadata.troubleshooting.length > 0) {
- sections.push('\n## Troubleshooting\n');
- item.metadata.troubleshooting.forEach((item) => {
- if (item.issue && item.solution) {
- sections.push(`### ${item.issue}\n`);
- sections.push(`${item.solution}\n`);
- } else if (item.question && item.answer) {
- sections.push(`### ${item.question}\n`);
- sections.push(`${item.answer}\n`);
- }
- });
- }
-
- // Footer
- sections.push('\n---\n');
- sections.push(`**Source:** ${item.author}`);
- if (item.authorProfileUrl) {
- sections.push(` | [Profile](${item.authorProfileUrl})`);
- }
- sections.push(`\n**Category:** ${item.category}`);
- sections.push(` | **Added:** ${item.dateAdded}`);
-
- return sections.join('\n');
-}
-
-/**
- * Format content as generic markdown
- *
- * Plain markdown format that works with any tool
- */
-export function formatGeneric(item: ContentItem): string {
- const sections: string[] = [];
-
- sections.push(`# ${item.title}\n`);
- if (item.description) {
- sections.push(`${item.description}\n`);
- }
- if (item.content) {
- sections.push(item.content);
- }
-
- // Include all available sections
- if (item.metadata.features && item.metadata.features.length > 0) {
- sections.push('\n## Features\n');
- item.metadata.features.forEach((feature) => {
- sections.push(`- ${feature}`);
- });
- }
-
- if (item.metadata.use_cases && item.metadata.use_cases.length > 0) {
- sections.push('\n## Use Cases\n');
- item.metadata.use_cases.forEach((useCase) => {
- sections.push(`- ${useCase}`);
- });
- }
-
- if (item.metadata.examples && item.metadata.examples.length > 0) {
- sections.push('\n## Examples\n');
- item.metadata.examples.forEach((example, index) => {
- if (example.title) {
- sections.push(`### ${example.title}\n`);
- } else {
- sections.push(`### Example ${index + 1}\n`);
- }
- if (example.description) {
- sections.push(`${example.description}\n`);
- }
- if (example.code) {
- const language = example.language || 'plaintext';
- sections.push(`\`\`\`${language}\n${example.code}\n\`\`\`\n`);
- }
- });
- }
-
- return sections.join('\n');
-}
-
-/**
- * Get platform-specific filename
- *
- * Based on official documentation:
- * - Claude Code: CLAUDE.md (in .claude/ directory)
- * - Cursor IDE: Any .mdc file in .cursor/rules/ directory (old .cursorrules deprecated)
- * - OpenAI Codex: AGENTS.md (in project root, supports CLAUDE.md as fallback)
- */
-export function getPlatformFilename(platform: string): string {
- switch (platform) {
- case 'claude-code':
- return 'CLAUDE.md';
- case 'cursor':
- // Cursor IDE now uses .cursor/rules/ directory
- // Files must use .mdc extension (not .md)
- // We'll use a descriptive filename based on content
- return 'cursor-rules.mdc';
- case 'chatgpt-codex':
- // OpenAI Codex uses AGENTS.md as primary filename
- // Supports fallback to CLAUDE.md for compatibility
- // Configuration is in ~/.codex/config.toml, not in project
- return 'AGENTS.md';
- default:
- return 'content.md';
- }
-}
-
-/**
- * Get platform-specific target directory
- *
- * Based on official documentation:
- * - Claude Code: .claude/ directory (recommended) or project root
- * - Cursor IDE: .cursor/rules/ directory (old .cursorrules in root is deprecated)
- * - Also supports .claude/CLAUDE.md (Claude Code compatibility)
- * - OpenAI Codex: Project root (supports hierarchical subdirectories)
- */
-export function getTargetDirectory(platform: string): string {
- switch (platform) {
- case 'claude-code':
- return '.claude'; // Recommended location
- case 'cursor':
- return '.cursor/rules'; // New directory structure (old .cursorrules deprecated)
- // Note: Cursor IDE also supports .claude/CLAUDE.md (Claude Code compatibility)
- case 'chatgpt-codex':
- // OpenAI Codex uses project root for AGENTS.md
- // Supports hierarchical merging from subdirectories
- return '.';
- default:
- return '.';
- }
-}
-
-/**
- * Get installation instructions for platform
- *
- * @param platform - Platform identifier
- * @param filename - Platform-specific filename
- * @param targetDir - Target directory path
- * @param formattedContent - The formatted content to include in instructions (optional)
- */
-export function getInstallationInstructions(
- platform: string,
- filename: string,
- targetDir: string,
- formattedContent?: string
-): string {
- const instructions: string[] = [];
-
- switch (platform) {
- case 'claude-code': {
- instructions.push('## Installation Instructions\n');
- instructions.push('1. **Create `.claude` directory** in your project root:');
- instructions.push(' ```bash');
- instructions.push(' mkdir -p .claude');
- instructions.push(' ```\n');
- instructions.push('2. **Save the formatted content above** as `.claude/CLAUDE.md`.\n');
- instructions.push(' You can either:');
- instructions.push(' - **Copy and paste** the content above into `.claude/CLAUDE.md`');
- instructions.push(' - **Or use this command** (replace with actual content):');
- instructions.push(' ```bash');
- instructions.push(' cat > .claude/CLAUDE.md << \'EOF\'');
- if (formattedContent) {
- // Include actual content in heredoc (escaped for shell)
- const escapedContent = formattedContent.replace(/'/g, "'\\''");
- instructions.push(escapedContent);
- }
- instructions.push(' EOF');
- instructions.push(' ```\n');
- instructions.push('3. **Restart Claude Code** to load the new rules.\n');
- instructions.push('**Note:** The content above is already formatted for Claude Code. Simply copy it into `.claude/CLAUDE.md` in your project.');
- break;
- }
- case 'cursor': {
- instructions.push('## Installation Instructions\n');
- instructions.push('### Option 1: Native Cursor Format (`.cursor/rules/`)\n');
- instructions.push('1. **Create `.cursor/rules` directory** in your project root (if it doesn\'t exist):');
- instructions.push(' ```bash');
- instructions.push(' mkdir -p .cursor/rules');
- instructions.push(' ```\n');
- instructions.push('2. **Save the formatted content above** as a file in `.cursor/rules/` directory.\n');
- instructions.push(' You can either:');
- instructions.push(' - **Copy and paste** the content above into `.cursor/rules/cursor-rules.mdc`');
- instructions.push(' - **Or use this command** (replace with actual content):');
- instructions.push(' ```bash');
- instructions.push(' cat > .cursor/rules/cursor-rules.mdc << \'EOF\'');
- if (formattedContent) {
- const escapedContent = formattedContent.replace(/'/g, "'\\''");
- instructions.push(escapedContent);
- }
- instructions.push(' EOF');
- instructions.push(' ```\n');
- instructions.push('3. **Restart Cursor IDE** to load the new rules.\n');
- instructions.push('\n### Option 2: Claude Code Compatibility (`.claude/CLAUDE.md`)\n');
- instructions.push('**Cursor IDE also supports Claude Code format!** You can use the same `CLAUDE.md` file for both platforms:\n');
- instructions.push('1. **Create `.claude` directory** in your project root (if it doesn\'t exist):');
- instructions.push(' ```bash');
- instructions.push(' mkdir -p .claude');
- instructions.push(' ```\n');
- instructions.push('2. **Save the content** as `.claude/CLAUDE.md` (same format as Claude Code).\n');
- instructions.push('3. **Both Claude Code and Cursor IDE** will automatically load this file.\n');
- instructions.push('\n**Note:** The content above is already formatted for Cursor IDE. Cursor now uses `.cursor/rules/` directory instead of the deprecated `.cursorrules` file in project root.');
- instructions.push('\n**Important:**');
- instructions.push('- Files in `.cursor/rules/` must use the `.mdc` extension (not `.md`)');
- instructions.push('- Cursor IDE also supports `CLAUDE.md` from `.claude/` directory (Claude Code compatibility)');
- instructions.push('- Using `.claude/CLAUDE.md` allows sharing rules between Claude Code and Cursor IDE');
- break;
- }
- case 'chatgpt-codex': {
- instructions.push('## Installation Instructions\n');
- instructions.push('1. **Save the formatted content above** as `AGENTS.md` in your project root.\n');
- instructions.push(' You can either:');
- instructions.push(' - **Copy and paste** the content above into `AGENTS.md`');
- instructions.push(' - **Or use this command** (replace with actual content):');
- instructions.push(' ```bash');
- instructions.push(' cat > AGENTS.md << \'EOF\'');
- if (formattedContent) {
- const escapedContent = formattedContent.replace(/'/g, "'\\''");
- instructions.push(escapedContent);
- }
- instructions.push(' EOF');
- instructions.push(' ```\n');
- instructions.push('2. **Codex will automatically load** `AGENTS.md` at session start.\n');
- instructions.push('**Note:** The content above is already formatted for OpenAI Codex. Simply copy it into `AGENTS.md` in your project root.');
- instructions.push('\n**Alternative:** Codex supports fallback to `CLAUDE.md` if you prefer that filename. Configure in `~/.codex/config.toml` with `project_doc_fallback_filenames = ["CLAUDE.md"]`.');
- instructions.push('\n**Hierarchical Support:** Codex supports hierarchical merging - you can place `AGENTS.md` files in subdirectories, and they will be merged with the root `AGENTS.md`.');
- break;
- }
- default: {
- instructions.push('## Installation Instructions\n');
- instructions.push(`1. **Save the formatted content above** as \`${filename}\` in your project.\n`);
- instructions.push(' You can either:');
- instructions.push(` - **Copy and paste** the content above into \`${targetDir}/${filename}\``);
- instructions.push(' - **Or use this command** (replace with actual content):');
- instructions.push(' ```bash');
- if (targetDir !== '.') {
- instructions.push(` mkdir -p ${targetDir}`);
- }
- instructions.push(` cat > ${targetDir}/${filename} << 'EOF'`);
- if (formattedContent) {
- const escapedContent = formattedContent.replace(/'/g, "'\\''");
- instructions.push(escapedContent);
- }
- instructions.push(' EOF');
- instructions.push(' ```\n');
- instructions.push(`2. **Follow your platform's instructions** to load the configuration.\n`);
- instructions.push(`**Note:** The content above is already formatted. Simply copy it into \`${targetDir}/${filename}\` in your project.`);
- }
- }
-
- return instructions.join('\n');
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/lib/rate-limit.ts b/apps/edge/supabase/functions/heyclaude-mcp/lib/rate-limit.ts
deleted file mode 100644
index e7a2b664b..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/lib/rate-limit.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-/**
- * Rate Limiting
- *
- * Simple in-memory rate limiting for MCP edge function.
- * Tracks requests per user per time window.
- *
- * Note: This is a basic implementation. For production at scale,
- * consider using Redis or Supabase Edge Function rate limiting.
- */
-
-interface RateLimitEntry {
- count: number;
- resetAt: number;
-}
-
-/**
- * In-memory rate limit store
- * Key: userId:toolName or userId:global
- * Value: { count, resetAt }
- */
-const rateLimitStore = new Map();
-
-/**
- * Rate limit configuration
- */
-export interface RateLimitConfig {
- /** Maximum requests per window */
- maxRequests: number;
- /** Window duration in milliseconds */
- windowMs: number;
- /** Whether to apply per-tool or global */
- perTool?: boolean;
-}
-
-/**
- * Default rate limit configurations
- */
-export const RATE_LIMITS: Record = {
- // Global rate limit (all tools combined)
- global: {
- maxRequests: 100,
- windowMs: 60 * 1000, // 1 minute
- perTool: false,
- },
- // Per-tool rate limits (in addition to global)
- 'searchContent': {
- maxRequests: 30,
- windowMs: 60 * 1000,
- perTool: true,
- },
- 'getContentDetail': {
- maxRequests: 50,
- windowMs: 60 * 1000,
- perTool: true,
- },
- 'downloadContentForPlatform': {
- maxRequests: 20,
- windowMs: 60 * 1000,
- perTool: true,
- },
- 'subscribeNewsletter': {
- maxRequests: 5,
- windowMs: 60 * 1000,
- perTool: true,
- },
- 'createAccount': {
- maxRequests: 10,
- windowMs: 60 * 1000,
- perTool: true,
- },
- 'submitContent': {
- maxRequests: 10,
- windowMs: 60 * 1000,
- perTool: true,
- },
-};
-
-/**
- * Check if request should be rate limited
- *
- * @param userId - User ID from JWT token
- * @param toolName - Tool name being called (optional, for per-tool limits)
- * @returns Object with `allowed` boolean and `retryAfter` seconds if limited
- */
-export function checkRateLimit(
- userId: string,
- toolName?: string
-): { allowed: boolean; retryAfter?: number; remaining?: number } {
- const now = Date.now();
-
- // Check global rate limit
- const globalKey = `${userId}:global`;
- const globalEntry = rateLimitStore.get(globalKey);
- const globalConfig = RATE_LIMITS.global;
-
- if (!globalEntry || now >= globalEntry.resetAt) {
- // Reset or initialize global counter
- rateLimitStore.set(globalKey, {
- count: 1,
- resetAt: now + globalConfig.windowMs,
- });
- } else {
- globalEntry.count++;
- if (globalEntry.count > globalConfig.maxRequests) {
- const retryAfter = Math.ceil((globalEntry.resetAt - now) / 1000);
- return {
- allowed: false,
- retryAfter,
- remaining: 0,
- };
- }
- }
-
- // Check per-tool rate limit if tool name provided
- if (toolName && RATE_LIMITS[toolName]) {
- const toolConfig = RATE_LIMITS[toolName];
- const toolKey = `${userId}:${toolName}`;
- const toolEntry = rateLimitStore.get(toolKey);
-
- if (!toolEntry || now >= toolEntry.resetAt) {
- // Reset or initialize tool counter
- rateLimitStore.set(toolKey, {
- count: 1,
- resetAt: now + toolConfig.windowMs,
- });
- } else {
- toolEntry.count++;
- if (toolEntry.count > toolConfig.maxRequests) {
- const retryAfter = Math.ceil((toolEntry.resetAt - now) / 1000);
- return {
- allowed: false,
- retryAfter,
- remaining: 0,
- };
- }
- }
- }
-
- // Calculate remaining requests
- const globalRemaining = globalEntry
- ? Math.max(0, globalConfig.maxRequests - globalEntry.count)
- : globalConfig.maxRequests - 1;
-
- return {
- allowed: true,
- remaining: globalRemaining,
- };
-}
-
-/**
- * Clean up expired rate limit entries
- * Should be called periodically to prevent memory leaks
- */
-export function cleanupRateLimits(): void {
- const now = Date.now();
- for (const [key, entry] of rateLimitStore.entries()) {
- if (now >= entry.resetAt) {
- rateLimitStore.delete(key);
- }
- }
-}
-
-/**
- * Initialize periodic cleanup (every 5 minutes)
- */
-if (typeof globalThis !== 'undefined') {
- // Run cleanup every 5 minutes
- setInterval(cleanupRateLimits, 5 * 60 * 1000);
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/lib/types.ts b/apps/edge/supabase/functions/heyclaude-mcp/lib/types.ts
deleted file mode 100644
index caae24bec..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/lib/types.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * MCP Tool Type Definitions
- *
- * Centralized types for all MCP tools exposed by the HeyClaude server.
- * Leverages existing database types from @heyclaude/database-types
- */
-
-import { z } from 'zod';
-
-/**
- * MCP Server Metadata
- */
-export const MCP_SERVER_VERSION = '1.0.0';
-export const MCP_PROTOCOL_VERSION = '2025-06-18';
-
-/**
- * Category enum - matches database enum
- */
-export const CategorySchema = z.enum([
- 'agents',
- 'rules',
- 'commands',
- 'skills',
- 'collections',
- 'mcp',
-]);
-
-export type Category = z.infer;
-
-/**
- * Input schemas for each tool
- */
-export const ListCategoriesInputSchema = z.object({});
-
-export const SearchContentInputSchema = z.object({
- query: z.string().optional().describe('Search query string'),
- category: CategorySchema.optional().describe('Filter by category'),
- tags: z.array(z.string()).optional().describe('Filter by tags'),
- page: z.number().min(1).default(1).describe('Page number for pagination'),
- limit: z.number().min(1).max(50).default(20).describe('Items per page (max 50)'),
-});
-
-export const GetContentDetailInputSchema = z.object({
- slug: z.string().describe('Content slug identifier'),
- category: CategorySchema.describe('Content category'),
-});
-
-export const GetTrendingInputSchema = z.object({
- category: CategorySchema.optional().describe('Filter by category (optional)'),
- limit: z.number().min(1).max(50).default(20).describe('Number of items to return (max 50)'),
-});
-
-export const GetFeaturedInputSchema = z.object({});
-
-export const GetTemplatesInputSchema = z.object({
- category: CategorySchema.optional().describe('Get templates for specific category (optional)'),
-});
-
-// Advanced tool schemas (Phase 3)
-export const GetMcpServersInputSchema = z.object({
- limit: z.number().min(1).max(50).default(20).describe('Number of servers to return (max 50)'),
-});
-
-export const GetRelatedContentInputSchema = z.object({
- slug: z.string().describe('Reference content slug'),
- category: CategorySchema.describe('Reference content category'),
- limit: z.number().min(1).max(20).default(10).describe('Number of related items (max 20)'),
-});
-
-export const GetContentByTagInputSchema = z.object({
- tags: z.array(z.string()).min(1).describe('Tags to filter by'),
- logic: z.enum(['AND', 'OR']).default('OR').describe('Logical operator for multiple tags'),
- category: CategorySchema.optional().describe('Filter by category (optional)'),
- limit: z.number().min(1).max(50).default(20).describe('Number of items (max 50)'),
-});
-
-export const GetPopularInputSchema = z.object({
- category: CategorySchema.optional().describe('Filter by category (optional)'),
- limit: z.number().min(1).max(50).default(20).describe('Number of items (max 50)'),
-});
-
-export const GetRecentInputSchema = z.object({
- category: CategorySchema.optional().describe('Filter by category (optional)'),
- limit: z.number().min(1).max(50).default(20).describe('Number of items (max 50)'),
-});
-
-// Phase 2: Platform Formatting
-export const DownloadContentForPlatformInputSchema = z.object({
- category: CategorySchema.describe('Content category'),
- slug: z.string().describe('Content slug identifier'),
- platform: z
- .enum(['claude-code', 'cursor', 'chatgpt-codex', 'generic'])
- .default('claude-code')
- .describe('Target platform for formatting (default: claude-code)'),
- targetDirectory: z
- .string()
- .optional()
- .describe('Optional: Target directory path (e.g., "/Users/username/project/.claude")'),
-});
-
-// Phase 3: Growth Tools
-export const SubscribeNewsletterInputSchema = z.object({
- email: z.string().email().describe('Email address to subscribe'),
- source: z
- .string()
- .default('mcp')
- .describe('Newsletter subscription source (default: "mcp")'),
- referrer: z
- .string()
- .optional()
- .describe('Optional: Referrer URL or source identifier'),
- metadata: z
- .record(z.unknown())
- .optional()
- .describe('Optional: Additional metadata for tracking'),
-});
-
-export const CreateAccountInputSchema = z.object({
- provider: z
- .enum(['github', 'google', 'discord'])
- .default('github')
- .describe('OAuth provider to use for account creation (default: "github")'),
- newsletterOptIn: z
- .boolean()
- .default(false)
- .describe('Whether to automatically subscribe to newsletter (default: false)'),
- redirectTo: z
- .string()
- .optional()
- .describe('Optional: Path to redirect to after account creation (e.g., "/account")'),
-});
-
-export const SubmitContentInputSchema = z.object({
- submission_type: z
- .enum(['agents', 'mcp', 'rules', 'commands', 'hooks', 'statuslines', 'skills'])
- .optional()
- .describe('Type of content to submit (required for complete submission)'),
- category: CategorySchema.optional().describe('Content category (usually matches submission_type)'),
- name: z.string().optional().describe('Title/name of the content (required)'),
- description: z.string().optional().describe('Brief description of the content (required)'),
- author: z.string().optional().describe('Author name or handle (required)'),
- content_data: z.any().optional().describe('Content data object (structure varies by type)'),
- author_profile_url: z.string().url().optional().describe('Optional: Author profile URL'),
- github_url: z.string().url().optional().describe('Optional: GitHub repository URL'),
- tags: z.array(z.string()).optional().describe('Optional: Array of relevant tags'),
-});
-
-/**
- * Infer TypeScript types from Zod schemas
- */
-export type ListCategoriesInput = z.infer;
-export type SearchContentInput = z.infer;
-export type GetContentDetailInput = z.infer;
-export type GetTrendingInput = z.infer;
-export type GetFeaturedInput = z.infer;
-export type GetTemplatesInput = z.infer;
-export type GetMcpServersInput = z.infer;
-export type GetRelatedContentInput = z.infer;
-export type GetContentByTagInput = z.infer;
-export type GetPopularInput = z.infer;
-export type GetRecentInput = z.infer;
-export type DownloadContentForPlatformInput = z.infer;
-export type SubscribeNewsletterInput = z.infer;
-export type CreateAccountInput = z.infer;
-export type SubmitContentInput = z.infer;
-
-// Phase 1.5: Feature Enhancements
-export const GetSearchSuggestionsInputSchema = z.object({
- query: z.string().min(2).describe('Search query string (minimum 2 characters)'),
- limit: z.number().min(1).max(20).default(10).describe('Number of suggestions to return (1-20, default: 10)'),
-});
-
-export const GetSearchFacetsInputSchema = z.object({});
-
-export const GetChangelogInputSchema = z.object({
- format: z.enum(['llms-txt', 'json']).default('llms-txt').describe('Output format (default: llms-txt)'),
-});
-
-export const GetSocialProofStatsInputSchema = z.object({});
-
-export const GetCategoryConfigsInputSchema = z.object({
- category: CategorySchema.optional().describe('Filter by specific category (optional)'),
-});
-
-export type GetSearchSuggestionsInput = z.infer;
-export type GetSearchFacetsInput = z.infer;
-export type GetChangelogInput = z.infer;
-export type GetSocialProofStatsInput = z.infer;
-export type GetCategoryConfigsInput = z.infer;
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/lib/usage-hints.ts b/apps/edge/supabase/functions/heyclaude-mcp/lib/usage-hints.ts
deleted file mode 100644
index 7bb40fb03..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/lib/usage-hints.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Usage Hints Helper
- *
- * Provides contextual usage hints for AI agents based on tool responses.
- * Helps AI agents understand how to use the data they receive.
- */
-
-/**
- * Get usage hints for a content item
- */
-export function getContentUsageHints(category: string, slug: string): string[] {
- return [
- `Use downloadContentForPlatform to get this content formatted for your platform (Claude Code, Cursor, Codex)`,
- `Use getRelatedContent with category="${category}" and slug="${slug}" to find similar content`,
- `Check the tags in the metadata to discover related content with getContentByTag`,
- `Use searchContent to find more content in the "${category}" category`,
- ];
-}
-
-/**
- * Get usage hints for search results
- */
-export function getSearchUsageHints(hasResults: boolean, category?: string): string[] {
- if (!hasResults) {
- return [
- 'Try broadening your search query',
- 'Use getSearchSuggestions to see popular searches',
- 'Use getSearchFacets to see available filters',
- category ? `Try searching without the category filter` : 'Try adding a category filter with listCategories',
- ];
- }
-
- return [
- 'Use getContentDetail to get full details for any result',
- 'Use downloadContentForPlatform to get formatted versions',
- 'Use getRelatedContent to find similar items',
- 'Check the tags to refine your search with getContentByTag',
- ];
-}
-
-/**
- * Get usage hints for category listing
- */
-export function getCategoryUsageHints(): string[] {
- return [
- 'Use searchContent with a category filter to browse content',
- 'Use getTrending to see popular content in a category',
- 'Use getRecent to see newly added content',
- 'Use getCategoryConfigs to see category-specific requirements',
- ];
-}
-
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/lib/utils.ts b/apps/edge/supabase/functions/heyclaude-mcp/lib/utils.ts
deleted file mode 100644
index bc1a8c0b3..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/lib/utils.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-/**
- * Utility Functions
- *
- * Shared utilities for timeout handling, retry logic, and other common operations
- */
-
-/**
- * Execute a promise with a timeout
- *
- * @param promise - Promise to execute
- * @param timeoutMs - Timeout in milliseconds
- * @param errorMessage - Error message if timeout occurs
- * @returns Promise that resolves or rejects with timeout error
- */
-export async function withTimeout(
- promise: Promise,
- timeoutMs: number,
- errorMessage: string
-): Promise {
- const timeout = new Promise((_, reject) => {
- setTimeout(() => {
- reject(new Error(errorMessage));
- }, timeoutMs);
- });
-
- return Promise.race([promise, timeout]);
-}
-
-/**
- * Retry configuration
- */
-export interface RetryConfig {
- maxRetries?: number;
- initialDelayMs?: number;
- maxDelayMs?: number;
- backoffMultiplier?: number;
- retryableErrors?: (error: unknown) => boolean;
-}
-
-const DEFAULT_RETRY_CONFIG: Required = {
- maxRetries: 3,
- initialDelayMs: 100,
- maxDelayMs: 2000,
- backoffMultiplier: 2,
- retryableErrors: (error) => {
- // Retry on network errors, 5xx errors, and timeouts
- if (error instanceof Error) {
- if (error.message.includes('timeout') || error.message.includes('network')) {
- return true;
- }
- }
- if (error && typeof error === 'object' && 'status' in error) {
- const status = error.status as number;
- return status >= 500 && status < 600;
- }
- return false;
- },
-};
-
-/**
- * Execute a function with retry logic and exponential backoff
- *
- * @param fn - Function to execute
- * @param config - Retry configuration
- * @returns Promise that resolves with function result or rejects after all retries
- */
-export async function withRetry(
- fn: () => Promise,
- config: RetryConfig = {}
-): Promise {
- const finalConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
- let lastError: unknown;
-
- for (let attempt = 0; attempt <= finalConfig.maxRetries; attempt++) {
- try {
- return await fn();
- } catch (error) {
- lastError = error;
-
- // Don't retry if this was the last attempt
- if (attempt === finalConfig.maxRetries) {
- break;
- }
-
- // Don't retry if error is not retryable
- if (!finalConfig.retryableErrors(error)) {
- throw error;
- }
-
- // Calculate delay with exponential backoff and jitter
- const baseDelay = Math.min(
- finalConfig.initialDelayMs * Math.pow(finalConfig.backoffMultiplier, attempt),
- finalConfig.maxDelayMs
- );
- const jitter = Math.random() * 0.3 * baseDelay; // 0-30% jitter
- const delay = baseDelay + jitter;
-
- // Wait before retrying
- await new Promise((resolve) => setTimeout(resolve, delay));
- }
- }
-
- throw lastError;
-}
-
-/**
- * Sanitize string input to prevent XSS
- * Basic sanitization - removes potentially dangerous characters
- *
- * @param input - String to sanitize
- * @returns Sanitized string
- */
-export function sanitizeString(input: string): string {
- if (typeof input !== 'string') {
- return '';
- }
-
- // Remove null bytes and control characters
- return input
- .replace(/\0/g, '')
- .replace(/[\x00-\x1F\x7F]/g, '')
- .trim();
-}
-
-/**
- * Validate URL format
- *
- * @param url - URL string to validate
- * @returns true if valid URL, false otherwise
- */
-export function isValidUrl(url: string): boolean {
- try {
- const parsed = new URL(url);
- return ['http:', 'https:'].includes(parsed.protocol);
- } catch {
- return false;
- }
-}
-
-/**
- * Validate slug format
- * Slugs should be alphanumeric with hyphens and underscores
- *
- * @param slug - Slug string to validate
- * @returns true if valid slug, false otherwise
- */
-export function isValidSlug(slug: string): boolean {
- if (!slug || typeof slug !== 'string') {
- return false;
- }
-
- // Allow alphanumeric, hyphens, underscores, and dots
- // Must start and end with alphanumeric
- const slugRegex = /^[a-z0-9]([a-z0-9\-_.]*[a-z0-9])?$/i;
- return slugRegex.test(slug) && slug.length >= 1 && slug.length <= 200;
-}
-
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/resources/content.ts b/apps/edge/supabase/functions/heyclaude-mcp/resources/content.ts
deleted file mode 100644
index c4a8bb7be..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/resources/content.ts
+++ /dev/null
@@ -1,502 +0,0 @@
-/**
- * MCP Resource Handlers
- *
- * Handles resource requests for content in various formats by calling existing Next.js API routes.
- * This leverages the existing API infrastructure with aggressive caching, reducing database pressure.
- *
- * Resources are accessed via URI templates:
- * - claudepro://content/{category}/{slug}/{format}
- * - claudepro://category/{category}/{format}
- * - claudepro://sitewide/{format}
- *
- * Implementation: MCP Resources → Next.js API Routes → Supabase Database
- * Benefits: 95%+ cache hit rate, 10-100x DB query reduction, CDN-level caching
- */
-
-import type { Database } from '@heyclaude/database-types';
-import { Constants } from '@heyclaude/database-types';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import { sanitizeString } from '../lib/utils.ts';
-
-/**
- * Base URL for Next.js API routes
- * Can be overridden via environment variable for local testing
- */
-const API_BASE_URL = Deno.env.get('API_BASE_URL') || 'https://claudepro.directory';
-
-/**
- * MCP Resource return type
- */
-interface McpResource {
- uri: string;
- mimeType: string;
- text: string;
-}
-
-/**
- * Validates if a string is a valid content category enum value
- */
-function isValidContentCategory(
- value: string
-): value is Database['public']['Enums']['content_category'] {
- return Constants.public.Enums.content_category.includes(
- value as Database['public']['Enums']['content_category']
- );
-}
-
-/**
- * Binary content types that should not be converted to text
- */
-const BINARY_CONTENT_TYPES = [
- 'application/zip',
- 'application/x-zip-compressed',
- 'application/octet-stream',
- 'application/x-binary',
- 'application/gzip',
- 'application/x-gzip',
-];
-
-/**
- * Check if a content type indicates binary data
- */
-function isBinaryContentType(contentType: string): boolean {
- return BINARY_CONTENT_TYPES.some((binaryType) =>
- contentType.toLowerCase().includes(binaryType.toLowerCase())
- );
-}
-
-/**
- * Fetch content from Next.js API route with error handling, timeout, retry logic, and binary detection
- *
- * @param url - API route URL to fetch
- * @param uri - MCP resource URI for logging
- * @param context - Additional context for error logging
- * @param timeoutMs - Request timeout in milliseconds (default: 30000)
- * @returns Object with text content and MIME type
- * @throws Error if request fails, times out, or returns non-OK status
- */
-async function fetchApiRoute(
- url: string,
- uri: string,
- context: Record,
- timeoutMs: number = 30000
-): Promise<{ text: string; mimeType: string }> {
- // Import retry utility
- const { withRetry } = await import('../lib/utils.ts');
-
- // Wrap fetch in retry logic with exponential backoff
- return withRetry(
- async () => {
- const abortController = new AbortController();
- const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
-
- try {
- const response = await fetch(url, {
- headers: {
- 'User-Agent': 'heyclaude-mcp/1.0.0',
- 'Accept': '*/*',
- },
- signal: abortController.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const errorText = await response.text().catch(() => 'Unknown error');
- const errorMessage = `API route returned ${response.status}: ${errorText}`;
- await logError('API route returned error status', {
- url,
- uri,
- status: response.status,
- statusText: response.statusText,
- errorText: errorText.substring(0, 500), // Limit error text length
- ...context,
- });
- throw new Error(errorMessage);
- }
-
- const contentType = response.headers.get('content-type') || 'text/plain; charset=utf-8';
-
- // Handle binary content types - return metadata instead of binary data
- if (isBinaryContentType(contentType)) {
- // For binary content, we should return metadata, not the binary file
- // This is especially important for storage format
- const metadata = {
- contentType,
- size: response.headers.get('content-length') || 'unknown',
- url,
- note: 'Binary content detected. Use storage metadata endpoint instead.',
- };
-
- return {
- text: JSON.stringify(metadata, null, 2),
- mimeType: 'application/json; charset=utf-8',
- };
- }
-
- // For text content, read as text
- const text = await response.text();
-
- return {
- text,
- mimeType: contentType,
- };
- } catch (error) {
- clearTimeout(timeoutId);
-
- // Check if error is due to timeout
- if (error instanceof Error && error.name === 'AbortError') {
- const timeoutError = new Error(
- `API route request timed out after ${timeoutMs}ms: ${url}`
- );
- await logError('API route request timeout', {
- url,
- uri,
- timeoutMs,
- ...context,
- }, timeoutError);
- throw timeoutError;
- }
-
- // Re-throw to let retry logic handle it
- throw error;
- }
- },
- {
- maxRetries: 3,
- initialDelayMs: 100,
- maxDelayMs: 2000,
- retryableErrors: (error) => {
- // Retry on network errors, 5xx errors, and timeouts
- if (error instanceof Error) {
- if (error.message.includes('timeout') || error.message.includes('network')) {
- return true;
- }
- }
- // Don't retry on 4xx errors (client errors)
- if (error instanceof Error && error.message.includes('API route returned')) {
- const statusMatch = error.message.match(/returned (\d+)/);
- if (statusMatch) {
- const status = parseInt(statusMatch[1], 10);
- return status >= 500 && status < 600; // Only retry 5xx errors
- }
- }
- return false;
- },
- }
- ).catch((error) => {
- // Final error handling after all retries exhausted
- logError('Failed to fetch from API route after retries', {
- url,
- uri,
- ...context,
- }, error).catch(() => {
- // Swallow logging errors
- });
- throw new Error(
- `Failed to fetch content from API: ${error instanceof Error ? error.message : 'Unknown error'} (URL: ${url})`
- );
- });
-}
-
-/**
- * Handle individual content resource requests
- *
- * URI format: claudepro://content/{category}/{slug}/{format}
- *
- * Supported formats:
- * - llms, llms-txt: LLMs.txt format
- * - markdown, md: Markdown format
- * - json: JSON format
- * - download, storage: Storage/download format
- *
- * Implementation: Calls existing /api/content/[category]/[slug] route with format query param
- * Benefits: Leverages Next.js caching (95%+ hit rate), reduces DB pressure
- *
- * @param uri - Resource URI to parse and handle
- * @returns MCP resource with content and MIME type
- * @throws Error if URI is invalid, category is invalid, or content not found
- */
-export async function handleContentResource(
- uri: string
-): Promise {
- // Sanitize URI
- const sanitizedUri = sanitizeString(uri);
-
- // Parse URI: claudepro://content/{category}/{slug}/{format}
- const match = sanitizedUri.match(/^claudepro:\/\/content\/([^/]+)\/([^/]+)\/(.+)$/);
- if (!match) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_URI,
- `Invalid content resource URI format: ${sanitizedUri}. Expected: claudepro://content/{category}/{slug}/{format}`
- );
- throw new Error(error.message);
- }
-
- const [, category, slug, format] = match;
-
- // Sanitize parsed values
- const sanitizedCategory = sanitizeString(category);
- const sanitizedSlug = sanitizeString(slug);
- const sanitizedFormat = sanitizeString(format);
-
- // Validate category
- if (!isValidContentCategory(sanitizedCategory)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_CATEGORY,
- `Invalid category: ${sanitizedCategory}. Use listCategories tool to see valid categories.`
- );
- throw new Error(error.message);
- }
-
- // Map MCP format to API route format
- let apiFormat: string;
- switch (sanitizedFormat) {
- case 'llms':
- case 'llms-txt':
- apiFormat = 'llms';
- break;
- case 'markdown':
- case 'md':
- apiFormat = 'markdown';
- break;
- case 'json':
- apiFormat = 'json';
- break;
- case 'download':
- case 'storage':
- // Storage format: Return metadata JSON instead of binary file
- // MCP resources cannot handle binary data, so we return storage metadata
- // that clients can use to construct download URLs
- apiFormat = 'storage-metadata';
- break;
- default: {
- const error = createErrorResponse(
- McpErrorCode.INVALID_FORMAT,
- `Unsupported format: ${sanitizedFormat}. Supported formats: llms, llms-txt, markdown, md, json, download, storage`
- );
- throw new Error(error.message);
- }
- }
-
- // Handle storage format specially - return metadata instead of binary file
- if (apiFormat === 'storage-metadata') {
- // Call API route with metadata query param to get storage info as JSON
- // This avoids binary file corruption in MCP resources
- const apiUrl = `${API_BASE_URL}/api/content/${sanitizedCategory}/${sanitizedSlug}?format=storage&metadata=true`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { category: sanitizedCategory, slug: sanitizedSlug, format: 'storage-metadata' }, 30000);
-
- return {
- uri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- // Call existing API route: /api/content/[category]/[slug]?format=...
- const apiUrl = `${API_BASE_URL}/api/content/${sanitizedCategory}/${sanitizedSlug}?format=${apiFormat}`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { category: sanitizedCategory, slug: sanitizedSlug, format: apiFormat }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
-}
-
-/**
- * Handle category-level resource requests
- *
- * URI format: claudepro://category/{category}/{format}
- *
- * Supported formats:
- * - llms-category: Category LLMs.txt
- * - rss: RSS feed
- * - atom: Atom feed
- * - json: Category JSON list
- *
- * Implementation:
- * - LLMs.txt: Calls /api/content/[category]?format=llms-category
- * - RSS/Atom: Calls /api/feeds?type=rss|atom&category={category}
- * - JSON: Calls /api/content/[category]?format=json (hyper-optimized with 7-day cache)
- *
- * @param uri - Resource URI to parse and handle
- * @returns MCP resource with content and MIME type
- * @throws Error if URI is invalid, category is invalid, or generation fails
- */
-export async function handleCategoryResource(
- uri: string
-): Promise {
- // Sanitize URI
- const sanitizedUri = sanitizeString(uri);
-
- // Parse URI: claudepro://category/{category}/{format}
- const match = sanitizedUri.match(/^claudepro:\/\/category\/([^/]+)\/(.+)$/);
- if (!match) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_URI,
- `Invalid category resource URI format: ${sanitizedUri}. Expected: claudepro://category/{category}/{format}`
- );
- throw new Error(error.message);
- }
-
- const [, category, format] = match;
-
- // Sanitize parsed values
- const sanitizedCategory = sanitizeString(category);
- const sanitizedFormat = sanitizeString(format);
-
- // Validate category
- if (!isValidContentCategory(sanitizedCategory)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_CATEGORY,
- `Invalid category: ${sanitizedCategory}. Use listCategories tool to see valid categories.`
- );
- throw new Error(error.message);
- }
-
- switch (sanitizedFormat) {
- case 'llms-category': {
- // Call existing API route: /api/content/[category]?format=llms-category
- const apiUrl = `${API_BASE_URL}/api/content/${sanitizedCategory}?format=llms-category`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { category: sanitizedCategory, format: 'llms-category' }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- case 'rss': {
- // Call existing API route: /api/feeds?type=rss&category={category}
- const apiUrl = `${API_BASE_URL}/api/feeds?type=rss&category=${sanitizedCategory}`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { category: sanitizedCategory, format: 'rss' }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- case 'atom': {
- // Call existing API route: /api/feeds?type=atom&category={category}
- const apiUrl = `${API_BASE_URL}/api/feeds?type=atom&category=${sanitizedCategory}`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { category: sanitizedCategory, format: 'atom' }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- case 'json': {
- // Call existing API route: /api/content/[category]?format=json
- const apiUrl = `${API_BASE_URL}/api/content/${sanitizedCategory}?format=json`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { category: sanitizedCategory, format: 'json' }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- default: {
- const error = createErrorResponse(
- McpErrorCode.INVALID_FORMAT,
- `Unsupported category format: ${sanitizedFormat}. Supported formats: llms-category, rss, atom, json`
- );
- throw new Error(error.message);
- }
- }
-}
-
-/**
- * Handle sitewide resource requests
- *
- * URI format: claudepro://sitewide/{format}
- *
- * Supported formats:
- * - llms, llms-txt: Sitewide LLMs.txt
- * - readme: README JSON data
- * - json: Complete directory JSON
- *
- * Implementation: Calls existing /api/content/sitewide route with format query param
- * Benefits: Leverages Next.js caching (95%+ hit rate), reduces DB pressure
- * - All formats use aggressive caching (7-day TTL, 14-day stale for JSON)
- * - CDN-level caching via Vercel Edge Network
- *
- * @param uri - Resource URI to parse and handle
- * @returns MCP resource with content and MIME type
- * @throws Error if URI is invalid or generation fails
- */
-export async function handleSitewideResource(
- uri: string
-): Promise {
- // Sanitize URI
- const sanitizedUri = sanitizeString(uri);
-
- // Parse URI: claudepro://sitewide/{format}
- const match = sanitizedUri.match(/^claudepro:\/\/sitewide\/(.+)$/);
- if (!match) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_URI,
- `Invalid sitewide resource URI format: ${sanitizedUri}. Expected: claudepro://sitewide/{format}`
- );
- throw new Error(error.message);
- }
-
- const [, format] = match;
- const sanitizedFormat = sanitizeString(format);
-
- switch (sanitizedFormat) {
- case 'llms':
- case 'llms-txt': {
- // Call existing API route: /api/content/sitewide?format=llms
- const apiUrl = `${API_BASE_URL}/api/content/sitewide?format=llms`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { format: 'llms' }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- case 'readme': {
- // Call existing API route: /api/content/sitewide?format=readme
- const apiUrl = `${API_BASE_URL}/api/content/sitewide?format=readme`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { format: 'readme' }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- case 'json': {
- // Call existing API route: /api/content/sitewide?format=json
- const apiUrl = `${API_BASE_URL}/api/content/sitewide?format=json`;
- const result = await fetchApiRoute(apiUrl, sanitizedUri, { format: 'json' }, 30000);
-
- return {
- uri: sanitizedUri,
- mimeType: result.mimeType,
- text: result.text,
- };
- }
-
- default: {
- const error = createErrorResponse(
- McpErrorCode.INVALID_FORMAT,
- `Unsupported sitewide format: ${sanitizedFormat}. Supported formats: llms, llms-txt, readme, json`
- );
- throw new Error(error.message);
- }
- }
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/account.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/account.ts
deleted file mode 100644
index f199c15b8..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/account.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-/**
- * createAccount Tool Handler
- *
- * Provides OAuth URLs and instructions for creating an account.
- * Supports newsletter opt-in during account creation.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { edgeEnv } from '@heyclaude/edge-runtime/config/env.ts';
-import { getEnvVar } from '@heyclaude/shared-runtime/env.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import { sanitizeString, isValidUrl } from '../lib/utils.ts';
-import type { CreateAccountInput } from '../lib/types.ts';
-
-const SUPABASE_URL = edgeEnv.supabase.url;
-const SUPABASE_AUTH_URL = `${SUPABASE_URL}/auth/v1`;
-const APP_URL = getEnvVar('APP_URL') || 'https://claudepro.directory';
-const MCP_SERVER_URL = getEnvVar('MCP_SERVER_URL') ?? 'https://mcp.claudepro.directory';
-
-/**
- * Generates OAuth authorization URL for account creation
- *
- * For social OAuth providers (GitHub, Google, Discord), Supabase uses a provider-specific endpoint.
- * The URL format is: /auth/v1/authorize?provider={provider}&redirect_to={callback}
- *
- * @param provider - OAuth provider ('github', 'google', 'discord')
- * @param newsletterOptIn - Whether to opt in to newsletter
- * @param redirectTo - Optional redirect path after authentication
- * @returns OAuth authorization URL
- */
-function generateOAuthUrl(
- provider: 'github' | 'google' | 'discord',
- newsletterOptIn: boolean,
- redirectTo?: string
-): string {
- // Build callback URL with newsletter and redirect parameters
- const callbackUrl = new URL(`${APP_URL}/auth/callback`);
- callbackUrl.searchParams.set('newsletter', newsletterOptIn ? 'true' : 'false');
- if (redirectTo) {
- callbackUrl.searchParams.set('next', redirectTo);
- }
-
- // Build Supabase OAuth authorization URL for social providers
- // Format: /auth/v1/authorize?provider={provider}&redirect_to={callback}
- const authUrl = new URL(`${SUPABASE_AUTH_URL}/authorize`);
- authUrl.searchParams.set('provider', provider);
- authUrl.searchParams.set('redirect_to', callbackUrl.toString());
-
- return authUrl.toString();
-}
-
-/**
- * Creates account creation instructions and OAuth URLs
- *
- * @param supabase - Authenticated Supabase client (not used but kept for consistency)
- * @param input - Tool input with provider, newsletter opt-in, and optional redirect
- * @returns Account creation instructions with OAuth URLs
- * @throws If provider is invalid
- */
-export async function handleCreateAccount(
- supabase: SupabaseClient,
- input: CreateAccountInput
-) {
- const { provider = 'github', newsletterOptIn = false, redirectTo } = input;
-
- // Sanitize inputs
- const sanitizedProvider = sanitizeString(provider);
- const sanitizedRedirectTo = redirectTo ? sanitizeString(redirectTo) : undefined;
-
- // Validate provider
- const validProviders = ['github', 'google', 'discord'];
- if (!validProviders.includes(sanitizedProvider)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_PROVIDER,
- `Invalid provider: ${sanitizedProvider}. Supported providers: ${validProviders.join(', ')}`
- );
- throw new Error(error.message);
- }
-
- // Validate redirectTo if provided
- if (sanitizedRedirectTo && !isValidUrl(sanitizedRedirectTo)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_INPUT,
- `Invalid redirectTo URL: ${sanitizedRedirectTo}`
- );
- throw new Error(error.message);
- }
-
- // Generate OAuth URL
- const oauthUrl = generateOAuthUrl(sanitizedProvider as 'github' | 'google' | 'discord', newsletterOptIn, sanitizedRedirectTo);
-
- // Build instructions text
- const instructions: string[] = [];
-
- instructions.push(`## Create Account with ${sanitizedProvider.charAt(0).toUpperCase() + sanitizedProvider.slice(1)}\n`);
-
- instructions.push(
- `To create an account on Claude Pro Directory, you can sign up using your ${sanitizedProvider.charAt(0).toUpperCase() + sanitizedProvider.slice(1)} account.\n`
- );
-
- instructions.push('### Option 1: Use the OAuth URL (Recommended)\n');
- instructions.push(`Click or visit this URL to start the account creation process:\n`);
- instructions.push(`\`${oauthUrl}\`\n`);
-
- instructions.push('### Option 2: Manual Steps\n');
- instructions.push('1. Visit the Claude Pro Directory website');
- instructions.push(`2. Click "Sign in" or "Get Started"`);
- instructions.push(`3. Select "${sanitizedProvider.charAt(0).toUpperCase() + sanitizedProvider.slice(1)}" as your provider`);
- if (newsletterOptIn) {
- instructions.push('4. You will be automatically subscribed to the newsletter');
- }
- instructions.push('5. Complete the OAuth flow in your browser');
- instructions.push('6. Your account will be created automatically\n');
-
- if (newsletterOptIn) {
- instructions.push('### Newsletter Subscription\n');
- instructions.push(
- 'You will be automatically subscribed to the Claude Pro Directory newsletter when you create your account.\n'
- );
- }
-
- instructions.push('### What You Get\n');
- instructions.push('- Access to personalized content recommendations');
- instructions.push('- Ability to bookmark and save favorite configurations');
- instructions.push('- Submit your own content to the directory');
- instructions.push('- Track your submissions and engagement');
- if (newsletterOptIn) {
- instructions.push('- Weekly newsletter with new content and updates');
- }
-
- instructions.push('\n### After Account Creation\n');
- instructions.push('Once your account is created, you can:');
- instructions.push('- Use the MCP server with your authenticated account');
- instructions.push('- Access protected resources and tools');
- instructions.push('- Submit content for review');
- instructions.push('- Manage your profile and preferences');
-
- const instructionsText = instructions.join('\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: instructionsText,
- },
- ],
- _meta: {
- provider: sanitizedProvider,
- oauthUrl,
- newsletterOptIn,
- redirectTo: sanitizedRedirectTo || null,
- appUrl: APP_URL,
- callbackUrl: `${APP_URL}/auth/callback`,
- instructions: [
- 'Visit the OAuth URL to start account creation',
- 'Complete authentication with your provider',
- 'Account will be created automatically',
- newsletterOptIn ? 'Newsletter subscription will be enabled' : 'Newsletter subscription is optional',
- ],
- },
- };
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/auth-metadata.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/auth-metadata.ts
deleted file mode 100644
index bc3a6858b..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/auth-metadata.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * MCP Authorization Metadata Routes
- *
- * Implements OAuth 2.0 Protected Resource Metadata (RFC 9728)
- * and provides authorization server discovery information.
- */
-
-import { edgeEnv } from '@heyclaude/edge-runtime/config/env.ts';
-import { initRequestLogging, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { jsonResponse } from '@heyclaude/edge-runtime/utils/http.ts';
-import { createDataApiContext, logError, logger } from '@heyclaude/shared-runtime/logging.ts';
-import { getEnvVar } from '@heyclaude/shared-runtime/env.ts';
-import type { Context } from 'hono';
-
-const MCP_SERVER_URL = getEnvVar('MCP_SERVER_URL') ?? 'https://mcp.claudepro.directory';
-const MCP_RESOURCE_URL = `${MCP_SERVER_URL}/mcp`;
-const SUPABASE_URL = edgeEnv.supabase.url;
-const SUPABASE_AUTH_URL = `${SUPABASE_URL}/auth/v1`;
-
-/**
- * Create and initialize a logging context for metadata endpoints and bind it to the global logger.
- *
- * @param action - The name of the logging action or operation for this request
- * @param method - The HTTP method associated with the request; defaults to `'GET'`
- * @returns The created logging context for the request
- */
-function setupMetadataLogging(action: string, method: string = 'GET') {
- const logContext = createDataApiContext(action, {
- app: 'heyclaude-mcp',
- method,
- });
-
- // Initialize request logging with trace and bindings (Phase 1 & 2)
- initRequestLogging(logContext);
- traceStep(`${action} request received`, logContext);
-
- // Set bindings for this request - mixin will automatically inject these into all subsequent logs
- logger.setBindings({
- requestId: typeof logContext['request_id'] === "string" ? logContext['request_id'] : undefined,
- operation: typeof logContext['action'] === "string" ? logContext['action'] : action,
- function: typeof logContext['function'] === "string" ? logContext['function'] : "unknown",
- method,
- });
-
- return logContext;
-}
-
-/**
- * Serve the protected-resource metadata (RFC 9728) for this MCP server.
- *
- * @returns A Response containing the protected resource metadata JSON (HTTP 200) on success, or an error JSON (HTTP 500) on failure
- */
-export async function handleProtectedResourceMetadata(_c: Context): Promise {
- const logContext = setupMetadataLogging('oauth-protected-resource-metadata');
-
- try {
- const metadata = {
- resource: MCP_RESOURCE_URL,
- authorization_servers: [
- SUPABASE_AUTH_URL, // Supabase Auth acts as our authorization server
- ],
- scopes_supported: [
- 'mcp:tools', // Access to MCP tools
- 'mcp:resources', // Access to MCP resources (if we add them)
- ],
- bearer_methods_supported: ['header'],
- resource_documentation: 'https://claudepro.directory/mcp/heyclaude-mcp',
- // Indicate that resource parameter (RFC 8707) is supported
- resource_parameter_supported: true,
- };
-
- return jsonResponse(metadata, 200, {
- 'Content-Type': 'application/json',
- 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type',
- });
- } catch (error) {
- await logError('Failed to generate protected resource metadata', logContext, error);
- return jsonResponse({ error: 'Internal server error' }, 500, {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- });
- }
-}
-
-/**
- * Serves OAuth 2.0 / OIDC authorization server metadata for this service.
- *
- * Returns metadata that advertises the issuer and endpoints, points clients to Supabase's OIDC discovery for full configuration, and exposes the service's proxy authorization endpoint to support the resource parameter.
- *
- * @returns The HTTP Response containing the authorization server metadata JSON
- */
-export async function handleAuthorizationServerMetadata(_c: Context): Promise {
- const logContext = setupMetadataLogging('oauth-authorization-server-metadata');
-
- try {
- // After OAuth 2.1 Server is enabled, Supabase Auth provides:
- // - OAuth 2.1 Authorization Server Metadata at: /.well-known/oauth-authorization-server/auth/v1
- // - OIDC Discovery at: /auth/v1/.well-known/openid-configuration
- //
- // For OAuth 2.1 with resource parameter (RFC 8707), we use our proxy endpoint
- // which ensures the resource parameter is included and tokens have correct audience
- const authorizationEndpoint = `${MCP_SERVER_URL}/oauth/authorize`;
-
- const metadata = {
- issuer: SUPABASE_AUTH_URL,
- authorization_endpoint: authorizationEndpoint, // Our proxy endpoint (adds resource parameter)
- token_endpoint: `${SUPABASE_AUTH_URL}/oauth/token`, // OAuth 2.1 token endpoint (requires OAuth 2.1 Server)
- jwks_uri: `${SUPABASE_AUTH_URL}/.well-known/jwks.json`,
- response_types_supported: ['code'],
- grant_types_supported: ['authorization_code', 'refresh_token'],
- code_challenge_methods_supported: ['S256'], // PKCE support (required for OAuth 2.1)
- scopes_supported: ['openid', 'email', 'profile', 'phone', 'mcp:tools', 'mcp:resources'],
- token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
- // Resource Indicators (RFC 8707) - MCP spec requires this
- resource_parameter_supported: true,
- // Point to Supabase's OAuth 2.1 discovery endpoint (requires OAuth 2.1 Server to be enabled)
- oauth_discovery_url: `${SUPABASE_URL}/.well-known/oauth-authorization-server/auth/v1`,
- // Point to OIDC discovery for full metadata
- oidc_discovery_url: `${SUPABASE_URL}/auth/v1/.well-known/openid-configuration`,
- };
-
- return jsonResponse(metadata, 200, {
- 'Content-Type': 'application/json',
- 'Cache-Control': 'public, max-age=3600',
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type',
- });
- } catch (error) {
- await logError('Failed to generate authorization server metadata', logContext, error);
- return jsonResponse({ error: 'Internal server error' }, 500, {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- });
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/categories.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/categories.ts
deleted file mode 100644
index b747be3c4..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/categories.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * listCategories Tool Handler
- *
- * Returns all content categories in the HeyClaude directory with counts and descriptions.
- * Uses the get_category_configs_with_features RPC.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { getCategoryUsageHints } from '../lib/usage-hints.ts';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import type { ListCategoriesInput } from '../lib/types.ts';
-
-/**
- * Retrieve directory category configurations with optional content counts and produce a textual summary and structured metadata.
- *
- * If fetching content counts fails, categories are still returned and each category's `count` defaults to `0`.
- *
- * @returns An object with `content`: an array containing a single text block summarizing categories, and `_meta`: an object with `categories` (array of category objects with `name`, `slug`, `description`, `count`, and `icon`) and `total` (number of categories)
- * @throws Error if the `get_category_configs_with_features` RPC fails or returns no data
- */
-export async function handleListCategories(
- supabase: SupabaseClient,
- _input: ListCategoriesInput
-) {
- // Call the RPC to get category configs with features
- const { data, error } = await supabase.rpc('get_category_configs_with_features');
-
- if (error) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('RPC call failed in listCategories', {
- dbQuery: {
- rpcName: 'get_category_configs_with_features',
- },
- }, error);
- throw new Error(`Failed to fetch categories: ${error.message}`);
- }
-
- if (!data) {
- throw new Error('No category data returned');
- }
-
- // Get content counts from get_search_facets
- const { data: facetsData, error: facetsError } = await supabase.rpc('get_search_facets');
-
- if (facetsError) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('RPC call failed in listCategories (get_search_facets)', {
- dbQuery: {
- rpcName: 'get_search_facets',
- },
- }, facetsError);
- // Continue without counts - not critical
- }
- const countsMap = new Map(
- (facetsData || []).map((f: { category: string; content_count: number }) => [
- f.category,
- f.content_count,
- ])
- );
-
- // Format the response for MCP
- type CategoryConfig = Database['public']['CompositeTypes']['category_config_with_features'];
- const categories = data.map((cat: CategoryConfig) => ({
- name: cat.title || cat.category || '',
- slug: cat.category || '',
- description: cat.description || '',
- count: countsMap.get(cat.category || '') || 0,
- icon: cat.icon_name || '',
- }));
-
- // Return both structured data and a text summary
- const textSummary = categories
- .map((c: { name: string; slug: string; count: number; description: string }) => `• ${c.name} (${c.slug}): ${c.count} items - ${c.description}`)
- .join('\n');
-
- // Get usage hints for categories
- const usageHints = getCategoryUsageHints();
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `HeyClaude Directory Categories:\n\n${textSummary}\n\nTotal: ${categories.length} categories`,
- },
- ],
- // Also include structured data for programmatic access
- _meta: {
- categories,
- total: categories.length,
- usageHints,
- relatedTools: ['searchContent', 'getTrending', 'getRecent', 'getCategoryConfigs'],
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/category-configs.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/category-configs.ts
deleted file mode 100644
index 3b387396a..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/category-configs.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * getCategoryConfigs Tool Handler
- *
- * Get category-specific configurations and features.
- * Helps understand category-specific requirements and submission guidelines.
- */
-
-import { ContentService } from '@heyclaude/data-layer/services/content.ts';
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { sanitizeString } from '../lib/utils.ts';
-import type { GetCategoryConfigsInput } from '../lib/types.ts';
-
-/**
- * Fetches category configurations and features.
- *
- * @param supabase - Authenticated Supabase client
- * @param input - Tool input with optional category filter
- * @returns Category configurations with features and submission guidelines
- * @throws If RPC fails
- */
-export async function handleGetCategoryConfigs(
- supabase: SupabaseClient,
- input: GetCategoryConfigsInput
-) {
- const category = input.category ? sanitizeString(input.category) : undefined;
-
- try {
- const contentService = new ContentService(supabase);
- const data = await contentService.getCategoryConfigs();
-
- if (!data || !Array.isArray(data)) {
- return {
- content: [
- {
- type: 'text' as const,
- text: 'No category configurations found.',
- },
- ],
- _meta: {
- configs: [],
- count: 0,
- },
- };
- }
-
- // Filter by category if provided
- const filteredConfigs = category
- ? data.filter((config: { category?: string }) => config.category === category)
- : data;
-
- // Create text summary
- const textSummary = category
- ? `**Category Configuration: ${category}**\n\n${filteredConfigs.length > 0 ? JSON.stringify(filteredConfigs[0], null, 2) : 'No configuration found for this category.'}`
- : `**Available Category Configurations**\n\n${filteredConfigs.length} categor${filteredConfigs.length === 1 ? 'y' : 'ies'} configured:\n\n${filteredConfigs.map((config: { category?: string }, i: number) => `${i + 1}. ${config.category || 'unknown'}`).join('\n')}\n\nUse getCategoryConfigs with a specific category parameter to see detailed configuration.`;
-
- return {
- content: [
- {
- type: 'text' as const,
- text: textSummary,
- },
- ],
- _meta: {
- configs: filteredConfigs,
- count: filteredConfigs.length,
- category: category || null,
- },
- };
- } catch (error) {
- await logError('Category configs fetch failed', {
- category,
- }, error);
- throw new Error(`Failed to fetch category configs: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/changelog.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/changelog.ts
deleted file mode 100644
index 17ae04e70..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/changelog.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * getChangelog Tool Handler
- *
- * Get changelog of content updates in LLMs.txt format.
- * Helps AI agents understand recent changes and stay current.
- */
-
-import { ContentService } from '@heyclaude/data-layer/services/content.ts';
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import type { GetChangelogInput } from '../lib/types.ts';
-
-/**
- * Fetches changelog in LLMs.txt format.
- *
- * @param supabase - Authenticated Supabase client
- * @param input - Tool input with optional format (default: 'llms-txt')
- * @returns Changelog content in requested format
- * @throws If changelog generation fails
- */
-export async function handleGetChangelog(
- supabase: SupabaseClient,
- input: GetChangelogInput
-) {
- const format = input.format || 'llms-txt';
-
- // Validate format
- if (format !== 'llms-txt' && format !== 'json') {
- const error = createErrorResponse(
- McpErrorCode.INVALID_FORMAT,
- `Invalid format: ${format}. Supported formats: llms-txt, json`
- );
- throw new Error(error.message);
- }
-
- try {
- const contentService = new ContentService(supabase);
- const data = await contentService.getChangelogLlmsTxt();
-
- if (!data) {
- throw new Error('Changelog not found or invalid');
- }
-
- // Format the changelog (replace escaped newlines)
- const formatted = data.replaceAll(String.raw`\n`, '\n');
-
- if (format === 'json') {
- // For JSON format, parse and return structured data
- // Note: LLMs.txt format is primarily text-based, so JSON format may be limited
- return {
- content: [
- {
- type: 'text' as const,
- text: formatted,
- },
- ],
- _meta: {
- format: 'llms-txt', // Note: Currently only LLMs.txt format is available
- length: formatted.length,
- note: 'Changelog is available in LLMs.txt format. JSON format conversion not yet implemented.',
- },
- };
- }
-
- // LLMs.txt format (default)
- return {
- content: [
- {
- type: 'text' as const,
- text: formatted,
- },
- ],
- _meta: {
- format: 'llms-txt',
- length: formatted.length,
- },
- };
- } catch (error) {
- await logError('Changelog generation failed', {
- format,
- }, error);
- throw new Error(`Failed to generate changelog: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/detail.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/detail.ts
deleted file mode 100644
index 1cc97cc76..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/detail.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * getContentDetail Tool Handler
- *
- * Get complete metadata for a specific content item by slug and category.
- * Uses direct table query for content details.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import { sanitizeString, isValidSlug } from '../lib/utils.ts';
-import { getContentUsageHints } from '../lib/usage-hints.ts';
-import type { GetContentDetailInput } from '../lib/types.ts';
-
-/**
- * Fetches a content item by slug and category and returns a markdown-like text summary plus a normalized `_meta` details object.
- *
- * Queries the `content` table for the specified item, normalizes missing fields with sensible defaults, and builds a readable text block and metadata summary.
- *
- * @param input - Object containing `slug` and `category` of the content item to retrieve
- * @returns An object with `content` — an array containing a single text block (`type: 'text'`, `text`: string) — and `_meta` — a details object containing `slug`, `title`, `displayTitle`, `category`, `description`, `content`, `tags`, `author`, `authorProfileUrl`, `dateAdded`, `dateUpdated`, `createdAt`, `metadata`, and `stats` (`views`, `bookmarks`, `copies`)
- * @throws If no content is found for the provided category/slug, or if the database query fails (the error is logged and rethrown)
- */
-export async function handleGetContentDetail(
- supabase: SupabaseClient,
- input: GetContentDetailInput
-) {
- // Sanitize and validate inputs
- const slug = sanitizeString(input.slug);
- const category = input.category;
-
- // Validate slug format
- if (!isValidSlug(slug)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_SLUG,
- `Invalid slug format: ${slug}. Slugs must be alphanumeric with hyphens, underscores, or dots.`
- );
- throw new Error(error.message);
- }
-
- // Query the content table directly since get_content_detail_complete returns nested nulls
- const { data, error } = await supabase
- .from('content')
- .select(`
- slug,
- title,
- display_title,
- category,
- description,
- content,
- tags,
- author,
- author_profile_url,
- date_added,
- created_at,
- updated_at,
- metadata,
- view_count,
- bookmark_count,
- copy_count
- `)
- .eq('category', category)
- .eq('slug', slug)
- .single();
-
- if (error) {
- // PGRST116: JSON object requested, multiple (or no) rows returned
- if ((error as any).code === 'PGRST116') {
- throw new Error(`Content not found: ${category}/${slug}`);
- }
-
- // Use dbQuery serializer for consistent database query formatting
- await logError('Database query failed in getContentDetail', {
- dbQuery: {
- table: 'content',
- operation: 'select',
- schema: 'public',
- args: {
- category,
- slug,
- },
- },
- }, error);
- throw new Error(`Failed to fetch content details: ${error.message}`);
- }
-
- // Format the response - data is now a single object, not an array
- const details = {
- slug: data.slug,
- title: data.title,
- displayTitle: data.display_title || data.title,
- category: data.category,
- description: data.description || '',
- content: data.content || '',
- tags: data.tags || [],
- author: data.author || 'Unknown',
- authorProfileUrl: data.author_profile_url || null,
- dateAdded: data.date_added,
- dateUpdated: data.updated_at,
- createdAt: data.created_at,
- metadata: data.metadata || {},
- stats: {
- views: data.view_count || 0,
- bookmarks: data.bookmark_count || 0,
- copies: data.copy_count || 0,
- },
- };
-
- // Create detailed text summary
- const dateAddedText = details.dateAdded
- ? new Date(details.dateAdded).toLocaleDateString()
- : 'Unknown';
-
- const textSummary = `
-# ${details.title}
-
-**Category:** ${details.category}
-**Author:** ${details.author}
-**Added:** ${dateAddedText}
-**Tags:** ${details.tags.join(', ')}
-
-## Description
-${details.description}
-
-## Stats
-- Views: ${details.stats.views}
-- Bookmarks: ${details.stats.bookmarks}
-- Copies: ${details.stats.copies}
-
-${details.content ? `## Content\n${details.content}` : ''}
-`.trim();
-
- // Get usage hints for this content
- const usageHints = getContentUsageHints(details.category, details.slug);
-
- return {
- content: [
- {
- type: 'text' as const,
- text: textSummary,
- },
- ],
- _meta: {
- ...details,
- usageHints,
- relatedTools: ['downloadContentForPlatform', 'getRelatedContent', 'getContentByTag', 'searchContent'],
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/download-platform.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/download-platform.ts
deleted file mode 100644
index d8b859dfd..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/download-platform.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-/**
- * downloadContentForPlatform Tool Handler
- *
- * Downloads content formatted for a specific platform (Claude Code, Cursor, etc.)
- * Returns formatted content with installation instructions.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import { sanitizeString, isValidSlug } from '../lib/utils.ts';
-import type { DownloadContentForPlatformInput } from '../lib/types.ts';
-import {
- formatForClaudeCode,
- formatForCursor,
- formatForCodex,
- formatGeneric,
- getPlatformFilename,
- getTargetDirectory,
- getInstallationInstructions,
- type ContentItem,
-} from '../lib/platform-formatters.ts';
-
-/**
- * Fetches content and formats it for the specified platform.
- *
- * @param supabase - Authenticated Supabase client
- * @param input - Tool input with category, slug, platform, and optional targetDirectory
- * @returns Formatted content with installation instructions
- * @throws If content not found or formatting fails
- */
-export async function handleDownloadContentForPlatform(
- supabase: SupabaseClient,
- input: DownloadContentForPlatformInput
-) {
- // Sanitize and validate inputs
- const category = input.category;
- const slug = sanitizeString(input.slug);
- const platform = input.platform || 'claude-code';
- const targetDirectory = input.targetDirectory ? sanitizeString(input.targetDirectory) : undefined;
-
- // Validate slug format
- if (!isValidSlug(slug)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_SLUG,
- `Invalid slug format: ${slug}. Slugs must be alphanumeric with hyphens, underscores, or dots.`
- );
- throw new Error(error.message);
- }
-
- // Validate platform
- const validPlatforms = ['claude-code', 'cursor', 'chatgpt-codex', 'generic'];
- if (!validPlatforms.includes(platform)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_PLATFORM,
- `Invalid platform: ${platform}. Supported platforms: ${validPlatforms.join(', ')}`
- );
- throw new Error(error.message);
- }
-
- // Fetch content using the same query as getContentDetail
- const { data, error } = await supabase
- .from('content')
- .select(`
- slug,
- title,
- display_title,
- category,
- description,
- content,
- tags,
- author,
- author_profile_url,
- date_added,
- created_at,
- updated_at,
- metadata,
- view_count,
- bookmark_count,
- copy_count
- `)
- .eq('category', category)
- .eq('slug', slug)
- .single();
-
- if (error) {
- // PGRST116: JSON object requested, multiple (or no) rows returned
- if ((error as any).code === 'PGRST116') {
- throw new Error(`Content not found: ${category}/${slug}`);
- }
-
- await logError('Database query failed in downloadContentForPlatform', {
- dbQuery: {
- table: 'content',
- operation: 'select',
- schema: 'public',
- args: {
- category,
- slug,
- },
- },
- }, error);
- throw new Error(`Failed to fetch content: ${error.message}`);
- }
-
- // Build ContentItem structure
- const contentItem: ContentItem = {
- slug: data.slug,
- title: data.title,
- displayTitle: data.display_title || data.title,
- category: data.category,
- description: data.description || '',
- content: data.content || '',
- tags: data.tags || [],
- author: data.author || 'Unknown',
- authorProfileUrl: data.author_profile_url || null,
- dateAdded: data.date_added,
- dateUpdated: data.updated_at,
- createdAt: data.created_at,
- metadata: (data.metadata as ContentItem['metadata']) || {},
- stats: {
- views: data.view_count || 0,
- bookmarks: data.bookmark_count || 0,
- copies: data.copy_count || 0,
- },
- };
-
- // Format content for platform
- let formattedContent: string;
- try {
- switch (platform) {
- case 'claude-code':
- formattedContent = formatForClaudeCode(contentItem);
- break;
- case 'cursor':
- formattedContent = formatForCursor(contentItem);
- break;
- case 'chatgpt-codex':
- formattedContent = formatForCodex(contentItem);
- break;
- case 'generic':
- formattedContent = formatGeneric(contentItem);
- break;
- default:
- const errorResponse = createErrorResponse(
- McpErrorCode.INVALID_PLATFORM,
- `Unsupported platform: ${platform}. Supported platforms: claude-code, cursor, chatgpt-codex, generic`
- );
- throw new Error(errorResponse.message);
- }
- } catch (error) {
- await logError('Failed to format content for platform', {
- platform,
- category,
- slug,
- }, error);
- throw new Error(`Failed to format content: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
-
- // Get platform-specific metadata
- const filename = getPlatformFilename(platform);
- const targetDir = targetDirectory || getTargetDirectory(platform);
- const fullPath = targetDir === '.' ? filename : `${targetDir}/${filename}`;
- const installationInstructions = getInstallationInstructions(platform, filename, targetDir, formattedContent);
-
- // Build response with formatted content and instructions
- const responseText = `${formattedContent}\n\n${installationInstructions}`;
-
- return {
- content: [
- {
- type: 'text' as const,
- text: responseText,
- },
- ],
- _meta: {
- platform,
- filename,
- targetDirectory: targetDir,
- fullPath,
- category: contentItem.category,
- slug: contentItem.slug,
- title: contentItem.title,
- installationInstructions: installationInstructions.split('\n').filter((line) => line.trim()),
- },
- };
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/featured.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/featured.ts
deleted file mode 100644
index 75cfbcf02..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/featured.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-/**
- * getFeatured Tool Handler
- *
- * Get featured and highlighted content from the homepage.
- * Uses the get_content_paginated_slim RPC for each category in parallel.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import { Constants } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import type { GetFeaturedInput } from '../lib/types.ts';
-
-/**
- * Retrieve and assemble featured homepage content grouped by category.
- *
- * Fetches featured items for a fixed set of categories, aggregates successful RPC responses
- * into a per-category `featured` structure, and builds a single text summary listing up to
- * three items per category. If no featured content is available, returns a default text message.
- *
- * @returns An object containing:
- * - `content`: an array with a single text item (`{ type: 'text', text: string }`) holding the generated summary or a fallback message.
- * - `_meta`: metadata with `featured` (per-category arrays of items), `categories` (array of category keys present in `featured`), and `totalItems` (total number of items across all categories).
- */
-export async function handleGetFeatured(
- supabase: SupabaseClient,
- _input: GetFeaturedInput
-) {
- // get_homepage_optimized returns empty categoryData due to RPC issues
- // Use get_content_paginated_slim as fallback for featured content
- // Use explicit enum string values to avoid fragility from enum ordering changes
- const allowedCategoryValues: Database['public']['Enums']['content_category'][] = [
- 'agents',
- 'rules',
- 'commands',
- 'skills',
- 'collections',
- 'mcp',
- ];
- const validCategories = Constants.public.Enums.content_category.filter(
- (cat: Database['public']['Enums']['content_category']) => allowedCategoryValues.includes(cat)
- );
- const featured: Record = {};
-
- // OPTIMIZATION: Fetch all categories in parallel instead of sequentially
- // This reduces latency from sum(query_times) to max(query_time) - ~83% improvement
- const categoryQueries = validCategories.map((category: Database['public']['Enums']['content_category']) =>
- supabase.rpc('get_content_paginated_slim', {
- p_category: category,
- p_limit: 6,
- p_offset: 0,
- p_order_by: 'popularity_score',
- p_order_direction: 'desc',
- })
- );
-
- // Execute all queries in parallel with error handling
- const results = await Promise.allSettled(categoryQueries);
-
- // Process results - handle both fulfilled and rejected promises
- for (const [index, result] of results.entries()) {
- const category = validCategories[index];
-
- if (!category) continue; // Skip if category is undefined
-
- if (result.status === 'fulfilled') {
- const { data, error } = result.value;
-
- if (error) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('RPC call failed in getFeatured', {
- dbQuery: {
- rpcName: 'get_content_paginated_slim',
- args: {
- p_category: category,
- p_limit: 6,
- p_offset: 0,
- p_order_by: 'popularity_score',
- p_order_direction: 'desc',
- },
- },
- category,
- }, error);
- // Continue gracefully - category will be missing from featured object
- continue;
- }
-
- if (data) {
- // Type the RPC return value
- type PaginatedSlimResult = Database['public']['CompositeTypes']['content_paginated_slim_result'];
- const typedData = data as PaginatedSlimResult;
-
- // Validate response structure
- if (!typedData || typeof typedData !== 'object') {
- continue; // Skip malformed responses
- }
-
- if (typedData.items) {
- // p_limit: 6 already restricts results, so slice is unnecessary
- type FeaturedItem = {
- slug: string;
- title: string;
- category: string;
- description: string;
- tags: unknown[];
- views: number;
- };
- featured[category] = typedData.items.map((item: unknown): FeaturedItem | null => {
- if (typeof item !== 'object' || item === null) return null;
- const itemObj = item as Record;
- return {
- slug: typeof itemObj['slug'] === 'string' ? itemObj['slug'] : '',
- title:
- (typeof itemObj['title'] === 'string'
- ? itemObj['title']
- : typeof itemObj['display_title'] === 'string'
- ? itemObj['display_title']
- : '') || '',
- category: typeof itemObj['category'] === 'string' ? itemObj['category'] : '',
- description:
- typeof itemObj['description'] === 'string'
- ? itemObj['description'].substring(0, 150)
- : '',
- tags: Array.isArray(itemObj['tags']) ? itemObj['tags'] : [],
- views: typeof itemObj['view_count'] === 'number' ? itemObj['view_count'] : 0,
- };
- })
- .filter((item: FeaturedItem | null): item is FeaturedItem => item !== null);
- }
- }
- } else if (result.status === 'rejected') {
- // Promise was rejected - log with dbQuery serializer
- await logError('RPC promise rejected in getFeatured', {
- dbQuery: {
- rpcName: 'get_content_paginated_slim',
- args: {
- p_category: category,
- p_limit: 6,
- p_offset: 0,
- p_order_by: 'popularity_score',
- p_order_direction: 'desc',
- },
- },
- category,
- }, result.reason);
- // Continue gracefully - category will be missing from featured object
- }
- }
-
- if (Object.keys(featured).length === 0) {
- return {
- content: [
- {
- type: 'text' as const,
- text: 'No featured content available.',
- },
- ],
- _meta: {
- featured: {},
- },
- };
- }
-
- // Format text summary by category
- const textParts: string[] = [];
- for (const [category, items] of Object.entries(featured)) {
- if (items.length > 0) {
- textParts.push(`\n## ${category.charAt(0).toUpperCase() + category.slice(1)}:`);
- const typedItems = items as Array<{ title: string; views: number; description: string }>;
- textParts.push(
- typedItems
- .slice(0, 3)
- .map(
- (item: { title: string; views: number; description: string }, _idx: number) =>
- `${_idx + 1}. ${item.title} - ${item.views} views\n ${item.description}${item.description.length >= 150 ? '...' : ''}`
- )
- .join('\n\n')
- );
- }
- }
-
- const textSummary = `HeyClaude Directory - Featured Content:\n\n${textParts.join('\n')}`;
-
- return {
- content: [
- {
- type: 'text' as const,
- text: textSummary,
- },
- ],
- _meta: {
- featured,
- categories: Object.keys(featured),
- totalItems: Object.values(featured).flat().length,
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/mcp-servers.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/mcp-servers.ts
deleted file mode 100644
index 1f6e36be5..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/mcp-servers.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * getMcpServers Tool Handler
- * Uses ContentService.getContentPaginated to filter MCP servers (category='mcp')
- * Includes .mcpb download URLs and configuration details
- */
-
-import { ContentService } from '@heyclaude/data-layer/services/content.ts';
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import type { GetMcpServersInput } from '../lib/types.ts';
-
-type ContentPaginatedItem = Database['public']['CompositeTypes']['content_paginated_item'];
-
-/**
- * Fetches MCP entries from the HeyClaude directory, enriches them with available metadata and download URLs, and returns a formatted text summary plus a metadata payload containing the full server representations.
- *
- * @param supabase - Supabase client used to read content and metadata rows
- * @param input - Input options; `limit` controls the maximum number of MCP items to fetch
- * @returns An object with:
- * - `content`: an array containing a single text item with a human-readable list of MCP servers and a total count
- * - `_meta`: an object with `servers` (the full array of server objects) and `count` (number of servers)
- *
- * Each server object in `_meta.servers` includes: `slug`, `title`, `description` (trimmed to 200 chars), `author`, `dateAdded`, `tags`, `mcpbUrl` (string or `null`), `requiresAuth` (boolean), `tools` (array of `{ name, description }`), `configuration` (object), and `stats` (`views` and `bookmarks`).
- */
-export async function handleGetMcpServers(
- supabase: SupabaseClient,
- input: GetMcpServersInput
-) {
- const { limit } = input;
-
- // Use ContentService to get MCP content
- const contentService = new ContentService(supabase);
-
- const result = await contentService.getContentPaginated({
- p_category: 'mcp',
- p_order_by: 'created_at',
- p_order_direction: 'desc',
- p_limit: limit,
- p_offset: 0,
- });
-
- if (!(result && result.items) || result.items.length === 0) {
- return {
- content: [
- {
- type: 'text' as const,
- text: 'No MCP servers found in the directory.',
- },
- ],
- _meta: {
- servers: [],
- count: 0,
- },
- };
- }
-
- // Fetch metadata separately since content_paginated_item doesn't include it
- const slugs = result.items
- .map((item: ContentPaginatedItem) => item.slug)
- .filter((slug): slug is string => typeof slug === 'string' && slug !== null);
-
- const { data: metadataRows, error: metadataError } = await supabase
- .from('content')
- .select('slug, metadata, mcpb_storage_url')
- .in('slug', slugs)
- .eq('category', 'mcp');
-
- if (metadataError) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('Database query failed in getMcpServers', {
- dbQuery: {
- table: 'content',
- operation: 'select',
- schema: 'public',
- args: {
- slugs: slugs.slice(0, 10), // Log first 10 slugs to avoid huge logs
- category: 'mcp',
- },
- },
- }, metadataError);
- // Continue without metadata - not critical
- }
-
- // Create metadata map for quick lookup
- const metadataMap = new Map<
- string,
- { metadata: Record; mcpb_storage_url: string | null }
- >();
- if (metadataRows && !metadataError) {
- for (const row of metadataRows) {
- metadataMap.set(row.slug, {
- metadata: (row.metadata as Record) || {},
- mcpb_storage_url: row.mcpb_storage_url,
- });
- }
- }
-
- // Format MCP servers with complete metadata
- const servers = result.items.map((item: ContentPaginatedItem) => {
- const itemMetadata = metadataMap.get(item.slug || '') || {
- metadata: {},
- mcpb_storage_url: null,
- };
- const metadata = itemMetadata.metadata;
- const mcpbUrl =
- itemMetadata.mcpb_storage_url ||
- (typeof metadata['mcpb_storage_url'] === 'string' ? metadata['mcpb_storage_url'] : null);
-
- const configuration = (
- typeof metadata['configuration'] === 'object' && metadata['configuration'] !== null
- ? metadata['configuration']
- : {}
- ) as Record;
- const requiresAuth = Boolean(metadata['requires_auth']);
- const tools = (Array.isArray(metadata['tools']) ? metadata['tools'] : []) as Array<{
- name?: string;
- description?: string;
- }>;
-
- return {
- slug: item.slug || '',
- title: item.title || item.display_title || '',
- description: item.description?.substring(0, 200) || '',
- author: item.author || 'Unknown',
- dateAdded: item.date_added || '',
- tags: item.tags || [],
- mcpbUrl,
- requiresAuth,
- tools: tools.map((tool) => ({
- name: tool.name || '',
- description: tool.description || '',
- })),
- configuration,
- stats: {
- views: item.view_count || 0,
- bookmarks: item.bookmark_count || 0,
- },
- };
- });
-
- // Create text summary
- const textSummary = servers
- .map(
- (
- server: {
- title: string;
- author: string;
- description: string;
- tools: Array<{ name: string }>;
- requiresAuth: boolean;
- mcpbUrl: string | null;
- stats: { views: number; bookmarks: number };
- },
- idx: number
- ) => {
- const toolsList =
- server.tools.length > 0 ? server.tools.map((t) => t.name).join(', ') : 'No tools listed';
-
- const downloadInfo = server.mcpbUrl
- ? `\n Download: ${server.mcpbUrl}`
- : '\n Download: Not available';
-
- return `${idx + 1}. ${server.title}
- Author: ${server.author}
- ${server.description}${server.description.length >= 200 ? '...' : ''}
- Tools: ${toolsList}
- Auth Required: ${server.requiresAuth ? 'Yes' : 'No'}${downloadInfo}
- Stats: ${server.stats.views} views, ${server.stats.bookmarks} bookmarks`;
- }
- )
- .join('\n\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `MCP Servers in HeyClaude Directory:\n\n${textSummary}\n\nTotal: ${servers.length} servers`,
- },
- ],
- _meta: {
- servers,
- count: servers.length,
- limit,
- pagination: {
- total: servers.length,
- limit,
- hasMore: false, // MCP servers doesn't support pagination
- },
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/newsletter.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/newsletter.ts
deleted file mode 100644
index b37713c1c..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/newsletter.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * subscribeNewsletter Tool Handler
- *
- * Subscribes a user to the newsletter via Inngest.
- * Sends event to Inngest which handles:
- * - Email validation
- * - Resend audience sync
- * - Database subscription
- * - Welcome email
- * - Drip campaign enrollment
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import { sanitizeString } from '../lib/utils.ts';
-import type { SubscribeNewsletterInput } from '../lib/types.ts';
-
-/**
- * Validates email format
- */
-function validateEmail(email: string): { valid: boolean; normalized?: string; error?: string } {
- const trimmed = email.trim().toLowerCase();
-
- // Basic email validation regex
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-
- if (!trimmed) {
- return { valid: false, error: 'Email is required' };
- }
-
- if (!emailRegex.test(trimmed)) {
- return { valid: false, error: 'Invalid email format' };
- }
-
- return { valid: true, normalized: trimmed };
-}
-
-/**
- * Sends newsletter subscription event to Inngest
- *
- * Uses Inngest HTTP API to send events.
- * For local dev, uses Inngest Dev Server (http://localhost:8288)
- * For production, uses Inngest Cloud API
- */
-async function sendInngestEvent(
- email: string,
- source: string,
- referrer?: string,
- metadata?: Record
-): Promise {
- // Get Inngest configuration from environment
- // INNGEST_EVENT_KEY is required for sending events
- // INNGEST_URL is optional (defaults to Inngest Cloud or local dev server)
- const inngestEventKey = Deno.env.get('INNGEST_EVENT_KEY');
- const inngestUrl = Deno.env.get('INNGEST_URL') || Deno.env.get('INNGEST_SIGNING_KEY')
- ? 'https://api.inngest.com'
- : 'http://localhost:8288'; // Local dev server
-
- if (!inngestEventKey) {
- // For local dev, event key might not be required
- // But for production, it's required
- if (!inngestUrl.includes('localhost')) {
- throw new Error('INNGEST_EVENT_KEY environment variable is required for production');
- }
- }
-
- const eventData = {
- name: 'email/subscribe',
- data: {
- email,
- source,
- ...(referrer ? { referrer } : {}),
- ...(metadata ? { metadata } : {}),
- },
- };
-
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
-
- // Add authorization header if event key is available
- if (inngestEventKey) {
- headers['Authorization'] = `Bearer ${inngestEventKey}`;
- }
-
- const response = await fetch(`${inngestUrl}/v1/events`, {
- method: 'POST',
- headers,
- body: JSON.stringify(eventData),
- });
-
- if (!response.ok) {
- const errorText = await response.text().catch(() => 'Unknown error');
- throw new Error(`Inngest event send failed: ${response.status} ${response.statusText} - ${errorText}`);
- }
-}
-
-/**
- * Subscribes a user to the newsletter
- *
- * @param supabase - Authenticated Supabase client (not used but kept for consistency)
- * @param input - Tool input with email, source, and optional metadata
- * @returns Success message
- * @throws If email validation fails or Inngest event send fails
- */
-export async function handleSubscribeNewsletter(
- supabase: SupabaseClient,
- input: SubscribeNewsletterInput
-) {
- // Sanitize inputs
- const email = sanitizeString(input.email);
- const source = sanitizeString(input.source || 'mcp');
- const referrer = input.referrer ? sanitizeString(input.referrer) : undefined;
-
- // Validate email
- const emailValidation = validateEmail(email);
- if (!emailValidation.valid || !emailValidation.normalized) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_EMAIL,
- emailValidation.error || 'Invalid email address format'
- );
- throw new Error(error.message);
- }
-
- const normalizedEmail = emailValidation.normalized;
-
- // Send event to Inngest
- try {
- await sendInngestEvent(normalizedEmail, source, referrer, input.metadata);
- } catch (error) {
- await logError('Failed to send newsletter subscription event to Inngest', {
- email: normalizedEmail, // Auto-hashed by logger
- source,
- referrer,
- }, error);
-
- // Check if it's an Inngest-specific error
- if (error instanceof Error && error.message.includes('Inngest')) {
- const mcpError = createErrorResponse(
- McpErrorCode.INNGEST_ERROR,
- error.message
- );
- throw new Error(mcpError.message);
- }
-
- throw new Error(`Failed to process subscription: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Newsletter subscription request received for ${normalizedEmail}. You will receive a confirmation email shortly.`,
- },
- ],
- _meta: {
- email: normalizedEmail, // Auto-hashed in logs
- source,
- status: 'pending',
- message: 'Subscription request sent to Inngest for processing',
- },
- };
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/oauth-authorize.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/oauth-authorize.ts
deleted file mode 100644
index d430e907c..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/oauth-authorize.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-/**
- * OAuth Authorization Proxy Endpoint
- *
- * Proxies OAuth authorization requests to Supabase Auth OAuth 2.1 Server with the resource parameter
- * (RFC 8707) to ensure tokens include the MCP server URL in the audience claim.
- *
- * **IMPORTANT:** This requires OAuth 2.1 Server to be enabled in your Supabase project.
- * Enable it in: Authentication > OAuth Server in the Supabase dashboard.
- *
- * This endpoint enables full OAuth 2.1 flow for MCP clients:
- * 1. Client initiates OAuth with resource parameter
- * 2. Supabase Auth OAuth 2.1 Server validates and redirects to authorization UI
- * 3. User authenticates (using existing account) and approves/denies
- * 4. Token issued with correct audience claim (from resource parameter)
- * 5. Client uses token with MCP server
- */
-
-import { edgeEnv } from '@heyclaude/edge-runtime/config/env.ts';
-import { initRequestLogging, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { createDataApiContext, logError, logger } from '@heyclaude/shared-runtime/logging.ts';
-import { getEnvVar } from '@heyclaude/shared-runtime/env.ts';
-import type { Context } from 'hono';
-
-const SUPABASE_URL = edgeEnv.supabase.url;
-const SUPABASE_AUTH_URL = `${SUPABASE_URL}/auth/v1`;
-// Use getEnvVar for consistency with edgeEnv pattern (could be moved to edgeEnv config in future)
-const MCP_SERVER_URL = getEnvVar('MCP_SERVER_URL') ?? 'https://mcp.claudepro.directory';
-const MCP_RESOURCE_URL = `${MCP_SERVER_URL}/mcp`;
-
-/**
- * Create an OAuth-style JSON error response and include CORS and JSON content-type headers.
- *
- * @param c - The request/response context used to build the response
- * @param error - OAuth error code to return (e.g., `invalid_request`, `server_error`)
- * @param description - Human-readable error description to include as `error_description`
- * @param status - HTTP status code to send (400 or 500); defaults to 400
- * @returns A Response whose JSON body contains `error` and `error_description` and whose headers include `Content-Type: application/json` and `Access-Control-Allow-Origin: *`
- */
-function jsonError(
- c: Context,
- error: string,
- description: string,
- status: 400 | 500 = 400
-): Response {
- return c.json({ error, error_description: description }, status, {
- 'Content-Type': 'application/json',
- 'Access-Control-Allow-Origin': '*',
- });
-}
-
-/**
- * Proxies incoming OAuth authorization requests to Supabase Auth OAuth 2.1 Server, injecting the RFC 8707 `resource` parameter for MCP audience and preserving required OAuth and PKCE parameters.
- *
- * **IMPORTANT:** This requires OAuth 2.1 Server to be enabled in your Supabase project.
- * Enable it in: Authentication > OAuth Server in the Supabase dashboard.
- *
- * Performs required validation for `client_id`, `response_type` (must be `code`), `redirect_uri`, and PKCE (`code_challenge` / `code_challenge_method` must be `S256`) and returns appropriate JSON OAuth error responses on validation failure.
- *
- * @returns A redirect Response to the Supabase Auth `/oauth/authorize` endpoint (OAuth 2.1 Server) when the request is valid, or a JSON error Response describing the validation or server error.
- */
-export async function handleOAuthAuthorize(c: Context): Promise {
- const logContext = createDataApiContext('oauth-authorize', {
- app: 'heyclaude-mcp',
- method: 'GET',
- });
-
- // Initialize request logging with trace and bindings (Phase 1 & 2)
- initRequestLogging(logContext);
- traceStep('OAuth authorization request received', logContext);
-
- // Set bindings for this request - mixin will automatically inject these into all subsequent logs
- logger.setBindings({
- requestId: typeof logContext['request_id'] === "string" ? logContext['request_id'] : undefined,
- operation: typeof logContext['action'] === "string" ? logContext['action'] : 'oauth-authorize',
- function: typeof logContext['function'] === "string" ? logContext['function'] : "unknown",
- method: 'GET',
- });
-
- try {
- // Get query parameters from Hono request
- const query = c.req.query();
-
- // Extract OAuth parameters from request
- // Hono query() returns Record, so we need to handle arrays
- const getQueryParam = (key: string): string | undefined => {
- const value = query[key];
- if (Array.isArray(value)) {
- return value[0]; // Take first value if array
- }
- return value;
- };
-
- const clientId = getQueryParam('client_id');
- const responseType = getQueryParam('response_type');
- const redirectUri = getQueryParam('redirect_uri');
- const scope = getQueryParam('scope');
- const state = getQueryParam('state');
- const codeChallenge = getQueryParam('code_challenge');
- const codeChallengeMethod = getQueryParam('code_challenge_method');
-
- // Validate required parameters
- if (!(clientId && responseType && redirectUri)) {
- return jsonError(c, 'invalid_request', 'Missing required OAuth parameters', 400);
- }
-
- // Basic URL validation for redirect_uri to prevent open redirect attacks
- // Note: Supabase Auth also validates registered redirect URIs, but we add
- // an extra layer of defense here
- // redirectUri is guaranteed to be truthy from the check above
- try {
- new URL(redirectUri);
- } catch {
- return jsonError(c, 'invalid_request', 'Invalid redirect_uri format', 400);
- }
-
- // Validate response_type (OAuth 2.1 requires 'code')
- if (responseType !== 'code') {
- return jsonError(c, 'unsupported_response_type', 'Only "code" response type is supported', 400);
- }
-
- // Validate PKCE (OAuth 2.1 requires PKCE)
- if (!(codeChallenge && codeChallengeMethod)) {
- return jsonError(c, 'invalid_request', 'PKCE (code_challenge) is required', 400);
- }
-
- if (codeChallengeMethod !== 'S256') {
- return jsonError(c, 'invalid_request', 'Only S256 code challenge method is supported', 400);
- }
-
- // Build Supabase Auth OAuth 2.1 authorization URL
- // Use /oauth/authorize endpoint (requires OAuth 2.1 Server to be enabled)
- // Include resource parameter (RFC 8707) to ensure token has correct audience
- const supabaseAuthUrl = new URL(`${SUPABASE_AUTH_URL}/oauth/authorize`);
-
- // Preserve all OAuth parameters
- supabaseAuthUrl.searchParams.set('client_id', clientId);
- supabaseAuthUrl.searchParams.set('response_type', responseType);
- supabaseAuthUrl.searchParams.set('redirect_uri', redirectUri);
-
- // Add resource parameter (RFC 8707) - CRITICAL for MCP spec compliance
- // This tells Supabase Auth to include the MCP server URL in the token's audience claim
- supabaseAuthUrl.searchParams.set('resource', MCP_RESOURCE_URL);
-
- // Preserve optional parameters
- if (scope) {
- supabaseAuthUrl.searchParams.set('scope', scope);
- }
- if (state) {
- supabaseAuthUrl.searchParams.set('state', state);
- }
- // PKCE parameters are validated as required above, so they're always present here
- supabaseAuthUrl.searchParams.set('code_challenge', codeChallenge);
- supabaseAuthUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
-
- // Redirect to Supabase Auth with all parameters including resource
- return c.redirect(supabaseAuthUrl.toString(), 302);
- } catch (error) {
- await logError('OAuth authorization proxy failed', logContext, error);
- return jsonError(c, 'server_error', 'Internal server error', 500);
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/popular.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/popular.ts
deleted file mode 100644
index 8a82c590e..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/popular.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * getPopular Tool Handler
- * Uses TrendingService.getPopularContent for consistent behavior with web app
- */
-
-import { TrendingService } from '@heyclaude/data-layer/services/trending.ts';
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import type { GetPopularInput } from '../lib/types.ts';
-
-type PopularContentItem = Database['public']['Functions']['get_popular_content']['Returns'][number];
-
-/**
- * Retrieve and format popular content, optionally filtered by category, into a textual summary and metadata.
- *
- * @param input - Query options: `category` to filter results and `limit` to cap the number of items returned.
- * @returns An object with:
- * - `content`: a single text item containing a human-readable summary (or a "no popular content found" message when empty).
- * - `_meta`: metadata including `items` (the formatted list with slug, title, category, truncated description, tags, author, dateAdded, and stats), `category` (the requested category or `'all'`), and `count` (number of items, present when results exist).
- */
-export async function handleGetPopular(supabase: SupabaseClient, input: GetPopularInput) {
- const { category, limit } = input;
-
- // Use TrendingService for consistent behavior with web app
- const trendingService = new TrendingService(supabase);
-
- const data = await trendingService.getPopularContent({
- ...(category ? { p_category: category } : {}),
- p_limit: limit,
- });
-
- if (!data || data.length === 0) {
- const categoryDesc = category ? ` in ${category}` : '';
- return {
- content: [
- {
- type: 'text' as const,
- text: `No popular content found${categoryDesc}.`,
- },
- ],
- _meta: {
- items: [],
- category: category || 'all',
- count: 0,
- },
- };
- }
-
- // Format results
- const items = data.map((item: PopularContentItem) => {
- const originalDescription = item.description || '';
- const truncatedDescription = originalDescription.substring(0, 150);
- const wasTruncated = originalDescription.length > 150;
- return {
- slug: item.slug,
- title: item.title,
- category: item.category,
- description: truncatedDescription,
- wasTruncated,
- tags: item.tags || [],
- author: item.author || 'Unknown',
- dateAdded: item.date_added || '',
- stats: {
- views: item.view_count || 0,
- bookmarks: item.bookmark_count || 0,
- upvotes: 0,
- },
- };
- });
-
- // Create text summary
- const categoryDesc = category ? ` in ${category}` : ' across all categories';
- const textSummary = items
- .map(
- (
- item: {
- title: string;
- category: string;
- description: string;
- wasTruncated: boolean;
- stats: { views: number; bookmarks: number };
- },
- idx: number
- ) =>
- `${idx + 1}. ${item.title} (${item.category}) 👀 ${item.stats.views} views\n ${item.description}${item.wasTruncated ? '...' : ''}\n ${item.stats.bookmarks} bookmarks`
- )
- .join('\n\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Popular Content${categoryDesc}:\n\n${textSummary}`,
- },
- ],
- _meta: {
- items,
- category: category || 'all',
- count: items.length,
- limit,
- pagination: {
- total: items.length,
- limit,
- hasMore: false, // Popular doesn't support pagination
- },
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/recent.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/recent.ts
deleted file mode 100644
index 449689886..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/recent.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * getRecent Tool Handler
- * Uses TrendingService.getRecentContent for consistent behavior with web app
- */
-
-import { TrendingService } from '@heyclaude/data-layer/services/trending.ts';
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import type { GetRecentInput } from '../lib/types.ts';
-
-type RecentContentItem = Database['public']['Functions']['get_recent_content']['Returns'][number];
-
-/**
- * Retrieve recent content (optionally filtered by category) and return a formatted textual summary plus metadata.
- *
- * @param input - Query options; may include `category` to filter results and `limit` to cap the number of items returned.
- * @returns An object with:
- * - `content`: an array containing a single text block summarizing recently added items,
- * - `_meta.items`: the mapped list of items (slug, title, category, description, tags, author, dateAdded),
- * - `_meta.category`: the requested category or `'all'`,
- * - `_meta.count`: the number of items (when items are present).
- */
-export async function handleGetRecent(supabase: SupabaseClient, input: GetRecentInput) {
- const { category, limit } = input;
-
- // Use TrendingService for consistent behavior with web app
- const trendingService = new TrendingService(supabase);
-
- const data = await trendingService.getRecentContent({
- ...(category ? { p_category: category } : {}),
- p_limit: limit,
- p_days: 30,
- });
-
- if (!data || data.length === 0) {
- const categoryDesc = category ? ` in ${category}` : '';
- return {
- content: [
- {
- type: 'text' as const,
- text: `No recent content found${categoryDesc}.`,
- },
- ],
- _meta: {
- items: [],
- category: category || 'all',
- },
- };
- }
-
- // Format results
- const items = data.map((item: RecentContentItem) => ({
- slug: item.slug,
- title: item.title || item.display_title || '',
- category: item.category,
- description: item.description?.substring(0, 150) || '',
- tags: item.tags || [],
- author: item.author || 'Unknown',
- dateAdded: item.date_added,
- }));
-
- // Create text summary with relative dates
- const categoryDesc = category ? ` in ${category}` : ' across all categories';
- const now = new Date();
- const textSummary = items
- .map(
- (
- item: {
- title: string;
- category: string;
- description: string;
- tags: string[];
- dateAdded: string;
- },
- idx: number
- ) => {
- const date = new Date(item.dateAdded);
- const diffMs = now.getTime() - date.getTime();
- const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
-
- let timeDesc: string;
- if (diffDays === 0) {
- timeDesc = 'Today';
- } else if (diffDays === 1) {
- timeDesc = 'Yesterday';
- } else if (diffDays < 7) {
- timeDesc = `${diffDays} days ago`;
- } else if (diffDays < 30) {
- const weeks = Math.floor(diffDays / 7);
- timeDesc = `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
- } else {
- const months = Math.floor(diffDays / 30);
- timeDesc = `${months} ${months === 1 ? 'month' : 'months'} ago`;
- }
-
- return `${idx + 1}. ${item.title} (${item.category}) - ${timeDesc}\n ${item.description}${item.description.length >= 150 ? '...' : ''}\n Tags: ${item.tags.slice(0, 5).join(', ')}`;
- }
- )
- .join('\n\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Recently Added Content${categoryDesc}:\n\n${textSummary}`,
- },
- ],
- _meta: {
- items,
- category: category || 'all',
- count: items.length,
- limit,
- pagination: {
- total: items.length,
- limit,
- hasMore: false, // Recent doesn't support pagination
- },
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/related.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/related.ts
deleted file mode 100644
index 58bc1e6da..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/related.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * getRelatedContent Tool Handler
- * Uses get_related_content RPC for consistent behavior with web app
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import type { GetRelatedContentInput } from '../lib/types.ts';
-
-type RelatedContentItem = Database['public']['CompositeTypes']['related_content_item'];
-
-/**
- * Retrieves related content for a given slug and category and returns a textual summary and metadata.
- *
- * @param input - Query parameters: `slug` of the source item, `category` to match, and optional `limit` for number of results.
- * @returns A payload containing a single text content block summarizing the related items and an `_meta` object with `items`, `source`, and `count` (when results exist) or an empty `items` array when none are found.
- * @throws Error when the backend RPC call to fetch related content fails.
- */
-export async function handleGetRelatedContent(
- supabase: SupabaseClient,
- input: GetRelatedContentInput
-) {
- const { slug, category, limit } = input;
-
- // Call get_related_content RPC directly with correct parameter order
- // Signature: p_category, p_slug, p_tags, p_limit, p_exclude_slugs
- const rpcArgs = {
- p_category: category,
- p_slug: slug,
- p_tags: [],
- p_limit: limit,
- p_exclude_slugs: [],
- };
- const { data, error } = await supabase.rpc('get_related_content', rpcArgs);
-
- if (error) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('RPC call failed in getRelatedContent', {
- dbQuery: {
- rpcName: 'get_related_content',
- args: rpcArgs, // Will be redacted by Pino's redact config
- },
- }, error);
- throw new Error(`Failed to fetch related content: ${error.message}`);
- }
-
- if (!data || data.length === 0) {
- return {
- content: [
- {
- type: 'text' as const,
- text: `No related content found for ${slug}.`,
- },
- ],
- _meta: {
- items: [],
- source: { slug, category },
- count: 0,
- },
- };
- }
-
- const items = data.map((item: RelatedContentItem) => ({
- slug: item.slug || '',
- title: item.title || '',
- category: item.category || '',
- description: item.description?.substring(0, 150) || '',
- tags: item.tags || [],
- relevanceScore: item.score || 0, // Fixed: function returns 'score', not 'relevance_score'
- }));
-
- const textSummary = items
- .map(
- (
- item: { title: string; category: string; description: string; relevanceScore: number },
- idx: number
- ) =>
- `${idx + 1}. ${item.title} (${item.category}) - Relevance: ${item.relevanceScore}\n ${item.description}${item.description.length >= 150 ? '...' : ''}`
- )
- .join('\n\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Related Content:\n\n${textSummary}`,
- },
- ],
- _meta: {
- items,
- source: { slug, category },
- count: items.length,
- limit,
- pagination: {
- total: items.length,
- limit,
- hasMore: false, // Related doesn't support pagination
- },
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/search-facets.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/search-facets.ts
deleted file mode 100644
index b0aa268cc..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/search-facets.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * getSearchFacets Tool Handler
- *
- * Get available search facets (categories, tags, authors) for filtering content.
- * Helps AI agents understand what filters are available.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-
-/**
- * Fetches available search facets (categories, tags, authors).
- *
- * @param supabase - Authenticated Supabase client
- * @returns Search facets with categories, tags, and authors
- * @throws If RPC fails
- */
-export async function handleGetSearchFacets(
- supabase: SupabaseClient
-) {
- // Call RPC for search facets
- const { data, error } = await supabase.rpc('get_search_facets');
-
- if (error) {
- await logError('Search facets RPC failed', {
- rpcName: 'get_search_facets',
- }, error);
- throw new Error(`Failed to fetch search facets: ${error.message}`);
- }
-
- // Format response
- interface FacetRow {
- all_tags?: null | readonly string[];
- authors?: null | readonly string[];
- category: null | string;
- content_count: null | number;
- }
-
- const rows: FacetRow[] = Array.isArray(data) ? (data as FacetRow[]) : [];
- const facets = rows.map((item) => ({
- category: item.category ?? 'unknown',
- contentCount: Number(item.content_count ?? 0),
- tags: Array.isArray(item.all_tags)
- ? item.all_tags.filter((tag): tag is string => typeof tag === 'string')
- : [],
- authors: Array.isArray(item.authors)
- ? item.authors.filter((author): author is string => typeof author === 'string')
- : [],
- }));
-
- // Create text summary
- const totalCategories = facets.length;
- const totalContent = facets.reduce((sum, f) => sum + f.contentCount, 0);
- const allTags = new Set();
- const allAuthors = new Set();
-
- facets.forEach((f) => {
- f.tags.forEach((tag) => allTags.add(tag));
- f.authors.forEach((author) => allAuthors.add(author));
- });
-
- const textSummary = `Available search facets:\n\n` +
- `**Categories:** ${totalCategories} categories with ${totalContent} total content items\n` +
- `**Tags:** ${allTags.size} unique tags available\n` +
- `**Authors:** ${allAuthors.size} unique authors\n\n` +
- `**By Category:**\n${facets.map((f) => `- ${f.category}: ${f.contentCount} items, ${f.tags.length} tags, ${f.authors.length} authors`).join('\n')}\n\n` +
- `Use these facets to filter content with searchContent or getContentByTag tools.`;
-
- return {
- content: [
- {
- type: 'text' as const,
- text: textSummary,
- },
- ],
- _meta: {
- facets,
- summary: {
- totalCategories,
- totalContent,
- totalTags: allTags.size,
- totalAuthors: allAuthors.size,
- },
- },
- };
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/search-suggestions.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/search-suggestions.ts
deleted file mode 100644
index 0a3626f5c..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/search-suggestions.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * getSearchSuggestions Tool Handler
- *
- * Get search suggestions based on query history. Helps discover popular searches
- * and provides autocomplete functionality for AI agents.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import { sanitizeString } from '../lib/utils.ts';
-import type { GetSearchSuggestionsInput } from '../lib/types.ts';
-
-/**
- * Fetches search suggestions based on query history.
- *
- * @param supabase - Authenticated Supabase client
- * @param input - Tool input with query (min 2 chars) and optional limit (1-20, default 10)
- * @returns Search suggestions with text, search count, and popularity indicator
- * @throws If query is too short or RPC fails
- */
-export async function handleGetSearchSuggestions(
- supabase: SupabaseClient,
- input: GetSearchSuggestionsInput
-) {
- // Sanitize and validate inputs
- const query = sanitizeString(input.query);
- const limit = input.limit ?? 10;
-
- // Validate query length (min 2 characters)
- if (query.length < 2) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_INPUT,
- 'Query must be at least 2 characters long'
- );
- throw new Error(error.message);
- }
-
- // Validate limit (1-20)
- if (limit < 1 || limit > 20) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_INPUT,
- 'Limit must be between 1 and 20'
- );
- throw new Error(error.message);
- }
-
- // Call RPC for search suggestions
- const rpcArgs: Database['public']['Functions']['get_search_suggestions_from_history']['Args'] =
- {
- p_query: query,
- p_limit: limit,
- };
-
- const { data, error } = await supabase.rpc('get_search_suggestions_from_history', rpcArgs);
-
- if (error) {
- await logError('Search suggestions RPC failed', {
- rpcName: 'get_search_suggestions_from_history',
- query,
- limit,
- }, error);
- throw new Error(`Failed to fetch search suggestions: ${error.message}`);
- }
-
- // Format response
- interface SuggestionRow {
- search_count: null | number;
- suggestion: null | string;
- }
-
- const rows: SuggestionRow[] = Array.isArray(data) ? (data as SuggestionRow[]) : [];
- const suggestions = rows
- .map((item) => ({
- text: item.suggestion?.trim() ?? '',
- searchCount: Number(item.search_count ?? 0),
- isPopular: Number(item.search_count ?? 0) >= 2,
- }))
- .filter((item) => item.text.length > 0);
-
- // Create text summary
- const textSummary = suggestions.length > 0
- ? `Found ${suggestions.length} search suggestion${suggestions.length === 1 ? '' : 's'} for "${query}":\n\n${suggestions.map((s, i) => `${i + 1}. ${s.text}${s.isPopular ? ' (popular)' : ''} - searched ${s.searchCount} time${s.searchCount === 1 ? '' : 's'}`).join('\n')}`
- : `No search suggestions found for "${query}". Try a different query or check available content with listCategories.`;
-
- return {
- content: [
- {
- type: 'text' as const,
- text: textSummary,
- },
- ],
- _meta: {
- suggestions,
- query,
- count: suggestions.length,
- },
- };
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/search.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/search.ts
deleted file mode 100644
index ca737c64e..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/search.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * searchContent Tool Handler
- * Uses SearchService for consistent search behavior with web app
- * Follows architectural strategy: data layer -> database RPC -> DB
- *
- * Uses search_content_optimized when category/tags are provided (matches API route behavior)
- * Uses search_unified for simple queries without filters
- */
-
-import { SearchService } from '@heyclaude/data-layer/services/search.ts';
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { getSearchUsageHints } from '../lib/usage-hints.ts';
-import type { SearchContentInput } from '../lib/types.ts';
-
-// Use generated types from database - functions now return composite types
-type UnifiedSearchResult = Database['public']['CompositeTypes']['search_unified_row'];
-type ContentSearchResult = Database['public']['CompositeTypes']['search_content_optimized_row'];
-
-/**
- * Fetches unified search results matching the given search filters and returns a text summary plus metadata.
- *
- * @param input - Search filters and pagination options: `query` (search text), `category`, `tags`, `page`, and `limit`.
- * @returns An object with `content` (a single text block summarizing matched items) and `_meta` containing `items` (formatted items with slug, title, category, truncated description, tags, author, and dateAdded), `total`, `page`, `limit`, and `hasMore`.
- */
-export async function handleSearchContent(
- supabase: SupabaseClient,
- input: SearchContentInput
-) {
- const { query, category, tags, page, limit } = input;
- const offset = (page - 1) * limit;
-
- // Use SearchService for consistent behavior with web app
- // Follows architectural strategy: data layer -> database RPC -> DB
- const searchService = new SearchService(supabase);
-
- // Use search_content_optimized when category/tags are provided (matches API route behavior)
- // Use search_unified for simple queries without filters
- const hasFilters = category || (tags && tags.length > 0);
-
- let results: (UnifiedSearchResult | ContentSearchResult)[] = [];
- let total = 0;
-
- if (hasFilters) {
- // Use search_content_optimized for filtered searches (matches API route 'content' search type)
- const contentArgs: Database['public']['Functions']['search_content_optimized']['Args'] = {
- p_query: query || '',
- p_limit: limit,
- p_offset: offset,
- p_sort: 'relevance',
- ...(category ? { p_categories: [category] } : {}),
- ...(tags && tags.length > 0 ? { p_tags: tags } : {}),
- ...(query ? { p_highlight_query: query } : {}),
- };
-
- const contentResponse = await searchService.searchContent(contentArgs);
- results = (contentResponse.data || []) as ContentSearchResult[];
- total = typeof contentResponse.total_count === 'number' ? contentResponse.total_count : results.length;
- } else {
- // Use search_unified for simple queries (matches API route 'unified' search type)
- const unifiedArgs: Database['public']['Functions']['search_unified']['Args'] = {
- p_query: query || '',
- p_entities: ['content'],
- p_limit: limit,
- p_offset: offset,
- ...(query ? { p_highlight_query: query } : {}),
- };
-
- const unifiedResponse = await searchService.searchUnified(unifiedArgs);
- results = (unifiedResponse.data || []) as UnifiedSearchResult[];
- total = typeof unifiedResponse.total_count === 'number' ? unifiedResponse.total_count : results.length;
- }
-
- if (!results || results.length === 0) {
- return {
- content: [
- {
- type: 'text' as const,
- text: 'No results found.',
- },
- ],
- _meta: {
- items: [],
- total: 0,
- page,
- limit,
- hasMore: false,
- },
- };
- }
-
- // Calculate pagination
- const hasMore = results.length === limit && (total === 0 || (page * limit) < total);
-
- // Handle empty results explicitly
- if (items.length === 0) {
- const usageHints = getSearchUsageHints(false, category);
- return {
- content: [{ type: 'text' as const, text: 'No results found.' }],
- _meta: {
- items: [],
- total: 0,
- page,
- limit,
- hasMore: false,
- usageHints,
- relatedTools: ['getSearchSuggestions', 'getSearchFacets', 'listCategories'],
- },
- };
- }
-
- // Format results - both search types have similar structure
- const formattedItems = results.map((item) => {
- const originalDescription = item.description || '';
- const truncatedDescription = originalDescription.substring(0, 200);
- const wasTruncated = originalDescription.length > 200;
-
- // search_content_optimized includes author, search_unified doesn't
- const author = 'author' in item && typeof item.author === 'string'
- ? item.author
- : 'Unknown';
-
- return {
- slug: item.slug || '',
- title: item.title || '',
- category: item.category || '',
- description: truncatedDescription,
- wasTruncated,
- tags: item.tags || [],
- author,
- dateAdded: 'created_at' in item && typeof item.created_at === 'string'
- ? item.created_at
- : 'updated_at' in item && typeof item.updated_at === 'string'
- ? item.updated_at
- : '',
- };
- });
-
- // Create text summary
- const searchDesc = query ? `for "${query}"` : 'all content';
- const categoryDesc = category ? ` in ${category}` : '';
- const tagDesc = tags && tags.length > 0 ? ` with tags: ${tags.join(', ')}` : '';
-
- const textSummary = formattedItems
- .map(
- (item, idx) =>
- `${idx + 1}. ${item.title} (${item.category})\n ${item.description}${item.wasTruncated ? '...' : ''}${item.tags.length > 0 ? `\n Tags: ${item.tags.join(', ')}` : ''}`
- )
- .join('\n\n');
-
- // Calculate pagination metadata
- const totalPages = total > 0 ? Math.ceil(total / limit) : (hasMore ? page + 1 : page);
- const hasNext = hasMore;
- const hasPrev = page > 1;
-
- // Get usage hints for search results
- const usageHints = getSearchUsageHints(true, category);
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Search Results ${searchDesc}${categoryDesc}${tagDesc}:\n\nShowing ${formattedItems.length} of ${total} results (page ${page} of ${totalPages}):\n\n${textSummary}${hasMore ? '\n\n(More results available on next page)' : ''}`,
- },
- ],
- _meta: {
- items: formattedItems,
- pagination: {
- total,
- page,
- limit,
- totalPages,
- hasNext,
- hasPrev,
- hasMore,
- },
- usageHints,
- relatedTools: ['getContentDetail', 'downloadContentForPlatform', 'getRelatedContent', 'getContentByTag'],
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/social-proof.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/social-proof.ts
deleted file mode 100644
index 807d985d4..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/social-proof.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * getSocialProofStats Tool Handler
- *
- * Get community statistics including top contributors, recent submissions,
- * success rate, and total user count. Provides social proof data for engagement.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-
-/**
- * Fetches social proof statistics from the community.
- *
- * @param supabase - Authenticated Supabase client (should use admin/service role for stats)
- * @returns Social proof stats including contributors, submissions, success rate, and total users
- * @throws If database queries fail
- */
-export async function handleGetSocialProofStats(
- supabase: SupabaseClient
-) {
- try {
- // Calculate date ranges
- const weekAgo = new Date();
- weekAgo.setDate(weekAgo.getDate() - 7);
- const monthAgo = new Date();
- monthAgo.setDate(monthAgo.getDate() - 30);
-
- // Execute all queries in parallel
- const [recentResult, monthResult, contentResult] = await Promise.allSettled([
- supabase
- .from('content_submissions')
- .select('id, status, created_at, author')
- .gte('created_at', weekAgo.toISOString())
- .order('created_at', { ascending: false }),
- supabase
- .from('content_submissions')
- .select('status')
- .gte('created_at', monthAgo.toISOString()),
- supabase.from('content').select('id', { count: 'exact', head: true }),
- ]);
-
- // Extract results and handle errors
- interface SubmissionRow {
- author: null | string;
- created_at: string;
- id: string;
- status: string;
- }
- interface StatusRow {
- status: string;
- }
-
- let recentSubmissions: null | SubmissionRow[] = null;
- let submissionsError: unknown = null;
- if (recentResult.status === 'fulfilled') {
- const response = recentResult.value as { data: null | SubmissionRow[]; error: unknown };
- recentSubmissions = response.data;
- submissionsError = response.error ?? null;
- } else {
- submissionsError = recentResult.reason;
- }
-
- if (submissionsError !== null && submissionsError !== undefined) {
- await logError('Failed to fetch recent submissions', {
- error: submissionsError,
- });
- }
-
- let monthSubmissions: null | StatusRow[] = null;
- let monthError: unknown = null;
- if (monthResult.status === 'fulfilled') {
- const response = monthResult.value as { data: null | StatusRow[]; error: unknown };
- monthSubmissions = response.data;
- monthError = response.error ?? null;
- } else {
- monthError = monthResult.reason;
- }
-
- if (monthError !== null && monthError !== undefined) {
- await logError('Failed to fetch month submissions', {
- error: monthError,
- });
- }
-
- let contentCount: null | number = null;
- let contentError: unknown = null;
- if (contentResult.status === 'fulfilled') {
- const response = contentResult.value as { count: null | number; error: unknown };
- contentCount = response.count;
- contentError = response.error ?? null;
- } else {
- contentError = contentResult.reason;
- }
-
- if (contentError !== null && contentError !== undefined) {
- await logError('Failed to fetch content count', {
- error: contentError,
- });
- }
-
- // Calculate stats
- const submissionCount = recentSubmissions?.length ?? 0;
- const total = monthSubmissions?.length ?? 0;
- const approved = monthSubmissions?.filter((s) => s.status === 'merged').length ?? 0;
- const successRate = total > 0 ? Math.round((approved / total) * 100) : null;
-
- // Get top contributors this week (unique authors with most submissions)
- const contributorCounts = new Map();
- if (recentSubmissions) {
- for (const sub of recentSubmissions) {
- if (sub.author) {
- contributorCounts.set(sub.author, (contributorCounts.get(sub.author) ?? 0) + 1);
- }
- }
- }
-
- const topContributors = [...contributorCounts.entries()]
- .toSorted((a, b) => b[1] - a[1])
- .slice(0, 5)
- .map(([name]) => {
- // Defensively extract username: handle both email and non-email formats
- const atIndex = name.indexOf('@');
- if (atIndex !== -1) {
- // Email format: extract username part before '@'
- return name.slice(0, Math.max(0, atIndex));
- }
- // Non-email format: return trimmed original name
- return name.trim();
- });
-
- const totalUsers = contentCount ?? null;
- const timestamp = new Date().toISOString();
-
- // Create text summary
- const textSummary = `**Community Statistics**\n\n` +
- `**Top Contributors (This Week):**\n${topContributors.length > 0 ? topContributors.map((name, i) => `${i + 1}. ${name}`).join('\n') : 'No contributors this week'}\n\n` +
- `**Recent Activity:**\n` +
- `- Submissions (past 7 days): ${submissionCount}\n` +
- `- Success Rate (past 30 days): ${successRate !== null ? `${successRate}%` : 'N/A'}\n` +
- `- Total Content Items: ${totalUsers !== null ? totalUsers : 'N/A'}\n\n` +
- `*Last updated: ${new Date(timestamp).toLocaleString()}*`;
-
- return {
- content: [
- {
- type: 'text' as const,
- text: textSummary,
- },
- ],
- _meta: {
- stats: {
- contributors: {
- count: topContributors.length,
- names: topContributors,
- },
- submissions: submissionCount,
- successRate,
- totalUsers,
- },
- timestamp,
- },
- };
- } catch (error) {
- await logError('Social proof stats generation failed', {}, error);
- throw new Error(`Failed to generate social proof stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/submit-content.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/submit-content.ts
deleted file mode 100644
index 163311b10..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/submit-content.ts
+++ /dev/null
@@ -1,276 +0,0 @@
-/**
- * submitContent Tool Handler
- *
- * Guides users through content submission using MCP elicitation.
- * Collects submission data step-by-step and provides submission instructions.
- *
- * Note: Actual submission requires authentication via the web interface.
- * This tool collects data and provides instructions/URLs for submission.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import { getEnvVar } from '@heyclaude/shared-runtime/env.ts';
-import { McpErrorCode, createErrorResponse } from '../lib/errors.ts';
-import { sanitizeString, isValidSlug, isValidUrl } from '../lib/utils.ts';
-import type { SubmitContentInput } from '../lib/types.ts';
-
-const APP_URL = getEnvVar('APP_URL') || 'https://claudepro.directory';
-
-/**
- * Validates submission type
- */
-function isValidSubmissionType(type: string): type is Database['public']['Enums']['submission_type'] {
- const validTypes: Database['public']['Enums']['submission_type'][] = [
- 'agents',
- 'mcp',
- 'rules',
- 'commands',
- 'hooks',
- 'statuslines',
- 'skills',
- ];
- return validTypes.includes(type as Database['public']['Enums']['submission_type']);
-}
-
-/**
- * Validates category
- */
-function isValidCategory(category: string): category is Database['public']['Enums']['content_category'] {
- const validCategories: Database['public']['Enums']['content_category'][] = [
- 'agents',
- 'mcp',
- 'rules',
- 'commands',
- 'hooks',
- 'statuslines',
- 'skills',
- 'collections',
- 'guides',
- 'jobs',
- 'changelog',
- ];
- return validCategories.includes(category as Database['public']['Enums']['content_category']);
-}
-
-/**
- * Generates submission URL with pre-filled data
- */
-function generateSubmissionUrl(submissionData: SubmitContentInput): string {
- const url = new URL(`${APP_URL}/submit`);
-
- // Add basic parameters that can be pre-filled
- if (submissionData.submission_type) {
- url.searchParams.set('type', submissionData.submission_type);
- }
- if (submissionData.name) {
- url.searchParams.set('name', submissionData.name);
- }
-
- return url.toString();
-}
-
-/**
- * Formats submission data for display
- */
-function formatSubmissionData(data: SubmitContentInput, sanitized: {
- name?: string;
- description?: string;
- author?: string;
- authorProfileUrl?: string;
- githubUrl?: string;
-}): string {
- const sections: string[] = [];
-
- sections.push('## Submission Data Summary\n');
- sections.push(`**Type:** ${data.submission_type || 'Not specified'}`);
- sections.push(`**Category:** ${data.category || 'Not specified'}`);
- sections.push(`**Name:** ${sanitized.name || 'Not specified'}`);
- sections.push(`**Author:** ${sanitized.author || 'Not specified'}`);
-
- if (sanitized.description) {
- sections.push(`\n**Description:**\n${sanitized.description}`);
- }
-
- if (data.tags && data.tags.length > 0) {
- const sanitizedTags = data.tags.map(tag => sanitizeString(tag)).filter(Boolean);
- if (sanitizedTags.length > 0) {
- sections.push(`\n**Tags:** ${sanitizedTags.join(', ')}`);
- }
- }
-
- if (sanitized.authorProfileUrl) {
- sections.push(`\n**Author Profile:** ${sanitized.authorProfileUrl}`);
- }
-
- if (sanitized.githubUrl) {
- sections.push(`\n**GitHub URL:** ${sanitized.githubUrl}`);
- }
-
- if (data.content_data && typeof data.content_data === 'object') {
- sections.push('\n**Content Data:**');
- sections.push('```json');
- sections.push(JSON.stringify(data.content_data, null, 2));
- sections.push('```');
- }
-
- return sections.join('\n');
-}
-
-/**
- * Guides users through content submission
- *
- * This tool uses MCP elicitation to collect submission data step-by-step,
- * then provides instructions and URLs for completing the submission via the web interface.
- *
- * @param supabase - Authenticated Supabase client (not used but kept for consistency)
- * @param input - Tool input with submission data (may be partial for elicitation)
- * @returns Submission instructions and pre-filled URL
- * @throws If validation fails
- */
-export async function handleSubmitContent(
- supabase: SupabaseClient,
- input: SubmitContentInput
-) {
- // Sanitize string inputs
- const sanitizedName = input.name ? sanitizeString(input.name) : undefined;
- const sanitizedDescription = input.description ? sanitizeString(input.description) : undefined;
- const sanitizedAuthor = input.author ? sanitizeString(input.author) : undefined;
- const sanitizedAuthorProfileUrl = input.author_profile_url ? sanitizeString(input.author_profile_url) : undefined;
- const sanitizedGithubUrl = input.github_url ? sanitizeString(input.github_url) : undefined;
-
- // Validate submission type if provided
- if (input.submission_type && !isValidSubmissionType(input.submission_type)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_SUBMISSION_TYPE,
- `Invalid submission_type: ${input.submission_type}. Valid types: agents, mcp, rules, commands, hooks, statuslines, skills`
- );
- throw new Error(error.message);
- }
-
- // Validate category if provided
- if (input.category && !isValidCategory(input.category)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_CATEGORY,
- `Invalid category: ${input.category}. Valid categories: agents, mcp, rules, commands, hooks, statuslines, skills, collections, guides, jobs, changelog`
- );
- throw new Error(error.message);
- }
-
- // Validate URLs if provided
- if (sanitizedAuthorProfileUrl && !isValidUrl(sanitizedAuthorProfileUrl)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_INPUT,
- `Invalid author_profile_url: ${sanitizedAuthorProfileUrl}`
- );
- throw new Error(error.message);
- }
-
- if (sanitizedGithubUrl && !isValidUrl(sanitizedGithubUrl)) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_INPUT,
- `Invalid github_url: ${sanitizedGithubUrl}`
- );
- throw new Error(error.message);
- }
-
- // Validate name if provided (should be valid slug-like)
- if (sanitizedName && sanitizedName.length > 200) {
- const error = createErrorResponse(
- McpErrorCode.INVALID_INPUT,
- 'Name must be 200 characters or less'
- );
- throw new Error(error.message);
- }
-
- // Check if we have minimum required data (use sanitized values)
- const hasMinimumData = input.submission_type && sanitizedName && sanitizedDescription && sanitizedAuthor;
-
- // Generate submission URL (use sanitized name if available)
- const submissionUrl = generateSubmissionUrl({
- ...input,
- name: sanitizedName || input.name,
- });
-
- // Build response
- const instructions: string[] = [];
-
- if (hasMinimumData) {
- instructions.push('## Content Submission Ready\n');
- instructions.push('Your submission data has been collected. Here\'s how to complete your submission:\n');
- instructions.push('### Step 1: Visit Submission Page\n');
- instructions.push(`Visit: ${submissionUrl}\n`);
- instructions.push('### Step 2: Sign In (Required)\n');
- instructions.push('You must be signed in to submit content. If you don\'t have an account:');
- instructions.push('- Use the `createAccount` tool to create one');
- instructions.push('- Or visit the website and sign in with GitHub, Google, or Discord\n');
- instructions.push('### Step 3: Complete Submission\n');
- instructions.push('The submission form will be pre-filled with your data. Review and submit.\n');
- instructions.push('### Your Submission Data\n');
- instructions.push(formatSubmissionData(input, {
- name: sanitizedName,
- description: sanitizedDescription,
- author: sanitizedAuthor,
- authorProfileUrl: sanitizedAuthorProfileUrl,
- githubUrl: sanitizedGithubUrl,
- }));
- } else {
- instructions.push('## Content Submission Guide\n');
- instructions.push('To submit content to Claude Pro Directory, I need to collect some information.\n');
- instructions.push('### Required Information\n');
- instructions.push('- **Submission Type**: agents, mcp, rules, commands, hooks, statuslines, or skills');
- instructions.push('- **Category**: Content category (usually matches submission type)');
- instructions.push('- **Name**: Title of your content');
- instructions.push('- **Description**: Brief description of your content');
- instructions.push('- **Author**: Your name or handle');
- instructions.push('- **Content Data**: The actual content (varies by type)\n');
- instructions.push('### Optional Information\n');
- instructions.push('- **Tags**: Array of relevant tags');
- instructions.push('- **Author Profile URL**: Link to your profile');
- instructions.push('- **GitHub URL**: Link to GitHub repository (if applicable)\n');
- instructions.push('### Next Steps\n');
- instructions.push('I\'ll ask you for this information step-by-step using MCP elicitation.\n');
- instructions.push('Once I have all the required data, I\'ll provide you with:');
- instructions.push('- A pre-filled submission URL');
- instructions.push('- Step-by-step instructions');
- instructions.push('- Your complete submission data for review\n');
- instructions.push('### Alternative: Use Web Form\n');
- instructions.push(`You can also submit directly via the web form: ${APP_URL}/submit`);
- instructions.push('The web form provides dynamic validation and a better submission experience.');
- }
-
- const instructionsText = instructions.join('\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: instructionsText,
- },
- ],
- _meta: {
- submissionUrl,
- hasMinimumData,
- submissionType: input.submission_type || null,
- category: input.category || null,
- name: sanitizedName || null,
- author: sanitizedAuthor || null,
- appUrl: APP_URL,
- webFormUrl: `${APP_URL}/submit`,
- nextSteps: hasMinimumData
- ? [
- 'Visit the submission URL',
- 'Sign in to your account',
- 'Review and submit your content',
- ]
- : [
- 'Provide submission type',
- 'Provide name and description',
- 'Provide author information',
- 'Provide content data',
- 'Complete submission via web form',
- ],
- },
- };
-}
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/tags.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/tags.ts
deleted file mode 100644
index cd396b278..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/tags.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * getContentByTag Tool Handler
- *
- * Get content filtered by specific tags with AND/OR logic support.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import type { GetContentByTagInput } from '../lib/types.ts';
-
-type ContentPaginatedItem = Database['public']['CompositeTypes']['content_paginated_item'];
-
-/**
- * Fetches content matching the provided tags (with optional AND/OR logic and category) and returns a human-readable summary plus structured metadata.
- *
- * @param input - Query options:
- * - `tags`: Array of tag strings to match.
- * - `logic`: `'AND'` to require all tags or any other value to use OR semantics.
- * - `category`: Optional category to filter results.
- * - `limit`: Maximum number of items to retrieve.
- * @returns An object containing:
- * - `content`: An array with a single text block summarizing the found items and their matching tags.
- * - `_meta`: Structured metadata including `items` (formatted results with slug, title, category, truncated description, tags, matchingTags, author, dateAdded), `tags`, `logic`, `category` (or `'all'`), and `count` (number of returned items).
- */
-export async function handleGetContentByTag(
- supabase: SupabaseClient,
- input: GetContentByTagInput
-) {
- const { tags, logic, category, limit } = input;
-
- // Use get_content_paginated with proper parameters
- const rpcArgs = {
- ...(category ? { p_category: category } : {}),
- p_tags: tags, // Pass tags array
- p_order_by: 'created_at',
- p_order_direction: 'desc',
- p_limit: limit,
- p_offset: 0,
- };
- const { data, error } = await supabase.rpc('get_content_paginated', rpcArgs);
-
- if (error) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('RPC call failed in getContentByTag', {
- dbQuery: {
- rpcName: 'get_content_paginated',
- args: rpcArgs, // Will be redacted by Pino's redact config
- },
- }, error);
- throw new Error(`Failed to fetch content by tags: ${error.message}`);
- }
-
- // Extract items from paginated result
- const items = data?.items || [];
-
- if (items.length === 0) {
- const categoryDesc = category ? ` in ${category}` : '';
- return {
- content: [
- {
- type: 'text' as const,
- text: `No content found with tags: ${tags.join(', ')}${categoryDesc}`,
- },
- ],
- _meta: {
- items: [],
- tags,
- logic,
- category: category || 'all',
- count: 0,
- },
- };
- }
-
- // Filter by logic (AND vs OR)
- // Note: RPC uses OR logic for tags, so AND filtering is done client-side.
- // For large datasets, consider RPC-level AND support.
- let filteredItems = items;
-
- if (logic === 'AND') {
- // For AND logic, only include items that have ALL tags
- filteredItems = items.filter((item: ContentPaginatedItem) => {
- const itemTags = item.tags || [];
- return tags.every((tag) => itemTags.includes(tag));
- });
-
- if (filteredItems.length === 0) {
- const categoryDesc = category ? ` in ${category}` : '';
- return {
- content: [
- {
- type: 'text' as const,
- text: `No content found with ALL tags: ${tags.join(', ')}${categoryDesc}`,
- },
- ],
- _meta: {
- items: [],
- tags,
- logic,
- category: category || 'all',
- count: 0,
- },
- };
- }
- }
-
- // Format results
- const formattedItems = filteredItems.map((item: ContentPaginatedItem) => {
- const itemTags = item.tags || [];
- const matchingTags = itemTags.filter((tag: string) => tags.includes(tag));
-
- return {
- slug: item.slug || '',
- title: item.title || item.display_title || '',
- category: item.category || '',
- description: item.description?.substring(0, 150) || '',
- tags: itemTags,
- matchingTags,
- author: item.author || 'Unknown',
- dateAdded: item.date_added || '',
- };
- });
-
- // Create text summary
- const logicDesc = logic === 'AND' ? 'ALL' : 'ANY';
- const categoryDesc = category ? ` in ${category}` : '';
- const textSummary = formattedItems
- .map(
- (
- item: { title: string; category: string; description: string; matchingTags: string[] },
- idx: number
- ) =>
- `${idx + 1}. ${item.title} (${item.category})\n ${item.description}${item.description.length >= 150 ? '...' : ''}\n Matching tags: ${item.matchingTags.join(', ')}`
- )
- .join('\n\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Content with ${logicDesc} of tags: ${tags.join(', ')}${categoryDesc}\n\nFound ${formattedItems.length} items:\n\n${textSummary}`,
- },
- ],
- _meta: {
- items: formattedItems,
- tags,
- logic,
- category: category || 'all',
- count: formattedItems.length,
- limit,
- pagination: {
- total: formattedItems.length,
- limit,
- hasMore: false, // Tags doesn't support pagination (uses limit only)
- },
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/templates.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/templates.ts
deleted file mode 100644
index f92a6b329..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/templates.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * getTemplates Tool Handler
- *
- * Get submission templates for creating new content.
- * Uses the get_content_templates RPC.
- */
-
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import { logError } from '@heyclaude/shared-runtime/logging.ts';
-import type { GetTemplatesInput } from '../lib/types.ts';
-
-type ContentTemplatesItem = Database['public']['CompositeTypes']['content_templates_item'];
-type TemplateField = { name: string; description?: string; type?: string };
-
-/**
- * Fetches content submission templates from the database, normalizes their shape,
- * and returns a human-readable text summary together with structured template metadata.
- *
- * @param input - Input containing optional `category` to filter templates; when omitted the RPC defaults to `'agents'`
- * @returns An object with:
- * - `content`: an array containing a single text element with a header and per-template summary (name, category, description, and fields),
- * - `_meta`: an object with `templates` (the normalized templates array) and `count` (number of templates).
- * If no templates are found, `content` contains a friendly "no templates configured" message, `_meta.templates` is an empty array, and `count` is 0.
- */
-export async function handleGetTemplates(
- supabase: SupabaseClient,
- input: GetTemplatesInput
-) {
- const { category } = input;
-
- // Call the RPC to get content templates
- // Note: get_content_templates requires p_category, so we use a default if not provided
- const rpcArgs = {
- p_category: category || 'agents', // Default to 'agents' if not provided
- };
- const { data, error } = await supabase.rpc('get_content_templates', rpcArgs);
-
- if (error) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('RPC call failed in getTemplates', {
- dbQuery: {
- rpcName: 'get_content_templates',
- args: rpcArgs, // Will be redacted by Pino's redact config
- },
- }, error);
- throw new Error(`Failed to fetch templates: ${error.message}`);
- }
-
- // get_content_templates returns {templates: {...}} where templates is a JSON object
- // Extract the templates object
- const templatesData = data?.templates || data || {};
-
- // Check if templates is an object or already an array
- let templatesArray: Array> = [];
-
- if (Array.isArray(templatesData)) {
- templatesArray = templatesData as ContentTemplatesItem[];
- } else if (typeof templatesData === 'object' && templatesData !== null) {
- // Convert object to array of entries
- templatesArray = Object.entries(templatesData as Record).map(
- ([key, value]: [string, unknown]) => ({
- category: key,
- ...(typeof value === 'object' && value !== null ? value : {}),
- })
- );
- }
-
- if (templatesArray.length === 0) {
- const categoryMsg = category ? ` for ${category}` : '';
- return {
- content: [
- {
- type: 'text' as const,
- text: `No templates configured${categoryMsg}. Templates are used for content submission.`,
- },
- ],
- _meta: {
- templates: [],
- count: 0,
- },
- };
- }
-
- // Format templates
- const templates = templatesArray.map(
- (template: ContentTemplatesItem | Record) => {
- const templateObj = template as Record;
- return {
- category: typeof templateObj['category'] === 'string' ? templateObj['category'] : '',
- name:
- typeof templateObj['template_name'] === 'string'
- ? templateObj['template_name']
- : typeof templateObj['name'] === 'string'
- ? templateObj['name']
- : typeof templateObj['category'] === 'string'
- ? templateObj['category']
- : '',
- description:
- typeof templateObj['description'] === 'string' ? templateObj['description'] : '',
- fields: (Array.isArray(templateObj['fields'])
- ? templateObj['fields']
- : []) as TemplateField[],
- requiredFields: (Array.isArray(templateObj['required_fields'])
- ? templateObj['required_fields']
- : Array.isArray(templateObj['requiredFields'])
- ? templateObj['requiredFields']
- : []) as string[],
- examples: Array.isArray(templateObj['examples']) ? templateObj['examples'] : [],
- };
- }
- );
-
- // Create text summary
- const textSummary = templates
- .map(
- (template: {
- name: string;
- category: string;
- description: string;
- fields: TemplateField[];
- requiredFields: string[];
- }) => {
- const fieldsText = template.fields
- .map((field: TemplateField) => {
- const required = template.requiredFields.includes(field.name)
- ? '(required)'
- : '(optional)';
- return ` - ${field.name} ${required}: ${field.description || field.type || ''}`;
- })
- .join('\n');
-
- return `## ${template.name} (${template.category})\n${template.description}\n\n### Fields:\n${fieldsText}`;
- }
- )
- .join('\n\n');
-
- const categoryDesc = category ? ` for ${category}` : ' for all categories';
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Content Submission Templates${categoryDesc}:\n\n${textSummary}`,
- },
- ],
- _meta: {
- templates,
- count: templates.length,
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/heyclaude-mcp/routes/trending.ts b/apps/edge/supabase/functions/heyclaude-mcp/routes/trending.ts
deleted file mode 100644
index 47078d41c..000000000
--- a/apps/edge/supabase/functions/heyclaude-mcp/routes/trending.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * getTrending Tool Handler
- *
- * Get trending content across categories or within a specific category.
- * Uses TrendingService.getTrendingContent for consistent behavior with web app.
- */
-
-import { TrendingService } from '@heyclaude/data-layer/services/trending.ts';
-import type { Database } from '@heyclaude/database-types';
-import type { SupabaseClient } from '@supabase/supabase-js';
-import type { GetTrendingInput } from '../lib/types.ts';
-
-/**
- * Fetches trending content (optionally filtered by category) and returns a formatted text summary plus metadata.
- *
- * @param input - Query options; may include `category` to filter results and `limit` to bound the number of items returned
- * @returns An object with:
- * - `content`: an array with a single text item summarizing the trending results or a no-content message,
- * - `_meta.items`: an array of formatted items (`slug`, `title`, `category`, `description` trimmed to 150 characters, `tags`, `author`, `views`, `dateAdded`),
- * - `_meta.category`: the requested category or `'all'`,
- * - `_meta.count` (when items exist): the number of returned items
- */
-export async function handleGetTrending(
- supabase: SupabaseClient,
- input: GetTrendingInput
-) {
- const { category, limit } = input;
-
- // Use TrendingService for consistent behavior with web app
- const trendingService = new TrendingService(supabase);
- const data = await trendingService.getTrendingContent({
- ...(category ? { p_category: category } : {}),
- p_limit: limit,
- });
-
- if (!data || data.length === 0) {
- const categoryMsg = category ? ` in ${category}` : '';
- return {
- content: [
- {
- type: 'text' as const,
- text: `No trending content found${categoryMsg}.`,
- },
- ],
- _meta: {
- items: [],
- category: category || 'all',
- count: 0,
- },
- };
- }
-
- // Format the results
- const items = data.map((item) => ({
- slug: item.slug,
- title: item.title || item.display_title,
- category: item.category,
- description: item.description?.substring(0, 150) || '',
- tags: item.tags || [],
- author: item.author || 'Unknown',
- views: item.view_count || 0,
- dateAdded: item.date_added,
- }));
-
- // Create text summary
- const categoryDesc = category ? ` in ${category}` : ' across all categories';
- const textSummary = items
- .map(
- (item, idx) =>
- `${idx + 1}. ${item.title} (${item.category})\n ${item.description}${item.description.length >= 150 ? '...' : ''}\n Views: ${item.views} | Tags: ${item.tags.slice(0, 3).join(', ')}`
- )
- .join('\n\n');
-
- return {
- content: [
- {
- type: 'text' as const,
- text: `Trending Content${categoryDesc}:\n\n${textSummary}`,
- },
- ],
- _meta: {
- items,
- category: category || 'all',
- count: items.length,
- limit,
- pagination: {
- total: items.length,
- limit,
- hasMore: false, // Trending doesn't support pagination
- },
- },
- };
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/deno.json b/apps/edge/supabase/functions/public-api/deno.json
deleted file mode 100644
index 4631990d2..000000000
--- a/apps/edge/supabase/functions/public-api/deno.json
+++ /dev/null
@@ -1,48 +0,0 @@
-{
- "nodeModulesDir": "auto",
- "imports": {
- "@heyclaude/database-types": "../../../../../packages/database-types/src/index.ts",
- "@heyclaude/shared-runtime/": "../../../../../packages/shared-runtime/src/",
- "@heyclaude/edge-runtime/": "../../../../../packages/edge-runtime/src/",
- "@heyclaude/data-layer/": "../../../../../packages/data-layer/src/",
- "@supabase/supabase-js": "npm:@supabase/supabase-js@2.86.0",
- "@supabase/supabase-js/": "npm:@supabase/supabase-js@2.86.0/",
- "react": "npm:react@18.3.1",
- "yoga-layout": "npm:yoga-layout@3.2.1",
- "https://esm.sh/yoga-layout@3.2.1": "npm:yoga-layout@3.2.1",
- "https://esm.sh/yoga-layout@3.2.1/": "npm:yoga-layout@3.2.1/",
- "@imagemagick/magick-wasm": "npm:@imagemagick/magick-wasm@0.0.30",
- "pino": "npm:pino@10.1.0",
- "zod": "npm:zod@3.24.1",
- "sanitize-html": "npm:sanitize-html@2.17.0"
- },
- "lint": {
- "include": ["**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules/**", "deno.lock"],
- "rules": {
- "tags": ["recommended"],
- "exclude": ["no-var", "no-explicit-any"]
- }
- },
- "compilerOptions": {
- "lib": ["deno.ns", "deno.unstable", "dom"],
- "types": ["@heyclaude/edge-runtime/deno-globals.d.ts", "@heyclaude/edge-runtime/jsx-types.d.ts", "../../tsconfig-setup.d.ts"],
- "strict": true,
- "noImplicitAny": true,
- "strictNullChecks": true,
- "strictFunctionTypes": true,
- "strictPropertyInitialization": true,
- "noImplicitThis": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noImplicitReturns": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
- "exactOptionalPropertyTypes": true,
- "noPropertyAccessFromIndexSignature": true,
- "noImplicitOverride": true,
- "allowUnusedLabels": false,
- "allowUnreachableCode": false,
- "skipLibCheck": true
- }
-}
diff --git a/apps/edge/supabase/functions/public-api/index.ts b/apps/edge/supabase/functions/public-api/index.ts
deleted file mode 100644
index e5983cd81..000000000
--- a/apps/edge/supabase/functions/public-api/index.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-/**
- * Public API - Main entry point for public API edge function
- */
-
-import { analytics } from '@heyclaude/edge-runtime/middleware/analytics.ts';
-import { buildStandardContext, type StandardContext } from '@heyclaude/edge-runtime/utils/context.ts';
-import { chain } from '@heyclaude/edge-runtime/middleware/chain.ts';
-import type { Handler } from '@heyclaude/edge-runtime/middleware/types.ts';
-import type { HttpMethod } from '@heyclaude/edge-runtime/utils/router.ts';
-import { jsonResponse } from '@heyclaude/edge-runtime/utils/http.ts';
-import { rateLimit } from '@heyclaude/edge-runtime/middleware/rate-limit.ts';
-import { serveEdgeApp } from '@heyclaude/edge-runtime/app.ts';
-import { checkRateLimit, RATE_LIMIT_PRESETS } from '@heyclaude/shared-runtime/rate-limit.ts';
-import { createDataApiContext } from '@heyclaude/shared-runtime/logging.ts';
-import { handleGeneratePackage } from './routes/content-generate/index.ts';
-import { handlePackageGenerationQueue } from './routes/content-generate/queue-worker.ts';
-import { handleUploadPackage } from './routes/content-generate/upload.ts';
-import { handleEmbeddingGenerationQueue, handleEmbeddingWebhook } from './routes/embedding/index.ts';
-import { handleImageGenerationQueue } from './routes/image-generation/index.ts';
-import { handleTransformImageRoute } from './routes/transform/index.ts';
-
-import { ROUTES } from './routes.config.ts';
-
-/**
- * CORS headers for public API endpoints
- * Includes GET, POST, OPTIONS to support both read and write operations
- */
-const PUBLIC_API_CORS = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type, X-Email-Action, Authorization',
-};
-
-const BASE_CORS = PUBLIC_API_CORS;
-const PUBLIC_API_APP_LABEL = 'public-api';
-const analyticsPublic = (routeName: string) => analytics(routeName, { app: PUBLIC_API_APP_LABEL });
-const createPublicApiContext = (
- route: string,
- options?: { path?: string; method?: string; resource?: string }
-) => createDataApiContext(route, { ...options, app: PUBLIC_API_APP_LABEL });
-
-// Use StandardContext directly as it matches our needs
-type PublicApiContext = StandardContext;
-
-/**
- * Enforces a rate limit for the incoming request and returns either a 429 error or the handler's response augmented with rate-limit headers.
- *
- * @param ctx - The request context used to evaluate the rate limit
- * @param preset - The rate limit preset to apply for this request
- * @param handler - Function invoked when the request is allowed; its response will be returned with rate-limit headers
- * @returns A Response. If the request exceeds the limit, a 429 JSON response with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers is returned; otherwise the handler's response is returned augmented with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers.
- */
-async function withRateLimit(
- ctx: PublicApiContext,
- preset: (typeof RATE_LIMIT_PRESETS)[keyof typeof RATE_LIMIT_PRESETS],
- handler: () => Promise
-): Promise {
- const rateLimitResult = checkRateLimit(ctx.request, preset);
- if (!rateLimitResult.allowed) {
- return jsonResponse(
- {
- error: 'Too Many Requests',
- message: 'Rate limit exceeded',
- retryAfter: rateLimitResult.retryAfter,
- },
- 429,
- BASE_CORS,
- {
- 'Retry-After': String(rateLimitResult.retryAfter ?? 60),
- 'X-RateLimit-Limit': String(preset.maxRequests),
- 'X-RateLimit-Remaining': String(rateLimitResult.remaining),
- 'X-RateLimit-Reset': String(rateLimitResult.resetAt),
- }
- );
- }
-
- const response = await handler();
- const headers = new Headers(response.headers);
- headers.set('X-RateLimit-Limit', String(preset.maxRequests));
- headers.set('X-RateLimit-Remaining', String(rateLimitResult.remaining));
- headers.set('X-RateLimit-Reset', String(rateLimitResult.resetAt));
- return new Response(response.body, { status: response.status, headers });
-}
-
-// Define handlers map
-const ROUTE_HANDLERS: Record Promise> = {
- 'transform-image': (ctx) =>
- handleTransformImageRoute({
- request: ctx.request,
- pathname: ctx.pathname,
- method: ctx.method,
- segments: ctx.segments,
- }),
- 'content-generate': (ctx) => {
- // segments[0] = 'content', segments[1] = 'generate-package', segments[2] = sub-route
- const subRoute = ctx.segments[2];
- if (subRoute === 'upload') {
- return withRateLimit(ctx, RATE_LIMIT_PRESETS.heavy, () => {
- const logContext = createPublicApiContext('content-generate-upload', {
- path: ctx.pathname,
- });
- return handleUploadPackage(ctx.request, logContext);
- });
- }
- if (subRoute === 'process') {
- return withRateLimit(ctx, RATE_LIMIT_PRESETS.heavy, () => {
- const logContext = createPublicApiContext('content-generate-process', {
- path: ctx.pathname,
- });
- return handlePackageGenerationQueue(ctx.request, logContext);
- });
- }
- return withRateLimit(ctx, RATE_LIMIT_PRESETS.heavy, () => {
- const logContext = createPublicApiContext('content-generate', {
- path: ctx.pathname,
- method: ctx.method,
- resource: 'generate-package',
- });
- return handleGeneratePackage(ctx.request, logContext);
- });
- },
- // Queue processing routes (migrated from flux-station)
- 'embedding-process': (ctx) => handleEmbeddingGenerationQueue(ctx.request),
- 'embedding-webhook': (ctx) => handleEmbeddingWebhook(ctx.request),
- 'image-generation-process': (ctx) => handleImageGenerationQueue(ctx.request),
-};
-
-/**
- * Create a matcher that determines whether a request path begins with a given segment pattern.
- *
- * Leading and trailing slashes in `pathPattern` are ignored; an empty pattern matches only when the request has no segments.
- *
- * @param pathPattern - Route pattern using `/`-separated segments (for example "search/auto" or "sitemap")
- * @returns `true` if `ctx.segments` begins with the pattern's segments (segment-wise prefix), `false` otherwise
- */
-function createPathMatcher(pathPattern: string) {
- const parts = pathPattern.split('/').filter(Boolean);
- return (ctx: PublicApiContext) => {
- if (parts.length === 0) return ctx.segments.length === 0;
- // Exact match for specified segments, allowing trailing segments (prefix match)
- if (ctx.segments.length < parts.length) return false;
- for (let i = 0; i < parts.length; i++) {
- if (ctx.segments[i] !== parts[i]) return false;
- }
- return true;
- };
-}
-
-// Custom matchers for special cases
-const CUSTOM_MATCHERS: Record boolean> = {};
-
-serveEdgeApp({
- buildContext: (request) =>
- buildStandardContext(request, ['/functions/v1/public-api', '/public-api']),
- defaultCors: BASE_CORS,
- onNoMatch: (ctx) =>
- jsonResponse(
- {
- error: 'Not Found',
- message: 'Unknown data resource',
- path: ctx.pathname,
- },
- 404,
- BASE_CORS
- ),
- routes: ROUTES.map((route) => {
- const handler = ROUTE_HANDLERS[route.name];
- if (!handler) throw new Error(`Missing handler for route: ${route.name}`);
-
- let chainHandler: Handler = handler;
-
- // Apply rate limit if configured
- if (route.rateLimit) {
- chainHandler = chain(rateLimit(route.rateLimit))(chainHandler);
- }
-
- // Apply analytics
- chainHandler = chain(analyticsPublic(route.analytics || route.name))(
- chainHandler
- );
-
- return {
- name: route.name,
- methods: route.methods as readonly HttpMethod[],
- cors: BASE_CORS,
- match: CUSTOM_MATCHERS[route.name] || createPathMatcher(route.path),
- handler: chainHandler,
- };
- }),
-});
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes.config.ts b/apps/edge/supabase/functions/public-api/routes.config.ts
deleted file mode 100644
index 0a120de65..000000000
--- a/apps/edge/supabase/functions/public-api/routes.config.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-// -----------------------------------------------------------------------------
-// Edge Route Configuration
-// -----------------------------------------------------------------------------
-
-export interface RouteConfig {
- name: string;
- path: string; // Path pattern (e.g. "/", "/status", "/search/autocomplete")
- methods: string[];
- handler: {
- import: string; // Path relative to public-api/index.ts
- function: string;
- };
- analytics?: string; // Defaults to name
- rateLimit?: 'public' | 'heavy' | 'indexnow';
-}
-
-export const ROUTES: RouteConfig[] = [
- {
- name: 'transform-image',
- path: '/transform/image',
- methods: ['GET', 'HEAD', 'POST', 'OPTIONS'],
- handler: { import: './routes/transform/index.ts', function: 'handleTransformImageRoute' },
- },
- // Complex nested routes like content-generate need careful handling in the generator
- // or manual override support. For now, we'll use path prefixes.
- {
- name: 'content-generate',
- path: '/content/generate-package',
- methods: ['POST', 'OPTIONS'],
- handler: { import: './routes/content-generate/index.ts', function: 'handleGeneratePackage' },
- },
- // Queue processing routes (migrated from flux-station)
- {
- name: 'embedding-process',
- path: '/embedding/process',
- methods: ['POST', 'OPTIONS'],
- handler: { import: './routes/embedding/index.ts', function: 'handleEmbeddingGenerationQueue' },
- rateLimit: 'heavy',
- },
- {
- name: 'embedding-webhook',
- path: '/embedding/webhook',
- methods: ['POST', 'OPTIONS'],
- handler: { import: './routes/embedding/index.ts', function: 'handleEmbeddingWebhook' },
- rateLimit: 'public',
- },
- {
- name: 'image-generation-process',
- path: '/image-generation/process',
- methods: ['POST', 'OPTIONS'],
- handler: { import: './routes/image-generation/index.ts', function: 'handleImageGenerationQueue' },
- rateLimit: 'heavy',
- },
-];
diff --git a/apps/edge/supabase/functions/public-api/routes/content-generate/generators/mcp.ts b/apps/edge/supabase/functions/public-api/routes/content-generate/generators/mcp.ts
deleted file mode 100644
index 9113b35d7..000000000
--- a/apps/edge/supabase/functions/public-api/routes/content-generate/generators/mcp.ts
+++ /dev/null
@@ -1,834 +0,0 @@
-/**
- * MCP .mcpb Package Generator
- *
- * Generates one-click installer .mcpb packages for MCP servers.
- * Uses shared storage and database utilities from data-api.
- */
-
-import type { Database as DatabaseGenerated } from '@heyclaude/database-types';
-import { supabaseServiceRole } from '@heyclaude/edge-runtime/clients/supabase.ts';
-import { getStorageServiceClient } from '@heyclaude/edge-runtime/utils/storage/client.ts';
-import { uploadObject } from '@heyclaude/edge-runtime/utils/storage/upload.ts';
-import type { ContentRow, GenerateResult, PackageGenerator } from '../types.ts';
-
-type McpRow = ContentRow & { category: 'mcp' };
-
-// MCP metadata is stored as Json type in database, validated at runtime
-
-/**
- * Type for user config entry in manifest
- */
-type UserConfigEntry = {
- type: string;
- title: string;
- description: string;
- required: boolean;
- sensitive: boolean;
-};
-
-/**
- * Builds a manifest template placeholder in the form `${variable}`.
- *
- * @param variable - The template variable name to embed
- * @returns The placeholder string formatted as `${variable}`
- */
-function createTemplateVar(variable: string): string {
- return `\${${variable}}`;
-}
-
-/**
- * Produce a string safe for embedding in a single-quoted JavaScript string literal.
- *
- * @param s - The input string to escape
- * @returns The input with backslashes, single quotes, dollar signs (`$`), and backticks escaped
- */
-function escapeForSingleQuotedLiteral(s: string): string {
- // Escape backslashes, then single quotes, then template literal chars ($ and `)
- // just in case it ends up inside a template literal too
- return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$').replace(/`/g, '\\`');
-}
-
-/**
- * Retrieve a named own property value from an unknown value if it is a plain object.
- *
- * @param obj - The value to inspect for the property
- * @param key - The property name to read
- * @returns The property's value if `obj` is a non-null object and has an own property `key`, `undefined` otherwise
- */
-function getProperty(obj: unknown, key: string): unknown {
- if (typeof obj !== 'object' || obj === null) {
- return undefined;
- }
- const desc = Object.getOwnPropertyDescriptor(obj, key);
- return desc ? desc.value : undefined;
-}
-
-/**
- * Retrieve a string property from an unknown object if present.
- *
- * @param obj - The value to read the property from
- * @param key - The property name to retrieve
- * @returns The string value of `key` if present and of type `string`, `undefined` otherwise
- */
-function getStringProperty(obj: unknown, key: string): string | undefined {
- const value = getProperty(obj, key);
- return typeof value === 'string' ? value : undefined;
-}
-
-/**
- * Builds user_config entries for the MCP manifest based on the MCP's metadata.
- *
- * If `metadata.requires_auth` is true, returns a single API key entry whose key name is derived from the MCP slug (falling back to `api_key`) and whose title/description include the MCP title or slug. Otherwise returns an empty object.
- *
- * @param mcp - The MCP content row whose metadata, slug, and title are used to derive user_config entries
- * @returns A map of `UserConfigEntry` objects keyed by their environment variable name; empty when no user config is required
- */
-function extractUserConfig(mcp: McpRow): Record {
- const userConfig: Record = {};
-
- const metadata = mcp.metadata;
- if (!metadata || typeof metadata !== 'object') {
- return userConfig;
- }
-
- // Check if requires_auth is true
- const requiresAuthDesc = Object.getOwnPropertyDescriptor(metadata, 'requires_auth');
- const requiresAuth = requiresAuthDesc && requiresAuthDesc.value === true;
-
- if (requiresAuth) {
- // Extract common API key patterns from metadata
- const rawServerName = mcp.slug.replace(/-mcp-server$/, '').replace(/-mcp$/, '');
- const serverName = rawServerName.trim();
- const apiKeyName = serverName ? `${serverName}_api_key` : 'api_key';
-
- userConfig[apiKeyName] = {
- type: 'string',
- title: `${mcp.title || mcp.slug} API Key`,
- description: `API key or token for ${mcp.title || mcp.slug}`,
- required: true,
- sensitive: true,
- };
- }
-
- return userConfig;
-}
-
-/**
- * Build an MCP .mcpb manifest (v0.2) for the provided MCP content row.
- *
- * @param mcp - The MCP content row whose metadata, slug, title, description, and author populate manifest fields
- * @returns The manifest.json content as a pretty-printed JSON string conforming to the .mcpb v0.2 specification
- */
-function generateManifest(mcp: McpRow): string {
- const metadata = mcp.metadata;
- const config =
- metadata && typeof metadata === 'object' ? getProperty(metadata, 'configuration') : undefined;
- const claudeDesktop =
- config && typeof config === 'object' ? getProperty(config, 'claudeDesktop') : undefined;
- const mcpConfig =
- claudeDesktop && typeof claudeDesktop === 'object'
- ? getProperty(claudeDesktop, 'mcp')
- : undefined;
-
- const configObj =
- mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) ? mcpConfig : undefined;
- const serverName = configObj ? Object.keys(configObj)[0] || mcp.slug : mcp.slug;
- const serverConfig =
- configObj && typeof configObj === 'object' ? getProperty(configObj, serverName) : undefined;
- const httpUrl =
- serverConfig && typeof serverConfig === 'object'
- ? getStringProperty(serverConfig, 'url')
- : undefined;
-
- const userConfig = extractUserConfig(mcp);
-
- // Determine server type - always node for now (HTTP proxy or stdio)
- const serverType = 'node';
-
- const manifest = {
- manifest_version: '0.2',
- name: mcp.slug,
- version: '1.0.0',
- description: mcp.description || `MCP server: ${mcp.title || mcp.slug}`,
- author: {
- name: mcp.author || 'HeyClaud',
- },
- server: {
- type: serverType,
- entry_point: 'server/index.js',
- mcp_config: httpUrl
- ? {
- command: 'node',
- args: [`${createTemplateVar('__dirname')}/server/index.js`],
- env: Object.keys(userConfig).reduce>((acc, key) => {
- acc[key.toUpperCase()] = createTemplateVar(`user_config.${key}`);
- return acc;
- }, {}),
- }
- : {
- command: 'node',
- args: [`${createTemplateVar('__dirname')}/server/index.js`],
- env: {},
- },
- },
- ...(Object.keys(userConfig).length > 0 && { user_config: userConfig }),
- compatibility: {
- claude_desktop: '>=1.0.0',
- platforms: ['darwin', 'win32', 'linux'],
- runtimes: {
- // Uses global `fetch`, which is reliably available in Node >=18
- node: '>=18.0.0',
- },
- },
- };
-
- return JSON.stringify(manifest, null, 2);
-}
-
-/**
- * Generates the Node.js server entry file (server/index.js) for an MCP package.
- *
- * Produces an HTTP-to-stdio proxy bridge when the MCP metadata specifies a remote HTTP endpoint,
- * otherwise produces a minimal stdio-based MCP server placeholder.
- *
- * @param mcp - The MCP content row; used to read metadata (title, description, slug, and configuration) that determine server type and embedded values.
- * @returns The generated source code for server/index.js as a string.
- */
-function generateServerIndex(mcp: McpRow): string {
- const metadata = mcp.metadata;
- const config =
- metadata && typeof metadata === 'object' ? getProperty(metadata, 'configuration') : undefined;
- const claudeDesktop =
- config && typeof config === 'object' ? getProperty(config, 'claudeDesktop') : undefined;
- const mcpConfig =
- claudeDesktop && typeof claudeDesktop === 'object'
- ? getProperty(claudeDesktop, 'mcp')
- : undefined;
-
- const configObj =
- mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) ? mcpConfig : undefined;
- const serverName = configObj ? Object.keys(configObj)[0] || mcp.slug : mcp.slug;
- const serverConfig =
- configObj && typeof configObj === 'object' ? getProperty(configObj, serverName) : undefined;
- const httpUrl =
- serverConfig && typeof serverConfig === 'object'
- ? getStringProperty(serverConfig, 'url')
- : undefined;
-
- const title = escapeForSingleQuotedLiteral(mcp.title || mcp.slug);
- const description = escapeForSingleQuotedLiteral(mcp.description || '');
- const slug = escapeForSingleQuotedLiteral(mcp.slug);
-
- // For HTTP-based servers, create a proxy bridge
- if (httpUrl && typeof httpUrl === 'string') {
- // Use JSON.stringify to safe-guard URL injection
- // This handles quotes, backslashes, and other special chars correctly
- const escapedHttpUrl = JSON.stringify(httpUrl);
-
- return `#!/usr/bin/env node
-/**
- * MCP Server Proxy: ${title}
- * ${description}
- *
- * HTTP-to-stdio proxy bridge for Claude Desktop integration.
- * Converts stdio MCP protocol to HTTP requests to the remote server.
- */
-
-import { Server } from '@modelcontextprotocol/sdk/server/index.js';
-import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
-
-const server = new Server(
- {
- name: '${slug}',
- version: '1.0.0',
- },
- {
- capabilities: {
- tools: {},
- resources: {},
- prompts: {},
- },
- }
-);
-
-// HTTP proxy bridge: Forward MCP requests to remote HTTP endpoint
-const HTTP_ENDPOINT = ${escapedHttpUrl};
-
-// Proxy tools list request
-server.setRequestHandler('tools/list', async () => {
- try {
- const response = await fetch(\`\${HTTP_ENDPOINT}/tools/list\`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({}),
- });
- if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
- return await response.json();
- } catch (error) {
- console.error('Proxy error (tools/list):', error);
- return { tools: [] };
- }
-});
-
-// Proxy tool call request
-server.setRequestHandler('tools/call', async (request) => {
- try {
- const response = await fetch(\`\${HTTP_ENDPOINT}/tools/call\`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(request.params),
- });
- if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
- return await response.json();
- } catch (error) {
- console.error('Proxy error (tools/call):', error);
- throw error;
- }
-});
-
-// Proxy resources list request
-server.setRequestHandler('resources/list', async () => {
- try {
- const response = await fetch(\`\${HTTP_ENDPOINT}/resources/list\`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({}),
- });
- if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
- return await response.json();
- } catch (error) {
- console.error('Proxy error (resources/list):', error);
- return { resources: [] };
- }
-});
-
-// Proxy resource read request
-server.setRequestHandler('resources/read', async (request) => {
- try {
- const response = await fetch(\`\${HTTP_ENDPOINT}/resources/read\`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(request.params),
- });
- if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
- return await response.json();
- } catch (error) {
- console.error('Proxy error (resources/read):', error);
- throw error;
- }
-});
-
-// Proxy prompts list request
-server.setRequestHandler('prompts/list', async () => {
- try {
- const response = await fetch(\`\${HTTP_ENDPOINT}/prompts/list\`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({}),
- });
- if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
- return await response.json();
- } catch (error) {
- console.error('Proxy error (prompts/list):', error);
- return { prompts: [] };
- }
-});
-
-async function main() {
- const transport = new StdioServerTransport();
- await server.connect(transport);
- console.error('${slug} MCP server proxy running on stdio -> ' + ${escapedHttpUrl});
-}
-
-main().catch((error) => {
- console.error('Server error:', error);
- process.exit(1);
-});
-`;
- }
-
- // For stdio-based servers, generate minimal server
- return `#!/usr/bin/env node
-/**
- * MCP Server: ${title}
- * ${description}
- *
- * Stdio-based MCP server implementation.
- * This server communicates via stdio transport for Claude Desktop integration.
- *
- * Note: This is a minimal placeholder. The actual server implementation
- * should be provided by the MCP server author or installed as a dependency.
- */
-
-import { Server } from '@modelcontextprotocol/sdk/server/index.js';
-import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
-
-const server = new Server(
- {
- name: '${slug}',
- version: '1.0.0',
- },
- {
- capabilities: {
- tools: {},
- resources: {},
- prompts: {},
- },
- }
-);
-
-// Register tools, resources, and prompts based on MCP server capabilities
-// This is a placeholder - actual implementation should be provided by the server author
-// Example implementation:
-// server.setRequestHandler('tools/list', async () => ({
-// tools: [
-// {
-// name: 'example-tool',
-// description: 'Example tool',
-// inputSchema: { type: 'object', properties: {} },
-// },
-// ],
-// }));
-
-async function main() {
- const transport = new StdioServerTransport();
- await server.connect(transport);
- console.error('${slug} MCP server running on stdio');
-}
-
-main().catch((error) => {
- console.error('Server error:', error);
- process.exit(1);
-});
-`;
-}
-
-/**
- * Create the contents of a package.json for an MCP Node.js package.
- *
- * @param mcp - MCP row whose `slug`, `description`, and `title` are used to populate the package fields
- * @returns A pretty-printed JSON string for package.json including `name`, `version`, `description`, `type`, `main`, and `dependencies`
- */
-function generatePackageJson(mcp: McpRow): string {
- const packageJson = {
- name: mcp.slug,
- version: '1.0.0',
- description: mcp.description || `MCP server: ${mcp.title || mcp.slug}`,
- type: 'module',
- main: 'server/index.js',
- dependencies: {
- '@modelcontextprotocol/sdk': '^1.22.0',
- },
- };
-
- return JSON.stringify(packageJson, null, 2);
-}
-
-/**
- * Generates README.md content for an MCP package using the MCP's title, description, configuration flag, and documentation URL.
- *
- * @param mcp - MCP content row; uses `title`, `description`, `metadata.configuration`, and `documentation_url` to populate sections
- * @returns The generated README.md content
- */
-function generateReadme(mcp: McpRow): string {
- return `# ${mcp.title || mcp.slug}
-
-${mcp.description || ''}
-
-## Installation
-
-1. Download the .mcpb file
-2. Double-click to install in Claude Desktop
-3. Enter your API key when prompted
-4. Restart Claude Desktop
-
-## Configuration
-
-${
- mcp.metadata && typeof mcp.metadata === 'object' && 'configuration' in mcp.metadata
- ? 'See Claude Desktop configuration for setup details.'
- : 'No additional configuration required.'
-}
-
-## Documentation
-
-${mcp.documentation_url ? `[View Documentation](${mcp.documentation_url})` : ''}
-
----
-Generated by HeyClaud
-`;
-}
-
-/**
- * Assembles a .mcpb package containing manifest, server index, package.json, and README into a ZIP-formatted byte array.
- *
- * @param manifest - The text content for `manifest.json`
- * @param serverIndex - The text content for `server/index.js`
- * @param packageJson - The text content for `package.json`
- * @param readme - The text content for `README.md`
- * @returns A `Uint8Array` containing the ZIP-formatted `.mcpb` package
- */
-function createMcpbPackage(
- manifest: string,
- serverIndex: string,
- packageJson: string,
- readme: string
-): Uint8Array {
- // Create ZIP with multiple files
- // For simplicity, we'll create a basic ZIP structure
- // A proper ZIP library would be better, but this works for now
-
- const encoder = new TextEncoder();
- const files = [
- { name: 'manifest.json', content: encoder.encode(manifest) },
- { name: 'server/index.js', content: encoder.encode(serverIndex) },
- { name: 'package.json', content: encoder.encode(packageJson) },
- { name: 'README.md', content: encoder.encode(readme) },
- ];
-
- const dosTime = dateToDosTime(new Date());
- const dosDate = dateToDosDate(new Date());
-
- // Build ZIP structure
- const parts: Uint8Array[] = [];
- const centralDir: Uint8Array[] = [];
- let offset = 0;
-
- for (const file of files) {
- const crc = crc32(file.content);
- // Local File Header
- const localHeader = createZipLocalFileHeader(
- file.name,
- file.content.length,
- dosTime,
- dosDate,
- crc
- );
- parts.push(localHeader);
- offset += localHeader.length;
-
- // File Data
- parts.push(file.content);
- offset += file.content.length;
-
- // Central Directory Entry
- const cdEntry = createZipCentralDirEntry(
- file.name,
- file.content.length,
- offset - file.content.length - localHeader.length,
- dosTime,
- dosDate,
- crc
- );
- centralDir.push(cdEntry);
- }
-
- const centralDirSize = centralDir.reduce((sum, entry) => sum + entry.length, 0);
- const centralDirOffset = offset;
-
- // Add Central Directory
- parts.push(...centralDir);
- offset += centralDirSize;
-
- // End of Central Directory
- const eocd = createZipEocd(centralDirOffset, centralDirSize, files.length);
- parts.push(eocd);
-
- // Combine all parts
- const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
- const zipBuffer = new Uint8Array(totalLength);
- let currentOffset = 0;
- for (const part of parts) {
- zipBuffer.set(part, currentOffset);
- currentOffset += part.length;
- }
-
- return zipBuffer;
-}
-
-/**
- * Builds a ZIP local file header for a single file entry.
- *
- * @param fileName - The file name to include in the header (UTF-8)
- * @param fileSize - The file's size in bytes (used for both compressed and uncompressed sizes)
- * @param dosTime - The last modification time encoded in DOS time format
- * @param dosDate - The last modification date encoded in DOS date format
- * @param crc - The CRC-32 checksum of the file data
- * @returns A `Uint8Array` containing the local file header followed by the file name bytes
- */
-function createZipLocalFileHeader(
- fileName: string,
- fileSize: number,
- dosTime: number,
- dosDate: number,
- crc: number
-): Uint8Array {
- const header = new Uint8Array(30 + fileName.length);
- const view = new DataView(header.buffer);
-
- view.setUint32(0, 0x04034b50, true); // Local file header signature
- view.setUint16(4, 20, true); // Version needed
- view.setUint16(6, 0, true); // General purpose bit flag
- view.setUint16(8, 0, true); // Compression method (0 = stored)
- view.setUint16(10, dosTime, true); // Last mod time
- view.setUint16(12, dosDate, true); // Last mod date
- view.setUint32(14, crc, true); // CRC-32
- view.setUint32(18, fileSize, true); // Compressed size
- view.setUint32(22, fileSize, true); // Uncompressed size
- view.setUint16(26, fileName.length, true); // File name length
- view.setUint16(28, 0, true); // Extra field length
-
- new TextEncoder().encodeInto(fileName, header.subarray(30));
- return header;
-}
-
-/**
- * Builds a ZIP central directory entry for a single file.
- *
- * @param fileName - The file name to store in the central directory entry.
- * @param fileSize - The file size in bytes (used for both compressed and uncompressed size fields).
- * @param localHeaderOffset - The relative offset (from start of archive) to the file's local header.
- * @param dosTime - The last-modified time encoded in DOS time format.
- * @param dosDate - The last-modified date encoded in DOS date format.
- * @param crc - The CRC-32 checksum of the file data.
- * @returns A Uint8Array containing the binary central directory entry for the specified file.
- */
-function createZipCentralDirEntry(
- fileName: string,
- fileSize: number,
- localHeaderOffset: number,
- dosTime: number,
- dosDate: number,
- crc: number
-): Uint8Array {
- const entry = new Uint8Array(46 + fileName.length);
- const view = new DataView(entry.buffer);
-
- view.setUint32(0, 0x02014b50, true); // Central file header signature
- view.setUint16(4, 20, true); // Version made by
- view.setUint16(6, 20, true); // Version needed
- view.setUint16(8, 0, true); // General purpose bit flag
- view.setUint16(10, 0, true); // Compression method
- view.setUint16(12, dosTime, true); // Last mod time
- view.setUint16(14, dosDate, true); // Last mod date
- view.setUint32(16, crc, true); // CRC-32
- view.setUint32(20, fileSize, true); // Compressed size
- view.setUint32(24, fileSize, true); // Uncompressed size
- view.setUint16(28, fileName.length, true); // File name length
- view.setUint16(30, 0, true); // Extra field length
- view.setUint16(32, 0, true); // File comment length
- view.setUint16(34, 0, true); // Disk number start
- view.setUint16(36, 0, true); // Internal file attributes
- view.setUint32(38, 0, true); // External file attributes
- view.setUint32(42, localHeaderOffset, true); // Relative offset of local header
-
- new TextEncoder().encodeInto(fileName, entry.subarray(46));
- return entry;
-}
-
-/**
- * Compute the CRC-32 checksum of a byte array.
- *
- * @param data - The input bytes to checksum
- * @returns The CRC-32 checksum of `data` as an unsigned 32-bit integer
- */
-function crc32(data: Uint8Array): number {
- let crc = 0xffffffff;
- for (const byte of data) {
- crc ^= byte;
- for (let j = 0; j < 8; j++) {
- crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
- }
- }
- return (crc ^ 0xffffffff) >>> 0;
-}
-
-/**
- * Builds the ZIP End of Central Directory (EOCD) record.
- *
- * @param centralDirOffset - Byte offset where the central directory starts within the archive
- * @param centralDirSize - Size in bytes of the central directory
- * @param entryCount - Number of entries contained in the central directory
- * @returns A 22-byte `Uint8Array` containing the EOCD record ready to append to the ZIP archive
- */
-function createZipEocd(
- centralDirOffset: number,
- centralDirSize: number,
- entryCount: number
-): Uint8Array {
- const eocd = new Uint8Array(22);
- const view = new DataView(eocd.buffer);
-
- view.setUint32(0, 0x06054b50, true); // End of central dir signature
- view.setUint16(4, 0, true); // Number of this disk
- view.setUint16(6, 0, true); // Number of disk with start of central directory
- view.setUint16(8, entryCount, true); // Total entries in central dir on this disk
- view.setUint16(10, entryCount, true); // Total entries in central directory
- view.setUint32(12, centralDirSize, true); // Size of central directory
- view.setUint32(16, centralDirOffset, true); // Offset of start of central directory
- view.setUint16(20, 0, true); // ZIP file comment length
-
- return eocd;
-}
-
-/**
- * Encode a Date's time component into 16-bit MS-DOS time format.
- *
- * @param date - The date whose local time will be encoded
- * @returns A 16-bit DOS time value: hours in bits 11–15, minutes in bits 5–10, and seconds/2 in bits 0–4
- */
-function dateToDosTime(date: Date): number {
- const hour = date.getHours();
- const minute = date.getMinutes();
- const second = date.getSeconds();
- return (hour << 11) | (minute << 5) | (second >> 1);
-}
-
-/**
- * Converts a Date into a 16-bit DOS date value.
- *
- * @param date - Date to convert
- * @returns A 16-bit DOS date where bits encode year since 1980 (bits 15–9), month (bits 8–5), and day (bits 4–0)
- */
-function dateToDosDate(date: Date): number {
- const year = date.getFullYear() - 1980;
- const month = date.getMonth() + 1;
- const day = date.getDate();
- return (year << 9) | (month << 5) | day;
-}
-
-export class McpGenerator implements PackageGenerator {
- canGenerate(content: ContentRow): boolean {
- return content.category === 'mcp' && content.slug != null && content.slug.trim().length > 0;
- }
-
- async generate(content: ContentRow): Promise {
- if (!content.slug) {
- throw new Error('MCP server slug is required');
- }
-
- // Validate content is MCP category
- if (content.category !== 'mcp') {
- throw new Error('Content must be MCP category');
- }
- // After validation, content.category is narrowed to 'mcp', so we can use it as McpRow
- const mcp: McpRow = {
- ...content,
- category: 'mcp',
- };
-
- // 1. Compute content hash FIRST to check if regeneration is needed
- // Generate manifest temporarily to compute hash (matches build script logic)
- const manifestForHash = generateManifest(mcp);
- const packageContent = JSON.stringify({
- manifest: manifestForHash,
- metadata: mcp.metadata,
- description: mcp.description,
- title: mcp.title,
- });
- const contentHash = await computeContentHash(packageContent);
-
- // 2. Check if package already exists and content hasn't changed
- // Skip generation if hash matches and storage URL exists (optimization)
- if (
- mcp.mcpb_build_hash === contentHash &&
- mcp.mcpb_storage_url &&
- mcp.mcpb_storage_url.trim().length > 0
- ) {
- // Package is up to date, return existing storage URL
- return {
- storageUrl: mcp.mcpb_storage_url,
- metadata: {
- file_size_kb: '0', // Unknown, but package exists
- package_type: 'mcpb',
- build_hash: contentHash,
- skipped: true,
- reason: 'content_unchanged',
- },
- };
- }
-
- // 3. Generate package files (content changed or package missing)
- const manifest = generateManifest(mcp);
- const serverIndex = generateServerIndex(mcp);
- const packageJson = generatePackageJson(mcp);
- const readme = generateReadme(mcp);
-
- // 4. Create .mcpb package (ZIP file)
- const mcpbBuffer = await createMcpbPackage(manifest, serverIndex, packageJson, readme);
- const fileSizeKB = (mcpbBuffer.length / 1024).toFixed(2);
-
- // 5. Upload to Supabase Storage using shared utility
- const fileName = `packages/${mcp.slug}.mcpb`;
- // Convert Uint8Array to ArrayBuffer for upload
- // Create a new ArrayBuffer to ensure type compatibility (ArrayBufferLike includes SharedArrayBuffer)
- // Copy the buffer to ensure we have a proper ArrayBuffer, not SharedArrayBuffer
- const arrayBuffer =
- mcpbBuffer.buffer instanceof ArrayBuffer
- ? mcpbBuffer.buffer
- : (new Uint8Array(mcpbBuffer).buffer as ArrayBuffer);
- const uploadResult = await uploadObject({
- bucket: this.getBucketName(),
- buffer: arrayBuffer,
- mimeType: 'application/zip',
- objectPath: fileName,
- cacheControl: '3600',
- upsert: true,
- client: getStorageServiceClient(),
- });
-
- if (!(uploadResult.success && uploadResult.publicUrl)) {
- throw new Error(uploadResult['error'] || 'Failed to upload .mcpb package to storage');
- }
-
- // 6. Update database with storage URL and build metadata
- // Use updateTable helper to properly handle extended Database type
- const updateData = {
- mcpb_storage_url: uploadResult.publicUrl,
- mcpb_build_hash: contentHash,
- mcpb_last_built_at: new Date().toISOString(),
- } satisfies DatabaseGenerated['public']['Tables']['content']['Update'];
- const { error: updateError } = await supabaseServiceRole
- .from('content')
- .update(updateData)
- .eq('id', mcp.id);
-
- if (updateError) {
- throw new Error(
- `Database update failed: ${updateError instanceof Error ? updateError.message : String(updateError)}`
- );
- }
-
- return {
- storageUrl: uploadResult.publicUrl,
- metadata: {
- file_size_kb: fileSizeKB,
- package_type: 'mcpb',
- build_hash: contentHash,
- },
- };
- }
-
- getBucketName(): string {
- return 'mcpb-packages';
- }
-
- getDatabaseFields(): string[] {
- return ['mcpb_storage_url', 'mcpb_build_hash', 'mcpb_last_built_at'];
- }
-}
-
-/**
- * Produce a SHA-256 hash of the given content as a lowercase hexadecimal string.
- *
- * @param content - The input string to hash
- * @returns The SHA-256 digest of `content` encoded as a lowercase hex string
- */
-async function computeContentHash(content: string): Promise {
- const encoder = new TextEncoder();
- const data = encoder.encode(content);
- const hashBuffer = await crypto.subtle.digest('SHA-256', data);
- const hashArray = Array.from(new Uint8Array(hashBuffer));
- const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
- return hashHex;
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/content-generate/generators/skills.ts b/apps/edge/supabase/functions/public-api/routes/content-generate/generators/skills.ts
deleted file mode 100644
index a4961251a..000000000
--- a/apps/edge/supabase/functions/public-api/routes/content-generate/generators/skills.ts
+++ /dev/null
@@ -1,455 +0,0 @@
-/**
- * Skills ZIP Package Generator
- *
- * Generates Claude Desktop-compatible SKILL.md ZIP packages for skills content.
- * Uses shared storage and database utilities from data-api.
- */
-
-import type { Database as DatabaseGenerated } from '@heyclaude/database-types';
-import { supabaseServiceRole } from '@heyclaude/edge-runtime/clients/supabase.ts';
-import { getStorageServiceClient } from '@heyclaude/edge-runtime/utils/storage/client.ts';
-import { uploadObject } from '@heyclaude/edge-runtime/utils/storage/upload.ts';
-import type { ContentRow, GenerateResult, PackageGenerator } from '../types.ts';
-
-// Fixed date for deterministic ZIP output
-const FIXED_DATE = new Date('2024-01-01T00:00:00.000Z');
-
-/**
- * Compute the CRC-32 checksum for the given byte array.
- *
- * @param data - The input bytes to checksum
- * @returns The CRC-32 checksum as an unsigned 32‑bit number
- */
-function crc32(data: Uint8Array): number {
- let crc = 0xffffffff;
- for (const byte of data) {
- crc ^= byte;
- for (let j = 0; j < 8; j++) {
- crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
- }
- }
- return (crc ^ 0xffffffff) >>> 0;
-}
-
-/**
- * Convert a ContentRow skill into a SKILL.md Markdown document with YAML frontmatter and conditional sections.
- *
- * Produces Markdown that includes frontmatter (name and escaped description) followed by any of the following
- * sections when present: Content, Prerequisites, Key Features, Use Cases, Examples, Troubleshooting, and Learn More.
- *
- * @param skill - The ContentRow representing the skill and its metadata
- * @returns The generated SKILL.md content as a Markdown string
- */
-function transformSkillToMarkdown(skill: ContentRow): string {
- const frontmatter = `---
-name: ${skill.slug}
-description: ${escapeYamlString(skill.description || '')}
----`;
-
- const sections: string[] = [];
-
- if (skill.content) {
- sections.push(skill.content);
- }
-
- // Safely extract properties from metadata
- const getProperty = (obj: unknown, key: string): unknown => {
- if (typeof obj !== 'object' || obj === null) {
- return undefined;
- }
- const desc = Object.getOwnPropertyDescriptor(obj, key);
- return desc ? desc.value : undefined;
- };
-
- const getStringArray = (value: unknown): string[] | null => {
- if (!Array.isArray(value)) {
- return null;
- }
- const result: string[] = [];
- for (const item of value) {
- if (typeof item === 'string') {
- result.push(item);
- }
- }
- return result.length > 0 ? result : null;
- };
-
- const metadata = skill.metadata;
- const metadataObj =
- metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? metadata : undefined;
-
- // Prerequisites
- const dependencies = metadataObj
- ? getStringArray(getProperty(metadataObj, 'dependencies'))
- : null;
- if (dependencies && dependencies.length > 0) {
- sections.push(`## Prerequisites\n\n${dependencies.map((d) => `- ${d}`).join('\n')}`);
- }
-
- // Features
- const features = getStringArray(skill.features);
- if (features && features.length > 0) {
- sections.push(`## Key Features\n\n${features.map((f) => `- ${f}`).join('\n')}`);
- }
-
- // Use Cases
- const useCases = getStringArray(skill.use_cases);
- if (useCases && useCases.length > 0) {
- sections.push(`## Use Cases\n\n${useCases.map((uc) => `- ${uc}`).join('\n')}`);
- }
-
- // Examples
- const examplesRaw = skill.examples;
- const examples = Array.isArray(examplesRaw)
- ? examplesRaw
- .map((ex) => {
- if (typeof ex !== 'object' || ex === null) {
- return null;
- }
- const titleDesc = Object.getOwnPropertyDescriptor(ex, 'title');
- const codeDesc = Object.getOwnPropertyDescriptor(ex, 'code');
- const languageDesc = Object.getOwnPropertyDescriptor(ex, 'language');
- const descriptionDesc = Object.getOwnPropertyDescriptor(ex, 'description');
-
- if (
- !titleDesc ||
- typeof titleDesc.value !== 'string' ||
- !codeDesc ||
- typeof codeDesc.value !== 'string' ||
- !languageDesc ||
- typeof languageDesc.value !== 'string'
- ) {
- return null;
- }
-
- const example: {
- title: string;
- code: string;
- language: string;
- description?: string;
- } = {
- title: titleDesc.value,
- code: codeDesc.value,
- language: languageDesc.value,
- };
- if (descriptionDesc && typeof descriptionDesc.value === 'string') {
- example.description = descriptionDesc.value;
- }
- return example;
- })
- .filter(
- (ex): ex is { title: string; code: string; language: string; description?: string } =>
- ex !== null
- )
- : null;
-
- if (examples && examples.length > 0) {
- const exampleBlocks = examples
- .map((ex, idx) => {
- const parts = [`### Example ${idx + 1}: ${ex.title}`];
- if (ex.description) parts.push(ex.description);
- parts.push(`\`\`\`${ex.language}\n${ex.code}\n\`\`\``);
- return parts.join('\n\n');
- })
- .join('\n\n');
- sections.push(`## Examples\n\n${exampleBlocks}`);
- }
-
- // Troubleshooting
- const troubleshootingRaw = metadataObj ? getProperty(metadataObj, 'troubleshooting') : undefined;
- const troubleshooting = Array.isArray(troubleshootingRaw)
- ? troubleshootingRaw
- .map((item) => {
- if (typeof item !== 'object' || item === null) {
- return null;
- }
- const issueDesc = Object.getOwnPropertyDescriptor(item, 'issue');
- const solutionDesc = Object.getOwnPropertyDescriptor(item, 'solution');
-
- if (
- !issueDesc ||
- typeof issueDesc.value !== 'string' ||
- !solutionDesc ||
- typeof solutionDesc.value !== 'string'
- ) {
- return null;
- }
-
- return {
- issue: issueDesc.value,
- solution: solutionDesc.value,
- };
- })
- .filter((item): item is { issue: string; solution: string } => item !== null)
- : null;
-
- if (troubleshooting && troubleshooting.length > 0) {
- const items = troubleshooting
- .map((item) => `### ${item.issue}\n\n${item.solution}`)
- .join('\n\n');
- sections.push(`## Troubleshooting\n\n${items}`);
- }
-
- // Learn More
- if (skill.documentation_url) {
- sections.push(
- `## Learn More\n\nFor additional documentation and resources, visit:\n\n${skill.documentation_url}`
- );
- }
-
- return `${frontmatter}\n\n${sections.filter(Boolean).join('\n\n')}`.trim();
-}
-
-/**
- * Escapes a string for safe inclusion in YAML frontmatter, adding double quotes only when required.
- *
- * @param str - The input string to escape for YAML
- * @returns The original `str` if no quoting is required; otherwise `str` with backslashes and double quotes escaped and wrapped in double quotes
- */
-function escapeYamlString(str: string): string {
- const needsQuoting =
- str.includes(':') ||
- str.includes('"') ||
- str.includes("'") ||
- str.includes('#') ||
- str.includes('\n');
-
- if (needsQuoting) {
- const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
- return `"${escaped}"`;
- }
-
- return str;
-}
-
-/**
- * Create a minimal ZIP archive containing a single file at `{slug}/SKILL.md` with the provided Markdown content.
- *
- * The archive uses a fixed DOS timestamp for deterministic metadata, stores the file without compression, and includes a CRC-32 checksum.
- *
- * @param slug - Directory name (slug) to use as the archive path prefix for the SKILL.md file
- * @param skillMdContent - The SKILL.md file content to include in the archive
- * @returns A Uint8Array containing the bytes of the ZIP archive with one entry: `{slug}/SKILL.md`
- */
-function generateZipBuffer(slug: string, skillMdContent: string): Uint8Array {
- // Create a minimal ZIP structure manually
- // This is a simplified implementation
-
- // Create ZIP file structure manually
- // ZIP format: [Local File Header][File Data][Central Directory][End of Central Directory]
-
- const fileName = `${slug}/SKILL.md`;
- const fileContent = new TextEncoder().encode(skillMdContent);
- const dosTime = dateToDosTime(FIXED_DATE);
- const dosDate = dateToDosDate(FIXED_DATE);
- const crc = crc32(fileContent);
-
- // Local File Header (30 bytes + filename length)
- const localFileHeader = new Uint8Array(30 + fileName.length);
- const view = new DataView(localFileHeader.buffer);
-
- // ZIP signature: 0x04034b50
- view.setUint32(0, 0x04034b50, true);
- // Version needed to extract: 20 (2.0)
- view.setUint16(4, 20, true);
- // General purpose bit flag: 0
- view.setUint16(6, 0, true);
- // Compression method: 0 (stored, no compression)
- view.setUint16(8, 0, true);
- // Last mod file time: DOS time
- view.setUint16(10, dosTime & 0xffff, true);
- // Last mod file date: DOS date
- view.setUint16(12, dosDate, true);
- // CRC-32: Calculated
- view.setUint32(14, crc, true);
- // Compressed size
- view.setUint32(18, fileContent.length, true);
- // Uncompressed size
- view.setUint32(22, fileContent.length, true);
- // File name length
- view.setUint16(26, fileName.length, true);
- // Extra field length: 0
- view.setUint16(28, 0, true);
- // File name
- new TextEncoder().encodeInto(fileName, localFileHeader.subarray(30));
-
- // Central Directory Header (46 bytes + filename length)
- const centralDirHeader = new Uint8Array(46 + fileName.length);
- const cdView = new DataView(centralDirHeader.buffer);
-
- // Central file header signature: 0x02014b50
- cdView.setUint32(0, 0x02014b50, true);
- // Version made by: 20
- cdView.setUint16(4, 20, true);
- // Version needed: 20
- cdView.setUint16(6, 20, true);
- // General purpose bit flag: 0
- cdView.setUint16(8, 0, true);
- // Compression method: 0
- cdView.setUint16(10, 0, true);
- // Last mod file time
- cdView.setUint16(12, dosTime & 0xffff, true);
- // Last mod file date
- cdView.setUint16(14, dosDate, true);
- // CRC-32: Calculated
- cdView.setUint32(16, crc, true);
- // Compressed size
- cdView.setUint32(20, fileContent.length, true);
- // Uncompressed size
- cdView.setUint32(24, fileContent.length, true);
- // File name length
- cdView.setUint16(28, fileName.length, true);
- // Extra field length: 0
- cdView.setUint16(30, 0, true);
- // File comment length: 0
- cdView.setUint16(32, 0, true);
- // Disk number start: 0
- cdView.setUint16(34, 0, true);
- // Internal file attributes: 0
- cdView.setUint16(36, 0, true);
- // External file attributes: 0
- cdView.setUint32(38, 0, true);
- // Relative offset of local header (0, since it's the first file)
- cdView.setUint32(42, 0, true);
- // File name
- new TextEncoder().encodeInto(fileName, centralDirHeader.subarray(46));
-
- // End of Central Directory Record (22 bytes)
- const eocd = new Uint8Array(22);
- const eocdView = new DataView(eocd.buffer);
-
- // End of central dir signature: 0x06054b50
- eocdView.setUint32(0, 0x06054b50, true);
- // Number of this disk: 0
- eocdView.setUint16(4, 0, true);
- // Number of disk with start of central directory: 0
- eocdView.setUint16(6, 0, true);
- // Total number of entries in central directory on this disk: 1
- eocdView.setUint16(8, 1, true);
- // Total number of entries in central directory: 1
- eocdView.setUint16(10, 1, true);
- // Size of central directory
- eocdView.setUint32(12, centralDirHeader.length, true);
- // Offset of start of central directory (after local header + file content)
- eocdView.setUint32(16, localFileHeader.length + fileContent.length, true);
- // ZIP file comment length: 0
- eocdView.setUint16(20, 0, true);
-
- // Combine all parts
- const totalLength =
- localFileHeader.length + fileContent.length + centralDirHeader.length + eocd.length;
- const zipBuffer = new Uint8Array(totalLength);
- let offset = 0;
-
- zipBuffer.set(localFileHeader, offset);
- offset += localFileHeader.length;
-
- zipBuffer.set(fileContent, offset);
- offset += fileContent.length;
-
- // Central Directory is already correctly configured with offset 0
- // EOCD is already correctly configured with offset (localHeader + content)
-
- zipBuffer.set(centralDirHeader, offset);
- offset += centralDirHeader.length;
-
- zipBuffer.set(eocd, offset);
-
- return zipBuffer;
-}
-
-/**
- * Converts a Date to a 16-bit DOS time value used in ZIP file headers.
- *
- * @param date - The date to convert
- * @returns A 16-bit DOS time value encoding hour, minute, and seconds (seconds stored as seconds/2)
- */
-function dateToDosTime(date: Date): number {
- const hour = date.getHours();
- const minute = date.getMinutes();
- const second = date.getSeconds();
- return (hour << 11) | (minute << 5) | (second >> 1);
-}
-
-/**
- * Encodes a Date into the 16-bit DOS date format used in ZIP file headers.
- *
- * @param date - The date to encode; only year, month, and day are used
- * @returns A 16-bit DOS date where bits store the year offset from 1980, the month (1–12), and the day (1–31)
- */
-function dateToDosDate(date: Date): number {
- const year = date.getFullYear() - 1980;
- const month = date.getMonth() + 1;
- const day = date.getDate();
- return (year << 9) | (month << 5) | day;
-}
-
-export class SkillsGenerator implements PackageGenerator {
- canGenerate(content: ContentRow): boolean {
- return content.category === 'skills' && content.slug != null && content.slug.trim().length > 0;
- }
-
- async generate(content: ContentRow): Promise {
- if (!content.slug) {
- throw new Error('Skill slug is required');
- }
-
- // 1. Transform skill to SKILL.md markdown
- const skillMd = transformSkillToMarkdown(content);
-
- // 2. Generate ZIP buffer
- const zipBuffer = await generateZipBuffer(content.slug, skillMd);
- const fileSizeKB = (zipBuffer.length / 1024).toFixed(2);
-
- // 3. Upload to Supabase Storage using shared utility
- const fileName = `packages/${content.slug}.zip`;
- // Convert Uint8Array to ArrayBuffer for upload
- // Create a new ArrayBuffer by copying the Uint8Array data
- const arrayBuffer = new Uint8Array(zipBuffer).buffer;
- const uploadResult = await uploadObject({
- bucket: this.getBucketName(),
- buffer: arrayBuffer,
- mimeType: 'application/zip',
- objectPath: fileName,
- cacheControl: '3600',
- upsert: true,
- client: getStorageServiceClient(),
- });
-
- if (!(uploadResult.success && uploadResult.publicUrl)) {
- throw new Error(uploadResult['error'] || 'Failed to upload skill package to storage');
- }
-
- // 4. Update database with storage URL
- // Use updateTable helper to properly handle Database type
- const updateData = {
- storage_url: uploadResult.publicUrl,
- } satisfies DatabaseGenerated['public']['Tables']['content']['Update'];
- const { error: updateError } = await supabaseServiceRole
- .from('content')
- .update(updateData)
- .eq('id', content.id);
-
- if (updateError) {
- throw new Error(
- `Database update failed: ${updateError instanceof Error ? updateError.message : String(updateError)}`
- );
- }
-
- return {
- storageUrl: uploadResult.publicUrl,
- metadata: {
- file_size_kb: fileSizeKB,
- package_type: 'skill',
- },
- };
- }
-
- getBucketName(): string {
- return 'skills';
- }
-
- getDatabaseFields(): string[] {
- return ['storage_url'];
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/content-generate/index.ts b/apps/edge/supabase/functions/public-api/routes/content-generate/index.ts
deleted file mode 100644
index bda6fe663..000000000
--- a/apps/edge/supabase/functions/public-api/routes/content-generate/index.ts
+++ /dev/null
@@ -1,327 +0,0 @@
-/**
- * Package Generation Route Handler
- *
- * Handles POST /content/generate-package requests for automatic package generation.
- * Internal-only endpoint (requires SUPABASE_SERVICE_ROLE_KEY authentication).
- *
- * Authentication:
- * - Database triggers use: Authorization: Bearer
- * - The service role key is available via SUPABASE_SERVICE_ROLE_KEY environment variable
- * - This key is automatically set by Supabase for all edge functions
- *
- * Uses extensible generator registry to support multiple content categories.
- */
-
-import { Constants, type Database as DatabaseGenerated } from '@heyclaude/database-types';
-import { badRequestResponse, errorResponse, getOnlyCorsHeaders, jsonResponse, methodNotAllowedResponse } from '@heyclaude/edge-runtime/utils/http.ts';
-import { initRequestLogging, traceRequestComplete, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { parseJsonBody } from '@heyclaude/edge-runtime/utils/parse-json-body.ts';
-import { pgmqSend } from '@heyclaude/edge-runtime/utils/pgmq-client.ts';
-import { supabaseServiceRole } from '@heyclaude/edge-runtime/clients/supabase.ts';
-import { buildSecurityHeaders } from '@heyclaude/shared-runtime/security-headers.ts';
-import { logError, logInfo, logger } from '@heyclaude/shared-runtime/logging.ts';
-import { timingSafeEqual } from '@heyclaude/shared-runtime/crypto-utils.ts';
-import { getGenerator, getSupportedCategories, isCategorySupported } from './registry.ts';
-import type { GeneratePackageRequest, GeneratePackageResponse } from './types.ts';
-
-// Use generated type directly from database
-type ContentRow = DatabaseGenerated['public']['Tables']['content']['Row'];
-
-const CORS = getOnlyCorsHeaders;
-
-/**
- * Handle incoming requests to generate a content package for a given content ID and category.
- *
- * @param logContext - Optional request-level logging context (will be created automatically if omitted)
- * @returns An HTTP Response representing the result of the request — e.g. 200 for successful synchronous generation, 202 when queued for asynchronous processing, or an appropriate error status (400, 401, 404, 405, 500) with an explanatory payload.
- */
-export async function handleGeneratePackage(
- request: Request,
- logContext?: Record
-): Promise {
- // Create log context if not provided
- const finalLogContext = logContext || {
- function: 'content-generate',
- action: 'generate-package',
- request_id: crypto.randomUUID(),
- started_at: new Date().toISOString(),
- };
-
- // Initialize request logging with trace and bindings
- initRequestLogging(finalLogContext);
- traceStep('Package generation request received', finalLogContext);
-
- // Only allow POST
- if (request.method === 'OPTIONS') {
- return new Response(null, {
- status: 204,
- headers: {
- ...buildSecurityHeaders(),
- ...CORS,
- },
- });
- }
-
- if (request.method !== 'POST') {
- return methodNotAllowedResponse('POST', CORS);
- }
-
- // Set bindings for this request
- logger.setBindings({
- requestId: typeof finalLogContext['request_id'] === 'string' ? finalLogContext['request_id'] : undefined,
- operation: typeof finalLogContext['action'] === 'string' ? finalLogContext['action'] : 'generate-package',
- method: request.method,
- });
-
- // Authenticate: Internal-only endpoint (requires service role key)
- // Database triggers and internal services use SUPABASE_SERVICE_ROLE_KEY
- const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
- if (!serviceRoleKey) {
- if (logContext) {
- await logError('SUPABASE_SERVICE_ROLE_KEY not configured', logContext);
- }
- return jsonResponse(
- {
- error: 'Internal Server Error',
- message: 'Service role key not configured',
- },
- 500,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- // Accept Authorization: Bearer header
- // Database triggers use: Authorization: Bearer
- // Use case-insensitive matching per OAuth2 spec (RFC 6750)
- const authHeader = request.headers.get('Authorization');
- const providedKey = authHeader?.replace(/^Bearer /i, '').trim();
-
- // Use timing-safe comparison to prevent timing attacks
- if (!providedKey || !timingSafeEqual(providedKey, serviceRoleKey)) {
- if (logContext) {
- logInfo('Unauthorized package generation request', {
- ...logContext,
- hasAuthHeader: !!authHeader,
- hasKey: !!providedKey,
- });
- }
- return jsonResponse(
- {
- error: 'Unauthorized',
- message: 'Invalid or missing service role key. This endpoint is for internal use only.',
- },
- 401,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- // Parse request body
- const parseResult = await parseJsonBody(request, {
- maxSize: 10 * 1024, // 10KB max (just JSON with IDs)
- cors: CORS,
- });
-
- if (!parseResult.success) {
- return parseResult.response;
- }
-
- const { content_id, category } = parseResult.data;
-
- // Validate required fields
- if (!content_id || typeof content_id !== 'string') {
- return badRequestResponse('content_id is required and must be a string', CORS);
- }
-
- if (!category || typeof category !== 'string') {
- return badRequestResponse('category is required and must be a string', CORS);
- }
-
- /**
- * Check whether a value is one of the `content_category` enum values.
- *
- * @param value - The value to validate as a `content_category`
- * @returns `true` if `value` matches a `content_category` enum value, `false` otherwise.
- */
- function isValidContentCategory(
- value: unknown
- ): value is DatabaseGenerated['public']['Enums']['content_category'] {
- if (typeof value !== 'string') {
- return false;
- }
- // Use enum values directly from @heyclaude/database-types Constants
- return Constants.public.Enums.content_category.includes(
- value as DatabaseGenerated['public']['Enums']['content_category']
- );
- }
-
- if (!isValidContentCategory(category)) {
- return badRequestResponse(`Category '${category}' is not a valid content category`, CORS);
- }
-
- // Check if category is supported
- if (!isCategorySupported(category)) {
- return badRequestResponse(
- `Category '${category}' does not support package generation. Supported categories: ${getSupportedCategories().join(', ')}`,
- CORS
- );
- }
-
- // Get generator for category
- const generator = getGenerator(category);
- if (!generator) {
- return await errorResponse(
- new Error(`Generator not found for category '${category}'`),
- 'content-generate:getGenerator',
- CORS,
- logContext
- );
- }
-
- // Fetch content from database
- const { data: content, error: fetchError } = await supabaseServiceRole
- .from('content')
- .select('*')
- .eq('id', content_id)
- .single();
-
- if (fetchError || !content) {
- if (logContext) {
- await logError('Content not found', logContext, fetchError);
- }
- return jsonResponse(
- {
- error: 'Not Found',
- message: `Content with ID '${content_id}' not found`,
- content_id,
- },
- 404,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- // Content is guaranteed to be non-null after the check above
- // Use satisfies to ensure type correctness without assertion
- const contentRow = content satisfies ContentRow;
-
- // Validate content can be generated
- if (!generator.canGenerate(contentRow)) {
- return badRequestResponse(
- `Content '${content_id}' cannot be generated. Missing required fields or invalid category.`,
- CORS
- );
- }
-
- // Check if async mode is requested (via query parameter or header)
- const url = new URL(request.url);
- const asyncParam = url.searchParams.get('async') === 'true';
- const asyncHeader = request.headers.get('X-Async-Mode') === 'true';
- const asyncMode = asyncParam || asyncHeader;
-
- // Async mode: Enqueue to queue and return immediately
- if (asyncMode) {
- try {
- if (logContext) {
- logInfo('Enqueuing package generation', {
- ...logContext,
- content_id,
- category,
- slug: contentRow.slug,
- });
- }
-
- await pgmqSend('package_generation', {
- content_id,
- category,
- slug: contentRow.slug || '',
- created_at: new Date().toISOString(),
- });
-
- const response: GeneratePackageResponse = {
- success: true,
- content_id,
- category,
- slug: contentRow.slug || '',
- storage_url: null, // Will be populated when generation completes
- message: 'Package generation queued successfully',
- };
-
- if (logContext) {
- logInfo('Package generation queued', {
- ...logContext,
- content_id,
- category,
- slug: contentRow.slug,
- });
- }
-
- return jsonResponse(response, 202, {
- // 202 Accepted (async processing)
- ...CORS,
- ...buildSecurityHeaders(),
- 'X-Generated-By': `data-api:content-generate:${category}`,
- 'X-Processing-Mode': 'async',
- });
- } catch (error) {
- // Log error details server-side (not exposed to users)
- if (logContext) {
- await logError('Failed to enqueue package generation', logContext, error);
- }
-
- // errorResponse accepts optional logContext, so no need for conditional
- return await errorResponse(error, 'data-api:content-generate-enqueue', CORS, logContext);
- }
- }
-
- // Sync mode: Generate immediately (for manual triggers or testing)
- try {
- if (logContext) {
- logInfo('Generating package (sync mode)', {
- ...logContext,
- content_id,
- category,
- slug: contentRow.slug,
- });
- }
-
- const result = await generator.generate(contentRow);
-
- const response: GeneratePackageResponse = {
- success: true,
- content_id,
- category,
- slug: contentRow.slug || '',
- storage_url: result.storageUrl,
- ...(result.metadata !== undefined ? { metadata: result.metadata } : {}),
- message: 'Package generated successfully',
- };
-
- logInfo('Package generated successfully', {
- ...finalLogContext,
- content_id,
- category,
- slug: contentRow.slug,
- storage_url: result.storageUrl,
- });
- traceRequestComplete(finalLogContext);
-
- return jsonResponse(response, 200, {
- ...CORS,
- ...buildSecurityHeaders(),
- 'X-Generated-By': `data-api:content-generate:${category}`,
- 'X-Processing-Mode': 'sync',
- });
- } catch (error) {
- // Log error details server-side (not exposed to users)
- await logError('Package generation failed', finalLogContext, error);
- return await errorResponse(error, 'data-api:content-generate-sync', CORS, finalLogContext);
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/content-generate/queue-worker.ts b/apps/edge/supabase/functions/public-api/routes/content-generate/queue-worker.ts
deleted file mode 100644
index 6a8143286..000000000
--- a/apps/edge/supabase/functions/public-api/routes/content-generate/queue-worker.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-/**
- * Package Generation Queue Worker
- * Processes package_generation queue: Generate Skills ZIP and MCP .mcpb packages
- *
- * Flow:
- * 1. Read batch from package_generation queue
- * 2. For each message: Fetch content → Generate package → Upload to storage → Update DB
- * 3. Delete message on success, leave in queue for retry on failure
- *
- * Route: POST /content/generate-package/process
- */
-
-import type { Database as DatabaseGenerated } from '@heyclaude/database-types';
-import { Constants } from '@heyclaude/database-types';
-import { errorResponse, successResponse } from '@heyclaude/edge-runtime/utils/http.ts';
-import { initRequestLogging, traceRequestComplete, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { pgmqDelete, pgmqRead } from '@heyclaude/edge-runtime/utils/pgmq-client.ts';
-import { supabaseServiceRole } from '@heyclaude/edge-runtime/clients/supabase.ts';
-import { createUtilityContext, logError, logInfo } from '@heyclaude/shared-runtime/logging.ts';
-import { normalizeError } from '@heyclaude/shared-runtime/error-handling.ts';
-import { TIMEOUT_PRESETS, withTimeout } from '@heyclaude/shared-runtime/timeout.ts';
-import { getGenerator } from './registry.ts';
-import type { ContentRow } from './types.ts';
-
-const PACKAGE_GENERATION_QUEUE = 'package_generation';
-const QUEUE_BATCH_SIZE = 5; // Smaller batch size for expensive operations
-
-/**
- * Safely extract a string property from an unknown object.
- * Uses Object.getOwnPropertyDescriptor to avoid prototype pollution.
- */
-const getStringProperty = (obj: unknown, key: string): string | undefined => {
- if (typeof obj !== 'object' || obj === null) {
- return undefined;
- }
- const desc = Object.getOwnPropertyDescriptor(obj, key);
- if (desc && typeof desc.value === 'string') {
- return desc.value;
- }
- return undefined;
-};
-
-/**
- * Determine whether a raw queue message contains the required string fields and a valid content category.
- *
- * @param msg - Raw queue message to validate
- * @returns `true` if `msg` is an object with string `content_id`, `slug`, and `created_at`, and a `category` equal to one of the allowed content category enum values, `false` otherwise.
- */
-function isValidQueueMessage(msg: unknown): msg is {
- content_id: string;
- category: DatabaseGenerated['public']['Enums']['content_category'];
- slug: string;
- created_at: string;
-} {
- if (typeof msg !== 'object' || msg === null) {
- return false;
- }
- const contentId = getStringProperty(msg, 'content_id');
- const slug = getStringProperty(msg, 'slug');
- const createdAt = getStringProperty(msg, 'created_at');
- const category = getStringProperty(msg, 'category');
-
- if (!(contentId && slug && createdAt && category)) {
- return false;
- }
-
- // Validate category enum - use enum values directly from @heyclaude/database-types Constants
- const validCategories = Constants.public.Enums.content_category;
- for (const validCategory of validCategories) {
- if (category === validCategory) {
- return true;
- }
- }
- return false;
-}
-
-interface PackageGenerationQueueMessage {
- msg_id: bigint;
- read_ct: number;
- vt: string;
- enqueued_at: string;
- message: {
- content_id: string;
- category: DatabaseGenerated['public']['Enums']['content_category'];
- slug: string;
- created_at: string;
- };
-}
-
-/**
- * Process a single package generation job from a queue message.
- *
- * Fetches the content row, locates the generator for the message's category,
- * validates that the content can be generated, executes generation (generate → upload → update DB),
- * and logs outcomes.
- *
- * @param message - The queue message containing `content_id`, `category`, `slug`, and metadata
- * @param logContext - Optional structured logging context used for enriched logs
- * @returns An object with `success` indicating overall outcome and `errors` containing human-readable error messages
- */
-async function processPackageGeneration(
- message: PackageGenerationQueueMessage,
- logContext?: Record
-): Promise<{ success: boolean; errors: string[] }> {
- const errors: string[] = [];
- const { content_id, category, slug } = message.message;
-
- try {
- // Fetch content from database
- const { data: content, error: fetchError } = await supabaseServiceRole
- .from('content')
- .select('*')
- .eq('id', content_id)
- .single();
-
- if (fetchError || !content) {
- // Use dbQuery serializer for consistent database query formatting
- if (logContext) {
- await logError('Failed to fetch content for package generation', {
- ...logContext,
- dbQuery: {
- table: 'content',
- operation: 'select',
- schema: 'public',
- args: {
- id: content_id,
- },
- },
- }, fetchError);
- }
- errors.push(`Failed to fetch content: ${fetchError?.message || 'Content not found'}`);
- return { success: false, errors };
- }
-
- // Content is guaranteed to be non-null after the check above
- // Use satisfies to ensure type correctness without assertion
- const contentRow = content satisfies ContentRow;
-
- // Get generator for category
- const generator = getGenerator(category);
- if (!generator) {
- errors.push(`Generator not found for category '${category}'`);
- return { success: false, errors };
- }
-
- // Validate content can be generated
- if (!generator.canGenerate(contentRow)) {
- errors.push(
- `Content '${content_id}' cannot be generated. Missing required fields or invalid category.`
- );
- return { success: false, errors };
- }
-
- // Generate package (this does: generate → upload → update DB)
- await generator.generate(contentRow);
-
- if (logContext) {
- logInfo('Package generated successfully', {
- ...logContext,
- content_id,
- category,
- slug,
- });
- }
-
- return { success: true, errors: [] };
- } catch (error) {
- const errorMsg = normalizeError(error, "Operation failed").message;
- errors.push(`Generation failed: ${errorMsg}`);
- if (logContext) {
- await logError(
- 'Package generation error',
- {
- ...logContext,
- content_id,
- category,
- slug,
- },
- error
- );
- }
- return { success: false, errors };
- }
-}
-
-/**
- * Process a batch of package generation queue messages and return a summary response.
- *
- * Processes up to QUEUE_BATCH_SIZE messages from the package_generation queue, validates each message,
- * invokes package generation for valid messages, deletes messages that succeeded or are structurally invalid,
- * and leaves failed messages for automatic retry via the queue visibility timeout.
- *
- * @param _req - Incoming HTTP request (unused; present for route handler compatibility)
- * @param logContext - Optional logging context to attach to structured logs and traces
- * @returns A Response containing a summary object with `processed` (number) and `results` (per-message status, errors, and optional `will_retry`) or an error response on fatal failure
- */
-export async function handlePackageGenerationQueue(
- _req: Request,
- logContext?: Record
-): Promise {
- // Create log context if not provided
- const finalLogContext = logContext || createUtilityContext('content-generate', 'package-generation-queue', {});
-
- // Initialize request logging with trace and bindings
- initRequestLogging(finalLogContext);
- traceStep('Starting package generation queue processing', finalLogContext);
-
- try {
- // Read messages with timeout protection
- traceStep('Reading package generation queue', finalLogContext);
- const messages = await withTimeout(
- pgmqRead(PACKAGE_GENERATION_QUEUE, {
- sleep_seconds: 0,
- n: QUEUE_BATCH_SIZE,
- }),
- TIMEOUT_PRESETS.rpc,
- 'Package generation queue read timed out'
- );
-
- if (!messages || messages.length === 0) {
- traceRequestComplete(finalLogContext);
- return successResponse({ message: 'No messages in queue', processed: 0 }, 200);
- }
-
- logInfo(`Processing ${messages.length} package generation jobs`, finalLogContext);
- traceStep(`Processing ${messages.length} package generation jobs`, finalLogContext);
-
- const results: Array<{
- msg_id: string;
- status: 'success' | 'failed';
- errors: string[];
- will_retry?: boolean;
- }> = [];
-
- for (const msg of messages) {
- // Validate message structure
- if (!isValidQueueMessage(msg.message)) {
- await logError('Invalid queue message structure', {
- ...finalLogContext,
- msg_id: msg.msg_id.toString(),
- });
-
- // Delete invalid message to prevent infinite retries
- try {
- await pgmqDelete(PACKAGE_GENERATION_QUEUE, msg.msg_id);
- } catch (error) {
- await logError(
- 'Failed to delete invalid message',
- {
- ...finalLogContext,
- msg_id: msg.msg_id.toString(),
- },
- error
- );
- }
-
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- errors: ['Invalid message structure'],
- will_retry: false, // Don't retry invalid messages
- });
- continue;
- }
-
- const message: PackageGenerationQueueMessage = {
- msg_id: msg.msg_id,
- read_ct: msg.read_ct,
- vt: msg.vt,
- enqueued_at: msg.enqueued_at,
- message: msg.message,
- };
-
- try {
- const result = await processPackageGeneration(message, finalLogContext);
-
- if (result.success) {
- await pgmqDelete(PACKAGE_GENERATION_QUEUE, message.msg_id);
- results.push({
- msg_id: message.msg_id.toString(),
- status: 'success',
- errors: result.errors,
- });
- } else {
- // Leave in queue for retry (pgmq visibility timeout will retry)
- results.push({
- msg_id: message.msg_id.toString(),
- status: 'failed',
- errors: result.errors,
- will_retry: true,
- });
- }
- } catch (error) {
- const errorMsg = normalizeError(error, "Operation failed").message;
- await logError(
- 'Unexpected error processing package generation',
- {
- ...finalLogContext,
- msg_id: message.msg_id.toString(),
- },
- error
- );
- results.push({
- msg_id: message.msg_id.toString(),
- status: 'failed',
- errors: [errorMsg],
- will_retry: true,
- });
- }
- }
-
- logInfo('Package generation queue processing complete', {
- ...finalLogContext,
- processed: messages.length,
- successCount: results.filter((r) => r.status === 'success').length,
- failedCount: results.filter((r) => r.status === 'failed').length,
- });
- traceRequestComplete(finalLogContext);
-
- return successResponse(
- {
- message: `Processed ${messages.length} messages`,
- processed: messages.length,
- results,
- },
- 200
- );
- } catch (error) {
- await logError('Fatal package generation queue error', finalLogContext, error);
- return await errorResponse(error, 'data-api:package-generation-queue-fatal', undefined, finalLogContext);
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/content-generate/registry.ts b/apps/edge/supabase/functions/public-api/routes/content-generate/registry.ts
deleted file mode 100644
index 6fe381f73..000000000
--- a/apps/edge/supabase/functions/public-api/routes/content-generate/registry.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Package Generator Registry
- *
- * Maps content categories to their package generators.
- * Easy to extend: just add new generator to this map.
- */
-
-import type { Database as DatabaseGenerated } from '@heyclaude/database-types';
-
-import { McpGenerator } from './generators/mcp.ts';
-import { SkillsGenerator } from './generators/skills.ts';
-import type { PackageGenerator } from './types.ts';
-
-type ContentCategory = DatabaseGenerated['public']['Enums']['content_category'];
-
-/**
- * Registry of category generators
- *
- * To add a new category:
- * 1. Create generator in generators/[category].ts
- * 2. Import it above
- * 3. Add entry to this map
- */
-const GENERATORS = new Map([
- ['skills', new SkillsGenerator()],
- ['mcp', new McpGenerator()],
- // Future: ['hooks', new HooksGenerator()],
- // Future: ['rules', new RulesGenerator()],
-]);
-
-/**
- * Retrieve the package generator associated with a content category.
- *
- * @param category - The content category to look up
- * @returns The generator instance for `category`, or `null` if no generator is registered for that category
- */
-export function getGenerator(category: ContentCategory): PackageGenerator | null {
- return GENERATORS.get(category) ?? null;
-}
-
-/**
- * Lists content categories that have a registered package generator.
- *
- * @returns An array of supported content categories.
- */
-export function getSupportedCategories(): ContentCategory[] {
- // Array.from returns the correct type, no assertion needed
- return Array.from(GENERATORS.keys());
-}
-
-/**
- * Determine whether a content category has a registered package generator.
- *
- * @param category - The content category to check
- * @returns `true` if the category has a registered generator, `false` otherwise.
- */
-export function isCategorySupported(category: ContentCategory): boolean {
- return GENERATORS.has(category);
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/content-generate/types.ts b/apps/edge/supabase/functions/public-api/routes/content-generate/types.ts
deleted file mode 100644
index e5ddcea6e..000000000
--- a/apps/edge/supabase/functions/public-api/routes/content-generate/types.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Types for package generation system
- */
-
-import type { Database as DatabaseGenerated } from '@heyclaude/database-types';
-
-/**
- * Content row type (from database)
- */
-export type ContentRow = DatabaseGenerated['public']['Tables']['content']['Row'];
-
-/**
- * Result of package generation
- */
-export interface GenerateResult {
- storageUrl: string;
- metadata?: Record; // Category-specific metadata (e.g., build_hash, file_size)
-}
-
-/**
- * Package generator interface
- * All category generators must implement this interface
- */
-export interface PackageGenerator {
- /**
- * Check if content can be generated
- * @param content - Content row from database
- * @returns true if generation is possible
- */
- canGenerate(content: ContentRow): boolean;
-
- /**
- * Generate package and return storage URL
- * @param content - Content row from database
- * @returns Generation result with storage URL and metadata
- */
- generate(content: ContentRow): Promise;
-
- /**
- * Get Supabase Storage bucket name
- * @returns Bucket name for this category
- */
- getBucketName(): string;
-
- /**
- * Get database fields to update after generation
- * @returns Array of field names to update in content table
- */
- getDatabaseFields(): string[];
-}
-
-/**
- * Request body for package generation
- */
-export interface GeneratePackageRequest {
- content_id: string;
- category: DatabaseGenerated['public']['Enums']['content_category'];
-}
-
-/**
- * Response for package generation
- */
-export interface GeneratePackageResponse {
- success: boolean;
- content_id: string;
- category: DatabaseGenerated['public']['Enums']['content_category'];
- slug: string;
- storage_url: string | null; // URL when generation completes, null for async/queued cases
- metadata?: Record;
- message?: string;
- error?: string;
-}
diff --git a/apps/edge/supabase/functions/public-api/routes/content-generate/upload.ts b/apps/edge/supabase/functions/public-api/routes/content-generate/upload.ts
deleted file mode 100644
index 33ca37771..000000000
--- a/apps/edge/supabase/functions/public-api/routes/content-generate/upload.ts
+++ /dev/null
@@ -1,372 +0,0 @@
-/**
- * Package Upload Handler
- *
- * Handles POST /content/generate-package/upload requests for uploading pre-generated packages.
- * Used by build scripts to upload CLI-validated .mcpb packages.
- *
- * Authentication: Requires SUPABASE_SERVICE_ROLE_KEY (via Authorization: Bearer header)
- *
- * Body: {
- * content_id: string;
- * category: 'mcp';
- * mcpb_file: string; // base64 encoded .mcpb file
- * content_hash: string; // pre-computed hash
- * }
- */
-
-import type { Database as DatabaseGenerated } from '@heyclaude/database-types';
-import { Constants } from '@heyclaude/database-types';
-import { badRequestResponse, errorResponse, postCorsHeaders, jsonResponse, methodNotAllowedResponse } from '@heyclaude/edge-runtime/utils/http.ts';
-import { initRequestLogging, traceRequestComplete, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { parseJsonBody } from '@heyclaude/edge-runtime/utils/parse-json-body.ts';
-import { supabaseServiceRole } from '@heyclaude/edge-runtime/clients/supabase.ts';
-import { getStorageServiceClient } from '@heyclaude/edge-runtime/utils/storage/client.ts';
-import { uploadObject } from '@heyclaude/edge-runtime/utils/storage/upload.ts';
-import { buildSecurityHeaders } from '@heyclaude/shared-runtime/security-headers.ts';
-import { createDataApiContext, logError, logInfo, logger } from '@heyclaude/shared-runtime/logging.ts';
-import { timingSafeEqual } from '@heyclaude/shared-runtime/crypto-utils.ts';
-
-const CORS = postCorsHeaders;
-
-interface UploadPackageRequest {
- content_id: string;
- category: DatabaseGenerated['public']['Enums']['content_category'];
- mcpb_file: string; // base64 encoded
- content_hash: string;
-}
-
-interface UploadPackageResponse {
- success: boolean;
- content_id: string;
- category: DatabaseGenerated['public']['Enums']['content_category'];
- slug: string;
- storage_url: string;
- message?: string;
- error?: string;
-}
-
-/**
- * Handle internal MCP package uploads for an existing content entry.
- *
- * Validates service-role authorization, accepts a base64-encoded `.mcpb` package for content with category `mcp`, stores the file in object storage, updates the content record with the storage URL and build metadata, and returns the upload result.
- *
- * @param request - The incoming HTTP request
- * @param logContext - Optional logging context to attach to request logs and traces; if omitted a context is created
- * @returns An UploadPackageResponse object containing `success`, `content_id`, `category`, `slug`, `storage_url`, and an optional `message` or `error`
- */
-export async function handleUploadPackage(
- request: Request,
- logContext?: Record
-): Promise {
- // Create log context if not provided
- const finalLogContext = logContext || createDataApiContext('content-generate-upload', {
- path: '/content/generate-package/upload',
- method: request.method,
- app: 'public-api',
- });
-
- // Initialize request logging with trace and bindings
- initRequestLogging(finalLogContext);
- traceStep('Package upload request received', finalLogContext);
-
- // Set bindings for this request
- logger.setBindings({
- requestId: typeof finalLogContext['request_id'] === 'string' ? finalLogContext['request_id'] : undefined,
- operation: typeof finalLogContext['action'] === 'string' ? finalLogContext['action'] : 'package-upload',
- method: request.method,
- });
-
- // Only allow POST
- if (request.method === 'OPTIONS') {
- return new Response(null, {
- status: 204,
- headers: {
- ...buildSecurityHeaders(),
- ...CORS,
- },
- });
- }
-
- if (request.method !== 'POST') {
- return methodNotAllowedResponse('POST', CORS);
- }
-
- // Authenticate: Internal-only endpoint (requires service role key)
- const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
- if (!serviceRoleKey) {
- await logError('SUPABASE_SERVICE_ROLE_KEY not configured', finalLogContext);
- return jsonResponse(
- {
- error: 'Internal Server Error',
- message: 'Service role key not configured',
- },
- 500,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- // Accept Authorization: Bearer header (case-insensitive)
- const authHeader = request.headers.get('Authorization');
- const providedKey = authHeader?.replace(/^Bearer\s+/i, '').trim();
-
- if (!providedKey || !timingSafeEqual(providedKey, serviceRoleKey)) {
- logInfo('Unauthorized package upload request', {
- ...finalLogContext,
- hasAuthHeader: !!authHeader,
- hasKey: !!providedKey,
- });
- return jsonResponse(
- {
- error: 'Unauthorized',
- message: 'Invalid or missing service role key. This endpoint is for internal use only.',
- },
- 401,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- // Parse request body
- const parseResult = await parseJsonBody(request, {
- maxSize: 50 * 1024 * 1024, // 50MB max (for base64 encoded .mcpb files)
- cors: CORS,
- });
-
- if (!parseResult.success) {
- return parseResult.response;
- }
-
- const { content_id, category, mcpb_file, content_hash } = parseResult.data;
-
- // Validate required fields
- if (!content_id || typeof content_id !== 'string') {
- return badRequestResponse('Missing or invalid content_id', CORS);
- }
-
- /**
- * Checks whether a value is one of the known `content_category` enum values.
- *
- * Narrows the type to `DatabaseGenerated['public']['Enums']['content_category']` when true.
- *
- * @param value - The value to validate as a `content_category`
- * @returns `true` if `value` matches a `content_category` enum member, `false` otherwise.
- */
- function isValidContentCategory(
- value: unknown
- ): value is DatabaseGenerated['public']['Enums']['content_category'] {
- if (typeof value !== 'string') {
- return false;
- }
- // Use enum values directly from @heyclaude/database-types Constants
- const validValues = Constants.public.Enums.content_category;
- for (const validValue of validValues) {
- if (value === validValue) {
- return true;
- }
- }
- return false;
- }
-
- if (!isValidContentCategory(category)) {
- return badRequestResponse(`Category '${category}' is not a valid content category`, CORS);
- }
-
- if (category !== 'mcp') {
- return badRequestResponse(
- `Invalid category '${category}'. Only 'mcp' category is supported for package uploads.`,
- CORS
- );
- }
-
- if (!mcpb_file || typeof mcpb_file !== 'string') {
- return badRequestResponse('Missing or invalid mcpb_file (base64 encoded)', CORS);
- }
-
- if (!content_hash || typeof content_hash !== 'string') {
- return badRequestResponse('Missing or invalid content_hash', CORS);
- }
-
- try {
- // Fetch content from database to get slug
- const { data: contentData, error: fetchError } = await supabaseServiceRole
- .from('content')
- .select('id, slug, category')
- .eq('id', content_id)
- .single();
-
- if (fetchError || !contentData) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('Content not found for upload', {
- ...finalLogContext,
- dbQuery: {
- table: 'content',
- operation: 'select',
- schema: 'public',
- args: {
- id: content_id,
- },
- },
- }, fetchError);
- return jsonResponse(
- {
- success: false,
- content_id,
- category,
- slug: '',
- storage_url: '',
- error: 'Not Found',
- message: `Content with id '${content_id}' not found`,
- } satisfies UploadPackageResponse,
- 404,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- // Content is guaranteed to be non-null after the check above
- type ContentRow = DatabaseGenerated['public']['Tables']['content']['Row'];
- const content = contentData satisfies Pick;
-
- // Validate category matches
- if (content.category !== 'mcp') {
- return badRequestResponse(
- `Content category mismatch. Expected 'mcp', got '${content.category}'`,
- CORS
- );
- }
-
- if (!content.slug) {
- return badRequestResponse('Content slug is required for MCP package upload', CORS);
- }
-
- // Decode base64 file to ArrayBuffer
- let mcpbBuffer: ArrayBuffer;
- try {
- // Decode base64 to binary string, then to ArrayBuffer
- const binaryString = atob(mcpb_file);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- mcpbBuffer = bytes.buffer;
- } catch (error) {
- await logError('Failed to decode base64 mcpb_file', finalLogContext, error);
- return badRequestResponse('Invalid base64 encoding in mcpb_file', CORS);
- }
-
- // Upload to Supabase Storage
- const fileName = `packages/${content.slug}.mcpb`;
- const uploadResult = await uploadObject({
- bucket: 'mcpb-packages',
- buffer: mcpbBuffer,
- mimeType: 'application/zip',
- objectPath: fileName,
- cacheControl: '3600',
- upsert: true,
- client: getStorageServiceClient(),
- });
-
- if (!(uploadResult.success && uploadResult.publicUrl)) {
- await logError('Storage upload failed', finalLogContext, {
- error: uploadResult['error'] || 'Unknown upload error',
- content_id,
- slug: content.slug,
- });
- return jsonResponse(
- {
- success: false,
- content_id,
- category,
- slug: content.slug,
- storage_url: '',
- error: 'Upload Failed',
- message: uploadResult['error'] || 'Failed to upload .mcpb package to storage',
- } satisfies UploadPackageResponse,
- 500,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- // Update database with storage URL and build metadata
- const updateData = {
- mcpb_storage_url: uploadResult.publicUrl,
- mcpb_build_hash: content_hash,
- mcpb_last_built_at: new Date().toISOString(),
- } satisfies DatabaseGenerated['public']['Tables']['content']['Update'];
-
- const { error: updateError } = await supabaseServiceRole
- .from('content')
- .update(updateData)
- .eq('id', content_id);
-
- if (updateError) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('Database update failed', {
- ...finalLogContext,
- dbQuery: {
- table: 'content',
- operation: 'update',
- schema: 'public',
- args: {
- id: content_id,
- // Update fields redacted by Pino's redact config
- },
- },
- }, updateError);
- return jsonResponse(
- {
- success: false,
- content_id,
- category,
- slug: content.slug,
- storage_url: '',
- error: 'Database Update Failed',
- message: updateError instanceof Error ? updateError.message : String(updateError),
- } satisfies UploadPackageResponse,
- 500,
- {
- ...CORS,
- ...buildSecurityHeaders(),
- }
- );
- }
-
- logInfo('Package uploaded successfully', {
- ...finalLogContext,
- content_id,
- category,
- slug: content.slug,
- storage_url: uploadResult.publicUrl,
- });
- traceRequestComplete(finalLogContext);
-
- const response: UploadPackageResponse = {
- success: true,
- content_id,
- category,
- slug: content.slug,
- storage_url: uploadResult.publicUrl,
- message: 'Package uploaded and database updated successfully',
- };
-
- return jsonResponse(response, 200, {
- ...CORS,
- ...buildSecurityHeaders(),
- 'X-Uploaded-By': 'data-api:content-generate:upload',
- });
- } catch (error) {
- // Log the real error server-side for debugging
- await logError('Package upload failed', finalLogContext, error);
- return await errorResponse(error, 'data-api:content-generate-upload', CORS, finalLogContext);
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/embedding/index.ts b/apps/edge/supabase/functions/public-api/routes/embedding/index.ts
deleted file mode 100644
index 2d9935f14..000000000
--- a/apps/edge/supabase/functions/public-api/routes/embedding/index.ts
+++ /dev/null
@@ -1,757 +0,0 @@
-/**
- * Generate Embedding Edge Function
- */
-
-///
-
-import type { Database as DatabaseGenerated } from '@heyclaude/database-types';
-import { badRequestResponse, errorResponse, publicCorsHeaders, successResponse, unauthorizedResponse, webhookCorsHeaders } from '@heyclaude/edge-runtime/utils/http.ts';
-import { initRequestLogging, traceRequestComplete, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { parseJsonBody } from '@heyclaude/edge-runtime/utils/parse-json-body.ts';
-import { pgmqDelete, pgmqRead, pgmqSend } from '@heyclaude/edge-runtime/utils/pgmq-client.ts';
-import { supabaseServiceRole } from '@heyclaude/edge-runtime/clients/supabase.ts';
-import { buildSecurityHeaders } from '@heyclaude/shared-runtime/security-headers.ts';
-import { CIRCUIT_BREAKER_CONFIGS, withCircuitBreaker } from '@heyclaude/shared-runtime/circuit-breaker.ts';
-import { createUtilityContext, logError, logInfo, logWarn, logger } from '@heyclaude/shared-runtime/logging.ts';
-import { normalizeError } from '@heyclaude/shared-runtime/error-handling.ts';
-import { TIMEOUT_PRESETS, withTimeout } from '@heyclaude/shared-runtime/timeout.ts';
-import { verifySupabaseDatabaseWebhook } from '@heyclaude/shared-runtime/webhook/crypto.ts';
-
-// Webhook payload structure from Supabase database webhooks
-// Use generated type for the record (content table row)
-type ContentRow = DatabaseGenerated['public']['Tables']['content']['Row'];
-type ContentWebhookPayload = {
- type: 'INSERT' | 'UPDATE' | 'DELETE';
- table: string;
- record: ContentRow;
- old_record?: ContentRow | null;
- schema: string;
-};
-
-/**
- * Create a single searchable string from a content row for embedding generation.
- *
- * Builds a space-separated string composed of the record's title, description, tags (joined),
- * author, and up to the first 1000 characters of the content, skipping any missing fields.
- *
- * @param record - The content row to extract searchable text from
- * @returns A trimmed string suitable for embedding generation containing the concatenated content fields
- */
-function buildSearchableText(record: ContentRow): string {
- const parts: string[] = [];
-
- if (record.title) {
- parts.push(record.title);
- }
-
- if (record.description) {
- parts.push(record.description);
- }
-
- // Add tags as searchable text
- if (record.tags && record.tags.length > 0) {
- parts.push(record.tags.join(' '));
- }
-
- // Add author as searchable text
- if (record.author) {
- parts.push(record.author);
- }
-
- // Optionally include content body (if not too long)
- // Limit to first 1000 chars to avoid token limits
- if (record.content && record.content.length > 0) {
- const contentPreview = record.content.substring(0, 1000);
- parts.push(contentPreview);
- }
-
- return parts.join(' ').trim();
-}
-
-/**
- * Generate a normalized numeric embedding for the provided text using the `gte-small` AI model.
- *
- * @param text - The input content used to produce the embedding
- * @returns The embedding vector as an array of numbers
- * @throws Error if the model returns a non-array, an empty array, or values that are not numbers; may also throw on timeout or circuit-breaker failure
- */
-async function generateEmbedding(text: string): Promise {
- const generateEmbeddingInternal = async () => {
- // Initialize Supabase AI session with gte-small model
- // Supabase global is provided by Supabase Edge Runtime (declared in @heyclaude/edge-runtime/deno-globals.d.ts)
- const model = new Supabase.ai.Session('gte-small');
-
- // Generate embedding with normalization
- const embeddingResult = await model.run(text, {
- mean_pool: true, // Use mean pooling for better quality
- normalize: true, // Normalize for inner product similarity
- });
-
- // Validate embedding is an array of numbers
- if (!Array.isArray(embeddingResult)) {
- throw new Error('Embedding generation returned non-array result');
- }
- if (embeddingResult.length === 0) {
- throw new Error('Embedding generation returned empty array');
- }
- if (!embeddingResult.every((val) => typeof val === 'number' && !Number.isNaN(val))) {
- throw new Error('Embedding generation returned non-numeric values');
- }
- return embeddingResult;
- };
-
- // Wrap with circuit breaker and timeout
- return await withTimeout(
- withCircuitBreaker(
- 'generate-embedding:ai-model',
- generateEmbeddingInternal,
- CIRCUIT_BREAKER_CONFIGS.external
- ),
- TIMEOUT_PRESETS.external * 2, // AI model calls can take longer (10s)
- 'Embedding generation timed out'
- );
-}
-
-/**
- * Persist a content embedding record to the database.
- *
- * Stores the provided embedding (as a JSON string) along with the content text and
- * a generated-at timestamp into the `content_embeddings` table. The operation is
- * protected by a circuit breaker and a timeout.
- *
- * @param contentId - The content record's unique identifier
- * @param contentText - The searchable text associated with the content (stored for retrieval/search)
- * @param embedding - The numeric embedding vector for `contentText`
- * @throws Error If the database upsert fails or if the operation is aborted by the circuit breaker or timeout
- */
-async function storeEmbedding(
- contentId: string,
- contentText: string,
- embedding: number[]
-): Promise {
- const storeEmbeddingInternal = async () => {
- type ContentEmbeddingsInsert =
- DatabaseGenerated['public']['Tables']['content_embeddings']['Insert'];
- // Store embedding as JSON string for TEXT/JSONB column
- // Note: If this column is a pgvector type, it would require format '[1,2,3]' instead
- // The query_content_embeddings RPC function handles format conversion if needed
- const insertData: ContentEmbeddingsInsert = {
- content_id: contentId,
- content_text: contentText,
- embedding: JSON.stringify(embedding),
- embedding_generated_at: new Date().toISOString(),
- };
- const { error } = await supabaseServiceRole.from('content_embeddings').upsert(insertData);
-
- if (error) {
- // Use dbQuery serializer for consistent database query formatting
- const logContext = createUtilityContext('generate-embedding', 'store-embedding');
- await logError('Failed to store embedding', {
- ...logContext,
- dbQuery: {
- table: 'content_embeddings',
- operation: 'upsert',
- schema: 'public',
- args: {
- content_id: contentId,
- // Embedding data redacted by Pino's redact config
- },
- },
- }, error);
- throw new Error(`Failed to store embedding: ${normalizeError(error, "Operation failed").message}`);
- }
- };
-
- // Wrap with circuit breaker and timeout
- await withTimeout(
- withCircuitBreaker(
- 'generate-embedding:database',
- storeEmbeddingInternal,
- CIRCUIT_BREAKER_CONFIGS.rpc
- ),
- TIMEOUT_PRESETS.rpc,
- 'Database storage timed out'
- );
-}
-
-/**
- * Wraps a webhook handler with request-scoped analytics, structured logging, and standardized error responses.
- *
- * Executes the provided handler while ensuring request-scoped logging context is established, records the
- * handler outcome (success or error), and converts thrown errors into a standardized error Response that
- * includes CORS headers and the request log context.
- *
- * @param handler - A function that handles the webhook and returns a Response
- * @returns The Response produced by `handler` on success, or a standardized error Response containing CORS headers and request log context on failure
- */
-function respondWithAnalytics(handler: () => Promise): Promise {
- const logContext = createUtilityContext('generate-embedding', 'webhook-handler');
-
- // Initialize request logging with trace and bindings (Phase 1 & 2)
- initRequestLogging(logContext);
- traceStep('Embedding webhook handler started', logContext);
-
- // Set bindings for this request - mixin will automatically inject these into all subsequent logs
- logger.setBindings({
- requestId: typeof logContext['request_id'] === "string" ? logContext['request_id'] : undefined,
- operation: typeof logContext['action'] === "string" ? logContext['action'] : 'embedding-webhook',
- function: typeof logContext['function'] === "string" ? logContext['function'] : "unknown",
- });
-
- const logEvent = async (status: number, outcome: 'success' | 'error', error?: unknown) => {
- const errorData = error ? { error: normalizeError(error, "Operation failed").message } : {};
- const logData = {
- ...logContext,
- status,
- outcome,
- ...errorData,
- };
-
- if (outcome === 'error') {
- await logError('Embedding generation failed', logContext, error);
- } else {
- logInfo('Embedding generation completed', logData);
- }
- };
-
- return handler()
- .then((response) => {
- logEvent(response.status, response.ok ? 'success' : 'error');
- return response;
- })
- .catch(async (error) => {
- await logEvent(500, 'error', error);
- return await errorResponse(error, 'generate-embedding', publicCorsHeaders, logContext);
- });
-}
-
-const EMBEDDING_GENERATION_QUEUE = 'embedding_generation';
-const EMBEDDING_GENERATION_DLQ = 'embedding_generation_dlq';
-const QUEUE_BATCH_SIZE = 10; // Moderate batch size for AI operations
-const MAX_EMBEDDING_ATTEMPTS = 5;
-
-interface EmbeddingGenerationQueueMessage {
- msg_id: bigint;
- read_ct: number;
- vt: string;
- enqueued_at: string;
- message: {
- content_id: string;
- type: 'INSERT' | 'UPDATE';
- created_at: string;
- };
-}
-
-/**
- * Determine whether a value is a queue message that contains a string `content_id`.
- *
- * @param msg - The value to validate as a queue message
- * @returns `true` if `msg` is an object with a string `content_id`, `false` otherwise.
- */
-function isValidQueueMessage(
- msg: unknown
-): msg is { content_id: string; type?: string; created_at?: string } {
- if (typeof msg !== 'object' || msg === null) {
- return false;
- }
- // Check for required content_id field
- return 'content_id' in msg && typeof (msg as Record)['content_id'] === 'string';
-}
-
-/**
- * Process one embedding-generation queue message for the given content and persist an embedding when applicable.
- *
- * Attempts to fetch the referenced content, build a searchable text summary, generate a normalized embedding, and store the embedding record. If the content has no searchable text the message is considered handled (skipped) and treated as successful.
- *
- * @param message - Queue message containing the `content_id` and job metadata
- * @returns An object with `success` (`true` if processing completed or the message was intentionally skipped, `false` on failure) and `errors` (array of error messages encountered during processing)
- */
-async function processEmbeddingGeneration(
- message: EmbeddingGenerationQueueMessage
-): Promise<{ success: boolean; errors: string[] }> {
- const errors: string[] = [];
- const { content_id } = message.message;
-
- try {
- // Fetch content from database (with circuit breaker + timeout)
- type ContentRow = DatabaseGenerated['public']['Tables']['content']['Row'];
- const fetchResult = await withTimeout(
- withCircuitBreaker(
- 'generate-embedding:fetch-content',
- async () =>
- await supabaseServiceRole.from('content').select('*').eq('id', content_id).single(),
- CIRCUIT_BREAKER_CONFIGS.rpc
- ),
- TIMEOUT_PRESETS.rpc,
- 'Content fetch timed out'
- );
- const { data: content, error: fetchError } = fetchResult as {
- data: ContentRow | null;
- error: { message?: string } | null;
- };
-
- if (fetchError || !content) {
- // Use dbQuery serializer for consistent database query formatting
- const logContext = createUtilityContext('generate-embedding', 'fetch-content');
- if (fetchError) {
- await logError('Failed to fetch content for embedding generation', {
- ...logContext,
- dbQuery: {
- table: 'content',
- operation: 'select',
- schema: 'public',
- args: {
- id: content_id,
- },
- },
- }, fetchError);
- }
- errors.push(`Failed to fetch content: ${fetchError?.message || 'Content not found'}`);
- return { success: false, errors };
- }
-
- // Content is guaranteed to be non-null after the check above
- // Type is already correctly inferred from the Supabase query
- const contentRow = content;
-
- // Build searchable text - use contentRow directly (already typed as ContentRow)
- const searchableText = buildSearchableText(contentRow);
-
- if (!searchableText || searchableText.trim().length === 0) {
- // Skip empty content (not an error, just nothing to embed)
- const logContext = createUtilityContext('generate-embedding', 'skip-empty');
- logInfo('Skipping embedding generation: empty searchable text', {
- ...logContext,
- content_id,
- });
- return { success: true, errors: [] }; // Mark as success (skipped)
- }
-
- // Generate embedding (with circuit breaker + timeout)
- const embedding = await generateEmbedding(searchableText);
-
- // Store embedding (with circuit breaker + timeout)
- await storeEmbedding(content_id, searchableText, embedding);
-
- const logContext = createUtilityContext('generate-embedding', 'store-success');
- logInfo('Embedding generated and stored', {
- ...logContext,
- content_id,
- embedding_dim: embedding.length,
- });
-
- return { success: true, errors: [] };
- } catch (error) {
- const errorMsg = normalizeError(error, "Operation failed").message;
- errors.push(`Embedding generation failed: ${errorMsg}`);
- const logContext = createUtilityContext('generate-embedding', 'generation-error');
- await logError(
- 'Embedding generation error',
- {
- ...logContext,
- content_id,
- },
- error
- );
- return { success: false, errors };
- }
-}
-
-/**
- * Process a batch of embedding generation queue messages: validate each message, generate and store embeddings for valid content, delete successful messages, move messages that exceeded max attempts to the dead-letter queue, and leave retryable failures in the queue.
- *
- * @returns A Response containing a summary object with the number of messages processed and an array of per-message results (each result includes `msg_id`, `status`, `errors`, and optional `will_retry`); on a fatal error returns an error response with logging context.
- */
-export async function handleEmbeddingGenerationQueue(_req: Request): Promise {
- const logContext = createUtilityContext('generate-embedding', 'queue-processor', {});
-
- // Initialize request logging with trace and bindings
- initRequestLogging(logContext);
- traceStep('Starting embedding generation queue processing', logContext);
-
- try {
- // Read messages with timeout protection
- traceStep('Reading embedding generation queue', logContext);
- const messages = await withTimeout(
- pgmqRead(EMBEDDING_GENERATION_QUEUE, {
- sleep_seconds: 0,
- n: QUEUE_BATCH_SIZE,
- }),
- TIMEOUT_PRESETS.rpc,
- 'Embedding generation queue read timed out'
- );
-
- if (!messages || messages.length === 0) {
- traceRequestComplete(logContext);
- return successResponse({ message: 'No messages in queue', processed: 0 }, 200);
- }
-
- logInfo(`Processing ${messages.length} embedding generation jobs`, {
- ...logContext,
- count: messages.length,
- });
- traceStep(`Processing ${messages.length} embedding generation jobs`, logContext);
-
- const results: Array<{
- msg_id: string;
- status: 'success' | 'skipped' | 'failed';
- errors: string[];
- will_retry?: boolean;
- }> = [];
-
- for (const msg of messages) {
- // Validate message structure matches expected format
- const queueMessage = msg.message;
-
- if (!isValidQueueMessage(queueMessage)) {
- const logContext = createUtilityContext('generate-embedding', 'invalid-message');
- await logError(
- 'Invalid queue message format',
- {
- ...logContext,
- msg_id: msg.msg_id.toString(),
- },
- new Error(`Invalid message: ${JSON.stringify(queueMessage)}`)
- );
-
- // Delete invalid message to prevent infinite retry loop
- try {
- await pgmqDelete(EMBEDDING_GENERATION_QUEUE, msg.msg_id);
- logInfo('Deleted invalid message', {
- ...logContext,
- msg_id: msg.msg_id.toString(),
- });
- } catch (deleteError) {
- await logError(
- 'Failed to delete invalid message',
- {
- ...logContext,
- msg_id: msg.msg_id.toString(),
- },
- deleteError
- );
- }
-
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- errors: ['Invalid queue message format'],
- will_retry: false, // Don't retry malformed messages
- });
- continue;
- }
-
- const message: EmbeddingGenerationQueueMessage = {
- msg_id: msg.msg_id,
- read_ct: msg.read_ct,
- vt: msg.vt,
- enqueued_at: msg.enqueued_at,
- message: {
- content_id: queueMessage.content_id,
- type: queueMessage.type === 'UPDATE' ? 'UPDATE' : 'INSERT',
- created_at: queueMessage.created_at ?? new Date().toISOString(),
- },
- };
-
- logInfo('Processing queue message', {
- ...logContext,
- msg_id: message.msg_id.toString(),
- content_id: message.message.content_id,
- attempt: Number(message.read_ct ?? 0) + 1,
- });
-
- try {
- const result = await processEmbeddingGeneration(message);
-
- if (result.success) {
- await pgmqDelete(EMBEDDING_GENERATION_QUEUE, message.msg_id);
- results.push({
- msg_id: message.msg_id.toString(),
- status: 'success',
- errors: result.errors,
- });
- } else {
- const hasExceededAttempts = Number(message.read_ct ?? 0) >= MAX_EMBEDDING_ATTEMPTS;
-
- if (hasExceededAttempts) {
- await pgmqSend(
- EMBEDDING_GENERATION_DLQ,
- {
- original_message: message,
- errors: result.errors,
- failed_at: new Date().toISOString(),
- },
- { sleepSeconds: 0 }
- );
- await pgmqDelete(EMBEDDING_GENERATION_QUEUE, message.msg_id);
- logWarn('Message moved to DLQ after max attempts', {
- ...logContext,
- msg_id: message.msg_id.toString(),
- content_id: message.message.content_id,
- attempts: message.read_ct,
- });
- results.push({
- msg_id: message.msg_id.toString(),
- status: 'failed',
- errors: [...result.errors, 'Moved to embedding_generation_dlq'],
- will_retry: false,
- });
- } else {
- // Leave in queue for retry
- results.push({
- msg_id: message.msg_id.toString(),
- status: 'failed',
- errors: result.errors,
- will_retry: true,
- });
- }
- }
- } catch (error) {
- const errorMsg = normalizeError(error, "Operation failed").message;
- await logError(
- 'Unexpected error processing embedding generation',
- {
- ...logContext,
- msg_id: message.msg_id.toString(),
- },
- error
- );
- results.push({
- msg_id: message.msg_id.toString(),
- status: 'failed',
- errors: [errorMsg],
- will_retry: Number(message.read_ct ?? 0) < MAX_EMBEDDING_ATTEMPTS,
- });
- }
- }
-
- traceRequestComplete(logContext);
- return successResponse(
- {
- message: `Processed ${messages.length} messages`,
- processed: messages.length,
- results,
- },
- 200
- );
- } catch (error) {
- await logError('Fatal embedding generation queue error', logContext, error);
- return await errorResponse(error, 'generate-embedding:queue-fatal', publicCorsHeaders, logContext);
- }
-}
-
-/**
- * Process a Supabase content webhook to generate and store an embedding for the referenced content record.
- *
- * Validates the webhook signature and optional timestamp, enforces the webhook originates from the `public.content`
- * table, and handles only INSERT and UPDATE events. For valid events it builds searchable text from the record,
- * generates a normalized embedding, and upserts the embedding into storage. DELETE events and records that yield
- * empty searchable text are acknowledged and skipped.
- *
- * @param req - Incoming HTTP request containing the Supabase database webhook payload and signature headers
- * @returns An HTTP Response describing the outcome. On successful generation the response includes `content_id` and
- * `embedding_dim`; for skipped events the response includes a `reason` (for example, `delete_event` or `empty_text`);
- * on failure the response contains standardized error information and an appropriate status code.
- */
-export function handleEmbeddingWebhook(req: Request): Promise {
- // Otherwise, handle as direct webhook (legacy)
- return respondWithAnalytics(async () => {
- const logContext = createUtilityContext('generate-embedding', 'webhook-handler');
-
- // Only accept POST requests
- if (req.method !== 'POST') {
- return badRequestResponse('Method not allowed', webhookCorsHeaders, {
- Allow: 'POST',
- ...buildSecurityHeaders(),
- });
- }
-
- // Read raw body for signature verification (before parsing)
- const rawBody = await req.text();
-
- // Verify webhook signature - INTERNAL_API_SECRET is required for security
- const webhookSecret = Deno.env.get('INTERNAL_API_SECRET');
- if (!webhookSecret) {
- await logError(
- 'INTERNAL_API_SECRET environment variable is not configured - rejecting request for security',
- logContext,
- new Error('Missing INTERNAL_API_SECRET')
- );
- return await errorResponse(
- new Error('Server configuration error: INTERNAL_API_SECRET is not set'),
- 'embedding-webhook:config_error',
- webhookCorsHeaders
- );
- }
-
- // Check for common signature header names
- const signature =
- req.headers.get('x-supabase-signature') ||
- req.headers.get('x-webhook-signature') ||
- req.headers.get('x-signature');
- const timestamp = req.headers.get('x-webhook-timestamp') || req.headers.get('x-timestamp');
-
- if (!signature) {
- const headerNames: string[] = [];
- req.headers.forEach((_, key) => {
- headerNames.push(key);
- });
- logWarn('Missing webhook signature header', {
- ...logContext,
- headers: headerNames,
- });
- return unauthorizedResponse('Missing webhook signature', webhookCorsHeaders);
- }
-
- // Validate timestamp if provided (prevent replay attacks)
- if (timestamp) {
- const timestampMs = Number.parseInt(timestamp, 10);
- if (Number.isNaN(timestampMs)) {
- return badRequestResponse('Invalid timestamp format', webhookCorsHeaders);
- }
-
- const now = Date.now();
- const timestampAge = now - timestampMs;
- const maxAge = 5 * 60 * 1000; // 5 minutes
- // Allow 30 seconds into the future for clock skew
- const futureTolerance = 30 * 1000; // 30 seconds
-
- if (timestampAge > maxAge || timestampAge < -futureTolerance) {
- logWarn('Webhook timestamp too old or too far in future', {
- ...logContext,
- timestamp: timestampMs,
- now,
- age_ms: timestampAge,
- });
- return unauthorizedResponse(
- 'Webhook timestamp out of acceptable range',
- webhookCorsHeaders
- );
- }
- }
-
- const isValid = await verifySupabaseDatabaseWebhook({
- rawBody,
- signature,
- timestamp: timestamp || null,
- secret: webhookSecret,
- });
-
- if (!isValid) {
- logWarn('Webhook signature verification failed', {
- ...logContext,
- has_timestamp: !!timestamp,
- });
- return unauthorizedResponse('Invalid webhook signature', webhookCorsHeaders);
- }
-
- logInfo('Webhook signature verified', {
- ...logContext,
- has_timestamp: !!timestamp,
- });
-
- // Parse webhook payload (create new request from raw body since we already read it)
- const parseResult = await parseJsonBody(
- new Request(req.url, {
- method: req.method,
- headers: req.headers,
- body: rawBody,
- }),
- {
- maxSize: 100 * 1024, // 100KB max payload
- cors: webhookCorsHeaders,
- }
- );
-
- if (!parseResult.success) {
- return parseResult.response;
- }
-
- const payload = parseResult.data;
-
- // Validate webhook source (defense-in-depth: ensure webhook is from expected table)
- if (payload.schema !== 'public' || payload.table !== 'content') {
- logWarn('Unexpected webhook source', {
- ...logContext,
- schema: payload.schema,
- table: payload.table,
- });
- return badRequestResponse(
- 'Unexpected webhook source',
- webhookCorsHeaders,
- buildSecurityHeaders()
- );
- }
-
- // Validate payload structure
- if (!payload.record?.id) {
- const securityHeaders = buildSecurityHeaders();
- return badRequestResponse(
- 'Invalid webhook payload: missing record.id',
- webhookCorsHeaders,
- securityHeaders
- );
- }
-
- // Only process INSERT and UPDATE events
- if (payload.type === 'DELETE') {
- // Deletions are handled by CASCADE in database
- logInfo('Content deleted, embedding will be CASCADE deleted', {
- ...logContext,
- content_id: payload.old_record?.id,
- });
- const securityHeaders = buildSecurityHeaders();
- return successResponse({ skipped: true, reason: 'delete_event' }, 200, {
- ...webhookCorsHeaders,
- ...securityHeaders,
- });
- }
-
- const { record } = payload;
- const contentId = record.id;
-
- // Build searchable text
- const searchableText = buildSearchableText(record);
-
- if (!searchableText || searchableText.trim().length === 0) {
- logInfo('Skipping embedding generation: empty searchable text', {
- ...logContext,
- content_id: contentId,
- });
- const securityHeaders = buildSecurityHeaders();
- return successResponse({ skipped: true, reason: 'empty_text' }, 200, {
- ...webhookCorsHeaders,
- ...securityHeaders,
- });
- }
-
- // Generate embedding (with circuit breaker + timeout)
- logInfo('Generating embedding', {
- ...logContext,
- content_id: contentId,
- text_length: searchableText.length,
- });
-
- const embedding = await generateEmbedding(searchableText);
-
- // Store embedding (with circuit breaker + timeout)
- await storeEmbedding(contentId, searchableText, embedding);
-
- logInfo('Embedding generated and stored', {
- ...logContext,
- content_id: contentId,
- embedding_dim: embedding.length,
- });
-
- const securityHeaders = buildSecurityHeaders();
- return successResponse(
- {
- success: true,
- content_id: contentId,
- embedding_dim: embedding.length,
- },
- 200,
- { ...webhookCorsHeaders, ...securityHeaders }
- );
- });
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/image-generation/index.ts b/apps/edge/supabase/functions/public-api/routes/image-generation/index.ts
deleted file mode 100644
index 4165ec95a..000000000
--- a/apps/edge/supabase/functions/public-api/routes/image-generation/index.ts
+++ /dev/null
@@ -1,496 +0,0 @@
-/**
- * Image Generation Queue Worker
- * Processes image_generation queue: Generate content cards, thumbnails, and optimize logos
- *
- * Flow:
- * 1. Read batch from image_generation queue
- * 2. For each message: Route to appropriate handler (card/thumbnail/logo)
- * 3. Call edge function API internally
- * 4. Delete message on success, leave in queue for retry on failure
- *
- * Route: POST /image-generation/process
- */
-
-import { edgeEnv } from '@heyclaude/edge-runtime/config/env.ts';
-import { initRequestLogging, traceRequestComplete, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { pgmqDelete, pgmqRead } from '@heyclaude/edge-runtime/utils/pgmq-client.ts';
-import { createUtilityContext, withContext, logError, logInfo } from '@heyclaude/shared-runtime/logging.ts';
-import { normalizeError } from '@heyclaude/shared-runtime/error-handling.ts';
-import { TIMEOUT_PRESETS, withTimeout } from '@heyclaude/shared-runtime/timeout.ts';
-
-const IMAGE_GENERATION_QUEUE = 'image_generation';
-const QUEUE_BATCH_SIZE = 5; // Process 5 messages at a time
-const MAX_RETRY_ATTEMPTS = 5; // Maximum number of retry attempts before giving up
-
-/**
- * Image generation queue message format
- */
-interface ImageGenerationMessage {
- type: 'card' | 'thumbnail' | 'logo';
- content_id?: string;
- company_id?: string;
- image_url?: string;
- image_data?: string; // base64 for thumbnails/logos
- params?: {
- // For cards
- title?: string;
- description?: string;
- category?: string;
- slug?: string;
- author?: string;
- tags?: string[];
- featured?: boolean;
- rating?: number | null;
- viewCount?: number;
- };
- priority: 'high' | 'normal' | 'low';
- created_at: string;
-}
-
-/**
- * Process a single image-generation job by invoking the appropriate internal image transform API.
- *
- * Supports message types `card`, `thumbnail`, and `logo` and constructs the request payload from the message fields.
- *
- * @param message - Image generation job payload (uses `type`, `content_id`, `company_id`, `image_data`, and `params`)
- * @param logContext - Context object used for structured logging
- * @returns `{ success: true }` when the job succeeded, `{ success: false, error: string }` when it failed
- */
-async function processImageGeneration(
- message: ImageGenerationMessage,
- logContext: Record
-): Promise<{ success: boolean; error?: string }> {
- const { type, content_id, company_id, params } = message;
-
- try {
- const apiUrl = edgeEnv.supabase.url;
- const serviceRoleKey = edgeEnv.supabase.serviceRoleKey;
-
- if (!apiUrl || !serviceRoleKey) {
- throw new Error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY');
- }
-
- let endpoint: string;
- let requestBody: Record;
-
- if (type === 'card') {
- endpoint = `${apiUrl}/functions/v1/public-api/transform/image/card`;
- requestBody = {
- params: {
- title: params?.title ?? '',
- description: params?.description ?? '',
- category: params?.category ?? '',
- tags: params?.tags ?? [],
- author: params?.author ?? '',
- featured: params?.featured ?? false,
- rating: params?.rating ?? null,
- viewCount: params?.viewCount ?? 0,
- },
- contentId: content_id,
- userId: 'system', // System-generated
- saveToStorage: true,
- };
- } else if (type === 'thumbnail') {
- endpoint = `${apiUrl}/functions/v1/public-api/transform/image/thumbnail`;
- requestBody = {
- imageData: message.image_data,
- userId: 'system',
- contentId: content_id,
- useSlug: false,
- saveToStorage: true,
- };
- } else if (type === 'logo') {
- endpoint = `${apiUrl}/functions/v1/public-api/transform/image/logo`;
- requestBody = {
- imageData: message.image_data,
- userId: 'system',
- companyId: company_id,
- saveToStorage: true,
- };
- } else {
- throw new Error(`Unknown image generation type: ${type}`);
- }
-
- logInfo(`Calling image generation API: ${type}`, {
- ...logContext,
- type,
- content_id,
- company_id,
- endpoint,
- });
-
- // Use external timeout (10s) with additional buffer for image processing
- // Image generation can take longer, so we use 30s (same as RPC timeout)
- const response = await withTimeout(
- fetch(endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${serviceRoleKey}`,
- },
- body: JSON.stringify(requestBody),
- }),
- TIMEOUT_PRESETS.rpc, // 30 seconds - image processing can be slow
- `Image generation API call timed out (${type})`
- );
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Image generation failed (${response.status}): ${errorText}`);
- }
-
- // Parse JSON response, with fallback for non-JSON responses (e.g., HTML error pages)
- // Clone response before parsing so we can read the body as text if JSON parsing fails
- const responseClone = response.clone();
- let result: { success?: boolean; error?: string; publicUrl?: string };
- try {
- result = await response.json();
- } catch (parseError) {
- // If JSON parsing fails, log the parse error and read the raw body for better error reporting
- const normalized = normalizeError(parseError, 'JSON parsing failed for image generation response');
- await logError('Image generation response JSON parse failed', { ...logContext, err: normalized });
- const rawBody = await responseClone.text().catch(() => 'Unable to read response body');
- throw new Error(
- `Image generation API returned non-JSON response (${response.status} ${response.statusText}): ${rawBody}`
- );
- }
-
- if (!result.success) {
- throw new Error(result.error || 'Image generation failed');
- }
-
- logInfo(`Image generation successful: ${type}`, {
- ...logContext,
- type,
- content_id,
- company_id,
- publicUrl: result.publicUrl,
- });
-
- return { success: true };
- } catch (error) {
- const errorMessage = normalizeError(error, "Operation failed").message;
- await logError(`Image generation error (${type})`, logContext, error);
- return {
- success: false,
- error: errorMessage,
- };
- }
-}
-
-/**
- * Process up to QUEUE_BATCH_SIZE image-generation queue messages and return a summary of results.
- *
- * Validates each message, invokes the appropriate image transformation, deletes messages that
- * are invalid or successfully processed, and removes messages that have exceeded the maximum
- * retry attempts; messages that fail but have remaining attempts are left for retry.
- *
- * @returns A Response containing a JSON summary of processing results. On normal completion returns status 200 with `{ processed, total, success, failed, results }`. On an unexpected error returns status 500 with `{ error: 'An unexpected error occurred', processed: 0 }`.
- */
-export async function handleImageGenerationQueue(_req: Request): Promise {
- const logContext = createUtilityContext('image-generation', 'queue-processor', {});
-
- // Initialize request logging with trace and bindings
- initRequestLogging(logContext);
- traceStep('Starting image generation queue processing', logContext);
-
- try {
- // Read messages with timeout protection
- traceStep('Reading image generation queue', logContext);
- const messages = await withTimeout(
- pgmqRead(IMAGE_GENERATION_QUEUE, {
- sleep_seconds: 0,
- n: QUEUE_BATCH_SIZE,
- }),
- TIMEOUT_PRESETS.rpc,
- 'Image generation queue read timed out'
- );
-
- if (!messages || messages.length === 0) {
- traceRequestComplete(logContext);
- return new Response(
- JSON.stringify({ message: 'No messages in queue', processed: 0 }),
- {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- }
- );
- }
-
- logInfo(`Processing ${messages.length} image generation jobs`, {
- ...logContext,
- count: messages.length,
- });
- traceStep(`Processing ${messages.length} image generation jobs`, logContext);
-
- const results: Array<{
- msg_id: string;
- status: 'success' | 'failed';
- error?: string;
- }> = [];
-
- for (const msg of messages) {
- // Validate message structure before type assertion
- const rawMessage = msg.message as Record;
- if (!rawMessage || typeof rawMessage['type'] !== 'string' || !['card', 'thumbnail', 'logo'].includes(rawMessage['type'] as string)) {
- const messageLogContext = withContext(logContext, {
- action: 'image-generation.invalid-message',
- msg_id: msg.msg_id.toString(),
- });
- await logError(
- 'Invalid queue message format',
- messageLogContext,
- new Error(`Invalid message type: ${JSON.stringify(rawMessage)}`)
- );
-
- // Delete invalid message to prevent infinite retry loop
- try {
- await pgmqDelete(IMAGE_GENERATION_QUEUE, msg.msg_id);
- logInfo('Deleted invalid message', messageLogContext);
- } catch (deleteError) {
- await logError(
- 'Failed to delete invalid message',
- messageLogContext,
- deleteError
- );
- }
-
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- error: 'Invalid queue message format',
- });
- continue;
- }
-
- // Type assertion is safe after validation
- const queueMessage = rawMessage as unknown as ImageGenerationMessage;
-
- // Validate type-specific required fields
- if (queueMessage.type === 'card' && (!queueMessage.params?.title || !queueMessage.params.title.trim())) {
- const messageLogContext = withContext(logContext, {
- action: 'image-generation.invalid-message',
- msg_id: msg.msg_id.toString(),
- content_id: queueMessage.content_id,
- });
- await logError(
- 'Card generation message missing required title',
- messageLogContext,
- new Error('Card generation requires params.title')
- );
-
- // Delete invalid message (card generation cannot proceed without title)
- try {
- await pgmqDelete(IMAGE_GENERATION_QUEUE, msg.msg_id);
- logInfo('Deleted invalid card message (missing title)', messageLogContext);
- } catch (deleteError) {
- await logError(
- 'Failed to delete invalid message',
- messageLogContext,
- deleteError
- );
- }
-
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- error: 'Card generation requires params.title',
- });
- continue;
- }
-
- // Validate thumbnail has image_data
- if (queueMessage.type === 'thumbnail' && !queueMessage.image_data) {
- const messageLogContext = withContext(logContext, {
- action: 'image-generation.invalid-message',
- msg_id: msg.msg_id.toString(),
- content_id: queueMessage.content_id,
- });
- await logError(
- 'Thumbnail generation message missing required image_data',
- messageLogContext,
- new Error('Thumbnail generation requires image_data')
- );
-
- // Delete invalid message (thumbnail generation cannot proceed without image_data)
- try {
- await pgmqDelete(IMAGE_GENERATION_QUEUE, msg.msg_id);
- logInfo('Deleted invalid thumbnail message (missing image_data)', messageLogContext);
- } catch (deleteError) {
- await logError(
- 'Failed to delete invalid message',
- messageLogContext,
- deleteError
- );
- }
-
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- error: 'Thumbnail generation requires image_data',
- });
- continue;
- }
-
- // Validate logo has image_data and company_id
- if (queueMessage.type === 'logo' && (!queueMessage.image_data || !queueMessage.company_id)) {
- const messageLogContext = withContext(logContext, {
- action: 'image-generation.invalid-message',
- msg_id: msg.msg_id.toString(),
- company_id: queueMessage.company_id,
- has_image_data: !!queueMessage.image_data,
- });
- await logError(
- 'Logo generation message missing required fields',
- messageLogContext,
- new Error('Logo generation requires image_data and company_id')
- );
-
- // Delete invalid message (logo generation cannot proceed without image_data and company_id)
- try {
- await pgmqDelete(IMAGE_GENERATION_QUEUE, msg.msg_id);
- logInfo('Deleted invalid logo message (missing required fields)', messageLogContext);
- } catch (deleteError) {
- await logError(
- 'Failed to delete invalid message',
- messageLogContext,
- deleteError
- );
- }
-
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- error: 'Logo generation requires image_data and company_id',
- });
- continue;
- }
-
- const message: ImageGenerationMessage = {
- type: queueMessage.type,
- ...(queueMessage.content_id ? { content_id: queueMessage.content_id } : {}),
- ...(queueMessage.company_id ? { company_id: queueMessage.company_id } : {}),
- ...(queueMessage.image_url ? { image_url: queueMessage.image_url } : {}),
- ...(queueMessage.image_data ? { image_data: queueMessage.image_data } : {}),
- ...(queueMessage.params ? { params: queueMessage.params } : {}),
- priority: queueMessage.priority ?? 'normal',
- created_at: queueMessage.created_at ?? new Date().toISOString(),
- };
-
- const attempts = Number(msg.read_ct ?? 0) + 1;
- logInfo('Processing image generation message', {
- ...logContext,
- msg_id: msg.msg_id.toString(),
- type: message.type,
- content_id: message.content_id,
- company_id: message.company_id,
- attempt: attempts,
- });
-
- try {
- const result = await processImageGeneration(message, logContext);
-
- if (result.success) {
- await pgmqDelete(IMAGE_GENERATION_QUEUE, msg.msg_id);
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'success',
- });
- } else {
- // Check if message has exceeded maximum retry attempts
- if (attempts >= MAX_RETRY_ATTEMPTS) {
- const error = result.error ? new Error(result.error) : new Error('Unknown error');
- await logError(
- 'Message exceeded max retry attempts, removing from queue',
- {
- ...logContext,
- msg_id: msg.msg_id.toString(),
- attempts,
- max_attempts: MAX_RETRY_ATTEMPTS,
- },
- error
- );
- // Delete message to prevent infinite retry loop
- // Optionally: could move to dead-letter queue for investigation
- try {
- await pgmqDelete(IMAGE_GENERATION_QUEUE, msg.msg_id);
- logInfo('Deleted message after exceeding max retry attempts', {
- ...logContext,
- msg_id: msg.msg_id.toString(),
- attempts,
- });
- } catch (deleteError) {
- await logError(
- 'Failed to delete message after max retries',
- {
- ...logContext,
- msg_id: msg.msg_id.toString(),
- },
- deleteError
- );
- }
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- error: `Max retry attempts (${MAX_RETRY_ATTEMPTS}) exceeded: ${result.error || 'Unknown error'}`,
- });
- } else {
- // Leave in queue for retry (visibility timeout will handle retry)
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- ...(result.error ? { error: result.error } : {}),
- });
- }
- }
- } catch (error) {
- await logError('Unexpected error processing image generation', logContext, error);
- // Leave in queue for retry
- results.push({
- msg_id: msg.msg_id.toString(),
- status: 'failed',
- error: normalizeError(error, "Operation failed").message,
- });
- }
- }
-
- const successCount = results.filter((r) => r.status === 'success').length;
- const failedCount = results.filter((r) => r.status === 'failed').length;
-
- logInfo('Image generation queue processing complete', {
- ...logContext,
- total: messages.length,
- success: successCount,
- failed: failedCount,
- });
- traceRequestComplete(logContext);
-
- return new Response(
- JSON.stringify({
- processed: successCount,
- total: messages.length,
- success: successCount,
- failed: failedCount,
- results,
- }),
- {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- }
- );
- } catch (error) {
- // Log full error details server-side for troubleshooting
- await logError('Image generation queue worker error', logContext, error);
- // Never expose internal error details to users - always use generic message
- return new Response(
- JSON.stringify({
- error: 'An unexpected error occurred',
- processed: 0,
- }),
- {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- }
- );
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/transform/image/card.ts b/apps/edge/supabase/functions/public-api/routes/transform/image/card.ts
deleted file mode 100644
index 6dd9456b6..000000000
--- a/apps/edge/supabase/functions/public-api/routes/transform/image/card.ts
+++ /dev/null
@@ -1,591 +0,0 @@
-///
-
-/**
- * Content Card Image Generator
- * Generates social media preview cards for content items (agents, MCP servers, hooks, etc.)
- */
-
-import { ImageResponse } from 'https://deno.land/x/og_edge@0.0.4/mod.ts';
-import React from 'react';
-import { publicCorsHeaders, jsonResponse } from '@heyclaude/edge-runtime/utils/http.ts';
-import { initRequestLogging, traceRequestComplete, traceStep } from '@heyclaude/edge-runtime/utils/logger-helpers.ts';
-import { uploadObject } from '@heyclaude/edge-runtime/utils/storage/upload.ts';
-import { getStorageServiceClient } from '@heyclaude/edge-runtime/utils/storage/client.ts';
-import { createDataApiContext, logError, logInfo, logger } from '@heyclaude/shared-runtime/logging.ts';
-import { normalizeError } from '@heyclaude/shared-runtime/error-handling.ts';
-import {
- ensureImageMagickInitialized,
- getImageDimensions,
- optimizeImage,
-} from '@heyclaude/shared-runtime/image/manipulation.ts';
-import { MagickFormat } from '@imagemagick/magick-wasm';
-
-const CORS = publicCorsHeaders;
-const CARD_WIDTH = 1200;
-const CARD_HEIGHT = 630; // Same as OG images for consistency
-const CARD_MAX_DIMENSION = 1200;
-const CARD_QUALITY = 85;
-const BUCKET_SIZE_LIMIT = 200 * 1024; // 200KB
-
-export interface ContentCardParams {
- title: string;
- description?: string;
- category?: string;
- tags?: string[];
- author?: string;
- authorAvatar?: string; // URL to avatar image
- featured?: boolean;
- rating?: number;
- viewCount?: number;
- backgroundColor?: string;
- textColor?: string;
- accentColor?: string;
-}
-
-export interface ContentCardGenerateRequest {
- params: ContentCardParams;
- userId?: string;
- contentId?: string; // Optional: to update content.og_image
- useSlug?: boolean; // If true, use slug instead of ID
- oldCardPath?: string; // Optional: path to old card to delete
- saveToStorage?: boolean; // Default: true
- maxDimension?: number;
-}
-
-export interface ContentCardGenerateResponse {
- success: boolean;
- publicUrl?: string;
- path?: string;
- originalSize?: number;
- optimizedSize?: number;
- dimensions?: { width: number; height: number };
- error?: string;
- warning?: string; // Warning message when storage upload is skipped (e.g., missing userId)
-}
-
-/**
- * Generate a social-preview content card image from the provided card parameters.
- *
- * @param params - Card content and style options (title, description, category, tags, author, authorAvatar, featured, rating, viewCount, backgroundColor, textColor, accentColor)
- * @returns An ImageResponse containing the rendered card image with dimensions 1200×630
- */
-function generateContentCardImage(params: ContentCardParams): Response {
- const {
- title,
- description,
- category,
- tags = [],
- author,
- featured = false,
- rating,
- viewCount,
- backgroundColor = '#1a1410',
- textColor = '#ffffff',
- accentColor = '#FF6F4A',
- } = params;
-
- return new ImageResponse(
- React.createElement(
- 'div',
- {
- style: {
- height: '100%',
- width: '100%',
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'flex-start',
- justifyContent: 'space-between',
- backgroundColor,
- backgroundImage:
- 'radial-gradient(circle at 25px 25px, rgba(42, 32, 16, 0.3) 2%, transparent 0%), radial-gradient(circle at 75px 75px, rgba(42, 32, 16, 0.3) 2%, transparent 0%)',
- backgroundSize: '100px 100px',
- padding: '60px',
- position: 'relative',
- },
- },
- // Featured badge (top right)
- featured &&
- React.createElement(
- 'div',
- {
- style: {
- position: 'absolute',
- top: '40px',
- right: '40px',
- backgroundColor: accentColor,
- color: '#1A1B17',
- padding: '8px 20px',
- borderRadius: '9999px',
- fontSize: '18px',
- fontWeight: '800',
- textTransform: 'uppercase',
- letterSpacing: '0.05em',
- },
- },
- 'Featured'
- ),
- React.createElement(
- 'div',
- {
- style: {
- display: 'flex',
- flexDirection: 'column',
- gap: '24px',
- width: '100%',
- flex: 1,
- },
- },
- // Category badge
- category &&
- React.createElement(
- 'div',
- { style: { display: 'flex', alignItems: 'center', gap: '12px' } },
- React.createElement(
- 'div',
- {
- style: {
- backgroundColor: accentColor,
- color: '#1A1B17',
- padding: '6px 18px',
- borderRadius: '9999px',
- fontSize: '20px',
- fontWeight: '800',
- textTransform: 'uppercase',
- letterSpacing: '0.05em',
- },
- },
- category
- )
- ),
- // Title
- React.createElement(
- 'h1',
- {
- style: {
- fontSize: '72px',
- fontWeight: '800',
- color: textColor,
- lineHeight: '1.1',
- margin: '0',
- maxWidth: '1000px',
- fontFamily: 'system-ui, -apple-system, sans-serif',
- },
- },
- title
- ),
- // Description
- description &&
- React.createElement(
- 'p',
- {
- style: {
- fontSize: '32px',
- color: '#9ca3af',
- lineHeight: '1.4',
- margin: '0',
- maxWidth: '900px',
- fontFamily: 'system-ui, -apple-system, sans-serif',
- },
- },
- description
- ),
- // Tags
- tags.length > 0 &&
- React.createElement(
- 'div',
- {
- style: {
- display: 'flex',
- flexWrap: 'wrap',
- gap: '10px',
- marginTop: '8px',
- },
- },
- ...tags.slice(0, 5).map((tag) =>
- React.createElement(
- 'div',
- {
- key: tag,
- style: {
- backgroundColor: '#2a2010',
- color: accentColor,
- padding: '4px 14px',
- borderRadius: '9999px',
- fontSize: '18px',
- fontWeight: '600',
- border: '1px solid #3a3020',
- fontFamily: 'system-ui, -apple-system, sans-serif',
- },
- },
- tag
- )
- )
- )
- ),
- // Bottom section
- React.createElement(
- 'div',
- {
- style: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- width: '100%',
- marginTop: 'auto',
- },
- },
- // Author section (left)
- author &&
- React.createElement(
- 'div',
- {
- style: {
- display: 'flex',
- flexDirection: 'column',
- gap: '4px',
- },
- },
- React.createElement(
- 'div',
- {
- style: {
- fontSize: '20px',
- color: textColor,
- fontWeight: '600',
- fontFamily: 'system-ui, -apple-system, sans-serif',
- },
- },
- author
- ),
- (rating !== undefined || viewCount !== undefined) &&
- React.createElement(
- 'div',
- {
- style: {
- display: 'flex',
- alignItems: 'center',
- gap: '16px',
- fontSize: '16px',
- color: '#9ca3af',
- fontFamily: 'system-ui, -apple-system, sans-serif',
- },
- },
- rating !== undefined &&
- React.createElement('span', {}, `⭐ ${String(rating.toFixed(1))}`),
- viewCount !== undefined &&
- React.createElement('span', {}, `👁️ ${String(viewCount.toLocaleString())}`)
- )
- ),
- // Domain (right)
- React.createElement(
- 'div',
- {
- style: {
- fontSize: '22px',
- color: '#6b7280',
- fontFamily: 'system-ui, -apple-system, sans-serif',
- fontWeight: 500,
- letterSpacing: '0.01em',
- },
- },
- 'claudepro.directory'
- )
- )
- ),
- {
- width: CARD_WIDTH,
- height: CARD_HEIGHT,
- }
- );
-}
-
-/**
- * Handle POST requests to generate a content card image, optimize it, and optionally store and register it.
- *
- * Expects a JSON body matching ContentCardGenerateRequest with a required `params.title`. Generates a 1200x630 card image, runs image optimization and size validation, and — unless `saveToStorage` is false or `userId` is missing — uploads the optimized image to the `content-cards` storage bucket. When an upload succeeds the handler may delete an `oldCardPath` and update the content record (`contentId`) in the database (by id or slug). Handles CORS preflight (OPTIONS) and returns appropriate HTTP error statuses for invalid methods, invalid input, optimization failures, upload failures, and size limit violations.
- *
- * @returns An HTTP Response whose JSON payload conforms to ContentCardGenerateResponse. On success (200) the payload includes `success: true` and metadata such as `originalSize`, `optimizedSize`, `dimensions`, and, if applicable, `publicUrl` and `path`. Error responses use 400, 405, or 500 with `success: false` and an `error` message.
- */
-export async function handleContentCardGenerateRoute(req: Request): Promise {
- const logContext = createDataApiContext('transform-image-card', {
- path: 'transform/image/card',
- method: 'POST',
- app: 'public-api',
- });
-
- // Initialize request logging with trace and bindings
- initRequestLogging(logContext);
- traceStep('Content card generation request received', logContext);
-
- // Set bindings for this request
- logger.setBindings({
- requestId: typeof logContext['request_id'] === "string" ? logContext['request_id'] : undefined,
- operation: typeof logContext['action'] === "string" ? logContext['action'] : 'card-generate',
- method: req.method,
- });
-
- // Handle CORS preflight
- if (req.method === 'OPTIONS') {
- return new Response(null, {
- status: 204,
- headers: CORS,
- });
- }
-
- if (req.method !== 'POST') {
- return jsonResponse(
- {
- success: false,
- error: 'Method not allowed. Use POST.',
- } satisfies ContentCardGenerateResponse,
- 405,
- CORS
- );
- }
-
- try {
- traceStep('Processing content card generation', logContext);
- // Parse request body
- let body: ContentCardGenerateRequest;
- const contentType = req.headers.get('content-type') || '';
-
- if (contentType.includes('application/json')) {
- body = await req.json();
- } else {
- return jsonResponse(
- {
- success: false,
- error: 'Content-Type must be application/json',
- } satisfies ContentCardGenerateResponse,
- 400,
- CORS
- );
- }
-
- // Validate required fields
- if (!body.params || !body.params.title) {
- return jsonResponse(
- {
- success: false,
- error: 'Missing required field: params.title',
- } satisfies ContentCardGenerateResponse,
- 400,
- CORS
- );
- }
-
- // Generate the card image
- const cardResponse = generateContentCardImage(body.params);
- const cardImageData = new Uint8Array(await cardResponse.arrayBuffer());
-
- logInfo('Content card generated', {
- ...logContext,
- title: body.params.title,
- category: body.params.category || 'none',
- tagsCount: body.params.tags?.length || 0,
- originalSize: cardImageData.length,
- });
-
- // Optimize the image
- await ensureImageMagickInitialized();
- const maxDimension = body.maxDimension ?? CARD_MAX_DIMENSION;
- const optimizedImage = await optimizeImage(
- cardImageData,
- maxDimension,
- MagickFormat.Png,
- CARD_QUALITY
- );
-
- // Detect actual format
- const isPng = optimizedImage[0] === 0x89 && optimizedImage[1] === 0x50 &&
- optimizedImage[2] === 0x4e && optimizedImage[3] === 0x47;
- const isJpeg = optimizedImage[0] === 0xff && optimizedImage[1] === 0xd8;
- const actualFormat = isPng ? 'png' : (isJpeg ? 'jpeg' : 'png');
- const actualMimeType = isPng ? 'image/png' : (isJpeg ? 'image/jpeg' : 'image/png');
-
- if (!isPng && !isJpeg) {
- await logError('Optimized card format is unrecognized', logContext, new Error('Invalid image format'));
- return jsonResponse(
- {
- success: false,
- error: 'Image optimization failed - output format is invalid',
- } satisfies ContentCardGenerateResponse,
- 500,
- CORS
- );
- }
-
- const optimizedDimensions = await getImageDimensions(optimizedImage);
-
- logInfo('Content card optimized', {
- ...logContext,
- originalSize: cardImageData.length,
- optimizedSize: optimizedImage.length,
- compressionRatio: ((1 - optimizedImage.length / cardImageData.length) * 100).toFixed(1) + '%',
- dimensions: `${optimizedDimensions.width}x${optimizedDimensions.height}`,
- });
-
- // Validate optimized image size
- if (optimizedImage.length > BUCKET_SIZE_LIMIT) {
- await logError('Optimized card exceeds bucket size limit', logContext, new Error(`Size: ${optimizedImage.length} bytes, limit: ${BUCKET_SIZE_LIMIT} bytes`));
- return jsonResponse(
- {
- success: false,
- error: `Optimized card too large (${Math.round(optimizedImage.length / 1024)}KB). Maximum allowed: ${BUCKET_SIZE_LIMIT / 1024}KB.`,
- } satisfies ContentCardGenerateResponse,
- 400,
- CORS
- );
- }
-
- // Upload to storage if requested (default: true)
- // Note: If saveToStorage is true but userId is missing, we skip upload and return success
- // without publicUrl/path. Clients should check for publicUrl if they expect a stored asset.
- let publicUrl: string | undefined;
- let path: string | undefined;
- let storageSkipped = false;
-
- if (body.saveToStorage !== false) {
- if (!body.userId) {
- logInfo('Skipping storage upload - userId not provided (saveToStorage requires userId)', logContext);
- storageSkipped = true;
- } else {
- const arrayBuffer = optimizedImage.buffer.slice(
- optimizedImage.byteOffset,
- optimizedImage.byteOffset + optimizedImage.byteLength
- ) as ArrayBuffer;
-
- logInfo('Uploading content card to storage', {
- ...logContext,
- optimizedSize: optimizedImage.length,
- bucket: 'content-cards',
- });
-
- const uploadResult = await uploadObject({
- bucket: 'content-cards',
- buffer: arrayBuffer,
- mimeType: actualMimeType,
- pathOptions: {
- userId: body.userId,
- fileName: 'content-card',
- extension: actualFormat,
- includeTimestamp: true,
- sanitize: true,
- },
- cacheControl: '31536000', // 1 year cache
- });
-
- if (!uploadResult.success || !uploadResult.publicUrl) {
- await logError('Failed to upload content card', logContext, new Error(uploadResult.error || 'Unknown upload error'));
- return jsonResponse(
- {
- success: false,
- error: uploadResult.error || 'Failed to upload content card to storage',
- } satisfies ContentCardGenerateResponse,
- 500,
- CORS
- );
- }
-
- publicUrl = uploadResult.publicUrl;
- path = uploadResult.path;
-
- // Delete old card if provided
- if (body.oldCardPath && path) {
- try {
- const supabase = getStorageServiceClient();
- const { error: deleteError } = await supabase.storage
- .from('content-cards')
- .remove([body.oldCardPath]);
- if (deleteError) {
- await logError('Error deleting old card', logContext, deleteError);
- } else {
- logInfo('Old content card deleted', { ...logContext, oldPath: body.oldCardPath });
- }
- } catch (error) {
- await logError('Error deleting old card', logContext, error);
- }
- }
-
- // Update database if contentId provided
- if (body.contentId && publicUrl) {
- try {
- const supabase = getStorageServiceClient();
- const updateData = { og_image: publicUrl };
-
- if (body.useSlug) {
- const { error: updateError } = await supabase
- .from('content')
- .update(updateData)
- .eq('slug', body.contentId);
- if (updateError) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('Error updating content card in database (slug)', {
- ...logContext,
- dbQuery: {
- table: 'content',
- operation: 'update',
- schema: 'public',
- args: {
- slug: body.contentId,
- // Update fields redacted by Pino's redact config
- },
- },
- }, updateError);
- } else {
- logInfo('Content card URL updated in database (slug)', { ...logContext, slug: body.contentId });
- }
- } else {
- const { error: updateError } = await supabase
- .from('content')
- .update(updateData)
- .eq('id', body.contentId);
- if (updateError) {
- // Use dbQuery serializer for consistent database query formatting
- await logError('Error updating content card in database (id)', {
- ...logContext,
- dbQuery: {
- table: 'content',
- operation: 'update',
- schema: 'public',
- args: {
- id: body.contentId,
- // Update fields redacted by Pino's redact config
- },
- },
- }, updateError);
- } else {
- logInfo('Content card URL updated in database (id)', { ...logContext, contentId: body.contentId });
- }
- }
- } catch (error) {
- await logError('Error updating content card in database', logContext, error);
- }
- }
- }
- }
-
- const response: ContentCardGenerateResponse = {
- success: true,
- ...(publicUrl ? { publicUrl } : {}),
- ...(storageSkipped ? { warning: 'Storage upload skipped: userId required when saveToStorage is true' } : {}),
- ...(path ? { path } : {}),
- originalSize: cardImageData.length,
- optimizedSize: optimizedImage.length,
- ...(optimizedDimensions ? { dimensions: optimizedDimensions } : {}),
- };
-
- traceRequestComplete(logContext);
- return jsonResponse(response, 200, CORS);
- } catch (error) {
- await logError('Content card generation failed', logContext, error);
- return jsonResponse(
- {
- success: false,
- error: normalizeError(error, 'Unknown error occurred').message,
- } satisfies ContentCardGenerateResponse,
- 500,
- CORS
- );
- }
-}
\ No newline at end of file
diff --git a/apps/edge/supabase/functions/public-api/routes/transform/image/logo.ts b/apps/edge/supabase/functions/public-api/routes/transform/image/logo.ts
deleted file mode 100644
index 17a5ce8bd..000000000
--- a/apps/edge/supabase/functions/public-api/routes/transform/image/logo.ts
+++ /dev/null
@@ -1,426 +0,0 @@
-///