EXTENSION for TreeOS
team
How people work together on trees. Trees start with one owner. Team adds the ability to invite contributors, transfer ownership, remove users, and retire trees. The invite system is atomic: pending invites transition to accepted or declined in a single findOneAndUpdate to prevent double-processing. Ownership transfers are immediate. The old owner becomes a contributor. The new owner's LLM assignments clear because they do not own the old connections. Cross-land invites work through Canopy federation. Invite a user by username@domain and the extension auto-peers with the remote land via Horizon, resolves the remote user, sends an invite offer, and creates a local pending record. When the remote user accepts, a canopy event fires back and the contributor is added. @mentions in notes are first-class. The beforeNote hook rewrites @username references to canonical usernames (case-insensitive lookup, single DB query for all mentions in a note). The afterNote hook syncs NoteTag records so tagged users can query all notes they were mentioned in, with date filtering and pagination. Tags auto-cleanup on note deletion. The /user/:userId/tags endpoint returns the full mention history. Every invite action (create, accept, deny, remove, transfer, retire) writes to the contribution log for a complete audit trail.
v1.0.2 by TreeOS Site 0 downloads 10 files 1,895 lines 55.3 KB published 38d ago
treeos ext install team
View changelog

Manifest

Provides

  • 2 models
  • routes
  • 5 CLI commands

Requires

  • services: contributions
  • models: User, Node, Note

Optional

  • services: energy, websocket
  • extensions: html-rendering, treeos-base
SHA256: b4f46f5184e4343943014fb824d170c0720a600dd47b83781036cd3b06a652ba

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
invitesGETList pending invites
invitePOSTInvite a user to the current tree
transfer-ownerPOSTTransfer tree ownership to another user
remove-userPOSTRemove a contributor from the tree
retirePOSTRetire (soft-delete) a tree you own

Source Code

