Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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=$?
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 6 additions & 11 deletions hasna.contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,18 @@
"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.",
"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"],
"deploymentModes": ["local", "self_hosted"],
"storage": {
"mode": "cloud",
"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=cloud ATTACHMENTS_REQUIRE_POSTGRES=1 bun test src/db"
"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": [
Expand All @@ -25,7 +24,6 @@
"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 },
Expand All @@ -37,7 +35,6 @@
"kind": "sdk",
"status": "supported",
"authMode": "none",
"deploymentModes": ["local", "self_hosted"],
"exportSubpath": ".",
"generatedFrom": "/openapi.json"
},
Expand All @@ -46,16 +43,14 @@
"kind": "mcp",
"status": "supported",
"mcpBin": "attachments-mcp",
"authMode": "local-only",
"deploymentModes": ["local", "self_hosted"]
"authMode": "local-only"
},
{
"name": "attachments-cli",
"kind": "cli",
"status": "supported",
"bin": "attachments",
"authMode": "local-only",
"deploymentModes": ["local", "self_hosted"]
"authMode": "local-only"
}
],
"metadata": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion scripts/scan-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]+/, "");
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/scan-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions src/core/cloud-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 24 additions & 32 deletions src/core/cloud-v1.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -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 `<API_URL>/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 `<API_URL>/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) };
}
Expand All @@ -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) };
Expand Down
8 changes: 4 additions & 4 deletions src/db/pg-live.test-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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,
Expand Down
16 changes: 8 additions & 8 deletions src/generated/storage-kit/.storage-kit-manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion src/generated/storage-kit/health.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/generated/storage-kit/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/generated/storage-kit/migrations.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading