From db868f4bdf833bebdab62bd7cab16f79145a7242 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Wed, 9 Sep 2026 17:38:51 +0200 Subject: [PATCH] fix(prod): close legacy storage fallbacks in orders/subscriptions/blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `resolveStoreMode` kill switch (Master-DoD §3/§10) covered the newer `src/repositories/*` stores but not two older paths, both of which are live production execution paths today. 1. LEGACY NEXT.JS STORES. `lib/orders-store.ts` and `lib/subscriptions-store.ts` predate `src/platform/dependency-mode.ts` and called `getSql()` directly, silently falling back to module-global `Map`s when no database is configured. For orders, payments and subscriptions that is not a degraded mode, it is invisible loss of money-bearing state: the API returns 201 and the row evaporates on the next restart or on any other replica. The live 0pt deployment (Coolify app 54) runs NODE_ENV=production with DATABASE_URL, POSTGRES_URL and POSTGRES_PRISMA_URL all unset — so this is the behavior in production right now, across 7 API routes. These now go through `getSqlForDurableStore()`, which reuses the SAME `resolveStoreMode` decision every `src/repositories/*` store already uses rather than re-implementing a second production check. The reuse is load-bearing: `isProductionRuntime` deliberately exempts `NEXT_PHASE=phase-production-build`, because `next build` runs page-data collection under NODE_ENV=production while serving nothing. A naive `process.env.NODE_ENV === "production"` test here would have failed the BUILD instead of protecting a request path. 2. LOCALBLOBSTORE. It writes document bytes to a container-local disk while `document-capture-service` registers an artifact pointer claiming the bytes are stored — durable object storage silently swapped for ephemeral disk. It is the default blob store for both the capture and extraction services. In production it now raises DEPENDENCY_UNAVAILABLE (capability blocked: no ST0RE bytes path exists yet) instead of fabricating success. Asserted per-operation, not in the constructor, so merely wiring the services is not fatal — only actually persisting or reading bytes is. Evidence: full suite 956 passed / 184 skipped, typecheck clean, dependency-cruiser 0 errors, lint identical to main's pre-existing baseline (17 problems, none in changed files). Both guards asserted in both directions and mutation-tested: bypassing resolveStoreMode -> 2 failed; dropping the write assert -> 2 failed; dropping the read assert -> 1 failed; all restored -> 8 passed. 7 non-test call sites. DEPLOYMENT NOTE: merging changes no runtime (no Gitea webhook exists on any repo; deploys are manual). Deploying this build REQUIRES provisioning DATABASE_URL for 0pt first, or the order/subscription routes will correctly return DEPENDENCY_UNAVAILABLE instead of silently accepting orders into memory. That provisioning is a Simon action. Co-Authored-By: Claude Opus 5 --- lib/db.ts | 31 ++++ lib/orders-store.ts | 12 +- lib/subscriptions-store.ts | 6 +- src/adapters/local-blob-store.ts | 29 ++++ ...duction-storage-fallbacks.contract.test.ts | 158 ++++++++++++++++++ 5 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 tests/contracts/production-storage-fallbacks.contract.test.ts diff --git a/lib/db.ts b/lib/db.ts index 0a9f8da..1b4353a 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,4 +1,5 @@ import postgres from "postgres"; +import { resolveStoreMode } from "@/src/platform/dependency-mode"; // Lazy, env-guarded Postgres client. Works with Vercel Postgres (POSTGRES_URL) // or any DATABASE_URL. Returns null when no DB is configured so the app still @@ -40,3 +41,33 @@ export function getSql() { } return _sql; } + +/** + * Durable-store accessor for the legacy Next.js stores (`lib/orders-store.ts`, + * `lib/subscriptions-store.ts`). + * + * Those stores predate `src/platform/dependency-mode.ts` and still call + * `getSql()` directly, so when no database is configured they silently fall + * back to module-global `Map`s. For orders, payments and subscriptions that is + * not a degraded mode, it is invisible data loss: the API accepts an order, + * returns 201, and the row evaporates on the next restart or on any other + * container replica. Money-bearing state must never have a process-memory + * "success" path in production. + * + * This routes those stores through the SAME decision every `src/repositories/*` + * store already uses — `resolveStoreMode` — rather than re-implementing a + * second production check. Reusing it matters for more than tidiness: + * `isProductionRuntime` deliberately exempts `NEXT_PHASE=phase-production-build`, + * because `next build` runs page-data collection under NODE_ENV=production + * while serving nothing. A naive `process.env.NODE_ENV === "production"` test + * here would fail the BUILD instead of protecting a request path. + * + * Returns a `sql` client when a database is configured, `null` outside + * production when it is not (keeping the in-memory seam for tests/local dev), + * and throws `SiaxError("DEPENDENCY_UNAVAILABLE")` in a production runtime. + */ +export function getSqlForDurableStore(storeName: string) { + const mode = resolveStoreMode(storeName, dbUrl()); + if (mode.mode === "memory") return null; + return getSql(); +} diff --git a/lib/orders-store.ts b/lib/orders-store.ts index a677c27..65cf80b 100644 --- a/lib/orders-store.ts +++ b/lib/orders-store.ts @@ -1,4 +1,4 @@ -import { getSql } from "@/lib/db"; +import { getSqlForDurableStore } from "@/lib/db"; import { computeEstimate, type EntityType, type Frequency } from "@/lib/data"; import { serviceById } from "@/lib/services"; @@ -58,7 +58,7 @@ function sameSubmission(a: Order, flow: string, config: OrderConfig): boolean { } export async function findOrderByIdempotencyKey(key: string): Promise { - const sql = getSql(); + const sql = getSqlForDurableStore("0pt.orders"); if (sql) { const rows = await sql` SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at @@ -129,7 +129,7 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency } const est = estimateFor(config); - const sql = getSql(); + const sql = getSqlForDurableStore("0pt.orders"); if (sql) { try { const [row] = await sql<{ order_id: string; created_at: string }[]>` @@ -169,7 +169,7 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency } export async function getOrder(id: string): Promise { - const sql = getSql(); + const sql = getSqlForDurableStore("0pt.orders"); if (sql) { const rows = await sql` SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at @@ -180,7 +180,7 @@ export async function getOrder(id: string): Promise { } export async function listOrders(): Promise { - const sql = getSql(); + const sql = getSqlForDurableStore("0pt.orders"); if (sql) { const rows = await sql` SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at @@ -191,7 +191,7 @@ export async function listOrders(): Promise { } export async function setStatus(id: string, status: string): Promise { - const sql = getSql(); + const sql = getSqlForDurableStore("0pt.orders"); if (sql) { const rows = await sql` UPDATE orders SET status = ${status}, updated_at = now() WHERE order_id::text = ${id} diff --git a/lib/subscriptions-store.ts b/lib/subscriptions-store.ts index 0747770..f068994 100644 --- a/lib/subscriptions-store.ts +++ b/lib/subscriptions-store.ts @@ -1,4 +1,4 @@ -import { getSql } from "@/lib/db"; +import { getSqlForDurableStore } from "@/lib/db"; export interface Subscription { sub_id: string; @@ -13,7 +13,7 @@ export interface Subscription { const mem = new Map(); export async function createSubscription(order_id: string, cadence: string, webhook_url: string | null): Promise { - const sql = getSql(); + const sql = getSqlForDurableStore("0pt.subscriptions"); if (sql) { const [row] = await sql` INSERT INTO subscriptions (order_id, cadence, webhook_url, status) @@ -28,7 +28,7 @@ export async function createSubscription(order_id: string, cadence: string, webh } export async function listSubscriptions(): Promise { - const sql = getSql(); + const sql = getSqlForDurableStore("0pt.subscriptions"); if (sql) { return sql`SELECT sub_id, order_id, cadence, webhook_url, status, next_run_at, created_at FROM subscriptions ORDER BY created_at DESC LIMIT 100`; } diff --git a/src/adapters/local-blob-store.ts b/src/adapters/local-blob-store.ts index a2f4582..2655396 100644 --- a/src/adapters/local-blob-store.ts +++ b/src/adapters/local-blob-store.ts @@ -20,6 +20,8 @@ */ import { promises as fs } from "fs"; import path from "path"; +import { isProductionRuntime } from "../platform/dependency-mode"; +import { SiaxError } from "../platform/errors"; export interface LocalBlobWriteResult { provider: "local_interim"; @@ -32,7 +34,33 @@ export interface LocalBlobWriteResult { export class LocalBlobStore { constructor(private rootDir: string = process.env.OPT_LOCAL_BLOB_DIR || path.join(process.cwd(), ".data", "artifacts")) {} + /** + * A fallback that silently swaps durable object storage for a container's + * local filesystem is a double truth: `document-capture-service` registers + * an artifact pointer claiming the bytes are stored, while the bytes sit on + * an ephemeral single-node disk that is discarded on the next redeploy. In + * production the honest answer is DEPENDENCY_UNAVAILABLE (the capability is + * blocked because no ST0RE bytes path exists yet), never a fabricated + * success. Dev/test keep the adapter so the capture -> persist -> read-back + * flow stays exercisable end to end. + * + * Asserted per-operation rather than in the constructor so that merely + * WIRING the service (both services construct one eagerly as a default) is + * not itself fatal — only actually persisting or reading bytes is. + */ + private assertAllowed(operation: string): void { + if (isProductionRuntime()) { + throw new SiaxError( + "DEPENDENCY_UNAVAILABLE", + "0pt.local_blob_store", + `LocalBlobStore.${operation} is an interim dev/test adapter and may not persist document ` + + `bytes in a production runtime; no canonical ST0RE bytes path is configured` + ); + } + } + async write(bucket: string, key: string, bytes: Buffer): Promise { + this.assertAllowed("write"); const safeKey = key.replace(/\.\./g, "_"); const absolutePath = path.join(this.rootDir, bucket, safeKey); await fs.mkdir(path.dirname(absolutePath), { recursive: true }); @@ -41,6 +69,7 @@ export class LocalBlobStore { } async read(bucket: string, key: string): Promise { + this.assertAllowed("read"); const absolutePath = path.join(this.rootDir, bucket, key); return fs.readFile(absolutePath); } diff --git a/tests/contracts/production-storage-fallbacks.contract.test.ts b/tests/contracts/production-storage-fallbacks.contract.test.ts new file mode 100644 index 0000000..4d74f95 --- /dev/null +++ b/tests/contracts/production-storage-fallbacks.contract.test.ts @@ -0,0 +1,158 @@ +/** + * Master-DoD §3/§10 for the storage paths that `src/platform/dependency-mode.ts` + * did NOT yet cover when it was introduced: + * + * 1. The legacy Next.js stores (`lib/orders-store.ts`, + * `lib/subscriptions-store.ts`) predate that module and called `getSql()` + * directly, silently falling back to module-global `Map`s. For orders, + * payments and subscriptions that is invisible loss of money-bearing + * state: 201 returned, row gone on the next restart or on another replica. + * + * 2. `LocalBlobStore` writes document bytes to a container-local disk while + * an artifact pointer claims they are stored — a fallback that silently + * swaps durable object storage for ephemeral disk. + * + * Both are asserted in BOTH directions: production refuses, and the + * dev/test seam plus the correctly-configured production path still work. A + * guard that had degenerated into a blanket refusal would fail these too. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { getSqlForDurableStore } from "@/lib/db"; +import { LocalBlobStore } from "@/src/adapters/local-blob-store"; +import { SiaxError } from "@/src/platform/errors"; +import { promises as fs } from "fs"; +import os from "os"; +import path from "path"; + +/** + * Drive the real process env (these read it through `process.env`), restoring + * after. + * + * NOTE the async variant below is not a stylistic duplicate. A synchronous + * `try/finally` wrapper around an async callback restores the environment at + * the callback's FIRST `await`, not when it finishes — so every assertion + * after that await runs with the env already restored and silently measures + * the wrong runtime. That produced a false failure while writing these tests + * (a `read` that should have been refused returned ENOENT instead, because + * NODE_ENV was no longer "production" by the time it ran). + */ +function withEnv(vars: Record, fn: () => T): T { + const restore = applyEnv(vars); + try { + return fn(); + } finally { + restore(); + } +} + +async function withEnvAsync( + vars: Record, + fn: () => Promise +): Promise { + const restore = applyEnv(vars); + try { + return await fn(); + } finally { + restore(); + } +} + +function applyEnv(vars: Record): () => void { + const prev: Record = {}; + for (const [k, v] of Object.entries(vars)) { + prev[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + return () => { + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }; +} + +const NO_DB = { + DATABASE_URL: undefined, + POSTGRES_URL: undefined, + POSTGRES_PRISMA_URL: undefined, +}; + +const tmpDirs: string[] = []; +afterEach(async () => { + while (tmpDirs.length) { + const d = tmpDirs.pop()!; + await fs.rm(d, { recursive: true, force: true }).catch(() => {}); + } +}); + +describe("legacy Next.js durable stores (orders/subscriptions)", () => { + it("production + no database => throws DEPENDENCY_UNAVAILABLE instead of an in-memory Map", () => { + withEnv({ NODE_ENV: "production", NEXT_PHASE: undefined, ...NO_DB }, () => { + try { + getSqlForDurableStore("0pt.orders"); + expect.unreachable("must throw rather than return the memory seam"); + } catch (err) { + expect(err).toBeInstanceOf(SiaxError); + expect((err as SiaxError).code).toBe("DEPENDENCY_UNAVAILABLE"); + expect((err as SiaxError).source).toBe("0pt.orders"); + } + }); + }); + + it("names the specific store that is unbacked, so the operator knows what is at risk", () => { + withEnv({ NODE_ENV: "production", NEXT_PHASE: undefined, ...NO_DB }, () => { + expect(() => getSqlForDurableStore("0pt.subscriptions")).toThrowError(/0pt\.subscriptions/); + }); + }); + + it("non-production + no database => null, keeping the in-memory seam for tests/dev", () => { + withEnv({ NODE_ENV: "test", ...NO_DB }, () => { + expect(getSqlForDurableStore("0pt.orders")).toBeNull(); + }); + }); + + it("the Next BUILD phase is not a production runtime (page-data collection must compile)", () => { + withEnv({ NODE_ENV: "production", NEXT_PHASE: "phase-production-build", ...NO_DB }, () => { + expect(getSqlForDurableStore("0pt.orders")).toBeNull(); + }); + }); +}); + +describe("LocalBlobStore is not production object storage", () => { + it("refuses to persist document bytes in a production runtime", async () => { + const store = new LocalBlobStore(path.join(os.tmpdir(), "0pt-blob-guard-prod")); + await withEnvAsync({ NODE_ENV: "production", NEXT_PHASE: undefined }, async () => { + await expect(store.write("docs", "a.pdf", Buffer.from("x"))).rejects.toThrowError(SiaxError); + await expect(store.read("docs", "a.pdf")).rejects.toThrowError(/DEPENDENCY_UNAVAILABLE|ST0RE/); + }); + }); + + it("does not write anything to disk when it refuses", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "0pt-blob-noleak-")); + tmpDirs.push(dir); + const store = new LocalBlobStore(dir); + await withEnvAsync({ NODE_ENV: "production", NEXT_PHASE: undefined }, async () => { + await expect(store.write("docs", "a.pdf", Buffer.from("x"))).rejects.toThrowError(SiaxError); + }); + expect(await fs.readdir(dir)).toEqual([]); + }); + + it("constructing the adapter is never fatal — only persisting/reading bytes is", () => { + withEnv({ NODE_ENV: "production", NEXT_PHASE: undefined }, () => { + expect(() => new LocalBlobStore("/tmp/whatever")).not.toThrow(); + }); + }); + + it("still round-trips bytes outside production (dev/test capability stays real)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "0pt-blob-roundtrip-")); + tmpDirs.push(dir); + const store = new LocalBlobStore(dir); + const result = await withEnvAsync({ NODE_ENV: "test" }, () => + store.write("docs", "a.pdf", Buffer.from("hello")) + ); + expect(result.sizeBytes).toBe(5); + const back = await withEnvAsync({ NODE_ENV: "test" }, () => store.read("docs", "a.pdf")); + expect(back.toString()).toBe("hello"); + }); +}); -- 2.54.0