From 5fe90f9a2edafb7c685622ec6bdf454ca1595f2e Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 7 Jul 2026 13:01:53 +0300 Subject: [PATCH] fix(client): infer cloud from API_URL+API_KEY when no STORAGE_MODE (fleet-flip) The fleet env-flip writes exactly HASNA__API_URL + HASNA__API_KEY per app and NO explicit STORAGE_MODE (machines FLEET-FLIP.md). resolveClientTransport only switched to cloud on an explicit mode env, so every app using the shared resolver (accounts, attachments, domains, files, logs, sandboxes, secrets, sessions, shortlinks, todos) stayed on local and read its on-box store after a flip. Infer mode=cloud from the joint presence of url+key, mirroring the vendored projects/recordings client fix. Revert removes both vars -> back to local. Bump 0.5.0 -> 0.5.1. --- package.json | 2 +- src/client/transport.test.ts | 21 +++++++++++++++++++++ src/client/transport.ts | 20 ++++++++++++++++---- src/schemas.ts | 2 +- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3893c71..9ace844 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/contracts", - "version": "0.5.0", + "version": "0.5.1", "description": "Shared schemas and validators for Hasna open-source agent infrastructure contracts.", "type": "module", "license": "Apache-2.0", diff --git a/src/client/transport.test.ts b/src/client/transport.test.ts index 82f02c8..9a49536 100644 --- a/src/client/transport.test.ts +++ b/src/client/transport.test.ts @@ -41,6 +41,27 @@ describe("resolveClientTransport — the client-flip contract", () => { 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_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.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", + }); + expect(r.transport).toBe("local"); + expect(r.mode).toBe("local"); + expect(r.misconfigured).toBe(false); + }); + test("self_hosted alias normalizes to cloud and defaults the host", () => { const r = resolveClientTransport("knowledge", { HASNA_KNOWLEDGE_MODE: "self_hosted", diff --git a/src/client/transport.ts b/src/client/transport.ts index aa32830..ac80185 100644 --- a/src/client/transport.ts +++ b/src/client/transport.ts @@ -25,10 +25,14 @@ // _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. +// 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. // // SAFETY: this module never returns, logs, or embeds the API key value. Callers // receive only presence flags and env-key names. @@ -143,6 +147,14 @@ export function resolveClientTransport(name: string, env: Env = process.env): Cl `Deprecated mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Prefer ${keys.modeKeys[0]}=cloud.`, ); } + } else if (urlHit && keyHit) { + // Flip signal: the fleet env-flip writes EXACTLY HASNA__API_URL + + // HASNA__API_KEY per app and NO explicit STORAGE_MODE (see machines + // FLEET-FLIP.md). Their joint presence IS the self_hosted intent, so infer + // `cloud`. Revert removes both vars, so the client falls back to local. Without + // this, a flipped client with only url+key silently kept reading its local store. + mode = "cloud"; + modeSource = `${urlHit.key}+${keyHit.key}`; } // Local mode: never route to the network, regardless of URL/key presence. diff --git a/src/schemas.ts b/src/schemas.ts index 2b0a493..6c29d0b 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.5.0"; +export const CONTRACTS_PACKAGE_VERSION = "0.5.1"; export const SCHEMA_IDS = { actorRef: "hasna.actor_ref.v1",