fix(prod): close legacy storage fallbacks in orders/subscriptions/blobs #51

Merged
admin merged 1 commits from fix/production-storage-fallbacks into main 2026-09-10 22:59:06 +00:00
Owner

What

Closes the two storage paths that the resolveStoreMode kill switch (Master-DoD §3/§10) did not cover. Both are live production execution paths today.

Why this is not theoretical

The live 0pt deployment (Coolify app 54, container shxd6lepxuxfsjsp0ath3z1g-…, up 42h healthy) runs with:

  • NODE_ENV=production
  • DATABASE_URL, POSTGRES_URL, POSTGRES_PRISMA_URL all unset

So getSql() returns null and lib/orders-store.ts / lib/subscriptions-store.ts are serving out of module-global Maps in production right now, across 7 API routes:

app/api/orders, app/api/orders/[id], app/api/subscriptions, app/api/payments/[order_id]/deposit, app/api/payments/[order_id]/release, app/api/webhooks/stripe, app/api/admin/metrics

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 two guards

1. Legacy Next.js stores now call getSqlForDurableStore(), which reuses the same resolveStoreMode decision every src/repositories/* store already uses, instead of re-implementing a second production check.

The reuse is load-bearing, not tidiness. isProductionRuntime deliberately exempts NEXT_PHASE=phase-production-build, because next build runs page-data collection under NODE_ENV=production while serving nothing. The naive process.env.NODE_ENV === "production" check would have failed the build rather than protecting a request path. There is a test pinning exactly that.

2. LocalBlobStore 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 (opts?.blobStore ?? new LocalBlobStore()). 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. A test pins that too.

Evidence

Check Result
vitest run (full suite) 956 passed / 184 skipped
npm run typecheck (blocking tsconfig.check.json) clean, exit 0
npm run deps:check 0 errors, 7 pre-existing circular warnings
npm run lint 17 problems — byte-identical to main's baseline; 0 in changed files
non-test call sites of the new function 7 (→ 7 live API routes)

Mutation-tested, both directions:

Mutation Result
getSqlForDurableStore bypasses resolveStoreMode 2 failed / 6 passed
drop assertAllowed("write") 2 failed / 6 passed
drop assertAllowed("read") 1 failed / 7 passed
all restored 8 passed

Guards are asserted in both directions — production refuses, and the dev/test seam, the next build phase, and the configured-production path all still work. A guard that degenerated into a blanket refusal would fail these too.

Deployment note — requires action before deploy

Merging changes no runtime: there is no Gitea webhook on any repo in this estate (webhook table = 0 rows, hook_task = 0, verified against a positive control of 402 repositories), so 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.

Not claimed: no live verification, no restore proof. This is a code guard only.

🤖 Generated with Claude Code

## What Closes the two storage paths that the `resolveStoreMode` kill switch (Master-DoD §3/§10) did **not** cover. Both are live production execution paths today. ## Why this is not theoretical The live 0pt deployment (Coolify app 54, container `shxd6lepxuxfsjsp0ath3z1g-…`, up 42h healthy) runs with: - `NODE_ENV=production` - `DATABASE_URL`, `POSTGRES_URL`, `POSTGRES_PRISMA_URL` **all unset** So `getSql()` returns `null` and `lib/orders-store.ts` / `lib/subscriptions-store.ts` are serving **out of module-global `Map`s in production right now**, across 7 API routes: `app/api/orders`, `app/api/orders/[id]`, `app/api/subscriptions`, `app/api/payments/[order_id]/deposit`, `app/api/payments/[order_id]/release`, `app/api/webhooks/stripe`, `app/api/admin/metrics` 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 two guards **1. Legacy Next.js stores** now call `getSqlForDurableStore()`, which reuses the **same** `resolveStoreMode` decision every `src/repositories/*` store already uses, instead of re-implementing a second production check. The reuse is load-bearing, not tidiness. `isProductionRuntime` deliberately exempts `NEXT_PHASE=phase-production-build`, because `next build` runs page-data collection under `NODE_ENV=production` while serving nothing. The naive `process.env.NODE_ENV === "production"` check would have **failed the build** rather than protecting a request path. There is a test pinning exactly that. **2. `LocalBlobStore`** 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 (`opts?.blobStore ?? new LocalBlobStore()`). 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. A test pins that too. ## Evidence | Check | Result | |---|---| | `vitest run` (full suite) | **956 passed / 184 skipped** | | `npm run typecheck` (blocking `tsconfig.check.json`) | clean, exit 0 | | `npm run deps:check` | **0 errors**, 7 pre-existing circular warnings | | `npm run lint` | 17 problems — **byte-identical to main's baseline**; 0 in changed files | | non-test call sites of the new function | **7** (→ 7 live API routes) | Mutation-tested, both directions: | Mutation | Result | |---|---| | `getSqlForDurableStore` bypasses `resolveStoreMode` | 2 failed / 6 passed | | drop `assertAllowed("write")` | 2 failed / 6 passed | | drop `assertAllowed("read")` | 1 failed / 7 passed | | all restored | **8 passed** | Guards are asserted in both directions — production refuses, **and** the dev/test seam, the `next build` phase, and the configured-production path all still work. A guard that degenerated into a blanket refusal would fail these too. ## Deployment note — requires action before deploy Merging changes no runtime: there is no Gitea webhook on any repo in this estate (`webhook` table = 0 rows, `hook_task` = 0, verified against a positive control of 402 repositories), so 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. Not claimed: no live verification, no restore proof. This is a code guard only. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
admin added 1 commit 2026-09-09 15:39:24 +00:00
fix(prod): close legacy storage fallbacks in orders/subscriptions/blobs
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 21s
CI (SIAX Cloud) / security (pull_request) Successful in 15s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / quality (push) Successful in 44s
CI (SIAX Cloud) / integration (push) Successful in 45s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m45s
db868f4bdf
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 <noreply@anthropic.com>
CODEOWNERS rules requested review from sax3l 2026-09-09 15:39:25 +00:00
siax-bot approved these changes 2026-09-10 22:58:59 +00:00
siax-bot left a comment
Collaborator

siax-bot CI-verifiering: samtliga grinding-jobb success. Auto-approval enligt masterplan-execution-mandat.

siax-bot CI-verifiering: samtliga grinding-jobb success. Auto-approval enligt masterplan-execution-mandat.
admin merged commit into main 2026-09-10 22:59:06 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/0pt#51