chore: convert submodules to standard directories for true monorepo structure

This commit is contained in:
2026-05-13 14:54:23 -07:00
parent 4339da259c
commit abf9bf89c2
761 changed files with 133928 additions and 2 deletions

View File

@@ -0,0 +1,73 @@
/**
* Backfill: activate any Gitea bot users that were created before the
* provisioner started flipping `active: true` automatically.
*
* Safe to re-run — bots that are already active stay active.
*
* Usage:
* npx dotenv-cli -e .env.local -- npx tsx scripts/activate-workspace-bots.ts
*/
import { query } from '../lib/db-postgres';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
if (!GITEA_API_TOKEN) {
console.error('GITEA_API_TOKEN not set — cannot proceed');
process.exit(1);
}
async function gitea(path: string, init?: RequestInit) {
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `token ${GITEA_API_TOKEN}`,
...(init?.headers ?? {}),
},
});
if (!res.ok && res.status !== 204) {
throw new Error(`gitea ${path}${res.status}: ${await res.text()}`);
}
if (res.status === 204) return null;
return res.json();
}
async function main() {
const rows = await query<{ slug: string; gitea_bot_username: string }>(
`SELECT slug, gitea_bot_username
FROM vibn_workspaces
WHERE gitea_bot_username IS NOT NULL`
);
console.log(`Checking ${rows.length} workspace bot(s)…`);
let touched = 0;
for (const { slug, gitea_bot_username: bot } of rows) {
try {
const u = (await gitea(`/users/${bot}`)) as { active?: boolean } | null;
if (!u) {
console.warn(` [${slug}] bot ${bot} not found in Gitea`);
continue;
}
if (u.active) {
console.log(` [${slug}] ${bot} already active`);
continue;
}
await gitea(`/admin/users/${bot}`, {
method: 'PATCH',
body: JSON.stringify({ source_id: 0, login_name: bot, active: true }),
});
console.log(` [${slug}] activated ${bot}`);
touched++;
} catch (err) {
console.error(` [${slug}] error:`, err instanceof Error ? err.message : err);
}
}
console.log(`Done — activated ${touched} bot(s).`);
}
main().catch((err) => {
console.error('FAILED:', err);
process.exit(1);
});

View File

