From 42e71ffd33ae6b8df3f283461c35117c5ec6c52c Mon Sep 17 00:00:00 2001 From: andreihasna Date: Mon, 20 Jul 2026 11:05:44 +0300 Subject: [PATCH] fix(security): remove internal infra hostnames from published package --- src/client/conformance.live.test.ts | 11 +++++++--- src/client/storage.test.ts | 8 +++---- src/client/transport.test.ts | 24 ++++++++++----------- src/client/transport.ts | 33 ++++++++++++++++++++--------- 4 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/client/conformance.live.test.ts b/src/client/conformance.live.test.ts index 68b5af1..2e7baec 100644 --- a/src/client/conformance.live.test.ts +++ b/src/client/conformance.live.test.ts @@ -1,5 +1,5 @@ // 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 — +// (`knowledge.your-deployment.example/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. // @@ -14,11 +14,16 @@ 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"; +import { createHasnaHttpTransport, defaultCloudBaseUrl, toV1BaseUrl } from "./transport.js"; const APP = "knowledge"; const RESOURCE = "notes"; -const HOST = `https://${APP}.hasna.xyz`; +// Real live runs (with AWS creds present) must set HASNA_FLEET_API_DOMAIN to the +// operator's real deployment domain; absent that, this falls back to the same +// neutral, non-resolving placeholder as the rest of the package, so a live run +// without the env var configured fails fast rather than silently targeting a +// guessed real hostname. +const HOST = defaultCloudBaseUrl(APP); function fetchApiKey(app: string): string | null { if (process.env.HASNA_CONTRACTS_LIVE_CONFORMANCE === "0") return null; diff --git a/src/client/storage.test.ts b/src/client/storage.test.ts index 7ff2fea..e41b669 100644 --- a/src/client/storage.test.ts +++ b/src/client/storage.test.ts @@ -37,7 +37,7 @@ 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: sampleApiKey, fetchImpl, retry: false }); + const transport = createHasnaHttpTransport({ name: "knowledge", baseUrl: "https://knowledge.your-deployment.example/v1", apiKey: sampleApiKey, fetchImpl, retry: false }); return { store: createHasnaStorageClient("knowledge", transport), calls }; } @@ -47,7 +47,7 @@ describe("HasnaStorageClient CRUD mapping", () => { 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"); + expect(calls[0]!.url).toBe("https://knowledge.your-deployment.example/v1/notes?limit=2"); // key never in URL expect(calls[0]!.url).not.toContain("secret"); }); @@ -81,7 +81,7 @@ describe("HasnaStorageClient CRUD mapping", () => { 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[0]!.url).toBe("https://knowledge.your-deployment.example/v1/notes/id1"); expect(calls[1]!.method).toBe("PUT"); }); @@ -107,7 +107,7 @@ describe("HasnaStorageClient CRUD mapping", () => { 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"); + expect(calls[0]!.url).toBe("https://knowledge.your-deployment.example/v1/notes/a%2Fb%20c"); }); }); diff --git a/src/client/transport.test.ts b/src/client/transport.test.ts index 9a49536..845caf0 100644 --- a/src/client/transport.test.ts +++ b/src/client/transport.test.ts @@ -22,7 +22,7 @@ describe("resolveClientTransport — the client-flip contract", () => { 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_URL: "https://todos.your-deployment.example", HASNA_TODOS_API_KEY: "hasna_todos_x", }); expect(r.transport).toBe("local"); @@ -31,11 +31,11 @@ describe("resolveClientTransport — the client-flip contract", () => { 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_URL: "https://todos.your-deployment.example", HASNA_TODOS_API_KEY: "hasna_todos_abc", }); expect(r.transport).toBe("cloud-http"); - expect(r.baseUrl).toBe("https://todos.hasna.xyz/v1"); + expect(r.baseUrl).toBe("https://todos.your-deployment.example/v1"); expect(r.apiKeyPresent).toBe(true); // secret value is never surfaced expect(JSON.stringify(r)).not.toContain("hasna_todos_abc"); @@ -43,19 +43,19 @@ describe("resolveClientTransport — the client-flip contract", () => { test("FLIP: url+key with NO mode env => inferred cloud-http (fleet-flip contract)", () => { const r = resolveClientTransport("todos", { - HASNA_TODOS_API_URL: "https://todos.hasna.xyz", + HASNA_TODOS_API_URL: "https://todos.your-deployment.example", HASNA_TODOS_API_KEY: "hasna_todos_flip", }); expect(r.transport).toBe("cloud-http"); expect(r.mode).toBe("cloud"); - expect(r.baseUrl).toBe("https://todos.hasna.xyz/v1"); + expect(r.baseUrl).toBe("https://todos.your-deployment.example/v1"); expect(r.modeSource).toBe("HASNA_TODOS_API_URL+HASNA_TODOS_API_KEY"); expect(JSON.stringify(r)).not.toContain("hasna_todos_flip"); }); test("FLIP revert: url present but key removed => back to local (not misconfigured)", () => { const r = resolveClientTransport("todos", { - HASNA_TODOS_API_URL: "https://todos.hasna.xyz", + HASNA_TODOS_API_URL: "https://todos.your-deployment.example", }); expect(r.transport).toBe("local"); expect(r.mode).toBe("local"); @@ -69,14 +69,14 @@ describe("resolveClientTransport — the client-flip contract", () => { }); expect(r.transport).toBe("cloud-http"); expect(r.deprecatedAlias).toBe("self_hosted"); - expect(r.baseUrl).toBe("https://knowledge.hasna.xyz/v1"); + expect(r.baseUrl).toBe("https://knowledge.your-deployment.example/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_URL: "https://todos.your-deployment.example", TODOS_API_KEY: "hasna_todos_z", }); expect(r.transport).toBe("cloud-http"); @@ -99,13 +99,13 @@ describe("resolveClientTransport — the client-flip contract", () => { 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"); + expect(defaultCloudBaseUrl("agent-registry")).toBe("https://agent-registry.your-deployment.example"); }); 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"); + expect(toV1BaseUrl("https://todos.your-deployment.example")).toBe("https://todos.your-deployment.example/v1"); + expect(toV1BaseUrl("https://todos.your-deployment.example/")).toBe("https://todos.your-deployment.example/v1"); + expect(toV1BaseUrl("https://todos.your-deployment.example/v1")).toBe("https://todos.your-deployment.example/v1"); }); }); diff --git a/src/client/transport.ts b/src/client/transport.ts index 981b8e3..03af12b 100644 --- a/src/client/transport.ts +++ b/src/client/transport.ts @@ -7,7 +7,7 @@ // // 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`) +// app's cloud HTTP API (`/v1`, default `https://./v1`) // with the API key, or fall through to the local store. // // THE CLIENT-FLIP CONTRACT (env vars). For app `` = envToken(name): @@ -18,7 +18,7 @@ // _STORAGE_MODE (alias) // _MODE (alias) // API base URL (optional; `/v1` is appended automatically): -// HASNA__API_URL = https://.hasna.xyz +// HASNA__API_URL = https://.your-deployment.example // _API_URL (alias) // API key (bearer / x-api-key): // HASNA__API_KEY -> value from the app-owned vault @@ -28,11 +28,13 @@ // key is present. The mode is `cloud` when either (a) an explicit mode env resolves // to cloud, OR (b) no mode env is set but BOTH the API URL and API key are present — // the fleet env-flip writes exactly those two vars (no STORAGE_MODE), so their joint -// presence is inferred as self_hosted intent. 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. +// presence is inferred as self_hosted intent. When a key is present but no explicit +// URL is set, the base URL falls back to `https://.` where `` +// comes from `HASNA_FLEET_API_DOMAIN` (REQUIRED for a real deployment) or else a +// neutral, non-resolving placeholder — this published package never bakes in a real +// internal hostname. 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. @@ -40,9 +42,20 @@ import { normalizeStorageMode, envToken, type Env } from "../mode.js"; import type { StorageMode } from "../schemas.js"; +/** + * Fleet API domain suffix. This published package never ships a real internal + * hostname: override with `HASNA_FLEET_API_DOMAIN` (REQUIRED in a real + * deployment) or set an explicit `HASNA__API_URL` per app. Absent both, + * this falls back to a neutral placeholder that intentionally does not + * resolve to any service. + */ +export function fleetApiDomain(env: Env = process.env as Env): string { + return env["HASNA_FLEET_API_DOMAIN"]?.trim() || "your-deployment.example"; +} + /** Default cloud host template. `` is the app slug. */ -export function defaultCloudBaseUrl(name: string): string { - return `https://${name}.hasna.xyz`; +export function defaultCloudBaseUrl(name: string, env: Env = process.env as Env): string { + return `https://${name}.${fleetApiDomain(env)}`; } export interface ClientTransportEnvKeys { @@ -192,7 +205,7 @@ export function resolveClientTransport(name: string, env: Env = process.env): Cl }; } - const rawUrl = urlHit?.value ?? defaultCloudBaseUrl(name); + const rawUrl = urlHit?.value ?? defaultCloudBaseUrl(name, env); const apiUrlSource = urlHit ? urlHit.key : "default"; let baseUrl: string; try {