EXTENSION for treeos-connect
gateway-tree
Tree-to-tree gateway channel. Connects a tree on this land to a tree on another land, without Canopy federation. Canopy is infrastructure-level peering between land operators. Gateway-tree is user-level connection between tree owners. You don't need your land operator to peer with their land operator. You just need your tree to talk to their tree. The channel handles auth, rate limiting, and energy through the existing gateway framework. Two users connect their trees without any admin involvement. Land A's tree writes a note. The gateway-tree channel formats it and POSTs it to Land B's receiver endpoint. Land B's gateway processes it through the normal conversation loop. The AI responds. The response flows back in the HTTP response. Land A receives the reply as a gateway result. Output: sends tree content to a remote tree as a gateway message. The local tree's cascade signals, notes, or AI outputs become input for the remote tree. Input: receives messages from remote trees. The remote tree's output arrives as a gateway message on this land. processGatewayMessage fires. The AI at the connected node reads it, generates a response, and the response is returned to the caller in the HTTP response body. Input-output: bidirectional. Both trees talk to each other. A research tree on Land A asks questions. A knowledge tree on Land B answers. The conversation history lives on both sides as notes and contributions. Two trees, two lands, one conversation. No admin involvement. No federation required. Auth: the channel stores an API key or share token for the remote land. The remote land's gateway-tree extension verifies it against the receiving channel's webhook secret. Energy budget of the local channel owner covers outbound calls. Energy budget of the remote channel owner covers AI processing. Each side pays for its own work.
v1.0.1 by TreeOS Site 0 downloads 4 files 384 lines 14.5 KB published 38d ago
treeos ext install gateway-tree
View changelog

Manifest

Requires

  • extensions: gateway
SHA256: 9f08fc7a56f60ea5e942fb17407ed43223e2397a0b5ea613c283c487e1665496

Dependents

1 package depend on this

PackageTypeRelationship
treeos-connect v1.0.3bundleincludes

Environment Variables

KeyRequiredDescription
TREE_GATEWAY_SECRET secret auto No Shared secret for verifying inbound tree-to-tree gateway messages

Source Code