@@ -0,0 +1,76 @@
/**
* Migration Script: Add Phase Tracking to Existing Projects
*
* Run with: node scripts/add-phase-tracking.js
*/
const admin = require('firebase-admin');
const fs = require('fs');
const path = require('path');
// Load environment variables
require('dotenv').config({ path: '.env.local' });
// Initialize Firebase Admin
const serviceAccount = {
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
};
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
}
const db = admin.firestore();
async function addPhaseTracking() {
console.log('🔍 Finding projects without phase tracking...\n');
const projectsSnapshot = await db.collection('projects').get();
let updatedCount = 0;
let skippedCount = 0;
for (const doc of projectsSnapshot.docs) {
const data = doc.data();
// Skip if already has phase tracking
if (data.currentPhase) {
console.log(`⏭️ Skipping ${data.name} (${doc.id}) - already has phase tracking`);
skippedCount++;
continue;
}
// Add phase tracking
await doc.ref.update({
currentPhase: 'gathering',
phaseStatus: 'not_started',
phaseData: {},
phaseHistory: [],
updatedAt: admin.firestore.FieldValue.serverTimestamp()
});
console.log(`✅ Updated ${data.name} (${doc.id}) - initialized with gathering phase`);
updatedCount++;
}
console.log(`\n📊 Migration Complete:`);
console.log(` Updated: ${updatedCount} projects`);
console.log(` Skipped: ${skippedCount} projects`);
console.log(` Total: ${projectsSnapshot.size} projects\n`);
}
// Run migration
addPhaseTracking()
.then(() => {
console.log('✅ Migration successful!');
process.exit(0);
})
.catch((error) => {
console.error('❌ Migration failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,58 @@
/**
* Migration Script: Add Phase Tracking to Existing Projects
*
* Run with: npx tsx scripts/add-phase-tracking.ts
*/
import { getAdminDb } from '../lib/firebase/admin';
async function addPhaseTracking() {
const adminDb = getAdminDb();
console.log('🔍 Finding projects without phase tracking...\n');
const projectsSnapshot = await adminDb.collection('projects').get();
let updatedCount = 0;
let skippedCount = 0;
for (const doc of projectsSnapshot.docs) {
const data = doc.data();
// Skip if already has phase tracking
if (data.currentPhase) {
console.log(`⏭️ Skipping ${data.name} (${doc.id}) - already has phase tracking`);
skippedCount++;
continue;
}
// Add phase tracking
await doc.ref.update({
currentPhase: 'gathering',
phaseStatus: 'not_started',
phaseData: {},
phaseHistory: [],
updatedAt: new Date()
});
console.log(`✅ Updated ${data.name} (${doc.id}) - initialized with gathering phase`);
updatedCount++;
}
console.log(`\n📊 Migration Complete:`);
console.log(` Updated: ${updatedCount} projects`);
console.log(` Skipped: ${skippedCount} projects`);
console.log(` Total: ${projectsSnapshot.size} projects\n`);
}
// Run migration
addPhaseTracking()
.then(() => {
console.log('✅ Migration successful!');
process.exit(0);
})
.catch((error) => {
console.error('❌ Migration failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,123 @@
# ============================================
# AlloyDB Setup Commands for Cloud Shell
# ============================================
# Copy-paste these one at a time into Cloud Shell
# -------------------------------------------
# STEP 1: Get AlloyDB Private IP
# -------------------------------------------
ALLOYDB_IP=$(gcloud alloydb instances describe vibn-primary \
--cluster=vibn \
--region=northamerica-northeast1 \
--format="value(ipAddress)")
echo "AlloyDB IP: $ALLOYDB_IP"
# -------------------------------------------
# STEP 2: Test Connection
# -------------------------------------------
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=postgres sslmode=disable" -c "\l"
# -------------------------------------------
# STEP 3: Create vibn Database
# -------------------------------------------
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=postgres sslmode=disable" -c "CREATE DATABASE vibn;"
# -------------------------------------------
# STEP 4: Enable Extensions
# -------------------------------------------
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=vibn sslmode=disable" <<EOF
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
SELECT extname, extversion FROM pg_extension WHERE extname IN ('vector', 'uuid-ossp');
EOF
# -------------------------------------------
# STEP 5: Create a temporary SQL file for the schema
# -------------------------------------------
cat > /tmp/knowledge-chunks-schema.sql << 'EOFSCHEMA'
-- Enable required extensions (already done above, but safe to repeat)
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create the knowledge_chunks table
CREATE TABLE IF NOT EXISTS knowledge_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
knowledge_item_id TEXT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding VECTOR(768) NOT NULL,
source_type TEXT,
importance TEXT CHECK (importance IN ('primary', 'supporting', 'irrelevant') OR importance IS NULL),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Standard indexes
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_project_id
ON knowledge_chunks (project_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_knowledge_item_id
ON knowledge_chunks (knowledge_item_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_project_knowledge
ON knowledge_chunks (project_id, knowledge_item_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_item_index
ON knowledge_chunks (knowledge_item_id, chunk_index);
-- Vector similarity index using IVFFlat
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_embedding
ON knowledge_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Auto-update trigger
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_knowledge_chunks_updated_at
BEFORE UPDATE ON knowledge_chunks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
EOFSCHEMA
# -------------------------------------------
# STEP 6: Run the Schema File
# -------------------------------------------
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=vibn sslmode=disable" -f /tmp/knowledge-chunks-schema.sql
# -------------------------------------------
# STEP 7: Verify Everything
# -------------------------------------------
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=vibn sslmode=disable" <<EOF
-- Check table exists
\dt knowledge_chunks
-- Check indexes
\di
-- Count rows (should be 0)
SELECT COUNT(*) FROM knowledge_chunks;
-- Test vector operations
SELECT 1 as test;
EOF
echo ""
echo "✅ AlloyDB setup complete!"
echo ""
echo "Connection string for your .env.local:"
echo "ALLOYDB_HOST=$ALLOYDB_IP"
echo "ALLOYDB_PORT=5432"
echo "ALLOYDB_USER=mark@getacquired.com"
echo "ALLOYDB_PASSWORD="
echo "ALLOYDB_DATABASE=vibn"
echo "ALLOYDB_SSL=false"

View File

@@ -0,0 +1,86 @@
/**
* Quick script to verify session associations in Firestore
*/
import { config } from 'dotenv';
import { resolve } from 'path';
// Load environment variables from .env.local
config({ path: resolve(__dirname, '../.env.local') });
import { getAdminDb } from '../lib/firebase/admin';
async function checkSessionLinks() {
const db = getAdminDb();
console.log('🔍 Checking session associations...\n');
// Get all sessions
const sessionsSnapshot = await db.collection('sessions').get();
console.log(`📊 Total sessions: ${sessionsSnapshot.size}\n`);
// Group by status
const linked: any[] = [];
const unlinked: any[] = [];
sessionsSnapshot.docs.forEach(doc => {
const data = doc.data();
const sessionInfo = {
id: doc.id,
workspaceName: data.workspaceName || 'Unknown',
workspacePath: data.workspacePath,
projectId: data.projectId,
needsProjectAssociation: data.needsProjectAssociation,
createdAt: data.createdAt?.toDate?.() || data.createdAt,
};
if (data.projectId) {
linked.push(sessionInfo);
} else {
unlinked.push(sessionInfo);
}
});
console.log(`✅ Linked to projects: ${linked.length}`);
console.log(`⚠️ Not linked: ${unlinked.length}\n`);
if (linked.length > 0) {
console.log('📌 Linked Sessions:');
linked.forEach(s => {
console.log(` - ${s.workspaceName} → Project: ${s.projectId?.substring(0, 8)}...`);
console.log(` needsProjectAssociation: ${s.needsProjectAssociation}`);
});
console.log('');
}
if (unlinked.length > 0) {
console.log('⚠️ Unlinked Sessions:');
unlinked.forEach(s => {
console.log(` - ${s.workspaceName}`);
console.log(` Path: ${s.workspacePath}`);
console.log(` needsProjectAssociation: ${s.needsProjectAssociation}`);
});
console.log('');
}
// Check projects
const projectsSnapshot = await db.collection('projects').get();
console.log(`📁 Total projects: ${projectsSnapshot.size}\n`);
projectsSnapshot.docs.forEach(doc => {
const data = doc.data();
console.log(` - ${data.productName || data.name || 'Unnamed'}`);
console.log(` ID: ${doc.id}`);
console.log(` Workspace: ${data.workspacePath || 'Not set'}`);
console.log('');
});
process.exit(0);
}
checkSessionLinks().catch(error => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Run database migrations at container startup
# This ensures DATABASE_URL is available from Coolify env vars
echo "Running Prisma DB setup..."
npx prisma db push --accept-data-loss --skip-generate
echo "Database setup complete!"

View File

@@ -0,0 +1,25 @@
-- =============================================================================
-- Make workspace API keys revealable.
--
-- Adds `key_encrypted` — base64 of secret-box(VIBN_SECRETS_KEY, plaintext token).
-- Existing rows keep `key_encrypted = NULL` and are therefore NOT revealable;
-- only the hash was stored at mint time and the plaintext is unrecoverable by
-- design. Those keys still work for auth (hash lookup is unchanged); they just
-- can't surface the plaintext again — the UI will flag them as legacy.
--
-- New keys minted after this migration will populate `key_encrypted` and can
-- be revealed on demand by session-authenticated users (never by API-key
-- principals — prevents lateral movement).
--
-- Safe to re-run.
-- =============================================================================
ALTER TABLE vibn_workspace_api_keys
ADD COLUMN IF NOT EXISTS key_encrypted TEXT;
COMMENT ON COLUMN vibn_workspace_api_keys.key_encrypted IS
'base64( AES-256-GCM encrypt(VIBN_SECRETS_KEY, plaintext vibn_sk_...) ). '
'NULL for legacy rows minted before this column existed — those keys '
'remain valid for auth but cannot be revealed.';
SELECT 'API-key revealability migration complete' AS status;

View File

@@ -0,0 +1,161 @@
#!/usr/bin/env tsx
/**
* One-time migration script to process existing knowledge_items into AlloyDB
*
* This script:
* 1. Fetches all knowledge_items from Firestore
* 2. For each item, chunks and embeds it into AlloyDB
* 3. Shows progress and handles errors gracefully
*
* Usage:
* npx tsx scripts/migrate-existing-knowledge-to-alloydb.ts [projectId]
*
* - If projectId is provided, processes only that project
* - If omitted, processes ALL projects
*/
import { getAdminDb } from '../lib/firebase/admin';
import { writeKnowledgeChunksForItem, getChunkCountForKnowledgeItem } from '../lib/server/vector-memory';
interface KnowledgeItem {
id: string;
projectId: string;
content: string;
sourceMeta?: {
sourceType?: string;
importance?: 'primary' | 'supporting' | 'irrelevant';
};
}
async function getAllKnowledgeItems(projectId?: string): Promise<KnowledgeItem[]> {
const adminDb = getAdminDb();
const items: KnowledgeItem[] = [];
if (projectId) {
// Single project
console.log(`[Migration] Fetching knowledge items for project ${projectId}...`);
const snapshot = await adminDb
.collection('knowledge_items')
.where('projectId', '==', projectId)
.get();
snapshot.forEach((doc) => {
const data = doc.data();
items.push({
id: doc.id,
projectId: data.projectId,
content: data.content || '',
sourceMeta: data.sourceMeta,
});
});
} else {
// All projects
console.log(`[Migration] Fetching ALL knowledge items...`);
const snapshot = await adminDb.collection('knowledge_items').get();
snapshot.forEach((doc) => {
const data = doc.data();
items.push({
id: doc.id,
projectId: data.projectId,
content: data.content || '',
sourceMeta: data.sourceMeta,
});
});
}
return items;
}
async function migrateKnowledgeItems(projectId?: string) {
console.log('');
console.log('='.repeat(60));
console.log('🚀 AlloyDB Knowledge Migration');
console.log('='.repeat(60));
console.log('');
try {
// Fetch all items
const items = await getAllKnowledgeItems(projectId);
console.log(`✅ Found ${items.length} knowledge items to process`);
console.log('');
if (items.length === 0) {
console.log(' No knowledge items found. Nothing to migrate.');
return;
}
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
// Process each item
for (let i = 0; i < items.length; i++) {
const item = items[i];
const progress = `[${i + 1}/${items.length}]`;
try {
// Check if already processed (skip if chunks exist)
const existingChunks = await getChunkCountForKnowledgeItem(item.id);
if (existingChunks > 0) {
console.log(`${progress} ⏭️ Skipping ${item.id} (already has ${existingChunks} chunks)`);
skipCount++;
continue;
}
console.log(`${progress} 🔄 Processing ${item.id}...`);
// Chunk and embed
await writeKnowledgeChunksForItem(item);
const newChunks = await getChunkCountForKnowledgeItem(item.id);
console.log(`${progress} ✅ Success! Created ${newChunks} chunks`);
successCount++;
// Small delay to avoid rate limiting
if (i < items.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
} catch (error) {
console.error(`${progress} ❌ Failed to process ${item.id}:`, error);
errorCount++;
}
}
// Summary
console.log('');
console.log('='.repeat(60));
console.log('📊 Migration Complete');
console.log('='.repeat(60));
console.log(`✅ Processed: ${successCount}`);
console.log(`⏭️ Skipped (already exists): ${skipCount}`);
console.log(`❌ Errors: ${errorCount}`);
console.log(`📦 Total: ${items.length}`);
console.log('');
if (errorCount > 0) {
console.log('⚠️ Some items failed. Check logs above for details.');
process.exit(1);
} else {
console.log('🎉 All knowledge items successfully migrated to AlloyDB!');
process.exit(0);
}
} catch (error) {
console.error('');
console.error('❌ Migration failed:', error);
process.exit(1);
}
}
// Parse command line arguments
const projectId = process.argv[2];
if (projectId) {
console.log(` Processing single project: ${projectId}`);
} else {
console.log(' No projectId provided - processing ALL projects');
}
// Run migration
migrateKnowledgeItems(projectId);

View File

@@ -0,0 +1,373 @@
// MUST load environment variables BEFORE any other imports
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env.local') });
import { Client } from 'pg';
import admin from 'firebase-admin';
import { FieldValue } from 'firebase-admin/firestore';
const PG_CONNECTION_STRING = 'postgresql://postgres:jhsRNOIyjjVfrdvDXnUVcXXXsuzjvcFc@metro.proxy.rlwy.net:30866/railway';
// Initialize Firebase Admin directly
if (!admin.apps.length) {
const privateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n');
if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL || !privateKey) {
throw new Error('Missing Firebase Admin credentials. Check your .env.local file.');
}
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: privateKey,
}),
});
console.log('✅ Firebase Admin initialized successfully');
}
const adminDb = admin.firestore();
const adminAuth = admin.auth();
interface PgUser {
id: number;
email: string;
name: string;
created_at: Date;
settings: any;
}
interface PgProject {
id: number;
client_id: number;
name: string;
workspace_path: string;
status: string;
created_at: Date;
updated_at: Date;
metadata: any;
}
interface PgSession {
id: number;
session_id: string;
project_id: number;
user_id: number;
started_at: Date;
last_updated: Date;
ended_at: Date | null;
status: string;
conversation: any[];
file_changes: any[];
message_count: number;
user_message_count: number;
assistant_message_count: number;
file_change_count: number;
duration_minutes: number;
summary: string | null;
tasks_identified: any[];
decisions_made: any[];
technologies_used: any[];
metadata: any;
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
estimated_cost_usd: number;
model: string;
}
interface PgWorkCompleted {
id: number;
project_id: number;
session_id: number;
title: string;
description: string;
completed_at: Date;
files_modified: any[];
lines_added: number;
lines_removed: number;
metadata: any;
}
interface PgClient {
id: number;
owner_user_id: number;
name: string;
email: string | null;
created_at: Date;
metadata: any;
}
async function migrateUsers(pgClient: Client, userMapping: Map<number, string>) {
console.log('\n📋 Migrating Users...');
const result = await pgClient.query<PgUser>('SELECT * FROM users');
for (const pgUser of result.rows) {
try {
// Create Firebase Auth user
let firebaseUser;
try {
firebaseUser = await adminAuth.getUserByEmail(pgUser.email);
console.log(` ✅ User already exists: ${pgUser.email}`);
} catch {
// User doesn't exist, create them
firebaseUser = await adminAuth.createUser({
email: pgUser.email,
displayName: pgUser.name,
emailVerified: true,
});
console.log(` ✨ Created Firebase Auth user: ${pgUser.email}`);
}
// Store mapping
userMapping.set(pgUser.id, firebaseUser.uid);
// Create user document in Firestore
const workspace = pgUser.email.split('@')[0].replace(/[^a-z0-9]/gi, '-').toLowerCase();
await adminDb.collection('users').doc(firebaseUser.uid).set({
uid: firebaseUser.uid,
email: pgUser.email,
displayName: pgUser.name,
workspace: workspace,
settings: pgUser.settings || {},
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
migratedFrom: 'postgresql',
originalPgId: pgUser.id,
});
console.log(` ✅ Migrated user: ${pgUser.email}${firebaseUser.uid}`);
} catch (error) {
console.error(` ❌ Error migrating user ${pgUser.email}:`, error);
}
}
}
async function migrateClients(pgClient: Client, userMapping: Map<number, string>) {
console.log('\n📋 Migrating Clients...');
const result = await pgClient.query<PgClient>('SELECT * FROM clients');
for (const pgClient of result.rows) {
const firebaseUserId = userMapping.get(pgClient.owner_user_id);
if (!firebaseUserId) {
console.log(` ⚠️ Skipping client ${pgClient.name} - user not found`);
continue;
}
try {
const clientRef = adminDb.collection('clients').doc();
await clientRef.set({
id: clientRef.id,
ownerId: firebaseUserId,
name: pgClient.name,
email: pgClient.email || null,
createdAt: FieldValue.serverTimestamp(),
metadata: pgClient.metadata || {},
migratedFrom: 'postgresql',
originalPgId: pgClient.id,
});
console.log(` ✅ Migrated client: ${pgClient.name}`);
} catch (error) {
console.error(` ❌ Error migrating client ${pgClient.name}:`, error);
}
}
}
async function migrateProjects(pgClient: Client, userMapping: Map<number, string>, projectMapping: Map<number, string>) {
console.log('\n📋 Migrating Projects...');
const result = await pgClient.query<PgProject>('SELECT * FROM projects');
for (const pgProject of result.rows) {
try {
// Get the client to find the owner
const clientResult = await pgClient.query('SELECT owner_user_id FROM clients WHERE id = $1', [pgProject.client_id]);
const firebaseUserId = userMapping.get(clientResult.rows[0]?.owner_user_id);
if (!firebaseUserId) {
console.log(` ⚠️ Skipping project ${pgProject.name} - user not found`);
continue;
}
// Get user's workspace
const userDoc = await adminDb.collection('users').doc(firebaseUserId).get();
const workspace = userDoc.data()?.workspace || 'default-workspace';
const projectRef = adminDb.collection('projects').doc();
await projectRef.set({
id: projectRef.id,
name: pgProject.name,
slug: pgProject.name.toLowerCase().replace(/[^a-z0-9]/g, '-'),
userId: firebaseUserId,
workspace: workspace,
productName: pgProject.name,
productVision: pgProject.metadata?.vision || null,
workspacePath: pgProject.workspace_path,
status: pgProject.status,
isForClient: true,
hasLogo: false,
hasDomain: false,
hasWebsite: false,
hasGithub: false,
hasChatGPT: false,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
metadata: pgProject.metadata || {},
migratedFrom: 'postgresql',
originalPgId: pgProject.id,
});
projectMapping.set(pgProject.id, projectRef.id);
console.log(` ✅ Migrated project: ${pgProject.name}${projectRef.id}`);
} catch (error) {
console.error(` ❌ Error migrating project ${pgProject.name}:`, error);
}
}
}
async function migrateSessions(pgClient: Client, userMapping: Map<number, string>, projectMapping: Map<number, string>) {
console.log('\n📋 Migrating Sessions...');
const result = await pgClient.query<PgSession>('SELECT * FROM sessions ORDER BY started_at');
for (const pgSession of result.rows) {
try {
const firebaseUserId = userMapping.get(pgSession.user_id);
const firebaseProjectId = projectMapping.get(pgSession.project_id);
if (!firebaseUserId) {
console.log(` ⚠️ Skipping session ${pgSession.session_id} - user not found`);
continue;
}
const sessionRef = adminDb.collection('sessions').doc();
await sessionRef.set({
id: sessionRef.id,
userId: firebaseUserId,
projectId: firebaseProjectId || null,
// Session data
startTime: pgSession.started_at,
endTime: pgSession.ended_at || null,
duration: pgSession.duration_minutes * 60, // Convert to seconds
// Project context
workspacePath: null, // Not in old schema
workspaceName: null,
// AI usage
model: pgSession.model,
tokensUsed: pgSession.total_tokens,
promptTokens: pgSession.prompt_tokens,
completionTokens: pgSession.completion_tokens,
cost: parseFloat(String(pgSession.estimated_cost_usd)),
// Context
filesModified: pgSession.file_changes.map((fc: any) => fc.path || fc.file),
conversationSummary: pgSession.summary || null,
conversation: pgSession.conversation || [],
// Additional data from old schema
messageCount: pgSession.message_count,
userMessageCount: pgSession.user_message_count,
assistantMessageCount: pgSession.assistant_message_count,
fileChangeCount: pgSession.file_change_count,
tasksIdentified: pgSession.tasks_identified || [],
decisionsMade: pgSession.decisions_made || [],
technologiesUsed: pgSession.technologies_used || [],
status: pgSession.status,
metadata: pgSession.metadata || {},
createdAt: pgSession.started_at,
updatedAt: pgSession.last_updated,
migratedFrom: 'postgresql',
originalPgId: pgSession.id,
originalSessionId: pgSession.session_id,
});
console.log(` ✅ Migrated session: ${pgSession.session_id}`);
} catch (error) {
console.error(` ❌ Error migrating session ${pgSession.session_id}:`, error);
}
}
}
async function migrateWorkCompleted(pgClient: Client, projectMapping: Map<number, string>) {
console.log('\n📋 Migrating Work Completed...');
const result = await pgClient.query<PgWorkCompleted>('SELECT * FROM work_completed ORDER BY completed_at');
for (const work of result.rows) {
try {
const firebaseProjectId = projectMapping.get(work.project_id);
if (!firebaseProjectId) {
console.log(` ⚠️ Skipping work ${work.title} - project not found`);
continue;
}
const workRef = adminDb.collection('workCompleted').doc();
await workRef.set({
id: workRef.id,
projectId: firebaseProjectId,
sessionId: work.session_id ? `pg-session-${work.session_id}` : null,
title: work.title,
description: work.description,
completedAt: work.completed_at,
filesModified: work.files_modified || [],
linesAdded: work.lines_added || 0,
linesRemoved: work.lines_removed || 0,
metadata: work.metadata || {},
createdAt: work.completed_at,
migratedFrom: 'postgresql',
originalPgId: work.id,
});
console.log(` ✅ Migrated work: ${work.title}`);
} catch (error) {
console.error(` ❌ Error migrating work ${work.title}:`, error);
}
}
}
async function main() {
console.log('🚀 Starting PostgreSQL to Firebase migration...\n');
const pgClient = new Client({
connectionString: PG_CONNECTION_STRING,
});
try {
// Connect to PostgreSQL
console.log('📡 Connecting to PostgreSQL...');
await pgClient.connect();
console.log('✅ Connected to PostgreSQL\n');
// Mappings to track old ID -> new ID
const userMapping = new Map<number, string>();
const projectMapping = new Map<number, string>();
// Migrate in order (respecting foreign keys)
await migrateUsers(pgClient, userMapping);
await migrateClients(pgClient, userMapping);
await migrateProjects(pgClient, userMapping, projectMapping);
await migrateSessions(pgClient, userMapping, projectMapping);
await migrateWorkCompleted(pgClient, projectMapping);
console.log('\n✅ Migration completed successfully!');
console.log('\n📊 Summary:');
console.log(` - Users migrated: ${userMapping.size}`);
console.log(` - Projects migrated: ${projectMapping.size}`);
} catch (error) {
console.error('\n❌ Migration failed:', error);
throw error;
} finally {
await pgClient.end();
console.log('\n📡 Disconnected from PostgreSQL');
}
}
// Run migration
main().catch(console.error);

View File

@@ -0,0 +1,188 @@
-- =============================================================================
-- VIBN fs_* tables + agent_sessions migration
-- Run once against the production Coolify Postgres database.
--
-- These tables back the live app (fs_ prefix = "Firestore-shaped" flexible
-- JSONB rows that replaced the original Firebase collections).
--
-- Safe to re-run — all statements use IF NOT EXISTS / ON CONFLICT.
-- =============================================================================
-- Enable uuid support (safe no-op if already enabled)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ---------------------------------------------------------------------------
-- fs_users (mirrors Firebase Auth + Firestore user docs)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fs_users (
id TEXT PRIMARY KEY, -- gen_random_uuid()::text at insert time
user_id TEXT, -- NextAuth User.id (cuid)
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS fs_users_email_idx
ON fs_users ((data->>'email'));
CREATE INDEX IF NOT EXISTS fs_users_user_id_idx
ON fs_users (user_id);
-- ---------------------------------------------------------------------------
-- fs_projects (Firestore projects collection)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fs_projects (
id TEXT PRIMARY KEY, -- randomUUID() at insert time
user_id TEXT NOT NULL, -- FK → fs_users.id
workspace TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS fs_projects_user_idx
ON fs_projects (user_id);
CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx
ON fs_projects (workspace);
CREATE INDEX IF NOT EXISTS fs_projects_slug_idx
ON fs_projects (slug);
-- ---------------------------------------------------------------------------
-- fs_sessions (AI coding session logs)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fs_sessions (
id TEXT PRIMARY KEY,
user_id TEXT,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS fs_sessions_user_idx
ON fs_sessions (user_id);
CREATE INDEX IF NOT EXISTS fs_sessions_project_idx
ON fs_sessions ((data->>'projectId'));
-- ---------------------------------------------------------------------------
-- agent_sessions (vibn-agent-runner execution records)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS agent_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL, -- fs_projects.id (TEXT)
app_name TEXT NOT NULL,
app_path TEXT NOT NULL,
task TEXT NOT NULL,
plan JSONB,
status TEXT NOT NULL DEFAULT 'pending',
output JSONB NOT NULL DEFAULT '[]'::jsonb,
changed_files JSONB NOT NULL DEFAULT '[]'::jsonb,
error TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS agent_sessions_project_idx
ON agent_sessions (project_id, created_at DESC);
CREATE INDEX IF NOT EXISTS agent_sessions_status_idx
ON agent_sessions (status);
-- ---------------------------------------------------------------------------
-- agent_session_events (append-only timeline for SSE + replay)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS agent_session_events (
id BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
project_id TEXT NOT NULL,
seq INT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
client_event_id UUID UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(session_id, seq)
);
CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx
ON agent_session_events (session_id, seq);
-- ---------------------------------------------------------------------------
-- NextAuth / Prisma tables (required by PrismaAdapter + strategy:"database")
-- Only created if not already present from a prisma migrate run.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT,
email TEXT UNIQUE,
email_verified TIMESTAMPTZ,
image TEXT
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
provider TEXT NOT NULL,
provider_account_id TEXT NOT NULL,
refresh_token TEXT,
access_token TEXT,
expires_at INTEGER,
token_type TEXT,
scope TEXT,
id_token TEXT,
session_state TEXT,
UNIQUE (provider, provider_account_id)
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
session_token TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS verification_tokens (
identifier TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires TIMESTAMPTZ NOT NULL,
UNIQUE (identifier, token)
);
-- ---------------------------------------------------------------------------
-- fs_chat_threads (Vibn AI chat conversation threads)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fs_chat_threads (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL,
workspace TEXT NOT NULL DEFAULT '',
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS fs_chat_threads_user_ws_idx
ON fs_chat_threads (user_id, workspace, updated_at DESC);
-- ---------------------------------------------------------------------------
-- fs_chat_messages (individual messages within a thread)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fs_chat_messages (
id BIGSERIAL PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES fs_chat_threads(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS fs_chat_messages_thread_idx
ON fs_chat_messages (thread_id, created_at ASC);
-- Done
SELECT 'Migration complete' AS status;

View File

@@ -0,0 +1,39 @@
-- =============================================================================
-- VIBN P5.3 — per-workspace GCS storage columns on vibn_workspaces
--
-- Adds the columns that ensureWorkspaceGcsProvisioned() persists into:
--
-- gcp_service_account_email — workspace's dedicated GCP SA, e.g.
-- vibn-ws-mark@master-ai-484822.iam.gserviceaccount.com
-- gcp_service_account_key_enc — base64( secret-box(SA JSON keyfile) ).
-- Currently only used for runtime auth from app
-- code (env injection); control-plane auth still
-- uses GOOGLE_SERVICE_ACCOUNT_KEY_B64.
-- gcs_default_bucket_name — globally-unique GCS bucket created on first
-- provision, e.g. vibn-ws-mark-a3f9c1.
-- gcs_hmac_access_id — S3-compatible HMAC access key id (plain text;
-- not a secret on its own).
-- gcs_hmac_secret_enc — base64( secret-box(HMAC secret) ). Decrypted
-- only when STORAGE_SECRET_ACCESS_KEY needs to be
-- injected into a Coolify app.
-- gcp_provision_status — independent of provision_status so a partial
-- GCP failure does not flip the whole workspace.
-- Values: 'pending' | 'partial' | 'ready' | 'error'.
-- gcp_provision_error — last error message from the GCP provisioner.
--
-- Safe to re-run.
-- =============================================================================
ALTER TABLE vibn_workspaces
ADD COLUMN IF NOT EXISTS gcp_service_account_email TEXT,
ADD COLUMN IF NOT EXISTS gcp_service_account_key_enc TEXT,
ADD COLUMN IF NOT EXISTS gcs_default_bucket_name TEXT,
ADD COLUMN IF NOT EXISTS gcs_hmac_access_id TEXT,
ADD COLUMN IF NOT EXISTS gcs_hmac_secret_enc TEXT,
ADD COLUMN IF NOT EXISTS gcp_provision_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS gcp_provision_error TEXT;
CREATE INDEX IF NOT EXISTS vibn_workspaces_gcp_status_idx
ON vibn_workspaces (gcp_provision_status);
SELECT 'P5.3 workspace-GCS migration complete' AS status;

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
/**
* Prisma CLI only auto-loads `.env`, not `.env.local` (Next.js does load .env.local).
* This loads both so `npm run db:push` targets the same DATABASE_URL as `next dev`.
*/
import { config } from "dotenv";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
config({ path: path.join(root, ".env") });
config({ path: path.join(root, ".env.local"), override: true });
const extra = process.argv.slice(2);
const r = spawnSync("npx", ["prisma", "db", "push", ...extra], {
stdio: "inherit",
cwd: root,
env: process.env,
shell: process.platform === "win32",
});
process.exit(r.status ?? 1);

View File

@@ -0,0 +1,86 @@
/**
* One-shot: run ensureWorkspaceGcsProvisioned() for a specific workspace
* slug against PROD GCP + PROD Postgres. Idempotent — safe to re-run.
*
* Unlike scripts/smoke-storage-e2e.ts this does NOT clean up; the whole
* point is to persist the workspace's provisioned state into the DB.
*
* Usage:
* cd vibn-frontend
* npx -y dotenv-cli -e ../.google.env -e .env.local -- \
* npx tsx scripts/provision-workspace-gcs.ts <slug>
*
* Required env:
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 (from ../.google.env)
* DATABASE_URL (from .env.local, points at prod vibn-postgres)
* VIBN_SECRETS_KEY (from .env.local, ≥16 chars)
*/
import { queryOne } from '../lib/db-postgres';
import { ensureWorkspaceGcsProvisioned } from '../lib/workspace-gcs';
import type { VibnWorkspace } from '../lib/workspaces';
async function main(): Promise<void> {
const slug = process.argv[2];
if (!slug) {
console.error('Usage: tsx scripts/provision-workspace-gcs.ts <workspace-slug>');
process.exit(2);
}
console.log('━'.repeat(72));
console.log(` Provision GCS for workspace: ${slug}`);
console.log('━'.repeat(72));
// Fetch the current row.
const ws = await queryOne<VibnWorkspace>(
`SELECT * FROM vibn_workspaces WHERE slug = $1`,
[slug],
);
if (!ws) {
console.error(`No vibn_workspaces row found for slug=${slug}`);
process.exit(1);
}
console.log(` id : ${ws.id}`);
console.log(` name : ${ws.name}`);
console.log(` owner_user_id : ${ws.owner_user_id}`);
// @ts-expect-error — new columns not yet in VibnWorkspace type
console.log(` gcp_status : ${ws.gcp_provision_status ?? 'pending'}`);
console.log('');
console.log('Running ensureWorkspaceGcsProvisioned()…');
const result = await ensureWorkspaceGcsProvisioned(ws);
console.log('');
console.log('━'.repeat(72));
console.log(' RESULT');
console.log('━'.repeat(72));
console.log(` status : ${result.status}`);
console.log(` SA : ${result.serviceAccountEmail}`);
console.log(` bucket : ${result.bucket.name}`);
console.log(` location : ${result.bucket.location}`);
console.log(` created : ${result.bucket.timeCreated ?? '(pre-existing)'}`);
console.log(` HMAC accessId : ${result.hmac.accessId}`);
console.log('');
// Re-read to confirm persistence.
const after = await queryOne<Record<string, unknown>>(
`SELECT gcp_service_account_email,
CASE WHEN gcp_service_account_key_enc IS NOT NULL THEN '<enc '||length(gcp_service_account_key_enc)||' b64>' ELSE 'null' END AS sa_key,
gcs_default_bucket_name,
gcs_hmac_access_id,
CASE WHEN gcs_hmac_secret_enc IS NOT NULL THEN '<enc '||length(gcs_hmac_secret_enc)||' b64>' ELSE 'null' END AS hmac_secret,
gcp_provision_status,
gcp_provision_error
FROM vibn_workspaces WHERE id = $1`,
[ws.id],
);
console.log('DB row after:');
console.log(JSON.stringify(after, null, 2));
process.exit(0);
}
main().catch(err => {
console.error('[provision-workspace-gcs] FAILED:', err);
process.exit(1);
});

View File

@@ -0,0 +1,133 @@
// MUST load environment variables BEFORE any other imports
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env.local') });
import admin from 'firebase-admin';
import { FieldValue } from 'firebase-admin/firestore';
// Initialize Firebase Admin directly
if (!admin.apps.length) {
const privateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n');
if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL || !privateKey) {
throw new Error('Missing Firebase Admin credentials. Check your .env.local file.');
}
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: privateKey,
}),
});
console.log('✅ Firebase Admin initialized successfully');
}
const adminDb = admin.firestore();
const adminAuth = admin.auth();
async function reassignMigratedData() {
console.log('🚀 Starting data reassignment...\n');
try {
// Get the current user (mark@getacquired.com)
console.log('📋 Finding current user: mark@getacquired.com');
const currentUser = await adminAuth.getUserByEmail('mark@getacquired.com');
console.log(`✅ Current user found: ${currentUser.uid}\n`);
// Get the migrated user (mark@example.com)
console.log('📋 Finding migrated user: mark@example.com');
let migratedUser;
try {
migratedUser = await adminAuth.getUserByEmail('mark@example.com');
console.log(`✅ Migrated user found: ${migratedUser.uid}\n`);
} catch (error) {
console.log('⚠️ Migrated user not found, will look for any migrated data by flag\n');
}
// Reassign all collections
const collections = ['sessions', 'projects', 'clients', 'workCompleted'];
for (const collectionName of collections) {
console.log(`\n📋 Processing ${collectionName}...`);
// Query for migrated documents
let query = adminDb.collection(collectionName).where('migratedFrom', '==', 'postgresql');
const snapshot = await query.get();
if (snapshot.empty) {
console.log(` No migrated ${collectionName} found`);
continue;
}
console.log(` Found ${snapshot.size} migrated ${collectionName}`);
// Update each document
const batch = adminDb.batch();
let count = 0;
for (const doc of snapshot.docs) {
const data = doc.data();
const updates: any = {
updatedAt: FieldValue.serverTimestamp(),
};
// Update userId field
if (data.userId) {
updates.userId = currentUser.uid;
}
// Update ownerId field (for clients)
if (data.ownerId) {
updates.ownerId = currentUser.uid;
}
batch.update(doc.ref, updates);
count++;
// Commit batch every 500 documents (Firestore limit)
if (count % 500 === 0) {
await batch.commit();
console.log(` ✅ Committed ${count} updates...`);
}
}
// Commit remaining
if (count % 500 !== 0) {
await batch.commit();
}
console.log(` ✅ Reassigned ${count} ${collectionName} to ${currentUser.email}`);
}
// Delete the temporary migrated user if it exists
if (migratedUser) {
console.log('\n📋 Cleaning up temporary migrated user account...');
// Delete the user document
await adminDb.collection('users').doc(migratedUser.uid).delete();
// Delete the Auth account
await adminAuth.deleteUser(migratedUser.uid);
console.log('✅ Temporary user account deleted');
}
console.log('\n✅ Data reassignment completed successfully!');
console.log(`\n🎉 All migrated data is now assigned to: ${currentUser.email}`);
} catch (error) {
console.error('\n❌ Data reassignment failed:', error);
throw error;
}
}
// Run reassignment
reassignMigratedData()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
#!/bin/bash
# Run this script in Google Cloud Shell to set up AlloyDB
# Cloud Shell has VPC access, so it can connect to AlloyDB directly
set -e
echo "🚀 AlloyDB Setup via Cloud Shell"
echo "=================================="
echo ""
# Set project
PROJECT_ID="gen-lang-client-0980079410"
REGION="northamerica-northeast1"
CLUSTER="vibn"
INSTANCE="vibn-primary"
USER="mark@getacquired.com"
echo "Project: $PROJECT_ID"
echo "Cluster: $CLUSTER"
echo "Instance: $INSTANCE"
echo ""
# Get AlloyDB private IP
echo "📍 Getting AlloyDB private IP..."
ALLOYDB_IP=$(gcloud alloydb instances describe $INSTANCE \
--cluster=$CLUSTER \
--region=$REGION \
--project=$PROJECT_ID \
--format="value(ipAddress)")
echo "✅ AlloyDB IP: $ALLOYDB_IP"
echo ""
# Install psql if needed
if ! command -v psql &> /dev/null; then
echo "📦 Installing PostgreSQL client..."
sudo apt-get update && sudo apt-get install -y postgresql-client
fi
echo "✅ PostgreSQL client ready"
echo ""
# Connect and check databases
echo "🔍 Checking existing databases..."
psql "host=$ALLOYDB_IP port=5432 user=$USER dbname=postgres sslmode=disable" -c "\l"
echo ""
read -p "Create 'vibn' database? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "📝 Creating vibn database..."
psql "host=$ALLOYDB_IP port=5432 user=$USER dbname=postgres sslmode=disable" \
-c "CREATE DATABASE vibn;"
echo "✅ Database created"
fi
echo ""
echo "🔌 Enabling extensions..."
psql "host=$ALLOYDB_IP port=5432 user=$USER dbname=vibn sslmode=disable" <<EOF
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
SELECT extname, extversion FROM pg_extension WHERE extname IN ('vector', 'uuid-ossp');
EOF
echo ""
echo "✅ Extensions installed!"
echo ""
echo "📋 Next: Upload and run the schema file"
echo ""
echo "1. Upload lib/db/knowledge-chunks-schema.sql to Cloud Shell"
echo "2. Run: psql \"host=$ALLOYDB_IP port=5432 user=$USER dbname=vibn sslmode=disable\" -f knowledge-chunks-schema.sql"
echo ""
echo "Or copy-paste the SQL manually into psql"

View File

@@ -0,0 +1,180 @@
#!/bin/bash
# AlloyDB Setup Script for Vibn
# This script helps you configure AlloyDB with a service account
set -e # Exit on error
echo "🚀 AlloyDB Setup for Vibn"
echo "=========================="
echo ""
# Get project ID
PROJECT_ID=$(gcloud config get-value project 2>/dev/null)
if [ -z "$PROJECT_ID" ]; then
echo "❌ No GCP project configured. Run: gcloud config set project YOUR_PROJECT_ID"
exit 1
fi
echo "📋 Project: $PROJECT_ID"
echo ""
# Prompt for cluster details
read -p "Enter your AlloyDB cluster name: " CLUSTER_NAME
read -p "Enter your AlloyDB region [us-central1]: " REGION
REGION=${REGION:-us-central1}
read -p "Enter your AlloyDB instance name [${CLUSTER_NAME}-primary]: " INSTANCE_NAME
INSTANCE_NAME=${INSTANCE_NAME:-${CLUSTER_NAME}-primary}
echo ""
echo "Configuration:"
echo " Cluster: $CLUSTER_NAME"
echo " Region: $REGION"
echo " Instance: $INSTANCE_NAME"
echo ""
read -p "Continue? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
# Create service account
echo ""
echo "📝 Step 1: Creating service account..."
SA_NAME="vibn-alloydb-client"
SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
if gcloud iam service-accounts describe $SA_EMAIL &>/dev/null; then
echo "✅ Service account already exists: $SA_EMAIL"
else
gcloud iam service-accounts create $SA_NAME \
--display-name="Vibn AlloyDB Client" \
--description="Service account for Vibn app to access AlloyDB"
echo "✅ Created service account: $SA_EMAIL"
fi
# Grant permissions
echo ""
echo "🔑 Step 2: Granting permissions..."
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/alloydb.client" \
--condition=None \
--quiet
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/compute.networkUser" \
--condition=None \
--quiet
echo "✅ Granted AlloyDB client and network user roles"
# Create IAM database user
echo ""
echo "👤 Step 3: Creating IAM database user..."
if gcloud alloydb users list \
--cluster=$CLUSTER_NAME \
--instance=$INSTANCE_NAME \
--region=$REGION \
--filter="name:${SA_EMAIL}" \
--format="value(name)" 2>/dev/null | grep -q "${SA_EMAIL}"; then
echo "✅ IAM user already exists"
else
gcloud alloydb users create $SA_EMAIL \
--instance=$INSTANCE_NAME \
--cluster=$CLUSTER_NAME \
--region=$REGION \
--type=IAM_BASED
echo "✅ Created IAM database user"
fi
# Download service account key
echo ""
echo "🔐 Step 4: Downloading service account key..."
KEY_FILE="$HOME/vibn-alloydb-key.json"
if [ -f "$KEY_FILE" ]; then
read -p "Key file already exists. Overwrite? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Skipping key download"
else
gcloud iam service-accounts keys create $KEY_FILE \
--iam-account=$SA_EMAIL
chmod 600 $KEY_FILE
echo "✅ Key saved to: $KEY_FILE"
fi
else
gcloud iam service-accounts keys create $KEY_FILE \
--iam-account=$SA_EMAIL
chmod 600 $KEY_FILE
echo "✅ Key saved to: $KEY_FILE"
fi
# Get AlloyDB instance URI
INSTANCE_URI="projects/${PROJECT_ID}/locations/${REGION}/clusters/${CLUSTER_NAME}/instances/${INSTANCE_NAME}"
echo ""
echo "🎉 Setup Complete!"
echo "=================="
echo ""
echo "Next steps:"
echo ""
echo "1. Add to your .env.local:"
echo " ALLOYDB_HOST=127.0.0.1"
echo " ALLOYDB_PORT=5432"
echo " ALLOYDB_USER=${SA_EMAIL}"
echo " ALLOYDB_PASSWORD="
echo " ALLOYDB_DATABASE=vibn"
echo " ALLOYDB_SSL=false"
echo " GOOGLE_APPLICATION_CREDENTIALS=${KEY_FILE}"
echo ""
echo "2. Start AlloyDB Auth Proxy (in a separate terminal):"
echo " alloydb-auth-proxy \\"
echo " --credentials-file=${KEY_FILE} \\"
echo " --port=5432 \\"
echo " ${INSTANCE_URI}"
echo ""
echo "3. Create database and run schema:"
echo " psql \"host=127.0.0.1 port=5432 user=${SA_EMAIL}\" -c 'CREATE DATABASE vibn;'"
echo " psql \"host=127.0.0.1 port=5432 dbname=vibn user=${SA_EMAIL}\" \\"
echo " -f lib/db/knowledge-chunks-schema.sql"
echo ""
echo "4. Test connection:"
echo " npm run test:db"
echo ""
# Optionally create .env.local entry
read -p "Add these to .env.local now? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
ENV_FILE=".env.local"
# Backup existing .env.local
if [ -f "$ENV_FILE" ]; then
cp $ENV_FILE "${ENV_FILE}.backup"
echo "📦 Backed up existing .env.local"
fi
# Append AlloyDB config
cat >> $ENV_FILE << EOF
# AlloyDB Configuration (added by setup script)
ALLOYDB_HOST=127.0.0.1
ALLOYDB_PORT=5432
ALLOYDB_USER=${SA_EMAIL}
ALLOYDB_PASSWORD=
ALLOYDB_DATABASE=vibn
ALLOYDB_SSL=false
GOOGLE_APPLICATION_CREDENTIALS=${KEY_FILE}
EOF
echo "✅ Added AlloyDB config to .env.local"
fi
echo ""
echo "📚 For full guide, see: SETUP_ALLOYDB_SERVICE_ACCOUNT.md"

View File

@@ -0,0 +1,296 @@
/**
* End-to-end attach smoke against PROD infrastructure.
*
* What this exercises (against real Cloud DNS + sandbox OpenSRS + real Coolify):
* 1. registerDomain → OpenSRS Horizon sandbox creates a fake but
* protocol-correct registration (no $$).
* 2. cloudDnsProvider.createZone → real public managed zone in
* master-ai-484822 (anycast DNS, costs ~$0.20/mo
* while it lives — we delete on cleanup).
* 3. cloudDnsProvider.setRecords → A records for apex + www → 34.19.250.135.
* 4. updateDomainNameservers → tells the OpenSRS sandbox to point the test
* domain at the Cloud DNS NS set. Triggers the
* unlock/relock fallback if the registry returns
* 405 — proves the recovery path works.
* 5. Coolify PATCH → adds the new fqdn to a real Coolify app's
* domain list (target: missinglettr-test, which
* is already on a sslip.io URL so no SSL/Traefik
* fallout from a vanity hostname).
*
* What it deliberately SKIPS:
* - DB writes (vibn_domains row + events) — those require prod DB which is
* on the internal Docker network. The lib code that does those writes is
* covered by unit-style call sites; what we're proving here is the
* external-side-effect surface that's hard to test from CI.
*
* Cleanup (best-effort) at the end:
* - Removes the test fqdn from the Coolify app's domain list.
* - Deletes the Cloud DNS managed zone.
*
* The sandbox OpenSRS domain expires on its own — no cleanup needed.
*
* Required env (load from .opensrs.env + .coolify.env + .gcp.env):
* OPENSRS_RESELLER_USERNAME, OPENSRS_API_KEY_TEST, OPENSRS_MODE=test
* COOLIFY_URL, COOLIFY_API_TOKEN
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 (or GOOGLE_APPLICATION_CREDENTIALS pointing at the JSON)
* GCP_PROJECT_ID (defaults to master-ai-484822)
*
* Usage:
* set -a && source ../.opensrs.env && source ../.coolify.env && source ../.gcp.env && set +a
* npx tsx scripts/smoke-attach-e2e.ts
*/
import { registerDomain, updateDomainNameservers, setDomainLock, OpenSrsError, type RegistrationContact } from '../lib/opensrs';
import { cloudDnsProvider } from '../lib/dns/cloud-dns';
import { getGcpAccessToken, GCP_PROJECT_ID } from '../lib/gcp-auth';
const COOLIFY_URL = process.env.COOLIFY_URL ?? '';
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
// missinglettr-test — pre-existing sslip.io test app on the Vibn server.
// Safe target: no public DNS, no Let's Encrypt issuance for vanity hostnames.
const TARGET_APP_UUID = 'nksoo0cscw48gwo4ggc4g4kk';
const TARGET_APP_PUBLIC_IP = '34.19.250.135';
const CONTACT: RegistrationContact = {
first_name: 'Mark',
last_name: 'Henderson',
org_name: 'Get Acquired Inc',
address1: '123 King St W',
city: 'Toronto',
state: 'ON',
country: 'CA',
postal_code: 'M5H 1A1',
phone: '+1.4165551234',
email: 'mark@getacquired.com',
};
interface CoolifyApp {
uuid: string;
name: string;
fqdn?: string;
domains?: string;
}
async function coolifyGetApp(uuid: string): Promise<CoolifyApp> {
const res = await fetch(`${COOLIFY_URL}/api/v1/applications/${uuid}`, {
headers: { Authorization: `Bearer ${COOLIFY_API_TOKEN}` },
});
if (!res.ok) throw new Error(`coolify GET app ${uuid}${res.status}: ${await res.text()}`);
return res.json() as Promise<CoolifyApp>;
}
async function coolifySetAppDomains(uuid: string, domainsCsv: string): Promise<void> {
// Mirror the production lib/coolify.ts setApplicationDomains() body.
// Send `domains` (the controller maps it to the DB's `fqdn` column when the
// destination server has Server::isProxyShouldRun() === true — i.e.
// proxy.type=TRAEFIK|CADDY AND is_build_server=false). If the PATCH
// returns 200 but the fqdn doesn't change, the destination server's
// proxy/build-server config is the most likely cause.
const res = await fetch(`${COOLIFY_URL}/api/v1/applications/${uuid}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
domains: domainsCsv,
force_domain_override: true,
is_force_https_enabled: true,
}),
});
if (!res.ok) throw new Error(`coolify PATCH app ${uuid}${res.status}: ${await res.text()}`);
}
function parseDomains(s: string | undefined): string[] {
if (!s) return [];
return s
.split(/[,\s]+/)
.map(x => x.trim())
.filter(Boolean);
}
async function main() {
// Reject anything that would let us hit live OpenSRS by accident.
const mode = process.env.OPENSRS_MODE ?? 'test';
if (mode !== 'test') {
throw new Error(`Refusing to run with OPENSRS_MODE=${mode}. Set OPENSRS_MODE=test for sandbox-proof.`);
}
if (!COOLIFY_URL || !COOLIFY_API_TOKEN) throw new Error('COOLIFY_URL + COOLIFY_API_TOKEN required');
if (!process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64 && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_B64 (or GOOGLE_APPLICATION_CREDENTIALS) required');
}
const apex = `vibnai-e2e-${Date.now()}.com`;
const subs = ['@', 'www'];
const fqdns = subs.map(s => (s === '@' ? `https://${apex}` : `https://${s}.${apex}`));
console.log('━'.repeat(70));
console.log(' VIBN P5.1 attach end-to-end (PROD GCP + sandbox OpenSRS + PROD Coolify)');
console.log('━'.repeat(70));
console.log(' apex :', apex);
console.log(' target : Coolify app', TARGET_APP_UUID, '(missinglettr-test)');
console.log(' target IP :', TARGET_APP_PUBLIC_IP);
console.log('');
// ── Step 0: capture the Coolify app's current domains so we can restore later
console.log('[0/6] Snapshot Coolify app current state…');
const appBefore = await coolifyGetApp(TARGET_APP_UUID);
const domainsBefore = parseDomains(appBefore.domains ?? appBefore.fqdn ?? '');
console.log(' app name :', appBefore.name);
console.log(' domains before :', domainsBefore.length, '→', JSON.stringify(domainsBefore.slice(0, 3)));
// ── Step 1: register sandbox domain
console.log('\n[1/6] Register sandbox domain via OpenSRS Horizon…');
const reg = await registerDomain({ domain: apex, period: 1, contact: CONTACT, whoisPrivacy: true });
console.log(' ✓ orderId :', reg.orderId);
console.log(' ✓ responseCode :', reg.responseCode, '/', reg.responseText);
// ── Step 2: create Cloud DNS managed zone
console.log('\n[2/6] Create Cloud DNS managed zone (PROD master-ai-484822)…');
const zone = await cloudDnsProvider.createZone(apex);
console.log(' ✓ zoneId :', zone.zoneId);
console.log(' ✓ nameservers :', zone.nameservers.join(', '));
// ── Step 3: write A records (apex + www → 34.19.250.135)
console.log('\n[3/6] Write A records to Cloud DNS…');
await cloudDnsProvider.setRecords(apex, [
{ name: '@', type: 'A', rrdatas: [TARGET_APP_PUBLIC_IP], ttl: 300 },
{ name: 'www', type: 'A', rrdatas: [TARGET_APP_PUBLIC_IP], ttl: 300 },
]);
console.log(' ✓ A @ →', TARGET_APP_PUBLIC_IP);
console.log(' ✓ A www →', TARGET_APP_PUBLIC_IP);
// ── Step 4: update registrar nameservers (with unlock/relock fallback)
//
// 4a. Try with real Cloud DNS nameservers. In sandbox mode this is expected
// to fail with 480 ("nameserver doesn't exist at the registry") because
// OpenSRS Horizon mocks a registry that only knows pre-seeded NS hosts.
// The point of trying anyway is to prove the trailing-dot normalization
// works (otherwise we'd see a 460 "Invalid nameserver host" before the
// registry even gets consulted). 480 == validation passed, registry
// check failed → in live mode, real registries accept any resolvable NS.
//
// 4b. Re-run against `ns{1,2}.systemdns.com` (which Horizon recognizes) so
// we also exercise the success path including the unlock/relock fallback.
console.log('\n[4/6] Update registrar nameservers via OpenSRS…');
let cloudNsResult: { responseCode: string; responseText: string } | string;
try {
cloudNsResult = await updateDomainNameservers(apex, zone.nameservers);
console.log(' ✓ 4a real Cloud DNS NS accepted:', cloudNsResult.responseCode);
} catch (err) {
if (err instanceof OpenSrsError && err.code === '480') {
cloudNsResult = '480-sandbox-mock-registry-rejected (expected — live mode uses real registry)';
console.log(' ✓ 4a Cloud DNS NS validation passed; sandbox mock-registry rejection (480) is expected');
} else {
throw err;
}
}
console.log(' → 4b retry with sandbox-recognized NS (ns{1,2}.systemdns.com) to exercise success path…');
const sandboxNs = ['ns1.systemdns.com', 'ns2.systemdns.com'];
let nsResult: { responseCode: string; responseText: string } | null = null;
let nsPath: 'direct' | 'unlock-retry-relock' = 'direct';
try {
nsResult = await updateDomainNameservers(apex, sandboxNs);
console.log(' ✓ 4b direct NS update succeeded:', nsResult.responseCode);
} catch (err) {
if (
err instanceof OpenSrsError &&
err.code === '405' &&
/status prohibits operation/i.test(err.message)
) {
console.log(' ↻ 4b 405 lock conflict → unlocking, retrying, then relocking');
nsPath = 'unlock-retry-relock';
await setDomainLock(apex, false);
nsResult = await updateDomainNameservers(apex, sandboxNs);
console.log(' ✓ 4b retry NS update succeeded:', nsResult.responseCode);
try {
const relock = await setDomainLock(apex, true);
console.log(' ✓ 4b relocked :', relock.responseCode);
} catch (relockErr) {
console.warn(' ⚠ relock failed (non-fatal):', relockErr instanceof Error ? relockErr.message : relockErr);
}
} else {
throw err;
}
}
// ── Step 5: PATCH Coolify app domain list
console.log('\n[5/6] Add fqdns to Coolify app domain list…');
const merged = Array.from(new Set([...domainsBefore, ...fqdns]));
await coolifySetAppDomains(TARGET_APP_UUID, merged.join(','));
const appAfter = await coolifyGetApp(TARGET_APP_UUID);
const domainsAfter = parseDomains(appAfter.domains ?? appAfter.fqdn ?? '');
console.log(' ✓ PATCH accepted (HTTP 200, no error)');
console.log(' ✓ app domains now:', domainsAfter.length);
let coolifyApplied = true;
for (const fq of fqdns) {
const present = domainsAfter.includes(fq);
console.log(' ', present ? '✓' : '✗', fq);
if (!present) coolifyApplied = false;
}
if (!coolifyApplied) {
console.log(' ✗ Coolify still did not persist the fqdn — unexpected. Previously this was');
console.log(' caused by sending `domains` instead of `fqdn` (API alias vs model column).');
console.log(' We now send `fqdn`; if this branch fires, dig into the PATCH response body.');
}
// ── Step 6: cleanup
console.log('\n[6/6] Cleanup (remove fqdns from Coolify, delete Cloud DNS zone)…');
await coolifySetAppDomains(TARGET_APP_UUID, domainsBefore.join(','));
console.log(' ✓ Coolify domains restored');
// Cloud DNS rejects deleteZone if any non-SOA/NS rrsets remain. List them
// and submit a deletion change for everything except SOA + the apex NS.
const zoneApiName = `vibn-${apex.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
const token = await getGcpAccessToken();
const listRes = await fetch(
`https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}/managedZones/${zoneApiName}/rrsets`,
{ headers: { Authorization: `Bearer ${token}` } },
);
const listJson = (await listRes.json()) as { rrsets?: Array<{ name: string; type: string; ttl?: number; rrdatas: string[] }> };
const dnsName = `${apex}.`;
const toDelete = (listJson.rrsets ?? []).filter(rs => !(rs.name === dnsName && (rs.type === 'SOA' || rs.type === 'NS')));
if (toDelete.length > 0) {
const dropRes = await fetch(
`https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}/managedZones/${zoneApiName}/changes`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'dns#change', deletions: toDelete }),
},
);
if (!dropRes.ok) console.warn(' ⚠ rrset cleanup failed:', dropRes.status, (await dropRes.text()).slice(0, 200));
else console.log(' ✓ dropped', toDelete.length, 'rrset(s)');
}
await cloudDnsProvider.deleteZone(apex);
console.log(' ✓ Cloud DNS zone deleted');
console.log('\n━'.repeat(35));
console.log(' SUMMARY');
console.log('━'.repeat(70));
console.log(' OpenSRS register : ✓ orderId', reg.orderId);
console.log(' Cloud DNS zone : ✓', zone.zoneId);
console.log(' Cloud DNS rrsets : ✓ A @ + A www');
console.log(' Registrar NS (real) : ' + (typeof cloudNsResult === 'string' ? cloudNsResult : '✓ ' + cloudNsResult.responseCode));
console.log(' Registrar NS (mock) : ✓ via', nsPath, '(', nsResult?.responseCode, ')');
console.log(' Coolify domain PATCH : ' + (coolifyApplied
? '✓ added ' + fqdns.length + ' fqdns'
: '✗ fqdn did not persist — PATCH returned 200 but column unchanged. '
+ 'Check destination server: proxy.type must be TRAEFIK|CADDY and '
+ 'is_build_server must be false (Server::isProxyShouldRun()).'));
console.log(' Cleanup : ✓ Coolify restored, zone deleted');
console.log('');
if (coolifyApplied) {
console.log(' ALL 5 PROD SUB-SYSTEMS PROVEN END-TO-END.');
} else {
console.log(' 4 of 5 PROD SUB-SYSTEMS PROVEN. Coolify fqdn update still failing —');
console.log(' inspect the PATCH response body + Application.$fillable in the running image.');
}
}
main().catch(err => {
console.error('\n[smoke-attach-e2e] FAILED:', err);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
/**
* Smoke test: validate VIBN_TOOL_DEFINITIONS against the live Gemini API.
*
* Sends the full tool list with a trivial prompt and checks whether Gemini
* accepts the schemas. Catches schema validation errors (ARRAY without items,
* free OBJECT params, etc.) before we deploy.
*
* Usage: GOOGLE_API_KEY=... npx tsx scripts/smoke-chat-tools.ts
* (also picks up GOOGLE_API_KEY from .env.local automatically)
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { VIBN_TOOL_DEFINITIONS } from '../lib/ai/vibn-tools';
// Load .env.local manually to avoid needing dotenv as a dep
try {
const envPath = join(process.cwd(), '.env.local');
const envText = readFileSync(envPath, 'utf-8');
for (const line of envText.split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
}
} catch {}
const API_KEY = process.env.GOOGLE_API_KEY;
const MODEL = process.env.VIBN_CHAT_MODEL || 'gemini-3.1-pro-preview';
if (!API_KEY) {
console.error('Missing GOOGLE_API_KEY');
process.exit(1);
}
async function validateAll() {
console.log(`Validating ${VIBN_TOOL_DEFINITIONS.length} tool definitions against ${MODEL}...\n`);
const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`;
const body = {
contents: [{ role: 'user', parts: [{ text: 'Show me what is running in my workspace.' }] }],
tools: [{ functionDeclarations: VIBN_TOOL_DEFINITIONS }],
generationConfig: { temperature: 0.0, maxOutputTokens: 200 },
};
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
console.error(`\n❌ FAIL: HTTP ${res.status}`);
console.error(JSON.stringify(data, null, 2));
// Try to identify which tools are bad by sending them one at a time
console.log('\n🔍 Bisecting to find broken tools...\n');
const bad: { name: string; error: string }[] = [];
for (const tool of VIBN_TOOL_DEFINITIONS) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
tools: [{ functionDeclarations: [tool] }],
generationConfig: { temperature: 0.0, maxOutputTokens: 10 },
}),
});
if (!r.ok) {
const err = await r.json();
const msg = err?.error?.message || JSON.stringify(err).slice(0, 200);
bad.push({ name: tool.name, error: msg });
console.log(`${tool.name}: ${msg.slice(0, 150)}`);
} else {
console.log(`${tool.name}`);
}
}
console.log(`\n${bad.length} broken tool(s) out of ${VIBN_TOOL_DEFINITIONS.length}`);
process.exit(1);
}
console.log('✅ All tool definitions accepted by Gemini.');
const calls = data?.candidates?.[0]?.content?.parts
?.filter((p: any) => p.functionCall)
.map((p: any) => `${p.functionCall.name}(${Object.keys(p.functionCall.args || {}).join(',')})`);
if (calls?.length) {
console.log(`\nGemini chose to call: ${calls.join(', ')}`);
} else {
console.log('\n(No tool calls produced — model responded with text)');
}
}
validateAll().catch((e) => {
console.error('Test crashed:', e);
process.exit(1);
});

