Files
js-sdk/test/token.test.mjs
zeekayandHanzo Dev a2874e29b0 fix(six): one word, one body, one shape across the three SDKs
Thirteen places where this SDK and the other two answered the same question
differently. Every one is now what the served handler reads, or what HIP-0044
says where the handler is silent.

Where this SDK was already right, the other two moved: `put` lets the store
decide whether a document exists, `Filter.order` and `links(project)` are real
capabilities the routes serve, `Import` carries what the handler answers, and
`Fact` keeps the server's own three. Where it was not:

- search speaks kinds, not doctype addresses. `/v1/search` matches `doctypes`
  against kb.page, kb.memory and kb.source EXACTLY and silently drops anything
  else (knowledge/subsystem.go, sanitizeDocTypes), so `{kinds: ['page']}` — the
  word the contract uses — narrowed the query to nothing at all, and `Hit.kind`
  came back in a different vocabulary than `Doc.kind`. The mapping is `kb.ts`'s
  `doctype` and `kind`, applied at the boundary in both directions; a doctype
  from another corpus passes through untouched.
- A write body is field data and nothing else. The framework validates against
  DocType.Fields and drops what it does not declare, and `name` is not a field
  of any of the three: a page is named from `slug`, a memory and a source are
  hash-named. Every member of Doc but `kind` is now optional, so a caller writes
  what it has instead of passing empty strings for `project` and `url` — which
  were going out on the wire as empty fields the store dropped anyway.
- import(format, data, project). Two values and an option are not an aggregate,
  so kb.Source is gone — which also leaves one Source in this package, graph's.
- policy_denied and entitlement_required do not exist. Neither string appears
  anywhere in hanzoai/cloud, so a 403 read as denied on one of them would hand
  an unauthenticated caller a cure for a refusal nobody made. The refusal set is
  the two codes cloud emits.
- audit.list reports the server's own count. A per-page count under a `request`
  filter is not "what the filter matched across every page"; the caller now gets
  the count the server matched for the filter the server applied.
- The fourth outcome is Fault. RFC 9457's problem detail is a body a 402 carries
  and this client reads as a `denied` arm, so naming the outcome with no
  decision in it `Problem` gave one word two meanings. `Error` is taken — by the
  global here, by Go's builtin, by Python's hierarchy. `Fault` collides with
  nothing in any of the three.
- Money counts minor units, not cents: hundredths for USD, whole yen for JPY,
  thousandths for KWD.
- Balance.reserved, not held. The wire says `holds`; `held` is an arm.
- Vocabulary.rules, because it is a list. The wire member stays `rule`.
- A client with no credential raises a Fault carrying no code. `no_credential`
  is not a code any server sends, and a code is what a caller branches on.

HIP-0044 is amended where the code was right and the document was not.

tsc, tsc -p tsconfig.esm.json, smoke, 30 node tests and the examples project all
clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 16:27:05 -07:00

146 lines
5.0 KiB
JavaScript