1/**
2 * Canopy invite handlers.
3 * Called by canopy.js routes via dynamic delegation to the team extension.
4 * Each handler receives (req, res) and the canopy infrastructure it needs.
5 */
6import { sendOk, sendError, ERR } from "../../seed/protocol.js";
7import { Invite } from "./model.js";
8
9/**
10 * POST /canopy/invite/offer
11 * A remote land notifies us that one of our users has been invited.
12 */
13export async function handleInviteOffer(req, res, { User, RemoteUser, validateCanopyRequest }) {
14  const validation = validateCanopyRequest("invite_offer", req.body);
15  if (!validation.valid) {
16    return sendError(res, 400, ERR.INVALID_INPUT, "Validation failed", { errors: validation.errors });
17  }
18
19  const { receivingUsername, rootId, rootName, invitingUserId, invitingUsername, sourceInviteId } =
20    req.body;
21  const sourceLandDomain = req.canopy.sourceLandDomain;
22
23  const localUser = await User.findOne({
24    username: receivingUsername,
25    isRemote: { $ne: true },
26  });
27
28  if (!localUser) {
29    return sendError(res, 404, ERR.USER_NOT_FOUND, `User ${receivingUsername} not found on this land`);
30  }
31
32  await RemoteUser.findOneAndUpdate(
33    { _id: invitingUserId },
34    {
35      _id: invitingUserId,
36      username: invitingUsername || "unknown",
37      homeLandDomain: sourceLandDomain,
38      displayName: invitingUsername || "",
39      lastSyncedAt: new Date(),
40    },
41    { upsert: true, new: true }
42  );
43
44  const existingInvite = await Invite.findOne({
45    userReceiving: localUser._id,
46    rootId,
47    remoteLandDomain: sourceLandDomain,
48    status: "pending",
49  });
50
51  if (existingInvite) {
52    return sendOk(res, {
53      inviteId: existingInvite._id,
54      message: "Invite already pending",
55      userId: localUser._id,
56      username: localUser.username,
57    });
58  }
59
60  const invite = await Invite.create({
61    userInviting: invitingUserId,
62    userReceiving: localUser._id,
63    rootId,
64    remoteLandDomain: sourceLandDomain,
65    remoteRootName: rootName || "Untitled",
66    remoteInviteId: sourceInviteId || null,
67    remoteInvitingUsername: invitingUsername || null,
68    status: "pending",
69  });
70
71  sendOk(res, {
72    inviteId: invite._id,
73    message: "Invite offer received",
74    userId: localUser._id,
75    username: localUser.username,
76  });
77}
78
79/**
80 * POST /canopy/invite/accept
81 * A remote land confirms that their user accepted an invite.
82 */
83export async function handleInviteAccept(req, res, { User, Node, RemoteUser, validateCanopyRequest, addContributor }) {
84  const validation = validateCanopyRequest("invite_accept", req.body);
85  if (!validation.valid) {
86    return sendError(res, 400, ERR.INVALID_INPUT, "Validation failed", { errors: validation.errors });
87  }
88
89  const { inviteId, userId, username } = req.body;
90
91  const invite = await Invite.findOneAndUpdate(
92    { _id: inviteId, status: "pending" },
93    { $set: { status: "accepted" } },
94    { new: true }
95  );
96  if (!invite) {
97    return sendError(res, 404, ERR.NODE_NOT_FOUND, "Invite not found or already processed");
98  }
99
100  // SECURITY: Verify the accepting land is the one the invite was intended for.
101  const intendedRecipient = await RemoteUser.findById(invite.userReceiving);
102  if (!intendedRecipient || intendedRecipient.homeLandDomain !== req.canopy.sourceLandDomain) {
103    await Invite.findByIdAndUpdate(inviteId, { $set: { status: "pending" } });
104    return sendError(res, 403, ERR.FORBIDDEN, "This invite was not sent to your land");
105  }
106
107  // SECURITY: Check if this UUID belongs to a local user.
108  const existingLocal = await User.findOne({ _id: userId, isRemote: { $ne: true } });
109  if (existingLocal) {
110    await Invite.findByIdAndUpdate(inviteId, { $set: { status: "pending" } });
111    return sendError(res, 403, ERR.FORBIDDEN, "User ID conflicts with a local user. Invite rejected.");
112  }
113
114  // Find or create ghost user atomically
115  const ghostCount = await User.countDocuments({
116    isRemote: true,
117    homeLand: req.canopy.sourceLandDomain,
118  });
119
120  const ghostUsername = username
121    ? `${username}@${req.canopy.sourceLandDomain}`
122    : `${req.canopy.sourceLandDomain}_${userId.slice(0, 8)}`;
123
124  let ghostUser = await User.findOne({
125    _id: userId,
126    isRemote: true,
127    homeLand: req.canopy.sourceLandDomain,
128  });
129
130  if (!ghostUser) {
131    if (ghostCount >= 1000) {
132      await Invite.findByIdAndUpdate(inviteId, { $set: { status: "pending" } });
133      return sendError(res, 429, ERR.RATE_LIMITED, "Ghost user quota exceeded for this land");
134    }
135
136    try {
137      ghostUser = await User.create({
138        _id: userId,
139        username: ghostUsername,
140        email: `${userId}@${req.canopy.sourceLandDomain}`,
141        password: "$2b$00$REMOTE_NOLOGIN_PLACEHOLDER.......................",
142        isRemote: true,
143        homeLand: req.canopy.sourceLandDomain,
144      });
145    } catch (createErr) {
146      if (createErr.code === 11000) {
147        ghostUser = await User.findOne({ _id: userId, isRemote: true });
148        if (!ghostUser) {
149          return sendError(res, 409, ERR.RESOURCE_CONFLICT, "User ID conflicts with a local account");
150        }
151      } else {
152        throw createErr;
153      }
154    }
155  }
156
157  // afterOwnershipChange hook updates metadata.nav.roots for the ghost user
158  await addContributor(invite.rootId, userId, invite.userInviting);
159
160  sendOk(res, { message: "Invite accepted" });
161}
162
163/**
164 * POST /canopy/invite/decline
165 * A remote land confirms that their user declined an invite.
166 */
167export async function handleInviteDecline(req, res, { validateCanopyRequest }) {
168  const validation = validateCanopyRequest("invite_decline", req.body);
169  if (!validation.valid) {
170    return sendError(res, 400, ERR.INVALID_INPUT, "Validation failed", { errors: validation.errors });
171  }
172
173  const { inviteId } = req.body;
174
175  const invite = await Invite.findById(inviteId);
176  if (!invite) {
177    return sendError(res, 404, ERR.NODE_NOT_FOUND, "Invite not found");
178  }
179
180  // SECURITY: Verify the declining land is the one the invite was sent to.
181  if (invite.remoteLandDomain && invite.remoteLandDomain !== req.canopy.sourceLandDomain) {
182    return sendError(res, 403, ERR.FORBIDDEN, "This invite was not sent to your land");
183  }
184
185  invite.status = "declined";
186  await invite.save();
187
188  sendOk(res, { message: "Invite declined" });
189}
190
191/**
192 * POST /canopy/invite-remote
193 * A local tree owner invites a user from a remote land.
194 */
195export async function handleInviteRemote(req, res, {
196  User, Node, RemoteUser,
197  getLandIdentity, signCanopyToken,
198  getPeerByDomain, getPeerBaseUrl, registerPeer,
199  lookupLandByDomain, queueCanopyEvent,
200}) {
201  const { canopyId, rootId } = req.body;
202
203  if (!canopyId || !rootId) {
204    return sendError(res, 400, ERR.INVALID_INPUT, "Missing canopyId (username@domain) or rootId");
205  }
206
207  const atIndex = canopyId.lastIndexOf("@");
208  if (atIndex === -1) {
209    return sendError(res, 400, ERR.INVALID_INPUT, "Invalid canopy ID format. Expected username@domain");
210  }
211
212  const username = canopyId.slice(0, atIndex);
213  const domain = canopyId.slice(atIndex + 1);
214
215  if (domain === getLandIdentity().domain) {
216    return sendError(res, 400, ERR.INVALID_INPUT, "That user is on this land. Use a local invite instead of user@domain.");
217  }
218
219  const rootNode = await Node.findById(rootId);
220  if (!rootNode) {
221    return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
222  }
223
224  if (rootNode.rootOwner !== req.userId) {
225    return sendError(res, 403, ERR.FORBIDDEN, "Only the tree owner can invite remote users");
226  }
227
228  let peer = await getPeerByDomain(domain);
229  if (!peer) {
230    const horizonLand = await lookupLandByDomain(domain);
231    if (horizonLand && horizonLand.baseUrl) {
232      try {
233        peer = await registerPeer(horizonLand.baseUrl);
234      } catch (peerErr) {
235        return sendError(res, 502, ERR.PEER_UNREACHABLE, `Found land ${domain} on the Horizon but could not connect: ${peerErr.message}`);
236      }
237    } else {
238      return sendError(res, 404, ERR.PEER_NOT_FOUND, `Land ${domain} not found. Not a peer and not on the Horizon.`);
239    }
240  }
241
242  const peerBaseUrl = getPeerBaseUrl(peer);
243  const lookupToken = await signCanopyToken(req.userId, domain);
244  const resolveRes = await fetch(
245    `${peerBaseUrl}/canopy/user/${encodeURIComponent(username)}`,
246    {
247      headers: { Authorization: `CanopyToken ${lookupToken}` },
248      signal: AbortSignal.timeout(10000),
249    }
250  );
251
252  if (!resolveRes.ok) {
253    return sendError(res, 404, ERR.USER_NOT_FOUND, `User ${username} not found on land ${domain}`);
254  }
255
256  const remoteUserInfo = await resolveRes.json();
257
258  await RemoteUser.findOneAndUpdate(
259    { _id: remoteUserInfo.userId },
260    {
261      _id: remoteUserInfo.userId,
262      username: remoteUserInfo.username,
263      homeLandDomain: domain,
264      displayName: remoteUserInfo.username,
265      lastSyncedAt: new Date(),
266    },
267    { upsert: true }
268  );
269
270  const invite = await Invite.create({
271    userInviting: req.userId,
272    userReceiving: remoteUserInfo.userId,
273    rootId,
274    status: "pending",
275  });
276
277  const identity = getLandIdentity();
278  const owner = await User.findById(req.userId).select("username").lean();
279
280  await queueCanopyEvent(domain, "invite_offer", {
281    sourceInviteId: invite._id,
282    invitingUserId: req.userId,
283    invitingUsername: owner?.username || "unknown",
284    receivingUsername: username,
285    rootId,
286    rootName: rootNode.name || "Untitled",
287    sourceLandDomain: identity.domain,
288  });
289
290  sendOk(res, {
291    message: `Invite sent to ${canopyId}`,
292    inviteId: invite._id,
293  });
294}
295
296/**
297 * GET /canopy/admin/invites
298 * Server-rendered invites page for cross-land collaboration.
299 */
300export async function handleAdminInvites(req, res, { User, Node, RemoteUser, getExtension }) {
301  if (!getExtension("html-rendering")) {
302    return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Server-rendered HTML is disabled.");
303  }
304
305  const invites = await Invite.find({
306    userReceiving: req.userId,
307  }).lean();
308
309  for (const inv of invites) {
310    const root = await Node.findById(inv.rootId).select("name").lean();
311    inv.rootName = root?.name || "Untitled";
312  }
313
314  const remoteUserIds = invites.map((i) => i.userInviting);
315  const remoteUsers = await RemoteUser.find({
316    _id: { $in: remoteUserIds },
317  }).lean();
318
319  const userTrees = await Node.find({
320    rootOwner: { $nin: [null, "SYSTEM"] },
321    $or: [
322      { rootOwner: req.userId },
323      { contributors: req.userId },
324    ],
325    "versions.0.status": "active",
326  })
327    .select("_id name rootOwner")
328    .lean();
329
330  const localTrees = userTrees.map((t) => ({
331    _id: t._id,
332    name: t.name || "Untitled",
333    isOwner: t.rootOwner === req.userId,
334  }));
335
336  const renderCanopyInvites = getExtension("html-rendering")?.exports?.renderCanopyInvites;
337  if (!renderCanopyInvites) return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "html-rendering extension not available.");
338  const page = renderCanopyInvites({ invites, remoteUsers, localTrees });
339  res.send(page);
340}
341
1import log from "../../seed/log.js";
2import { escapeRegex } from "../../seed/utils.js";
3import { queueCanopyEvent } from "../../canopy/events.js";
4import { extractTaggedUsersAndRewrite, syncTagsForNote, clearTagsForNote } from "./tags.js";
5import { getPendingInvitesForUser, respondToInvite } from "./invites.js";
6import { buildRouter } from "./routes.js";
7
8export async function init(core) {
9  const { User } = core.models;
10
11  // ── Hook: beforeNote (rewrite @mentions to canonical usernames) ────
12  core.hooks.register("beforeNote", async (hookData) => {
13    if (hookData.contentType === "text" && hookData.content) {
14      const { rewrittenContent } = await extractTaggedUsersAndRewrite(hookData.content, User);
15      hookData.content = rewrittenContent;
16    }
17  }, "team");
18
19  // ── Hook: afterNote (sync NoteTag records) ────────────────────────
20  core.hooks.register("afterNote", async (data) => {
21    const { note, action, nodeId, userId } = data;
22
23    if (action === "delete") {
24      await clearTagsForNote(note._id);
25      return;
26    }
27
28    if ((action === "create" || action === "edit") && note.contentType === "text") {
29      await syncTagsForNote({
30        noteId: note._id,
31        content: note.content,
32        nodeId,
33        taggedBy: userId,
34        User,
35      });
36    }
37  }, "team");
38
39  log.verbose("Team", "Hooks registered (beforeNote, afterNote)");
40
41  const router = buildRouter(core, { escapeRegex, queueCanopyEvent });
42
43  const { Node, Note } = core.models;
44  const { logContribution } = core.contributions;
45
46  // Pre-bound: callers just pass inviteId/userId/acceptInvite, no deps needed
47  async function boundRespondToInvite({ inviteId, userId, acceptInvite }) {
48    return respondToInvite({
49      inviteId,
50      userId,
51      acceptInvite,
52      Node,
53      User,
54      logContribution,
55      queueCanopyEvent,
56    });
57  }
58
59  const canopyHandlers = await import("./canopyHandlers.js");
60
61  // Register quick link on user profile
62  try {
63    const { getExtension } = await import("../loader.js");
64    const treeos = getExtension("treeos-base");
65    treeos?.exports?.registerSlot?.("user-quick-links", "team", ({ userId, queryString }) =>
66      `<li><a href="/api/v1/user/${userId}/invites${queryString}">Invites</a></li>`,
67      { priority: 35 }
68    );
69
70    treeos?.exports?.registerSlot?.("tree-team", "team", ({ ownerHtml, contributorsHtml, inviteFormHtml }) => {
71      return `<div class="content-card">
72  <div class="section-header"><h2>Team</h2></div>
73  ${ownerHtml || ""}
74  ${contributorsHtml || ""}
75  ${inviteFormHtml || ""}
76</div>`;
77    }, { priority: 10 });
78  } catch {}
79
80  return {
81    router,
82    exports: {
83      getPendingInvitesForUser,
84      respondToInvite: boundRespondToInvite,
85      canopyHandlers,
86    },
87  };
88}
89
1import { Invite } from "./model.js";
2import { invalidateNode } from "../../seed/tree/ancestorCache.js";
3import { getExtension } from "../loader.js";
4import { DELETED } from "../../seed/protocol.js";
5
6// EXACT UUID REGEX FROM OLD CODE
7const isValidUUID = (id) =>
8  /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
9    id,
10  );
11
12async function resolveReceivingUser(User, userReceiving, escapeRegex) {
13  let receivingUser = null;
14
15  if (isValidUUID(userReceiving)) {
16    receivingUser = await User.findById(userReceiving);
17  }
18
19  if (!receivingUser) {
20    receivingUser = await User.findOne({
21      username: { $regex: `^${escapeRegex(userReceiving)}$`, $options: "i" },
22    });
23  }
24
25  return receivingUser;
26}
27
28export async function createInvite({
29  userInvitingId,
30  userReceiving,
31  rootId,
32  isToBeOwner,
33  isUninviting,
34  Node,
35  User,
36  logContribution,
37  escapeRegex,
38  queueCanopyEvent,
39  ownership,
40}) {
41  const node = await Node.findById(rootId).populate("rootOwner contributors");
42  if (!node) throw new Error("Root node not found");
43  if (node.parent === DELETED) {
44    throw new Error(
45      "You can't invite users or delete a root that's already deleted..",
46    );
47  }
48
49  const invitingUser = await User.findById(userInvitingId);
50  if (!invitingUser) throw new Error("Inviting user not found");
51
52  const receivingUser = await resolveReceivingUser(User, userReceiving, escapeRegex);
53  if (!receivingUser) throw new Error("Receiving user not found");
54  const existingInvite = await Invite.findOne({
55    rootId,
56    userReceiving: receivingUser._id,
57    status: "pending",
58    isToBeOwner,
59  });
60
61  if (existingInvite) {
62    throw new Error("An invite has already been sent to this user");
63  }
64
65  // EXACT OLD SELF-INVITE CHECK
66  if (!isUninviting && receivingUser._id.toString() === userInvitingId) {
67    throw new Error("You cannot invite yourself");
68  }
69
70  // EXACT OLD OWNER CHECK (no optional chaining)
71  const isOwner = node.rootOwner._id.toString() === userInvitingId;
72
73  const invite = new Invite({
74    userInviting: userInvitingId,
75    userReceiving: receivingUser._id,
76    isToBeOwner,
77    isUninviting,
78    rootId,
79    status: "pending",
80  });
81
82  const inviteAction = {
83    receivingId: receivingUser._id,
84  };
85
86  // ---------------- INVITE CONTRIBUTOR ----------------
87  if (!isToBeOwner && !isUninviting) {
88    if (!isOwner) {
89      throw new Error("Only the current owner can invite a new contributor");
90    }
91    if (
92      node.rootOwner &&
93      node.rootOwner._id.toString() === receivingUser._id.toString()
94    ) {
95      throw new Error("User already owns this root");
96    }
97    const alreadyContributor = node.contributors.some(
98      (u) => u._id.toString() === receivingUser._id.toString(),
99    );
100
101    if (alreadyContributor) {
102      throw new Error("User is already a contributor");
103    }
104
105    inviteAction.action = "invite";
106
107    await logContribution({
108      userId: userInvitingId,
109      nodeId: node.id,
110      action: "invite",
111      inviteAction,
112
113    });
114
115    await invite.save();
116    return { message: "Contributor invite created and logged" };
117  }
118
119  // ---------------- TRANSFER OWNERSHIP ----------------
120  if (isToBeOwner) {
121    if (!isOwner) {
122      throw new Error("Only the current owner can invite a new owner");
123    }
124    if (
125      node.rootOwner &&
126      node.rootOwner._id.toString() === receivingUser._id.toString()
127    ) {
128      throw new Error("User already owns this root");
129    }
130
131    // Kernel handles: set new owner, remove new owner from contributors, demote old owner to contributor
132    // afterOwnershipChange hook updates metadata.nav.roots for the new owner
133    await ownership.transferOwnership(rootId, receivingUser._id, userInvitingId);
134    // Clear LLM assignments. The new owner doesn't own the old connections.
135    await Node.updateOne({ _id: rootId }, { $set: { llmDefault: null } });
136
137    invite.status = "accepted";
138    await invite.save();
139
140    inviteAction.action = "switchOwner";
141
142    await logContribution({
143      userId: userInvitingId,
144      nodeId: node.id,
145      action: "invite",
146      inviteAction,
147
148    });
149
150    return { message: "Ownership transferred and invite logged" };
151  }
152
153  // ---------------- UNINVITE ----------------
154  if (!isToBeOwner && isUninviting) {
155    // Case 1: Owner tries to remove themselves but contributors exist
156    if (
157      isOwner &&
158      receivingUser._id.toString() === userInvitingId &&
159      node.contributors.length > 0
160    ) {
161      throw new Error("Owner cannot leave when contributors exist");
162    }
163
164    // Case 2: Owner removes a contributor
165    if (isOwner && receivingUser._id.toString() !== userInvitingId) {
166      // afterOwnershipChange hook updates metadata.nav.roots for the removed contributor
167      await ownership.removeContributor(rootId, receivingUser._id, userInvitingId);
168
169      invite.status = "accepted";
170      await invite.save();
171
172      inviteAction.action = "removeContributor";
173
174      await logContribution({
175        userId: userInvitingId,
176        nodeId: node.id,
177        action: "invite",
178        inviteAction,
179
180      });
181
182      return { message: "Contributor removed by owner and invite logged" };
183    }
184
185    // Case 3: Owner removes themselves (no contributors)
186    if (
187      isOwner &&
188      receivingUser._id.toString() === userInvitingId &&
189      node.contributors.length === 0
190    ) {
191      node.parent = DELETED;
192      await node.save();
193      invalidateNode(rootId);
194
195      // Remove from navigation list (not an ownership.js op, so no hook fires)
196      const nav = getExtension("navigation")?.exports;
197      if (nav?.removeRoot) await nav.removeRoot(userInvitingId, rootId);
198
199      inviteAction.action = "removeContributor";
200
201      await logContribution({
202        userId: userInvitingId,
203        nodeId: node.id,
204        action: "invite",
205        inviteAction,
206
207      });
208      await logContribution({
209        userId: userInvitingId,
210        nodeId: node.id,
211        action: "branchLifecycle",
212
213        branchLifecycle: {
214          action: "retired",
215          fromParentId: null,
216        },
217      });
218
219      return { message: "Owner retired root" };
220    }
221
222    // Case 4: Contributor removes themselves
223    if (!isOwner && receivingUser._id.toString() === userInvitingId) {
224      const isContributor = node.contributors.some(
225        (u) => u._id.toString() === userInvitingId,
226      );
227
228      if (!isContributor) {
229        throw new Error(
230          "You are not a contributor and cannot remove yourself.",
231        );
232      }
233
234      // afterOwnershipChange hook updates metadata.nav.roots
235      await ownership.removeContributor(rootId, userInvitingId, userInvitingId);
236
237      invite.status = "accepted";
238      await invite.save();
239
240      inviteAction.action = "removeContributor";
241
242      await logContribution({
243        userId: userInvitingId,
244        nodeId: node.id,
245        action: "invite",
246        inviteAction,
247
248      });
249
250      return { message: "Contributor removed themselves and invite logged" };
251    }
252
253    throw new Error("Invalid uninviting request");
254  }
255
256  throw new Error("Invalid invite operation");
257}
258
259export async function respondToInvite({ inviteId, userId, acceptInvite, Node, User, logContribution, queueCanopyEvent, ownership }) {
260  // Atomic status transition prevents double-processing
261  const invite = await Invite.findOneAndUpdate(
262    { _id: inviteId, status: "pending" },
263    { $set: { status: acceptInvite ? "accepted" : "declined" } },
264    { new: true }
265  );
266  if (!invite) throw new Error("Invite not found");
267
268  if (invite.userReceiving.toString() !== userId.toString()) {
269    // Revert status since this user shouldn't have changed it
270    await Invite.findByIdAndUpdate(inviteId, { $set: { status: "pending" } });
271    throw new Error("Invite not intended for this user");
272  }
273
274  // Remote invite: tree lives on another land, no local node to modify
275  if (invite.remoteLandDomain) {
276    if (acceptInvite) {
277      // Avoid duplicates: only add if rootId + landDomain combo doesn't exist
278      await User.updateOne(
279        {
280          _id: userId,
281          "metadata.canopy.remoteRoots": {
282            $not: { $elemMatch: { rootId: invite.rootId, landDomain: invite.remoteLandDomain } }
283          }
284        },
285        {
286          $push: {
287            "metadata.canopy.remoteRoots": {
288              rootId: invite.rootId,
289              rootName: invite.remoteRootName || "Untitled",
290              landDomain: invite.remoteLandDomain,
291            },
292          },
293        }
294      );
295
296      const acceptingUser = await User.findById(userId).select("username").lean();
297      await queueCanopyEvent(invite.remoteLandDomain, "invite_accept", {
298        inviteId: invite.remoteInviteId || invite._id,
299        userId,
300        username: acceptingUser?.username || null,
301      });
302    } else {
303      await queueCanopyEvent(invite.remoteLandDomain, "invite_decline", {
304        inviteId: invite.remoteInviteId || invite._id,
305      });
306    }
307
308    return {
309      success: true,
310      message: acceptInvite ? "Remote invite accepted" : "Remote invite declined",
311    };
312  }
313
314  const node = await Node.findById(invite.rootId);
315  if (!node) throw new Error("Node not found");
316
317  const inviteAction = { receivingId: userId };
318
319  if (acceptInvite) {
320    // Kernel validates user exists, prevents adding owner as contributor, invalidates cache
321    // If addContributor fails (inviter lost ownership, user became owner, etc.),
322    // revert the invite status so it can be retried or re-issued.
323    try {
324      await ownership.addContributor(invite.rootId, userId, invite.userInviting);
325    } catch (err) {
326      await Invite.findByIdAndUpdate(inviteId, { $set: { status: "pending" } });
327      throw err;
328    }
329
330    // afterOwnershipChange hook updates metadata.nav.roots for the new contributor
331    inviteAction.action = "acceptInvite";
332  } else {
333    inviteAction.action = "denyInvite";
334  }
335
336  await logContribution({
337    userId,
338    nodeId: node.id,
339    action: "invite",
340    inviteAction,
341  });
342
343  return {
344    success: true,
345    message: acceptInvite
346      ? "Invite accepted, user added as contributor, and roots updated"
347      : "Invite declined",
348  };
349}
350
351export async function getPendingInvitesForUser(userId) {
352  return Invite.find({
353    userReceiving: userId,
354    status: "pending",
355  })
356    .populate("userInviting", "username isRemote homeLand")
357    .populate("rootId", "name");
358}
359
1export default {
2  name: "team",
3  version: "1.0.2",
4  builtFor: "TreeOS",
5  description:
6    "How people work together on trees. Trees start with one owner. Team adds the ability " +
7    "to invite contributors, transfer ownership, remove users, and retire trees. The invite " +
8    "system is atomic: pending invites transition to accepted or declined in a single " +
9    "findOneAndUpdate to prevent double-processing. Ownership transfers are immediate. " +
10    "The old owner becomes a contributor. The new owner's LLM assignments clear because " +
11    "they do not own the old connections.\n\n" +
12    "Cross-land invites work through Canopy federation. Invite a user by username@domain " +
13    "and the extension auto-peers with the remote land via Horizon, resolves the remote " +
14    "user, sends an invite offer, and creates a local pending record. When the remote user " +
15    "accepts, a canopy event fires back and the contributor is added.\n\n" +
16    "@mentions in notes are first-class. The beforeNote hook rewrites @username references " +
17    "to canonical usernames (case-insensitive lookup, single DB query for all mentions in " +
18    "a note). The afterNote hook syncs NoteTag records so tagged users can query all notes " +
19    "they were mentioned in, with date filtering and pagination. Tags auto-cleanup on note " +
20    "deletion. The /user/:userId/tags endpoint returns the full mention history. Every " +
21    "invite action (create, accept, deny, remove, transfer, retire) writes to the " +
22    "contribution log for a complete audit trail.",
23
24  needs: {
25    services: ["contributions"],
26    models: ["User", "Node", "Note"],
27    extensions: [],
28  },
29
30  optional: {
31    services: ["energy", "websocket"],
32    extensions: ["html-rendering", "treeos-base"],
33  },
34
35  provides: {
36    models: {
37      Invite: "./model.js",
38      NoteTag: "./model.js",
39    },
40    routes: "./routes.js",
41    tools: false,
42    jobs: false,
43    energyActions: {},
44    sessionTypes: {},
45    env: [],
46    cli: [
47      { command: "invites", scope: ["tree"], description: "List pending invites", method: "GET", endpoint: "/user/:userId/invites", userIdParam: true },
48      { command: "invite", scope: ["tree"], description: "Invite a user to the current tree", method: "POST", endpoint: "/root/:rootId/invite", rootIdParam: true, bodyMap: { userReceiving: 0 } },
49      { command: "transfer-owner", scope: ["tree"], description: "Transfer tree ownership to another user", method: "POST", endpoint: "/root/:rootId/transfer-owner", rootIdParam: true, bodyMap: { userReceiving: 0 } },
50      { command: "remove-user", scope: ["tree"], description: "Remove a contributor from the tree", method: "POST", endpoint: "/root/:rootId/remove-user", rootIdParam: true, bodyMap: { userReceiving: 0 } },
51      { command: "retire", scope: ["tree"], description: "Retire (soft-delete) a tree you own", method: "POST", endpoint: "/root/:rootId/retire", rootIdParam: true },
52    ],
53  },
54};
55
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4// ── Invite ─────────────────────────────────────────────────────────────
5const InviteSchema = new mongoose.Schema({
6  _id: { type: String, required: true, default: uuidv4 },
7  userInviting: { type: String, ref: "User", required: true },
8  userReceiving: { type: String, ref: "User", required: true },
9  isToBeOwner: { type: Boolean, default: false },
10  isUninviting: { type: Boolean, default: false },
11  status: {
12    type: String,
13    enum: ["pending", "accepted", "declined"],
14    default: "pending",
15  },
16  rootId: { type: String, ref: "Node", required: true },
17
18  // Set when this invite is from a remote land (cross-land invitation)
19  remoteLandDomain: { type: String, default: null },
20  remoteRootName: { type: String, default: null },
21  remoteInviteId: { type: String, default: null },
22  remoteInvitingUsername: { type: String, default: null },
23});
24
25export const Invite = mongoose.model("Invite", InviteSchema);
26
27// ── NoteTag ────────────────────────────────────────────────────────────
28// Tracks @username mentions in notes. Replaces the old Note.tagged array.
29const NoteTagSchema = new mongoose.Schema({
30  _id: { type: String, required: true, default: uuidv4 },
31  noteId: { type: String, ref: "Note", required: true, index: true },
32  userId: { type: String, ref: "User", required: true, index: true },
33  nodeId: { type: String, ref: "Node", required: true },
34  taggedBy: { type: String, ref: "User", required: true },
35  createdAt: { type: Date, default: Date.now },
36});
37
38// Compound index for efficient queries
39NoteTagSchema.index({ userId: 1, createdAt: -1 });
40NoteTagSchema.index({ noteId: 1 });
41
42export const NoteTag = mongoose.model("NoteTag", NoteTagSchema);
43
1import { page } from "../../html-rendering/html/layout.js";
2import { esc, escapeHtml } from "../../html-rendering/html/utils.js";
3
4export function renderInvites({ userId, invites, token }) {
5  const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
6
7  const css = `
8.header-subtitle {
9  margin-bottom: 0;
10}
11
12
13@keyframes waterDrift {
14  0% { transform: translateY(-1px); }
15  100% { transform: translateY(1px); }
16}
17
18/* Glass Invites List */
19.invites-list {
20  list-style: none;
21  display: flex;
22  flex-direction: column;
23  gap: 16px;
24}
25
26.invite-card {
27  position: relative;
28  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
29  backdrop-filter: blur(22px) saturate(140%);
30  -webkit-backdrop-filter: blur(22px) saturate(140%);
31  border-radius: 16px;
32  padding: 24px;
33  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
34    inset 0 1px 0 rgba(255, 255, 255, 0.25);
35  border: 1px solid rgba(255, 255, 255, 0.28);
36  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
37  color: white;
38  overflow: hidden;
39  
40  /* Start hidden for lazy loading */
41  opacity: 0;
42  transform: translateY(30px);
43}
44
45/* When item becomes visible */
46.invite-card.visible {
47  animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
48}
49
50.invite-card::before {
51  content: "";
52  position: absolute;
53  inset: -40%;
54  background: radial-gradient(
55    120% 60% at 0% 0%,
56    rgba(255, 255, 255, 0.35),
57    transparent 60%
58  );
59  opacity: 0;
60  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
61  pointer-events: none;
62}
63
64.invite-card:hover {
65  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
66  transform: translateY(-2px);
67  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
68}
69
70.invite-card:hover::before {
71  opacity: 1;
72  transform: translateX(30%) translateY(10%);
73}
74
75.invite-text {
76  font-size: 16px;
77  line-height: 1.6;
78  color: white;
79  margin-bottom: 16px;
80  font-weight: 400;
81}
82
83.invite-text strong {
84  font-weight: 600;
85  color: white;
86  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
87}
88
89.invite-actions {
90  display: flex;
91  gap: 10px;
92  flex-wrap: wrap;
93}
94
95.invite-actions form {
96  margin: 0;
97}
98
99.accept-button,
100.decline-button {
101  position: relative;
102  overflow: hidden;
103  padding: 10px 20px;
104  border-radius: 980px;
105  font-weight: 600;
106  font-size: 14px;
107  cursor: pointer;
108  transition: all 0.3s;
109  font-family: inherit;
110  border: 1px solid rgba(255, 255, 255, 0.3);
111}
112
113.accept-button {
114  background: rgba(255, 255, 255, 0.3);
115  backdrop-filter: blur(10px);
116  color: white;
117  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
118}
119
120.accept-button::before {
121  content: "";
122  position: absolute;
123  inset: -40%;
124  background: radial-gradient(
125    120% 60% at 0% 0%,
126    rgba(255, 255, 255, 0.35),
127    transparent 60%
128  );
129  opacity: 0;
130  transform: translateX(-30%) translateY(-10%);
131  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
132  pointer-events: none;
133}
134
135.accept-button:hover {
136  background: rgba(255, 255, 255, 0.4);
137  transform: translateY(-2px);
138  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
139}
140
141.accept-button:hover::before {
142  opacity: 1;
143  transform: translateX(30%) translateY(10%);
144}
145
146.decline-button {
147  background: rgba(255, 255, 255, 0.15);
148  backdrop-filter: blur(10px);
149  color: white;
150  opacity: 0.85;
151}
152
153.decline-button:hover {
154  background: rgba(239, 68, 68, 0.3);
155  border-color: rgba(239, 68, 68, 0.5);
156  opacity: 1;
157  transform: translateY(-1px);
158  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
159}
160
161/* Responsive Design */
162@media (max-width: 640px) {
163  body {
164    padding: 16px;
165  }
166
167  .invite-card {
168    padding: 20px 16px;
169  }
170
171  .invite-actions {
172    flex-direction: column;
173  }
174
175  .accept-button,
176  .decline-button {
177    width: 100%;
178  }
179
180}`;
181
182  const invitesList = invites.length > 0
183    ? `<ul class="invites-list">
184      ${invites.map((i) => {
185        const remoteTag = i.userInviting.isRemote && i.userInviting.homeLand ? "@" + i.userInviting.homeLand : "";
186        const landTag = i.userInviting.isRemote && i.userInviting.homeLand ? " on " + i.userInviting.homeLand : "";
187        return `<li class="invite-card">
188          <div class="invite-text">
189            <strong>${esc(i.userInviting.username)}${esc(remoteTag)}</strong>
190            invited you to
191            <strong>${esc(i.rootId.name)}${esc(landTag)}</strong>
192          </div>
193          <div class="invite-actions">
194            <form method="POST" action="/api/v1/user/${userId}/invites/${i._id}${tokenQS}">
195              <input type="hidden" name="accept" value="true" />
196              <button type="submit" class="accept-button">Accept</button>
197            </form>
198            <form method="POST" action="/api/v1/user/${userId}/invites/${i._id}${tokenQS}">
199              <input type="hidden" name="accept" value="false" />
200              <button type="submit" class="decline-button">Decline</button>
201            </form>
202          </div>
203        </li>`;
204      }).join("")}
205    </ul>`
206    : `<div class="empty-state">
207      <div class="empty-state-icon">\uD83D\uDCEC</div>
208      <div class="empty-state-text">No pending invites</div>
209    </div>`;
210
211  const body = `
212  <div class="container">
213    <div class="back-nav">
214      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
215        \u2190 Back to Profile
216      </a>
217    </div>
218    <div class="header">
219      <h1>Invites</h1>
220      <div class="header-subtitle">Join other people's trees</div>
221    </div>
222    ${invitesList}
223  </div>`;
224
225  const js = `
226    // Intersection Observer for lazy loading animations
227    const observerOptions = {
228      root: null,
229      rootMargin: '50px',
230      threshold: 0.1
231    };
232
233    const observer = new IntersectionObserver((entries) => {
234      entries.forEach((entry, index) => {
235        if (entry.isIntersecting) {
236          // Add a small stagger delay based on order
237          setTimeout(() => {
238            entry.target.classList.add('visible');
239          }, index * 50); // 50ms stagger between items
240          
241          // Stop observing once animated
242          observer.unobserve(entry.target);
243        }
244      });
245    }, observerOptions);
246
247    // Observe all invite cards
248    document.querySelectorAll('.invite-card').forEach(card => {
249      observer.observe(card);
250    });`;
251
252  return page({
253    title: "Invites",
254    css,
255    body,
256    js,
257  });
258}
259
1import { page } from "../../html-rendering/html/layout.js";
2import { esc, escapeHtml } from "../../html-rendering/html/utils.js";
3
4export async function renderUserTags({ userId, user, notes, getNodeName, token }) {
5  const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
6
7  const css = `
8.header-subtitle {
9  margin-bottom: 0;
10}
11
12
13.note-author {
14  font-weight: 600;
15  color: white;
16  font-size: 15px;
17  margin-bottom: 8px;
18  display: block;
19  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
20}
21
22.note-author a {
23  color: white;
24  text-decoration: none;
25  transition: all 0.2s;
26}
27
28.note-author a:hover {
29  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
30}
31
32.note-link {
33  color: white;
34  text-decoration: none;
35  font-size: 15px;
36  line-height: 1.6;
37  display: block;
38  word-wrap: break-word;
39  transition: all 0.2s;
40  font-weight: 400;
41}
42
43.note-link:hover {
44  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
45}
46
47.file-badge {
48  display: inline-block;
49  padding: 4px 10px;
50  background: rgba(255, 255, 255, 0.25);
51  color: white;
52  border-radius: 6px;
53  font-size: 11px;
54  font-weight: 600;
55  margin-right: 8px;
56  border: 1px solid rgba(255, 255, 255, 0.3);
57  text-transform: uppercase;
58  letter-spacing: 0.5px;
59}
60
61/* Responsive Design */`;
62
63  const notesHtml = notes.length > 0
64    ? `
65    <ul class="notes-list">
66      ${await Promise.all(
67        notes.map(async (n) => {
68          const nodeName = await getNodeName(n.nodeId);
69          const preview =
70            n.contentType === "text"
71              ? n.content.length > 120
72                ? n.content.substring(0, 120) + "\u2026"
73                : n.content
74              : n.content.split("/").pop();
75
76          const author = n.userId.username || n.userId._id;
77
78          return `
79          <li class="note-card">
80            <div class="note-content">
81              <div class="note-author">
82                <a href="/api/v1/user/${n.userId._id}${tokenQS}">
83                  ${escapeHtml(author)}
84                </a>
85              </div>
86              <a href="/api/v1/node/${n.nodeId}/${n.version}/notes/${
87                n._id
88              }${tokenQS}" class="note-link">
89                ${
90                  n.contentType === "file"
91                    ? `<span class="file-badge">FILE</span>`
92                    : ""
93                }${escapeHtml(preview)}
94              </a>
95            </div>
96
97            <div class="note-meta">
98              ${new Date(n.createdAt).toLocaleString()}
99              <span class="meta-separator">\u2022</span>
100              <a href="/api/v1/node/${n.nodeId}/${n.version}${tokenQS}">
101                ${escapeHtml(nodeName)} v${n.version}
102              </a>
103              <span class="meta-separator">\u2022</span>
104              <a href="/api/v1/node/${n.nodeId}/${n.version}/notes${tokenQS}">
105                View Notes
106              </a>
107            </div>
108          </li>
109        `;
110        }),
111      ).then((results) => results.join(""))}
112    </ul>
113    `
114    : `
115    <div class="empty-state">
116      <div class="empty-state-icon">\uD83D\uDCEC</div>
117      <div class="empty-state-text">No messages yet</div>
118      <div class="empty-state-subtext">
119        Notes where you're mentioned will appear here
120      </div>
121    </div>
122    `;
123
124  const body = `
125  <div class="container">
126    <!-- Back Navigation -->
127    <div class="back-nav">
128      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
129        \u2190 Back to Profile
130      </a>
131    </div>
132
133    <!-- Header Section -->
134    <div class="header">
135      <h1>
136        Mail for
137        <a href="/api/v1/user/${userId}${tokenQS}">@${escapeHtml(user.username)}</a>
138        ${
139          notes.length > 0
140            ? `<span class="message-count">${notes.length}</span>`
141            : ""
142        }
143      </h1>
144      <div class="header-subtitle">Notes where others have mentioned you</div>
145    </div>
146
147    <!-- Notes List -->
148    ${notesHtml}
149  </div>`;
150
151  return page({
152    title: `${escapeHtml(user.username)} \u2014 Mail`,
153    css,
154    body,
155  });
156}
157
1import { Invite } from "./model.js";
2import { DELETED } from "../../seed/protocol.js";
3
4/**
5 * Send an invite to a remote user (username@domain format).
6 *
7 * Flow:
8 * 1. Parse canopyId into username and domain
9 * 2. Find or auto-peer with the remote land
10 * 3. Resolve the remote user via GET /canopy/user/:username
11 * 4. Send invite offer to remote land via POST /canopy/invite/offer
12 * 5. Create a local pending invite with the remote user info
13 */
14export async function sendRemoteInvite({ userInvitingId, canopyId, rootId, Node, User, RemoteUser, canopy }) {
15  const [username, domain] = canopyId.split("@");
16  if (!username || !domain) {
17    throw new Error("Invalid canopy ID. Use username@domain format.");
18  }
19
20  const identity = canopy.getLandIdentity();
21  if (domain === identity.domain) {
22    throw new Error("That domain is this land. Use just the username for local invites.");
23  }
24
25  // Validate the tree exists and inviting user has access
26  const rootNode = await Node.findById(rootId).lean();
27  if (!rootNode) throw new Error("Root node not found");
28  if (rootNode.parent === DELETED) throw new Error("This tree has been deleted");
29
30  const isOwner = rootNode.rootOwner?.toString() === userInvitingId;
31  const isContributor = rootNode.contributors?.some(
32    (c) => c.toString() === userInvitingId
33  );
34  if (!isOwner && !isContributor) {
35    throw new Error("You must be an owner or contributor to invite users");
36  }
37
38  const invitingUser = await User.findById(userInvitingId).select("username").lean();
39  if (!invitingUser) throw new Error("Inviting user not found");
40
41  // Find the peer land, or auto-peer via Horizon
42  let peer = await canopy.getPeerByDomain(domain);
43
44  if (!peer) {
45    // Try the Horizon
46    const horizonLand = await canopy.lookupLandByDomain(domain);
47    if (horizonLand && horizonLand.baseUrl) {
48      peer = await canopy.registerPeer(horizonLand.baseUrl);
49    }
50  }
51
52  if (!peer) {
53    throw new Error(
54      `Could not find land "${domain}". Add it as a peer first or check the domain.`
55    );
56  }
57
58  if (peer.status === "blocked") {
59    throw new Error(`Land ${domain} is blocked`);
60  }
61
62  // Resolve the remote user via their land
63  const baseUrl = canopy.getPeerBaseUrl(peer);
64  const token = await canopy.signCanopyToken(userInvitingId, domain);
65
66  let remoteUserData;
67  try {
68    const lookupRes = await fetch(
69      `${baseUrl}/canopy/user/${encodeURIComponent(username)}`,
70      {
71        headers: { Authorization: `CanopyToken ${token}` },
72        signal: AbortSignal.timeout(10000),
73      }
74    );
75    if (!lookupRes.ok) {
76      throw new Error(`User "${username}" not found on ${domain}`);
77    }
78    remoteUserData = await lookupRes.json();
79    if (!remoteUserData.success || !remoteUserData.userId) {
80      throw new Error(`User "${username}" not found on ${domain}`);
81    }
82  } catch (err) {
83    if (err.name === "TimeoutError") {
84      throw new Error(`Could not reach ${domain} (timed out)`);
85    }
86    throw err;
87  }
88
89  // Cache the remote user info locally
90  await RemoteUser.findOneAndUpdate(
91    { _id: remoteUserData.userId },
92    {
93      username: remoteUserData.username,
94      homeLandDomain: domain,
95      displayName: remoteUserData.displayName || remoteUserData.username,
96    },
97    { upsert: true }
98  );
99
100  // Create a local invite first so we can send its ID to the remote land
101  const invite = await Invite.create({
102    userInviting: userInvitingId,
103    userReceiving: remoteUserData.userId,
104    rootId,
105    status: "pending",
106    isToBeOwner: false,
107    isUninviting: false,
108  });
109
110  // Send the invite offer to the remote land (includes our invite ID)
111  let offerData;
112  try {
113    const offerRes = await fetch(`${baseUrl}/canopy/invite/offer`, {
114      method: "POST",
115      headers: {
116        "Content-Type": "application/json",
117        Authorization: `CanopyToken ${token}`,
118      },
119      body: JSON.stringify({
120        receivingUsername: remoteUserData.username,
121        rootId,
122        rootName: rootNode.name || "Untitled",
123        sourceLandDomain: identity.domain,
124        invitingUserId: userInvitingId,
125        invitingUsername: invitingUser.username,
126        sourceInviteId: invite._id,
127      }),
128      signal: AbortSignal.timeout(15000),
129    });
130
131    offerData = await offerRes.json();
132    if (!offerRes.ok || !offerData.success) {
133      // Clean up our local invite since the remote land rejected
134      await Invite.findByIdAndDelete(invite._id);
135      throw new Error(offerData.error || "Remote land rejected the invite");
136    }
137  } catch (err) {
138    // Clean up on network failure too
139    if (!offerData) await Invite.findByIdAndDelete(invite._id);
140    throw err;
141  }
142
143  // Store the remote land's invite ID for cross-reference
144  if (offerData.inviteId) {
145    invite.remoteInviteId = offerData.inviteId;
146    await invite.save();
147  }
148
149  return {
150    inviteId: invite._id,
151    remoteUser: `${remoteUserData.username}@${domain}`,
152  };
153}
154
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { createInvite, respondToInvite, getPendingInvitesForUser } from "./invites.js";
5import { sendRemoteInvite } from "./remoteInvites.js";
6import { getAllTagsForUser } from "./tags.js";
7import { getExtension } from "../loader.js";
8import { renderInvites } from "./pages/invites.js";
9import { renderUserTags } from "./pages/userTags.js";
10
11export function buildRouter(core, { escapeRegex, queueCanopyEvent }) {
12  const htmlExt = getExtension("html-rendering");
13  const htmlAuth = htmlExt?.exports?.urlAuth || authenticate;
14  const router = express.Router();
15  const { User, Node, Note } = core.models;
16  const { logContribution } = core.contributions;
17  const ownership = core.ownership;
18
19  // ── Invite routes (moved from routes/api/root.js) ─────────────────
20
21  // POST /root/:rootId/invite
22  router.post("/root/:rootId/invite", authenticate, async (req, res) => {
23    try {
24      const { rootId } = req.params;
25      const { userReceiving } = req.body;
26
27      if (!userReceiving) {
28        return sendError(res, 400, ERR.INVALID_INPUT, "userReceiving is required");
29      }
30
31      // Detect cross-land invite (username@domain.tld format)
32      const atIndex = userReceiving.indexOf("@");
33      const afterAt = atIndex > 0 ? userReceiving.slice(atIndex + 1) : "";
34      if (atIndex > 0 && afterAt.includes(".") && afterAt.length > 2) {
35        const RemoteUser = (await import("../../canopy/models/remoteUser.js")).default;
36        const canopy = await import("../../canopy/identity.js");
37        const peers = await import("../../canopy/peers.js");
38        const horizon = await import("../../canopy/horizon.js");
39
40        const result = await sendRemoteInvite({
41          userInvitingId: req.userId,
42          canopyId: userReceiving,
43          rootId,
44          Node,
45          User,
46          RemoteUser,
47          canopy: {
48            getLandIdentity: canopy.getLandIdentity,
49            signCanopyToken: canopy.signCanopyToken,
50            getPeerByDomain: peers.getPeerByDomain,
51            getPeerBaseUrl: peers.getPeerBaseUrl,
52            registerPeer: peers.registerPeer,
53            lookupLandByDomain: horizon.lookupLandByDomain,
54          },
55        });
56
57        if ("html" in req.query) {
58          return res.redirect(
59            `/api/v1/root/${rootId}?token=${req.query.token ?? ""}&html`,
60          );
61        }
62        return sendOk(res, { remote: true, ...result });
63      }
64
65      await createInvite({
66        userInvitingId: req.userId,
67        userReceiving,
68        rootId,
69        isToBeOwner: false,
70        isUninviting: false,
71        Node,
72        User,
73        logContribution,
74        escapeRegex,
75        queueCanopyEvent,
76        ownership,
77      });
78
79      if ("html" in req.query) {
80        return res.redirect(
81          `/api/v1/root/${rootId}?token=${req.query.token ?? ""}&html`,
82        );
83      }
84
85      return sendOk(res);
86    } catch (err) {
87      return sendError(res, 400, ERR.INVALID_INPUT, err.message);
88    }
89  });
90
91  // POST /root/:rootId/transfer-owner
92  router.post("/root/:rootId/transfer-owner", authenticate, async (req, res) => {
93    try {
94      const { rootId } = req.params;
95      const { userReceiving } = req.body;
96
97      if (!userReceiving) {
98        return sendError(res, 400, ERR.INVALID_INPUT, "userReceiving is required");
99      }
100
101      await createInvite({
102        userInvitingId: req.userId,
103        userReceiving,
104        rootId,
105        isToBeOwner: true,
106        isUninviting: false,
107        Node,
108        User,
109        logContribution,
110        escapeRegex,
111        queueCanopyEvent,
112        ownership,
113      });
114
115      if ("html" in req.query) {
116        return res.redirect(
117          `/api/v1/root/${rootId}?token=${req.query.token ?? ""}&html`,
118        );
119      }
120
121      return sendOk(res);
122    } catch (err) {
123      return sendError(res, 400, ERR.INVALID_INPUT, err.message);
124    }
125  });
126
127  // POST /root/:rootId/remove-user
128  router.post("/root/:rootId/remove-user", authenticate, async (req, res) => {
129    try {
130      const { rootId } = req.params;
131      const { userReceiving } = req.body;
132
133      if (!userReceiving) {
134        return sendError(res, 400, ERR.INVALID_INPUT, "userReceiving is required");
135      }
136
137      await createInvite({
138        userInvitingId: req.userId,
139        userReceiving,
140        rootId,
141        isToBeOwner: false,
142        isUninviting: true,
143        Node,
144        User,
145        logContribution,
146        escapeRegex,
147        queueCanopyEvent,
148        ownership,
149      });
150
151      if ("html" in req.query) {
152        return res.redirect(
153          `/api/v1/user/${req.userId}?token=${req.query.token ?? ""}&html`,
154        );
155      }
156
157      return sendOk(res);
158    } catch (err) {
159      return sendError(res, 400, ERR.INVALID_INPUT, err.message);
160    }
161  });
162
163  // POST /root/:rootId/retire
164  router.post("/root/:rootId/retire", authenticate, async (req, res) => {
165    try {
166      const { rootId } = req.params;
167
168      await createInvite({
169        userInvitingId: req.userId,
170        userReceiving: req.userId,
171        rootId,
172        isToBeOwner: false,
173        isUninviting: true,
174        Node,
175        User,
176        logContribution,
177        escapeRegex,
178        queueCanopyEvent,
179        ownership,
180      });
181
182      if ("html" in req.query) {
183        return res.redirect(
184          `/api/v1/user/${req.userId}?token=${req.query.token ?? ""}&html`,
185        );
186      }
187
188      return sendOk(res);
189    } catch (err) {
190      return sendError(res, 400, ERR.INVALID_INPUT, err.message);
191    }
192  });
193
194  // ── Invite list + respond (moved from routes/api/user.js) ─────────
195
196  router.get("/user/:userId/invites", htmlAuth, async (req, res) => {
197    try {
198      const { userId } = req.params;
199
200      if (req.userId.toString() !== userId.toString()) {
201        return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
202      }
203
204      const invites = await getPendingInvitesForUser(userId);
205
206      const wantHtml = "html" in req.query;
207      if (!wantHtml || !getExtension("html-rendering")) {
208        return sendOk(res, { invites });
209      }
210
211      const token = req.query.token ?? "";
212      return res.send(renderInvites({ userId, invites, token }));
213    } catch (err) {
214      sendError(res, 500, ERR.INTERNAL, err.message);
215    }
216  });
217
218  router.post(
219    "/user/:userId/invites/:inviteId",
220    authenticate,
221
222    async (req, res) => {
223      try {
224        const { userId, inviteId } = req.params;
225        const { accept } = req.body;
226
227        if (req.userId.toString() !== userId.toString()) {
228          return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
229        }
230
231        const acceptInvite = accept === true || accept === "true";
232
233        await respondToInvite({
234          inviteId,
235          userId: req.userId,
236          acceptInvite,
237          Node,
238          User,
239          logContribution,
240          queueCanopyEvent,
241          ownership,
242        });
243
244        if ("html" in req.query) {
245          return res.redirect(
246            `/api/v1/user/${userId}/invites?token=${req.query.token ?? ""}&html`,
247          );
248        }
249
250        return sendOk(res, { accepted: acceptInvite });
251      } catch (err) {
252        return sendError(res, 400, ERR.INVALID_INPUT, err.message);
253      }
254    },
255  );
256
257  // ── Tags (moved from extensions/user-queries) ─────────────────────
258
259  router.get("/user/:userId/tags", htmlAuth, async (req, res) => {
260    try {
261      const userId = req.params.userId;
262      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
263      const startDate = req.query.startDate;
264      const endDate = req.query.endDate;
265
266      const token = req.query.token ?? "";
267      const rawLimit = req.query.limit;
268      const limit = rawLimit !== undefined ? Number(rawLimit) : undefined;
269
270      if (limit !== undefined && (isNaN(limit) || limit <= 0)) {
271        return sendError(res, 400, ERR.INVALID_INPUT, "Invalid limit: must be a positive number");
272      }
273
274      const result = await getAllTagsForUser(userId, limit, startDate, endDate, Note);
275
276      const notes = result.notes.map((n) => ({
277        ...n,
278        content: n.contentType === "file" ? `/api/v1/uploads/${n.content}` : n.content,
279      }));
280
281      if (!wantHtml || !getExtension("html-rendering")) {
282        return sendOk(res, { taggedBy: result.taggedBy, notes });
283      }
284
285      const user = await User.findById(userId).lean();
286      const getNodeName = (await import("../../routes/api/helpers/getNameById.js")).default;
287      return res.send(await renderUserTags({ userId, user, notes, getNodeName, token }));
288    } catch (err) {
289      sendError(res, 400, ERR.INVALID_INPUT, err.message);
290    }
291  });
292
293  return router;
294}
295
1import { NoteTag } from "./model.js";
2
3/**
4 * Extract @mentions from text and rewrite to canonical usernames.
5 * Returns { tagged: [userId, ...], rewrittenContent }.
6 */
7export async function extractTaggedUsersAndRewrite(content, User) {
8  const mentionRegex = /@([\w-]+)/g;
9  const matches = [...content.matchAll(mentionRegex)];
10
11  if (matches.length === 0) {
12    return { tagged: [], rewrittenContent: content };
13  }
14
15  // normalize mentions to lowercase
16  const identifiers = matches.map((m) => m[1].toLowerCase());
17
18  // fetch all users once
19  const users = await User.find({
20    username: { $in: identifiers },
21  }).collation({ locale: "en", strength: 2 }); // case-insensitive
22
23  // build lookup maps
24  const usernameToUser = {};
25  users.forEach((u) => {
26    usernameToUser[u.username.toLowerCase()] = u;
27  });
28
29  const taggedUserIds = [...new Set(users.map((u) => u._id.toString()))];
30
31  // rewrite mentions using canonical username
32  const rewrittenContent = content.replace(mentionRegex, (full, raw) => {
33    const user = usernameToUser[raw.toLowerCase()];
34    if (!user) return full;
35    return `@${user.username}`;
36  });
37
38  return {
39    tagged: taggedUserIds,
40    rewrittenContent,
41  };
42}
43
44/**
45 * Sync NoteTag records for a note. Called from the afterNote hook.
46 * Replaces any existing tags for this note with the current set.
47 */
48export async function syncTagsForNote({ noteId, content, nodeId, taggedBy, User }) {
49  if (!content) {
50    await NoteTag.deleteMany({ noteId });
51    return;
52  }
53
54  const mentionRegex = /@([\w-]+)/g;
55  const matches = [...content.matchAll(mentionRegex)];
56
57  if (matches.length === 0) {
58    await NoteTag.deleteMany({ noteId });
59    return;
60  }
61
62  const usernames = matches.map((m) => m[1].toLowerCase());
63  const users = await User.find({
64    username: { $in: usernames },
65  }).collation({ locale: "en", strength: 2 });
66
67  const taggedUserIds = [...new Set(users.map((u) => u._id.toString()))];
68
69  // Replace all tags for this note
70  await NoteTag.deleteMany({ noteId });
71  if (taggedUserIds.length > 0) {
72    await NoteTag.insertMany(
73      taggedUserIds.map((userId) => ({
74        noteId,
75        userId,
76        nodeId,
77        taggedBy,
78      })),
79    );
80  }
81}
82
83/**
84 * Remove all tags for a note. Called from afterNote on delete.
85 */
86export async function clearTagsForNote(noteId) {
87  await NoteTag.deleteMany({ noteId });
88}
89
90/**
91 * Get all notes where a user was tagged (mentioned).
92 */
93export async function getAllTagsForUser(userId, limit, startDate, endDate, Note) {
94  if (!userId) {
95    throw new Error("Missing required parameter: userId");
96  }
97
98  if (limit !== undefined && (typeof limit !== "number" || limit <= 0)) {
99    throw new Error("Invalid limit: must be a positive number");
100  }
101
102  const queryObj = { userId };
103
104  if (startDate || endDate) {
105    queryObj.createdAt = {};
106    if (startDate) queryObj.createdAt.$gte = new Date(startDate);
107    if (endDate) queryObj.createdAt.$lte = new Date(endDate);
108  }
109
110  let query = NoteTag.find(queryObj).sort({ createdAt: -1 }).lean();
111  if (typeof limit === "number") query = query.limit(limit);
112  const tags = await query;
113
114  if (tags.length === 0) return { notes: [] };
115
116  // Fetch the actual notes
117  const noteIds = [...new Set(tags.map((t) => t.noteId))];
118  const notes = await Note.find({ _id: { $in: noteIds } })
119    .populate("userId", "username")
120    .sort({ createdAt: -1 })
121    .lean();
122
123  const noteMap = {};
124  notes.forEach((n) => {
125    noteMap[n._id] = n;
126  });
127
128  const notesWithTaggedBy = tags
129    .map((t) => {
130      const note = noteMap[t.noteId];
131      if (!note) return null;
132      return {
133        ...note,
134        authorId: note.userId?._id?.toString(),
135        authorUsername: note.userId?.username,
136        taggedBy: note.userId?._id?.toString(),
137      };
138    })
139    .filter(Boolean);
140
141  return { notes: notesWithTaggedBy };
142}
143

Versions

Version Published Downloads
1.0.2 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star team

Comments

Loading comments...

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