feat: deploy standalone Hono/Bun auth and API backend

This commit is contained in:
2026-05-29 13:38:44 -07:00
parent 7c8def0aaa
commit 62e73eedd2
86 changed files with 16694 additions and 38 deletions

90
src/db/client.ts Normal file
View File

@@ -0,0 +1,90 @@
// Database client using Drizzle ORM and Turso (libsql)
import type { Client } from '@libsql/client';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import type { Env } from '../types/env';
import * as schema from './schema';
// Cache for database instances
let dbInstance: ReturnType<typeof drizzle<typeof schema>> | null = null;
let clientInstance: Client | null = null;
/**
* Reset database cache (useful for tests)
*/
export function resetDbCache() {
dbInstance = null;
clientInstance = null;
}
/**
* Get database instance (works in both Bun and Cloudflare Workers)
*/
export function getDb(env?: Env) {
if (dbInstance && clientInstance) {
return { db: dbInstance, client: clientInstance };
}
// Get database URL and auth token from environment
let databaseUrl: string | undefined;
let authToken: string | undefined;
if (typeof Bun !== 'undefined') {
// Check if we're running in test mode
// The test runner (run-tests.ts) overrides TURSO_DATABASE_URL with the test database
// so we can just use the regular environment variables
databaseUrl = Bun.env.TURSO_DATABASE_URL;
authToken = Bun.env.TURSO_AUTH_TOKEN;
} else if (env) {
// Cloudflare Workers (from context.env)
databaseUrl = env.TURSO_DATABASE_URL;
authToken = env.TURSO_AUTH_TOKEN;
}
if (!databaseUrl) {
throw new Error('TURSO_DATABASE_URL environment variable is required');
}
if (!authToken) {
throw new Error('TURSO_AUTH_TOKEN environment variable is required');
}
// Create Turso client
clientInstance = createClient({
url: databaseUrl,
authToken: authToken,
});
dbInstance = drizzle(clientInstance, { schema });
return { db: dbInstance, client: clientInstance };
}
// For backward compatibility in local development
export const db = new Proxy({} as ReturnType<typeof drizzle<typeof schema>>, {
get(_, prop) {
const { db } = getDb();
return db[prop as keyof typeof db];
},
});
export const client = new Proxy({} as Client, {
get(_, prop) {
const { client } = getDb();
return client[prop as keyof Client];
},
});
// Health check function
export async function checkDatabaseConnection(env?: Env): Promise<boolean> {
try {
const { client } = getDb(env);
await client.execute('SELECT 1');
console.log('✅ Database connection successful');
return true;
} catch (error) {
console.error('❌ Database connection failed:', error);
return false;
}
}

View File