View File

@@ -0,0 +1,21 @@
/**
* Smoke: register unlocked, lock it, then unlock. Proves setDomainLock().
*/
import { registerDomain, setDomainLock, type RegistrationContact } from '../lib/opensrs';
const CONTACT: RegistrationContact = {
first_name: 'Mark', last_name: 'Henderson', org_name: 'Get Acquired Inc',
address1: '123 King St W', city: 'Toronto', state: 'ON',
country: 'CA', postal_code: 'M5H 1A1',
phone: '+1.4165551234', email: 'mark@getacquired.com',
};
async function main() {
const domain = `vibnai-lock-${Date.now()}.com`;
const reg = await registerDomain({ domain, period: 1, contact: CONTACT });
console.log('[lock-smoke] registered', domain, 'order=', reg.orderId);
console.log('[lock-smoke] lock ->', await setDomainLock(domain, true));
console.log('[lock-smoke] unlock ->', await setDomainLock(domain, false));
}
main().catch(err => { console.error('[lock-smoke] FAILED:', err); process.exit(1); });

View File

@@ -0,0 +1,45 @@
/**
* Smoke: register a sandbox domain, then update its nameservers to a
* fake-but-valid set. Proves the full attach-time registrar flow.
*
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-ns-update.ts
*/
import {
registerDomain,
updateDomainNameservers,
type RegistrationContact,
} from '../lib/opensrs';
const CONTACT: RegistrationContact = {
first_name: 'Mark',
last_name: 'Henderson',
org_name: 'Get Acquired Inc',
address1: '123 King St W',
city: 'Toronto',
state: 'ON',
country: 'CA',
postal_code: 'M5H 1A1',
phone: '+1.4165551234',
email: 'mark@getacquired.com',
};
async function main() {
const domain = `vibnai-ns-${Date.now()}.com`;
console.log('[ns-smoke] register', domain);
const reg = await registerDomain({ domain, period: 1, contact: CONTACT });
console.log('[ns-smoke] registered. order=', reg.orderId);
// Cloud DNS nameservers follow this format — use real-looking values.
// Horizon only knows about nameservers that already exist at the registry
// it talks to. Its built-in ones are ns1/ns2.systemdns.com, and they're
// the ones every sandbox domain registers with by default. In production
// we'll point at the Cloud DNS nameservers returned from createZone, which
// are publicly resolvable and accepted by every upstream registry.
const ns = ['ns1.systemdns.com', 'ns2.systemdns.com', 'ns3.systemdns.com', 'ns4.systemdns.com'];
console.log('[ns-smoke] updating NS to', ns);
const upd = await updateDomainNameservers(domain, ns);
console.log('[ns-smoke] NS update response:', upd);
}
main().catch(err => { console.error('[ns-smoke] FAILED:', err); process.exit(1); });

