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.
Files changed (187) hide show
  1. package/bin/swagmanager-mcp.js +7 -0
  2. package/dist/cli/app.js +30 -2
  3. package/dist/cli/chat/ChatApp.d.ts +4 -4
  4. package/dist/cli/chat/ChatApp.js +114 -44
  5. package/dist/cli/chat/ChatInput.d.ts +13 -6
  6. package/dist/cli/chat/ChatInput.js +433 -89
  7. package/dist/cli/chat/MemoryManager.d.ts +15 -0
  8. package/dist/cli/chat/MemoryManager.js +61 -0
  9. package/dist/cli/chat/MessageList.d.ts +8 -0
  10. package/dist/cli/chat/MessageList.js +1 -1
  11. package/dist/cli/chat/NodeManager.d.ts +30 -0
  12. package/dist/cli/chat/NodeManager.js +89 -0
  13. package/dist/cli/chat/NodeSelector.d.ts +19 -0
  14. package/dist/cli/chat/NodeSelector.js +37 -0
  15. package/dist/cli/chat/PlanApproval.d.ts +17 -0
  16. package/dist/cli/chat/PlanApproval.js +82 -0
  17. package/dist/cli/chat/SessionManager.d.ts +16 -0
  18. package/dist/cli/chat/SessionManager.js +43 -0
  19. package/dist/cli/chat/SlashMenu.d.ts +38 -0
  20. package/dist/cli/chat/SlashMenu.js +208 -0
  21. package/dist/cli/chat/StatusBar.d.ts +16 -0
  22. package/dist/cli/chat/StatusBar.js +22 -0
  23. package/dist/cli/chat/ThemeSelector.d.ts +14 -0
  24. package/dist/cli/chat/ThemeSelector.js +29 -0
  25. package/dist/cli/chat/ToolIndicator.d.ts +8 -0
  26. package/dist/cli/chat/ToolIndicator.js +33 -9
  27. package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
  28. package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
  29. package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
  30. package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
  31. package/dist/cli/commands/config-cmd.js +4 -25
  32. package/dist/cli/commands/db.d.ts +13 -0
  33. package/dist/cli/commands/db.js +243 -0
  34. package/dist/cli/commands/doctor.js +6 -9
  35. package/dist/cli/commands/mcp.js +1 -20
  36. package/dist/cli/services/agent-events.d.ts +22 -1
  37. package/dist/cli/services/agent-events.js +9 -0
  38. package/dist/cli/services/agent-loop.js +66 -2
  39. package/dist/cli/services/agent-worker-base.js +21 -6
  40. package/dist/cli/services/api-retry.d.ts +25 -0
  41. package/dist/cli/services/api-retry.js +91 -0
  42. package/dist/cli/services/auth-service.d.ts +1 -1
  43. package/dist/cli/services/auth-service.js +40 -19
  44. package/dist/cli/services/background-processes.js +26 -2
  45. package/dist/cli/services/config-store.d.ts +13 -1
  46. package/dist/cli/services/config-store.js +116 -13
  47. package/dist/cli/services/format-server-response.js +12 -6
  48. package/dist/cli/services/ink-resize-fix.d.ts +18 -0
  49. package/dist/cli/services/ink-resize-fix.js +66 -0
  50. package/dist/cli/services/interactive-tools.d.ts +14 -0
  51. package/dist/cli/services/interactive-tools.js +47 -2
  52. package/dist/cli/services/keybinding-manager.js +1 -1
  53. package/dist/cli/services/local-tools.js +35 -2
  54. package/dist/cli/services/server-tools.js +175 -3
  55. package/dist/cli/services/subagent.js +15 -3
  56. package/dist/cli/services/system-prompt.js +5 -3
  57. package/dist/cli/services/task-decomposer.d.ts +35 -0
  58. package/dist/cli/services/task-decomposer.js +199 -0
  59. package/dist/cli/services/team-lead.d.ts +18 -0
  60. package/dist/cli/services/team-lead.js +80 -0
  61. package/dist/cli/services/teammate.js +5 -5
  62. package/dist/cli/services/telemetry.d.ts +8 -2
  63. package/dist/cli/services/telemetry.js +116 -92
  64. package/dist/cli/services/tools/agent-tools.d.ts +1 -0
  65. package/dist/cli/services/tools/agent-tools.js +50 -4
  66. package/dist/cli/services/tools/file-ops.d.ts +2 -0
  67. package/dist/cli/services/tools/file-ops.js +71 -19
  68. package/dist/cli/services/tools/shell-exec.js +22 -12
  69. package/dist/cli/shared/Theme.d.ts +1 -2
  70. package/dist/cli/shared/Theme.js +1 -1
  71. package/dist/cli/shared/WhaleBanner.d.ts +4 -1
  72. package/dist/cli/shared/WhaleBanner.js +12 -8
  73. package/dist/cli/shared/markdown.d.ts +5 -4
  74. package/dist/cli/shared/markdown.js +376 -334
  75. package/dist/cli/shared/theme-manager.d.ts +27 -0
  76. package/dist/cli/shared/theme-manager.js +178 -0
  77. package/dist/cli/shared/theme-presets.d.ts +16 -0
  78. package/dist/cli/shared/theme-presets.js +265 -0
  79. package/dist/index.js +0 -51
  80. package/dist/node/adapters/imessage.d.ts +10 -0
  81. package/dist/node/adapters/imessage.js +45 -6
  82. package/dist/node/cli.js +459 -8
  83. package/dist/node/config.d.ts +17 -0
  84. package/dist/node/gateway-client.d.ts +55 -0
  85. package/dist/node/gateway-client.js +201 -0
  86. package/dist/node/portal/clipboard.d.ts +28 -0
  87. package/dist/node/portal/clipboard.js +183 -0
  88. package/dist/node/portal/discovery.d.ts +29 -0
  89. package/dist/node/portal/discovery.js +61 -0
  90. package/dist/node/portal/forward.d.ts +30 -0
  91. package/dist/node/portal/forward.js +90 -0
  92. package/dist/node/portal/index.d.ts +47 -0
  93. package/dist/node/portal/index.js +250 -0
  94. package/dist/node/portal/multiplexer.d.ts +48 -0
  95. package/dist/node/portal/multiplexer.js +207 -0
  96. package/dist/node/portal/permissions.d.ts +36 -0
  97. package/dist/node/portal/permissions.js +131 -0
  98. package/dist/node/portal/protocol.d.ts +140 -0
  99. package/dist/node/portal/protocol.js +193 -0
  100. package/dist/node/portal/screen.d.ts +18 -0
  101. package/dist/node/portal/screen.js +93 -0
  102. package/dist/node/portal/session.d.ts +68 -0
  103. package/dist/node/portal/session.js +127 -0
  104. package/dist/node/portal/shell.d.ts +26 -0
  105. package/dist/node/portal/shell.js +142 -0
  106. package/dist/node/portal/stream.d.ts +43 -0
  107. package/dist/node/portal/stream.js +90 -0
  108. package/dist/node/portal/transfer.d.ts +33 -0
  109. package/dist/node/portal/transfer.js +231 -0
  110. package/dist/node/portal/ui.d.ts +16 -0
  111. package/dist/node/portal/ui.js +148 -0
  112. package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
  113. package/dist/node/remote-desktop/compile-helper.js +73 -0
  114. package/dist/node/remote-desktop/index.d.ts +67 -0
  115. package/dist/node/remote-desktop/index.js +220 -0
  116. package/dist/node/remote-desktop/protocol.d.ts +96 -0
  117. package/dist/node/remote-desktop/protocol.js +67 -0
  118. package/dist/node/runtime.d.ts +8 -1
  119. package/dist/node/runtime.js +117 -9
  120. package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
  121. package/dist/server/handlers/__test-utils__/test-db.js +128 -0
  122. package/dist/server/handlers/api-keys.js +26 -2
  123. package/dist/server/handlers/browser.d.ts +0 -4
  124. package/dist/server/handlers/browser.js +0 -46
  125. package/dist/server/handlers/catalog.js +37 -14
  126. package/dist/server/handlers/clickhouse.d.ts +10 -0
  127. package/dist/server/handlers/clickhouse.js +215 -0
  128. package/dist/server/handlers/comms.d.ts +308 -4
  129. package/dist/server/handlers/comms.js +444 -11
  130. package/dist/server/handlers/creations.js +1 -1
  131. package/dist/server/handlers/crm.d.ts +54 -8
  132. package/dist/server/handlers/crm.js +353 -68
  133. package/dist/server/handlers/embeddings.js +3 -3
  134. package/dist/server/handlers/enrichment.js +39 -55
  135. package/dist/server/handlers/inventory.js +1 -1
  136. package/dist/server/handlers/kali.d.ts +9 -1
  137. package/dist/server/handlers/kali.js +50 -1
  138. package/dist/server/handlers/media.d.ts +8 -0
  139. package/dist/server/handlers/media.js +902 -0
  140. package/dist/server/handlers/meta-ads.js +6 -3
  141. package/dist/server/handlers/nodes.d.ts +2 -0
  142. package/dist/server/handlers/nodes.js +331 -40
  143. package/dist/server/handlers/operations.d.ts +4 -6
  144. package/dist/server/handlers/operations.js +99 -38
  145. package/dist/server/handlers/platform.js +224 -107
  146. package/dist/server/handlers/remove-bg.d.ts +6 -0
  147. package/dist/server/handlers/remove-bg.js +96 -0
  148. package/dist/server/handlers/storefront.d.ts +6 -0
  149. package/dist/server/handlers/storefront.js +477 -0
  150. package/dist/server/handlers/supply-chain.js +21 -3
  151. package/dist/server/handlers/workflow-steps.js +87 -31
  152. package/dist/server/handlers/workflows.js +4 -1
  153. package/dist/server/index.js +334 -88
  154. package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
  155. package/dist/server/lib/clickhouse-buffer.js +175 -0
  156. package/dist/server/lib/clickhouse-client.d.ts +112 -0
  157. package/dist/server/lib/clickhouse-client.js +141 -0
  158. package/dist/server/lib/coa-renderer.d.ts +91 -0
  159. package/dist/server/lib/coa-renderer.js +411 -0
  160. package/dist/server/lib/compaction-service.js +45 -1
  161. package/dist/server/lib/pdf-renderer.d.ts +143 -0
  162. package/dist/server/lib/pdf-renderer.js +867 -0
  163. package/dist/server/lib/react-pdf-layout.d.ts +40 -0
  164. package/dist/server/lib/react-pdf-layout.js +437 -0
  165. package/dist/server/lib/server-agent-loop.d.ts +2 -0
  166. package/dist/server/lib/server-agent-loop.js +61 -15
  167. package/dist/server/lib/server-subagent.d.ts +3 -0
  168. package/dist/server/lib/server-subagent.js +7 -4
  169. package/dist/server/lib/supabase-client.js +51 -3
  170. package/dist/server/lib/template-resolver.js +14 -4
  171. package/dist/server/lib/utils.js +15 -0
  172. package/dist/server/local-agent-gateway.d.ts +44 -0
  173. package/dist/server/local-agent-gateway.js +389 -49
  174. package/dist/server/providers/anthropic.js +12 -2
  175. package/dist/server/providers/gemini.js +17 -2
  176. package/dist/server/proxy-handlers.js +151 -0
  177. package/dist/server/tool-router.d.ts +2 -2
  178. package/dist/server/tool-router.js +25 -35
  179. package/dist/shared/agent-core.d.ts +5 -2
  180. package/dist/shared/agent-core.js +30 -4
  181. package/dist/shared/api-client.js +54 -3
  182. package/dist/shared/sse-parser.d.ts +1 -1
  183. package/dist/shared/sse-parser.js +5 -2
  184. package/dist/shared/tool-dispatch.js +1 -1
  185. package/package.json +16 -10
  186. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
  187. 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
- // Audit trail (fire-and-forget)
1645
- sb.from("audit_logs").insert({
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
- }).then(() => { });
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
- /** Authenticate a node by API key, returns node row or null */
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
- const { data } = await supabase
23
- .from("nodes")
24
- .select("id, store_id, name")
25
- .eq("api_key_hash", hash)
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
- return data;
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
- /** Log a node event to both node_events and audit_logs (unified telemetry) */
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
- // audit_logs unified telemetry (same schema as tool/chat spans)
121
+ // Telemetry ClickHouse
39
122
  try {
40
123
  const now = new Date();
41
- const auditRow = {
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
- // Audit must never break node operations
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 { data: stores } = await supabase
152
- .from("user_stores")
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 { data: stores } = await supabase
289
- .from("user_stores")
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
- // Audit log for inbound message (unified telemetry)
769
+ // Telemetry ClickHouse for inbound message
492
770
  if (direction === "inbound") {
493
771
  try {
494
- const auditRow = {
495
- action: `node.message.inbound`,
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
- // Audit must never break message flow
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;