@@ -0,0 +1,71 @@
// Utility script to mark migrations as completed without running them
// Useful when migrations have already been applied manually
import { createClient } from '@libsql/client';
const migrationToMark = process.argv[2];
if (!migrationToMark) {
console.error('Usage: bun run src/db/mark-migration-complete.ts <migration-tag>');
console.error('Example: bun run src/db/mark-migration-complete.ts 0000_wakeful_tana_nile');
process.exit(1);
}
async function markComplete() {
const databaseUrl = process.env.TURSO_DATABASE_URL || Bun.env?.TURSO_DATABASE_URL;
const authToken = process.env.TURSO_AUTH_TOKEN || Bun.env?.TURSO_AUTH_TOKEN;
if (!databaseUrl || !authToken) {
throw new Error('TURSO_DATABASE_URL and TURSO_AUTH_TOKEN are required');
}
const client = createClient({
url: databaseUrl,
authToken: authToken,
});
// Create migrations table if it doesn't exist
await client.execute(`
CREATE TABLE IF NOT EXISTS __drizzle_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
// Parse migration tag to get index
const match = migrationToMark.match(/^(\d+)_(.+)$/);
if (!match) {
throw new Error('Invalid migration tag format. Expected: 0000_migration_name');
}
const idx = parseInt(match[1], 10);
const migrationHash = `${idx}:${migrationToMark}`;
// Check if already marked
const existing = await client.execute({
sql: 'SELECT hash FROM __drizzle_migrations WHERE hash = ?',
args: [migrationHash],
});
if (existing.rows.length > 0) {
console.log(`⏭️ Migration ${migrationToMark} is already marked as complete`);
return;
}
// Mark as complete
await client.execute({
sql: 'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
args: [migrationHash, Date.now()],
});
console.log(`✅ Marked migration ${migrationToMark} as complete`);
}
try {
await markComplete();
process.exit(0);
} catch (error) {
console.error('❌ Failed:', error);
process.exit(1);
}

173
src/db/migrate.ts Normal file
View File

@@ -0,0 +1,173 @@
// Database migration script for Turso
// Custom implementation to handle multi-statement SQL files with Turso
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from './schema';
console.log('🚀 Running database migrations...');
async function runMigrations() {
// Get database URL and auth token
const databaseUrl = process.env.TURSO_DATABASE_URL || Bun.env?.TURSO_DATABASE_URL;
const authToken = process.env.TURSO_AUTH_TOKEN || Bun.env?.TURSO_AUTH_TOKEN;
if (!databaseUrl) {
throw new Error('TURSO_DATABASE_URL environment variable is required');
}
if (!authToken) {
throw new Error('TURSO_AUTH_TOKEN environment variable is required');
}
// Create Turso client
const client = createClient({
url: databaseUrl,
authToken: authToken,
});
const _db = drizzle(client, { schema });
// Create migrations tracking table if it doesn't exist
await client.execute(`
CREATE TABLE IF NOT EXISTS __drizzle_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
// Read migration journal
const migrationsFolder = './src/db/migrations';
const journalPath = join(migrationsFolder, 'meta/_journal.json');
const journalContent = await readFile(journalPath, 'utf-8');
const journal = JSON.parse(journalContent);
// Get already applied migrations
const appliedMigrations = await client.execute('SELECT hash FROM __drizzle_migrations');
const appliedHashes = new Set(
appliedMigrations.rows.map((row) => (row as unknown as { hash: string }).hash)
);
// Process each migration in order
for (const entry of journal.entries) {
const migrationHash = `${entry.idx}:${entry.tag}`;
if (appliedHashes.has(migrationHash)) {
console.log(`⏭️ Skipping already applied migration: ${entry.tag}`);
continue;
}
console.log(`📦 Applying migration: ${entry.tag}`);
// Read migration SQL file
const sqlFilePath = join(migrationsFolder, `${entry.tag}.sql`);
const sqlContent = await readFile(sqlFilePath, 'utf-8');
// Split SQL into individual statements
let statements: string[];
if (sqlContent.includes('--> statement-breakpoint')) {
// Drizzle-generated SQL uses statement-breakpoint
statements = sqlContent
.split('--> statement-breakpoint')
.map((stmt) => {
// Remove SQL comments (lines starting with --)
return stmt
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.trim();
})
.filter((stmt) => stmt.length > 0)
.map((stmt) => stmt.replace(/;$/, ''));
} else {
// Hand-written SQL - need to parse carefully
// Remove comment lines but preserve block structure for triggers
const cleanedSql = sqlContent
.split('\n')
.filter((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith('--');
})
.join('\n');
// Split by semicolons, but be careful with BEGIN...END blocks
statements = [];
let current = '';
let inBlock = 0;
for (const char of cleanedSql) {
current += char;
// Track BEGIN...END blocks
const upperCurrent = current.toUpperCase();
if (upperCurrent.endsWith('BEGIN')) {
inBlock++;
} else if (upperCurrent.endsWith('END')) {
inBlock--;
}
// Split on semicolon only if not in a block
if (char === ';' && inBlock === 0) {
const stmt = current.slice(0, -1).trim();
if (stmt) {
statements.push(stmt);
}
current = '';
}
}
// Add any remaining statement
const lastStmt = current.trim();
if (lastStmt) {
statements.push(lastStmt);
}
}
// Execute each statement individually
// Turso requires each statement to be executed separately
if (statements.length > 0) {
try {
console.log(` Executing ${statements.length} statements...`);
for (let i = 0; i < statements.length; i++) {
const stmt = statements[i];
if (stmt.trim()) {
try {
await client.execute(stmt);
console.log(` ✓ Statement ${i + 1}/${statements.length}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(` ✗ Statement ${i + 1}/${statements.length} failed:`, errorMessage);
throw error;
}
}
}
// Record successful migration
await client.execute({
sql: 'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
args: [migrationHash, Date.now()],
});
console.log(`✅ Applied migration: ${entry.tag}`);
} catch (error) {
console.error(`❌ Failed to apply migration ${entry.tag}:`, error);
throw error;
}
}
}
console.log('✅ All migrations completed successfully!');
}
try {
await runMigrations();
process.exit(0);
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}

View File

@@ -0,0 +1,250 @@
CREATE TABLE `agent_categories` (
`agent_id` text NOT NULL,
`category_id` text NOT NULL,
FOREIGN KEY (`agent_id`) REFERENCES `marketplace_agents`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `agent_categories_agent_idx` ON `agent_categories` (`agent_id`);--> statement-breakpoint
CREATE INDEX `agent_categories_category_idx` ON `agent_categories` (`category_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `agent_categories_pk` ON `agent_categories` (`agent_id`,`category_id`);--> statement-breakpoint
CREATE TABLE `agent_stats` (
`id` text PRIMARY KEY NOT NULL,
`agent_id` text NOT NULL,
`version` text(50),
`event_type` text(20) NOT NULL,
`user_id` text,
`device_id` text(255),
`created_at` integer NOT NULL,
FOREIGN KEY (`agent_id`) REFERENCES `marketplace_agents`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `stats_agent_idx` ON `agent_stats` (`agent_id`);--> statement-breakpoint
CREATE INDEX `stats_date_idx` ON `agent_stats` (`created_at`);--> statement-breakpoint
CREATE INDEX `stats_event_idx` ON `agent_stats` (`event_type`);--> statement-breakpoint
CREATE INDEX `stats_user_idx` ON `agent_stats` (`user_id`);--> statement-breakpoint
CREATE TABLE `agent_tags` (
`agent_id` text NOT NULL,
`tag_id` text NOT NULL,
FOREIGN KEY (`agent_id`) REFERENCES `marketplace_agents`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `agent_tags_agent_idx` ON `agent_tags` (`agent_id`);--> statement-breakpoint
CREATE INDEX `agent_tags_tag_idx` ON `agent_tags` (`tag_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `agent_tags_pk` ON `agent_tags` (`agent_id`,`tag_id`);--> statement-breakpoint
CREATE TABLE `agent_versions` (
`id` text PRIMARY KEY NOT NULL,
`agent_id` text NOT NULL,
`version` text(50) NOT NULL,
`system_prompt` text NOT NULL,
`tools_config` text NOT NULL,
`rules` text,
`output_format` text,
`dynamic_prompt_config` text,
`change_log` text,
`is_prerelease` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`agent_id`) REFERENCES `marketplace_agents`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `versions_agent_idx` ON `agent_versions` (`agent_id`);--> statement-breakpoint
CREATE INDEX `versions_created_idx` ON `agent_versions` (`created_at`);--> statement-breakpoint
CREATE UNIQUE INDEX `versions_unique` ON `agent_versions` (`agent_id`,`version`);--> statement-breakpoint
CREATE TABLE `categories` (
`id` text PRIMARY KEY NOT NULL,
`name` text(100) NOT NULL,
`slug` text(100) NOT NULL,
`description` text,
`icon` text(50),
`display_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `categories_name_unique` ON `categories` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `categories_slug_unique` ON `categories` (`slug`);--> statement-breakpoint
CREATE INDEX `categories_slug_idx` ON `categories` (`slug`);--> statement-breakpoint
CREATE INDEX `categories_order_idx` ON `categories` (`display_order`);--> statement-breakpoint
CREATE TABLE `collection_agents` (
`collection_id` text NOT NULL,
`agent_id` text NOT NULL,
`display_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`agent_id`) REFERENCES `marketplace_agents`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `collection_agents_collection_idx` ON `collection_agents` (`collection_id`);--> statement-breakpoint
CREATE INDEX `collection_agents_agent_idx` ON `collection_agents` (`agent_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `collection_agents_pk` ON `collection_agents` (`collection_id`,`agent_id`);--> statement-breakpoint
CREATE TABLE `collections` (
`id` text PRIMARY KEY NOT NULL,
`name` text(255) NOT NULL,
`slug` text(100) NOT NULL,
`description` text,
`icon` text(50),
`is_featured` integer DEFAULT false NOT NULL,
`display_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `collections_slug_unique` ON `collections` (`slug`);--> statement-breakpoint
CREATE INDEX `collections_slug_idx` ON `collections` (`slug`);--> statement-breakpoint
CREATE INDEX `collections_featured_idx` ON `collections` (`is_featured`);--> statement-breakpoint
CREATE INDEX `collections_order_idx` ON `collections` (`display_order`);--> statement-breakpoint
CREATE TABLE `marketplace_agents` (
`id` text PRIMARY KEY NOT NULL,
`slug` text(100) NOT NULL,
`name` text(255) NOT NULL,
`description` text NOT NULL,
`long_description` text,
`author_id` text NOT NULL,
`model` text(100) NOT NULL,
`system_prompt` text NOT NULL,
`tools_config` text NOT NULL,
`rules` text,
`output_format` text,
`dynamic_prompt_config` text,
`icon_url` text,
`banner_url` text,
`download_count` integer DEFAULT 0 NOT NULL,
`install_count` integer DEFAULT 0 NOT NULL,
`usage_count` integer DEFAULT 0 NOT NULL,
`rating` integer DEFAULT 0 NOT NULL,
`rating_count` integer DEFAULT 0 NOT NULL,
`is_featured` integer DEFAULT false NOT NULL,
`is_published` integer DEFAULT false NOT NULL,
`published_at` integer,
`latest_version` text(50) NOT NULL,
`search_vector` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `marketplace_agents_slug_unique` ON `marketplace_agents` (`slug`);--> statement-breakpoint
CREATE INDEX `agents_slug_idx` ON `marketplace_agents` (`slug`);--> statement-breakpoint
CREATE INDEX `agents_author_idx` ON `marketplace_agents` (`author_id`);--> statement-breakpoint
CREATE INDEX `agents_featured_idx` ON `marketplace_agents` (`is_featured`);--> statement-breakpoint
CREATE INDEX `agents_downloads_idx` ON `marketplace_agents` (`download_count`);--> statement-breakpoint
CREATE INDEX `agents_created_idx` ON `marketplace_agents` (`created_at`);--> statement-breakpoint
CREATE INDEX `agents_published_idx` ON `marketplace_agents` (`is_published`);--> statement-breakpoint
CREATE TABLE `marketplace_skills` (
`id` text PRIMARY KEY NOT NULL,
`slug` text(100) NOT NULL,
`name` text(255) NOT NULL,
`description` text NOT NULL,
`long_description` text,
`author_id` text NOT NULL,
`system_prompt_fragment` text,
`workflow_rules` text,
`documentation` text NOT NULL,
`icon_url` text,
`banner_url` text,
`download_count` integer DEFAULT 0 NOT NULL,
`install_count` integer DEFAULT 0 NOT NULL,
`usage_count` integer DEFAULT 0 NOT NULL,
`rating` integer DEFAULT 0 NOT NULL,
`rating_count` integer DEFAULT 0 NOT NULL,
`is_featured` integer DEFAULT false NOT NULL,
`is_published` integer DEFAULT false NOT NULL,
`published_at` integer,
`latest_version` text(50) NOT NULL,
`search_vector` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `marketplace_skills_slug_unique` ON `marketplace_skills` (`slug`);--> statement-breakpoint
CREATE INDEX `skills_slug_idx` ON `marketplace_skills` (`slug`);--> statement-breakpoint
CREATE INDEX `skills_author_idx` ON `marketplace_skills` (`author_id`);--> statement-breakpoint
CREATE INDEX `skills_featured_idx` ON `marketplace_skills` (`is_featured`);--> statement-breakpoint
CREATE INDEX `skills_downloads_idx` ON `marketplace_skills` (`download_count`);--> statement-breakpoint
CREATE INDEX `skills_created_idx` ON `marketplace_skills` (`created_at`);--> statement-breakpoint
CREATE INDEX `skills_published_idx` ON `marketplace_skills` (`is_published`);--> statement-breakpoint
CREATE TABLE `skill_categories` (
`skill_id` text NOT NULL,
`category_id` text NOT NULL,
FOREIGN KEY (`skill_id`) REFERENCES `marketplace_skills`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `skill_categories_skill_idx` ON `skill_categories` (`skill_id`);--> statement-breakpoint
CREATE INDEX `skill_categories_category_idx` ON `skill_categories` (`category_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `skill_categories_pk` ON `skill_categories` (`skill_id`,`category_id`);--> statement-breakpoint
CREATE TABLE `skill_stats` (
`id` text PRIMARY KEY NOT NULL,
`skill_id` text NOT NULL,
`version` text(50),
`event_type` text(20) NOT NULL,
`user_id` text,
`device_id` text(255),
`created_at` integer NOT NULL,
FOREIGN KEY (`skill_id`) REFERENCES `marketplace_skills`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `skill_stats_skill_idx` ON `skill_stats` (`skill_id`);--> statement-breakpoint
CREATE INDEX `skill_stats_date_idx` ON `skill_stats` (`created_at`);--> statement-breakpoint
CREATE INDEX `skill_stats_event_idx` ON `skill_stats` (`event_type`);--> statement-breakpoint
CREATE INDEX `skill_stats_user_idx` ON `skill_stats` (`user_id`);--> statement-breakpoint
CREATE TABLE `skill_tags` (
`skill_id` text NOT NULL,
`tag_id` text NOT NULL,
FOREIGN KEY (`skill_id`) REFERENCES `marketplace_skills`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `skill_tags_skill_idx` ON `skill_tags` (`skill_id`);--> statement-breakpoint
CREATE INDEX `skill_tags_tag_idx` ON `skill_tags` (`tag_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `skill_tags_pk` ON `skill_tags` (`skill_id`,`tag_id`);--> statement-breakpoint
CREATE TABLE `skill_versions` (
`id` text PRIMARY KEY NOT NULL,
`skill_id` text NOT NULL,
`version` text(50) NOT NULL,
`system_prompt_fragment` text,
`workflow_rules` text,
`documentation` text NOT NULL,
`change_log` text,
`is_prerelease` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`skill_id`) REFERENCES `marketplace_skills`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `skill_versions_skill_idx` ON `skill_versions` (`skill_id`);--> statement-breakpoint
CREATE INDEX `skill_versions_created_idx` ON `skill_versions` (`created_at`);--> statement-breakpoint
CREATE UNIQUE INDEX `skill_versions_unique` ON `skill_versions` (`skill_id`,`version`);--> statement-breakpoint
CREATE TABLE `tags` (
`id` text PRIMARY KEY NOT NULL,
`name` text(50) NOT NULL,
`slug` text(50) NOT NULL,
`usage_count` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `tags_slug_unique` ON `tags` (`slug`);--> statement-breakpoint
CREATE INDEX `tags_slug_idx` ON `tags` (`slug`);--> statement-breakpoint
CREATE INDEX `tags_usage_idx` ON `tags` (`usage_count`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text(255) NOT NULL,
`name` text(255) NOT NULL,
`avatar_url` text,
`role` text(20) DEFAULT 'user' NOT NULL,
`bio` text,
`website` text,
`github_id` text(255),
`google_id` text(255),
`is_verified` integer DEFAULT false NOT NULL,
`last_login_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `users_email_idx` ON `users` (`email`);--> statement-breakpoint
CREATE INDEX `users_github_idx` ON `users` (`github_id`);--> statement-breakpoint
CREATE INDEX `users_google_idx` ON `users` (`google_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

View File

@@ -0,0 +1,193 @@
-- ============================================
-- FTS5 Full-Text Search Implementation
-- Migration: 0002_add_fts5_search
-- ============================================
-- This migration adds FTS5 (Full-Text Search) support to the marketplace
-- for fast, relevance-ranked search across agents and skills.
--
-- FTS5 provides:
-- - Fast indexed search (10-50x faster than LIKE)
-- - Relevance ranking using BM25 algorithm
-- - Advanced search syntax (phrases, boolean operators, prefix matching)
-- - International character support
-- ============================================
-- ============================================
-- 1. Create FTS5 Virtual Tables
-- ============================================
-- FTS5 virtual table for marketplace agents
CREATE VIRTUAL TABLE IF NOT EXISTS marketplace_agents_fts USING fts5(
id UNINDEXED, -- Store ID but don't index it (used for joining)
name, -- Agent name (highest weight in ranking)
description, -- Short description (medium weight)
long_description, -- Detailed description (lower weight)
tokenize='porter unicode61 remove_diacritics 1' -- Stemming + international chars + diacritic removal
);
-- FTS5 virtual table for marketplace skills
CREATE VIRTUAL TABLE IF NOT EXISTS marketplace_skills_fts USING fts5(
id UNINDEXED,
name,
description,
long_description,
tokenize='porter unicode61 remove_diacritics 1'
);
-- ============================================
-- 2. Populate FTS5 Tables with Existing Data
-- ============================================
-- Populate agents FTS table
-- Note: COALESCE ensures NULL values don't break the index
INSERT INTO marketplace_agents_fts(id, name, description, long_description)
SELECT
id,
name,
description,
COALESCE(long_description, '')
FROM marketplace_agents
WHERE is_published = 1; -- Only index published agents
-- Populate skills FTS table
INSERT INTO marketplace_skills_fts(id, name, description, long_description)
SELECT
id,
name,
description,
COALESCE(long_description, '')
FROM marketplace_skills
WHERE is_published = 1; -- Only index published skills
-- ============================================
-- 3. Create Triggers for Automatic Sync
-- ============================================
-- These triggers keep the FTS5 tables in sync with the source tables
-- without requiring application-level code.
-- ----------------
-- Agents Triggers
-- ----------------
-- Insert: Add new published agents to FTS
CREATE TRIGGER IF NOT EXISTS marketplace_agents_fts_insert
AFTER INSERT ON marketplace_agents
WHEN new.is_published = 1
BEGIN
INSERT INTO marketplace_agents_fts(id, name, description, long_description)
VALUES (
new.id,
new.name,
new.description,
COALESCE(new.long_description, '')
);
END;
-- Update: Sync changes to FTS when relevant fields change
CREATE TRIGGER IF NOT EXISTS marketplace_agents_fts_update
AFTER UPDATE OF name, description, long_description, is_published ON marketplace_agents
BEGIN
-- Delete from FTS if unpublished
DELETE FROM marketplace_agents_fts WHERE id = old.id AND new.is_published = 0;
-- Update FTS if published and was already in FTS
UPDATE marketplace_agents_fts
SET
name = new.name,
description = new.description,
long_description = COALESCE(new.long_description, '')
WHERE id = new.id AND new.is_published = 1;
-- Insert to FTS if newly published
INSERT INTO marketplace_agents_fts(id, name, description, long_description)
SELECT new.id, new.name, new.description, COALESCE(new.long_description, '')
WHERE new.is_published = 1 AND old.is_published = 0;
END;
-- Delete: Remove from FTS when agent is deleted
CREATE TRIGGER IF NOT EXISTS marketplace_agents_fts_delete
AFTER DELETE ON marketplace_agents
BEGIN
DELETE FROM marketplace_agents_fts WHERE id = old.id;
END;
-- ----------------
-- Skills Triggers
-- ----------------
-- Insert: Add new published skills to FTS
CREATE TRIGGER IF NOT EXISTS marketplace_skills_fts_insert
AFTER INSERT ON marketplace_skills
WHEN new.is_published = 1
BEGIN
INSERT INTO marketplace_skills_fts(id, name, description, long_description)
VALUES (
new.id,
new.name,
new.description,
COALESCE(new.long_description, '')
);
END;
-- Update: Sync changes to FTS when relevant fields change
CREATE TRIGGER IF NOT EXISTS marketplace_skills_fts_update
AFTER UPDATE OF name, description, long_description, is_published ON marketplace_skills
BEGIN
-- Delete from FTS if unpublished
DELETE FROM marketplace_skills_fts WHERE id = old.id AND new.is_published = 0;
-- Update FTS if published and was already in FTS
UPDATE marketplace_skills_fts
SET
name = new.name,
description = new.description,
long_description = COALESCE(new.long_description, '')
WHERE id = new.id AND new.is_published = 1;
-- Insert to FTS if newly published
INSERT INTO marketplace_skills_fts(id, name, description, long_description)
SELECT new.id, new.name, new.description, COALESCE(new.long_description, '')
WHERE new.is_published = 1 AND old.is_published = 0;
END;
-- Delete: Remove from FTS when skill is deleted
CREATE TRIGGER IF NOT EXISTS marketplace_skills_fts_delete
AFTER DELETE ON marketplace_skills
BEGIN
DELETE FROM marketplace_skills_fts WHERE id = old.id;
END;
-- ============================================
-- 4. Create Maintenance Procedures
-- ============================================
-- Note: These can be run manually or via cron job for optimization
-- To rebuild FTS5 index (run periodically for optimal performance):
-- INSERT INTO marketplace_agents_fts(marketplace_agents_fts) VALUES('rebuild');
-- INSERT INTO marketplace_skills_fts(marketplace_skills_fts) VALUES('rebuild');
-- To optimize FTS5 index (merge segments):
-- INSERT INTO marketplace_agents_fts(marketplace_agents_fts) VALUES('optimize');
-- INSERT INTO marketplace_skills_fts(marketplace_skills_fts) VALUES('optimize');
-- To check FTS5 integrity:
-- INSERT INTO marketplace_agents_fts(marketplace_agents_fts) VALUES('integrity-check');
-- INSERT INTO marketplace_skills_fts(marketplace_skills_fts) VALUES('integrity-check');
-- ============================================
-- Migration Complete
-- ============================================
-- FTS5 full-text search is now enabled for:
-- - marketplace_agents (name, description, long_description)
-- - marketplace_skills (name, description, long_description)
--
-- Search syntax examples:
-- - Simple: "python"
-- - Phrase: "code review"
-- - Boolean: "python AND testing"
-- - Prefix: "java*"
-- - Proximity: "code NEAR testing"
--
-- Performance: ~10-50x faster than LIKE queries
-- Ranking: BM25 relevance algorithm
-- ============================================

View File

@@ -0,0 +1,19 @@
-- ============================================
-- Add display_name column to users table
-- Migration: 0002_add_display_name
-- ============================================
-- This migration adds the display_name field to the users table
-- to allow users to set a custom display name separate from
-- their OAuth provider name.
-- ============================================
-- Add display_name column to users table
ALTER TABLE users ADD COLUMN display_name TEXT;
-- ============================================
-- Migration Complete
-- ============================================
-- Users can now set a custom display name that will be shown
-- in the marketplace and other public-facing areas.
-- If display_name is NULL, the system will fallback to the name field.
-- ============================================

View File

@@ -0,0 +1,26 @@
-- Migration: Add R2 storage fields to skills tables
-- Description: Add fields to support file-based skills with R2 storage
-- Add R2 storage fields to marketplace_skills
ALTER TABLE marketplace_skills ADD COLUMN storage_url TEXT;
ALTER TABLE marketplace_skills ADD COLUMN package_size INTEGER;
ALTER TABLE marketplace_skills ADD COLUMN checksum TEXT;
ALTER TABLE marketplace_skills ADD COLUMN required_permission TEXT DEFAULT 'read-only';
ALTER TABLE marketplace_skills ADD COLUMN has_scripts INTEGER DEFAULT 0 NOT NULL;
-- statement-breakpoint
-- Add index for storage_url
CREATE INDEX skills_storage_idx ON marketplace_skills(storage_url);
-- statement-breakpoint
-- Add R2 storage fields to skill_versions
ALTER TABLE skill_versions ADD COLUMN storage_url TEXT;
ALTER TABLE skill_versions ADD COLUMN package_size INTEGER;
ALTER TABLE skill_versions ADD COLUMN checksum TEXT;
-- statement-breakpoint
-- Add index for storage_url
CREATE INDEX skill_versions_storage_idx ON skill_versions(storage_url);

View File

@@ -0,0 +1,22 @@
-- Migration: Add analytics_events table for tracking app usage
-- This table stores anonymous usage events for product analytics
CREATE TABLE IF NOT EXISTS `analytics_events` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text(255) NOT NULL,
`event_type` text(50) NOT NULL,
`session_id` text(255) NOT NULL,
`os_name` text(50),
`os_version` text(50),
`app_version` text(50),
`country` text(10),
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `analytics_device_idx` ON `analytics_events` (`device_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `analytics_event_type_idx` ON `analytics_events` (`event_type`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `analytics_session_idx` ON `analytics_events` (`session_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `analytics_date_idx` ON `analytics_events` (`created_at`);

View File

@@ -0,0 +1,21 @@
-- Migration: Add provider_usage table for TalkCody provider
-- Tracks usage by user ID for rate limiting
-- Provider Usage Table - tracks usage for TalkCody provider rate limiting
CREATE TABLE IF NOT EXISTS `provider_usage` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text(255) NOT NULL,
`provider` text(50) NOT NULL,
`model` text(100) NOT NULL,
`input_tokens` integer DEFAULT 0 NOT NULL,
`output_tokens` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`usage_date` text(10) NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `provider_usage_user_date_idx` ON `provider_usage` (`user_id`, `usage_date`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `provider_usage_provider_idx` ON `provider_usage` (`provider`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `provider_usage_date_idx` ON `provider_usage` (`usage_date`);

View File

@@ -0,0 +1,61 @@
-- Migration: Agent Skills Standardization
-- Adds compatibility and metadata fields, removes deprecated fields from skill tables
-- Created: 2026-01-01
-- =====================================================
-- STEP 1: Add new fields to marketplace_skills table
-- =====================================================
-- Add compatibility field for Agent Skills Specification
ALTER TABLE `marketplace_skills` ADD COLUMN `compatibility` text(500);
--> statement-breakpoint
-- Add metadata JSON field for Agent Skills Specification
ALTER TABLE `marketplace_skills` ADD COLUMN `metadata` text;
--> statement-breakpoint
-- =====================================================
-- STEP 2: Add new fields to skill_versions table
-- =====================================================
-- Add compatibility field to versions
ALTER TABLE `skill_versions` ADD COLUMN `compatibility` text(500);
--> statement-breakpoint
-- Add metadata JSON field to versions
ALTER TABLE `skill_versions` ADD COLUMN `metadata` text;
--> statement-breakpoint
-- =====================================================
-- STEP 3: Create indexes for new fields
-- =====================================================
-- Index for compatibility field (used in filtering)
CREATE INDEX IF NOT EXISTS `skills_compatibility_idx` ON `marketplace_skills` (`compatibility`);
--> statement-breakpoint
-- Index for metadata field (for JSON queries if needed)
CREATE INDEX IF NOT EXISTS `skills_metadata_idx` ON `marketplace_skills` (`metadata`);
--> statement-breakpoint
-- Indexes for version fields
CREATE INDEX IF NOT EXISTS `skill_versions_compatibility_idx` ON `skill_versions` (`compatibility`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `skill_versions_metadata_idx` ON `skill_versions` (`metadata`);
--> statement-breakpoint
-- =====================================================
-- STEP 4: Create index for skill stats table (if not exists)
-- =====================================================
CREATE INDEX IF NOT EXISTS `skill_stats_version_idx` ON `skill_stats` (`version`);
--> statement-breakpoint
-- =====================================================
-- STEP 5: Update existing skills with default compatibility value
-- =====================================================
-- Set default compatibility for existing skills
UPDATE `marketplace_skills` SET `compatibility` = 'General purpose' WHERE `compatibility` IS NULL;
--> statement-breakpoint

View File

@@ -0,0 +1,31 @@
-- Migration: Add Search Usage Table
-- Adds search_usage table for tracking search API rate limiting
-- Created: 2026-01-05
-- =====================================================
-- STEP 1: Create search_usage table
-- =====================================================
CREATE TABLE IF NOT EXISTS `search_usage` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text(255) NOT NULL,
`user_id` text(255),
`search_count` integer DEFAULT 1 NOT NULL,
`usage_date` text(10) NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
-- =====================================================
-- STEP 2: Create indexes for search_usage table
-- =====================================================
CREATE INDEX IF NOT EXISTS `search_usage_device_date_idx` ON `search_usage` (`device_id`, `usage_date`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `search_usage_user_date_idx` ON `search_usage` (`user_id`, `usage_date`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `search_usage_date_idx` ON `search_usage` (`usage_date`);
--> statement-breakpoint

View File

@@ -0,0 +1,27 @@
-- Migration: Add task_shares table for sharing task conversations
-- Created at: 2026-01-05
-- Create task_shares table
CREATE TABLE IF NOT EXISTS task_shares (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
user_id TEXT,
task_title TEXT NOT NULL,
messages_json TEXT NOT NULL,
storage_url TEXT,
model TEXT,
password_hash TEXT,
expires_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0,
is_public INTEGER NOT NULL DEFAULT 1,
metadata TEXT,
created_at INTEGER NOT NULL,
created_by TEXT
);
-- Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS shares_task_id_idx ON task_shares(task_id);
CREATE INDEX IF NOT EXISTS shares_user_id_idx ON task_shares(user_id);
CREATE INDEX IF NOT EXISTS shares_expires_at_idx ON task_shares(expires_at);
CREATE INDEX IF NOT EXISTS shares_created_at_idx ON task_shares(created_at);
CREATE INDEX IF NOT EXISTS shares_is_public_idx ON task_shares(is_public);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0691efc4-f0b5-4e00-91e0-6792f478454e",
"prevId": "0691efc4-f0b5-4e00-91e0-6792f478454f",
"tables": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"enums": {},
"views": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,415 @@
{
"version": "6",
"dialect": "sqlite",
"id": "agent_skills_v2",
"prevId": "fd7cc6d5-f485-4486-9fe8-3816aa7448ca",
"tables": {
"marketplace_skills": {
"name": "marketplace_skills",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"long_description": {
"name": "long_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"system_prompt_fragment": {
"name": "system_prompt_fragment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"workflow_rules": {
"name": "workflow_rules",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"documentation": {
"name": "documentation",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"storage_url": {
"name": "storage_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"package_size": {
"name": "package_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"required_permission": {
"name": "required_permission",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'read-only'"
},
"has_scripts": {
"name": "has_scripts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"banner_url": {
"name": "banner_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"download_count": {
"name": "download_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"install_count": {
"name": "install_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"usage_count": {
"name": "usage_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"rating_count": {
"name": "rating_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_featured": {
"name": "is_featured",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_published": {
"name": "is_published",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_version": {
"name": "latest_version",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"search_vector": {
"name": "search_vector",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compatibility": {
"name": "compatibility",
"type": "text(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"skills_slug_idx": {
"name": "skills_slug_idx",
"columns": ["slug"],
"isUnique": false
},
"skills_author_idx": {
"name": "skills_author_idx",
"columns": ["author_id"],
"isUnique": false
},
"skills_featured_idx": {
"name": "skills_featured_idx",
"columns": ["is_featured"],
"isUnique": false
},
"skills_downloads_idx": {
"name": "skills_downloads_idx",
"columns": ["download_count"],
"isUnique": false
},
"skills_created_idx": {
"name": "skills_created_idx",
"columns": ["created_at"],
"isUnique": false
},
"skills_published_idx": {
"name": "skills_published_idx",
"columns": ["is_published"],
"isUnique": false
},
"skills_storage_idx": {
"name": "skills_storage_idx",
"columns": ["storage_url"],
"isUnique": false
},
"skills_compatibility_idx": {
"name": "skills_compatibility_idx",
"columns": ["compatibility"],
"isUnique": false
},
"skills_metadata_idx": {
"name": "skills_metadata_idx",
"columns": ["metadata"],
"isUnique": false
}
}
},
"skill_versions": {
"name": "skill_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"skill_id": {
"name": "skill_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"system_prompt_fragment": {
"name": "system_prompt_fragment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"workflow_rules": {
"name": "workflow_rules",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"documentation": {
"name": "documentation",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"storage_url": {
"name": "storage_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"package_size": {
"name": "package_size",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"change_log": {
"name": "change_log",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_prerelease": {
"name": "is_prerelease",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compatibility": {
"name": "compatibility",
"type": "text(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"skill_versions_skill_idx": {
"name": "skill_versions_skill_idx",
"columns": ["skill_id"],
"isUnique": false
},
"skill_versions_unique": {
"name": "skill_versions_unique",
"columns": ["skill_id", "version"],
"isUnique": true
},
"skill_versions_created_idx": {
"name": "skill_versions_created_idx",
"columns": ["created_at"],
"isUnique": false
},
"skill_versions_storage_idx": {
"name": "skill_versions_storage_idx",
"columns": ["storage_url"],
"isUnique": false
},
"skill_versions_compatibility_idx": {
"name": "skill_versions_compatibility_idx",
"columns": ["compatibility"],
"isUnique": false
},
"skill_versions_metadata_idx": {
"name": "skill_versions_metadata_idx",
"columns": ["metadata"],
"isUnique": false
}
}
}
}
}

View File

@@ -0,0 +1,69 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1762658621304,
"tag": "0000_wakeful_tana_nile",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1762658621305,
"tag": "0001_add_fts5_search",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1731484800000,
"tag": "0002_add_display_name",
"breakpoints": false
},
{
"idx": 3,
"version": "6",
"when": 1737619200000,
"tag": "0004_add_r2_storage_fields",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1732704000000,
"tag": "0005_add_analytics_events",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1734566400000,
"tag": "0006_add_devices_provider_usage",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1735689600000,
"tag": "0007_agent_skills_standardization",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1735689600000,
"tag": "0008_add_search_usage",
"breakpoints": false
},
{
"idx": 8,
"version": "6",
"when": 1735689600000,
"tag": "0009_add_task_shares",
"breakpoints": true
}
]
}

97
src/db/run-migration.ts Normal file
View File

@@ -0,0 +1,97 @@
// Run manual migration
import { sql } from 'drizzle-orm';
import { db } from './client';
async function runMigration() {
try {
console.log('Running migration: Adding missing columns...');
// Add missing columns to marketplace_agents
await db.run(sql`
ALTER TABLE marketplace_agents
ADD COLUMN IF NOT EXISTS model varchar(100) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS system_prompt text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS tools_config jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS rules text,
ADD COLUMN IF NOT EXISTS output_format text,
ADD COLUMN IF NOT EXISTS dynamic_prompt_config jsonb,
ADD COLUMN IF NOT EXISTS rating integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS rating_count integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS published_at timestamp,
ADD COLUMN IF NOT EXISTS search_vector text
`);
console.log('✅ Added columns to marketplace_agents');
// Update latest_version column
await db.run(sql`
ALTER TABLE marketplace_agents
ALTER COLUMN latest_version SET DEFAULT '1.0.0'
`);
// Update description column
await db.run(sql`
ALTER TABLE marketplace_agents
ALTER COLUMN description SET DEFAULT ''
`);
console.log('✅ Updated marketplace_agents constraints');
// Drop old columns from users table
await db.run(sql`
ALTER TABLE users
DROP COLUMN IF EXISTS oauth_provider,
DROP COLUMN IF EXISTS oauth_id,
DROP COLUMN IF EXISTS username,
DROP COLUMN IF EXISTS display_name
`);
console.log('✅ Dropped old columns from users table');
// Update users table
await db.run(sql`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS name varchar(255) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS role varchar(20) NOT NULL DEFAULT 'user',
ADD COLUMN IF NOT EXISTS bio text,
ADD COLUMN IF NOT EXISTS website text,
ADD COLUMN IF NOT EXISTS github_id varchar(255),
ADD COLUMN IF NOT EXISTS google_id varchar(255),
ADD COLUMN IF NOT EXISTS is_verified boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS last_login_at timestamp
`);
console.log('✅ Updated users table');
// Add indexes
await db.run(sql`
CREATE INDEX IF NOT EXISTS users_email_idx ON users USING btree (email)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS users_github_idx ON users USING btree (github_id)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS users_google_idx ON users USING btree (google_id)
`);
console.log('✅ Added indexes');
// Update agent_versions table
await db.run(sql`
ALTER TABLE agent_versions
DROP COLUMN IF EXISTS model,
ADD COLUMN IF NOT EXISTS dynamic_prompt_config jsonb,
ADD COLUMN IF NOT EXISTS change_log text
`);
console.log('✅ Updated agent_versions table');
console.log('✅ Migration completed successfully');
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}
}
runMigration();

728
src/db/schema.ts Normal file
View File

@@ -0,0 +1,728 @@
// Database schema using Drizzle ORM for SQLite (Turso)
import { relations, sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core';
// Helper function for generating UUIDs in SQLite
const _uuid = () =>
sql`(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))))`;
// Helper for current timestamp
const _now = () => sql`(unixepoch() * 1000)`;
// ==================== Users Table ====================
export const users = sqliteTable(
'users',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
email: text('email', { length: 255 }).notNull(),
name: text('name', { length: 255 }).notNull(),
displayName: text('display_name'),
avatarUrl: text('avatar_url'),
role: text('role', { length: 20 }).default('user').notNull(), // 'user' | 'admin'
bio: text('bio'),
website: text('website'),
// OAuth provider IDs
githubId: text('github_id', { length: 255 }),
googleId: text('google_id', { length: 255 }),
isVerified: integer('is_verified', { mode: 'boolean' }).default(false).notNull(),
lastLoginAt: integer('last_login_at'), // Unix timestamp in milliseconds
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
updatedAt: integer('updated_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
emailIdx: index('users_email_idx').on(table.email),
githubIdx: index('users_github_idx').on(table.githubId),
googleIdx: index('users_google_idx').on(table.googleId),
emailUnique: unique('users_email_unique').on(table.email),
})
);
// ==================== Marketplace Agents Table ====================
export const marketplaceAgents = sqliteTable(
'marketplace_agents',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
slug: text('slug', { length: 100 }).notNull().unique(),
name: text('name', { length: 255 }).notNull(),
description: text('description').notNull(),
longDescription: text('long_description'),
authorId: text('author_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
// Agent configuration (latest version)
model: text('model', { length: 100 }).notNull(),
systemPrompt: text('system_prompt').notNull(),
toolsConfig: text('tools_config', { mode: 'json' })
.$type<Record<string, boolean | string | number | null | undefined>>()
.notNull(),
rules: text('rules'),
outputFormat: text('output_format'),
dynamicPromptConfig: text('dynamic_prompt_config', { mode: 'json' }).$type<{
enabled?: boolean;
variables?: Record<string, string | number | boolean>;
templates?: string[];
providers?: string[];
} | null>(),
iconUrl: text('icon_url'),
bannerUrl: text('banner_url'),
// Statistics
downloadCount: integer('download_count').default(0).notNull(),
installCount: integer('install_count').default(0).notNull(),
usageCount: integer('usage_count').default(0).notNull(),
rating: integer('rating').default(0).notNull(),
ratingCount: integer('rating_count').default(0).notNull(),
// Status
isFeatured: integer('is_featured', { mode: 'boolean' }).default(false).notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(false).notNull(),
publishedAt: integer('published_at'),
latestVersion: text('latest_version', { length: 50 }).notNull(),
// Full-text search (stored as JSON-serialized search terms)
searchVector: text('search_vector'),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
updatedAt: integer('updated_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
slugIdx: index('agents_slug_idx').on(table.slug),
authorIdx: index('agents_author_idx').on(table.authorId),
featuredIdx: index('agents_featured_idx').on(table.isFeatured),
downloadsIdx: index('agents_downloads_idx').on(table.downloadCount),
createdIdx: index('agents_created_idx').on(table.createdAt),
publishedIdx: index('agents_published_idx').on(table.isPublished),
})
);
// ==================== Agent Versions Table ====================
export const agentVersions = sqliteTable(
'agent_versions',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
agentId: text('agent_id')
.references(() => marketplaceAgents.id, { onDelete: 'cascade' })
.notNull(),
version: text('version', { length: 50 }).notNull(),
// Agent definition
systemPrompt: text('system_prompt').notNull(),
toolsConfig: text('tools_config', { mode: 'json' })
.$type<Record<string, boolean | string | number | null | undefined>>()
.notNull(),
rules: text('rules'),
outputFormat: text('output_format'),
dynamicPromptConfig: text('dynamic_prompt_config', { mode: 'json' }).$type<{
enabled?: boolean;
variables?: Record<string, string | number | boolean>;
templates?: string[];
providers?: string[];
} | null>(),
changeLog: text('change_log'),
isPrerelease: integer('is_prerelease', { mode: 'boolean' }).default(false).notNull(),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
agentIdx: index('versions_agent_idx').on(table.agentId),
versionUnique: unique('versions_unique').on(table.agentId, table.version),
createdIdx: index('versions_created_idx').on(table.createdAt),
})
);
// ==================== Categories Table ====================
export const categories = sqliteTable(
'categories',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name', { length: 100 }).notNull().unique(),
slug: text('slug', { length: 100 }).notNull().unique(),
description: text('description'),
icon: text('icon', { length: 50 }),
displayOrder: integer('display_order').default(0).notNull(),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
slugIdx: index('categories_slug_idx').on(table.slug),
orderIdx: index('categories_order_idx').on(table.displayOrder),
})
);
// ==================== Agent-Categories Junction Table ====================
export const agentCategories = sqliteTable(
'agent_categories',
{
agentId: text('agent_id')
.references(() => marketplaceAgents.id, { onDelete: 'cascade' })
.notNull(),
categoryId: text('category_id')
.references(() => categories.id, { onDelete: 'cascade' })
.notNull(),
},
(table) => ({
pk: unique('agent_categories_pk').on(table.agentId, table.categoryId),
agentIdx: index('agent_categories_agent_idx').on(table.agentId),
categoryIdx: index('agent_categories_category_idx').on(table.categoryId),
})
);
// ==================== Tags Table ====================
export const tags = sqliteTable(
'tags',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name', { length: 50 }).notNull().unique(),
slug: text('slug', { length: 50 }).notNull().unique(),
usageCount: integer('usage_count').default(0).notNull(),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
slugIdx: index('tags_slug_idx').on(table.slug),
usageIdx: index('tags_usage_idx').on(table.usageCount),
})
);
// ==================== Agent-Tags Junction Table ====================
export const agentTags = sqliteTable(
'agent_tags',
{
agentId: text('agent_id')
.references(() => marketplaceAgents.id, { onDelete: 'cascade' })
.notNull(),
tagId: text('tag_id')
.references(() => tags.id, { onDelete: 'cascade' })
.notNull(),
},
(table) => ({
pk: unique('agent_tags_pk').on(table.agentId, table.tagId),
agentIdx: index('agent_tags_agent_idx').on(table.agentId),
tagIdx: index('agent_tags_tag_idx').on(table.tagId),
})
);
// ==================== Collections Table ====================
export const collections = sqliteTable(
'collections',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name', { length: 255 }).notNull(),
slug: text('slug', { length: 100 }).notNull().unique(),
description: text('description'),
icon: text('icon', { length: 50 }),
isFeatured: integer('is_featured', { mode: 'boolean' }).default(false).notNull(),
displayOrder: integer('display_order').default(0).notNull(),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
updatedAt: integer('updated_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
slugIdx: index('collections_slug_idx').on(table.slug),
featuredIdx: index('collections_featured_idx').on(table.isFeatured),
orderIdx: index('collections_order_idx').on(table.displayOrder),
})
);
// ==================== Collection-Agents Junction Table ====================
export const collectionAgents = sqliteTable(
'collection_agents',
{
collectionId: text('collection_id')
.references(() => collections.id, { onDelete: 'cascade' })
.notNull(),
agentId: text('agent_id')
.references(() => marketplaceAgents.id, { onDelete: 'cascade' })
.notNull(),
displayOrder: integer('display_order').default(0).notNull(),
},
(table) => ({
pk: unique('collection_agents_pk').on(table.collectionId, table.agentId),
collectionIdx: index('collection_agents_collection_idx').on(table.collectionId),
agentIdx: index('collection_agents_agent_idx').on(table.agentId),
})
);
// ==================== Agent Stats Table ====================
export const agentStats = sqliteTable(
'agent_stats',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
agentId: text('agent_id')
.references(() => marketplaceAgents.id, { onDelete: 'cascade' })
.notNull(),
version: text('version', { length: 50 }),
eventType: text('event_type', { length: 20 }).notNull(), // 'download' | 'install' | 'usage'
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
deviceId: text('device_id', { length: 255 }),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
agentIdx: index('stats_agent_idx').on(table.agentId),
dateIdx: index('stats_date_idx').on(table.createdAt),
eventIdx: index('stats_event_idx').on(table.eventType),
userIdx: index('stats_user_idx').on(table.userId),
})
);
// ==================== Relations ====================
export const usersRelations = relations(users, ({ many }) => ({
agents: many(marketplaceAgents),
stats: many(agentStats),
}));
export const marketplaceAgentsRelations = relations(marketplaceAgents, ({ one, many }) => ({
author: one(users, {
fields: [marketplaceAgents.authorId],
references: [users.id],
}),
versions: many(agentVersions),
agentCategories: many(agentCategories),
agentTags: many(agentTags),
stats: many(agentStats),
collectionAgents: many(collectionAgents),
}));
export const agentVersionsRelations = relations(agentVersions, ({ one }) => ({
agent: one(marketplaceAgents, {
fields: [agentVersions.agentId],
references: [marketplaceAgents.id],
}),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({
agentCategories: many(agentCategories),
}));
export const agentCategoriesRelations = relations(agentCategories, ({ one }) => ({
agent: one(marketplaceAgents, {
fields: [agentCategories.agentId],
references: [marketplaceAgents.id],
}),
category: one(categories, {
fields: [agentCategories.categoryId],
references: [categories.id],
}),
}));
export const tagsRelations = relations(tags, ({ many }) => ({
agentTags: many(agentTags),
}));
export const agentTagsRelations = relations(agentTags, ({ one }) => ({
agent: one(marketplaceAgents, {
fields: [agentTags.agentId],
references: [marketplaceAgents.id],
}),
tag: one(tags, {
fields: [agentTags.tagId],
references: [tags.id],
}),
}));
export const collectionsRelations = relations(collections, ({ many }) => ({
collectionAgents: many(collectionAgents),
}));
export const collectionAgentsRelations = relations(collectionAgents, ({ one }) => ({
collection: one(collections, {
fields: [collectionAgents.collectionId],
references: [collections.id],
}),
agent: one(marketplaceAgents, {
fields: [collectionAgents.agentId],
references: [marketplaceAgents.id],
}),
}));
export const agentStatsRelations = relations(agentStats, ({ one }) => ({
agent: one(marketplaceAgents, {
fields: [agentStats.agentId],
references: [marketplaceAgents.id],
}),
user: one(users, {
fields: [agentStats.userId],
references: [users.id],
}),
}));
// ==================== Skills Marketplace Tables ====================
// ==================== Marketplace Skills Table ====================
export const marketplaceSkills = sqliteTable(
'marketplace_skills',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
slug: text('slug', { length: 100 }).notNull().unique(),
name: text('name', { length: 255 }).notNull(),
description: text('description').notNull(),
longDescription: text('long_description'),
authorId: text('author_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
// Deprecated: Skill content now stored in R2
// Keep for backward compatibility with database-stored skills
systemPromptFragment: text('system_prompt_fragment'),
workflowRules: text('workflow_rules'),
documentation: text('documentation', { mode: 'json' }).$type<Array<Record<string, unknown>>>(),
// R2 Storage (for file-based skills)
storageUrl: text('storage_url'),
packageSize: integer('package_size'),
checksum: text('checksum'),
requiredPermission: text('required_permission').default('read-only'),
hasScripts: integer('has_scripts', { mode: 'boolean' }).default(false).notNull(),
// Agent Skills Specification fields
compatibility: text('compatibility', { length: 500 }),
metadata: text('metadata'), // JSON string for key-value metadata
iconUrl: text('icon_url'),
bannerUrl: text('banner_url'),
// Statistics
downloadCount: integer('download_count').default(0).notNull(),
installCount: integer('install_count').default(0).notNull(),
usageCount: integer('usage_count').default(0).notNull(),
rating: integer('rating').default(0).notNull(),
ratingCount: integer('rating_count').default(0).notNull(),
// Status
isFeatured: integer('is_featured', { mode: 'boolean' }).default(false).notNull(),
isPublished: integer('is_published', { mode: 'boolean' }).default(false).notNull(),
publishedAt: integer('published_at'),
latestVersion: text('latest_version', { length: 50 }).notNull(),
// Full-text search
searchVector: text('search_vector'),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
updatedAt: integer('updated_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
slugIdx: index('skills_slug_idx').on(table.slug),
authorIdx: index('skills_author_idx').on(table.authorId),
featuredIdx: index('skills_featured_idx').on(table.isFeatured),
downloadsIdx: index('skills_downloads_idx').on(table.downloadCount),
createdIdx: index('skills_created_idx').on(table.createdAt),
publishedIdx: index('skills_published_idx').on(table.isPublished),
storageIdx: index('skills_storage_idx').on(table.storageUrl),
})
);
// ==================== Skill Versions Table ====================
export const skillVersions = sqliteTable(
'skill_versions',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
skillId: text('skill_id')
.references(() => marketplaceSkills.id, { onDelete: 'cascade' })
.notNull(),
version: text('version', { length: 50 }).notNull(),
// Deprecated: Skill content now stored in R2
systemPromptFragment: text('system_prompt_fragment'),
workflowRules: text('workflow_rules'),
documentation: text('documentation', { mode: 'json' }).$type<Array<Record<string, unknown>>>(),
// R2 Storage (for file-based skills)
storageUrl: text('storage_url'),
packageSize: integer('package_size'),
checksum: text('checksum'),
// Agent Skills Specification fields
compatibility: text('compatibility', { length: 500 }),
metadata: text('metadata'), // JSON string for key-value metadata
changeLog: text('change_log'),
isPrerelease: integer('is_prerelease', { mode: 'boolean' }).default(false).notNull(),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
skillIdx: index('skill_versions_skill_idx').on(table.skillId),
versionUnique: unique('skill_versions_unique').on(table.skillId, table.version),
createdIdx: index('skill_versions_created_idx').on(table.createdAt),
storageIdx: index('skill_versions_storage_idx').on(table.storageUrl),
})
);
// ==================== Skill-Categories Junction Table ====================
export const skillCategories = sqliteTable(
'skill_categories',
{
skillId: text('skill_id')
.references(() => marketplaceSkills.id, { onDelete: 'cascade' })
.notNull(),
categoryId: text('category_id')
.references(() => categories.id, { onDelete: 'cascade' })
.notNull(),
},
(table) => ({
pk: unique('skill_categories_pk').on(table.skillId, table.categoryId),
skillIdx: index('skill_categories_skill_idx').on(table.skillId),
categoryIdx: index('skill_categories_category_idx').on(table.categoryId),
})
);
// ==================== Skill-Tags Junction Table ====================
export const skillTags = sqliteTable(
'skill_tags',
{
skillId: text('skill_id')
.references(() => marketplaceSkills.id, { onDelete: 'cascade' })
.notNull(),
tagId: text('tag_id')
.references(() => tags.id, { onDelete: 'cascade' })
.notNull(),
},
(table) => ({
pk: unique('skill_tags_pk').on(table.skillId, table.tagId),
skillIdx: index('skill_tags_skill_idx').on(table.skillId),
tagIdx: index('skill_tags_tag_idx').on(table.tagId),
})
);
// ==================== Skill Stats Table ====================
export const skillStats = sqliteTable(
'skill_stats',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
skillId: text('skill_id')
.references(() => marketplaceSkills.id, { onDelete: 'cascade' })
.notNull(),
version: text('version', { length: 50 }),
eventType: text('event_type', { length: 20 }).notNull(), // 'download' | 'install' | 'usage'
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
deviceId: text('device_id', { length: 255 }),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
skillIdx: index('skill_stats_skill_idx').on(table.skillId),
dateIdx: index('skill_stats_date_idx').on(table.createdAt),
eventIdx: index('skill_stats_event_idx').on(table.eventType),
userIdx: index('skill_stats_user_idx').on(table.userId),
})
);
// ==================== Skills Relations ====================
export const marketplaceSkillsRelations = relations(marketplaceSkills, ({ one, many }) => ({
author: one(users, {
fields: [marketplaceSkills.authorId],
references: [users.id],
}),
versions: many(skillVersions),
skillCategories: many(skillCategories),
skillTags: many(skillTags),
stats: many(skillStats),
}));
export const skillVersionsRelations = relations(skillVersions, ({ one }) => ({
skill: one(marketplaceSkills, {
fields: [skillVersions.skillId],
references: [marketplaceSkills.id],
}),
}));
export const skillCategoriesRelations = relations(skillCategories, ({ one }) => ({
skill: one(marketplaceSkills, {
fields: [skillCategories.skillId],
references: [marketplaceSkills.id],
}),
category: one(categories, {
fields: [skillCategories.categoryId],
references: [categories.id],
}),
}));
export const skillTagsRelations = relations(skillTags, ({ one }) => ({
skill: one(marketplaceSkills, {
fields: [skillTags.skillId],
references: [marketplaceSkills.id],
}),
tag: one(tags, {
fields: [skillTags.tagId],
references: [tags.id],
}),
}));
export const skillStatsRelations = relations(skillStats, ({ one }) => ({
skill: one(marketplaceSkills, {
fields: [skillStats.skillId],
references: [marketplaceSkills.id],
}),
user: one(users, {
fields: [skillStats.userId],
references: [users.id],
}),
}));
// ==================== Analytics Events Table ====================
export const analyticsEvents = sqliteTable(
'analytics_events',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
deviceId: text('device_id', { length: 255 }).notNull(),
eventType: text('event_type', { length: 50 }).notNull(), // 'session_start' | 'session_end'
sessionId: text('session_id', { length: 255 }).notNull(),
osName: text('os_name', { length: 50 }),
osVersion: text('os_version', { length: 50 }),
appVersion: text('app_version', { length: 50 }),
country: text('country', { length: 10 }),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
deviceIdx: index('analytics_device_idx').on(table.deviceId),
eventTypeIdx: index('analytics_event_type_idx').on(table.eventType),
sessionIdx: index('analytics_session_idx').on(table.sessionId),
dateIdx: index('analytics_date_idx').on(table.createdAt),
})
);
// ==================== Provider Usage Table ====================
// Tracks usage for VibnCode provider rate limiting (by user ID)
export const providerUsage = sqliteTable(
'provider_usage',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text('user_id', { length: 255 }).notNull(),
provider: text('provider', { length: 50 }).notNull(), // 'vibncode'
model: text('model', { length: 100 }).notNull(),
inputTokens: integer('input_tokens').default(0).notNull(),
outputTokens: integer('output_tokens').default(0).notNull(),
totalTokens: integer('total_tokens').default(0).notNull(),
usageDate: text('usage_date', { length: 10 }).notNull(), // YYYY-MM-DD format
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
userDateIdx: index('provider_usage_user_date_idx').on(table.userId, table.usageDate),
providerIdx: index('provider_usage_provider_idx').on(table.provider),
dateIdx: index('provider_usage_date_idx').on(table.usageDate),
})
);
// ==================== Search Usage Table ====================
// Tracks search usage for rate limiting (by device ID and optional user ID)
export const searchUsage = sqliteTable(
'search_usage',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
deviceId: text('device_id', { length: 255 }).notNull(),
userId: text('user_id', { length: 255 }),
searchCount: integer('search_count').default(1).notNull(),
usageDate: text('usage_date', { length: 10 }).notNull(), // YYYY-MM-DD format
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
updatedAt: integer('updated_at')
.notNull()
.$defaultFn(() => Date.now()),
},
(table) => ({
deviceDateIdx: index('search_usage_device_date_idx').on(table.deviceId, table.usageDate),
userDateIdx: index('search_usage_user_date_idx').on(table.userId, table.usageDate),
dateIdx: index('search_usage_date_idx').on(table.usageDate),
})
);
// ==================== Task Shares Table ====================
// Stores shared task snapshots for public viewing
export const taskShares = sqliteTable(
'task_shares',
{
id: text('id').primaryKey(), // nanoid generated short ID
taskId: text('task_id', { length: 255 }).notNull(),
userId: text('user_id', { length: 255 }), // Optional: creator's user ID
taskTitle: text('task_title', { length: 500 }).notNull(),
messagesJson: text('messages_json').notNull(), // JSON string of messages
storageUrl: text('storage_url'), // R2 URL for large shares
model: text('model', { length: 100 }),
passwordHash: text('password_hash'), // SHA-256 hash
expiresAt: integer('expires_at'), // Unix timestamp in milliseconds
viewCount: integer('view_count').default(0).notNull(),
isPublic: integer('is_public', { mode: 'boolean' }).default(true).notNull(),
metadata: text('metadata', { mode: 'json' }).$type<{
vibncodeVersion?: string;
platform?: string;
sharedAt?: number;
}>(),
createdAt: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
createdBy: text('created_by', { length: 255 }), // Device ID
},
(table) => ({
taskIdIdx: index('shares_task_id_idx').on(table.taskId),
userIdIdx: index('shares_user_id_idx').on(table.userId),
expiresAtIdx: index('shares_expires_at_idx').on(table.expiresAt),
createdAtIdx: index('shares_created_at_idx').on(table.createdAt),
isPublicIdx: index('shares_is_public_idx').on(table.isPublic),
})
);

76
src/db/seed.ts Normal file
View File

@@ -0,0 +1,76 @@
// Database seed script - populate with initial data
import { DEFAULT_CATEGORIES } from '@vibncode/shared';
import { db } from './client';
import { categories, collections, tags } from './schema';
console.log('🌱 Seeding database...');
try {
// Seed categories
console.log('Creating categories...');
const createdCategories = await db
.insert(categories)
.values(
DEFAULT_CATEGORIES.map(
(cat: { name: string; slug: string; icon: string }, index: number) => ({
name: cat.name,
slug: cat.slug,
icon: cat.icon,
displayOrder: index,
})
)
)
.onConflictDoNothing()
.returning();
console.log(`✅ Created ${createdCategories.length} categories`);
// Seed some default tags
console.log('Creating default tags...');
const defaultTags = [
'typescript',
'javascript',
'python',
'react',
'vue',
'nodejs',
'debugging',
'code-review',
'documentation',
'testing',
];
const createdTags = await db
.insert(tags)
.values(
defaultTags.map((name) => ({
name,
slug: name.toLowerCase(),
}))
)
.onConflictDoNothing()
.returning();
console.log(`✅ Created ${createdTags.length} tags`);
// Seed featured collection
console.log('Creating featured collection...');
await db
.insert(collections)
.values({
name: 'Featured Agents',
slug: 'featured',
description: 'Hand-picked collection of the best agents',
icon: '⭐',
isFeatured: true,
displayOrder: 0,
})
.onConflictDoNothing();
console.log('✅ Seeding completed successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Seeding failed:', error);
process.exit(1);
}

156
src/index.ts Normal file
View File

@@ -0,0 +1,156 @@
// VibnCode Agent Marketplace API
// Built with Hono and Bun
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { prettyJSON } from 'hono/pretty-json';
import { checkDatabaseConnection } from './db/client';
import { errorHandler } from './middlewares/error-handler';
import agentRoutes from './routes/agents';
import analyticsRoutes from './routes/analytics';
// Import routes
import authRoutes from './routes/auth';
import marketplaceRoutes from './routes/marketplace';
import modelsRoutes from './routes/models';
import remoteAgentsRoutes from './routes/remote-agents';
import remoteSkillsRoutes from './routes/remote-skills';
import searchRoutes from './routes/search';
import shareRoutes from './routes/shares';
import skillRoutes from './routes/skills';
import skillsMarketplaceRoutes from './routes/skills-marketplace';
import vibncodeProviderRoutes from './routes/vibncode-provider';
import updatesRoutes from './routes/updates';
import userRoutes from './routes/users';
import webFetchRoutes from './routes/web-fetch';
import type { HonoContext } from './types/context';
const app = new Hono<HonoContext>();
// Determine if running in development mode
const isDevelopment = typeof Bun !== 'undefined' ? Bun.env.NODE_ENV !== 'production' : false;
// CORS origins based on environment
const corsOrigins: string[] = [
'tauri://localhost', // Tauri app always needs access
];
// Only allow localhost origins in development
if (isDevelopment) {
corsOrigins.push('http://localhost:1420', 'http://localhost:5173');
}
// Global middlewares
app.use('*', logger());
app.use('*', prettyJSON());
app.use(
'*',
cors({
origin: corsOrigins,
credentials: true,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Device-ID'],
exposeHeaders: ['X-VibnCode-Remaining-Tokens'],
})
);
// Initialize database connection for Cloudflare Workers
app.use('*', async (c, next) => {
if (c.env) {
// Initialize DB with environment variables from Cloudflare Workers
const { getDb } = await import('./db/client');
getDb(c.env);
}
await next();
});
// Health check endpoint
app.get('/health', async (c) => {
const dbHealthy = await checkDatabaseConnection(c.env);
const runtime = typeof Bun !== 'undefined' ? 'bun' : 'cloudflare-workers';
const version = typeof Bun !== 'undefined' ? Bun.version : 'n/a';
return c.json({
status: 'ok',
runtime,
version,
database: dbHealthy ? 'connected' : 'disconnected',
timestamp: new Date().toISOString(),
});
});
// API info
app.get('/', (c) => {
return c.json({
name: 'VibnCode Agent Marketplace API',
version: '1.0.0',
docs: '/api/docs',
health: '/health',
});
});
// API routes
app.route('/api/auth', authRoutes);
app.route('/api/marketplace', marketplaceRoutes);
app.route('/api/skills-marketplace', skillsMarketplaceRoutes);
app.route('/api/agents', agentRoutes);
app.route('/api/skills', skillRoutes);
app.route('/api/users', userRoutes);
app.route('/api/models', modelsRoutes);
app.route('/api/remote-skills', remoteSkillsRoutes);
app.route('/api/remote-agents', remoteAgentsRoutes);
app.route('/api/updates', updatesRoutes);
app.route('/api/analytics', analyticsRoutes);
app.route('/api/vibncode', vibncodeProviderRoutes);
app.route('/api/search', searchRoutes);
app.route('/api/shares', shareRoutes);
app.route('/api/web-fetch', webFetchRoutes);
// 404 handler
app.notFound((c) => {
return c.json({ error: 'Not found' }, 404);
});
// Error handler
app.onError(errorHandler);
// Conditional export based on runtime environment
// Check if we're running in Bun (local dev) or Cloudflare Workers
const isBunRuntime = typeof Bun !== 'undefined';
// Scheduled handler for cron triggers (Cloudflare Workers only)
export async function scheduled(
event: ScheduledEvent,
env: HonoContext['env'],
_ctx: ExecutionContext
) {
console.log('[Cron] Running scheduled task:', event.cron);
try {
// Initialize database
const { getDb } = await import('./db/client');
const { db } = getDb(env);
// Import and run cleanup
const { ShareService } = await import('./services/share-service');
const shareService = new ShareService(db);
const deletedCount = await shareService.cleanupExpiredShares();
console.log(`[Cron] Cleaned up ${deletedCount} expired shares`);
} catch (error) {
console.error('[Cron] Failed to run cleanup:', error);
}
}
// Export for Cloudflare Workers (when Bun is not available)
// Export for Bun runtime (when Bun is available)
export default isBunRuntime
? {
port: parseInt(Bun.env.PORT || '3000', 10),
fetch: app.fetch,
development: Bun.env.NODE_ENV !== 'production',
}
: app;
// Also export the app explicitly for Cloudflare Workers compatibility
export { app };

70
src/lib/jwt.ts Normal file
View File

@@ -0,0 +1,70 @@
// JWT utilities using jose library
import { jwtVerify, SignJWT } from 'jose';
import type { Env } from '../types/env';
export interface JWTPayload {
userId: string;
username: string;
email?: string;
[key: string]: string | number | boolean | undefined;
}
/**
* Get JWT secret from environment
*/
function getJWTSecret(env?: Env): Uint8Array {
let secret: string | undefined;
if (typeof Bun !== 'undefined' && Bun.env.JWT_SECRET) {
// Bun runtime (local development)
secret = Bun.env.JWT_SECRET;
} else if (env?.JWT_SECRET) {
// Cloudflare Workers (from context.env)
secret = env.JWT_SECRET;
}
if (!secret) {
throw new Error('JWT_SECRET environment variable is required');
}
return new TextEncoder().encode(secret);
}
/**
* Sign a JWT token
*/
export async function signToken(payload: JWTPayload, expiresIn = '7d', env?: Env): Promise<string> {
const secret = getJWTSecret(env);
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(secret);
}
/**
* Verify and decode a JWT token
*/
export async function verifyToken(token: string, env?: Env): Promise<JWTPayload | null> {
try {
const secret = getJWTSecret(env);
const { payload } = await jwtVerify(token, secret);
return payload as JWTPayload;
} catch (error) {
console.error('JWT verification failed:', error);
return null;
}
}
/**
* Extract token from Authorization header
*/
export function extractTokenFromHeader(authorization?: string): string | null {
if (!authorization) return null;
const parts = authorization.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
return parts[1];
}

45
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,45 @@
// Utility functions
/**
* Generate a URL-friendly slug from a string
*/
export function generateSlug(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Generate a unique slug by appending a counter if needed
*/
export function generateUniqueSlug(baseSlug: string, existingSlugs: string[]): string {
let slug = baseSlug;
let counter = 1;
while (existingSlugs.includes(slug)) {
slug = `${baseSlug}-${counter}`;
counter++;
}
return slug;
}
/**
* Parse comma-separated tags from query string
*/
export function parseTagsFromQuery(tagsString?: string): string[] {
if (!tagsString) return [];
return tagsString
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
/**
* Get device ID from request headers
*/
export function getDeviceId(headers: Headers): string | undefined {
return headers.get('X-Device-ID') || undefined;
}

121
src/middlewares/auth.ts Normal file
View File

@@ -0,0 +1,121 @@
// Authentication middleware
import type { User } from '@vibncode/shared';
import { eq } from 'drizzle-orm';
import type { Context } from 'hono';
import { createMiddleware } from 'hono/factory';
import { db } from '../db/client';
import { users } from '../db/schema';
import { extractTokenFromHeader, verifyToken } from '../lib/jwt';
import type { HonoContext } from '../types/context';
import type { DbUser } from '../types/database';
/**
* Map database user to API user format
*/
function mapDbUserToUser(user: DbUser): User {
return {
id: user.id,
name: user.name,
email: user.email || undefined,
avatarUrl: user.avatarUrl || undefined,
displayName: user.displayName || undefined,
oauthProvider: user.githubId ? 'github' : 'google',
oauthId: (user.githubId || user.googleId) ?? '',
createdAt: new Date(user.createdAt).toISOString(),
updatedAt: new Date(user.updatedAt).toISOString(),
};
}
/**
* Authentication middleware
* Validates JWT token and loads user from database
*/
export const authMiddleware = createMiddleware<HonoContext>(async (c, next) => {
const authorization = c.req.header('Authorization');
const token = extractTokenFromHeader(authorization);
if (!token) {
return c.json({ error: 'Unauthorized: No token provided' }, 401);
}
const payload = await verifyToken(token, c.env);
if (!payload || !payload.userId) {
return c.json({ error: 'Unauthorized: Invalid token' }, 401);
}
// Load user from database
try {
const [user] = await db.select().from(users).where(eq(users.id, payload.userId)).limit(1);
if (!user) {
return c.json({ error: 'Unauthorized: User not found' }, 401);
}
// Set user in context
c.set('user', mapDbUserToUser(user));
c.set('userId', user.id);
} catch (error) {
console.error('Error loading user:', error);
return c.json({ error: 'Internal server error' }, 500);
}
await next();
});
/**
* Optional authentication middleware
* Does not fail if no token is provided, but loads user if available
*/
export const optionalAuthMiddleware = createMiddleware<HonoContext>(async (c, next) => {
const authorization = c.req.header('Authorization');
const token = extractTokenFromHeader(authorization);
if (token) {
const payload = await verifyToken(token, c.env);
if (payload?.userId) {
try {
const [user] = await db.select().from(users).where(eq(users.id, payload.userId)).limit(1);
if (user) {
c.set('user', mapDbUserToUser(user));
c.set('userId', user.id);
}
} catch (error) {
console.error('Error loading user:', error);
}
}
}
await next();
});
/**
* Helper function to get authenticated user and userId
* Use this after authMiddleware
*/
export function getAuth(c: Context<HonoContext>): { userId: string; user: User } {
const userId = c.get('userId');
const user = c.get('user');
if (!userId || !user) {
throw new Error('Unauthorized');
}
return { userId, user };
}
/**
* Helper function to get optional authenticated user
* Use this after optionalAuthMiddleware
*/
export function getOptionalAuth(c: Context<HonoContext>): { userId: string; user: User } | null {
const userId = c.get('userId');
const user = c.get('user');
if (!userId || !user) {
return null;
}
return { userId, user };
}

View File

@@ -0,0 +1,38 @@
// Global error handler middleware
import type { Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { HonoContext } from '../types/context';
export function errorHandler(err: Error, c: Context<HonoContext>) {
console.error('Unhandled error:', err);
if (err instanceof HTTPException) {
return c.json(
{
error: err.message || 'Request failed',
},
err.status
);
}
// Check if it's a known error type
if (err.name === 'ZodError') {
return c.json(
{
error: 'Validation error',
details: err.message,
},
400
);
}
// Default error response
return c.json(
{
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
},
500
);
}

58
src/routes/agents.ts Normal file
View File

@@ -0,0 +1,58 @@
// Agent management routes (CRUD operations)
import { Hono } from 'hono';
const agents = new Hono();
/**
* Create new agent (requires authentication)
* POST /api/agents
* Body: CreateAgentRequest
*/
agents.post('/', (_c) => {
return new Response(null, { status: 410 });
});
/**
* Update agent (requires authentication and ownership)
* PATCH /api/agents/:agentId
* Body: UpdateAgentRequest
*/
agents.patch('/:agentId', (_c) => {
return new Response(null, { status: 410 });
});
/**
* Publish agent (make it public)
* POST /api/agents/:agentId/publish
*/
agents.post('/:agentId/publish', (_c) => {
return new Response(null, { status: 410 });
});
/**
* Unpublish agent
* POST /api/agents/:agentId/unpublish
*/
agents.post('/:agentId/unpublish', (_c) => {
return new Response(null, { status: 410 });
});
/**
* Delete agent (requires authentication and ownership)
* DELETE /api/agents/:agentId
*/
agents.delete('/:agentId', (_c) => {
return new Response(null, { status: 410 });
});
/**
* Create new version for agent
* POST /api/agents/:agentId/versions
* Body: { version, systemPrompt?, toolsConfig?, rules?, outputFormat?, dynamicPromptConfig?, changeLog }
*/
agents.post('/:agentId/versions', (_c) => {
return new Response(null, { status: 410 });
});
export default agents;

355
src/routes/analytics.ts Normal file
View File

@@ -0,0 +1,355 @@
// Analytics routes
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { db } from '../db/client';
import { users } from '../db/schema';
import { verifyToken } from '../lib/jwt';
import { authMiddleware, getAuth } from '../middlewares/auth';
import { analyticsService } from '../services/analytics-service';
import type { HonoContext } from '../types/context';
const analytics = new Hono<HonoContext>();
// POST /api/analytics/events - Track event (no auth required)
analytics.post('/events', async (c) => {
try {
const body = await c.req.json();
const { eventType, sessionId, osName, osVersion, appVersion } = body;
// Accept deviceId from header (preferred) or body (for sendBeacon which can't set headers)
const deviceId = c.req.header('X-Device-ID') || body.deviceId;
if (!deviceId) {
return c.json({ error: 'X-Device-ID header or deviceId in body required' }, 400);
}
if (!eventType || !sessionId) {
return c.json({ error: 'eventType and sessionId are required' }, 400);
}
if (!['session_start', 'session_end'].includes(eventType)) {
return c.json({ error: 'Invalid eventType' }, 400);
}
// Get country from Cloudflare header
const country = c.req.header('CF-IPCountry') || undefined;
await analyticsService.trackEvent({
deviceId,
eventType,
sessionId,
osName,
osVersion,
appVersion,
country,
});
return c.json({ success: true });
} catch (error) {
console.error('Track event error:', error);
return c.json({ error: 'Failed to track event' }, 500);
}
});
// GET /api/analytics/dashboard - Admin dashboard (JSON)
analytics.get('/dashboard', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
// Check if user is admin
const [user] = await db
.select({ role: users.role })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (user?.role !== 'admin') {
return c.json({ error: 'Admin access required' }, 403);
}
const stats = await analyticsService.getDashboardStats();
return c.json({ stats });
} catch (error) {
console.error('Dashboard error:', error);
return c.json({ error: 'Failed to get dashboard' }, 500);
}
});
// GET /api/analytics/dashboard/html - Admin dashboard (HTML page)
// Supports both Authorization header and ?token= query parameter for browser access
analytics.get('/dashboard/html', async (c) => {
try {
// Try to get token from query parameter first (for browser access)
let userId: string | null = null;
const queryToken = c.req.query('token');
if (queryToken) {
const payload = await verifyToken(queryToken, c.env);
if (payload?.userId) {
userId = payload.userId;
}
}
// Fall back to Authorization header
if (!userId) {
const authHeader = c.req.header('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const payload = await verifyToken(token, c.env);
if (payload?.userId) {
userId = payload.userId;
}
}
}
if (!userId) {
return c.html(
'<h1>401 Unauthorized - Token required</h1><p>Add ?token=YOUR_JWT_TOKEN to the URL</p>',
401
);
}
// Check if user is admin
const [user] = await db
.select({ role: users.role })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (user?.role !== 'admin') {
return c.html('<h1>403 Forbidden - Admin access required</h1>', 403);
}
const stats = await analyticsService.getDashboardStats();
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VibnCode Analytics Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
h1 { margin-bottom: 0.5rem; }
.subtitle { color: #666; margin-bottom: 2rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.card {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.card h3 {
margin: 0 0 0.5rem;
color: #666;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card .value {
font-size: 2.5rem;
font-weight: 700;
color: #111;
}
.card .unit {
font-size: 1rem;
color: #666;
font-weight: normal;
}
h2 {
margin: 2rem 0 1rem;
font-size: 1.25rem;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th { background: #f9f9f9; font-weight: 600; }
tr:last-child td { border-bottom: none; }
.chart {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 150px;
padding-top: 1rem;
}
.bar {
flex: 1;
background: #3b82f6;
border-radius: 4px 4px 0 0;
min-width: 8px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.bar:hover { background: #2563eb; }
.bar-label {
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: #666;
white-space: nowrap;
}
.tooltip {
position: fixed;
background: #1f2937;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 4px 6px rgba(0,0,0,0.15);
transform: translateX(-50%);
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #1f2937 transparent transparent transparent;
}
.tooltip .date { font-weight: 600; margin-bottom: 2px; }
.tooltip .count { color: #93c5fd; }
</style>
</head>
<body>
<h1>VibnCode Analytics</h1>
<p class="subtitle">Updated: ${new Date().toISOString()}</p>
<div class="grid">
<div class="card">
<h3>Daily Active Users</h3>
<div class="value">${stats.dau}</div>
</div>
<div class="card">
<h3>Monthly Active Users</h3>
<div class="value">${stats.mau}</div>
</div>
<div class="card">
<h3>Registered Users</h3>
<div class="value">${stats.totalUsers}</div>
</div>
<div class="card">
<h3>Avg Session Duration</h3>
<div class="value">${stats.avgSessionDurationMinutes.toFixed(1)} <span class="unit">min</span></div>
</div>
</div>
<h2>Daily Active Users (Last 30 Days)</h2>
<div class="chart">
<div class="bar-chart">
${(() => {
const maxCount = Math.max(...stats.dailyActiveHistory.map((d) => d.count), 1);
return stats.dailyActiveHistory
.map(
(d) => `
<div class="bar" style="height: ${(d.count / maxCount) * 100}%" data-date="${d.date}" data-count="${d.count}">
<span class="bar-label">${d.date.slice(5)}</span>
</div>
`
)
.join('');
})()}
</div>
</div>
<h2>Top Countries</h2>
<table>
<tr><th>Country</th><th>Unique Devices</th></tr>
${stats.topCountries.map((c) => `<tr><td>${c.country}</td><td>${c.count}</td></tr>`).join('')}
${stats.topCountries.length === 0 ? '<tr><td colspan="2">No data yet</td></tr>' : ''}
</table>
<h2>App Versions</h2>
<table>
<tr><th>Version</th><th>Unique Devices</th></tr>
${stats.topVersions.map((v) => `<tr><td>${v.version}</td><td>${v.count}</td></tr>`).join('')}
${stats.topVersions.length === 0 ? '<tr><td colspan="2">No data yet</td></tr>' : ''}
</table>
<h2>Operating Systems</h2>
<table>
<tr><th>OS</th><th>Unique Devices</th></tr>
${stats.topOS.map((o) => `<tr><td>${o.os}</td><td>${o.count}</td></tr>`).join('')}
${stats.topOS.length === 0 ? '<tr><td colspan="2">No data yet</td></tr>' : ''}
</table>
<div class="tooltip" id="tooltip">
<div class="date"></div>
<div class="count"></div>
</div>
<script>
const tooltip = document.getElementById('tooltip');
const bars = document.querySelectorAll('.bar');
bars.forEach(bar => {
bar.addEventListener('mouseenter', (e) => {
const date = bar.dataset.date;
const count = bar.dataset.count;
tooltip.querySelector('.date').textContent = date;
tooltip.querySelector('.count').textContent = count + ' active users';
tooltip.style.opacity = '1';
});
bar.addEventListener('mousemove', (e) => {
const rect = bar.getBoundingClientRect();
tooltip.style.left = (rect.left + rect.width / 2) + 'px';
tooltip.style.top = (rect.top - tooltip.offsetHeight - 10) + 'px';
});
bar.addEventListener('mouseleave', () => {
tooltip.style.opacity = '0';
});
});
</script>
</body>
</html>`;
return c.html(html);
} catch (error) {
console.error('Dashboard HTML error:', error);
return c.html('<h1>500 Internal Server Error</h1>', 500);
}
});
export default analytics;

346
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,346 @@
// Authentication routes
import { githubAuth } from '@hono/oauth-providers/github';
import { googleAuth } from '@hono/oauth-providers/google';
import type { Context } from 'hono';
import { Hono } from 'hono';
import { authMiddleware, getAuth } from '../middlewares/auth';
import { authService } from '../services/auth-service';
import type { HonoContext } from '../types/context';
const auth = new Hono<HonoContext>();
// Helper to get OAuth config from environment (works in both Bun and Cloudflare Workers)
function normalizeEnvValue(value?: string | null) {
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function getOAuthConfig(c: Context<HonoContext>) {
const env = c.env;
return {
githubClientId: normalizeEnvValue(
env?.GITHUB_CLIENT_ID || (typeof Bun !== 'undefined' ? Bun.env.GITHUB_CLIENT_ID : '')
),
githubClientSecret: normalizeEnvValue(
env?.GITHUB_CLIENT_SECRET || (typeof Bun !== 'undefined' ? Bun.env.GITHUB_CLIENT_SECRET : '')
),
googleClientId: normalizeEnvValue(
env?.GOOGLE_CLIENT_ID || (typeof Bun !== 'undefined' ? Bun.env.GOOGLE_CLIENT_ID : '')
),
googleClientSecret: normalizeEnvValue(
env?.GOOGLE_CLIENT_SECRET || (typeof Bun !== 'undefined' ? Bun.env.GOOGLE_CLIENT_SECRET : '')
),
googleRedirectUri: normalizeEnvValue(
env?.GOOGLE_REDIRECT_URI || (typeof Bun !== 'undefined' ? Bun.env.GOOGLE_REDIRECT_URI : '')
),
};
}
function renderSuccessPage(deepLink: string) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authentication Successful</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 20% 20%, #1c1c1f, #0b0b0f 60%);
color: #f5f5f5;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
letter-spacing: 0.01em;
}
.wrap { width: min(540px, 90vw); padding: 32px; }
.card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 12, 16, 0.85);
border-radius: 20px;
padding: 32px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(16px);
text-align: center;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
font-size: 28px;
margin-bottom: 20px;
}
h1 { margin: 0 0 12px; font-size: 26px; font-weight: 600; color: #f8f8f8; }
.sub { margin: 0 0 24px; color: #cfcfd4; font-size: 15px; }
.spinner {
margin: 0 auto 20px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid rgba(255, 255, 255, 0.15);
border-top-color: #ffffff;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.hint { margin: 0; color: #b6b6bd; line-height: 1.6; font-size: 14px; }
.link {
color: #ffffff;
text-decoration: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.4);
padding-bottom: 2px;
transition: opacity 0.2s ease;
}
.link:hover { opacity: 0.7; }
.actions {
margin-top: 18px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
color: #ffffff;
text-decoration: none;
font-size: 13px;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.button:hover { opacity: 0.85; transform: translateY(-1px); }
.code {
margin-top: 16px;
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
word-break: break-all;
color: #d8d8df;
}
.copy {
border: none;
background: none;
color: #ffffff;
text-decoration: underline;
cursor: pointer;
font-size: 12px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="badge">✓</div>
<h1>Authentication Successful</h1>
<p class="sub">Signed in. Redirecting to VibnCode...</p>
<div class="spinner" aria-label="Loading"></div>
<p class="hint">
If the app doesn't open automatically, <a class="link" href="${deepLink}" id="manual-link">click to continue</a> or return to the app to finish.
</p>
<div class="actions">
<a class="button" href="${deepLink}">Open VibnCode</a>
<button class="button" type="button" id="copy-link">Copy sign-in link</button>
</div>
<div class="code" id="link-preview">${deepLink}</div>
<button class="copy" type="button" id="copy-token">Copy token only</button>
</div>
</div>
<script>
const deepLink = '${deepLink}';
const token = new URL(deepLink).searchParams.get('token') || '';
const copyLinkButton = document.getElementById('copy-link');
const copyTokenButton = document.getElementById('copy-token');
const copyText = async (value) => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
} catch (error) {
const textArea = document.createElement('textarea');
textArea.value = value;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
};
copyLinkButton?.addEventListener('click', () => copyText(deepLink));
copyTokenButton?.addEventListener('click', () => copyText(token));
setTimeout(() => { window.location.href = deepLink; }, 900);
setTimeout(() => { window.close(); }, 9000);
</script>
</body>
</html>
`;
}
/**
* GitHub OAuth
*/
// Dynamic middleware that gets config from context
auth.use('/github', async (c, next) => {
const config = getOAuthConfig(c);
if (!config.githubClientId || !config.githubClientSecret) {
return c.json(
{
error: 'GitHub OAuth is not configured',
message: 'Missing GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET',
},
400
);
}
const middleware = githubAuth({
client_id: config.githubClientId,
client_secret: config.githubClientSecret,
scope: ['read:user', 'user:email'],
oauthApp: true, // Use OAuth App instead of GitHub App (doesn't require email endpoint)
});
return middleware(c, next);
});
auth.get('/github', async (c) => {
const githubUser = c.get('user-github');
if (!githubUser) {
return c.json({ error: 'GitHub authentication failed' }, 400);
}
try {
const _config = getOAuthConfig(c);
console.log('GitHub user data:', {
id: githubUser.id,
login: githubUser.login,
email: githubUser.email,
name: githubUser.name,
});
// Use a fallback email if GitHub email is not available
const email = githubUser.email || `${githubUser.login}@users.noreply.github.com`;
// Find or create user
const user = await authService.findOrCreateUser({
provider: 'github',
providerId: githubUser.id?.toString() ?? '',
email: email,
name: githubUser.name ?? githubUser.login ?? 'GitHub User',
avatarUrl: githubUser.avatar_url,
});
// Generate JWT token
const token = await authService.generateToken(user.id, user.email, c.env);
// Return HTML page that handles deep link redirect
const deepLink = `vibncode://auth/callback?token=${token}`;
return c.html(renderSuccessPage(deepLink));
} catch (error) {
console.error('GitHub auth error:', error);
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
return c.redirect(`vibncode://auth/error?message=${encodeURIComponent(errorMessage)}`);
}
});
/**
* Google OAuth
*/
// Dynamic middleware that gets config from context
// Explicit redirect_uri ensures Google console settings match and avoids code exchange errors.
auth.use('/google', async (c, next) => {
const config = getOAuthConfig(c);
if (!config.googleClientId || !config.googleClientSecret) {
return c.json(
{
error: 'Google OAuth is not configured',
message: 'Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET',
},
400
);
}
const baseRedirectUri = config.googleRedirectUri || c.req.url.split('?')[0];
const middleware = googleAuth({
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
scope: ['openid', 'email', 'profile'],
redirect_uri: baseRedirectUri,
access_type: 'offline',
prompt: 'consent',
});
return middleware(c, next);
});
auth.get('/google', async (c) => {
const googleUser = c.get('user-google');
if (!googleUser) {
return c.json({ error: 'Google authentication failed' }, 400);
}
try {
// Find or create user
const user = await authService.findOrCreateUser({
provider: 'google',
providerId: googleUser.id ?? googleUser.sub ?? '',
email: googleUser.email ?? '',
name: googleUser.name ?? 'Google User',
avatarUrl: googleUser.picture,
});
// Generate JWT token
const token = await authService.generateToken(user.id, user.email, c.env);
// Return HTML page that handles deep link redirect
const deepLink = `vibncode://auth/callback?token=${token}`;
return c.html(renderSuccessPage(deepLink));
} catch (error) {
console.error('Google auth error:', error);
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
return c.redirect(`vibncode://auth/error?message=${encodeURIComponent(errorMessage)}`);
}
});
/**
* Get current user (requires authentication)
*/
auth.get('/me', authMiddleware, async (c) => {
const { userId } = getAuth(c);
const user = await authService.getUserById(userId);
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json({ user });
});
/**
* Logout endpoint (client-side token removal)
*/
auth.post('/logout', authMiddleware, async (c) => {
// JWT is stateless, so logout is handled client-side by removing the token
return c.json({ message: 'Logged out successfully' });
});
export default auth;

192
src/routes/marketplace.ts Normal file
View File

@@ -0,0 +1,192 @@
// Marketplace browsing routes (compatibility layer for legacy clients)
import { Hono } from 'hono';
import {
filterAndSortRemoteAgents,
normalizeRemoteAgent,
} from '../services/marketplace-compat-service';
import { remoteAgentsService } from '../services/remote-agents-service';
const marketplace = new Hono();
type SortBy = 'popular' | 'recent' | 'installs' | 'name';
const parseBool = (value: string | null | undefined): boolean | undefined => {
if (value === undefined || value === null) return undefined;
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
};
/**
* List agents with filtering and sorting
* GET /api/marketplace/agents?limit=20&offset=0&sortBy=popular&search=coding&categoryIds=cat1,cat2&tagIds=tag1,tag2&isFeatured=true
*/
marketplace.get('/agents', (c) => {
const limit = parseInt(c.req.query('limit') || '20', 10);
const offset = parseInt(c.req.query('offset') || '0', 10);
const sortBy = (c.req.query('sortBy') || 'popular') as SortBy;
const search = c.req.query('search') || undefined;
const categoryIds = c.req.query('categoryIds')?.split(',').filter(Boolean);
const tagIds = c.req.query('tagIds')?.split(',').filter(Boolean);
const isFeatured = parseBool(c.req.query('isFeatured'));
const configs = remoteAgentsService.getConfigs();
const { paginated, total } = filterAndSortRemoteAgents(configs.remoteAgents, {
limit,
offset,
sortBy,
search,
categoryIds,
tagIds,
isFeatured,
});
return c.json({
count: total,
total,
limit,
offset,
agents: paginated.map(normalizeRemoteAgent),
});
});
/**
* Get featured agents
* GET /api/marketplace/agents/featured?limit=10
*/
marketplace.get('/agents/featured', (c) => {
const limit = parseInt(c.req.query('limit') || '10', 10);
const configs = remoteAgentsService.getConfigs();
const { paginated, total } = filterAndSortRemoteAgents(configs.remoteAgents, {
limit,
offset: 0,
sortBy: 'popular',
isFeatured: true,
});
return c.json({
count: total,
total,
limit,
offset: 0,
agents: paginated.map(normalizeRemoteAgent),
});
});
/**
* Get agent by slug
* GET /api/marketplace/agents/:slug
*/
marketplace.get('/agents/:slug', (c) => {
const slug = c.req.param('slug');
const configs = remoteAgentsService.getConfigs();
const agent = configs.remoteAgents.find(
(item) => (item as { slug?: string; id?: string }).slug === slug || item.id === slug
);
if (!agent) {
return c.json({ error: 'Agent not found' }, 404);
}
return c.json({ agent: normalizeRemoteAgent(agent) });
});
/**
* Download agent (tracking disabled)
* POST /api/marketplace/agents/:slug/download
*/
marketplace.get('/agents/:slug/download', (c) => {
const slug = c.req.param('slug');
const configs = remoteAgentsService.getConfigs();
const agent = configs.remoteAgents.find(
(item) => (item as { slug?: string; id?: string }).slug === slug || item.id === slug
);
if (!agent) {
return c.json({ error: 'Agent not found' }, 404);
}
return c.json({
message: 'Download tracking disabled',
agent: normalizeRemoteAgent(agent),
});
});
/**
* Install agent (tracking disabled)
* POST /api/marketplace/agents/:slug/install
*/
marketplace.post('/agents/:slug/install', (c) => {
const slug = c.req.param('slug');
const configs = remoteAgentsService.getConfigs();
const agent = configs.remoteAgents.find(
(item) => (item as { slug?: string; id?: string }).slug === slug || item.id === slug
);
if (!agent) {
return c.json({ error: 'Agent not found' }, 404);
}
return c.json({
message: 'Installation tracking disabled',
});
});
/**
* Get all categories
* GET /api/marketplace/categories
*/
marketplace.get('/categories', (c) => {
const configs = remoteAgentsService.getConfigs();
const categories = new Map<
string,
{
id: string;
name: string;
slug: string;
description: string;
icon?: string;
displayOrder: number;
}
>();
for (const agent of configs.remoteAgents) {
const category = (agent as { category?: string }).category;
if (category && !categories.has(category)) {
categories.set(category, {
id: category,
name: category,
slug: category,
description: '',
icon: undefined,
displayOrder: 0,
});
}
}
return c.json({ categories: Array.from(categories.values()) });
});
/**
* Get all tags
* GET /api/marketplace/tags
*/
marketplace.get('/tags', (c) => {
const configs = remoteAgentsService.getConfigs();
const tags = new Map<string, { id: string; name: string; slug: string; usageCount: number }>();
for (const agent of configs.remoteAgents) {
const tagList = ((agent as { tags?: string[] }).tags || []) as string[];
if (Array.isArray(tagList)) {
for (const tag of tagList) {
if (!tags.has(tag)) {
tags.set(tag, { id: tag, name: tag, slug: tag, usageCount: 0 });
}
}
}
}
return c.json({ tags: Array.from(tags.values()) });
});
export default marketplace;

54
src/routes/models.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Hono } from 'hono';
import { modelsService } from '../services/models-service';
import type { HonoContext } from '../types/context';
const models = new Hono<HonoContext>();
/**
* GET /api/models/version
* Returns the current models configuration version
*/
models.get('/version', (c) => {
const version = modelsService.getVersion();
return c.json(version);
});
/**
* GET /api/models/configs
* Returns the complete models configuration
*/
models.get('/configs', (c) => {
const configs = modelsService.getConfigs();
return c.json(configs);
});
/**
* GET /api/models/:modelKey
* Returns a specific model configuration
*/
models.get('/:modelKey', (c) => {
const modelKey = c.req.param('modelKey');
const model = modelsService.getModel(modelKey);
if (!model) {
return c.json({ error: 'Model not found' }, 404);
}
return c.json(model);
});
/**
* GET /api/models
* Returns a list of all model keys
*/
models.get('/', (c) => {
const keys = modelsService.getModelKeys();
const count = modelsService.getModelsCount();
return c.json({
count,
models: keys,
});
});
export default models;

View File

@@ -0,0 +1,38 @@
import { Hono } from 'hono';
import { remoteAgentsService } from '../services/remote-agents-service';
import type { HonoContext } from '../types/context';
const remoteAgents = new Hono<HonoContext>();
remoteAgents.get('/version', (c) => {
const version = remoteAgentsService.getVersion();
return c.json(version);
});
remoteAgents.get('/configs', (c) => {
const configs = remoteAgentsService.getConfigs();
return c.json(configs);
});
remoteAgents.get('/:agentId', (c) => {
const agentId = c.req.param('agentId');
const agent = remoteAgentsService.getRemoteAgent(agentId);
if (!agent) {
return c.json({ error: 'Remote agent not found' }, 404);
}
return c.json(agent);
});
remoteAgents.get('/', (c) => {
const ids = remoteAgentsService.getRemoteAgentIds();
const count = remoteAgentsService.getRemoteAgentsCount();
return c.json({
count,
agents: ids,
});
});
export default remoteAgents;

View File

@@ -0,0 +1,63 @@
import { Hono } from 'hono';
import { remoteSkillsService } from '../services/remote-skills-service';
import type { HonoContext } from '../types/context';
const remoteSkills = new Hono<HonoContext>();
/**
* GET /api/remote-skills/version
* Returns the current remote skills configuration version
*/
remoteSkills.get('/version', (c) => {
const version = remoteSkillsService.getVersion();
return c.json(version);
});
/**
* GET /api/remote-skills/configs
* Returns the complete remote skills configuration
*/
remoteSkills.get('/configs', (c) => {
const configs = remoteSkillsService.getConfigs();
return c.json(configs);
});
/**
* GET /api/remote-skills/categories
* Returns all unique categories
*/
remoteSkills.get('/categories', (c) => {
const categories = remoteSkillsService.getCategories();
return c.json({ categories });
});
/**
* GET /api/remote-skills/:skillId
* Returns a specific remote skill configuration
*/
remoteSkills.get('/:skillId', (c) => {
const skillId = c.req.param('skillId');
const skill = remoteSkillsService.getRemoteSkill(skillId);
if (!skill) {
return c.json({ error: 'Remote skill not found' }, 404);
}
return c.json(skill);
});
/**
* GET /api/remote-skills
* Returns a list of all remote skill IDs
*/
remoteSkills.get('/', (c) => {
const ids = remoteSkillsService.getRemoteSkillIds();
const count = remoteSkillsService.getRemoteSkillsCount();
return c.json({
count,
skills: ids,
});
});
export default remoteSkills;

242
src/routes/search.ts Normal file
View File

@@ -0,0 +1,242 @@
// Search API route - Proxies Serper search requests with rate limiting
import { Hono } from 'hono';
import { getOptionalAuth, optionalAuthMiddleware } from '../middlewares/auth';
import { searchUsageService } from '../services/search-usage-service';
import type { HonoContext } from '../types/context';
const search = new Hono<HonoContext>();
// Serper API types
interface SerperSearchResult {
title: string;
link: string;
snippet?: string;
}
interface SerperSearchResponse {
organic?: SerperSearchResult[];
}
// Web search result format (frontend compatible)
interface WebSearchResult {
title: string;
url: string;
content: string;
}
// Request body schema
interface SearchRequest {
query: string;
numResults?: number; // default 10, max 20
type?: 'auto' | 'neural' | 'fast' | 'deep'; // default 'auto'
}
// Response schema
interface SearchResponse {
results: WebSearchResult[];
usage: {
remaining: number;
limit: number;
used: number;
};
}
/**
* Get SERPER_API_KEY from environment
*/
function getSerperApiKey(env?: HonoContext['Bindings']): string | undefined {
if (typeof Bun !== 'undefined') {
return Bun.env.SERPER_API_KEY;
}
return env?.SERPER_API_KEY;
}
async function callSerperApi(
query: string,
numResults: number,
apiKey: string
): Promise<SerperSearchResponse> {
const endpoint = 'https://google.serper.dev/search';
const body = {
q: query,
num: numResults,
};
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Serper API error: ${response.status} - ${errorText}`);
}
return (await response.json()) as SerperSearchResponse;
}
function transformSerperResults(serperResults: SerperSearchResult[]): WebSearchResult[] {
return serperResults.map((result) => ({
title: result.title,
url: result.link,
content: (result.snippet || '').substring(0, 10000),
}));
}
search.post('/', optionalAuthMiddleware, async (c) => {
// Get device ID from header (required)
const deviceId = c.req.header('X-Device-ID');
if (!deviceId) {
return c.json(
{
error: 'Missing X-Device-ID header',
},
400
);
}
// Get optional user ID from auth
const auth = getOptionalAuth(c);
const userId = auth?.userId;
// Parse request body
let requestBody: SearchRequest;
try {
requestBody = await c.req.json();
} catch {
return c.json(
{
error: 'Invalid JSON body',
},
400
);
}
// Validate request
if (!requestBody.query || typeof requestBody.query !== 'string') {
return c.json(
{
error: 'Missing or invalid query parameter',
},
400
);
}
const numResults = Math.min(requestBody.numResults || 10, 20);
// Check rate limits
try {
const usageCheck = await searchUsageService.checkSearchLimits(deviceId, userId);
if (!usageCheck.allowed) {
return c.json(
{
error: usageCheck.reason || 'Rate limit exceeded',
usage: {
remaining: usageCheck.remaining,
limit: usageCheck.limit,
used: usageCheck.used,
},
},
429
);
}
// Get Serper API key
const serperApiKey = getSerperApiKey(c.env);
if (!serperApiKey) {
console.error('SERPER_API_KEY is not configured');
return c.json(
{
error: 'Search service not configured',
},
500
);
}
// Call Serper API
const serperResponse = await callSerperApi(requestBody.query, numResults, serperApiKey);
// Transform results
const results = transformSerperResults(serperResponse.organic || []);
// Record usage
await searchUsageService.recordSearch(deviceId, userId);
// Get updated usage stats
const stats = await searchUsageService.getSearchStats(deviceId, userId);
// Return results with usage info
const response: SearchResponse = {
results,
usage: {
remaining: stats.remaining,
limit: stats.limit,
used: stats.used,
},
};
return c.json(response, 200);
} catch (error) {
console.error('Search API error:', error);
// Handle Serper API errors
if (error instanceof Error && error.message.includes('Serper API error')) {
return c.json(
{
error: 'Search provider error',
details: error.message,
},
500
);
}
return c.json(
{
error: 'Internal server error',
},
500
);
}
});
search.get('/usage', optionalAuthMiddleware, async (c) => {
const deviceId = c.req.header('X-Device-ID');
if (!deviceId) {
return c.json(
{
error: 'Missing X-Device-ID header',
},
400
);
}
const auth = getOptionalAuth(c);
const userId = auth?.userId;
try {
const stats = await searchUsageService.getSearchStats(deviceId, userId);
return c.json(stats);
} catch (error) {
console.error('Failed to get search stats:', error);
return c.json({ error: 'Failed to get search statistics' }, 500);
}
});
search.get('/health', async (c) => {
const serperApiKey = getSerperApiKey(c.env);
return c.json({
status: serperApiKey ? 'ok' : 'not_configured',
provider: 'serper',
timestamp: new Date().toISOString(),
});
});
export default search;

212
src/routes/shares.ts Normal file
View File

@@ -0,0 +1,212 @@
// apps/api/src/routes/shares.ts
// API routes for task sharing
import { Hono } from 'hono';
import { getDb } from '../db/client';
import { ShareService } from '../services/share-service';
import type { HonoContext } from '../types/context';
const shares = new Hono<HonoContext>();
/**
* POST /api/shares - Create a new share
*/
shares.post('/', async (c) => {
try {
const body = await c.req.json();
// Validate request
if (!body.snapshot || !body.snapshot.task || !body.snapshot.messages) {
return c.json({ error: 'Invalid request: missing snapshot data' }, 400);
}
const { db } = getDb(c.env);
const shareService = new ShareService(db);
// Get optional user ID from auth (if authenticated)
const userId = c.get('userId') as string | undefined;
// Get device ID from header (for anonymous shares)
const deviceId = c.req.header('X-Device-ID');
// Verify device ID if no user authentication
if (!userId && deviceId) {
const isValidDevice = await shareService.verifyDeviceId(deviceId);
if (!isValidDevice) {
return c.json(
{ error: 'Invalid device ID. Please ensure your VibnCode app is up to date.' },
401
);
}
}
const result = await shareService.createShare(body, userId, deviceId);
return c.json(result, 201);
} catch (error) {
console.error('Failed to create share:', error);
// Check for size limit error
if (error instanceof Error && error.message.includes('exceeds maximum allowed size')) {
return c.json({ error: error.message }, 413); // 413 Payload Too Large
}
return c.json({ error: 'Failed to create share' }, 500);
}
});
/**
* GET /api/shares/:shareId - Get share data
*/
shares.get('/:shareId', async (c) => {
try {
const shareId = c.req.param('shareId');
const password = c.req.query('password');
console.log(
'[Shares] GET /api/shares/',
shareId,
'password:',
password ? 'provided' : 'not provided'
);
const { db } = getDb(c.env);
const shareService = new ShareService(db);
// First check if share exists and requires password
const access = await shareService.checkShareAccess(shareId);
console.log('[Shares] access:', access);
if (!access.exists) {
return c.json({ error: 'Share not found' }, 404);
}
if (access.expired) {
return c.json({ error: 'Share has expired' }, 410);
}
if (access.requiresPassword && !password) {
return c.json({ requiresPassword: true, shareId }, 401);
}
// Get the full share data
const snapshot = await shareService.getShare(shareId, password);
if (!snapshot) {
return c.json({ error: 'Share not found' }, 404);
}
return c.json(snapshot);
} catch (error) {
if (error instanceof Error) {
if (error.message === 'PASSWORD_REQUIRED') {
return c.json({ requiresPassword: true }, 401);
}
if (error.message === 'INVALID_PASSWORD') {
return c.json({ error: 'Invalid password' }, 401);
}
}
console.error('Failed to get share:', error);
return c.json({ error: 'Failed to get share' }, 500);
}
});
/**
* POST /api/shares/:shareId/verify - Verify share password
*/
shares.post('/:shareId/verify', async (c) => {
try {
const shareId = c.req.param('shareId');
const body = await c.req.json();
const password = body.password;
if (!password) {
return c.json({ error: 'Password required' }, 400);
}
const { db } = getDb(c.env);
const shareService = new ShareService(db);
const snapshot = await shareService.getShare(shareId, password);
if (!snapshot) {
return c.json({ error: 'Share not found' }, 404);
}
return c.json(snapshot);
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_PASSWORD') {
return c.json({ error: 'Invalid password' }, 401);
}
console.error('Failed to verify share:', error);
return c.json({ error: 'Failed to verify share' }, 500);
}
});
/**
* GET /api/shares/user/list - Get user's shares (requires auth)
*/
shares.get('/user/list', async (c) => {
const userId = c.get('userId') as string | undefined;
if (!userId) {
return c.json({ error: 'Authentication required' }, 401);
}
try {
const { db } = getDb(c.env);
const shareService = new ShareService(db);
const shares = await shareService.getUserShares(userId);
return c.json({ shares });
} catch (error) {
console.error('Failed to get user shares:', error);
return c.json({ error: 'Failed to get shares' }, 500);
}
});
/**
* DELETE /api/shares/:shareId - Delete a share
*/
shares.delete('/:shareId', async (c) => {
const shareId = c.req.param('shareId');
const userId = c.get('userId') as string | undefined;
const deviceId = c.req.header('X-Device-ID');
if (!userId && !deviceId) {
return c.json({ error: 'Authentication required' }, 401);
}
try {
const { db } = getDb(c.env);
const shareService = new ShareService(db);
// Verify device ID if no user authentication
if (!userId && deviceId) {
const isValidDevice = await shareService.verifyDeviceId(deviceId);
if (!isValidDevice) {
return c.json(
{ error: 'Invalid device ID. Please ensure your VibnCode app is up to date.' },
401
);
}
}
let deleted = false;
if (userId) {
deleted = await shareService.deleteShare(shareId, userId);
} else if (deviceId) {
deleted = await shareService.deleteShareByDevice(shareId, deviceId);
}
if (!deleted) {
return c.json({ error: 'Share not found or not authorized' }, 404);
}
return c.json({ success: true });
} catch (error) {
console.error('Failed to delete share:', error);
return c.json({ error: 'Failed to delete share' }, 500);
}
});
export default shares;

View File

@@ -0,0 +1,197 @@
// Skills Marketplace browsing routes (compatibility layer for legacy clients)
import { Hono } from 'hono';
import { optionalAuthMiddleware } from '../middlewares/auth';
import {
filterAndSortRemoteSkills,
normalizeRemoteSkill,
} from '../services/marketplace-compat-service';
import { remoteSkillsService } from '../services/remote-skills-service';
const skillsMarketplace = new Hono();
/**
* List skills with filtering and sorting
* GET /api/skills-marketplace/skills?limit=20&offset=0&sortBy=popular&search=xxxx&categoryIds=cat1,cat2&tagIds=tag1,tag2&isFeatured=true
*/
skillsMarketplace.get('/skills', optionalAuthMiddleware, (c) => {
const limit = parseInt(c.req.query('limit') || '20', 10);
const offset = parseInt(c.req.query('offset') || '0', 10);
const sortBy = (c.req.query('sortBy') || 'popular') as
| 'popular'
| 'recent'
| 'downloads'
| 'installs'
| 'name'
| 'rating'
| 'updated';
const search = c.req.query('search') || undefined;
const categoryIds = c.req.query('categoryIds')?.split(',').filter(Boolean);
const tagIds = c.req.query('tagIds')?.split(',').filter(Boolean);
const isFeatured = c.req.query('isFeatured') ? c.req.query('isFeatured') === 'true' : undefined;
const configs = remoteSkillsService.getConfigs();
const { paginated, total } = filterAndSortRemoteSkills(configs.remoteSkills, {
limit,
offset,
sortBy,
search,
categoryIds,
tagIds,
isFeatured,
});
return c.json({
skills: paginated.map(normalizeRemoteSkill),
total,
limit,
offset,
});
});
/**
* Get featured skills
* GET /api/skills-marketplace/skills/featured?limit=10
*/
skillsMarketplace.get('/skills/featured', (c) => {
const limit = parseInt(c.req.query('limit') || '10', 10);
const configs = remoteSkillsService.getConfigs();
const { paginated, total } = filterAndSortRemoteSkills(configs.remoteSkills, {
limit,
offset: 0,
sortBy: 'popular',
isFeatured: true,
});
return c.json({
skills: paginated.map(normalizeRemoteSkill),
total,
limit,
offset: 0,
});
});
/**
* Get skill by slug
* GET /api/skills-marketplace/skills/:slug
*/
skillsMarketplace.get('/skills/:slug', (c) => {
const slug = c.req.param('slug');
const configs = remoteSkillsService.getConfigs();
const skill = configs.remoteSkills.find(
(item) => (item as { slug?: string; id?: string }).slug === slug || item.id === slug
);
if (!skill) {
return c.json({ error: 'Skill not found' }, 404);
}
return c.json({ skill: normalizeRemoteSkill(skill) });
});
/**
* Download skill (tracking disabled)
* POST /api/skills-marketplace/skills/:slug/download
*/
skillsMarketplace.post('/skills/:slug/download', optionalAuthMiddleware, (c) => {
const slug = c.req.param('slug');
const configs = remoteSkillsService.getConfigs();
const skill = configs.remoteSkills.find(
(item) => (item as { slug?: string; id?: string }).slug === slug || item.id === slug
);
if (!skill) {
return c.json({ error: 'Skill not found' }, 404);
}
return c.json({
message: 'Download tracking disabled',
skill: normalizeRemoteSkill(skill),
});
});
/**
* Install skill (tracking disabled)
* POST /api/skills-marketplace/skills/:slug/install
* Body: { version: "1.0.0" }
*/
skillsMarketplace.post('/skills/:slug/install', optionalAuthMiddleware, async (c) => {
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => ({}));
const version = (body as { version?: string }).version;
if (!version) {
return c.json({ error: 'Version is required' }, 400);
}
const configs = remoteSkillsService.getConfigs();
const skill = configs.remoteSkills.find(
(item) => (item as { slug?: string; id?: string }).slug === slug || item.id === slug
);
if (!skill) {
return c.json({ error: 'Skill not found' }, 404);
}
return c.json({
message: 'Installation tracking disabled',
});
});
/**
* Get all categories
* GET /api/skills-marketplace/categories
*/
skillsMarketplace.get('/categories', (c) => {
const configs = remoteSkillsService.getConfigs();
const categories = new Map<
string,
{
id: string;
name: string;
slug: string;
description?: string;
icon?: string;
displayOrder: number;
}
>();
for (const skill of configs.remoteSkills) {
const category = (skill as { category?: string }).category;
if (category && !categories.has(category)) {
categories.set(category, {
id: category,
name: category,
slug: category,
description: '',
icon: undefined,
displayOrder: 0,
});
}
}
return c.json({ categories: Array.from(categories.values()) });
});
/**
* Get all tags
* GET /api/skills-marketplace/tags
*/
skillsMarketplace.get('/tags', (c) => {
const configs = remoteSkillsService.getConfigs();
const tags = new Map<string, { id: string; name: string; slug: string; usageCount: number }>();
for (const skill of configs.remoteSkills) {
const tagList = ((skill as { tags?: string[] }).tags || []) as string[];
if (Array.isArray(tagList)) {
for (const tag of tagList) {
if (!tags.has(tag)) {
tags.set(tag, { id: tag, name: tag, slug: tag, usageCount: 0 });
}
}
}
}
return c.json({ tags: Array.from(tags.values()) });
});
export default skillsMarketplace;

169
src/routes/skills.ts Normal file
View File

@@ -0,0 +1,169 @@
// Skill management routes (CRUD operations)
import type { CreateSkillRequest, UpdateSkillRequest } from '@vibncode/shared';
import { Hono } from 'hono';
import { authMiddleware, getAuth } from '../middlewares/auth';
import { skillService } from '../services/skill-service';
import type { HonoContext } from '../types/context';
const skills = new Hono<HonoContext>();
/**
* Create new skill (requires authentication)
* POST /api/skills
* Body: CreateSkillRequest
*/
skills.post('/', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const data = await c.req.json<CreateSkillRequest>();
// Validate required fields
if (!data.name || !data.description || !data.documentation) {
return c.json({ error: 'Missing required fields: name, description, documentation' }, 400);
}
const skill = await skillService.createSkill(userId, data);
return c.json({ skill }, 201);
} catch (error) {
console.error('Create skill error:', error);
const message = error instanceof Error ? error.message : 'Failed to create skill';
return c.json({ error: message }, 500);
}
});
/**
* Update skill (requires authentication and ownership)
* PATCH /api/skills/:skillId
* Body: UpdateSkillRequest
*/
skills.patch('/:skillId', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const skillId = c.req.param('skillId');
const data = await c.req.json<UpdateSkillRequest>();
const skill = await skillService.updateSkill(userId, skillId, data);
return c.json({ skill });
} catch (error) {
console.error('Update skill error:', error);
const message = error instanceof Error ? error.message : 'Failed to update skill';
if (message.includes('not found') || message.includes('unauthorized')) {
return c.json({ error: message }, 404);
}
return c.json({ error: message }, 500);
}
});
/**
* Publish skill (make it public)
* POST /api/skills/:skillId/publish
*/
skills.post('/:skillId/publish', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const skillId = c.req.param('skillId');
const skill = await skillService.publishSkill(userId, skillId);
return c.json({ skill });
} catch (error) {
console.error('Publish skill error:', error);
const message = error instanceof Error ? error.message : 'Failed to publish skill';
if (message.includes('not found') || message.includes('unauthorized')) {
return c.json({ error: message }, 404);
}
return c.json({ error: message }, 500);
}
});
/**
* Unpublish skill
* POST /api/skills/:skillId/unpublish
*/
skills.post('/:skillId/unpublish', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const skillId = c.req.param('skillId');
const skill = await skillService.unpublishSkill(userId, skillId);
return c.json({ skill });
} catch (error) {
console.error('Unpublish skill error:', error);
const message = error instanceof Error ? error.message : 'Failed to unpublish skill';
if (message.includes('not found') || message.includes('unauthorized')) {
return c.json({ error: message }, 404);
}
return c.json({ error: message }, 500);
}
});
/**
* Delete skill (requires authentication and ownership)
* DELETE /api/skills/:skillId
*/
skills.delete('/:skillId', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const skillId = c.req.param('skillId');
await skillService.deleteSkill(userId, skillId);
return c.json({ message: 'Skill deleted successfully' });
} catch (error) {
console.error('Delete skill error:', error);
const message = error instanceof Error ? error.message : 'Failed to delete skill';
if (message.includes('not found') || message.includes('unauthorized')) {
return c.json({ error: message }, 404);
}
return c.json({ error: message }, 500);
}
});
/**
* Create new version for skill
* POST /api/skills/:skillId/versions
* Body: { version, systemPromptFragment?, workflowRules?, documentation?, changeLog }
*/
skills.post('/:skillId/versions', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const skillId = c.req.param('skillId');
const data = await c.req.json();
// Validate required fields
if (!data.version || !data.changeLog) {
return c.json({ error: 'Missing required fields: version, changeLog' }, 400);
}
const version = await skillService.createVersion(userId, skillId, data);
return c.json({ version }, 201);
} catch (error) {
console.error('Create version error:', error);
const message = error instanceof Error ? error.message : 'Failed to create version';
if (message.includes('not found') || message.includes('unauthorized')) {
return c.json({ error: message }, 404);
}
if (message.includes('already exists')) {
return c.json({ error: message }, 409);
}
return c.json({ error: message }, 500);
}
});
export default skills;

148
src/routes/updates.ts Normal file
View File

@@ -0,0 +1,148 @@
import { Hono } from 'hono';
import type { HonoContext } from '../types/context';
const updates = new Hono<HonoContext>();
/**
* Compare two semantic versions
* Returns true if latest > current
*/
function isNewerVersion(latest: string, current: string): boolean {
const latestParts = latest.split('.').map(Number);
const currentParts = current.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const latestPart = latestParts[i] || 0;
const currentPart = currentParts[i] || 0;
if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}
return false;
}
/**
* GET /api/updates/:target/:arch/:currentVersion
* Check for application updates
*
* Example: /api/updates/darwin/aarch64/0.1.0
*
* Returns:
* - HTTP 200 + JSON: Update available
* - HTTP 204: No update available
* - HTTP 500: Server error
*/
updates.get('/:target/:arch/:currentVersion', async (c) => {
const { target, arch, currentVersion } = c.req.param();
try {
// Validate parameters
if (!target || !arch || !currentVersion) {
return c.json({ error: 'Missing required parameters' }, 400);
}
// Access R2 bucket from environment
const bucket = c.env?.RELEASES_BUCKET;
if (!bucket) {
console.error('RELEASES_BUCKET not configured');
return c.json({ error: 'Update service not configured' }, 500);
}
const latestObject = await bucket.get('latest.json');
if (!latestObject) {
console.error('latest.json not found in R2');
return c.json({ error: 'No releases available' }, 404);
}
const latestData = (await latestObject.json()) as {
version: string;
pub_date: string;
notes: string;
manifest_url: string;
};
// Compare versions
if (!isNewerVersion(latestData.version, currentVersion)) {
// No update available
return c.body(null, 204);
}
const manifestPath = `releases/v${latestData.version}/manifest.json`;
const manifestObject = await bucket.get(manifestPath);
if (!manifestObject) {
console.error(`Manifest not found: ${manifestPath}`);
return c.json({ error: 'Manifest not found' }, 404);
}
const manifest = (await manifestObject.json()) as {
version: string;
pub_date: string;
notes: string;
platforms: Record<
string,
{
url: string;
signature: string;
download_url?: string;
}
>;
};
// Get platform-specific update info
const platformKey = `${target}-${arch}`;
const platformData = manifest.platforms[platformKey];
if (!platformData) {
console.error(`Platform not found: ${platformKey}`);
return c.json({ error: 'Platform not supported' }, 404);
}
// Return update information in Tauri updater format
return c.json({
version: manifest.version,
notes: manifest.notes || latestData.notes,
pub_date: manifest.pub_date || latestData.pub_date,
url: platformData.url,
signature: platformData.signature,
});
} catch (error) {
console.error('Error checking for updates:', error);
return c.json(
{
error: 'Failed to check for updates',
details: error instanceof Error ? error.message : 'Unknown error',
},
500
);
}
});
/**
* GET /api/updates/latest
* Get latest version information (for informational purposes)
*/
updates.get('/latest', async (c) => {
try {
const bucket = c.env?.RELEASES_BUCKET;
if (!bucket) {
return c.json({ error: 'Update service not configured' }, 500);
}
const latestObject = await bucket.get('latest.json');
if (!latestObject) {
return c.json({ error: 'No releases available' }, 404);
}
const latestData = await latestObject.json();
return c.json(latestData);
} catch (error) {
console.error('Error fetching latest version:', error);
return c.json({ error: 'Failed to fetch latest version' }, 500);
}
});
export default updates;

154
src/routes/users.ts Normal file
View File

@@ -0,0 +1,154 @@
// User routes
import { Hono } from 'hono';
import { authMiddleware, getAuth } from '../middlewares/auth';
import { uploadService } from '../services/upload-service';
import { userService } from '../services/user-service';
const users = new Hono();
/**
* Get user profile by ID
* GET /api/users/:userId
*/
users.get('/:userId', async (c) => {
try {
const userId = c.req.param('userId');
const profile = await userService.getUserProfile(userId);
if (!profile) {
return c.json({ error: 'User not found' }, 404);
}
return c.json({ user: profile });
} catch (error) {
console.error('Get user profile error:', error);
return c.json({ error: 'Failed to get user profile' }, 500);
}
});
/**
* Get user statistics
* GET /api/users/:userId/stats
*/
users.get('/:userId/stats', async (c) => {
try {
const userId = c.req.param('userId');
const stats = await userService.getUserStats(userId);
return c.json({ stats });
} catch (error) {
console.error('Get user stats error:', error);
return c.json({ error: 'Failed to get user statistics' }, 500);
}
});
/**
* Get user's published agents
* GET /api/users/:userId/agents?limit=20&offset=0
*/
users.get('/:userId/agents', async (c) => {
try {
const userId = c.req.param('userId');
const limit = parseInt(c.req.query('limit') || '20', 10);
const offset = parseInt(c.req.query('offset') || '0', 10);
const result = await userService.getUserAgents(userId, { limit, offset });
return c.json(result);
} catch (error) {
console.error('Get user agents error:', error);
return c.json({ error: 'Failed to get user agents' }, 500);
}
});
/**
* Update current user's profile (requires authentication)
* PATCH /api/users/me
* Body: { name?, bio?, website?, avatarUrl? }
*/
users.patch('/me', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const data = await c.req.json();
const user = await userService.updateUserProfile(userId, data);
return c.json({ user });
} catch (error) {
console.error('Update user profile error:', error);
// Return the actual error message to help debug the issue
const errorMessage = error instanceof Error ? error.message : 'Failed to update user profile';
return c.json({ error: errorMessage }, 500);
}
});
/**
* Get current user's profile (requires authentication)
* GET /api/users/me
*/
users.get('/me', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const profile = await userService.getUserProfile(userId);
if (!profile) {
return c.json({ error: 'User not found' }, 404);
}
return c.json({ user: profile });
} catch (error) {
console.error('Get current user profile error:', error);
return c.json({ error: 'Failed to get user profile' }, 500);
}
});
/**
* Upload avatar image (requires authentication)
* POST /api/users/me/avatar
* Body: multipart/form-data with 'avatar' file
*/
users.post('/me/avatar', authMiddleware, async (c) => {
try {
const { userId } = getAuth(c);
const body = await c.req.parseBody();
const file = body.avatar;
if (!file || !(file instanceof File)) {
return c.json({ error: 'No file provided' }, 400);
}
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return c.json({ error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed' }, 400);
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return c.json({ error: 'File too large. Maximum size is 5MB' }, 400);
}
// Get R2 bucket from environment
const bucket = c.env?.RELEASES_BUCKET;
if (!bucket) {
return c.json({ error: 'Storage service not available' }, 503);
}
// Upload to R2
const avatarUrl = await uploadService.uploadAvatar(userId, file, bucket);
// Update user's avatar URL
const user = await userService.updateUserProfile(userId, { avatarUrl });
return c.json({ user, avatarUrl });
} catch (error) {
console.error('Upload avatar error:', error);
return c.json({ error: 'Failed to upload avatar' }, 500);
}
});
export default users;

View File

@@ -0,0 +1,277 @@
import { Hono } from 'hono';
import { authMiddleware, getAuth } from '../middlewares/auth';
import { userUsageService } from '../services/user-usage-service';
import type { HonoContext } from '../types/context';
const vibncodeProvider = new Hono<HonoContext>();
const ALLOWED_MODELS = ['MiniMax-M2.5', 'MiniMax-M2.7'];
function getUpstreamApiUrl(env?: HonoContext['Bindings']): string | undefined {
if (typeof Bun !== 'undefined') {
return Bun.env.TALKCODY_UPSTREAM_API;
}
return env?.TALKCODY_UPSTREAM_API;
}
function getUpstreamApiKey(env?: HonoContext['Bindings']): string | undefined {
if (typeof Bun !== 'undefined') {
return Bun.env.TALKCODY_UPSTREAM_API_KEY;
}
return env?.TALKCODY_UPSTREAM_API_KEY;
}
/**
* Messages endpoint - Anthropic compatible
* POST /api/vibncode/v1/messages
*/
vibncodeProvider.post('/v1/messages', authMiddleware, async (c) => {
const { userId } = getAuth(c);
// Check usage limits
const usageCheck = await userUsageService.checkUsageLimits(userId, 'vibncode', c.env);
if (!usageCheck.allowed) {
return c.json(
{
type: 'error',
error: {
type: 'rate_limit_error',
message: usageCheck.reason || 'Usage limit exceeded',
},
},
429
);
}
// Get upstream API configuration
const upstreamApiUrl = getUpstreamApiUrl(c.env);
const upstreamApiKey = getUpstreamApiKey(c.env);
if (!upstreamApiUrl || !upstreamApiKey) {
console.error('TALKCODY_UPSTREAM_API or TALKCODY_UPSTREAM_API_KEY is not configured');
return c.json(
{
type: 'error',
error: {
type: 'api_error',
message: 'Provider not configured',
},
},
500
);
}
try {
const body = await c.req.json();
// Validate model
if (body.model && !ALLOWED_MODELS.includes(body.model as string)) {
return c.json(
{
type: 'error',
error: {
type: 'invalid_request_error',
message: `Model not allowed. Available models: ${ALLOWED_MODELS.join(', ')}`,
},
},
400
);
}
// Forward request to upstream Anthropic-compatible API
const response = await fetch(`${upstreamApiUrl}/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': upstreamApiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
// Handle error responses (non-2xx status)
if (!response.ok) {
const errorData = await response.json();
return c.json(errorData, response.status as 400 | 401 | 403 | 404 | 429 | 500);
}
// Handle streaming response
if (body.stream) {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const reader = response.body?.getReader();
// Track tokens for usage recording (Anthropic format)
let inputTokens = 0;
let outputTokens = 0;
// Helper function to parse SSE line and extract token counts
const parseSSELine = (line: string): { inputTokens?: number; outputTokens?: number } => {
if (!line.startsWith('data: ')) return {};
try {
const data = JSON.parse(line.slice(6));
// Anthropic streaming format:
// - message_start: { message: { usage: { input_tokens } } }
// - message_delta (end): { usage: { output_tokens } }
return {
inputTokens: data.message?.usage?.input_tokens ?? data.usage?.input_tokens,
outputTokens: data.usage?.output_tokens,
};
} catch {
// Ignore parse errors for non-JSON data lines
return {};
}
};
// Process stream in background with waitUntil to ensure completion
const streamProcessing = (async () => {
try {
const decoder = new TextDecoder();
let buffer = '';
while (reader) {
const { done, value } = await reader.read();
if (done) break;
// Decode and buffer the text
buffer += decoder.decode(value, { stream: true });
// Parse SSE data to extract token counts
// Look for usage info in the stream (Anthropic format)
// message_delta event contains usage: { output_tokens: xxx }
// message_start event contains usage: { input_tokens: xxx }
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
const parsed = parseSSELine(line);
if (parsed.inputTokens !== undefined) inputTokens = parsed.inputTokens;
if (parsed.outputTokens !== undefined) outputTokens = parsed.outputTokens;
}
await writer.write(value);
}
// Process any remaining content in buffer after stream ends
// This handles the case where the last SSE event doesn't have a trailing newline
if (buffer.trim()) {
const parsed = parseSSELine(buffer);
if (parsed.inputTokens !== undefined) inputTokens = parsed.inputTokens;
if (parsed.outputTokens !== undefined) outputTokens = parsed.outputTokens;
}
} catch (error) {
console.error('Stream processing error:', error);
} finally {
await writer.close();
try {
await userUsageService.recordUsage(
userId,
'vibncode',
(body.model as string) || 'unknown',
{ input: inputTokens, output: outputTokens },
c.env,
usageCheck.used?.dailyTokens ?? 0
);
} catch (error) {
console.error('Failed to record usage:', error);
}
}
})();
// Use waitUntil to ensure stream processing completes in Cloudflare Workers
if (c.executionCtx?.waitUntil) {
c.executionCtx.waitUntil(streamProcessing);
}
return new Response(readable, {
status: response.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-VibnCode-Remaining-Tokens': String(usageCheck.remaining?.dailyTokens || 0),
},
});
}
// Handle non-streaming response
const data = await response.json();
// Record usage and get remaining tokens (Anthropic format: input_tokens, output_tokens)
const usage = data.usage || {};
const { remainingDailyTokens } = await userUsageService.recordUsage(
userId,
'vibncode',
(body.model as string) || 'unknown',
{ input: usage.input_tokens || 0, output: usage.output_tokens || 0 },
c.env,
usageCheck.used?.dailyTokens ?? 0
);
return c.json(data, 200, {
'X-VibnCode-Remaining-Tokens': String(remainingDailyTokens),
});
} catch (error) {
console.error('VibnCode provider error:', error);
return c.json(
{
type: 'error',
error: {
type: 'api_error',
message: 'Internal server error',
},
},
500
);
}
});
/**
* Get usage statistics
* GET /api/vibncode/usage
*/
vibncodeProvider.get('/usage', authMiddleware, async (c) => {
const { userId } = getAuth(c);
try {
const stats = await userUsageService.getUsageStats(userId, 'vibncode', c.env);
return c.json(stats);
} catch (error) {
console.error('Failed to get usage stats:', error);
return c.json({ error: 'Failed to get usage statistics' }, 500);
}
});
/**
* List available models
* GET /api/vibncode/models
*/
vibncodeProvider.get('/models', async (c) => {
return c.json({
object: 'list',
data: ALLOWED_MODELS.map((id) => ({
id,
object: 'model',
created: Date.now(),
owned_by: 'vibncode',
permission: [],
root: id,
parent: null,
})),
});
});
vibncodeProvider.get('/health', async (c) => {
const upstreamApiUrl = getUpstreamApiUrl(c.env);
const upstreamApiKey = getUpstreamApiKey(c.env);
return c.json({
status: upstreamApiUrl && upstreamApiKey ? 'ok' : 'not_configured',
provider: 'vibncode',
models: ALLOWED_MODELS.length,
timestamp: new Date().toISOString(),
});
});
export default vibncodeProvider;

230
src/routes/web-fetch.ts Normal file
View File

@@ -0,0 +1,230 @@
// Web Fetch API route - Proxies Jina Reader requests with rate limiting
import { Hono } from 'hono';
import { getOptionalAuth, optionalAuthMiddleware } from '../middlewares/auth';
import { searchUsageService } from '../services/search-usage-service';
import type { HonoContext } from '../types/context';
const webFetch = new Hono<HonoContext>();
// Request body schema
interface WebFetchRequest {
url: string;
}
// Response schema
interface WebFetchResponse {
content: string;
url: string;
usage: {
remaining: number;
limit: number;
used: number;
};
}
/**
* Get JINA_API_KEY from environment
*/
function getJinaApiKey(env?: HonoContext['Bindings']): string | undefined {
if (typeof Bun !== 'undefined') {
return Bun.env.JINA_API_KEY;
}
return env?.JINA_API_KEY;
}
/**
* Build Jina Reader URL
*/
function buildJinaReaderUrl(url: string): string {
const JINA_READER_PREFIX = 'https://r.jina.ai/';
const JINA_READER_PREFIX_HTTP = 'http://r.jina.ai/';
if (url.startsWith(JINA_READER_PREFIX) || url.startsWith(JINA_READER_PREFIX_HTTP)) {
return url;
}
return `${JINA_READER_PREFIX}${url}`;
}
/**
* Call Jina Reader API
*/
const JINA_FETCH_TIMEOUT_MS = 20000;
async function callJinaReader(url: string, apiKey: string): Promise<string> {
const jinaUrl = buildJinaReaderUrl(url);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), JINA_FETCH_TIMEOUT_MS);
try {
const response = await fetch(jinaUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'X-Retain-Images': 'none',
'X-Timeout': '20',
Accept: 'text/markdown,text/plain,*/*',
},
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Jina Reader API error: ${response.status} - ${errorText}`);
}
return await response.text();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Jina Reader API error: timeout');
}
if (error instanceof Error && error.message.startsWith('Jina Reader API error')) {
throw error;
}
throw new Error(
`Jina Reader API error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
} finally {
clearTimeout(timeoutId);
}
}
/**
* POST /api/web-fetch
* Fetch web page content using Jina Reader API
*/
webFetch.post('/', optionalAuthMiddleware, async (c) => {
// Get device ID from header (required)
const deviceId = c.req.header('X-Device-ID');
if (!deviceId) {
return c.json(
{
error: 'Missing X-Device-ID header',
},
400
);
}
// Get optional user ID from auth
const auth = getOptionalAuth(c);
const userId = auth?.userId;
// Parse request body
let requestBody: WebFetchRequest;
try {
requestBody = await c.req.json();
} catch {
return c.json(
{
error: 'Invalid JSON body',
},
400
);
}
// Validate request
if (!requestBody.url || typeof requestBody.url !== 'string') {
return c.json(
{
error: 'Missing or invalid url parameter',
},
400
);
}
// Validate URL format (must be http or https)
try {
const parsedUrl = new URL(requestBody.url);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return c.json(
{
error: 'URL must use http or https protocol',
},
400
);
}
} catch {
return c.json(
{
error: 'Invalid URL format',
},
400
);
}
// Check rate limits
try {
const usageCheck = await searchUsageService.checkSearchLimits(deviceId, userId);
if (!usageCheck.allowed) {
return c.json(
{
error: usageCheck.reason || 'Rate limit exceeded',
usage: {
remaining: usageCheck.remaining,
limit: usageCheck.limit,
used: usageCheck.used,
},
},
429
);
}
// Get Jina API key
const jinaApiKey = getJinaApiKey(c.env);
if (!jinaApiKey) {
console.error('JINA_API_KEY is not configured');
return c.json(
{
error: 'Web fetch service not configured',
},
500
);
}
// Call Jina Reader API
const content = await callJinaReader(requestBody.url, jinaApiKey);
// Record usage
await searchUsageService.recordSearch(deviceId, userId);
// Get updated usage stats
const stats = await searchUsageService.getSearchStats(deviceId, userId);
// Return results with usage info
const response: WebFetchResponse = {
content: content.trim(),
url: requestBody.url,
usage: {
remaining: stats.remaining,
limit: stats.limit,
used: stats.used,
},
};
return c.json(response, 200);
} catch (error) {
console.error('Web fetch API error:', error);
// Handle Jina Reader API errors
if (error instanceof Error && error.message.includes('Jina Reader API error')) {
return c.json(
{
error: 'Content extraction failed',
details: error.message,
},
500
);
}
return c.json(
{
error: 'Internal server error',
},
500
);
}
});
export default webFetch;

View File

@@ -0,0 +1,366 @@
// Agent management service for CRUD operations
import type { CreateAgentRequest, UpdateAgentRequest } from '@vibncode/shared';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '../db/client';
import {
agentCategories,
agentTags,
agentVersions,
categories,
marketplaceAgents,
tags,
} from '../db/schema';
import type { DynamicPromptConfig, ToolsConfig } from '../types/database';
export class AgentService {
/**
* Create a new agent (publish to marketplace)
*/
async createAgent(userId: string, data: CreateAgentRequest) {
// Generate slug from name
const slug = this.generateSlug(data.name);
// Check if slug already exists
const existing = await db
.select()
.from(marketplaceAgents)
.where(eq(marketplaceAgents.slug, slug))
.limit(1);
if (existing.length > 0) {
throw new Error('Agent with this name already exists');
}
let agentId: string | null = null;
try {
// Step 1: Validate and get category UUIDs first (before creating anything)
let categoryRecords: Array<{ id: string; slug: string; name: string }> = [];
if (data.categoryIds && data.categoryIds.length > 0) {
categoryRecords = await db
.select()
.from(categories)
.where(
sql`${categories.slug} IN (${sql.join(
data.categoryIds.map((id: string) => sql`${id}`),
sql`, `
)})`
);
if (categoryRecords.length === 0) {
throw new Error('No valid categories found');
}
}
// Step 2: Create agent
const agent = await db
.insert(marketplaceAgents)
.values({
slug,
name: data.name,
description: data.description,
authorId: userId,
model: data.model,
systemPrompt: data.systemPrompt,
toolsConfig: data.toolsConfig || {},
rules: data.rules || null,
outputFormat: data.outputFormat || null,
dynamicPromptConfig: data.dynamicPromptConfig || null,
latestVersion: '1.0.0',
iconUrl: data.iconUrl || null,
isPublished: false, // Draft by default
})
.returning();
agentId = agent[0].id;
// Step 3: Create initial version
await db.insert(agentVersions).values({
agentId: agent[0].id,
version: '1.0.0',
systemPrompt: data.systemPrompt,
toolsConfig: data.toolsConfig || {},
rules: data.rules || null,
outputFormat: data.outputFormat || null,
dynamicPromptConfig: data.dynamicPromptConfig || null,
changeLog: 'Initial release',
});
// Step 4: Link categories
if (categoryRecords.length > 0) {
await db.insert(agentCategories).values(
categoryRecords.map((category) => ({
agentId: agent[0].id,
categoryId: category.id,
}))
);
}
// Step 5: Link or create tags
if (data.tags && data.tags.length > 0) {
await this.linkTags(agent[0].id, data.tags);
}
return agent[0];
} catch (error) {
// If we created an agent but something failed, delete it
if (agentId) {
try {
await db.delete(marketplaceAgents).where(eq(marketplaceAgents.id, agentId));
} catch (deleteError) {
console.error('Failed to rollback agent creation:', deleteError);
}
}
throw error;
}
}
/**
* Update agent
*/
async updateAgent(userId: string, agentId: string, data: UpdateAgentRequest) {
// Check ownership
const agent = await this.getAgentById(agentId);
if (!agent || agent.authorId !== userId) {
throw new Error('Agent not found or unauthorized');
}
const updates: Partial<{
name: string;
slug: string;
description: string;
longDescription: string;
model: string;
systemPrompt: string;
toolsConfig: ToolsConfig;
rules: string | null;
outputFormat: string | null;
dynamicPromptConfig: DynamicPromptConfig | null;
iconUrl: string;
bannerUrl: string;
}> = {};
if (data.name !== undefined) {
updates.name = data.name;
updates.slug = this.generateSlug(data.name);
}
if (data.description !== undefined) updates.description = data.description;
if (data.model !== undefined) updates.model = data.model;
if (data.systemPrompt !== undefined) updates.systemPrompt = data.systemPrompt;
if (data.toolsConfig !== undefined) updates.toolsConfig = data.toolsConfig;
if (data.rules !== undefined) updates.rules = data.rules;
if (data.outputFormat !== undefined) updates.outputFormat = data.outputFormat;
if (data.dynamicPromptConfig !== undefined) {
updates.dynamicPromptConfig = data.dynamicPromptConfig;
}
if (data.iconUrl !== undefined) updates.iconUrl = data.iconUrl;
// Update agent
if (Object.keys(updates).length > 0) {
await db.update(marketplaceAgents).set(updates).where(eq(marketplaceAgents.id, agentId));
}
// Update categories if provided
if (data.categoryIds !== undefined) {
// Remove existing
await db.delete(agentCategories).where(eq(agentCategories.agentId, agentId));
// Add new
if (data.categoryIds.length > 0) {
await db.insert(agentCategories).values(
data.categoryIds.map((categoryId: string) => ({
agentId,
categoryId,
}))
);
}
}
// Update tags if provided
if (data.tags !== undefined) {
// Remove existing
await db.delete(agentTags).where(eq(agentTags.agentId, agentId));
// Add new
if (data.tags.length > 0) {
await this.linkTags(agentId, data.tags);
}
}
return this.getAgentById(agentId);
}
/**
* Publish agent (make it public)
*/
async publishAgent(userId: string, agentId: string) {
const agent = await this.getAgentById(agentId);
if (!agent || agent.authorId !== userId) {
throw new Error('Agent not found or unauthorized');
}
await db
.update(marketplaceAgents)
.set({ isPublished: true, publishedAt: Date.now() })
.where(eq(marketplaceAgents.id, agentId));
return this.getAgentById(agentId);
}
/**
* Unpublish agent
*/
async unpublishAgent(userId: string, agentId: string) {
const agent = await this.getAgentById(agentId);
if (!agent || agent.authorId !== userId) {
throw new Error('Agent not found or unauthorized');
}
await db
.update(marketplaceAgents)
.set({ isPublished: false })
.where(eq(marketplaceAgents.id, agentId));
return this.getAgentById(agentId);
}
/**
* Delete agent
*/
async deleteAgent(userId: string, agentId: string) {
const agent = await this.getAgentById(agentId);
if (!agent || agent.authorId !== userId) {
throw new Error('Agent not found or unauthorized');
}
// Delete agent (cascade will handle relations)
await db.delete(marketplaceAgents).where(eq(marketplaceAgents.id, agentId));
return true;
}
/**
* Create new version
*/
async createVersion(
userId: string,
agentId: string,
data: {
version: string;
systemPrompt?: string;
toolsConfig?: ToolsConfig;
rules?: string;
outputFormat?: string;
dynamicPromptConfig?: DynamicPromptConfig;
changeLog: string;
}
) {
const agent = await this.getAgentById(agentId);
if (!agent || agent.authorId !== userId) {
throw new Error('Agent not found or unauthorized');
}
// Check if version already exists
const existingVersion = await db
.select()
.from(agentVersions)
.where(and(eq(agentVersions.agentId, agentId), eq(agentVersions.version, data.version)))
.limit(1);
if (existingVersion.length > 0) {
throw new Error('Version already exists');
}
// Create version
const version = await db
.insert(agentVersions)
.values({
agentId,
version: data.version,
systemPrompt: data.systemPrompt || agent.systemPrompt,
toolsConfig: data.toolsConfig || agent.toolsConfig,
rules: data.rules !== undefined ? data.rules : agent.rules,
outputFormat: data.outputFormat !== undefined ? data.outputFormat : agent.outputFormat,
dynamicPromptConfig:
data.dynamicPromptConfig !== undefined
? data.dynamicPromptConfig
: agent.dynamicPromptConfig,
changeLog: data.changeLog,
})
.returning();
// Update latest version
await db
.update(marketplaceAgents)
.set({ latestVersion: data.version })
.where(eq(marketplaceAgents.id, agentId));
return version[0];
}
/**
* Get agent by ID (internal)
*/
private async getAgentById(agentId: string) {
const results = await db
.select()
.from(marketplaceAgents)
.where(eq(marketplaceAgents.id, agentId))
.limit(1);
return results.length > 0 ? results[0] : null;
}
/**
* Link tags to agent (create tags if they don't exist)
*/
private async linkTags(agentId: string, tagNames: string[]) {
for (const tagName of tagNames) {
const tagSlug = this.generateSlug(tagName);
// Find or create tag
let tag = await db.select().from(tags).where(eq(tags.slug, tagSlug)).limit(1);
if (tag.length === 0) {
const newTag = await db
.insert(tags)
.values({
name: tagName,
slug: tagSlug,
})
.returning();
tag = newTag;
} else {
// Increment usage count
await db
.update(tags)
.set({
usageCount: sql`${tags.usageCount} + 1`,
})
.where(eq(tags.id, tag[0].id));
}
// Link to agent
await db.insert(agentTags).values({
agentId,
tagId: tag[0].id,
});
}
}
/**
* Generate slug from name
*/
private generateSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
}
export const agentService = new AgentService();

View File

@@ -0,0 +1,228 @@
// Analytics service for tracking app usage
import { count, countDistinct, gte } from 'drizzle-orm';
import { db, getDb } from '../db/client';
import { analyticsEvents, users } from '../db/schema';
export interface TrackEventInput {
deviceId: string;
eventType: 'session_start' | 'session_end';
sessionId: string;
osName?: string;
osVersion?: string;
appVersion?: string;
country?: string;
}
export interface DashboardStats {
dau: number;
mau: number;
totalUsers: number;
avgSessionDurationMinutes: number;
topCountries: Array<{ country: string; count: number }>;
topVersions: Array<{ version: string; count: number }>;
topOS: Array<{ os: string; count: number }>;
dailyActiveHistory: Array<{ date: string; count: number }>;
}
export class AnalyticsService {
async trackEvent(input: TrackEventInput): Promise<void> {
await db.insert(analyticsEvents).values({
deviceId: input.deviceId,
eventType: input.eventType,
sessionId: input.sessionId,
osName: input.osName || null,
osVersion: input.osVersion || null,
appVersion: input.appVersion || null,
country: input.country || null,
});
}
async getDashboardStats(): Promise<DashboardStats> {
const now = Date.now();
const todayStart = this.getStartOfDay(now);
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
// Run queries with individual error handling to prevent one failure from breaking all
const [
dau,
mau,
totalUsers,
avgSessionDurationMinutes,
topCountries,
topVersions,
topOS,
dailyActiveHistory,
] = await Promise.all([
this.getDAU(todayStart).catch((e) => {
console.error('getDAU error:', e);
return 0;
}),
this.getMAU(thirtyDaysAgo).catch((e) => {
console.error('getMAU error:', e);
return 0;
}),
this.getTotalUsers().catch((e) => {
console.error('getTotalUsers error:', e);
return 0;
}),
this.getAvgSessionDuration(thirtyDaysAgo),
this.getTopCountries(thirtyDaysAgo).catch((e) => {
console.error('getTopCountries error:', e);
return [];
}),
this.getTopVersions(thirtyDaysAgo).catch((e) => {
console.error('getTopVersions error:', e);
return [];
}),
this.getTopOS(thirtyDaysAgo).catch((e) => {
console.error('getTopOS error:', e);
return [];
}),
this.getDailyActiveHistory(thirtyDaysAgo),
]);
return {
dau,
mau,
totalUsers,
avgSessionDurationMinutes,
topCountries,
topVersions,
topOS,
dailyActiveHistory,
};
}
private getStartOfDay(timestamp: number): number {
const date = new Date(timestamp);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
private async getDAU(todayStart: number): Promise<number> {
const result = await db
.select({ count: countDistinct(analyticsEvents.deviceId) })
.from(analyticsEvents)
.where(gte(analyticsEvents.createdAt, todayStart));
return result[0]?.count || 0;
}
private async getMAU(thirtyDaysAgo: number): Promise<number> {
const result = await db
.select({ count: countDistinct(analyticsEvents.deviceId) })
.from(analyticsEvents)
.where(gte(analyticsEvents.createdAt, thirtyDaysAgo));
return result[0]?.count || 0;
}
private async getTotalUsers(): Promise<number> {
const result = await db.select({ count: count() }).from(users);
return result[0]?.count || 0;
}
private async getAvgSessionDuration(since: number): Promise<number> {
// Calculate average session duration by matching session_start with session_end
// Returns 0 if no complete sessions exist
try {
const { client } = getDb();
const result = await client.execute({
sql: `SELECT COALESCE(AVG(duration_minutes), 0) as avg_duration FROM (
SELECT
s.session_id,
(e.created_at - s.created_at) / 60000.0 as duration_minutes
FROM analytics_events s
INNER JOIN analytics_events e ON s.session_id = e.session_id AND e.event_type = 'session_end'
WHERE s.event_type = 'session_start' AND s.created_at >= ?
)
WHERE duration_minutes > 0`,
args: [since],
});
const avgDuration = (result.rows[0] as { avg_duration: number | null })?.avg_duration;
return avgDuration || 0;
} catch (error) {
console.error('getAvgSessionDuration error:', error);
return 0;
}
}
private async getTopCountries(since: number): Promise<Array<{ country: string; count: number }>> {
try {
const { client } = getDb();
const result = await client.execute({
sql: `SELECT country, COUNT(DISTINCT device_id) as count
FROM analytics_events
WHERE created_at >= ? AND country IS NOT NULL
GROUP BY country
ORDER BY count DESC
LIMIT 10`,
args: [since],
});
return result.rows as Array<{ country: string; count: number }>;
} catch (error) {
console.error('getTopCountries error:', error);
return [];
}
}
private async getTopVersions(since: number): Promise<Array<{ version: string; count: number }>> {
try {
const { client } = getDb();
const result = await client.execute({
sql: `SELECT app_version as version, COUNT(DISTINCT device_id) as count
FROM analytics_events
WHERE created_at >= ? AND event_type = 'session_start' AND app_version IS NOT NULL
GROUP BY app_version
ORDER BY count DESC
LIMIT 10`,
args: [since],
});
return result.rows as Array<{ version: string; count: number }>;
} catch (error) {
console.error('getTopVersions error:', error);
return [];
}
}
private async getTopOS(since: number): Promise<Array<{ os: string; count: number }>> {
try {
const { client } = getDb();
const result = await client.execute({
sql: `SELECT os_name as os, COUNT(DISTINCT device_id) as count
FROM analytics_events
WHERE created_at >= ? AND os_name IS NOT NULL
GROUP BY os_name
ORDER BY count DESC`,
args: [since],
});
return result.rows as Array<{ os: string; count: number }>;
} catch (error) {
console.error('getTopOS error:', error);
return [];
}
}
private async getDailyActiveHistory(
since: number
): Promise<Array<{ date: string; count: number }>> {
try {
const { client } = getDb();
const result = await client.execute({
sql: `SELECT
date(created_at / 1000, 'unixepoch') as date,
COUNT(DISTINCT device_id) as count
FROM analytics_events
WHERE created_at >= ?
GROUP BY date(created_at / 1000, 'unixepoch')
ORDER BY date ASC`,
args: [since],
});
return result.rows as Array<{ date: string; count: number }>;
} catch (error) {
console.error('getDailyActiveHistory error:', error);
return [];
}
}
}
export const analyticsService = new AnalyticsService();

View File

@@ -0,0 +1,177 @@
// Authentication service
import type { User } from '@vibncode/shared';
import { eq } from 'drizzle-orm';
import { sign, verify } from 'hono/jwt';
import { db } from '../db/client';
import { users } from '../db/schema';
import type { DbUser } from '../types/database';
import type { Env } from '../types/env';
const _JWT_EXPIRATION = '7d'; // 7 days
export interface JWTPayload {
userId: string;
email: string;
exp?: number;
}
/**
* Get JWT secret from environment
*/
function getJWTSecret(env?: Env): string {
if (typeof Bun !== 'undefined' && Bun.env.JWT_SECRET) {
return Bun.env.JWT_SECRET;
} else if (env?.JWT_SECRET) {
return env.JWT_SECRET;
}
throw new Error('JWT_SECRET environment variable is required');
}
export class AuthService {
/**
* Generate JWT token for user
*/
async generateToken(userId: string, email: string, env?: Env): Promise<string> {
const payload: JWTPayload = {
userId,
email,
};
return await sign(payload, getJWTSecret(env));
}
/**
* Verify JWT token
*/
async verifyToken(token: string, env?: Env): Promise<JWTPayload | null> {
try {
const payload = await verify(token, getJWTSecret(env));
return payload as JWTPayload;
} catch (_error) {
return null;
}
}
/**
* Find or create user from OAuth profile
*/
async findOrCreateUser(profile: {
provider: 'github' | 'google';
providerId: string;
email: string;
name: string;
avatarUrl?: string;
}): Promise<User> {
// Check if user exists by provider ID
const providerIdColumn = profile.provider === 'github' ? users.githubId : users.googleId;
const existingUsers = await db
.select()
.from(users)
.where(eq(providerIdColumn, profile.providerId))
.limit(1);
if (existingUsers.length > 0) {
// Update last login and avatar (only if user has no avatar)
const updatedAvatarUrl = existingUsers[0].avatarUrl || profile.avatarUrl;
await db
.update(users)
.set({
lastLoginAt: Date.now(),
avatarUrl: updatedAvatarUrl,
})
.where(eq(users.id, existingUsers[0].id));
// Return updated user data
return this.mapToPublicUser({
...existingUsers[0],
avatarUrl: updatedAvatarUrl,
});
}
// Check if user exists by email
const existingByEmail = await db
.select()
.from(users)
.where(eq(users.email, profile.email))
.limit(1);
if (existingByEmail.length > 0) {
// Link provider to existing account and update avatar (only if user has no avatar)
const updatedAvatarUrl = existingByEmail[0].avatarUrl || profile.avatarUrl;
await db
.update(users)
.set({
githubId:
profile.provider === 'github' ? profile.providerId : existingByEmail[0].githubId,
googleId:
profile.provider === 'google' ? profile.providerId : existingByEmail[0].googleId,
lastLoginAt: Date.now(),
avatarUrl: updatedAvatarUrl,
})
.where(eq(users.id, existingByEmail[0].id));
// Return updated user data
return this.mapToPublicUser({
...existingByEmail[0],
avatarUrl: updatedAvatarUrl,
githubId: profile.provider === 'github' ? profile.providerId : existingByEmail[0].githubId,
googleId: profile.provider === 'google' ? profile.providerId : existingByEmail[0].googleId,
});
}
// Create new user
const newUser = await db
.insert(users)
.values({
email: profile.email,
name: profile.name,
avatarUrl: profile.avatarUrl,
githubId: profile.provider === 'github' ? profile.providerId : null,
googleId: profile.provider === 'google' ? profile.providerId : null,
role: 'user',
bio: null,
website: null,
isVerified: true, // OAuth users are pre-verified
lastLoginAt: new Date(),
})
.returning();
return this.mapToPublicUser(newUser[0]);
}
/**
* Get user by ID
*/
async getUserById(userId: string): Promise<User | null> {
const results = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (results.length === 0) {
return null;
}
return this.mapToPublicUser(results[0]);
}
/**
* Map database user to public user format
*/
private mapToPublicUser(dbUser: DbUser): User {
// Determine OAuth provider and ID
const oauthProvider = dbUser.githubId ? 'github' : 'google';
const oauthId = dbUser.githubId || dbUser.googleId || '';
return {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
displayName: dbUser.displayName || undefined,
avatarUrl: dbUser.avatarUrl,
oauthProvider,
oauthId,
createdAt: new Date(dbUser.createdAt).toISOString(),
updatedAt: new Date(dbUser.updatedAt).toISOString(),
};
}
}
export const authService = new AuthService();

View File

@@ -0,0 +1,313 @@
// Legacy marketplace compatibility helpers for remote agents/skills
import type { MarketplaceAgent, MarketplaceSkill, SkillCategory, SkillTag } from '@vibncode/shared';
import type { RemoteAgentMetadata } from '@vibncode/shared/types/remote-agents';
import type { RemoteSkillConfig } from '@vibncode/shared/types/remote-skills';
export type MarketplaceSortBy = 'popular' | 'recent' | 'installs' | 'name';
export type SkillsMarketplaceSortBy =
| 'popular'
| 'recent'
| 'downloads'
| 'installs'
| 'name'
| 'rating'
| 'updated';
const normalizeTimestamp = (value: unknown): string => {
if (typeof value === 'string' && value.length > 0) return value;
return new Date(0).toISOString();
};
const normalizeString = (value: unknown): string => (typeof value === 'string' ? value : '');
const normalizeStringOrNull = (value: unknown): string | null =>
typeof value === 'string' ? value : null;
const normalizeBoolean = (value: unknown, fallback: boolean): boolean =>
typeof value === 'boolean' ? value : fallback;
const normalizeNumber = (value: unknown, fallback = 0): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
export const normalizeRemoteAgent = (agent: RemoteAgentMetadata): MarketplaceAgent => {
const agentAny = agent as Record<string, unknown>;
const tagsRaw = agentAny.tags as string[] | undefined;
const tagObjects = Array.isArray(tagsRaw)
? tagsRaw.map((tag) => ({ id: tag, name: tag, slug: tag, usageCount: 0 }))
: [];
const category = typeof agentAny.category === 'string' ? agentAny.category : '';
const categoryObjects = category
? [
{
id: category,
name: category,
slug: category,
description: '',
icon: undefined,
displayOrder: 0,
},
]
: [];
return {
id: normalizeString(agentAny.id),
slug: normalizeString(agentAny.slug || agentAny.id),
name: normalizeString(agentAny.name),
description: normalizeString(agentAny.description),
longDescription: normalizeString(agentAny.longDescription),
author: {
id: normalizeString(agentAny.authorId || ''),
name: normalizeString(agentAny.authorName || ''),
displayName: undefined,
avatarUrl: normalizeStringOrNull(agentAny.authorAvatarUrl),
bio: normalizeStringOrNull(agentAny.authorBio),
website: normalizeStringOrNull(agentAny.authorWebsite),
agentCount: normalizeNumber(agentAny.authorAgentCount, 0),
},
iconUrl: normalizeStringOrNull(agentAny.iconUrl),
bannerUrl: normalizeStringOrNull(agentAny.bannerUrl),
installCount: normalizeNumber(agentAny.installCount, 0),
usageCount: normalizeNumber(agentAny.usageCount, 0),
rating: normalizeNumber(agentAny.rating, 0),
ratingCount: normalizeNumber(agentAny.ratingCount, 0),
latestVersion: normalizeString(agentAny.latestVersion),
categories: categoryObjects,
tags: tagObjects,
isFeatured: normalizeBoolean(agentAny.isFeatured, false),
isPublished: normalizeBoolean(agentAny.isPublished, true),
createdAt: normalizeTimestamp(agentAny.createdAt),
updatedAt: normalizeTimestamp(agentAny.updatedAt),
model: typeof agentAny.model === 'string' ? agentAny.model : undefined,
systemPrompt: typeof agentAny.systemPrompt === 'string' ? agentAny.systemPrompt : undefined,
rules: typeof agentAny.rules === 'string' ? agentAny.rules : undefined,
outputFormat: typeof agentAny.outputFormat === 'string' ? agentAny.outputFormat : undefined,
};
};
export const normalizeRemoteSkill = (skill: RemoteSkillConfig): MarketplaceSkill => {
const skillAny = skill as Record<string, unknown>;
const category = typeof skillAny.category === 'string' ? skillAny.category : '';
const categories: SkillCategory[] = category
? [
{
id: category,
name: category,
slug: category,
description: '',
icon: undefined,
displayOrder: 0,
},
]
: [];
const tagsRaw = skillAny.tags as string[] | undefined;
const tags: SkillTag[] = Array.isArray(tagsRaw)
? tagsRaw.map((tag) => ({ id: tag, name: tag, slug: tag, usageCount: 0 }))
: [];
return {
id: normalizeString(skillAny.id),
slug: normalizeString(skillAny.slug || skillAny.id),
name: normalizeString(skillAny.name),
description: normalizeString(skillAny.description),
longDescription: normalizeString(skillAny.longDescription),
author: {
id: normalizeString(skillAny.authorId || ''),
name: normalizeString(skillAny.authorName || ''),
displayName: undefined,
avatarUrl: normalizeStringOrNull(skillAny.authorAvatarUrl),
bio: normalizeStringOrNull(skillAny.authorBio),
website: normalizeStringOrNull(skillAny.authorWebsite),
agentCount: normalizeNumber(skillAny.authorAgentCount, 0),
},
iconUrl: normalizeStringOrNull(skillAny.iconUrl),
bannerUrl: normalizeStringOrNull(skillAny.bannerUrl),
installCount: normalizeNumber(skillAny.installCount, 0),
usageCount: normalizeNumber(skillAny.usageCount, 0),
rating: normalizeNumber(skillAny.rating, 0),
ratingCount: normalizeNumber(skillAny.ratingCount, 0),
latestVersion: normalizeString(skillAny.latestVersion),
categories,
tags,
isFeatured: normalizeBoolean(skillAny.isFeatured, false),
isPublished: normalizeBoolean(skillAny.isPublished, true),
createdAt: normalizeTimestamp(skillAny.createdAt),
updatedAt: normalizeTimestamp(skillAny.updatedAt),
systemPromptFragment:
typeof skillAny.systemPromptFragment === 'string' ? skillAny.systemPromptFragment : undefined,
workflowRules: typeof skillAny.workflowRules === 'string' ? skillAny.workflowRules : undefined,
documentation: Array.isArray(skillAny.documentation)
? (skillAny.documentation as MarketplaceSkill['documentation'])
: undefined,
hasScripts: typeof skillAny.hasScripts === 'boolean' ? skillAny.hasScripts : undefined,
compatibility: typeof skillAny.compatibility === 'string' ? skillAny.compatibility : undefined,
metadata:
skillAny.metadata && typeof skillAny.metadata === 'object'
? (skillAny.metadata as Record<string, string>)
: null,
};
};
export const filterAndSortRemoteAgents = (
agents: RemoteAgentMetadata[],
options: {
limit: number;
offset: number;
sortBy: MarketplaceSortBy;
search?: string;
categoryIds?: string[];
tagIds?: string[];
isFeatured?: boolean;
}
) => {
const { limit, offset, sortBy, search, categoryIds, tagIds, isFeatured } = options;
let filtered = agents.filter((agent) => {
const agentAny = agent as Record<string, unknown>;
if (isFeatured !== undefined) {
const featuredFlag = agentAny.isFeatured ?? false;
if (featuredFlag !== isFeatured) return false;
}
if (search) {
const term = search.toLowerCase();
const name = normalizeString(agentAny.name).toLowerCase();
const desc = normalizeString(agentAny.description).toLowerCase();
const longDesc = normalizeString(agentAny.longDescription).toLowerCase();
if (!name.includes(term) && !desc.includes(term) && !longDesc.includes(term)) {
return false;
}
}
if (categoryIds && categoryIds.length > 0) {
const category = agentAny.category;
if (!category || !categoryIds.includes(String(category))) return false;
}
if (tagIds && tagIds.length > 0) {
const tags = (agentAny.tags || []) as string[];
if (!Array.isArray(tags) || !tags.some((tag) => tagIds.includes(tag))) {
return false;
}
}
return true;
});
filtered = [...filtered].sort((a, b) => {
const aAny = a as Record<string, unknown>;
const bAny = b as Record<string, unknown>;
switch (sortBy) {
case 'recent': {
const aDate = new Date(normalizeString(aAny.createdAt)).getTime();
const bDate = new Date(normalizeString(bAny.createdAt)).getTime();
return bDate - aDate;
}
case 'installs':
case 'popular': {
const aInstall = normalizeNumber(aAny.installCount, 0);
const bInstall = normalizeNumber(bAny.installCount, 0);
return bInstall - aInstall;
}
case 'name':
return normalizeString(aAny.name).localeCompare(normalizeString(bAny.name));
default:
return 0;
}
});
const total = filtered.length;
const paginated = filtered.slice(offset, offset + limit);
return { paginated, total };
};
export const filterAndSortRemoteSkills = (
skills: RemoteSkillConfig[],
options: {
limit: number;
offset: number;
sortBy: SkillsMarketplaceSortBy;
search?: string;
categoryIds?: string[];
tagIds?: string[];
isFeatured?: boolean;
}
) => {
const { limit, offset, sortBy, search, categoryIds, tagIds, isFeatured } = options;
let filtered = skills.filter((skill) => {
const skillAny = skill as Record<string, unknown>;
if (isFeatured !== undefined) {
const featuredFlag = skillAny.isFeatured ?? false;
if (featuredFlag !== isFeatured) return false;
}
if (search) {
const term = search.toLowerCase();
const name = normalizeString(skillAny.name).toLowerCase();
const desc = normalizeString(skillAny.description).toLowerCase();
const longDesc = normalizeString(skillAny.longDescription).toLowerCase();
if (!name.includes(term) && !desc.includes(term) && !longDesc.includes(term)) {
return false;
}
}
if (categoryIds && categoryIds.length > 0) {
const category = skillAny.category;
if (!category || !categoryIds.includes(String(category))) return false;
}
if (tagIds && tagIds.length > 0) {
const tags = (skillAny.tags || []) as string[];
if (!Array.isArray(tags) || !tags.some((tag) => tagIds.includes(tag))) {
return false;
}
}
return true;
});
filtered = [...filtered].sort((a, b) => {
const aAny = a as Record<string, unknown>;
const bAny = b as Record<string, unknown>;
switch (sortBy) {
case 'recent': {
const aDate = new Date(normalizeString(aAny.createdAt)).getTime();
const bDate = new Date(normalizeString(bAny.createdAt)).getTime();
return bDate - aDate;
}
case 'downloads':
case 'installs':
case 'popular': {
const aInstall = normalizeNumber(aAny.installCount, 0);
const bInstall = normalizeNumber(bAny.installCount, 0);
return bInstall - aInstall;
}
case 'name':
return normalizeString(aAny.name).localeCompare(normalizeString(bAny.name));
case 'rating':
return normalizeNumber(bAny.rating, 0) - normalizeNumber(aAny.rating, 0);
case 'updated': {
const aDate = new Date(normalizeString(aAny.updatedAt)).getTime();
const bDate = new Date(normalizeString(bAny.updatedAt)).getTime();
return bDate - aDate;
}
default:
return 0;
}
});
const total = filtered.length;
const paginated = filtered.slice(offset, offset + limit);
return { paginated, total };
};

View File

@@ -0,0 +1,32 @@
import type { ModelConfig, ModelsConfiguration, ModelVersionResponse } from '@vibncode/shared';
import modelsConfig from '@vibncode/shared/data/models-config.json';
export class ModelsService {
getVersion(): ModelVersionResponse {
return {
version: (modelsConfig as ModelsConfiguration).version,
};
}
getConfigs(): ModelsConfiguration {
return modelsConfig as ModelsConfiguration;
}
getModel(modelKey: string): ModelConfig | null {
const config = modelsConfig as ModelsConfiguration;
return config.models[modelKey] || null;
}
getModelKeys(): string[] {
const config = modelsConfig as ModelsConfiguration;
return Object.keys(config.models);
}
getModelsCount(): number {
const config = modelsConfig as ModelsConfiguration;
return Object.keys(config.models).length;
}
}
// Export singleton instance
export const modelsService = new ModelsService();

View File

@@ -0,0 +1,34 @@
import remoteAgentsConfig from '@vibncode/shared/data/remote-agents-config.json';
import type {
RemoteAgentConfig,
RemoteAgentsConfiguration,
} from '@vibncode/shared/types/remote-agents';
export class RemoteAgentsService {
getVersion(): { version: string } {
return {
version: (remoteAgentsConfig as RemoteAgentsConfiguration).version,
};
}
getConfigs(): RemoteAgentsConfiguration {
return remoteAgentsConfig as RemoteAgentsConfiguration;
}
getRemoteAgent(agentId: string): RemoteAgentConfig | null {
const config = remoteAgentsConfig as RemoteAgentsConfiguration;
return config.remoteAgents.find((agent) => agent.id === agentId) || null;
}
getRemoteAgentIds(): string[] {
const config = remoteAgentsConfig as RemoteAgentsConfiguration;
return config.remoteAgents.map((agent) => agent.id);
}
getRemoteAgentsCount(): number {
const config = remoteAgentsConfig as RemoteAgentsConfiguration;
return config.remoteAgents.length;
}
}
export const remoteAgentsService = new RemoteAgentsService();

View File

@@ -0,0 +1,71 @@
import remoteSkillsConfig from '@vibncode/shared/data/remote-skills-config.json';
import type {
RemoteSkillConfig,
RemoteSkillsConfiguration,
RemoteSkillVersionResponse,
} from '@vibncode/shared/types/remote-skills';
/**
* RemoteSkillsService handles remote skill configuration data on the API side
*/
export class RemoteSkillsService {
/**
* Get the current version timestamp
*/
getVersion(): RemoteSkillVersionResponse {
return {
version: (remoteSkillsConfig as RemoteSkillsConfiguration).version,
};
}
/**
* Get the complete remote skills configuration
*/
getConfigs(): RemoteSkillsConfiguration {
return remoteSkillsConfig as RemoteSkillsConfiguration;
}
/**
* Get a specific remote skill configuration by ID
*/
getRemoteSkill(skillId: string): RemoteSkillConfig | null {
const config = remoteSkillsConfig as RemoteSkillsConfiguration;
return config.remoteSkills.find((skill) => skill.id === skillId) || null;
}
/**
* Get all remote skill IDs
*/
getRemoteSkillIds(): string[] {
const config = remoteSkillsConfig as RemoteSkillsConfiguration;
return config.remoteSkills.map((skill) => skill.id);
}
/**
* Get remote skills count
*/
getRemoteSkillsCount(): number {
const config = remoteSkillsConfig as RemoteSkillsConfiguration;
return config.remoteSkills.length;
}
/**
* Get remote skills filtered by category
*/
getRemoteSkillsByCategory(category: string): RemoteSkillConfig[] {
const config = remoteSkillsConfig as RemoteSkillsConfiguration;
return config.remoteSkills.filter((skill) => skill.category === category);
}
/**
* Get all unique categories
*/
getCategories(): string[] {
const config = remoteSkillsConfig as RemoteSkillsConfiguration;
const categories = new Set(config.remoteSkills.map((skill) => skill.category));
return Array.from(categories).sort();
}
}
// Export singleton instance
export const remoteSkillsService = new RemoteSkillsService();

View File

@@ -0,0 +1,167 @@
// Search usage service - tracks search usage by device ID and optional user ID for rate limiting
import { and, eq, sql } from 'drizzle-orm';
import { db } from '../db/client';
import { analyticsEvents, searchUsage } from '../db/schema';
// Rate limits
const ANONYMOUS_DAILY_LIMIT = 100; // 100 searches/day for anonymous users
const AUTHENTICATED_DAILY_LIMIT = 1000; // 1000 searches/day for authenticated users
export interface SearchUsageCheckResult {
allowed: boolean;
reason?: string;
remaining: number;
used: number;
limit: number;
}
export class SearchUsageService {
/**
* Verify device ID exists in analytics_events table
* This ensures the request comes from a real VibnCode application
*/
async verifyDeviceId(deviceId: string): Promise<boolean> {
const result = await db
.select({ count: sql<number>`COUNT(*)` })
.from(analyticsEvents)
.where(eq(analyticsEvents.deviceId, deviceId))
.limit(1);
return (result[0]?.count || 0) > 0;
}
/**
* Check search limits for a device/user
* @param deviceId - Required device ID
* @param userId - Optional user ID (if authenticated, gets higher limit)
*/
async checkSearchLimits(deviceId: string, userId?: string): Promise<SearchUsageCheckResult> {
// Verify device ID for anonymous users
// Authenticated users are already verified through auth system
if (!userId) {
const isValidDevice = await this.verifyDeviceId(deviceId);
if (!isValidDevice) {
return {
allowed: false,
reason:
'Invalid device ID. Please ensure you are using the official VibnCode application.',
remaining: 0,
used: 0,
limit: ANONYMOUS_DAILY_LIMIT,
};
}
}
const today = new Date().toISOString().split('T')[0];
const limit = userId ? AUTHENTICATED_DAILY_LIMIT : ANONYMOUS_DAILY_LIMIT;
// Get today's search usage
// If userId is provided, check user usage; otherwise check device usage
const usageResult = await db
.select({
searchCount: sql<number>`COALESCE(SUM(${searchUsage.searchCount}), 0)`,
})
.from(searchUsage)
.where(
and(
userId ? eq(searchUsage.userId, userId) : eq(searchUsage.deviceId, deviceId),
eq(searchUsage.usageDate, today)
)
);
const used = usageResult[0]?.searchCount || 0;
if (used >= limit) {
return {
allowed: false,
reason: userId
? `Daily search limit exceeded (${limit} searches/day for authenticated users)`
: `Daily search limit exceeded (${limit} searches/day). Sign in for higher limits.`,
remaining: 0,
used,
limit,
};
}
return {
allowed: true,
remaining: limit - used,
used,
limit,
};
}
/**
* Record a search request
* @param deviceId - Required device ID
* @param userId - Optional user ID (if authenticated)
*/
async recordSearch(deviceId: string, userId?: string): Promise<void> {
const today = new Date().toISOString().split('T')[0];
// Check if record exists for today
const existing = await db
.select()
.from(searchUsage)
.where(
and(
userId ? eq(searchUsage.userId, userId) : eq(searchUsage.deviceId, deviceId),
eq(searchUsage.usageDate, today)
)
)
.limit(1);
if (existing.length > 0) {
// Update existing record - increment search count
await db
.update(searchUsage)
.set({
searchCount: sql`${searchUsage.searchCount} + 1`,
updatedAt: Date.now(),
})
.where(eq(searchUsage.id, existing[0].id));
} else {
// Insert new record
await db.insert(searchUsage).values({
deviceId,
userId: userId || null,
searchCount: 1,
usageDate: today,
});
}
}
/**
* Get search usage statistics
* @param deviceId - Required device ID
* @param userId - Optional user ID
*/
async getSearchStats(deviceId: string, userId?: string) {
const today = new Date().toISOString().split('T')[0];
const limit = userId ? AUTHENTICATED_DAILY_LIMIT : ANONYMOUS_DAILY_LIMIT;
const [usageResult] = await db
.select({
searchCount: sql<number>`COALESCE(SUM(${searchUsage.searchCount}), 0)`,
})
.from(searchUsage)
.where(
and(
userId ? eq(searchUsage.userId, userId) : eq(searchUsage.deviceId, deviceId),
eq(searchUsage.usageDate, today)
)
);
const used = usageResult?.searchCount || 0;
return {
date: today,
used,
limit,
remaining: Math.max(0, limit - used),
isAuthenticated: !!userId,
};
}
}
export const searchUsageService = new SearchUsageService();

View File

@@ -0,0 +1,302 @@
// apps/api/src/services/share-service.ts
// Service for managing task shares
import type {
CreateShareRequest,
CreateShareResponse,
ShareListItem,
TaskShareSnapshot,
} from '@vibncode/shared/types/share';
import { EXPIRATION_DURATIONS, MAX_SHARE_SIZE } from '@vibncode/shared/types/share';
import { and, eq, gt, isNull, lt, or, sql } from 'drizzle-orm';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '../db/schema';
import { analyticsEvents, taskShares } from '../db/schema';
type Database = LibSQLDatabase<typeof schema>;
/**
* Generate a short unique ID using Web Crypto API
* Compatible with Cloudflare Workers
*/
function generateShareId(length = 10): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
return Array.from(randomValues, (v) => chars[v % chars.length]).join('');
}
/**
* Hash a password using SHA-256 (Web Crypto API)
*/
async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Calculate expiration timestamp based on duration string
*/
function calculateExpiresAt(expiresIn?: string): number | undefined {
if (!expiresIn || expiresIn === 'never') {
return undefined;
}
const duration = EXPIRATION_DURATIONS[expiresIn];
return duration ? Date.now() + duration : undefined;
}
export class ShareService {
constructor(private db: Database) {}
/**
* Create a new share
*/
async createShare(
request: CreateShareRequest,
userId?: string,
deviceId?: string
): Promise<CreateShareResponse> {
const shareId = generateShareId(10);
const now = Date.now();
// Calculate expiration
const expiresAt = calculateExpiresAt(request.options?.expiresIn);
// Hash password if provided
let passwordHash: string | undefined;
if (request.options?.password) {
passwordHash = await hashPassword(request.options.password);
}
// Serialize snapshot to JSON
const messagesJson = JSON.stringify(request.snapshot);
// Validate size (2MB limit)
const sizeInBytes = new Blob([messagesJson]).size;
if (sizeInBytes > MAX_SHARE_SIZE) {
throw new Error(
`Share size (${Math.round(sizeInBytes / 1024)} KB) exceeds maximum allowed size (${Math.round(MAX_SHARE_SIZE / 1024)} KB)`
);
}
await this.db.insert(taskShares).values({
id: shareId,
taskId: request.snapshot.task.id,
userId,
taskTitle: request.snapshot.task.title,
messagesJson,
model: request.snapshot.task.model,
passwordHash,
expiresAt,
viewCount: 0,
isPublic: true,
metadata: {
vibncodeVersion: request.snapshot.metadata.vibncodeVersion,
platform: request.snapshot.metadata.platform,
sharedAt: request.snapshot.metadata.sharedAt,
},
createdAt: now,
createdBy: deviceId,
});
const baseUrl = 'https://vibncode.com';
return {
shareId,
shareUrl: `${baseUrl}/share/${shareId}`,
expiresAt,
};
}
/**
* Get a share by ID
* Returns null if not found or expired
* Throws error if password required or invalid
*/
async getShare(shareId: string, password?: string): Promise<TaskShareSnapshot | null> {
const now = Date.now();
const result = await this.db
.select()
.from(taskShares)
.where(
and(
eq(taskShares.id, shareId),
eq(taskShares.isPublic, true),
or(isNull(taskShares.expiresAt), gt(taskShares.expiresAt, now))
)
)
.limit(1);
const share = result[0];
if (!share) {
return null;
}
// Verify password if required
if (share.passwordHash) {
if (!password) {
throw new Error('PASSWORD_REQUIRED');
}
const inputHash = await hashPassword(password);
if (inputHash !== share.passwordHash) {
throw new Error('INVALID_PASSWORD');
}
}
// Increment view count (fire-and-forget)
this.db
.update(taskShares)
.set({ viewCount: share.viewCount + 1 })
.where(eq(taskShares.id, shareId))
.catch((error) => {
// Log error but don't block the response
console.error('[ShareService] Failed to increment view count for share:', shareId, error);
});
// Parse and return snapshot
try {
return JSON.parse(share.messagesJson) as TaskShareSnapshot;
} catch {
return null;
}
}
/**
* Check if a share requires password (without fetching full data)
*/
async checkShareAccess(
shareId: string
): Promise<{ exists: boolean; requiresPassword: boolean; expired: boolean }> {
const now = Date.now();
console.log('[ShareService] checkShareAccess called with shareId:', shareId);
const result = await this.db
.select({
id: taskShares.id,
passwordHash: taskShares.passwordHash,
expiresAt: taskShares.expiresAt,
isPublic: taskShares.isPublic,
})
.from(taskShares)
.where(eq(taskShares.id, shareId))
.limit(1);
console.log('[ShareService] checkShareAccess result:', result);
const share = result[0];
if (!share || !share.isPublic) {
console.log('[ShareService] Share not found or not public');
return { exists: false, requiresPassword: false, expired: false };
}
const expired = share.expiresAt !== null && share.expiresAt < now;
console.log('[ShareService] Share found, expired:', expired);
return {
exists: true,
requiresPassword: !!share.passwordHash,
expired,
};
}
/**
* Get shares created by a user
*/
async getUserShares(userId: string): Promise<ShareListItem[]> {
const result = await this.db
.select({
id: taskShares.id,
taskTitle: taskShares.taskTitle,
model: taskShares.model,
viewCount: taskShares.viewCount,
expiresAt: taskShares.expiresAt,
createdAt: taskShares.createdAt,
passwordHash: taskShares.passwordHash,
messagesJson: taskShares.messagesJson,
})
.from(taskShares)
.where(eq(taskShares.userId, userId))
.orderBy(taskShares.createdAt);
return result.map((share) => {
let messageCount = 0;
try {
const snapshot = JSON.parse(share.messagesJson) as TaskShareSnapshot;
messageCount = snapshot.messages?.length || 0;
} catch {
// Ignore parse errors
}
return {
id: share.id,
taskTitle: share.taskTitle,
model: share.model ?? undefined,
messageCount,
viewCount: share.viewCount,
expiresAt: share.expiresAt ?? undefined,
createdAt: share.createdAt,
hasPassword: !!share.passwordHash,
};
});
}
/**
* Delete a share (only by owner)
*/
async deleteShare(shareId: string, userId: string): Promise<boolean> {
const result = await this.db
.delete(taskShares)
.where(and(eq(taskShares.id, shareId), eq(taskShares.userId, userId)));
return result.rowsAffected > 0;
}
/**
* Delete a share by ID (admin or device owner)
*/
async deleteShareByDevice(shareId: string, deviceId: string): Promise<boolean> {
const result = await this.db
.delete(taskShares)
.where(and(eq(taskShares.id, shareId), eq(taskShares.createdBy, deviceId)));
return result.rowsAffected > 0;
}
/**
* Cleanup expired shares
* Should be called periodically (e.g., cron job)
*/
async cleanupExpiredShares(): Promise<number> {
const now = Date.now();
const result = await this.db
.delete(taskShares)
.where(and(lt(taskShares.expiresAt, now), gt(taskShares.expiresAt, 0)));
return result.rowsAffected;
}
/**
* Verify device ID exists in analytics_events table
* This ensures the request comes from a real VibnCode application
*/
async verifyDeviceId(deviceId: string): Promise<boolean> {
try {
const result = await this.db
.select({ count: sql<number>`COUNT(*)` })
.from(analyticsEvents)
.where(eq(analyticsEvents.deviceId, deviceId))
.limit(1);
return (result[0]?.count || 0) > 0;
} catch (error) {
console.error('[ShareService] Failed to verify device ID:', error);
return false;
}
}
}

View File

@@ -0,0 +1,387 @@
// Skill management service for CRUD operations
import type { CreateSkillRequest, UpdateSkillRequest } from '@vibncode/shared';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '../db/client';
import {
categories,
marketplaceSkills,
skillCategories,
skillTags,
skillVersions,
tags,
} from '../db/schema';
import type { CategoryRecord } from '../types/database';
export class SkillService {
/**
* Create a new skill (publish to marketplace)
*/
async createSkill(userId: string, data: CreateSkillRequest) {
// Generate slug from name
const slug = this.generateSlug(data.name);
// Check if slug already exists
const existing = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.slug, slug))
.limit(1);
if (existing.length > 0) {
throw new Error('Skill with this name already exists');
}
let skillId: string | null = null;
try {
// Step 1: Validate and get category UUIDs first
let categoryRecords: CategoryRecord[] = [];
if (data.categories && data.categories.length > 0) {
categoryRecords = await db
.select()
.from(categories)
.where(
sql`${categories.slug} IN (${sql.join(
data.categories.map((id: string) => sql`${id}`),
sql`, `
)})`
);
if (categoryRecords.length === 0) {
throw new Error('No valid categories found');
}
}
// Step 2: Create skill
const skill = await db
.insert(marketplaceSkills)
.values({
slug,
name: data.name,
description: data.description,
longDescription: data.longDescription || null,
authorId: userId,
systemPromptFragment: data.systemPromptFragment || null,
workflowRules: data.workflowRules || null,
documentation: data.documentation,
latestVersion: '1.0.0',
iconUrl: data.iconUrl || null,
isPublished: false, // Draft by default
// R2 storage fields
storageUrl: data.storageUrl || null,
packageSize: data.packageSize || null,
checksum: data.checksum || null,
hasScripts: data.hasScripts ? 1 : 0,
// Agent Skills Specification fields
compatibility: data.compatibility || null,
metadata: data.metadata ? JSON.stringify(data.metadata) : null,
})
.returning();
skillId = skill[0].id;
// Step 3: Create initial version
await db.insert(skillVersions).values({
skillId: skill[0].id,
version: '1.0.0',
systemPromptFragment: data.systemPromptFragment || null,
workflowRules: data.workflowRules || null,
documentation: data.documentation,
changeLog: 'Initial release',
// R2 storage fields for this version
storageUrl: data.storageUrl || null,
packageSize: data.packageSize || null,
checksum: data.checksum || null,
// Agent Skills Specification fields
compatibility: data.compatibility || null,
metadata: data.metadata ? JSON.stringify(data.metadata) : null,
});
// Step 4: Link categories
if (categoryRecords.length > 0) {
await db.insert(skillCategories).values(
categoryRecords.map((category) => ({
skillId: skill[0].id,
categoryId: category.id,
}))
);
}
// Step 5: Link or create tags
if (data.tags && data.tags.length > 0) {
await this.linkTags(skill[0].id, data.tags);
}
return skill[0];
} catch (error) {
// If we created a skill but something failed, delete it
if (skillId) {
try {
await db.delete(marketplaceSkills).where(eq(marketplaceSkills.id, skillId));
} catch (deleteError) {
console.error('Failed to rollback skill creation:', deleteError);
}
}
throw error;
}
}
/**
* Update skill
*/
async updateSkill(userId: string, skillId: string, data: UpdateSkillRequest) {
// Check ownership
const skill = await this.getSkillById(skillId);
if (!skill || skill.authorId !== userId) {
throw new Error('Skill not found or unauthorized');
}
const updates: Partial<typeof marketplaceSkills.$inferInsert> = {};
if (data.name !== undefined) {
const newSlug = this.generateSlug(data.name);
// Check if the new slug conflicts with another skill (excluding current skill)
const existing = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.slug, newSlug))
.limit(1);
if (existing.length > 0 && existing[0].id !== skillId) {
throw new Error('Skill with this name already exists');
}
updates.name = data.name;
updates.slug = newSlug;
}
if (data.description !== undefined) updates.description = data.description;
if (data.longDescription !== undefined) updates.longDescription = data.longDescription;
if (data.systemPromptFragment !== undefined)
updates.systemPromptFragment = data.systemPromptFragment;
if (data.workflowRules !== undefined) updates.workflowRules = data.workflowRules;
if (data.documentation !== undefined) updates.documentation = data.documentation;
if (data.iconUrl !== undefined) updates.iconUrl = data.iconUrl;
if (data.bannerUrl !== undefined) updates.bannerUrl = data.bannerUrl;
// Agent Skills Specification fields
if (data.compatibility !== undefined) updates.compatibility = data.compatibility;
if (data.metadata !== undefined) {
updates.metadata = data.metadata ? JSON.stringify(data.metadata) : null;
}
// Update skill
if (Object.keys(updates).length > 0) {
await db.update(marketplaceSkills).set(updates).where(eq(marketplaceSkills.id, skillId));
}
// Update categories if provided
if (data.categories !== undefined) {
// Remove existing
await db.delete(skillCategories).where(eq(skillCategories.skillId, skillId));
// Add new
if (data.categories.length > 0) {
const categoryRecords = await db
.select()
.from(categories)
.where(
sql`${categories.slug} IN (${sql.join(
data.categories.map((slug: string) => sql`${slug}`),
sql`, `
)})`
);
if (categoryRecords.length > 0) {
await db.insert(skillCategories).values(
categoryRecords.map((category) => ({
skillId,
categoryId: category.id,
}))
);
}
}
}
// Update tags if provided
if (data.tags !== undefined) {
// Remove existing
await db.delete(skillTags).where(eq(skillTags.skillId, skillId));
// Add new
if (data.tags.length > 0) {
await this.linkTags(skillId, data.tags);
}
}
return this.getSkillById(skillId);
}
/**
* Publish skill (make it public)
*/
async publishSkill(userId: string, skillId: string) {
const skill = await this.getSkillById(skillId);
if (!skill || skill.authorId !== userId) {
throw new Error('Skill not found or unauthorized');
}
await db
.update(marketplaceSkills)
.set({ isPublished: true, publishedAt: Date.now() })
.where(eq(marketplaceSkills.id, skillId));
return this.getSkillById(skillId);
}
/**
* Unpublish skill
*/
async unpublishSkill(userId: string, skillId: string) {
const skill = await this.getSkillById(skillId);
if (!skill || skill.authorId !== userId) {
throw new Error('Skill not found or unauthorized');
}
await db
.update(marketplaceSkills)
.set({ isPublished: false })
.where(eq(marketplaceSkills.id, skillId));
return this.getSkillById(skillId);
}
/**
* Delete skill
*/
async deleteSkill(userId: string, skillId: string) {
const skill = await this.getSkillById(skillId);
if (!skill || skill.authorId !== userId) {
throw new Error('Skill not found or unauthorized');
}
// Delete skill (cascade will handle relations)
await db.delete(marketplaceSkills).where(eq(marketplaceSkills.id, skillId));
return true;
}
/**
* Create new version
*/
async createVersion(
userId: string,
skillId: string,
data: {
version: string;
systemPromptFragment?: string;
workflowRules?: string;
documentation?: unknown[];
changeLog: string;
}
) {
const skill = await this.getSkillById(skillId);
if (!skill || skill.authorId !== userId) {
throw new Error('Skill not found or unauthorized');
}
// Check if version already exists
const existingVersion = await db
.select()
.from(skillVersions)
.where(and(eq(skillVersions.skillId, skillId), eq(skillVersions.version, data.version)))
.limit(1);
if (existingVersion.length > 0) {
throw new Error('Version already exists');
}
// Create version
const version = await db
.insert(skillVersions)
.values({
skillId,
version: data.version,
systemPromptFragment:
data.systemPromptFragment !== undefined
? data.systemPromptFragment
: skill.systemPromptFragment,
workflowRules: data.workflowRules !== undefined ? data.workflowRules : skill.workflowRules,
documentation: data.documentation !== undefined ? data.documentation : skill.documentation,
changeLog: data.changeLog,
})
.returning();
// Update latest version
await db
.update(marketplaceSkills)
.set({ latestVersion: data.version })
.where(eq(marketplaceSkills.id, skillId));
return version[0];
}
/**
* Get skill by ID (internal)
*/
private async getSkillById(skillId: string) {
const results = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.id, skillId))
.limit(1);
return results.length > 0 ? results[0] : null;
}
/**
* Link tags to skill (create tags if they don't exist)
*/
private async linkTags(skillId: string, tagNames: string[]) {
for (const tagName of tagNames) {
const tagSlug = this.generateSlug(tagName);
// Find or create tag
let tag = await db.select().from(tags).where(eq(tags.slug, tagSlug)).limit(1);
if (tag.length === 0) {
const newTag = await db
.insert(tags)
.values({
name: tagName,
slug: tagSlug,
})
.returning();
tag = newTag;
} else {
// Increment usage count
await db
.update(tags)
.set({
usageCount: sql`${tags.usageCount} + 1`,
})
.where(eq(tags.id, tag[0].id));
}
// Link to skill
await db.insert(skillTags).values({
skillId,
tagId: tag[0].id,
});
}
}
/**
* Generate slug from name
*/
private generateSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
}
export const skillService = new SkillService();

