fix(db): RDS-CA i poolen (app+migrator delar TLS)

This commit is contained in:
Sven (AAMOS AI)
2026-08-12 05:34:43 +07:00
parent 983f3fb2aa
commit 81927b6cdc
5 changed files with 86 additions and 40 deletions
+22 -18
View File
@@ -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. Roller: OpenClaw bygger. Extern granskare ("Sven", i Johans chatt) verifierar + ger promptar.
## Var saker finns ## 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 - OpenClaw-workspace: cibello-work/cibello
- Region: eu-north-1 - Region: eu-north-1
## Infra (uppe) ## Infra (uppe)
- EIP 13.48.40.1 → api.cibello.app + admin.cibello.app - EIP 13.48.40.1 → api.cibello.app + admin.cibello.app
- CloudFront → cibello.app (marknadssajt, S3 cibello-web) - 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) - S3: cibello-production (app + training/cibello/-prefix), cibello-web (sajt)
- Route53 zon cibello.app. Mail: Mailu mail.aamos.systems (hello@cibello.app) - 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) ## AI-modell
- AAMOS Core kan INTE serva Cibello (/v1/tasks 404, ej prod-duglig). Lansering kör GEMINI. - AAMOS_MODE=gemini. GeminiAamosClient täcker scan/etikett/kvitto/utgång.
- AAMOS_MODE=gemini. GeminiAamosClient (commit 9a9f8a6) täcker scan/etikett/kvitto/utgång.
- Shadow-capture skriver träningspar → s3://cibello-production/training/cibello/. Insamling PÅ - 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). för admin/beta (imageTraining=true); publika användare följer samtycke (gate ej byggd än).
- FAS 23 (distillation, cutover) = AMOS-agentens jobb, senare. Bygg INTE här.
## Hemligheter ## Hemligheter
- SSM /cibello/prod/* (SecureString). Gemini-nyckel = f.n. återanvänd /openclaw/-nyckel (ROTERA → egen). - SSM /cibello/prod/* (SecureString). Gemini-nyckel = f.n. återanvänd /openclaw/-nyckel (ROTERA → egen).
## KLART ## KLART
- FAS 1 byggt (9a9f8a6): GeminiAamosClient + shadow-capture.ts + scans-integration. - [x] FAS 1 byggt: GeminiAamosClient + shadow-capture.ts + scans-integration.
- Server prepped: Node 22, pnpm, Redis. S3 via IAM-instansroll (aws s3 ls OK). RDS SSL löst. - [x] Gemini som committat prod-läge (AAMOS_MODE=gemini).
- Giltig Gemini-nyckel i SSM /cibello/prod/gemini-api-key + /opt/cibello-platform/.env. - [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) ## PENDING (i ordning)
1. Hermetiska eval-bilder: sviten hämtar externt (429/404) → gör Grind 5 opålitlig. Använd 1. **Rotera exponerade hemligheter** (gemini-nyckel, JWT-secrets, db-password, eventuellt SMTP).
LOKALA committade fixtures. (Blockerar deploy nu.) Några hemligheter exponerades i transkript/debug under felsökningen.
2. gemini som committat prod-läge: first-deploy.sh Grind 1 (~L118) + Grind 8 REQUIRE_REAL 2. **End-user samtyckes-gate** för bildträning + in-app allergen-disclaimer före publik lansering.
(~L281) hårdkodar "http". Patchat bara på servern → COMMITTA i repot. 3. **Säkerhetsgranskning**: öppna portar, Caddy-konfig, IAM, S3-bucket-policy.
3. Kör first-deploy.sh (grind 08) till grönt. 4. **Övervakning/larm**: logga fel, sentry, health-check alert.
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.
## Så här återupptar en färsk session ## 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 Läs denna fil + `git log --oneline -8` + senaste prompt från Sven. Arbeta stramt. Echo ALDRIG
+5
View File
@@ -1,9 +1,14 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import { config } from "dotenv";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.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({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
+17
View File
@@ -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,
},
});
+16 -4
View File
@@ -118,10 +118,19 @@ say "GRIND 1 OK: .env på plats ($([ "$MODE" = production ] && echo 'produktions
db_reachable() { db_reachable() {
(cd "$ROOT" && node -e " (cd "$ROOT" && node -e "
const { Client } = require('pg'); const { Client } = require('pg');
const extraCa = process.env.NODE_EXTRA_CA_CERTS ? require('fs').readFileSync(process.env.NODE_EXTRA_CA_CERTS) : undefined; const fs = require('fs');
const ssl = extraCa ? { ca: extraCa } : undefined; const caPath = process.env.DATABASE_CA_CERT || process.env.NODE_EXTRA_CA_CERTS;
const c = new Client({ connectionString: process.env.DB_URL, connectionTimeoutMillis: 3000, ...(ssl ? { ssl } : {}) }); const rawUrl = process.env.DB_URL || '';
c.connect().then(() => c.end()).then(() => process.exit(0)).catch(() => process.exit(1)); 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 ") 2>/dev/null
} }
# Rå TCP-PING: alla svar (även -NOAUTH) bevisar att Redis är nåbar. # 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 DB_URL="$(get_env DATABASE_URL)"
export R_URL="$(get_env REDIS_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 if DB_URL="$DB_URL" db_reachable; then
say "Databasen svarar redan ingen uppstart behövs." say "Databasen svarar redan ingen uppstart behövs."
elif [ "$MODE" = "staging" ]; then elif [ "$MODE" = "staging" ]; then
+26 -18
View File
@@ -1,12 +1,33 @@
import fs from "node:fs";
import pg from "pg"; import pg from "pg";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { readFileSync } from "node:fs";
import * as schema from "./schema/index.js"; import * as schema from "./schema/index.js";
export type Database = ReturnType<typeof createDatabase>["db"]; export type Database = ReturnType<typeof createDatabase>["db"];
let sharedPool: pg.Pool | undefined; 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. * 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 * 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) { export function createDatabase(connectionString?: string) {
const isTest = process.env.NODE_ENV === "test" || process.env.VITEST !== undefined; 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) { if (isTest) {
throw new Error( throw new Error(
"Sätt TEST_DATABASE_URL till en dedikerad testdatabas. Tester får aldrig använda DATABASE_URL.", "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."); 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. const url = stripSslmode(rawUrl);
// 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 pool = new pg.Pool({ const pool = new pg.Pool({
connectionString: url, connectionString: url,
max: Number(process.env.DATABASE_POOL_MAX ?? 10), max: Number(process.env.DATABASE_POOL_MAX ?? 10),
idleTimeoutMillis: 30_000, idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000, connectionTimeoutMillis: 10_000,
...(ssl ? { ssl } : {}), ssl: resolveSsl(rawUrl),
}); });
const db = drizzle(pool, { schema, casing: "snake_case" }); const db = drizzle(pool, { schema, casing: "snake_case" });