@promptowl/contextnest-community 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17,18 +17,23 @@ import {
17
17
  listNotifications,
18
18
  listWatchers,
19
19
  markNotificationsRead,
20
+ nodeWriteKey,
20
21
  notifyNestEvent,
22
+ notifyNestInvite,
21
23
  parseConditionSchema,
22
24
  reject,
23
25
  removeWatcher,
26
+ repointPendingReview,
24
27
  requireWorkflowPlane,
25
28
  resolveCallerEmail,
26
29
  safeJson,
27
30
  safePublishDocument,
28
31
  seedDefaultEdgeTypes,
32
+ sendTestNotification,
33
+ stripUndefinedDeep,
29
34
  submitForReview,
30
- verifySmtp
31
- } from "./chunk-XB7T3THE.js";
35
+ withNodeWriteLock
36
+ } from "./chunk-MZ4IR5KH.js";
32
37
  import {
33
38
  createGrant,
34
39
  deleteGrant,
@@ -37,7 +42,7 @@ import {
37
42
  listGrants,
38
43
  listUserGrants,
39
44
  resolveNodeGrant
40
- } from "./chunk-ZMSU7BCC.js";
45
+ } from "./chunk-IHGPZ6NY.js";
41
46
  import {
42
47
  generateApiKey,
43
48
  getKeyPrefix,
@@ -53,8 +58,9 @@ import {
53
58
  getCurrentVersion,
54
59
  getDisplayStatus,
55
60
  getVersions,
56
- setApprovedVersion
57
- } from "./chunk-JHIU6RZU.js";
61
+ setApprovedVersion,
62
+ upsertVersion
63
+ } from "./chunk-JP67WXLA.js";
58
64
  import {
59
65
  addMember,
60
66
  buildTitleMap,
@@ -63,7 +69,6 @@ import {
63
69
  canUserAccess,
64
70
  canUserApprove,
65
71
  canUserEdit,
66
- collabPermToRole,
67
72
  createNest,
68
73
  createStewardRecord,
69
74
  createTeam,
@@ -98,6 +103,7 @@ import {
98
103
  listSharedNests,
99
104
  listStewards,
100
105
  listTeamsForUser,
106
+ listVisibleNests,
101
107
  loadAccessConfig,
102
108
  loadGrantedSuperAdmins,
103
109
  loadServerSettings,
@@ -111,13 +117,14 @@ import {
111
117
  renameNest,
112
118
  renameTeam,
113
119
  replaceTeamRoster,
120
+ resolveGrantRoles,
114
121
  resolveMemberIdentity,
115
122
  resolveNestPath,
116
123
  resolveNestPermission,
117
124
  resolveStewardsForNode,
118
125
  resolveStewardsWithFallback,
119
- resolveTeamRolesForUser,
120
126
  resolveUserRoles,
127
+ sendEmailToRecipient,
121
128
  setAllowSelfApprove,
122
129
  setStewardshipEnabled,
123
130
  shareTeamWithNest,
@@ -129,8 +136,9 @@ import {
129
136
  unshareTeamFromNest,
130
137
  updateMemberRole,
131
138
  updateSteward,
132
- validateLicense
133
- } from "./chunk-F7EKYNXG.js";
139
+ validateLicense,
140
+ verifySmtp
141
+ } from "./chunk-KPK656BH.js";
134
142
  import {
135
143
  AppError,
136
144
  ConflictError,
@@ -153,7 +161,7 @@ import {
153
161
  initDb,
154
162
  isEmailListish,
155
163
  isEmailish
156
- } from "./chunk-3M7677XW.js";
164
+ } from "./chunk-I5KYGMIT.js";
157
165
  import {
158
166
  ANON_EMAIL,
159
167
  ANON_USER_ID
@@ -901,6 +909,9 @@ authRoutes.post("/register", async (c) => {
901
909
  if (!body.email || !body.password) {
902
910
  throw new ValidationError("email and password are required");
903
911
  }
912
+ if (!isEmailish(body.email)) {
913
+ throw new ValidationError("Enter a valid email address.");
914
+ }
904
915
  assertValidPassword(body.password);
905
916
  const email = normalizeEmail(body.email);
906
917
  const ip = clientIp(c);
@@ -1421,6 +1432,9 @@ authRoutes.delete("/users/:userId", async (c) => {
1421
1432
  authRoutes.post("/invite", async (c) => {
1422
1433
  const body = await c.req.json();
1423
1434
  if (!body.email) throw new ValidationError("email is required");
1435
+ if (!isEmailish(body.email)) {
1436
+ throw new ValidationError("Enter a valid email address.");
1437
+ }
1424
1438
  const email = normalizeEmail(body.email);
1425
1439
  const callerId = await resolveCallerUserId(c);
1426
1440
  if (!callerId) {
@@ -1469,6 +1483,18 @@ authRoutes.post("/invite", async (c) => {
1469
1483
  );
1470
1484
  }
1471
1485
  trackEvent("admin.invite", { adminId: callerId, email });
1486
+ const inviter = await db.get("SELECT email FROM users WHERE id = ?", [
1487
+ callerId
1488
+ ]);
1489
+ void sendEmailToRecipient(email, {
1490
+ status: "invited",
1491
+ docTitle: "ContextNest",
1492
+ path: "",
1493
+ actor: email,
1494
+ by: inviter?.email || void 0,
1495
+ tempPassword,
1496
+ link: requestBaseUrl(c.req.url)
1497
+ });
1472
1498
  return c.json(
1473
1499
  {
1474
1500
  temporary_password: tempPassword,
@@ -1758,7 +1784,7 @@ async function approveExternalEdit(input) {
1758
1784
  const node = await storage.readDocument(input.documentId);
1759
1785
  const versionNum = result.versionEntry.version;
1760
1786
  const tags = node.frontmatter.tags || [];
1761
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-YQIIQ7FN.js");
1787
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-FGL47APL.js");
1762
1788
  await createVersion2({
1763
1789
  nestId: input.nestId,
1764
1790
  nodeId: input.documentId,
@@ -1867,18 +1893,6 @@ async function departmentTagFor(userEmail) {
1867
1893
  return null;
1868
1894
  }
1869
1895
  }
1870
- var nodeWriteChains = /* @__PURE__ */ new Map();
1871
- function withNodeWriteLock(key, fn) {
1872
- const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
1873
- });
1874
- const run = prev.then(fn);
1875
- nodeWriteChains.set(key, run);
1876
- void run.catch(() => {
1877
- }).finally(() => {
1878
- if (nodeWriteChains.get(key) === run) nodeWriteChains.delete(key);
1879
- });
1880
- return run;
1881
- }
1882
1896
  function toSafeError(err, context, message) {
1883
1897
  if (err instanceof AppError) return err;
1884
1898
  console.error(`${context}:`, err);
@@ -2050,6 +2064,11 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2050
2064
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2051
2065
  return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
2052
2066
  }
2067
+ async function actorAutoPublishes(nestId, userEmail) {
2068
+ if (!await nestAllowsSelfApprove(nestId)) return false;
2069
+ const roles = await resolveUserRoles(nestId, userEmail);
2070
+ return roles.includes("owner") || roles.includes("admin");
2071
+ }
2053
2072
  async function createNode(nestId, input, userEmail) {
2054
2073
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2055
2074
  const slug = slugify(input.title);
@@ -2084,8 +2103,9 @@ async function createNode(nestId, input, userEmail) {
2084
2103
  tags.push(deptTag);
2085
2104
  }
2086
2105
  const hasStewards = await isStewardshipEnabled(nestId);
2087
- const initialStatus = hasStewards ? "draft" : "published";
2088
- const initialVersion = hasStewards ? 1 : 0;
2106
+ const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
2107
+ const initialStatus = autoPublish ? "published" : "draft";
2108
+ const initialVersion = autoPublish ? 0 : 1;
2089
2109
  let node = {
2090
2110
  id,
2091
2111
  filePath: "",
@@ -2109,7 +2129,7 @@ async function createNode(nestId, input, userEmail) {
2109
2129
  await storage.writeDocument(id, serializeDocument(node));
2110
2130
  await syncNodeTags(nestId, id, tags);
2111
2131
  let savedVersion = 1;
2112
- if (hasStewards) {
2132
+ if (!autoPublish) {
2113
2133
  try {
2114
2134
  await versionManager.createVersion(node, userEmail);
2115
2135
  } catch (err) {
@@ -2128,7 +2148,7 @@ async function createNode(nestId, input, userEmail) {
2128
2148
  try {
2129
2149
  const result = await safePublishDocument(storage, id, {
2130
2150
  editedBy: userEmail,
2131
- note: "Auto-published on create (no stewards configured)"
2151
+ note: hasStewards ? "Auto-published on create (owner/admin self-approve)" : "Auto-published on create (no stewards configured)"
2132
2152
  });
2133
2153
  savedVersion = result.node.frontmatter.version || 1;
2134
2154
  await createVersion({
@@ -2287,7 +2307,7 @@ async function registerImportedDocuments(nestId, userEmail, onProgress) {
2287
2307
  return registered;
2288
2308
  }
2289
2309
  async function updateNode(nestId, nodeId, patch, userEmail) {
2290
- return withNodeWriteLock(`${nestId}::${nodeId}`, async () => {
2310
+ return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
2291
2311
  try {
2292
2312
  const { storage, versions: versionManager } = await engineCache.get(
2293
2313
  nestId
@@ -2337,14 +2357,17 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2337
2357
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2338
2358
  }
2339
2359
  const hasStewards = await isStewardshipEnabled(nestId);
2360
+ const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
2340
2361
  const currentTags = node.frontmatter.tags || [];
2341
- if (hasStewards && await getPendingReview(nestId, nodeId)) {
2362
+ const pendingReview = hasStewards ? await getPendingReview(nestId, nodeId) : null;
2363
+ const isAuthorEditingOwnReview = !!pendingReview && pendingReview.requestedBy.toLowerCase() === userEmail.toLowerCase();
2364
+ if (pendingReview && !isAuthorEditingOwnReview) {
2342
2365
  throw new LockedError(
2343
2366
  "This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
2344
2367
  );
2345
2368
  }
2346
2369
  let responseVersion;
2347
- if (hasStewards) {
2370
+ if (!autoPublish || isAuthorEditingOwnReview) {
2348
2371
  const currentVersion = await getCurrentVersion(nestId, nodeId);
2349
2372
  const newVersion = currentVersion + 1;
2350
2373
  node = {
@@ -2382,6 +2405,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2382
2405
  tags: currentTags,
2383
2406
  changeNote: patch.changeNote
2384
2407
  });
2408
+ if (isAuthorEditingOwnReview) {
2409
+ await repointPendingReview({ nestId, nodeId, newVersion });
2410
+ }
2385
2411
  responseVersion = newVersion;
2386
2412
  } else {
2387
2413
  node = {
@@ -2399,7 +2425,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2399
2425
  try {
2400
2426
  const result = await safePublishDocument(storage, nodeId, {
2401
2427
  editedBy: userEmail,
2402
- note: patch.changeNote || "Auto-published on edit (no stewards)"
2428
+ note: patch.changeNote || (hasStewards ? "Auto-published on edit (owner/admin self-approve)" : "Auto-published on edit (no stewards)")
2403
2429
  });
2404
2430
  publishedVersion = result.node.frontmatter.version || publishedVersion;
2405
2431
  node = result.node;
@@ -2410,7 +2436,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2410
2436
  );
2411
2437
  throw err;
2412
2438
  }
2413
- await createVersion({
2439
+ await upsertVersion({
2414
2440
  nestId,
2415
2441
  nodeId,
2416
2442
  version: publishedVersion,
@@ -2734,6 +2760,25 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
2734
2760
  }
2735
2761
  return { nest, documents };
2736
2762
  }
2763
+ function deleteUnsyncedFolder(folderName) {
2764
+ console.log(`[unsynced] delete requested folder="${folderName}"`);
2765
+ assertSafeFolderName(folderName);
2766
+ const src = join3(config.DATA_ROOT, folderName);
2767
+ if (isNestStorageRoot(src)) {
2768
+ throw new ValidationError("Folder is not eligible for delete");
2769
+ }
2770
+ let stat;
2771
+ try {
2772
+ stat = statSync(src);
2773
+ } catch {
2774
+ throw new NotFoundError(`Folder not found: ${folderName}`);
2775
+ }
2776
+ if (!stat.isDirectory()) {
2777
+ throw new ValidationError(`Not a directory: ${folderName}`);
2778
+ }
2779
+ rmSync(src, { recursive: true, force: true });
2780
+ console.log(`[unsynced] deleted folder ${src}`);
2781
+ }
2737
2782
 
2738
2783
  // src/nests/routes.ts
2739
2784
  async function effectivePermission(nestId, userId) {
@@ -2759,54 +2804,42 @@ nestRoutes.get("/", async (c) => {
2759
2804
  const annotate = async (n) => {
2760
2805
  const permission = await effectivePermission(n.id, userId);
2761
2806
  const is_owner = permission === "owner";
2762
- let owner_email = null;
2763
- let roles;
2764
- if (is_owner) {
2765
- roles = ["owner"];
2766
- } else {
2767
- const grantRole = collabPermToRole(
2768
- await getCollaboratorRole(n.id, callerEmail)
2769
- );
2770
- const stewardRoles = await getStewardRolesForUser(n.id, callerEmail);
2771
- const teamRoles = await resolveTeamRolesForUser(n.id, userId);
2772
- roles = [
2773
- ...new Set(
2774
- [grantRole, ...stewardRoles, ...teamRoles].filter(Boolean)
2775
- )
2776
- ];
2777
- }
2778
- if (!is_owner && n.user_id !== ANON_USER_ID) {
2779
- const row = await db.get(ownerEmailSql, [n.user_id]);
2780
- owner_email = row?.email ?? null;
2781
- }
2807
+ const needOwnerEmail = !is_owner && n.user_id !== ANON_USER_ID;
2808
+ const [roles, owner_email] = await Promise.all([
2809
+ is_owner ? Promise.resolve(["owner"]) : resolveGrantRoles(n.id, callerEmail, userId),
2810
+ needOwnerEmail ? db.get(ownerEmailSql, [n.user_id]).then(
2811
+ (row) => row?.email ?? null
2812
+ ) : Promise.resolve(null)
2813
+ ]);
2782
2814
  return {
2783
2815
  ...n,
2784
2816
  permission,
2785
2817
  is_owner,
2786
2818
  owner_email,
2787
2819
  roles,
2788
- stewardship_enabled: await isStewardshipEnabled(n.id)
2820
+ stewardship_enabled: !!n.stewardship_enabled
2789
2821
  };
2790
2822
  };
2791
2823
  const seen = /* @__PURE__ */ new Set();
2792
- const out = [];
2824
+ const deduped = [];
2793
2825
  for (const n of [...owned, ...shared, ...publicExtras]) {
2794
2826
  if (seen.has(n.id)) continue;
2795
2827
  seen.add(n.id);
2796
- out.push(await annotate(n));
2828
+ deduped.push(n);
2797
2829
  }
2830
+ const out = await Promise.all(deduped.map(annotate));
2798
2831
  const docRows = await db.all(
2799
2832
  "SELECT nest_id, COUNT(DISTINCT node_id) AS c FROM node_versions GROUP BY nest_id"
2800
2833
  );
2801
- const docByNest = new Map(docRows.map((r) => [r.nest_id, r.c]));
2834
+ const docByNest = new Map(docRows.map((r) => [r.nest_id, Number(r.c)]));
2802
2835
  const collabRows = await db.all(
2803
2836
  "SELECT nest_id, COUNT(*) AS c FROM nest_collaborators GROUP BY nest_id"
2804
2837
  );
2805
- const collabByNest = new Map(collabRows.map((r) => [r.nest_id, r.c]));
2838
+ const collabByNest = new Map(collabRows.map((r) => [r.nest_id, Number(r.c)]));
2806
2839
  const stewardRows = await db.all(
2807
2840
  "SELECT nest_id, COUNT(DISTINCT user_email) AS c FROM stewards WHERE is_active = 1 GROUP BY nest_id"
2808
2841
  );
2809
- const stewardByNest = new Map(stewardRows.map((r) => [r.nest_id, r.c]));
2842
+ const stewardByNest = new Map(stewardRows.map((r) => [r.nest_id, Number(r.c)]));
2810
2843
  const stewardEmailRows = await db.all(
2811
2844
  `SELECT nest_id, user_email FROM stewards WHERE is_active = 1
2812
2845
  GROUP BY nest_id, user_email ORDER BY MIN(assigned_at)`
@@ -2913,6 +2946,21 @@ nestRoutes.post("/:nestId/sync", async (c) => {
2913
2946
  );
2914
2947
  return c.json(result, 201);
2915
2948
  });
2949
+ nestRoutes.delete("/:nestId/sync", async (c) => {
2950
+ if (c.req.param("nestId") !== "unsynced") {
2951
+ throw new NotFoundError("Not found");
2952
+ }
2953
+ const userId = c.get("userId");
2954
+ if (config.AUTH_MODE !== "open" && !await isServerAdminUserId(userId)) {
2955
+ throw new ForbiddenError("Only the server admin can delete folders");
2956
+ }
2957
+ const body = await c.req.json().catch(() => ({}));
2958
+ if (!body?.name) {
2959
+ throw new ValidationError("name is required");
2960
+ }
2961
+ deleteUnsyncedFolder(body.name);
2962
+ return c.json({ ok: true });
2963
+ });
2916
2964
  nestRoutes.get("/:nestId", async (c) => {
2917
2965
  const nestId = c.req.param("nestId");
2918
2966
  if (nestId === "unsynced") return handleUnsyncedList(c);
@@ -3037,6 +3085,9 @@ async function addCollaborator(params) {
3037
3085
  const db = getDb();
3038
3086
  let userId = params.userId;
3039
3087
  if (!userId && params.email) {
3088
+ if (!isEmailish(params.email)) {
3089
+ throw new ValidationError("Enter a valid email address.");
3090
+ }
3040
3091
  const email = normalizeEmail(params.email);
3041
3092
  const existing = await db.get(
3042
3093
  "SELECT id FROM users WHERE LOWER(email) = ?",
@@ -3104,8 +3155,30 @@ async function addCollaborator(params) {
3104
3155
  actor: params.email || userId,
3105
3156
  permission: params.permission,
3106
3157
  by: params.grantedByEmail || void 0
3107
- }
3108
- );
3158
+ },
3159
+ // Invite email goes to the invitee directly (below), NOT the shared
3160
+ // NOTIFY_EMAIL_TO team inbox — so skip the team-channel email for this
3161
+ // event. Slack/Teams still fire.
3162
+ { skipEmail: true }
3163
+ );
3164
+ const inviteeEmail = params.email || (await db.get("SELECT email FROM users WHERE id = ?", [userId]))?.email;
3165
+ if (inviteeEmail) {
3166
+ void sendEmailToRecipient(inviteeEmail, {
3167
+ status: "shared",
3168
+ docTitle: shareNestName,
3169
+ path: shareNestName,
3170
+ link: docLink(nestId, void 0, params.baseUrl),
3171
+ actor: inviteeEmail,
3172
+ permission: params.permission,
3173
+ by: params.grantedByEmail || void 0
3174
+ });
3175
+ void notifyNestInvite({
3176
+ nestId,
3177
+ inviteeEmail,
3178
+ permission: params.permission,
3179
+ nestName: shareNestName
3180
+ });
3181
+ }
3109
3182
  return await db.get(
3110
3183
  "SELECT * FROM nest_collaborators WHERE id = ?",
3111
3184
  [collabId]
@@ -3297,7 +3370,8 @@ nestTeamRoutes.post("/", async (c) => {
3297
3370
  nestId: c.req.param("nestId"),
3298
3371
  teamId: body.team_id,
3299
3372
  callerUserId: userId,
3300
- allowAdmin: await isLicenseAdminUserId(userId)
3373
+ allowAdmin: await isLicenseAdminUserId(userId),
3374
+ baseUrl: requestBaseUrl(c.req.url)
3301
3375
  });
3302
3376
  return c.json({ teams }, 201);
3303
3377
  });
@@ -4011,7 +4085,7 @@ nodeRoutes.post("/", async (c) => {
4011
4085
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4012
4086
  const nestId = c.req.param("nestId");
4013
4087
  const nodeId = c.req.param("nodeId");
4014
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-7BHI2YXB.js");
4088
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-G7IF7ZNP.js");
4015
4089
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4016
4090
  nestId,
4017
4091
  nodeId
@@ -4032,7 +4106,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4032
4106
  nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
4033
4107
  const nestId = c.req.param("nestId");
4034
4108
  const nodeId = c.req.param("nodeId");
4035
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-YQIIQ7FN.js");
4109
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-FGL47APL.js");
4036
4110
  const allVersions = await getVersions2(nestId, nodeId);
4037
4111
  const approved = await getApprovedVersion2(nestId, nodeId);
4038
4112
  const db = getDb();
@@ -4064,7 +4138,7 @@ nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
4064
4138
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4065
4139
  const nestId = c.req.param("nestId");
4066
4140
  const nodeId = c.req.param("nodeId");
4067
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-DPN26C47.js");
4141
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-6G7MVTY6.js");
4068
4142
  const history = await getReviewHistory2(nestId, nodeId);
4069
4143
  return c.json({ reviews: history });
4070
4144
  });
@@ -4479,7 +4553,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
4479
4553
 
4480
4554
  // src/nodes/query-routes.ts
4481
4555
  import { Hono as Hono10 } from "hono";
4482
- import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
4556
+ import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
4483
4557
 
4484
4558
  // src/tables/service.ts
4485
4559
  function splitCsvLine(line) {
@@ -4898,6 +4972,68 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
4898
4972
  }
4899
4973
  }
4900
4974
 
4975
+ // src/nests/bundle-service.ts
4976
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
4977
+ import { join as join4, sep } from "path";
4978
+ import JSZip from "jszip";
4979
+ import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
4980
+ function walkFiles(root) {
4981
+ const out = [];
4982
+ let entries;
4983
+ try {
4984
+ entries = readdirSync2(root, { recursive: true });
4985
+ } catch {
4986
+ return out;
4987
+ }
4988
+ for (const rel of entries) {
4989
+ try {
4990
+ if (statSync2(join4(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
4991
+ } catch {
4992
+ }
4993
+ }
4994
+ return out;
4995
+ }
4996
+ async function exportNestBundle(nestId, includeDrafts) {
4997
+ const db = getDb();
4998
+ const approvedRows = await db.all(
4999
+ "SELECT node_id, approved_version FROM approved_versions WHERE nest_id = ?",
5000
+ [nestId]
5001
+ );
5002
+ const approvedByNode = new Map(
5003
+ approvedRows.map((r) => [r.node_id, r.approved_version])
5004
+ );
5005
+ const { storage } = await engineCache.get(nestId);
5006
+ const docs = await storage.discoverDocuments();
5007
+ const zip = new JSZip();
5008
+ let count = 0;
5009
+ for (const doc of docs) {
5010
+ const approved = approvedByNode.get(doc.id);
5011
+ const frontmatter = stripUndefinedDeep({ ...doc.frontmatter });
5012
+ let body = doc.body || "";
5013
+ if (approved != null) {
5014
+ const approvedBody = await resolveExportBody(nestId, doc.id, body);
5015
+ if (approvedBody == null) continue;
5016
+ body = approvedBody;
5017
+ frontmatter.status = "published";
5018
+ frontmatter.version = approved;
5019
+ } else {
5020
+ if (!includeDrafts) continue;
5021
+ frontmatter.status = "draft";
5022
+ }
5023
+ zip.file(`${doc.id}.md`, serializeDocument2({ ...doc, frontmatter, body }));
5024
+ count++;
5025
+ }
5026
+ const nestPath = resolveNestPath(nestId);
5027
+ for (const rel of walkFiles(nestPath)) {
5028
+ const segs = rel.split("/");
5029
+ const keep = segs.includes(".versions") || rel === ".context/config.yaml" || rel === "CONTEXT.md";
5030
+ if (!keep) continue;
5031
+ zip.file(rel, readFileSync2(join4(nestPath, rel)));
5032
+ }
5033
+ trackEvent("nest.export.bundle", { nestId, documents: count, includeDrafts });
5034
+ return zip.generateAsync({ type: "arraybuffer" });
5035
+ }
5036
+
4901
5037
  // src/nodes/graph-service.ts
4902
5038
  var MAX_GRAPH_NODES = 150;
4903
5039
  var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
@@ -5451,10 +5587,24 @@ queryRoutes.get("/context", async (c) => {
5451
5587
  return c.json({ content: content || "" });
5452
5588
  });
5453
5589
  queryRoutes.get("/export", async (c) => {
5590
+ const nestId = c.req.param("nestId");
5591
+ if (c.req.query("format") === "bundle") {
5592
+ const perm = await resolveNestPermission(nestId, c.get("userId"));
5593
+ if (perm !== "owner" && perm !== "admin") {
5594
+ throw new ForbiddenError("Only the nest owner or an admin can export a bundle");
5595
+ }
5596
+ const draftsParam = c.req.query("includeDrafts");
5597
+ const includeDrafts = draftsParam === "1" || draftsParam === "true";
5598
+ const zip = await exportNestBundle(nestId, includeDrafts);
5599
+ const filename = `nest-${nestId}.zip`;
5600
+ return c.body(zip, 200, {
5601
+ "Content-Type": "application/zip",
5602
+ "Content-Disposition": `attachment; filename="${filename}"`
5603
+ });
5604
+ }
5454
5605
  if (!isMarkdownFormat(c)) {
5455
- throw new ValidationError("format=markdown is required");
5606
+ throw new ValidationError("format=markdown or format=bundle is required");
5456
5607
  }
5457
- const nestId = c.req.param("nestId");
5458
5608
  const { storage, query: queryEngine } = await engineCache.get(nestId);
5459
5609
  const selector = c.req.query("selector")?.trim() || null;
5460
5610
  let documents;
@@ -5553,7 +5703,7 @@ queryRoutes.post("/publish", async (c) => {
5553
5703
  body: doc.content,
5554
5704
  rawContent: ""
5555
5705
  };
5556
- const serialized = serializeDocument2(node);
5706
+ const serialized = serializeDocument3(node);
5557
5707
  await storage.writeDocument(id, serialized);
5558
5708
  await syncNodeTags(nestId, id, tags);
5559
5709
  created.push(id);
@@ -7358,17 +7508,15 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
7358
7508
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7359
7509
  );
7360
7510
  if (!node) return `Node not found: ${args.title}`;
7361
- const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
7362
7511
  try {
7363
7512
  const request = await approve({
7364
7513
  nestId: ctx.nestId,
7365
7514
  nodeId: node.id,
7366
- version: currentVersion,
7367
7515
  approvedBy: ctx.userEmail,
7368
7516
  note: args.note,
7369
7517
  baseUrl: ctx.baseUrl
7370
7518
  });
7371
- return `Approved "${args.title}" v${currentVersion}. This version is now live for AI queries.${args.note ? `
7519
+ return `Approved "${args.title}" v${request.version}. This version is now live for AI queries.${args.note ? `
7372
7520
  Note: ${args.note}` : ""}`;
7373
7521
  } catch (err) {
7374
7522
  return `Cannot approve: ${err.message}`;
@@ -7380,17 +7528,15 @@ Note: ${args.note}` : ""}`;
7380
7528
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7381
7529
  );
7382
7530
  if (!node) return `Node not found: ${args.title}`;
7383
- const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
7384
7531
  try {
7385
7532
  const request = await reject({
7386
7533
  nestId: ctx.nestId,
7387
7534
  nodeId: node.id,
7388
- version: currentVersion,
7389
7535
  rejectedBy: ctx.userEmail,
7390
7536
  note: args.note,
7391
7537
  baseUrl: ctx.baseUrl
7392
7538
  });
7393
- return `Rejected "${args.title}" v${currentVersion}.
7539
+ return `Rejected "${args.title}" v${request.version}.
7394
7540
  Reason: ${args.note}`;
7395
7541
  } catch (err) {
7396
7542
  return `Cannot reject: ${err.message}`;
@@ -7471,8 +7617,8 @@ ${list}`;
7471
7617
  if (!target) return "target is required (a node id or folder prefix).";
7472
7618
  if (!["read", "write"].includes(role)) return "role must be read or write.";
7473
7619
  try {
7474
- const { createGrant: createGrant2 } = await import("./grants-service-PS5RMMEA.js");
7475
- const db = (await import("./client-YUOEVUFU.js")).getDb();
7620
+ const { createGrant: createGrant2 } = await import("./grants-service-5ZFISCBH.js");
7621
+ const db = (await import("./client-3MGIP57D.js")).getDb();
7476
7622
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
7477
7623
  const e = normalizeEmail2(String(args.email || ""));
7478
7624
  if (!e) return "email is required.";
@@ -8559,7 +8705,7 @@ definitionRoutes.delete("/:id", async (c) => {
8559
8705
  import { Hono as Hono18 } from "hono";
8560
8706
  import { v4 as uuid13 } from "uuid";
8561
8707
  import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
8562
- import { join as join4 } from "path";
8708
+ import { join as join5 } from "path";
8563
8709
  var assetRoutes = new Hono18();
8564
8710
  var CONTENT_TYPES = {
8565
8711
  png: "image/png",
@@ -8572,7 +8718,7 @@ var CONTENT_TYPES = {
8572
8718
  };
8573
8719
  var VIDEO_EXTS = /* @__PURE__ */ new Set(["mp4", "webm"]);
8574
8720
  var IMAGE_MAX_BYTES = 10 * 1024 * 1024;
8575
- var VIDEO_MAX_BYTES = 100 * 1024 * 1024;
8721
+ var VIDEO_MAX_BYTES = config.VIDEO_MAX_BYTES;
8576
8722
  var ASSET_MAX_REQUEST_BYTES = VIDEO_MAX_BYTES + 1024 * 1024;
8577
8723
  var SAFE_NAME = /^[0-9a-f-]{36}\.(png|jpe?g|gif|webp|mp4|webm)$/;
8578
8724
  assetRoutes.post("/", async (c) => {
@@ -8607,9 +8753,9 @@ assetRoutes.post("/", async (c) => {
8607
8753
  );
8608
8754
  }
8609
8755
  const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
8610
- const dir = join4(resolveNestPath(nestId), "assets");
8756
+ const dir = join5(resolveNestPath(nestId), "assets");
8611
8757
  await mkdir2(dir, { recursive: true });
8612
- await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
8758
+ await writeFile(join5(dir, name), Buffer.from(await file.arrayBuffer()));
8613
8759
  const url = `/nests/${nestId}/assets/${name}`;
8614
8760
  return c.json(
8615
8761
  {
@@ -8631,7 +8777,7 @@ assetRoutes.get("/:file", async (c) => {
8631
8777
  }
8632
8778
  let bytes;
8633
8779
  try {
8634
- bytes = await readFile2(join4(resolveNestPath(nestId), "assets", name));
8780
+ bytes = await readFile2(join5(resolveNestPath(nestId), "assets", name));
8635
8781
  } catch {
8636
8782
  throw new NotFoundError("Asset not found");
8637
8783
  }
@@ -9386,8 +9532,8 @@ function rowToComment(row) {
9386
9532
  }
9387
9533
 
9388
9534
  // src/governance/stewards-parser.ts
9389
- import { readFileSync as readFileSync2, existsSync } from "fs";
9390
- import { join as join5 } from "path";
9535
+ import { readFileSync as readFileSync3, existsSync } from "fs";
9536
+ import { join as join6 } from "path";
9391
9537
  function parseStewardsYaml(content) {
9392
9538
  const result = { version: 1 };
9393
9539
  const lines = content.split("\n");
@@ -9453,13 +9599,13 @@ function parseEntry(str) {
9453
9599
  function loadStewardsConfig(nestId) {
9454
9600
  const nestPath = resolveNestPath(nestId);
9455
9601
  const candidates = [
9456
- join5(nestPath, "stewards.yaml"),
9457
- join5(nestPath, "stewards.yml"),
9458
- join5(nestPath, ".context", "stewards.yaml")
9602
+ join6(nestPath, "stewards.yaml"),
9603
+ join6(nestPath, "stewards.yml"),
9604
+ join6(nestPath, ".context", "stewards.yaml")
9459
9605
  ];
9460
9606
  for (const candidatePath of candidates) {
9461
9607
  if (existsSync(candidatePath)) {
9462
- const content = readFileSync2(candidatePath, "utf-8");
9608
+ const content = readFileSync3(candidatePath, "utf-8");
9463
9609
  return parseStewardsYaml(content);
9464
9610
  }
9465
9611
  }
@@ -9515,7 +9661,8 @@ governanceRoutes.post("/stewards", async (c) => {
9515
9661
  documentId: body.documentId,
9516
9662
  tagName: body.tagName,
9517
9663
  users: body.users,
9518
- assignedBy
9664
+ assignedBy,
9665
+ baseUrl: requestBaseUrl(c.req.url)
9519
9666
  });
9520
9667
  return c.json({ stewards: created2 }, 201);
9521
9668
  }
@@ -9533,7 +9680,8 @@ governanceRoutes.post("/stewards", async (c) => {
9533
9680
  role: body.role
9534
9681
  }
9535
9682
  ],
9536
- assignedBy
9683
+ assignedBy,
9684
+ baseUrl: requestBaseUrl(c.req.url)
9537
9685
  });
9538
9686
  return c.json({ steward: created[0] }, 201);
9539
9687
  });
@@ -9852,7 +10000,6 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
9852
10000
  const request = await approve({
9853
10001
  nestId,
9854
10002
  nodeId,
9855
- version: await getCurrentVersion(nestId, nodeId),
9856
10003
  approvedBy: userEmail,
9857
10004
  note: body.note,
9858
10005
  override: body.override && isAdmin,
@@ -9875,7 +10022,6 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
9875
10022
  const request = await reject({
9876
10023
  nestId,
9877
10024
  nodeId,
9878
- version: await getCurrentVersion(nestId, nodeId),
9879
10025
  rejectedBy: userEmail,
9880
10026
  note: body.note,
9881
10027
  baseUrl: requestBaseUrl(c.req.url)
@@ -9968,19 +10114,19 @@ async function ensureAnonymousUser() {
9968
10114
  // src/app.ts
9969
10115
  import { serveStatic } from "@hono/node-server/serve-static";
9970
10116
  import { fileURLToPath } from "url";
9971
- import { dirname as dirname2, join as join6, relative as relative2 } from "path";
9972
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
10117
+ import { dirname as dirname2, join as join7, relative as relative2 } from "path";
10118
+ import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
9973
10119
  var HERE = dirname2(fileURLToPath(import.meta.url));
9974
10120
  var SERVICE_VERSION = (() => {
9975
10121
  try {
9976
- return JSON.parse(readFileSync3(join6(HERE, "..", "package.json"), "utf8")).version;
10122
+ return JSON.parse(readFileSync4(join7(HERE, "..", "package.json"), "utf8")).version;
9977
10123
  } catch {
9978
10124
  return "unknown";
9979
10125
  }
9980
10126
  })();
9981
10127
  var UI_DIR_CANDIDATES = [
9982
- join6(HERE, "web3"),
9983
- join6(process.cwd(), "dist", "web3")
10128
+ join7(HERE, "web3"),
10129
+ join7(process.cwd(), "dist", "web3")
9984
10130
  ];
9985
10131
  var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
9986
10132
  var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
@@ -10016,6 +10162,22 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
10016
10162
  }
10017
10163
  return c.json({ error: "Missing or invalid credentials" }, 401);
10018
10164
  });
10165
+ var APPROVE_CACHE_TTL_MS = 3e4;
10166
+ var approveCache = /* @__PURE__ */ new Map();
10167
+ async function canApproveInNest(nestId, email) {
10168
+ const key = `${nestId} ${email.toLowerCase()}`;
10169
+ const now = Date.now();
10170
+ const hit = approveCache.get(key);
10171
+ if (hit && hit.exp > now) return hit.ok;
10172
+ const roles = await resolveUserRoles(nestId, email);
10173
+ let ok = roles.includes("admin") || roles.includes("reviewer");
10174
+ if (!ok && roles.includes("owner")) ok = await nestAllowsSelfApprove(nestId);
10175
+ if (approveCache.size > 500) {
10176
+ for (const [k, v] of approveCache) if (v.exp <= now) approveCache.delete(k);
10177
+ }
10178
+ approveCache.set(key, { ok, exp: now + APPROVE_CACHE_TTL_MS });
10179
+ return ok;
10180
+ }
10019
10181
  function createApp() {
10020
10182
  const app = new Hono21({ router: new LinearRouter() });
10021
10183
  const corsOrigins = config.CORS_ORIGINS;
@@ -10185,6 +10347,7 @@ function createApp() {
10185
10347
  });
10186
10348
  const serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
10187
10349
  app.use("/admin/settings", flexAuthMiddleware);
10350
+ app.use("/admin/settings/*", flexAuthMiddleware);
10188
10351
  const currentServerSettings = () => ({
10189
10352
  promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
10190
10353
  manual_sign_in: config.MANUAL_SIGN_IN,
@@ -10217,6 +10380,7 @@ function createApp() {
10217
10380
  // Server-wide runner default. NEVER the value — masked tail only.
10218
10381
  anthropic_api_key_masked: process.env.ANTHROPIC_API_KEY ? `\u2022\u2022\u2022\u2022${process.env.ANTHROPIC_API_KEY.slice(-4)}` : null,
10219
10382
  slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
10383
+ msteams_webhook_url: config.MSTEAMS_WEBHOOK_URL ?? "",
10220
10384
  smtp_url: config.SMTP_URL ?? "",
10221
10385
  notify_email_from: config.NOTIFY_EMAIL_FROM ?? "",
10222
10386
  notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
@@ -10226,6 +10390,20 @@ function createApp() {
10226
10390
  return c.json({ error: "Only the server admin can view this." }, 403);
10227
10391
  return c.json(currentServerSettings());
10228
10392
  });
10393
+ app.post("/admin/settings/test-notification", async (c) => {
10394
+ if (!await serverAdminAllowed(c))
10395
+ return c.json({ error: "Only the server admin can do this." }, 403);
10396
+ let body;
10397
+ try {
10398
+ body = await c.req.json();
10399
+ } catch {
10400
+ return c.json({ error: "Invalid JSON body" }, 400);
10401
+ }
10402
+ if (body.channel !== "slack" && body.channel !== "teams")
10403
+ return c.json({ error: "channel must be 'slack' or 'teams'" }, 400);
10404
+ const result = await sendTestNotification(body.channel);
10405
+ return result.ok ? c.json({ ok: true }) : c.json({ error: result.error }, 502);
10406
+ });
10229
10407
  app.patch("/admin/settings", async (c) => {
10230
10408
  if (!await serverAdminAllowed(c))
10231
10409
  return c.json({ error: "Only the server admin can change this." }, 403);
@@ -10418,6 +10596,12 @@ function createApp() {
10418
10596
  addError("slack_webhook_url", "Slack webhook must be an https:// URL (or leave it empty to turn Slack notifications off).");
10419
10597
  else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
10420
10598
  }
10599
+ if ("msteams_webhook_url" in body) {
10600
+ const v = String(body.msteams_webhook_url ?? "").trim();
10601
+ if (v && !/^https:\/\//i.test(v))
10602
+ addError("msteams_webhook_url", "Microsoft Teams webhook must be an https:// URL (or leave it empty to turn Teams notifications off).");
10603
+ else pending.push({ name: "MSTEAMS_WEBHOOK_URL", value: v || null });
10604
+ }
10421
10605
  if ("smtp_url" in body) {
10422
10606
  const v = String(body.smtp_url ?? "").trim();
10423
10607
  if (v && !/^smtps?:\/\//i.test(v))
@@ -10633,12 +10817,16 @@ function createApp() {
10633
10817
  const userId = c.get("userId");
10634
10818
  const me = await resolveCallerEmail(userId);
10635
10819
  const nestName2 = /* @__PURE__ */ new Map();
10636
- for (const n of [
10637
- ...await listNests(userId),
10638
- ...await listSharedNests(userId)
10639
- ])
10820
+ for (const n of await listVisibleNests(userId))
10640
10821
  nestName2.set(n.id, n.name ?? n.id);
10641
- const { requests } = await getReviewQueue({ status: "pending", limit: 200 });
10822
+ const limit = Math.min(Math.max(Number(c.req.query("limit")) || 50, 1), 100);
10823
+ const offset = Math.max(Number(c.req.query("offset")) || 0, 0);
10824
+ const { requests, total } = await getReviewQueue({
10825
+ status: "pending",
10826
+ nestIds: [...nestName2.keys()],
10827
+ limit,
10828
+ offset
10829
+ });
10642
10830
  const toItem = (r) => ({
10643
10831
  type: "review",
10644
10832
  nest_id: r.nestId,
@@ -10649,12 +10837,29 @@ function createApp() {
10649
10837
  requested_at: r.requestedAt,
10650
10838
  priority: r.priority
10651
10839
  });
10652
- const mine = requests.filter((r) => nestName2.has(r.nestId));
10653
10840
  const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
10654
- const items = mine.filter((r) => r.requestedBy !== me).map(toItem).sort(byNewest);
10655
- const waiting = mine.filter((r) => r.requestedBy === me).map(toItem).sort(byNewest);
10841
+ const others = requests.filter((r) => r.requestedBy !== me);
10842
+ const nestApprove = new Map(
10843
+ await Promise.all(
10844
+ [...new Set(others.map((r) => r.nestId))].map(
10845
+ async (nestId) => [
10846
+ nestId,
10847
+ await canApproveInNest(nestId, me)
10848
+ ]
10849
+ )
10850
+ )
10851
+ );
10852
+ const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem).sort(byNewest);
10853
+ const waiting = requests.filter((r) => r.requestedBy === me).map(toItem).sort(byNewest);
10656
10854
  const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
10657
- return c.json({ items, waiting, notifications });
10855
+ return c.json({
10856
+ items,
10857
+ waiting,
10858
+ notifications,
10859
+ // total = all pending across visible nests (both buckets); has_more drives
10860
+ // the "Load more" control on the client.
10861
+ pagination: { total, limit, offset, has_more: offset + requests.length < total }
10862
+ });
10658
10863
  });
10659
10864
  app.get("/me/notifications", async (c) => {
10660
10865
  const me = await resolveCallerEmail(c.get("userId"));
@@ -10672,9 +10877,7 @@ function createApp() {
10672
10877
  const db = getDb();
10673
10878
  const userId = c.get("userId");
10674
10879
  const userEmail = await resolveCallerEmail(userId);
10675
- const owned = await listNests(userId);
10676
- const sharedNests = await listSharedNests(userId);
10677
- const visibleNests = [...owned, ...sharedNests];
10880
+ const visibleNests = await listVisibleNests(userId);
10678
10881
  let documents = 0;
10679
10882
  for (const nest of visibleNests) {
10680
10883
  try {