diff --git a/README.md b/README.md index 2aaf53c..e784b55 100644 --- a/README.md +++ b/README.md @@ -250,18 +250,20 @@ resolved deterministically with suffixes. This package supports optional remote storage sync to a PostgreSQL database: ```bash -export HASNA_CONVERSATIONS_DATABASE_URL="" +export HASNA_CONVERSATIONS_DATABASE_URL="" conversations storage status conversations storage push conversations storage pull ``` -Production storage for Hasna XYZ uses the `conversations` database on -`hasna-xyz-infra-apps-prod-postgres`. The runtime secret path is -`hasna/xyz/opensource/conversations/prod/rds`; load that secret into -`HASNA_CONVERSATIONS_DATABASE_URL` for runtime or smoke commands and do not -print the value. `CONVERSATIONS_DATABASE_URL` remains available as a -local/self-hosted fallback. +This package ships no default database, cluster, or secret-manager identifier — +`HASNA_CONVERSATIONS_DATABASE_URL` (fallback: `CONVERSATIONS_DATABASE_URL`) is +the only required configuration, and its value is never logged or printed. +Operators running a self-hosted deployment may optionally set +`HASNA_CONVERSATIONS_RDS_CLUSTER` and `HASNA_CONVERSATIONS_RDS_SECRET_PATH` to +surface their own cluster name / secret path in `conversations storage +status` output; both are informational only and default to "not configured" +when unset. Before cutover, verify `conversations storage status`, run a read-only smoke against the canonical database, and keep legacy sources read-only until the diff --git a/docs/CUTOVER-RUNBOOK.md b/docs/CUTOVER-RUNBOOK.md deleted file mode 100644 index ac02f15..0000000 --- a/docs/CUTOVER-RUNBOOK.md +++ /dev/null @@ -1,204 +0,0 @@ -# open-conversations — Cloud Cutover Runbook - -Fleet cutover of the conversations store from per-machine SQLite to the shared -cloud PostgreSQL under **Storage Amendment A1 (PURE REMOTE)**. - -> **Scope of this document.** This is the operator runbook for the eventual -> all-machines flip. The current lane (`todos b9b907a9`) delivered **cloud -> readiness only — NO flip.** The schema is live and CRUD-proven on cloud, but -> every machine still runs local SQLite. Do not execute Section 4 until the -> council sequence reaches conversations. - ---- - -## 1. Ordering — conversations flips LAST - -Council-approved cutover sequence for the coordination stores: - -``` -todos -> sessions -> conversations -``` - -Conversations **must not flip** until **todos** and **sessions** are fully -remote, stable, and verified. Conversations is the fleet's live agent-to-agent -channel; flipping it early would strand coordination if an earlier store rolls -back. This ordering is fixed by the final plan (`OSS-CLOUD-RUNTIME-FINAL-PLAN`). - ---- - -## 2. Target infrastructure - -| Item | Value | -|------|-------| -| Account | `hasna-xyz-infra` (789877399345) | -| Region | `us-east-1` | -| RDS cluster | `hasna-xyz-infra-apps-prod-postgres` (pg16, MultiAZ) | -| Database | `conversations` | -| Publicly accessible | **NO** (private; reach via SSM tunnel today) | -| App runtime role | `conversations_app` (owns the `conversations` DB) | -| App runtime secret | `hasna/xyz/opensource/conversations/prod/rds` | -| Owner/DDL secret | `hasna/xyz/infra/apps/prod/postgres/master` (RDS-managed; see `rds_managed_secret_arn`) | -| Runtime env var | `HASNA_CONVERSATIONS_DATABASE_URL` (fallback: `CONVERSATIONS_DATABASE_URL`) | -| Mode env var | `HASNA_CONVERSATIONS_STORAGE_MODE` (fallback: `CONVERSATIONS_STORAGE_MODE`) | - -### Secret naming note (reachability fallback) - -The program catalog references an owner secret `hasna/oss/conversations/database-url-owner`. -That name does **not** exist yet. The **reachability fallback rule** applies: -use the reachable secrets that already exist — -- **DDL / schema:** `hasna/xyz/infra/apps/prod/postgres/master` (owner-capable). -- **App runtime DSN:** `hasna/xyz/opensource/conversations/prod/rds` (`conversations_app`). -When the canonical `hasna/oss/...` owner secret is provisioned, repoint DDL to it. - -### Access path (replace before flip) - -The instance is private. Ops access today is via SSM port-forwarding through -bastion `i-086c334559bec7e0f` (spec lives in the `...prod/rds` secret under -`ssm`, local port `15432`): - -```bash -aws ssm start-session --target i-086c334559bec7e0f \ - --document-name AWS-StartPortForwardingSessionToRemoteHost \ - --parameters '{"host":["hasna-xyz-infra-apps-prod-postgres.culaqeaao9n7.us-east-1.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["15432"]}' -``` - -**Before the fleet flip, the SSM-tunnel access must be replaced by the serve -API path** (per the final plan) so runtime nodes do not depend on bastion -tunnels. Fleet machines reach RDS over Tailscale/VPC networking, not SSM. - ---- - -## 3. A1 compliance — PURE REMOTE, and what must be gated off - -**Cloud mode = reads AND writes go directly to cloud Postgres.** No hybrid, no -sync engine, no cache-as-mode, no merge/conflict logic. After a machine flips, -its local `~/.hasna/conversations/*.db` becomes a **dated backup file**, never a -live read/write source. - -### Bidirectional-sync surfaces that MUST be disabled at flip - -`src/lib/storage-sync.ts` is the LEGACY bidirectional sync engine. It is marked -in-code with `CUTOVER: gate off`. The flip lane MUST disable/remove: - -- `StorageMode` value **`"hybrid"`** and its acceptance in `normalizeStorageMode` - (A1 permits only `local` pre-flip and `remote` post-flip). -- `storageSync()` and the **`storage sync`** CLI subcommand (pull-then-push). -- The `storage push` / `storage pull` subcommands **as a runtime data path** - (`syncPush`/`pushTable`/`upsertPg`, `syncPull`/`pullTable`/`upsertSqlite`). -- The `_sync_conflicts` machinery: `detectConflicts`, `storeConflicts`, - `listConflicts`, `detectAndLogConflicts`, `CONFLICT_TABLES`, `SyncConflict`. - -**Kept (safe) as ops/migration tooling, not a runtime data path:** -`getStoragePg`, `runStorageMigrations`, `listPgTables`, `PG_MIGRATIONS`, and the -PgAdapterAsync remote adapter — these are how the runtime talks to cloud and how -schema is applied. - -> **Current runtime reality.** `src/lib/db.ts` is SQLite-only today; there is no -> `getDb()`-to-Postgres path yet. Standing up that pure-remote runtime (routing -> the app's reads/writes through PgAdapterAsync) is the **flip lane's** job and -> is intentionally NOT done here. - -### TLS - -The remote adapter (`src/lib/remote-storage.ts`) currently sets -`ssl: { rejectUnauthorized: false }` when the DSN contains `sslmode=require`. -This still connects today but is on `pg`'s deprecated path (pg v9 will treat -`sslmode=require` as `verify-full`). The flip lane should adopt the shared -`@hasna/contracts` storage-kit TLS approach (libpq `sslmode` semantics + the RDS -CA via `PGSSLROOTCERT`/`NODE_EXTRA_CA_CERTS`) so verification is correct against -the real RDS hostname. Note: because prod nodes connect to the real RDS DNS -name, the cert matches — the hostname mismatch only appears when tunneling to -`127.0.0.1` for ops testing (use `uselibpqcompat=true&sslmode=require` for that). - ---- - -## 4. Flip procedure (all machines together, single-writer) - -Do **not** do a per-machine staggered flip of a coordination store — a mixed -local/remote fleet splits the agent channel (split-brain). Flip **all machines -together**. - -### 4.0 Preconditions -- [ ] `todos` and `sessions` are fully remote, stable, and verified. -- [ ] Schema on cloud is current (Section 5 confirms it is applied). -- [ ] Readiness CRUD proof green on the app runtime role (Section 6). -- [ ] SSM-tunnel access replaced by the serve/VPC path for runtime nodes. -- [ ] Announce a short coordination-store freeze window in `#oss-cloud-runtime`. - -### 4.1 Final drain (optional, one-time backfill) -If any machine holds local-only channel/project rows that must survive, run a -**one-time** `conversations storage push` from each machine to seed cloud, then -verify counts. This is a migration step, not the runtime mode — after it, sync -is never run again. - -### 4.2 Flip -On every machine, atomically: -1. Set `HASNA_CONVERSATIONS_DATABASE_URL` from `hasna/xyz/opensource/conversations/prod/rds` (never printed to logs). -2. Set `HASNA_CONVERSATIONS_STORAGE_MODE=remote`. -3. Restart the conversations MCP/CLI runtime. - -### 4.3 Retire local SQLite -Rename the local DB to a dated backup so nothing reads it: -`~/.hasna/conversations/conversations.db` -> `conversations.db.pre-cutover-YYYYMMDD.bak`. - -### 4.4 Verify -- [ ] `conversations storage status --json` reports `mode: remote`, `configured: true`. -- [ ] A canary message sent from machine A is read on machine B within seconds. -- [ ] No process is opening the retired local `.db`. -- [ ] Error logs clean; RDS connection count sane. - ---- - -## 5. Schema state (already applied) - -The full schema (`PG_MIGRATIONS` in `src/lib/pg-migrations.ts`) is applied to the -cloud `conversations` DB. Tables present: `projects, channels, channel_members, -channel_subscriptions, messages, agent_presence, resource_locks, reactions, -message_read_receipts, channel_notification_reads, message_mentions, feedback, -graph_edges, tasks, task_comments, task_activity, task_dependencies, _migrations`. - -Re-apply idempotently (owner secret) with: -``` -bunx open-conversations storage migrate # runs PG_MIGRATIONS (IF NOT EXISTS) -``` - -### High-volume `messages` indexes - -Migration 2 adds two hot-path indexes on the write-heaviest table: -- `idx_messages_channel_created (channel, created_at, id)` — channel history read + pagination (`WHERE channel = ? ORDER BY created_at, id`). -- `idx_messages_to_agent_unread (to_agent) WHERE read_at IS NULL` — partial index for the unread-inbox fan-in (`WHERE to_agent = ? AND read_at IS NULL`). - -Both are live on cloud. Existing single-column indexes (`session, to, created, -channel, pinned, blocking, reply_to, project`) and the GIN FTS index -(`search_vector`) remain. - ---- - -## 6. Readiness proof (how it was verified) - -A scripted CRUD cycle was run through the repo's **remote storage code path** -(`PgAdapterAsync`) using the **app runtime role** `conversations_app` — NOT -local SQLite: - -1. `INSERT` scratch channel -2. `INSERT` message into it (verified the `search_vector` trigger fired) -3. `SELECT` it back and assert content -4. `DELETE` message + channel; confirm both gone - -Result: **PASS** — pure-remote reads and writes work under the least-privilege -app role. Rerun (via SSM tunnel) by pointing `READINESS_DSN` at -`postgres://conversations_app:***@127.0.0.1:15432/conversations?uselibpqcompat=true&sslmode=require` -and executing a CRUD script against `src/lib/remote-storage.ts`. - ---- - -## 7. Rollback - -Because conversations flips **last** and **all-at-once**, rollback is symmetric: -1. Set `HASNA_CONVERSATIONS_STORAGE_MODE=local` on all machines, unset the DSN. -2. Restore each machine's `conversations.db.pre-cutover-*.bak` -> `conversations.db`. -3. Restart runtimes. - -Cloud rows written during the remote window are **not** merged back (A1 forbids -sync). Treat the freeze window as short and low-traffic to minimize divergence, -and prefer fixing forward over rolling back once verified. diff --git a/src/cli/storage-contract.test.ts b/src/cli/storage-contract.test.ts index b1eb038..ce085e6 100644 --- a/src/cli/storage-contract.test.ts +++ b/src/cli/storage-contract.test.ts @@ -6,7 +6,7 @@ const retiredCloudCommand = ["program.command(\"", "cloud", "\""].join(""); const retiredCloudExport = ["register", "Cloud", "Commands"].join(""); describe("conversations storage CLI contract", () => { - it("reports canonical RDS metadata in status output without printing a URL", () => { + it("reports canonical RDS metadata as unconfigured when no env override is set (no OSS-baked default)", () => { const result = Bun.spawnSync({ cmd: ["bun", "src/cli/index.tsx", "storage", "status", "--json"], cwd: join(import.meta.dir, "../.."), @@ -16,6 +16,10 @@ describe("conversations storage CLI contract", () => { CONVERSATIONS_DB_PATH: ":memory:", HASNA_CONVERSATIONS_STORAGE_MODE: "local", NO_COLOR: "1", + // Explicitly unset so this test is deterministic even if the ambient + // environment (e.g. a real deployment host) has these configured. + HASNA_CONVERSATIONS_RDS_CLUSTER: "", + HASNA_CONVERSATIONS_RDS_SECRET_PATH: "", }, }); @@ -23,9 +27,9 @@ describe("conversations storage CLI contract", () => { const stdout = new TextDecoder().decode(result.stdout); const info = JSON.parse(stdout) as { canonical: { - cluster: string; + cluster: string | null; database: string; - runtimeSecretPath: string; + runtimeSecretPath: string | null; env: string; fallbackEnv: string; }; @@ -33,9 +37,9 @@ describe("conversations storage CLI contract", () => { }; expect(info.canonical).toEqual({ - cluster: "hasna-xyz-infra-apps-prod-postgres", + cluster: null, database: "conversations", - runtimeSecretPath: "hasna/xyz/opensource/conversations/prod/rds", + runtimeSecretPath: null, env: "HASNA_CONVERSATIONS_DATABASE_URL", fallbackEnv: "CONVERSATIONS_DATABASE_URL", }); @@ -43,6 +47,31 @@ describe("conversations storage CLI contract", () => { expect(stdout).not.toContain("postgres://"); }); + it("reports operator-supplied canonical RDS metadata from env, never a package default", () => { + const result = Bun.spawnSync({ + cmd: ["bun", "src/cli/index.tsx", "storage", "status", "--json"], + cwd: join(import.meta.dir, "../.."), + env: { + ...process.env, + HASNA_CONVERSATIONS_DB_PATH: ":memory:", + CONVERSATIONS_DB_PATH: ":memory:", + HASNA_CONVERSATIONS_STORAGE_MODE: "local", + NO_COLOR: "1", + HASNA_CONVERSATIONS_RDS_CLUSTER: "example-cluster", + HASNA_CONVERSATIONS_RDS_SECRET_PATH: "example/secret/path", + }, + }); + + expect(result.exitCode).toBe(0); + const stdout = new TextDecoder().decode(result.stdout); + const info = JSON.parse(stdout) as { + canonical: { cluster: string | null; runtimeSecretPath: string | null }; + }; + expect(info.canonical.cluster).toBe("example-cluster"); + expect(info.canonical.runtimeSecretPath).toBe("example/secret/path"); + expect(stdout).not.toContain("postgres://"); + }); + it("shows storage command in help without a cloud alias", () => { const result = Bun.spawnSync({ cmd: ["bun", "src/cli/index.tsx", "--help"], diff --git a/src/cli/storage.ts b/src/cli/storage.ts index 2b8f7a2..2de46fd 100644 --- a/src/cli/storage.ts +++ b/src/cli/storage.ts @@ -1,6 +1,8 @@ import type { Command } from "commander"; import chalk from "chalk"; import { + CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV, + CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV, DEFAULT_STORAGE_TABLES, getCanonicalConversationsRdsConfig, getStorageConfig, @@ -47,9 +49,9 @@ function installStorageSubcommands(storage: Command): void { if (opts.json) { printJson(info); return; } console.log(`Mode: ${info.mode}`); console.log("Service: conversations"); - console.log(`Canonical RDS cluster: ${canonical.cluster}`); + console.log(`Canonical RDS cluster: ${canonical.cluster ?? `(not configured — set ${CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV})`}`); console.log(`Canonical database: ${canonical.database}`); - console.log(`Runtime secret path: ${canonical.runtimeSecretPath}`); + console.log(`Runtime secret path: ${canonical.runtimeSecretPath ?? `(not configured — set ${CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV})`}`); console.log(`Database env: ${canonical.env} (fallback: ${canonical.fallbackEnv})`); console.log(`Remote storage configured: ${info.configured ? "yes" : "no"}`); console.log(`Tables: ${info.tables.join(", ")}`); @@ -80,8 +82,8 @@ function installStorageSubcommands(storage: Command): void { // CUTOVER: gate off. Bidirectional sync is forbidden under Storage Amendment // A1 (pure remote). The flip lane must remove this `sync` subcommand (and the - // push/pull data-path subcommands) before conversations goes remote. See - // docs/CUTOVER-RUNBOOK.md. + // push/pull data-path subcommands) before conversations goes remote. The flip + // procedure lives in Hasna's internal ops documentation (not shipped here). storage.command("sync").description("Bidirectional sync: pull then push").option("--tables ", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts: { tables?: string; json?: boolean }) => { try { const result = await storageSync({ tables: opts.tables }); diff --git a/src/index.ts b/src/index.ts index 8b743ce..8deeb5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,9 +103,9 @@ export { export { CANONICAL_CONVERSATIONS_DATABASE_ENV, - CANONICAL_CONVERSATIONS_RDS_CLUSTER, + CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV, CANONICAL_CONVERSATIONS_RDS_DATABASE, - CANONICAL_CONVERSATIONS_RDS_SECRET_PATH, + CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV, CONVERSATIONS_DATABASE_FALLBACK_ENV, DEFAULT_STORAGE_TABLES, SYNC_EXCLUDED, diff --git a/src/lib/cloud-store.test.ts b/src/lib/cloud-store.test.ts index de831b1..0730e18 100644 --- a/src/lib/cloud-store.test.ts +++ b/src/lib/cloud-store.test.ts @@ -10,7 +10,7 @@ import { } from "./cloud-store.js"; const CLOUD_ENV = { - HASNA_CONVERSATIONS_API_URL: "https://conversations.hasna.xyz", + HASNA_CONVERSATIONS_API_URL: "https://conversations.md", HASNA_CONVERSATIONS_API_KEY: "hasna_conversations_testkey_00000000", }; @@ -40,7 +40,7 @@ describe("resolveConversationsCloud", () => { test("cloud client when url + key set", () => { const client = resolveConversationsCloud({ ...CLOUD_ENV }); expect(client).not.toBeNull(); - expect(client!.baseUrl).toBe("https://conversations.hasna.xyz/v1"); + expect(client!.baseUrl).toBe("https://conversations.md/v1"); expect(isCloudMode({ ...CLOUD_ENV })).toBe(true); }); test("null (local) when unset", () => { @@ -115,7 +115,7 @@ describe("routed message CRUD hits the cloud API when self_hosted", () => { const msg = await sendMessage({ from: "alice", to: "bob", content: "hi" }, { ...CLOUD_ENV }); expect(msg.id).toBe(42); const post = calls.find((c) => c.method === "POST"); - expect(post?.url).toBe("https://conversations.hasna.xyz/v1/messages"); + expect(post?.url).toBe("https://conversations.md/v1/messages"); expect(post?.auth).toBe(`Bearer ${CLOUD_ENV.HASNA_CONVERSATIONS_API_KEY}`); expect(JSON.parse(post!.body!)).toMatchObject({ from: "alice", to: "bob", content: "hi" }); }); diff --git a/src/lib/cloud-store.ts b/src/lib/cloud-store.ts index 47abbd9..480cb0c 100644 --- a/src/lib/cloud-store.ts +++ b/src/lib/cloud-store.ts @@ -4,7 +4,7 @@ * When the client-flip contract resolves to `cloud-http` — i.e. mode is * cloud/self_hosted AND `HASNA_CONVERSATIONS_API_URL` + * `HASNA_CONVERSATIONS_API_KEY` are set — the routed message reads/writes below - * go to `https://conversations.hasna.xyz/v1` with the bearer key instead of the + * go to `https://conversations.md/v1` with the bearer key instead of the * local SQLite store. Otherwise they fall through to the local implementation in * `messages.ts`. * diff --git a/src/lib/storage-sync.test.ts b/src/lib/storage-sync.test.ts index c4513c3..c7178fd 100644 --- a/src/lib/storage-sync.test.ts +++ b/src/lib/storage-sync.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from "bun:test"; import { CANONICAL_CONVERSATIONS_DATABASE_ENV, - CANONICAL_CONVERSATIONS_RDS_CLUSTER, + CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV, CANONICAL_CONVERSATIONS_RDS_DATABASE, - CANONICAL_CONVERSATIONS_RDS_SECRET_PATH, + CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV, CONVERSATIONS_DATABASE_FALLBACK_ENV, DEFAULT_STORAGE_TABLES, STORAGE_DATABASE_ENV, @@ -20,6 +20,8 @@ import { ConversationsDatabase } from "./db.js"; const ENV_NAMES = [ ...STORAGE_DATABASE_ENV, ...STORAGE_MODE_ENV, + CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV, + CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV, ["HASNA", "CONVERSATIONS", "CLOUD", "DATABASE", "URL"].join("_"), ["OPEN", "CONVERSATIONS", "CLOUD", "DATABASE", "URL"].join("_"), ["CONVERSATIONS", "CLOUD", "DATABASE", "URL"].join("_"), @@ -35,11 +37,13 @@ afterEach(() => { }); describe("conversations storage configuration", () => { - it("exposes the canonical Hasna XYZ RDS descriptor without secret values", () => { + it("ships no baked-in RDS cluster/secret-path default (operator env only)", () => { + delete process.env[CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV]; + delete process.env[CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV]; expect(getCanonicalConversationsRdsConfig()).toEqual({ - cluster: CANONICAL_CONVERSATIONS_RDS_CLUSTER, + cluster: null, database: CANONICAL_CONVERSATIONS_RDS_DATABASE, - runtimeSecretPath: CANONICAL_CONVERSATIONS_RDS_SECRET_PATH, + runtimeSecretPath: null, env: CANONICAL_CONVERSATIONS_DATABASE_ENV, fallbackEnv: CONVERSATIONS_DATABASE_FALLBACK_ENV, }); @@ -49,6 +53,18 @@ describe("conversations storage configuration", () => { ]); }); + it("surfaces operator-supplied RDS cluster/secret-path from env, never a literal", () => { + process.env[CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV] = "example-cluster"; + process.env[CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV] = "example/secret/path"; + expect(getCanonicalConversationsRdsConfig()).toEqual({ + cluster: "example-cluster", + database: CANONICAL_CONVERSATIONS_RDS_DATABASE, + runtimeSecretPath: "example/secret/path", + env: CANONICAL_CONVERSATIONS_DATABASE_ENV, + fallbackEnv: CONVERSATIONS_DATABASE_FALLBACK_ENV, + }); + }); + it("uses canonical storage database envs", () => { process.env["HASNA_CONVERSATIONS_DATABASE_URL"] = "postgres://new.example/conversations"; diff --git a/src/lib/storage-sync.ts b/src/lib/storage-sync.ts index 85ed865..bce6b23 100644 --- a/src/lib/storage-sync.ts +++ b/src/lib/storage-sync.ts @@ -16,7 +16,9 @@ // - syncPush/pushTable/upsertPg and syncPull/pullTable/upsertSqlite as a // RUNTIME data path (the pg schema helpers here — getStoragePg, // runStorageMigrations, listPgTables — remain safe as ops/migration tools). -// See docs/CUTOVER-RUNBOOK.md for the exact flip procedure and gate list. +// The exact flip procedure and gate list live in Hasna's internal ops +// documentation (not shipped in this public package — see SECURITY note below +// on canonical RDS identifiers). // ============================================================================ import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; @@ -75,9 +77,15 @@ const CONFLICT_TABLES = new Set(["channels", "projects", "agent_presence"]); // the flip; kept here only so pre-cutover configs still parse. export type StorageMode = "local" | "remote" | "hybrid"; -export const CANONICAL_CONVERSATIONS_RDS_CLUSTER = "hasna-xyz-infra-apps-prod-postgres"; +// SECURITY: this OSS package must never ship a literal internal RDS cluster +// name or Secrets Manager path. The canonical cluster/secret-path identifiers +// are operator-supplied, server-side configuration only (set these env vars on +// the machine/service that runs the real deployment); the package ships no +// default value for either. `CANONICAL_CONVERSATIONS_RDS_DATABASE` is just the +// logical database name (not an infra identifier) and is safe to default. +export const CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV = "HASNA_CONVERSATIONS_RDS_CLUSTER"; export const CANONICAL_CONVERSATIONS_RDS_DATABASE = "conversations"; -export const CANONICAL_CONVERSATIONS_RDS_SECRET_PATH = "hasna/xyz/opensource/conversations/prod/rds"; +export const CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV = "HASNA_CONVERSATIONS_RDS_SECRET_PATH"; export const CANONICAL_CONVERSATIONS_DATABASE_ENV = "HASNA_CONVERSATIONS_DATABASE_URL"; export const CONVERSATIONS_DATABASE_FALLBACK_ENV = "CONVERSATIONS_DATABASE_URL"; @@ -95,9 +103,11 @@ export interface StorageConfig { } export interface CanonicalConversationsRdsConfig { - cluster: typeof CANONICAL_CONVERSATIONS_RDS_CLUSTER; + /** Operator-configured cluster identifier, or `null` when unset (server-side only; no OSS default). */ + cluster: string | null; database: typeof CANONICAL_CONVERSATIONS_RDS_DATABASE; - runtimeSecretPath: typeof CANONICAL_CONVERSATIONS_RDS_SECRET_PATH; + /** Operator-configured Secrets Manager path, or `null` when unset (server-side only; no OSS default). */ + runtimeSecretPath: string | null; env: typeof CANONICAL_CONVERSATIONS_DATABASE_ENV; fallbackEnv: typeof CONVERSATIONS_DATABASE_FALLBACK_ENV; } @@ -110,11 +120,13 @@ export const STORAGE_CONFIG_PATH = join(homedir(), ".hasna", "conversations", "s export const STORAGE_DATABASE_ENV = [CANONICAL_CONVERSATIONS_DATABASE_ENV, CONVERSATIONS_DATABASE_FALLBACK_ENV] as const; export const STORAGE_MODE_ENV = ["HASNA_CONVERSATIONS_STORAGE_MODE", "CONVERSATIONS_STORAGE_MODE"] as const; -export function getCanonicalConversationsRdsConfig(): CanonicalConversationsRdsConfig { +export function getCanonicalConversationsRdsConfig( + env: Record = process.env, +): CanonicalConversationsRdsConfig { return { - cluster: CANONICAL_CONVERSATIONS_RDS_CLUSTER, + cluster: env[CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV] || null, database: CANONICAL_CONVERSATIONS_RDS_DATABASE, - runtimeSecretPath: CANONICAL_CONVERSATIONS_RDS_SECRET_PATH, + runtimeSecretPath: env[CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV] || null, env: CANONICAL_CONVERSATIONS_DATABASE_ENV, fallbackEnv: CONVERSATIONS_DATABASE_FALLBACK_ENV, }; diff --git a/src/mcp/tools/storage.test.ts b/src/mcp/tools/storage.test.ts index 786066f..c092dda 100644 --- a/src/mcp/tools/storage.test.ts +++ b/src/mcp/tools/storage.test.ts @@ -42,9 +42,16 @@ const TEST_DB = join(tmpdir(), `conversations-test-storage-${Date.now()}.db`); let client: Client; let server: McpServer; +const savedRdsClusterEnv = process.env.HASNA_CONVERSATIONS_RDS_CLUSTER; +const savedRdsSecretPathEnv = process.env.HASNA_CONVERSATIONS_RDS_SECRET_PATH; + beforeAll(async () => { saveStorageConfig(); process.env.CONVERSATIONS_DB_PATH = TEST_DB; + // Isolate from any real deployment env (this package ships no default for + // either — see SECURITY note in lib/storage-sync.ts). + delete process.env.HASNA_CONVERSATIONS_RDS_CLUSTER; + delete process.env.HASNA_CONVERSATIONS_RDS_SECRET_PATH; closeDb(); server = new McpServer({ name: "test-storage", version: "0.0.1" }); @@ -58,6 +65,8 @@ beforeAll(async () => { afterAll(async () => { restoreStorageConfig(); + if (savedRdsClusterEnv !== undefined) process.env.HASNA_CONVERSATIONS_RDS_CLUSTER = savedRdsClusterEnv; + if (savedRdsSecretPathEnv !== undefined) process.env.HASNA_CONVERSATIONS_RDS_SECRET_PATH = savedRdsSecretPathEnv; try { await server.close(); } catch {} try { await client.close(); } catch {} @@ -86,8 +95,8 @@ describe("conversations_storage_status", () => { const text = getText(result as any); expect(text).toContain("Mode: local"); expect(text).toContain("Service: conversations"); - expect(text).toContain("Canonical RDS cluster: hasna-xyz-infra-apps-prod-postgres"); - expect(text).toContain("Runtime secret path: hasna/xyz/opensource/conversations/prod/rds"); + expect(text).toContain("Canonical RDS cluster: (not configured — set HASNA_CONVERSATIONS_RDS_CLUSTER)"); + expect(text).toContain("Runtime secret path: (not configured — set HASNA_CONVERSATIONS_RDS_SECRET_PATH)"); expect(text).toContain("PostgreSQL: skipped in local mode"); }, 10000); diff --git a/src/mcp/tools/storage.ts b/src/mcp/tools/storage.ts index 6105f61..f18f7ce 100644 --- a/src/mcp/tools/storage.ts +++ b/src/mcp/tools/storage.ts @@ -1,6 +1,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { + CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV, + CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV, DEFAULT_STORAGE_TABLES, detectAndLogConflicts, ensureConflictsTable, @@ -31,9 +33,9 @@ export function registerStorageSyncTools(server: McpServer): void { const lines = [ `Mode: ${config.mode}`, "Service: conversations", - `Canonical RDS cluster: ${canonical.cluster}`, + `Canonical RDS cluster: ${canonical.cluster ?? `(not configured — set ${CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV})`}`, `Canonical database: ${canonical.database}`, - `Runtime secret path: ${canonical.runtimeSecretPath}`, + `Runtime secret path: ${canonical.runtimeSecretPath ?? `(not configured — set ${CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV})`}`, `Database env: ${canonical.env} (fallback: ${canonical.fallbackEnv})`, `RDS Host: ${config.rds.host || (getStorageDatabaseUrl() ? "(env database url)" : "(not configured)")}`, ]; diff --git a/src/storage.ts b/src/storage.ts index e417291..2cfdbee 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -2,9 +2,9 @@ export { PG_MIGRATIONS } from "./lib/pg-migrations.js"; export { PgAdapterAsync } from "./lib/remote-storage.js"; export { CANONICAL_CONVERSATIONS_DATABASE_ENV, - CANONICAL_CONVERSATIONS_RDS_CLUSTER, + CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV, CANONICAL_CONVERSATIONS_RDS_DATABASE, - CANONICAL_CONVERSATIONS_RDS_SECRET_PATH, + CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV, CONVERSATIONS_DATABASE_FALLBACK_ENV, DEFAULT_STORAGE_TABLES, STORAGE_CONFIG_PATH,