miladyai 2.0.0-alpha.27
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/_virtual/_rolldown/runtime.js +7 -0
- package/dist/actions/emote.js +64 -0
- package/dist/actions/restart.js +81 -0
- package/dist/actions/send-message.js +152 -0
- package/dist/agent-admin-routes.js +82 -0
- package/dist/agent-lifecycle-routes.js +79 -0
- package/dist/agent-transfer-routes.js +102 -0
- package/dist/api/agent-admin-routes.js +82 -0
- package/dist/api/agent-lifecycle-routes.js +79 -0
- package/dist/api/agent-transfer-routes.js +102 -0
- package/dist/api/apps-hyperscape-routes.js +58 -0
- package/dist/api/apps-routes.js +114 -0
- package/dist/api/auth-routes.js +56 -0
- package/dist/api/autonomy-routes.js +44 -0
- package/dist/api/bug-report-routes.js +111 -0
- package/dist/api/character-routes.js +195 -0
- package/dist/api/cloud-routes.js +330 -0
- package/dist/api/cloud-status-routes.js +155 -0
- package/dist/api/compat-utils.js +111 -0
- package/dist/api/database.js +735 -0
- package/dist/api/diagnostics-routes.js +205 -0
- package/dist/api/drop-service.js +134 -0
- package/dist/api/early-logs.js +86 -0
- package/dist/api/http-helpers.js +131 -0
- package/dist/api/knowledge-routes.js +534 -0
- package/dist/api/memory-bounds.js +71 -0
- package/dist/api/models-routes.js +28 -0
- package/dist/api/og-tracker.js +36 -0
- package/dist/api/permissions-routes.js +109 -0
- package/dist/api/plugin-validation.js +198 -0
- package/dist/api/provider-switch-config.js +41 -0
- package/dist/api/registry-routes.js +86 -0
- package/dist/api/registry-service.js +164 -0
- package/dist/api/sandbox-routes.js +1112 -0
- package/dist/api/server.js +7949 -0
- package/dist/api/subscription-routes.js +172 -0
- package/dist/api/terminal-run-limits.js +24 -0
- package/dist/api/training-routes.js +158 -0
- package/dist/api/trajectory-routes.js +300 -0
- package/dist/api/trigger-routes.js +246 -0
- package/dist/api/twitter-verify.js +134 -0
- package/dist/api/tx-service.js +108 -0
- package/dist/api/wallet-routes.js +266 -0
- package/dist/api/wallet.js +568 -0
- package/dist/api/whatsapp-routes.js +182 -0
- package/dist/api/zip-utils.js +109 -0
- package/dist/apps-hyperscape-routes.js +58 -0
- package/dist/apps-routes.js +114 -0
- package/dist/ascii.js +20 -0
- package/dist/auth/anthropic.js +44 -0
- package/dist/auth/apply-stealth.js +41 -0
- package/dist/auth/claude-code-stealth.js +78 -0
- package/dist/auth/credentials.js +156 -0
- package/dist/auth/index.js +5 -0
- package/dist/auth/openai-codex.js +66 -0
- package/dist/auth/types.js +9 -0
- package/dist/auth-routes.js +56 -0
- package/dist/autonomy-routes.js +44 -0
- package/dist/bug-report-routes.js +111 -0
- package/dist/build-info.json +6 -0
- package/dist/character-routes.js +195 -0
- package/dist/cli/argv.js +63 -0
- package/dist/cli/banner.js +34 -0
- package/dist/cli/cli-name.js +21 -0
- package/dist/cli/cli-utils.js +16 -0
- package/dist/cli/git-commit.js +78 -0
- package/dist/cli/parse-duration.js +15 -0
- package/dist/cli/plugins-cli.js +590 -0
- package/dist/cli/profile-utils.js +9 -0
- package/dist/cli/profile.js +95 -0
- package/dist/cli/program/build-program.js +17 -0
- package/dist/cli/program/command-registry.js +23 -0
- package/dist/cli/program/help.js +47 -0
- package/dist/cli/program/preaction.js +33 -0
- package/dist/cli/program/register.config.js +106 -0
- package/dist/cli/program/register.configure.js +20 -0
- package/dist/cli/program/register.dashboard.js +124 -0
- package/dist/cli/program/register.models.js +23 -0
- package/dist/cli/program/register.setup.js +36 -0
- package/dist/cli/program/register.start.js +22 -0
- package/dist/cli/program/register.subclis.js +70 -0
- package/dist/cli/program/register.tui.js +163 -0
- package/dist/cli/program/register.update.js +154 -0
- package/dist/cli/program.js +3 -0
- package/dist/cli/run-main.js +37 -0
- package/dist/cli/version.js +7 -0
- package/dist/cloud/validate-url.js +93 -0
- package/dist/cloud-routes.js +330 -0
- package/dist/cloud-status-routes.js +155 -0
- package/dist/compat-utils.js +111 -0
- package/dist/config/config.js +69 -0
- package/dist/config/env-vars.js +19 -0
- package/dist/config/includes.js +121 -0
- package/dist/config/object-utils.js +7 -0
- package/dist/config/paths.js +38 -0
- package/dist/config/plugin-auto-enable.js +231 -0
- package/dist/config/schema.js +864 -0
- package/dist/config/telegram-custom-commands.js +76 -0
- package/dist/config/zod-schema.agent-runtime.js +519 -0
- package/dist/config/zod-schema.core.js +538 -0
- package/dist/config/zod-schema.hooks.js +103 -0
- package/dist/config/zod-schema.js +488 -0
- package/dist/config/zod-schema.providers-core.js +785 -0
- package/dist/config/zod-schema.session.js +73 -0
- package/dist/core-plugins.js +37 -0
- package/dist/custom-actions.js +250 -0
- package/dist/database.js +735 -0
- package/dist/diagnostics/integration-observability.js +57 -0
- package/dist/diagnostics-routes.js +205 -0
- package/dist/drop-service.js +134 -0
- package/dist/early-logs.js +24 -0
- package/dist/eliza.js +2061 -0
- package/dist/emotes/catalog.js +271 -0
- package/dist/entry.js +40 -0
- package/dist/hooks/discovery.js +167 -0
- package/dist/hooks/eligibility.js +64 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/loader.js +147 -0
- package/dist/hooks/registry.js +55 -0
- package/dist/http-helpers.js +131 -0
- package/dist/index.js +49 -0
- package/dist/knowledge-routes.js +534 -0
- package/dist/memory-bounds.js +71 -0
- package/dist/milady-plugin.js +90 -0
- package/dist/models-routes.js +28 -0
- package/dist/onboarding-names.js +78 -0
- package/dist/onboarding-presets.js +922 -0
- package/dist/package.json +1 -0
- package/dist/permissions-routes.js +109 -0
- package/dist/plugin-validation.js +107 -0
- package/dist/plugins/whatsapp/actions.js +91 -0
- package/dist/plugins/whatsapp/index.js +16 -0
- package/dist/plugins/whatsapp/service.js +270 -0
- package/dist/provider-switch-config.js +41 -0
- package/dist/providers/admin-trust.js +46 -0
- package/dist/providers/autonomous-state.js +101 -0
- package/dist/providers/session-bridge.js +86 -0
- package/dist/providers/session-utils.js +36 -0
- package/dist/providers/simple-mode.js +50 -0
- package/dist/providers/ui-catalog.js +15 -0
- package/dist/providers/workspace-provider.js +93 -0
- package/dist/providers/workspace.js +348 -0
- package/dist/registry-routes.js +86 -0
- package/dist/registry-service.js +164 -0
- package/dist/restart.js +40 -0
- package/dist/runtime/core-plugins.js +37 -0
- package/dist/runtime/custom-actions.js +250 -0
- package/dist/runtime/eliza.js +2061 -0
- package/dist/runtime/embedding-manager-support.js +185 -0
- package/dist/runtime/embedding-manager.js +193 -0
- package/dist/runtime/embedding-presets.js +54 -0
- package/dist/runtime/embedding-state.js +8 -0
- package/dist/runtime/milady-plugin.js +90 -0
- package/dist/runtime/onboarding-names.js +78 -0
- package/dist/runtime/restart.js +40 -0
- package/dist/runtime/version.js +7 -0
- package/dist/sandbox-routes.js +1112 -0
- package/dist/security/audit-log.js +149 -0
- package/dist/security/network-policy.js +70 -0
- package/dist/server.js +7949 -0
- package/dist/services/agent-export.js +559 -0
- package/dist/services/app-manager.js +389 -0
- package/dist/services/browser-capture.js +86 -0
- package/dist/services/fallback-training-service.js +128 -0
- package/dist/services/mcp-marketplace.js +134 -0
- package/dist/services/plugin-installer.js +396 -0
- package/dist/services/plugin-manager-types.js +15 -0
- package/dist/services/registry-client-app-meta.js +144 -0
- package/dist/services/registry-client-endpoints.js +166 -0
- package/dist/services/registry-client-local.js +271 -0
- package/dist/services/registry-client-network.js +93 -0
- package/dist/services/registry-client-queries.js +70 -0
- package/dist/services/registry-client.js +157 -0
- package/dist/services/sandbox-engine.js +511 -0
- package/dist/services/sandbox-manager.js +297 -0
- package/dist/services/self-updater.js +175 -0
- package/dist/services/skill-catalog-client.js +119 -0
- package/dist/services/skill-marketplace.js +521 -0
- package/dist/services/stream-manager.js +236 -0
- package/dist/services/update-checker.js +121 -0
- package/dist/services/update-notifier.js +29 -0
- package/dist/services/version-compat.js +78 -0
- package/dist/services/whatsapp-pairing.js +196 -0
- package/dist/shared/ui-catalog-prompt.js +728 -0
- package/dist/subscription-routes.js +172 -0
- package/dist/terminal/links.js +19 -0
- package/dist/terminal/palette.js +14 -0
- package/dist/terminal/theme.js +25 -0
- package/dist/terminal-run-limits.js +24 -0
- package/dist/training-routes.js +158 -0
- package/dist/trajectory-routes.js +300 -0
- package/dist/trigger-routes.js +246 -0
- package/dist/triggers/action.js +218 -0
- package/dist/triggers/runtime.js +281 -0
- package/dist/triggers/scheduling.js +295 -0
- package/dist/triggers/types.js +5 -0
- package/dist/tui/components/assistant-message.js +76 -0
- package/dist/tui/components/chat-editor.js +34 -0
- package/dist/tui/components/embeddings-overlay.js +46 -0
- package/dist/tui/components/footer.js +60 -0
- package/dist/tui/components/index.js +15 -0
- package/dist/tui/components/modal-frame.js +45 -0
- package/dist/tui/components/modal-style.js +15 -0
- package/dist/tui/components/model-selector.js +70 -0
- package/dist/tui/components/pinned-chat-layout.js +46 -0
- package/dist/tui/components/plugins-endpoints-tab.js +196 -0
- package/dist/tui/components/plugins-installed-tab-view.js +69 -0
- package/dist/tui/components/plugins-installed-tab.js +319 -0
- package/dist/tui/components/plugins-overlay-catalog.js +81 -0
- package/dist/tui/components/plugins-overlay-data-api.js +21 -0
- package/dist/tui/components/plugins-overlay-data-shared.js +20 -0
- package/dist/tui/components/plugins-overlay-data.js +323 -0
- package/dist/tui/components/plugins-overlay.js +117 -0
- package/dist/tui/components/plugins-store-tab.js +148 -0
- package/dist/tui/components/settings-overlay.js +61 -0
- package/dist/tui/components/status-bar.js +64 -0
- package/dist/tui/components/tool-execution.js +68 -0
- package/dist/tui/components/user-message.js +22 -0
- package/dist/tui/eliza-tui-bridge.js +606 -0
- package/dist/tui/index.js +370 -0
- package/dist/tui/modal-presets.js +33 -0
- package/dist/tui/model-spec.js +46 -0
- package/dist/tui/sse-parser.js +78 -0
- package/dist/tui/theme.js +110 -0
- package/dist/tui/titlebar-spinner.js +62 -0
- package/dist/tui/tui-app.js +311 -0
- package/dist/tui/ws-client.js +215 -0
- package/dist/twitter-verify.js +134 -0
- package/dist/tx-service.js +108 -0
- package/dist/utils/exec-safety.js +17 -0
- package/dist/utils/globals.js +20 -0
- package/dist/utils/milady-root.js +61 -0
- package/dist/utils/number-parsing.js +37 -0
- package/dist/version-resolver.js +37 -0
- package/dist/version.js +7 -0
- package/dist/wallet-routes.js +266 -0
- package/dist/wallet.js +568 -0
- package/dist/whatsapp-routes.js +182 -0
- package/dist/zip-utils.js +109 -0
- package/milady.mjs +14 -0
- package/package.json +111 -0
package/dist/database.js
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import { loadMiladyConfig, saveMiladyConfig } from "./config/config.js";
|
|
2
|
+
import { isLoopbackHost, normalizeHostLike, normalizeIpForPolicy } from "./security/network-policy.js";
|
|
3
|
+
import { readJsonBody as readJsonBody$1, sendJson, sendJsonError } from "./http-helpers.js";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { logger } from "@elizaos/core";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import dns from "node:dns";
|
|
8
|
+
|
|
9
|
+
//#region src/api/database.ts
|
|
10
|
+
/**
|
|
11
|
+
* Database management API handlers for the Milady Control UI.
|
|
12
|
+
*
|
|
13
|
+
* Provides endpoints for:
|
|
14
|
+
* - Database provider configuration (PGLite vs Postgres)
|
|
15
|
+
* - Connection testing for remote Postgres
|
|
16
|
+
* - Table browsing and introspection
|
|
17
|
+
* - Row-level CRUD operations
|
|
18
|
+
* - Raw SQL query execution
|
|
19
|
+
* - Database status and health
|
|
20
|
+
*
|
|
21
|
+
* All data endpoints use the active runtime's database adapter (Drizzle ORM)
|
|
22
|
+
* so they work identically for both PGLite and Postgres.
|
|
23
|
+
*/
|
|
24
|
+
async function readJsonBody(req, res) {
|
|
25
|
+
return readJsonBody$1(req, res, { maxBytes: 2 * 1024 * 1024 });
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Safely quote a SQL identifier (table or column name).
|
|
29
|
+
* Postgres uses double-quote escaping: embedded " becomes "".
|
|
30
|
+
*/
|
|
31
|
+
function quoteIdent(name) {
|
|
32
|
+
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build a Postgres connection string from individual credential fields.
|
|
36
|
+
*/
|
|
37
|
+
function buildConnectionString(creds) {
|
|
38
|
+
if (creds.connectionString) return creds.connectionString;
|
|
39
|
+
const host = creds.host ?? "localhost";
|
|
40
|
+
const port = creds.port ?? 5432;
|
|
41
|
+
const user = encodeURIComponent(creds.user ?? "postgres");
|
|
42
|
+
const password = creds.password ? encodeURIComponent(creds.password) : "";
|
|
43
|
+
const database = creds.database ?? "postgres";
|
|
44
|
+
return `postgresql://${password ? `${user}:${password}` : user}@${host}:${port}/${database}${creds.ssl ? "?sslmode=require" : ""}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Return a copy of credentials with host pinned to a validated IP address.
|
|
48
|
+
* For connection strings, rewrites URL hostname to avoid re-resolution later.
|
|
49
|
+
*/
|
|
50
|
+
function withPinnedHost(creds, pinnedHost) {
|
|
51
|
+
const normalizedPinned = pinnedHost.replace(/^::ffff:/i, "");
|
|
52
|
+
const next = {
|
|
53
|
+
...creds,
|
|
54
|
+
host: normalizedPinned
|
|
55
|
+
};
|
|
56
|
+
if (next.connectionString) try {
|
|
57
|
+
const parsed = new URL(next.connectionString);
|
|
58
|
+
parsed.hostname = normalizedPinned;
|
|
59
|
+
parsed.searchParams.set("host", normalizedPinned);
|
|
60
|
+
parsed.searchParams.set("hostaddr", normalizedPinned);
|
|
61
|
+
next.connectionString = parsed.toString();
|
|
62
|
+
} catch {
|
|
63
|
+
delete next.connectionString;
|
|
64
|
+
}
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
const dnsLookupAll = promisify(dns.lookup);
|
|
68
|
+
/**
|
|
69
|
+
* IP ranges that are ALWAYS blocked regardless of bind address.
|
|
70
|
+
* Cloud metadata and "this" network are never legitimate Postgres targets.
|
|
71
|
+
*/
|
|
72
|
+
const ALWAYS_BLOCKED_IP_PATTERNS = [
|
|
73
|
+
/^169\.254\./,
|
|
74
|
+
/^0\./,
|
|
75
|
+
/^fe[89ab][0-9a-f]:/i
|
|
76
|
+
];
|
|
77
|
+
/**
|
|
78
|
+
* Private/internal IP ranges — blocked only when the API is bound to a
|
|
79
|
+
* non-loopback address (i.e. remotely reachable). When bound to 127.0.0.1
|
|
80
|
+
* (the default), these are allowed since local Postgres is the most common
|
|
81
|
+
* setup and an attacker who can reach the loopback API already has local
|
|
82
|
+
* network access.
|
|
83
|
+
*/
|
|
84
|
+
const PRIVATE_IP_PATTERNS = [
|
|
85
|
+
/^127\./,
|
|
86
|
+
/^10\./,
|
|
87
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
88
|
+
/^192\.168\./,
|
|
89
|
+
/^::1$/,
|
|
90
|
+
/^f[cd][0-9a-f]{2}:/i
|
|
91
|
+
];
|
|
92
|
+
/**
|
|
93
|
+
* Returns true when the API server is bound to a loopback-only address.
|
|
94
|
+
* In that case, private/internal IP ranges are allowed for DB connections
|
|
95
|
+
* since only local processes can reach the API.
|
|
96
|
+
*/
|
|
97
|
+
function isApiLoopbackOnly() {
|
|
98
|
+
let bind = (process.env.MILADY_API_BIND ?? "127.0.0.1").trim().toLowerCase();
|
|
99
|
+
if (!bind) bind = "127.0.0.1";
|
|
100
|
+
if (bind.startsWith("http://") || bind.startsWith("https://")) try {
|
|
101
|
+
bind = new URL(bind).hostname.toLowerCase();
|
|
102
|
+
} catch {}
|
|
103
|
+
const bracketedIpv6 = /^\[([^\]]+)\](?::\d+)?$/.exec(bind);
|
|
104
|
+
if (bracketedIpv6?.[1]) bind = bracketedIpv6[1];
|
|
105
|
+
else {
|
|
106
|
+
const singleColonHostPort = /^([^:]+):(\d+)$/.exec(bind);
|
|
107
|
+
if (singleColonHostPort?.[1]) bind = singleColonHostPort[1];
|
|
108
|
+
}
|
|
109
|
+
bind = bind.replace(/^\[|\]$/g, "");
|
|
110
|
+
return isLoopbackHost(bind);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Extract all potential hosts from a Postgres connection string or credentials object.
|
|
114
|
+
* Includes query params like ?host= and ?hostaddr= which Postgres clients honor.
|
|
115
|
+
* Returns empty array if no host can be determined.
|
|
116
|
+
*/
|
|
117
|
+
function extractHosts(creds) {
|
|
118
|
+
if (creds.connectionString) try {
|
|
119
|
+
const url = new URL(creds.connectionString);
|
|
120
|
+
const hosts = [];
|
|
121
|
+
const hostParam = url.searchParams.get("host");
|
|
122
|
+
if (hostParam) hosts.push(...hostParam.split(",").map((h) => normalizeHostLike(h)).filter(Boolean));
|
|
123
|
+
const hostAddrParam = url.searchParams.get("hostaddr");
|
|
124
|
+
if (hostAddrParam) hosts.push(...hostAddrParam.split(",").map((h) => normalizeHostLike(h)).filter(Boolean));
|
|
125
|
+
if (url.hostname) hosts.push(normalizeHostLike(url.hostname));
|
|
126
|
+
return [...new Set(hosts)];
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
if (creds.host) {
|
|
131
|
+
const host = normalizeHostLike(creds.host);
|
|
132
|
+
return host ? [host] : [];
|
|
133
|
+
}
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Check whether an IP address falls in a blocked range.
|
|
138
|
+
* When the API is remotely reachable, private ranges are also blocked.
|
|
139
|
+
*/
|
|
140
|
+
function isBlockedIp(ip) {
|
|
141
|
+
const normalized = normalizeIpForPolicy(ip);
|
|
142
|
+
if (ALWAYS_BLOCKED_IP_PATTERNS.some((p) => p.test(normalized))) return true;
|
|
143
|
+
if (!isApiLoopbackOnly() && PRIVATE_IP_PATTERNS.some((p) => p.test(normalized))) return true;
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Validate that all target hosts do not resolve to blocked addresses.
|
|
148
|
+
*
|
|
149
|
+
* Performs DNS resolution to catch hostnames like `metadata.google.internal`
|
|
150
|
+
* or `169.254.169.254.nip.io` that resolve to link-local / cloud metadata
|
|
151
|
+
* IPs. Also handles IPv6-mapped IPv4 addresses (e.g. `::ffff:169.254.x.y`).
|
|
152
|
+
*
|
|
153
|
+
* Returns a validation result including a pinned host IP when successful.
|
|
154
|
+
*/
|
|
155
|
+
async function validateDbHost(creds, opts = {}) {
|
|
156
|
+
const hosts = extractHosts(creds);
|
|
157
|
+
if (hosts.length === 0) return {
|
|
158
|
+
error: "Could not determine target host from the provided credentials.",
|
|
159
|
+
pinnedHost: null
|
|
160
|
+
};
|
|
161
|
+
let pinnedHost = null;
|
|
162
|
+
for (const host of hosts) {
|
|
163
|
+
const literalNormalized = normalizeIpForPolicy(host);
|
|
164
|
+
if (isBlockedIp(literalNormalized)) return {
|
|
165
|
+
error: `Connection to "${host}" is blocked: link-local and metadata addresses are not allowed.`,
|
|
166
|
+
pinnedHost: null
|
|
167
|
+
};
|
|
168
|
+
if (net.isIP(literalNormalized)) {
|
|
169
|
+
if (!pinnedHost) pinnedHost = literalNormalized;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const results = await dnsLookupAll(host, { all: true });
|
|
174
|
+
const addresses = Array.isArray(results) ? results : [results];
|
|
175
|
+
for (const entry of addresses) {
|
|
176
|
+
const ip = typeof entry === "string" ? entry : entry.address;
|
|
177
|
+
const normalized = normalizeIpForPolicy(ip);
|
|
178
|
+
if (isBlockedIp(normalized)) return {
|
|
179
|
+
error: `Connection to "${host}" is blocked: it resolves to ${ip} which is a link-local or metadata address.`,
|
|
180
|
+
pinnedHost: null
|
|
181
|
+
};
|
|
182
|
+
if (!pinnedHost) pinnedHost = normalized;
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
if (!opts.allowUnresolvedHostnames) return {
|
|
186
|
+
error: `Connection to "${host}" failed DNS resolution during validation. Use a resolvable hostname or a literal IP address.`,
|
|
187
|
+
pinnedHost: null
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!pinnedHost) {
|
|
192
|
+
if (opts.allowUnresolvedHostnames) return {
|
|
193
|
+
error: null,
|
|
194
|
+
pinnedHost: null
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
error: "Could not validate any host to a concrete IP address.",
|
|
198
|
+
pinnedHost: null
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
error: null,
|
|
203
|
+
pinnedHost
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Convert a JS value to a SQL literal for use in raw queries. */
|
|
207
|
+
function sqlLiteral(v) {
|
|
208
|
+
if (v === null || v === void 0) return "NULL";
|
|
209
|
+
if (typeof v === "number") return String(v);
|
|
210
|
+
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
|
211
|
+
if (typeof v === "object") return `'${JSON.stringify(v).replace(/'/g, "''")}'::jsonb`;
|
|
212
|
+
return `'${String(v).replace(/'/g, "''")}'`;
|
|
213
|
+
}
|
|
214
|
+
/** Build a "col = val" SQL assignment clause. */
|
|
215
|
+
function sqlAssign(col, val) {
|
|
216
|
+
if (val === null || val === void 0) return `${quoteIdent(col)} = NULL`;
|
|
217
|
+
return `${quoteIdent(col)} = ${sqlLiteral(val)}`;
|
|
218
|
+
}
|
|
219
|
+
/** Build a "col = val" or "col IS NULL" SQL WHERE predicate. */
|
|
220
|
+
function sqlPredicate(col, val) {
|
|
221
|
+
if (val === null || val === void 0) return `${quoteIdent(col)} IS NULL`;
|
|
222
|
+
return `${quoteIdent(col)} = ${sqlLiteral(val)}`;
|
|
223
|
+
}
|
|
224
|
+
let _sqlHelper = null;
|
|
225
|
+
async function getDrizzleSql() {
|
|
226
|
+
if (!_sqlHelper) _sqlHelper = (await import("drizzle-orm")).sql;
|
|
227
|
+
return _sqlHelper;
|
|
228
|
+
}
|
|
229
|
+
/** Execute raw SQL via the runtime's Drizzle adapter. */
|
|
230
|
+
async function executeRawSql(runtime, sqlText) {
|
|
231
|
+
const drizzleSql = await getDrizzleSql();
|
|
232
|
+
const db = runtime.adapter.db;
|
|
233
|
+
const rawQuery = drizzleSql?.raw(sqlText);
|
|
234
|
+
if (!rawQuery) throw new Error("SQL module not available");
|
|
235
|
+
const result = await db.execute(rawQuery);
|
|
236
|
+
const rows = Array.isArray(result.rows) ? result.rows : result;
|
|
237
|
+
let columns = [];
|
|
238
|
+
if (result.fields && Array.isArray(result.fields)) columns = result.fields.map((f) => f.name);
|
|
239
|
+
else if (rows.length > 0) columns = Object.keys(rows[0]);
|
|
240
|
+
return {
|
|
241
|
+
rows,
|
|
242
|
+
columns
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Detect the current database provider from environment / runtime state.
|
|
247
|
+
*/
|
|
248
|
+
function detectCurrentProvider() {
|
|
249
|
+
return process.env.POSTGRES_URL ? "postgres" : "pglite";
|
|
250
|
+
}
|
|
251
|
+
/** Verify a table name refers to a real user table. */
|
|
252
|
+
async function assertTableExists(runtime, tableName) {
|
|
253
|
+
const { rows } = await executeRawSql(runtime, `SELECT 1 FROM information_schema.tables
|
|
254
|
+
WHERE table_name = '${tableName.replace(/'/g, "''")}'
|
|
255
|
+
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
256
|
+
AND table_type = 'BASE TABLE'
|
|
257
|
+
LIMIT 1`);
|
|
258
|
+
return rows.length > 0;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* GET /api/database/status
|
|
262
|
+
* Returns current connection status, provider, table count, version.
|
|
263
|
+
*/
|
|
264
|
+
async function handleGetStatus(_req, res, runtime) {
|
|
265
|
+
const provider = detectCurrentProvider();
|
|
266
|
+
if (!runtime?.adapter) {
|
|
267
|
+
sendJson(res, {
|
|
268
|
+
provider,
|
|
269
|
+
connected: false,
|
|
270
|
+
serverVersion: null,
|
|
271
|
+
tableCount: 0,
|
|
272
|
+
pgliteDataDir: process.env.PGLITE_DATA_DIR ?? null,
|
|
273
|
+
postgresHost: null
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const { rows } = await executeRawSql(runtime, "SELECT version()");
|
|
278
|
+
const serverVersion = rows.length > 0 ? String(rows[0].version ?? "") : null;
|
|
279
|
+
const tableResult = await executeRawSql(runtime, `SELECT count(*) AS cnt
|
|
280
|
+
FROM information_schema.tables
|
|
281
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
282
|
+
AND table_type = 'BASE TABLE'`);
|
|
283
|
+
sendJson(res, {
|
|
284
|
+
provider,
|
|
285
|
+
connected: true,
|
|
286
|
+
serverVersion,
|
|
287
|
+
tableCount: tableResult.rows.length > 0 ? Number(tableResult.rows[0].cnt ?? 0) : 0,
|
|
288
|
+
pgliteDataDir: provider === "pglite" ? process.env.PGLITE_DATA_DIR ?? null : null,
|
|
289
|
+
postgresHost: provider === "postgres" ? process.env.POSTGRES_URL?.replace(/^postgresql:\/\/[^@]*@/, "").replace(/\/.*$/, "") ?? null : null
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* GET /api/database/config
|
|
294
|
+
* Returns the persisted database configuration from milady.json.
|
|
295
|
+
*/
|
|
296
|
+
function handleGetConfig(_req, res) {
|
|
297
|
+
const dbConfig = loadMiladyConfig().database ?? { provider: "pglite" };
|
|
298
|
+
const sanitized = { ...dbConfig };
|
|
299
|
+
if (sanitized.postgres?.password) sanitized.postgres = {
|
|
300
|
+
...sanitized.postgres,
|
|
301
|
+
password: "••••••••"
|
|
302
|
+
};
|
|
303
|
+
if (sanitized.postgres?.connectionString) sanitized.postgres = {
|
|
304
|
+
...sanitized.postgres,
|
|
305
|
+
connectionString: sanitized.postgres.connectionString.replace(/:([^@]+)@/, ":••••••••@")
|
|
306
|
+
};
|
|
307
|
+
sendJson(res, {
|
|
308
|
+
config: sanitized,
|
|
309
|
+
activeProvider: detectCurrentProvider(),
|
|
310
|
+
needsRestart: (dbConfig.provider ?? "pglite") !== detectCurrentProvider()
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* PUT /api/database/config
|
|
315
|
+
* Saves new database configuration. Does NOT restart the agent automatically;
|
|
316
|
+
* the UI prompts the user to restart.
|
|
317
|
+
*/
|
|
318
|
+
async function handlePutConfig(req, res) {
|
|
319
|
+
const body = await readJsonBody(req, res);
|
|
320
|
+
if (!body) return;
|
|
321
|
+
if (body.provider && body.provider !== "pglite" && body.provider !== "postgres") {
|
|
322
|
+
sendJsonError(res, `Invalid provider: ${String(body.provider)}. Must be "pglite" or "postgres".`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const config = loadMiladyConfig();
|
|
326
|
+
const existingDb = config.database ?? {};
|
|
327
|
+
const effectiveProvider = body.provider ?? existingDb.provider ?? "pglite";
|
|
328
|
+
let validatedPostgres = null;
|
|
329
|
+
if (body.postgres) {
|
|
330
|
+
const pg = body.postgres;
|
|
331
|
+
if (effectiveProvider === "postgres" && !pg.connectionString && !pg.host) {
|
|
332
|
+
sendJsonError(res, "Postgres configuration requires either a connectionString or at least a host.");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const validation = await validateDbHost(pg, { allowUnresolvedHostnames: Boolean(pg.connectionString) });
|
|
336
|
+
if (validation.error) {
|
|
337
|
+
sendJsonError(res, validation.error);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
validatedPostgres = validation.pinnedHost ? withPinnedHost(pg, validation.pinnedHost) : pg;
|
|
341
|
+
}
|
|
342
|
+
const merged = {
|
|
343
|
+
...existingDb,
|
|
344
|
+
...body
|
|
345
|
+
};
|
|
346
|
+
if (merged.provider === "postgres" && body.postgres) merged.postgres = {
|
|
347
|
+
...existingDb.postgres,
|
|
348
|
+
...validatedPostgres ?? body.postgres
|
|
349
|
+
};
|
|
350
|
+
if (merged.provider === "pglite" && body.pglite) merged.pglite = {
|
|
351
|
+
...existingDb.pglite,
|
|
352
|
+
...body.pglite
|
|
353
|
+
};
|
|
354
|
+
config.database = merged;
|
|
355
|
+
saveMiladyConfig(config);
|
|
356
|
+
logger.info({
|
|
357
|
+
src: "database-api",
|
|
358
|
+
provider: merged.provider
|
|
359
|
+
}, "Database configuration saved");
|
|
360
|
+
sendJson(res, {
|
|
361
|
+
saved: true,
|
|
362
|
+
config: merged,
|
|
363
|
+
needsRestart: (merged.provider ?? "pglite") !== detectCurrentProvider()
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* POST /api/database/test
|
|
368
|
+
* Tests a Postgres connection without persisting anything.
|
|
369
|
+
* Body: { connectionString?, host?, port?, user?, password?, database?, ssl? }
|
|
370
|
+
*/
|
|
371
|
+
async function handleTestConnection(req, res) {
|
|
372
|
+
const body = await readJsonBody(req, res);
|
|
373
|
+
if (!body) return;
|
|
374
|
+
const validation = await validateDbHost(body);
|
|
375
|
+
if (validation.error) {
|
|
376
|
+
sendJsonError(res, validation.error);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const connectionString = buildConnectionString(validation.pinnedHost ? withPinnedHost(body, validation.pinnedHost) : body);
|
|
380
|
+
const start = Date.now();
|
|
381
|
+
let Pool;
|
|
382
|
+
try {
|
|
383
|
+
const pgModule = await import("pg");
|
|
384
|
+
Pool = pgModule.default?.Pool ?? pgModule.Pool;
|
|
385
|
+
} catch {
|
|
386
|
+
sendJson(res, {
|
|
387
|
+
success: false,
|
|
388
|
+
serverVersion: null,
|
|
389
|
+
error: "PostgreSQL client library (pg) is not available. Ensure @elizaos/plugin-sql is installed.",
|
|
390
|
+
durationMs: Date.now() - start
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const pool = new Pool({
|
|
395
|
+
connectionString,
|
|
396
|
+
max: 1,
|
|
397
|
+
connectionTimeoutMillis: 1e4,
|
|
398
|
+
idleTimeoutMillis: 5e3
|
|
399
|
+
});
|
|
400
|
+
let client = null;
|
|
401
|
+
try {
|
|
402
|
+
client = await pool.connect();
|
|
403
|
+
const versionResult = await client.query("SELECT version()");
|
|
404
|
+
sendJson(res, {
|
|
405
|
+
success: true,
|
|
406
|
+
serverVersion: String(versionResult.rows[0]?.version ?? ""),
|
|
407
|
+
error: null,
|
|
408
|
+
durationMs: Date.now() - start
|
|
409
|
+
});
|
|
410
|
+
} catch (err) {
|
|
411
|
+
const durationMs = Date.now() - start;
|
|
412
|
+
sendJson(res, {
|
|
413
|
+
success: false,
|
|
414
|
+
serverVersion: null,
|
|
415
|
+
error: err instanceof Error ? err.message : String(err),
|
|
416
|
+
durationMs
|
|
417
|
+
});
|
|
418
|
+
} finally {
|
|
419
|
+
if (client) client.release();
|
|
420
|
+
await pool.end();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* GET /api/database/tables
|
|
425
|
+
* Lists all user tables with column metadata and approximate row counts.
|
|
426
|
+
*/
|
|
427
|
+
async function handleGetTables(_req, res, runtime) {
|
|
428
|
+
const tablesResult = await executeRawSql(runtime, `SELECT
|
|
429
|
+
t.table_schema AS schema,
|
|
430
|
+
t.table_name AS name,
|
|
431
|
+
COALESCE(s.n_live_tup, 0) AS row_count
|
|
432
|
+
FROM information_schema.tables t
|
|
433
|
+
LEFT JOIN pg_stat_user_tables s
|
|
434
|
+
ON s.schemaname = t.table_schema
|
|
435
|
+
AND s.relname = t.table_name
|
|
436
|
+
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
437
|
+
AND t.table_type = 'BASE TABLE'
|
|
438
|
+
ORDER BY t.table_schema, t.table_name`);
|
|
439
|
+
const columnsResult = await executeRawSql(runtime, `SELECT
|
|
440
|
+
c.table_schema AS schema,
|
|
441
|
+
c.table_name AS table_name,
|
|
442
|
+
c.column_name AS name,
|
|
443
|
+
c.data_type AS type,
|
|
444
|
+
(c.is_nullable = 'YES') AS nullable,
|
|
445
|
+
c.column_default AS default_value,
|
|
446
|
+
COALESCE(
|
|
447
|
+
(SELECT true
|
|
448
|
+
FROM information_schema.table_constraints tc
|
|
449
|
+
JOIN information_schema.key_column_usage kcu
|
|
450
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
451
|
+
AND tc.table_schema = kcu.table_schema
|
|
452
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
453
|
+
AND tc.table_schema = c.table_schema
|
|
454
|
+
AND tc.table_name = c.table_name
|
|
455
|
+
AND kcu.column_name = c.column_name),
|
|
456
|
+
false
|
|
457
|
+
) AS is_primary_key
|
|
458
|
+
FROM information_schema.columns c
|
|
459
|
+
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
460
|
+
ORDER BY c.table_schema, c.table_name, c.ordinal_position`);
|
|
461
|
+
const columnsByTable = /* @__PURE__ */ new Map();
|
|
462
|
+
for (const row of columnsResult.rows) {
|
|
463
|
+
const key = `${String(row.schema)}.${String(row.table_name)}`;
|
|
464
|
+
const cols = columnsByTable.get(key) ?? [];
|
|
465
|
+
cols.push({
|
|
466
|
+
name: String(row.name),
|
|
467
|
+
type: String(row.type),
|
|
468
|
+
nullable: Boolean(row.nullable),
|
|
469
|
+
defaultValue: row.default_value != null ? String(row.default_value) : null,
|
|
470
|
+
isPrimaryKey: Boolean(row.is_primary_key)
|
|
471
|
+
});
|
|
472
|
+
columnsByTable.set(key, cols);
|
|
473
|
+
}
|
|
474
|
+
sendJson(res, { tables: tablesResult.rows.map((row) => {
|
|
475
|
+
const key = `${String(row.schema)}.${String(row.name)}`;
|
|
476
|
+
return {
|
|
477
|
+
name: String(row.name),
|
|
478
|
+
schema: String(row.schema),
|
|
479
|
+
rowCount: Number(row.row_count ?? 0),
|
|
480
|
+
columns: columnsByTable.get(key) ?? []
|
|
481
|
+
};
|
|
482
|
+
}) });
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* GET /api/database/tables/:table/rows?offset=0&limit=50&sort=col&order=asc&search=term
|
|
486
|
+
* Paginated row retrieval for a specific table.
|
|
487
|
+
*/
|
|
488
|
+
async function handleGetRows(req, res, runtime, tableName) {
|
|
489
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
490
|
+
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? "0"));
|
|
491
|
+
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit") ?? "50")));
|
|
492
|
+
const sortCol = url.searchParams.get("sort") ?? "";
|
|
493
|
+
const sortOrder = url.searchParams.get("order") === "desc" ? "DESC" : "ASC";
|
|
494
|
+
const search = url.searchParams.get("search") ?? "";
|
|
495
|
+
if (!await assertTableExists(runtime, tableName)) {
|
|
496
|
+
sendJsonError(res, `Table "${tableName}" not found`, 404);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const colResult = await executeRawSql(runtime, `SELECT column_name, data_type
|
|
500
|
+
FROM information_schema.columns
|
|
501
|
+
WHERE table_name = '${tableName.replace(/'/g, "''")}'
|
|
502
|
+
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
503
|
+
ORDER BY ordinal_position`);
|
|
504
|
+
const columnNames = colResult.rows.map((r) => String(r.column_name));
|
|
505
|
+
const columnTypes = new Map(colResult.rows.map((r) => [String(r.column_name), String(r.data_type)]));
|
|
506
|
+
const validSort = sortCol && columnNames.includes(sortCol) ? sortCol : "";
|
|
507
|
+
let whereClause = "";
|
|
508
|
+
if (search.trim()) {
|
|
509
|
+
const escapedSearch = search.replace(/'/g, "''").replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
510
|
+
const textColumns = columnNames.filter((col) => {
|
|
511
|
+
const t = columnTypes.get(col) ?? "";
|
|
512
|
+
return t.includes("char") || t.includes("text") || t === "uuid" || t === "jsonb" || t === "json" || t === "integer" || t === "bigint" || t === "numeric" || t === "timestamp" || t.includes("timestamp");
|
|
513
|
+
});
|
|
514
|
+
if (textColumns.length > 0) whereClause = `WHERE (${textColumns.map((col) => `${quoteIdent(col)}::text ILIKE '%${escapedSearch}%' ESCAPE '\\'`).join(" OR ")})`;
|
|
515
|
+
}
|
|
516
|
+
const countResult = await executeRawSql(runtime, `SELECT count(*) AS total FROM ${quoteIdent(tableName)} ${whereClause}`);
|
|
517
|
+
const total = Number(countResult.rows[0]?.total ?? 0);
|
|
518
|
+
const orderClause = validSort ? `ORDER BY ${quoteIdent(validSort)} ${sortOrder}` : "";
|
|
519
|
+
const result = await executeRawSql(runtime, `SELECT * FROM ${quoteIdent(tableName)} ${whereClause} ${orderClause} LIMIT ${limit} OFFSET ${offset}`);
|
|
520
|
+
sendJson(res, {
|
|
521
|
+
table: tableName,
|
|
522
|
+
rows: result.rows,
|
|
523
|
+
columns: result.columns,
|
|
524
|
+
total,
|
|
525
|
+
offset,
|
|
526
|
+
limit
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* POST /api/database/tables/:table/rows
|
|
531
|
+
* Insert a new row. Body: { data: Record<string, unknown> }
|
|
532
|
+
*/
|
|
533
|
+
async function handleInsertRow(req, res, runtime, tableName) {
|
|
534
|
+
const body = await readJsonBody(req, res);
|
|
535
|
+
if (!body) return;
|
|
536
|
+
if (!body.data || typeof body.data !== "object" || Object.keys(body.data).length === 0) {
|
|
537
|
+
sendJsonError(res, "Request body must include a non-empty 'data' object.");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (!await assertTableExists(runtime, tableName)) {
|
|
541
|
+
sendJsonError(res, `Table "${tableName}" not found`, 404);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const columns = Object.keys(body.data);
|
|
545
|
+
const values = Object.values(body.data);
|
|
546
|
+
const colList = columns.map((c) => quoteIdent(c)).join(", ");
|
|
547
|
+
const valList = values.map(sqlLiteral).join(", ");
|
|
548
|
+
sendJson(res, {
|
|
549
|
+
inserted: true,
|
|
550
|
+
row: (await executeRawSql(runtime, `INSERT INTO ${quoteIdent(tableName)} (${colList}) VALUES (${valList}) RETURNING *`)).rows[0] ?? null
|
|
551
|
+
}, 201);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* PUT /api/database/tables/:table/rows
|
|
555
|
+
* Update a row. Body: { where: Record<string, unknown>, data: Record<string, unknown> }
|
|
556
|
+
*/
|
|
557
|
+
async function handleUpdateRow(req, res, runtime, tableName) {
|
|
558
|
+
const body = await readJsonBody(req, res);
|
|
559
|
+
if (!body) return;
|
|
560
|
+
if (!body.where || Object.keys(body.where).length === 0) {
|
|
561
|
+
sendJsonError(res, "Request body must include a non-empty 'where' object for row identification.");
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (!body.data || Object.keys(body.data).length === 0) {
|
|
565
|
+
sendJsonError(res, "Request body must include a non-empty 'data' object with fields to update.");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const setClauses = Object.entries(body.data).map(([col, val]) => sqlAssign(col, val));
|
|
569
|
+
const whereClauses = Object.entries(body.where).map(([col, val]) => sqlPredicate(col, val));
|
|
570
|
+
const result = await executeRawSql(runtime, `UPDATE ${quoteIdent(tableName)}
|
|
571
|
+
SET ${setClauses.join(", ")}
|
|
572
|
+
WHERE ${whereClauses.join(" AND ")}
|
|
573
|
+
RETURNING *`);
|
|
574
|
+
if (result.rows.length === 0) {
|
|
575
|
+
sendJsonError(res, "No matching row found to update.", 404);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
sendJson(res, {
|
|
579
|
+
updated: true,
|
|
580
|
+
row: result.rows[0]
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* DELETE /api/database/tables/:table/rows
|
|
585
|
+
* Delete a row. Body: { where: Record<string, unknown> }
|
|
586
|
+
*/
|
|
587
|
+
async function handleDeleteRow(req, res, runtime, tableName) {
|
|
588
|
+
const body = await readJsonBody(req, res);
|
|
589
|
+
if (!body) return;
|
|
590
|
+
if (!body.where || Object.keys(body.where).length === 0) {
|
|
591
|
+
sendJsonError(res, "Request body must include a non-empty 'where' object for row identification.");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const whereClauses = Object.entries(body.where).map(([col, val]) => sqlPredicate(col, val));
|
|
595
|
+
const result = await executeRawSql(runtime, `DELETE FROM ${quoteIdent(tableName)}
|
|
596
|
+
WHERE ${whereClauses.join(" AND ")}
|
|
597
|
+
RETURNING *`);
|
|
598
|
+
if (result.rows.length === 0) {
|
|
599
|
+
sendJsonError(res, "No matching row found to delete.", 404);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
sendJson(res, {
|
|
603
|
+
deleted: true,
|
|
604
|
+
row: result.rows[0]
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* POST /api/database/query
|
|
609
|
+
* Execute a raw SQL query. Body: { sql: string, readOnly?: boolean }
|
|
610
|
+
*/
|
|
611
|
+
async function handleQuery(req, res, runtime) {
|
|
612
|
+
const body = await readJsonBody(req, res);
|
|
613
|
+
if (!body) return;
|
|
614
|
+
if (!body.sql || typeof body.sql !== "string" || body.sql.trim().length === 0) {
|
|
615
|
+
sendJsonError(res, "Request body must include a non-empty 'sql' string.");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const sqlText = body.sql.trim();
|
|
619
|
+
if (body.readOnly !== false) {
|
|
620
|
+
const stripped = sqlText.replace(/\/\*[\s\S]*?\*\//g, "").replace(/--.*$/gm, "").trim();
|
|
621
|
+
const noLiterals = stripped.replace(/\$([A-Za-z0-9_]*)\$[\s\S]*?\$\1\$/g, " ").replace(/'(?:[^']|'')*'/g, " ");
|
|
622
|
+
const noStrings = noLiterals.replace(/"(?:[^"]|"")*"/g, " ");
|
|
623
|
+
const match = new RegExp(`\\b(${[
|
|
624
|
+
"INSERT",
|
|
625
|
+
"UPDATE",
|
|
626
|
+
"DELETE",
|
|
627
|
+
"INTO",
|
|
628
|
+
"DROP",
|
|
629
|
+
"ALTER",
|
|
630
|
+
"TRUNCATE",
|
|
631
|
+
"CREATE",
|
|
632
|
+
"COPY",
|
|
633
|
+
"MERGE",
|
|
634
|
+
"CALL",
|
|
635
|
+
"DO",
|
|
636
|
+
"REFRESH",
|
|
637
|
+
"REINDEX",
|
|
638
|
+
"VACUUM",
|
|
639
|
+
"GRANT",
|
|
640
|
+
"REVOKE"
|
|
641
|
+
].join("|")})\\b`, "i").exec(noStrings);
|
|
642
|
+
if (match) {
|
|
643
|
+
sendJsonError(res, `Query rejected: "${match[1].toUpperCase()}" is a mutation keyword. Set readOnly: false to execute mutations.`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const mutatingFunctionMatch = /(?:^|[^\w$])"?((?:nextval|setval))"?\s*\(/i.exec(noLiterals);
|
|
647
|
+
if (mutatingFunctionMatch) {
|
|
648
|
+
sendJsonError(res, `Query rejected: "${mutatingFunctionMatch[1].toUpperCase()}" is a mutating function. Set readOnly: false to execute mutations.`);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (stripped.replace(/;\s*$/, "").includes(";")) {
|
|
652
|
+
sendJsonError(res, "Query rejected: multi-statement queries are not allowed in read-only mode.");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const start = Date.now();
|
|
657
|
+
const result = await executeRawSql(runtime, sqlText);
|
|
658
|
+
const durationMs = Date.now() - start;
|
|
659
|
+
sendJson(res, {
|
|
660
|
+
columns: result.columns,
|
|
661
|
+
rows: result.rows,
|
|
662
|
+
rowCount: result.rows.length,
|
|
663
|
+
durationMs
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Route a database API request. Returns true if handled, false if not matched.
|
|
668
|
+
*
|
|
669
|
+
* Expected URL patterns:
|
|
670
|
+
* GET /api/database/status
|
|
671
|
+
* GET /api/database/config
|
|
672
|
+
* PUT /api/database/config
|
|
673
|
+
* POST /api/database/test
|
|
674
|
+
* GET /api/database/tables
|
|
675
|
+
* GET /api/database/tables/:table/rows
|
|
676
|
+
* POST /api/database/tables/:table/rows
|
|
677
|
+
* PUT /api/database/tables/:table/rows
|
|
678
|
+
* DELETE /api/database/tables/:table/rows
|
|
679
|
+
* POST /api/database/query
|
|
680
|
+
*/
|
|
681
|
+
async function handleDatabaseRoute(req, res, runtime, pathname) {
|
|
682
|
+
const method = req.method ?? "GET";
|
|
683
|
+
if (method === "GET" && pathname === "/api/database/status") {
|
|
684
|
+
await handleGetStatus(req, res, runtime);
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
if (method === "GET" && pathname === "/api/database/config") {
|
|
688
|
+
handleGetConfig(req, res);
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
if (method === "PUT" && pathname === "/api/database/config") {
|
|
692
|
+
await handlePutConfig(req, res);
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
if (method === "POST" && pathname === "/api/database/test") {
|
|
696
|
+
await handleTestConnection(req, res);
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
if (!runtime?.adapter) {
|
|
700
|
+
sendJsonError(res, "Database not available. The agent may not be running or the database adapter is not initialized.", 503);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
if (method === "GET" && pathname === "/api/database/tables") {
|
|
704
|
+
await handleGetTables(req, res, runtime);
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
if (method === "POST" && pathname === "/api/database/query") {
|
|
708
|
+
await handleQuery(req, res, runtime);
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
const rowsMatch = pathname.match(/^\/api\/database\/tables\/([^/]+)\/rows$/);
|
|
712
|
+
if (rowsMatch) {
|
|
713
|
+
const tableNameDecoded = decodeURIComponent(rowsMatch[1]);
|
|
714
|
+
if (method === "GET") {
|
|
715
|
+
await handleGetRows(req, res, runtime, tableNameDecoded);
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
if (method === "POST") {
|
|
719
|
+
await handleInsertRow(req, res, runtime, tableNameDecoded);
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
if (method === "PUT") {
|
|
723
|
+
await handleUpdateRow(req, res, runtime, tableNameDecoded);
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
if (method === "DELETE") {
|
|
727
|
+
await handleDeleteRow(req, res, runtime, tableNameDecoded);
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
//#endregion
|
|
735
|
+
export { handleDatabaseRoute };
|