mirror of
https://github.com/hanzoai/js-sdk.git
synced 2026-10-30 15:02:31 +00:00
Every Hanzo SDK offers the same six capabilities in the same six words. Go and
Python hang them off a client that already existed; TypeScript had none, so it
gains one — `Client`, holding an IAM client id and secret and minting its own
access token (client_secret_basic, RFC 8707 `resource`, held until 60s before
expiry, re-minted once on a 401). It takes no bearer and reads no
HANZO_API_KEY: a credential the SDK is handed is one nobody rotates and one
that says nothing about who is calling.
A refused budget and a refused policy are ANSWERS. `Answer<T>` is a union
discriminated on a literal tag — ok{value} | denied{code, reason, product?,
cures[]} | held{id, clause, reason}, all three carrying the x-request-id that
joins a call to its audit row — so `a.value` does not typecheck until the union
is narrowed and a switch that forgets an arm does not compile. The mapping from
HTTP to arm lives in one function; no capability can grow a rule of its own.
Exceptions stay for outcomes with no decision in them.
The layer is hand-written because the routes it needs are the ones the document
states as an address and not as a shape: /v1/authz/check declares neither a
request body nor a response, /v1/billing/balance and /usage declare no
response. Those three are modelled from what the served handlers read and
write, measured. It sits in src/ beside the generated tree because .generated
is the driver's manifest of what it owns — a file it does not name is this
repo's own and a regeneration never touches it — and the package entry moves to
src/hanzo.ts, which re-exports the generated src/index.ts and adds the client
beside it. Capability types are namespaced (budget.Allowance, search.Hit,
audit.Event, graph.Fact) because thirteen of the contract's names already
belong to generated models.
policy.check sends {subject, verb, path}, which is what
hanzoai/[email protected] serve/use.go reads, not the {sub, obj, act} the route's
own openapi.Describe prose states. The caller still says (sub, act, obj). The
prose and the handler are one fact and should be made to agree upstream.
Gates: `npm run wire` runs the six against a scripted fetch, asserting the
exact request each sends and decoding a realistic answer including both of
cloud's 402 bodies, a 202 hold and a 403 that is not a refusal. examples/six
drives all six in one pass against the live API and is type-checked with the
rest. The agent and tools flows are repaired for the tag names the current
document singularized (AgentsApi -> AgentApi, getTools -> getTool).
Co-authored-by: Hanzo Dev <[email protected]>
65 lines
2.2 KiB
JavaScript
65 lines
2.2 KiB
JavaScript
// A fetch that answers from a script and records what it was asked.
|
|
//
|
|
// The six are hand-written HTTP, so what has to be proved about them is what
|
|
// goes out and what is made of what comes back. Nothing here mocks the SDK: the
|
|
// client under test is the one the build publishes, reached the way a consumer
|
|
// reaches it, and only the network is replaced.
|
|
|
|
import { createRequire } from 'node:module';
|
|
|
|
export const hanzoai = createRequire(import.meta.url)('hanzoai');
|
|
|
|
/** A minted token, so a test that is not about auth does not have to script one. */
|
|
export const TOKEN = { access_token: 'tok-1', expires_in: 3600 };
|
|
|
|
/**
|
|
* Install a fetch that answers each call with the next entry of `script`.
|
|
*
|
|
* An entry is `{status, body, headers}`; `body` is serialized as JSON unless it
|
|
* is already a string. Returns the array every request is recorded into, and a
|
|
* `restore` to put the real fetch back.
|
|
*/
|
|
export function wire(script) {
|
|
const sent = [];
|
|
const real = globalThis.fetch;
|
|
let n = 0;
|
|
globalThis.fetch = async (url, init = {}) => {
|
|
const step = script[n++];
|
|
if (!step) throw new Error(`wire: no answer scripted for ${init.method ?? 'GET'} ${url}`);
|
|
sent.push({
|
|
url: String(url),
|
|
method: init.method ?? 'GET',
|
|
headers: init.headers ?? {},
|
|
body: init.body,
|
|
});
|
|
const body = typeof step.body === 'string' || step.body === undefined
|
|
? step.body ?? ''
|
|
: JSON.stringify(step.body);
|
|
return new Response(body, {
|
|
status: step.status ?? 200,
|
|
headers: { 'content-type': 'application/json', ...(step.headers ?? {}) },
|
|
});
|
|
};
|
|
sent.restore = () => {
|
|
globalThis.fetch = real;
|
|
};
|
|
return sent;
|
|
}
|
|
|
|
/** A client whose whole configuration is the test's, never the environment's. */
|
|
export const client = (options = {}) =>
|
|
new hanzoai.Client({
|
|
id: 'cid',
|
|
secret: 'csec',
|
|
base: 'https://api.hanzo.ai',
|
|
issuer: 'https://hanzo.id',
|
|
resource: 'https://api.hanzo.ai',
|
|
...options,
|
|
});
|
|
|
|
/** The JSON a recorded request carried. */
|
|
export const json = (request) => JSON.parse(request.body);
|
|
|
|
/** The query terms a recorded request carried. */
|
|
export const terms = (request) => Object.fromEntries(new URL(request.url).searchParams);
|