feat: deploy standalone Hono/Bun auth and API backend
This commit is contained in:
90
src/db/client.ts
Normal file
90
src/db/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/db/mark-migration-complete.ts
Normal file
71
src/db/mark-migration-complete.ts
Normal 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
173
src/db/migrate.ts
Normal 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);
|
||||
}
|
||||
250
src/db/migrations/0000_wakeful_tana_nile.sql
Normal file
250
src/db/migrations/0000_wakeful_tana_nile.sql
Normal 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`);
|
||||
193
src/db/migrations/0001_add_fts5_search.sql
Normal file
193
src/db/migrations/0001_add_fts5_search.sql
Normal 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
|
||||
-- ============================================
|
||||
19
src/db/migrations/0002_add_display_name.sql
Normal file
19
src/db/migrations/0002_add_display_name.sql
Normal 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.
|
||||
-- ============================================
|
||||
26
src/db/migrations/0004_add_r2_storage_fields.sql
Normal file
26
src/db/migrations/0004_add_r2_storage_fields.sql
Normal 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);
|
||||
22
src/db/migrations/0005_add_analytics_events.sql
Normal file
22
src/db/migrations/0005_add_analytics_events.sql
Normal 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`);
|
||||
21
src/db/migrations/0006_add_devices_provider_usage.sql
Normal file
21
src/db/migrations/0006_add_devices_provider_usage.sql
Normal 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`);
|
||||
61
src/db/migrations/0007_agent_skills_standardization.sql
Normal file
61
src/db/migrations/0007_agent_skills_standardization.sql
Normal 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
|
||||
31
src/db/migrations/0008_add_search_usage.sql
Normal file
31
src/db/migrations/0008_add_search_usage.sql
Normal 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
|
||||
27
src/db/migrations/0009_add_task_shares.sql
Normal file
27
src/db/migrations/0009_add_task_shares.sql
Normal 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);
|
||||
1767
src/db/migrations/meta/0000_snapshot.json
Normal file
1767
src/db/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
14
src/db/migrations/meta/0001_snapshot.json
Normal file
14
src/db/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
1774
src/db/migrations/meta/0003_snapshot.json
Normal file
1774
src/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
415
src/db/migrations/meta/0007_snapshot.json
Normal file
415
src/db/migrations/meta/0007_snapshot.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/db/migrations/meta/_journal.json
Normal file
69
src/db/migrations/meta/_journal.json
Normal 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
97
src/db/run-migration.ts
Normal 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
728
src/db/schema.ts
Normal 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
76
src/db/seed.ts
Normal 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
156
src/index.ts
Normal 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
70
src/lib/jwt.ts
Normal 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
45
src/lib/utils.ts
Normal 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
121
src/middlewares/auth.ts
Normal 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 };
|
||||
}
|
||||
38
src/middlewares/error-handler.ts
Normal file
38
src/middlewares/error-handler.ts
Normal 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
58
src/routes/agents.ts
Normal 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
355
src/routes/analytics.ts
Normal 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
346
src/routes/auth.ts
Normal 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
192
src/routes/marketplace.ts
Normal 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
54
src/routes/models.ts
Normal 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;
|
||||
38
src/routes/remote-agents.ts
Normal file
38
src/routes/remote-agents.ts
Normal 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;
|
||||
63
src/routes/remote-skills.ts
Normal file
63
src/routes/remote-skills.ts
Normal 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
242
src/routes/search.ts
Normal 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
212
src/routes/shares.ts
Normal 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;
|
||||
197
src/routes/skills-marketplace.ts
Normal file
197
src/routes/skills-marketplace.ts
Normal 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
169
src/routes/skills.ts
Normal 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
148
src/routes/updates.ts
Normal 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
154
src/routes/users.ts
Normal 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;
|
||||
277
src/routes/vibncode-provider.ts
Normal file
277
src/routes/vibncode-provider.ts
Normal 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
230
src/routes/web-fetch.ts
Normal 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;
|
||||
366
src/services/agent-service.ts
Normal file
366
src/services/agent-service.ts
Normal 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();
|
||||
228
src/services/analytics-service.ts
Normal file
228
src/services/analytics-service.ts
Normal 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();
|
||||
177
src/services/auth-service.ts
Normal file
177
src/services/auth-service.ts
Normal 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();
|
||||
313
src/services/marketplace-compat-service.ts
Normal file
313
src/services/marketplace-compat-service.ts
Normal 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 };
|
||||
};
|
||||
32
src/services/models-service.ts
Normal file
32
src/services/models-service.ts
Normal 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();
|
||||
34
src/services/remote-agents-service.ts
Normal file
34
src/services/remote-agents-service.ts
Normal 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();
|
||||
71
src/services/remote-skills-service.ts
Normal file
71
src/services/remote-skills-service.ts
Normal 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();
|
||||
167
src/services/search-usage-service.ts
Normal file
167
src/services/search-usage-service.ts
Normal 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();
|
||||
302
src/services/share-service.ts
Normal file
302
src/services/share-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
387
src/services/skill-service.ts
Normal file
387
src/services/skill-service.ts
Normal 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();
|
||||
69
src/services/upload-service.ts
Normal file
69
src/services/upload-service.ts
Normal 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();
|
||||
159
src/services/user-service.ts
Normal file
159
src/services/user-service.ts
Normal 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();
|
||||
167
src/services/user-usage-service.ts
Normal file
167
src/services/user-usage-service.ts
Normal 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();
|
||||
41
src/test/auth-google.test.ts
Normal file
41
src/test/auth-google.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
123
src/test/auth-service.test.ts
Normal file
123
src/test/auth-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
24
src/test/check-existing-agents.ts
Normal file
24
src/test/check-existing-agents.ts
Normal 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
57
src/test/check-tags.ts
Normal 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);
|
||||
});
|
||||
51
src/test/check-user-agents.ts
Normal file
51
src/test/check-user-agents.ts
Normal 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
41
src/test/db-client.ts
Normal 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
516
src/test/fixtures.ts
Normal 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
78
src/test/init-fts5.sql
Normal 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
224
src/test/init-schema.ts
Normal 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
434
src/test/init-test-db.ts
Normal 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 };
|
||||
68
src/test/marketplace.test.ts
Normal file
68
src/test/marketplace.test.ts
Normal 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
113
src/test/r2-upload.test.ts
Normal 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
112
src/test/run-tests.ts
Normal 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);
|
||||
395
src/test/search-usage-service.test.ts
Normal file
395
src/test/search-usage-service.test.ts
Normal 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
567
src/test/search.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
407
src/test/skill-publishing-integration.test.ts
Normal file
407
src/test/skill-publishing-integration.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
484
src/test/skill-routes.test.ts
Normal file
484
src/test/skill-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
453
src/test/skill-service.test.ts
Normal file
453
src/test/skill-service.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
80
src/test/skills-marketplace.test.ts
Normal file
80
src/test/skills-marketplace.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
33
src/test/test-api-response.ts
Normal file
33
src/test/test-api-response.ts
Normal 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();
|
||||
101
src/test/test-create-agent-with-tags.ts
Normal file
101
src/test/test-create-agent-with-tags.ts
Normal 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
40
src/test/test-list-api.ts
Normal 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();
|
||||
191
src/test/user-profile.test.ts
Normal file
191
src/test/user-profile.test.ts
Normal 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
13
src/types/context.ts
Normal 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
47
src/types/database.ts
Normal 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
94
src/types/env.ts
Normal 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
5
src/worker.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Cloudflare Workers entry point
|
||||
import { app } from './index';
|
||||
|
||||
// Export for Cloudflare Workers
|
||||
export default app;
|
||||
Reference in New Issue
Block a user