whale-code 6.4.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 (319) hide show
  1. package/README.md +95 -0
  2. package/bin/swag-agent.js +9 -0
  3. package/bin/swagmanager-mcp.js +321 -0
  4. package/dist/cli/app.d.ts +26 -0
  5. package/dist/cli/app.js +64 -0
  6. package/dist/cli/chat/AgentSelector.d.ts +14 -0
  7. package/dist/cli/chat/AgentSelector.js +14 -0
  8. package/dist/cli/chat/ChatApp.d.ts +9 -0
  9. package/dist/cli/chat/ChatApp.js +267 -0
  10. package/dist/cli/chat/ChatInput.d.ts +39 -0
  11. package/dist/cli/chat/ChatInput.js +509 -0
  12. package/dist/cli/chat/MarkdownText.d.ts +10 -0
  13. package/dist/cli/chat/MarkdownText.js +20 -0
  14. package/dist/cli/chat/MessageList.d.ts +37 -0
  15. package/dist/cli/chat/MessageList.js +80 -0
  16. package/dist/cli/chat/ModelSelector.d.ts +20 -0
  17. package/dist/cli/chat/ModelSelector.js +73 -0
  18. package/dist/cli/chat/RewindViewer.d.ts +26 -0
  19. package/dist/cli/chat/RewindViewer.js +185 -0
  20. package/dist/cli/chat/StoreSelector.d.ts +14 -0
  21. package/dist/cli/chat/StoreSelector.js +24 -0
  22. package/dist/cli/chat/StreamingText.d.ts +12 -0
  23. package/dist/cli/chat/StreamingText.js +12 -0
  24. package/dist/cli/chat/SubagentPanel.d.ts +45 -0
  25. package/dist/cli/chat/SubagentPanel.js +110 -0
  26. package/dist/cli/chat/TeamPanel.d.ts +21 -0
  27. package/dist/cli/chat/TeamPanel.js +42 -0
  28. package/dist/cli/chat/ToolIndicator.d.ts +25 -0
  29. package/dist/cli/chat/ToolIndicator.js +436 -0
  30. package/dist/cli/chat/hooks/useAgentLoop.d.ts +39 -0
  31. package/dist/cli/chat/hooks/useAgentLoop.js +382 -0
  32. package/dist/cli/chat/hooks/useSlashCommands.d.ts +37 -0
  33. package/dist/cli/chat/hooks/useSlashCommands.js +387 -0
  34. package/dist/cli/commands/config-cmd.d.ts +10 -0
  35. package/dist/cli/commands/config-cmd.js +99 -0
  36. package/dist/cli/commands/doctor.d.ts +14 -0
  37. package/dist/cli/commands/doctor.js +172 -0
  38. package/dist/cli/commands/init.d.ts +16 -0
  39. package/dist/cli/commands/init.js +278 -0
  40. package/dist/cli/commands/mcp.d.ts +12 -0
  41. package/dist/cli/commands/mcp.js +162 -0
  42. package/dist/cli/login/LoginApp.d.ts +7 -0
  43. package/dist/cli/login/LoginApp.js +157 -0
  44. package/dist/cli/print-mode.d.ts +31 -0
  45. package/dist/cli/print-mode.js +202 -0
  46. package/dist/cli/serve-mode.d.ts +37 -0
  47. package/dist/cli/serve-mode.js +636 -0
  48. package/dist/cli/services/agent-definitions.d.ts +25 -0
  49. package/dist/cli/services/agent-definitions.js +91 -0
  50. package/dist/cli/services/agent-events.d.ts +178 -0
  51. package/dist/cli/services/agent-events.js +175 -0
  52. package/dist/cli/services/agent-loop.d.ts +90 -0
  53. package/dist/cli/services/agent-loop.js +762 -0
  54. package/dist/cli/services/agent-worker-base.d.ts +97 -0
  55. package/dist/cli/services/agent-worker-base.js +220 -0
  56. package/dist/cli/services/auth-service.d.ts +30 -0
  57. package/dist/cli/services/auth-service.js +160 -0
  58. package/dist/cli/services/background-processes.d.ts +126 -0
  59. package/dist/cli/services/background-processes.js +318 -0
  60. package/dist/cli/services/browser-auth.d.ts +24 -0
  61. package/dist/cli/services/browser-auth.js +180 -0
  62. package/dist/cli/services/claude-md-loader.d.ts +16 -0
  63. package/dist/cli/services/claude-md-loader.js +58 -0
  64. package/dist/cli/services/config-store.d.ts +47 -0
  65. package/dist/cli/services/config-store.js +79 -0
  66. package/dist/cli/services/debug-log.d.ts +10 -0
  67. package/dist/cli/services/debug-log.js +52 -0
  68. package/dist/cli/services/error-logger.d.ts +58 -0
  69. package/dist/cli/services/error-logger.js +269 -0
  70. package/dist/cli/services/file-history.d.ts +21 -0
  71. package/dist/cli/services/file-history.js +83 -0
  72. package/dist/cli/services/format-server-response.d.ts +16 -0
  73. package/dist/cli/services/format-server-response.js +440 -0
  74. package/dist/cli/services/git-context.d.ts +11 -0
  75. package/dist/cli/services/git-context.js +66 -0
  76. package/dist/cli/services/hooks.d.ts +85 -0
  77. package/dist/cli/services/hooks.js +258 -0
  78. package/dist/cli/services/interactive-tools.d.ts +125 -0
  79. package/dist/cli/services/interactive-tools.js +260 -0
  80. package/dist/cli/services/keybinding-manager.d.ts +52 -0
  81. package/dist/cli/services/keybinding-manager.js +115 -0
  82. package/dist/cli/services/local-tools.d.ts +22 -0
  83. package/dist/cli/services/local-tools.js +697 -0
  84. package/dist/cli/services/lsp-manager.d.ts +18 -0
  85. package/dist/cli/services/lsp-manager.js +717 -0
  86. package/dist/cli/services/mcp-client.d.ts +48 -0
  87. package/dist/cli/services/mcp-client.js +157 -0
  88. package/dist/cli/services/memory-manager.d.ts +16 -0
  89. package/dist/cli/services/memory-manager.js +57 -0
  90. package/dist/cli/services/model-manager.d.ts +18 -0
  91. package/dist/cli/services/model-manager.js +71 -0
  92. package/dist/cli/services/model-router.d.ts +26 -0
  93. package/dist/cli/services/model-router.js +149 -0
  94. package/dist/cli/services/permission-modes.d.ts +13 -0
  95. package/dist/cli/services/permission-modes.js +43 -0
  96. package/dist/cli/services/rewind.d.ts +84 -0
  97. package/dist/cli/services/rewind.js +194 -0
  98. package/dist/cli/services/ripgrep.d.ts +28 -0
  99. package/dist/cli/services/ripgrep.js +138 -0
  100. package/dist/cli/services/sandbox.d.ts +29 -0
  101. package/dist/cli/services/sandbox.js +97 -0
  102. package/dist/cli/services/server-tools.d.ts +61 -0
  103. package/dist/cli/services/server-tools.js +543 -0
  104. package/dist/cli/services/session-persistence.d.ts +23 -0
  105. package/dist/cli/services/session-persistence.js +99 -0
  106. package/dist/cli/services/subagent-worker.d.ts +19 -0
  107. package/dist/cli/services/subagent-worker.js +41 -0
  108. package/dist/cli/services/subagent.d.ts +47 -0
  109. package/dist/cli/services/subagent.js +647 -0
  110. package/dist/cli/services/system-prompt.d.ts +7 -0
  111. package/dist/cli/services/system-prompt.js +198 -0
  112. package/dist/cli/services/team-lead.d.ts +73 -0
  113. package/dist/cli/services/team-lead.js +512 -0
  114. package/dist/cli/services/team-state.d.ts +77 -0
  115. package/dist/cli/services/team-state.js +398 -0
  116. package/dist/cli/services/teammate.d.ts +31 -0
  117. package/dist/cli/services/teammate.js +689 -0
  118. package/dist/cli/services/telemetry.d.ts +61 -0
  119. package/dist/cli/services/telemetry.js +209 -0
  120. package/dist/cli/services/tools/agent-tools.d.ts +14 -0
  121. package/dist/cli/services/tools/agent-tools.js +347 -0
  122. package/dist/cli/services/tools/file-ops.d.ts +15 -0
  123. package/dist/cli/services/tools/file-ops.js +487 -0
  124. package/dist/cli/services/tools/search-tools.d.ts +8 -0
  125. package/dist/cli/services/tools/search-tools.js +186 -0
  126. package/dist/cli/services/tools/shell-exec.d.ts +10 -0
  127. package/dist/cli/services/tools/shell-exec.js +168 -0
  128. package/dist/cli/services/tools/task-manager.d.ts +28 -0
  129. package/dist/cli/services/tools/task-manager.js +209 -0
  130. package/dist/cli/services/tools/web-tools.d.ts +11 -0
  131. package/dist/cli/services/tools/web-tools.js +395 -0
  132. package/dist/cli/setup/SetupApp.d.ts +9 -0
  133. package/dist/cli/setup/SetupApp.js +191 -0
  134. package/dist/cli/shared/MatrixIntro.d.ts +4 -0
  135. package/dist/cli/shared/MatrixIntro.js +83 -0
  136. package/dist/cli/shared/Theme.d.ts +74 -0
  137. package/dist/cli/shared/Theme.js +127 -0
  138. package/dist/cli/shared/WhaleBanner.d.ts +10 -0
  139. package/dist/cli/shared/WhaleBanner.js +12 -0
  140. package/dist/cli/shared/markdown.d.ts +21 -0
  141. package/dist/cli/shared/markdown.js +756 -0
  142. package/dist/cli/status/StatusApp.d.ts +4 -0
  143. package/dist/cli/status/StatusApp.js +105 -0
  144. package/dist/cli/stores/StoreApp.d.ts +7 -0
  145. package/dist/cli/stores/StoreApp.js +81 -0
  146. package/dist/index.d.ts +15 -0
  147. package/dist/index.js +538 -0
  148. package/dist/local-agent/connection.d.ts +48 -0
  149. package/dist/local-agent/connection.js +332 -0
  150. package/dist/local-agent/discovery.d.ts +18 -0
  151. package/dist/local-agent/discovery.js +146 -0
  152. package/dist/local-agent/executor.d.ts +34 -0
  153. package/dist/local-agent/executor.js +241 -0
  154. package/dist/local-agent/index.d.ts +14 -0
  155. package/dist/local-agent/index.js +198 -0
  156. package/dist/node/adapters/base.d.ts +35 -0
  157. package/dist/node/adapters/base.js +10 -0
  158. package/dist/node/adapters/discord.d.ts +29 -0
  159. package/dist/node/adapters/discord.js +299 -0
  160. package/dist/node/adapters/email.d.ts +23 -0
  161. package/dist/node/adapters/email.js +218 -0
  162. package/dist/node/adapters/imessage.d.ts +17 -0
  163. package/dist/node/adapters/imessage.js +118 -0
  164. package/dist/node/adapters/slack.d.ts +26 -0
  165. package/dist/node/adapters/slack.js +259 -0
  166. package/dist/node/adapters/sms.d.ts +23 -0
  167. package/dist/node/adapters/sms.js +161 -0
  168. package/dist/node/adapters/telegram.d.ts +17 -0
  169. package/dist/node/adapters/telegram.js +101 -0
  170. package/dist/node/adapters/webchat.d.ts +27 -0
  171. package/dist/node/adapters/webchat.js +160 -0
  172. package/dist/node/adapters/whatsapp.d.ts +28 -0
  173. package/dist/node/adapters/whatsapp.js +230 -0
  174. package/dist/node/cli.d.ts +2 -0
  175. package/dist/node/cli.js +325 -0
  176. package/dist/node/config.d.ts +17 -0
  177. package/dist/node/config.js +31 -0
  178. package/dist/node/runtime.d.ts +50 -0
  179. package/dist/node/runtime.js +351 -0
  180. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +11 -0
  181. package/dist/server/handlers/__test-utils__/mock-supabase.js +393 -0
  182. package/dist/server/handlers/analytics.d.ts +17 -0
  183. package/dist/server/handlers/analytics.js +266 -0
  184. package/dist/server/handlers/api-keys.d.ts +6 -0
  185. package/dist/server/handlers/api-keys.js +221 -0
  186. package/dist/server/handlers/billing.d.ts +33 -0
  187. package/dist/server/handlers/billing.js +272 -0
  188. package/dist/server/handlers/browser.d.ts +10 -0
  189. package/dist/server/handlers/browser.js +517 -0
  190. package/dist/server/handlers/catalog.d.ts +99 -0
  191. package/dist/server/handlers/catalog.js +976 -0
  192. package/dist/server/handlers/comms.d.ts +254 -0
  193. package/dist/server/handlers/comms.js +588 -0
  194. package/dist/server/handlers/creations.d.ts +6 -0
  195. package/dist/server/handlers/creations.js +479 -0
  196. package/dist/server/handlers/crm.d.ts +89 -0
  197. package/dist/server/handlers/crm.js +538 -0
  198. package/dist/server/handlers/discovery.d.ts +6 -0
  199. package/dist/server/handlers/discovery.js +288 -0
  200. package/dist/server/handlers/embeddings.d.ts +92 -0
  201. package/dist/server/handlers/embeddings.js +197 -0
  202. package/dist/server/handlers/enrichment.d.ts +8 -0
  203. package/dist/server/handlers/enrichment.js +768 -0
  204. package/dist/server/handlers/image-gen.d.ts +6 -0
  205. package/dist/server/handlers/image-gen.js +409 -0
  206. package/dist/server/handlers/inventory.d.ts +319 -0
  207. package/dist/server/handlers/inventory.js +447 -0
  208. package/dist/server/handlers/kali.d.ts +10 -0
  209. package/dist/server/handlers/kali.js +210 -0
  210. package/dist/server/handlers/llm-providers.d.ts +6 -0
  211. package/dist/server/handlers/llm-providers.js +673 -0
  212. package/dist/server/handlers/local-agent.d.ts +6 -0
  213. package/dist/server/handlers/local-agent.js +118 -0
  214. package/dist/server/handlers/meta-ads.d.ts +111 -0
  215. package/dist/server/handlers/meta-ads.js +2279 -0
  216. package/dist/server/handlers/nodes.d.ts +33 -0
  217. package/dist/server/handlers/nodes.js +699 -0
  218. package/dist/server/handlers/operations.d.ts +138 -0
  219. package/dist/server/handlers/operations.js +131 -0
  220. package/dist/server/handlers/platform.d.ts +23 -0
  221. package/dist/server/handlers/platform.js +227 -0
  222. package/dist/server/handlers/supply-chain.d.ts +19 -0
  223. package/dist/server/handlers/supply-chain.js +327 -0
  224. package/dist/server/handlers/transcription.d.ts +17 -0
  225. package/dist/server/handlers/transcription.js +121 -0
  226. package/dist/server/handlers/video-gen.d.ts +6 -0
  227. package/dist/server/handlers/video-gen.js +466 -0
  228. package/dist/server/handlers/voice.d.ts +8 -0
  229. package/dist/server/handlers/voice.js +1146 -0
  230. package/dist/server/handlers/workflow-steps.d.ts +86 -0
  231. package/dist/server/handlers/workflow-steps.js +2349 -0
  232. package/dist/server/handlers/workflows.d.ts +7 -0
  233. package/dist/server/handlers/workflows.js +989 -0
  234. package/dist/server/index.d.ts +1 -0
  235. package/dist/server/index.js +2427 -0
  236. package/dist/server/lib/batch-client.d.ts +80 -0
  237. package/dist/server/lib/batch-client.js +467 -0
  238. package/dist/server/lib/code-worker-pool.d.ts +31 -0
  239. package/dist/server/lib/code-worker-pool.js +224 -0
  240. package/dist/server/lib/code-worker.d.ts +1 -0
  241. package/dist/server/lib/code-worker.js +188 -0
  242. package/dist/server/lib/compaction-service.d.ts +32 -0
  243. package/dist/server/lib/compaction-service.js +162 -0
  244. package/dist/server/lib/logger.d.ts +19 -0
  245. package/dist/server/lib/logger.js +46 -0
  246. package/dist/server/lib/otel.d.ts +38 -0
  247. package/dist/server/lib/otel.js +126 -0
  248. package/dist/server/lib/pg-rate-limiter.d.ts +21 -0
  249. package/dist/server/lib/pg-rate-limiter.js +86 -0
  250. package/dist/server/lib/prompt-sanitizer.d.ts +37 -0
  251. package/dist/server/lib/prompt-sanitizer.js +177 -0
  252. package/dist/server/lib/provider-capabilities.d.ts +85 -0
  253. package/dist/server/lib/provider-capabilities.js +190 -0
  254. package/dist/server/lib/provider-failover.d.ts +74 -0
  255. package/dist/server/lib/provider-failover.js +210 -0
  256. package/dist/server/lib/rate-limiter.d.ts +39 -0
  257. package/dist/server/lib/rate-limiter.js +147 -0
  258. package/dist/server/lib/server-agent-loop.d.ts +107 -0
  259. package/dist/server/lib/server-agent-loop.js +667 -0
  260. package/dist/server/lib/server-subagent.d.ts +78 -0
  261. package/dist/server/lib/server-subagent.js +203 -0
  262. package/dist/server/lib/session-checkpoint.d.ts +51 -0
  263. package/dist/server/lib/session-checkpoint.js +145 -0
  264. package/dist/server/lib/ssrf-guard.d.ts +13 -0
  265. package/dist/server/lib/ssrf-guard.js +240 -0
  266. package/dist/server/lib/supabase-client.d.ts +7 -0
  267. package/dist/server/lib/supabase-client.js +78 -0
  268. package/dist/server/lib/template-resolver.d.ts +31 -0
  269. package/dist/server/lib/template-resolver.js +215 -0
  270. package/dist/server/lib/utils.d.ts +16 -0
  271. package/dist/server/lib/utils.js +147 -0
  272. package/dist/server/local-agent-gateway.d.ts +82 -0
  273. package/dist/server/local-agent-gateway.js +426 -0
  274. package/dist/server/providers/anthropic.d.ts +20 -0
  275. package/dist/server/providers/anthropic.js +199 -0
  276. package/dist/server/providers/bedrock.d.ts +20 -0
  277. package/dist/server/providers/bedrock.js +194 -0
  278. package/dist/server/providers/gemini.d.ts +24 -0
  279. package/dist/server/providers/gemini.js +486 -0
  280. package/dist/server/providers/openai.d.ts +24 -0
  281. package/dist/server/providers/openai.js +522 -0
  282. package/dist/server/providers/registry.d.ts +32 -0
  283. package/dist/server/providers/registry.js +58 -0
  284. package/dist/server/providers/shared.d.ts +32 -0
  285. package/dist/server/providers/shared.js +124 -0
  286. package/dist/server/providers/types.d.ts +92 -0
  287. package/dist/server/providers/types.js +12 -0
  288. package/dist/server/proxy-handlers.d.ts +6 -0
  289. package/dist/server/proxy-handlers.js +89 -0
  290. package/dist/server/tool-router.d.ts +149 -0
  291. package/dist/server/tool-router.js +803 -0
  292. package/dist/server/validation.d.ts +24 -0
  293. package/dist/server/validation.js +301 -0
  294. package/dist/server/worker.d.ts +19 -0
  295. package/dist/server/worker.js +201 -0
  296. package/dist/setup.d.ts +8 -0
  297. package/dist/setup.js +181 -0
  298. package/dist/shared/agent-core.d.ts +157 -0
  299. package/dist/shared/agent-core.js +534 -0
  300. package/dist/shared/anthropic-types.d.ts +105 -0
  301. package/dist/shared/anthropic-types.js +7 -0
  302. package/dist/shared/api-client.d.ts +90 -0
  303. package/dist/shared/api-client.js +379 -0
  304. package/dist/shared/constants.d.ts +33 -0
  305. package/dist/shared/constants.js +80 -0
  306. package/dist/shared/sse-parser.d.ts +26 -0
  307. package/dist/shared/sse-parser.js +259 -0
  308. package/dist/shared/tool-dispatch.d.ts +52 -0
  309. package/dist/shared/tool-dispatch.js +191 -0
  310. package/dist/shared/types.d.ts +72 -0
  311. package/dist/shared/types.js +7 -0
  312. package/dist/updater.d.ts +25 -0
  313. package/dist/updater.js +140 -0
  314. package/dist/webchat/widget.d.ts +0 -0
  315. package/dist/webchat/widget.js +397 -0
  316. package/package.json +95 -0
  317. package/src/cli/services/builtin-skills/commit.md +19 -0
  318. package/src/cli/services/builtin-skills/review-pr.md +21 -0
  319. package/src/cli/services/builtin-skills/review.md +18 -0
@@ -0,0 +1,699 @@
1
+ // handlers/nodes.ts — Node & Channel management endpoints
2
+ // Auth: User JWT for management, Node API key for node operations
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { checkPlanLimits, incrementUsage } from "./billing.js";
5
+ let agentInvoker = null;
6
+ /** Set the agent invoker — called once from index.ts to break circular dependency */
7
+ export function setNodeAgentInvoker(invoker) {
8
+ agentInvoker = invoker;
9
+ }
10
+ // ============================================================================
11
+ // HELPERS
12
+ // ============================================================================
13
+ function hashApiKey(key) {
14
+ return createHash("sha256").update(key).digest("hex");
15
+ }
16
+ function generateNodeApiKey() {
17
+ return randomBytes(32).toString("hex");
18
+ }
19
+ /** Authenticate a node by API key, returns node row or null */
20
+ async function authenticateNode(supabase, apiKey) {
21
+ const hash = hashApiKey(apiKey);
22
+ const { data } = await supabase
23
+ .from("nodes")
24
+ .select("id, store_id, name")
25
+ .eq("api_key_hash", hash)
26
+ .single();
27
+ return data;
28
+ }
29
+ /** Log a node event to both node_events and audit_logs (unified telemetry) */
30
+ async function logNodeEvent(supabase, storeId, nodeId, eventType, details = {}) {
31
+ // node_events — existing table for node lifecycle
32
+ await supabase.from("node_events").insert({
33
+ store_id: storeId,
34
+ node_id: nodeId,
35
+ event_type: eventType,
36
+ details,
37
+ });
38
+ // audit_logs — unified telemetry (same schema as tool/chat spans)
39
+ try {
40
+ const now = new Date();
41
+ const auditRow = {
42
+ action: `node.${eventType}`,
43
+ severity: "info",
44
+ store_id: storeId,
45
+ resource_type: "whale_node",
46
+ resource_id: nodeId,
47
+ source: "whale-node",
48
+ user_id: (details.registered_by || details.deleted_by || null),
49
+ trace_id: randomUUID(),
50
+ span_kind: "INTERNAL",
51
+ service_name: "whale-node",
52
+ status_code: "OK",
53
+ start_time: now.toISOString(),
54
+ end_time: now.toISOString(),
55
+ 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
+ }
63
+ }
64
+ catch {
65
+ // Audit must never break node operations
66
+ }
67
+ }
68
+ /** Resolve or create a conversation for a sender on a channel.
69
+ * Reuses conversation if last message from same sender was < 30 min ago.
70
+ * Creates an ai_conversations row for new sessions so telemetry/history works.
71
+ * Resolves customer identity from the dynamic bridge table. */
72
+ async function resolveConversation(supabase, channelId, senderId, storeId, channelType, senderName) {
73
+ // ── Resolve customer identity (best-effort, any channel type) ──
74
+ let customerId = null;
75
+ let customerName = null;
76
+ if (channelType) {
77
+ try {
78
+ const { data } = await supabase.rpc("resolve_sender_to_customer", {
79
+ p_store_id: storeId,
80
+ p_channel_type: channelType,
81
+ p_sender_id: senderId,
82
+ p_sender_name: senderName || null,
83
+ });
84
+ if (data?.length) {
85
+ customerId = data[0].customer_id;
86
+ customerName = data[0].customer_name;
87
+ }
88
+ }
89
+ catch { /* customer resolution non-critical */ }
90
+ }
91
+ // ── 30-min window — reuse existing conversation ──
92
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
93
+ const { data: recent } = await supabase
94
+ .from("channel_messages")
95
+ .select("conversation_id")
96
+ .eq("channel_id", channelId)
97
+ .eq("sender_id", senderId)
98
+ .gt("created_at", thirtyMinAgo)
99
+ .not("conversation_id", "is", null)
100
+ .order("created_at", { ascending: false })
101
+ .limit(1);
102
+ if (recent?.length && recent[0].conversation_id) {
103
+ // Backfill customer_id on existing conversation if newly resolved
104
+ if (customerId) {
105
+ Promise.resolve(supabase.from("ai_conversations")
106
+ .update({ customer_id: customerId })
107
+ .eq("id", recent[0].conversation_id)
108
+ .is("customer_id", null)).catch(() => { });
109
+ }
110
+ return { conversationId: recent[0].conversation_id, customerId, customerName, isNewSession: false };
111
+ }
112
+ // ── New session — create ai_conversations row ──
113
+ const newId = randomUUID();
114
+ const title = senderName
115
+ ? `${senderName} via ${channelType || "channel"}`
116
+ : `${senderId} via ${channelType || "channel"}`;
117
+ try {
118
+ await supabase.from("ai_conversations").insert({
119
+ id: newId,
120
+ store_id: storeId,
121
+ agent_id: null, // set later by invokeAgentForChannel
122
+ channel_id: channelId,
123
+ sender_id: senderId,
124
+ channel_type: channelType || null,
125
+ customer_id: customerId,
126
+ title,
127
+ metadata: { source: "channel", channel_type: channelType || "unknown" },
128
+ });
129
+ }
130
+ catch { /* ai_conversations insert non-critical — agent still works without it */ }
131
+ return { conversationId: newId, customerId, customerName, isNewSession: true };
132
+ }
133
+ // ============================================================================
134
+ // ROUTE HANDLER
135
+ // ============================================================================
136
+ export async function handleNodeRoutes(pathname, method, body, supabase, auth, queryParams) {
137
+ // ── POST /nodes/register ──────────────────────────────────────
138
+ // User auth required. Creates a node and returns API key (shown once).
139
+ if (pathname === "/nodes/register" && method === "POST") {
140
+ if (!auth.userId && !auth.isServiceRole) {
141
+ return { status: 401, body: { error: "User authentication required" } };
142
+ }
143
+ if (!body)
144
+ return { status: 400, body: { error: "Request body required" } };
145
+ const reg = body;
146
+ if (!reg.name || !reg.store_id) {
147
+ return { status: 400, body: { error: "name and store_id required" } };
148
+ }
149
+ // Verify user has access to this store
150
+ 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) {
157
+ return { status: 403, body: { error: "No access to this store" } };
158
+ }
159
+ }
160
+ // Check node limit against store plan
161
+ const { data: planRow } = await supabase
162
+ .from("store_plans")
163
+ .select("plan, limits")
164
+ .eq("store_id", reg.store_id)
165
+ .single();
166
+ const planLimits = planRow?.limits;
167
+ const nodesMax = planLimits?.nodes_max ?? 1; // free plan default
168
+ const { count } = await supabase
169
+ .from("nodes")
170
+ .select("id", { count: "exact", head: true })
171
+ .eq("store_id", reg.store_id);
172
+ if ((count || 0) >= nodesMax) {
173
+ return { status: 429, body: { error: `Node limit reached (${count}/${nodesMax} on ${planRow?.plan || "free"} plan)` } };
174
+ }
175
+ const apiKey = generateNodeApiKey();
176
+ const apiKeyHash = hashApiKey(apiKey);
177
+ const { data: node, error } = await supabase
178
+ .from("nodes")
179
+ .insert({
180
+ store_id: reg.store_id,
181
+ name: reg.name,
182
+ api_key_hash: apiKeyHash,
183
+ capabilities: reg.capabilities || [],
184
+ hardware: reg.hardware || {},
185
+ version: reg.version || "1.0.0",
186
+ status: "offline",
187
+ })
188
+ .select("id, name, store_id, status, created_at")
189
+ .single();
190
+ if (error) {
191
+ return { status: 500, body: { error: error.message } };
192
+ }
193
+ await logNodeEvent(supabase, reg.store_id, node.id, "registered", {
194
+ name: reg.name,
195
+ registered_by: auth.userId,
196
+ });
197
+ return {
198
+ status: 201,
199
+ body: {
200
+ success: true,
201
+ node,
202
+ api_key: apiKey, // Shown ONCE — user must save this
203
+ message: "Save your API key — it cannot be retrieved later.",
204
+ },
205
+ };
206
+ }
207
+ // ── POST /nodes/heartbeat ─────────────────────────────────────
208
+ // Node API key auth. Updates status + hardware info.
209
+ if (pathname === "/nodes/heartbeat" && method === "POST") {
210
+ const node = await authenticateNode(supabase, auth.rawToken);
211
+ if (!node) {
212
+ return { status: 401, body: { error: "Invalid node API key" } };
213
+ }
214
+ const hb = body;
215
+ const updates = {
216
+ status: "online",
217
+ last_heartbeat: new Date().toISOString(),
218
+ updated_at: new Date().toISOString(),
219
+ };
220
+ if (hb.hardware)
221
+ updates.hardware = hb.hardware;
222
+ if (hb.capabilities)
223
+ updates.capabilities = hb.capabilities;
224
+ if (hb.version)
225
+ updates.version = hb.version;
226
+ await supabase.from("nodes").update(updates).eq("id", node.id);
227
+ // Sync channel statuses if reported
228
+ if (hb.channels?.length) {
229
+ for (const ch of hb.channels) {
230
+ await supabase
231
+ .from("channels")
232
+ .update({ status: ch.status, updated_at: new Date().toISOString() })
233
+ .eq("node_id", node.id)
234
+ .eq("type", ch.type);
235
+ }
236
+ }
237
+ return {
238
+ status: 200,
239
+ body: { success: true, node_id: node.id },
240
+ };
241
+ }
242
+ // ── GET|POST /nodes ────────────────────────────────────────────
243
+ // User auth. Lists nodes for a store.
244
+ if (pathname === "/nodes" && (method === "GET" || method === "POST")) {
245
+ // store_id from body or query params
246
+ const storeId = body?.store_id || queryParams?.get("store_id");
247
+ if (!storeId) {
248
+ return { status: 400, body: { error: "store_id required" } };
249
+ }
250
+ const { data: nodes, error } = await supabase
251
+ .from("nodes")
252
+ .select("id, name, status, hardware, capabilities, version, ip_address, last_heartbeat, created_at")
253
+ .eq("store_id", storeId)
254
+ .order("created_at", { ascending: false });
255
+ if (error) {
256
+ return { status: 500, body: { error: error.message } };
257
+ }
258
+ // Also fetch channels per node
259
+ const nodeIds = (nodes || []).map((n) => n.id);
260
+ const { data: channels } = nodeIds.length
261
+ ? await supabase
262
+ .from("channels")
263
+ .select("id, node_id, type, name, status, stats")
264
+ .in("node_id", nodeIds)
265
+ : { data: [] };
266
+ // Attach channels to nodes
267
+ const result = (nodes || []).map((n) => ({
268
+ ...n,
269
+ channels: (channels || []).filter((c) => c.node_id === n.id),
270
+ }));
271
+ return { status: 200, body: { success: true, nodes: result } };
272
+ }
273
+ // ── DELETE /nodes/:id ─────────────────────────────────────────
274
+ // User auth. Deletes a node and all its channels.
275
+ const deleteMatch = pathname.match(/^\/nodes\/([a-f0-9-]+)$/);
276
+ if (deleteMatch && method === "DELETE") {
277
+ const nodeId = deleteMatch[1];
278
+ // Verify ownership
279
+ const { data: node } = await supabase
280
+ .from("nodes")
281
+ .select("id, store_id, name")
282
+ .eq("id", nodeId)
283
+ .single();
284
+ if (!node) {
285
+ return { status: 404, body: { error: "Node not found" } };
286
+ }
287
+ 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) {
294
+ return { status: 403, body: { error: "No access to this node" } };
295
+ }
296
+ }
297
+ await logNodeEvent(supabase, node.store_id, nodeId, "deleted", {
298
+ name: node.name,
299
+ deleted_by: auth.userId,
300
+ });
301
+ const { error } = await supabase.from("nodes").delete().eq("id", nodeId);
302
+ if (error) {
303
+ return { status: 500, body: { error: error.message } };
304
+ }
305
+ return { status: 200, body: { success: true, deleted: nodeId } };
306
+ }
307
+ // ── POST /channels ───────────────────────────────────────────
308
+ // User auth OR node API key. Registers a channel on a node.
309
+ if (pathname === "/channels" && method === "POST") {
310
+ if (!body)
311
+ return { status: 400, body: { error: "Request body required" } };
312
+ const reg = body;
313
+ if (!reg.store_id || !reg.node_id || !reg.type || !reg.name) {
314
+ return { status: 400, body: { error: "store_id, node_id, type, and name required" } };
315
+ }
316
+ // Verify node exists and belongs to store
317
+ const { data: node } = await supabase
318
+ .from("nodes")
319
+ .select("id, store_id")
320
+ .eq("id", reg.node_id)
321
+ .eq("store_id", reg.store_id)
322
+ .single();
323
+ if (!node) {
324
+ return { status: 404, body: { error: "Node not found in this store" } };
325
+ }
326
+ // Check channel-per-node limit against store plan
327
+ const { data: chanPlanRow } = await supabase
328
+ .from("store_plans")
329
+ .select("plan, limits")
330
+ .eq("store_id", reg.store_id)
331
+ .single();
332
+ const chanPlanLimits = chanPlanRow?.limits;
333
+ const channelsPerNode = chanPlanLimits?.channels_per_node ?? 2; // free plan default
334
+ const { count: existingChannels } = await supabase
335
+ .from("channels")
336
+ .select("id", { count: "exact", head: true })
337
+ .eq("node_id", reg.node_id);
338
+ if ((existingChannels || 0) >= channelsPerNode) {
339
+ return { status: 429, body: { error: `Channel limit reached (${existingChannels}/${channelsPerNode} per node on ${chanPlanRow?.plan || "free"} plan)` } };
340
+ }
341
+ const { data: channel, error } = await supabase
342
+ .from("channels")
343
+ .insert({
344
+ store_id: reg.store_id,
345
+ node_id: reg.node_id,
346
+ type: reg.type,
347
+ name: reg.name,
348
+ config: reg.config || {},
349
+ agent_id: reg.agent_id || null,
350
+ status: "inactive",
351
+ })
352
+ .select("id, type, name, status, created_at")
353
+ .single();
354
+ if (error) {
355
+ return { status: 500, body: { error: error.message } };
356
+ }
357
+ await logNodeEvent(supabase, reg.store_id, reg.node_id, "channel_added", {
358
+ channel_id: channel.id,
359
+ type: reg.type,
360
+ name: reg.name,
361
+ });
362
+ return { status: 201, body: { success: true, channel } };
363
+ }
364
+ // ── GET|POST /channels/list ──────────────────────────────────
365
+ // User auth. Lists channels for a store.
366
+ if (pathname === "/channels/list" && (method === "GET" || method === "POST")) {
367
+ const storeId = body?.store_id || queryParams?.get("store_id");
368
+ if (!storeId) {
369
+ return { status: 400, body: { error: "store_id required" } };
370
+ }
371
+ const { data: channels, error } = await supabase
372
+ .from("channels")
373
+ .select(`
374
+ id, node_id, type, name, status, config, agent_id, stats,
375
+ error_message, created_at, updated_at,
376
+ nodes!inner(id, name, status)
377
+ `)
378
+ .eq("store_id", storeId)
379
+ .order("created_at", { ascending: false });
380
+ if (error) {
381
+ return { status: 500, body: { error: error.message } };
382
+ }
383
+ return { status: 200, body: { success: true, channels: channels || [] } };
384
+ }
385
+ // ── PATCH /channels/:id ────────────────────────────────────────
386
+ // User auth. Update channel config/agent assignment.
387
+ const channelPatchMatch = pathname.match(/^\/channels\/([a-f0-9-]+)$/);
388
+ if (channelPatchMatch && (method === "PUT" || method === "POST") && body?._method === "PATCH") {
389
+ const channelId = channelPatchMatch[1];
390
+ const updates = {};
391
+ if (body.agent_id !== undefined)
392
+ updates.agent_id = body.agent_id;
393
+ if (body.config !== undefined)
394
+ updates.config = body.config;
395
+ if (body.name !== undefined)
396
+ updates.name = body.name;
397
+ if (body.status !== undefined)
398
+ updates.status = body.status;
399
+ updates.updated_at = new Date().toISOString();
400
+ const { data: channel, error } = await supabase
401
+ .from("channels")
402
+ .update(updates)
403
+ .eq("id", channelId)
404
+ .select("id, type, name, status, agent_id, config, updated_at")
405
+ .single();
406
+ if (error) {
407
+ return { status: 500, body: { error: error.message } };
408
+ }
409
+ return { status: 200, body: { success: true, channel } };
410
+ }
411
+ // ── POST /channels/:id/messages ──────────────────────────────
412
+ // Node API key auth. Ingests a message from a channel.
413
+ // If direction=inbound and channel has agent_id, auto-invokes agent.
414
+ const messageMatch = pathname.match(/^\/channels\/([a-f0-9-]+)\/messages$/);
415
+ if (messageMatch && method === "POST") {
416
+ const channelId = messageMatch[1];
417
+ if (!body)
418
+ return { status: 400, body: { error: "Request body required" } };
419
+ // Authenticate node (or service role for admin access)
420
+ const node = await authenticateNode(supabase, auth.rawToken);
421
+ if (!node && !auth.isServiceRole) {
422
+ return { status: 401, body: { error: "Invalid node API key" } };
423
+ }
424
+ // Verify channel exists (and belongs to this node if node auth)
425
+ let channelQuery = supabase
426
+ .from("channels")
427
+ .select("id, store_id, node_id, agent_id, config, type, name")
428
+ .eq("id", channelId);
429
+ if (node)
430
+ channelQuery = channelQuery.eq("node_id", node.id);
431
+ const { data: channel } = await channelQuery.single();
432
+ if (!channel) {
433
+ return { status: 404, body: { error: "Channel not found" } };
434
+ }
435
+ const msg = body;
436
+ const direction = msg.direction || "inbound";
437
+ const senderId = msg.sender_id || "unknown";
438
+ // Check plan message limits before accepting
439
+ const limitCheck = await checkPlanLimits(supabase, channel.store_id, "message");
440
+ if (!limitCheck.allowed) {
441
+ return { status: 429, body: { error: limitCheck.reason || "Message limit exceeded" } };
442
+ }
443
+ // Resolve conversation ID (reuse if < 30 min gap from same sender)
444
+ // Channel type is dynamic — read from channel.config.type or channel row's type column
445
+ const channelType = channel.config?.type || channel.type || "unknown";
446
+ const channelName = channel.name || "";
447
+ let conversationId = msg.conversation_id;
448
+ let senderContext;
449
+ if (!conversationId && direction === "inbound") {
450
+ const ctx = await resolveConversation(supabase, channelId, senderId, channel.store_id, channelType, msg.sender_name);
451
+ conversationId = ctx.conversationId;
452
+ senderContext = {
453
+ senderId,
454
+ senderName: msg.sender_name || undefined,
455
+ customerId: ctx.customerId,
456
+ customerName: ctx.customerName,
457
+ channelType,
458
+ channelId,
459
+ channelName,
460
+ };
461
+ }
462
+ // Insert inbound message
463
+ const { data: message, error } = await supabase
464
+ .from("channel_messages")
465
+ .insert({
466
+ store_id: channel.store_id,
467
+ channel_id: channelId,
468
+ direction,
469
+ sender_id: senderId,
470
+ sender_name: msg.sender_name,
471
+ content: msg.content,
472
+ content_type: msg.content_type || "text",
473
+ metadata: msg.metadata || {},
474
+ agent_id: channel.agent_id,
475
+ conversation_id: conversationId,
476
+ })
477
+ .select("id, direction, content, conversation_id, created_at")
478
+ .single();
479
+ if (error) {
480
+ return { status: 500, body: { error: error.message } };
481
+ }
482
+ // Update channel stats (best-effort)
483
+ try {
484
+ await supabase.rpc("increment_channel_stats", {
485
+ p_channel_id: channelId,
486
+ });
487
+ }
488
+ catch {
489
+ // Stats function may not exist yet — that's fine
490
+ }
491
+ // Audit log for inbound message (unified telemetry)
492
+ if (direction === "inbound") {
493
+ try {
494
+ const auditRow = {
495
+ action: `node.message.inbound`,
496
+ severity: "info",
497
+ store_id: channel.store_id,
498
+ resource_type: "whale_node",
499
+ resource_id: channelId,
500
+ source: "whale-node",
501
+ conversation_id: conversationId || message.conversation_id || null,
502
+ trace_id: randomUUID(),
503
+ span_kind: "INTERNAL",
504
+ service_name: "whale-node",
505
+ status_code: "OK",
506
+ start_time: new Date().toISOString(),
507
+ end_time: new Date().toISOString(),
508
+ details: {
509
+ channel_id: channelId,
510
+ channel_type: channelType,
511
+ sender_id: senderId,
512
+ sender_name: msg.sender_name || null,
513
+ node_id: node?.id || null,
514
+ has_agent: !!channel.agent_id,
515
+ },
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
+ }
522
+ }
523
+ catch {
524
+ // Audit must never break message flow
525
+ }
526
+ }
527
+ // Track message usage (best-effort, non-blocking)
528
+ incrementUsage(supabase, channel.store_id, direction === "inbound" ? { messages_in: 1 } : { messages_out: 1 }).catch((err) => console.error("[billing] usage increment failed:", err.message));
529
+ // ── Agent auto-invocation ───────────────────────────────────
530
+ // If inbound message and channel has an agent assigned, invoke it
531
+ let agentResponse = null;
532
+ if (direction === "inbound" && channel.agent_id && agentInvoker) {
533
+ // Check agent invocation limits before calling
534
+ const agentLimitCheck = await checkPlanLimits(supabase, channel.store_id, "agent_invocation");
535
+ if (!agentLimitCheck.allowed) {
536
+ console.warn(`[channel-agent] Agent invocation blocked: ${agentLimitCheck.reason}`);
537
+ }
538
+ else {
539
+ try {
540
+ console.log(`[channel-agent] Invoking agent ${channel.agent_id} for channel ${channelId}`);
541
+ const result = await agentInvoker(supabase, channel.agent_id, msg.content, channel.store_id, conversationId || message.conversation_id, senderContext);
542
+ // Track agent invocation usage
543
+ incrementUsage(supabase, channel.store_id, { agent_invocations: 1 })
544
+ .catch((err) => console.error("[billing] agent usage increment failed:", err.message));
545
+ if (result.success && result.response) {
546
+ // Insert agent response as outbound message
547
+ const { data: outMsg, error: outErr } = await supabase
548
+ .from("channel_messages")
549
+ .insert({
550
+ store_id: channel.store_id,
551
+ channel_id: channelId,
552
+ direction: "outbound",
553
+ sender_id: "agent",
554
+ sender_name: "AI Agent",
555
+ content: result.response,
556
+ content_type: "text",
557
+ metadata: { agent_id: channel.agent_id, auto_response: true },
558
+ agent_id: channel.agent_id,
559
+ conversation_id: conversationId || message.conversation_id,
560
+ })
561
+ .select("id, direction, content, conversation_id, created_at")
562
+ .single();
563
+ if (!outErr && outMsg) {
564
+ agentResponse = outMsg;
565
+ // Track outbound message usage
566
+ incrementUsage(supabase, channel.store_id, { messages_out: 1 })
567
+ .catch((err) => console.error("[billing] outbound usage increment failed:", err.message));
568
+ console.log(`[channel-agent] Response stored: ${outMsg.id}`);
569
+ }
570
+ }
571
+ else if (result.error) {
572
+ console.error(`[channel-agent] Agent error: ${result.error}`);
573
+ }
574
+ }
575
+ catch (err) {
576
+ console.error(`[channel-agent] Invocation failed:`, err.message);
577
+ }
578
+ }
579
+ }
580
+ return {
581
+ status: 201,
582
+ body: {
583
+ success: true,
584
+ message,
585
+ agent_response: agentResponse,
586
+ conversation_id: conversationId || message.conversation_id,
587
+ },
588
+ };
589
+ }
590
+ // ── GET /channels/:id/messages ─────────────────────────────────
591
+ // Node API key auth. Retrieves messages for a channel.
592
+ // Query params (via body): direction, undelivered, limit, after
593
+ const getMessagesMatch = pathname.match(/^\/channels\/([a-f0-9-]+)\/messages$/);
594
+ if (getMessagesMatch && method === "GET") {
595
+ const channelId = getMessagesMatch[1];
596
+ // Authenticate node (or service role for admin access)
597
+ const node = await authenticateNode(supabase, auth.rawToken);
598
+ if (!node && !auth.isServiceRole) {
599
+ return { status: 401, body: { error: "Invalid node API key" } };
600
+ }
601
+ // Verify channel exists (and belongs to this node if node auth)
602
+ let chQuery = supabase.from("channels").select("id, node_id").eq("id", channelId);
603
+ if (node)
604
+ chQuery = chQuery.eq("node_id", node.id);
605
+ const { data: channel } = await chQuery.single();
606
+ if (!channel) {
607
+ return { status: 404, body: { error: "Channel not found" } };
608
+ }
609
+ let query = supabase
610
+ .from("channel_messages")
611
+ .select("id, direction, sender_id, sender_name, content, content_type, metadata, conversation_id, delivered_at, created_at")
612
+ .eq("channel_id", channelId);
613
+ // Filter by direction (body or query param)
614
+ const direction = body?.direction || queryParams?.get("direction");
615
+ if (direction) {
616
+ query = query.eq("direction", direction);
617
+ }
618
+ // Filter undelivered outbound messages (for node polling)
619
+ const undelivered = body?.undelivered || queryParams?.get("undelivered");
620
+ if (undelivered === true || undelivered === "true") {
621
+ query = query.eq("direction", "outbound").is("delivered_at", null);
622
+ }
623
+ // After a specific timestamp
624
+ const after = body?.after || queryParams?.get("after");
625
+ if (after) {
626
+ query = query.gt("created_at", after);
627
+ }
628
+ const rawLimit = body?.limit || queryParams?.get("limit") || 50;
629
+ const limit = Math.min(Number(rawLimit), 200);
630
+ query = query.order("created_at", { ascending: true }).limit(limit);
631
+ const { data: messages, error: msgErr } = await query;
632
+ if (msgErr) {
633
+ return { status: 500, body: { error: msgErr.message } };
634
+ }
635
+ return { status: 200, body: { success: true, messages: messages || [] } };
636
+ }
637
+ // ── PATCH /channels/:id/messages/:msgId ──────────────────────
638
+ // Node API key auth. Mark message as delivered.
639
+ const deliverMatch = pathname.match(/^\/channels\/([a-f0-9-]+)\/messages\/([a-f0-9-]+)$/);
640
+ if (deliverMatch && (method === "PUT" || method === "POST") && body?._method === "PATCH") {
641
+ const channelId = deliverMatch[1];
642
+ const msgId = deliverMatch[2];
643
+ const node = await authenticateNode(supabase, auth.rawToken);
644
+ if (!node) {
645
+ return { status: 401, body: { error: "Invalid node API key" } };
646
+ }
647
+ const { error } = await supabase
648
+ .from("channel_messages")
649
+ .update({
650
+ delivered_at: body?.delivered_at || new Date().toISOString(),
651
+ metadata: { ...(body?.metadata || {}), delivered_by_node: node.id },
652
+ })
653
+ .eq("id", msgId)
654
+ .eq("channel_id", channelId);
655
+ if (error) {
656
+ return { status: 500, body: { error: error.message } };
657
+ }
658
+ return { status: 200, body: { success: true, delivered: msgId } };
659
+ }
660
+ // ── POST /channels/:id/messages/:msgId/delivered ─────────────
661
+ // Simpler delivery marking endpoint (no _method hack needed)
662
+ const deliverSimpleMatch = pathname.match(/^\/channels\/([a-f0-9-]+)\/messages\/([a-f0-9-]+)\/delivered$/);
663
+ if (deliverSimpleMatch && method === "POST") {
664
+ const channelId = deliverSimpleMatch[1];
665
+ const msgId = deliverSimpleMatch[2];
666
+ const node = await authenticateNode(supabase, auth.rawToken);
667
+ if (!node) {
668
+ return { status: 401, body: { error: "Invalid node API key" } };
669
+ }
670
+ const { error } = await supabase
671
+ .from("channel_messages")
672
+ .update({ delivered_at: new Date().toISOString() })
673
+ .eq("id", msgId)
674
+ .eq("channel_id", channelId);
675
+ if (error) {
676
+ return { status: 500, body: { error: error.message } };
677
+ }
678
+ return { status: 200, body: { success: true, delivered: msgId } };
679
+ }
680
+ // ── GET /nodes/:id/events ────────────────────────────────────
681
+ // User auth. Lists recent events for a node.
682
+ const eventsMatch = pathname.match(/^\/nodes\/([a-f0-9-]+)\/events$/);
683
+ if (eventsMatch && method === "GET") {
684
+ const nodeId = eventsMatch[1];
685
+ const limit = body?.limit || 50;
686
+ const { data: events, error } = await supabase
687
+ .from("node_events")
688
+ .select("id, event_type, details, created_at")
689
+ .eq("node_id", nodeId)
690
+ .order("created_at", { ascending: false })
691
+ .limit(limit);
692
+ if (error) {
693
+ return { status: 500, body: { error: error.message } };
694
+ }
695
+ return { status: 200, body: { success: true, events: events || [] } };
696
+ }
697
+ // No route matched
698
+ return null;
699
+ }