View File

@@ -0,0 +1,69 @@
// Upload service for handling file uploads to Cloudflare R2
import type { R2Bucket } from '../types/env';
export class UploadService {
private cdnBaseUrl = 'https://cdn.vibncode.com';
/**
* Upload avatar image to R2
* @param userId - User ID
* @param file - File to upload
* @param bucket - R2 bucket instance
* @returns CDN URL to the uploaded file
*/
async uploadAvatar(userId: string, file: File, bucket: R2Bucket): Promise<string> {
// Extract file extension
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
// Define R2 key (path)
const key = `users/${userId}/avatar.${ext}`;
// Delete old avatar files for this user
await this.deleteOldAvatars(userId, bucket);
// Read file data
const arrayBuffer = await file.arrayBuffer();
// Upload to R2
await bucket.put(key, arrayBuffer, {
httpMetadata: {
contentType: file.type,
cacheControl: 'public, max-age=31536000', // Cache for 1 year
},
});
// Return CDN URL
return `${this.cdnBaseUrl}/${key}`;
}
/**
* Delete all old avatar files for a user
* @param userId - User ID
* @param bucket - R2 bucket instance
*/
private async deleteOldAvatars(userId: string, bucket: R2Bucket): Promise<void> {
const prefix = `users/${userId}/`;
// List all files in user directory
const listed = await bucket.list({ prefix });
// Delete files that contain 'avatar' in the name
for (const object of listed.objects) {
if (object.key.includes('avatar')) {
await bucket.delete(object.key);
}
}
}
/**
* Delete avatar by user ID
* @param userId - User ID
* @param bucket - R2 bucket instance
*/
async deleteAvatar(userId: string, bucket: R2Bucket): Promise<void> {
await this.deleteOldAvatars(userId, bucket);
}
}
export const uploadService = new UploadService();

View File

@@ -0,0 +1,159 @@
// User service
import type { PublicUser } from '@vibncode/shared';
import { and, count, desc, eq, sql } from 'drizzle-orm';
import { db } from '../db/client';
import { marketplaceAgents, users } from '../db/schema';
export interface UserStats {
totalAgents: number;
totalInstalls: number;
featuredAgents: number;
}
export class UserService {
/**
* Get user profile by ID
*/
async getUserProfile(userId: string): Promise<PublicUser | null> {
const results = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (results.length === 0) {
return null;
}
// Get published agent count for this user
const agentCountResult = await db
.select({ count: count() })
.from(marketplaceAgents)
.where(and(eq(marketplaceAgents.authorId, userId), eq(marketplaceAgents.isPublished, true)));
const agentCount = agentCountResult[0]?.count || 0;
return this.mapToPublicUser(results[0], agentCount);
}
/**
* Get user statistics
*/
async getUserStats(userId: string): Promise<UserStats> {
// Get total agents count
const agentCountResult = await db
.select({ count: count() })
.from(marketplaceAgents)
.where(eq(marketplaceAgents.authorId, userId));
// Get total installs
const installStats = await db
.select({
totalInstalls: sql<number>`COALESCE(SUM(${marketplaceAgents.installCount}), 0)`,
featuredCount: sql<number>`COUNT(CASE WHEN ${marketplaceAgents.isFeatured} = true THEN 1 END)`,
})
.from(marketplaceAgents)
.where(eq(marketplaceAgents.authorId, userId));
return {
totalAgents: agentCountResult[0]?.count || 0,
totalInstalls: Number(installStats[0]?.totalInstalls) || 0,
featuredAgents: Number(installStats[0]?.featuredCount) || 0,
};
}
/**
* Get user's published agents
*/
async getUserAgents(
userId: string,
options?: {
limit?: number;
offset?: number;
}
) {
const limit = options?.limit || 20;
const offset = options?.offset || 0;
const agents = await db
.select()
.from(marketplaceAgents)
.where(eq(marketplaceAgents.authorId, userId))
.orderBy(desc(marketplaceAgents.createdAt))
.limit(limit)
.offset(offset);
// Get total count
const totalResult = await db
.select({ count: count() })
.from(marketplaceAgents)
.where(eq(marketplaceAgents.authorId, userId));
return {
agents,
total: totalResult[0]?.count || 0,
limit,
offset,
};
}
/**
* Update user profile
*/
async updateUserProfile(
userId: string,
data: {
name?: string;
displayName?: string;
bio?: string;
website?: string;
avatarUrl?: string;
}
) {
const updates: Partial<{
name: string;
displayName: string;
bio: string;
website: string;
avatarUrl: string;
}> = {};
if (data.name !== undefined) updates.name = data.name;
if (data.displayName !== undefined) updates.displayName = data.displayName;
if (data.bio !== undefined) updates.bio = data.bio;
if (data.website !== undefined) updates.website = data.website;
if (data.avatarUrl !== undefined) updates.avatarUrl = data.avatarUrl;
if (Object.keys(updates).length === 0) {
return this.getUserProfile(userId);
}
await db.update(users).set(updates).where(eq(users.id, userId));
return this.getUserProfile(userId);
}
/**
* Map database user to public user format
*/
private mapToPublicUser(
dbUser: {
id: string;
name: string;
displayName?: string | null;
avatarUrl: string | null;
bio: string | null;
website: string | null;
},
agentCount: number = 0
): PublicUser {
return {
id: dbUser.id,
name: dbUser.name,
displayName: dbUser.displayName || undefined,
avatarUrl: dbUser.avatarUrl,
bio: dbUser.bio,
website: dbUser.website,
agentCount,
};
}
}
export const userService = new UserService();

