From 9908ee838030fcb705ee8c7e3eb1b87f9dd06d18 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 7 Jul 2026 10:28:37 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(client):=20cloud-http=20transport=20re?= =?UTF-8?q?solver=20=E2=80=94=20make=20self=5Fhosted=20clients=20actually?= =?UTF-8?q?=20use=20the=20cloud=20(B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core B2 fix. Setting a client to cloud/self_hosted mode was a no-op: the CLI/MCP kept reading the local SQLite/db.json store because a DSN on the client does not switch the dataset a CLI reads. Add a shared client-flip mechanism in @hasna/contracts (exported at the root and at the ./client subpath) that every app's storage resolver can adopt: - resolveClientTransport(name, env): decides local vs cloud-http from the env flip contract. Transport is cloud-http IFF the resolved mode is cloud AND an API key is present; base URL defaults to https://.hasna.xyz and /v1 is appended. If cloud is requested but the API key is missing/URL invalid, it returns local + misconfigured=true with a loud warning so callers hard-fail instead of silently serving wrong local data. - createHasnaHttpTransport(): authenticated fetch client for /v1, sends the key as both x-api-key and Authorization: Bearer, JSON in/out, typed errors. - createClientTransport(): resolve + build in one call; throws on misconfig. Flip contract (env), per app : HASNA__STORAGE_MODE | HASNA__MODE = cloud|self_hosted|local HASNA__API_URL (default https://.hasna.xyz) HASNA__API_KEY (hasna__...) Rollback = unset the mode/url/key; local original is never touched. The transport routes ALL reads+writes through the app's HTTP API, so it covers the app's REAL dataset (db.json items, not just sqlite tables) — addressing B5: the data source is the server, whatever the server persists. Includes an end-to-end test that stands up a real loopback /v1 server plus a demo storage resolver using the shared code, proving: flip on => reads/writes hit the cloud server; flip off => local file; key is sent and enforced. Never logs the key. --- package.json | 8 +- src/client/transport.test.ts | 221 +++++++++++++++++++++ src/client/transport.ts | 360 +++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 4 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 src/client/transport.test.ts create mode 100644 src/client/transport.ts diff --git a/package.json b/package.json index dc63661..c2d9d8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/contracts", - "version": "0.4.1", + "version": "0.5.0", "description": "Shared schemas and validators for Hasna open-source agent infrastructure contracts.", "type": "module", "license": "Apache-2.0", @@ -61,6 +61,10 @@ "types": "./dist/sdk/generate.d.ts", "import": "./dist/sdk/generate.js" }, + "./client": { + "types": "./dist/client/transport.d.ts", + "import": "./dist/client/transport.js" + }, "./hasna.contract.schema.json": "./dist/hasna.contract.schema.json" }, "files": [ @@ -75,7 +79,7 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/conformance.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts --root src --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", + "build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/conformance.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts src/client/transport.ts --root src --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", "typecheck": "tsc --noEmit", "test": "bun test", "lint": "tsc --noEmit", diff --git a/src/client/transport.test.ts b/src/client/transport.test.ts new file mode 100644 index 0000000..82f02c8 --- /dev/null +++ b/src/client/transport.test.ts @@ -0,0 +1,221 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clientTransportEnvKeys, + createClientTransport, + createHasnaHttpTransport, + defaultCloudBaseUrl, + resolveClientTransport, + toV1BaseUrl, +} from "./transport.js"; + +describe("resolveClientTransport — the client-flip contract", () => { + test("no env => local", () => { + const r = resolveClientTransport("todos", {}); + expect(r.transport).toBe("local"); + expect(r.mode).toBe("local"); + expect(r.misconfigured).toBe(false); + }); + + test("explicit local mode never routes to cloud even with url+key", () => { + const r = resolveClientTransport("todos", { + HASNA_TODOS_STORAGE_MODE: "local", + HASNA_TODOS_API_URL: "https://todos.hasna.xyz", + HASNA_TODOS_API_KEY: "hasna_todos_x", + }); + expect(r.transport).toBe("local"); + }); + + test("cloud + url + key => cloud-http with /v1 base", () => { + const r = resolveClientTransport("todos", { + HASNA_TODOS_STORAGE_MODE: "cloud", + HASNA_TODOS_API_URL: "https://todos.hasna.xyz", + HASNA_TODOS_API_KEY: "hasna_todos_abc", + }); + expect(r.transport).toBe("cloud-http"); + expect(r.baseUrl).toBe("https://todos.hasna.xyz/v1"); + expect(r.apiKeyPresent).toBe(true); + // secret value is never surfaced + expect(JSON.stringify(r)).not.toContain("hasna_todos_abc"); + }); + + test("self_hosted alias normalizes to cloud and defaults the host", () => { + const r = resolveClientTransport("knowledge", { + HASNA_KNOWLEDGE_MODE: "self_hosted", + HASNA_KNOWLEDGE_API_KEY: "hasna_knowledge_k", + }); + expect(r.transport).toBe("cloud-http"); + expect(r.deprecatedAlias).toBe("self_hosted"); + expect(r.baseUrl).toBe("https://knowledge.hasna.xyz/v1"); + expect(r.apiUrlSource).toBe("default"); + }); + + test("STORAGE_MODE=cloud (bare alias) is honored", () => { + const r = resolveClientTransport("todos", { + TODOS_STORAGE_MODE: "cloud", + TODOS_API_URL: "https://todos.hasna.xyz", + TODOS_API_KEY: "hasna_todos_z", + }); + expect(r.transport).toBe("cloud-http"); + expect(r.modeSource).toBe("TODOS_STORAGE_MODE"); + }); + + test("cloud requested but NO key => local + misconfigured (never silent wrong data)", () => { + const r = resolveClientTransport("todos", { HASNA_TODOS_STORAGE_MODE: "cloud" }); + expect(r.transport).toBe("local"); + expect(r.misconfigured).toBe(true); + expect(r.warning).toContain("HASNA_TODOS_API_KEY"); + }); + + test("createClientTransport throws on misconfigured cloud", () => { + expect(() => createClientTransport("todos", { HASNA_TODOS_STORAGE_MODE: "cloud" })).toThrow(); + }); + + test("env-key spec + defaults", () => { + const keys = clientTransportEnvKeys("agent-registry"); + expect(keys.modeKeys[0]).toBe("HASNA_AGENT_REGISTRY_STORAGE_MODE"); + expect(keys.apiUrlKeys[0]).toBe("HASNA_AGENT_REGISTRY_API_URL"); + expect(keys.apiKeyKeys[0]).toBe("HASNA_AGENT_REGISTRY_API_KEY"); + expect(defaultCloudBaseUrl("agent-registry")).toBe("https://agent-registry.hasna.xyz"); + }); + + test("toV1BaseUrl is idempotent and strips trailing slash / existing /v1", () => { + expect(toV1BaseUrl("https://todos.hasna.xyz")).toBe("https://todos.hasna.xyz/v1"); + expect(toV1BaseUrl("https://todos.hasna.xyz/")).toBe("https://todos.hasna.xyz/v1"); + expect(toV1BaseUrl("https://todos.hasna.xyz/v1")).toBe("https://todos.hasna.xyz/v1"); + }); +}); + +// --------------------------------------------------------------------------- +// END-TO-END PROOF: a real loopback `/v1` server (the "cloud") + a demo app +// storage resolver that uses the shared contract to flip between a local file +// and the HTTP transport. Proves: flip on => reads/writes hit the cloud server +// (data lands in the server's store); flip off => reads/writes hit the local +// file. Same code path every real app will use. +// --------------------------------------------------------------------------- + +interface Item { + id: string; + text: string; +} + +describe("end-to-end data-source flip (real HTTP loopback)", () => { + const EXPECTED_KEY = "hasna_demo_e2e_secret"; + const cloudStore = new Map(); + const seenAuth: string[] = []; + let server: ReturnType; + let baseUrl: string; + let localFile: string; + let tmp: string; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const key = req.headers.get("x-api-key") ?? ""; + const bearer = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, ""); + seenAuth.push(key); + if (key !== EXPECTED_KEY || bearer !== EXPECTED_KEY) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + if (url.pathname === "/v1/items" && req.method === "GET") { + return Response.json({ items: [...cloudStore.values()] }); + } + if (url.pathname === "/v1/items" && req.method === "POST") { + const body = (await req.json()) as Item; + cloudStore.set(body.id, body); + return Response.json({ ok: true, item: body }, { status: 201 }); + } + return Response.json({ error: "not_found", path: url.pathname }, { status: 404 }); + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; + tmp = mkdtempSync(join(tmpdir(), "hasna-flip-")); + localFile = join(tmp, "db.json"); + writeFileSync(localFile, JSON.stringify({ items: [{ id: "local-1", text: "from local file" }] })); + }); + + afterAll(() => { + server.stop(true); + rmSync(tmp, { recursive: true, force: true }); + }); + + // A demo app storage layer whose ONLY decision is `resolveClientTransport`. + function makeStore(env: Record) { + const wired = createClientTransport("demo", env, {}); + return { + wired, + async list(): Promise { + if (wired.transport === "cloud-http") { + const res = await wired.client.get<{ items: Item[] }>("/items"); + return res.items; + } + return JSON.parse(readFileSync(localFile, "utf8")).items as Item[]; + }, + async add(item: Item): Promise { + if (wired.transport === "cloud-http") { + await wired.client.post("/items", item); + return; + } + const data = JSON.parse(readFileSync(localFile, "utf8")); + data.items.push(item); + writeFileSync(localFile, JSON.stringify(data)); + }, + }; + } + + const cloudEnv = { + HASNA_DEMO_STORAGE_MODE: "self_hosted", + HASNA_DEMO_API_URL: "", // filled in test (dynamic port) + HASNA_DEMO_API_KEY: EXPECTED_KEY, + }; + + test("flip OFF (local): reads the local file, writes land locally", async () => { + const store = makeStore({}); + expect(store.wired.transport).toBe("local"); + const before = await store.list(); + expect(before).toEqual([{ id: "local-1", text: "from local file" }]); + await store.add({ id: "local-2", text: "local write" }); + const after = await store.list(); + expect(after.map((i) => i.id)).toEqual(["local-1", "local-2"]); + // Nothing leaked to the cloud store. + expect(cloudStore.size).toBe(0); + }); + + test("flip ON (cloud): read hits cloud, write LANDS in cloud DB, not local", async () => { + // Seed cloud so a read is provably from the server, not the local file. + cloudStore.set("cloud-1", { id: "cloud-1", text: "from cloud server" }); + const env = { ...cloudEnv, HASNA_DEMO_API_URL: baseUrl }; + const store = makeStore(env); + expect(store.wired.transport).toBe("cloud-http"); + expect(store.wired.resolution.baseUrl).toBe(`${baseUrl}/v1`); + + const read = await store.list(); + expect(read).toEqual([{ id: "cloud-1", text: "from cloud server" }]); // NOT the local-file items + + await store.add({ id: "cloud-2", text: "cloud write" }); + // Write landed in the SERVER store... + expect(cloudStore.has("cloud-2")).toBe(true); + // ...and the local file is untouched (still just local-1 + local-2 from prior test). + const localItems = JSON.parse(readFileSync(localFile, "utf8")).items as Item[]; + expect(localItems.map((i) => i.id)).toEqual(["local-1", "local-2"]); + // The API key was actually sent on the wire. + expect(seenAuth).toContain(EXPECTED_KEY); + }); + + test("flip BACK OFF (unset): instantly reverts to the untouched local original", async () => { + const store = makeStore({}); + expect(store.wired.transport).toBe("local"); + const items = await store.list(); + // Exactly the local writes; zero cloud contamination. + expect(items.map((i) => i.id)).toEqual(["local-1", "local-2"]); + }); + + test("wrong/absent key is rejected by the server (auth is enforced)", async () => { + const bad = createHasnaHttpTransport({ name: "demo", baseUrl: `${baseUrl}/v1`, apiKey: "wrong" }); + await expect(bad.get("/items")).rejects.toThrow(/401/); + }); +}); diff --git a/src/client/transport.ts b/src/client/transport.ts new file mode 100644 index 0000000..a265b0c --- /dev/null +++ b/src/client/transport.ts @@ -0,0 +1,360 @@ +// Client-side transport resolver for the Hasna Service Contract v1. +// +// THIS IS THE B2 CORE FIX. Historically, setting a client to cloud/self_hosted +// mode was a NO-OP: the CLI/MCP still read the local SQLite/db.json store even +// though `HASNA__STORAGE_MODE=cloud` and a DATABASE_URL were set. A DSN on +// the client does NOT switch the dataset a CLI reads. +// +// This module makes the client actually talk to the cloud. Given an app name and +// the environment it decides whether reads AND writes should be routed to the +// app's cloud HTTP API (`/v1`, default `https://.hasna.xyz/v1`) +// with the API key, or fall through to the local store. +// +// THE CLIENT-FLIP CONTRACT (env vars). For app `` = envToken(name): +// +// Mode (any one, first match wins; aliases self_hosted/remote/hybrid -> cloud): +// HASNA__STORAGE_MODE = cloud | self_hosted | local | ... +// HASNA__MODE = cloud | self_hosted | local | ... (alias) +// _STORAGE_MODE (alias) +// _MODE (alias) +// API base URL (optional; `/v1` is appended automatically): +// HASNA__API_URL = https://.hasna.xyz +// _API_URL (alias) +// API key (bearer / x-api-key): +// HASNA__API_KEY = hasna__... +// _API_KEY (alias) +// +// DECISION: transport is `cloud-http` IFF the resolved mode is `cloud` AND an API +// key is present. The base URL defaults to `https://.hasna.xyz` when a key is +// present but no URL is set. If mode is `cloud` but the API key is MISSING, we do +// NOT silently serve wrong local data — we return `local` with a loud `warning` +// and `misconfigured: true` so the caller can hard-fail instead of drifting. +// +// SAFETY: this module never returns, logs, or embeds the API key value. Callers +// receive only presence flags and env-key names. + +import { normalizeStorageMode, envToken, type Env } from "../mode.js"; +import type { StorageMode } from "../schemas.js"; + +/** Default cloud host template. `` is the app slug. */ +export function defaultCloudBaseUrl(name: string): string { + return `https://${name}.hasna.xyz`; +} + +export interface ClientTransportEnvKeys { + /** Mode keys, in precedence order. */ + modeKeys: string[]; + /** API base-URL keys, in precedence order. */ + apiUrlKeys: string[]; + /** API-key keys, in precedence order. */ + apiKeyKeys: string[]; +} + +/** Resolve the canonical client-flip env-key spec for an app. */ +export function clientTransportEnvKeys(name: string): ClientTransportEnvKeys { + const token = envToken(name); + return { + modeKeys: [ + `HASNA_${token}_STORAGE_MODE`, + `HASNA_${token}_MODE`, + `${token}_STORAGE_MODE`, + `${token}_MODE`, + ], + apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`], + apiKeyKeys: [`HASNA_${token}_API_KEY`, `${token}_API_KEY`], + }; +} + +function firstEnv(env: Env, keys: readonly string[]): { key: string; value: string } | null { + for (const key of keys) { + const value = env[key]?.trim(); + if (value) return { key, value }; + } + return null; +} + +/** Normalize a base URL to `/v1` (dropping any trailing slash or existing /v1). */ +export function toV1BaseUrl(apiUrl: string): string { + const url = new URL(apiUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("API URL must use http or https."); + } + let path = url.pathname.replace(/\/+$/, ""); + if (path.endsWith("/v1")) path = path.slice(0, -"/v1".length); + url.pathname = `${path}/v1`; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +export type ClientTransportKind = "local" | "cloud-http"; + +export interface ClientTransportResolution { + /** Where the client should read/write from. */ + transport: ClientTransportKind; + /** Resolved storage mode (`local` | `cloud`). */ + mode: StorageMode; + /** Deprecated mode alias that was normalized (e.g. `self_hosted`), if any. */ + deprecatedAlias: string | null; + /** Env key the mode was read from, or `"default"`. */ + modeSource: string; + /** `/v1` base for the cloud API when transport is cloud-http, else null. */ + baseUrl: string | null; + /** Env key the API URL came from, `"default"` (host template), or null. */ + apiUrlSource: string | null; + /** Whether an API key is present (value never exposed). */ + apiKeyPresent: boolean; + /** Env key the API key came from, or null. */ + apiKeySource: string | null; + /** + * True when the operator asked for cloud but the config is incomplete (no API + * key), so we fell back to local. Callers SHOULD treat this as an error rather + * than silently reading local data. + */ + misconfigured: boolean; + /** Human-readable warning, or null. Never contains secret values. */ + warning: string | null; +} + +/** + * Resolve how a client should reach an app's data given the environment. + * + * Precedence for the mode: the first present of `HASNA__STORAGE_MODE`, + * `HASNA__MODE`, `_STORAGE_MODE`, `_MODE`, else `local`. + */ +export function resolveClientTransport(name: string, env: Env = process.env): ClientTransportResolution { + const keys = clientTransportEnvKeys(name); + const modeHit = firstEnv(env, keys.modeKeys); + const urlHit = firstEnv(env, keys.apiUrlKeys); + const keyHit = firstEnv(env, keys.apiKeyKeys); + + let mode: StorageMode = "local"; + let deprecatedAlias: string | null = null; + let modeSource = "default"; + const warnings: string[] = []; + + if (modeHit) { + const normalized = normalizeStorageMode(modeHit.value); + mode = normalized.mode; + deprecatedAlias = normalized.deprecatedAlias; + modeSource = modeHit.key; + if (deprecatedAlias) { + warnings.push( + `Deprecated mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Prefer ${keys.modeKeys[0]}=cloud.`, + ); + } + } + + // Local mode: never route to the network, regardless of URL/key presence. + if (mode === "local") { + return { + transport: "local", + mode, + deprecatedAlias, + modeSource, + baseUrl: null, + apiUrlSource: null, + apiKeyPresent: Boolean(keyHit), + apiKeySource: keyHit ? keyHit.key : null, + misconfigured: false, + warning: warnings.length > 0 ? warnings.join(" ") : null, + }; + } + + // Cloud mode but no API key: fall back to local, but flag it loudly. + if (!keyHit) { + warnings.push( + `${modeSource}=cloud but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to cloud; using local store. Set ${keys.apiKeyKeys[0]} to enable the cloud client.`, + ); + return { + transport: "local", + mode, + deprecatedAlias, + modeSource, + baseUrl: null, + apiUrlSource: null, + apiKeyPresent: false, + apiKeySource: null, + misconfigured: true, + warning: warnings.join(" "), + }; + } + + const rawUrl = urlHit?.value ?? defaultCloudBaseUrl(name); + const apiUrlSource = urlHit ? urlHit.key : "default"; + let baseUrl: string; + try { + baseUrl = toV1BaseUrl(rawUrl); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`); + return { + transport: "local", + mode, + deprecatedAlias, + modeSource, + baseUrl: null, + apiUrlSource: null, + apiKeyPresent: true, + apiKeySource: keyHit.key, + misconfigured: true, + warning: warnings.join(" "), + }; + } + + return { + transport: "cloud-http", + mode, + deprecatedAlias, + modeSource, + baseUrl, + apiUrlSource, + apiKeyPresent: true, + apiKeySource: keyHit.key, + misconfigured: false, + warning: warnings.length > 0 ? warnings.join(" ") : null, + }; +} + +/** Thrown when a cloud HTTP request returns a non-2xx status. */ +export class HasnaHttpError extends Error { + readonly status: number; + readonly method: string; + readonly path: string; + readonly body: unknown; + constructor(method: string, path: string, status: number, body: unknown) { + super(`Hasna cloud request failed: ${method} ${path} -> ${status}`); + this.name = "HasnaHttpError"; + this.status = status; + this.method = method; + this.path = path; + this.body = body; + } +} + +type FetchLike = (input: string, init?: RequestInit) => Promise; + +export interface HasnaHttpTransportOptions { + /** App slug (for error context / default host). */ + name: string; + /** `/v1` base. Usually from `resolveClientTransport().baseUrl`. */ + baseUrl: string; + /** The API key (secret). Sent as both `x-api-key` and `Authorization: Bearer`. */ + apiKey: string; + /** Override fetch (tests). Defaults to global fetch. */ + fetchImpl?: FetchLike; + /** Extra headers merged into every request. */ + headers?: Record; + /** Per-request timeout in ms. Default 30000. */ + timeoutMs?: number; +} + +export interface HasnaHttpTransport { + readonly baseUrl: string; + request(method: string, path: string, body?: unknown): Promise; + get(path: string): Promise; + post(path: string, body?: unknown): Promise; + put(path: string, body?: unknown): Promise; + patch(path: string, body?: unknown): Promise; + del(path: string, body?: unknown): Promise; +} + +/** + * Build an authenticated HTTP transport for an app's cloud `/v1` API. Sends the + * API key on every request as BOTH `x-api-key` and `Authorization: Bearer` + * (serve apps accept either), and returns parsed JSON. Never logs the key. + */ +export function createHasnaHttpTransport(options: HasnaHttpTransportOptions): HasnaHttpTransport { + const fetchImpl: FetchLike = options.fetchImpl ?? ((input, init) => fetch(input, init)); + const base = options.baseUrl.replace(/\/+$/, ""); + const timeoutMs = options.timeoutMs ?? 30_000; + + async function request(method: string, path: string, body?: unknown): Promise { + const rel = path.startsWith("/") ? path : `/${path}`; + const url = `${base}${rel}`; + const headers: Record = { + "x-api-key": options.apiKey, + Authorization: `Bearer ${options.apiKey}`, + Accept: "application/json", + ...(options.headers ?? {}), + }; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + init.signal = controller.signal; + let response: Response; + try { + response = await fetchImpl(url, init); + } finally { + clearTimeout(timer); + } + const text = await response.text(); + let parsed: unknown = undefined; + if (text.length > 0) { + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + } + if (!response.ok) { + throw new HasnaHttpError(method, rel, response.status, parsed); + } + return parsed as T; + } + + return { + baseUrl: base, + request, + get: (path) => request("GET", path), + post: (path, body) => request("POST", path, body), + put: (path, body) => request("PUT", path, body), + patch: (path, body) => request("PATCH", path, body), + del: (path, body) => request("DELETE", path, body), + }; +} + +/** + * Convenience: resolve transport from env and, when cloud-http, build the HTTP + * client in one call. Returns `{ transport: 'local', resolution }` for local, or + * `{ transport: 'cloud-http', client, resolution }` for cloud. Throws if the + * config is `misconfigured` (cloud requested but unusable) so callers can't drift + * onto local data by accident. + */ +export function createClientTransport( + name: string, + env: Env = process.env, + overrides?: Partial>, +): + | { transport: "local"; client: null; resolution: ClientTransportResolution } + | { transport: "cloud-http"; client: HasnaHttpTransport; resolution: ClientTransportResolution } { + const resolution = resolveClientTransport(name, env); + if (resolution.misconfigured) { + throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for cloud mode.`); + } + if (resolution.transport === "local" || !resolution.baseUrl) { + return { transport: "local", client: null, resolution }; + } + const keys = clientTransportEnvKeys(name); + const apiKey = firstEnv(env, keys.apiKeyKeys)?.value; + if (!apiKey) { + // Should be unreachable given resolution logic, but never build without a key. + throw new Error(`Client for '${name}' resolved to cloud-http without an API key.`); + } + return { + transport: "cloud-http", + client: createHasnaHttpTransport({ + name, + baseUrl: resolution.baseUrl, + apiKey, + ...(overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {}), + ...(overrides?.headers ? { headers: overrides.headers } : {}), + ...(overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {}), + }), + resolution, + }; +} diff --git a/src/index.ts b/src/index.ts index 02fdc17..6e0bcfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,3 +7,4 @@ export * from "./conformance"; export * from "./kit/generate"; export * from "./auth/index"; export * from "./sdk/generate"; +export * from "./client/transport"; From ccc7f5f9c50d67e89edaca15ada58071c4e0e7ec Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 7 Jul 2026 11:04:24 +0300 Subject: [PATCH 2/2] feat(client): HTTP storage client (list/get/create/update/delete) with retries + idempotency Adds createHasnaStorageClient + resolveStorageClient on top of the cloud-http transport: the resource CRUD interface every Hasna serve app exposes under /v1, which an app's storage resolver selects when mode=self_hosted and API_URL+API_KEY are set (else local). Transport gains per-call query, retries (exp backoff+jitter, idempotent methods + POST-with-Idempotency-Key), and caller-abort propagation. Errors surface as HasnaHttpError. Live conformance test round-trips CRUD against a real cloud app (key from Secrets Manager; skips offline). Exported via @hasna/contracts and ./client/storage. Version 0.5.0. --- package.json | 6 +- src/client/conformance.live.test.ts | 109 +++++++++++++ src/client/storage.test.ts | 228 +++++++++++++++++++++++++++ src/client/storage.ts | 230 ++++++++++++++++++++++++++++ src/client/transport.ts | 168 +++++++++++++++++--- src/index.ts | 1 + src/no-cloud.ts | 2 + src/schemas.ts | 2 +- 8 files changed, 725 insertions(+), 21 deletions(-) create mode 100644 src/client/conformance.live.test.ts create mode 100644 src/client/storage.test.ts create mode 100644 src/client/storage.ts diff --git a/package.json b/package.json index c2d9d8c..3893c71 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,10 @@ "types": "./dist/client/transport.d.ts", "import": "./dist/client/transport.js" }, + "./client/storage": { + "types": "./dist/client/storage.d.ts", + "import": "./dist/client/storage.js" + }, "./hasna.contract.schema.json": "./dist/hasna.contract.schema.json" }, "files": [ @@ -79,7 +83,7 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/conformance.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts src/client/transport.ts --root src --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", + "build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/conformance.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts src/client/transport.ts src/client/storage.ts --root src --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", "typecheck": "tsc --noEmit", "test": "bun test", "lint": "tsc --noEmit", diff --git a/src/client/conformance.live.test.ts b/src/client/conformance.live.test.ts new file mode 100644 index 0000000..68b5af1 --- /dev/null +++ b/src/client/conformance.live.test.ts @@ -0,0 +1,109 @@ +// LIVE conformance: drive the HTTP storage client against a REAL Hasna cloud app +// (`knowledge.hasna.xyz/v1`) end to end — create, get, list, update, delete — +// proving the client satisfies the app storage interface over the wire with a +// real API key. +// +// The key is pulled at runtime from Secrets Manager (`hasna/oss//api-key`) +// via the AWS CLI. If AWS creds / the CLI / network are unavailable, the test is +// SKIPPED (not failed) so offline CI stays green. Set +// HASNA_CONTRACTS_LIVE_CONFORMANCE=0 to force-skip. +// +// SAFETY: the key value is never printed. Everything the client creates is +// deleted at the end; the note is tagged so a stray row is identifiable. + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHasnaStorageClient, type HasnaStorageClient } from "./storage.js"; +import { createHasnaHttpTransport, toV1BaseUrl } from "./transport.js"; + +const APP = "knowledge"; +const RESOURCE = "notes"; +const HOST = `https://${APP}.hasna.xyz`; + +function fetchApiKey(app: string): string | null { + if (process.env.HASNA_CONTRACTS_LIVE_CONFORMANCE === "0") return null; + // Allow an already-exported key (CI secret) to avoid an AWS round-trip. + const fromEnv = process.env[`HASNA_${app.toUpperCase()}_API_KEY`]?.trim(); + if (fromEnv) return fromEnv; + try { + const res = spawnSync( + "aws", + ["secretsmanager", "get-secret-value", "--secret-id", `hasna/oss/${app}/api-key`, "--query", "SecretString", "--output", "text", "--region", "us-east-1"], + { encoding: "utf8", timeout: 20_000 }, + ); + if (res.status !== 0) return null; + const key = res.stdout.trim(); + return key.length > 0 && !key.startsWith("None") ? key : null; + } catch { + return null; + } +} + +async function reachable(): Promise { + try { + const r = await fetch(`${HOST}/health`, { signal: AbortSignal.timeout(8_000) }); + return r.ok; + } catch { + return false; + } +} + +describe("LIVE conformance: HTTP storage client vs a real cloud app", async () => { + const apiKey = fetchApiKey(APP); + const up = apiKey ? await reachable() : false; + const run = Boolean(apiKey) && up; + if (!run) { + test.skip(`skipped: ${apiKey ? "app unreachable" : "no API key (AWS/env)"} — set HASNA_${APP.toUpperCase()}_API_KEY or ensure AWS creds`, () => {}); + return; + } + + let store: HasnaStorageClient; + const createdIds: string[] = []; + + beforeAll(() => { + const transport = createHasnaHttpTransport({ name: APP, baseUrl: toV1BaseUrl(HOST), apiKey: apiKey!, timeoutMs: 20_000 }); + store = createHasnaStorageClient(APP, transport); + }); + + afterAll(async () => { + for (const id of createdIds) { + try { + await store.delete(RESOURCE, id); + } catch { + /* best-effort cleanup */ + } + } + }); + + test("create -> get -> list -> update -> delete round-trips on the live API", async () => { + const marker = `contracts-live-conformance ${Date.now()}`; + const created = await store.create<{ id: string; title: string; tags: string[] }>(RESOURCE, { + title: marker, + content: "created by @hasna/contracts live conformance test", + tags: ["conformance", "contracts-client"], + }); + expect(created.id).toBeTruthy(); + createdIds.push(created.id); + + const got = await store.get<{ id: string; title: string }>(RESOURCE, created.id); + expect(got?.id).toBe(created.id); + expect(got?.title).toBe(marker); + + const listed = await store.list<{ id: string }>(RESOURCE, { query: { limit: 5 } }); + expect(Array.isArray(listed.items)).toBe(true); + expect(listed.total === null || typeof listed.total === "number").toBe(true); + + const updated = await store.update<{ id: string; tags: string[] }>(RESOURCE, created.id, { tags: ["conformance", "updated"] }); + expect(updated.id).toBe(created.id); + + await store.delete(RESOURCE, created.id); + createdIds.pop(); + const afterDelete = await store.get(RESOURCE, created.id); + expect(afterDelete).toBeNull(); + }, 40_000); + + test("get on a non-existent id returns null (404 => null)", async () => { + const missing = await store.get(RESOURCE, "does-not-exist-00000000-0000-0000-0000-000000000000"); + expect(missing).toBeNull(); + }, 20_000); +}); diff --git a/src/client/storage.test.ts b/src/client/storage.test.ts new file mode 100644 index 0000000..993a1e8 --- /dev/null +++ b/src/client/storage.test.ts @@ -0,0 +1,228 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + appendQuery, + createHasnaHttpTransport, + HasnaHttpError, + type HasnaRequestOptions, +} from "./transport.js"; +import { createHasnaStorageClient, resolveStorageClient } from "./storage.js"; + +// A scriptable fetch stub that records requests and returns queued responses. +function makeFetch(handler: (req: { method: string; url: string; headers: Record; body: unknown }) => { status: number; body?: unknown; text?: string }) { + const calls: { method: string; url: string; headers: Record; body: unknown }[] = []; + const fetchImpl = async (input: string, init?: RequestInit) => { + const headers: Record = {}; + if (init?.headers) for (const [k, v] of Object.entries(init.headers as Record)) headers[k.toLowerCase()] = v; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + const call = { method: init?.method ?? "GET", url: input, headers, body }; + calls.push(call); + const r = handler(call); + const text = r.text !== undefined ? r.text : r.body !== undefined ? JSON.stringify(r.body) : ""; + return new Response(text, { status: r.status, headers: { "content-type": "application/json" } }); + }; + return { fetchImpl, calls }; +} + +describe("appendQuery", () => { + test("serializes scalars, arrays; drops nullish; respects existing query", () => { + expect(appendQuery("/notes", { limit: 10, archived: false, skip: undefined, q: null })).toBe("/notes?limit=10&archived=false"); + expect(appendQuery("/notes", { tag: ["a", "b"] })).toBe("/notes?tag=a&tag=b"); + expect(appendQuery("/notes?x=1", { y: 2 })).toBe("/notes?x=1&y=2"); + expect(appendQuery("/notes")).toBe("/notes"); + }); +}); + +describe("HasnaStorageClient CRUD mapping", () => { + function client(handler: Parameters[0]) { + const { fetchImpl, calls } = makeFetch(handler); + const transport = createHasnaHttpTransport({ name: "knowledge", baseUrl: "https://knowledge.hasna.xyz/v1", apiKey: "hasna_knowledge_secret", fetchImpl, retry: false }); + return { store: createHasnaStorageClient("knowledge", transport), calls }; + } + + test("list -> GET /, extracts items + total from envelope", async () => { + const { store, calls } = client(() => ({ status: 200, body: { items: [{ id: "1" }, { id: "2" }], total: 42 } })); + const res = await store.list("notes", { query: { limit: 2 } }); + expect(res.items.map((i: any) => i.id)).toEqual(["1", "2"]); + expect(res.total).toBe(42); + expect(calls[0]!.method).toBe("GET"); + expect(calls[0]!.url).toBe("https://knowledge.hasna.xyz/v1/notes?limit=2"); + // key never in URL + expect(calls[0]!.url).not.toContain("secret"); + }); + + test("get -> GET //; 404 => null", async () => { + const { store } = client((req) => (req.url.endsWith("/miss") ? { status: 404, body: { error: "not_found" } } : { status: 200, body: { id: "abc" } })); + expect(await store.get("notes", "abc")).toEqual({ id: "abc" } as any); + expect(await store.get("notes", "miss")).toBeNull(); + }); + + test("create -> POST / with auto Idempotency-Key + bearer", async () => { + const { store, calls } = client(() => ({ status: 201, body: { id: "new" } })); + const out = await store.create("notes", { title: "t" }); + expect(out).toEqual({ id: "new" } as any); + const c = calls[0]!; + expect(c.method).toBe("POST"); + expect(c.headers["idempotency-key"]).toBeTruthy(); + expect(c.headers["authorization"]).toBe("Bearer hasna_knowledge_secret"); + expect(c.headers["x-api-key"]).toBe("hasna_knowledge_secret"); + expect(c.body).toEqual({ title: "t" }); + }); + + test("create honors a caller-supplied idempotency key", async () => { + const { store, calls } = client(() => ({ status: 201, body: {} })); + await store.create("notes", { title: "t" }, { idempotencyKey: "fixed-123" }); + expect(calls[0]!.headers["idempotency-key"]).toBe("fixed-123"); + }); + + test("update -> PATCH by default, PUT on option", async () => { + const { store, calls } = client(() => ({ status: 200, body: { ok: true } })); + await store.update("notes", "id1", { tags: ["x"] }); + await store.update("notes", "id2", { tags: ["y"] }, { method: "PUT" }); + expect(calls[0]!.method).toBe("PATCH"); + expect(calls[0]!.url).toBe("https://knowledge.hasna.xyz/v1/notes/id1"); + expect(calls[1]!.method).toBe("PUT"); + }); + + test("delete -> DELETE //; 204 and 404 both resolve", async () => { + const { store, calls } = client((req) => (req.url.endsWith("/gone") ? { status: 404 } : { status: 204, text: "" })); + await store.delete("notes", "id1"); + await store.delete("notes", "gone"); + expect(calls[0]!.method).toBe("DELETE"); + }); + + test("non-2xx (non-404) surfaces HasnaHttpError with status + body", async () => { + const { store } = client(() => ({ status: 400, body: { error: "bad_request", message: "title required" } })); + await expect(store.create("notes", {})).rejects.toBeInstanceOf(HasnaHttpError); + try { + await store.create("notes", {}); + } catch (e) { + const err = e as HasnaHttpError; + expect(err.status).toBe(400); + expect((err.body as any).message).toBe("title required"); + } + }); + + test("id path segments are URL-encoded", async () => { + const { store, calls } = client(() => ({ status: 200, body: {} })); + await store.get("notes", "a/b c"); + expect(calls[0]!.url).toBe("https://knowledge.hasna.xyz/v1/notes/a%2Fb%20c"); + }); +}); + +describe("retries + idempotency", () => { + const noSleep = async () => {}; + + test("GET retries transient 503 then succeeds", async () => { + let n = 0; + const { fetchImpl, calls } = makeFetch(() => (++n < 3 ? { status: 503, body: { error: "unavailable" } } : { status: 200, body: { id: "ok" } })); + const t = createHasnaHttpTransport({ name: "app", baseUrl: "https://x/v1", apiKey: "k", fetchImpl, sleepImpl: noSleep }); + expect(await t.get("/notes")).toEqual({ id: "ok" } as any); + expect(calls.length).toBe(3); + }); + + test("POST without idempotency key is NOT retried (no duplicate writes)", async () => { + let n = 0; + const { fetchImpl, calls } = makeFetch(() => { n++; return { status: 503, body: { error: "unavailable" } }; }); + const t = createHasnaHttpTransport({ name: "app", baseUrl: "https://x/v1", apiKey: "k", fetchImpl, sleepImpl: noSleep }); + await expect(t.post("/notes", { title: "t" })).rejects.toBeInstanceOf(HasnaHttpError); + expect(calls.length).toBe(1); + }); + + test("POST WITH idempotency key IS retried", async () => { + let n = 0; + const { fetchImpl, calls } = makeFetch(() => (++n < 2 ? { status: 503, body: {} } : { status: 201, body: { id: "z" } })); + const t = createHasnaHttpTransport({ name: "app", baseUrl: "https://x/v1", apiKey: "k", fetchImpl, sleepImpl: noSleep }); + const opts: HasnaRequestOptions = { idempotencyKey: "abc" }; + expect(await t.post("/notes", { title: "t" }, opts)).toEqual({ id: "z" } as any); + expect(calls.length).toBe(2); + }); + + test("4xx (non-retry status) is not retried", async () => { + const { fetchImpl, calls } = makeFetch(() => ({ status: 400, body: { error: "bad" } })); + const t = createHasnaHttpTransport({ name: "app", baseUrl: "https://x/v1", apiKey: "k", fetchImpl, sleepImpl: noSleep }); + await expect(t.get("/notes")).rejects.toBeInstanceOf(HasnaHttpError); + expect(calls.length).toBe(1); + }); + + test("caller-initiated abort is NOT retried (propagates immediately)", async () => { + const controller = new AbortController(); + const { fetchImpl, calls } = makeFetch(() => { controller.abort(); throw new DOMException("aborted", "AbortError"); }); + const t = createHasnaHttpTransport({ name: "app", baseUrl: "https://x/v1", apiKey: "k", fetchImpl, sleepImpl: noSleep }); + await expect(t.get("/notes", { signal: controller.signal })).rejects.toThrow(); + expect(calls.length).toBe(1); + }); + + test("retries give up after configured attempts", async () => { + const { fetchImpl, calls } = makeFetch(() => ({ status: 500, body: {} })); + const t = createHasnaHttpTransport({ name: "app", baseUrl: "https://x/v1", apiKey: "k", fetchImpl, sleepImpl: noSleep, retry: { retries: 3 } }); + await expect(t.get("/notes")).rejects.toBeInstanceOf(HasnaHttpError); + expect(calls.length).toBe(4); // 1 + 3 retries + }); +}); + +// End-to-end: a demo app storage resolver picks the HTTP client on flip, local otherwise. +describe("resolveStorageClient — the resolver an app wires", () => { + const KEY = "hasna_demo_resolver_secret"; + const cloud = new Map(); + let server: ReturnType; + let baseUrl: string; + + beforeAll(() => { + let seq = 0; + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if ((req.headers.get("authorization") ?? "") !== `Bearer ${KEY}`) return Response.json({ error: "unauthorized" }, { status: 401 }); + const m = req.method; + if (url.pathname === "/v1/things" && m === "GET") return Response.json({ items: [...cloud.values()], total: cloud.size }); + if (url.pathname === "/v1/things" && m === "POST") { + const b = (await req.json()) as { title: string }; + const id = `t${++seq}`; + const row = { id, title: b.title }; + cloud.set(id, row); + return Response.json(row, { status: 201 }); + } + const idm = url.pathname.match(/^\/v1\/things\/(.+)$/); + if (idm) { + const id = decodeURIComponent(idm[1]!); + if (m === "GET") return cloud.has(id) ? Response.json(cloud.get(id)) : Response.json({ error: "not_found" }, { status: 404 }); + if (m === "PATCH") { const b = (await req.json()) as any; const row = { ...cloud.get(id)!, ...b }; cloud.set(id, row); return Response.json(row); } + if (m === "DELETE") { cloud.delete(id); return new Response("", { status: 204 }); } + } + return Response.json({ error: "not_found" }, { status: 404 }); + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; + }); + afterAll(() => server.stop(true)); + + test("no env => local (client null)", () => { + const r = resolveStorageClient("demo", {}); + expect(r.transport).toBe("local"); + expect(r.client).toBeNull(); + }); + + test("self_hosted + url + key => cloud-http, full CRUD lands in cloud store", async () => { + const env = { HASNA_DEMO_STORAGE_MODE: "self_hosted", HASNA_DEMO_API_URL: baseUrl, HASNA_DEMO_API_KEY: KEY }; + const r = resolveStorageClient("demo", env); + expect(r.transport).toBe("cloud-http"); + const store = r.client!; + const created = await store.create<{ id: string; title: string }>("things", { title: "first" }); + expect(created.id).toBeTruthy(); + expect(cloud.has(created.id)).toBe(true); + expect(await store.get("things", created.id)).toEqual(created as any); + const listed = await store.list("things"); + expect(listed.items.length).toBe(1); + expect(listed.total).toBe(1); + const updated = await store.update<{ title: string }>("things", created.id, { title: "second" }); + expect(updated.title).toBe("second"); + await store.delete("things", created.id); + expect(await store.get("things", created.id)).toBeNull(); + expect(cloud.size).toBe(0); + }); + + test("cloud requested but no key => throws (never silent local drift)", () => { + expect(() => resolveStorageClient("demo", { HASNA_DEMO_STORAGE_MODE: "cloud" })).toThrow(); + }); +}); diff --git a/src/client/storage.ts b/src/client/storage.ts new file mode 100644 index 0000000..0cc22c1 --- /dev/null +++ b/src/client/storage.ts @@ -0,0 +1,230 @@ +// HTTP storage client for the Hasna Service Contract v1. +// +// This is the piece that makes `mode=self_hosted` real for a client. It sits on +// top of `createHasnaHttpTransport` and implements the generic resource CRUD +// vocabulary every Hasna serve app exposes under `/v1`: +// +// list -> GET /v1/ -> { items, total, ... } +// get -> GET /v1// -> | null (404 => null) +// create -> POST /v1/ -> (auto Idempotency-Key) +// update -> PATCH /v1// -> (PUT via method opt) +// delete -> DELETE /v1// -> void (204/404 => ok) +// +// An app's storage resolver selects this client when the client-flip contract +// resolves to `cloud-http` (mode=cloud/self_hosted AND API_URL+API_KEY set), and +// falls through to the local store otherwise. See `resolveClientTransport` / +// `createClientTransport` in ./transport.ts. +// +// Guarantees carried up from the transport: JSON in/out, per-request timeout, +// retries with exponential backoff + jitter for transient failures, and +// idempotency (create() attaches an `Idempotency-Key` so a retried POST cannot +// duplicate). Non-2xx responses surface as `HasnaHttpError` (status + body). +// +// SAFETY: never logs, returns, or embeds the API key. The key lives only inside +// the transport it wraps. + +import type { Env } from "../mode.js"; +import { + createClientTransport, + HasnaHttpError, + type HasnaHttpTransport, + type HasnaRequestOptions, + type QueryParams, +} from "./transport.js"; + +/** Options for a list() call: filters/pagination as query params. */ +export interface StorageListOptions extends Pick { + /** Query params (limit, offset, cursor, filters, ...). */ + query?: QueryParams; +} + +/** Options for a get() call. */ +export type StorageGetOptions = Pick; + +/** Options for a create() call. */ +export interface StorageCreateOptions extends Pick { + /** + * Idempotency key for the create. Defaults to a fresh UUID so a transparently + * retried POST is deduped by the server instead of creating a duplicate. Pass + * a stable value to make an app-level operation idempotent across calls. + */ + idempotencyKey?: string; +} + +/** Options for an update() call. */ +export interface StorageUpdateOptions extends Pick { + /** HTTP verb for the update. Default `PATCH` (partial); use `PUT` for replace. */ + method?: "PATCH" | "PUT"; + /** Idempotency key. PUT is idempotent by definition; set this to make PATCH retry-safe too. */ + idempotencyKey?: string; +} + +/** Options for a delete() call. */ +export type StorageDeleteOptions = Pick; + +/** Result of a list() call. `items` is the extracted array; `raw` is the full envelope. */ +export interface StorageListResult { + items: T[]; + /** Total count when the server reports one (`total`/`count`), else null. */ + total: number | null; + /** Opaque pagination cursor when the server reports one, else null. */ + cursor: string | null; + /** The full parsed response body (envelope preserved). */ + raw: unknown; +} + +/** + * The app storage interface, HTTP edition. This is deliberately the same small + * CRUD surface a local store exposes, so an app's resolver can return either a + * local implementation or this one behind one interface. + */ +export interface HasnaStorageClient { + /** App slug this client targets. */ + readonly name: string; + /** `/v1` base URL. */ + readonly baseUrl: string; + /** The underlying HTTP transport (escape hatch for non-CRUD routes). */ + readonly transport: HasnaHttpTransport; + + /** List a collection. Returns extracted `items` plus the raw envelope. */ + list(resource: string, options?: StorageListOptions): Promise>; + /** Fetch one entity by id. Returns `null` on 404. */ + get(resource: string, id: string, options?: StorageGetOptions): Promise; + /** Create one entity. Retry-safe via an auto `Idempotency-Key`. */ + create(resource: string, body: unknown, options?: StorageCreateOptions): Promise; + /** Update one entity by id (PATCH by default). */ + update(resource: string, id: string, patch: unknown, options?: StorageUpdateOptions): Promise; + /** Delete one entity by id. Resolves for 2xx and 404 (already gone). */ + delete(resource: string, id: string, options?: StorageDeleteOptions): Promise; +} + +function resourcePath(resource: string): string { + const trimmed = resource.replace(/^\/+|\/+$/g, ""); + if (!trimmed) throw new Error("resource must be a non-empty path segment"); + return `/${trimmed}`; +} + +function entityPath(resource: string, id: string): string { + if (id === undefined || id === null || `${id}`.length === 0) { + throw new Error("id must be a non-empty string"); + } + return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`; +} + +function newIdempotencyKey(): string { + const g = globalThis as { crypto?: { randomUUID?: () => string } }; + if (g.crypto?.randomUUID) return g.crypto.randomUUID(); + // Fallback for runtimes without WebCrypto. + return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`; +} + +function extractItems(raw: unknown): T[] { + if (Array.isArray(raw)) return raw as T[]; + if (raw && typeof raw === "object") { + const obj = raw as Record; + for (const key of ["items", "data", "results", "rows", "records"]) { + if (Array.isArray(obj[key])) return obj[key] as T[]; + } + } + return []; +} + +function extractTotal(raw: unknown): number | null { + if (raw && typeof raw === "object") { + const obj = raw as Record; + for (const key of ["total", "count", "totalCount", "total_count"]) { + if (typeof obj[key] === "number") return obj[key] as number; + } + } + return null; +} + +function extractCursor(raw: unknown): string | null { + if (raw && typeof raw === "object") { + const obj = raw as Record; + for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) { + if (typeof obj[key] === "string") return obj[key] as string; + } + } + return null; +} + +/** + * Wrap an HTTP transport with the resource CRUD storage interface. Use this when + * you already have a transport (e.g. from `createClientTransport`). + */ +export function createHasnaStorageClient(name: string, transport: HasnaHttpTransport): HasnaStorageClient { + return { + name, + baseUrl: transport.baseUrl, + transport, + + async list(resource: string, options: StorageListOptions = {}): Promise> { + const raw = await transport.get(resourcePath(resource), options); + return { + items: extractItems(raw), + total: extractTotal(raw), + cursor: extractCursor(raw), + raw, + }; + }, + + async get(resource: string, id: string, options: StorageGetOptions = {}): Promise { + try { + return await transport.get(entityPath(resource, id), options); + } catch (error) { + if (error instanceof HasnaHttpError && error.status === 404) return null; + throw error; + } + }, + + async create(resource: string, body: unknown, options: StorageCreateOptions = {}): Promise { + const { idempotencyKey, ...rest } = options; + return transport.post(resourcePath(resource), body, { + ...rest, + idempotencyKey: idempotencyKey ?? newIdempotencyKey(), + }); + }, + + async update(resource: string, id: string, patch: unknown, options: StorageUpdateOptions = {}): Promise { + const { method = "PATCH", idempotencyKey, ...rest } = options; + const call = method === "PUT" ? transport.put : transport.patch; + return call(entityPath(resource, id), patch, { ...rest, ...(idempotencyKey ? { idempotencyKey } : {}) }); + }, + + async delete(resource: string, id: string, options: StorageDeleteOptions = {}): Promise { + try { + await transport.del(entityPath(resource, id), undefined, options); + } catch (error) { + // Deleting an already-absent entity is not an error (idempotent delete). + if (error instanceof HasnaHttpError && error.status === 404) return; + throw error; + } + }, + }; +} + +/** Result of {@link resolveStorageClient}. */ +export type ResolveStorageClientResult = + | { transport: "local"; client: null } + | { transport: "cloud-http"; client: HasnaStorageClient }; + +/** + * The one call an app's storage resolver makes. Reads the client-flip env for + * `name`; when it resolves to `cloud-http` (mode=cloud/self_hosted + API_URL + + * API_KEY), returns a ready {@link HasnaStorageClient}. Otherwise returns + * `{ transport: 'local', client: null }` so the app uses its local store. + * Throws if cloud was requested but is misconfigured (so callers never silently + * read the wrong dataset). + */ +export function resolveStorageClient( + name: string, + env: Env = process.env, + overrides?: Parameters[2], +): ResolveStorageClientResult { + const wired = createClientTransport(name, env, overrides); + if (wired.transport === "cloud-http") { + return { transport: "cloud-http", client: createHasnaStorageClient(name, wired.client) }; + } + return { transport: "local", client: null }; +} diff --git a/src/client/transport.ts b/src/client/transport.ts index a265b0c..aa32830 100644 --- a/src/client/transport.ts +++ b/src/client/transport.ts @@ -234,6 +234,47 @@ export class HasnaHttpError extends Error { type FetchLike = (input: string, init?: RequestInit) => Promise; +/** Query params for a request. Nullish values are dropped; arrays repeat the key. */ +export type QueryParams = + | URLSearchParams + | Record>; + +/** Retry policy for transient failures (network errors, timeouts, 5xx, 429). */ +export interface HasnaRetryOptions { + /** Max RETRY attempts after the first try. Default 2 (=> up to 3 total tries). */ + retries?: number; + /** Base backoff in ms for exponential backoff. Default 200. */ + baseDelayMs?: number; + /** Backoff ceiling in ms. Default 2000. */ + maxDelayMs?: number; + /** HTTP statuses that trigger a retry. Default 408, 425, 429, 500, 502, 503, 504. */ + retryStatuses?: number[]; +} + +/** Per-call request options: query, idempotency, timeout, retry, extra headers. */ +export interface HasnaRequestOptions { + /** Query string params appended to the URL. */ + query?: QueryParams; + /** + * Idempotency key sent as `Idempotency-Key`. When set, unsafe methods (POST) + * become safe to retry: the server dedupes replays. Auto-generated for + * `create()` in the storage client. + */ + idempotencyKey?: string; + /** Override the transport timeout for this call (ms). */ + timeoutMs?: number; + /** Extra headers merged into this call (override transport headers). */ + headers?: Record; + /** Override or disable retry for this call. `false` disables retries. */ + retry?: HasnaRetryOptions | false; + /** Caller abort signal, combined with the internal timeout. */ + signal?: AbortSignal; +} + +const DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504] as const; +/** Methods that are idempotent by definition and always safe to retry. */ +const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]); + export interface HasnaHttpTransportOptions { /** App slug (for error context / default host). */ name: string; @@ -247,50 +288,113 @@ export interface HasnaHttpTransportOptions { headers?: Record; /** Per-request timeout in ms. Default 30000. */ timeoutMs?: number; + /** Default retry policy for all requests. Pass `false` to disable. */ + retry?: HasnaRetryOptions | false; + /** Injectable sleep (tests). Defaults to a real timer. */ + sleepImpl?: (ms: number) => Promise; } export interface HasnaHttpTransport { readonly baseUrl: string; - request(method: string, path: string, body?: unknown): Promise; - get(path: string): Promise; - post(path: string, body?: unknown): Promise; - put(path: string, body?: unknown): Promise; - patch(path: string, body?: unknown): Promise; - del(path: string, body?: unknown): Promise; + request(method: string, path: string, body?: unknown, opts?: HasnaRequestOptions): Promise; + get(path: string, opts?: HasnaRequestOptions): Promise; + post(path: string, body?: unknown, opts?: HasnaRequestOptions): Promise; + put(path: string, body?: unknown, opts?: HasnaRequestOptions): Promise; + patch(path: string, body?: unknown, opts?: HasnaRequestOptions): Promise; + del(path: string, body?: unknown, opts?: HasnaRequestOptions): Promise; +} + +/** Append query params to a `/v1`-relative path (no-op when empty). */ +export function appendQuery(path: string, query?: QueryParams): string { + if (!query) return path; + const params = query instanceof URLSearchParams ? query : new URLSearchParams(); + if (!(query instanceof URLSearchParams)) { + for (const [key, value] of Object.entries(query)) { + if (value === null || value === undefined) continue; + if (Array.isArray(value)) { + for (const v of value) params.append(key, String(v)); + } else { + params.append(key, String(value)); + } + } + } + const qs = params.toString(); + if (!qs) return path; + return `${path}${path.includes("?") ? "&" : "?"}${qs}`; } +const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + /** * Build an authenticated HTTP transport for an app's cloud `/v1` API. Sends the * API key on every request as BOTH `x-api-key` and `Authorization: Bearer` - * (serve apps accept either), and returns parsed JSON. Never logs the key. + * (serve apps accept either), returns parsed JSON, times out, and retries + * transient failures with exponential backoff + jitter. Never logs the key. + * + * Retry safety: idempotent methods (GET/HEAD/PUT/DELETE/OPTIONS) are always + * retried on transient failure; POST/PATCH are retried ONLY when an + * `Idempotency-Key` is supplied, so replays can't create duplicates. */ export function createHasnaHttpTransport(options: HasnaHttpTransportOptions): HasnaHttpTransport { const fetchImpl: FetchLike = options.fetchImpl ?? ((input, init) => fetch(input, init)); const base = options.baseUrl.replace(/\/+$/, ""); const timeoutMs = options.timeoutMs ?? 30_000; + const sleep = options.sleepImpl ?? defaultSleep; + const defaultRetry = options.retry; - async function request(method: string, path: string, body?: unknown): Promise { - const rel = path.startsWith("/") ? path : `/${path}`; - const url = `${base}${rel}`; + function resolveRetry(callRetry: HasnaRequestOptions["retry"]): Required | null { + const chosen = callRetry !== undefined ? callRetry : defaultRetry; + if (chosen === false) return null; + const r = chosen ?? {}; + return { + retries: r.retries ?? 2, + baseDelayMs: r.baseDelayMs ?? 200, + maxDelayMs: r.maxDelayMs ?? 2_000, + retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES], + }; + } + + async function once( + method: string, + rel: string, + url: string, + body: unknown, + opts: HasnaRequestOptions, + ): Promise<{ ok: true; value: T } | { ok: false; retryable: boolean; error: Error }> { const headers: Record = { "x-api-key": options.apiKey, Authorization: `Bearer ${options.apiKey}`, Accept: "application/json", ...(options.headers ?? {}), + ...(opts.headers ?? {}), }; + if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey; const init: RequestInit = { method, headers }; if (body !== undefined) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + if (opts.signal) { + if (opts.signal.aborted) controller.abort(); + else opts.signal.addEventListener("abort", onAbort, { once: true }); + } + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs); init.signal = controller.signal; let response: Response; try { response = await fetchImpl(url, init); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + // A caller-initiated abort is a cancellation, not a transient failure — + // propagate it immediately instead of retrying. Our own timeout abort and + // ordinary network errors ARE transient and retryable. + if (opts.signal?.aborted) return { ok: false, retryable: false, error: err }; + return { ok: false, retryable: true, error: err }; } finally { clearTimeout(timer); + if (opts.signal) opts.signal.removeEventListener("abort", onAbort); } const text = await response.text(); let parsed: unknown = undefined; @@ -302,19 +406,43 @@ export function createHasnaHttpTransport(options: HasnaHttpTransportOptions): Ha } } if (!response.ok) { - throw new HasnaHttpError(method, rel, response.status, parsed); + const retry = resolveRetry(opts.retry); + const retryable = retry ? retry.retryStatuses.includes(response.status) : false; + return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) }; + } + return { ok: true, value: parsed as T }; + } + + async function request(method: string, path: string, body?: unknown, opts: HasnaRequestOptions = {}): Promise { + const upper = method.toUpperCase(); + const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query); + const url = `${base}${rel}`; + const retry = resolveRetry(opts.retry); + const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey); + const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1; + + let last: { retryable: boolean; error: Error } | null = null; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const result = await once(upper, rel, url, body, opts); + if (result.ok) return result.value; + last = result; + const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts; + if (!canRetry) break; + const backoff = Math.min(retry!.maxDelayMs, retry!.baseDelayMs * 2 ** (attempt - 1)); + const jitter = Math.floor(Math.random() * (backoff / 2 + 1)); + await sleep(backoff + jitter); } - return parsed as T; + throw last!.error; } return { baseUrl: base, request, - get: (path) => request("GET", path), - post: (path, body) => request("POST", path, body), - put: (path, body) => request("PUT", path, body), - patch: (path, body) => request("PATCH", path, body), - del: (path, body) => request("DELETE", path, body), + get: (path, opts) => request("GET", path, undefined, opts), + post: (path, body, opts) => request("POST", path, body, opts), + put: (path, body, opts) => request("PUT", path, body, opts), + patch: (path, body, opts) => request("PATCH", path, body, opts), + del: (path, body, opts) => request("DELETE", path, body, opts), }; } @@ -328,7 +456,7 @@ export function createHasnaHttpTransport(options: HasnaHttpTransportOptions): Ha export function createClientTransport( name: string, env: Env = process.env, - overrides?: Partial>, + overrides?: Partial>, ): | { transport: "local"; client: null; resolution: ClientTransportResolution } | { transport: "cloud-http"; client: HasnaHttpTransport; resolution: ClientTransportResolution } { @@ -354,6 +482,8 @@ export function createClientTransport( ...(overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {}), ...(overrides?.headers ? { headers: overrides.headers } : {}), ...(overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {}), + ...(overrides?.retry !== undefined ? { retry: overrides.retry } : {}), + ...(overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}), }), resolution, }; diff --git a/src/index.ts b/src/index.ts index 6e0bcfb..f2c1de7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export * from "./kit/generate"; export * from "./auth/index"; export * from "./sdk/generate"; export * from "./client/transport"; +export * from "./client/storage"; diff --git a/src/no-cloud.ts b/src/no-cloud.ts index 4f4c87b..0ee9c4d 100644 --- a/src/no-cloud.ts +++ b/src/no-cloud.ts @@ -58,6 +58,8 @@ const CONTRACTS_DECLARATION_PATHS = new Set([ "dist/mode.js", "dist/service-contract.js", "dist/conformance.js", + "dist/client/transport.js", + "dist/client/storage.js", "dist/cli/index.js", "dist/no-cloud.d.ts", "dist/schemas.d.ts", diff --git a/src/schemas.ts b/src/schemas.ts index 04ab311..2b0a493 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const CONTRACTS_PACKAGE_NAME = "@hasna/contracts"; -export const CONTRACTS_PACKAGE_VERSION = "0.4.1"; +export const CONTRACTS_PACKAGE_VERSION = "0.5.0"; export const SCHEMA_IDS = { actorRef: "hasna.actor_ref.v1",