@suluk/keys 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@suluk/keys",
3
+ "version": "0.1.0",
4
+ "description": "The delegation-chain ALGEBRA for hierarchical API keys: effective-caps (scope ∩, cap/expiry min up the chain), POOLED subtree headroom (a parent cap bounds parent+children TOTAL spend — abuse-proof), the cascade read-checks (expired/disabled ancestor), child-grant clamping, the materialized-path utilities, and the scope/metadata model. Pure + portable: the app supplies the chain rows + spend (its DB query is the seam); this owns the money/abuse-correctness logic. Extracted from a real app (C046). CANDIDATE tooling.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/keys"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@suluk/better-auth": "^0.1.3",
23
+ "@suluk/credits": "^0.1.0",
24
+ "drizzle-orm": "^0.45.2"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "latest"
28
+ },
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit -p ."
32
+ }
33
+ }
package/src/chain.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The delegation-chain ALGEBRA (C046) — the money/abuse-correctness logic, pure and portable. A caller's chain is itself
3
+ * (last) + its ancestors (root→parent), each a {@link ChainNode} with its OWN granted scopes + caps + expiry. The four
4
+ * rules that keep a child from ever out-scoping or out-spending an ancestor:
5
+ * • effectiveCaps — scopes = ∩ up the chain, credit-cap/rate-share/expiry = the MIN (soonest) of the declared ones.
6
+ * • pooledHeadroom — a node's cap bounds its WHOLE subtree's total spend (pooling = the abuse-proof property).
7
+ * • expired/disabledAncestor — a child dies the moment a parent up the chain expires or is revoked (the read-time cascade).
8
+ * • clampChildGrant — a freshly-minted child is clamped to the parent's effective grant.
9
+ * The app supplies the rows (its DB query is the seam); this owns the algebra. Extracted verbatim from the source.
10
+ */
11
+ import { inSubtree } from "./path";
12
+
13
+ /** One node of a caller's chain — itself or an ancestor — with its OWN (pre-chain) grant + caps + its materialized path. */
14
+ export interface ChainNode {
15
+ keyId: string;
16
+ /** the node's own materialized path (a prefix of the caller's) — used to sum its subtree spend. */
17
+ path: string;
18
+ /** the node's OWN granted tool scopes (an unrestricted account-root never appears as a node). */
19
+ scopes: string[];
20
+ ownCreditLimit: number | null;
21
+ ownRateSharePct: number | null;
22
+ /** epoch ms — the node's own expiry; null = never. */
23
+ ownExpiresAt: number | null;
24
+ /** an ancestor soft-disabled (enabled=false) — drives the auth-time revocation cascade. */
25
+ disabled?: boolean;
26
+ }
27
+
28
+ export interface EffectiveCaps {
29
+ scopes: string[];
30
+ creditLimit: number | null;
31
+ rateLimitSharePct: number | null;
32
+ expiresAt: number | null;
33
+ }
34
+
35
+ /** The caller's EFFECTIVE grant, derived by walking UP the chain. Scopes = the intersection of every node's grant; the
36
+ * credit cap + rate share + expiry = the MIN (soonest) of the declared (non-null) ones. The depth-0 identity for a plain
37
+ * root key (one node → its own values), so single-key behaviour is preserved. */
38
+ export function effectiveCaps(chain: ChainNode[]): EffectiveCaps {
39
+ let scopes: string[] | null = null;
40
+ for (const n of chain) scopes = scopes == null ? [...n.scopes] : scopes.filter((s) => n.scopes.includes(s));
41
+ const limits = chain.map((n) => n.ownCreditLimit).filter((x): x is number => x != null);
42
+ const shares = chain.map((n) => n.ownRateSharePct).filter((x): x is number => x != null);
43
+ const expiries = chain.map((n) => n.ownExpiresAt).filter((x): x is number => x != null);
44
+ return {
45
+ scopes: scopes ?? [],
46
+ creditLimit: limits.length ? Math.min(...limits) : null,
47
+ rateLimitSharePct: shares.length ? Math.min(...shares) : null,
48
+ expiresAt: expiries.length ? Math.min(...expiries) : null, // soonest up the chain — a child can't outlive any ancestor
49
+ };
50
+ }
51
+
52
+ /** TRUE when any ANCESTOR (a node other than the caller) has already expired — so the caller auto-expires the moment a
53
+ * parent does. The caller's OWN expiry is enforced upstream (the token verify rejects it), so it's excluded. */
54
+ export function expiredAncestor(chain: ChainNode[], callerKeyId: string, now: number): boolean {
55
+ return chain.some((n) => n.keyId !== callerKeyId && n.ownExpiresAt != null && n.ownExpiresAt <= now);
56
+ }
57
+
58
+ /** TRUE when any ANCESTOR has been soft-disabled — so a child auto-dies the moment a parent is revoked, EVEN when the
59
+ * revocation didn't cascade through the write path. The read-time half of the cascade. The caller's OWN disable is
60
+ * enforced upstream, so it's excluded. */
61
+ export function disabledAncestor(chain: ChainNode[], callerKeyId: string): boolean {
62
+ return chain.some((n) => n.keyId !== callerKeyId && n.disabled === true);
63
+ }
64
+
65
+ /** One row of per-path spend (a positive amount), as the app's subtree query returns it. */
66
+ export interface SpendRow {
67
+ path: string;
68
+ spent: number;
69
+ }
70
+
71
+ export interface Headroom {
72
+ limit: number;
73
+ spent: number;
74
+ remaining: number;
75
+ }
76
+
77
+ /**
78
+ * The chain's POOLED credit headroom — the BINDING constraint a charge must clear: over every node that declares an own
79
+ * cap, the LEAST `cap − subtreeSpend(node)` (a node's subtree = itself ∪ descendants). Pooling is what makes a cap
80
+ * abuse-proof: a parent capped at 50 can't mint children to spend 50 each, because every child's spend lands in the
81
+ * parent's subtree. The app fetches `spendRows` (per-path spend over the topmost capped node's subtree — one grouped
82
+ * query); this sums per node in O(nodes × rows). Returns null when no node declares a cap (uncapped — only the balance gates).
83
+ */
84
+ export function pooledHeadroom(chain: ChainNode[], spendRows: readonly SpendRow[]): Headroom | null {
85
+ const capped = chain.filter((n): n is ChainNode & { ownCreditLimit: number } => n.ownCreditLimit != null);
86
+ if (capped.length === 0) return null;
87
+ let binding: Headroom | null = null;
88
+ for (const node of capped) {
89
+ let spent = 0;
90
+ for (const r of spendRows) if (inSubtree(node.path, r.path)) spent += Number(r.spent);
91
+ const remaining = node.ownCreditLimit - spent;
92
+ if (binding === null || remaining < binding.remaining) binding = { limit: node.ownCreditLimit, spent, remaining };
93
+ }
94
+ return binding;
95
+ }
96
+
97
+ /** The topmost capped node in a chain (the shortest path) — whose subtree contains every other capped node's subtree, so
98
+ * one query over it suffices for {@link pooledHeadroom}. Null when no node declares a cap. */
99
+ export function topCappedPath(chain: ChainNode[]): string | null {
100
+ const capped = chain.filter((n) => n.ownCreditLimit != null);
101
+ if (!capped.length) return null;
102
+ return capped.reduce((a, b) => (a.path.split("/").length <= b.path.split("/").length ? a : b)).path;
103
+ }
104
+
105
+ /** Clamp a requested CHILD grant to the parent's EFFECTIVE grant — a child can never out-scope or out-spend an ancestor.
106
+ * scopes ⊆ parent's; each cap/expiry = min(requested ?? ∞, parent ?? ∞) (null only when BOTH are unbounded). Pure. */
107
+ export function clampChildGrant(
108
+ parent: EffectiveCaps,
109
+ requested: { scopes: string[]; creditLimit?: number | null; rateLimitSharePct?: number | null; expiresAt?: number | null },
110
+ ): EffectiveCaps {
111
+ const minOrNull = (a: number | null | undefined, b: number | null): number | null => {
112
+ const xs = [a, b].filter((x): x is number => typeof x === "number");
113
+ return xs.length ? Math.min(...xs) : null;
114
+ };
115
+ return {
116
+ scopes: requested.scopes.filter((s) => parent.scopes.includes(s)),
117
+ creditLimit: minOrNull(requested.creditLimit, parent.creditLimit),
118
+ rateLimitSharePct: minOrNull(requested.rateLimitSharePct, parent.rateLimitSharePct),
119
+ // a child that asks for no/longer expiry INHERITS the parent's (min of one finite + ∞ = the finite one).
120
+ expiresAt: minOrNull(requested.expiresAt, parent.expiresAt),
121
+ };
122
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @suluk/keys — the delegation-chain ALGEBRA for hierarchical API keys (C046, extracted from a real app).
3
+ *
4
+ * The app builds a `ChainNode[]` (a caller + its ancestors) and per-path `SpendRow[]` from its OWN store — the DB query
5
+ * is the seam (a Drizzle reference adapter is a follow-on, deferred per C046) — then calls these PURE functions for the
6
+ * money/abuse-correctness logic so it can never drift:
7
+ * • effectiveCaps — scope ∩, credit-cap/rate-share/expiry min up the chain (a child can't out-scope/out-spend a parent)
8
+ * • pooledHeadroom — a node's cap bounds its WHOLE subtree's total spend (the abuse-proof property)
9
+ * • expired/disabledAncestor — the read-time revocation/expiry cascade
10
+ * • clampChildGrant — clamp a minted child to the parent's effective grant
11
+ * plus the materialized-path utilities and the scope/metadata model.
12
+ */
13
+ export {
14
+ type ChainNode, type EffectiveCaps, type SpendRow, type Headroom,
15
+ effectiveCaps, expiredAncestor, disabledAncestor, pooledHeadroom, topCappedPath, clampChildGrant,
16
+ } from "./chain";
17
+ export { MAX_KEY_DEPTH, escapeLike, subtreeLikePattern, inSubtree, childPath, pathDepth, ancestorIdsOf, pathAt } from "./path";
18
+ export { parseScopes, parseKeyMeta } from "./scopes";
19
+ // the lineage-tree DB ops (over an injected Drizzle handle) + the pooled-headroom query that joins @suluk/credits.
20
+ export { type KeysDB, keyLineage, subtreeOf, parentPathOf, insertLineage, chainHeadroom, revokeKeyTree } from "./lineage";
package/src/lineage.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The lineage-tree DB ops (C046) — the materialized-path queries over a key delegation tree, plus the POOLED headroom
3
+ * query that joins the credit ledger (where @suluk/keys meets @suluk/credits). The package OWNS the `key_lineage` schema;
4
+ * the app injects a Drizzle handle. The grant-fetch that builds a ChainNode[] is app-specific (apikey vs MCP tables), so
5
+ * it stays in the app and calls the pure algebra (chain.ts); these are the generic, table-owned operations. Extracted
6
+ * verbatim from the source.
7
+ */
8
+ import { and, eq, lt, sql } from "drizzle-orm";
9
+ import type { DrizzleD1Database } from "drizzle-orm/d1";
10
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
11
+ import { creditKey, creditTransaction } from "@suluk/credits";
12
+ import { type ChainNode, type Headroom, pooledHeadroom, topCappedPath } from "./chain";
13
+ import { subtreeLikePattern } from "./path";
14
+
15
+ /** The injected DB handle (drizzle/d1 in prod; bun:sqlite bridged in tests). */
16
+ export type KeysDB = DrizzleD1Database;
17
+
18
+ /** The delegation tree: each node's parent + a materialized `path` of keyIds (root→…→self). `userId`/`keyId` are plain
19
+ * columns (the app owns the user + apikey tables); `keyId` is the SAME string as `credit_key.keyId`. */
20
+ export const keyLineage = sqliteTable("key_lineage", {
21
+ keyId: text("keyId").primaryKey(),
22
+ parentKeyId: text("parentKeyId"),
23
+ userId: text("userId").notNull(),
24
+ path: text("path").notNull(),
25
+ depth: integer("depth").notNull(),
26
+ });
27
+
28
+ /** SQL: "`key_lineage.path` is within <path>'s subtree" — self (exact) OR a descendant (escaped LIKE prefix). */
29
+ const subtreeSql = (path: string) => sql`(${keyLineage.path} = ${path} OR ${keyLineage.path} LIKE ${subtreeLikePattern(path)} ESCAPE '\\')`;
30
+
31
+ /** The keyIds in a node's subtree (itself + every descendant) — for cascade revoke. Falls back to `[keyId]` for a
32
+ * legacy caller with no lineage row (a childless root). */
33
+ export async function subtreeOf(db: KeysDB, keyId: string): Promise<string[]> {
34
+ const self = await db.select({ path: keyLineage.path }).from(keyLineage).where(eq(keyLineage.keyId, keyId)).limit(1);
35
+ const path = self[0]?.path ?? keyId;
36
+ const rows = await db.select({ keyId: keyLineage.keyId }).from(keyLineage).where(subtreeSql(path));
37
+ const ids = rows.map((r) => r.keyId);
38
+ return ids.length ? ids : [keyId];
39
+ }
40
+
41
+ /** A parent's materialized path (for building a child's path). A parent with no row is a root → its bare id; a null
42
+ * parent (a session/account caller) → null (the child is a root). */
43
+ export async function parentPathOf(db: KeysDB, parentKeyId: string | null): Promise<string | null> {
44
+ if (parentKeyId == null) return null;
45
+ const rows = await db.select({ path: keyLineage.path }).from(keyLineage).where(eq(keyLineage.keyId, parentKeyId)).limit(1);
46
+ return rows[0]?.path ?? parentKeyId;
47
+ }
48
+
49
+ /** Record a freshly-minted child (or root, when parentKeyId is null) in the lineage tree. Idempotent on the keyId PK. */
50
+ export async function insertLineage(db: KeysDB, opts: { keyId: string; parentKeyId: string | null; userId: string; parentPath: string | null }): Promise<void> {
51
+ const path = opts.parentPath ? `${opts.parentPath}/${opts.keyId}` : opts.keyId;
52
+ const depth = path.split("/").length - 1;
53
+ await db.insert(keyLineage).values({ keyId: opts.keyId, parentKeyId: opts.parentKeyId, userId: opts.userId, path, depth }).onConflictDoNothing().run();
54
+ }
55
+
56
+ /**
57
+ * The chain's POOLED credit headroom — one grouped query over the TOPMOST capped node's subtree (joining the credit
58
+ * ledger via the `credit_key` sidecar), then {@link pooledHeadroom}. This is where the abuse-proof cap becomes real: a
59
+ * parent's cap bounds its whole subtree's spend. Null when no node in the chain declares a cap (uncapped).
60
+ */
61
+ export async function chainHeadroom(db: KeysDB, chain: ChainNode[]): Promise<Headroom | null> {
62
+ const top = topCappedPath(chain);
63
+ if (top == null) return null;
64
+ const rows = await db
65
+ .select({ path: keyLineage.path, spent: sql<number>`coalesce(sum(-${creditTransaction.delta}), 0)` })
66
+ .from(keyLineage)
67
+ .innerJoin(creditKey, eq(creditKey.keyId, keyLineage.keyId))
68
+ .innerJoin(creditTransaction, eq(creditTransaction.id, creditKey.txnId))
69
+ .where(and(subtreeSql(top), lt(creditTransaction.delta, 0)))
70
+ .groupBy(keyLineage.path);
71
+ return pooledHeadroom(chain, rows.map((r) => ({ path: r.path, spent: Number(r.spent) })));
72
+ }
73
+
74
+ /**
75
+ * Cascade-revoke a key's subtree: compute the api-key ids in `keyId`'s subtree (a keyed caller may revoke ONLY a STRICT
76
+ * descendant of itself — not itself, an ancestor, or another branch) and soft-disable them via the injected `disableKeys`
77
+ * (the app's apikey update — so @suluk/keys stays free of the Better Auth apikey table). MCP ids are skipped (a
78
+ * connection is revoked elsewhere). Returns the count disabled.
79
+ */
80
+ export async function revokeKeyTree(
81
+ db: KeysDB,
82
+ opts: { userId: string; keyId: string; callerKeyId?: string },
83
+ disableKeys: (userId: string, keyIds: string[]) => Promise<number>,
84
+ ): Promise<{ revoked: number }> {
85
+ if (opts.callerKeyId != null) {
86
+ const callerRow = await db.select({ path: keyLineage.path }).from(keyLineage).where(eq(keyLineage.keyId, opts.callerKeyId)).limit(1);
87
+ const callerPath = callerRow[0]?.path ?? opts.callerKeyId;
88
+ const targetRow = await db.select({ path: keyLineage.path }).from(keyLineage).where(eq(keyLineage.keyId, opts.keyId)).limit(1);
89
+ const targetPath = targetRow[0]?.path;
90
+ // the target must be a STRICT descendant of the caller (its path extends the caller's by ≥1 segment)
91
+ if (targetPath == null || !targetPath.startsWith(callerPath + "/")) return { revoked: 0 };
92
+ }
93
+ const ids = (await subtreeOf(db, opts.keyId)).filter((id) => !id.startsWith("mcp:")); // only api-keys are disable-able
94
+ if (ids.length === 0) return { revoked: 0 };
95
+ return { revoked: await disableKeys(opts.userId, ids) };
96
+ }
package/src/path.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Materialized-path utilities for a key delegation tree (C046). A node's `path` is its keyIds root→…→self joined by "/".
3
+ * That makes every chain/subtree question recursion-free: ancestors = the ids before self; descendants = a "/"-prefix
4
+ * match. Pure — the app owns the storage; this owns the shape. Extracted verbatim from the source's key-lineage logic.
5
+ */
6
+
7
+ /** A delegation chain can be at most this deep (root..leaf) — bounds the path length + the per-request walk. */
8
+ export const MAX_KEY_DEPTH = 8;
9
+
10
+ /** Escape SQL-LIKE metacharacters (a keyId can contain `_`, a LIKE wildcard) so a path prefix matches LITERALLY — pair
11
+ * with `ESCAPE '\'` in the query. Without this, a sibling whose id shares a `_`-adjacent prefix could leak into a
12
+ * subtree match. */
13
+ export const escapeLike = (s: string): string => s.replace(/[\\%_]/g, (c) => `\\${c}`);
14
+
15
+ /** The `LIKE` pattern for "<path>'s strict descendants" — pair with `ESCAPE '\'`. (The node itself is matched by `= path`.) */
16
+ export const subtreeLikePattern = (path: string): string => `${escapeLike(path)}/%`;
17
+
18
+ /** TRUE when `candidate` is within `path`'s subtree: the node itself (exact) OR a descendant (a "/"-prefix). The JS twin
19
+ * of the SQL subtree predicate — the single rule for spend pooling, log visibility, and cascade. */
20
+ export const inSubtree = (path: string, candidate: string): boolean => candidate === path || candidate.startsWith(path + "/");
21
+
22
+ /** A child's path = `parentPath/childId`, or the bare `childId` when the parent is a root (no path / a session caller). */
23
+ export const childPath = (parentPath: string | null | undefined, childId: string): string => (parentPath ? `${parentPath}/${childId}` : childId);
24
+
25
+ /** Depth of a path: 0 = root, >0 = a delegated child. */
26
+ export const pathDepth = (path: string): number => path.split("/").length - 1;
27
+
28
+ /** The ancestor keyIds in a path (everything before self), root→parent order. */
29
+ export const ancestorIdsOf = (path: string): string[] => path.split("/").slice(0, -1);
30
+
31
+ /** The own-path of the ancestor at index `i` in a path's segments (the prefix up to and including it). */
32
+ export const pathAt = (path: string, i: number): string => path.split("/").slice(0, i + 1).join("/");
package/src/scopes.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The key permission/metadata model (C046) — pure, defensive parsing of what a key carries: its permissions JSON
3
+ * (`{resource:[actions]}` → flat `["resource:action"]` scopes, via @suluk/better-auth) and its metadata (the per-key
4
+ * PAID credit cap + the rate-limit share %). A bad/absent value reads as "no scopes" / "no override" — never throws.
5
+ * Extracted verbatim from the source.
6
+ */
7
+ import { permissionsToScopes } from "@suluk/better-auth";
8
+
9
+ /** permissions JSON (`{resource:[actions]}`) → flat `["resource:action"]` scopes, defensively (a bad value → no scopes). */
10
+ export const parseScopes = (permissions: string | null): string[] => {
11
+ if (!permissions) return [];
12
+ try {
13
+ const parsed: unknown = JSON.parse(permissions);
14
+ if (typeof parsed !== "object" || parsed === null) return [];
15
+ const perms: Record<string, string[]> = {};
16
+ for (const [k, v] of Object.entries(parsed)) if (Array.isArray(v)) perms[k] = v.filter((x): x is string => typeof x === "string");
17
+ return permissionsToScopes(perms);
18
+ } catch {
19
+ return [];
20
+ }
21
+ };
22
+
23
+ /** metadata JSON → the per-key controls (each null when absent/invalid): the PAID credit cap + the rate-limit share %.
24
+ * Defensive — a bad value reads as "no override"; the share is clamped to [1,100] to mirror the auth-time clamp. */
25
+ export function parseKeyMeta(metadata: string | null): { creditLimit: number | null; rateLimitSharePct: number | null } {
26
+ let creditLimit: number | null = null;
27
+ let rateLimitSharePct: number | null = null;
28
+ if (metadata) {
29
+ try {
30
+ const parsed: unknown = JSON.parse(metadata);
31
+ if (typeof parsed === "object" && parsed !== null) {
32
+ if ("creditLimit" in parsed) {
33
+ const n = Number((parsed as Record<string, unknown>).creditLimit);
34
+ if (Number.isFinite(n)) creditLimit = n;
35
+ }
36
+ if ("rateLimitSharePct" in parsed) {
37
+ const n = Number((parsed as Record<string, unknown>).rateLimitSharePct);
38
+ if (Number.isFinite(n)) rateLimitSharePct = Math.min(100, Math.max(1, Math.round(n)));
39
+ }
40
+ }
41
+ } catch {
42
+ /* bad value → no override */
43
+ }
44
+ }
45
+ return { creditLimit, rateLimitSharePct };
46
+ }
@@ -0,0 +1,81 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { effectiveCaps, expiredAncestor, disabledAncestor, pooledHeadroom, topCappedPath, clampChildGrant, type ChainNode } from "../src/index";
3
+
4
+ /**
5
+ * C046 — the delegation-chain algebra (money/abuse-correctness). The pooledHeadroom test is load-bearing: a parent cap
6
+ * must bound the parent + ALL descendants' TOTAL spend, so a key can't multiply its budget by minting children.
7
+ */
8
+ const node = (over: Partial<ChainNode> & { keyId: string; path: string }): ChainNode => ({ scopes: [], ownCreditLimit: null, ownRateSharePct: null, ownExpiresAt: null, ...over });
9
+
10
+ describe("effectiveCaps — ∩ scopes, min caps/expiry up the chain", () => {
11
+ test("scopes intersect; caps + expiry take the min (soonest)", () => {
12
+ const chain = [
13
+ node({ keyId: "root", path: "root", scopes: ["a", "b", "c"], ownCreditLimit: 100, ownRateSharePct: 100, ownExpiresAt: 5000 }),
14
+ node({ keyId: "child", path: "root/child", scopes: ["a", "b"], ownCreditLimit: 40, ownRateSharePct: 50, ownExpiresAt: 3000 }),
15
+ ];
16
+ expect(effectiveCaps(chain)).toEqual({ scopes: ["a", "b"], creditLimit: 40, rateLimitSharePct: 50, expiresAt: 3000 });
17
+ });
18
+
19
+ test("a lone root key is the depth-0 identity (its own values)", () => {
20
+ const root = node({ keyId: "r", path: "r", scopes: ["x"], ownCreditLimit: 10, ownRateSharePct: null, ownExpiresAt: null });
21
+ expect(effectiveCaps([root])).toEqual({ scopes: ["x"], creditLimit: 10, rateLimitSharePct: null, expiresAt: null });
22
+ });
23
+
24
+ test("an unrestricted ancestor (no scopes node) does not appear; a node with [] scopes intersects to empty", () => {
25
+ const chain = [node({ keyId: "p", path: "p", scopes: ["a"] }), node({ keyId: "c", path: "p/c", scopes: [] })];
26
+ expect(effectiveCaps(chain).scopes).toEqual([]);
27
+ });
28
+ });
29
+
30
+ describe("cascade read-checks", () => {
31
+ const chain = [
32
+ node({ keyId: "root", path: "root", ownExpiresAt: 1000, disabled: true }),
33
+ node({ keyId: "self", path: "root/self", ownExpiresAt: 9999, disabled: false }),
34
+ ];
35
+ test("expiredAncestor is true when a PARENT expired (caller's own expiry excluded)", () => {
36
+ expect(expiredAncestor(chain, "self", 2000)).toBe(true); // root expired at 1000
37
+ expect(expiredAncestor(chain, "self", 500)).toBe(false); // not yet
38
+ expect(expiredAncestor([chain[1]], "self", 999999)).toBe(false); // self only — own expiry ignored here
39
+ });
40
+ test("disabledAncestor is true when a PARENT is soft-disabled (caller's own disable excluded)", () => {
41
+ expect(disabledAncestor(chain, "self")).toBe(true);
42
+ expect(disabledAncestor([chain[1]], "self")).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("pooledHeadroom — the abuse-proof property", () => {
47
+ test("a parent cap bounds the parent + descendants' TOTAL spend (pooled, not just its own)", () => {
48
+ const chain = [node({ keyId: "root", path: "root", ownCreditLimit: 50 }), node({ keyId: "child", path: "root/child" })];
49
+ const spend = [{ path: "root", spent: 20 }, { path: "root/child", spent: 30 }];
50
+ // pooled: root's subtree spend = 20 + 30 = 50 (NOT just its own 20) → remaining 0
51
+ expect(pooledHeadroom(chain, spend)).toEqual({ limit: 50, spent: 50, remaining: 0 });
52
+ });
53
+
54
+ test("the BINDING constraint is the least remaining across every capped node", () => {
55
+ const chain = [node({ keyId: "root", path: "root", ownCreditLimit: 100 }), node({ keyId: "child", path: "root/child", ownCreditLimit: 40 })];
56
+ const spend = [{ path: "root", spent: 10 }, { path: "root/child", spent: 35 }];
57
+ // root: subtree 45 → remaining 55; child: subtree 35 → remaining 5 → binding
58
+ expect(pooledHeadroom(chain, spend)).toEqual({ limit: 40, spent: 35, remaining: 5 });
59
+ });
60
+
61
+ test("no cap anywhere → null (only the balance gates)", () => {
62
+ expect(pooledHeadroom([node({ keyId: "r", path: "r" })], [])).toBeNull();
63
+ });
64
+
65
+ test("topCappedPath picks the shortest-path capped node (whose subtree covers the rest)", () => {
66
+ const chain = [node({ keyId: "root", path: "root", ownCreditLimit: 100 }), node({ keyId: "c", path: "root/c", ownCreditLimit: 40 })];
67
+ expect(topCappedPath(chain)).toBe("root");
68
+ expect(topCappedPath([node({ keyId: "r", path: "r" })])).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe("clampChildGrant — a child never out-scopes/out-spends a parent", () => {
73
+ const parent = { scopes: ["a", "b"], creditLimit: 40, rateLimitSharePct: 50, expiresAt: 3000 };
74
+ test("scopes ⊆ parent; caps = min(requested, parent)", () => {
75
+ expect(clampChildGrant(parent, { scopes: ["a", "z"], creditLimit: 100, rateLimitSharePct: 10, expiresAt: 9999 })).toEqual({ scopes: ["a"], creditLimit: 40, rateLimitSharePct: 10, expiresAt: 3000 });
76
+ });
77
+ test("a child asking for no expiry INHERITS the parent's; both-null stays null", () => {
78
+ expect(clampChildGrant(parent, { scopes: ["b"] }).expiresAt).toBe(3000);
79
+ expect(clampChildGrant({ scopes: [], creditLimit: null, rateLimitSharePct: null, expiresAt: null }, { scopes: [] }).creditLimit).toBeNull();
80
+ });
81
+ });
@@ -0,0 +1,84 @@
1
+ import { test, expect, describe, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { drizzle } from "drizzle-orm/bun-sqlite";
4
+ import { insertLineage, subtreeOf, parentPathOf, chainHeadroom, revokeKeyTree, type KeysDB, type ChainNode } from "../src/index";
5
+ import { addCredits, debitIfCovers, type CreditsDB } from "@suluk/credits";
6
+
7
+ /**
8
+ * C046 — the keys lineage DB ops, witnessed against a REAL bun:sqlite. chainHeadroom is the INTEGRATION point where
9
+ * @suluk/keys meets @suluk/credits: the pooled headroom is computed by joining the credit ledger, proving the abuse-proof
10
+ * cap is real across a whole subtree (a parent cap bounds parent + children TOTAL spend, not just the parent's own).
11
+ */
12
+ function freshDb(): KeysDB & CreditsDB {
13
+ const sqlite = new Database(":memory:");
14
+ sqlite.run(`CREATE TABLE key_lineage (keyId TEXT PRIMARY KEY, parentKeyId TEXT, userId TEXT NOT NULL, path TEXT NOT NULL, depth INTEGER NOT NULL)`);
15
+ sqlite.run(`CREATE TABLE credit_transaction (id TEXT PRIMARY KEY, userId TEXT NOT NULL, delta INTEGER NOT NULL, reason TEXT NOT NULL, createdAt INTEGER NOT NULL)`);
16
+ sqlite.run(`CREATE TABLE credit_amount (txnId TEXT PRIMARY KEY REFERENCES credit_transaction(id), amountCents INTEGER NOT NULL)`);
17
+ sqlite.run(`CREATE TABLE credit_key (txnId TEXT PRIMARY KEY REFERENCES credit_transaction(id), keyId TEXT NOT NULL)`);
18
+ return drizzle(sqlite) as unknown as KeysDB & CreditsDB;
19
+ }
20
+
21
+ const node = (over: Partial<ChainNode> & { keyId: string; path: string }): ChainNode => ({ scopes: [], ownCreditLimit: null, ownRateSharePct: null, ownExpiresAt: null, ...over });
22
+ let db: KeysDB & CreditsDB;
23
+ const U = "user_1";
24
+ beforeEach(() => {
25
+ db = freshDb();
26
+ });
27
+
28
+ describe("lineage tree", () => {
29
+ test("insertLineage builds materialized paths; subtreeOf + parentPathOf walk them", async () => {
30
+ await insertLineage(db, { keyId: "root", parentKeyId: null, userId: U, parentPath: null });
31
+ await insertLineage(db, { keyId: "child", parentKeyId: "root", userId: U, parentPath: "root" });
32
+ await insertLineage(db, { keyId: "grand", parentKeyId: "child", userId: U, parentPath: "root/child" });
33
+
34
+ expect((await subtreeOf(db, "root")).sort()).toEqual(["child", "grand", "root"]);
35
+ expect((await subtreeOf(db, "child")).sort()).toEqual(["child", "grand"]);
36
+ expect(await parentPathOf(db, "child")).toBe("root/child");
37
+ expect(await parentPathOf(db, null)).toBeNull();
38
+ expect(await subtreeOf(db, "unknown")).toEqual(["unknown"]); // legacy/childless fallback
39
+ });
40
+ });
41
+
42
+ describe("chainHeadroom — keys × credits, the pooled cap is real", () => {
43
+ test("a parent cap bounds parent + child TOTAL spend (pooled across the subtree)", async () => {
44
+ await insertLineage(db, { keyId: "root", parentKeyId: null, userId: U, parentPath: null });
45
+ await insertLineage(db, { keyId: "child", parentKeyId: "root", userId: U, parentPath: "root" });
46
+ await addCredits(db, U, 1000, "topup");
47
+ await debitIfCovers(db, U, 30, "use", "root"); // attributed to key "root"
48
+ await debitIfCovers(db, U, 10, "use", "child"); // attributed to key "child"
49
+
50
+ const chain = [node({ keyId: "root", path: "root", ownCreditLimit: 50 }), node({ keyId: "child", path: "root/child" })];
51
+ // pooled: root's subtree spend = 30 (own) + 10 (child) = 40 → remaining 10 (NOT 20, which un-pooled would give)
52
+ expect(await chainHeadroom(db, chain)).toEqual({ limit: 50, spent: 40, remaining: 10 });
53
+ });
54
+
55
+ test("no cap in the chain → null (only the account balance gates)", async () => {
56
+ await insertLineage(db, { keyId: "root", parentKeyId: null, userId: U, parentPath: null });
57
+ expect(await chainHeadroom(db, [node({ keyId: "root", path: "root" })])).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe("revokeKeyTree — cascade with the strict-descendant guard", () => {
62
+ test("disables the subtree via the injected disableKeys; a keyed caller may revoke only a STRICT descendant", async () => {
63
+ await insertLineage(db, { keyId: "root", parentKeyId: null, userId: U, parentPath: null });
64
+ await insertLineage(db, { keyId: "child", parentKeyId: "root", userId: U, parentPath: "root" });
65
+ let disabled: string[] = [];
66
+ const disableKeys = async (_userId: string, ids: string[]) => {
67
+ disabled = ids;
68
+ return ids.length;
69
+ };
70
+
71
+ // a session (no callerKeyId) revokes child + its subtree
72
+ expect(await revokeKeyTree(db, { userId: U, keyId: "child" }, disableKeys)).toEqual({ revoked: 1 });
73
+ expect(disabled).toEqual(["child"]);
74
+
75
+ // a keyed caller "root" may revoke its STRICT descendant "child"
76
+ disabled = [];
77
+ expect(await revokeKeyTree(db, { userId: U, keyId: "child", callerKeyId: "root" }, disableKeys)).toEqual({ revoked: 1 });
78
+
79
+ // a keyed caller may NOT revoke itself (not a strict descendant)
80
+ disabled = [];
81
+ expect(await revokeKeyTree(db, { userId: U, keyId: "child", callerKeyId: "child" }, disableKeys)).toEqual({ revoked: 0 });
82
+ expect(disabled).toEqual([]);
83
+ });
84
+ });
@@ -0,0 +1,49 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { escapeLike, subtreeLikePattern, inSubtree, childPath, pathDepth, ancestorIdsOf, parseScopes, parseKeyMeta } from "../src/index";
3
+
4
+ /** C046 — the materialized-path utilities + the scope/metadata model. */
5
+
6
+ describe("path utilities", () => {
7
+ test("inSubtree: self (exact) or a descendant (/-prefix); a sibling is NOT in the subtree", () => {
8
+ expect(inSubtree("root", "root")).toBe(true);
9
+ expect(inSubtree("root", "root/child")).toBe(true);
10
+ expect(inSubtree("root", "root2")).toBe(false); // a sibling sharing a prefix is not a descendant
11
+ expect(inSubtree("root/a", "root/b")).toBe(false);
12
+ });
13
+ test("escapeLike guards LIKE wildcards (a keyId can contain `_`)", () => {
14
+ expect(escapeLike("ab_c%d\\e")).toBe("ab\\_c\\%d\\\\e");
15
+ expect(subtreeLikePattern("k_1")).toBe("k\\_1/%");
16
+ });
17
+ test("childPath / pathDepth / ancestorIdsOf", () => {
18
+ expect(childPath("root/a", "b")).toBe("root/a/b");
19
+ expect(childPath(null, "b")).toBe("b");
20
+ expect(pathDepth("root")).toBe(0);
21
+ expect(pathDepth("root/a/b")).toBe(2);
22
+ expect(ancestorIdsOf("root/a/b")).toEqual(["root", "a"]);
23
+ });
24
+ });
25
+
26
+ describe("parseScopes — defensive permissions → scopes", () => {
27
+ test("null / non-JSON / non-object → no scopes (never throws)", () => {
28
+ expect(parseScopes(null)).toEqual([]);
29
+ expect(parseScopes("not json")).toEqual([]);
30
+ expect(parseScopes("42")).toEqual([]);
31
+ });
32
+ test("a valid permissions object yields a non-empty scope list", () => {
33
+ const scopes = parseScopes(JSON.stringify({ tools: ["read", "write"] }));
34
+ expect(Array.isArray(scopes)).toBe(true);
35
+ expect(scopes.length).toBeGreaterThan(0);
36
+ });
37
+ });
38
+
39
+ describe("parseKeyMeta — defensive per-key controls", () => {
40
+ test("absent / bad → null overrides", () => {
41
+ expect(parseKeyMeta(null)).toEqual({ creditLimit: null, rateLimitSharePct: null });
42
+ expect(parseKeyMeta("nope")).toEqual({ creditLimit: null, rateLimitSharePct: null });
43
+ });
44
+ test("reads creditLimit; clamps rateLimitSharePct to [1,100] and rounds", () => {
45
+ expect(parseKeyMeta(JSON.stringify({ creditLimit: 250, rateLimitSharePct: 33.4 }))).toEqual({ creditLimit: 250, rateLimitSharePct: 33 });
46
+ expect(parseKeyMeta(JSON.stringify({ rateLimitSharePct: 0 })).rateLimitSharePct).toBe(1);
47
+ expect(parseKeyMeta(JSON.stringify({ rateLimitSharePct: 999 })).rateLimitSharePct).toBe(100);
48
+ });
49
+ });
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }