mirror of
https://github.com/hanzoai/js-sdk.git
synced 2026-10-30 15:02:31 +00:00
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]>
146 lines
5.0 KiB
JavaScript
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);
|
|
});
|