diff --git a/.env.example b/.env.example index 950812e..7d6cfcf 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,22 @@ -TRIPLIT_SERVICE_TOKEN="your-triplit-service-token" # given to you on startup, or by running `tsx generate-tokens.ts` -NUXT_TRIPLIT_ANON_TOKEN="your-anonymous-triplit-token" # also given to you on startup, or by running `tsx generate-tokens.ts` -BETTER_AUTH_SECRET="super-secret" # openssl rand -base64 32 -TRIPLIT_JWT_SECRET="super-secret" -EXTERNAL_JWT_SECRET=${BETTER_AUTH_SECRET} # no need to change this -NUXT_PUBLIC_TRIPLIT_URL="http://localhost:6543" # your triplit server url +# Database +POSTGRES_DB=veridian +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changeme +DATABASE_URL=postgresql://postgres:changeme@db:5432/veridian + +# Auth +BETTER_AUTH_SECRET=changeme + +# App URL (your domain, used for CORS and auth redirects) +NUXT_PUBLIC_URL=https://veridian.example.com + +# S3-compatible storage (optional, for file uploads) +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_BUCKET_NAME= +S3_REGION= +S3_ENDPOINT= + +# Auth controls (optional) +DISABLE_SIGNUP=false +DISABLE_LOCAL_AUTH=false diff --git a/AGENTS.md b/AGENTS.md index bb3e21e..789cc93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Veridian AGENTS.md -**Nuxt 4 AI chat platform with Triplit database + better-auth** +**Nuxt 4 AI chat platform with Drizzle + PostgreSQL + better-auth** ## Architecture Overview @@ -21,9 +21,10 @@ server/ # Server-side API routes ├── chat/ # Generation, cancel endpoints └── provider/ # Model fetching from providers -triplit/ # Database -├── schema.ts # Full schema with auth + app collections -└── auth-schema.ts # Auth collections (users, sessions, accounts) +drizzle/ # Database (migrated from Triplit) +├── schema.ts # Full schema with auth + app tables +├── relations.ts # Table relationships +└── migrations/ # SQL migration files lib/ # Shared utilities ├── auth.ts # Better-auth server config @@ -39,7 +40,9 @@ database commands. ### Database Commands -- `bunx triplit schema push` - Push schema changes to database +- `bunx drizzle-kit push` - Push schema changes to database +- `bunx drizzle-kit generate` - Generate migration from schema changes +- `bunx drizzle-kit migrate` - Run pending migrations ## Code Style @@ -57,15 +60,12 @@ database commands. Group imports in this order (alphabetical within groups): 1. Type imports (`types/`) 2. Dependency imports (npm packages) -3. Triplit imports (`#triplit/`, `@triplit/`) -4. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`) -5. Local imports (`lib/`, `server/`) +3. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`) +4. Local imports (`lib/`, `server/`) ```typescript // Example import order import type { Result, Ok, Err } from '~~/types/result'; -import type { Entity } from '@triplit/client'; -import type schema from '#triplit/schema'; import type { Foo } from 'vue'; import { ref, computed } from 'vue'; import { useFeature } from '~/composables/useFeature'; @@ -128,25 +128,30 @@ export default defineEventHandler(async (event) => { }); ``` -## Database Query Pattern (Triplit) +## Database Query Pattern (Drizzle) ```typescript -// Client queries use useQuery() with auto-includes -useQuery('collection', triplit, triplit.query('collection').Include('relation')); +// Server-side queries use Drizzle ORM +import { db } from '~~/server/db'; +import { agents } from '~~/drizzle/schema'; -// Server-side -import { httpClient } from '~~/server/lib/triplit'; -await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', providerId)); +// Query with relations +const result = await db.query.agents.findMany({ + where: eq(agents.userId, userId), + with: { topics: true, messages: true }, +}); + +// Insert with conflict handling +await db.insert(agents).values(data).onConflictDoNothing(); ``` ## Authentication Flow 1. **Login**: Client uses `authClient.signIn.email()` → derives encryption key from password 2. **Session**: Better-auth creates session token → stored in cookie -3. **Triplit**: Session token passed to `triplit.startSession(token)` for DB access -4. **API Keys**: Encrypted client-side with AES-GCM (key derived from password + userId) +3. **API Keys**: Encrypted client-side with AES-GCM (key derived from password + userId) -**Key files**: `lib/auth.ts`, `lib/auth-client.ts`, `app/plugins/auth.*.ts`, `app/middleware/auth.global.ts` +**Key files**: `lib/auth.ts`, `app/plugins/auth.*.ts`, `app/middleware/auth.global.ts`, `app/composables/useAuth.ts` ## Styling System @@ -159,10 +164,8 @@ await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', provid | Variable | Purpose | |----------|---------| -| `TRIPLIT_SERVICE_TOKEN` | Admin DB access | -| `NUXT_TRIPLIT_ANON_TOKEN` | Anonymous DB access | +| `DATABASE_URL` | PostgreSQL connection string | | `BETTER_AUTH_SECRET` | Auth encryption | -| `NUXT_PUBLIC_TRIPLIT_URL` | DB server URL | ## Key Composables @@ -176,37 +179,11 @@ await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', provid | `useTheme()` | Accent/neutral theme cookies | | `useSettings()` | Settings dialog state | -## Key Routes - -| Route | File | -|-------|------| -| `/` | `app/pages/index.vue` | -| `/auth/login` | `app/pages/auth/login.vue` | -| `/auth/register` | `app/pages/auth/register.vue` | -| `/agent/:id` | `app/pages/agent/[id]/index.vue` | -| `/agent/:id/topic/:topicId` | `app/pages/agent/[id]/topic/[topicId].vue` | -| `/agent/:id/profile` | `app/pages/agent/[id]/profile.vue` | - -## Server API Routes - -| Endpoint | File | -|----------|------| -| `POST /api/chat/generate` | `server/api/chat/generate.post.ts` | -| `POST /api/chat/cancel/:id` | `server/api/chat/cancel/[generationId].post.ts` | -| `POST /api/provider/:id/models` | `server/api/provider/[providerId]/models.post.ts` | -| `* /api/auth/*` | `server/api/auth/[...all].ts` | - -## Known Issues (from BUGS.md) - -1. Triplit occasionally makes duplicate connections (race condition) -2. Sidebar hover animation occasionally glitches on agent routes -3. Theme switcher + sidebar interaction bug - ## Development Notes - **Never run dev server as agent** - just complete tasks and signal done -- Always run `bunx triplit schema push` after schema changes +- Always run `bunx drizzle-kit push` after schema changes - Use `protectRoute()` in all API routes for auth - All UI state that persists → use cookies (`useCookie()`) -- Real-time data → Triplit subscriptions via `useQuery()` +- Real-time data → SSE events via `server/utils/events.ts` - API keys → encrypted client-side before DB storage diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..e767923 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,186 @@ +# Deploying Veridian + +Self-hosted deployment guide using Docker Compose on a VPS. + +## Prerequisites + +- A VPS with Docker and Docker Compose installed +- A domain name pointed at your VPS IP address +- An external reverse proxy (Caddy, Nginx, etc.) for HTTPS termination + +## First Deploy + +### 1. Clone the repository + +```bash +git clone https://gitea.wildcardproject.com/zoeissleeping/veridian.git +cd veridian +``` + +### 2. Configure environment variables + +```bash +cp .env.example .env +``` + +Edit `.env` and set the required values: + +```bash +# Generate a secure auth secret +openssl rand -base64 32 + +# Set these in .env +POSTGRES_PASSWORD= +BETTER_AUTH_SECRET= +NUXT_PUBLIC_URL=https://your-domain.com +``` + +### 3. Build and start + +```bash +docker compose up --build -d +``` + +This will: +1. Build the Nuxt application +2. Start PostgreSQL and wait for it to be healthy +3. Run all pending database migrations automatically +4. Start the application server + +### 4. Verify + +```bash +# Check container status +docker compose ps + +# Check app logs (should show migration + server start) +docker compose logs app + +# Check for errors +docker compose logs app | grep -i error +``` + +### 5. Set up your reverse proxy + +Point your reverse proxy at `127.0.0.1:3000`. Example Caddy config: + +``` +your-domain.com { + reverse_proxy 127.0.0.1:3000 +} +``` + +## Updating + +When you pull new changes: + +```bash +git pull + +# Rebuild and restart (migrations run automatically on startup) +docker compose up --build -d +``` + +The app container will: +1. Rebuild with the latest code +2. Run any new migrations that were added +3. Restart the server + +## Database Migrations + +Migrations run automatically every time the app container starts, via [`scripts/start.ts`](scripts/start.ts). This uses Drizzle ORM's programmatic migration API (`drizzle-orm/node-postgres/migrator`). + +- **First deploy**: All migrations run, creating all tables +- **Updates**: Only new/pending migrations run +- **No changes**: Migrations are a no-op + +If a migration fails, the app will not start and the container will exit with an error. Check logs with: + +```bash +docker compose logs app +``` + +### Creating new migrations + +After modifying [`drizzle/schema.ts`](drizzle/schema.ts): + +```bash +bunx drizzle-kit generate +``` + +This creates a new SQL file in `drizzle/migrations/`. Commit it to git. It will be applied automatically on next deploy. + +## Database Backups + +### Manual backup + +```bash +docker compose exec db pg_dump -U postgres veridian > backup_$(date +%Y%m%d).sql +``` + +### Restore from backup + +```bash +cat backup_20250101.sql | docker compose exec -T db psql -U postgres veridian +``` + +### Automated backups (optional) + +Add a cron job on the host: + +```bash +# Daily backup at 3 AM +0 3 * * * cd /path/to/veridian && docker compose exec -T db pg_dump -U postgres veridian | gzip > /var/backups/veridian/veridian_$(date +\%Y\%m\%d).sql.gz +``` + +## Troubleshooting + +### App won't start + +```bash +# Check logs +docker compose logs app + +# Common issues: +# - Missing .env file or required variables not set +# - Database not reachable (check db container is healthy) +# - Migration errors (schema conflicts) +``` + +### Database connection refused + +```bash +# Check DB is healthy +docker compose ps db + +# Check DB logs +docker compose logs db + +# Test connection +docker compose exec db psql -U postgres veridian -c "SELECT 1" +``` + +### Reset database (destructive) + +```bash +docker compose down -v # Removes volumes! +docker compose up --build -d +``` + +## Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `POSTGRES_DB` | No | Database name (default: `veridian`) | +| `POSTGRES_USER` | No | Database user (default: `postgres`) | +| `POSTGRES_PASSWORD` | **Yes** | Database password | +| `DATABASE_URL` | Auto | Built from the above vars by docker-compose | +| `BETTER_AUTH_SECRET` | **Yes** | Auth encryption secret (`openssl rand -base64 32`) | +| `NUXT_PUBLIC_URL` | **Yes** | Public URL (e.g., `https://veridian.example.com`) | +| `S3_ACCESS_KEY_ID` | No | S3-compatible storage access key | +| `S3_SECRET_ACCESS_KEY` | No | S3-compatible storage secret key | +| `S3_BUCKET_NAME` | No | S3 bucket name | +| `S3_REGION` | No | S3 region | +| `S3_ENDPOINT` | No | S3 endpoint URL | +| `DISABLE_SIGNUP` | No | Set to `true` to disable new user signups | +| `DISABLE_LOCAL_AUTH` | No | Set to `true` to disable email/password auth | diff --git a/Dockerfile b/Dockerfile index ac4e05a..ebbee2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,19 @@ WORKDIR /app # Only `.output` folder is needed from the build stage COPY --from=build /app/.output /app +# Copy migration SQL files for runtime migrations +COPY --from=build /app/drizzle/migrations /app/drizzle/migrations + +# Copy entrypoint script +COPY --from=build /app/scripts/start.ts /app/scripts/start.ts + +# Copy package.json and lockfile for production dependency install +COPY --from=build /app/package.json /app/package.json +COPY --from=build /app/bun.lock* ./ + +# Install production dependencies only (drizzle-orm, pg, etc.) +RUN bun install --frozen-lockfile --production --ignore-scripts + # run the app EXPOSE 3000/tcp -ENTRYPOINT [ "bun", "--bun", "run", "/app/server/index.mjs" ] +ENTRYPOINT [ "bun", "run", "/app/scripts/start.ts" ] diff --git a/app/app.vue b/app/app.vue index 5bbadc2..1c44b80 100644 --- a/app/app.vue +++ b/app/app.vue @@ -2,10 +2,11 @@ import '~/assets/css/reset.css'; import '~/assets/css/base.css'; -const { accent, neutral, hinting } = useUserSettings(); - -// by default, disable hinting -if (Number.isNaN(Number(hinting.value))) hinting.value = '0'; +const { user } = useAuth(); +const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings(); +watch(user, () => { + refreshSettings(); +}) watchEffect(() => { useHead({ diff --git a/app/assets/css/base.css b/app/assets/css/base.css index 130bc31..926a4b8 100644 --- a/app/assets/css/base.css +++ b/app/assets/css/base.css @@ -186,6 +186,10 @@ body { height: 100%; } +a { + color: currentColor; +} + button { color: inherit; display: flex; @@ -220,6 +224,45 @@ button.accent:hover { animation: blink 1s step-end infinite; } +.animate-pulse { + background: linear-gradient(90deg, + currentColor 0%, + currentColor 35%, + rgba(255, 255, 255, 0.65) 50%, + currentColor 65%, + currentColor 100%); + background-size: 300% 100%; + background-repeat: no-repeat; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: + pulse-shimmer 2s linear infinite, + pulse-opacity 2s ease-in-out infinite; +} + +@keyframes pulse-shimmer { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0% 0; + } +} + +@keyframes pulse-opacity { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } +} + @keyframes blink { 0%, @@ -273,15 +316,15 @@ button.accent:hover { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0'/%3E%3C/svg%3E"); } -.reasoning-contaizner.middle { +.reasoning-container.middle { mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent) } -.reasoning-contaizner.top { +.reasoning-container.top { mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent) } -.reasoning-contaizner.bottom { +.reasoning-container.bottom { mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000) } diff --git a/app/assets/css/reset.css b/app/assets/css/reset.css index 65e79a7..410c1a3 100644 --- a/app/assets/css/reset.css +++ b/app/assets/css/reset.css @@ -67,10 +67,6 @@ h6 { text-wrap: balance; } -a { - color: #fff; -} - a:hover { text-decoration: none; } diff --git a/app/components/Attachment/Display.vue b/app/components/Attachment/Display.vue index 8a8da56..3151fd6 100644 --- a/app/components/Attachment/Display.vue +++ b/app/components/Attachment/Display.vue @@ -1,4 +1,6 @@ diff --git a/app/components/Attachment/Preview.vue b/app/components/Attachment/Preview.vue index 1b11de8..b2902fb 100644 --- a/app/components/Attachment/Preview.vue +++ b/app/components/Attachment/Preview.vue @@ -21,7 +21,7 @@ const handleDelete = async () => {