// The credential: minted from IAM, held until it nearly expires, never accepted
// from the caller.
import assert from 'node:assert/strict';
import test from 'node:test';
import { TOKEN, client, hanzoai, terms, wire } from './wire.mjs';
const allowance = { status: 200, body: { plan: 'pro', limit: 20, used: 3, window: 'day' } };
test('mints with client credentials and carries the token', async (t) => {
const sent = wire([{ status: 200, body: TOKEN }, allowance]);
t.after(sent.restore);
await client().budget.left();
const [mint, call] = sent;
assert.equal(mint.url, 'https://hanzo.id/v1/iam/oauth/token');
assert.equal(mint.method, 'POST');
// client_secret_basic, not the id and secret in the form.
assert.equal(mint.headers.authorization, 'Basic ' + Buffer.from('cid:csec').toString('base64'));
assert.equal(mint.headers['content-type'], 'application/x-www-form-urlencoded');
assert.equal(
mint.body,
// RFC 8707: the token names what it may be spent at.
'grant_type=client_credentials&resource=https%3A%2F%2Fapi.hanzo.ai',
);
assert.equal(call.url, 'https://api.hanzo.ai/v1/allowance');
assert.equal(call.headers.authorization, 'Bearer tok-1');
});
test('holds the token rather than minting per call', async (t) => {
const sent = wire([{ status: 200, body: TOKEN }, allowance, allowance]);
t.after(sent.restore);
const c = client();
await c.budget.left();
await c.budget.left();
assert.equal(sent.length, 3);
assert.equal(sent.filter((r) => r.url.includes('/oauth/token')).length, 1);
});
test('a 401 re-mints once and replays; a second 401 is the server saying no', async (t) => {
const sent = wire([
{ status: 200, body: TOKEN },
{ status: 401, body: { title: 'Unauthorized', detail: 'invalid_token', code: 'unauthorized' } },
{ status: 200, body: { ...TOKEN, access_token: 'tok-2' } },
allowance,
]);
t.after(sent.restore);
const left = await client().budget.left();
assert.equal(left.plan, 'pro');
assert.equal(sent.at(-1).headers.authorization, 'Bearer tok-2');
const again = wire([
{ status: 200, body: TOKEN },
{ status: 401, body: { detail: 'invalid_token', code: 'unauthorized' } },
{ status: 200, body: TOKEN },
{ status: 401, body: { detail: 'invalid_token', code: 'unauthorized' } },
]);
t.after(again.restore);
await assert.rejects(() => client().budget.left(), (err) => {
assert.ok(err instanceof hanzoai.answer.Fault);
assert.equal(err.status, 401);
return true;
});
});
test('as() presents the subject token and leaves the operator credential behind', async (t) => {
const sent = wire([
{ status: 200, body: TOKEN },
{ status: 200, body: { accessToken: 'tok-usr7', expiresIn: 900 } },
allowance,
]);
t.after(sent.restore);
await client().as('usr_7').budget.left();
const [, grant, call] = sent;
assert.equal(grant.method, 'POST');
assert.equal(new URL(grant.url).pathname, '/v1/iam/tokens/issue');
assert.deepEqual(terms(grant), { id: 'usr_7' });
// IAM reads the act grant off the operator's own token.
assert.equal(grant.headers.authorization, 'Bearer tok-1');
assert.equal(grant.body, undefined);
assert.equal(call.headers.authorization, 'Bearer tok-usr7');
});
test('every option falls back to an environment variable', async (t) => {
const names = [
'HANZO_CLIENT_ID',
'HANZO_CLIENT_SECRET',
'HANZO_BASE_URL',
'HANZO_ISSUER_URL',
'HANZO_RESOURCE',
];
const held = Object.fromEntries(names.map((n) => [n, process.env[n]]));
t.after(() => {
for (const [n, v] of Object.entries(held)) {
if (v === undefined) delete process.env[n];
else process.env[n] = v;
}
});
for (const n of names) delete process.env[n];
const bare = new hanzoai.Client();
assert.equal(bare.base, 'https://api.hanzo.ai');
assert.equal(bare.issuer, 'https://hanzo.id');
// The audience defaults to the endpoint the token is spent at.
assert.equal(bare.resource, 'https://api.hanzo.ai');
process.env.HANZO_BASE_URL = 'https://cloud.example/';
process.env.HANZO_ISSUER_URL = 'https://id.example';
const moved = new hanzoai.Client();
assert.equal(moved.base, 'https://cloud.example');
assert.equal(moved.issuer, 'https://id.example');
assert.equal(moved.resource, 'https://cloud.example');
});
test('no credential builds, and fails at the first call rather than at construction', async (t) => {
const sent = wire([]);
t.after(sent.restore);
// Nothing goes to IAM, because there is nothing to exchange, and nothing goes
// unsigned to the gateway — an unsigned call comes back a bare 403, which
// reads as a refusal of the caller rather than the absence of one. The code
// is empty because no answer named one.
const c = new hanzoai.Client({ id: '', secret: '' });
await assert.rejects(
() => c.budget.left(),
(err) => {
assert.ok(err instanceof hanzoai.answer.Fault);
assert.equal(err.status, 0);
assert.equal(err.code, '');
assert.match(err.message, /HANZO_CLIENT_ID/);
return true;
},
);
assert.equal(sent.length, 0);
});