View File

@@ -0,0 +1,167 @@
// User usage service for VibnCode provider - tracks usage by user ID
import { and, eq, sql } from 'drizzle-orm';
import { db } from '../db/client';
import { providerUsage } from '../db/schema';
import type { Env } from '../types/env';
export interface UsageCheckResult {
allowed: boolean;
reason?: string;
remaining?: {
dailyTokens: number;
};
used?: {
dailyTokens: number;
};
}
export class UserUsageService {
/**
* Check usage limits for a user
*/
async checkUsageLimits(userId: string, provider: string, env?: Env): Promise<UsageCheckResult> {
const today = new Date().toISOString().split('T')[0];
const dailyTokenLimit = this.getDailyTokenLimit(env);
// Get today's usage
const dailyUsage = await db
.select({
totalTokens: sql<number>`COALESCE(SUM(${providerUsage.totalTokens}), 0)`,
})
.from(providerUsage)
.where(
and(
eq(providerUsage.userId, userId),
eq(providerUsage.provider, provider),
eq(providerUsage.usageDate, today)
)
);
const usedTokens = dailyUsage[0]?.totalTokens || 0;
if (usedTokens >= dailyTokenLimit) {
return {
allowed: false,
reason: 'Daily token limit exceeded',
remaining: { dailyTokens: 0 },
used: { dailyTokens: usedTokens },
};
}
return {
allowed: true,
remaining: { dailyTokens: dailyTokenLimit - usedTokens },
used: { dailyTokens: usedTokens },
};
}
/**
* Record usage for a request and return remaining tokens
* @param previouslyUsedTokens - Optional: tokens already used today (from checkUsageLimits)
* If provided, skips the second query for remaining calculation
*/
async recordUsage(
userId: string,
provider: string,
model: string,
tokens: { input: number; output: number },
env?: Env,
previouslyUsedTokens?: number
): Promise<{ remainingDailyTokens: number }> {
const today = new Date().toISOString().split('T')[0];
const totalTokensUsed = tokens.input + tokens.output;
// Get daily token limit
const dailyTokenLimit = this.getDailyTokenLimit(env);
await db.insert(providerUsage).values({
userId,
provider,
model,
inputTokens: tokens.input,
outputTokens: tokens.output,
totalTokens: totalTokensUsed,
usageDate: today,
});
// If we already know the previously used tokens, calculate remaining directly
if (previouslyUsedTokens !== undefined) {
const newTotal = previouslyUsedTokens + totalTokensUsed;
return { remainingDailyTokens: Math.max(0, dailyTokenLimit - newTotal) };
}
// Fallback: query for total (only if previouslyUsedTokens not provided)
const dailyUsage = await db
.select({
totalTokens: sql<number>`COALESCE(SUM(${providerUsage.totalTokens}), 0)`,
})
.from(providerUsage)
.where(
and(
eq(providerUsage.userId, userId),
eq(providerUsage.provider, provider),
eq(providerUsage.usageDate, today)
)
);
const usedTokens = dailyUsage[0]?.totalTokens || 0;
return { remainingDailyTokens: Math.max(0, dailyTokenLimit - usedTokens) };
}
/**
* Get daily token limit from environment
*/
private getDailyTokenLimit(env?: Env): number {
if (typeof Bun !== 'undefined') {
return parseInt(Bun.env.TALKCODY_DAILY_TOKEN_LIMIT || '100000', 10);
}
if (env?.TALKCODY_DAILY_TOKEN_LIMIT) {
return parseInt(env.TALKCODY_DAILY_TOKEN_LIMIT, 10);
}
return 100000;
}
/**
* Get usage statistics for a user
*/
async getUsageStats(userId: string, provider: string, env?: Env) {
const today = new Date().toISOString().split('T')[0];
const dailyTokenLimit = this.getDailyTokenLimit(env);
// Get today's usage
const [dailyUsage] = await db
.select({
totalTokens: sql<number>`COALESCE(SUM(${providerUsage.totalTokens}), 0)`,
inputTokens: sql<number>`COALESCE(SUM(${providerUsage.inputTokens}), 0)`,
outputTokens: sql<number>`COALESCE(SUM(${providerUsage.outputTokens}), 0)`,
requestCount: sql<number>`COUNT(*)`,
})
.from(providerUsage)
.where(
and(
eq(providerUsage.userId, userId),
eq(providerUsage.provider, provider),
eq(providerUsage.usageDate, today)
)
);
return {
date: today,
used: {
totalTokens: dailyUsage?.totalTokens || 0,
inputTokens: dailyUsage?.inputTokens || 0,
outputTokens: dailyUsage?.outputTokens || 0,
requestCount: dailyUsage?.requestCount || 0,
},
limit: {
dailyTokens: dailyTokenLimit,
},
remaining: {
dailyTokens: Math.max(0, dailyTokenLimit - (dailyUsage?.totalTokens || 0)),
},
};
}
}
export const userUsageService = new UserUsageService();