1// Tree-to-tree gateway channel handler.
2//
3// Connects trees across lands without Canopy federation. Canopy is
4// infrastructure-level peering between land operators. Gateway-tree is
5// user-level connection between tree owners. You don't need your land
6// operator to peer with their land operator. You just need your tree
7// to talk to their tree.
8//
9// Fully async. Same pattern as every other channel type.
10//
11// Output: POST the message to the remote land's input endpoint. The
12//   remote land responds 200 immediately. Connection closed. Done.
13//   The remote land processes the message in the background through
14//   processGatewayMessage. If the remote channel is input-output,
15//   the remote AI's reply fires as a separate output POST back to
16//   this land's input endpoint.
17//
18// Input: receive POSTed JSON from a remote tree. Respond 200 immediately.
19//   Process through processGatewayMessage in the background. If this
20//   channel is input-output, the reply fires through this channel's
21//   output (a separate POST to the remote land's input endpoint).
22//
23// Two async one-way messages. Not one synchronous round trip. Each
24// direction is independent. Each responds 200 before processing.
25// If the remote land is slow, this land isn't hanging. If the remote
26// AI fails, a cascade result with status:failed appears in .flow.
27//
28// Setup: both lands need gateway-tree installed. Both configure a channel
29// pointing at the other. Land A's output URL is Land B's input endpoint.
30// Land B's output URL is Land A's input endpoint. Two channels. Two
31// directions. Fully async.
32//
33// The message arrives as rain on the receiving land. The reply arrives
34// as rain on the sending land. Both flow through .flow. Both get
35// filtered by perspective. Both get processed by the conversation loop.
36// The gateway doesn't know it's talking to another tree. It just sends
37// and receives messages through the same interface every other channel uses.
38
39import log from "../../seed/log.js";
40import crypto from "crypto";
41
42// ─────────────────────────────────────────────────────────────────────────
43// VALIDATION
44// ─────────────────────────────────────────────────────────────────────────
45
46function validateConfig(config, direction) {
47  const hasOutput = direction === "output" || direction === "input-output";
48  const hasInput = direction === "input" || direction === "input-output";
49
50  if (hasOutput) {
51    if (!config.remoteUrl || typeof config.remoteUrl !== "string") {
52      throw new Error(
53        "Tree output requires a remoteUrl: the remote land's gateway-tree input endpoint " +
54        "(e.g., https://otherland.example.com/api/v1/gateway/tree/<theirChannelId>)"
55      );
56    }
57    try {
58      const parsed = new URL(config.remoteUrl);
59      if (!parsed.protocol.startsWith("http")) {
60        throw new Error("remoteUrl must use http or https");
61      }
62    } catch (err) {
63      if (err.message.includes("http")) throw err;
64      throw new Error("remoteUrl is not a valid URL");
65    }
66
67    if (!config.remoteSecret || typeof config.remoteSecret !== "string") {
68      throw new Error(
69        "Tree output requires a remoteSecret: the webhook secret of the remote land's receiving channel"
70      );
71    }
72  }
73
74  if (hasInput) {
75    // Input channels generate their own webhook secret on creation.
76    // Share it with the remote tree owner. They configure it as their remoteSecret.
77    if (config.senderFilter && typeof config.senderFilter !== "string") {
78      throw new Error("senderFilter must be a string (land domain or username)");
79    }
80  }
81}
82
83function buildEncryptedConfig(config, direction) {
84  const hasInput = direction === "input" || direction === "input-output";
85  const hasOutput = direction === "output" || direction === "input-output";
86
87  const secrets = {};
88  const metadata = {};
89
90  if (hasOutput) {
91    secrets.remoteUrl = config.remoteUrl;
92    secrets.remoteSecret = config.remoteSecret;
93  }
94
95  if (hasInput) {
96    metadata.webhookSecret = crypto.randomBytes(32).toString("hex");
97    if (config.senderFilter) metadata.senderFilter = config.senderFilter;
98  }
99
100  let display = "tree";
101  if (config.remoteUrl) {
102    try { display = new URL(config.remoteUrl).hostname; } catch (err) { log.debug("GatewayTree", "Could not parse remoteUrl for display:", err.message); }
103  }
104  if (config.remoteName) display = config.remoteName;
105
106  return {
107    secrets,
108    metadata,
109    displayIdentifier: display,
110  };
111}
112
113// ─────────────────────────────────────────────────────────────────────────
114// SENDER (output: fire-and-forget POST to remote land)
115// ─────────────────────────────────────────────────────────────────────────
116
117async function send(secrets, metadata, notification) {
118  const url = secrets.remoteUrl;
119  const secret = secrets.remoteSecret;
120
121  if (!url || !secret) {
122    throw new Error("Tree channel not configured for output (missing remoteUrl or remoteSecret)");
123  }
124
125  const payload = {
126    secret,
127    senderLand: process.env.LAND_DOMAIN || null,
128    message: notification.content || "",
129    title: notification.title || null,
130    type: notification.type || "notification",
131  };
132
133  // Fire and forget. We only care that the remote land accepted the message (200).
134  // The remote land processes in the background. If the remote AI produces a reply,
135  // it comes back as a separate POST to our input endpoint. Not in this response.
136  const res = await fetch(url, {
137    method: "POST",
138    headers: { "Content-Type": "application/json" },
139    body: JSON.stringify(payload),
140    signal: AbortSignal.timeout(10000), // 10s. Just waiting for 200 acceptance, not AI processing.
141  });
142
143  if (!res.ok) {
144    const body = await res.text().catch(() => "");
145    throw new Error(`Remote land responded ${res.status}: ${body.slice(0, 200)}`);
146  }
147}
148
149// ─────────────────────────────────────────────────────────────────────────
150// INPUT LIFECYCLE
151// ─────────────────────────────────────────────────────────────────────────
152
153async function registerInput(channel, secrets) {
154  const webhookSecret = channel.config?.metadata?.webhookSecret;
155  log.info("GatewayTree",
156    `Tree input registered for channel ${channel._id}. ` +
157    `Endpoint: POST /api/v1/gateway/tree/${channel._id}. ` +
158    `Share the webhook secret with the remote tree owner: ${webhookSecret ? webhookSecret.slice(0, 12) + "..." : "none"}`,
159  );
160}
161
162async function unregisterInput(channel, secrets) {
163  log.verbose("GatewayTree", `Tree input unregistered for channel ${channel._id}`);
164}
165
166export default {
167  allowedDirections: ["input", "output", "input-output"],
168  validateConfig,
169  buildEncryptedConfig,
170  send,
171  registerInput,
172  unregisterInput,
173};
174
1import log from "../../seed/log.js";
2import handler from "./handler.js";
3import { getExtension } from "../loader.js";
4
5export async function init(core) {
6  const gateway = getExtension("gateway");
7  if (!gateway?.exports?.registerChannelType) {
8    throw new Error("gateway-tree requires the gateway extension to be loaded first");
9  }
10
11  gateway.exports.registerChannelType("tree", handler);
12  log.verbose("GatewayTree", "Registered tree-to-tree channel type");
13
14  const { default: router } = await import("./routes.js");
15
16  return { router };
17}
18
1export default {
2  name: "gateway-tree",
3  version: "1.0.1",
4  builtFor: "treeos-connect",
5  description:
6    "Tree-to-tree gateway channel. Connects a tree on this land to a tree on " +
7    "another land, without Canopy federation. Canopy is infrastructure-level " +
8    "peering between land operators. Gateway-tree is user-level connection " +
9    "between tree owners. You don't need your land operator to peer with their " +
10    "land operator. You just need your tree to talk to their tree. " +
11    "\n\n" +
12    "The channel handles auth, rate limiting, and energy through the existing " +
13    "gateway framework. Two users connect their trees without any admin involvement. " +
14    "Land A's tree writes a note. The gateway-tree channel formats it and POSTs it " +
15    "to Land B's receiver endpoint. Land B's gateway processes it through the normal " +
16    "conversation loop. The AI responds. The response flows back in the HTTP response. " +
17    "Land A receives the reply as a gateway result. " +
18    "\n\n" +
19    "Output: sends tree content to a remote tree as a gateway message. The local " +
20    "tree's cascade signals, notes, or AI outputs become input for the remote tree. " +
21    "Input: receives messages from remote trees. The remote tree's output arrives " +
22    "as a gateway message on this land. processGatewayMessage fires. The AI at the " +
23    "connected node reads it, generates a response, and the response is returned " +
24    "to the caller in the HTTP response body. " +
25    "\n\n" +
26    "Input-output: bidirectional. Both trees talk to each other. A research tree " +
27    "on Land A asks questions. A knowledge tree on Land B answers. The conversation " +
28    "history lives on both sides as notes and contributions. Two trees, two lands, " +
29    "one conversation. No admin involvement. No federation required. " +
30    "\n\n" +
31    "Auth: the channel stores an API key or share token for the remote land. " +
32    "The remote land's gateway-tree extension verifies it against the receiving " +
33    "channel's webhook secret. Energy budget of the local channel owner covers " +
34    "outbound calls. Energy budget of the remote channel owner covers AI processing. " +
35    "Each side pays for its own work.",
36
37  needs: {
38    extensions: ["gateway"],
39  },
40
41  optional: {},
42
43  provides: {
44    models: {},
45    routes: false,
46    tools: false,
47    jobs: false,
48    orchestrator: false,
49    energyActions: {},
50    sessionTypes: {},
51    env: [
52      { key: "TREE_GATEWAY_SECRET", required: false, secret: true, autoGenerate: true, description: "Shared secret for verifying inbound tree-to-tree gateway messages" },
53    ],
54    cli: [],
55  },
56};
57
1// Tree-to-tree gateway webhook receiver.
2//
3// No auth middleware. Remote trees call this directly with a shared secret.
4// Same async pattern as every other channel type.
5//
6// Respond 200 immediately. Process in background. If input-output, the AI's
7// reply fires as a separate outbound POST through the gateway dispatch pipeline
8// (the channel's output side sends it back to the remote land's input endpoint).
9//
10// The remote land doesn't wait. The message arrives as rain. The reply
11// arrives as rain on the other side. Two one-way messages. Fully async.
12//
13// Endpoint: POST /api/v1/gateway/tree/:channelId
14//
15// Request body (JSON):
16//   {
17//     secret: string,        // must match the channel's webhookSecret
18//     senderLand: string,    // domain of the sending land (informational)
19//     message: string,       // the content to process
20//     title: string | null,  // optional title/subject
21//     type: string,          // notification type (informational)
22//   }
23
24import log from "../../seed/log.js";
25import { sendOk, sendError, ERR } from "../../seed/protocol.js";
26import express from "express";
27
28const router = express.Router();
29
30router.post("/gateway/tree/:channelId", async (req, res) => {
31  const channelId = req.params.channelId;
32  const body = req.body;
33
34  // Validate payload structure before responding
35  if (!body || !body.message || typeof body.message !== "string") {
36    return sendError(res, 400, ERR.INVALID_INPUT, "Missing or invalid message field");
37  }
38  if (!body.secret || typeof body.secret !== "string") {
39    return sendError(res, 401, ERR.UNAUTHORIZED, "Missing secret");
40  }
41
42  // Load and validate channel synchronously (fast DB lookup)
43  let channel;
44  try {
45    const { getExtension } = await import("../loader.js");
46    const GatewayChannel = getExtension("gateway")?.exports?.GatewayChannel;
47    channel = await GatewayChannel.findById(channelId).lean();
48  } catch {
49    return sendError(res, 500, ERR.INTERNAL, "Failed to load channel");
50  }
51
52  if (!channel || !channel.enabled) {
53    return sendError(res, 404, ERR.NODE_NOT_FOUND, "Channel not found or disabled");
54  }
55  if (channel.type !== "tree") {
56    return sendError(res, 400, ERR.INVALID_INPUT, "Channel is not a tree type");
57  }
58  const hasInput = channel.direction === "input" || channel.direction === "input-output";
59  if (!hasInput) {
60    return sendError(res, 400, ERR.INVALID_INPUT, "Channel does not accept input");
61  }
62
63  // Verify webhook secret
64  const expectedSecret = channel.config?.metadata?.webhookSecret;
65  if (!expectedSecret || body.secret !== expectedSecret) {
66    return sendError(res, 401, ERR.UNAUTHORIZED, "Invalid secret");
67  }
68
69  // Respond 200 immediately. Processing happens in background.
70  sendOk(res, { received: true });
71
72  // ── Background processing ──────────────────────────────────────────
73  try {
74    // Optional sender filter
75    const senderFilter = channel.config?.metadata?.senderFilter;
76    if (senderFilter && body.senderLand) {
77      const filterLower = senderFilter.toLowerCase();
78      const senderLower = body.senderLand.toLowerCase();
79      if (senderLower !== filterLower && !senderLower.endsWith("." + filterLower)) {
80        log.verbose("GatewayTree", `Filtered out message from ${body.senderLand} (filter: ${senderFilter})`);
81        return;
82      }
83    }
84
85    const senderName = body.senderLand || "remote-tree";
86    const senderPlatformId = body.senderLand || "unknown";
87    let messageText = body.message.trim();
88
89    if (body.title) {
90      messageText = `[${body.title}] ${messageText}`;
91    }
92    if (!messageText) return;
93
94    log.verbose("GatewayTree",
95      `Tree message on channel ${channelId} from ${senderName}: "${messageText.slice(0, 80)}"`,
96    );
97
98    // Process via gateway core. Same pipeline as Telegram, Discord, etc.
99    const { getExtension } = await import("../loader.js");
100    const gateway = getExtension("gateway");
101    if (!gateway?.exports?.processGatewayMessage) {
102      log.error("GatewayTree", "Gateway core not loaded");
103      return;
104    }
105
106    const result = await gateway.exports.processGatewayMessage(channelId, {
107      senderName,
108      senderPlatformId,
109      messageText,
110    });
111
112    // If input-output and there's a reply, dispatch it back through the
113    // gateway output pipeline. The channel's output side (send()) POSTs
114    // the reply to the remote land's input endpoint as a separate message.
115    if (result.reply && channel.direction === "input-output" && channel.rootId) {
116      try {
117        await gateway.exports.dispatchNotifications(channel.rootId, [{
118          type: "tree-reply",
119          title: null,
120          content: result.reply,
121        }]);
122      } catch (err) {
123        log.warn("GatewayTree", `Failed to dispatch reply for channel ${channelId}: ${err.message}`);
124      }
125    }
126  } catch (err) {
127    log.error("GatewayTree",
128      `Tree webhook processing error for channel ${channelId}:`,
129      err.message,
130    );
131  }
132});
133
134export default router;
135

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star gateway-tree

Comments

Loading comments...

Post comments from the CLI: treeos ext comment gateway-tree "your comment"
Max 3 comments per extension. One star and one flag per user.