fix(prod): close legacy storage fallbacks in orders/subscriptions/blobs #51
@@ -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();
|
||||
}
|
||||
|
||||
+6
-6
@@ -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<Order | null> {
|
||||
const sql = getSql();
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
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<Order | null> {
|
||||
const sql = getSql();
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
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<Order | null> {
|
||||
}
|
||||
|
||||
export async function listOrders(): Promise<Order[]> {
|
||||
const sql = getSql();
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
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<Order[]> {
|
||||
}
|
||||
|
||||
export async function setStatus(id: string, status: string): Promise<Order | null> {
|
||||
const sql = getSql();
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
UPDATE orders SET status = ${status}, updated_at = now() WHERE order_id::text = ${id}
|
||||
|
||||
@@ -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<string, Subscription>();
|
||||
|
||||
export async function createSubscription(order_id: string, cadence: string, webhook_url: string | null): Promise<Subscription> {
|
||||
const sql = getSql();
|
||||
const sql = getSqlForDurableStore("0pt.subscriptions");
|
||||
if (sql) {
|
||||
const [row] = await sql<Subscription[]>`
|
||||
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<Subscription[]> {
|
||||
const sql = getSql();
|
||||
const sql = getSqlForDurableStore("0pt.subscriptions");
|
||||
if (sql) {
|
||||
return sql<Subscription[]>`SELECT sub_id, order_id, cadence, webhook_url, status, next_run_at, created_at FROM subscriptions ORDER BY created_at DESC LIMIT 100`;
|
||||
}
|
||||
|
||||
@@ -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<LocalBlobWriteResult> {
|
||||
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<Buffer> {
|
||||
this.assertAllowed("read");
|
||||
const absolutePath = path.join(this.rootDir, bucket, key);
|
||||
return fs.readFile(absolutePath);
|
||||
}
|
||||
|
||||
@@ -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<T>(vars: Record<string, string | undefined>, fn: () => T): T {
|
||||
const restore = applyEnv(vars);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function withEnvAsync<T>(
|
||||
vars: Record<string, string | undefined>,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const restore = applyEnv(vars);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
function applyEnv(vars: Record<string, string | undefined>): () => void {
|
||||
const prev: Record<string, string | undefined> = {};
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user