View File

@@ -0,0 +1,41 @@
// Google OAuth route validation tests
import { describe, expect, it } from 'bun:test';
import { app } from '../index';
const baseEnv = {
JWT_SECRET: 'test-jwt-secret',
TURSO_DATABASE_URL: 'file:./test.db',
TURSO_AUTH_TOKEN: 'test-token',
GOOGLE_CLIENT_ID: 'test-google-client-id',
GOOGLE_CLIENT_SECRET: 'test-google-client-secret',
};
describe('Auth API - Google OAuth', () => {
it('should reject request when Google client config is missing', async () => {
const res = await app.request('/api/auth/google', {
env: {
JWT_SECRET: 'test-jwt-secret',
TURSO_DATABASE_URL: 'file:./test.db',
TURSO_AUTH_TOKEN: 'test-token',
},
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data).toHaveProperty('error');
});
it('should redirect to Google when config is provided', async () => {
const res = await app.request('/api/auth/google', {
env: baseEnv,
});
expect(res.status).toBe(302);
const location = res.headers.get('location');
expect(location).toContain('https://accounts.google.com/o/oauth2/v2/auth?');
expect(location).toContain('client_id=test-google-client-id');
expect(location).toContain('scope=openid');
});
});

View File

@@ -0,0 +1,123 @@
// Auth service tests - focusing on avatar preservation during OAuth login
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { eq } from 'drizzle-orm';
import { users } from '../db/schema';
import { authService } from '../services/auth-service';
import { testDb as db } from './db-client';
import { clearDatabase, verifyTestEnvironment } from './fixtures';
// Initialize test database before all tests
beforeAll(async () => {
console.log('\n🔧 Setting up auth service test environment...\n');
verifyTestEnvironment();
await clearDatabase();
console.log('\n✅ Auth service test environment ready\n');
});
// Clean up after all tests
afterAll(async () => {
console.log('\n🧹 Cleaning up auth service test environment...\n');
await clearDatabase();
console.log('✅ Cleanup complete\n');
});
describe('AuthService - Avatar handling during OAuth login', () => {
it('should use OAuth avatar when creating a new user', async () => {
const oauthProfile = {
provider: 'github' as const,
providerId: 'github-new-user-123',
email: 'newuser@example.com',
name: 'New User',
avatarUrl: 'https://github.com/avatar/new-user.png',
};
const user = await authService.findOrCreateUser(oauthProfile);
expect(user.avatarUrl).toBe('https://github.com/avatar/new-user.png');
});
it('should preserve existing avatar when user re-logs in via OAuth', async () => {
// First, create a user with GitHub OAuth
const initialProfile = {
provider: 'github' as const,
providerId: 'github-existing-user-456',
email: 'existinguser@example.com',
name: 'Existing User',
avatarUrl: 'https://github.com/avatar/initial.png',
};
await authService.findOrCreateUser(initialProfile);
// Simulate user uploading a custom avatar
const customAvatarUrl = 'https://r2.vibncode.com/users/custom-avatar.png';
await db
.update(users)
.set({ avatarUrl: customAvatarUrl })
.where(eq(users.githubId, 'github-existing-user-456'));
// Now user logs in again via GitHub with a different avatar URL
const reLoginProfile = {
provider: 'github' as const,
providerId: 'github-existing-user-456',
email: 'existinguser@example.com',
name: 'Existing User',
avatarUrl: 'https://github.com/avatar/updated.png', // GitHub's new avatar
};
const user = await authService.findOrCreateUser(reLoginProfile);
// The custom avatar should be preserved, NOT overwritten by GitHub's avatar
expect(user.avatarUrl).toBe(customAvatarUrl);
});
it('should use OAuth avatar when existing user has no avatar', async () => {
// First, create a user without an avatar via email lookup scenario
await db.insert(users).values({
email: 'noavatar@example.com',
name: 'No Avatar User',
avatarUrl: null,
role: 'user',
isVerified: true,
});
// User logs in via GitHub (linking account by email)
const oauthProfile = {
provider: 'github' as const,
providerId: 'github-noavatar-789',
email: 'noavatar@example.com',
name: 'No Avatar User',
avatarUrl: 'https://github.com/avatar/noavatar.png',
};
const user = await authService.findOrCreateUser(oauthProfile);
// Since user had no avatar, OAuth avatar should be used
expect(user.avatarUrl).toBe('https://github.com/avatar/noavatar.png');
});
it('should preserve avatar when linking account by email', async () => {
// Create a user with a custom avatar (e.g., registered via Google first)
await db.insert(users).values({
email: 'multiauth@example.com',
name: 'Multi Auth User',
avatarUrl: 'https://r2.vibncode.com/users/google-avatar.png',
googleId: 'google-multiauth-123',
role: 'user',
isVerified: true,
});
// User now logs in via GitHub (linking account by email)
const githubProfile = {
provider: 'github' as const,
providerId: 'github-multiauth-456',
email: 'multiauth@example.com',
name: 'Multi Auth User',
avatarUrl: 'https://github.com/avatar/multiauth.png',
};
const user = await authService.findOrCreateUser(githubProfile);
// The existing avatar should be preserved
expect(user.avatarUrl).toBe('https://r2.vibncode.com/users/google-avatar.png');
});
});

View File

@@ -0,0 +1,24 @@
// Check existing agents details
import { db } from '../db/client';
import { marketplaceAgents } from '../db/schema';
async function checkExistingAgents() {
console.log('\n=== Existing Agents Details ===\n');
const agents = await db.select().from(marketplaceAgents);
for (const agent of agents) {
console.log(`\nAgent: ${agent.name}`);
console.log(` Slug: ${agent.slug}`);
console.log(` Created at: ${agent.createdAt}`);
console.log(` Published: ${agent.isPublished}`);
console.log(` Published at: ${agent.publishedAt}`);
}
process.exit(0);
}
checkExistingAgents().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

57
src/test/check-tags.ts Normal file
View File

@@ -0,0 +1,57 @@
// Check tags in database
import { eq } from 'drizzle-orm';
import { db } from '../db/client';
import { agentTags, marketplaceAgents, tags } from '../db/schema';
async function checkTags() {
console.log('\n=== Checking Tags in Database ===\n');
// Get all agents
const agents = await db.select().from(marketplaceAgents);
console.log(`Total agents: ${agents.length}`);
for (const agent of agents) {
console.log(`\nAgent: ${agent.name} (${agent.slug})`);
// Get tags for this agent
const agentTagsResult = await db
.select({
agentId: agentTags.agentId,
tagId: agentTags.tagId,
tag: tags,
})
.from(agentTags)
.innerJoin(tags, eq(agentTags.tagId, tags.id))
.where(eq(agentTags.agentId, agent.id));
if (agentTagsResult.length === 0) {
console.log(' ❌ No tags found');
} else {
console.log(` ✅ Tags (${agentTagsResult.length}):`);
for (const at of agentTagsResult) {
console.log(` - ${at.tag.name} (slug: ${at.tag.slug}, usage: ${at.tag.usageCount})`);
}
}
}
// Get all tags
console.log('\n=== All Tags in Database ===');
const allTags = await db.select().from(tags);
console.log(`Total tags: ${allTags.length}`);
for (const tag of allTags) {
console.log(` - ${tag.name} (slug: ${tag.slug}, usage: ${tag.usageCount})`);
}
// Get all agent_tags relations
console.log('\n=== All Agent-Tag Relations ===');
const allAgentTags = await db.select().from(agentTags);
console.log(`Total relations: ${allAgentTags.length}`);
process.exit(0);
}
checkTags().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,51 @@
// Check user agents publication status
import { eq } from 'drizzle-orm';
import { db } from '../db/client';
import { marketplaceAgents, users } from '../db/schema';
async function checkUserAgents() {
try {
// List all users first
console.log('📋 Listing all users in database:\n');
const allUsers = await db.select().from(users);
if (allUsers.length === 0) {
console.log('❌ No users found in database');
return;
}
for (const user of allUsers) {
console.log(`\n👤 User: ${user.name}`);
console.log(` - ID: ${user.id}`);
console.log(` - Email: ${user.email}`);
// Get all agents by this user
const allAgents = await db
.select()
.from(marketplaceAgents)
.where(eq(marketplaceAgents.authorId, user.id));
console.log(` - Total agents: ${allAgents.length}`);
if (allAgents.length > 0) {
allAgents.forEach((agent, index) => {
console.log(` ${index + 1}. ${agent.name} (${agent.slug})`);
console.log(` - Published: ${agent.isPublished ? '✅ YES' : '❌ NO'}`);
console.log(` - Installs: ${agent.installCount}`);
});
// Count published agents
const publishedAgents = allAgents.filter((a) => a.isPublished);
console.log(` - ✅ Published: ${publishedAgents.length}`);
console.log(` - ❌ Unpublished: ${allAgents.length - publishedAgents.length}`);
}
}
} catch (error) {
console.error('Error:', error);
}
process.exit(0);
}
checkUserAgents();

41
src/test/db-client.ts Normal file
View File

@@ -0,0 +1,41 @@
// Test database client - uses TURSO_DATABASE_URL_TEST environment variable
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from '../db/schema';
// Get test database URL and auth token from environment
const testDatabaseUrl = Bun.env.TURSO_DATABASE_URL_TEST || process.env.TURSO_DATABASE_URL_TEST;
const testAuthToken = Bun.env.TURSO_AUTH_TOKEN_TEST || process.env.TURSO_AUTH_TOKEN_TEST;
if (!testDatabaseUrl) {
throw new Error('TURSO_DATABASE_URL_TEST environment variable is required for running tests');
}
if (!testAuthToken) {
throw new Error('TURSO_AUTH_TOKEN_TEST environment variable is required for running tests');
}
// Create Turso client for test database
const testClient = createClient({
url: testDatabaseUrl,
authToken: testAuthToken,
});
// Create Drizzle instance with schema for test database
export const testDb = drizzle(testClient, { schema });
// Export test client for raw queries
export { testClient };
// Health check function for test database
export async function checkTestDatabaseConnection(): Promise<boolean> {
try {
await testClient.execute('SELECT 1');
console.log('✅ Test database connection successful');
return true;
} catch (error) {
console.error('❌ Test database connection failed:', error);
return false;
}
}

516
src/test/fixtures.ts Normal file
View File

@@ -0,0 +1,516 @@
// Test fixtures and seed data for test database
// Use the test database client to ensure we're always using the test database
import {
agentCategories,
agentTags,
agentVersions,
categories,
marketplaceAgents,
marketplaceSkills,
skillCategories,
skillTags,
skillVersions,
tags,
users,
} from '../db/schema';
import { testDb as db, testClient } from './db-client';
/**
* Verify test environment is properly configured
* This should be called at the very beginning of test execution
*/
export function verifyTestEnvironment() {
// SAFETY CHECK 1: Verify TEST_MODE is enabled
if (process.env.TEST_MODE !== 'true') {
throw new Error(
'🚨 SAFETY CHECK FAILED: TEST_MODE is not enabled!\n\n' +
'This prevents accidentally clearing production data.\n' +
'Tests must be run with: bun run test (not bun test directly)\n\n' +
'The test runner (run-tests.ts) sets TEST_MODE=true and switches to DATABASE_URL_TEST.'
);
}
// SAFETY CHECK 2: Verify TURSO_DATABASE_URL matches TURSO_DATABASE_URL_TEST
const currentDbUrl = process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL || '';
const testDbUrl = process.env.TURSO_DATABASE_URL_TEST || process.env.DATABASE_URL_TEST || '';
if (!testDbUrl) {
throw new Error(
'🚨 SAFETY CHECK FAILED: TURSO_DATABASE_URL_TEST is not set!\n\n' +
'Please configure TURSO_DATABASE_URL_TEST in your .env file.'
);
}
if (currentDbUrl !== testDbUrl) {
throw new Error(
`🚨 SAFETY CHECK FAILED: TURSO_DATABASE_URL does not match TURSO_DATABASE_URL_TEST!\n\n` +
'This indicates tests are not running through the proper test runner.\n' +
'Tests must be run with: bun run test (not bun test directly)\n\n' +
`Current TURSO_DATABASE_URL: ${currentDbUrl.split('@')[1]?.split('?')[0] || currentDbUrl}\n` +
`Expected TEST URL: ${testDbUrl.split('@')[1]?.split('?')[0] || testDbUrl}`
);
}
console.log('✅ Test environment safety checks passed');
}
/**
* Clear all test data from database
*/
export async function clearDatabase() {
// For Turso/SQLite, we don't need the PostgreSQL safety checks
console.log('🧹 Clearing test database...');
// Clear FTS5 tables first
try {
await testClient.execute('DELETE FROM marketplace_agents_fts');
await testClient.execute('DELETE FROM marketplace_skills_fts');
} catch (_error: any) {
// FTS5 tables might not exist in all environments, ignore errors
console.log(' (Skipping FTS5 tables)');
}
// Delete in order to respect foreign key constraints
await db.delete(skillTags);
await db.delete(skillCategories);
await db.delete(skillVersions);
await db.delete(marketplaceSkills);
await db.delete(agentTags);
await db.delete(agentCategories);
await db.delete(agentVersions);
await db.delete(marketplaceAgents);
await db.delete(categories);
await db.delete(tags);
await db.delete(users);
console.log('✅ Test database cleared');
}
/**
* Seed test users
*/
export async function seedTestUsers() {
const testUsers = await db
.insert(users)
.values([
{
email: 'alice@example.com',
name: 'Alice Smith',
role: 'user',
avatarUrl: 'https://example.com/alice.jpg',
bio: 'AI enthusiast and developer',
website: 'https://alice.dev',
isVerified: true,
},
{
email: 'bob@example.com',
name: 'Bob Johnson',
role: 'user',
avatarUrl: 'https://example.com/bob.jpg',
bio: 'Developer and agent creator',
website: 'https://bob.dev',
isVerified: true,
},
])
.returning();
console.log(`✅ Seeded ${testUsers.length} test users`);
return testUsers;
}
/**
* Seed test categories
*/
export async function seedTestCategories() {
const testCategories = await db
.insert(categories)
.values([
{
name: 'Coding',
slug: 'coding',
description: 'Agents for coding and development tasks',
icon: 'code',
displayOrder: 1,
},
{
name: 'Data Analysis',
slug: 'data-analysis',
description: 'Agents for data analysis and visualization',
icon: 'chart',
displayOrder: 2,
},
{
name: 'Writing',
slug: 'writing',
description: 'Agents for writing and content creation',
icon: 'pen',
displayOrder: 3,
},
])
.returning();
console.log(`✅ Seeded ${testCategories.length} test categories`);
return testCategories;
}
/**
* Seed test tags
*/
export async function seedTestTags() {
const testTags = await db
.insert(tags)
.values([
{
name: 'Python',
slug: 'python',
usageCount: 5,
},
{
name: 'TypeScript',
slug: 'typescript',
usageCount: 8,
},
{
name: 'Machine Learning',
slug: 'machine-learning',
usageCount: 3,
},
])
.returning();
console.log(`✅ Seeded ${testTags.length} test tags`);
return testTags;
}
/**
* Seed test agents
*/
export async function seedTestAgents(testUsers: any[], testCategories: any[], testTags: any[]) {
const testAgents = await db
.insert(marketplaceAgents)
.values([
{
slug: 'python-expert',
name: 'Python Expert',
description: 'Expert Python programming developer agent',
longDescription:
'This agent helps with Python development tasks including debugging, code review, and best practices.',
authorId: testUsers[0].id,
model: 'deepseek-reasoner',
systemPrompt:
'You are an expert Python developer with deep knowledge of Python best practices.',
toolsConfig: { bash: true, read: true, write: true },
rules: 'Always write clean, readable code following PEP 8',
latestVersion: '1.0.0',
installCount: 50,
rating: 4,
ratingCount: 10,
isFeatured: true,
isPublished: true,
publishedAt: new Date(),
},
{
slug: 'typescript-helper',
name: 'TypeScript Helper',
description: 'TypeScript development assistant',
longDescription:
'Helps with TypeScript projects, type definitions, and modern JavaScript development.',
authorId: testUsers[1].id,
model: 'deepseek-reasoner',
systemPrompt: 'You are a TypeScript expert helping developers write type-safe code.',
toolsConfig: { bash: true, read: true },
latestVersion: '2.1.0',
installCount: 120,
rating: 5,
ratingCount: 25,
isFeatured: true,
isPublished: true,
publishedAt: new Date(),
},
{
slug: 'data-analyzer',
name: 'Data Analyzer',
description: 'Analyze data with AI',
longDescription: 'Advanced data analysis agent for statistical analysis and insights.',
authorId: testUsers[0].id,
model: 'deepseek-reasoner',
systemPrompt: 'You are a data analysis expert specializing in statistical analysis.',
toolsConfig: { bash: true },
latestVersion: '1.5.0',
installCount: 40,
rating: 4,
ratingCount: 8,
isFeatured: false,
isPublished: true,
publishedAt: new Date(),
},
{
slug: 'draft-agent',
name: 'Draft Agent',
description: 'This is an unpublished draft agent',
authorId: testUsers[1].id,
model: 'deepseek-reasoner',
systemPrompt: 'Draft agent for testing purposes',
toolsConfig: {},
latestVersion: '0.1.0',
installCount: 0,
isFeatured: false,
isPublished: false, // Not published - should not appear in marketplace
},
])
.returning();
console.log(`✅ Seeded ${testAgents.length} test agents`);
// Add agent-category relationships
await db.insert(agentCategories).values([
{ agentId: testAgents[0].id, categoryId: testCategories[0].id }, // Python Expert -> Coding
{ agentId: testAgents[1].id, categoryId: testCategories[0].id }, // TypeScript Helper -> Coding
{ agentId: testAgents[2].id, categoryId: testCategories[1].id }, // Data Analyzer -> Data Analysis
]);
console.log('✅ Seeded agent-category relationships');
// Add agent-tag relationships
await db.insert(agentTags).values([
{ agentId: testAgents[0].id, tagId: testTags[0].id }, // Python Expert -> Python
{ agentId: testAgents[1].id, tagId: testTags[1].id }, // TypeScript Helper -> TypeScript
{ agentId: testAgents[2].id, tagId: testTags[2].id }, // Data Analyzer -> ML
]);
console.log('✅ Seeded agent-tag relationships');
// Add versions
await db.insert(agentVersions).values([
{
agentId: testAgents[0].id,
version: '1.0.0',
systemPrompt: 'You are an expert Python developer.',
toolsConfig: { bash: true, read: true, write: true },
changeLog: 'Initial release',
},
{
agentId: testAgents[1].id,
version: '2.1.0',
systemPrompt: 'You are a TypeScript expert.',
toolsConfig: { bash: true, read: true },
changeLog: 'Added new features and improvements',
},
{
agentId: testAgents[1].id,
version: '2.0.0',
systemPrompt: 'You are a TypeScript expert.',
toolsConfig: { bash: true },
changeLog: 'Major update with breaking changes',
},
]);
console.log('✅ Seeded agent versions');
return testAgents;
}
/**
* Seed test skills
*/
export async function seedTestSkills(testUsers: any[], testCategories: any[], testTags: any[]) {
const testSkills = await db
.insert(marketplaceSkills)
.values([
{
slug: 'clickhouse-expert',
name: 'ClickHouse Expert',
description: 'Domain knowledge for ClickHouse database development',
longDescription:
'Comprehensive knowledge about ClickHouse database, including SQL syntax, table engines, and optimization techniques.',
authorId: testUsers[0].id,
systemPromptFragment:
'You are an expert in ClickHouse database. You understand columnar storage, distributed queries, and performance optimization.',
workflowRules:
'Always consider data compression, partition keys, and query optimization when working with ClickHouse.',
documentation: JSON.stringify([
{
type: 'url',
title: 'ClickHouse Official Documentation',
url: 'https://clickhouse.com/docs',
},
]),
latestVersion: '1.0.0',
installCount: 75,
rating: 5,
ratingCount: 15,
isFeatured: true,
isPublished: true,
publishedAt: new Date(),
},
{
slug: 'starrocks-expert',
name: 'StarRocks Expert',
description: 'Domain knowledge for StarRocks database development',
longDescription:
'Expert knowledge about StarRocks MPP database, including query optimization and data modeling.',
authorId: testUsers[1].id,
systemPromptFragment:
'You are an expert in StarRocks database. You understand MPP architecture, materialized views, and SQL optimization.',
workflowRules:
'Focus on query performance, proper indexing, and efficient data loading strategies.',
documentation: JSON.stringify([
{
type: 'url',
title: 'StarRocks Documentation',
url: 'https://docs.starrocks.io',
},
]),
latestVersion: '2.0.0',
installCount: 60,
rating: 4,
ratingCount: 12,
isFeatured: true,
isPublished: true,
publishedAt: new Date(),
},
{
slug: 'duckdb-expert',
name: 'DuckDB Expert',
description: 'Domain knowledge for DuckDB in-process database',
longDescription: 'Specialized knowledge for DuckDB, the in-process analytical database.',
authorId: testUsers[0].id,
systemPromptFragment:
'You are an expert in DuckDB. You understand its in-process architecture, Parquet integration, and analytical query optimization.',
workflowRules:
"Leverage DuckDB's columnar storage and efficient query execution for analytical workloads.",
documentation: JSON.stringify([]),
latestVersion: '1.5.0',
installCount: 45,
rating: 5,
ratingCount: 9,
isFeatured: false,
isPublished: true,
publishedAt: new Date(),
},
{
slug: 'draft-skill',
name: 'Draft Skill',
description: 'This is an unpublished draft skill',
authorId: testUsers[1].id,
systemPromptFragment: 'Draft skill for testing purposes',
documentation: JSON.stringify([]),
latestVersion: '0.1.0',
installCount: 0,
isFeatured: false,
isPublished: false, // Not published - should not appear in marketplace
},
])
.returning();
console.log(`✅ Seeded ${testSkills.length} test skills`);
// Add skill-category relationships
await db.insert(skillCategories).values([
{ skillId: testSkills[0].id, categoryId: testCategories[1].id }, // ClickHouse -> Data Analysis
{ skillId: testSkills[1].id, categoryId: testCategories[1].id }, // StarRocks -> Data Analysis
{ skillId: testSkills[2].id, categoryId: testCategories[1].id }, // DuckDB -> Data Analysis
]);
console.log('✅ Seeded skill-category relationships');
// Add skill-tag relationships
await db.insert(skillTags).values([
{ skillId: testSkills[0].id, tagId: testTags[0].id }, // ClickHouse -> Python (for testing)
{ skillId: testSkills[1].id, tagId: testTags[1].id }, // StarRocks -> TypeScript (for testing)
{ skillId: testSkills[2].id, tagId: testTags[2].id }, // DuckDB -> ML (for testing)
]);
console.log('✅ Seeded skill-tag relationships');
// Add skill versions
await db.insert(skillVersions).values([
{
skillId: testSkills[0].id,
version: '1.0.0',
systemPromptFragment: 'You are an expert in ClickHouse database.',
workflowRules: 'Always consider query optimization.',
documentation: JSON.stringify([
{
type: 'url',
title: 'ClickHouse Documentation',
url: 'https://clickhouse.com/docs',
},
]),
changeLog: 'Initial release',
},
{
skillId: testSkills[1].id,
version: '2.0.0',
systemPromptFragment: 'You are an expert in StarRocks database.',
workflowRules: 'Focus on query performance.',
documentation: JSON.stringify([]),
changeLog: 'Major update with new features',
},
{
skillId: testSkills[1].id,
version: '1.0.0',
systemPromptFragment: 'You are knowledgeable about StarRocks.',
documentation: JSON.stringify([]),
changeLog: 'Initial release',
},
]);
console.log('✅ Seeded skill versions');
return testSkills;
}
/**
* Populate FTS5 tables manually
* This ensures FTS5 search works even if triggers don't fire during batch inserts
*/
export async function populateFts5Tables() {
console.log('🔍 Populating FTS5 search tables...');
try {
// Populate agents FTS5 table
await testClient.execute(`
INSERT INTO marketplace_agents_fts(id, name, description, long_description)
SELECT id, name, description, COALESCE(long_description, '')
FROM marketplace_agents
WHERE is_published = 1
`);
console.log(` ✅ Populated marketplace_agents_fts`);
// Populate skills FTS5 table
await testClient.execute(`
INSERT INTO marketplace_skills_fts(id, name, description, long_description)
SELECT id, name, description, COALESCE(long_description, '')
FROM marketplace_skills
WHERE is_published = 1
`);
console.log(` ✅ Populated marketplace_skills_fts`);
} catch (error: any) {
console.error('❌ Failed to populate FTS5 tables:', error.message);
throw error;
}
}
/**
* Seed complete test database
*/
export async function seedTestDatabase() {
console.log('🌱 Seeding test database...');
await clearDatabase();
const testUsers = await seedTestUsers();
const testCategories = await seedTestCategories();
const testTags = await seedTestTags();
const testAgents = await seedTestAgents(testUsers, testCategories, testTags);
const testSkills = await seedTestSkills(testUsers, testCategories, testTags);
// Manually populate FTS5 tables to ensure search works
await populateFts5Tables();
console.log('🎉 Test database seeding complete!');
return {
users: testUsers,
categories: testCategories,
tags: testTags,
agents: testAgents,
skills: testSkills,
};
}

78
src/test/init-fts5.sql Normal file
View File

@@ -0,0 +1,78 @@
-- ============================================
-- FTS5 Tables and Triggers
-- ============================================
-- Create FTS5 virtual tables
CREATE VIRTUAL TABLE IF NOT EXISTS marketplace_agents_fts USING fts5(
id UNINDEXED,
name,
description,
long_description,
tokenize='porter unicode61 remove_diacritics 1'
);
CREATE VIRTUAL TABLE IF NOT EXISTS marketplace_skills_fts USING fts5(
id UNINDEXED,
name,
description,
long_description,
tokenize='porter unicode61 remove_diacritics 1'
);
-- Agents INSERT trigger
CREATE TRIGGER IF NOT EXISTS marketplace_agents_fts_insert
AFTER INSERT ON marketplace_agents
WHEN new.is_published = 1
BEGIN
INSERT INTO marketplace_agents_fts(id, name, description, long_description)
VALUES (new.id, new.name, new.description, COALESCE(new.long_description, ''));
END;
-- Agents UPDATE trigger
CREATE TRIGGER IF NOT EXISTS marketplace_agents_fts_update
AFTER UPDATE OF name, description, long_description, is_published ON marketplace_agents
BEGIN
DELETE FROM marketplace_agents_fts WHERE id = old.id AND new.is_published = 0;
UPDATE marketplace_agents_fts
SET name = new.name, description = new.description, long_description = COALESCE(new.long_description, '')
WHERE id = new.id AND new.is_published = 1;
INSERT INTO marketplace_agents_fts(id, name, description, long_description)
SELECT new.id, new.name, new.description, COALESCE(new.long_description, '')
WHERE new.is_published = 1 AND old.is_published = 0;
END;
-- Agents DELETE trigger
CREATE TRIGGER IF NOT EXISTS marketplace_agents_fts_delete
AFTER DELETE ON marketplace_agents
BEGIN
DELETE FROM marketplace_agents_fts WHERE id = old.id;
END;
-- Skills INSERT trigger
CREATE TRIGGER IF NOT EXISTS marketplace_skills_fts_insert
AFTER INSERT ON marketplace_skills
WHEN new.is_published = 1
BEGIN
INSERT INTO marketplace_skills_fts(id, name, description, long_description)
VALUES (new.id, new.name, new.description, COALESCE(new.long_description, ''));
END;
-- Skills UPDATE trigger
CREATE TRIGGER IF NOT EXISTS marketplace_skills_fts_update
AFTER UPDATE OF name, description, long_description, is_published ON marketplace_skills
BEGIN
DELETE FROM marketplace_skills_fts WHERE id = old.id AND new.is_published = 0;
UPDATE marketplace_skills_fts
SET name = new.name, description = new.description, long_description = COALESCE(new.long_description, '')
WHERE id = new.id AND new.is_published = 1;
INSERT INTO marketplace_skills_fts(id, name, description, long_description)
SELECT new.id, new.name, new.description, COALESCE(new.long_description, '')
WHERE new.is_published = 1 AND old.is_published = 0;
END;
-- Skills DELETE trigger
CREATE TRIGGER IF NOT EXISTS marketplace_skills_fts_delete
AFTER DELETE ON marketplace_skills
BEGIN
DELETE FROM marketplace_skills_fts WHERE id = old.id;
END;

224
src/test/init-schema.ts Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env bun
// Initialize test database schema
// This script creates all tables including FTS5 tables
import { createClient } from '@libsql/client';
const testDatabaseUrl = process.env.TURSO_DATABASE_URL_TEST || Bun.env?.TURSO_DATABASE_URL_TEST;
const testAuthToken = process.env.TURSO_AUTH_TOKEN_TEST || Bun.env?.TURSO_AUTH_TOKEN_TEST;
if (!testDatabaseUrl || !testAuthToken) {
console.error('❌ ERROR: TURSO_DATABASE_URL_TEST and TURSO_AUTH_TOKEN_TEST are required');
process.exit(1);
}
console.log('🚀 Initializing test database schema...');
console.log(`📊 Database: ${testDatabaseUrl.split('@')[1]?.split('/')[1] || 'test'}`);
const client = createClient({
url: testDatabaseUrl,
authToken: testAuthToken,
});
try {
// Drop all existing schema objects
console.log('🗑️ Cleaning database...');
// Drop FTS5 tables first (they may have dependencies)
try {
await client.execute('DROP TABLE IF EXISTS marketplace_agents_fts');
await client.execute('DROP TABLE IF EXISTS marketplace_skills_fts');
} catch (_e) {
// Ignore
}
// Get all schema objects
const allObjects = await client.execute(`
SELECT type, name FROM sqlite_master
WHERE name NOT LIKE 'sqlite_%'
ORDER BY
CASE type
WHEN 'trigger' THEN 1
WHEN 'index' THEN 2
WHEN 'table' THEN 3
ELSE 4
END
`);
// Drop all objects
for (const row of allObjects.rows) {
const objType = (row as unknown as { type: string; name: string }).type;
const objName = (row as unknown as { type: string; name: string }).name;
try {
if (objType === 'table') {
await client.execute(`DROP TABLE IF EXISTS "${objName}"`);
} else if (objType === 'index') {
await client.execute(`DROP INDEX IF EXISTS "${objName}"`);
} else if (objType === 'trigger') {
await client.execute(`DROP TRIGGER IF EXISTS "${objName}"`);
}
} catch (_e) {
// Ignore errors
}
}
console.log('✅ Database cleaned');
// Read the base schema migration
const baseSchemaSql = await Bun.file('./src/db/migrations/0000_wakeful_tana_nile.sql').text();
// Read the simplified FTS5 schema (just tables and triggers, no initial data)
const fts5Sql = await Bun.file('./src/test/init-fts5.sql').text();
// Execute base schema
console.log('📝 Creating base tables...');
const baseStatements = baseSchemaSql
.split('--> statement-breakpoint')
.map((s) => s.trim())
.filter((s) => s.length > 0 && !s.startsWith('--'));
for (const statement of baseStatements) {
try {
await client.execute(statement);
} catch (error: unknown) {
// Skip if already exists
if (error instanceof Error && error.message?.includes('already exists')) {
continue;
}
throw error;
}
}
console.log('✅ Base tables created');
// Add missing columns to tables
console.log('📝 Adding missing columns...');
const alterStatements = [
// users table
{ table: 'users', column: 'display_name', type: 'TEXT' },
// marketplace_skills table
{ table: 'marketplace_skills', column: 'storage_url', type: 'TEXT' },
{ table: 'marketplace_skills', column: 'package_size', type: 'INTEGER' },
{ table: 'marketplace_skills', column: 'checksum', type: 'TEXT' },
{
table: 'marketplace_skills',
column: 'required_permission',
type: "TEXT DEFAULT 'read-only'",
},
{ table: 'marketplace_skills', column: 'has_scripts', type: 'INTEGER DEFAULT 0 NOT NULL' },
// Agent Skills Specification fields
{ table: 'marketplace_skills', column: 'compatibility', type: 'TEXT' },
{ table: 'marketplace_skills', column: 'metadata', type: 'TEXT' },
// skill_versions table
{ table: 'skill_versions', column: 'storage_url', type: 'TEXT' },
{ table: 'skill_versions', column: 'package_size', type: 'INTEGER' },
{ table: 'skill_versions', column: 'checksum', type: 'TEXT' },
// Agent Skills Specification fields for versions
{ table: 'skill_versions', column: 'compatibility', type: 'TEXT' },
{ table: 'skill_versions', column: 'metadata', type: 'TEXT' },
];
for (const { table, column, type } of alterStatements) {
try {
await client.execute(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
console.log(`${table}.${column} added`);
} catch (error: unknown) {
if (error instanceof Error && error.message?.includes('duplicate column name')) {
console.log(` ${table}.${column} already exists`);
} else {
throw error;
}
}
}
console.log('✅ Missing columns added');
// Execute FTS5 schema
console.log('📝 Creating FTS5 tables and triggers...');
// Parse SQL: split on END; for triggers, regular ; for everything else
const statements = [];
let current = '';
let inTrigger = false;
for (const line of fts5Sql.split('\n')) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('--')) continue;
// Track if we're in a CREATE TRIGGER block
if (trimmed.toUpperCase().startsWith('CREATE TRIGGER')) {
inTrigger = true;
}
current += `${line}\n`;
// End of statement detection
if (trimmed.endsWith(';')) {
// If in trigger, only end when we see END;
if (inTrigger) {
if (trimmed.toUpperCase() === 'END;') {
statements.push(current.trim());
current = '';
inTrigger = false;
}
} else {
statements.push(current.trim());
current = '';
}
}
}
// Execute each statement
console.log(` Found ${statements.length} FTS5 statements`);
for (const stmt of statements) {
if (!stmt) continue;
try {
await client.execute(stmt);
} catch (e: unknown) {
if (e instanceof Error && e.message?.includes('already exists')) {
continue;
}
console.error(`Failed to execute:`, stmt.substring(0, 100));
throw e;
}
}
console.log('✅ FTS5 tables and triggers created');
// Verify tables exist
const result = await client.execute(`
SELECT name, type FROM sqlite_master
WHERE type IN ('table', 'view')
ORDER BY name
`);
console.log(`\n✅ Schema initialized successfully!`);
console.log(`📊 Total objects created: ${result.rows.length}`);
console.log('\nTables:');
for (const row of result.rows) {
console.log(
` - ${(row as unknown as { name: string; type: string }).name} (${(row as unknown as { name: string; type: string }).type})`
);
}
// Verify FTS5 tables specifically
console.log('\n🔍 Verifying FTS5 tables...');
try {
const agentsFts = await client.execute('SELECT COUNT(*) as count FROM marketplace_agents_fts');
console.log(` ✅ marketplace_agents_fts exists (${agentsFts.rows[0].count} rows)`);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
console.log(` ❌ marketplace_agents_fts: ${message}`);
}
try {
const skillsFts = await client.execute('SELECT COUNT(*) as count FROM marketplace_skills_fts');
console.log(` ✅ marketplace_skills_fts exists (${skillsFts.rows[0].count} rows)`);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
console.log(` ❌ marketplace_skills_fts: ${message}`);
}
} catch (error) {
console.error('❌ Failed to initialize schema:', error);
process.exit(1);
}

434
src/test/init-test-db.ts Normal file
View File

