Barazo AppView backend barazo.forum

feat(appview): scaffold Fastify backend with Drizzle, Valkey, and health checks (#5)

* feat(appview): scaffold Fastify backend with Drizzle, Valkey, and health checks

Phase 3 M1: Initialize the atgora-api project with all foundational
infrastructure. Establishes patterns for subsequent milestones.

- Fastify 5 with Helmet (CSP, HSTS), CORS, and rate limiting
- Drizzle ORM with PostgreSQL (pgvector image), initial schema
(users + firehose_cursor tables), and generated migration
- Valkey cache client (ioredis) with retry strategy
- Zod environment variable validation (fail fast on invalid config)
- Pino structured logging via Fastify with GlitchTip/Sentry integration
- Health check endpoints: GET /api/health (liveness), GET /api/health/ready
(readiness with db + cache checks)
- @atgora-forum/lexicons imported from workspace
- TypeScript strict mode with zero errors
- Vitest unit tests (14) and integration tests (2) against real services
- GitHub Actions CI (lint, typecheck, test, integration, build, security)

* fix(ci): make repo self-contained for standalone CI checkout

Each GitHub Actions job checks out only this repo, so workspace-level
references (pnpm-lock.yaml cache, tsconfig.base.json, eslint.config.base.js,
workspace:* dependencies) all fail. Inline base configs, remove workspace
dependency (lexicons not imported yet), and drop --frozen-lockfile until a
repo-local lockfile is committed.

authored by

Guido X Jansen and committed by
GitHub
df2cc2e5 dd92c284

+1071
+41
.env.example
··· 1 + # ATgora API - Development Environment 2 + # Copy to .env and adjust values 3 + 4 + # Database (PostgreSQL with pgvector) 5 + DATABASE_URL=postgresql://atgora:atgora_dev@localhost:5432/atgora 6 + 7 + # Cache (Valkey) 8 + VALKEY_URL=redis://localhost:6379 9 + 10 + # Tap (Firehose consumer) 11 + TAP_URL=http://localhost:2480 12 + TAP_ADMIN_PASSWORD=tap_dev_secret 13 + 14 + # Community 15 + COMMUNITY_MODE=single 16 + COMMUNITY_DID= 17 + COMMUNITY_NAME=ATgora Dev Community 18 + 19 + # Server 20 + HOST=0.0.0.0 21 + PORT=3000 22 + LOG_LEVEL=debug 23 + 24 + # CORS 25 + CORS_ORIGINS=http://localhost:3001 26 + 27 + # Rate Limiting (requests per minute) 28 + RATE_LIMIT_AUTH=10 29 + RATE_LIMIT_WRITE=10 30 + RATE_LIMIT_READ_ANON=100 31 + RATE_LIMIT_READ_AUTH=300 32 + 33 + # OAuth (configured when ready for M3) 34 + OAUTH_CLIENT_ID= 35 + OAUTH_REDIRECT_URI= 36 + 37 + # Error Monitoring (GlitchTip - Sentry SDK compatible) 38 + GLITCHTIP_DSN= 39 + 40 + # Optional: Embedding API (enables semantic search) 41 + EMBEDDING_URL=
+111
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + pull_request: 5 + branches: [main] 6 + push: 7 + branches: [main] 8 + 9 + permissions: 10 + contents: read 11 + 12 + jobs: 13 + lint: 14 + name: Lint 15 + runs-on: ubuntu-latest 16 + steps: 17 + - uses: actions/checkout@v4 18 + - uses: pnpm/action-setup@v4 19 + - uses: actions/setup-node@v4 20 + with: 21 + node-version: 24 22 + - run: pnpm install 23 + - run: pnpm lint 24 + 25 + typecheck: 26 + name: Type Check 27 + runs-on: ubuntu-latest 28 + steps: 29 + - uses: actions/checkout@v4 30 + - uses: pnpm/action-setup@v4 31 + - uses: actions/setup-node@v4 32 + with: 33 + node-version: 24 34 + - run: pnpm install 35 + - run: pnpm typecheck 36 + 37 + test: 38 + name: Unit Tests 39 + runs-on: ubuntu-latest 40 + steps: 41 + - uses: actions/checkout@v4 42 + - uses: pnpm/action-setup@v4 43 + - uses: actions/setup-node@v4 44 + with: 45 + node-version: 24 46 + - run: pnpm install 47 + - run: pnpm test 48 + 49 + test-integration: 50 + name: Integration Tests 51 + runs-on: ubuntu-latest 52 + services: 53 + postgres: 54 + image: pgvector/pgvector:pg16 55 + env: 56 + POSTGRES_USER: atgora 57 + POSTGRES_PASSWORD: atgora_dev 58 + POSTGRES_DB: atgora 59 + ports: 60 + - 5432:5432 61 + options: >- 62 + --health-cmd "pg_isready -U atgora" 63 + --health-interval 10s 64 + --health-timeout 5s 65 + --health-retries 5 66 + valkey: 67 + image: valkey/valkey:8-alpine 68 + ports: 69 + - 6379:6379 70 + options: >- 71 + --health-cmd "valkey-cli ping" 72 + --health-interval 10s 73 + --health-timeout 5s 74 + --health-retries 3 75 + steps: 76 + - uses: actions/checkout@v4 77 + - uses: pnpm/action-setup@v4 78 + - uses: actions/setup-node@v4 79 + with: 80 + node-version: 24 81 + - run: pnpm install 82 + - run: pnpm test:integration 83 + env: 84 + DATABASE_URL: postgresql://atgora:atgora_dev@localhost:5432/atgora 85 + VALKEY_URL: redis://localhost:6379 86 + TAP_URL: http://localhost:2480 87 + TAP_ADMIN_PASSWORD: tap_dev_secret 88 + 89 + build: 90 + name: Build 91 + runs-on: ubuntu-latest 92 + steps: 93 + - uses: actions/checkout@v4 94 + - uses: pnpm/action-setup@v4 95 + - uses: actions/setup-node@v4 96 + with: 97 + node-version: 24 98 + - run: pnpm install 99 + - run: pnpm build 100 + 101 + security: 102 + name: Security Scan 103 + runs-on: ubuntu-latest 104 + steps: 105 + - uses: actions/checkout@v4 106 + - uses: pnpm/action-setup@v4 107 + - uses: actions/setup-node@v4 108 + with: 109 + node-version: 24 110 + - run: pnpm install 111 + - run: pnpm audit --audit-level=high
+13
drizzle.config.ts
··· 1 + import { defineConfig } from "drizzle-kit"; 2 + 3 + export default defineConfig({ 4 + schema: [ 5 + "./src/db/schema/users.ts", 6 + "./src/db/schema/firehose.ts", 7 + ], 8 + out: "./drizzle", 9 + dialect: "postgresql", 10 + dbCredentials: { 11 + url: process.env["DATABASE_URL"] ?? "postgresql://atgora:atgora_dev@localhost:5432/atgora", 12 + }, 13 + });
+17
drizzle/0000_new_ink.sql
··· 1 + CREATE TABLE "users" ( 2 + "did" text PRIMARY KEY NOT NULL, 3 + "handle" text NOT NULL, 4 + "display_name" text, 5 + "avatar_url" text, 6 + "role" text DEFAULT 'user' NOT NULL, 7 + "is_banned" boolean DEFAULT false NOT NULL, 8 + "reputation_score" integer DEFAULT 0 NOT NULL, 9 + "first_seen_at" timestamp with time zone DEFAULT now() NOT NULL, 10 + "last_active_at" timestamp with time zone DEFAULT now() NOT NULL 11 + ); 12 + --> statement-breakpoint 13 + CREATE TABLE "firehose_cursor" ( 14 + "id" text PRIMARY KEY DEFAULT 'default' NOT NULL, 15 + "cursor" bigint, 16 + "updated_at" timestamp with time zone DEFAULT now() NOT NULL 17 + );
+124
drizzle/meta/0000_snapshot.json
··· 1 + { 2 + "id": "c85eafbe-186b-455c-be5e-3d722a29ac20", 3 + "prevId": "00000000-0000-0000-0000-000000000000", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.users": { 8 + "name": "users", 9 + "schema": "", 10 + "columns": { 11 + "did": { 12 + "name": "did", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "handle": { 18 + "name": "handle", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "display_name": { 24 + "name": "display_name", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": false 28 + }, 29 + "avatar_url": { 30 + "name": "avatar_url", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": false 34 + }, 35 + "role": { 36 + "name": "role", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true, 40 + "default": "'user'" 41 + }, 42 + "is_banned": { 43 + "name": "is_banned", 44 + "type": "boolean", 45 + "primaryKey": false, 46 + "notNull": true, 47 + "default": false 48 + }, 49 + "reputation_score": { 50 + "name": "reputation_score", 51 + "type": "integer", 52 + "primaryKey": false, 53 + "notNull": true, 54 + "default": 0 55 + }, 56 + "first_seen_at": { 57 + "name": "first_seen_at", 58 + "type": "timestamp with time zone", 59 + "primaryKey": false, 60 + "notNull": true, 61 + "default": "now()" 62 + }, 63 + "last_active_at": { 64 + "name": "last_active_at", 65 + "type": "timestamp with time zone", 66 + "primaryKey": false, 67 + "notNull": true, 68 + "default": "now()" 69 + } 70 + }, 71 + "indexes": {}, 72 + "foreignKeys": {}, 73 + "compositePrimaryKeys": {}, 74 + "uniqueConstraints": {}, 75 + "policies": {}, 76 + "checkConstraints": {}, 77 + "isRLSEnabled": false 78 + }, 79 + "public.firehose_cursor": { 80 + "name": "firehose_cursor", 81 + "schema": "", 82 + "columns": { 83 + "id": { 84 + "name": "id", 85 + "type": "text", 86 + "primaryKey": true, 87 + "notNull": true, 88 + "default": "'default'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "bigint", 93 + "primaryKey": false, 94 + "notNull": false 95 + }, 96 + "updated_at": { 97 + "name": "updated_at", 98 + "type": "timestamp with time zone", 99 + "primaryKey": false, 100 + "notNull": true, 101 + "default": "now()" 102 + } 103 + }, 104 + "indexes": {}, 105 + "foreignKeys": {}, 106 + "compositePrimaryKeys": {}, 107 + "uniqueConstraints": {}, 108 + "policies": {}, 109 + "checkConstraints": {}, 110 + "isRLSEnabled": false 111 + } 112 + }, 113 + "enums": {}, 114 + "schemas": {}, 115 + "sequences": {}, 116 + "roles": {}, 117 + "policies": {}, 118 + "views": {}, 119 + "_meta": { 120 + "columns": {}, 121 + "schemas": {}, 122 + "tables": {} 123 + } 124 + }
+13
drizzle/meta/_journal.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "postgresql", 4 + "entries": [ 5 + { 6 + "idx": 0, 7 + "version": "7", 8 + "when": 1770940615311, 9 + "tag": "0000_new_ink", 10 + "breakpoints": true 11 + } 12 + ] 13 + }
+28
eslint.config.js
··· 1 + import tseslint from "typescript-eslint"; 2 + 3 + export default tseslint.config( 4 + ...tseslint.configs.strictTypeChecked, 5 + { 6 + languageOptions: { 7 + parserOptions: { 8 + project: "./tsconfig.eslint.json", 9 + tsconfigRootDir: import.meta.dirname, 10 + }, 11 + }, 12 + }, 13 + { 14 + rules: { 15 + "no-console": "error", 16 + "@typescript-eslint/no-explicit-any": "error", 17 + "@typescript-eslint/no-unused-vars": [ 18 + "error", 19 + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 20 + ], 21 + "@typescript-eslint/consistent-type-imports": [ 22 + "error", 23 + { prefer: "type-imports" }, 24 + ], 25 + }, 26 + }, 27 + { ignores: ["dist/", "node_modules/", "drizzle/", "*.config.*"] }, 28 + );
+54
package.json
··· 1 + { 2 + "name": "atgora-api", 3 + "version": "0.1.0", 4 + "description": "ATgora AppView backend — AT Protocol forum API", 5 + "type": "module", 6 + "packageManager": "pnpm@10.29.2", 7 + "license": "AGPL-3.0-only", 8 + "repository": { 9 + "type": "git", 10 + "url": "https://github.com/atgora-forum/atgora-api.git" 11 + }, 12 + "engines": { 13 + "node": ">=24.0.0" 14 + }, 15 + "scripts": { 16 + "dev": "tsx watch src/server.ts", 17 + "build": "tsc", 18 + "start": "node dist/server.js", 19 + "typecheck": "tsc --noEmit", 20 + "lint": "eslint src/ tests/", 21 + "lint:fix": "eslint --fix src/ tests/", 22 + "test": "vitest run", 23 + "test:watch": "vitest", 24 + "test:coverage": "vitest run --coverage", 25 + "test:integration": "vitest run --config vitest.config.integration.ts", 26 + "db:generate": "drizzle-kit generate", 27 + "db:migrate": "drizzle-kit migrate", 28 + "db:studio": "drizzle-kit studio" 29 + }, 30 + "dependencies": { 31 + "@fastify/cors": "^11.0.0", 32 + "@fastify/helmet": "^13.0.0", 33 + "@fastify/rate-limit": "^10.2.0", 34 + "@sentry/node": "^9.27.0", 35 + "drizzle-orm": "^0.45.1", 36 + "fastify": "^5.7.4", 37 + "ioredis": "^5.6.1", 38 + "postgres": "^3.4.8", 39 + "zod": "^4.3.6" 40 + }, 41 + "devDependencies": { 42 + "@testcontainers/postgresql": "^10.23.0", 43 + "@types/node": "^25.2.3", 44 + "@vitest/coverage-v8": "^4.0.18", 45 + "drizzle-kit": "^0.31.4", 46 + "eslint": "^9.28.0", 47 + "supertest": "^7.1.0", 48 + "testcontainers": "^10.23.0", 49 + "tsx": "^4.20.3", 50 + "typescript": "^5.9.3", 51 + "typescript-eslint": "^8.55.0", 52 + "vitest": "^4.0.18" 53 + } 54 + }
+121
src/app.ts
··· 1 + import Fastify from "fastify"; 2 + import helmet from "@fastify/helmet"; 3 + import cors from "@fastify/cors"; 4 + import rateLimit from "@fastify/rate-limit"; 5 + import * as Sentry from "@sentry/node"; 6 + import type { FastifyError } from "fastify"; 7 + import type { Env } from "./config/env.js"; 8 + import { createDb } from "./db/index.js"; 9 + import { createCache } from "./cache/index.js"; 10 + import healthRoutes from "./routes/health.js"; 11 + import type { Database } from "./db/index.js"; 12 + import type { Cache } from "./cache/index.js"; 13 + 14 + // Extend Fastify types with decorated properties 15 + declare module "fastify" { 16 + interface FastifyInstance { 17 + db: Database; 18 + cache: Cache; 19 + env: Env; 20 + } 21 + } 22 + 23 + export async function buildApp(env: Env) { 24 + // Initialize GlitchTip/Sentry if DSN provided 25 + if (env.GLITCHTIP_DSN) { 26 + Sentry.init({ 27 + dsn: env.GLITCHTIP_DSN, 28 + environment: 29 + env.LOG_LEVEL === "debug" || env.LOG_LEVEL === "trace" 30 + ? "development" 31 + : "production", 32 + }); 33 + } 34 + 35 + const app = Fastify({ 36 + logger: { 37 + level: env.LOG_LEVEL, 38 + ...(env.LOG_LEVEL === "debug" || env.LOG_LEVEL === "trace" 39 + ? { transport: { target: "pino-pretty" } } 40 + : {}), 41 + }, 42 + trustProxy: true, 43 + }); 44 + 45 + // Database 46 + const { db, client: dbClient } = createDb(env.DATABASE_URL); 47 + app.decorate("db", db); 48 + app.decorate("env", env); 49 + 50 + // Cache 51 + const cache = createCache(env.VALKEY_URL, app.log); 52 + app.decorate("cache", cache); 53 + 54 + // Security headers 55 + await app.register(helmet, { 56 + contentSecurityPolicy: { 57 + directives: { 58 + defaultSrc: ["'self'"], 59 + scriptSrc: ["'self'"], 60 + styleSrc: ["'self'", "'unsafe-inline'"], 61 + imgSrc: ["'self'", "data:", "https:"], 62 + connectSrc: ["'self'"], 63 + fontSrc: ["'self'"], 64 + objectSrc: ["'none'"], 65 + frameSrc: ["'none'"], 66 + }, 67 + }, 68 + hsts: { 69 + maxAge: 31536000, 70 + includeSubDomains: true, 71 + preload: true, 72 + }, 73 + }); 74 + 75 + // CORS 76 + await app.register(cors, { 77 + origin: env.CORS_ORIGINS.split(",").map((o) => o.trim()), 78 + credentials: true, 79 + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], 80 + allowedHeaders: ["Content-Type", "Authorization"], 81 + }); 82 + 83 + // Rate limiting 84 + await app.register(rateLimit, { 85 + max: env.RATE_LIMIT_READ_ANON, 86 + timeWindow: "1 minute", 87 + }); 88 + 89 + // Routes 90 + await app.register(healthRoutes); 91 + 92 + // Graceful shutdown 93 + app.addHook("onClose", async () => { 94 + app.log.info("Shutting down..."); 95 + await cache.quit(); 96 + await dbClient.end(); 97 + app.log.info("Connections closed"); 98 + }); 99 + 100 + // GlitchTip error handler 101 + app.setErrorHandler((error: FastifyError, request, reply) => { 102 + if (env.GLITCHTIP_DSN) { 103 + Sentry.captureException(error); 104 + } 105 + app.log.error( 106 + { err: error, requestId: request.id }, 107 + "Unhandled error", 108 + ); 109 + const statusCode = error.statusCode ?? 500; 110 + return reply.status(statusCode).send({ 111 + error: "Internal Server Error", 112 + message: 113 + env.LOG_LEVEL === "debug" || env.LOG_LEVEL === "trace" 114 + ? error.message 115 + : "An unexpected error occurred", 116 + statusCode, 117 + }); 118 + }); 119 + 120 + return app; 121 + }
+25
src/cache/index.ts
··· 1 + import { Redis } from "ioredis"; 2 + import type { FastifyBaseLogger } from "fastify"; 3 + 4 + export function createCache(valkeyUrl: string, logger: FastifyBaseLogger) { 5 + const cache = new Redis(valkeyUrl, { 6 + maxRetriesPerRequest: 3, 7 + retryStrategy(times: number) { 8 + const delay = Math.min(times * 200, 2000); 9 + return delay; 10 + }, 11 + lazyConnect: true, 12 + }); 13 + 14 + cache.on("error", (err: Error) => { 15 + logger.error({ err }, "Valkey connection error"); 16 + }); 17 + 18 + cache.on("connect", () => { 19 + logger.info("Connected to Valkey"); 20 + }); 21 + 22 + return cache; 23 + } 24 + 25 + export type Cache = ReturnType<typeof createCache>;
+64
src/config/env.ts
··· 1 + import { z } from "zod/v4"; 2 + 3 + const portSchema = z 4 + .string() 5 + .default("3000") 6 + .transform((val) => Number(val)) 7 + .pipe(z.number().int().min(1).max(65535)); 8 + 9 + const intFromString = (defaultVal: string) => 10 + z 11 + .string() 12 + .default(defaultVal) 13 + .transform((val) => Number(val)) 14 + .pipe(z.number().int().min(0)); 15 + 16 + export const envSchema = z.object({ 17 + // Required 18 + DATABASE_URL: z.url(), 19 + VALKEY_URL: z.url(), 20 + TAP_URL: z.url(), 21 + TAP_ADMIN_PASSWORD: z.string().min(1), 22 + 23 + // Server 24 + HOST: z.string().default("0.0.0.0"), 25 + PORT: portSchema, 26 + LOG_LEVEL: z 27 + .enum(["fatal", "error", "warn", "info", "debug", "trace"]) 28 + .default("info"), 29 + 30 + // CORS 31 + CORS_ORIGINS: z.string().default("http://localhost:3001"), 32 + 33 + // Community 34 + COMMUNITY_MODE: z.enum(["single", "global"]).default("single"), 35 + COMMUNITY_DID: z.string().optional(), 36 + COMMUNITY_NAME: z.string().default("ATgora Community"), 37 + 38 + // Rate Limiting (requests per minute) 39 + RATE_LIMIT_AUTH: intFromString("10"), 40 + RATE_LIMIT_WRITE: intFromString("10"), 41 + RATE_LIMIT_READ_ANON: intFromString("100"), 42 + RATE_LIMIT_READ_AUTH: intFromString("300"), 43 + 44 + // OAuth (optional at scaffold, required at M3) 45 + OAUTH_CLIENT_ID: z.string().optional(), 46 + OAUTH_REDIRECT_URI: z.string().optional(), 47 + 48 + // Monitoring (GlitchTip - Sentry SDK compatible) 49 + GLITCHTIP_DSN: z.string().optional(), 50 + 51 + // Optional: semantic search 52 + EMBEDDING_URL: z.string().optional(), 53 + }); 54 + 55 + export type Env = z.infer<typeof envSchema>; 56 + 57 + export function parseEnv(env: Record<string, unknown>): Env { 58 + const result = envSchema.safeParse(env); 59 + if (!result.success) { 60 + const formatted = z.prettifyError(result.error); 61 + throw new Error(`Invalid environment configuration:\n${formatted}`); 62 + } 63 + return result.data; 64 + }
+17
src/db/index.ts
··· 1 + import { drizzle } from "drizzle-orm/postgres-js"; 2 + import postgres from "postgres"; 3 + import * as schema from "./schema/index.js"; 4 + 5 + export function createDb(databaseUrl: string) { 6 + const client = postgres(databaseUrl, { 7 + max: 20, 8 + idle_timeout: 30, 9 + connect_timeout: 5, 10 + }); 11 + 12 + const db = drizzle(client, { schema }); 13 + 14 + return { db, client }; 15 + } 16 + 17 + export type Database = ReturnType<typeof createDb>["db"];
+9
src/db/schema/firehose.ts
··· 1 + import { pgTable, text, bigint, timestamp } from "drizzle-orm/pg-core"; 2 + 3 + export const firehoseCursor = pgTable("firehose_cursor", { 4 + id: text("id").primaryKey().default("default"), 5 + cursor: bigint("cursor", { mode: "bigint" }), 6 + updatedAt: timestamp("updated_at", { withTimezone: true }) 7 + .notNull() 8 + .defaultNow(), 9 + });
+2
src/db/schema/index.ts
··· 1 + export { users } from "./users.js"; 2 + export { firehoseCursor } from "./firehose.js";
+19
src/db/schema/users.ts
··· 1 + import { pgTable, text, timestamp, boolean, integer } from "drizzle-orm/pg-core"; 2 + 3 + export const users = pgTable("users", { 4 + did: text("did").primaryKey(), 5 + handle: text("handle").notNull(), 6 + displayName: text("display_name"), 7 + avatarUrl: text("avatar_url"), 8 + role: text("role", { enum: ["user", "moderator", "admin"] }) 9 + .notNull() 10 + .default("user"), 11 + isBanned: boolean("is_banned").notNull().default(false), 12 + reputationScore: integer("reputation_score").notNull().default(0), 13 + firstSeenAt: timestamp("first_seen_at", { withTimezone: true }) 14 + .notNull() 15 + .defaultNow(), 16 + lastActiveAt: timestamp("last_active_at", { withTimezone: true }) 17 + .notNull() 18 + .defaultNow(), 19 + });
+3
src/lib/logger.ts
··· 1 + // Fastify creates its own Pino logger instance. 2 + // This module re-exports the logger type for use outside request context. 3 + export type { FastifyBaseLogger as Logger } from "fastify";
+53
src/routes/health.ts
··· 1 + import type { FastifyPluginCallback } from "fastify"; 2 + import { sql } from "drizzle-orm"; 3 + 4 + const healthRoutes: FastifyPluginCallback = (fastify, _opts, done) => { 5 + fastify.get("/api/health", async (_request, reply) => { 6 + return reply.send({ 7 + status: "healthy", 8 + version: "0.1.0", 9 + uptime: process.uptime(), 10 + }); 11 + }); 12 + 13 + fastify.get("/api/health/ready", async (_request, reply) => { 14 + const checks: Record<string, { status: string; latency?: number }> = {}; 15 + 16 + // Check database 17 + const dbStart = performance.now(); 18 + try { 19 + await fastify.db.execute(sql`SELECT 1`); 20 + checks["database"] = { 21 + status: "healthy", 22 + latency: Math.round(performance.now() - dbStart), 23 + }; 24 + } catch { 25 + checks["database"] = { status: "unhealthy" }; 26 + } 27 + 28 + // Check cache 29 + const cacheStart = performance.now(); 30 + try { 31 + await fastify.cache.ping(); 32 + checks["cache"] = { 33 + status: "healthy", 34 + latency: Math.round(performance.now() - cacheStart), 35 + }; 36 + } catch { 37 + checks["cache"] = { status: "unhealthy" }; 38 + } 39 + 40 + const allHealthy = Object.values(checks).every( 41 + (c) => c.status === "healthy", 42 + ); 43 + 44 + return reply.status(allHealthy ? 200 : 503).send({ 45 + status: allHealthy ? "ready" : "degraded", 46 + checks, 47 + }); 48 + }); 49 + 50 + done(); 51 + }; 52 + 53 + export default healthRoutes;
+16
src/server.ts
··· 1 + import { parseEnv } from "./config/env.js"; 2 + import { buildApp } from "./app.js"; 3 + 4 + async function main() { 5 + const env = parseEnv(process.env); 6 + const app = await buildApp(env); 7 + 8 + try { 9 + await app.listen({ host: env.HOST, port: env.PORT }); 10 + } catch (err) { 11 + app.log.fatal(err, "Failed to start server"); 12 + process.exit(1); 13 + } 14 + } 15 + 16 + void main();
+79
tests/integration/health.test.ts
··· 1 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 2 + import { buildApp } from "../../src/app.js"; 3 + import type { FastifyInstance } from "fastify"; 4 + 5 + interface HealthResponse { 6 + status: string; 7 + version: string; 8 + uptime: number; 9 + } 10 + 11 + interface ReadyResponse { 12 + status: string; 13 + checks: Record<string, { status: string; latency?: number }>; 14 + } 15 + 16 + /** 17 + * Integration test: requires PostgreSQL and Valkey running. 18 + * Uses docker-compose.dev.yml services (start with `pnpm dev:infra` from workspace root). 19 + */ 20 + describe("health routes (integration)", () => { 21 + let app: FastifyInstance; 22 + 23 + beforeAll(async () => { 24 + app = await buildApp({ 25 + DATABASE_URL: 26 + process.env["DATABASE_URL"] ?? 27 + "postgresql://atgora:atgora_dev@localhost:5432/atgora", 28 + VALKEY_URL: process.env["VALKEY_URL"] ?? "redis://localhost:6379", 29 + TAP_URL: process.env["TAP_URL"] ?? "http://localhost:2480", 30 + TAP_ADMIN_PASSWORD: 31 + process.env["TAP_ADMIN_PASSWORD"] ?? "tap_dev_secret", 32 + HOST: "0.0.0.0", 33 + PORT: 0, 34 + LOG_LEVEL: "silent", 35 + CORS_ORIGINS: "http://localhost:3001", 36 + COMMUNITY_MODE: "single" as const, 37 + COMMUNITY_NAME: "Test Community", 38 + RATE_LIMIT_AUTH: 10, 39 + RATE_LIMIT_WRITE: 10, 40 + RATE_LIMIT_READ_ANON: 100, 41 + RATE_LIMIT_READ_AUTH: 300, 42 + }); 43 + 44 + await app.cache.connect(); 45 + await app.ready(); 46 + }); 47 + 48 + afterAll(async () => { 49 + await app.close(); 50 + }); 51 + 52 + it("GET /api/health returns 200 with version", async () => { 53 + const response = await app.inject({ 54 + method: "GET", 55 + url: "/api/health", 56 + }); 57 + 58 + expect(response.statusCode).toBe(200); 59 + const body = response.json<HealthResponse>(); 60 + expect(body.status).toBe("healthy"); 61 + expect(body.version).toBe("0.1.0"); 62 + expect(typeof body.uptime).toBe("number"); 63 + }); 64 + 65 + it("GET /api/health/ready returns 200 when all services healthy", async () => { 66 + const response = await app.inject({ 67 + method: "GET", 68 + url: "/api/health/ready", 69 + }); 70 + 71 + expect(response.statusCode).toBe(200); 72 + const body = response.json<ReadyResponse>(); 73 + expect(body.status).toBe("ready"); 74 + expect(body.checks["database"]?.status).toBe("healthy"); 75 + expect(body.checks["cache"]?.status).toBe("healthy"); 76 + expect(typeof body.checks["database"]?.latency).toBe("number"); 77 + expect(typeof body.checks["cache"]?.latency).toBe("number"); 78 + }); 79 + });
+127
tests/unit/config/env.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { envSchema, parseEnv } from "../../../src/config/env.js"; 3 + 4 + describe("envSchema", () => { 5 + const validEnv = { 6 + DATABASE_URL: "postgresql://atgora:atgora_dev@localhost:5432/atgora", 7 + VALKEY_URL: "redis://localhost:6379", 8 + TAP_URL: "http://localhost:2480", 9 + TAP_ADMIN_PASSWORD: "tap_dev_secret", 10 + HOST: "0.0.0.0", 11 + PORT: "3000", 12 + LOG_LEVEL: "info", 13 + CORS_ORIGINS: "http://localhost:3001", 14 + COMMUNITY_MODE: "single", 15 + }; 16 + 17 + it("parses valid environment variables", () => { 18 + const result = envSchema.safeParse(validEnv); 19 + expect(result.success).toBe(true); 20 + if (result.success) { 21 + expect(result.data.PORT).toBe(3000); 22 + expect(result.data.LOG_LEVEL).toBe("info"); 23 + expect(result.data.COMMUNITY_MODE).toBe("single"); 24 + } 25 + }); 26 + 27 + it("rejects missing DATABASE_URL", () => { 28 + const { DATABASE_URL: _, ...env } = validEnv; 29 + const result = envSchema.safeParse(env); 30 + expect(result.success).toBe(false); 31 + }); 32 + 33 + it("rejects missing VALKEY_URL", () => { 34 + const { VALKEY_URL: _, ...env } = validEnv; 35 + const result = envSchema.safeParse(env); 36 + expect(result.success).toBe(false); 37 + }); 38 + 39 + it("rejects missing TAP_URL", () => { 40 + const { TAP_URL: _, ...env } = validEnv; 41 + const result = envSchema.safeParse(env); 42 + expect(result.success).toBe(false); 43 + }); 44 + 45 + it("rejects missing TAP_ADMIN_PASSWORD", () => { 46 + const { TAP_ADMIN_PASSWORD: _, ...env } = validEnv; 47 + const result = envSchema.safeParse(env); 48 + expect(result.success).toBe(false); 49 + }); 50 + 51 + it("applies default values for optional fields", () => { 52 + const result = envSchema.safeParse({ 53 + DATABASE_URL: validEnv.DATABASE_URL, 54 + VALKEY_URL: validEnv.VALKEY_URL, 55 + TAP_URL: validEnv.TAP_URL, 56 + TAP_ADMIN_PASSWORD: validEnv.TAP_ADMIN_PASSWORD, 57 + }); 58 + expect(result.success).toBe(true); 59 + if (result.success) { 60 + expect(result.data.HOST).toBe("0.0.0.0"); 61 + expect(result.data.PORT).toBe(3000); 62 + expect(result.data.LOG_LEVEL).toBe("info"); 63 + expect(result.data.CORS_ORIGINS).toBe("http://localhost:3001"); 64 + expect(result.data.COMMUNITY_MODE).toBe("single"); 65 + expect(result.data.RATE_LIMIT_AUTH).toBe(10); 66 + expect(result.data.RATE_LIMIT_WRITE).toBe(10); 67 + expect(result.data.RATE_LIMIT_READ_ANON).toBe(100); 68 + expect(result.data.RATE_LIMIT_READ_AUTH).toBe(300); 69 + } 70 + }); 71 + 72 + it("rejects invalid PORT (non-numeric)", () => { 73 + const result = envSchema.safeParse({ ...validEnv, PORT: "abc" }); 74 + expect(result.success).toBe(false); 75 + }); 76 + 77 + it("rejects invalid COMMUNITY_MODE", () => { 78 + const result = envSchema.safeParse({ 79 + ...validEnv, 80 + COMMUNITY_MODE: "invalid", 81 + }); 82 + expect(result.success).toBe(false); 83 + }); 84 + 85 + it("accepts global COMMUNITY_MODE", () => { 86 + const result = envSchema.safeParse({ 87 + ...validEnv, 88 + COMMUNITY_MODE: "global", 89 + }); 90 + expect(result.success).toBe(true); 91 + if (result.success) { 92 + expect(result.data.COMMUNITY_MODE).toBe("global"); 93 + } 94 + }); 95 + 96 + it("accepts optional GLITCHTIP_DSN", () => { 97 + const result = envSchema.safeParse({ 98 + ...validEnv, 99 + GLITCHTIP_DSN: "https://key@glitchtip.example.com/1", 100 + }); 101 + expect(result.success).toBe(true); 102 + if (result.success) { 103 + expect(result.data.GLITCHTIP_DSN).toBe( 104 + "https://key@glitchtip.example.com/1", 105 + ); 106 + } 107 + }); 108 + 109 + it("accepts optional EMBEDDING_URL", () => { 110 + const result = envSchema.safeParse({ 111 + ...validEnv, 112 + EMBEDDING_URL: "https://api.openrouter.ai/v1/embeddings", 113 + }); 114 + expect(result.success).toBe(true); 115 + if (result.success) { 116 + expect(result.data.EMBEDDING_URL).toBe( 117 + "https://api.openrouter.ai/v1/embeddings", 118 + ); 119 + } 120 + }); 121 + }); 122 + 123 + describe("parseEnv", () => { 124 + it("throws on invalid environment", () => { 125 + expect(() => parseEnv({})).toThrow(); 126 + }); 127 + });
+72
tests/unit/routes/health.test.ts
··· 1 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 2 + import { buildApp } from "../../../src/app.js"; 3 + import type { FastifyInstance } from "fastify"; 4 + 5 + interface HealthResponse { 6 + status: string; 7 + version: string; 8 + uptime: number; 9 + } 10 + 11 + interface ReadyResponse { 12 + status: string; 13 + checks: Record<string, { status: string; latency?: number }>; 14 + } 15 + 16 + describe("health routes", () => { 17 + let app: FastifyInstance; 18 + 19 + beforeAll(async () => { 20 + app = await buildApp({ 21 + DATABASE_URL: "postgresql://atgora:atgora_dev@localhost:5432/atgora", 22 + VALKEY_URL: "redis://localhost:6379", 23 + TAP_URL: "http://localhost:2480", 24 + TAP_ADMIN_PASSWORD: "tap_dev_secret", 25 + HOST: "0.0.0.0", 26 + PORT: 0, 27 + LOG_LEVEL: "silent", 28 + CORS_ORIGINS: "http://localhost:3001", 29 + COMMUNITY_MODE: "single" as const, 30 + COMMUNITY_NAME: "Test Community", 31 + RATE_LIMIT_AUTH: 10, 32 + RATE_LIMIT_WRITE: 10, 33 + RATE_LIMIT_READ_ANON: 100, 34 + RATE_LIMIT_READ_AUTH: 300, 35 + }); 36 + await app.ready(); 37 + }); 38 + 39 + afterAll(async () => { 40 + await app.close(); 41 + }); 42 + 43 + describe("GET /api/health", () => { 44 + it("returns 200 with status healthy", async () => { 45 + const response = await app.inject({ 46 + method: "GET", 47 + url: "/api/health", 48 + }); 49 + 50 + expect(response.statusCode).toBe(200); 51 + const body = response.json<HealthResponse>(); 52 + expect(body.status).toBe("healthy"); 53 + expect(body.version).toBe("0.1.0"); 54 + expect(typeof body.uptime).toBe("number"); 55 + }); 56 + }); 57 + 58 + describe("GET /api/health/ready", () => { 59 + it("returns dependency check results", async () => { 60 + const response = await app.inject({ 61 + method: "GET", 62 + url: "/api/health/ready", 63 + }); 64 + 65 + const body = response.json<ReadyResponse>(); 66 + expect(body).toHaveProperty("status"); 67 + expect(body).toHaveProperty("checks"); 68 + expect(body.checks).toHaveProperty("database"); 69 + expect(body.checks).toHaveProperty("cache"); 70 + }); 71 + }); 72 + });
+5
tsconfig.eslint.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "include": ["src", "tests"], 4 + "exclude": ["node_modules", "dist"] 5 + }
+26
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "strict": true, 4 + "target": "ES2024", 5 + "module": "NodeNext", 6 + "moduleResolution": "NodeNext", 7 + "esModuleInterop": true, 8 + "skipLibCheck": true, 9 + "forceConsistentCasingInFileNames": true, 10 + "resolveJsonModule": true, 11 + "declaration": true, 12 + "declarationMap": true, 13 + "sourceMap": true, 14 + "noUncheckedIndexedAccess": true, 15 + "noUnusedLocals": true, 16 + "noUnusedParameters": true, 17 + "exactOptionalPropertyTypes": true, 18 + "noImplicitReturns": true, 19 + "noFallthroughCasesInSwitch": true, 20 + "outDir": "dist", 21 + "rootDir": "src", 22 + "types": ["node"] 23 + }, 24 + "include": ["src"], 25 + "exclude": ["node_modules", "dist", "tests"] 26 + }
+11
vitest.config.integration.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: false, 6 + environment: "node", 7 + include: ["tests/integration/**/*.test.ts"], 8 + testTimeout: 30_000, 9 + hookTimeout: 60_000, 10 + }, 11 + });
+21
vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: false, 6 + environment: "node", 7 + include: ["tests/**/*.test.ts"], 8 + exclude: ["tests/integration/**"], 9 + coverage: { 10 + provider: "v8", 11 + include: ["src/**/*.ts"], 12 + exclude: ["src/server.ts", "src/db/migrations/**"], 13 + thresholds: { 14 + statements: 80, 15 + branches: 80, 16 + functions: 80, 17 + lines: 80, 18 + }, 19 + }, 20 + }, 21 + });