keymaker-cli 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/LICENSE +21 -0
- package/README.md +184 -0
- package/examples/todo-api.yaml +69 -0
- package/package.json +53 -0
- package/src/attest.js +73 -0
- package/src/billing.js +48 -0
- package/src/cli.js +159 -0
- package/src/doctor.js +63 -0
- package/src/extract.js +99 -0
- package/src/gateway.js +246 -0
- package/src/gen-authmd.js +63 -0
- package/src/gen-llms.js +41 -0
- package/src/gen-mcp.js +82 -0
- package/src/index.js +10 -0
- package/src/mcp-http.js +85 -0
- package/src/middleware.js +66 -0
- package/src/parse.js +36 -0
- package/src/score.js +60 -0
- package/src/serve.js +18 -0
- package/src/store.js +45 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createBilling } from "./billing.js";
|
|
4
|
+
import { findByKey, readKeys, writeKeys } from "./store.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drop-in auth middleware for the vendor's own API.
|
|
8
|
+
* Express-style (req, res, next) — also works with plain node:http
|
|
9
|
+
* (call without next; resolves true if the request may proceed).
|
|
10
|
+
*
|
|
11
|
+
* GET/HEAD/OPTIONS require the `read` or `write` scope; everything else requires `write`.
|
|
12
|
+
*/
|
|
13
|
+
export function keymakerAuth({ dir, rateLimitPerMinute = 60 } = {}) {
|
|
14
|
+
const hits = new Map();
|
|
15
|
+
const billingPromise = readFile(join(dir, "signup.config.json"), "utf8")
|
|
16
|
+
.then((raw) => createBilling(JSON.parse(raw).billing))
|
|
17
|
+
.catch(() => null);
|
|
18
|
+
|
|
19
|
+
return async function auth(req, res, next) {
|
|
20
|
+
const fail = (code, error) => {
|
|
21
|
+
if (res?.writeHead) {
|
|
22
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
23
|
+
res.end(JSON.stringify({ error }));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const header = req.headers?.authorization ?? "";
|
|
29
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
|
30
|
+
if (!token) return fail(401, "missing bearer token; see /auth.md to register");
|
|
31
|
+
|
|
32
|
+
const keys = await readKeys(dir);
|
|
33
|
+
const rec = findByKey(keys, token);
|
|
34
|
+
if (!rec) return fail(401, "unknown key; see /auth.md to register");
|
|
35
|
+
if (rec.revoked) return fail(401, "key revoked");
|
|
36
|
+
if (rec.expires_at && Date.parse(rec.expires_at) < Date.now()) {
|
|
37
|
+
return fail(401, "key expired; a human can make it permanent via /agent-auth/claim");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mutating = !["GET", "HEAD", "OPTIONS"].includes(req.method);
|
|
41
|
+
if (mutating && !rec.scopes.includes("write")) return fail(403, "write scope required");
|
|
42
|
+
if (!mutating && !rec.scopes.includes("read") && !rec.scopes.includes("write")) {
|
|
43
|
+
return fail(403, "read scope required");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const recent = (hits.get(rec.key_id) ?? []).filter((t) => t > now - 60_000);
|
|
48
|
+
if (recent.length >= rateLimitPerMinute) return fail(429, "rate limit exceeded");
|
|
49
|
+
recent.push(now);
|
|
50
|
+
hits.set(rec.key_id, recent);
|
|
51
|
+
|
|
52
|
+
rec.usage = (rec.usage ?? 0) + 1;
|
|
53
|
+
keys[rec.key_id] = rec;
|
|
54
|
+
await writeKeys(dir, keys);
|
|
55
|
+
billingPromise.then((b) => b?.onMeter(rec)).catch(() => {});
|
|
56
|
+
|
|
57
|
+
req.agent = {
|
|
58
|
+
key_id: rec.key_id,
|
|
59
|
+
client_name: rec.client_name,
|
|
60
|
+
scopes: rec.scopes,
|
|
61
|
+
status: rec.status,
|
|
62
|
+
};
|
|
63
|
+
if (typeof next === "function") next();
|
|
64
|
+
return true;
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/parse.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import yaml from "js-yaml";
|
|
3
|
+
|
|
4
|
+
export async function loadSpec(src) {
|
|
5
|
+
let text;
|
|
6
|
+
if (/^https?:\/\//.test(src)) {
|
|
7
|
+
const res = await fetch(src);
|
|
8
|
+
if (!res.ok) throw new Error(`Failed to fetch spec: HTTP ${res.status}`);
|
|
9
|
+
text = await res.text();
|
|
10
|
+
} else {
|
|
11
|
+
text = await readFile(src, "utf8");
|
|
12
|
+
}
|
|
13
|
+
const spec = yaml.load(text);
|
|
14
|
+
if (!spec || typeof spec !== "object" || (!spec.openapi && !spec.swagger)) {
|
|
15
|
+
throw new Error("Not an OpenAPI document (missing openapi/swagger field)");
|
|
16
|
+
}
|
|
17
|
+
return spec;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_DEPTH = 30;
|
|
21
|
+
|
|
22
|
+
export function deref(spec, node, depth = 0) {
|
|
23
|
+
if (depth > MAX_DEPTH || node == null || typeof node !== "object") return node;
|
|
24
|
+
if (Array.isArray(node)) return node.map((n) => deref(spec, n, depth + 1));
|
|
25
|
+
if (typeof node.$ref === "string" && node.$ref.startsWith("#/")) {
|
|
26
|
+
const target = node.$ref
|
|
27
|
+
.slice(2)
|
|
28
|
+
.split("/")
|
|
29
|
+
.reduce((acc, key) => acc?.[key.replaceAll("~1", "/").replaceAll("~0", "~")], spec);
|
|
30
|
+
if (!target) return { description: `Unresolved ref: ${node.$ref}` };
|
|
31
|
+
return deref(spec, target, depth + 1);
|
|
32
|
+
}
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [k, v] of Object.entries(node)) out[k] = deref(spec, v, depth + 1);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/src/score.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function scoreSpec(spec, ops, warnings) {
|
|
2
|
+
const totalOps = ops.length || 1;
|
|
3
|
+
const docWarn = warnings.filter((w) => w.includes("no summary or description")).length;
|
|
4
|
+
const paramWarn = warnings.filter((w) => w.includes("has no description")).length;
|
|
5
|
+
const totalParams = ops.reduce(
|
|
6
|
+
(n, o) => n + Object.values(o.paramLocs).filter((l) => l !== "body").length,
|
|
7
|
+
0
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const parts = [];
|
|
11
|
+
parts.push({
|
|
12
|
+
name: "Operation docs",
|
|
13
|
+
got: Math.round((40 * (totalOps - docWarn)) / totalOps),
|
|
14
|
+
max: 40,
|
|
15
|
+
fix: docWarn ? `${docWarn} operation(s) missing summary/description` : null,
|
|
16
|
+
});
|
|
17
|
+
parts.push({
|
|
18
|
+
name: "Parameter docs",
|
|
19
|
+
got: totalParams ? Math.round((20 * Math.max(0, totalParams - paramWarn)) / totalParams) : 20,
|
|
20
|
+
max: 20,
|
|
21
|
+
fix: paramWarn ? `${paramWarn} parameter(s) undocumented` : null,
|
|
22
|
+
});
|
|
23
|
+
parts.push({
|
|
24
|
+
name: "API description",
|
|
25
|
+
got: spec.info?.description ? 10 : 0,
|
|
26
|
+
max: 10,
|
|
27
|
+
fix: spec.info?.description ? null : "add info.description",
|
|
28
|
+
});
|
|
29
|
+
const server = spec.servers?.[0]?.url ?? "";
|
|
30
|
+
const absolute = /^https?:\/\//.test(server);
|
|
31
|
+
parts.push({
|
|
32
|
+
name: "Absolute base URL",
|
|
33
|
+
got: absolute ? 10 : 0,
|
|
34
|
+
max: 10,
|
|
35
|
+
fix: absolute ? null : "servers[0].url should be an absolute https URL",
|
|
36
|
+
});
|
|
37
|
+
const schemes = Object.values(spec.components?.securitySchemes ?? {});
|
|
38
|
+
const schemesDescribed = schemes.length && schemes.every((s) => s.description);
|
|
39
|
+
parts.push({
|
|
40
|
+
name: "Documented auth",
|
|
41
|
+
got: schemes.length ? (schemesDescribed ? 10 : 5) : 0,
|
|
42
|
+
max: 10,
|
|
43
|
+
fix: !schemes.length
|
|
44
|
+
? "declare components.securitySchemes"
|
|
45
|
+
: schemesDescribed
|
|
46
|
+
? null
|
|
47
|
+
: "add descriptions to security schemes",
|
|
48
|
+
});
|
|
49
|
+
const tagged = ops.filter((o) => o.tags.length).length;
|
|
50
|
+
parts.push({
|
|
51
|
+
name: "Tagged operations",
|
|
52
|
+
got: Math.round((10 * tagged) / totalOps),
|
|
53
|
+
max: 10,
|
|
54
|
+
fix: tagged === totalOps ? null : "tag all operations for grouping",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const total = parts.reduce((n, p) => n + p.got, 0);
|
|
58
|
+
const grade = total >= 90 ? "A" : total >= 75 ? "B" : total >= 60 ? "C" : total >= 40 ? "D" : "F";
|
|
59
|
+
return { total, grade, parts };
|
|
60
|
+
}
|
package/src/serve.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { keymakerGateway } from "./gateway.js";
|
|
3
|
+
|
|
4
|
+
export async function startSignupServer({ dir, port = 8787 }) {
|
|
5
|
+
const gateway = await keymakerGateway({ dir });
|
|
6
|
+
const server = createServer(async (req, res) => {
|
|
7
|
+
if (await gateway(req, res)) return;
|
|
8
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
9
|
+
res.end(
|
|
10
|
+
JSON.stringify({ error: "not found", hint: "GET /auth.md for agent registration instructions" })
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
await new Promise((resolve, reject) => {
|
|
14
|
+
server.on("error", reject);
|
|
15
|
+
server.listen(port, resolve);
|
|
16
|
+
});
|
|
17
|
+
return server;
|
|
18
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
2
|
+
import { createHash, timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** API keys are stored only as SHA-256 hashes; the raw key is shown once at registration. */
|
|
6
|
+
export function hashKey(token) {
|
|
7
|
+
return createHash("sha256").update(String(token)).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Timing-safe lookup of a key record by the presented bearer token. */
|
|
11
|
+
export function findByKey(keys, token) {
|
|
12
|
+
if (!token) return null;
|
|
13
|
+
const presented = Buffer.from(hashKey(token), "hex");
|
|
14
|
+
for (const rec of Object.values(keys)) {
|
|
15
|
+
if (!rec.key_hash) continue;
|
|
16
|
+
const stored = Buffer.from(rec.key_hash, "hex");
|
|
17
|
+
if (stored.length === presented.length && timingSafeEqual(stored, presented)) return rec;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readKeys(dir) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(await readFile(join(dir, "keys.json"), "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Writes are serialized per path and land via tmp-file + rename, so concurrent
|
|
31
|
+
// requests in one process can't interleave a partial keys.json.
|
|
32
|
+
const queues = new Map();
|
|
33
|
+
export function writeKeys(dir, keys) {
|
|
34
|
+
const path = join(dir, "keys.json");
|
|
35
|
+
const prev = queues.get(path) ?? Promise.resolve();
|
|
36
|
+
const next = prev
|
|
37
|
+
.then(async () => {
|
|
38
|
+
const tmp = `${path}.${randomBytes(4).toString("hex")}.tmp`;
|
|
39
|
+
await writeFile(tmp, JSON.stringify(keys, null, 2));
|
|
40
|
+
await rename(tmp, path);
|
|
41
|
+
})
|
|
42
|
+
.catch(() => {});
|
|
43
|
+
queues.set(path, next);
|
|
44
|
+
return next;
|
|
45
|
+
}
|