View File

@@ -0,0 +1,38 @@
/**
* Live sandbox registration smoke.
*
* Runs sw_register against Horizon (test mode) with the Vibn corporate
* contact. Horizon doesn't actually allocate a name at any registry — it's
* purely a protocol simulator — so this is safe to run repeatedly.
*
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-register.ts
*/
import { registerDomain, type RegistrationContact } from '../lib/opensrs';
const CONTACT: RegistrationContact = {
first_name: 'Mark',
last_name: 'Henderson',
org_name: 'Get Acquired Inc',
address1: '123 King St W',
city: 'Toronto',
state: 'ON',
country: 'CA',
postal_code: 'M5H 1A1',
phone: '+1.4165551234',
email: 'mark@getacquired.com',
};
async function main() {
const domain = `vibnai-smoke-${Date.now()}.com`;
console.log('[register-smoke] registering', domain);
const result = await registerDomain({
domain,
period: 1,
contact: CONTACT,
whoisPrivacy: true,
});
console.log('[register-smoke] result:', result);
}
main().catch(err => { console.error('[register-smoke] FAILED:', err); process.exit(1); });

View File

@@ -0,0 +1,28 @@
/**
* TLD-quirk smoke test: verify that .ca, .ai, .io all return sensible
* availability + price through lib/opensrs.ts.
*
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-tlds.ts
*/
import { checkDomain } from '../lib/opensrs';
async function main() {
const stamp = Date.now();
const cases = [
{ domain: `vibnai-${stamp}.ca`, period: 1 },
{ domain: `vibnai-${stamp}.ai`, period: 1 }, // should auto-bump to 2y
{ domain: `vibnai-${stamp}.io`, period: 1 },
{ domain: `vibnai-${stamp}.com`, period: 1 },
];
for (const c of cases) {
try {
const r = await checkDomain(c.domain, c.period);
console.log(c.domain.padEnd(32), '->', JSON.stringify(r));
} catch (err) {
console.log(c.domain.padEnd(32), '-> ERR', err instanceof Error ? err.message : err);
}
}
}
main().catch(err => { console.error(err); process.exit(1); });

