From 81927b6cdc4e0511fbbdcdcf6eb4b598aa1b127d Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Wed, 12 Aug 2026 05:34:43 +0700 Subject: [PATCH] fix(db): RDS-CA i poolen (app+migrator delar TLS) --- HANDOFF.md | 40 +++++++++++---------- apps/api/vitest.config.ts | 5 +++ apps/worker/vitest.config.ts | 17 +++++++++ infrastructure/deployment/first-deploy.sh | 20 ++++++++--- packages/database/src/client.ts | 44 +++++++++++++---------- 5 files changed, 86 insertions(+), 40 deletions(-) create mode 100644 apps/worker/vitest.config.ts diff --git a/HANDOFF.md b/HANDOFF.md index 0ff263e..d6ecf94 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,40 +4,44 @@ pnpm/turbo-monorepo: Fastify+Drizzle+BullMQ (apps/api, apps/worker), Expo (apps/ Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + ger promptar. ## Var saker finns -- Prod-server (EC2): 13.48.40.1, repo /opt/cibello-platform, HEAD 9a9f8a6 +- Prod-server (EC2): 13.48.40.1, repo /opt/cibello-platform, HEAD 23c4a78 - OpenClaw-workspace: cibello-work/cibello - Region: eu-north-1 ## Infra (uppe) - EIP 13.48.40.1 → api.cibello.app + admin.cibello.app - CloudFront → cibello.app (marknadssajt, S3 cibello-web) -- RDS cibello-prod-db (privat subnät, krypterad, SSL krävs: sslmode=require + NODE_EXTRA_CA_CERTS) +- RDS cibello-prod-db (privat subnät, krypterad, SSL krävs: sslmode=require) - S3: cibello-production (app + training/cibello/-prefix), cibello-web (sajt) - Route53 zon cibello.app. Mail: Mailu mail.aamos.systems (hello@cibello.app) +- Caddy reverse proxy på prod-servern: TLS (Let's Encrypt) → 127.0.0.1:4000 -## AI-modell (viktigt) -- AAMOS Core kan INTE serva Cibello (/v1/tasks 404, ej prod-duglig). Lansering kör GEMINI. -- AAMOS_MODE=gemini. GeminiAamosClient (commit 9a9f8a6) täcker scan/etikett/kvitto/utgång. +## AI-modell +- AAMOS_MODE=gemini. GeminiAamosClient täcker scan/etikett/kvitto/utgång. - Shadow-capture skriver träningspar → s3://cibello-production/training/cibello/. Insamling PÅ - för admin/beta (imageTraining=true); publika användare följer samtycke (gate ej byggd än). -- FAS 2–3 (distillation, cutover) = AMOS-agentens jobb, senare. Bygg INTE här. + för admin/beta (imageTraining=true); publika användare följer samtycke (gate ej byggd än). ## Hemligheter - SSM /cibello/prod/* (SecureString). Gemini-nyckel = f.n. återanvänd /openclaw/-nyckel (ROTERA → egen). ## KLART -- FAS 1 byggt (9a9f8a6): GeminiAamosClient + shadow-capture.ts + scans-integration. -- Server prepped: Node 22, pnpm, Redis. S3 via IAM-instansroll (aws s3 ls OK). RDS SSL löst. -- Giltig Gemini-nyckel i SSM /cibello/prod/gemini-api-key + /opt/cibello-platform/.env. +- [x] FAS 1 byggt: GeminiAamosClient + shadow-capture.ts + scans-integration. +- [x] Gemini som committat prod-läge (AAMOS_MODE=gemini). +- [x] Hermetiska eval-fixtures committade. +- [x] RDS SSL fixat vid källan: `packages/database/src/client.ts` strippar `sslmode` från URL och skapar explicit `ssl`-objekt från `DATABASE_CA_CERT` eller `rejectUnauthorized: false`. +- [x] `first-deploy.sh` uppdaterad för RDS-SSL + `DATABASE_CA_CERT` + `pg_dump` 16. +- [x] Vitest-konfig för `apps/api` och `apps/worker` laddar root `.env` så tester hittar `TEST_DATABASE_URL` även i produktion. +- [x] Prod-server: Node 22, pnpm, Redis, postgresql-client-16, Caddy. +- [x] `DEPLOY_MODE=production REQUIRE_REAL=1 ./infrastructure/deployment/first-deploy.sh` → **ALLA GRINDAR GRÖNA**. +- [x] `https://api.cibello.app/healthz` och `/readyz` svarar. +- [x] Röktest via HTTPS: registrering + token + recept fungerar. -## PENDING (kritiskt, i ordning) -1. Hermetiska eval-bilder: sviten hämtar externt (429/404) → gör Grind 5 opålitlig. Använd - LOKALA committade fixtures. (Blockerar deploy nu.) -2. gemini som committat prod-läge: first-deploy.sh Grind 1 (~L118) + Grind 8 REQUIRE_REAL - (~L281) hårdkodar "http". Patchat bara på servern → COMMITTA i repot. -3. Kör first-deploy.sh (grind 0–8) till grönt. -4. ROTERA exponerade hemligheter (gemini/JWT/SMTP/db-password) — exponerade i transkript vid debug. -5. Före publika användare: end-user samtyckes-gate (bildträning) + in-app allergen-disclaimer. +## PENDING (i ordning) +1. **Rotera exponerade hemligheter** (gemini-nyckel, JWT-secrets, db-password, eventuellt SMTP). + Några hemligheter exponerades i transkript/debug under felsökningen. +2. **End-user samtyckes-gate** för bildträning + in-app allergen-disclaimer före publik lansering. +3. **Säkerhetsgranskning**: öppna portar, Caddy-konfig, IAM, S3-bucket-policy. +4. **Övervakning/larm**: logga fel, sentry, health-check alert. ## Så här återupptar en färsk session Läs denna fil + `git log --oneline -8` + senaste prompt från Sven. Arbeta stramt. Echo ALDRIG diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 3614e4e..479eff8 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,9 +1,14 @@ import { defineConfig } from "vitest/config"; +import { config } from "dotenv"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Load root .env so integration tests can use TEST_DATABASE_URL / DATABASE_URL +// when running `pnpm test` from the workspace root. +config({ path: path.resolve(__dirname, "../../.env") }); + export default defineConfig({ test: { environment: "node", diff --git a/apps/worker/vitest.config.ts b/apps/worker/vitest.config.ts new file mode 100644 index 0000000..01b21b7 --- /dev/null +++ b/apps/worker/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import { config } from "dotenv"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load root .env so worker tests receive DATABASE_URL / TEST_DATABASE_URL +// when running `pnpm test` from the workspace root. +config({ path: path.resolve(__dirname, "../../.env") }); + +export default defineConfig({ + test: { + environment: "node", + globals: false, + }, +}); diff --git a/infrastructure/deployment/first-deploy.sh b/infrastructure/deployment/first-deploy.sh index 94abcd0..a533638 100755 --- a/infrastructure/deployment/first-deploy.sh +++ b/infrastructure/deployment/first-deploy.sh @@ -118,10 +118,19 @@ say "GRIND 1 OK: .env på plats ($([ "$MODE" = production ] && echo 'produktions db_reachable() { (cd "$ROOT" && node -e " const { Client } = require('pg'); - const extraCa = process.env.NODE_EXTRA_CA_CERTS ? require('fs').readFileSync(process.env.NODE_EXTRA_CA_CERTS) : undefined; - const ssl = extraCa ? { ca: extraCa } : undefined; - const c = new Client({ connectionString: process.env.DB_URL, connectionTimeoutMillis: 3000, ...(ssl ? { ssl } : {}) }); - c.connect().then(() => c.end()).then(() => process.exit(0)).catch(() => process.exit(1)); + const fs = require('fs'); + const caPath = process.env.DATABASE_CA_CERT || process.env.NODE_EXTRA_CA_CERTS; + const rawUrl = process.env.DB_URL || ''; + const hasSslmode = /sslmode=(require|verify)/.test(rawUrl); + const url = rawUrl.replace(/[?&]sslmode=[^&]+/, '').replace(/\?$/, ''); + let ssl; + if (caPath && fs.existsSync(caPath)) { + ssl = { ca: fs.readFileSync(caPath), rejectUnauthorized: true }; + } else if (hasSslmode) { + ssl = { rejectUnauthorized: false }; + } + const c = new Client({ connectionString: url, connectionTimeoutMillis: 3000, ...(ssl ? { ssl } : {}) }); + c.connect().then(() => c.end()).then(() => process.exit(0)).catch((e) => { console.error(e.message); process.exit(1); }); ") 2>/dev/null } # Rå TCP-PING: alla svar (även -NOAUTH) bevisar att Redis är nåbar. @@ -147,6 +156,9 @@ start_docker_services() { } export DB_URL="$(get_env DATABASE_URL)" export R_URL="$(get_env REDIS_URL)" +export DATABASE_CA_CERT="$(get_env DATABASE_CA_CERT)" +EXTRA_CA=$(get_env NODE_EXTRA_CA_CERTS) +[ -n "$EXTRA_CA" ] && export NODE_EXTRA_CA_CERTS="$EXTRA_CA" if DB_URL="$DB_URL" db_reachable; then say "Databasen svarar redan – ingen uppstart behövs." elif [ "$MODE" = "staging" ]; then diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 18a6149..f61dc4d 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -1,12 +1,33 @@ +import fs from "node:fs"; import pg from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; -import { readFileSync } from "node:fs"; import * as schema from "./schema/index.js"; export type Database = ReturnType["db"]; let sharedPool: pg.Pool | undefined; +function resolveSsl(url: string): pg.PoolConfig["ssl"] | undefined { + const caPath = process.env.DATABASE_CA_CERT; + if (caPath && fs.existsSync(caPath)) { + return { ca: fs.readFileSync(caPath, "utf8"), rejectUnauthorized: true }; + } + if (/sslmode=(require|verify)/.test(url)) { + return { rejectUnauthorized: false }; + } + return undefined; +} + +function stripSslmode(url: string): string { + try { + const u = new URL(url); + u.searchParams.delete("sslmode"); + return u.toString(); + } catch { + return url.replace(/[?&]sslmode=[^&]+/, "").replace(/\?$/, ""); + } +} + /** * Skapar en databasklient. API och worker delar mönster men äger varsin pool. * DATABASE_URL pekar på appens separata databas med egen minimalprivilegie-användare @@ -14,9 +35,9 @@ let sharedPool: pg.Pool | undefined; */ export function createDatabase(connectionString?: string) { const isTest = process.env.NODE_ENV === "test" || process.env.VITEST !== undefined; - const url = connectionString ?? (isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL); + const rawUrl = connectionString ?? (isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL); - if (!url) { + if (!rawUrl) { if (isTest) { throw new Error( "Sätt TEST_DATABASE_URL till en dedikerad testdatabas. Tester får aldrig använda DATABASE_URL.", @@ -25,26 +46,13 @@ export function createDatabase(connectionString?: string) { throw new Error("Missing DATABASE_URL. Set it in your .env or environment before starting the app."); } - // RDS SSL: om NODE_EXTRA_CA_CERTS är satt används det certifikatet explicit. - // Detta krävs för node-postgres när sslmode=require och certifikatet inte finns - // i systemets CA-butik (t.ex. AWS RDS regionala CA). - let ssl: pg.PoolConfig["ssl"] | undefined; - const extraCa = process.env.NODE_EXTRA_CA_CERTS; - if (extraCa && url.includes("sslmode=")) { - try { - ssl = { ca: readFileSync(extraCa) }; - } catch { - // Certifikatet kunde inte läsas – låt pg falla tillbaka till standardbeteende - // så att felet blir tydligt vid anslutning. - } - } - + const url = stripSslmode(rawUrl); const pool = new pg.Pool({ connectionString: url, max: Number(process.env.DATABASE_POOL_MAX ?? 10), idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, - ...(ssl ? { ssl } : {}), + ssl: resolveSsl(rawUrl), }); const db = drizzle(pool, { schema, casing: "snake_case" });