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]>
142 lines
5.5 KiB
TypeScript
142 lines
5.5 KiB
TypeScript
// six — budget, policy, audit, search, kb and graph in one pass.
|
|
//
|
|
// What a real caller does before and around a piece of work: see what it may
|
|
// spend, ask whether it may act, look in the corpus, write what it learned into
|
|
// the graph, and read back the trail of what it just did. One client, one
|
|
// credential, one answer type.
|
|
//
|
|
// export HANZO_CLIENT_ID=... HANZO_CLIENT_SECRET=...
|
|
// npm run build && npx tsx examples/six/index.ts
|
|
//
|
|
// Unlike the other flows this one needs a credential for every call: all six
|
|
// capabilities are org-scoped, and there is no allowance, no policy set and no
|
|
// trail without someone to hold them.
|
|
|
|
import { Client, type answer } from 'hanzoai';
|
|
|
|
/** The entity this run writes about, so a second run resolves against the first. */
|
|
const ENTITY = 'example:six';
|
|
|
|
async function main() {
|
|
const c = new Client(); // id and secret from the environment
|
|
|
|
// budget — a count of free calls and a sum of prepaid credit, which never
|
|
// stand in for each other.
|
|
const left = await c.budget.left();
|
|
const wallet = await c.budget.balance();
|
|
console.log(
|
|
`plan ${left.plan || '(none)'}: ${left.left ?? 'unbounded'} calls left this ${left.window || 'period'}`,
|
|
);
|
|
console.log(
|
|
`wallet ${wallet.account}: ${wallet.available.minor} available, ${wallet.reserved.minor} reserved, ` +
|
|
`in ${wallet.available.currency} minor units`,
|
|
);
|
|
|
|
// policy — asking whether you may is a question with an answer; being stopped
|
|
// mid-call is what produces a denied arm. The wallet echoed the ledger key
|
|
// this org bills from, and an org-rooted policy path is rooted at that name,
|
|
// so a browser never has to guess its own org by decoding its own token.
|
|
const may = await c.policy.check(wallet.account, 'write', `${wallet.account}/graph`);
|
|
console.log(
|
|
`policy: ${may.sub} may${may.allow ? '' : ' not'} ${may.act} ${may.obj}` +
|
|
(may.reason ? ` — ${may.reason}` : ''),
|
|
);
|
|
|
|
// What the plan opens, which is a different authority from what policy allows.
|
|
const me = await c.budget.plan();
|
|
console.log(`tier ${me.tier || '(free)'}, apps ${Object.keys(me.apps).length}`);
|
|
|
|
// search — one ranked set over the whole corpus. `partial` is the field that
|
|
// says whether a leg was down; ignoring it reads a truncated corpus as a
|
|
// complete one.
|
|
const found = await c.search.find('incident runbook', { limit: 5 });
|
|
const hits = read(found, 'search.find');
|
|
if (hits) {
|
|
console.log(`search ${found.request}: ${hits.items.length} hits in ${hits.took}ms${hits.partial ? ' (partial)' : ''}`);
|
|
for (const hit of hits.items.slice(0, 3)) console.log(` ${hit.score.toFixed(3)} ${hit.kind} ${hit.title}`);
|
|
for (const leg of hits.backends) if (leg.status !== 'ok') console.log(` ${leg.name}: ${leg.status} ${leg.error}`);
|
|
}
|
|
|
|
// kb — the corpus you write. `search` is how you read it.
|
|
const pages = await c.kb.list('page', { limit: 3 });
|
|
console.log(`kb: ${pages.total} pages`);
|
|
|
|
// graph — assertions with provenance and time. Nothing overwrites anything.
|
|
// The check above was a question; this write is where the authority is, so a
|
|
// refusal here comes back as an arm however the check answered.
|
|
const at = new Date();
|
|
const wrote = await c.graph.assert([
|
|
{
|
|
entity: ENTITY,
|
|
relation: 'ran',
|
|
value: at.toISOString(),
|
|
at,
|
|
source: 'examples/six',
|
|
evidence: 'js-sdk',
|
|
confidence: 1,
|
|
},
|
|
{
|
|
entity: ENTITY,
|
|
relation: 'reads',
|
|
value: hits?.items[0]?.id ?? 'nothing',
|
|
names: true,
|
|
at,
|
|
source: 'examples/six',
|
|
confidence: 0.5,
|
|
},
|
|
]);
|
|
const recorded = read(wrote, 'graph.assert');
|
|
if (recorded) {
|
|
console.log(`graph ${wrote.request}: ${recorded.recorded} recorded, ${recorded.duplicate} duplicate, ${recorded.refused} refused`);
|
|
for (const why of recorded.reasons) console.log(` refused: ${why}`);
|
|
}
|
|
|
|
const settled = await c.graph.resolve(ENTITY, 'ran');
|
|
console.log(
|
|
settled.known
|
|
? `graph: ${ENTITY} ran ${settled.winner?.value} (${settled.conflicts.length} other claims${settled.contested ? ', contested' : ''})`
|
|
: `graph: nothing is asserted about ${ENTITY} yet`,
|
|
);
|
|
|
|
// audit — the trail of what just happened. `request` is the same word Answer
|
|
// carries, and the join between a call and its row.
|
|
let rows = 0;
|
|
for await (const event of c.audit.all({ request: wrote.request, size: 100 })) {
|
|
console.log(`audit: ${event.actor} ${event.action} ${event.result} ${event.status}`);
|
|
if (++rows === 5) break;
|
|
}
|
|
if (rows === 0) console.log(`audit: no row yet for ${wrote.request}`);
|
|
}
|
|
|
|
/**
|
|
* Read an answer, or say why there is nothing to read.
|
|
*
|
|
* Every arm is named. The `never` in the default branch is what makes a
|
|
* forgotten arm a compile error rather than a silent fall-through: add a fourth
|
|
* status to Answer and this stops building.
|
|
*/
|
|
function read<T>(a: answer.Answer<T>, what: string): T | undefined {
|
|
switch (a.status) {
|
|
case 'ok':
|
|
return a.value;
|
|
case 'denied':
|
|
console.log(`${what} denied: ${a.code} — ${a.reason}`);
|
|
for (const cure of a.cures) console.log(` ${cure.kind}: ${cure.url}`);
|
|
return undefined;
|
|
case 'held':
|
|
console.log(`${what} held on ${a.clause}: approval ${a.id}`);
|
|
return undefined;
|
|
default: {
|
|
const unreached: never = a;
|
|
return unreached;
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((err: unknown) => {
|
|
// A Fault says which call, which status and which request id; anything else
|
|
// is this program's own bug.
|
|
console.error(err instanceof Error ? err.message : err);
|
|
process.exit(1);
|
|
});
|