@homenshum/convex-mcp-nodebench 0.4.1 → 0.7.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/dist/index.js +52 -2
- package/dist/tools/actionAuditTools.d.ts +2 -0
- package/dist/tools/actionAuditTools.js +180 -0
- package/dist/tools/authorizationTools.d.ts +2 -0
- package/dist/tools/authorizationTools.js +201 -0
- package/dist/tools/dataModelingTools.d.ts +2 -0
- package/dist/tools/dataModelingTools.js +168 -0
- package/dist/tools/deploymentTools.js +42 -2
- package/dist/tools/devSetupTools.d.ts +2 -0
- package/dist/tools/devSetupTools.js +170 -0
- package/dist/tools/embeddingProvider.d.ts +6 -0
- package/dist/tools/embeddingProvider.js +3 -0
- package/dist/tools/functionTools.js +24 -1
- package/dist/tools/httpTools.js +128 -48
- package/dist/tools/migrationTools.d.ts +2 -0
- package/dist/tools/migrationTools.js +133 -0
- package/dist/tools/paginationTools.d.ts +2 -0
- package/dist/tools/paginationTools.js +125 -0
- package/dist/tools/queryEfficiencyTools.d.ts +2 -0
- package/dist/tools/queryEfficiencyTools.js +191 -0
- package/dist/tools/schemaTools.js +95 -1
- package/dist/tools/storageAuditTools.d.ts +2 -0
- package/dist/tools/storageAuditTools.js +148 -0
- package/dist/tools/toolRegistry.d.ts +4 -0
- package/dist/tools/toolRegistry.js +200 -11
- package/dist/tools/transactionSafetyTools.d.ts +2 -0
- package/dist/tools/transactionSafetyTools.js +166 -0
- package/dist/tools/typeSafetyTools.d.ts +2 -0
- package/dist/tools/typeSafetyTools.js +146 -0
- package/dist/types.d.ts +6 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -26,6 +26,16 @@ import { cronTools } from "./tools/cronTools.js";
|
|
|
26
26
|
import { componentTools } from "./tools/componentTools.js";
|
|
27
27
|
import { httpTools } from "./tools/httpTools.js";
|
|
28
28
|
import { critterTools } from "./tools/critterTools.js";
|
|
29
|
+
import { authorizationTools } from "./tools/authorizationTools.js";
|
|
30
|
+
import { queryEfficiencyTools } from "./tools/queryEfficiencyTools.js";
|
|
31
|
+
import { actionAuditTools } from "./tools/actionAuditTools.js";
|
|
32
|
+
import { typeSafetyTools } from "./tools/typeSafetyTools.js";
|
|
33
|
+
import { transactionSafetyTools } from "./tools/transactionSafetyTools.js";
|
|
34
|
+
import { storageAuditTools } from "./tools/storageAuditTools.js";
|
|
35
|
+
import { paginationTools } from "./tools/paginationTools.js";
|
|
36
|
+
import { dataModelingTools } from "./tools/dataModelingTools.js";
|
|
37
|
+
import { devSetupTools } from "./tools/devSetupTools.js";
|
|
38
|
+
import { migrationTools } from "./tools/migrationTools.js";
|
|
29
39
|
import { CONVEX_GOTCHAS } from "./gotchaSeed.js";
|
|
30
40
|
import { REGISTRY } from "./tools/toolRegistry.js";
|
|
31
41
|
import { initEmbeddingIndex } from "./tools/embeddingProvider.js";
|
|
@@ -41,6 +51,16 @@ const ALL_TOOLS = [
|
|
|
41
51
|
...componentTools,
|
|
42
52
|
...httpTools,
|
|
43
53
|
...critterTools,
|
|
54
|
+
...authorizationTools,
|
|
55
|
+
...queryEfficiencyTools,
|
|
56
|
+
...actionAuditTools,
|
|
57
|
+
...typeSafetyTools,
|
|
58
|
+
...transactionSafetyTools,
|
|
59
|
+
...storageAuditTools,
|
|
60
|
+
...paginationTools,
|
|
61
|
+
...dataModelingTools,
|
|
62
|
+
...devSetupTools,
|
|
63
|
+
...migrationTools,
|
|
44
64
|
];
|
|
45
65
|
const toolMap = new Map();
|
|
46
66
|
for (const tool of ALL_TOOLS) {
|
|
@@ -59,10 +79,40 @@ const server = new Server({
|
|
|
59
79
|
getDb();
|
|
60
80
|
seedGotchasIfEmpty(CONVEX_GOTCHAS);
|
|
61
81
|
// ── Background: initialize embedding index for semantic search ───────
|
|
62
|
-
|
|
82
|
+
// Uses Agent-as-a-Graph bipartite corpus: tool nodes + domain nodes for graph-aware retrieval
|
|
83
|
+
const descMap = new Map(ALL_TOOLS.map((t) => [t.name, t.description]));
|
|
84
|
+
// Tool nodes: individual tools with full metadata text
|
|
85
|
+
const toolCorpus = REGISTRY.map((entry) => ({
|
|
63
86
|
name: entry.name,
|
|
64
|
-
text: `${entry.name} ${entry.tags.join(" ")} ${entry.category} ${entry.phase}`,
|
|
87
|
+
text: `${entry.name} ${entry.tags.join(" ")} ${entry.category} ${entry.phase} ${descMap.get(entry.name) ?? ""}`,
|
|
88
|
+
nodeType: "tool",
|
|
65
89
|
}));
|
|
90
|
+
// Domain nodes: aggregate category descriptions for upward traversal
|
|
91
|
+
// When a domain matches, all tools in that domain get a sibling boost
|
|
92
|
+
const categoryTools = new Map();
|
|
93
|
+
for (const entry of REGISTRY) {
|
|
94
|
+
const list = categoryTools.get(entry.category) ?? [];
|
|
95
|
+
list.push(entry.name);
|
|
96
|
+
categoryTools.set(entry.category, list);
|
|
97
|
+
}
|
|
98
|
+
const domainCorpus = [...categoryTools.entries()].map(([category, toolNames]) => {
|
|
99
|
+
const allTags = new Set();
|
|
100
|
+
const descs = [];
|
|
101
|
+
for (const tn of toolNames) {
|
|
102
|
+
const e = REGISTRY.find((r) => r.name === tn);
|
|
103
|
+
if (e)
|
|
104
|
+
e.tags.forEach((t) => allTags.add(t));
|
|
105
|
+
const d = descMap.get(tn);
|
|
106
|
+
if (d)
|
|
107
|
+
descs.push(d);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
name: `domain:${category}`,
|
|
111
|
+
text: `${category} domain: ${toolNames.join(" ")} ${[...allTags].join(" ")} ${descs.map(d => d.slice(0, 80)).join(" ")}`,
|
|
112
|
+
nodeType: "domain",
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
const embeddingCorpus = [...toolCorpus, ...domainCorpus];
|
|
66
116
|
initEmbeddingIndex(embeddingCorpus).catch(() => {
|
|
67
117
|
/* Embedding init failed — semantic search stays disabled */
|
|
68
118
|
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getDb, genId } from "../db.js";
|
|
4
|
+
import { getQuickRef } from "./toolRegistry.js";
|
|
5
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
6
|
+
function findConvexDir(projectDir) {
|
|
7
|
+
const candidates = [join(projectDir, "convex"), join(projectDir, "src", "convex")];
|
|
8
|
+
for (const c of candidates) {
|
|
9
|
+
if (existsSync(c))
|
|
10
|
+
return c;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
function collectTsFiles(dir) {
|
|
15
|
+
const results = [];
|
|
16
|
+
if (!existsSync(dir))
|
|
17
|
+
return results;
|
|
18
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const full = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "_generated") {
|
|
22
|
+
results.push(...collectTsFiles(full));
|
|
23
|
+
}
|
|
24
|
+
else if (entry.isFile() && entry.name.endsWith(".ts")) {
|
|
25
|
+
results.push(full);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
function auditActions(convexDir) {
|
|
31
|
+
const files = collectTsFiles(convexDir);
|
|
32
|
+
const issues = [];
|
|
33
|
+
let totalActions = 0;
|
|
34
|
+
let actionsWithDbAccess = 0;
|
|
35
|
+
let actionsWithoutNodeDirective = 0;
|
|
36
|
+
let actionsWithoutErrorHandling = 0;
|
|
37
|
+
let actionCallingAction = 0;
|
|
38
|
+
// Node APIs that require "use node" directive
|
|
39
|
+
const nodeApis = /\b(require|__dirname|__filename|Buffer\.|process\.env|fs\.|path\.|crypto\.|child_process|net\.|http\.|https\.)\b/;
|
|
40
|
+
for (const filePath of files) {
|
|
41
|
+
const content = readFileSync(filePath, "utf-8");
|
|
42
|
+
const relativePath = filePath.replace(convexDir, "").replace(/^[\\/]/, "");
|
|
43
|
+
const lines = content.split("\n");
|
|
44
|
+
const hasUseNode = /["']use node["']/.test(content);
|
|
45
|
+
const actionPattern = /export\s+(?:const\s+(\w+)\s*=|default)\s+(action|internalAction)\s*\(/g;
|
|
46
|
+
let m;
|
|
47
|
+
while ((m = actionPattern.exec(content)) !== null) {
|
|
48
|
+
const funcName = m[1] || "default";
|
|
49
|
+
const funcType = m[2];
|
|
50
|
+
totalActions++;
|
|
51
|
+
const startLine = content.slice(0, m.index).split("\n").length - 1;
|
|
52
|
+
// Extract body using brace tracking
|
|
53
|
+
let depth = 0;
|
|
54
|
+
let foundOpen = false;
|
|
55
|
+
let endLine = Math.min(startLine + 100, lines.length);
|
|
56
|
+
for (let j = startLine; j < lines.length; j++) {
|
|
57
|
+
for (const ch of lines[j]) {
|
|
58
|
+
if (ch === "{") {
|
|
59
|
+
depth++;
|
|
60
|
+
foundOpen = true;
|
|
61
|
+
}
|
|
62
|
+
if (ch === "}")
|
|
63
|
+
depth--;
|
|
64
|
+
}
|
|
65
|
+
if (foundOpen && depth <= 0) {
|
|
66
|
+
endLine = j + 1;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const body = lines.slice(startLine, endLine).join("\n");
|
|
71
|
+
// Check 1: ctx.db access in action (FATAL — not allowed)
|
|
72
|
+
if (/ctx\.db\.(get|query|insert|patch|replace|delete)\s*\(/.test(body)) {
|
|
73
|
+
actionsWithDbAccess++;
|
|
74
|
+
issues.push({
|
|
75
|
+
severity: "critical",
|
|
76
|
+
location: `${relativePath}:${startLine + 1}`,
|
|
77
|
+
functionName: funcName,
|
|
78
|
+
message: `${funcType} "${funcName}" accesses ctx.db directly. Actions cannot access the database — use ctx.runQuery/ctx.runMutation instead.`,
|
|
79
|
+
fix: "Move DB operations into a query or mutation, then call via ctx.runQuery(internal.file.func, args) or ctx.runMutation(...)",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Check 2: Node API usage without "use node"
|
|
83
|
+
if (!hasUseNode && nodeApis.test(body)) {
|
|
84
|
+
actionsWithoutNodeDirective++;
|
|
85
|
+
issues.push({
|
|
86
|
+
severity: "critical",
|
|
87
|
+
location: `${relativePath}:${startLine + 1}`,
|
|
88
|
+
functionName: funcName,
|
|
89
|
+
message: `${funcType} "${funcName}" uses Node.js APIs but file lacks "use node" directive. Will fail in Convex runtime.`,
|
|
90
|
+
fix: `Add "use node"; at the top of ${relativePath}`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Check 3: External API calls without try/catch
|
|
94
|
+
const hasFetch = /\bfetch\s*\(/.test(body);
|
|
95
|
+
const hasAxios = /\baxios\b/.test(body);
|
|
96
|
+
const hasExternalCall = hasFetch || hasAxios;
|
|
97
|
+
const hasTryCatch = /try\s*\{/.test(body);
|
|
98
|
+
if (hasExternalCall && !hasTryCatch) {
|
|
99
|
+
actionsWithoutErrorHandling++;
|
|
100
|
+
issues.push({
|
|
101
|
+
severity: "warning",
|
|
102
|
+
location: `${relativePath}:${startLine + 1}`,
|
|
103
|
+
functionName: funcName,
|
|
104
|
+
message: `${funcType} "${funcName}" makes external API calls without try/catch. Network failures will crash the action.`,
|
|
105
|
+
fix: "Wrap fetch/axios calls in try/catch and handle errors gracefully",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Check 4: Action calling another action
|
|
109
|
+
if (/ctx\.runAction\s*\(/.test(body)) {
|
|
110
|
+
actionCallingAction++;
|
|
111
|
+
issues.push({
|
|
112
|
+
severity: "warning",
|
|
113
|
+
location: `${relativePath}:${startLine + 1}`,
|
|
114
|
+
functionName: funcName,
|
|
115
|
+
message: `${funcType} "${funcName}" calls ctx.runAction(). Only call action from action when crossing runtimes (V8 ↔ Node). Otherwise extract shared logic into a helper function.`,
|
|
116
|
+
fix: "Extract shared logic into an async helper, or use ctx.runMutation/ctx.runQuery as intermediary",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// Check 5: Very long action body (likely doing too much)
|
|
120
|
+
const bodyLines = endLine - startLine;
|
|
121
|
+
if (bodyLines > 80) {
|
|
122
|
+
issues.push({
|
|
123
|
+
severity: "info",
|
|
124
|
+
location: `${relativePath}:${startLine + 1}`,
|
|
125
|
+
functionName: funcName,
|
|
126
|
+
message: `${funcType} "${funcName}" is ${bodyLines} lines long. Consider splitting into smaller actions or extracting helpers.`,
|
|
127
|
+
fix: "Break large actions into smaller, focused functions",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
issues,
|
|
134
|
+
stats: {
|
|
135
|
+
totalActions,
|
|
136
|
+
actionsWithDbAccess,
|
|
137
|
+
actionsWithoutNodeDirective,
|
|
138
|
+
actionsWithoutErrorHandling,
|
|
139
|
+
actionCallingAction,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// ── Tool Definition ─────────────────────────────────────────────────
|
|
144
|
+
export const actionAuditTools = [
|
|
145
|
+
{
|
|
146
|
+
name: "convex_audit_actions",
|
|
147
|
+
description: 'Audit Convex actions for: ctx.db access (fatal — actions cannot access DB directly), missing "use node" directive for Node APIs, external API calls without error handling, and action-calling-action anti-patterns.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
projectDir: {
|
|
152
|
+
type: "string",
|
|
153
|
+
description: "Absolute path to the project root containing a convex/ directory",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
required: ["projectDir"],
|
|
157
|
+
},
|
|
158
|
+
handler: async (args) => {
|
|
159
|
+
const projectDir = resolve(args.projectDir);
|
|
160
|
+
const convexDir = findConvexDir(projectDir);
|
|
161
|
+
if (!convexDir) {
|
|
162
|
+
return { error: "No convex/ directory found" };
|
|
163
|
+
}
|
|
164
|
+
const { issues, stats } = auditActions(convexDir);
|
|
165
|
+
const db = getDb();
|
|
166
|
+
db.prepare("INSERT INTO audit_results (id, project_dir, audit_type, issues_json, issue_count) VALUES (?, ?, ?, ?, ?)").run(genId("audit"), projectDir, "action_audit", JSON.stringify(issues), issues.length);
|
|
167
|
+
return {
|
|
168
|
+
summary: {
|
|
169
|
+
...stats,
|
|
170
|
+
totalIssues: issues.length,
|
|
171
|
+
critical: issues.filter((i) => i.severity === "critical").length,
|
|
172
|
+
warnings: issues.filter((i) => i.severity === "warning").length,
|
|
173
|
+
},
|
|
174
|
+
issues: issues.slice(0, 30),
|
|
175
|
+
quickRef: getQuickRef("convex_audit_actions"),
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
//# sourceMappingURL=actionAuditTools.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getDb, genId } from "../db.js";
|
|
4
|
+
import { getQuickRef } from "./toolRegistry.js";
|
|
5
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
6
|
+
function findConvexDir(projectDir) {
|
|
7
|
+
const candidates = [join(projectDir, "convex"), join(projectDir, "src", "convex")];
|
|
8
|
+
for (const c of candidates) {
|
|
9
|
+
if (existsSync(c))
|
|
10
|
+
return c;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
function collectTsFiles(dir) {
|
|
15
|
+
const results = [];
|
|
16
|
+
if (!existsSync(dir))
|
|
17
|
+
return results;
|
|
18
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const full = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "_generated") {
|
|
22
|
+
results.push(...collectTsFiles(full));
|
|
23
|
+
}
|
|
24
|
+
else if (entry.isFile() && entry.name.endsWith(".ts")) {
|
|
25
|
+
results.push(full);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
function auditAuthorization(convexDir) {
|
|
31
|
+
const files = collectTsFiles(convexDir);
|
|
32
|
+
const issues = [];
|
|
33
|
+
let totalPublic = 0;
|
|
34
|
+
let withAuth = 0;
|
|
35
|
+
let withoutAuth = 0;
|
|
36
|
+
let uncheckedIdentity = 0;
|
|
37
|
+
const funcTypes = ["query", "mutation", "action"];
|
|
38
|
+
const writeSensitive = /admin|delete|purge|remove|destroy|drop|reset|wipe|erase|revoke/i;
|
|
39
|
+
const dbWriteOps = /ctx\.db\.(insert|patch|replace|delete)\s*\(/;
|
|
40
|
+
for (const filePath of files) {
|
|
41
|
+
const content = readFileSync(filePath, "utf-8");
|
|
42
|
+
const relativePath = filePath.replace(convexDir, "").replace(/^[\\/]/, "");
|
|
43
|
+
const lines = content.split("\n");
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
for (const ft of funcTypes) {
|
|
47
|
+
const exportPattern = new RegExp(`export\\s+(?:const\\s+(\\w+)\\s*=|default)\\s+${ft}\\s*\\(`);
|
|
48
|
+
const match = line.match(exportPattern);
|
|
49
|
+
if (!match)
|
|
50
|
+
continue;
|
|
51
|
+
const funcName = match[1] || "default";
|
|
52
|
+
totalPublic++;
|
|
53
|
+
// Extract function body using brace tracking
|
|
54
|
+
let depth = 0;
|
|
55
|
+
let foundOpen = false;
|
|
56
|
+
let endLine = Math.min(i + 100, lines.length);
|
|
57
|
+
for (let j = i; j < lines.length; j++) {
|
|
58
|
+
for (const ch of lines[j]) {
|
|
59
|
+
if (ch === "{") {
|
|
60
|
+
depth++;
|
|
61
|
+
foundOpen = true;
|
|
62
|
+
}
|
|
63
|
+
if (ch === "}")
|
|
64
|
+
depth--;
|
|
65
|
+
}
|
|
66
|
+
if (foundOpen && depth <= 0) {
|
|
67
|
+
endLine = j + 1;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const body = lines.slice(i, endLine).join("\n");
|
|
72
|
+
const hasAuthCheck = /ctx\.auth\.getUserIdentity\s*\(\s*\)/.test(body) ||
|
|
73
|
+
/getUserIdentity/.test(body);
|
|
74
|
+
const hasDbWrite = dbWriteOps.test(body);
|
|
75
|
+
const isSensitiveName = writeSensitive.test(funcName);
|
|
76
|
+
if (hasAuthCheck) {
|
|
77
|
+
withAuth++;
|
|
78
|
+
// Check: getUserIdentity called but return not null-checked
|
|
79
|
+
const identityAssign = body.match(/(?:const|let)\s+(\w+)\s*=\s*await\s+ctx\.auth\.getUserIdentity\s*\(\s*\)/);
|
|
80
|
+
if (identityAssign) {
|
|
81
|
+
const varName = identityAssign[1];
|
|
82
|
+
// Look for null check: if (!var), if (var === null), if (var == null), if (!var)
|
|
83
|
+
const hasNullCheck = new RegExp(`if\\s*\\(\\s*!${varName}\\b|if\\s*\\(\\s*${varName}\\s*===?\\s*null|if\\s*\\(\\s*!${varName}\\s*\\)`).test(body);
|
|
84
|
+
if (!hasNullCheck) {
|
|
85
|
+
uncheckedIdentity++;
|
|
86
|
+
issues.push({
|
|
87
|
+
severity: "critical",
|
|
88
|
+
location: `${relativePath}:${i + 1}`,
|
|
89
|
+
functionName: funcName,
|
|
90
|
+
message: `${ft} "${funcName}" calls getUserIdentity() but doesn't check for null. Unauthenticated users will get undefined identity.`,
|
|
91
|
+
fix: `Add: if (!${varName}) { throw new Error("Not authenticated"); }`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
withoutAuth++;
|
|
98
|
+
// Critical: public mutation/action with DB writes but no auth
|
|
99
|
+
if ((ft === "mutation" || ft === "action") && hasDbWrite) {
|
|
100
|
+
issues.push({
|
|
101
|
+
severity: "critical",
|
|
102
|
+
location: `${relativePath}:${i + 1}`,
|
|
103
|
+
functionName: funcName,
|
|
104
|
+
message: `Public ${ft} "${funcName}" writes to DB without auth check. Any client can call this.`,
|
|
105
|
+
fix: `Add: const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated");`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Critical: sensitive-named function without auth
|
|
109
|
+
if (isSensitiveName) {
|
|
110
|
+
issues.push({
|
|
111
|
+
severity: "critical",
|
|
112
|
+
location: `${relativePath}:${i + 1}`,
|
|
113
|
+
functionName: funcName,
|
|
114
|
+
message: `Public ${ft} "${funcName}" has a sensitive name but no auth check. Consider making it internal or adding auth.`,
|
|
115
|
+
fix: `Either change to internal${ft.charAt(0).toUpperCase() + ft.slice(1)} or add auth check`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Warning: public query reading user-specific data without auth
|
|
119
|
+
if (ft === "query" && /ctx\.db\.get|ctx\.db\.query/.test(body) && /user|profile|account|email/i.test(body)) {
|
|
120
|
+
issues.push({
|
|
121
|
+
severity: "warning",
|
|
122
|
+
location: `${relativePath}:${i + 1}`,
|
|
123
|
+
functionName: funcName,
|
|
124
|
+
message: `Public query "${funcName}" accesses user-related data without auth. Consider if unauthenticated access is intended.`,
|
|
125
|
+
fix: "Add auth check if this data should be protected",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
issues,
|
|
134
|
+
stats: {
|
|
135
|
+
totalPublicFunctions: totalPublic,
|
|
136
|
+
publicWithAuth: withAuth,
|
|
137
|
+
publicWithoutAuth: withoutAuth,
|
|
138
|
+
uncheckedIdentity,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// ── Tool Definition ─────────────────────────────────────────────────
|
|
143
|
+
export const authorizationTools = [
|
|
144
|
+
{
|
|
145
|
+
name: "convex_audit_authorization",
|
|
146
|
+
description: "Audit Convex functions for authorization issues: public mutations/actions writing to DB without auth checks, getUserIdentity() without null checks, sensitive-named functions without auth, and unprotected user data queries.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
projectDir: {
|
|
151
|
+
type: "string",
|
|
152
|
+
description: "Absolute path to the project root containing a convex/ directory",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
required: ["projectDir"],
|
|
156
|
+
},
|
|
157
|
+
handler: async (args) => {
|
|
158
|
+
const projectDir = resolve(args.projectDir);
|
|
159
|
+
const convexDir = findConvexDir(projectDir);
|
|
160
|
+
if (!convexDir) {
|
|
161
|
+
return { error: "No convex/ directory found" };
|
|
162
|
+
}
|
|
163
|
+
const { issues, stats } = auditAuthorization(convexDir);
|
|
164
|
+
// Store audit result
|
|
165
|
+
const db = getDb();
|
|
166
|
+
db.prepare("INSERT INTO audit_results (id, project_dir, audit_type, issues_json, issue_count) VALUES (?, ?, ?, ?, ?)").run(genId("audit"), projectDir, "authorization", JSON.stringify(issues), issues.length);
|
|
167
|
+
const critical = issues.filter((i) => i.severity === "critical");
|
|
168
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
169
|
+
// Group by issue type
|
|
170
|
+
const byType = {};
|
|
171
|
+
for (const issue of issues) {
|
|
172
|
+
const type = issue.message.includes("without auth check") ? "no_auth_on_write" :
|
|
173
|
+
issue.message.includes("null") ? "unchecked_identity" :
|
|
174
|
+
issue.message.includes("sensitive name") ? "sensitive_no_auth" :
|
|
175
|
+
issue.message.includes("user-related") ? "unprotected_user_data" :
|
|
176
|
+
"other";
|
|
177
|
+
if (!byType[type])
|
|
178
|
+
byType[type] = { count: 0, examples: [] };
|
|
179
|
+
byType[type].count++;
|
|
180
|
+
if (byType[type].examples.length < 3)
|
|
181
|
+
byType[type].examples.push(issue);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
summary: {
|
|
185
|
+
...stats,
|
|
186
|
+
totalIssues: issues.length,
|
|
187
|
+
critical: critical.length,
|
|
188
|
+
warnings: warnings.length,
|
|
189
|
+
authCoverage: stats.totalPublicFunctions > 0
|
|
190
|
+
? `${stats.publicWithAuth}/${stats.totalPublicFunctions} (${Math.round(100 * stats.publicWithAuth / stats.totalPublicFunctions)}%)`
|
|
191
|
+
: "0/0",
|
|
192
|
+
},
|
|
193
|
+
issuesByType: Object.entries(byType)
|
|
194
|
+
.sort(([, a], [, b]) => b.count - a.count)
|
|
195
|
+
.map(([type, data]) => ({ type, count: data.count, examples: data.examples })),
|
|
196
|
+
quickRef: getQuickRef("convex_audit_authorization"),
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
//# sourceMappingURL=authorizationTools.js.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getDb, genId } from "../db.js";
|
|
4
|
+
import { getQuickRef } from "./toolRegistry.js";
|
|
5
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
6
|
+
function findConvexDir(projectDir) {
|
|
7
|
+
const candidates = [join(projectDir, "convex"), join(projectDir, "src", "convex")];
|
|
8
|
+
for (const c of candidates) {
|
|
9
|
+
if (existsSync(c))
|
|
10
|
+
return c;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
function auditDataModeling(convexDir) {
|
|
15
|
+
const schemaPath = join(convexDir, "schema.ts");
|
|
16
|
+
const issues = [];
|
|
17
|
+
let totalTables = 0;
|
|
18
|
+
let tablesWithArrays = 0;
|
|
19
|
+
let tablesWithDeepNesting = 0;
|
|
20
|
+
let danglingIdRefs = 0;
|
|
21
|
+
let vAnyCount = 0;
|
|
22
|
+
if (!existsSync(schemaPath)) {
|
|
23
|
+
return {
|
|
24
|
+
issues: [{ severity: "critical", location: "schema.ts", message: "No schema.ts found", fix: "Create convex/schema.ts" }],
|
|
25
|
+
stats: { totalTables: 0, tablesWithArrays: 0, tablesWithDeepNesting: 0, danglingIdRefs: 0, vAnyCount: 0 },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const content = readFileSync(schemaPath, "utf-8");
|
|
29
|
+
const lines = content.split("\n");
|
|
30
|
+
// Parse all table names
|
|
31
|
+
const tableNames = new Set();
|
|
32
|
+
const tableDefPattern = /(\w+)\s*[:=]\s*defineTable\s*\(/g;
|
|
33
|
+
let m;
|
|
34
|
+
while ((m = tableDefPattern.exec(content)) !== null) {
|
|
35
|
+
tableNames.add(m[1]);
|
|
36
|
+
totalTables++;
|
|
37
|
+
}
|
|
38
|
+
// Per-table analysis
|
|
39
|
+
let currentTable = "";
|
|
40
|
+
let tableStartLine = 0;
|
|
41
|
+
let tableNestDepth = 0;
|
|
42
|
+
let maxNestInTable = 0;
|
|
43
|
+
let tableHasArray = false;
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
// Track current table
|
|
47
|
+
const tableDef = line.match(/(\w+)\s*[:=]\s*defineTable\s*\(/);
|
|
48
|
+
if (tableDef) {
|
|
49
|
+
// Check previous table
|
|
50
|
+
if (currentTable && maxNestInTable > 3) {
|
|
51
|
+
tablesWithDeepNesting++;
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: "warning",
|
|
54
|
+
location: `schema.ts:${tableStartLine + 1}`,
|
|
55
|
+
table: currentTable,
|
|
56
|
+
message: `Table "${currentTable}" has ${maxNestInTable} levels of nesting. Deep nesting increases document size and query complexity.`,
|
|
57
|
+
fix: "Consider normalizing deeply nested data into separate tables with Id references",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (currentTable && tableHasArray)
|
|
61
|
+
tablesWithArrays++;
|
|
62
|
+
currentTable = tableDef[1];
|
|
63
|
+
tableStartLine = i;
|
|
64
|
+
maxNestInTable = 0;
|
|
65
|
+
tableHasArray = false;
|
|
66
|
+
}
|
|
67
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("*"))
|
|
68
|
+
continue;
|
|
69
|
+
// Track nesting depth within a table
|
|
70
|
+
if (currentTable) {
|
|
71
|
+
const opens = (line.match(/v\.object\s*\(/g) || []).length;
|
|
72
|
+
tableNestDepth += opens;
|
|
73
|
+
if (tableNestDepth > maxNestInTable)
|
|
74
|
+
maxNestInTable = tableNestDepth;
|
|
75
|
+
const closes = (line.match(/\)/g) || []).length;
|
|
76
|
+
tableNestDepth = Math.max(0, tableNestDepth - closes);
|
|
77
|
+
}
|
|
78
|
+
// Check: v.array() usage (potential size limit issue)
|
|
79
|
+
if (/v\.array\s*\(/.test(line) && currentTable) {
|
|
80
|
+
tableHasArray = true;
|
|
81
|
+
}
|
|
82
|
+
// Check: v.any() usage
|
|
83
|
+
if (/v\.any\s*\(\s*\)/.test(line)) {
|
|
84
|
+
vAnyCount++;
|
|
85
|
+
}
|
|
86
|
+
// Check: v.id("tableName") references — verify the table exists
|
|
87
|
+
const idRefPattern = /v\.id\s*\(\s*["'](\w+)["']\s*\)/g;
|
|
88
|
+
let idMatch;
|
|
89
|
+
while ((idMatch = idRefPattern.exec(line)) !== null) {
|
|
90
|
+
const refTable = idMatch[1];
|
|
91
|
+
if (refTable !== "_storage" && !tableNames.has(refTable)) {
|
|
92
|
+
danglingIdRefs++;
|
|
93
|
+
issues.push({
|
|
94
|
+
severity: "critical",
|
|
95
|
+
location: `schema.ts:${i + 1}`,
|
|
96
|
+
table: currentTable,
|
|
97
|
+
message: `v.id("${refTable}") references table "${refTable}" which is not defined in the schema. This will cause type errors.`,
|
|
98
|
+
fix: `Either add the "${refTable}" table to the schema or fix the reference`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Check last table
|
|
104
|
+
if (currentTable && maxNestInTable > 3) {
|
|
105
|
+
tablesWithDeepNesting++;
|
|
106
|
+
issues.push({
|
|
107
|
+
severity: "warning",
|
|
108
|
+
location: `schema.ts:${tableStartLine + 1}`,
|
|
109
|
+
table: currentTable,
|
|
110
|
+
message: `Table "${currentTable}" has ${maxNestInTable} levels of nesting.`,
|
|
111
|
+
fix: "Consider normalizing deeply nested data into separate tables",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (currentTable && tableHasArray)
|
|
115
|
+
tablesWithArrays++;
|
|
116
|
+
// Check: tables with many fields (approaching limits)
|
|
117
|
+
const fieldCountPattern = /defineTable\s*\(\s*v\.object\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
|
|
118
|
+
let fcm;
|
|
119
|
+
while ((fcm = fieldCountPattern.exec(content)) !== null) {
|
|
120
|
+
const fieldBlock = fcm[1];
|
|
121
|
+
const fieldCount = (fieldBlock.match(/\w+\s*:/g) || []).length;
|
|
122
|
+
if (fieldCount > 50) {
|
|
123
|
+
issues.push({
|
|
124
|
+
severity: "warning",
|
|
125
|
+
location: "schema.ts",
|
|
126
|
+
message: `A table has ${fieldCount} fields. Consider splitting into related tables if this grows further (Convex max: 1024 fields).`,
|
|
127
|
+
fix: "Split large tables into related tables with Id references",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
issues,
|
|
133
|
+
stats: { totalTables, tablesWithArrays, tablesWithDeepNesting, danglingIdRefs, vAnyCount },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// ── Tool Definition ─────────────────────────────────────────────────
|
|
137
|
+
export const dataModelingTools = [
|
|
138
|
+
{
|
|
139
|
+
name: "convex_audit_data_modeling",
|
|
140
|
+
description: "Audit Convex schema for data modeling issues: deeply nested objects (flatten into tables), dangling v.id() references to non-existent tables, tables approaching field count limits, v.any() overuse, and array fields that may hit size limits.",
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
projectDir: {
|
|
145
|
+
type: "string",
|
|
146
|
+
description: "Absolute path to the project root containing a convex/ directory",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
required: ["projectDir"],
|
|
150
|
+
},
|
|
151
|
+
handler: async (args) => {
|
|
152
|
+
const projectDir = resolve(args.projectDir);
|
|
153
|
+
const convexDir = findConvexDir(projectDir);
|
|
154
|
+
if (!convexDir) {
|
|
155
|
+
return { error: "No convex/ directory found" };
|
|
156
|
+
}
|
|
157
|
+
const { issues, stats } = auditDataModeling(convexDir);
|
|
158
|
+
const db = getDb();
|
|
159
|
+
db.prepare("INSERT INTO audit_results (id, project_dir, audit_type, issues_json, issue_count) VALUES (?, ?, ?, ?, ?)").run(genId("audit"), projectDir, "data_modeling", JSON.stringify(issues), issues.length);
|
|
160
|
+
return {
|
|
161
|
+
summary: { ...stats, totalIssues: issues.length },
|
|
162
|
+
issues,
|
|
163
|
+
quickRef: getQuickRef("convex_audit_data_modeling"),
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
//# sourceMappingURL=dataModelingTools.js.map
|