whale-code 6.4.0 → 6.5.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/bin/swagmanager-mcp.js +7 -0
- package/dist/cli/app.js +30 -2
- package/dist/cli/chat/ChatApp.d.ts +4 -4
- package/dist/cli/chat/ChatApp.js +114 -44
- package/dist/cli/chat/ChatInput.d.ts +13 -6
- package/dist/cli/chat/ChatInput.js +433 -89
- package/dist/cli/chat/MemoryManager.d.ts +15 -0
- package/dist/cli/chat/MemoryManager.js +61 -0
- package/dist/cli/chat/MessageList.d.ts +8 -0
- package/dist/cli/chat/MessageList.js +1 -1
- package/dist/cli/chat/NodeManager.d.ts +30 -0
- package/dist/cli/chat/NodeManager.js +89 -0
- package/dist/cli/chat/NodeSelector.d.ts +19 -0
- package/dist/cli/chat/NodeSelector.js +37 -0
- package/dist/cli/chat/PlanApproval.d.ts +17 -0
- package/dist/cli/chat/PlanApproval.js +82 -0
- package/dist/cli/chat/SessionManager.d.ts +16 -0
- package/dist/cli/chat/SessionManager.js +43 -0
- package/dist/cli/chat/SlashMenu.d.ts +38 -0
- package/dist/cli/chat/SlashMenu.js +208 -0
- package/dist/cli/chat/StatusBar.d.ts +16 -0
- package/dist/cli/chat/StatusBar.js +22 -0
- package/dist/cli/chat/ThemeSelector.d.ts +14 -0
- package/dist/cli/chat/ThemeSelector.js +29 -0
- package/dist/cli/chat/ToolIndicator.d.ts +8 -0
- package/dist/cli/chat/ToolIndicator.js +33 -9
- package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
- package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
- package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
- package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
- package/dist/cli/commands/config-cmd.js +4 -25
- package/dist/cli/commands/db.d.ts +13 -0
- package/dist/cli/commands/db.js +243 -0
- package/dist/cli/commands/doctor.js +6 -9
- package/dist/cli/commands/mcp.js +1 -20
- package/dist/cli/services/agent-events.d.ts +22 -1
- package/dist/cli/services/agent-events.js +9 -0
- package/dist/cli/services/agent-loop.js +66 -2
- package/dist/cli/services/agent-worker-base.js +21 -6
- package/dist/cli/services/api-retry.d.ts +25 -0
- package/dist/cli/services/api-retry.js +91 -0
- package/dist/cli/services/auth-service.d.ts +1 -1
- package/dist/cli/services/auth-service.js +40 -19
- package/dist/cli/services/background-processes.js +26 -2
- package/dist/cli/services/config-store.d.ts +13 -1
- package/dist/cli/services/config-store.js +116 -13
- package/dist/cli/services/format-server-response.js +12 -6
- package/dist/cli/services/ink-resize-fix.d.ts +18 -0
- package/dist/cli/services/ink-resize-fix.js +66 -0
- package/dist/cli/services/interactive-tools.d.ts +14 -0
- package/dist/cli/services/interactive-tools.js +47 -2
- package/dist/cli/services/keybinding-manager.js +1 -1
- package/dist/cli/services/local-tools.js +35 -2
- package/dist/cli/services/server-tools.js +175 -3
- package/dist/cli/services/subagent.js +15 -3
- package/dist/cli/services/system-prompt.js +5 -3
- package/dist/cli/services/task-decomposer.d.ts +35 -0
- package/dist/cli/services/task-decomposer.js +199 -0
- package/dist/cli/services/team-lead.d.ts +18 -0
- package/dist/cli/services/team-lead.js +80 -0
- package/dist/cli/services/teammate.js +5 -5
- package/dist/cli/services/telemetry.d.ts +8 -2
- package/dist/cli/services/telemetry.js +116 -92
- package/dist/cli/services/tools/agent-tools.d.ts +1 -0
- package/dist/cli/services/tools/agent-tools.js +50 -4
- package/dist/cli/services/tools/file-ops.d.ts +2 -0
- package/dist/cli/services/tools/file-ops.js +71 -19
- package/dist/cli/services/tools/shell-exec.js +22 -12
- package/dist/cli/shared/Theme.d.ts +1 -2
- package/dist/cli/shared/Theme.js +1 -1
- package/dist/cli/shared/WhaleBanner.d.ts +4 -1
- package/dist/cli/shared/WhaleBanner.js +12 -8
- package/dist/cli/shared/markdown.d.ts +5 -4
- package/dist/cli/shared/markdown.js +376 -334
- package/dist/cli/shared/theme-manager.d.ts +27 -0
- package/dist/cli/shared/theme-manager.js +178 -0
- package/dist/cli/shared/theme-presets.d.ts +16 -0
- package/dist/cli/shared/theme-presets.js +265 -0
- package/dist/index.js +0 -51
- package/dist/node/adapters/imessage.d.ts +10 -0
- package/dist/node/adapters/imessage.js +45 -6
- package/dist/node/cli.js +459 -8
- package/dist/node/config.d.ts +17 -0
- package/dist/node/gateway-client.d.ts +55 -0
- package/dist/node/gateway-client.js +201 -0
- package/dist/node/portal/clipboard.d.ts +28 -0
- package/dist/node/portal/clipboard.js +183 -0
- package/dist/node/portal/discovery.d.ts +29 -0
- package/dist/node/portal/discovery.js +61 -0
- package/dist/node/portal/forward.d.ts +30 -0
- package/dist/node/portal/forward.js +90 -0
- package/dist/node/portal/index.d.ts +47 -0
- package/dist/node/portal/index.js +250 -0
- package/dist/node/portal/multiplexer.d.ts +48 -0
- package/dist/node/portal/multiplexer.js +207 -0
- package/dist/node/portal/permissions.d.ts +36 -0
- package/dist/node/portal/permissions.js +131 -0
- package/dist/node/portal/protocol.d.ts +140 -0
- package/dist/node/portal/protocol.js +193 -0
- package/dist/node/portal/screen.d.ts +18 -0
- package/dist/node/portal/screen.js +93 -0
- package/dist/node/portal/session.d.ts +68 -0
- package/dist/node/portal/session.js +127 -0
- package/dist/node/portal/shell.d.ts +26 -0
- package/dist/node/portal/shell.js +142 -0
- package/dist/node/portal/stream.d.ts +43 -0
- package/dist/node/portal/stream.js +90 -0
- package/dist/node/portal/transfer.d.ts +33 -0
- package/dist/node/portal/transfer.js +231 -0
- package/dist/node/portal/ui.d.ts +16 -0
- package/dist/node/portal/ui.js +148 -0
- package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
- package/dist/node/remote-desktop/compile-helper.js +73 -0
- package/dist/node/remote-desktop/index.d.ts +67 -0
- package/dist/node/remote-desktop/index.js +220 -0
- package/dist/node/remote-desktop/protocol.d.ts +96 -0
- package/dist/node/remote-desktop/protocol.js +67 -0
- package/dist/node/runtime.d.ts +8 -1
- package/dist/node/runtime.js +117 -9
- package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
- package/dist/server/handlers/__test-utils__/test-db.js +128 -0
- package/dist/server/handlers/api-keys.js +26 -2
- package/dist/server/handlers/browser.d.ts +0 -4
- package/dist/server/handlers/browser.js +0 -46
- package/dist/server/handlers/catalog.js +37 -14
- package/dist/server/handlers/clickhouse.d.ts +10 -0
- package/dist/server/handlers/clickhouse.js +215 -0
- package/dist/server/handlers/comms.d.ts +308 -4
- package/dist/server/handlers/comms.js +444 -11
- package/dist/server/handlers/creations.js +1 -1
- package/dist/server/handlers/crm.d.ts +54 -8
- package/dist/server/handlers/crm.js +353 -68
- package/dist/server/handlers/embeddings.js +3 -3
- package/dist/server/handlers/enrichment.js +39 -55
- package/dist/server/handlers/inventory.js +1 -1
- package/dist/server/handlers/kali.d.ts +9 -1
- package/dist/server/handlers/kali.js +50 -1
- package/dist/server/handlers/media.d.ts +8 -0
- package/dist/server/handlers/media.js +902 -0
- package/dist/server/handlers/meta-ads.js +6 -3
- package/dist/server/handlers/nodes.d.ts +2 -0
- package/dist/server/handlers/nodes.js +331 -40
- package/dist/server/handlers/operations.d.ts +4 -6
- package/dist/server/handlers/operations.js +99 -38
- package/dist/server/handlers/platform.js +224 -107
- package/dist/server/handlers/remove-bg.d.ts +6 -0
- package/dist/server/handlers/remove-bg.js +96 -0
- package/dist/server/handlers/storefront.d.ts +6 -0
- package/dist/server/handlers/storefront.js +477 -0
- package/dist/server/handlers/supply-chain.js +21 -3
- package/dist/server/handlers/workflow-steps.js +87 -31
- package/dist/server/handlers/workflows.js +4 -1
- package/dist/server/index.js +334 -88
- package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
- package/dist/server/lib/clickhouse-buffer.js +175 -0
- package/dist/server/lib/clickhouse-client.d.ts +112 -0
- package/dist/server/lib/clickhouse-client.js +141 -0
- package/dist/server/lib/coa-renderer.d.ts +91 -0
- package/dist/server/lib/coa-renderer.js +411 -0
- package/dist/server/lib/compaction-service.js +45 -1
- package/dist/server/lib/pdf-renderer.d.ts +143 -0
- package/dist/server/lib/pdf-renderer.js +867 -0
- package/dist/server/lib/react-pdf-layout.d.ts +40 -0
- package/dist/server/lib/react-pdf-layout.js +437 -0
- package/dist/server/lib/server-agent-loop.d.ts +2 -0
- package/dist/server/lib/server-agent-loop.js +61 -15
- package/dist/server/lib/server-subagent.d.ts +3 -0
- package/dist/server/lib/server-subagent.js +7 -4
- package/dist/server/lib/supabase-client.js +51 -3
- package/dist/server/lib/template-resolver.js +14 -4
- package/dist/server/lib/utils.js +15 -0
- package/dist/server/local-agent-gateway.d.ts +44 -0
- package/dist/server/local-agent-gateway.js +389 -49
- package/dist/server/providers/anthropic.js +12 -2
- package/dist/server/providers/gemini.js +17 -2
- package/dist/server/proxy-handlers.js +151 -0
- package/dist/server/tool-router.d.ts +2 -2
- package/dist/server/tool-router.js +25 -35
- package/dist/shared/agent-core.d.ts +5 -2
- package/dist/shared/agent-core.js +30 -4
- package/dist/shared/api-client.js +54 -3
- package/dist/shared/sse-parser.d.ts +1 -1
- package/dist/shared/sse-parser.js +5 -2
- package/dist/shared/tool-dispatch.js +1 -1
- package/package.json +16 -10
- package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
- package/dist/server/handlers/__test-utils__/mock-supabase.js +0 -393
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* - Video URLs uploaded to Meta during publish → video_id
|
|
27
27
|
* - Image URLs uploaded to Meta during publish → image_hash
|
|
28
28
|
*/
|
|
29
|
+
import { queueSpan, auditRowToSpan } from "../lib/clickhouse-buffer.js";
|
|
29
30
|
// ============================================================================
|
|
30
31
|
// CONSTANTS
|
|
31
32
|
// ============================================================================
|
|
@@ -1641,8 +1642,8 @@ export async function handleMetaAds(sb, args, storeId) {
|
|
|
1641
1642
|
break;
|
|
1642
1643
|
default: return { success: false, error: `Invalid type: ${type}` };
|
|
1643
1644
|
}
|
|
1644
|
-
//
|
|
1645
|
-
|
|
1645
|
+
// Telemetry → ClickHouse (fire-and-forget)
|
|
1646
|
+
queueSpan(auditRowToSpan({
|
|
1646
1647
|
store_id: storeId,
|
|
1647
1648
|
action: `meta_publish_${type}`,
|
|
1648
1649
|
resource_type: `meta_${type === "ad_set" ? "ad_set" : type}`,
|
|
@@ -1650,7 +1651,9 @@ export async function handleMetaAds(sb, args, storeId) {
|
|
|
1650
1651
|
details: result,
|
|
1651
1652
|
source: "agent_chat",
|
|
1652
1653
|
severity: "info",
|
|
1653
|
-
|
|
1654
|
+
service_name: "agent-server", span_kind: "INTERNAL", status_code: "OK",
|
|
1655
|
+
start_time: new Date().toISOString(), end_time: new Date().toISOString(),
|
|
1656
|
+
}));
|
|
1654
1657
|
return { success: true, data: result };
|
|
1655
1658
|
}
|
|
1656
1659
|
// ==================================================================
|
|
@@ -23,6 +23,8 @@ export type AgentInvoker = (supabase: SupabaseClient, agentId: string, message:
|
|
|
23
23
|
}>;
|
|
24
24
|
/** Set the agent invoker — called once from index.ts to break circular dependency */
|
|
25
25
|
export declare function setNodeAgentInvoker(invoker: AgentInvoker): void;
|
|
26
|
+
/** Clear the node auth cache (for tests). */
|
|
27
|
+
export declare function clearNodeAuthCache(): void;
|
|
26
28
|
export declare function handleNodeRoutes(pathname: string, method: string, body: Record<string, unknown> | null, supabase: SupabaseClient, auth: {
|
|
27
29
|
userId?: string;
|
|
28
30
|
isServiceRole: boolean;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Auth: User JWT for management, Node API key for node operations
|
|
3
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
4
|
import { checkPlanLimits, incrementUsage } from "./billing.js";
|
|
5
|
+
import { queueSpan, auditRowToSpan } from "../lib/clickhouse-buffer.js";
|
|
5
6
|
let agentInvoker = null;
|
|
6
7
|
/** Set the agent invoker — called once from index.ts to break circular dependency */
|
|
7
8
|
export function setNodeAgentInvoker(invoker) {
|
|
@@ -16,17 +17,99 @@ function hashApiKey(key) {
|
|
|
16
17
|
function generateNodeApiKey() {
|
|
17
18
|
return randomBytes(32).toString("hex");
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
// 5-minute in-memory cache for node auth (survives transient Supabase 525s)
|
|
21
|
+
const NODE_AUTH_CACHE_TTL = 5 * 60 * 1000;
|
|
22
|
+
const nodeAuthCache = new Map();
|
|
23
|
+
/** Clear the node auth cache (for tests). */
|
|
24
|
+
export function clearNodeAuthCache() {
|
|
25
|
+
nodeAuthCache.clear();
|
|
26
|
+
}
|
|
27
|
+
/** Authenticate a node by API key, returns node row or null.
|
|
28
|
+
* Retries once on transient error, falls back to cache if both fail. */
|
|
20
29
|
async function authenticateNode(supabase, apiKey) {
|
|
21
30
|
const hash = hashApiKey(apiKey);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
// Attempt DB query with one retry on transient errors
|
|
32
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
const { data, error } = await supabase
|
|
35
|
+
.from("nodes")
|
|
36
|
+
.select("id, store_id, name")
|
|
37
|
+
.eq("api_key_hash", hash)
|
|
38
|
+
.single();
|
|
39
|
+
if (data) {
|
|
40
|
+
// Success — update cache and return
|
|
41
|
+
nodeAuthCache.set(hash, { node: data, cachedAt: Date.now() });
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
// Non-transient error (e.g. row not found) — no retry
|
|
45
|
+
if (error && !isTransientError(error)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// Transient error on first attempt — retry
|
|
49
|
+
if (attempt === 0 && error) {
|
|
50
|
+
console.warn(`[node-auth] Transient error (attempt 1): ${error.message}. Retrying...`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Network-level error (fetch failure, timeout)
|
|
57
|
+
if (attempt === 0) {
|
|
58
|
+
console.warn(`[node-auth] Network error (attempt 1): ${err.message}. Retrying...`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Both attempts failed — fall back to cache
|
|
64
|
+
const cached = nodeAuthCache.get(hash);
|
|
65
|
+
if (cached && Date.now() - cached.cachedAt < NODE_AUTH_CACHE_TTL) {
|
|
66
|
+
console.warn(`[node-auth] Using cached auth for node ${cached.node.id} (DB unavailable)`);
|
|
67
|
+
return cached.node;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Check if a user (by auth UUID) has access to a store.
|
|
72
|
+
* Mirrors the logic in get_user_store_ids():
|
|
73
|
+
* 1) store_members.auth_user_id = authUserId
|
|
74
|
+
* 2) stores.owner_user_id = platform_users.id WHERE auth_id = authUserId */
|
|
75
|
+
async function userHasStoreAccess(supabase, authUserId, storeId) {
|
|
76
|
+
// Check store_members first (direct membership)
|
|
77
|
+
const { data: membership } = await supabase
|
|
78
|
+
.from("store_members")
|
|
79
|
+
.select("id")
|
|
80
|
+
.eq("auth_user_id", authUserId)
|
|
81
|
+
.eq("store_id", storeId)
|
|
82
|
+
.limit(1);
|
|
83
|
+
if (membership?.length)
|
|
84
|
+
return true;
|
|
85
|
+
// Check via platform_users → stores.owner_user_id
|
|
86
|
+
const { data: platformUser } = await supabase
|
|
87
|
+
.from("platform_users")
|
|
88
|
+
.select("id")
|
|
89
|
+
.eq("auth_id", authUserId)
|
|
90
|
+
.limit(1)
|
|
26
91
|
.single();
|
|
27
|
-
|
|
92
|
+
if (platformUser) {
|
|
93
|
+
const { data: ownedStore } = await supabase
|
|
94
|
+
.from("stores")
|
|
95
|
+
.select("id")
|
|
96
|
+
.eq("owner_user_id", platformUser.id)
|
|
97
|
+
.eq("id", storeId)
|
|
98
|
+
.limit(1);
|
|
99
|
+
if (ownedStore?.length)
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
28
103
|
}
|
|
29
|
-
/**
|
|
104
|
+
/** Check if a Supabase error is transient (network, 5xx, Cloudflare) */
|
|
105
|
+
function isTransientError(error) {
|
|
106
|
+
const msg = error.message || "";
|
|
107
|
+
return msg.includes("525") || msg.includes("502") || msg.includes("503")
|
|
108
|
+
|| msg.includes("504") || msg.includes("520") || msg.includes("522")
|
|
109
|
+
|| msg.includes("524") || msg.includes("timeout") || msg.includes("ECONNRESET")
|
|
110
|
+
|| msg.includes("fetch failed");
|
|
111
|
+
}
|
|
112
|
+
/** Log a node event to node_events */
|
|
30
113
|
async function logNodeEvent(supabase, storeId, nodeId, eventType, details = {}) {
|
|
31
114
|
// node_events — existing table for node lifecycle
|
|
32
115
|
await supabase.from("node_events").insert({
|
|
@@ -35,10 +118,10 @@ async function logNodeEvent(supabase, storeId, nodeId, eventType, details = {})
|
|
|
35
118
|
event_type: eventType,
|
|
36
119
|
details,
|
|
37
120
|
});
|
|
38
|
-
//
|
|
121
|
+
// Telemetry → ClickHouse
|
|
39
122
|
try {
|
|
40
123
|
const now = new Date();
|
|
41
|
-
|
|
124
|
+
queueSpan(auditRowToSpan({
|
|
42
125
|
action: `node.${eventType}`,
|
|
43
126
|
severity: "info",
|
|
44
127
|
store_id: storeId,
|
|
@@ -53,16 +136,10 @@ async function logNodeEvent(supabase, storeId, nodeId, eventType, details = {})
|
|
|
53
136
|
start_time: now.toISOString(),
|
|
54
137
|
end_time: now.toISOString(),
|
|
55
138
|
details: { ...details, node_id: nodeId, event_type: eventType },
|
|
56
|
-
};
|
|
57
|
-
const { error } = await supabase.from("audit_logs").insert(auditRow);
|
|
58
|
-
// Retry without store_id on FK constraint
|
|
59
|
-
if (error?.message?.includes("store_id")) {
|
|
60
|
-
auditRow.store_id = null;
|
|
61
|
-
await supabase.from("audit_logs").insert(auditRow);
|
|
62
|
-
}
|
|
139
|
+
}));
|
|
63
140
|
}
|
|
64
141
|
catch {
|
|
65
|
-
//
|
|
142
|
+
// Telemetry must never break node operations
|
|
66
143
|
}
|
|
67
144
|
}
|
|
68
145
|
/** Resolve or create a conversation for a sender on a channel.
|
|
@@ -148,12 +225,8 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
148
225
|
}
|
|
149
226
|
// Verify user has access to this store
|
|
150
227
|
if (auth.userId) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
.select("store_id")
|
|
154
|
-
.eq("user_id", auth.userId)
|
|
155
|
-
.eq("store_id", reg.store_id);
|
|
156
|
-
if (!stores?.length) {
|
|
228
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, reg.store_id);
|
|
229
|
+
if (!hasAccess) {
|
|
157
230
|
return { status: 403, body: { error: "No access to this store" } };
|
|
158
231
|
}
|
|
159
232
|
}
|
|
@@ -183,6 +256,7 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
183
256
|
capabilities: reg.capabilities || [],
|
|
184
257
|
hardware: reg.hardware || {},
|
|
185
258
|
version: reg.version || "1.0.0",
|
|
259
|
+
config: reg.config || {},
|
|
186
260
|
status: "offline",
|
|
187
261
|
})
|
|
188
262
|
.select("id, name, store_id, status, created_at")
|
|
@@ -234,9 +308,32 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
234
308
|
.eq("type", ch.type);
|
|
235
309
|
}
|
|
236
310
|
}
|
|
311
|
+
// Fetch pending commands for this node
|
|
312
|
+
let pendingCommands = [];
|
|
313
|
+
try {
|
|
314
|
+
const { data: cmds } = await supabase
|
|
315
|
+
.from("node_commands")
|
|
316
|
+
.select("id, command, payload")
|
|
317
|
+
.eq("node_id", node.id)
|
|
318
|
+
.eq("status", "pending")
|
|
319
|
+
.order("created_at", { ascending: true })
|
|
320
|
+
.limit(10);
|
|
321
|
+
if (cmds?.length) {
|
|
322
|
+
pendingCommands = cmds;
|
|
323
|
+
// Mark them as acknowledged
|
|
324
|
+
const cmdIds = cmds.map((c) => c.id);
|
|
325
|
+
await supabase
|
|
326
|
+
.from("node_commands")
|
|
327
|
+
.update({ status: "acknowledged", acknowledged_at: new Date().toISOString() })
|
|
328
|
+
.in("id", cmdIds);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// Command delivery is best-effort
|
|
333
|
+
}
|
|
237
334
|
return {
|
|
238
335
|
status: 200,
|
|
239
|
-
body: { success: true, node_id: node.id },
|
|
336
|
+
body: { success: true, node_id: node.id, commands: pendingCommands },
|
|
240
337
|
};
|
|
241
338
|
}
|
|
242
339
|
// ── GET|POST /nodes ────────────────────────────────────────────
|
|
@@ -247,6 +344,16 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
247
344
|
if (!storeId) {
|
|
248
345
|
return { status: 400, body: { error: "store_id required" } };
|
|
249
346
|
}
|
|
347
|
+
// Verify user has access to the requested store
|
|
348
|
+
if (auth.userId) {
|
|
349
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, storeId);
|
|
350
|
+
if (!hasAccess) {
|
|
351
|
+
return { status: 403, body: { error: "Not authorized to access this store's nodes" } };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else if (!auth.isServiceRole) {
|
|
355
|
+
return { status: 401, body: { error: "Authentication required" } };
|
|
356
|
+
}
|
|
250
357
|
const { data: nodes, error } = await supabase
|
|
251
358
|
.from("nodes")
|
|
252
359
|
.select("id, name, status, hardware, capabilities, version, ip_address, last_heartbeat, created_at")
|
|
@@ -270,6 +377,152 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
270
377
|
}));
|
|
271
378
|
return { status: 200, body: { success: true, nodes: result } };
|
|
272
379
|
}
|
|
380
|
+
// ── PATCH /nodes/:id ──────────────────────────────────────────
|
|
381
|
+
// User auth. Update node name, capabilities, config.
|
|
382
|
+
const patchNodeMatch = pathname.match(/^\/nodes\/([a-f0-9-]+)$/);
|
|
383
|
+
if (patchNodeMatch && (method === "PUT" || method === "POST") && body?._method === "PATCH") {
|
|
384
|
+
const nodeId = patchNodeMatch[1];
|
|
385
|
+
if (!auth.userId && !auth.isServiceRole) {
|
|
386
|
+
return { status: 401, body: { error: "User authentication required" } };
|
|
387
|
+
}
|
|
388
|
+
// Verify ownership
|
|
389
|
+
const { data: nodeRow } = await supabase
|
|
390
|
+
.from("nodes")
|
|
391
|
+
.select("id, store_id, name")
|
|
392
|
+
.eq("id", nodeId)
|
|
393
|
+
.single();
|
|
394
|
+
if (!nodeRow) {
|
|
395
|
+
return { status: 404, body: { error: "Node not found" } };
|
|
396
|
+
}
|
|
397
|
+
if (auth.userId) {
|
|
398
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, nodeRow.store_id);
|
|
399
|
+
if (!hasAccess) {
|
|
400
|
+
return { status: 403, body: { error: "No access to this node" } };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const nodeUpdates = { updated_at: new Date().toISOString() };
|
|
404
|
+
if (body.name !== undefined)
|
|
405
|
+
nodeUpdates.name = body.name;
|
|
406
|
+
if (body.capabilities !== undefined)
|
|
407
|
+
nodeUpdates.capabilities = body.capabilities;
|
|
408
|
+
if (body.config !== undefined)
|
|
409
|
+
nodeUpdates.config = body.config;
|
|
410
|
+
const { data: updated, error: updateErr } = await supabase
|
|
411
|
+
.from("nodes")
|
|
412
|
+
.update(nodeUpdates)
|
|
413
|
+
.eq("id", nodeId)
|
|
414
|
+
.select("id, name, status, capabilities, config, version, hardware, last_heartbeat, updated_at")
|
|
415
|
+
.single();
|
|
416
|
+
if (updateErr) {
|
|
417
|
+
return { status: 500, body: { error: updateErr.message } };
|
|
418
|
+
}
|
|
419
|
+
return { status: 200, body: { success: true, node: updated } };
|
|
420
|
+
}
|
|
421
|
+
// ── POST /nodes/:id/commands ────────────────────────────────
|
|
422
|
+
// User auth. Queue a command for a node.
|
|
423
|
+
const commandMatch = pathname.match(/^\/nodes\/([a-f0-9-]+)\/commands$/);
|
|
424
|
+
if (commandMatch && method === "POST") {
|
|
425
|
+
const nodeId = commandMatch[1];
|
|
426
|
+
if (!auth.userId && !auth.isServiceRole) {
|
|
427
|
+
return { status: 401, body: { error: "User authentication required" } };
|
|
428
|
+
}
|
|
429
|
+
if (!body)
|
|
430
|
+
return { status: 400, body: { error: "Request body required" } };
|
|
431
|
+
const command = body.command;
|
|
432
|
+
const payload = body.payload || {};
|
|
433
|
+
if (!command) {
|
|
434
|
+
return { status: 400, body: { error: "command is required" } };
|
|
435
|
+
}
|
|
436
|
+
const validCommands = ["restart", "shutdown", "rotate_key", "pause_all_channels", "resume_all_channels", "update"];
|
|
437
|
+
if (!validCommands.includes(command)) {
|
|
438
|
+
return { status: 400, body: { error: `Invalid command. Valid: ${validCommands.join(", ")}` } };
|
|
439
|
+
}
|
|
440
|
+
// Verify node exists and get store_id
|
|
441
|
+
const { data: targetNode } = await supabase
|
|
442
|
+
.from("nodes")
|
|
443
|
+
.select("id, store_id")
|
|
444
|
+
.eq("id", nodeId)
|
|
445
|
+
.single();
|
|
446
|
+
if (!targetNode) {
|
|
447
|
+
return { status: 404, body: { error: "Node not found" } };
|
|
448
|
+
}
|
|
449
|
+
if (auth.userId) {
|
|
450
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, targetNode.store_id);
|
|
451
|
+
if (!hasAccess) {
|
|
452
|
+
return { status: 403, body: { error: "No access to this node" } };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const { data: cmd, error: cmdErr } = await supabase
|
|
456
|
+
.from("node_commands")
|
|
457
|
+
.insert({
|
|
458
|
+
node_id: nodeId,
|
|
459
|
+
store_id: targetNode.store_id,
|
|
460
|
+
command,
|
|
461
|
+
payload,
|
|
462
|
+
status: "pending",
|
|
463
|
+
})
|
|
464
|
+
.select("id, command, status, created_at")
|
|
465
|
+
.single();
|
|
466
|
+
if (cmdErr) {
|
|
467
|
+
return { status: 500, body: { error: cmdErr.message } };
|
|
468
|
+
}
|
|
469
|
+
await logNodeEvent(supabase, targetNode.store_id, nodeId, "command_queued", {
|
|
470
|
+
command,
|
|
471
|
+
command_id: cmd.id,
|
|
472
|
+
queued_by: auth.userId,
|
|
473
|
+
});
|
|
474
|
+
return { status: 201, body: { success: true, command: cmd } };
|
|
475
|
+
}
|
|
476
|
+
// ── POST /nodes/:id/rotate-key ─────────────────────────────
|
|
477
|
+
// User auth. Generate a new API key for a node.
|
|
478
|
+
const rotateMatch = pathname.match(/^\/nodes\/([a-f0-9-]+)\/rotate-key$/);
|
|
479
|
+
if (rotateMatch && method === "POST") {
|
|
480
|
+
const nodeId = rotateMatch[1];
|
|
481
|
+
if (!auth.userId && !auth.isServiceRole) {
|
|
482
|
+
return { status: 401, body: { error: "User authentication required" } };
|
|
483
|
+
}
|
|
484
|
+
// Verify ownership
|
|
485
|
+
const { data: rotateNode } = await supabase
|
|
486
|
+
.from("nodes")
|
|
487
|
+
.select("id, store_id, name")
|
|
488
|
+
.eq("id", nodeId)
|
|
489
|
+
.single();
|
|
490
|
+
if (!rotateNode) {
|
|
491
|
+
return { status: 404, body: { error: "Node not found" } };
|
|
492
|
+
}
|
|
493
|
+
if (auth.userId) {
|
|
494
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, rotateNode.store_id);
|
|
495
|
+
if (!hasAccess) {
|
|
496
|
+
return { status: 403, body: { error: "No access to this node" } };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const newApiKey = generateNodeApiKey();
|
|
500
|
+
const newHash = hashApiKey(newApiKey);
|
|
501
|
+
const { error: rotateErr } = await supabase
|
|
502
|
+
.from("nodes")
|
|
503
|
+
.update({ api_key_hash: newHash, updated_at: new Date().toISOString() })
|
|
504
|
+
.eq("id", nodeId);
|
|
505
|
+
if (rotateErr) {
|
|
506
|
+
return { status: 500, body: { error: rotateErr.message } };
|
|
507
|
+
}
|
|
508
|
+
// Invalidate auth cache for old hash
|
|
509
|
+
for (const [hash, cached] of nodeAuthCache) {
|
|
510
|
+
if (cached.node.id === nodeId) {
|
|
511
|
+
nodeAuthCache.delete(hash);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
await logNodeEvent(supabase, rotateNode.store_id, nodeId, "key_rotated", {
|
|
515
|
+
rotated_by: auth.userId,
|
|
516
|
+
});
|
|
517
|
+
return {
|
|
518
|
+
status: 200,
|
|
519
|
+
body: {
|
|
520
|
+
success: true,
|
|
521
|
+
api_key: newApiKey,
|
|
522
|
+
message: "Save your new API key — it cannot be retrieved later. The old key is now invalid.",
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
|
273
526
|
// ── DELETE /nodes/:id ─────────────────────────────────────────
|
|
274
527
|
// User auth. Deletes a node and all its channels.
|
|
275
528
|
const deleteMatch = pathname.match(/^\/nodes\/([a-f0-9-]+)$/);
|
|
@@ -285,12 +538,8 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
285
538
|
return { status: 404, body: { error: "Node not found" } };
|
|
286
539
|
}
|
|
287
540
|
if (auth.userId) {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
.select("store_id")
|
|
291
|
-
.eq("user_id", auth.userId)
|
|
292
|
-
.eq("store_id", node.store_id);
|
|
293
|
-
if (!stores?.length) {
|
|
541
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, node.store_id);
|
|
542
|
+
if (!hasAccess) {
|
|
294
543
|
return { status: 403, body: { error: "No access to this node" } };
|
|
295
544
|
}
|
|
296
545
|
}
|
|
@@ -368,6 +617,16 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
368
617
|
if (!storeId) {
|
|
369
618
|
return { status: 400, body: { error: "store_id required" } };
|
|
370
619
|
}
|
|
620
|
+
// Verify user has access to the requested store
|
|
621
|
+
if (auth.userId) {
|
|
622
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, storeId);
|
|
623
|
+
if (!hasAccess) {
|
|
624
|
+
return { status: 403, body: { error: "Not authorized to access this store's channels" } };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
else if (!auth.isServiceRole) {
|
|
628
|
+
return { status: 401, body: { error: "Authentication required" } };
|
|
629
|
+
}
|
|
371
630
|
const { data: channels, error } = await supabase
|
|
372
631
|
.from("channels")
|
|
373
632
|
.select(`
|
|
@@ -387,6 +646,25 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
387
646
|
const channelPatchMatch = pathname.match(/^\/channels\/([a-f0-9-]+)$/);
|
|
388
647
|
if (channelPatchMatch && (method === "PUT" || method === "POST") && body?._method === "PATCH") {
|
|
389
648
|
const channelId = channelPatchMatch[1];
|
|
649
|
+
// Fetch the channel first to verify ownership
|
|
650
|
+
const { data: existingChannel } = await supabase
|
|
651
|
+
.from("channels")
|
|
652
|
+
.select("id, store_id")
|
|
653
|
+
.eq("id", channelId)
|
|
654
|
+
.single();
|
|
655
|
+
if (!existingChannel) {
|
|
656
|
+
return { status: 404, body: { error: "Channel not found" } };
|
|
657
|
+
}
|
|
658
|
+
// Verify the user has access to this channel's store
|
|
659
|
+
if (auth.userId) {
|
|
660
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, existingChannel.store_id);
|
|
661
|
+
if (!hasAccess) {
|
|
662
|
+
return { status: 403, body: { error: "Not authorized to modify this channel" } };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
else if (!auth.isServiceRole) {
|
|
666
|
+
return { status: 401, body: { error: "Authentication required" } };
|
|
667
|
+
}
|
|
390
668
|
const updates = {};
|
|
391
669
|
if (body.agent_id !== undefined)
|
|
392
670
|
updates.agent_id = body.agent_id;
|
|
@@ -488,11 +766,11 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
488
766
|
catch {
|
|
489
767
|
// Stats function may not exist yet — that's fine
|
|
490
768
|
}
|
|
491
|
-
//
|
|
769
|
+
// Telemetry → ClickHouse for inbound message
|
|
492
770
|
if (direction === "inbound") {
|
|
493
771
|
try {
|
|
494
|
-
|
|
495
|
-
action:
|
|
772
|
+
queueSpan(auditRowToSpan({
|
|
773
|
+
action: "node.message.inbound",
|
|
496
774
|
severity: "info",
|
|
497
775
|
store_id: channel.store_id,
|
|
498
776
|
resource_type: "whale_node",
|
|
@@ -513,15 +791,10 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
513
791
|
node_id: node?.id || null,
|
|
514
792
|
has_agent: !!channel.agent_id,
|
|
515
793
|
},
|
|
516
|
-
};
|
|
517
|
-
const { error: auditErr } = await supabase.from("audit_logs").insert(auditRow);
|
|
518
|
-
if (auditErr?.message?.includes("store_id")) {
|
|
519
|
-
auditRow.store_id = null;
|
|
520
|
-
await supabase.from("audit_logs").insert(auditRow);
|
|
521
|
-
}
|
|
794
|
+
}));
|
|
522
795
|
}
|
|
523
796
|
catch {
|
|
524
|
-
//
|
|
797
|
+
// Telemetry must never break message flow
|
|
525
798
|
}
|
|
526
799
|
}
|
|
527
800
|
// Track message usage (best-effort, non-blocking)
|
|
@@ -683,6 +956,24 @@ export async function handleNodeRoutes(pathname, method, body, supabase, auth, q
|
|
|
683
956
|
if (eventsMatch && method === "GET") {
|
|
684
957
|
const nodeId = eventsMatch[1];
|
|
685
958
|
const limit = body?.limit || 50;
|
|
959
|
+
// Verify user has access to this node's store
|
|
960
|
+
const { data: node } = await supabase
|
|
961
|
+
.from("nodes")
|
|
962
|
+
.select("store_id")
|
|
963
|
+
.eq("id", nodeId)
|
|
964
|
+
.single();
|
|
965
|
+
if (!node) {
|
|
966
|
+
return { status: 404, body: { error: "Node not found" } };
|
|
967
|
+
}
|
|
968
|
+
if (auth.userId) {
|
|
969
|
+
const hasAccess = await userHasStoreAccess(supabase, auth.userId, node.store_id);
|
|
970
|
+
if (!hasAccess) {
|
|
971
|
+
return { status: 403, body: { error: "Not authorized to view this node's events" } };
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
else if (!auth.isServiceRole) {
|
|
975
|
+
return { status: 401, body: { error: "Authentication required" } };
|
|
976
|
+
}
|
|
686
977
|
const { data: events, error } = await supabase
|
|
687
978
|
.from("node_events")
|
|
688
979
|
.select("id, event_type, details, created_at")
|
|
@@ -99,17 +99,16 @@ export declare function handleAuditTrail(sb: SupabaseClient, args: Record<string
|
|
|
99
99
|
success: boolean;
|
|
100
100
|
data: {
|
|
101
101
|
query: string;
|
|
102
|
-
entries: {
|
|
102
|
+
entries: (Record<string, unknown> | {
|
|
103
103
|
id: any;
|
|
104
104
|
action: any;
|
|
105
105
|
severity: any;
|
|
106
106
|
source: any;
|
|
107
107
|
resource_type: any;
|
|
108
108
|
resource_id: any;
|
|
109
|
-
user_email: any;
|
|
110
109
|
details: any;
|
|
111
110
|
created_at: any;
|
|
112
|
-
}[];
|
|
111
|
+
})[];
|
|
113
112
|
count: number;
|
|
114
113
|
days: number;
|
|
115
114
|
summary?: undefined;
|
|
@@ -118,7 +117,7 @@ export declare function handleAuditTrail(sb: SupabaseClient, args: Record<string
|
|
|
118
117
|
} | {
|
|
119
118
|
success: boolean;
|
|
120
119
|
data: {
|
|
121
|
-
entries: {
|
|
120
|
+
entries: (Record<string, unknown> | {
|
|
122
121
|
id: any;
|
|
123
122
|
action: any;
|
|
124
123
|
severity: any;
|
|
@@ -126,9 +125,8 @@ export declare function handleAuditTrail(sb: SupabaseClient, args: Record<string
|
|
|
126
125
|
resource_type: any;
|
|
127
126
|
resource_id: any;
|
|
128
127
|
details: any;
|
|
129
|
-
user_email: any;
|
|
130
128
|
created_at: any;
|
|
131
|
-
}[];
|
|
129
|
+
})[];
|
|
132
130
|
summary: Record<string, number>;
|
|
133
131
|
days: number;
|
|
134
132
|
count: number;
|