@@ -0,0 +1,434 @@
// Initialize test database schema
// This script creates all tables in the test database
// NOTE: Uses DATABASE_URL_TEST via environment variable override
import { sql } from 'drizzle-orm';
import { testDb } from './db-client';
async function initTestDatabase() {
try {
console.log('🔄 Initializing test database schema...');
// Create users table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL,
"avatar_url" text,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"bio" text,
"website" text,
"github_id" varchar(255),
"google_id" varchar(255),
"is_verified" boolean DEFAULT false NOT NULL,
"last_login_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
)
`);
console.log('✅ Created users table');
// Create categories table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(100) NOT NULL,
"slug" varchar(100) NOT NULL,
"description" text,
"icon" varchar(50),
"display_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "categories_name_unique" UNIQUE("name"),
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
)
`);
console.log('✅ Created categories table');
// Create tags table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(50) NOT NULL,
"slug" varchar(50) NOT NULL,
"usage_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "tags_name_unique" UNIQUE("name"),
CONSTRAINT "tags_slug_unique" UNIQUE("slug")
)
`);
console.log('✅ Created tags table');
// Create marketplace_agents table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "marketplace_agents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(100) NOT NULL,
"name" varchar(255) NOT NULL,
"description" text NOT NULL,
"long_description" text,
"author_id" uuid NOT NULL,
"model" varchar(100) NOT NULL,
"system_prompt" text NOT NULL,
"tools_config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"rules" text,
"output_format" text,
"dynamic_prompt_config" jsonb,
"icon_url" text,
"banner_url" text,
"download_count" integer DEFAULT 0 NOT NULL,
"install_count" integer DEFAULT 0 NOT NULL,
"usage_count" integer DEFAULT 0 NOT NULL,
"rating" integer DEFAULT 0 NOT NULL,
"rating_count" integer DEFAULT 0 NOT NULL,
"is_featured" boolean DEFAULT false NOT NULL,
"is_published" boolean DEFAULT false NOT NULL,
"published_at" timestamp,
"latest_version" varchar(50) NOT NULL,
"search_vector" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "marketplace_agents_slug_unique" UNIQUE("slug")
)
`);
console.log('✅ Created marketplace_agents table');
// Create agent_versions table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "agent_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent_id" uuid NOT NULL,
"version" varchar(50) NOT NULL,
"system_prompt" text NOT NULL,
"tools_config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"rules" text,
"output_format" text,
"dynamic_prompt_config" jsonb,
"change_log" text,
"is_prerelease" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "versions_unique" UNIQUE("agent_id", "version")
)
`);
console.log('✅ Created agent_versions table');
// Create agent_categories junction table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "agent_categories" (
"agent_id" uuid NOT NULL,
"category_id" uuid NOT NULL,
CONSTRAINT "agent_categories_pk" UNIQUE("agent_id", "category_id")
)
`);
console.log('✅ Created agent_categories table');
// Create agent_tags junction table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "agent_tags" (
"agent_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
CONSTRAINT "agent_tags_pk" UNIQUE("agent_id", "tag_id")
)
`);
console.log('✅ Created agent_tags table');
// Create collections table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "collections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"slug" varchar(100) NOT NULL,
"description" text,
"icon" varchar(50),
"is_featured" boolean DEFAULT false NOT NULL,
"display_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "collections_slug_unique" UNIQUE("slug")
)
`);
console.log('✅ Created collections table');
// Create collection_agents junction table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "collection_agents" (
"collection_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"display_order" integer DEFAULT 0 NOT NULL,
CONSTRAINT "collection_agents_pk" UNIQUE("collection_id", "agent_id")
)
`);
console.log('✅ Created collection_agents table');
// Create agent_stats table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "agent_stats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"agent_id" uuid NOT NULL,
"version" varchar(50),
"event_type" varchar(20) NOT NULL,
"user_id" uuid,
"device_id" varchar(255),
"created_at" timestamp DEFAULT now() NOT NULL
)
`);
console.log('✅ Created agent_stats table');
// Create marketplace_skills table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "marketplace_skills" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(100) NOT NULL,
"name" varchar(255) NOT NULL,
"description" text NOT NULL,
"long_description" text,
"author_id" uuid NOT NULL,
"system_prompt_fragment" text,
"workflow_rules" text,
"documentation" jsonb DEFAULT '[]'::jsonb NOT NULL,
"icon_url" text,
"banner_url" text,
"download_count" integer DEFAULT 0 NOT NULL,
"install_count" integer DEFAULT 0 NOT NULL,
"usage_count" integer DEFAULT 0 NOT NULL,
"rating" integer DEFAULT 0 NOT NULL,
"rating_count" integer DEFAULT 0 NOT NULL,
"is_featured" boolean DEFAULT false NOT NULL,
"is_published" boolean DEFAULT false NOT NULL,
"published_at" timestamp,
"latest_version" varchar(50) NOT NULL,
"search_vector" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "marketplace_skills_slug_unique" UNIQUE("slug")
)
`);
console.log('✅ Created marketplace_skills table');
// Create skill_versions table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "skill_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"skill_id" uuid NOT NULL,
"version" varchar(50) NOT NULL,
"system_prompt_fragment" text,
"workflow_rules" text,
"documentation" jsonb DEFAULT '[]'::jsonb NOT NULL,
"change_log" text,
"is_prerelease" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "skill_versions_unique" UNIQUE("skill_id", "version")
)
`);
console.log('✅ Created skill_versions table');
// Create skill_categories junction table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "skill_categories" (
"skill_id" uuid NOT NULL,
"category_id" uuid NOT NULL,
CONSTRAINT "skill_categories_pk" UNIQUE("skill_id", "category_id")
)
`);
console.log('✅ Created skill_categories table');
// Create skill_tags junction table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "skill_tags" (
"skill_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
CONSTRAINT "skill_tags_pk" UNIQUE("skill_id", "tag_id")
)
`);
console.log('✅ Created skill_tags table');
// Create skill_stats table
await testDb.run(sql`
CREATE TABLE IF NOT EXISTS "skill_stats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"skill_id" uuid NOT NULL,
"version" varchar(50),
"event_type" varchar(20) NOT NULL,
"user_id" uuid,
"device_id" varchar(255),
"created_at" timestamp DEFAULT now() NOT NULL
)
`);
console.log('✅ Created skill_stats table');
// Add foreign keys
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "marketplace_agents" ADD CONSTRAINT "marketplace_agents_author_id_users_id_fk"
FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "agent_versions" ADD CONSTRAINT "agent_versions_agent_id_marketplace_agents_id_fk"
FOREIGN KEY ("agent_id") REFERENCES "marketplace_agents"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "agent_categories" ADD CONSTRAINT "agent_categories_agent_id_marketplace_agents_id_fk"
FOREIGN KEY ("agent_id") REFERENCES "marketplace_agents"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "agent_categories" ADD CONSTRAINT "agent_categories_category_id_categories_id_fk"
FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "agent_tags" ADD CONSTRAINT "agent_tags_agent_id_marketplace_agents_id_fk"
FOREIGN KEY ("agent_id") REFERENCES "marketplace_agents"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "agent_tags" ADD CONSTRAINT "agent_tags_tag_id_tags_id_fk"
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "agent_stats" ADD CONSTRAINT "agent_stats_agent_id_marketplace_agents_id_fk"
FOREIGN KEY ("agent_id") REFERENCES "marketplace_agents"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "agent_stats" ADD CONSTRAINT "agent_stats_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
// Skills foreign keys
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "marketplace_skills" ADD CONSTRAINT "marketplace_skills_author_id_users_id_fk"
FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "skill_versions" ADD CONSTRAINT "skill_versions_skill_id_marketplace_skills_id_fk"
FOREIGN KEY ("skill_id") REFERENCES "marketplace_skills"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "skill_categories" ADD CONSTRAINT "skill_categories_skill_id_marketplace_skills_id_fk"
FOREIGN KEY ("skill_id") REFERENCES "marketplace_skills"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "skill_categories" ADD CONSTRAINT "skill_categories_category_id_categories_id_fk"
FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "skill_tags" ADD CONSTRAINT "skill_tags_skill_id_marketplace_skills_id_fk"
FOREIGN KEY ("skill_id") REFERENCES "marketplace_skills"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "skill_tags" ADD CONSTRAINT "skill_tags_tag_id_tags_id_fk"
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "skill_stats" ADD CONSTRAINT "skill_stats_skill_id_marketplace_skills_id_fk"
FOREIGN KEY ("skill_id") REFERENCES "marketplace_skills"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
await testDb.run(sql`
DO $$ BEGIN
ALTER TABLE "skill_stats" ADD CONSTRAINT "skill_stats_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`);
console.log('✅ Added foreign key constraints');
// Create indexes
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "users_email_idx" ON "users" ("email")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "users_github_idx" ON "users" ("github_id")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "users_google_idx" ON "users" ("google_id")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "categories_slug_idx" ON "categories" ("slug")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "tags_slug_idx" ON "tags" ("slug")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "agents_slug_idx" ON "marketplace_agents" ("slug")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "agents_author_idx" ON "marketplace_agents" ("author_id")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "agents_featured_idx" ON "marketplace_agents" ("is_featured")
`);
await testDb.run(sql`
CREATE INDEX IF NOT EXISTS "agents_published_idx" ON "marketplace_agents" ("is_published")
`);
console.log('✅ Created indexes');
console.log('🎉 Test database initialization complete!');
} catch (error) {
console.error('❌ Failed to initialize test database:', error);
process.exit(1);
}
}
// Run if called directly
if (import.meta.main) {
initTestDatabase();
}
export { initTestDatabase };

View File

@@ -0,0 +1,68 @@
// Remote Agents API endpoint tests
import { describe, expect, it } from 'bun:test';
import { app } from '../index';
describe('Remote Agents API - Configs', () => {
it('should return remote agent configs', async () => {
const res = await app.request('/api/remote-agents/configs');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.remoteAgents).toBeDefined();
expect(Array.isArray(data.remoteAgents)).toBe(true);
if (data.remoteAgents.length > 0) {
const agent = data.remoteAgents[0];
expect(agent).toHaveProperty('id');
expect(agent).toHaveProperty('name');
expect(agent).toHaveProperty('description');
expect(agent).toHaveProperty('category');
expect(agent).toHaveProperty('repository');
expect(agent).toHaveProperty('githubPath');
}
});
});
describe('Remote Agents API - Version', () => {
it('should return version', async () => {
const res = await app.request('/api/remote-agents/version');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('version');
});
});
describe('Remote Agents API - Get Agent by Id', () => {
it('should return agent by id', async () => {
const listRes = await app.request('/api/remote-agents/configs');
const listData = await listRes.json();
const agent = listData.remoteAgents[0];
const res = await app.request(`/api/remote-agents/${agent.id}`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.id).toBe(agent.id);
});
it('should return 404 for unknown agent', async () => {
const res = await app.request('/api/remote-agents/unknown-agent-id');
expect(res.status).toBe(404);
});
});
describe('Remote Agents API - List IDs', () => {
it('should return ids list', async () => {
const res = await app.request('/api/remote-agents');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('count');
expect(data).toHaveProperty('agents');
expect(Array.isArray(data.agents)).toBe(true);
});
});

113
src/test/r2-upload.test.ts Normal file
View File

@@ -0,0 +1,113 @@
// R2 avatar upload tests
import { describe, expect, it } from 'bun:test';
// Mock R2 bucket implementation for testing
class MockR2Bucket {
private storage = new Map<string, { data: ArrayBuffer; metadata: any }>();
async put(key: string, data: ArrayBuffer, options?: any) {
this.storage.set(key, { data, metadata: options });
}
async get(key: string) {
const item = this.storage.get(key);
if (!item) return null;
return {
key,
body: item.data,
httpMetadata: item.metadata?.httpMetadata,
};
}
async delete(key: string) {
this.storage.delete(key);
}
async list(options?: { prefix?: string }) {
const prefix = options?.prefix || '';
const objects = Array.from(this.storage.keys())
.filter((key) => key.startsWith(prefix))
.map((key) => ({ key }));
return { objects };
}
// Helper for testing
has(key: string) {
return this.storage.has(key);
}
clear() {
this.storage.clear();
}
}
describe('R2 Avatar Upload', () => {
it('should generate correct R2 key for avatar', () => {
const userId = 'test-user-123';
const ext = 'jpg';
const expectedKey = `users/${userId}/avatar.${ext}`;
expect(expectedKey).toBe('users/test-user-123/avatar.jpg');
});
it('should delete old avatar before uploading new one', async () => {
const bucket = new MockR2Bucket();
const userId = 'test-user';
// Upload first avatar
await bucket.put(`users/${userId}/avatar.jpg`, new ArrayBuffer(100));
expect(bucket.has(`users/${userId}/avatar.jpg`)).toBe(true);
// Simulate deletion of old avatar
const listed = await bucket.list({ prefix: `users/${userId}/` });
for (const obj of listed.objects) {
if (obj.key.includes('avatar')) {
await bucket.delete(obj.key);
}
}
expect(bucket.has(`users/${userId}/avatar.jpg`)).toBe(false);
});
it('should support multiple file extensions', () => {
const userId = 'test-user';
const extensions = ['jpg', 'png', 'gif', 'webp'];
for (const ext of extensions) {
const key = `users/${userId}/avatar.${ext}`;
expect(key).toContain(`avatar.${ext}`);
}
});
it('should generate correct CDN URL', () => {
const cdnBaseUrl = 'https://cdn.vibncode.com';
const userId = 'test-user';
const ext = 'png';
const key = `users/${userId}/avatar.${ext}`;
const cdnUrl = `${cdnBaseUrl}/${key}`;
expect(cdnUrl).toBe('https://cdn.vibncode.com/users/test-user/avatar.png');
});
it('should only delete files with "avatar" in the name', async () => {
const bucket = new MockR2Bucket();
const userId = 'test-user';
// Create multiple files
await bucket.put(`users/${userId}/avatar.jpg`, new ArrayBuffer(100));
await bucket.put(`users/${userId}/document.pdf`, new ArrayBuffer(100));
await bucket.put(`users/${userId}/profile.txt`, new ArrayBuffer(100));
// Delete only avatar files
const listed = await bucket.list({ prefix: `users/${userId}/` });
for (const obj of listed.objects) {
if (obj.key.includes('avatar')) {
await bucket.delete(obj.key);
}
}
expect(bucket.has(`users/${userId}/avatar.jpg`)).toBe(false);
expect(bucket.has(`users/${userId}/document.pdf`)).toBe(true);
expect(bucket.has(`users/${userId}/profile.txt`)).toBe(true);
});
});

112
src/test/run-tests.ts Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bun
// Test runner that sets DATABASE_URL to DATABASE_URL_TEST
// This ensures the API uses the test database during tests
// Get the test database URL and auth token
const testDatabaseUrl =
Bun.env.TURSO_DATABASE_URL_TEST ||
process.env.TURSO_DATABASE_URL_TEST ||
Bun.env.DATABASE_URL_TEST ||
process.env.DATABASE_URL_TEST;
const testAuthToken =
Bun.env.TURSO_AUTH_TOKEN_TEST ||
process.env.TURSO_AUTH_TOKEN_TEST ||
Bun.env.AUTH_TOKEN_TEST ||
process.env.AUTH_TOKEN_TEST;
const productionDatabaseUrl =
Bun.env.TURSO_DATABASE_URL ||
process.env.TURSO_DATABASE_URL ||
Bun.env.DATABASE_URL ||
process.env.DATABASE_URL;
if (!testDatabaseUrl) {
console.error('❌ ERROR: TURSO_DATABASE_URL_TEST environment variable is required');
console.error('Please set TURSO_DATABASE_URL_TEST in your .env file');
process.exit(1);
}
if (!testAuthToken) {
console.error('❌ ERROR: TURSO_AUTH_TOKEN_TEST environment variable is required');
console.error('Please set TURSO_AUTH_TOKEN_TEST in your .env file');
process.exit(1);
}
// SAFETY CHECK 1: Verify test and production URLs are different
if (productionDatabaseUrl && testDatabaseUrl === productionDatabaseUrl) {
console.error('❌ SAFETY CHECK FAILED: Production and test database URLs are identical!');
console.error('Production and test databases must be different.');
console.error('Please check your .env file configuration.');
process.exit(1);
}
// SAFETY CHECK 2: Verify test database URL contains 'test' keyword
const testDbName = testDatabaseUrl.split('/').pop()?.split('?')[0] || '';
const testHostname = testDatabaseUrl.split('@')[1]?.split('/')[0] || '';
if (!testDbName.toLowerCase().includes('test') && !testHostname.toLowerCase().includes('test')) {
console.warn('⚠️ WARNING: Test database URL does not contain "test" keyword');
console.warn(`Database: ${testDbName}`);
console.warn(`Hostname: ${testHostname}`);
console.warn('Consider renaming your test database to include "test" for safety');
}
// Override TURSO_DATABASE_URL with TURSO_DATABASE_URL_TEST for this test run
Bun.env.TURSO_DATABASE_URL = testDatabaseUrl;
process.env.TURSO_DATABASE_URL = testDatabaseUrl;
Bun.env.TURSO_AUTH_TOKEN = testAuthToken;
process.env.TURSO_AUTH_TOKEN = testAuthToken;
// Also set legacy DATABASE_URL for backward compatibility
Bun.env.DATABASE_URL = testDatabaseUrl;
process.env.DATABASE_URL = testDatabaseUrl;
// Set TEST_MODE flag to indicate we're running tests
Bun.env.TEST_MODE = 'true';
process.env.TEST_MODE = 'true';
console.log('🔧 Test environment configured');
console.log(`📊 Using test database: ${testDatabaseUrl.split('@')[1]?.split('/')[1] || 'test'}`);
console.log('✅ Safety checks passed\n');
// Initialize database schema before running tests
console.log('📝 Initializing database schema...');
const initProcess = Bun.spawn(['bun', 'run', 'src/test/init-schema.ts'], {
stdio: ['inherit', 'inherit', 'inherit'],
env: {
...process.env,
TURSO_DATABASE_URL_TEST: testDatabaseUrl,
TURSO_AUTH_TOKEN_TEST: testAuthToken,
},
});
const initExitCode = await initProcess.exited;
if (initExitCode !== 0) {
console.error('❌ Failed to initialize database schema');
process.exit(initExitCode);
}
console.log('✅ Database schema initialized\n');
// Run the tests - run all test files matching the pattern
const testProcess = Bun.spawn(
[
'bun',
'run',
'test',
'src/test/marketplace.test.ts',
'src/test/skills-marketplace.test.ts',
'src/test/auth-service.test.ts',
],
{
stdio: ['inherit', 'inherit', 'inherit'],
env: {
...process.env,
TURSO_DATABASE_URL: testDatabaseUrl,
TURSO_AUTH_TOKEN: testAuthToken,
DATABASE_URL: testDatabaseUrl,
TEST_MODE: 'true',
},
}
);
const exitCode = await testProcess.exited;
process.exit(exitCode);

View File

@@ -0,0 +1,395 @@
// Search usage service tests
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import { eq, sql } from 'drizzle-orm';
import { db } from '../db/client';
import { analyticsEvents, searchUsage } from '../db/schema';
import { searchUsageService } from '../services/search-usage-service';
// Test device and user IDs
const TEST_DEVICE_ID = 'test-device-123';
const TEST_USER_ID = 'test-user-456';
// Clean up search usage data before and after tests
beforeAll(async () => {
console.log('\n🔧 Setting up search usage service test environment...\n');
// Create tables if they don't exist
try {
// Create analytics_events table
await db.run(sql`
CREATE TABLE IF NOT EXISTS analytics_events (
id TEXT PRIMARY KEY NOT NULL,
device_id TEXT(255) NOT NULL,
event_type TEXT(50) NOT NULL,
session_id TEXT(255) NOT NULL,
os_name TEXT(50),
os_version TEXT(50),
app_version TEXT(50),
country TEXT(10),
created_at INTEGER NOT NULL
)
`);
// Create search_usage table
await db.run(sql`
CREATE TABLE IF NOT EXISTS search_usage (
id TEXT PRIMARY KEY NOT NULL,
device_id TEXT(255) NOT NULL,
user_id TEXT(255),
search_count INTEGER DEFAULT 1 NOT NULL,
usage_date TEXT(10) NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS search_usage_device_date_idx
ON search_usage (device_id, usage_date)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS search_usage_user_date_idx
ON search_usage (user_id, usage_date)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS search_usage_date_idx
ON search_usage (usage_date)
`);
} catch (error) {
// Tables may already exist, continue
}
// Insert test analytics event to verify device
try {
await db.insert(analyticsEvents).values({
deviceId: TEST_DEVICE_ID,
eventType: 'session_start',
sessionId: 'test-session-123',
osName: 'macOS',
appVersion: '1.0.0',
});
} catch (error) {
// Event may already exist, continue
}
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
console.log('✅ Test environment ready\n');
});
afterAll(async () => {
console.log('\n🧹 Cleaning up search usage service test environment...\n');
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
await db.delete(analyticsEvents).where(eq(analyticsEvents.deviceId, TEST_DEVICE_ID));
console.log('✅ Cleanup complete\n');
});
// Clean up before each test
beforeEach(async () => {
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
});
describe('SearchUsageService', () => {
describe('verifyDeviceId', () => {
it('should return true for valid device ID', async () => {
const isValid = await searchUsageService.verifyDeviceId(TEST_DEVICE_ID);
expect(isValid).toBe(true);
});
it('should return false for invalid device ID', async () => {
const isValid = await searchUsageService.verifyDeviceId('invalid-device-id');
expect(isValid).toBe(false);
});
});
describe('checkSearchLimits', () => {
it('should reject invalid device ID for anonymous users', async () => {
const result = await searchUsageService.checkSearchLimits('invalid-device-id');
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Invalid device ID');
expect(result.remaining).toBe(0);
expect(result.used).toBe(0);
});
it('should allow anonymous user with no usage', async () => {
const result = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
expect(result.allowed).toBe(true);
expect(result.limit).toBe(100); // Anonymous limit
expect(result.used).toBe(0);
expect(result.remaining).toBe(100);
});
it('should allow authenticated user with no usage', async () => {
const result = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID, TEST_USER_ID);
expect(result.allowed).toBe(true);
expect(result.limit).toBe(1000); // Authenticated limit
expect(result.used).toBe(0);
expect(result.remaining).toBe(1000);
});
it('should track usage for anonymous user', async () => {
// Record 50 searches
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
searchCount: 50,
usageDate: today,
});
const result = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
expect(result.allowed).toBe(true);
expect(result.used).toBe(50);
expect(result.remaining).toBe(50);
});
it('should deny anonymous user when limit exceeded', async () => {
// Record 100 searches (at limit)
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
searchCount: 100,
usageDate: today,
});
const result = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Daily search limit exceeded');
expect(result.reason).toContain('Sign in for higher limits');
expect(result.used).toBe(100);
expect(result.remaining).toBe(0);
});
it('should track usage for authenticated user', async () => {
// Record 500 searches
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
userId: TEST_USER_ID,
searchCount: 500,
usageDate: today,
});
const result = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID, TEST_USER_ID);
expect(result.allowed).toBe(true);
expect(result.used).toBe(500);
expect(result.remaining).toBe(500);
});
it('should deny authenticated user when limit exceeded', async () => {
// Record 1000 searches (at limit)
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
userId: TEST_USER_ID,
searchCount: 1000,
usageDate: today,
});
const result = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID, TEST_USER_ID);
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Daily search limit exceeded');
expect(result.used).toBe(1000);
expect(result.remaining).toBe(0);
});
it('should not check old usage from previous days', async () => {
// Record usage from yesterday
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
searchCount: 100,
usageDate: yesterday,
});
const result = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
// Should start fresh today
expect(result.allowed).toBe(true);
expect(result.used).toBe(0);
expect(result.remaining).toBe(100);
});
});
describe('recordSearch', () => {
it('should create new record for first search', async () => {
await searchUsageService.recordSearch(TEST_DEVICE_ID);
const today = new Date().toISOString().split('T')[0];
const records = await db
.select()
.from(searchUsage)
.where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
expect(records.length).toBe(1);
expect(records[0].searchCount).toBe(1);
expect(records[0].usageDate).toBe(today);
expect(records[0].userId).toBeNull();
});
it('should create new record with user ID for authenticated user', async () => {
await searchUsageService.recordSearch(TEST_DEVICE_ID, TEST_USER_ID);
const records = await db
.select()
.from(searchUsage)
.where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
expect(records.length).toBe(1);
expect(records[0].searchCount).toBe(1);
expect(records[0].userId).toBe(TEST_USER_ID);
});
it('should increment count for subsequent searches', async () => {
// First search
await searchUsageService.recordSearch(TEST_DEVICE_ID);
// Second search
await searchUsageService.recordSearch(TEST_DEVICE_ID);
// Third search
await searchUsageService.recordSearch(TEST_DEVICE_ID);
const records = await db
.select()
.from(searchUsage)
.where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
expect(records.length).toBe(1);
expect(records[0].searchCount).toBe(3);
});
it('should update timestamp on increment', async () => {
// First search
await searchUsageService.recordSearch(TEST_DEVICE_ID);
const firstRecord = await db
.select()
.from(searchUsage)
.where(eq(searchUsage.deviceId, TEST_DEVICE_ID))
.limit(1);
const firstUpdatedAt = firstRecord[0].updatedAt;
// Wait a bit
await new Promise((resolve) => setTimeout(resolve, 10));
// Second search
await searchUsageService.recordSearch(TEST_DEVICE_ID);
const secondRecord = await db
.select()
.from(searchUsage)
.where(eq(searchUsage.deviceId, TEST_DEVICE_ID))
.limit(1);
expect(secondRecord[0].updatedAt).toBeGreaterThan(firstUpdatedAt);
});
});
describe('getSearchStats', () => {
it('should return stats for anonymous user with no usage', async () => {
const stats = await searchUsageService.getSearchStats(TEST_DEVICE_ID);
const today = new Date().toISOString().split('T')[0];
expect(stats.date).toBe(today);
expect(stats.used).toBe(0);
expect(stats.limit).toBe(100);
expect(stats.remaining).toBe(100);
expect(stats.isAuthenticated).toBe(false);
});
it('should return stats for authenticated user', async () => {
const stats = await searchUsageService.getSearchStats(TEST_DEVICE_ID, TEST_USER_ID);
expect(stats.limit).toBe(1000);
expect(stats.isAuthenticated).toBe(true);
});
it('should return correct usage stats after searches', async () => {
// Record 25 searches
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
searchCount: 25,
usageDate: today,
});
const stats = await searchUsageService.getSearchStats(TEST_DEVICE_ID);
expect(stats.used).toBe(25);
expect(stats.remaining).toBe(75);
});
});
describe('Integration: Full search flow', () => {
it('should handle complete search flow for anonymous user', async () => {
// Check initial limits
const initialCheck = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
expect(initialCheck.allowed).toBe(true);
expect(initialCheck.used).toBe(0);
// Record search
await searchUsageService.recordSearch(TEST_DEVICE_ID);
// Check updated limits
const afterSearch = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
expect(afterSearch.used).toBe(1);
expect(afterSearch.remaining).toBe(99);
// Get stats
const stats = await searchUsageService.getSearchStats(TEST_DEVICE_ID);
expect(stats.used).toBe(1);
expect(stats.remaining).toBe(99);
});
it('should handle complete search flow for authenticated user', async () => {
// Check initial limits
const initialCheck = await searchUsageService.checkSearchLimits(
TEST_DEVICE_ID,
TEST_USER_ID
);
expect(initialCheck.allowed).toBe(true);
expect(initialCheck.limit).toBe(1000);
// Record search
await searchUsageService.recordSearch(TEST_DEVICE_ID, TEST_USER_ID);
// Check updated limits
const afterSearch = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID, TEST_USER_ID);
expect(afterSearch.used).toBe(1);
expect(afterSearch.remaining).toBe(999);
});
it('should prevent search when limit reached', async () => {
// Simulate 100 searches
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
searchCount: 99,
usageDate: today,
});
// 100th search - should still be allowed
const beforeLimit = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
expect(beforeLimit.allowed).toBe(true);
expect(beforeLimit.remaining).toBe(1);
// Record the 100th search
await searchUsageService.recordSearch(TEST_DEVICE_ID);
// 101st search - should be denied
const atLimit = await searchUsageService.checkSearchLimits(TEST_DEVICE_ID);
expect(atLimit.allowed).toBe(false);
expect(atLimit.remaining).toBe(0);
});
});
});

567
src/test/search.test.ts Normal file
View File

@@ -0,0 +1,567 @@
// Search API endpoint tests
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import { eq, sql } from 'drizzle-orm';
import { app } from '../index';
import { db } from '../db/client';
import { analyticsEvents, searchUsage } from '../db/schema';
// Test device and user IDs
const TEST_DEVICE_ID = 'test-search-device-123';
const TEST_DEVICE_ID_2 = 'test-search-device-456';
// Mock Serper API response
const mockSerperResponse = {
organic: [
{
title: 'Test Result 1',
link: 'https://example.com/1',
snippet:
'This is test content for result 1. It contains relevant information about the search query.',
},
{
title: 'Test Result 2',
link: 'https://example.com/2',
snippet: 'This is test content for result 2. It also contains useful information.',
},
],
};
// Mock fetch for Serper API
const originalFetch = global.fetch;
let fetchMock: ReturnType<typeof mock>;
beforeAll(async () => {
console.log('\n🔧 Setting up search API test environment...\n');
// Set test SERPER_API_KEY
Bun.env.SERPER_API_KEY = 'test-serper-api-key';
// Create tables if they don't exist
try {
// Create analytics_events table
await db.run(sql`
CREATE TABLE IF NOT EXISTS analytics_events (
id TEXT PRIMARY KEY NOT NULL,
device_id TEXT(255) NOT NULL,
event_type TEXT(50) NOT NULL,
session_id TEXT(255) NOT NULL,
os_name TEXT(50),
os_version TEXT(50),
app_version TEXT(50),
country TEXT(10),
created_at INTEGER NOT NULL
)
`);
// Create search_usage table
await db.run(sql`
CREATE TABLE IF NOT EXISTS search_usage (
id TEXT PRIMARY KEY NOT NULL,
device_id TEXT(255) NOT NULL,
user_id TEXT(255),
search_count INTEGER DEFAULT 1 NOT NULL,
usage_date TEXT(10) NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS search_usage_device_date_idx
ON search_usage (device_id, usage_date)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS search_usage_user_date_idx
ON search_usage (user_id, usage_date)
`);
await db.run(sql`
CREATE INDEX IF NOT EXISTS search_usage_date_idx
ON search_usage (usage_date)
`);
} catch (error) {
// Tables may already exist, continue
}
// Insert test analytics events to verify devices
try {
await db.insert(analyticsEvents).values([
{
deviceId: TEST_DEVICE_ID,
eventType: 'session_start',
sessionId: 'test-session-123',
osName: 'macOS',
appVersion: '1.0.0',
},
{
deviceId: TEST_DEVICE_ID_2,
eventType: 'session_start',
sessionId: 'test-session-456',
osName: 'Windows',
appVersion: '1.0.0',
},
]);
} catch (error) {
// Events may already exist, continue
}
// Mock fetch to intercept Serper API calls
fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
// Mock Serper API
if (url.includes('google.serper.dev/search')) {
return Promise.resolve(
new Response(JSON.stringify(mockSerperResponse), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
// For other URLs, use original fetch
return originalFetch(input, init);
});
global.fetch = fetchMock;
// Clean up test data
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID_2));
await db.delete(analyticsEvents).where(eq(analyticsEvents.deviceId, TEST_DEVICE_ID));
await db.delete(analyticsEvents).where(eq(analyticsEvents.deviceId, TEST_DEVICE_ID_2));
console.log('✅ Test environment ready\n');
});
afterAll(async () => {
console.log('\n🧹 Cleaning up search API test environment...\n');
// Restore original fetch
global.fetch = originalFetch;
// Clean up test data
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID_2));
console.log('✅ Cleanup complete\n');
});
beforeEach(async () => {
// Clean up before each test
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID));
await db.delete(searchUsage).where(eq(searchUsage.deviceId, TEST_DEVICE_ID_2));
// Ensure analytics events exist for test devices
const existing = await db
.select()
.from(analyticsEvents)
.where(eq(analyticsEvents.deviceId, TEST_DEVICE_ID))
.limit(1);
if (existing.length === 0) {
await db.insert(analyticsEvents).values([
{
deviceId: TEST_DEVICE_ID,
eventType: 'session_start',
sessionId: 'test-session-123',
osName: 'macOS',
appVersion: '1.0.0',
},
{
deviceId: TEST_DEVICE_ID_2,
eventType: 'session_start',
sessionId: 'test-session-456',
osName: 'Windows',
appVersion: '1.0.0',
},
]);
}
// Reset mock call count
fetchMock.mockClear();
});
describe('Search API - POST /api/search', () => {
it('should reject invalid device ID', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': 'invalid-device-id-12345',
},
body: JSON.stringify({
query: 'test query',
}),
});
expect(res.status).toBe(429);
const data = await res.json();
expect(data.error).toContain('Invalid device ID');
});
it('should return search results for valid request', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({
query: 'test query',
}),
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.results).toBeDefined();
expect(Array.isArray(data.results)).toBe(true);
expect(data.results.length).toBeGreaterThan(0);
expect(data.usage).toBeDefined();
expect(data.usage.remaining).toBe(99); // 100 - 1 = 99
expect(data.usage.limit).toBe(100);
expect(data.usage.used).toBe(1);
// Check result structure
const result = data.results[0];
expect(result).toHaveProperty('title');
expect(result).toHaveProperty('url');
expect(result).toHaveProperty('content');
});
it('should require X-Device-ID header', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'test query',
}),
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('X-Device-ID');
});
it('should require valid JSON body', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: 'invalid json',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('Invalid JSON');
});
it('should require query parameter', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('query');
});
it('should support numResults parameter', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({
query: 'test query',
numResults: 5,
}),
});
expect(res.status).toBe(200);
});
it('should support type parameter', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({
query: 'test query',
type: 'fast',
}),
});
expect(res.status).toBe(200);
});
it('should limit numResults to 20', async () => {
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({
query: 'test query',
numResults: 100, // Should be capped at 20
}),
});
expect(res.status).toBe(200);
});
});
describe('Search API - Rate Limiting', () => {
it('should track search usage', async () => {
// First search
const res1 = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({ query: 'test 1' }),
});
const data1 = await res1.json();
expect(data1.usage.used).toBe(1);
expect(data1.usage.remaining).toBe(99);
// Second search
const res2 = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({ query: 'test 2' }),
});
const data2 = await res2.json();
expect(data2.usage.used).toBe(2);
expect(data2.usage.remaining).toBe(98);
});
it('should enforce rate limit for anonymous users', async () => {
// Simulate 100 searches already made
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
searchCount: 100,
usageDate: today,
});
// 101st search should be denied
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({ query: 'test' }),
});
expect(res.status).toBe(429);
const data = await res.json();
expect(data.error).toContain('limit exceeded');
expect(data.usage).toBeDefined();
expect(data.usage.remaining).toBe(0);
expect(data.usage.used).toBe(100);
});
it('should isolate usage by device ID', async () => {
// Device 1 makes 50 searches
const today = new Date().toISOString().split('T')[0];
await db.insert(searchUsage).values({
deviceId: TEST_DEVICE_ID,
searchCount: 50,
usageDate: today,
});
// Device 2 should have independent limit
const res = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID_2,
},
body: JSON.stringify({ query: 'test' }),
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.usage.used).toBe(1);
expect(data.usage.remaining).toBe(99);
});
it('should handle concurrent requests correctly', async () => {
// Make 5 concurrent requests
const requests = Array.from({ length: 5 }, (_, i) =>
app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({ query: `test ${i}` }),
})
);
const responses = await Promise.all(requests);
// All should succeed
for (const res of responses) {
expect(res.status).toBe(200);
}
// Final usage should be 5
const finalRes = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({ query: 'final test' }),
});
const finalData = await finalRes.json();
expect(finalData.usage.used).toBe(6);
});
});
describe('Search API - GET /api/search/usage', () => {
it('should return usage statistics', async () => {
const res = await app.request('/api/search/usage', {
method: 'GET',
headers: {
'X-Device-ID': TEST_DEVICE_ID,
},
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.date).toBeDefined();
expect(data.used).toBe(0);
expect(data.limit).toBe(100);
expect(data.remaining).toBe(100);
expect(data.isAuthenticated).toBe(false);
});
it('should require X-Device-ID header', async () => {
const res = await app.request('/api/search/usage', {
method: 'GET',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('X-Device-ID');
});
it('should show updated usage after searches', async () => {
// Make 3 searches
for (let i = 0; i < 3; i++) {
await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({ query: `test ${i}` }),
});
}
// Check usage
const res = await app.request('/api/search/usage', {
method: 'GET',
headers: {
'X-Device-ID': TEST_DEVICE_ID,
},
});
const data = await res.json();
expect(data.used).toBe(3);
expect(data.remaining).toBe(97);
});
});
describe('Search API - GET /api/search/health', () => {
it('should return health status', async () => {
const res = await app.request('/api/search/health', {
method: 'GET',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.status).toBeDefined();
expect(data.provider).toBe('serper');
expect(data.timestamp).toBeDefined();
});
});
describe('Search API - Integration', () => {
it('should handle full search workflow', async () => {
// 1. Check initial usage
const usageRes1 = await app.request('/api/search/usage', {
method: 'GET',
headers: {
'X-Device-ID': TEST_DEVICE_ID,
},
});
const usage1 = await usageRes1.json();
expect(usage1.used).toBe(0);
// 2. Make a search
const searchRes = await app.request('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Device-ID': TEST_DEVICE_ID,
},
body: JSON.stringify({
query: 'latest AI news',
numResults: 10,
}),
});
expect(searchRes.status).toBe(200);
const searchData = await searchRes.json();
expect(searchData.results.length).toBeGreaterThan(0);
expect(searchData.usage.used).toBe(1);
// 3. Check updated usage
const usageRes2 = await app.request('/api/search/usage', {
method: 'GET',
headers: {
'X-Device-ID': TEST_DEVICE_ID,
},
});
const usage2 = await usageRes2.json();
expect(usage2.used).toBe(1);
expect(usage2.remaining).toBe(99);
});
});

View File

@@ -0,0 +1,407 @@
// Skill Publishing Integration Tests
// Tests the complete flow: Create -> Publish -> Fork -> Update -> Delete
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { eq } from 'drizzle-orm';
import { DEFAULT_CATEGORIES } from '@vibncode/shared';
import { db } from '../db/client';
import { marketplaceSkills, skillVersions, users } from '../db/schema';
import { app } from '../index';
import { signToken } from '../lib/jwt';
import { clearDatabase, seedTestDatabase } from './fixtures';
let testUserId: string;
let authToken: string;
let testCategorySlug: string;
beforeAll(async () => {
console.log('\n🔧 Setting up Skill Publishing Integration tests...\n');
const _testData = await seedTestDatabase();
// Get test user
const usersResult = await db.select().from(users).limit(1);
if (usersResult.length === 0) {
throw new Error('No test users found');
}
testUserId = usersResult[0].id;
// Create a valid JWT token for testing
const token = await signToken({
userId: testUserId,
username: usersResult[0].username,
email: usersResult[0].email || undefined,
});
authToken = `Bearer ${token}`;
testCategorySlug = DEFAULT_CATEGORIES[0].slug;
console.log('✅ Skill Publishing Integration test setup complete\n');
});
afterAll(async () => {
console.log('\n🧹 Cleaning up Skill Publishing Integration tests...\n');
await clearDatabase();
console.log('✅ Cleanup complete\n');
});
describe('Skill Publishing Integration - Complete Flow', () => {
it('should complete full skill lifecycle: create -> publish -> update -> unpublish -> delete', async () => {
// ========== STEP 1: Create Skill ==========
const skillData = {
name: `Integration Test Skill ${Date.now()}`,
description: 'Testing full lifecycle',
longDescription: 'This skill tests the complete publishing flow',
systemPromptFragment: 'You are a test assistant',
workflowRules: 'Follow test procedures',
documentation: [
{
type: 'inline',
title: 'Introduction',
content: 'This is a test skill',
},
],
iconUrl: 'https://example.com/icon.png',
categories: [testCategorySlug],
tags: ['integration', 'test', 'lifecycle'],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
expect(createRes.status).toBe(201);
const createData = await createRes.json();
const skillId = createData.skill.id;
expect(createData.skill.name).toBe(skillData.name);
expect(createData.skill.isPublished).toBe(false);
expect(createData.skill.latestVersion).toBe('1.0.0');
// Verify initial version was created
const versions = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.skillId, skillId));
expect(versions.length).toBe(1);
expect(versions[0].version).toBe('1.0.0');
// ========== STEP 2: Publish Skill ==========
const publishRes = await app.request(`/api/skills/${skillId}/publish`, {
method: 'POST',
headers: {
Authorization: authToken,
},
});
expect(publishRes.status).toBe(200);
const publishData = await publishRes.json();
expect(publishData.skill.isPublished).toBe(true);
expect(publishData.skill.publishedAt).toBeGreaterThan(0);
// ========== STEP 3: Update Skill ==========
const updates = {
description: 'Updated description',
tags: ['integration', 'test', 'lifecycle', 'updated'],
};
const updateRes = await app.request(`/api/skills/${skillId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(updates),
});
expect(updateRes.status).toBe(200);
const updateData = await updateRes.json();
expect(updateData.skill.description).toBe(updates.description);
// ========== STEP 4: Create New Version ==========
const versionData = {
version: '1.1.0',
systemPromptFragment: 'Updated prompt for version 1.1',
changeLog: 'Added new features and improvements',
};
const versionRes = await app.request(`/api/skills/${skillId}/versions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(versionData),
});
expect(versionRes.status).toBe(201);
const versionResData = await versionRes.json();
expect(versionResData.version.version).toBe('1.1.0');
// Verify latest version was updated
const updatedSkill = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.id, skillId))
.limit(1);
expect(updatedSkill[0].latestVersion).toBe('1.1.0');
// ========== STEP 5: Unpublish Skill ==========
const unpublishRes = await app.request(`/api/skills/${skillId}/unpublish`, {
method: 'POST',
headers: {
Authorization: authToken,
},
});
expect(unpublishRes.status).toBe(200);
const unpublishData = await unpublishRes.json();
expect(unpublishData.skill.isPublished).toBe(false);
// ========== STEP 6: Delete Skill ==========
const deleteRes = await app.request(`/api/skills/${skillId}`, {
method: 'DELETE',
headers: {
Authorization: authToken,
},
});
expect(deleteRes.status).toBe(200);
// Verify skill and versions are deleted
const deletedSkill = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.id, skillId));
expect(deletedSkill.length).toBe(0);
const deletedVersions = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.skillId, skillId));
expect(deletedVersions.length).toBe(0);
});
it('should handle multiple versions correctly', async () => {
// Create skill
const skillData = {
name: `Versioned Skill ${Date.now()}`,
description: 'Testing version management',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
const createData = await createRes.json();
const skillId = createData.skill.id;
// Create multiple versions
const versions = ['1.1.0', '1.2.0', '2.0.0', '2.1.0'];
for (const version of versions) {
const versionRes = await app.request(`/api/skills/${skillId}/versions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify({
version,
changeLog: `Version ${version} changes`,
}),
});
expect(versionRes.status).toBe(201);
}
// Verify all versions were created
const allVersions = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.skillId, skillId));
expect(allVersions.length).toBe(5); // 1.0.0 + 4 new versions
// Verify latest version is correct
const skill = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.id, skillId))
.limit(1);
expect(skill[0].latestVersion).toBe('2.1.0');
// Cleanup
await app.request(`/api/skills/${skillId}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
});
it('should prevent unauthorized users from modifying skills', async () => {
// Create skill with one user
const skillData = {
name: `Protected Skill ${Date.now()}`,
description: 'Testing authorization',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
const createData = await createRes.json();
const skillId = createData.skill.id;
// Try to modify with different user (invalid token - will get 401)
const unauthorizedToken = 'Bearer different-user-token';
const updateRes = await app.request(`/api/skills/${skillId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: unauthorizedToken,
},
body: JSON.stringify({ name: 'Hacked Name' }),
});
expect(updateRes.status).toBe(401); // Unauthorized due to invalid token
const deleteRes = await app.request(`/api/skills/${skillId}`, {
method: 'DELETE',
headers: { Authorization: unauthorizedToken },
});
expect(deleteRes.status).toBe(401); // Unauthorized due to invalid token
// Cleanup with correct user
await app.request(`/api/skills/${skillId}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
});
it('should handle tag creation and reuse', async () => {
const uniqueTag = `integration-test-tag-${Date.now()}`;
const timestamp = Date.now();
// Create first skill with new tag
const skill1Data = {
name: `Skill with New Tag ${timestamp}`,
description: 'First skill',
documentation: [],
categories: [testCategorySlug],
tags: [uniqueTag, 'common-tag'],
};
const create1Res = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skill1Data),
});
const create1Data = await create1Res.json();
const skill1Id = create1Data.skill.id;
// Create second skill with same tag
const skill2Data = {
name: `Skill Reusing Tag ${timestamp}`,
description: 'Second skill',
documentation: [],
categories: [testCategorySlug],
tags: [uniqueTag, 'another-tag'],
};
const create2Res = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skill2Data),
});
const create2Data = await create2Res.json();
const skill2Id = create2Data.skill.id;
// Verify tags were created and reused properly
// Both skills should share the unique tag
// Cleanup
await app.request(`/api/skills/${skill1Id}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
await app.request(`/api/skills/${skill2Id}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
});
it('should validate required fields', async () => {
const invalidSkills = [
{
// Missing name
description: 'No name',
documentation: [],
categories: [testCategorySlug],
tags: [],
},
{
name: 'No Description',
// Missing description
documentation: [],
categories: [testCategorySlug],
tags: [],
},
{
name: 'No Documentation',
description: 'Has description',
// Missing documentation
categories: [testCategorySlug],
tags: [],
},
];
for (const invalidSkill of invalidSkills) {
const res = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(invalidSkill),
});
expect(res.status).toBe(400);
}
});
});

View File

@@ -0,0 +1,484 @@
// Skill API routes tests
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import { eq } from 'drizzle-orm';
import { DEFAULT_CATEGORIES } from '@vibncode/shared';
import { db } from '../db/client';
import { marketplaceSkills, users } from '../db/schema';
import { app } from '../index';
import { signToken } from '../lib/jwt';
import { clearDatabase, seedTestDatabase } from './fixtures';
let testUserId: string;
let authToken: string;
let testCategorySlug: string;
beforeAll(async () => {
console.log('\n🔧 Setting up Skill Routes tests...\n');
const _testData = await seedTestDatabase();
// Get test user and create auth token
const usersResult = await db.select().from(users).limit(1);
if (usersResult.length === 0) {
throw new Error('No test users found');
}
testUserId = usersResult[0].id;
// Create a valid JWT token for testing
const token = await signToken({
userId: testUserId,
username: usersResult[0].username,
email: usersResult[0].email || undefined,
});
authToken = `Bearer ${token}`;
testCategorySlug = DEFAULT_CATEGORIES[0].slug;
console.log('✅ Skill Routes test setup complete\n');
});
afterAll(async () => {
console.log('\n🧹 Cleaning up Skill Routes tests...\n');
await clearDatabase();
console.log('✅ Cleanup complete\n');
});
describe('POST /api/skills - Create Skill', () => {
it('should create a new skill with valid data', async () => {
const skillData = {
name: `API Test Skill ${Date.now()}`,
description: 'A skill created via API',
longDescription: 'Detailed description of the skill',
systemPromptFragment: 'You are a helpful assistant',
workflowRules: 'Follow best practices',
documentation: [
{
type: 'inline',
title: 'Introduction',
content: 'This is the introduction',
},
],
iconUrl: 'https://example.com/icon.png',
categories: [testCategorySlug],
tags: ['api', 'test'],
};
const res = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.skill).toBeDefined();
expect(data.skill.name).toBe(skillData.name);
expect(data.skill.description).toBe(skillData.description);
expect(data.skill.isPublished).toBe(false);
});
it('should fail without authentication', async () => {
const skillData = {
name: 'Unauthorized Skill',
description: 'Should fail',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const res = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(skillData),
});
expect(res.status).toBe(401);
});
it('should fail with missing required fields', async () => {
const invalidData = {
name: 'Missing Description',
// Missing description and documentation
categories: [testCategorySlug],
tags: [],
};
const res = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(invalidData),
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('Missing required fields');
});
});
describe('PATCH /api/skills/:skillId - Update Skill', () => {
let testSkillId: string;
beforeEach(async () => {
// Create a skill to update
const skillData = {
name: `Skill to Update via API ${Date.now()}`,
description: 'Original description',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
const createData = await createRes.json();
testSkillId = createData.skill.id;
});
afterEach(async () => {
// Clean up the test skill
if (testSkillId) {
try {
await app.request(`/api/skills/${testSkillId}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
} catch (_e) {
// Ignore errors if already deleted
}
}
});
it('should update skill successfully', async () => {
const updates = {
name: `Updated Skill Name ${Date.now()}${Math.random()}`,
description: 'Updated description',
tags: ['updated'],
};
const res = await app.request(`/api/skills/${testSkillId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(updates),
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.skill.name).toBe(updates.name);
expect(data.skill.description).toBe(updates.description);
});
it('should fail to update non-existent skill', async () => {
const res = await app.request('/api/skills/non-existent-id', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify({ name: 'New Name' }),
});
expect(res.status).toBe(404);
});
});
describe('POST /api/skills/:skillId/publish - Publish Skill', () => {
let testSkillId: string;
beforeEach(async () => {
const skillData = {
name: `Skill to Publish via API ${Date.now()}`,
description: 'Test publishing',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
const createData = await createRes.json();
testSkillId = createData.skill.id;
});
afterEach(async () => {
// Clean up the test skill
if (testSkillId) {
try {
await app.request(`/api/skills/${testSkillId}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
} catch (_e) {
// Ignore errors if already deleted
}
}
});
it('should publish skill successfully', async () => {
const res = await app.request(`/api/skills/${testSkillId}/publish`, {
method: 'POST',
headers: {
Authorization: authToken,
},
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.skill.isPublished).toBe(true);
expect(data.skill.publishedAt).toBeDefined();
});
it('should fail without authentication', async () => {
const res = await app.request(`/api/skills/${testSkillId}/publish`, {
method: 'POST',
});
expect(res.status).toBe(401);
});
});
describe('POST /api/skills/:skillId/unpublish - Unpublish Skill', () => {
let testSkillId: string;
beforeEach(async () => {
const skillData = {
name: `Skill to Unpublish via API ${Date.now()}`,
description: 'Test unpublishing',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
const createData = await createRes.json();
testSkillId = createData.skill.id;
// Publish first
await app.request(`/api/skills/${testSkillId}/publish`, {
method: 'POST',
headers: {
Authorization: authToken,
},
});
});
afterEach(async () => {
// Clean up the test skill
if (testSkillId) {
try {
await app.request(`/api/skills/${testSkillId}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
} catch (_e) {
// Ignore errors if already deleted
}
}
});
it('should unpublish skill successfully', async () => {
const res = await app.request(`/api/skills/${testSkillId}/unpublish`, {
method: 'POST',
headers: {
Authorization: authToken,
},
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.skill.isPublished).toBe(false);
});
});
describe('DELETE /api/skills/:skillId - Delete Skill', () => {
it('should delete skill successfully', async () => {
// Create a skill to delete
const skillData = {
name: 'Skill to Delete via API',
description: 'Will be deleted',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
const createData = await createRes.json();
const skillId = createData.skill.id;
// Delete the skill
const deleteRes = await app.request(`/api/skills/${skillId}`, {
method: 'DELETE',
headers: {
Authorization: authToken,
},
});
expect(deleteRes.status).toBe(200);
const deleteData = await deleteRes.json();
expect(deleteData.message).toContain('deleted successfully');
// Verify skill is deleted
const verifyRes = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.id, skillId))
.limit(1);
expect(verifyRes.length).toBe(0);
});
it('should fail to delete non-existent skill', async () => {
const res = await app.request('/api/skills/non-existent-id', {
method: 'DELETE',
headers: {
Authorization: authToken,
},
});
expect(res.status).toBe(404);
});
});
describe('POST /api/skills/:skillId/versions - Create Version', () => {
let testSkillId: string;
beforeEach(async () => {
const skillData = {
name: `Versioned Skill via API ${Date.now()}`,
description: 'Test versioning',
documentation: [],
categories: [testCategorySlug],
tags: [],
};
const createRes = await app.request('/api/skills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(skillData),
});
const createData = await createRes.json();
testSkillId = createData.skill.id;
});
afterEach(async () => {
// Clean up the test skill
if (testSkillId) {
try {
await app.request(`/api/skills/${testSkillId}`, {
method: 'DELETE',
headers: { Authorization: authToken },
});
} catch (_e) {
// Ignore errors if already deleted
}
}
});
it('should create new version successfully', async () => {
const versionData = {
version: '1.1.0',
systemPromptFragment: 'Updated prompt',
changeLog: 'Added new features',
};
const res = await app.request(`/api/skills/${testSkillId}/versions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(versionData),
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.version.version).toBe('1.1.0');
expect(data.version.changeLog).toBe(versionData.changeLog);
});
it('should fail with missing version or changeLog', async () => {
const invalidData = {
version: '1.2.0',
// Missing changeLog
};
const res = await app.request(`/api/skills/${testSkillId}/versions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(invalidData),
});
expect(res.status).toBe(400);
});
it('should fail to create duplicate version', async () => {
const versionData = {
version: '1.0.0', // Already exists
changeLog: 'Duplicate version',
};
const res = await app.request(`/api/skills/${testSkillId}/versions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken,
},
body: JSON.stringify(versionData),
});
expect(res.status).toBe(409);
});
});

View File

@@ -0,0 +1,453 @@
// SkillService unit tests
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import { eq } from 'drizzle-orm';
import { db } from '../db/client';
import {
categories,
marketplaceSkills,
skillCategories,
skillTags,
skillVersions,
tags,
users,
} from '../db/schema';
import { skillService } from '../services/skill-service';
import { clearDatabase, seedTestDatabase } from './fixtures';
let testUserId: string;
let testCategoryId: string;
beforeAll(async () => {
console.log('\n🔧 Setting up SkillService tests...\n');
const _testData = await seedTestDatabase();
// Get a test user
const usersResult = await db.select().from(users).limit(1);
if (usersResult.length === 0) {
throw new Error('No test users found');
}
testUserId = usersResult[0].id;
// Get a test category
const categoriesResult = await db.select().from(categories).limit(1);
if (categoriesResult.length === 0) {
throw new Error('No test categories found');
}
testCategoryId = categoriesResult[0].slug;
console.log('✅ SkillService test setup complete\n');
});
afterAll(async () => {
console.log('\n🧹 Cleaning up SkillService tests...\n');
await clearDatabase();
console.log('✅ Cleanup complete\n');
});
describe('SkillService - Create Skill', () => {
it('should create a skill successfully', async () => {
const skillData = {
name: `Test Skill ${Date.now()}`,
description: 'A test skill for unit testing',
longDescription: 'This is a detailed description of the test skill',
systemPromptFragment: 'You are a test assistant',
workflowRules: 'Follow test workflow',
documentation: [
{
type: 'inline' as const,
title: 'Getting Started',
content: 'This is how to use the test skill',
},
],
iconUrl: 'https://example.com/icon.png',
categories: [testCategoryId],
tags: ['test', 'unit-test', 'automation'],
};
const skill = await skillService.createSkill(testUserId, skillData);
expect(skill).toBeDefined();
expect(skill.id).toBeDefined();
expect(skill.name).toBe(skillData.name);
expect(skill.description).toBe(skillData.description);
expect(skill.systemPromptFragment).toBe(skillData.systemPromptFragment);
expect(skill.workflowRules).toBe(skillData.workflowRules);
expect(skill.latestVersion).toBe('1.0.0');
expect(skill.isPublished).toBe(false);
expect(skill.authorId).toBe(testUserId);
// Verify initial version was created
const versions = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.skillId, skill.id));
expect(versions.length).toBe(1);
expect(versions[0].version).toBe('1.0.0');
expect(versions[0].changeLog).toBe('Initial release');
// Verify categories were linked
const linkedCategories = await db
.select()
.from(skillCategories)
.where(eq(skillCategories.skillId, skill.id));
expect(linkedCategories.length).toBeGreaterThan(0);
// Verify tags were created and linked
const linkedTags = await db.select().from(skillTags).where(eq(skillTags.skillId, skill.id));
expect(linkedTags.length).toBe(3);
});
it('should fail when creating skill with duplicate name', async () => {
const skillData = {
name: `Duplicate Skill ${Date.now()}`,
description: 'First skill',
documentation: [],
categories: [testCategoryId],
tags: [],
};
// Create first skill
await skillService.createSkill(testUserId, skillData);
// Try to create duplicate - should fail
try {
await skillService.createSkill(testUserId, skillData);
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeDefined();
expect((error as Error).message).toContain('already exists');
}
});
it('should fail when creating skill without valid categories', async () => {
const skillData = {
name: `No Category Skill ${Date.now()}`,
description: 'Skill without categories',
documentation: [],
categories: ['non-existent-category'],
tags: [],
};
try {
await skillService.createSkill(testUserId, skillData);
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeDefined();
expect((error as Error).message).toContain('No valid categories');
}
});
});
describe('SkillService - Update Skill', () => {
let testSkillId: string;
beforeEach(async () => {
// Create a test skill
const skillData = {
name: `Skill to Update ${Date.now()}`,
description: 'Original description',
documentation: [],
categories: [testCategoryId],
tags: ['original'],
};
const skill = await skillService.createSkill(testUserId, skillData);
testSkillId = skill.id;
});
afterEach(async () => {
// Clean up the test skill
if (testSkillId) {
try {
await skillService.deleteSkill(testUserId, testSkillId);
} catch (_e) {
// Ignore errors if already deleted
}
}
});
it('should update skill successfully', async () => {
const updates = {
name: `Updated Skill Name ${Date.now()}`,
description: 'Updated description',
systemPromptFragment: 'Updated prompt',
tags: ['updated', 'modified'],
};
const updatedSkill = await skillService.updateSkill(testUserId, testSkillId, updates);
expect(updatedSkill).toBeDefined();
expect(updatedSkill?.name).toBe(updates.name);
expect(updatedSkill?.description).toBe(updates.description);
expect(updatedSkill?.systemPromptFragment).toBe(updates.systemPromptFragment);
});
it('should fail to update non-existent skill', async () => {
try {
await skillService.updateSkill(testUserId, 'non-existent-id', {
name: 'New Name',
});
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeDefined();
expect((error as Error).message).toContain('not found');
}
});
it('should fail to update skill without ownership', async () => {
const otherUserId = 'different-user-id';
try {
await skillService.updateSkill(otherUserId, testSkillId, {
name: 'New Name',
});
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeDefined();
expect((error as Error).message).toContain('unauthorized');
}
});
});
describe('SkillService - Publish/Unpublish Skill', () => {
let testSkillId: string;
beforeEach(async () => {
const skillData = {
name: `Skill to Publish ${Date.now()}`,
description: 'Test publishing',
documentation: [],
categories: [testCategoryId],
tags: [],
};
const skill = await skillService.createSkill(testUserId, skillData);
testSkillId = skill.id;
});
afterEach(async () => {
// Clean up the test skill
if (testSkillId) {
try {
await skillService.deleteSkill(testUserId, testSkillId);
} catch (_e) {
// Ignore errors if already deleted
}
}
});
it('should publish skill successfully', async () => {
const publishedSkill = await skillService.publishSkill(testUserId, testSkillId);
expect(publishedSkill).toBeDefined();
expect(publishedSkill?.isPublished).toBe(true);
expect(publishedSkill?.publishedAt).toBeDefined();
expect(publishedSkill?.publishedAt).toBeGreaterThan(0);
});
it('should unpublish skill successfully', async () => {
// First publish
await skillService.publishSkill(testUserId, testSkillId);
// Then unpublish
const unpublishedSkill = await skillService.unpublishSkill(testUserId, testSkillId);
expect(unpublishedSkill).toBeDefined();
expect(unpublishedSkill?.isPublished).toBe(false);
});
it('should fail to publish non-existent skill', async () => {
try {
await skillService.publishSkill(testUserId, 'non-existent-id');
expect(true).toBe(false);
} catch (error) {
expect(error).toBeDefined();
expect((error as Error).message).toContain('not found');
}
});
});
describe('SkillService - Delete Skill', () => {
it('should delete skill successfully', async () => {
const skillData = {
name: `Skill to Delete ${Date.now()}`,
description: 'Will be deleted',
documentation: [],
categories: [testCategoryId],
tags: ['deletable'],
};
const skill = await skillService.createSkill(testUserId, skillData);
const result = await skillService.deleteSkill(testUserId, skill.id);
expect(result).toBe(true);
// Verify skill is deleted
const deletedSkill = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.id, skill.id))
.limit(1);
expect(deletedSkill.length).toBe(0);
});
it('should fail to delete non-existent skill', async () => {
try {
await skillService.deleteSkill(testUserId, 'non-existent-id');
expect(true).toBe(false);
} catch (error) {
expect(error).toBeDefined();
expect((error as Error).message).toContain('not found');
}
});
});
describe('SkillService - Create Version', () => {
let testSkillId: string;
beforeEach(async () => {
const skillData = {
name: `Versioned Skill ${Date.now()}`,
description: 'Test versioning',
documentation: [],
categories: [testCategoryId],
tags: [],
};
const skill = await skillService.createSkill(testUserId, skillData);
testSkillId = skill.id;
});
afterEach(async () => {
// Clean up the test skill
if (testSkillId) {
try {
await skillService.deleteSkill(testUserId, testSkillId);
} catch (_e) {
// Ignore errors if already deleted
}
}
});
it('should create new version successfully', async () => {
const versionData = {
version: '1.1.0',
systemPromptFragment: 'Updated prompt for v1.1',
changeLog: 'Added new features',
};
const version = await skillService.createVersion(testUserId, testSkillId, versionData);
expect(version).toBeDefined();
expect(version.version).toBe('1.1.0');
expect(version.systemPromptFragment).toBe(versionData.systemPromptFragment);
expect(version.changeLog).toBe(versionData.changeLog);
// Verify latest version was updated
const skill = await db
.select()
.from(marketplaceSkills)
.where(eq(marketplaceSkills.id, testSkillId))
.limit(1);
expect(skill[0].latestVersion).toBe('1.1.0');
});
it('should fail to create duplicate version', async () => {
const versionData = {
version: '1.0.0', // Already exists from initial creation
changeLog: 'Duplicate version',
};
try {
await skillService.createVersion(testUserId, testSkillId, versionData);
expect(true).toBe(false);
} catch (error) {
expect(error).toBeDefined();
expect((error as Error).message).toContain('already exists');
}
});
});
describe('SkillService - Tag Management', () => {
it('should create new tags when they do not exist', async () => {
const skillData = {
name: `Skill with New Tags ${Date.now()}`,
description: 'Testing tag creation',
documentation: [],
categories: [testCategoryId],
tags: [`brand-new-tag-${Date.now()}`, `another-new-tag-${Date.now()}`],
};
const _skill = await skillService.createSkill(testUserId, skillData);
// Verify tags were created
for (const tagName of skillData.tags) {
const tagSlug = tagName.toLowerCase().replace(/\s+/g, '-');
const foundTag = await db.select().from(tags).where(eq(tags.slug, tagSlug)).limit(1);
expect(foundTag.length).toBe(1);
expect(foundTag[0].name).toBe(tagName);
}
});
it('should increment usage count for existing tags', async () => {
const tagName = `reusable-tag-${Date.now()}`;
const timestamp = Date.now();
// Create first skill with tag
const skill1Data = {
name: `First Skill ${timestamp}`,
description: 'First skill',
documentation: [],
categories: [testCategoryId],
tags: [tagName],
};
await skillService.createSkill(testUserId, skill1Data);
const tagSlug = tagName.toLowerCase().replace(/\s+/g, '-');
const tag1 = await db.select().from(tags).where(eq(tags.slug, tagSlug)).limit(1);
const initialCount = tag1[0].usageCount;
// Create second skill with same tag
const skill2Data = {
name: `Second Skill ${timestamp}`,
description: 'Second skill',
documentation: [],
categories: [testCategoryId],
tags: [tagName],
};
await skillService.createSkill(testUserId, skill2Data);
const tag2 = await db.select().from(tags).where(eq(tags.slug, tagSlug)).limit(1);
expect(tag2[0].usageCount).toBe(initialCount + 1);
});
});
describe('SkillService - Slug Generation', () => {
it('should generate valid slugs from skill names', async () => {
const testCases = [
{ name: 'My Awesome Skill', expectedSlug: 'my-awesome-skill' },
{ name: 'Skill with CAPS', expectedSlug: 'skill-with-caps' },
{ name: 'Skill!!!???', expectedSlug: 'skill' },
{ name: ' Spaced Out ', expectedSlug: 'spaced-out' },
];
for (const testCase of testCases) {
const skillData = {
name: testCase.name,
description: 'Test slug generation',
documentation: [],
categories: [testCategoryId],
tags: [],
};
const skill = await skillService.createSkill(testUserId, skillData);
expect(skill.slug).toBe(testCase.expectedSlug);
// Clean up
await skillService.deleteSkill(testUserId, skill.id);
}
});
});

