mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
docs.hanzo.ai documented three key types. Cloud mints two. cloud.APIKeyPrefixes is pk- and sk-; hk- and hz- have never existed and an unrecognized spelling is refused at the door, so a reader's first call failed asking for a credential nobody can issue. 149 files carried one. sk- was the subtler half: present, and described as an org-level provider credential when it resolves to the USER. A find-and-replace would have left that confidently wrong, so the pages that made a further claim -- a scope, a third family, a list of recognized prefixes -- were rewritten rather than swapped. Derived, not restated. openapi-doc gains Document.keys, read out of the /v1/keys prose the Go handler writes and zipdoc lifts into the document. gen-key-types renders that as an MDX fragment api-keys.mdx includes, so the table nobody may type is not typed. secretKey(doc) replaces the literal three generators each kept -- which is how three GENERATED pages also came to teach hk-. check-keys then holds every page to it, both directions: a prefix the document does not carry fails the build, and a class api-keys.mdx does not teach fails it too. Renaming pk- in cloud now stops the build in six places instead of publishing a stale page. Also measured against the live API and cluster, and corrected: /v1/keys does mint keys over HTTP (403 unauthenticated, not absent); Tasks is /v1/tasks on the one host, not gRPC elsewhere; the KMS example path 404s; and four KMSSecret manifests could not apply -- two named groups with no controller, and their spec was External Secrets' shape, not the CRD's five fields. Sidebar sits flush left. The docs grid was five columns centring a band, with the sidebar area spanning the leading gutter, so the nav began 176.5px in at 1920 and 496.5px in at 2560 -- inside its own bordered card. Three columns now: rails pinned, page between them. Measured at /docs/api-keys before -> after: 1920 176.5/232/1052/268/176.5 -> 232/1405/268, 2560 496.5/232/1052/268/496.5 -> 232/2045/268, nav at x=0. Phone 390 and tablet 820 unchanged (0/375/0 and 232/573/0, drawer opens, no page overflow); desktop collapse and restore verified.
496 lines
21 KiB
TypeScript
496 lines
21 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { parse as parseYaml } from 'yaml';
|
|
|
|
// THE DOCUMENT.
|
|
//
|
|
// hanzoai/cloud `openapi.yaml` is the one description of the Hanzo API. The
|
|
// reference, the flow pages, the SDKs, the CLI and the MCP tools are all
|
|
// PROJECTIONS of it. Nothing about the API is written twice: if a page states
|
|
// an endpoint's behaviour, that prose came from here.
|
|
//
|
|
// This module is the only reader. It resolves the document into products and
|
|
// operations; every generator downstream consumes those values and never
|
|
// re-parses YAML or re-invents the product rule.
|
|
|
|
/**
|
|
* Where the document is, and the release it is at — read from here, never
|
|
* spelled again.
|
|
*
|
|
* Seven callers each carried their own `openapi-specs/<name>.yaml` literal.
|
|
* When the reference stopped rendering hanzoai/openapi's projection and started
|
|
* rendering cloud's document, one of those seven was edited; the other six kept
|
|
* naming a file that no longer exists, and the build's own guard — "no page may
|
|
* state an endpoint the document does not have" — was among them, so it threw
|
|
* ENOENT instead of checking anything. A path that is a value has one place to
|
|
* change; a path repeated is six chances to miss one.
|
|
*/
|
|
const SPECS = path.join(path.dirname(fileURLToPath(import.meta.url)), '../openapi-specs');
|
|
export const DOCUMENT = path.join(SPECS, 'openapi.yaml');
|
|
|
|
/** The cloud release the document was taken at, from `.spec-lock`. */
|
|
export const release = (): string => {
|
|
try {
|
|
const lock = fs.readFileSync(path.join(SPECS, '.spec-lock'), 'utf8');
|
|
return (lock.match(/^ref=(.+)$/m)?.[1] ?? '').trim().slice(0, 9);
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const;
|
|
export type Method = (typeof METHODS)[number];
|
|
|
|
export interface Param {
|
|
name: string;
|
|
in: string;
|
|
required: boolean;
|
|
description: string;
|
|
schema: any;
|
|
}
|
|
|
|
export interface Body {
|
|
required: boolean;
|
|
contentType: string;
|
|
schema: any;
|
|
}
|
|
|
|
export interface Operation {
|
|
/** Product this page groups the operation under: its tag, else `/v1/<seg>`. */
|
|
product: string;
|
|
/** Full operationId, e.g. `get_v1_tools`. */
|
|
id: string;
|
|
/**
|
|
* The MCP door's tool name for this operation, which is the operationId
|
|
* itself — see the note where it is computed.
|
|
*/
|
|
name: string;
|
|
/** The operation's own tag — a section heading within its product. */
|
|
tag: string;
|
|
method: Method;
|
|
path: string;
|
|
summary: string;
|
|
description: string;
|
|
parameters: Param[];
|
|
body?: Body;
|
|
/** Success response schema, `$ref`s resolved one level. */
|
|
success?: { status: string; description: string; schema?: any };
|
|
deprecated: boolean;
|
|
}
|
|
|
|
export interface Product {
|
|
/** Slug used in URLs and as the operationId prefix. */
|
|
name: string;
|
|
/** Human title — the tag's `x-displayName`, else its name title-cased. */
|
|
title: string;
|
|
/**
|
|
* The owning package's doc synopsis, straight from the document's tag —
|
|
* empty when the document serves the product but declares no tag for it.
|
|
*/
|
|
description: string;
|
|
operations: Operation[];
|
|
}
|
|
|
|
/**
|
|
* One class of API key, as the document states it at `/v1/keys`.
|
|
*
|
|
* The class name is the product's own noun — it is the literal a caller sends
|
|
* as `{"type": …}` — and the prefix is what a key of that class is spelled
|
|
* with. `publishable` is not a judgement made here; it is the class the product
|
|
* NAMES publishable, which is the same word cloud's `PublishablePrefix` is
|
|
* named after.
|
|
*/
|
|
export interface KeyType {
|
|
/** The class as a caller names it: `secret`, `publishable`. */
|
|
name: string;
|
|
/** The spelling a key of this class carries: `sk-`, `pk-`. */
|
|
prefix: string;
|
|
/** What the document says the class is, verbatim from inside the parens. */
|
|
note: string;
|
|
/** True for the class the product calls publishable — the browser-safe one. */
|
|
publishable: boolean;
|
|
}
|
|
|
|
export interface Document {
|
|
title: string;
|
|
version: string;
|
|
description: string;
|
|
server: string;
|
|
securitySchemes: Record<string, any>;
|
|
/** Every class of API key the document says `/v1/keys` mints — see keyTypes. */
|
|
keys: KeyType[];
|
|
/** Products docs.hanzo.ai publishes. Excludes `internal` — see isInternal. */
|
|
products: Product[];
|
|
/** The operator surface, grouped the same way. Never published publicly. */
|
|
internal: Product[];
|
|
/** EVERY operation the document serves, internal ones included. */
|
|
operations: Operation[];
|
|
byId: Map<string, Operation>;
|
|
/** Operations whose product could not be resolved from the document. */
|
|
unresolved: Operation[];
|
|
/** Products with operations but no declared tag — nothing to write an intro from. */
|
|
undeclared: Set<string>;
|
|
raw: any;
|
|
}
|
|
|
|
/**
|
|
* The operator surface: what a public docs site does not publish.
|
|
*
|
|
* The document declares one `admin` tag, and tag, product and `/v1/admin` path
|
|
* prefix select the SAME 86 operations — so there is no heuristic here and no
|
|
* substring matching. The product IS the predicate.
|
|
*
|
|
* One operation outside that product belongs with it, and it is named rather
|
|
* than matched. `GET /v1/commerce/admin/catalog` is the catalogue projection
|
|
* carrying upstream cost and margin, and its own description says PLATFORM
|
|
* admin only: an org-level admin is refused 403 precisely so those economics
|
|
* stay in. The tempting rule — hide any path containing `admin` — would also
|
|
* have hidden `/v1/iam/admin/applications/upsert`, `/v1/iam/admin/provision`
|
|
* and `/v1/iam/admin/users/upsert`, which are the ORG administrator's own API:
|
|
* their prose is written in the second person ("a deployment can declare the
|
|
* applications it needs", "driven by one of your own services"), they are how a
|
|
* customer provisions declaratively, and they are customer-facing by design.
|
|
* Hiding a customer's provisioning API to hide one margin report is a worse
|
|
* error than the one being fixed.
|
|
*
|
|
* The real fix is upstream: an `x-internal` marker on the Go op would carry
|
|
* this decision in the same place the endpoint is written. The document has no
|
|
* such marker today — its only extension is `x-app`, on all 2305 operations —
|
|
* so until it does, the one exception is spelled out here with its reason.
|
|
*/
|
|
const INTERNAL_PRODUCT = 'admin';
|
|
const INTERNAL_IDS = new Set(['get_v1_commerce_admin_catalog']);
|
|
|
|
export const isInternal = (op: Operation): boolean =>
|
|
op.product === INTERNAL_PRODUCT || INTERNAL_IDS.has(op.id);
|
|
|
|
/**
|
|
* The key a server presents: the one class that is NOT publishable.
|
|
*
|
|
* Every generated page that shows a curl or an env var shows this one, and each
|
|
* used to spell it as a literal — three copies of a value the document states,
|
|
* which is how three generated pages came to teach a prefix cloud has never
|
|
* minted. Asking the document is one expression and cannot drift.
|
|
*/
|
|
export const secretKey = (doc: Document): KeyType =>
|
|
doc.keys.find((k) => !k.publishable) ?? doc.keys[0];
|
|
|
|
/**
|
|
* The document with the operator surface taken out, for publishing.
|
|
*
|
|
* `public/openapi/hanzo.yaml` ships in the static export and is what the
|
|
* interactive reference at /reference renders, so filtering the generated pages
|
|
* while copying the document whole would have left all 86 operations one URL
|
|
* away — the pages hidden and the surface still public.
|
|
*/
|
|
export function publicDocument(doc: Document): any {
|
|
const raw = structuredClone(doc.raw);
|
|
const hidden = new Set(doc.internal.flatMap((p) => p.operations).map((o) => `${o.method} ${o.path}`));
|
|
for (const [path, item] of Object.entries<any>(raw.paths ?? {})) {
|
|
for (const method of METHODS) {
|
|
if (item?.[method] && hidden.has(`${method} ${path}`)) delete item[method];
|
|
}
|
|
if (!METHODS.some((m) => item?.[m])) delete raw.paths[path];
|
|
}
|
|
// Only a product with nothing left to publish loses its tag. `commerce` has
|
|
// one internal operation and 40-odd public ones, so its synopsis stays.
|
|
const published = new Set(doc.products.map((p) => p.name));
|
|
const gone = new Set(doc.internal.map((p) => p.name).filter((n) => !published.has(n)));
|
|
if (Array.isArray(raw.tags)) {
|
|
raw.tags = raw.tags.filter((t: any) => !gone.has(squash(String(t?.name ?? ''))));
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
/** Resolve a `$ref` against the document. Non-refs pass through. */
|
|
export function deref(raw: any, node: any, depth = 0): any {
|
|
if (!node || typeof node !== 'object' || depth > 8) return node;
|
|
const ref = node.$ref;
|
|
if (typeof ref !== 'string' || !ref.startsWith('#/')) return node;
|
|
let cur: any = raw;
|
|
for (const seg of ref.slice(2).split('/')) {
|
|
cur = cur?.[seg.replace(/~1/g, '/').replace(/~0/g, '~')];
|
|
if (cur == null) return node;
|
|
}
|
|
return deref(raw, cur, depth + 1);
|
|
}
|
|
|
|
/**
|
|
* The classes of API key, read out of the document rather than restated.
|
|
*
|
|
* `/v1/keys` IS the key resource: one endpoint, and the class is a field on it.
|
|
* Its Go source spells each class beside its prefix — `"secret" (sk-, …)`,
|
|
* `"publishable" (pk-, …)` — zipdoc lifts those doc comments into this
|
|
* document, and this reads them back. So a page saying `sk-` is saying what the
|
|
* handler says, one hop away, instead of what someone remembered.
|
|
*
|
|
* The pairs are read from EVERY description the key resource carries and the
|
|
* readings must AGREE. Two doc comments state them today — the `type` field a
|
|
* caller sends and the `type` field they read back — written in different words
|
|
* at different places in cloud. Requiring agreement means a rename that lands in
|
|
* one of them and not the other stops the build here rather than publishing two
|
|
* answers.
|
|
*
|
|
* Refusing is the whole point of the exercise: an empty or contradictory reading
|
|
* throws, because a docs build that quietly published no key types would be the
|
|
* same failure — a reader who cannot learn which credential to ask for — wearing
|
|
* a green checkmark.
|
|
*/
|
|
const KEY_CLASS = /"?\b([a-z][a-z-]{2,20})"?\s*\((([a-z]{2,6})-)([,)][^)]*)?\)/g;
|
|
|
|
export function keyTypes(raw: any): KeyType[] {
|
|
const item = raw?.paths?.['/v1/keys'];
|
|
if (!item) {
|
|
// Every generated page prints a bearer credential, so none of them can be
|
|
// written without knowing how one is spelled. Stopping here is the honest
|
|
// failure; the alternative is a whole site of curls with a blank key.
|
|
throw new Error('the document does not serve /v1/keys — no page can state how a key is spelled');
|
|
}
|
|
|
|
// Every sentence the key resource carries: its operations, their parameters,
|
|
// and the schemas they send and return. Naming one schema would tie this to a
|
|
// Go type name; walking what the path item reaches ties it to the resource.
|
|
const said: string[] = [];
|
|
const collect = (node: any, depth = 0) => {
|
|
if (!node || typeof node !== 'object' || depth > 6) return;
|
|
const d = deref(raw, node, 0);
|
|
if (typeof d?.description === 'string') said.push(d.description);
|
|
for (const v of Object.values(d)) if (v && typeof v === 'object') collect(v, depth + 1);
|
|
};
|
|
collect(item);
|
|
|
|
// class -> prefix, and every reading of a class must give the same prefix.
|
|
const seen = new Map<string, KeyType>();
|
|
for (const text of said) {
|
|
for (const m of text.replace(/\s+/g, ' ').matchAll(KEY_CLASS)) {
|
|
const name = m[1];
|
|
const prefix = `${m[3]}-`;
|
|
const note = (m[4] ?? '').replace(/^[,)]\s*/, '').trim();
|
|
const prior = seen.get(name);
|
|
if (prior && prior.prefix !== prefix) {
|
|
throw new Error(
|
|
`the document spells the ${name} key both ${prior.prefix} and ${prefix} — cloud says it two ways`,
|
|
);
|
|
}
|
|
// Keep the longest note: the same pair is stated tersely in one place and
|
|
// with its reason in another, and the reason is the half worth publishing.
|
|
if (!prior || note.length > prior.note.length) {
|
|
seen.set(name, { name, prefix, note, publishable: name === 'publishable' });
|
|
}
|
|
}
|
|
}
|
|
|
|
const found = [...seen.values()];
|
|
if (found.length < 2) {
|
|
throw new Error(
|
|
`/v1/keys names ${found.length} key class(es) — the document stopped stating them, so no page can`,
|
|
);
|
|
}
|
|
if (!found.some((k) => k.publishable)) {
|
|
throw new Error('no key class is named publishable — nothing may be shipped in a browser');
|
|
}
|
|
// Publishable last: a reader meets the default before the exception.
|
|
return found.sort((a, b) => Number(a.publishable) - Number(b.publishable));
|
|
}
|
|
|
|
/** `agents` -> `Agents`; `Roles & Permissions` and `MFA` pass through. */
|
|
const titleCase = (name: string): string =>
|
|
/^[a-z]/.test(name) ? name[0].toUpperCase() + name.slice(1) : name;
|
|
|
|
const firstSentence = (s: string): string => {
|
|
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
const m = t.match(/^(.{0,220}?[.!?])(\s|$)/);
|
|
return (m ? m[1] : t.slice(0, 220)).trim();
|
|
};
|
|
|
|
/**
|
|
* The product rule, in exactly one place.
|
|
*
|
|
* Every operation in the document is `<product>_<name>`, and every prefix is a
|
|
* top-level tag carrying that product's synopsis. Where an operationId is
|
|
* malformed we fall back to the `/v1/<product>` path segment rather than
|
|
* inventing a product — and if neither resolves, the operation is reported as
|
|
* unresolved instead of being silently dropped.
|
|
*
|
|
* AN HTTP METHOD IS NEVER A PRODUCT, and saying so is what this rule was missing.
|
|
* A route outside `/v1/<product>` — `/.well-known/jwks`, `/{org}/{repo}/git-upload-pack`,
|
|
* `/_/commerce/healthz` — is idded `get_well_known_jwks`, so its prefix is the
|
|
* word `get`. The final fallback returned that prefix whether or not it named
|
|
* anything, which minted six products called Get, Post, Put, Patch, Delete and
|
|
* Options, each with a synopsis-less page and dozens of unrelated routes filed
|
|
* under the verb they happen to use. The comment above already said the rule
|
|
* does not invent a product; the code did, and only for the operations no tag
|
|
* covers, which is exactly where nobody was looking.
|
|
*
|
|
* Refusing them sends those routes to `unresolved`, where the generator prints
|
|
* them — one honest line naming what hanzoai/cloud must tag, instead of six
|
|
* pages that look like products and are not.
|
|
*/
|
|
function resolveProduct(id: string, path: string, known: (s: string) => boolean): string | null {
|
|
const prefix = id.includes('_') ? id.slice(0, id.indexOf('_')) : '';
|
|
if (prefix && known(prefix)) return prefix;
|
|
const seg = path.split('/').filter(Boolean);
|
|
if (seg[0] === 'v1' && seg[1] && known(seg[1])) return seg[1];
|
|
if (!prefix || (METHODS as readonly string[]).includes(prefix)) return null;
|
|
return prefix;
|
|
}
|
|
|
|
/**
|
|
* Product slugs are lowercase (`webhooks`, `pubsub`) because they come from an
|
|
* operationId prefix or a path segment; tag names are prose-cased (`Webhooks`,
|
|
* `Pub/Sub`). Matching them exactly stranded 16 packages' synopses on tags no
|
|
* page was keyed by. Compare on the squashed form so the prose finds its page,
|
|
* while the slug — and therefore the URL — stays the lowercase one.
|
|
*/
|
|
const squash = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
|
|
export function loadDocument(file: string): Document {
|
|
const raw = parseYaml(fs.readFileSync(file, 'utf8'));
|
|
if (!raw?.paths) throw new Error(`${file}: not an OpenAPI document (no paths)`);
|
|
|
|
/** Products the document serves but declares no tag for (so: no synopsis). */
|
|
const undeclared = new Set<string>();
|
|
|
|
const tags: any[] = Array.isArray(raw.tags) ? raw.tags : [];
|
|
// TWO indexes, deliberately not one. Resolution matches tag names EXACTLY;
|
|
// squash-matching there would let the woven document's `cloud_` prefix claim
|
|
// a `Cloud` tag and swallow every path-derived product with it (132 products
|
|
// collapsed to 29). Squashing is only for finding a slug's prose.
|
|
const tagByName = new Map<string, any>(tags.map((t) => [t.name, t]));
|
|
const tagBySquash = new Map<string, any>();
|
|
for (const t of tags) if (!tagBySquash.has(squash(t.name))) tagBySquash.set(squash(t.name), t);
|
|
const known = (s: string) => tagByName.has(s);
|
|
|
|
// Products are created on demand, keyed by SLUG. Pre-seeding one per tag
|
|
// would mint a second page for every tag whose name only squash-matches a
|
|
// slug (`Webhooks` beside `webhooks`).
|
|
const byName = new Map<string, Product>();
|
|
const product = (slug: string): Product => {
|
|
let p = byName.get(slug);
|
|
if (p) return p;
|
|
const t = tagByName.get(slug) ?? tagBySquash.get(squash(slug));
|
|
if (!t) undeclared.add(slug);
|
|
p = {
|
|
name: slug,
|
|
// The tag NAME is the product's identity; its description is a synopsis,
|
|
// not a heading ("Package agents is autonomous agents for your org: …").
|
|
// Using the description's first line as a title turned whole sentences
|
|
// into page titles once the document gained product-voice prose.
|
|
title: String(t?.['x-displayName'] ?? '').trim() || titleCase(t?.name ?? slug),
|
|
description: String(t?.description ?? '').trim(),
|
|
operations: [],
|
|
};
|
|
byName.set(slug, p);
|
|
return p;
|
|
};
|
|
|
|
const operations: Operation[] = [];
|
|
const unresolved: Operation[] = [];
|
|
const byId = new Map<string, Operation>();
|
|
|
|
for (const [path, item] of Object.entries<any>(raw.paths)) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const shared = Array.isArray(item.parameters) ? item.parameters : [];
|
|
for (const method of METHODS) {
|
|
const op = item[method];
|
|
if (!op || typeof op !== 'object') continue;
|
|
|
|
const id = String(op.operationId ?? '');
|
|
const product_ = resolveProduct(id, path, known);
|
|
|
|
const parameters: Param[] = [...shared, ...(op.parameters ?? [])]
|
|
.map((p) => deref(raw, p))
|
|
.filter((p) => p?.name)
|
|
.map((p) => ({
|
|
name: p.name,
|
|
in: p.in ?? 'query',
|
|
required: Boolean(p.required),
|
|
description: String(p.description ?? '').trim(),
|
|
schema: deref(raw, p.schema),
|
|
}));
|
|
|
|
const rb = deref(raw, op.requestBody);
|
|
const contentType = rb?.content ? Object.keys(rb.content)[0] : undefined;
|
|
const body: Body | undefined = contentType
|
|
? {
|
|
required: Boolean(rb.required),
|
|
contentType,
|
|
schema: deref(raw, rb.content[contentType]?.schema),
|
|
}
|
|
: undefined;
|
|
|
|
const okStatus = Object.keys(op.responses ?? {}).find((s) => s.startsWith('2'));
|
|
const okRaw = okStatus ? deref(raw, op.responses[okStatus]) : undefined;
|
|
const okCt = okRaw?.content ? Object.keys(okRaw.content)[0] : undefined;
|
|
|
|
const resolved: Operation = {
|
|
product: product_ ?? '',
|
|
id,
|
|
// The door's tool name IS the operationId. It was the id minus its own
|
|
// first segment while hanzoai/openapi prefixed every id with the spec it
|
|
// came from (`cloud_get_v1_tools`); one document needs no such
|
|
// disambiguator and that prefix is gone, so the ids are bare. Measured
|
|
// against the door's own tools/list: 802 of 833 names are the
|
|
// operationId exactly, where stripping a segment resolves 89.
|
|
name: id,
|
|
tag: (Array.isArray(op.tags) && op.tags[0]) || product_ || 'General',
|
|
method,
|
|
path,
|
|
summary: String(op.summary ?? '').replace(/\s+/g, ' ').trim(),
|
|
description: String(op.description ?? '').trim(),
|
|
parameters,
|
|
body,
|
|
success: okStatus
|
|
? {
|
|
status: okStatus,
|
|
description: String(okRaw?.description ?? '').trim(),
|
|
schema: okCt ? deref(raw, okRaw.content[okCt]?.schema) : undefined,
|
|
}
|
|
: undefined,
|
|
deprecated: Boolean(op.deprecated),
|
|
};
|
|
|
|
operations.push(resolved);
|
|
if (id) byId.set(id, resolved);
|
|
|
|
if (product_) product(product_).operations.push(resolved);
|
|
else unresolved.push(resolved);
|
|
}
|
|
}
|
|
|
|
const order = (o: Operation) => `${o.tag} |