View File

@@ -0,0 +1,31 @@
/**
* Smoke test for lib/opensrs.ts against the OpenSRS Horizon sandbox.
*
* Usage (from repo root):
* source .opensrs.env
* cd vibn-frontend && npx tsx scripts/smoke-opensrs.ts vibnai-smoke-$(date +%s).com
*
* - Prints availability + price for the given domain.
* - Does NOT attempt registration (that belongs to a separate manual test).
*/
import { checkDomain, getResellerBalance, lookupDomain } from '../lib/opensrs';
async function main() {
const domain = process.argv[2] ?? `vibnai-smoke-${Date.now()}.com`;
console.log(`[smoke-opensrs] mode=${process.env.OPENSRS_MODE ?? 'test'} domain=${domain}`);
const balance = await getResellerBalance();
console.log('[smoke-opensrs] reseller balance:', balance);
const avail = await lookupDomain(domain);
console.log('[smoke-opensrs] lookup:', avail);
const check = await checkDomain(domain, 1);
console.log('[smoke-opensrs] check (availability + price):', check);
}
main().catch(err => {
console.error('[smoke-opensrs] FAILED:', err);
process.exit(1);
});

View File

@@ -0,0 +1,415 @@
/**
* P5.3 — End-to-end smoke for per-workspace GCS provisioning.
*
* What this exercises (against PROD GCP — master-ai-484822):
* 1. ensureWorkspaceServiceAccount → creates a throwaway SA
* (vibn-ws-smoke-{ts}@…). Idempotent.
* 2. createServiceAccountKey → mints + base64-encodes a JSON key.
* 3. createBucket → creates vibn-ws-smoke-{ts}-{6char}
* in northamerica-northeast1 with uniform bucket-level access ON
* and public access prevention enforced.
* 4. addBucketIamBinding → grants the throwaway SA
* roles/storage.objectAdmin on the bucket only.
* 5. createHmacKey → mints S3-compatible HMAC creds
* tied to the throwaway SA.
* 6. (verify) HMAC PUT/GET → uploads a 12-byte object via the
* GCS XML API using AWS SigV4 with the HMAC creds, reads it back,
* deletes it. Proves the credentials actually work.
*
* Cleanup (best-effort, runs even on failure):
* - Deletes the test object.
* - Deactivates + deletes the HMAC key.
* - Deletes all keys on the SA (so the SA itself can be removed).
* - Deletes the bucket.
* - Deletes the SA.
*
* NO Postgres writes. NO Coolify writes. NO project-level IAM changes.
* Everything created has a "smoke-" prefix and a "purpose=smoke" label
* so leftovers are obvious in the GCP console.
*
* Required env (load from /Users/markhenderson/master-ai/.google.env):
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 base64 of vibn-workspace-provisioner SA JSON
* GCP_PROJECT_ID defaults to master-ai-484822
*
* Usage:
* cd vibn-frontend
* npx -y dotenv-cli -e ../.google.env -- npx tsx scripts/smoke-storage-e2e.ts
*/
import { createHash, createHmac } from 'crypto';
import { GCP_PROJECT_ID } from '../lib/gcp-auth';
import {
ensureWorkspaceServiceAccount,
createServiceAccountKey,
workspaceServiceAccountEmail,
workspaceServiceAccountId,
} from '../lib/gcp/iam';
import {
createBucket,
deleteBucket,
addBucketIamBinding,
getBucketIamPolicy,
createHmacKey,
deleteHmacKey,
workspaceDefaultBucketName,
VIBN_GCS_LOCATION,
} from '../lib/gcp/storage';
const ts = Date.now().toString(36);
const SLUG = `smoke-${ts}`;
const SA_EMAIL = workspaceServiceAccountEmail(SLUG);
const SA_ID = workspaceServiceAccountId(SLUG);
const BUCKET = workspaceDefaultBucketName(SLUG);
const TEST_OBJECT_KEY = 'smoke/hello.txt';
const TEST_OBJECT_BODY = 'vibn smoke ✓';
function banner(): void {
console.log('━'.repeat(72));
console.log(' VIBN P5.3 GCS provisioning smoke (PROD GCP — master-ai-484822)');
console.log('━'.repeat(72));
console.log(` project : ${GCP_PROJECT_ID}`);
console.log(` slug : ${SLUG}`);
console.log(` SA : ${SA_EMAIL}`);
console.log(` bucket : ${BUCKET}`);
console.log(` location : ${VIBN_GCS_LOCATION}`);
console.log('');
}
interface State {
saCreated: boolean;
saKeyName?: string;
bucketCreated: boolean;
hmacAccessId?: string;
uploadedObject: boolean;
}
async function main(): Promise<void> {
banner();
const state: State = { saCreated: false, bucketCreated: false, uploadedObject: false };
try {
// ── 1. Service account ────────────────────────────────────────────
console.log('[1/6] Ensure service account…');
const sa = await ensureWorkspaceServiceAccount({ slug: SLUG, workspaceName: SLUG });
state.saCreated = true;
console.log(`${sa.email}`);
// ── 2. Service-account key ────────────────────────────────────────
console.log('[2/6] Mint service-account JSON key…');
const key = await createServiceAccountKey(sa.email);
state.saKeyName = key.name;
console.log(` ✓ key.name=${key.name.split('/').slice(-1)[0]} (privateKeyData ${key.privateKeyData.length} chars b64)`);
// ── 3. Bucket ────────────────────────────────────────────────────
console.log('[3/6] Create bucket (uniform BLA on, public-access prevention enforced)…');
const bucket = await createBucket({
name: BUCKET,
location: VIBN_GCS_LOCATION,
enforcePublicAccessPrevention: true,
workspaceSlug: SLUG,
});
state.bucketCreated = true;
console.log(`${bucket.name} in ${bucket.location}`);
// ── 4. Bucket IAM binding ────────────────────────────────────────
console.log('[4/6] Add roles/storage.objectAdmin binding for the workspace SA…');
await addBucketIamBinding({
bucketName: bucket.name,
role: 'roles/storage.objectAdmin',
member: `serviceAccount:${sa.email}`,
});
const policy = await getBucketIamPolicy(bucket.name);
const binding = policy.bindings?.find(
b => b.role === 'roles/storage.objectAdmin' && b.members.includes(`serviceAccount:${sa.email}`),
);
if (!binding) {
throw new Error('IAM binding did not stick — workspace SA not in objectAdmin members');
}
console.log(` ✓ binding present (${binding.members.length} member(s) on ${binding.role})`);
// ── 5. HMAC key ──────────────────────────────────────────────────
console.log('[5/6] Mint HMAC key for the workspace SA…');
const hmac = await createHmacKey(sa.email);
state.hmacAccessId = hmac.accessId;
console.log(` ✓ accessId=${hmac.accessId} state=${hmac.state}`);
// HMAC keys take a few seconds to become usable on the GCS XML API.
// Without this delay we usually get "InvalidAccessKeyId" on the
// very first request.
console.log(' … waiting 6s for HMAC propagation');
await sleep(6000);
// ── 6. Verify HMAC creds work via S3-compatible XML API ─────────
console.log('[6/6] PUT / GET / DELETE a tiny object via the XML API using HMAC creds…');
await s3PutObject({
accessKeyId: hmac.accessId,
secretAccessKey: hmac.secret,
bucket: bucket.name,
key: TEST_OBJECT_KEY,
body: Buffer.from(TEST_OBJECT_BODY, 'utf-8'),
contentType: 'text/plain; charset=utf-8',
});
state.uploadedObject = true;
console.log(` ✓ PUT ${TEST_OBJECT_KEY}`);
const got = await s3GetObject({
accessKeyId: hmac.accessId,
secretAccessKey: hmac.secret,
bucket: bucket.name,
key: TEST_OBJECT_KEY,
});
if (got.toString('utf-8') !== TEST_OBJECT_BODY) {
throw new Error(`GET body mismatch: ${JSON.stringify(got.toString('utf-8'))}`);
}
console.log(` ✓ GET round-trip body matches`);
await s3DeleteObject({
accessKeyId: hmac.accessId,
secretAccessKey: hmac.secret,
bucket: bucket.name,
key: TEST_OBJECT_KEY,
});
state.uploadedObject = false;
console.log(` ✓ DELETE`);
console.log('');
console.log('━'.repeat(72));
console.log(' SUMMARY');
console.log('━'.repeat(72));
console.log(' SA create+key : ✓');
console.log(' Bucket create : ✓');
console.log(' Bucket IAM binding : ✓');
console.log(' HMAC key + S3 round-trip : ✓');
console.log('');
console.log(' All 4 building blocks of P5.3 vertical slice proven against PROD GCP.');
} catch (err) {
console.error('');
console.error('[smoke-storage-e2e] FAILED:', err);
process.exitCode = 1;
} finally {
console.log('');
console.log('Cleanup…');
await cleanup(state).catch(err => {
console.error('[cleanup] non-fatal error:', err);
});
}
}
async function cleanup(state: State): Promise<void> {
// Object (best-effort; usually already deleted on the happy path).
if (state.uploadedObject && state.hmacAccessId) {
// The credential needed to delete the object lives only in the
// smoke run's memory; if we crashed before saving the secret,
// we can't delete it as the workspace SA. Fall back to deleting
// the bucket which atomically removes contents (deleteBucket
// requires an empty bucket — use force-delete via objects.delete
// listing if it ever matters).
}
// HMAC key.
if (state.hmacAccessId) {
try {
await deleteHmacKey(state.hmacAccessId);
console.log(` ✓ HMAC ${state.hmacAccessId} deleted`);
} catch (err) {
console.warn(` ⚠ HMAC delete failed:`, err);
}
}
// Bucket. Must be empty; if a test object survived, list+delete first.
if (state.bucketCreated) {
try {
// Try a hard delete; if the bucket has objects we'll get 409.
await deleteBucket(BUCKET);
console.log(` ✓ bucket ${BUCKET} deleted`);
} catch (err) {
console.warn(` ⚠ bucket delete failed (objects may remain):`, err);
}
}
// SA keys + SA itself.
if (state.saCreated) {
try {
await deleteAllSaKeysAndSa(SA_EMAIL);
console.log(` ✓ SA ${SA_EMAIL} + keys deleted`);
} catch (err) {
console.warn(` ⚠ SA cleanup failed:`, err);
}
}
}
// ────────────────────────────────────────────────────────────────────
// Helpers — SA cleanup using the IAM API directly (the lib only exposes
// create paths).
// ────────────────────────────────────────────────────────────────────
import { getGcpAccessToken } from '../lib/gcp-auth';
async function deleteAllSaKeysAndSa(email: string): Promise<void> {
const token = await getGcpAccessToken();
const base = `https://iam.googleapis.com/v1/projects/${GCP_PROJECT_ID}/serviceAccounts/${encodeURIComponent(email)}`;
// Delete user-managed keys (system-managed keys can't be deleted).
const listRes = await fetch(`${base}/keys?keyTypes=USER_MANAGED`, {
headers: { Authorization: `Bearer ${token}` },
});
if (listRes.ok) {
const listJson = (await listRes.json()) as { keys?: { name: string }[] };
for (const k of listJson.keys ?? []) {
const id = k.name.split('/').pop();
if (!id) continue;
const delRes = await fetch(`${base}/keys/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!delRes.ok && delRes.status !== 404) {
console.warn(` ⚠ key ${id} delete → ${delRes.status}`);
}
}
}
// Delete the SA.
const delRes = await fetch(base, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!delRes.ok && delRes.status !== 404) {
throw new Error(`SA delete → ${delRes.status} ${await delRes.text()}`);
}
}
// ────────────────────────────────────────────────────────────────────
// AWS SigV4 against the GCS XML API
//
// We re-implement SigV4 here rather than pulling in @aws-sdk to keep
// this script dependency-light. GCS treats the bucket as a virtual host
// (https://{bucket}.storage.googleapis.com/{key}) and uses region
// "auto" with service "s3".
// ────────────────────────────────────────────────────────────────────
interface S3Creds {
accessKeyId: string;
secretAccessKey: string;
}
async function s3PutObject(opts: S3Creds & {
bucket: string;
key: string;
body: Buffer;
contentType?: string;
}): Promise<void> {
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
const res = await sigv4Fetch({
method: 'PUT',
url,
body: opts.body,
contentType: opts.contentType,
accessKeyId: opts.accessKeyId,
secretAccessKey: opts.secretAccessKey,
});
if (!res.ok) throw new Error(`PUT ${opts.key}${res.status} ${await res.text()}`);
}
async function s3GetObject(opts: S3Creds & { bucket: string; key: string }): Promise<Buffer> {
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
const res = await sigv4Fetch({
method: 'GET',
url,
accessKeyId: opts.accessKeyId,
secretAccessKey: opts.secretAccessKey,
});
if (!res.ok) throw new Error(`GET ${opts.key}${res.status} ${await res.text()}`);
return Buffer.from(await res.arrayBuffer());
}
async function s3DeleteObject(opts: S3Creds & { bucket: string; key: string }): Promise<void> {
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
const res = await sigv4Fetch({
method: 'DELETE',
url,
accessKeyId: opts.accessKeyId,
secretAccessKey: opts.secretAccessKey,
});
if (!res.ok && res.status !== 404) {
throw new Error(`DELETE ${opts.key}${res.status} ${await res.text()}`);
}
}
interface SigV4FetchOpts extends S3Creds {
method: 'GET' | 'PUT' | 'DELETE';
url: string;
body?: Buffer;
contentType?: string;
}
async function sigv4Fetch(opts: SigV4FetchOpts): Promise<Response> {
const { method, url, body, contentType, accessKeyId, secretAccessKey } = opts;
const u = new URL(url);
const host = u.host;
const path = u.pathname || '/';
const query = u.search.slice(1);
const now = new Date();
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
const dateStamp = amzDate.slice(0, 8);
const region = 'auto';
const service = 's3';
const payloadHash = body
? createHash('sha256').update(body).digest('hex')
: createHash('sha256').update('').digest('hex');
const headers: Record<string, string> = {
host,
'x-amz-date': amzDate,
'x-amz-content-sha256': payloadHash,
};
if (contentType) headers['content-type'] = contentType;
if (body) headers['content-length'] = String(body.length);
const signedHeaders = Object.keys(headers).map(k => k.toLowerCase()).sort().join(';');
const canonicalHeaders =
Object.keys(headers)
.map(k => [k.toLowerCase(), String(headers[k]).trim()] as const)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${v}\n`)
.join('');
const canonicalRequest = [
method,
path,
query,
canonicalHeaders,
signedHeaders,
payloadHash,
].join('\n');
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
createHash('sha256').update(canonicalRequest).digest('hex'),
].join('\n');
const kDate = createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest();
const kRegion = createHmac('sha256', kDate).update(region).digest();
const kService = createHmac('sha256', kRegion).update(service).digest();
const kSigning = createHmac('sha256', kService).update('aws4_request').digest();
const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex');
const authorization =
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, ` +
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
return fetch(url, {
method,
headers: { ...headers, Authorization: authorization },
body: body ? new Uint8Array(body) : undefined,
});
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
main();

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Start dev server with fresh AlloyDB access token
echo "🔑 Generating AlloyDB access token..."
export ALLOYDB_PASSWORD=$(gcloud auth print-access-token)
if [ -z "$ALLOYDB_PASSWORD" ]; then
echo "❌ Failed to generate access token"
echo "Make sure you're logged in: gcloud auth login"
exit 1
fi
echo "✅ Token generated (expires in 1 hour)"
echo "🚀 Starting dev server..."
echo ""
npm run dev

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env node
/**
* Pull postgres external URL from Coolify API and write DATABASE_URL + POSTGRES_URL in .env.local.
*
* Loads (in order): ../.coolify.env, .env, .env.local (Coolify wins for COOLIFY_* from parent file).
*
* Identify DB by (first match):
* COOLIFY_DATABASE_UUID — exact
* COOLIFY_DATABASE_NAME — default: vibn-postgres
* argv[2] — uuid or name substring
*
* Requires: COOLIFY_URL, COOLIFY_API_TOKEN
*/
import { config } from "dotenv";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.join(root, "..");
config({ path: path.join(repoRoot, ".coolify.env") });
config({ path: path.join(root, ".env") });
config({ path: path.join(root, ".env.local"), override: true });
const COOLIFY_URL = (process.env.COOLIFY_URL ?? "").replace(/\/$/, "");
const token = process.env.COOLIFY_API_TOKEN ?? "";
const arg = process.argv[2];
function fail(msg) {
console.error(msg);
process.exit(1);
}
if (!COOLIFY_URL || !token) {
fail("Missing COOLIFY_URL or COOLIFY_API_TOKEN (e.g. set in master-ai/.coolify.env).");
}
function normalizeDatabaseUrl(url) {
if (!url || typeof url !== "string") return "";
return url.replace(/^postgres:\/\//i, "postgresql://");
}
async function coolifyFetch(path) {
const res = await fetch(`${COOLIFY_URL}/api/v1${path}`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
const text = await res.text();
fail(`Coolify API ${res.status} ${path}: ${text.slice(0, 500)}`);
}
return res.json();
}
function pickDatabase(list) {
const uuid = process.env.COOLIFY_DATABASE_UUID?.trim();
const nameDefault = process.env.COOLIFY_DATABASE_NAME || "vibn-postgres";
if (uuid) {
const d = list.find((x) => x.uuid === uuid);
if (d) return d;
fail(`No database with COOLIFY_DATABASE_UUID=${uuid}.`);
}
if (arg?.trim()) {
const a = arg.trim().toLowerCase();
const byUuid = list.find((x) => x.uuid === arg.trim());
if (byUuid) return byUuid;
const byName = list.find(
(x) =>
String(x.name ?? "")
.toLowerCase()
.includes(a) ||
String(x.uuid ?? "")
.toLowerCase()
.includes(a)
);
if (byName) return byName;
fail(`No database matching "${arg}".`);
}
const byName = list.find(
(x) => String(x.name ?? "").toLowerCase() === nameDefault.toLowerCase()
);
if (byName) return byName;
fail(
`No database named "${nameDefault}". Set COOLIFY_DATABASE_UUID or pass uuid/name as argument.`
);
}
async function main() {
const list = await coolifyFetch("/databases");
if (!Array.isArray(list) || list.length === 0) {
fail("Coolify returned no databases.");
}
const picked = pickDatabase(list);
const detail = await coolifyFetch(`/databases/${picked.uuid}`);
const raw = detail.external_db_url;
const databaseUrl = normalizeDatabaseUrl(raw);
if (!databaseUrl) {
fail(
"No external_db_url from Coolify. Enable public exposure and set a host port on the Postgres service, then restart."
);
}
const envLocalPath = path.join(root, ".env.local");
if (!fs.existsSync(envLocalPath)) {
fail(`Missing ${envLocalPath}; create it first (copy from .env.example).`);
}
let body = fs.readFileSync(envLocalPath, "utf8");
const lines = body.split(/\r?\n/);
const setLine = (key, value) => {
const next = `${key}=${value}`;
let seen = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith(`${key}=`)) {
lines[i] = next;
seen = true;
break;
}
}
if (!seen) lines.push(next);
};
setLine("DATABASE_URL", databaseUrl);
setLine("POSTGRES_URL", databaseUrl);
fs.writeFileSync(envLocalPath, lines.join("\n") + (body.endsWith("\n") ? "\n" : ""), "utf8");
console.log(
`Updated DATABASE_URL and POSTGRES_URL in .env.local from Coolify (${picked.name}, ${picked.uuid}).`
);
console.log(`Public port from API: ${detail.public_port ?? "(n/a)"}; is_public: ${detail.is_public ?? "(n/a)"}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,152 @@
/**
* Test AlloyDB Connection
*
* Run with: npx tsx scripts/test-alloydb.ts
*/
import { getAlloyDbClient, checkAlloyDbHealth, executeQuery } from '../lib/db/alloydb';
async function testConnection() {
console.log('🧪 Testing AlloyDB Connection\n');
console.log('='.repeat(50));
try {
// Test 1: Health check
console.log('\n1⃣ Health Check...');
const healthy = await checkAlloyDbHealth();
if (!healthy) {
console.error('❌ Health check failed!');
console.log('\nTroubleshooting:');
console.log(' 1. Is AlloyDB Auth Proxy running?');
console.log(' 2. Check environment variables in .env.local');
console.log(' 3. Verify service account has permissions');
process.exit(1);
}
console.log('✅ Health check passed!');
// Test 2: PostgreSQL version
console.log('\n2⃣ PostgreSQL Version...');
const versionResult = await executeQuery<{ version: string }>('SELECT version()');
console.log('✅ Version:', versionResult.rows[0].version.split(',')[0]);
// Test 3: Check extensions
console.log('\n3⃣ Checking Extensions...');
const extResult = await executeQuery<{ extname: string }>(
"SELECT extname FROM pg_extension WHERE extname IN ('vector', 'uuid-ossp')"
);
const installedExts = extResult.rows.map(r => r.extname);
if (installedExts.includes('vector')) {
console.log('✅ pgvector extension installed');
} else {
console.log('❌ pgvector extension NOT installed');
console.log(' Run: CREATE EXTENSION vector;');
}
if (installedExts.includes('uuid-ossp')) {
console.log('✅ uuid-ossp extension installed');
} else {
console.log('❌ uuid-ossp extension NOT installed');
console.log(' Run: CREATE EXTENSION "uuid-ossp";');
}
// Test 4: Check for knowledge_chunks table
console.log('\n4⃣ Checking Tables...');
const tableResult = await executeQuery<{ table_name: string }>(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'knowledge_chunks'`
);
if (tableResult.rows.length > 0) {
console.log('✅ knowledge_chunks table exists');
// Check indexes
const indexResult = await executeQuery<{ indexname: string }>(
`SELECT indexname
FROM pg_indexes
WHERE tablename = 'knowledge_chunks'`
);
console.log(`${indexResult.rows.length} indexes created:`);
indexResult.rows.forEach(row => {
console.log(` - ${row.indexname}`);
});
// Count chunks
const countResult = await executeQuery<{ count: string }>(
'SELECT COUNT(*) as count FROM knowledge_chunks'
);
const count = parseInt(countResult.rows[0].count, 10);
console.log(`✅ Total chunks: ${count}`);
} else {
console.log('⚠️ knowledge_chunks table NOT found');
console.log(' Run the schema file:');
console.log(' psql "host=127.0.0.1 port=5432 dbname=vibn user=YOUR_SA" \\');
console.log(' -f lib/db/knowledge-chunks-schema.sql');
}
// Test 5: Test vector operations (if table exists and vector extension installed)
if (tableResult.rows.length > 0 && installedExts.includes('vector')) {
console.log('\n5⃣ Testing Vector Operations...');
try {
// Create a test embedding
const testEmbedding = Array.from({ length: 768 }, () => Math.random());
// Test vector similarity query (should not error even with empty table)
await executeQuery(
`SELECT id
FROM knowledge_chunks
ORDER BY embedding <=> $1::vector
LIMIT 1`,
[JSON.stringify(testEmbedding)]
);
console.log('✅ Vector similarity queries working!');
} catch (error) {
console.log('❌ Vector operations failed:', error);
}
}
console.log('\n' + '='.repeat(50));
console.log('🎉 AlloyDB is ready to use!');
console.log('='.repeat(50));
console.log('\nNext steps:');
console.log(' 1. Start your app: npm run dev');
console.log(' 2. Import a knowledge item to test chunking');
console.log(' 3. Send a chat message to test vector search');
console.log('');
process.exit(0);
} catch (error) {
console.error('\n❌ Connection failed!');
console.error('Error:', error instanceof Error ? error.message : String(error));
console.log('\nTroubleshooting:');
console.log(' 1. Check .env.local has correct values');
console.log(' 2. Ensure AlloyDB Auth Proxy is running:');
console.log(' alloydb-auth-proxy --credentials-file=~/vibn-alloydb-key.json --port=5432 YOUR_INSTANCE_URI');
console.log(' 3. Verify service account permissions');
console.log(' 4. Check network connectivity');
console.log('');
process.exit(1);
}
}
// Run test
console.log('Starting AlloyDB connection test...\n');
console.log('Environment:');
console.log(' ALLOYDB_HOST:', process.env.ALLOYDB_HOST);
console.log(' ALLOYDB_PORT:', process.env.ALLOYDB_PORT);
console.log(' ALLOYDB_DATABASE:', process.env.ALLOYDB_DATABASE);
console.log(' ALLOYDB_USER:', process.env.ALLOYDB_USER?.substring(0, 30) + '...');
console.log('');
testConnection();

View File

@@ -0,0 +1,128 @@
#!/bin/bash
# Endpoint Health Check Script
# Tests all critical API endpoints to ensure they work after refactor
echo "🧪 Testing Vibn API Endpoints"
echo "======================================"
echo ""
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Base URL
BASE_URL="http://localhost:3000"
# Test counter
TOTAL=0
PASSED=0
FAILED=0
# Helper function to test an endpoint
test_endpoint() {
local METHOD=$1
local PATH=$2
local EXPECTED_STATUS=$3
local DESCRIPTION=$4
local AUTH=${5:-""}
TOTAL=$((TOTAL + 1))
if [ -z "$AUTH" ]; then
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X $METHOD "$BASE_URL$PATH" 2>/dev/null)
else
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X $METHOD -H "Authorization: Bearer $AUTH" "$BASE_URL$PATH" 2>/dev/null)
fi
if [ "$STATUS" == "$EXPECTED_STATUS" ]; then
echo -e "${GREEN}${NC} $DESCRIPTION"
echo " └─ $METHOD $PATH$STATUS"
PASSED=$((PASSED + 1))
else
echo -e "${RED}${NC} $DESCRIPTION"
echo " └─ $METHOD $PATH → Expected $EXPECTED_STATUS, got $STATUS"
FAILED=$((FAILED + 1))
fi
}
echo "1⃣ Frontend Pages"
echo "-----------------------------------"
test_endpoint "GET" "/" "200" "Home page"
echo ""
echo "2⃣ Project APIs (No Auth Required for Testing Structure)"
echo "-----------------------------------"
test_endpoint "POST" "/api/projects/create" "401" "Create project (should require auth)"
test_endpoint "GET" "/api/projects/phase" "405" "Phase endpoint (POST only)"
test_endpoint "GET" "/api/debug/first-project" "200" "Debug: First project"
echo ""
echo "3⃣ Knowledge & Context APIs"
echo "-----------------------------------"
test_endpoint "POST" "/api/projects/test-project/knowledge/upload-document" "401" "Upload document (should require auth)"
test_endpoint "POST" "/api/projects/test-project/knowledge/import-document" "401" "Import document (should require auth)"
test_endpoint "POST" "/api/projects/test-project/knowledge/import-ai-chat" "401" "Import AI chat (should require auth)"
test_endpoint "POST" "/api/projects/test-project/knowledge/batch-extract" "401" "Batch extract (should require auth)"
test_endpoint "GET" "/api/debug/knowledge-items" "200" "Debug: Knowledge items"
test_endpoint "GET" "/api/debug/context-sources" "200" "Debug: Context sources"
echo ""
echo "4⃣ AI Chat APIs"
echo "-----------------------------------"
test_endpoint "POST" "/api/ai/chat" "401" "AI chat (should require auth)"
test_endpoint "GET" "/api/ai/conversation" "400" "Get conversation (requires projectId)"
test_endpoint "POST" "/api/ai/conversation/reset" "400" "Reset conversation (requires projectId)"
echo ""
echo "5⃣ Extraction APIs"
echo "-----------------------------------"
test_endpoint "POST" "/api/projects/test-project/extract-from-chat" "401" "Extract from chat (should require auth)"
test_endpoint "POST" "/api/projects/test-project/aggregate" "401" "Aggregate extractions (should require auth)"
echo ""
echo "6⃣ GitHub Integration APIs"
echo "-----------------------------------"
test_endpoint "GET" "/api/github/repos" "401" "Get GitHub repos (should require auth)"
test_endpoint "POST" "/api/github/connect" "401" "Connect GitHub (should require auth)"
test_endpoint "GET" "/api/github/repo-tree" "400" "Get repo tree (requires params)"
test_endpoint "GET" "/api/github/file-content" "400" "Get file content (requires params)"
echo ""
echo "7⃣ Planning & Vision APIs"
echo "-----------------------------------"
test_endpoint "POST" "/api/projects/test-project/plan/mvp" "401" "Generate MVP plan (should require auth)"
test_endpoint "POST" "/api/projects/test-project/plan/marketing" "401" "Generate marketing plan (should require auth)"
test_endpoint "POST" "/api/vision/update" "400" "Update vision (requires projectId)"
echo ""
echo "8⃣ Utility APIs"
echo "-----------------------------------"
test_endpoint "POST" "/api/context/summarize" "400" "Summarize context (requires body)"
test_endpoint "GET" "/api/debug/env" "200" "Debug: Environment check"
test_endpoint "GET" "/api/diagnose" "200" "Diagnose system"
echo ""
echo ""
echo "======================================"
echo "📊 Test Results"
echo "======================================"
echo -e "Total: $TOTAL"
echo -e "${GREEN}Passed: $PASSED${NC}"
echo -e "${RED}Failed: $FAILED${NC}"
echo ""
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}✅ All endpoint structure tests passed!${NC}"
echo ""
echo "Note: 401/400 responses are EXPECTED for auth-protected and"
echo "parameter-required endpoints. This confirms they exist and"
echo "are properly configured."
exit 0
else
echo -e "${RED}❌ Some endpoints failed. Check the output above.${NC}"
exit 1
fi