Skip to content
Open
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
11 changes: 8 additions & 3 deletions src/client/conformance.live.test.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand All @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions src/client/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("HasnaStorageClient CRUD mapping", () => {

function client(handler: Parameters<typeof makeFetch>[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 };
}

Expand All @@ -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");
});
Expand Down Expand Up @@ -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");
});

Expand All @@ -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");
});
});

Expand Down
24 changes: 12 additions & 12 deletions src/client/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -31,31 +31,31 @@ 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");
});

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");
Expand All @@ -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");
Expand All @@ -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");
});
});

Expand Down
33 changes: 23 additions & 10 deletions src/client/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<API_URL>/v1`, default `https://<app>.hasna.xyz/v1`)
// app's cloud HTTP API (`<API_URL>/v1`, default `https://<app>.<HASNA_FLEET_API_DOMAIN>/v1`)
// with the API key, or fall through to the local store.
//
// THE CLIENT-FLIP CONTRACT (env vars). For app `<NAME>` = envToken(name):
Expand All @@ -18,7 +18,7 @@
// <NAME>_STORAGE_MODE (alias)
// <NAME>_MODE (alias)
// API base URL (optional; `/v1` is appended automatically):
// HASNA_<NAME>_API_URL = https://<app>.hasna.xyz
// HASNA_<NAME>_API_URL = https://<app>.your-deployment.example
// <NAME>_API_URL (alias)
// API key (bearer / x-api-key):
// HASNA_<NAME>_API_KEY -> value from the app-owned vault
Expand All @@ -28,21 +28,34 @@
// 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://<app>.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://<app>.<domain>` where `<domain>`
// 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.

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_<NAME>_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. `<app>` 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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading