The delegation-chain algebra for hierarchical API keys — a child can never out-scope or out-spend an ancestor, and a parent's cap bounds its whole subtree's total spend.
Part of Suluk — one typed OpenAPI v4 contract projecting into every full-stack layer.
CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.
bun add @suluk/keys
When an API key can mint child keys — and each key carries its own scopes, credit cap, rate share,
and expiry — you need one place that decides what a caller may actually do. @suluk/keys is that
place: the pure, portable algebra (extracted verbatim from a real app, C046) for a materialized-
path key tree. The app builds a ChainNode[] (a caller plus its ancestors, root → self) and the
per-path SpendRow[] from its own store — the DB query is the seam — then calls these functions
so the money/abuse rules can never drift:
effectiveCaps — the caller's real grant, walking up the chain: scopes = the intersection
of every node's grant; credit cap / rate share / expiry = the min (soonest) declared. A child
can't out-scope or out-spend an ancestor.pooledHeadroom — a node's cap bounds its whole subtree's total spend. This is the
abuse-proof property: a parent capped at 50 can't mint children that each spend 50, because every
child's spend lands in the parent's subtree.expiredAncestor / disabledAncestor — the read-time revocation cascade: a child dies the
moment any ancestor expires or is disabled.clampChildGrant — clamp a freshly-minted child to the parent's effective grant.Plus the materialized-path utilities (inSubtree, childPath, …) and the scope/metadata parsers.
import { effectiveCaps, pooledHeadroom, clampChildGrant, type ChainNode } from "@suluk/keys";
// A caller's chain: root → parent → self (each with its OWN grant).
const chain: ChainNode[] = [
{ keyId: "root", path: "root", scopes: ["credits:read", "ask"], ownCreditLimit: 100, ownRateSharePct: null, ownExpiresAt: null },
{ keyId: "child", path: "root/child", scopes: ["ask"], ownCreditLimit: 30, ownRateSharePct: null, ownExpiresAt: null },
];
const caps = effectiveCaps(chain);
caps.scopes; // ["ask"] — the intersection
caps.creditLimit; // 30 — the min declared cap
// Pooled headroom: the BINDING constraint a charge must clear across the subtree.
const headroom = pooledHeadroom(chain, [{ path: "root/child", spent: 10 }]);
headroom; // { limit: 30, spent: 10, remaining: 20 } — or null when nothing is capped
import { insertLineage, chainHeadroom, subtreeOf, revokeKeyTree } from "@suluk/keys";
// Mint a child under a parent (materialized path is computed for you).
await insertLineage(db, { keyId: "child", parentKeyId: "root", userId: "user_42", parentPath: "root" });
// The pooled headroom, joined against the @suluk/credits ledger in one grouped query.
const room = await chainHeadroom(db, chain); // Headroom | null
// Cascade revoke — the whole subtree (self + every descendant).
await revokeKeyTree(db, "root");
| Export | What it does |
|---|---|
effectiveCaps(chain) |
The caller's real grant: scope ∩, cap/share/expiry min up the chain. |
pooledHeadroom(chain, spendRows) |
The binding subtree constraint → Headroom ({ limit, spent, remaining }), or null. |
topCappedPath(chain) |
The topmost capped node — whose subtree covers all others, so one query suffices. |
expiredAncestor / disabledAncestor |
The read-time expiry / revocation cascade. |
clampChildGrant(parent, requested) |
Clamp a minted child to the parent's effective grant. |
escapeLike / subtreeLikePattern / inSubtree / childPath / pathDepth / ancestorIdsOf / pathAt / MAX_KEY_DEPTH |
The materialized-path utilities. |
parseScopes / parseKeyMeta |
The scope + metadata model. |
keyLineage / keyLineage DB ops (subtreeOf, parentPathOf, insertLineage, chainHeadroom, revokeKeyTree) |
The lineage-tree schema + queries over an injected Drizzle handle. |
Types ChainNode, EffectiveCaps, SpendRow, Headroom, and KeysDB are exported alongside.
This package owns the algebra + the table-owned queries; the grant-fetch that builds a
ChainNode[] is app-specific (an apikey table vs an MCP-token table), so it stays in the app and
calls the pure functions. The pooled-headroom query is where @suluk/keys joins
@suluk/credits — the key tree meets the ledger. It also depends on
@suluk/better-auth + drizzle-orm.
Apache-2.0