From 417641d0596e31171d4fa1659e89a297ebded5df Mon Sep 17 00:00:00 2001 From: hasna-drain Date: Wed, 29 Jul 2026 14:22:30 +0000 Subject: [PATCH 1/3] chore: begin drain OPE18-00009 From 2c497554f87154697ef1a90799d353cffed2e64c Mon Sep 17 00:00:00 2001 From: hasna-drain Date: Wed, 29 Jul 2026 14:24:19 +0000 Subject: [PATCH 2/3] OPE18-00009: Migrate hasna/attachments to the @hasna/contracts 0.8.4 manifest sch --- hasna.contract.json | 75 +++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/hasna.contract.json b/hasna.contract.json index 42c8241..674990a 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -3,61 +3,78 @@ "name": "attachments", "class": "cli-with-store", "contractVersion": "v1", - "kitVersion": "0.8.2", - "description": "Open-source attachment transfer with local or private S3 storage, app-hosted share links, CLI, MCP, REST API (attachments-serve), and a generated SDK. Cloud service is PURE REMOTE (Amendment A1): reads/writes RDS Postgres and S3 directly.", - "bins": ["attachments", "attachments-mcp", "attachments-serve"], - "hosting": ["user-hosted"], - "deploymentModes": ["local", "self_hosted"], - "storage": { - "mode": "cloud", - "engines": ["sqlite", "postgres"], - "envPrefix": "HASNA_ATTACHMENTS_", - "aliasEnvPrefix": "ATTACHMENTS_", - "pgTestGate": { - "envVar": "HASNA_ATTACHMENTS_TEST_DATABASE_URL", - "command": "HASNA_ATTACHMENTS_DATABASE_URL=\"${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}\" HASNA_ATTACHMENTS_STORAGE_MODE=cloud ATTACHMENTS_REQUIRE_POSTGRES=1 bun test src/db" - } - }, + "kitVersion": "0.8.4", + "description": "Open-source attachment transfer with local or operator-owned S3 storage, app-hosted share links, CLI, MCP, REST API (attachments-serve), and a generated SDK. The server's data backend is a two-value switch, HASNA_ATTACHMENTS_STORAGE_MODE=sqlite|postgres.", + "bins": [ + "attachments", + "attachments-mcp", + "attachments-serve" + ], + "hosting": [ + "user-hosted" + ], "serviceSurfaces": [ { - "name": "attachments-api", + "name": "http-api", "kind": "api", "status": "supported", "bin": "attachments-serve", "authMode": "api-key", - "deploymentModes": ["self_hosted"], - "health": { "method": "GET", "path": "/health", "public": true }, - "readiness": { "method": "GET", "path": "/ready", "public": true }, - "version": { "method": "GET", "path": "/version", "public": true }, + "health": { + "method": "GET", + "path": "/health", + "public": true + }, + "readiness": { + "method": "GET", + "path": "/ready", + "public": false + }, + "version": { + "method": "GET", + "path": "/version", + "public": true + }, "apiBasePath": "/v1", "openApiPath": "/openapi.json" }, { - "name": "attachments-sdk", + "name": "typescript-sdk", "kind": "sdk", "status": "supported", - "authMode": "none", - "deploymentModes": ["local", "self_hosted"], + "authMode": "api-key", "exportSubpath": ".", "generatedFrom": "/openapi.json" }, { - "name": "attachments-mcp", + "name": "mcp", "kind": "mcp", "status": "supported", "mcpBin": "attachments-mcp", - "authMode": "local-only", - "deploymentModes": ["local", "self_hosted"] + "authMode": "local-only" }, { - "name": "attachments-cli", + "name": "cli", "kind": "cli", "status": "supported", "bin": "attachments", - "authMode": "local-only", - "deploymentModes": ["local", "self_hosted"] + "authMode": "local-only" } ], + "storage": { + "mode": "sqlite", + "engines": [ + "sqlite", + "postgres" + ], + "envPrefix": "HASNA_ATTACHMENTS_", + "aliasEnvPrefix": "ATTACHMENTS_", + "sqlitePath": "~/.hasna/attachments/attachments.db", + "pgTestGate": { + "envVar": "HASNA_ATTACHMENTS_TEST_DATABASE_URL", + "command": "HASNA_ATTACHMENTS_DATABASE_URL=\"${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}\" HASNA_ATTACHMENTS_STORAGE_MODE=cloud ATTACHMENTS_REQUIRE_POSTGRES=1 bun test src/db" + } + }, "metadata": { "release": { "artifactScan": { From 546a5024219c80558e661d790cee83fa3be033fa Mon Sep 17 00:00:00 2001 From: hasna-drain Date: Wed, 29 Jul 2026 14:29:40 +0000 Subject: [PATCH 3/3] review: address findings on OPE18-00009 --- .github/workflows/ci.yml | 8 +- Dockerfile | 2 +- README.md | 2 +- bun.lock | 4 +- docker-compose.yml | 2 +- hasna.contract.json | 62 +++++----------- package.json | 2 +- pnpm-workspace.yaml | 1 + scripts/scan-artifact.test.ts | 2 +- scripts/scan-artifact.ts | 2 +- scripts/test.sh | 2 +- src/core/cloud-v1.test.ts | 19 ++++- src/core/cloud-v1.ts | 56 ++++++-------- src/db/pg-live.test-harness.test.ts | 8 +- .../storage-kit/.storage-kit-manifest.json | 16 ++-- src/generated/storage-kit/health.ts | 2 +- src/generated/storage-kit/index.ts | 4 +- src/generated/storage-kit/migrations.ts | 2 +- src/generated/storage-kit/mode.ts | 73 ++++++++----------- src/generated/storage-kit/pool.ts | 35 ++++----- src/generated/storage-kit/query.ts | 2 +- src/generated/storage-kit/tls.ts | 2 +- src/serve/app.test.ts | 4 +- src/serve/app.upload.test.ts | 2 +- src/serve/index.ts | 4 +- src/serve/public-routes.test.ts | 2 +- 26 files changed, 145 insertions(+), 175 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81dc63f..6d0664d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: run: bun install --frozen-lockfile - name: Repository conformance - run: bunx @hasna/contracts@0.8.2 repo-conformance . + run: bunx @hasna/contracts@0.8.4 repo-conformance . - name: Vendored storage kit is current - run: bunx @hasna/contracts@0.8.2 vendor-kit --check . + run: bunx @hasna/contracts@0.8.4 vendor-kit --check . # The publish gate itself: typecheck, tests, build, packed-artifact scan. # Running the same script prepack runs is what keeps the wiring honest. @@ -65,7 +65,7 @@ jobs: HASNA_ATTACHMENTS_TEST_DATABASE_URL: postgresql://attachments:attachments@127.0.0.1:5432/attachments_test run: | HASNA_ATTACHMENTS_DATABASE_URL="${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}" \ - HASNA_ATTACHMENTS_STORAGE_MODE=cloud \ + HASNA_ATTACHMENTS_STORAGE_MODE=postgres \ ATTACHMENTS_REQUIRE_POSTGRES=1 \ bun test src/db @@ -77,7 +77,7 @@ jobs: run: | set +e HASNA_ATTACHMENTS_DATABASE_URL="$HASNA_ATTACHMENTS_TEST_DATABASE_URL" \ - HASNA_ATTACHMENTS_STORAGE_MODE=cloud \ + HASNA_ATTACHMENTS_STORAGE_MODE=postgres \ ATTACHMENTS_REQUIRE_POSTGRES=1 \ bun test src/db status=$? diff --git a/Dockerfile b/Dockerfile index 2bc892f..cf04f2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ FROM oven/bun:1-slim AS runtime WORKDIR /app ENV NODE_ENV=production \ PORT=8080 \ - HASNA_ATTACHMENTS_STORAGE_MODE=cloud + HASNA_ATTACHMENTS_STORAGE_MODE=postgres COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist diff --git a/README.md b/README.md index 96f6566..4515d3d 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ attachments service at its repo-owned AWS Postgres/RDS database. ```bash export HASNA_ATTACHMENTS_DATABASE_URL=postgres://... -export HASNA_ATTACHMENTS_STORAGE_MODE=cloud # local | cloud (AWS) +export HASNA_ATTACHMENTS_STORAGE_MODE=postgres # sqlite | postgres attachments storage status attachments storage push diff --git a/bun.lock b/bun.lock index 900c9ed..a2b54f2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@aws-sdk/client-s3": "^3.1007.0", "@aws-sdk/lib-storage": "3.1007.0", "@aws-sdk/s3-request-presigner": "^3.1007.0", - "@hasna/contracts": "^0.8.2", + "@hasna/contracts": "^0.8.4", "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.27.1", "@types/mime-types": "^3.0.1", @@ -67,7 +67,7 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], - "@hasna/contracts": ["@hasna/contracts@0.8.2", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/contracts-cli.js" } }, "sha512-oi+Q1QyxARpTRmE2za1rCzETi7DqB0EeimI3hIh2iMKV2Sh0D2Mi6KkDqNLnYsRZKjxEH5VapFoprb1Vi+R9+Q=="], + "@hasna/contracts": ["@hasna/contracts@0.8.4", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/contracts-cli.js" } }, "sha512-a/flLzdu8cbCUrScNQT3VBMl2HgwbjUYWrmwQ45x19X6F8FPiOf2gbb43MT0vlKTLUPEonh5jSoEPdjp44nAGw=="], "@hasna/events": ["@hasna/events@0.1.13", "", { "dependencies": { "commander": "13.1.0" }, "bin": { "events": "dist/cli/index.js", "hasna-events": "dist/cli/index.js" } }, "sha512-DzmEiDrBoibxzCBeZPEmdpWsNh+5SXTSnilqCeHxi0Do3a9mQJ43ZSxq/Ipzw/xNa2rShAx48KzduCZrGDHi9g=="], diff --git a/docker-compose.yml b/docker-compose.yml index 294954d..7faf038 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: condition: service_healthy environment: PORT: "8080" - HASNA_ATTACHMENTS_STORAGE_MODE: cloud + HASNA_ATTACHMENTS_STORAGE_MODE: postgres HASNA_ATTACHMENTS_DATABASE_URL: postgres://attachments:attachments@postgres:5432/attachments HASNA_ATTACHMENTS_API_SIGNING_KEY: local-dev-signing-secret-change-me ATTACHMENTS_PUBLIC_BASE_URL: http://localhost:8080 diff --git a/hasna.contract.json b/hasna.contract.json index 674990a..9091efa 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -5,76 +5,54 @@ "contractVersion": "v1", "kitVersion": "0.8.4", "description": "Open-source attachment transfer with local or operator-owned S3 storage, app-hosted share links, CLI, MCP, REST API (attachments-serve), and a generated SDK. The server's data backend is a two-value switch, HASNA_ATTACHMENTS_STORAGE_MODE=sqlite|postgres.", - "bins": [ - "attachments", - "attachments-mcp", - "attachments-serve" - ], - "hosting": [ - "user-hosted" - ], + "bins": ["attachments", "attachments-mcp", "attachments-serve"], + "hosting": ["user-hosted"], + "storage": { + "mode": "postgres", + "engines": ["sqlite", "postgres"], + "envPrefix": "HASNA_ATTACHMENTS_", + "aliasEnvPrefix": "ATTACHMENTS_", + "pgTestGate": { + "envVar": "HASNA_ATTACHMENTS_TEST_DATABASE_URL", + "command": "HASNA_ATTACHMENTS_DATABASE_URL=\"${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}\" HASNA_ATTACHMENTS_STORAGE_MODE=postgres ATTACHMENTS_REQUIRE_POSTGRES=1 bun test src/db" + } + }, "serviceSurfaces": [ { - "name": "http-api", + "name": "attachments-api", "kind": "api", "status": "supported", "bin": "attachments-serve", "authMode": "api-key", - "health": { - "method": "GET", - "path": "/health", - "public": true - }, - "readiness": { - "method": "GET", - "path": "/ready", - "public": false - }, - "version": { - "method": "GET", - "path": "/version", - "public": true - }, + "health": { "method": "GET", "path": "/health", "public": true }, + "readiness": { "method": "GET", "path": "/ready", "public": true }, + "version": { "method": "GET", "path": "/version", "public": true }, "apiBasePath": "/v1", "openApiPath": "/openapi.json" }, { - "name": "typescript-sdk", + "name": "attachments-sdk", "kind": "sdk", "status": "supported", - "authMode": "api-key", + "authMode": "none", "exportSubpath": ".", "generatedFrom": "/openapi.json" }, { - "name": "mcp", + "name": "attachments-mcp", "kind": "mcp", "status": "supported", "mcpBin": "attachments-mcp", "authMode": "local-only" }, { - "name": "cli", + "name": "attachments-cli", "kind": "cli", "status": "supported", "bin": "attachments", "authMode": "local-only" } ], - "storage": { - "mode": "sqlite", - "engines": [ - "sqlite", - "postgres" - ], - "envPrefix": "HASNA_ATTACHMENTS_", - "aliasEnvPrefix": "ATTACHMENTS_", - "sqlitePath": "~/.hasna/attachments/attachments.db", - "pgTestGate": { - "envVar": "HASNA_ATTACHMENTS_TEST_DATABASE_URL", - "command": "HASNA_ATTACHMENTS_DATABASE_URL=\"${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}\" HASNA_ATTACHMENTS_STORAGE_MODE=cloud ATTACHMENTS_REQUIRE_POSTGRES=1 bun test src/db" - } - }, "metadata": { "release": { "artifactScan": { diff --git a/package.json b/package.json index a230ab9..98af84e 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "@aws-sdk/client-s3": "^3.1007.0", "@aws-sdk/lib-storage": "3.1007.0", "@aws-sdk/s3-request-presigner": "^3.1007.0", - "@hasna/contracts": "^0.8.2", + "@hasna/contracts": "^0.8.4", "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.27.1", "@types/mime-types": "^3.0.1", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4c7f226..81b71f0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ minimumReleaseAgeExclude: - '@hasna/contracts@0.4.1' - '@hasna/contracts@0.5.2' - '@hasna/contracts@0.8.2' + - '@hasna/contracts@0.8.4' diff --git a/scripts/scan-artifact.test.ts b/scripts/scan-artifact.test.ts index 13d713d..cbee6a3 100644 --- a/scripts/scan-artifact.test.ts +++ b/scripts/scan-artifact.test.ts @@ -18,7 +18,7 @@ function stripComments(source: string): string { return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); } -/** Strip a leading range operator so "^0.8.2" and "0.8.2" compare equal. */ +/** Strip a leading range operator so "^0.8.4" and "0.8.4" compare equal. */ function rangeBaseVersion(range: string): string { return range.replace(/^[\^~=v]+/, ""); } diff --git a/scripts/scan-artifact.ts b/scripts/scan-artifact.ts index 00b6968..8f80904 100644 --- a/scripts/scan-artifact.ts +++ b/scripts/scan-artifact.ts @@ -17,7 +17,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; -export const CONTRACTS_KIT_VERSION = "0.8.2"; +export const CONTRACTS_KIT_VERSION = "0.8.4"; function run(command: string[], cwd: string): string { const result = Bun.spawnSync(command, { diff --git a/scripts/test.sh b/scripts/test.sh index d457f1c..4db26ad 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -13,7 +13,7 @@ set -e # / _MODE and at the API URL + key pair (see core/cloud-v1.ts:resolveStorageClient), # so with those exported the CLI/MCP tests silently ran against the real service # and 8 test files failed for environmental reasons on a clean checkout. -export HASNA_ATTACHMENTS_STORAGE_MODE=local +export HASNA_ATTACHMENTS_STORAGE_MODE=sqlite export ATTACHMENTS_CLIENT_MODE=local unset HASNA_ATTACHMENTS_MODE unset HASNA_ATTACHMENTS_API_URL diff --git a/src/core/cloud-v1.test.ts b/src/core/cloud-v1.test.ts index 87c48c5..249572b 100644 --- a/src/core/cloud-v1.test.ts +++ b/src/core/cloud-v1.test.ts @@ -37,17 +37,30 @@ describe("resolveAttachmentsV1", () => { expect(r.transport).toBe("local"); }); - test("returns cloud-http when URL+KEY set (mode implied self_hosted)", () => { + test("returns cloud-http when URL+KEY set (postgres implied)", () => { const r = resolveAttachmentsV1(cloudEnv); expect(r.transport).toBe("cloud-http"); if (r.transport === "cloud-http") expect(r.store.baseUrl).toBe(`${BASE}/v1`); }); - test("explicit STORAGE_MODE=local forces local even with URL+KEY", () => { - const r = resolveAttachmentsV1({ ...cloudEnv, HASNA_ATTACHMENTS_STORAGE_MODE: "local" } as NodeJS.ProcessEnv); + test("explicit STORAGE_MODE=sqlite forces local even with URL+KEY", () => { + const r = resolveAttachmentsV1({ ...cloudEnv, HASNA_ATTACHMENTS_STORAGE_MODE: "sqlite" } as NodeJS.ProcessEnv); expect(r.transport).toBe("local"); }); + test("explicit STORAGE_MODE=postgres selects HTTP with URL+KEY", () => { + const r = resolveAttachmentsV1({ ...cloudEnv, HASNA_ATTACHMENTS_STORAGE_MODE: "postgres" } as NodeJS.ProcessEnv); + expect(r.transport).toBe("cloud-http"); + }); + + test("rejects removed placement words instead of silently mapping them", () => { + for (const mode of ["local", "cloud", "remote", "self_hosted", "hybrid"]) { + expect(() => + resolveAttachmentsV1({ ...cloudEnv, HASNA_ATTACHMENTS_STORAGE_MODE: mode } as NodeJS.ProcessEnv), + ).toThrow(/runtime-placement axis was removed/); + } + }); + test("list routes GET /v1/attachments with bearer key and maps the envelope", async () => { const { calls, fetchImpl } = mockFetch(() => ({ status: 200, diff --git a/src/core/cloud-v1.ts b/src/core/cloud-v1.ts index 131b02c..a15eba9 100644 --- a/src/core/cloud-v1.ts +++ b/src/core/cloud-v1.ts @@ -1,4 +1,4 @@ -// Self-hosted (`mode=self_hosted`) storage backend for the attachments CLI. +// HTTP storage backend for the attachments CLI when server data is selected. // // LOCKED ARCHITECTURE: when `HASNA_ATTACHMENTS_API_URL` + `HASNA_ATTACHMENTS_API_KEY` // are set, every read and write routes to the app's cloud HTTP API at @@ -7,8 +7,8 @@ // surface so the CLI does not depend on unpublished contracts package exports. // // The toggle is the presence of the two env vars (that is what the fleet flip -// tool writes): both set -> cloud; either unset -> local. An explicit -// `HASNA_ATTACHMENTS_STORAGE_MODE=local` forces local even when the vars are set. +// tool writes): both set -> HTTP; either unset -> SQLite. An explicit +// `HASNA_ATTACHMENTS_STORAGE_MODE=sqlite` forces SQLite even when the vars are set. // // SAFETY: the API key never appears in logs or return values. It lives only // inside the contracts transport (and, for the binary download stream that the @@ -20,6 +20,7 @@ import { Readable } from "stream"; import { pipeline } from "stream/promises"; import { lookup as mimeLookup } from "mime-types"; import type { Attachment } from "./db"; +import { normalizeStorageMode } from "../generated/storage-kit/mode.js"; const APP_SLUG = "attachments"; @@ -120,34 +121,18 @@ function toAttachment(input: ApiAttachment): Attachment { }; } -/** - * Bridge the fleet flip's two-var convention to the local cloud resolver: when - * both `HASNA_ATTACHMENTS_API_URL` and `HASNA_ATTACHMENTS_API_KEY` are present - * (and the mode is not explicitly forced to `local`), treat the client as - * `self_hosted` so `resolveStorageClient` returns the cloud-http transport. - */ -function deriveEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const url = env.HASNA_ATTACHMENTS_API_URL || env.ATTACHMENTS_API_URL; - const key = env.HASNA_ATTACHMENTS_API_KEY || env.ATTACHMENTS_API_KEY; - const explicitMode = (env.HASNA_ATTACHMENTS_STORAGE_MODE || env.HASNA_ATTACHMENTS_MODE || "").toLowerCase(); - if (url && key && explicitMode !== "local") { - return { ...env, HASNA_ATTACHMENTS_STORAGE_MODE: "self_hosted" }; - } - return env; -} - /** * Resolve the attachments storage backend for this process. Returns a - * `cloud-http` store wired to `/v1` when self_hosted is configured, - * otherwise `{ transport: 'local' }` so the caller uses the local SQLite store. - * Throws if cloud is explicitly requested but misconfigured, so a client never - * silently drifts back to local. + * `cloud-http` store wired to `/v1` when the postgres backend is + * selected, otherwise `{ transport: 'local' }` so the caller uses SQLite. + * Throws if postgres is explicitly requested but misconfigured, so a client + * never silently reads the wrong dataset. */ export function resolveAttachmentsV1( env: NodeJS.ProcessEnv = process.env, overrides?: ResolveStorageClientOverrides, ): ResolveAttachmentsV1Result { - const resolved = resolveStorageClient(APP_SLUG, deriveEnv(env), overrides); + const resolved = resolveStorageClient(APP_SLUG, env, overrides); if (resolved.transport !== "cloud-http") return { transport: "local", store: null }; return { transport: "cloud-http", store: makeStore(resolved.client, env) }; } @@ -157,17 +142,24 @@ function resolveStorageClient( env: NodeJS.ProcessEnv, overrides: ResolveStorageClientOverrides = {}, ): StorageClientResolution { - const explicitMode = (env.HASNA_ATTACHMENTS_STORAGE_MODE || env.HASNA_ATTACHMENTS_MODE || "").toLowerCase(); - if (explicitMode === "local") return { transport: "local", client: null }; - const apiUrl = env.HASNA_ATTACHMENTS_API_URL || env.ATTACHMENTS_API_URL; const apiKey = env.HASNA_ATTACHMENTS_API_KEY || env.ATTACHMENTS_API_KEY; - if (!apiUrl && !apiKey) return { transport: "local", client: null }; + const explicitMode = + env.HASNA_ATTACHMENTS_STORAGE_MODE || + env.HASNA_ATTACHMENTS_MODE || + env.ATTACHMENTS_STORAGE_MODE || + env.ATTACHMENTS_MODE; + const mode = explicitMode + ? normalizeStorageMode(explicitMode).mode + : apiUrl && apiKey + ? "postgres" + : "sqlite"; + + if (mode === "sqlite") return { transport: "local", client: null }; if (!apiUrl || !apiKey) { - if (explicitMode === "self_hosted") { - throw new Error("Self-hosted attachments mode requires HASNA_ATTACHMENTS_API_URL and HASNA_ATTACHMENTS_API_KEY"); - } - return { transport: "local", client: null }; + throw new Error( + "Postgres-backed attachments require HASNA_ATTACHMENTS_API_URL and HASNA_ATTACHMENTS_API_KEY on the client", + ); } return { transport: "cloud-http", client: createStorageClient(apiUrl, apiKey, overrides.fetchImpl ?? fetch) }; diff --git a/src/db/pg-live.test-harness.test.ts b/src/db/pg-live.test-harness.test.ts index a9858dc..6f26aa5 100644 --- a/src/db/pg-live.test-harness.test.ts +++ b/src/db/pg-live.test-harness.test.ts @@ -25,7 +25,7 @@ */ import { randomBytes } from "node:crypto"; -import { createPgPool, createCloudPoolFromEnv } from "../generated/storage-kit/pool.js"; +import { createPgPool, createServerPoolFromEnv } from "../generated/storage-kit/pool.js"; import { createQueryClient, type PoolQueryClient } from "../generated/storage-kit/query.js"; const APP_SLUG = "attachments"; @@ -93,7 +93,7 @@ export interface LiveSchema { /** * Create an isolated schema and a kit client scoped to it. * - * The scoped client is built through `createCloudPoolFromEnv`, the same + * The scoped client is built through `createServerPoolFromEnv`, the same * entrypoint `attachments-serve` uses, so mode resolution, TLS handling and the * pool wiring are all exercised rather than bypassed. */ @@ -116,10 +116,10 @@ export async function createLiveSchema(label: string, baseUrl?: string): Promise let client: PoolQueryClient | null = null; try { await admin.execute(`CREATE SCHEMA "${schema}"`); - client = createCloudPoolFromEnv(APP_SLUG, { + client = createServerPoolFromEnv(APP_SLUG, { env: { ...process.env, - HASNA_ATTACHMENTS_STORAGE_MODE: "cloud", + HASNA_ATTACHMENTS_STORAGE_MODE: "postgres", HASNA_ATTACHMENTS_DATABASE_URL: withSearchPath(connectionString, schema), }, max: 4, diff --git a/src/generated/storage-kit/.storage-kit-manifest.json b/src/generated/storage-kit/.storage-kit-manifest.json index 53ecb9b..0157194 100644 --- a/src/generated/storage-kit/.storage-kit-manifest.json +++ b/src/generated/storage-kit/.storage-kit-manifest.json @@ -1,14 +1,14 @@ { "generator": "@hasna/contracts vendor-kit", - "kitVersion": "0.8.2", + "kitVersion": "0.8.4", "files": { - "mode.ts": "sha256:455046e716cc6840fd4fed7de2a47c11fab63164b132f251d035e83d6712c108", - "tls.ts": "sha256:2c5c733bd5c080d2a767e02f8fe27e0de580be42945c2070c9f0356b3a391a5e", - "query.ts": "sha256:13606bc6e8dca6d9b9ffc93c71010a85dffd38ce19f8ff38ef58363272624bb3", - "pool.ts": "sha256:db574c288ecaeacf9eebfef2600e4e0b44212fe674faea4e6627f02b7e1e7f0c", - "migrations.ts": "sha256:a39c1ed8e78cf50f3b74ba0270d974ff62373a4756521550ddfb7729d3efcbaf", - "health.ts": "sha256:7c7abf2b8765bd503f59d11e5e99a0bda5839c2f16d94728253bf672bfb2adf4", - "index.ts": "sha256:e03b32fee3df31f707840847f8f1fc58c388c7d90663cfb4946c536161055197", + "mode.ts": "sha256:74209bc3e9b7ae051f0892a5e26c6e3b2d83c17d913aa3602a2e58ba7ba5ca95", + "tls.ts": "sha256:c8fbfdd5d18777591ecd3ddb8ed477ee4436d2bdb3e171287b9ba849e6db2b46", + "query.ts": "sha256:23c8b9cd0f26d14b367c57df820a77b47a604b4384baf0d958e23abe7558273e", + "pool.ts": "sha256:3beed6251e242c2bc96868094734bd855c4c3396b0964fdffaeb154a43083123", + "migrations.ts": "sha256:5a084533ec938c5b4059e821cbc0df89fbdd22a853fb949cfeb3783d3e4c1fc6", + "health.ts": "sha256:9cb10b88a3cd09549f9cc90e9a4991596e74f891bdfa5fbda5e7e73819c97954", + "index.ts": "sha256:aa76ca2d01eabbc4e03491a5bfa819ab5e041db483a12d9dfb421cfe0ee520b8", "README.md": "sha256:0886dbdf751597bfc8f23d2e72f233d6c3516b478925c8118b93d9d06a4eaa66" } } diff --git a/src/generated/storage-kit/health.ts b/src/generated/storage-kit/health.ts index d20c8dd..9a0468f 100644 --- a/src/generated/storage-kit/health.ts +++ b/src/generated/storage-kit/health.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.8.2 +// KIT_VERSION: 0.8.4 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Health / readiness helpers for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/index.ts b/src/generated/storage-kit/index.ts index e8d3f6d..42764c3 100644 --- a/src/generated/storage-kit/index.ts +++ b/src/generated/storage-kit/index.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.8.2 +// KIT_VERSION: 0.8.4 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Public surface of the vendored Hasna storage kit. @@ -13,7 +13,7 @@ // generator with `contracts vendor-kit --check`. Regenerate with // `bunx @hasna/contracts vendor-kit`. -export const KIT_VERSION = "0.8.2"; +export const KIT_VERSION = "0.8.4"; export * from "./mode.js"; export * from "./tls.js"; diff --git a/src/generated/storage-kit/migrations.ts b/src/generated/storage-kit/migrations.ts index acd1187..141c18c 100644 --- a/src/generated/storage-kit/migrations.ts +++ b/src/generated/storage-kit/migrations.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.8.2 +// KIT_VERSION: 0.8.4 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Migration-ledger helper for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/mode.ts b/src/generated/storage-kit/mode.ts index 479654f..adfc78d 100644 --- a/src/generated/storage-kit/mode.ts +++ b/src/generated/storage-kit/mode.ts @@ -1,50 +1,42 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.8.2 +// KIT_VERSION: 0.8.4 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check -// Storage-mode resolution for the vendored Hasna storage kit. +// Storage-backend resolution for the vendored Hasna storage kit. // // Self-contained copy of the @hasna/contracts `mode.ts` contract so the // generated kit has zero runtime dependency on the contracts package. Keep // this in lockstep with the contract; regenerate the kit to pick up changes. // -// Amendment A1 (PURE REMOTE): there are exactly two runtime modes. -// - `local` : SQLite at ~/.hasna//.db is authoritative. -// - `cloud` : reads AND writes go directly to the app's cloud Postgres. -// There is NO sync engine, NO cache-as-mode, and NO hybrid/remote/self_hosted -// runtime. The legacy words `remote`, `hybrid`, and `self_hosted` are accepted -// only as deprecated *aliases* that normalize to `cloud`. - -export const STORAGE_MODES = ["local", "cloud"] as const; +// The runtime-placement axis was removed entirely (owner directive +// 2026-07-29). The only switch is the server's DATA BACKEND: +// - `sqlite` : SQLite at ~/.hasna//.db is authoritative. +// - `postgres` : reads AND writes go to a PostgreSQL server (DATABASE_URL). +// The long spelling `postgresql` normalizes to `postgres`; everything else — +// including every removed placement word — throws. Nothing is silently mapped. + +export const STORAGE_MODES = ["sqlite", "postgres"] as const; export type StorageMode = (typeof STORAGE_MODES)[number]; -export const DEPRECATED_STORAGE_MODE_ALIASES = [ - "remote", - "hybrid", - "self_hosted", -] as const; - export type Env = Record; export interface StorageModeNormalization { mode: StorageMode; - /** The deprecated alias that was normalized to `cloud`, if any. */ - deprecatedAlias: string | null; } /** - * Normalize a raw storage-mode string to the `local | cloud` runtime enum. - * Accepts deprecated aliases (`remote`, `hybrid`, `self_hosted`) and maps them - * to `cloud`. Throws on any other value. + * Normalize a raw storage-backend string to the `sqlite | postgres` enum. + * `postgresql` is accepted as the long spelling of `postgres`. Throws on any + * other value with a migration hint. */ export function normalizeStorageMode(value: string): StorageModeNormalization { const normalized = value.trim().toLowerCase().replace(/-/g, "_"); - if (normalized === "local") return { mode: "local", deprecatedAlias: null }; - if (normalized === "cloud") return { mode: "cloud", deprecatedAlias: null }; - if ((DEPRECATED_STORAGE_MODE_ALIASES as readonly string[]).includes(normalized)) { - return { mode: "cloud", deprecatedAlias: normalized }; - } - throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`); + if (normalized === "sqlite") return { mode: "sqlite" }; + if (normalized === "postgres" || normalized === "postgresql") return { mode: "postgres" }; + throw new Error( + `Unknown storage mode '${value}'. The runtime-placement axis was removed; ` + + `set sqlite for the on-box SQLite file or postgres for a PostgreSQL server (DATABASE_URL).`, + ); } /** Upper-snake env token for an app name, e.g. `todos` -> `TODOS`. */ @@ -78,9 +70,8 @@ function firstEnv(env: Env, keys: readonly string[]): { key: string; value: stri export interface StorageModeResolution { mode: StorageMode; - /** Env key the mode came from, or `"default"`. */ + /** Env key the backend came from, or `"default"`. */ source: string; - deprecatedAlias: string | null; databaseUrlPresent: boolean; /** Env key the database URL came from, or `null`. */ databaseUrlSource: string | null; @@ -88,9 +79,10 @@ export interface StorageModeResolution { } /** - * Resolve an app's storage mode from the environment per the contract env spec. - * Precedence: `HASNA__STORAGE_MODE`, then `_STORAGE_MODE`, else - * `local`. Never reads secret values — only detects DATABASE_URL presence. + * Resolve an app's storage backend from the environment per the contract env + * spec. Precedence: `HASNA__STORAGE_MODE`, then `_STORAGE_MODE`; + * absent both, a present `DATABASE_URL` selects `postgres`, else `sqlite`. + * Never reads secret values — only detects DATABASE_URL presence. */ export function resolveStorageMode(name: string, env: Env = process.env): StorageModeResolution { const { modeKeys, databaseUrlKeys } = storageEnvKeys(name); @@ -101,24 +93,18 @@ export function resolveStorageMode(name: string, env: Env = process.env): Storag const modeHit = firstEnv(env, modeKeys); if (!modeHit) { return { - mode: "local", - source: "default", - deprecatedAlias: null, + mode: databaseUrlPresent ? "postgres" : "sqlite", + source: databaseUrlPresent ? databaseUrlSource! : "default", databaseUrlPresent, databaseUrlSource, warning: null, }; } - const { mode, deprecatedAlias } = normalizeStorageMode(modeHit.value); + const { mode } = normalizeStorageMode(modeHit.value); const warnings: string[] = []; - if (deprecatedAlias) { - warnings.push( - `Deprecated storage mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Set ${modeKeys[0]}=cloud instead.`, - ); - } - if (mode === "cloud" && !databaseUrlPresent) { - warnings.push(`cloud mode needs ${databaseUrlKeys[0]} (PURE REMOTE: reads and writes go to cloud Postgres).`); + if (mode === "postgres" && !databaseUrlPresent) { + warnings.push(`postgres storage needs ${databaseUrlKeys[0]} (reads and writes go to PostgreSQL).`); } if (modeHit.key !== modeKeys[0]) { warnings.push(`Using alias env ${modeHit.key}; the canonical key is ${modeKeys[0]}.`); @@ -127,7 +113,6 @@ export function resolveStorageMode(name: string, env: Env = process.env): Storag return { mode, source: modeHit.key, - deprecatedAlias, databaseUrlPresent, databaseUrlSource, warning: warnings.length > 0 ? warnings.join(" ") : null, diff --git a/src/generated/storage-kit/pool.ts b/src/generated/storage-kit/pool.ts index 75fa59e..80dbe14 100644 --- a/src/generated/storage-kit/pool.ts +++ b/src/generated/storage-kit/pool.ts @@ -1,13 +1,13 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.8.2 +// KIT_VERSION: 0.8.4 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Postgres pool factory for the vendored Hasna storage kit. // -// The single sanctioned way to open a cloud Postgres connection. TLS is -// resolved through `tls.ts` (one correct approach), and env/mode resolution -// runs through `mode.ts` (the contract). PURE REMOTE (Amendment A1): a Pool is -// only ever built for `cloud` mode; there is no local/hybrid Postgres path. +// The single sanctioned way for a SERVER to open its PostgreSQL connection. +// TLS is resolved through `tls.ts` (one correct approach), and env resolution +// runs through `mode.ts` (the contract). A Pool is only ever built for the +// `postgres` backend; clients never open PostgreSQL directly (sqlite-or-http). import pg from "pg"; import type { Pool, PoolConfig } from "pg"; @@ -45,40 +45,41 @@ export function createPgPool(options: CreatePgPoolOptions): Pool { return new pg.Pool(config); } -export interface CreateCloudPoolFromEnvOptions extends TlsResolveOptions { +export interface CreateServerPoolFromEnvOptions extends TlsResolveOptions { max?: number; idleTimeoutMillis?: number; connectionTimeoutMillis?: number; applicationName?: string; } -export interface CloudPoolFromEnv { +export interface ServerPoolFromEnv { client: PoolQueryClient; connectionSource: string; } /** - * Resolve mode + database URL from the environment and build a cloud pool. + * Resolve backend + database URL from the environment and build the server's + * PostgreSQL pool. * - * Throws when the resolved mode is not `cloud` (PURE REMOTE has no Postgres in - * `local` mode) or when the database URL is missing. Never logs the URL. + * Throws when the resolved backend is not `postgres` (the sqlite backend has + * no Postgres pool) or when the database URL is missing. Never logs the URL. */ -export function createCloudPoolFromEnv( +export function createServerPoolFromEnv( appName: string, - options: CreateCloudPoolFromEnvOptions = {}, -): CloudPoolFromEnv { + options: CreateServerPoolFromEnvOptions = {}, +): ServerPoolFromEnv { const env = options.env ?? process.env; const resolution = resolveStorageMode(appName, env); - if (resolution.mode !== "cloud") { + if (resolution.mode !== "postgres") { throw new Error( - `createCloudPoolFromEnv requires ${appName} storage mode 'cloud', got '${resolution.mode}'. ` + - `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=cloud.`, + `createServerPoolFromEnv requires ${appName} storage mode 'postgres', got '${resolution.mode}'. ` + + `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=postgres.`, ); } const connectionString = resolveDatabaseUrl(appName, env); if (!connectionString) { throw new Error( - `cloud mode for ${appName} needs a database URL. Set ` + + `postgres storage for ${appName} needs a database URL. Set ` + `HASNA_${appName.toUpperCase().replace(/-/g, "_")}_DATABASE_URL.`, ); } diff --git a/src/generated/storage-kit/query.ts b/src/generated/storage-kit/query.ts index 55c22ca..2c060b9 100644 --- a/src/generated/storage-kit/query.ts +++ b/src/generated/storage-kit/query.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.8.2 +// KIT_VERSION: 0.8.4 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Typed query wrapper for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/tls.ts b/src/generated/storage-kit/tls.ts index ad86016..4ce35d7 100644 --- a/src/generated/storage-kit/tls.ts +++ b/src/generated/storage-kit/tls.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.8.2 +// KIT_VERSION: 0.8.4 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // TLS resolution for the vendored Hasna storage kit. diff --git a/src/serve/app.test.ts b/src/serve/app.test.ts index 84c47d4..78cbf96 100644 --- a/src/serve/app.test.ts +++ b/src/serve/app.test.ts @@ -45,7 +45,7 @@ function makeApp() { store: new PgAttachmentsStore(client), config: normalizeConfig({ storage: { backend: "local" } }), version: "test", - mode: "cloud", + mode: "postgres", signingSecret: SIGNING, }); } @@ -57,7 +57,7 @@ describe("attachments serve app", () => { const body = await res.json(); expect(body.status).toBe("ok"); expect(body.version).toBe("test"); - expect(body.mode).toBe("cloud"); + expect(body.mode).toBe("postgres"); }); test("GET /health probes the database", async () => { diff --git a/src/serve/app.upload.test.ts b/src/serve/app.upload.test.ts index 2aec1c7..c95118e 100644 --- a/src/serve/app.upload.test.ts +++ b/src/serve/app.upload.test.ts @@ -61,7 +61,7 @@ function makeApp() { defaults: { linkType: "presigned", expiry: "7d" }, }), version: "test", - mode: "cloud", + mode: "postgres", signingSecret: SIGNING, }); } diff --git a/src/serve/index.ts b/src/serve/index.ts index 54ff2df..c1199d6 100644 --- a/src/serve/index.ts +++ b/src/serve/index.ts @@ -12,7 +12,7 @@ * attachments-serve --no-migrate Serve without running migrations on boot. */ -import { createCloudPoolFromEnv } from "../generated/storage-kit/pool.js"; +import { createServerPoolFromEnv } from "../generated/storage-kit/pool.js"; import { MigrationLedger } from "../generated/storage-kit/migrations.js"; import type { TypedQueryClient } from "../generated/storage-kit/query.js"; import { ApiKeyStore } from "@hasna/contracts/auth"; @@ -86,7 +86,7 @@ async function main(): Promise { const skipMigrate = args.includes("--no-migrate") || process.env.ATTACHMENTS_SKIP_MIGRATE === "1"; const modeResolution = resolveStorageMode(APP_SLUG); - const { client, connectionSource } = createCloudPoolFromEnv(APP_SLUG, { + const { client, connectionSource } = createServerPoolFromEnv(APP_SLUG, { applicationName: "attachments-serve", }); diff --git a/src/serve/public-routes.test.ts b/src/serve/public-routes.test.ts index f2e1082..9a467a5 100644 --- a/src/serve/public-routes.test.ts +++ b/src/serve/public-routes.test.ts @@ -41,7 +41,7 @@ function makeApp() { store: store as unknown as PgAttachmentsStore, config: makeConfig(), version: "test", - mode: "cloud", + mode: "postgres", signingSecret: SIGNING, }); }