trickle-backend 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/dist/db/connection.d.ts +3 -0
- package/dist/db/connection.js +16 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.js +51 -0
- package/dist/db/queries.d.ts +70 -0
- package/dist/db/queries.js +186 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -0
- package/dist/routes/audit.d.ts +2 -0
- package/dist/routes/audit.js +251 -0
- package/dist/routes/codegen.d.ts +2 -0
- package/dist/routes/codegen.js +224 -0
- package/dist/routes/coverage.d.ts +2 -0
- package/dist/routes/coverage.js +98 -0
- package/dist/routes/dashboard.d.ts +2 -0
- package/dist/routes/dashboard.js +433 -0
- package/dist/routes/diff.d.ts +2 -0
- package/dist/routes/diff.js +181 -0
- package/dist/routes/errors.d.ts +2 -0
- package/dist/routes/errors.js +86 -0
- package/dist/routes/functions.d.ts +2 -0
- package/dist/routes/functions.js +69 -0
- package/dist/routes/ingest.d.ts +2 -0
- package/dist/routes/ingest.js +111 -0
- package/dist/routes/mock.d.ts +2 -0
- package/dist/routes/mock.js +57 -0
- package/dist/routes/search.d.ts +2 -0
- package/dist/routes/search.js +136 -0
- package/dist/routes/tail.d.ts +2 -0
- package/dist/routes/tail.js +11 -0
- package/dist/routes/types.d.ts +2 -0
- package/dist/routes/types.js +97 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +40 -0
- package/dist/services/sse-broker.d.ts +10 -0
- package/dist/services/sse-broker.js +39 -0
- package/dist/services/type-differ.d.ts +2 -0
- package/dist/services/type-differ.js +126 -0
- package/dist/services/type-generator.d.ts +319 -0
- package/dist/services/type-generator.js +3207 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +2 -0
- package/package.json +22 -0
- package/src/db/connection.ts +16 -0
- package/src/db/migrations.ts +50 -0
- package/src/db/queries.ts +260 -0
- package/src/index.ts +11 -0
- package/src/routes/audit.ts +283 -0
- package/src/routes/codegen.ts +237 -0
- package/src/routes/coverage.ts +120 -0
- package/src/routes/dashboard.ts +435 -0
- package/src/routes/diff.ts +215 -0
- package/src/routes/errors.ts +91 -0
- package/src/routes/functions.ts +75 -0
- package/src/routes/ingest.ts +139 -0
- package/src/routes/mock.ts +66 -0
- package/src/routes/search.ts +169 -0
- package/src/routes/tail.ts +12 -0
- package/src/routes/types.ts +106 -0
- package/src/server.ts +40 -0
- package/src/services/sse-broker.ts +51 -0
- package/src/services/type-differ.ts +141 -0
- package/src/services/type-generator.ts +3853 -0
- package/src/types.ts +37 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.db = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
10
|
+
const trickleDir = path_1.default.join(process.env.HOME || "~", ".trickle");
|
|
11
|
+
fs_1.default.mkdirSync(trickleDir, { recursive: true });
|
|
12
|
+
const dbPath = path_1.default.join(trickleDir, "trickle.db");
|
|
13
|
+
const db = new better_sqlite3_1.default(dbPath);
|
|
14
|
+
exports.db = db;
|
|
15
|
+
db.pragma("journal_mode = WAL");
|
|
16
|
+
db.pragma("foreign_keys = ON");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runMigrations = runMigrations;
|
|
4
|
+
function runMigrations(db) {
|
|
5
|
+
db.exec(`
|
|
6
|
+
CREATE TABLE IF NOT EXISTS functions (
|
|
7
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
+
function_name TEXT NOT NULL,
|
|
9
|
+
module TEXT NOT NULL,
|
|
10
|
+
environment TEXT NOT NULL DEFAULT 'unknown',
|
|
11
|
+
language TEXT NOT NULL,
|
|
12
|
+
first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
13
|
+
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
14
|
+
UNIQUE(function_name, module, language)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS type_snapshots (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
function_id INTEGER NOT NULL REFERENCES functions(id),
|
|
20
|
+
type_hash TEXT NOT NULL,
|
|
21
|
+
args_type TEXT NOT NULL,
|
|
22
|
+
return_type TEXT NOT NULL,
|
|
23
|
+
variables_type TEXT,
|
|
24
|
+
sample_input TEXT,
|
|
25
|
+
sample_output TEXT,
|
|
26
|
+
env TEXT NOT NULL DEFAULT 'unknown',
|
|
27
|
+
observed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
28
|
+
UNIQUE(function_id, type_hash, env)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS errors (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
function_id INTEGER NOT NULL REFERENCES functions(id),
|
|
34
|
+
error_type TEXT NOT NULL,
|
|
35
|
+
error_message TEXT NOT NULL,
|
|
36
|
+
stack_trace TEXT,
|
|
37
|
+
args_type TEXT,
|
|
38
|
+
return_type TEXT,
|
|
39
|
+
variables_type TEXT,
|
|
40
|
+
args_snapshot TEXT,
|
|
41
|
+
type_hash TEXT,
|
|
42
|
+
env TEXT NOT NULL DEFAULT 'unknown',
|
|
43
|
+
occurred_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_snapshots_function_id ON type_snapshots(function_id);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_errors_function_id ON errors(function_id);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_errors_occurred_at ON errors(occurred_at);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_errors_env ON errors(env);
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
export declare function upsertFunction(db: Database.Database, params: {
|
|
3
|
+
functionName: string;
|
|
4
|
+
module: string;
|
|
5
|
+
environment: string;
|
|
6
|
+
language: string;
|
|
7
|
+
}): Record<string, unknown>;
|
|
8
|
+
export declare function findSnapshotByHash(db: Database.Database, functionId: number, typeHash: string, env: string): Record<string, unknown> | undefined;
|
|
9
|
+
export declare function insertSnapshot(db: Database.Database, params: {
|
|
10
|
+
functionId: number;
|
|
11
|
+
typeHash: string;
|
|
12
|
+
argsType: string;
|
|
13
|
+
returnType: string;
|
|
14
|
+
variablesType?: string;
|
|
15
|
+
sampleInput?: string;
|
|
16
|
+
sampleOutput?: string;
|
|
17
|
+
env: string;
|
|
18
|
+
}): {
|
|
19
|
+
functionId: number;
|
|
20
|
+
typeHash: string;
|
|
21
|
+
argsType: string;
|
|
22
|
+
returnType: string;
|
|
23
|
+
variablesType?: string;
|
|
24
|
+
sampleInput?: string;
|
|
25
|
+
sampleOutput?: string;
|
|
26
|
+
env: string;
|
|
27
|
+
id: number | bigint;
|
|
28
|
+
};
|
|
29
|
+
export declare function insertError(db: Database.Database, params: {
|
|
30
|
+
functionId: number;
|
|
31
|
+
errorType: string;
|
|
32
|
+
errorMessage: string;
|
|
33
|
+
stackTrace?: string;
|
|
34
|
+
argsType?: string;
|
|
35
|
+
returnType?: string;
|
|
36
|
+
variablesType?: string;
|
|
37
|
+
argsSnapshot?: string;
|
|
38
|
+
typeHash?: string;
|
|
39
|
+
env: string;
|
|
40
|
+
}): Record<string, unknown>;
|
|
41
|
+
export declare function listFunctions(db: Database.Database, params: {
|
|
42
|
+
search?: string;
|
|
43
|
+
env?: string;
|
|
44
|
+
language?: string;
|
|
45
|
+
limit?: number;
|
|
46
|
+
offset?: number;
|
|
47
|
+
}): {
|
|
48
|
+
rows: Record<string, unknown>[];
|
|
49
|
+
total: number;
|
|
50
|
+
};
|
|
51
|
+
export declare function getFunction(db: Database.Database, id: number): Record<string, unknown> | undefined;
|
|
52
|
+
export declare function getFunctionByName(db: Database.Database, functionName: string): Record<string, unknown>[];
|
|
53
|
+
export declare function listSnapshots(db: Database.Database, params: {
|
|
54
|
+
functionId: number;
|
|
55
|
+
env?: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
}): Record<string, unknown>[];
|
|
58
|
+
export declare function getLatestSnapshot(db: Database.Database, functionId: number, env?: string): Record<string, unknown> | undefined;
|
|
59
|
+
export declare function listErrors(db: Database.Database, params: {
|
|
60
|
+
functionId?: number;
|
|
61
|
+
functionName?: string;
|
|
62
|
+
env?: string;
|
|
63
|
+
since?: string;
|
|
64
|
+
limit?: number;
|
|
65
|
+
offset?: number;
|
|
66
|
+
}): {
|
|
67
|
+
rows: Record<string, unknown>[];
|
|
68
|
+
total: number;
|
|
69
|
+
};
|
|
70
|
+
export declare function getError(db: Database.Database, id: number): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.upsertFunction = upsertFunction;
|
|
4
|
+
exports.findSnapshotByHash = findSnapshotByHash;
|
|
5
|
+
exports.insertSnapshot = insertSnapshot;
|
|
6
|
+
exports.insertError = insertError;
|
|
7
|
+
exports.listFunctions = listFunctions;
|
|
8
|
+
exports.getFunction = getFunction;
|
|
9
|
+
exports.getFunctionByName = getFunctionByName;
|
|
10
|
+
exports.listSnapshots = listSnapshots;
|
|
11
|
+
exports.getLatestSnapshot = getLatestSnapshot;
|
|
12
|
+
exports.listErrors = listErrors;
|
|
13
|
+
exports.getError = getError;
|
|
14
|
+
// --- Functions ---
|
|
15
|
+
function upsertFunction(db, params) {
|
|
16
|
+
const insertStmt = db.prepare(`
|
|
17
|
+
INSERT OR IGNORE INTO functions (function_name, module, environment, language)
|
|
18
|
+
VALUES (@functionName, @module, @environment, @language)
|
|
19
|
+
`);
|
|
20
|
+
const updateStmt = db.prepare(`
|
|
21
|
+
UPDATE functions
|
|
22
|
+
SET last_seen_at = datetime('now'), environment = @environment
|
|
23
|
+
WHERE function_name = @functionName AND module = @module AND language = @language
|
|
24
|
+
`);
|
|
25
|
+
const selectStmt = db.prepare(`
|
|
26
|
+
SELECT * FROM functions
|
|
27
|
+
WHERE function_name = @functionName AND module = @module AND language = @language
|
|
28
|
+
`);
|
|
29
|
+
insertStmt.run(params);
|
|
30
|
+
updateStmt.run(params);
|
|
31
|
+
return selectStmt.get(params);
|
|
32
|
+
}
|
|
33
|
+
// --- Type Snapshots ---
|
|
34
|
+
function findSnapshotByHash(db, functionId, typeHash, env) {
|
|
35
|
+
const stmt = db.prepare(`
|
|
36
|
+
SELECT * FROM type_snapshots
|
|
37
|
+
WHERE function_id = ? AND type_hash = ? AND env = ?
|
|
38
|
+
`);
|
|
39
|
+
return stmt.get(functionId, typeHash, env);
|
|
40
|
+
}
|
|
41
|
+
function insertSnapshot(db, params) {
|
|
42
|
+
const stmt = db.prepare(`
|
|
43
|
+
INSERT INTO type_snapshots (function_id, type_hash, args_type, return_type, variables_type, sample_input, sample_output, env)
|
|
44
|
+
VALUES (@functionId, @typeHash, @argsType, @returnType, @variablesType, @sampleInput, @sampleOutput, @env)
|
|
45
|
+
`);
|
|
46
|
+
const result = stmt.run(params);
|
|
47
|
+
return { id: result.lastInsertRowid, ...params };
|
|
48
|
+
}
|
|
49
|
+
// --- Errors ---
|
|
50
|
+
function insertError(db, params) {
|
|
51
|
+
const stmt = db.prepare(`
|
|
52
|
+
INSERT INTO errors (function_id, error_type, error_message, stack_trace, args_type, return_type, variables_type, args_snapshot, type_hash, env)
|
|
53
|
+
VALUES (@functionId, @errorType, @errorMessage, @stackTrace, @argsType, @returnType, @variablesType, @argsSnapshot, @typeHash, @env)
|
|
54
|
+
`);
|
|
55
|
+
const result = stmt.run(params);
|
|
56
|
+
const selectStmt = db.prepare(`SELECT * FROM errors WHERE id = ?`);
|
|
57
|
+
return selectStmt.get(result.lastInsertRowid);
|
|
58
|
+
}
|
|
59
|
+
// --- List / Get Functions ---
|
|
60
|
+
function listFunctions(db, params) {
|
|
61
|
+
const conditions = [];
|
|
62
|
+
const bindings = [];
|
|
63
|
+
if (params.search) {
|
|
64
|
+
conditions.push("(function_name LIKE ? OR module LIKE ?)");
|
|
65
|
+
bindings.push(`%${params.search}%`, `%${params.search}%`);
|
|
66
|
+
}
|
|
67
|
+
if (params.env) {
|
|
68
|
+
conditions.push("environment = ?");
|
|
69
|
+
bindings.push(params.env);
|
|
70
|
+
}
|
|
71
|
+
if (params.language) {
|
|
72
|
+
conditions.push("language = ?");
|
|
73
|
+
bindings.push(params.language);
|
|
74
|
+
}
|
|
75
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
76
|
+
const limit = params.limit ?? 50;
|
|
77
|
+
const offset = params.offset ?? 0;
|
|
78
|
+
const stmt = db.prepare(`
|
|
79
|
+
SELECT * FROM functions ${where}
|
|
80
|
+
ORDER BY last_seen_at DESC
|
|
81
|
+
LIMIT ? OFFSET ?
|
|
82
|
+
`);
|
|
83
|
+
bindings.push(limit, offset);
|
|
84
|
+
const countStmt = db.prepare(`SELECT COUNT(*) as total FROM functions ${where}`);
|
|
85
|
+
const countBindings = bindings.slice(0, bindings.length - 2);
|
|
86
|
+
const rows = stmt.all(...bindings);
|
|
87
|
+
const total = countStmt.get(...countBindings).total;
|
|
88
|
+
return { rows, total };
|
|
89
|
+
}
|
|
90
|
+
function getFunction(db, id) {
|
|
91
|
+
const stmt = db.prepare(`SELECT * FROM functions WHERE id = ?`);
|
|
92
|
+
return stmt.get(id);
|
|
93
|
+
}
|
|
94
|
+
function getFunctionByName(db, functionName) {
|
|
95
|
+
const stmt = db.prepare(`SELECT * FROM functions WHERE function_name LIKE ?`);
|
|
96
|
+
return stmt.all(`%${functionName}%`);
|
|
97
|
+
}
|
|
98
|
+
// --- List / Get Snapshots ---
|
|
99
|
+
function listSnapshots(db, params) {
|
|
100
|
+
const conditions = ["function_id = ?"];
|
|
101
|
+
const bindings = [params.functionId];
|
|
102
|
+
if (params.env) {
|
|
103
|
+
conditions.push("env = ?");
|
|
104
|
+
bindings.push(params.env);
|
|
105
|
+
}
|
|
106
|
+
const limit = params.limit ?? 50;
|
|
107
|
+
const where = conditions.join(" AND ");
|
|
108
|
+
const stmt = db.prepare(`
|
|
109
|
+
SELECT * FROM type_snapshots
|
|
110
|
+
WHERE ${where}
|
|
111
|
+
ORDER BY observed_at DESC
|
|
112
|
+
LIMIT ?
|
|
113
|
+
`);
|
|
114
|
+
bindings.push(limit);
|
|
115
|
+
return stmt.all(...bindings);
|
|
116
|
+
}
|
|
117
|
+
function getLatestSnapshot(db, functionId, env) {
|
|
118
|
+
if (env) {
|
|
119
|
+
const stmt = db.prepare(`
|
|
120
|
+
SELECT * FROM type_snapshots
|
|
121
|
+
WHERE function_id = ? AND env = ?
|
|
122
|
+
ORDER BY observed_at DESC
|
|
123
|
+
LIMIT 1
|
|
124
|
+
`);
|
|
125
|
+
return stmt.get(functionId, env);
|
|
126
|
+
}
|
|
127
|
+
const stmt = db.prepare(`
|
|
128
|
+
SELECT * FROM type_snapshots
|
|
129
|
+
WHERE function_id = ?
|
|
130
|
+
ORDER BY observed_at DESC
|
|
131
|
+
LIMIT 1
|
|
132
|
+
`);
|
|
133
|
+
return stmt.get(functionId);
|
|
134
|
+
}
|
|
135
|
+
// --- List / Get Errors ---
|
|
136
|
+
function listErrors(db, params) {
|
|
137
|
+
const conditions = [];
|
|
138
|
+
const bindings = [];
|
|
139
|
+
if (params.functionId) {
|
|
140
|
+
conditions.push("e.function_id = ?");
|
|
141
|
+
bindings.push(params.functionId);
|
|
142
|
+
}
|
|
143
|
+
if (params.functionName) {
|
|
144
|
+
conditions.push("f.function_name LIKE ?");
|
|
145
|
+
bindings.push(`%${params.functionName}%`);
|
|
146
|
+
}
|
|
147
|
+
if (params.env) {
|
|
148
|
+
conditions.push("e.env = ?");
|
|
149
|
+
bindings.push(params.env);
|
|
150
|
+
}
|
|
151
|
+
if (params.since) {
|
|
152
|
+
conditions.push("e.occurred_at >= ?");
|
|
153
|
+
bindings.push(params.since);
|
|
154
|
+
}
|
|
155
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
156
|
+
const limit = params.limit ?? 50;
|
|
157
|
+
const offset = params.offset ?? 0;
|
|
158
|
+
const stmt = db.prepare(`
|
|
159
|
+
SELECT e.*, f.function_name, f.module, f.language
|
|
160
|
+
FROM errors e
|
|
161
|
+
JOIN functions f ON f.id = e.function_id
|
|
162
|
+
${where}
|
|
163
|
+
ORDER BY e.occurred_at DESC
|
|
164
|
+
LIMIT ? OFFSET ?
|
|
165
|
+
`);
|
|
166
|
+
bindings.push(limit, offset);
|
|
167
|
+
const countStmt = db.prepare(`
|
|
168
|
+
SELECT COUNT(*) as total
|
|
169
|
+
FROM errors e
|
|
170
|
+
JOIN functions f ON f.id = e.function_id
|
|
171
|
+
${where}
|
|
172
|
+
`);
|
|
173
|
+
const countBindings = bindings.slice(0, bindings.length - 2);
|
|
174
|
+
const rows = stmt.all(...bindings);
|
|
175
|
+
const total = countStmt.get(...countBindings).total;
|
|
176
|
+
return { rows, total };
|
|
177
|
+
}
|
|
178
|
+
function getError(db, id) {
|
|
179
|
+
const stmt = db.prepare(`
|
|
180
|
+
SELECT e.*, f.function_name, f.module, f.language
|
|
181
|
+
FROM errors e
|
|
182
|
+
JOIN functions f ON f.id = e.function_id
|
|
183
|
+
WHERE e.id = ?
|
|
184
|
+
`);
|
|
185
|
+
return stmt.get(id);
|
|
186
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const server_1 = require("./server");
|
|
4
|
+
const connection_1 = require("./db/connection");
|
|
5
|
+
const migrations_1 = require("./db/migrations");
|
|
6
|
+
(0, migrations_1.runMigrations)(connection_1.db);
|
|
7
|
+
const PORT = parseInt(process.env.PORT || "4888", 10);
|
|
8
|
+
server_1.app.listen(PORT, () => {
|
|
9
|
+
console.log(`[trickle] Backend listening on http://localhost:${PORT}`);
|
|
10
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const express_1 = require("express");
|
|
4
|
+
const connection_1 = require("../db/connection");
|
|
5
|
+
const queries_1 = require("../db/queries");
|
|
6
|
+
const router = (0, express_1.Router)();
|
|
7
|
+
const SENSITIVE_FIELD_NAMES = new Set([
|
|
8
|
+
"password", "passwd", "pass", "secret", "token", "apikey", "api_key",
|
|
9
|
+
"apiKey", "accesstoken", "access_token", "accessToken", "refreshtoken",
|
|
10
|
+
"refresh_token", "refreshToken", "privatekey", "private_key", "privateKey",
|
|
11
|
+
"ssn", "creditcard", "credit_card", "creditCard", "cvv", "pin",
|
|
12
|
+
]);
|
|
13
|
+
function tryParseJson(value) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(value);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Count properties in a TypeNode recursively */
|
|
22
|
+
function countProperties(node) {
|
|
23
|
+
if (node.kind === "object") {
|
|
24
|
+
return Object.keys(node.properties).length;
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
/** Get max nesting depth of a TypeNode */
|
|
29
|
+
function maxDepth(node, current = 0) {
|
|
30
|
+
switch (node.kind) {
|
|
31
|
+
case "object": {
|
|
32
|
+
if (Object.keys(node.properties).length === 0)
|
|
33
|
+
return current;
|
|
34
|
+
let max = current;
|
|
35
|
+
for (const val of Object.values(node.properties)) {
|
|
36
|
+
max = Math.max(max, maxDepth(val, current + 1));
|
|
37
|
+
}
|
|
38
|
+
return max;
|
|
39
|
+
}
|
|
40
|
+
case "array":
|
|
41
|
+
return maxDepth(node.element, current + 1);
|
|
42
|
+
case "union":
|
|
43
|
+
return Math.max(...node.members.map((m) => maxDepth(m, current)), current);
|
|
44
|
+
case "tuple":
|
|
45
|
+
return Math.max(...node.elements.map((e) => maxDepth(e, current + 1)), current);
|
|
46
|
+
case "map":
|
|
47
|
+
return maxDepth(node.value, current + 1);
|
|
48
|
+
case "set":
|
|
49
|
+
return maxDepth(node.element, current + 1);
|
|
50
|
+
case "promise":
|
|
51
|
+
return maxDepth(node.resolved, current);
|
|
52
|
+
default:
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Check if an object mixes camelCase and snake_case */
|
|
57
|
+
function detectNamingInconsistency(node, path) {
|
|
58
|
+
if (node.kind !== "object")
|
|
59
|
+
return null;
|
|
60
|
+
const keys = Object.keys(node.properties);
|
|
61
|
+
if (keys.length < 2)
|
|
62
|
+
return null;
|
|
63
|
+
let hasCamel = false;
|
|
64
|
+
let hasSnake = false;
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
if (key.includes("_") && key !== key.toUpperCase())
|
|
67
|
+
hasSnake = true;
|
|
68
|
+
if (/[a-z][A-Z]/.test(key))
|
|
69
|
+
hasCamel = true;
|
|
70
|
+
}
|
|
71
|
+
if (hasCamel && hasSnake) {
|
|
72
|
+
const camelKeys = keys.filter((k) => /[a-z][A-Z]/.test(k));
|
|
73
|
+
const snakeKeys = keys.filter((k) => k.includes("_") && k !== k.toUpperCase());
|
|
74
|
+
return `Mixed naming: camelCase (${camelKeys.slice(0, 3).join(", ")}) and snake_case (${snakeKeys.slice(0, 3).join(", ")})`;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** Find sensitive fields in a TypeNode recursively */
|
|
79
|
+
function findSensitiveFields(node, path, results) {
|
|
80
|
+
if (node.kind === "object") {
|
|
81
|
+
for (const [key, val] of Object.entries(node.properties)) {
|
|
82
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
83
|
+
if (SENSITIVE_FIELD_NAMES.has(key.toLowerCase())) {
|
|
84
|
+
results.push(fullPath);
|
|
85
|
+
}
|
|
86
|
+
findSensitiveFields(val, fullPath, results);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (node.kind === "array") {
|
|
90
|
+
findSensitiveFields(node.element, `${path}[]`, results);
|
|
91
|
+
}
|
|
92
|
+
else if (node.kind === "union") {
|
|
93
|
+
for (const m of node.members) {
|
|
94
|
+
findSensitiveFields(m, path, results);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Collect all field names with their types across routes */
|
|
99
|
+
function collectFieldTypes(node, prefix, result) {
|
|
100
|
+
if (node.kind === "object") {
|
|
101
|
+
for (const [key, val] of Object.entries(node.properties)) {
|
|
102
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
103
|
+
if (!result.has(key))
|
|
104
|
+
result.set(key, new Set());
|
|
105
|
+
if (val.kind === "primitive") {
|
|
106
|
+
result.get(key).add(val.name);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
result.get(key).add(val.kind);
|
|
110
|
+
}
|
|
111
|
+
collectFieldTypes(val, fullKey, result);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (node.kind === "array") {
|
|
115
|
+
collectFieldTypes(node.element, `${prefix}[]`, result);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
router.get("/", (req, res) => {
|
|
119
|
+
try {
|
|
120
|
+
const { env } = req.query;
|
|
121
|
+
const issues = [];
|
|
122
|
+
// Collect all functions with their types
|
|
123
|
+
const listed = (0, queries_1.listFunctions)(connection_1.db, {
|
|
124
|
+
env: env,
|
|
125
|
+
limit: 500,
|
|
126
|
+
});
|
|
127
|
+
const functions = [];
|
|
128
|
+
for (const fn of listed.rows) {
|
|
129
|
+
const functionId = fn.id;
|
|
130
|
+
const functionName = fn.function_name;
|
|
131
|
+
const snapshot = (0, queries_1.getLatestSnapshot)(connection_1.db, functionId, env);
|
|
132
|
+
if (!snapshot) {
|
|
133
|
+
// Untyped function
|
|
134
|
+
issues.push({
|
|
135
|
+
severity: "warning",
|
|
136
|
+
rule: "no-types",
|
|
137
|
+
message: `No type observations recorded`,
|
|
138
|
+
route: functionName,
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const argsType = tryParseJson(snapshot.args_type);
|
|
143
|
+
const returnType = tryParseJson(snapshot.return_type);
|
|
144
|
+
if (!argsType || !returnType)
|
|
145
|
+
continue;
|
|
146
|
+
functions.push({ name: functionName, argsType, returnType });
|
|
147
|
+
}
|
|
148
|
+
// Analyze each function
|
|
149
|
+
const globalFieldTypes = new Map();
|
|
150
|
+
for (const fn of functions) {
|
|
151
|
+
const routeName = fn.name;
|
|
152
|
+
// 1. Sensitive data in responses
|
|
153
|
+
const sensitiveFields = [];
|
|
154
|
+
findSensitiveFields(fn.returnType, "", sensitiveFields);
|
|
155
|
+
for (const field of sensitiveFields) {
|
|
156
|
+
issues.push({
|
|
157
|
+
severity: "error",
|
|
158
|
+
rule: "sensitive-data",
|
|
159
|
+
message: `Response exposes potentially sensitive field "${field}"`,
|
|
160
|
+
route: routeName,
|
|
161
|
+
field,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// 2. Oversized responses (>15 top-level properties)
|
|
165
|
+
const propCount = countProperties(fn.returnType);
|
|
166
|
+
if (propCount > 15) {
|
|
167
|
+
issues.push({
|
|
168
|
+
severity: "warning",
|
|
169
|
+
rule: "oversized-response",
|
|
170
|
+
message: `Response has ${propCount} top-level fields — consider pagination or field selection`,
|
|
171
|
+
route: routeName,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// 3. Deeply nested types (>4 levels)
|
|
175
|
+
const depth = maxDepth(fn.returnType);
|
|
176
|
+
if (depth > 4) {
|
|
177
|
+
issues.push({
|
|
178
|
+
severity: "warning",
|
|
179
|
+
rule: "deep-nesting",
|
|
180
|
+
message: `Response is nested ${depth} levels deep — consider flattening`,
|
|
181
|
+
route: routeName,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// 4. Naming inconsistency
|
|
185
|
+
const namingIssue = detectNamingInconsistency(fn.returnType, "");
|
|
186
|
+
if (namingIssue) {
|
|
187
|
+
issues.push({
|
|
188
|
+
severity: "warning",
|
|
189
|
+
rule: "inconsistent-naming",
|
|
190
|
+
message: namingIssue,
|
|
191
|
+
route: routeName,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
// Also check args for naming issues
|
|
195
|
+
const argsNaming = detectNamingInconsistency(fn.argsType, "");
|
|
196
|
+
if (argsNaming) {
|
|
197
|
+
issues.push({
|
|
198
|
+
severity: "warning",
|
|
199
|
+
rule: "inconsistent-naming",
|
|
200
|
+
message: `Request: ${argsNaming}`,
|
|
201
|
+
route: routeName,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// 5. Empty response type
|
|
205
|
+
if (fn.returnType.kind === "unknown" ||
|
|
206
|
+
(fn.returnType.kind === "object" && Object.keys(fn.returnType.properties).length === 0)) {
|
|
207
|
+
issues.push({
|
|
208
|
+
severity: "info",
|
|
209
|
+
rule: "empty-response",
|
|
210
|
+
message: `Response type is empty or unknown — may need more observations`,
|
|
211
|
+
route: routeName,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// Collect field types for cross-route consistency check
|
|
215
|
+
collectFieldTypes(fn.returnType, "", globalFieldTypes);
|
|
216
|
+
}
|
|
217
|
+
// 6. Cross-route type inconsistency (same field name, different types)
|
|
218
|
+
for (const [fieldName, types] of globalFieldTypes) {
|
|
219
|
+
if (types.size > 1 && !["kind", "type", "data", "value", "result", "status"].includes(fieldName)) {
|
|
220
|
+
const typeList = Array.from(types).sort().join(", ");
|
|
221
|
+
issues.push({
|
|
222
|
+
severity: "warning",
|
|
223
|
+
rule: "type-inconsistency",
|
|
224
|
+
message: `Field "${fieldName}" has different types across routes: ${typeList}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Sort: errors first, then warnings, then info
|
|
229
|
+
const severityOrder = { error: 0, warning: 1, info: 2 };
|
|
230
|
+
issues.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
231
|
+
// Summary
|
|
232
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
233
|
+
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
234
|
+
const infoCount = issues.filter((i) => i.severity === "info").length;
|
|
235
|
+
res.json({
|
|
236
|
+
summary: {
|
|
237
|
+
total: issues.length,
|
|
238
|
+
errors: errorCount,
|
|
239
|
+
warnings: warningCount,
|
|
240
|
+
info: infoCount,
|
|
241
|
+
routesAnalyzed: functions.length,
|
|
242
|
+
},
|
|
243
|
+
issues,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
console.error("Audit error:", err);
|
|
248
|
+
res.status(500).json({ error: "Internal server error" });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
exports.default = router;
|