katman-mcp 0.1.0 → 0.3.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/README.md +56 -44
- package/dist/cli.js +27 -14
- package/dist/license.js +331 -98
- package/dist/meta.js +29 -7
- package/dist/plans.js +54 -0
- package/dist/tools/account/account.js +60 -30
- package/dist/tools/account/cli.js +5 -2
- package/dist/tools/account/index.js +7 -7
- package/dist/tools/account/sync.js +2 -2
- package/dist/tools/account/upsell.js +423 -118
- package/dist/tools/crawl/audit/report.js +2 -2
- package/dist/tools/crawl/cli.js +17 -5
- package/dist/tools/crawl/index.js +52 -18
- package/dist/tools/crawl/scan/framework.js +4 -1
- package/dist/tools/method/core/lint.js +15 -1
- package/dist/tools/method/core/prompt.js +2 -1
- package/dist/tools/method/core/start.js +1 -1
- package/dist/tools/method/data/_store.js +17 -0
- package/dist/tools/method/data/anatomy.js +12 -141
- package/dist/tools/method/data/appendices.js +22 -202
- package/dist/tools/method/data/cloudflare.js +28 -192
- package/dist/tools/method/data/evidence.js +18 -137
- package/dist/tools/method/data/geo-jobs.js +8 -128
- package/dist/tools/method/data/landing.js +14 -67
- package/dist/tools/method/data/lessons.js +16 -127
- package/dist/tools/method/data/measurement.js +38 -142
- package/dist/tools/method/data/offsite.js +16 -148
- package/dist/tools/method/data/pages.js +16 -167
- package/dist/tools/method/data/prompts.js +8 -633
- package/dist/tools/method/data/publish.js +14 -113
- package/dist/tools/method/data/research.js +14 -131
- package/dist/tools/method/data/roadmap.js +12 -105
- package/dist/tools/method/data/traps.js +6 -74
- package/dist/tools/method/data/verify.js +12 -88
- package/dist/tools/method/data/viral-list.js +12 -39
- package/dist/tools/method/index.js +27 -17
- package/dist/tools/method/lib/prompt-lookup.js +18 -0
- package/dist/tools/method/mcp/prompts.js +6 -6
- package/dist/tools/method/mcp/resources.js +21 -17
- package/dist/tools/visibility/index.js +10 -10
- package/dist/tools/visibility/monitoring.js +6 -3
- package/package.json +5 -3
- package/server.json +4 -4
package/dist/license.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
// Katman
|
|
1
|
+
// Katman licensing (docs/07-APP-SPEC.md §6). Nothing is free: every tool except katman_account needs KATMAN_KEY
|
|
2
|
+
// with an active plan: Katman Pro, 1 site or 3 sites (every tool; 5 or 30 audits a month), or an Audit Week bought
|
|
3
|
+
// earlier (audit and recommendations, 7 days); the plans, features and the tool → feature map live in plans.ts.
|
|
2
4
|
//
|
|
3
5
|
// - KATMAN_KEY comes from the environment (the MCP config). It is sent only to KATMAN_API_URL (default
|
|
4
|
-
// https://katman.pro), in the JSON body of POST /api/web/mcp/verify and /api/web/mcp/sync,
|
|
5
|
-
// printed, logged or written anywhere.
|
|
6
|
-
// - Verification runs at most once per `checkAgainAfter` (
|
|
7
|
-
// ~/.katman/license.json under the key's SHA-256 hash (never the key).
|
|
8
|
-
// - Offline grace: when katman.pro can't be reached, the last good answer keeps working for 7 days
|
|
9
|
-
//
|
|
6
|
+
// https://katman.pro), in the JSON body of POST /api/web/mcp/verify, /api/web/mcp/usage and /api/web/mcp/sync,
|
|
7
|
+
// and is never printed, logged or written anywhere.
|
|
8
|
+
// - Verification runs at most once per `checkAgainAfter` (≤ 24 h), and again as soon as the plan's end date
|
|
9
|
+
// (planEndsAt) has passed. The answer is cached in ~/.katman/license.json under the key's SHA-256 hash (never the key).
|
|
10
|
+
// - Offline grace: when katman.pro can't be reached, the last good answer keeps working for 7 days, never past the
|
|
11
|
+
// end of an Audit Week.
|
|
12
|
+
// - Audits are counted by katman.pro, not here: the audit runs on this machine, so katman_audit_site and the CLI
|
|
13
|
+
// `audit` ask first (useAudit → POST /api/web/mcp/usage, once the plan check has passed). A refusal (audit or site
|
|
14
|
+
// limit) explains and runs nothing; when katman.pro can't be reached or answers 5xx the audit runs anyway (fail
|
|
15
|
+
// open) and the result says the count wasn't checked.
|
|
16
|
+
// - Every tool goes through withPlan() at registration (katman_start through startLicense()); katman_account is the
|
|
17
|
+
// only open tool, and the CLI uses the same gate. Without a key nothing is sent to katman.pro.
|
|
10
18
|
// - Tests inject a LicenseProvider (createServer({ license }) / runCli(args, { license })); there is no env
|
|
11
19
|
// switch that turns the gate off.
|
|
12
20
|
import { createHash } from "node:crypto";
|
|
@@ -15,31 +23,11 @@ import path from "node:path";
|
|
|
15
23
|
import { promises as fs } from "node:fs";
|
|
16
24
|
import { resolveRoot } from "./context.js";
|
|
17
25
|
import { HOMEPAGE, VERSION } from "./meta.js";
|
|
26
|
+
import { AUDIT_FEATURES, PRO_FEATURES, TOOL_FEATURES, toolForFeature } from "./plans.js";
|
|
18
27
|
import { ok } from "./util.js";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
/** Tools that never need a key (the free diagnosis). */
|
|
23
|
-
export const FREE_TOOLS = ["katman_start", "katman_research", "katman_scan_code", "katman_audit_site", "katman_keywords", "katman_account"];
|
|
24
|
-
/** Pro tool → license feature. */
|
|
25
|
-
export const PRO_TOOLS = {
|
|
26
|
-
katman_plan: "plan",
|
|
27
|
-
katman_prompt: "prompts",
|
|
28
|
-
katman_page_brief: "briefs",
|
|
29
|
-
katman_generate: "generate",
|
|
30
|
-
katman_lint_content: "lint",
|
|
31
|
-
katman_visibility_run: "visibility",
|
|
32
|
-
katman_manual_check: "manual_check",
|
|
33
|
-
katman_indexnow: "indexnow",
|
|
34
|
-
katman_report: "report",
|
|
35
|
-
katman_setup_monitoring: "monitoring",
|
|
36
|
-
katman_sync: "sync",
|
|
37
|
-
};
|
|
38
|
-
/** Appended to every Pro tool's description. */
|
|
39
|
-
export const PRO_NOTE = " (Katman Pro — needs KATMAN_KEY; 7-day free trial at katman.pro/app)";
|
|
40
|
-
export function toolForFeature(feature) {
|
|
41
|
-
return Object.keys(PRO_TOOLS).find((t) => PRO_TOOLS[t] === feature) ?? `katman_${feature}`;
|
|
42
|
-
}
|
|
28
|
+
import { METHOD_INLINE, applyMethod } from "./tools/method/data/_store.js";
|
|
29
|
+
import { planNotice, projectFacts, renderUpsell, statusLine, upsellStructured } from "./tools/account/upsell.js";
|
|
30
|
+
export * from "./plans.js";
|
|
43
31
|
export function allows(status, feature) {
|
|
44
32
|
return status.state === "valid" && status.features.includes(feature);
|
|
45
33
|
}
|
|
@@ -79,7 +67,7 @@ export function apiBase(env) {
|
|
|
79
67
|
return { url: `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}` };
|
|
80
68
|
}
|
|
81
69
|
/** POST JSON. Redirects are not followed (a 3xx must never carry the key to another host). Never throws. */
|
|
82
|
-
export const postJson = async (url, body, { timeoutMs }) => {
|
|
70
|
+
export const postJson = async (url, body, { timeoutMs, maxChars = 100_000 }) => {
|
|
83
71
|
const ctrl = new AbortController();
|
|
84
72
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
85
73
|
try {
|
|
@@ -94,7 +82,7 @@ export const postJson = async (url, body, { timeoutMs }) => {
|
|
|
94
82
|
},
|
|
95
83
|
body: JSON.stringify(body),
|
|
96
84
|
});
|
|
97
|
-
const text = (await res.text()).slice(0,
|
|
85
|
+
const text = (await res.text()).slice(0, maxChars);
|
|
98
86
|
return { status: res.status, body: text };
|
|
99
87
|
}
|
|
100
88
|
catch (e) {
|
|
@@ -114,6 +102,17 @@ export const GRACE_MS = 7 * DAY_S * 1000;
|
|
|
114
102
|
/** After a failed attempt, wait this long before trying the network again while the grace covers us. */
|
|
115
103
|
const RETRY_AFTER_FAILURE_MS = 10 * 60 * 1000;
|
|
116
104
|
const TIMEOUT_MS = 8_000;
|
|
105
|
+
/** The audit check sits in front of every audit: short, and the audit runs anyway when it times out. */
|
|
106
|
+
const USAGE_TIMEOUT_MS = 5_000;
|
|
107
|
+
/** The method data is a few hundred kB, fetched once per process. */
|
|
108
|
+
const METHOD_TIMEOUT_MS = 20_000;
|
|
109
|
+
const METHOD_MAX_CHARS = 8_000_000;
|
|
110
|
+
/**
|
|
111
|
+
* Entries this version writes (verify API v2: plans none / audit / pro, now with limits and usage). Katman 0.1.x
|
|
112
|
+
* cached free / trial / pro with other feature names in the same file, and 0.2.x entries have no limits; they don't
|
|
113
|
+
* count here and are replaced at the next check.
|
|
114
|
+
*/
|
|
115
|
+
const CACHE_CONTRACT = 3;
|
|
117
116
|
export function defaultCacheFile() {
|
|
118
117
|
return path.join(os.homedir(), ".katman", "license.json");
|
|
119
118
|
}
|
|
@@ -150,9 +149,10 @@ function prune(entries, keep, now) {
|
|
|
150
149
|
out.sort((a, b) => Date.parse(b[1].checkedAt ?? "") - Date.parse(a[1].checkedAt ?? ""));
|
|
151
150
|
return Object.fromEntries(out.slice(0, 20));
|
|
152
151
|
}
|
|
152
|
+
/** checkAgainAfter in seconds: at least a minute, at most a day (the server lowers it at the end of an Audit Week). */
|
|
153
153
|
function ttlSeconds(x) {
|
|
154
154
|
const n = Number(x);
|
|
155
|
-
return Number.isFinite(n) && n > 0 ? Math.min(Math.max(Math.round(n), 60),
|
|
155
|
+
return Number.isFinite(n) && n > 0 ? Math.min(Math.max(Math.round(n), 60), DAY_S) : DEFAULT_TTL_S;
|
|
156
156
|
}
|
|
157
157
|
function toIso(x) {
|
|
158
158
|
if (typeof x === "string" && x.trim()) {
|
|
@@ -163,7 +163,43 @@ function toIso(x) {
|
|
|
163
163
|
return new Date(x < 1e12 ? x * 1000 : x).toISOString();
|
|
164
164
|
return undefined;
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
const isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
167
|
+
const wholeNumber = (x, min, max = 1_000_000) => typeof x === "number" && Number.isInteger(x) && x >= min && x <= max ? x : undefined;
|
|
168
|
+
/** {sites, auditsPerMonth} from katman.pro; anything that isn't two positive whole numbers is no limit. */
|
|
169
|
+
export function parseLimits(x) {
|
|
170
|
+
if (!isObj(x))
|
|
171
|
+
return undefined;
|
|
172
|
+
const sites = wholeNumber(x.sites, 1);
|
|
173
|
+
const auditsPerMonth = wholeNumber(x.auditsPerMonth, 1);
|
|
174
|
+
return sites !== undefined && auditsPerMonth !== undefined ? { sites, auditsPerMonth } : undefined;
|
|
175
|
+
}
|
|
176
|
+
/** {month, audits, sites, resetsAt} from katman.pro; malformed counts are dropped. */
|
|
177
|
+
export function parseUsage(x) {
|
|
178
|
+
if (!isObj(x))
|
|
179
|
+
return undefined;
|
|
180
|
+
const audits = wholeNumber(x.audits, 0);
|
|
181
|
+
const sites = wholeNumber(x.sites, 0);
|
|
182
|
+
const resetsAt = toIso(x.resetsAt);
|
|
183
|
+
const month = typeof x.month === "string" && /^\d{4}-\d{2}$/.test(x.month) ? x.month : undefined;
|
|
184
|
+
return audits !== undefined && sites !== undefined && resetsAt && month ? { month, audits, sites, resetsAt } : undefined;
|
|
185
|
+
}
|
|
186
|
+
/** "0.3.0" >= "0.3.0"; anything that isn't a plain x.y.z version counts as older. */
|
|
187
|
+
export function versionAtLeast(version, min) {
|
|
188
|
+
const parse = (v) => (/^\d+\.\d+\.\d+/.test(v) ? v.split("-")[0].split(".").map(Number) : undefined);
|
|
189
|
+
const a = parse(version);
|
|
190
|
+
const b = parse(min);
|
|
191
|
+
if (!a || !b)
|
|
192
|
+
return false;
|
|
193
|
+
for (let i = 0; i < 3; i++)
|
|
194
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
195
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Parse a /api/web/mcp/verify response (spec §6, API v2). Only a 401 (or an explicit ok:false) is a rejection.
|
|
200
|
+
* Tolerant of older servers: an unknown plan (trial, free…) is "none", `features` is read from the array whatever the
|
|
201
|
+
* plan says, and trialEndsAt counts as planEndsAt.
|
|
202
|
+
*/
|
|
167
203
|
export function parseVerify(res) {
|
|
168
204
|
let j;
|
|
169
205
|
try {
|
|
@@ -175,16 +211,36 @@ export function parseVerify(res) {
|
|
|
175
211
|
j = undefined;
|
|
176
212
|
}
|
|
177
213
|
if (res.status === 200 && j?.ok === true) {
|
|
178
|
-
const plan = j.plan === "
|
|
214
|
+
const plan = j.plan === "audit" || j.plan === "pro" ? j.plan : "none";
|
|
179
215
|
const features = Array.isArray(j.features)
|
|
180
216
|
? j.features.filter((f) => typeof f === "string").slice(0, 64)
|
|
181
|
-
: plan === "
|
|
182
|
-
? []
|
|
183
|
-
:
|
|
217
|
+
: plan === "pro"
|
|
218
|
+
? [...PRO_FEATURES]
|
|
219
|
+
: plan === "audit"
|
|
220
|
+
? [...AUDIT_FEATURES]
|
|
221
|
+
: [];
|
|
184
222
|
const account = j.account && typeof j.account === "object" ? j.account : {};
|
|
185
223
|
const emailMasked = typeof account.emailMasked === "string" ? account.emailMasked.slice(0, 120) : undefined;
|
|
186
|
-
const
|
|
187
|
-
|
|
224
|
+
const planEndsAt = toIso(j.planEndsAt) ?? toIso(j.trialEndsAt);
|
|
225
|
+
// Only a plan with limits (Katman Pro, 1 site / 3 sites) carries limits, usage, enforced and updateMcpTo.
|
|
226
|
+
const limits = parseLimits(j.limits);
|
|
227
|
+
const usage = limits ? parseUsage(j.usage) : undefined;
|
|
228
|
+
const updateMcpTo = limits && typeof j.updateMcpTo === "string" && /^\d{1,4}\.\d{1,5}\.\d{1,6}$/.test(j.updateMcpTo) ? j.updateMcpTo : undefined;
|
|
229
|
+
return {
|
|
230
|
+
kind: "good",
|
|
231
|
+
good: {
|
|
232
|
+
plan,
|
|
233
|
+
features,
|
|
234
|
+
checkAgainAfter: ttlSeconds(j.checkAgainAfter),
|
|
235
|
+
...(planEndsAt ? { planEndsAt } : {}),
|
|
236
|
+
...(j.auditWeekUsed === true ? { auditWeekUsed: true } : {}),
|
|
237
|
+
...(limits ? { limits } : {}),
|
|
238
|
+
...(usage ? { usage } : {}),
|
|
239
|
+
...(limits && typeof j.enforced === "boolean" ? { enforced: j.enforced } : {}),
|
|
240
|
+
...(updateMcpTo ? { updateMcpTo } : {}),
|
|
241
|
+
...(emailMasked ? { emailMasked } : {}),
|
|
242
|
+
},
|
|
243
|
+
};
|
|
188
244
|
}
|
|
189
245
|
if (res.status === 401 || (j?.ok === false && (j.error === "invalid_key" || j.error === "revoked"))) {
|
|
190
246
|
return { kind: "rejected", error: j?.error === "revoked" ? "revoked" : "invalid_key" };
|
|
@@ -195,6 +251,30 @@ export function parseVerify(res) {
|
|
|
195
251
|
return { kind: "failed", error: `unexpected redirect (HTTP ${res.status}); check KATMAN_API_URL` };
|
|
196
252
|
return { kind: "failed", error: `HTTP ${res.status}${res.status === 200 ? " (unexpected answer)" : ""}` };
|
|
197
253
|
}
|
|
254
|
+
/** The end date of a plan that grants features and still ran when it was checked; once it passes, check again. */
|
|
255
|
+
function endsAfterCheck(g, checkedAt) {
|
|
256
|
+
const ends = Date.parse(g.planEndsAt ?? "");
|
|
257
|
+
return g.features.length && Number.isFinite(ends) && ends > checkedAt ? ends : undefined;
|
|
258
|
+
}
|
|
259
|
+
function nextCheck(g) {
|
|
260
|
+
const checked = Date.parse(g.checkedAt);
|
|
261
|
+
return new Date(Math.min(checked + g.checkAgainAfter * 1000, endsAfterCheck(g, checked) ?? Infinity)).toISOString();
|
|
262
|
+
}
|
|
263
|
+
/** The offline grace: 7 days after the last good check, never past the end of an Audit Week. */
|
|
264
|
+
function graceUntil(g) {
|
|
265
|
+
const grace = Date.parse(g.checkedAt) + GRACE_MS;
|
|
266
|
+
const ends = g.plan === "pro" ? NaN : Date.parse(g.planEndsAt ?? "");
|
|
267
|
+
return new Date(Number.isFinite(ends) ? Math.min(grace, ends) : grace).toISOString();
|
|
268
|
+
}
|
|
269
|
+
/** A "none" answer right after a paid plan remembers which plan ended and, when the old end date has passed, when. */
|
|
270
|
+
function withEnded(next, prev, at) {
|
|
271
|
+
if (next.plan !== "none" || next.features.length || !prev)
|
|
272
|
+
return next;
|
|
273
|
+
if (prev.plan === "none")
|
|
274
|
+
return prev.ended ? { ...next, ended: prev.ended } : next;
|
|
275
|
+
const ends = Date.parse(prev.planEndsAt ?? "");
|
|
276
|
+
return { ...next, ended: { plan: prev.plan, ...(Number.isFinite(ends) && ends <= at ? { at: prev.planEndsAt } : {}) } };
|
|
277
|
+
}
|
|
198
278
|
export function createLicenseProvider(deps = {}) {
|
|
199
279
|
const env = () => deps.env ?? process.env;
|
|
200
280
|
const post = deps.post ?? postJson;
|
|
@@ -206,34 +286,48 @@ export function createLicenseProvider(deps = {}) {
|
|
|
206
286
|
/** Never let the key appear in an error string (belt and braces: it's only ever in a request body). */
|
|
207
287
|
const scrub = (s, key) => (key ? s.split(key).join(keyHint(key)) : s);
|
|
208
288
|
function fromGood(g, source, at, extra = {}) {
|
|
209
|
-
// A
|
|
210
|
-
|
|
211
|
-
const
|
|
289
|
+
// A plan past its end date gives nothing, even if the cached or offline answer still lists features (no offline
|
|
290
|
+
// extension of an Audit Week). Pro renews into a new period, so its end date only triggers a new check (fresh()).
|
|
291
|
+
const expired = g.plan !== "pro" && !!g.planEndsAt && Date.parse(g.planEndsAt) <= at;
|
|
292
|
+
const ended = expired ? { plan: g.plan === "audit" ? "audit" : g.ended?.plan, at: g.planEndsAt } : g.plan === "none" ? g.ended : undefined;
|
|
293
|
+
const endsAt = g.planEndsAt ?? ended?.at;
|
|
212
294
|
return {
|
|
213
295
|
state: "valid",
|
|
214
|
-
plan:
|
|
215
|
-
features:
|
|
216
|
-
...(
|
|
217
|
-
...(ended ? {
|
|
296
|
+
plan: expired ? "none" : g.plan,
|
|
297
|
+
features: expired ? [] : [...g.features],
|
|
298
|
+
...(endsAt ? { planEndsAt: endsAt } : {}),
|
|
299
|
+
...(ended ? { planEnded: true, ...(ended.plan ? { endedPlan: ended.plan } : {}) } : {}),
|
|
300
|
+
...(g.auditWeekUsed || (expired && g.plan === "audit") ? { auditWeekUsed: true } : {}),
|
|
301
|
+
...(!expired && g.limits ? { limits: g.limits } : {}),
|
|
302
|
+
// The counts start again on the 1st: a count from last month is not shown.
|
|
303
|
+
...(!expired && g.limits && g.usage && Date.parse(g.usage.resetsAt) > at ? { usage: g.usage } : {}),
|
|
304
|
+
...(!expired && g.limits && g.enforced !== undefined ? { enforced: g.enforced } : {}),
|
|
305
|
+
...(!expired && g.updateMcpTo && !versionAtLeast(version, g.updateMcpTo) ? { updateMcpTo: g.updateMcpTo } : {}),
|
|
218
306
|
...(g.emailMasked ? { emailMasked: g.emailMasked } : {}),
|
|
219
307
|
checkedAt: g.checkedAt,
|
|
220
|
-
nextCheckAt:
|
|
308
|
+
nextCheckAt: nextCheck(g),
|
|
221
309
|
source,
|
|
222
310
|
...extra,
|
|
223
311
|
};
|
|
224
312
|
}
|
|
225
313
|
function rejected(error, source, key) {
|
|
226
|
-
return { state: error === "revoked" ? "revoked" : "invalid", plan: "
|
|
314
|
+
return { state: error === "revoked" ? "revoked" : "invalid", plan: "none", features: [], source, keyHint: keyHint(key) };
|
|
227
315
|
}
|
|
228
316
|
function fresh(e, at) {
|
|
229
317
|
if (!e.result || !e.checkedAt)
|
|
230
318
|
return false;
|
|
231
|
-
const
|
|
319
|
+
const checked = Date.parse(e.checkedAt);
|
|
320
|
+
const age = at - checked;
|
|
232
321
|
if (!(age >= 0))
|
|
233
322
|
return false; // unparsable or in the future (clock change): check again
|
|
234
323
|
if (!e.result.ok)
|
|
235
324
|
return age < REJECTED_TTL_S * 1000;
|
|
236
|
-
|
|
325
|
+
// "Update katman-mcp" was the answer for an older version: after the update, ask again at once.
|
|
326
|
+
if (e.result.updateMcpTo && versionAtLeast(version, e.result.updateMcpTo))
|
|
327
|
+
return false;
|
|
328
|
+
// Once the plan's end date has passed, check again at once: an Audit Week ends, Pro renews into a new period.
|
|
329
|
+
const ends = endsAfterCheck(e.result, checked);
|
|
330
|
+
if (ends !== undefined && ends <= at)
|
|
237
331
|
return false;
|
|
238
332
|
return age < e.result.checkAgainAfter * 1000;
|
|
239
333
|
}
|
|
@@ -249,8 +343,12 @@ export function createLicenseProvider(deps = {}) {
|
|
|
249
343
|
const server = base.url ?? HOMEPAGE;
|
|
250
344
|
const cache = await readCache(cacheFile);
|
|
251
345
|
const cached = cache.entries[hash];
|
|
252
|
-
const entry = cached && (cached.base ?? HOMEPAGE) === server ? cached : {};
|
|
346
|
+
const entry = cached && cached.contract === CACHE_CONTRACT && (cached.base ?? HOMEPAGE) === server ? cached : {};
|
|
253
347
|
const hint = keyHint(key);
|
|
348
|
+
const offline = (g, error) => ({
|
|
349
|
+
...fromGood(g, "offline-grace", at, { graceUntil: graceUntil(g), ...(error ? { error } : {}) }),
|
|
350
|
+
keyHint: hint,
|
|
351
|
+
});
|
|
254
352
|
if (!refresh && fresh(entry, at)) {
|
|
255
353
|
const r = entry.result;
|
|
256
354
|
return r.ok ? { ...fromGood({ ...r, checkedAt: entry.checkedAt }, "cache", at), keyHint: hint } : rejected(r.error, "cache", key);
|
|
@@ -258,7 +356,7 @@ export function createLicenseProvider(deps = {}) {
|
|
|
258
356
|
// Offline and recently failed: don't wait for another timeout on every tool call.
|
|
259
357
|
const failedAt = Date.parse(entry.failedAt ?? "");
|
|
260
358
|
if (!refresh && Number.isFinite(failedAt) && at - failedAt >= 0 && at - failedAt < RETRY_AFTER_FAILURE_MS && withinGrace(entry, at)) {
|
|
261
|
-
return
|
|
359
|
+
return offline(entry.good, entry.failure);
|
|
262
360
|
}
|
|
263
361
|
const res = base.url
|
|
264
362
|
? await post(`${base.url}/api/web/mcp/verify`, { key, mcpVersion: version }, { timeoutMs })
|
|
@@ -267,21 +365,22 @@ export function createLicenseProvider(deps = {}) {
|
|
|
267
365
|
const checkedAt = new Date(at).toISOString();
|
|
268
366
|
let status;
|
|
269
367
|
if (parsed.kind === "good") {
|
|
270
|
-
|
|
271
|
-
|
|
368
|
+
const good = withEnded(parsed.good, entry.good, at);
|
|
369
|
+
cache.entries[hash] = { contract: CACHE_CONTRACT, base: server, checkedAt, result: { ok: true, ...good }, good: { ...good, checkedAt } };
|
|
370
|
+
status = { ...fromGood({ ...good, checkedAt }, "network", at), keyHint: hint };
|
|
272
371
|
}
|
|
273
372
|
else if (parsed.kind === "rejected") {
|
|
274
|
-
cache.entries[hash] = { base: server, checkedAt, result: { ok: false, error: parsed.error } };
|
|
373
|
+
cache.entries[hash] = { contract: CACHE_CONTRACT, base: server, checkedAt, result: { ok: false, error: parsed.error } };
|
|
275
374
|
status = rejected(parsed.error, "network", key);
|
|
276
375
|
}
|
|
277
376
|
else {
|
|
278
377
|
const error = scrub(parsed.error, key);
|
|
279
|
-
cache.entries[hash] = { ...entry, base: server, failedAt: checkedAt, failure: error };
|
|
378
|
+
cache.entries[hash] = { ...entry, contract: CACHE_CONTRACT, base: server, failedAt: checkedAt, failure: error };
|
|
280
379
|
status = withinGrace(entry, at)
|
|
281
|
-
?
|
|
380
|
+
? offline(entry.good, error)
|
|
282
381
|
: {
|
|
283
382
|
state: "unreachable",
|
|
284
|
-
plan: "
|
|
383
|
+
plan: "none",
|
|
285
384
|
features: [],
|
|
286
385
|
source: "network",
|
|
287
386
|
error,
|
|
@@ -293,17 +392,29 @@ export function createLicenseProvider(deps = {}) {
|
|
|
293
392
|
await writeCache(cacheFile, cache);
|
|
294
393
|
return status;
|
|
295
394
|
}
|
|
395
|
+
/** Keep the newest counts in the cached answer, so katman_account shows this month's audits without another check. */
|
|
396
|
+
async function remember(key, limits, usage) {
|
|
397
|
+
const server = apiBase(env()).url ?? HOMEPAGE;
|
|
398
|
+
const cache = await readCache(cacheFile);
|
|
399
|
+
const e = cache.entries[hashKey(key)];
|
|
400
|
+
if (!e || e.contract !== CACHE_CONTRACT || (e.base ?? HOMEPAGE) !== server || !e.result?.ok)
|
|
401
|
+
return;
|
|
402
|
+
e.result = { ...e.result, limits, usage };
|
|
403
|
+
if (e.good)
|
|
404
|
+
e.good = { ...e.good, limits, usage };
|
|
405
|
+
await writeCache(cacheFile, cache);
|
|
406
|
+
}
|
|
296
407
|
return {
|
|
297
408
|
hasKey: () => !!readKey(env()).key,
|
|
298
409
|
baseUrl: () => apiBase(env()).url ?? HOMEPAGE,
|
|
299
410
|
async status(opts = {}) {
|
|
300
411
|
const { key, hint } = readKey(env());
|
|
301
412
|
if (!key)
|
|
302
|
-
return { state: "no_key", plan: "
|
|
413
|
+
return { state: "no_key", plan: "none", features: [], source: "none", ...(hint ? { hint } : {}) };
|
|
303
414
|
if (!KEY_RE.test(key)) {
|
|
304
415
|
return {
|
|
305
416
|
state: "invalid",
|
|
306
|
-
plan: "
|
|
417
|
+
plan: "none",
|
|
307
418
|
features: [],
|
|
308
419
|
source: "local",
|
|
309
420
|
keyHint: keyHint(key),
|
|
@@ -320,6 +431,74 @@ export function createLicenseProvider(deps = {}) {
|
|
|
320
431
|
inflight.set(flightKey, p);
|
|
321
432
|
return p;
|
|
322
433
|
},
|
|
434
|
+
async loadMethod() {
|
|
435
|
+
const { key } = readKey(env());
|
|
436
|
+
if (!key || !KEY_RE.test(key))
|
|
437
|
+
return { ok: false, error: "KATMAN_KEY is not set" };
|
|
438
|
+
const base = apiBase(env());
|
|
439
|
+
if (!base.url)
|
|
440
|
+
return { ok: false, error: base.error ?? "no API URL" };
|
|
441
|
+
const res = await post(`${base.url}/api/web/mcp/method`, { key, mcpVersion: version }, { timeoutMs: METHOD_TIMEOUT_MS, maxChars: METHOD_MAX_CHARS });
|
|
442
|
+
let j;
|
|
443
|
+
try {
|
|
444
|
+
const x = JSON.parse(res.body);
|
|
445
|
+
if (isObj(x))
|
|
446
|
+
j = x;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
j = undefined;
|
|
450
|
+
}
|
|
451
|
+
if (res.status === 200 && j?.ok === true && isObj(j.data))
|
|
452
|
+
return { ok: true, data: j.data };
|
|
453
|
+
if (res.status === 401)
|
|
454
|
+
return { ok: false, error: "katman.pro did not accept this KATMAN_KEY" };
|
|
455
|
+
if (res.status === 403)
|
|
456
|
+
return { ok: false, error: "this key has no active Katman plan" };
|
|
457
|
+
const code = typeof j?.error === "string" && /^[a-z_]{1,40}$/.test(j.error) ? ` ${j.error}` : "";
|
|
458
|
+
const why = res.status === 0 ? res.error || "network error" : `HTTP ${res.status}${code}`;
|
|
459
|
+
return { ok: false, error: scrub(why, key).slice(0, 120) };
|
|
460
|
+
},
|
|
461
|
+
async useAudit(domain) {
|
|
462
|
+
const { key } = readKey(env());
|
|
463
|
+
if (!key || !KEY_RE.test(key))
|
|
464
|
+
return { allowed: false, reason: "no_plan" };
|
|
465
|
+
const unchecked = (error) => ({ allowed: true, unchecked: true, error: scrub(error, key).slice(0, 120) });
|
|
466
|
+
const base = apiBase(env());
|
|
467
|
+
if (!base.url)
|
|
468
|
+
return unchecked(base.error ?? "no API URL");
|
|
469
|
+
const res = await post(`${base.url}/api/web/mcp/usage`, { key, kind: "audit", domain, mcpVersion: version }, { timeoutMs: USAGE_TIMEOUT_MS });
|
|
470
|
+
let j;
|
|
471
|
+
try {
|
|
472
|
+
const x = JSON.parse(res.body);
|
|
473
|
+
if (isObj(x))
|
|
474
|
+
j = x;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
j = undefined;
|
|
478
|
+
}
|
|
479
|
+
if (res.status === 401)
|
|
480
|
+
return { allowed: false, reason: j?.error === "revoked" ? "revoked" : "invalid_key" };
|
|
481
|
+
if (res.status === 200 && j?.ok === true && typeof j.allowed === "boolean") {
|
|
482
|
+
const limits = parseLimits(j.limits);
|
|
483
|
+
const usage = parseUsage(j.usage);
|
|
484
|
+
const reason = j.reason === "audit_limit" || j.reason === "site_limit" || j.reason === "no_plan" ? j.reason : undefined;
|
|
485
|
+
const plan = j.plan === "audit" || j.plan === "pro" || j.plan === "none" ? j.plan : undefined;
|
|
486
|
+
if (limits && usage)
|
|
487
|
+
await remember(key, limits, usage).catch(() => undefined);
|
|
488
|
+
return {
|
|
489
|
+
allowed: j.allowed,
|
|
490
|
+
...(j.allowed ? {} : { reason: reason ?? "no_plan" }),
|
|
491
|
+
...(plan ? { plan } : {}),
|
|
492
|
+
...(typeof j.enforced === "boolean" ? { enforced: j.enforced } : {}),
|
|
493
|
+
limits: limits ?? null,
|
|
494
|
+
...(usage ? { usage } : {}),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
// Anything else (no network, a timeout, 5xx, a rate limit, a host without this endpoint, an answer this version
|
|
498
|
+
// doesn't understand): the audit runs, and the result says the count wasn't checked.
|
|
499
|
+
const code = typeof j?.error === "string" && /^[a-z_]{1,40}$/.test(j.error) ? j.error : undefined;
|
|
500
|
+
return unchecked(res.status === 0 ? res.error || "network error" : res.status >= 300 && res.status < 400 ? `unexpected redirect (HTTP ${res.status}); check KATMAN_API_URL` : `HTTP ${res.status}${code ? ` ${code}` : ""}`);
|
|
501
|
+
},
|
|
323
502
|
async sync(body) {
|
|
324
503
|
const { key } = readKey(env());
|
|
325
504
|
if (!key || !KEY_RE.test(key))
|
|
@@ -345,7 +524,7 @@ export function createLicenseProvider(deps = {}) {
|
|
|
345
524
|
return { ok: true, status: res.status, dashboardUrl: dashboardUrl(base.url, j.url ?? j.dashboardUrl, projectId), ...(projectId ? { projectId } : {}), ...(message ? { message } : {}) };
|
|
346
525
|
}
|
|
347
526
|
const code = typeof j.error === "string" ? j.error.slice(0, 60) : undefined;
|
|
348
|
-
const error = res.status === 0 ? "network" : res.status === 401 ? (code === "revoked" ? "revoked" : "invalid_key") : res.status === 402 || res.status === 403 ? code ?? "
|
|
527
|
+
const error = res.status === 0 ? "network" : res.status === 401 ? (code === "revoked" ? "revoked" : "invalid_key") : res.status === 402 || res.status === 403 ? code ?? "pro_required" : code ?? `HTTP ${res.status}`;
|
|
349
528
|
return { ok: false, status: res.status, error, ...(res.status === 0 && res.error ? { message: scrub(res.error, key) } : message ? { message } : {}) };
|
|
350
529
|
},
|
|
351
530
|
};
|
|
@@ -375,24 +554,61 @@ export function licenseOf(ctx) {
|
|
|
375
554
|
}
|
|
376
555
|
// ---------------------------------------------------------------------------------------------------------------
|
|
377
556
|
// Gate
|
|
378
|
-
|
|
379
|
-
|
|
557
|
+
/** The provider's status; a provider that throws counts as unreachable. */
|
|
558
|
+
export async function statusOf(provider) {
|
|
380
559
|
try {
|
|
381
|
-
|
|
560
|
+
return await provider.status();
|
|
382
561
|
}
|
|
383
562
|
catch (e) {
|
|
384
|
-
|
|
563
|
+
return { state: "unreachable", plan: "none", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
|
|
385
564
|
}
|
|
386
|
-
|
|
565
|
+
}
|
|
566
|
+
export async function requireFeature(feature, provider) {
|
|
567
|
+
const status = await statusOf(provider);
|
|
568
|
+
const allowed = allows(status, feature);
|
|
569
|
+
if (allowed)
|
|
570
|
+
await ensureMethod(provider);
|
|
571
|
+
return { allowed, status };
|
|
572
|
+
}
|
|
573
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
574
|
+
// Method data (the published package carries none; see src/tools/method/data/_store.ts)
|
|
575
|
+
let methodLoaded = METHOD_INLINE;
|
|
576
|
+
let methodLoading;
|
|
577
|
+
/** Thrown when a tool needs the method and katman.pro couldn't send it; the message is shown to the user. */
|
|
578
|
+
export class MethodUnavailableError extends Error {
|
|
387
579
|
}
|
|
388
580
|
/**
|
|
389
|
-
*
|
|
581
|
+
* Make sure the method data is in memory before a paid tool runs: fetched from katman.pro once per process with the
|
|
582
|
+
* key, never written to disk. Throws MethodUnavailableError with a readable reason when it can't be loaded.
|
|
583
|
+
*/
|
|
584
|
+
export async function ensureMethod(provider) {
|
|
585
|
+
if (methodLoaded)
|
|
586
|
+
return;
|
|
587
|
+
if (!provider.loadMethod)
|
|
588
|
+
throw new MethodUnavailableError("Katman could not load its method data (this license provider can't fetch it).");
|
|
589
|
+
methodLoading ??= provider.loadMethod().then((r) => {
|
|
590
|
+
if (r.ok) {
|
|
591
|
+
applyMethod(r.data);
|
|
592
|
+
methodLoaded = true;
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
methodLoading = undefined;
|
|
596
|
+
}
|
|
597
|
+
return r;
|
|
598
|
+
});
|
|
599
|
+
const r = await methodLoading;
|
|
600
|
+
if (!r.ok) {
|
|
601
|
+
throw new MethodUnavailableError(`Katman needs to reach ${provider.baseUrl()} once per session to load its method for your plan, and couldn't (${r.error}). Check the connection and KATMAN_KEY, then try again.`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Wrap a tool's handler at registration. Without a plan that includes the feature it returns a short explanation
|
|
390
606
|
* (not isError, so the agent relays it) instead of running the tool.
|
|
391
607
|
*/
|
|
392
|
-
export function
|
|
608
|
+
export function withPlan(ctx, feature, handler) {
|
|
393
609
|
const tool = toolForFeature(feature);
|
|
394
610
|
return async (args, extra) => {
|
|
395
|
-
const gate = await
|
|
611
|
+
const gate = await requireFeature(feature, licenseOf(ctx));
|
|
396
612
|
if (gate.allowed)
|
|
397
613
|
return handler(args, extra);
|
|
398
614
|
let facts;
|
|
@@ -407,42 +623,59 @@ export function withPro(ctx, feature, handler) {
|
|
|
407
623
|
return ok(renderUpsell({ tool, feature, status: gate.status, facts, args: a, surface: "mcp" }), upsellStructured(tool, feature, gate.status));
|
|
408
624
|
};
|
|
409
625
|
}
|
|
410
|
-
/**
|
|
411
|
-
|
|
412
|
-
* notice when the recommended next tool is a Pro tool the license doesn't cover.
|
|
413
|
-
*/
|
|
414
|
-
export async function startLicense(ctx) {
|
|
415
|
-
const provider = licenseOf(ctx);
|
|
416
|
-
let status;
|
|
626
|
+
/** Ask whether an audit of this domain may run (and have it counted). Never throws: a failing provider counts as unchecked, the audit runs. */
|
|
627
|
+
export async function meterAudit(provider, domain) {
|
|
417
628
|
try {
|
|
418
|
-
|
|
629
|
+
return await provider.useAudit(domain);
|
|
419
630
|
}
|
|
420
631
|
catch (e) {
|
|
421
|
-
|
|
632
|
+
return { allowed: true, unchecked: true, error: e instanceof Error ? e.message.slice(0, 120) : "error" };
|
|
422
633
|
}
|
|
423
|
-
|
|
634
|
+
}
|
|
635
|
+
/** For katman_start: the gate, the plan line, and a notice when the recommended next tool isn't in the plan. */
|
|
636
|
+
export async function startLicense(ctx) {
|
|
637
|
+
const provider = licenseOf(ctx);
|
|
638
|
+
const status = await statusOf(provider);
|
|
639
|
+
const allowed = allows(status, "start");
|
|
640
|
+
if (allowed)
|
|
641
|
+
await ensureMethod(provider);
|
|
424
642
|
return {
|
|
425
|
-
|
|
643
|
+
allowed,
|
|
644
|
+
status,
|
|
645
|
+
line: statusLine(status),
|
|
426
646
|
notice: (tool) => {
|
|
427
|
-
const feature =
|
|
428
|
-
return feature && !allows(status, feature) ?
|
|
647
|
+
const feature = Object.hasOwn(TOOL_FEATURES, tool) ? TOOL_FEATURES[tool] : undefined;
|
|
648
|
+
return feature && !allows(status, feature) ? planNotice(tool, feature, status) : undefined;
|
|
649
|
+
},
|
|
650
|
+
structured: {
|
|
651
|
+
keySet: provider.hasKey(),
|
|
652
|
+
state: status.state,
|
|
653
|
+
plan: status.plan,
|
|
654
|
+
...(status.planEndsAt ? { planEndsAt: status.planEndsAt } : {}),
|
|
655
|
+
...(status.planEnded ? { planEnded: true } : {}),
|
|
656
|
+
...(status.limits ? { limits: status.limits } : {}),
|
|
657
|
+
...(status.usage ? { usage: status.usage } : {}),
|
|
658
|
+
...(status.updateMcpTo ? { updateMcpTo: status.updateMcpTo } : {}),
|
|
429
659
|
},
|
|
430
|
-
structured: { keySet, state: status.state, plan: status.plan, ...(status.trialEndsAt ? { trialEndsAt: status.trialEndsAt } : {}) },
|
|
431
660
|
};
|
|
432
661
|
}
|
|
433
662
|
// ---------------------------------------------------------------------------------------------------------------
|
|
434
663
|
// CLI
|
|
435
|
-
/** Exit code of a
|
|
436
|
-
export const
|
|
437
|
-
/** CLI command → feature
|
|
438
|
-
export
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
664
|
+
/** Exit code of a CLI command without the plan it needs. */
|
|
665
|
+
export const EXIT_NEEDS_PLAN = 4;
|
|
666
|
+
/** CLI command → feature. Every command needs its feature except the OPEN_COMMANDS (and --help / --version). */
|
|
667
|
+
export const CLI_FEATURES = {
|
|
668
|
+
scan: "scan",
|
|
669
|
+
audit: "audit",
|
|
670
|
+
keywords: "keywords",
|
|
671
|
+
plan: "plan",
|
|
672
|
+
report: "report",
|
|
673
|
+
check: "visibility",
|
|
674
|
+
models: "visibility",
|
|
675
|
+
indexnow: "indexnow",
|
|
676
|
+
};
|
|
677
|
+
/** Commands that always work, even without a key. */
|
|
678
|
+
export const OPEN_COMMANDS = ["account"];
|
|
679
|
+
export function cliFeature(cmd) {
|
|
680
|
+
return cmd !== undefined && Object.hasOwn(CLI_FEATURES, cmd) ? CLI_FEATURES[cmd] : undefined;
|
|
448
681
|
}
|