View File

@@ -0,0 +1,80 @@
// Remote Skills API endpoint tests
import { describe, expect, it } from 'bun:test';
import { app } from '../index';
describe('Remote Skills API - Configs', () => {
it('should return remote skill configs', async () => {
const res = await app.request('/api/remote-skills/configs');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.remoteSkills).toBeDefined();
expect(Array.isArray(data.remoteSkills)).toBe(true);
if (data.remoteSkills.length > 0) {
const skill = data.remoteSkills[0];
expect(skill).toHaveProperty('id');
expect(skill).toHaveProperty('name');
expect(skill).toHaveProperty('description');
expect(skill).toHaveProperty('category');
expect(skill).toHaveProperty('repository');
expect(skill).toHaveProperty('githubPath');
}
});
});
describe('Remote Skills API - Version', () => {
it('should return version', async () => {
const res = await app.request('/api/remote-skills/version');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('version');
});
});
describe('Remote Skills API - Categories', () => {
it('should return categories', async () => {
const res = await app.request('/api/remote-skills/categories');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.categories).toBeDefined();
expect(Array.isArray(data.categories)).toBe(true);
});
});
describe('Remote Skills API - Get Skill by Id', () => {
it('should return skill by id', async () => {
const listRes = await app.request('/api/remote-skills/configs');
const listData = await listRes.json();
const skill = listData.remoteSkills[0];
const res = await app.request(`/api/remote-skills/${skill.id}`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.id).toBe(skill.id);
});
it('should return 404 for unknown skill', async () => {
const res = await app.request('/api/remote-skills/unknown-skill-id');
expect(res.status).toBe(404);
});
});
describe('Remote Skills API - List IDs', () => {
it('should return ids list', async () => {
const res = await app.request('/api/remote-skills');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveProperty('count');
expect(data).toHaveProperty('skills');
expect(Array.isArray(data.skills)).toBe(true);
});
});

View File

@@ -0,0 +1,33 @@
// Test API response for agent detail
import { app } from '../index';
async function testApiResponse() {
try {
console.log('Testing API response for Translator agent...\n');
const res = await app.request('/api/remote-agents/translator');
if (res.status !== 200) {
console.log(`❌ API returned status: ${res.status}`);
return;
}
const data = (await res.json()) as {
agent: { author: { name: string; id: string; agentCount: number } };
};
console.log('✅ API Response:');
console.log(JSON.stringify(data, null, 2));
console.log('\n📊 Author Info:');
console.log(` - Name: ${data.agent.author.name}`);
console.log(` - ID: ${data.agent.author.id}`);
console.log(` - Agent Count: ${data.agent.author.agentCount}`);
} catch (error) {
console.error('❌ Error:', error);
}
process.exit(0);
}
testApiResponse();

View File

@@ -0,0 +1,101 @@
// Test creating agent with tags
import { eq } from 'drizzle-orm';
import { db } from '../db/client';
import { agentTags, marketplaceAgents, tags, users } from '../db/schema';
import { AgentService } from '../services/agent-service';
async function testCreateAgentWithTags() {
console.log('\n=== Testing Create Agent with Tags ===\n');
// Find or create a test user
const testUser = await db.select().from(users).limit(1);
let userId: string;
if (testUser.length === 0) {
console.log('Creating test user...');
const newUser = await db
.insert(users)
.values({
email: 'test@example.com',
name: 'Test User',
})
.returning();
userId = newUser[0].id;
console.log(`Created test user: ${userId}`);
} else {
userId = testUser[0].id;
console.log(`Using existing user: ${userId}`);
}
// Create agent with tags
const agentService = new AgentService();
console.log('\nCreating agent with tags: ["ai", "chat", "assistant"]');
try {
const agent = await agentService.createAgent(userId, {
name: 'Test Agent with Tags',
description: 'Testing tags functionality',
model: 'deepseek-reasoner',
systemPrompt: 'You are a helpful assistant',
categoryIds: ['coding'],
tags: ['ai', 'chat', 'assistant'],
});
console.log(`\n✅ Agent created: ${agent.name} (${agent.id})`);
// Check if tags were created
console.log('\nChecking tags table...');
const createdTags = await db.select().from(tags).where(eq(tags.slug, 'ai'));
console.log(`Tags in DB: ${createdTags.length}`);
for (const tag of createdTags) {
console.log(` - ${tag.name} (slug: ${tag.slug})`);
}
// Check if agent-tag relations were created
console.log('\nChecking agent_tags table...');
const agentTagsRelations = await db
.select()
.from(agentTags)
.where(eq(agentTags.agentId, agent.id));
console.log(`Agent-tag relations: ${agentTagsRelations.length}`);
if (agentTagsRelations.length === 3) {
console.log('✅ All tags were linked correctly!');
} else {
console.log(`❌ Expected 3 relations, got ${agentTagsRelations.length}`);
}
// Get full tags with names
const fullTags = await db
.select({
agentId: agentTags.agentId,
tag: tags,
})
.from(agentTags)
.innerJoin(tags, eq(agentTags.tagId, tags.id))
.where(eq(agentTags.agentId, agent.id));
console.log('\nTags linked to agent:');
for (const ft of fullTags) {
console.log(` - ${ft.tag.name}`);
}
// Clean up - delete test agent
console.log('\nCleaning up test agent...');
await db.delete(marketplaceAgents).where(eq(marketplaceAgents.id, agent.id));
console.log('Test agent deleted');
} catch (error) {
console.error('❌ Error creating agent:', error);
}
process.exit(0);
}
testCreateAgentWithTags().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

40
src/test/test-list-api.ts Normal file
View File

@@ -0,0 +1,40 @@
// Test list API response
import { app } from '../index';
async function testListApi() {
try {
console.log('Testing list agents API...\n');
const res = await app.request('/api/remote-agents/configs');
if (res.status !== 200) {
console.log(`❌ API returned status: ${res.status}`);
return;
}
const data = (await res.json()) as {
agents: Array<{
name: string;
slug: string;
author: { name: string; id: string; agentCount: number };
}>;
};
console.log(`✅ Found ${data.agents.length} agents\n`);
// Check each agent's author.agentCount
data.agents.forEach((agent, index: number) => {
console.log(`${index + 1}. ${agent.name} (${agent.slug})`);
console.log(` - Author: ${agent.author.name}`);
console.log(` - Author ID: ${agent.author.id}`);
console.log(` - Author agentCount: ${agent.author.agentCount}`);
console.log('');
});
} catch (error) {
console.error('❌ Error:', error);
}
process.exit(0);
}
testListApi();

View File

@@ -0,0 +1,191 @@
// User profile update tests
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { app } from '../index';
import { clearDatabase, seedTestDatabase } from './fixtures';
// Test data references
let testData: any;
let authToken: string;
// Initialize test database before all tests
beforeAll(async () => {
console.log('\n🔧 Setting up user profile test environment...\n');
// Seed test data
testData = await seedTestDatabase();
// Create a test auth token (you may need to adjust this based on your auth implementation)
// For now, we'll use a mock token
authToken = 'test-auth-token';
console.log('\n✅ User profile test environment ready\n');
});
// Clean up after all tests
afterAll(async () => {
console.log('\n🧹 Cleaning up user profile test environment...\n');
await clearDatabase();
console.log('✅ Cleanup complete\n');
});
describe('User Profile API', () => {
it('should update user display name', async () => {
const updateData = {
displayName: 'Test Display Name',
};
const res = await app.request('/api/users/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(updateData),
});
// Note: This test might fail if auth middleware rejects the token
// You may need to create a proper test user and token
if (res.status === 401) {
console.log('⚠️ Auth not configured for testing, skipping authenticated test');
return;
}
expect(res.status).toBe(200);
const data = await res.json();
expect(data.user).toBeDefined();
expect(data.user.displayName).toBe('Test Display Name');
});
it('should update user avatar URL', async () => {
const updateData = {
avatarUrl: 'https://example.com/avatar.jpg',
};
const res = await app.request('/api/users/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(updateData),
});
if (res.status === 401) {
console.log('⚠️ Auth not configured for testing, skipping authenticated test');
return;
}
expect(res.status).toBe(200);
const data = await res.json();
expect(data.user).toBeDefined();
expect(data.user.avatarUrl).toBe('https://example.com/avatar.jpg');
});
it('should update both display name and avatar URL', async () => {
const updateData = {
displayName: 'Updated Display Name',
avatarUrl: 'https://example.com/new-avatar.jpg',
};
const res = await app.request('/api/users/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(updateData),
});
if (res.status === 401) {
console.log('⚠️ Auth not configured for testing, skipping authenticated test');
return;
}
expect(res.status).toBe(200);
const data = await res.json();
expect(data.user).toBeDefined();
expect(data.user.displayName).toBe('Updated Display Name');
expect(data.user.avatarUrl).toBe('https://example.com/new-avatar.jpg');
});
it('should update user display name with mixed case like "KaisenKang"', async () => {
const updateData = {
displayName: 'KaisenKang',
};
const res = await app.request('/api/users/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(updateData),
});
if (res.status === 401) {
console.log('⚠️ Auth not configured for testing, skipping authenticated test');
return;
}
expect(res.status).toBe(200);
const data = await res.json();
expect(data.user).toBeDefined();
expect(data.user.displayName).toBe('KaisenKang');
});
it('should reject avatar upload with invalid file type', async () => {
// Create a mock file with invalid type
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' });
const formData = new FormData();
formData.append('avatar', invalidFile);
const res = await app.request('/api/users/me/avatar', {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
},
body: formData,
});
if (res.status === 401) {
console.log('⚠️ Auth not configured for testing, skipping authenticated test');
return;
}
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('Invalid file type');
});
it('should accept avatar upload with valid image type', async () => {
// Create a mock image file
const validFile = new File(['test image data'], 'avatar.jpg', { type: 'image/jpeg' });
const formData = new FormData();
formData.append('avatar', validFile);
const res = await app.request('/api/users/me/avatar', {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
},
body: formData,
});
if (res.status === 401) {
console.log('⚠️ Auth not configured for testing, skipping authenticated test');
return;
}
expect(res.status).toBe(200);
const data = await res.json();
expect(data.user).toBeDefined();
expect(data.avatarUrl).toBeDefined();
expect(data.avatarUrl).toContain('/uploads/avatars/');
});
});

13
src/types/context.ts Normal file
View File

@@ -0,0 +1,13 @@
// Hono context type definitions
import type { User } from '@vibncode/shared';
import type { Env } from './env';
export type HonoContext = {
Bindings: Env;
Variables: {
user?: User;
userId?: string;
deviceId?: string;
};
};

47
src/types/database.ts Normal file
View File

@@ -0,0 +1,47 @@
// Type definitions for database tables
import type { SQL } from 'drizzle-orm';
import type {
categories,
marketplaceAgents,
marketplaceSkills,
skillCategories,
skillTags,
tags,
users,
} from '../db/schema';
// Types for database records
export type CategoryRecord = typeof categories.$inferSelect;
export type SkillCategoryRecord = typeof skillCategories.$inferSelect & {
category: CategoryRecord;
};
export type TagRecord = typeof tags.$inferSelect;
export type SkillTagRecord = typeof skillTags.$inferSelect & {
tag: TagRecord;
};
export type MarketplaceSkillRecord = typeof marketplaceSkills.$inferSelect;
// User types
export type DbUser = typeof users.$inferSelect;
// Category types (alias for backward compatibility)
export type DbCategory = CategoryRecord;
// Marketplace types
export type DbMarketplaceAgent = typeof marketplaceAgents.$inferSelect;
// Tag types (alias for backward compatibility)
export type DbTag = TagRecord;
// Config types
export type ToolsConfig = Record<string, boolean | string | number | null | undefined>;
export type DynamicPromptConfig = {
enabled?: boolean;
variables?: Record<string, string | number | boolean>;
templates?: string[];
providers?: string[];
} | null;
// SQL condition type
export type SQLCondition = SQL<unknown>;

94
src/types/env.ts Normal file
View File

@@ -0,0 +1,94 @@
// Environment variables type definitions
export interface Env {
TURSO_DATABASE_URL: string;
TURSO_AUTH_TOKEN: string;
JWT_SECRET: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GOOGLE_REDIRECT_URI?: string;
NODE_ENV?: string;
RELEASES_BUCKET?: R2Bucket;
TALKCODY_DAILY_TOKEN_LIMIT?: string;
SERPER_API_KEY?: string;
JINA_API_KEY?: string;
}
// Cloudflare R2 Bucket type
export interface R2Bucket {
get(key: string): Promise<R2Object | null>;
put(
key: string,
value: ReadableStream | ArrayBuffer | string,
options?: R2PutOptions
): Promise<R2Object>;
delete(key: string): Promise<void>;
list(options?: R2ListOptions): Promise<R2Objects>;
}
export interface R2Object {
key: string;
version: string;
size: number;
etag: string;
httpEtag: string;
uploaded: Date;
httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
body: ReadableStream;
bodyUsed: boolean;
arrayBuffer(): Promise<ArrayBuffer>;
text(): Promise<string>;
json<T = unknown>(): Promise<T>;
blob(): Promise<Blob>;
}
export interface R2PutOptions {
httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
}
export interface R2HTTPMetadata {
contentType?: string;
contentLanguage?: string;
contentDisposition?: string;
contentEncoding?: string;
cacheControl?: string;
cacheExpiry?: Date;
}
export interface R2ListOptions {
limit?: number;
prefix?: string;
cursor?: string;
delimiter?: string;
include?: ('httpMetadata' | 'customMetadata')[];
}
export interface R2Objects {
objects: R2Object[];
truncated: boolean;
cursor?: string;
delimitedPrefixes: string[];
}
declare global {
namespace Bun {
interface Env {
TURSO_DATABASE_URL: string;
TURSO_AUTH_TOKEN: string;
JWT_SECRET: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GOOGLE_REDIRECT_URI?: string;
NODE_ENV?: string;
TALKCODY_DAILY_TOKEN_LIMIT?: string;
SERPER_API_KEY?: string;
JINA_API_KEY?: string;
}
}
}

5
src/worker.ts Normal file
View File

@@ -0,0 +1,5 @@
// Cloudflare Workers entry point
import { app } from './index';
// Export for Cloudflare Workers
export default app;