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
Loading comments...