@promptowl/contextnest-community 1.13.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.
@@ -2,12 +2,13 @@ import {
2
2
  grantCoversNode,
3
3
  listUserGrants,
4
4
  resolveNodeGrant
5
- } from "./chunk-ZQS5W45U.js";
5
+ } from "./chunk-IHGPZ6NY.js";
6
6
  import {
7
7
  createVersion,
8
8
  getApprovedVersion,
9
+ getCurrentVersion,
9
10
  setApprovedVersion
10
- } from "./chunk-EG77SQHO.js";
11
+ } from "./chunk-JP67WXLA.js";
11
12
  import {
12
13
  buildDocContext,
13
14
  buildTitleMap,
@@ -17,8 +18,10 @@ import {
17
18
  engineCache,
18
19
  isPublicReader,
19
20
  isStewardshipEnabled,
20
- resolveStewardsForNode
21
- } from "./chunk-Q5NRCDD4.js";
21
+ notifyEmailForNest,
22
+ resolveStewardsForNode,
23
+ sendEmailToRecipient
24
+ } from "./chunk-KPK656BH.js";
22
25
  import {
23
26
  ConflictError,
24
27
  NotFoundError,
@@ -31,7 +34,7 @@ import {
31
34
  import {
32
35
  config,
33
36
  getDb
34
- } from "./chunk-5QQ7WKAI.js";
37
+ } from "./chunk-I5KYGMIT.js";
35
38
 
36
39
  // src/governance/review-service.ts
37
40
  import { v4 as uuid3 } from "uuid";
@@ -146,6 +149,25 @@ async function notifyReviewResolved(params) {
146
149
  return 0;
147
150
  }
148
151
  }
152
+ async function notifyNestInvite(params) {
153
+ const { nestId, inviteeEmail, permission, nestName } = params;
154
+ try {
155
+ await getDb().run(
156
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
157
+ VALUES (?, ?, ?, 'nest_invite', ?, ?, ?)`,
158
+ [
159
+ uuid(),
160
+ nestId,
161
+ inviteeEmail.toLowerCase(),
162
+ nestId,
163
+ `You were added to "${nestName}" as ${permission}`,
164
+ (/* @__PURE__ */ new Date()).toISOString()
165
+ ]
166
+ );
167
+ } catch (err) {
168
+ console.error("[notify] invite inbox insert failed", nestId, inviteeEmail, err);
169
+ }
170
+ }
149
171
  async function listNotifications(userEmail, opts = {}) {
150
172
  const limit = Math.min(opts.limit ?? 50, 200);
151
173
  return await getDb().all(
@@ -497,23 +519,158 @@ async function envValues(nestId) {
497
519
  return { ...base, ...Object.fromEntries(rows.map((r) => [r.key, r.value])) };
498
520
  }
499
521
 
500
- // src/notify/dispatch.ts
522
+ // src/notify/webhook.ts
523
+ async function resolveNestName(nestId) {
524
+ try {
525
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
526
+ nestId
527
+ ]);
528
+ if (row?.name) return row.name;
529
+ } catch {
530
+ }
531
+ return nestId;
532
+ }
533
+ async function rawPost(url, body, timeoutMs) {
534
+ const ctrl = new AbortController();
535
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
536
+ try {
537
+ return await fetch(url, {
538
+ method: "POST",
539
+ headers: { "Content-Type": "application/json" },
540
+ body: JSON.stringify(body),
541
+ signal: ctrl.signal
542
+ });
543
+ } finally {
544
+ clearTimeout(timer);
545
+ }
546
+ }
547
+ var warned = /* @__PURE__ */ new Set();
548
+ async function postWebhook(url, body, who) {
549
+ try {
550
+ await rawPost(url, body, 3e3);
551
+ } catch (err) {
552
+ if (!warned.has(who)) {
553
+ warned.add(who);
554
+ console.warn(
555
+ `[notify] delivery failed for ${who} (muting further warnings):`,
556
+ err instanceof Error ? err.message : err
557
+ );
558
+ }
559
+ }
560
+ }
561
+ async function testWebhook(url, body) {
562
+ try {
563
+ const res = await rawPost(url, body, 5e3);
564
+ return res.ok ? { ok: true } : { ok: false, error: `Webhook responded ${res.status} ${res.statusText}.` };
565
+ } catch (err) {
566
+ return {
567
+ ok: false,
568
+ error: err instanceof Error ? err.message : "Request failed."
569
+ };
570
+ }
571
+ }
572
+
573
+ // src/notify/msteams.ts
574
+ var EMOJI = {
575
+ ":memo:": "\u{1F4DD}",
576
+ ":white_check_mark:": "\u2705",
577
+ ":x:": "\u274C",
578
+ ":rotating_light:": "\u{1F6A8}",
579
+ ":key:": "\u{1F511}"
580
+ // collaborator added (sharing-service)
581
+ };
582
+ var BOLD_SPAN = /(^|\s)\*([^*\n]+)\*(?=\s|[.,;:!?)]|$)/g;
583
+ function slackEmojiToUnicode(s) {
584
+ return s.replace(/:[a-z0-9_+-]+:/g, (m) => EMOJI[m] ?? m);
585
+ }
586
+ function escapeAdaptiveText(s) {
587
+ return s.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
588
+ }
589
+ function toAdaptiveText(s) {
590
+ return slackEmojiToUnicode(escapeAdaptiveText(s)).replace(BOLD_SPAN, "$1**$2**");
591
+ }
592
+ function toPlainText(s) {
593
+ return slackEmojiToUnicode(s).replace(BOLD_SPAN, "$1$2");
594
+ }
595
+ function msTeamsPayload(title, message) {
596
+ return {
597
+ type: "message",
598
+ attachments: [
599
+ {
600
+ contentType: "application/vnd.microsoft.card.adaptive",
601
+ contentUrl: null,
602
+ content: {
603
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
604
+ type: "AdaptiveCard",
605
+ version: "1.4",
606
+ msteams: { width: "Full" },
607
+ fallbackText: `[${toPlainText(title)}] ${toPlainText(message)}`,
608
+ body: [
609
+ {
610
+ // Same pipeline as the message: the nest name is user-
611
+ // controlled text too and must not carry link syntax.
612
+ type: "TextBlock",
613
+ text: toAdaptiveText(title),
614
+ weight: "Bolder",
615
+ size: "Medium",
616
+ wrap: true
617
+ },
618
+ {
619
+ type: "TextBlock",
620
+ text: toAdaptiveText(message),
621
+ wrap: true
622
+ }
623
+ ]
624
+ }
625
+ }
626
+ ]
627
+ };
628
+ }
629
+ async function notifyMsTeamsForNest(nestId, message) {
630
+ if (!config.MSTEAMS_WEBHOOK_URL) return;
631
+ const nestName = await resolveNestName(nestId);
632
+ return notifyMsTeams(nestName, message);
633
+ }
634
+ async function notifyMsTeams(title, message) {
635
+ const url = config.MSTEAMS_WEBHOOK_URL;
636
+ if (!url) return;
637
+ await postWebhook(url, msTeamsPayload(title, message), "msteams");
638
+ }
639
+
640
+ // src/notify/slack.ts
641
+ async function notifySlackForNest(nestId, message) {
642
+ if (!config.SLACK_WEBHOOK_URL) return;
643
+ const nestName = await resolveNestName(nestId);
644
+ return notifySlack(`*[${nestName}]* ${message}`);
645
+ }
501
646
  function escapeMrkdwn(s) {
502
647
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
503
648
  }
649
+ async function notifySlack(text) {
650
+ const url = config.SLACK_WEBHOOK_URL;
651
+ if (!url) return;
652
+ await postWebhook(url, { text: escapeMrkdwn(text) }, "slack");
653
+ }
654
+
655
+ // src/notify/dispatch.ts
504
656
  var EVENT_EMOJI = {
505
657
  review_requested: ":memo:",
506
658
  review_approved: ":white_check_mark:",
507
659
  review_rejected: ":x:",
508
660
  run_failed: ":rotating_light:"
509
661
  };
662
+ var DIGEST_DELIVERED_KINDS = /* @__PURE__ */ new Set([
663
+ "review_requested",
664
+ "review_approved",
665
+ "review_rejected"
666
+ ]);
510
667
  function formatBody(channel, nestName, ev) {
511
668
  const line = `[${nestName}] ${ev.message}`;
512
669
  if (channel === "slack") {
513
670
  return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName}]* ${ev.message}`) };
514
671
  }
515
672
  if (channel === "teams") {
516
- return { text: line };
673
+ return msTeamsPayload(nestName, `${EVENT_EMOJI[ev.kind] ?? ""} ${ev.message}`.trim());
517
674
  }
518
675
  return {
519
676
  kind: ev.kind,
@@ -557,46 +714,23 @@ async function resolveUrl(nestId, raw) {
557
714
  }
558
715
  return isSafeConnectorUrl(raw) ? raw : null;
559
716
  }
560
- var warned = /* @__PURE__ */ new Set();
561
- async function post(url, body, who) {
562
- try {
563
- const ctrl = new AbortController();
564
- const timer = setTimeout(() => ctrl.abort(), 3e3);
565
- try {
566
- await fetch(url, {
567
- method: "POST",
568
- headers: { "Content-Type": "application/json" },
569
- body: JSON.stringify(body),
570
- signal: ctrl.signal
571
- });
572
- } finally {
573
- clearTimeout(timer);
574
- }
575
- } catch (err) {
576
- if (!warned.has(who)) {
577
- warned.add(who);
578
- console.warn(
579
- `[connectors] delivery failed for ${who} (muting further warnings):`,
580
- err instanceof Error ? err.message : err
581
- );
582
- }
583
- }
584
- }
585
717
  async function dispatchEvent(ev) {
586
718
  const db = getDb();
587
- let nestName = ev.nestId;
588
- try {
589
- const row = await db.get("SELECT name FROM nests WHERE id = ?", [ev.nestId]);
590
- if (row?.name) nestName = row.name;
591
- } catch {
592
- }
593
- if (config.SLACK_WEBHOOK_URL) {
594
- void post(
719
+ const nestName = await resolveNestName(ev.nestId);
720
+ if (config.SLACK_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
721
+ void postWebhook(
595
722
  config.SLACK_WEBHOOK_URL,
596
723
  formatBody("slack", nestName, ev),
597
724
  "server-slack"
598
725
  );
599
726
  }
727
+ if (config.MSTEAMS_WEBHOOK_URL && !DIGEST_DELIVERED_KINDS.has(ev.kind)) {
728
+ void postWebhook(
729
+ config.MSTEAMS_WEBHOOK_URL,
730
+ formatBody("teams", nestName, ev),
731
+ "server-msteams"
732
+ );
733
+ }
600
734
  let rows = [];
601
735
  try {
602
736
  rows = await db.all(
@@ -615,308 +749,20 @@ async function dispatchEvent(ev) {
615
749
  if (!kinds.includes("*") && !kinds.includes(ev.kind)) continue;
616
750
  const url = await resolveUrl(ev.nestId, r.url);
617
751
  if (!url) continue;
618
- void post(url, formatBody(r.channel, nestName, ev), `connector:${r.id}`);
752
+ void postWebhook(url, formatBody(r.channel, nestName, ev), `connector:${r.id}`);
619
753
  }
620
754
  }
621
-
622
- // src/notify/slack.ts
623
- var warnedOnce = false;
624
- async function notifySlackForNest(nestId, message) {
625
- if (!config.SLACK_WEBHOOK_URL) return;
626
- let nestName = nestId;
627
- try {
628
- const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
629
- nestId
630
- ]);
631
- if (row?.name) nestName = row.name;
632
- } catch {
633
- }
634
- return notifySlack(`*[${nestName}]* ${message}`);
635
- }
636
- function escapeMrkdwn2(s) {
637
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
638
- }
639
- async function notifySlack(text) {
640
- const url = config.SLACK_WEBHOOK_URL;
641
- if (!url) return;
642
- try {
643
- const ctrl = new AbortController();
644
- const timer = setTimeout(() => ctrl.abort(), 3e3);
645
- try {
646
- await fetch(url, {
647
- method: "POST",
648
- headers: { "Content-Type": "application/json" },
649
- body: JSON.stringify({ text: escapeMrkdwn2(text) }),
650
- signal: ctrl.signal
651
- });
652
- } finally {
653
- clearTimeout(timer);
654
- }
655
- } catch (err) {
656
- if (!warnedOnce) {
657
- warnedOnce = true;
658
- console.warn(
659
- "[slack] notification failed (suppressing further warnings):",
660
- err instanceof Error ? err.message : err
661
- );
662
- }
663
- }
664
- }
665
-
666
- // src/notify/email-render.ts
667
- function escapeHtml(s) {
668
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
669
- }
670
- var EVENT = {
671
- ":white_check_mark:": { icon: "\u2705", color: "#16a34a" },
672
- ":x:": { icon: "\u274C", color: "#dc2626" },
673
- ":memo:": { icon: "\u{1F4DD}", color: "#d97706" },
674
- ":key:": { icon: "\u{1F511}", color: "#2563eb" }
675
- };
676
- var ALL_EMOJI = Object.fromEntries(
677
- Object.entries(EVENT).map(([code, m]) => [code, m.icon])
678
- );
679
- function renderText(message) {
680
- return message.replace(/:[a-z_]+:/g, (m) => ALL_EMOJI[m] ?? "").replace(/\*/g, "").replace(/[ \t]{2,}/g, " ").trim();
681
- }
682
- var STATUS_META = {
683
- approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
684
- rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
685
- pending_review: {
686
- subjectWord: "awaiting review",
687
- label: "Pending review",
688
- color: "#d97706"
689
- },
690
- shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" }
691
- };
692
- function renderEmail(d) {
693
- const meta = STATUS_META[d.status];
694
- const title = d.docTitle.replace(/[\r\n]+/g, " ").trim();
695
- const heading = d.status === "shared" ? `New collaborator on ${title}` : `${title} is ${meta.subjectWord}`;
696
- const subject = heading.replace(/[\r\n]+/g, " ").trim();
697
- const vLabel = d.version != null ? `v${d.version}` : "";
698
- const detailsHeader = d.status === "shared" ? "Details" : "Document details";
699
- const linkLabel = d.status === "shared" ? "Open nest" : "Open document";
700
- const byT = d.actor ? ` by ${d.actor}` : "";
701
- const noteT = d.note ? ` with the note: "${d.note}"` : "";
702
- const vT = vLabel ? ` ${vLabel}` : "";
703
- let lineT;
704
- switch (d.status) {
705
- case "approved":
706
- lineT = `${title}${vT} has been approved${byT}${noteT}.`;
707
- break;
708
- case "rejected":
709
- lineT = `${title}${vT} has been rejected${byT}${noteT}.`;
710
- break;
711
- case "pending_review":
712
- lineT = `A review has been requested for ${title}${vT}${byT}${noteT}.`;
713
- break;
714
- case "shared":
715
- lineT = `${d.actor || "A user"} was added to ${title} as ${d.permission || "a collaborator"}${d.by ? ` by ${d.by}` : ""}.`;
716
- break;
717
- }
718
- const text = [
719
- "Hi,",
720
- "",
721
- lineT,
722
- "",
723
- "Document details:",
724
- ` Path: ${d.path}`,
725
- ` Status: ${meta.label}`,
726
- ...d.version != null ? [` Version: v${d.version}`] : [],
727
- ...d.link ? [` Open: ${d.link}`] : [],
728
- "",
729
- "Regards,",
730
- "ContextNest"
731
- ].join("\n");
732
- const doc = `<strong>${escapeHtml(title)}</strong>`;
733
- const byH = d.actor ? ` by ${escapeHtml(d.actor)}` : "";
734
- const noteH = d.note ? ` with the note: &ldquo;${escapeHtml(d.note)}&rdquo;` : "";
735
- const vH = vLabel ? ` ${vLabel}` : "";
736
- let lineH;
737
- switch (d.status) {
738
- case "approved":
739
- lineH = `${doc}${vH} has been approved${byH}${noteH}.`;
740
- break;
741
- case "rejected":
742
- lineH = `${doc}${vH} has been rejected${byH}${noteH}.`;
743
- break;
744
- case "pending_review":
745
- lineH = `A review has been requested for ${doc}${vH}${byH}${noteH}.`;
746
- break;
747
- case "shared":
748
- lineH = `${escapeHtml(d.actor || "A user")} was added to ${doc} as <strong>${escapeHtml(d.permission || "a collaborator")}</strong>${d.by ? ` by ${escapeHtml(d.by)}` : ""}.`;
749
- break;
750
- }
751
- const detailRow = (label, valueHtml) => `<tr>
752
- <td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
753
- <td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
754
- </tr>`;
755
- const detailRows = detailRow("Path", escapeHtml(d.path)) + detailRow(
756
- "Status",
757
- `<span style="display:inline-block;padding:2px 9px;border-radius:9999px;background:#f3f4f6;color:${meta.color};font-size:12px;font-weight:600;">${meta.label}</span>`
758
- ) + (d.version != null ? detailRow("Version", `v${d.version}`) : "");
759
- const buttonRow = d.link ? `<tr><td style="padding:20px 24px 0;">
760
- <table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
761
- <td bgcolor="${meta.color}" style="border-radius:6px;">
762
- <a href="${escapeHtml(d.link)}" target="_blank" style="display:inline-block;padding:10px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${linkLabel}</a>
763
- </td>
764
- </tr></table>
765
- </td></tr>` : "";
766
- const html = `<div style="margin:0;padding:24px;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;">
767
- <table role="presentation" width="480" cellpadding="0" cellspacing="0" border="0" align="center" style="width:100%;max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;">
768
- <tr><td style="padding:20px 24px 0;">
769
- <div style="font-size:12px;font-weight:700;color:#6b7280;letter-spacing:0.06em;text-transform:uppercase;">ContextNest</div>
770
- </td></tr>
771
- <tr><td style="padding:10px 24px 0;">
772
- <h1 style="margin:0;font-size:19px;line-height:1.35;font-weight:700;color:#111827;">${escapeHtml(heading)}</h1>
773
- </td></tr>
774
- <tr><td style="padding:16px 24px 0;font-size:14px;line-height:1.6;color:#374151;">
775
- Hi,<br><br>${lineH}
776
- </td></tr>
777
- <tr><td style="padding:20px 24px 0;">
778
- <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #eef0f2;border-radius:8px;">
779
- <tr><td style="padding:12px 16px;">
780
- <div style="font-size:11px;font-weight:600;color:#9ca3af;letter-spacing:0.04em;text-transform:uppercase;padding-bottom:4px;">${detailsHeader}</div>
781
- <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${detailRows}</table>
782
- </td></tr>
783
- </table>
784
- </td></tr>
785
- ${buttonRow}
786
- <tr><td style="padding:22px 24px 24px;font-size:14px;line-height:1.6;color:#374151;">
787
- Regards,<br>ContextNest
788
- </td></tr>
789
- <tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
790
- Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
791
- </td></tr>
792
- </table>
793
- </div>`;
794
- return { subject, text, html };
795
- }
796
- function renderHtml(message, nestName) {
797
- const lead = message.match(/^(:[a-z_]+:)\s*/);
798
- const meta = lead && EVENT[lead[1]] || { icon: "\u{1F514}", color: "#475569" };
799
- const rest = lead ? message.slice(lead[0].length) : message;
800
- const body = escapeHtml(rest).replace(/\*([^*]+)\*/g, "<strong>$1</strong>").replace(/\n/g, "<br>");
801
- const safeNest = escapeHtml(nestName);
802
- return `<div style="background:#f6f8fa;padding:24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
803
- <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
804
- <tr><td style="padding:20px 24px 16px;border-bottom:1px solid #f0f0f0;">
805
- <div style="font-size:15px;font-weight:700;color:#111827;letter-spacing:-0.01em;">ContextNest</div>
806
- <div style="font-size:13px;color:#6b7280;margin-top:2px;">${safeNest}</div>
807
- </td></tr>
808
- <tr><td style="padding:24px;">
809
- <table role="presentation" cellpadding="0" cellspacing="0"><tr>
810
- <td valign="top" style="font-size:22px;line-height:1;padding-right:14px;">${meta.icon}</td>
811
- <td valign="top" style="font-size:15px;line-height:1.55;color:#111827;border-left:3px solid ${meta.color};padding-left:14px;">${body}</td>
812
- </tr></table>
813
- </td></tr>
814
- <tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
815
- Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
816
- </td></tr>
817
- </table>
818
- </div>`;
819
- }
820
-
821
- // src/notify/email.ts
822
- var transporterPromise = null;
823
- var configuredUrl = null;
824
- var warnedOnce2 = false;
825
- var SMTP_TIMEOUT_DEFAULTS = [
826
- ["connectionTimeout", 5e3],
827
- ["greetingTimeout", 5e3],
828
- ["socketTimeout", 1e4]
829
- ];
830
- function withTimeouts(url) {
831
- const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
832
- const missing = SMTP_TIMEOUT_DEFAULTS.filter(
833
- ([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
834
- ).map(([name, ms]) => `${name}=${ms}`);
835
- if (missing.length === 0) return url;
836
- return url + (url.includes("?") ? "&" : "?") + missing.join("&");
837
- }
838
- async function verifySmtp(url) {
839
- let transporter = null;
840
- try {
841
- const nodemailer = await import("nodemailer");
842
- transporter = nodemailer.default.createTransport(withTimeouts(url), {
843
- disableFileAccess: true,
844
- disableUrlAccess: true
845
- });
846
- await transporter.verify();
847
- return { ok: true };
848
- } catch (err) {
849
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
850
- } finally {
851
- try {
852
- transporter?.close?.();
853
- } catch {
854
- }
855
- }
856
- }
857
- async function getTransporter() {
858
- const url = config.SMTP_URL;
859
- if (!url) return null;
860
- if (!transporterPromise || configuredUrl !== url) {
861
- configuredUrl = url;
862
- transporterPromise = import("nodemailer").then(
863
- (nodemailer) => nodemailer.default.createTransport(withTimeouts(url), {
864
- // Per-message defaults (belt-and-braces; also set on each sendMail).
865
- disableFileAccess: true,
866
- disableUrlAccess: true
867
- })
868
- );
869
- transporterPromise.catch(() => {
870
- transporterPromise = null;
871
- configuredUrl = null;
872
- });
873
- }
874
- return transporterPromise;
875
- }
876
- async function notifyEmailForNest(nestId, message, details) {
877
- const from = config.NOTIFY_EMAIL_FROM;
878
- const to = config.NOTIFY_EMAIL_TO;
879
- if (!config.SMTP_URL || !from || !to) return;
880
- try {
881
- const transporter = await getTransporter();
882
- if (!transporter) return;
883
- let subject;
884
- let text;
885
- let html;
886
- if (details) {
887
- ({ subject, text, html } = renderEmail(details));
888
- } else {
889
- let name = nestId;
890
- try {
891
- const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
892
- nestId
893
- ]);
894
- if (row?.name) name = row.name;
895
- } catch {
896
- }
897
- const safeName = name.replace(/[\r\n]+/g, " ").trim();
898
- subject = `ContextNest \xB7 ${safeName}`;
899
- text = renderText(message);
900
- html = renderHtml(message, safeName);
901
- }
902
- await transporter.sendMail({
903
- from,
904
- to,
905
- subject,
906
- text,
907
- html,
908
- disableFileAccess: true,
909
- disableUrlAccess: true
910
- });
911
- } catch (err) {
912
- if (!warnedOnce2) {
913
- warnedOnce2 = true;
914
- console.warn(
915
- "[email] notification failed (suppressing further warnings):",
916
- err instanceof Error ? err.message : err
917
- );
918
- }
919
- }
755
+ async function sendTestNotification(channel) {
756
+ const url = channel === "slack" ? config.SLACK_WEBHOOK_URL : config.MSTEAMS_WEBHOOK_URL;
757
+ const label = channel === "slack" ? "Slack" : "Microsoft Teams";
758
+ if (!url)
759
+ return { ok: false, error: `Save a ${label} webhook URL first, then test.` };
760
+ const ev = {
761
+ kind: "test",
762
+ nestId: "",
763
+ message: "Test notification from ContextNest \u2014 your connector is working."
764
+ };
765
+ return testWebhook(url, formatBody(channel, "ContextNest", ev));
920
766
  }
921
767
 
922
768
  // src/notify/notify.ts
@@ -935,22 +781,46 @@ async function flush(nestId) {
935
781
  if (!buf) return;
936
782
  buffers.delete(nestId);
937
783
  const text = buildText(buf.events.map((e) => e.line));
938
- const details = buf.events.length === 1 ? buf.events[0].details : void 0;
784
+ const emailEvents = buf.events.filter((e) => !e.skipEmail);
785
+ const emailText = buildText(emailEvents.map((e) => e.line));
786
+ const emailDetails = emailEvents.length === 1 ? emailEvents[0].details : void 0;
939
787
  await Promise.allSettled([
940
788
  notifySlackForNest(nestId, text),
941
- notifyEmailForNest(nestId, text, details)
789
+ notifyMsTeamsForNest(nestId, text),
790
+ emailEvents.length > 0 ? notifyEmailForNest(nestId, emailText, emailDetails) : Promise.resolve()
942
791
  ]);
943
792
  }
944
- async function notifyNestEvent(nestId, message, details) {
945
- if (!config.SLACK_WEBHOOK_URL && !config.SMTP_URL) return;
793
+ async function notifyNestEvent(nestId, message, details, opts) {
794
+ if (!config.SLACK_WEBHOOK_URL && !config.MSTEAMS_WEBHOOK_URL && !config.SMTP_URL)
795
+ return;
946
796
  const buf = buffers.get(nestId);
947
797
  if (buf) {
948
- buf.events.push({ line: message, details });
798
+ buf.events.push({ line: message, details, skipEmail: opts?.skipEmail });
949
799
  return;
950
800
  }
951
801
  const timer = setTimeout(() => void flush(nestId), config.NOTIFY_DEBOUNCE_MS);
952
802
  timer.unref?.();
953
- buffers.set(nestId, { events: [{ line: message, details }], timer });
803
+ buffers.set(nestId, {
804
+ events: [{ line: message, details, skipEmail: opts?.skipEmail }],
805
+ timer
806
+ });
807
+ }
808
+
809
+ // src/shared/node-write-lock.ts
810
+ var nodeWriteChains = /* @__PURE__ */ new Map();
811
+ function nodeWriteKey(nestId, nodeId) {
812
+ return `${nestId}::${nodeId}`;
813
+ }
814
+ function withNodeWriteLock(key, fn) {
815
+ const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
816
+ });
817
+ const run = prev.then(fn);
818
+ nodeWriteChains.set(key, run);
819
+ void run.catch(() => {
820
+ }).finally(() => {
821
+ if (nodeWriteChains.get(key) === run) nodeWriteChains.delete(key);
822
+ });
823
+ return run;
954
824
  }
955
825
 
956
826
  // src/governance/safe-publish.ts
@@ -1036,187 +906,270 @@ async function submitForReview(params) {
1036
906
  requestedBy: params.requestedBy,
1037
907
  reviewId: id
1038
908
  });
909
+ try {
910
+ const stewards = await resolveStewardsForNode(params.nestId, params.nodeId);
911
+ const reviewers = [
912
+ ...new Set(
913
+ stewards.filter((s) => s.steward.role === "reviewer").map((s) => s.steward.userEmail.toLowerCase())
914
+ )
915
+ ].filter((email) => email !== params.requestedBy.toLowerCase());
916
+ if (reviewers.length > 0) {
917
+ void sendEmailToRecipient(reviewers, {
918
+ status: "pending_review",
919
+ docTitle: reviewCtx.docTitle,
920
+ path: reviewCtx.path,
921
+ link: reviewCtx.link,
922
+ actor: params.requestedBy,
923
+ version: params.version,
924
+ note: params.note || null
925
+ });
926
+ }
927
+ } catch (err) {
928
+ console.error(
929
+ "[notify] reviewer email fan-out failed",
930
+ params.nestId,
931
+ params.nodeId,
932
+ err
933
+ );
934
+ }
1039
935
  return await getReviewRequest(id);
1040
936
  }
1041
- async function approve(params) {
937
+ async function repointPendingReview(params) {
1042
938
  const db = getDb();
1043
939
  const pending = await db.get(
1044
- "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
940
+ "SELECT id, version FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1045
941
  [params.nestId, params.nodeId]
1046
942
  );
1047
- if (!pending) {
1048
- throw new Error("No pending review for this node");
1049
- }
1050
- if (!params.override) {
1051
- const check = await canUserApprove(
1052
- params.nestId,
1053
- params.nodeId,
1054
- params.approvedBy
943
+ if (!pending || pending.version === params.newVersion) return;
944
+ await db.transaction(async (tx) => {
945
+ await tx.run("UPDATE review_requests SET version = ? WHERE id = ?", [
946
+ params.newVersion,
947
+ pending.id
948
+ ]);
949
+ await tx.run(
950
+ "UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
951
+ [params.nestId, params.nodeId, pending.version]
1055
952
  );
1056
- if (!check.allowed) {
1057
- throw new Error(check.reason);
953
+ await tx.run(
954
+ "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
955
+ [params.nestId, params.nodeId, params.newVersion]
956
+ );
957
+ });
958
+ }
959
+ async function approve(params) {
960
+ return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
961
+ const db = getDb();
962
+ const version = await getCurrentVersion(params.nestId, params.nodeId);
963
+ const pending = await db.get(
964
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
965
+ [params.nestId, params.nodeId]
966
+ );
967
+ if (!pending) {
968
+ throw new Error("No pending review for this node");
1058
969
  }
1059
- }
1060
- await db.run(
1061
- `UPDATE review_requests
970
+ if (!params.override) {
971
+ const check = await canUserApprove(
972
+ params.nestId,
973
+ params.nodeId,
974
+ params.approvedBy
975
+ );
976
+ if (!check.allowed) {
977
+ throw new Error(check.reason);
978
+ }
979
+ }
980
+ await db.run(
981
+ `UPDATE review_requests
1062
982
  SET status = 'approved', resolved_by = ?, resolved_at = ${nowExpr(db)},
1063
983
  resolution_note = ?, is_override = ?
1064
984
  WHERE id = ?`,
1065
- [params.approvedBy, params.note || null, params.override ? 1 : 0, pending.id]
1066
- );
1067
- void dispatchEvent({
1068
- kind: "review_approved",
1069
- nestId: params.nestId,
1070
- subjectId: params.nodeId,
1071
- actor: params.approvedBy,
1072
- message: `*${await describeNode(params.nestId, params.nodeId)}* v${params.version} approved by ${params.approvedBy}`
1073
- });
1074
- await notifyReviewResolved({
1075
- nestId: params.nestId,
1076
- nodeId: params.nodeId,
1077
- status: "approved",
1078
- resolvedBy: params.approvedBy,
1079
- requestedBy: pending.requested_by,
1080
- reviewId: pending.id
1081
- });
1082
- await db.run(
1083
- "UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?",
1084
- [params.nestId, params.nodeId, params.version]
1085
- );
1086
- await db.run(
1087
- insertOrReplace(
1088
- db,
1089
- `INSERT INTO approved_versions (nest_id, node_id, approved_version, approved_by)
1090
- VALUES (?, ?, ?, ?)`,
1091
- ["nest_id", "node_id"],
1092
- `approved_version = excluded.approved_version, approved_by = excluded.approved_by, approved_at = ${nowExpr(db)}`
1093
- ),
1094
- [params.nestId, params.nodeId, params.version, params.approvedBy]
1095
- );
1096
- try {
1097
- const { storage } = await engineCache.get(params.nestId);
1098
- const result = await safePublishDocument(storage, params.nodeId, {
1099
- editedBy: params.approvedBy,
1100
- note: params.note || `Approved review request ${pending.id}`
985
+ [params.approvedBy, params.note || null, params.override ? 1 : 0, pending.id]
986
+ );
987
+ void dispatchEvent({
988
+ kind: "review_approved",
989
+ nestId: params.nestId,
990
+ subjectId: params.nodeId,
991
+ actor: params.approvedBy,
992
+ message: `*${await describeNode(params.nestId, params.nodeId)}* v${version} approved by ${params.approvedBy}`
1101
993
  });
1102
- const engineVersion = result.versionEntry.version;
1103
- if (engineVersion !== params.version) {
1104
- const node = result.node;
1105
- const tags = node.frontmatter.tags || [];
1106
- await createVersion({
1107
- nestId: params.nestId,
1108
- nodeId: params.nodeId,
1109
- version: engineVersion,
1110
- content: node.body || "",
1111
- author: params.approvedBy,
1112
- status: "published",
1113
- tags,
1114
- changeNote: params.note || `Approved review request ${pending.id}`
994
+ await notifyReviewResolved({
995
+ nestId: params.nestId,
996
+ nodeId: params.nodeId,
997
+ status: "approved",
998
+ resolvedBy: params.approvedBy,
999
+ requestedBy: pending.requested_by,
1000
+ reviewId: pending.id
1001
+ });
1002
+ await db.run(
1003
+ "UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?",
1004
+ [params.nestId, params.nodeId, version]
1005
+ );
1006
+ await db.run(
1007
+ insertOrReplace(
1008
+ db,
1009
+ `INSERT INTO approved_versions (nest_id, node_id, approved_version, approved_by)
1010
+ VALUES (?, ?, ?, ?)`,
1011
+ ["nest_id", "node_id"],
1012
+ `approved_version = excluded.approved_version, approved_by = excluded.approved_by, approved_at = ${nowExpr(db)}`
1013
+ ),
1014
+ [params.nestId, params.nodeId, version, params.approvedBy]
1015
+ );
1016
+ try {
1017
+ const { storage } = await engineCache.get(params.nestId);
1018
+ const result = await safePublishDocument(storage, params.nodeId, {
1019
+ editedBy: params.approvedBy,
1020
+ note: params.note || `Approved review request ${pending.id}`
1115
1021
  });
1116
- await setApprovedVersion(
1117
- params.nestId,
1118
- params.nodeId,
1119
- engineVersion,
1120
- params.approvedBy
1022
+ const engineVersion = result.versionEntry.version;
1023
+ if (engineVersion !== version) {
1024
+ const node = result.node;
1025
+ const tags = node.frontmatter.tags || [];
1026
+ await createVersion({
1027
+ nestId: params.nestId,
1028
+ nodeId: params.nodeId,
1029
+ version: engineVersion,
1030
+ content: node.body || "",
1031
+ author: params.approvedBy,
1032
+ status: "published",
1033
+ tags,
1034
+ changeNote: params.note || `Approved review request ${pending.id}`
1035
+ });
1036
+ await setApprovedVersion(
1037
+ params.nestId,
1038
+ params.nodeId,
1039
+ engineVersion,
1040
+ params.approvedBy
1041
+ );
1042
+ }
1043
+ } catch (err) {
1044
+ console.error(
1045
+ `publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
1046
+ err
1121
1047
  );
1122
1048
  }
1123
- } catch (err) {
1124
- console.error(
1125
- `publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
1126
- err
1049
+ const approveCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1050
+ void notifyNestEvent(
1051
+ params.nestId,
1052
+ `:white_check_mark: *${approveCtx.label}* v${version} approved by ${params.approvedBy}`,
1053
+ {
1054
+ status: "approved",
1055
+ docTitle: approveCtx.docTitle,
1056
+ path: approveCtx.path,
1057
+ link: approveCtx.link,
1058
+ actor: params.approvedBy,
1059
+ version
1060
+ },
1061
+ // The verdict emails the submitter directly (below), not the shared
1062
+ // NOTIFY_EMAIL_TO team inbox — skip the team-channel email; Slack/Teams fire.
1063
+ { skipEmail: true }
1127
1064
  );
1128
- }
1129
- const approveCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1130
- void notifyNestEvent(
1131
- params.nestId,
1132
- `:white_check_mark: *${approveCtx.label}* v${params.version} approved by ${params.approvedBy}`,
1133
- {
1134
- status: "approved",
1135
- docTitle: approveCtx.docTitle,
1136
- path: approveCtx.path,
1137
- link: approveCtx.link,
1138
- actor: params.approvedBy,
1139
- version: params.version
1065
+ if (pending.requested_by?.toLowerCase() !== params.approvedBy.toLowerCase()) {
1066
+ void sendEmailToRecipient(pending.requested_by, {
1067
+ status: "approved",
1068
+ docTitle: approveCtx.docTitle,
1069
+ path: approveCtx.path,
1070
+ link: approveCtx.link,
1071
+ actor: params.approvedBy,
1072
+ version,
1073
+ note: params.note || null
1074
+ });
1140
1075
  }
1141
- );
1142
- return await getReviewRequest(pending.id);
1076
+ return await getReviewRequest(pending.id);
1077
+ });
1143
1078
  }
1144
1079
  async function reject(params) {
1145
- const db = getDb();
1146
- const pending = await db.get(
1147
- "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1148
- [params.nestId, params.nodeId]
1149
- );
1150
- if (!pending) {
1151
- throw new Error("No pending review for this node");
1152
- }
1153
- const check = await canUserApprove(
1154
- params.nestId,
1155
- params.nodeId,
1156
- params.rejectedBy
1157
- );
1158
- if (!check.allowed) {
1159
- throw new Error(check.reason);
1160
- }
1161
- await db.run(
1162
- `UPDATE review_requests
1080
+ return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
1081
+ const db = getDb();
1082
+ const version = await getCurrentVersion(params.nestId, params.nodeId);
1083
+ const pending = await db.get(
1084
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1085
+ [params.nestId, params.nodeId]
1086
+ );
1087
+ if (!pending) {
1088
+ throw new Error("No pending review for this node");
1089
+ }
1090
+ const check = await canUserApprove(
1091
+ params.nestId,
1092
+ params.nodeId,
1093
+ params.rejectedBy
1094
+ );
1095
+ if (!check.allowed) {
1096
+ throw new Error(check.reason);
1097
+ }
1098
+ await db.run(
1099
+ `UPDATE review_requests
1163
1100
  SET status = 'rejected', resolved_by = ?, resolved_at = ${nowExpr(db)}, resolution_note = ?
1164
1101
  WHERE id = ?`,
1165
- [params.rejectedBy, params.note, pending.id]
1166
- );
1167
- await db.run(
1168
- "UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
1169
- [params.nestId, params.nodeId, params.version]
1170
- );
1171
- const rejectCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1172
- void dispatchEvent({
1173
- kind: "review_rejected",
1174
- nestId: params.nestId,
1175
- subjectId: params.nodeId,
1176
- actor: params.rejectedBy,
1177
- message: `*${rejectCtx.label}* v${params.version} rejected by ${params.rejectedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
1178
- });
1179
- void notifyNestEvent(
1180
- params.nestId,
1181
- `:x: *${rejectCtx.label}* v${params.version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`,
1182
- {
1183
- status: "rejected",
1184
- docTitle: rejectCtx.docTitle,
1185
- path: rejectCtx.path,
1186
- link: rejectCtx.link,
1102
+ [params.rejectedBy, params.note, pending.id]
1103
+ );
1104
+ await db.run(
1105
+ "UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
1106
+ [params.nestId, params.nodeId, version]
1107
+ );
1108
+ const rejectCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1109
+ void dispatchEvent({
1110
+ kind: "review_rejected",
1111
+ nestId: params.nestId,
1112
+ subjectId: params.nodeId,
1187
1113
  actor: params.rejectedBy,
1188
- version: params.version,
1189
- note: params.note || null
1114
+ message: `*${rejectCtx.label}* v${version} rejected by ${params.rejectedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
1115
+ });
1116
+ void notifyNestEvent(
1117
+ params.nestId,
1118
+ `:x: *${rejectCtx.label}* v${version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`,
1119
+ {
1120
+ status: "rejected",
1121
+ docTitle: rejectCtx.docTitle,
1122
+ path: rejectCtx.path,
1123
+ link: rejectCtx.link,
1124
+ actor: params.rejectedBy,
1125
+ version,
1126
+ note: params.note || null
1127
+ },
1128
+ // Verdict emails the submitter directly (below), not the team inbox.
1129
+ { skipEmail: true }
1130
+ );
1131
+ if (pending.requested_by?.toLowerCase() !== params.rejectedBy.toLowerCase()) {
1132
+ void sendEmailToRecipient(pending.requested_by, {
1133
+ status: "rejected",
1134
+ docTitle: rejectCtx.docTitle,
1135
+ path: rejectCtx.path,
1136
+ link: rejectCtx.link,
1137
+ actor: params.rejectedBy,
1138
+ version,
1139
+ note: params.note || null
1140
+ });
1190
1141
  }
1191
- );
1192
- await notifyReviewResolved({
1193
- nestId: params.nestId,
1194
- nodeId: params.nodeId,
1195
- status: "rejected",
1196
- resolvedBy: params.rejectedBy,
1197
- requestedBy: pending.requested_by,
1198
- reviewId: pending.id
1142
+ await notifyReviewResolved({
1143
+ nestId: params.nestId,
1144
+ nodeId: params.nodeId,
1145
+ status: "rejected",
1146
+ resolvedBy: params.rejectedBy,
1147
+ requestedBy: pending.requested_by,
1148
+ reviewId: pending.id
1149
+ });
1150
+ return await getReviewRequest(pending.id);
1199
1151
  });
1200
- return await getReviewRequest(pending.id);
1201
1152
  }
1202
1153
  async function cancelReview(params) {
1203
- const db = getDb();
1204
- const pending = await db.get(
1205
- "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1206
- [params.nestId, params.nodeId]
1207
- );
1208
- if (!pending) return null;
1209
- await db.run(
1210
- `UPDATE review_requests
1154
+ return withNodeWriteLock(nodeWriteKey(params.nestId, params.nodeId), async () => {
1155
+ const db = getDb();
1156
+ const pending = await db.get(
1157
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1158
+ [params.nestId, params.nodeId]
1159
+ );
1160
+ if (!pending) return null;
1161
+ await db.run(
1162
+ `UPDATE review_requests
1211
1163
  SET status = 'cancelled', resolved_by = ?, resolved_at = ${nowExpr(db)}
1212
1164
  WHERE id = ?`,
1213
- [params.cancelledBy, pending.id]
1214
- );
1215
- await db.run(
1216
- "UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
1217
- [params.nestId, params.nodeId, pending.version]
1218
- );
1219
- return await getReviewRequest(pending.id);
1165
+ [params.cancelledBy, pending.id]
1166
+ );
1167
+ await db.run(
1168
+ "UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
1169
+ [params.nestId, params.nodeId, pending.version]
1170
+ );
1171
+ return await getReviewRequest(pending.id);
1172
+ });
1220
1173
  }
1221
1174
  async function getReviewQueue(params) {
1222
1175
  const db = getDb();
@@ -1322,9 +1275,11 @@ function rowToReviewRequest(row) {
1322
1275
 
1323
1276
  export {
1324
1277
  safePublishDocument,
1278
+ stripUndefinedDeep,
1325
1279
  listWatchers,
1326
1280
  addWatcher,
1327
1281
  removeWatcher,
1282
+ notifyNestInvite,
1328
1283
  listNotifications,
1329
1284
  markNotificationsRead,
1330
1285
  resolveCallerEmail,
@@ -1340,9 +1295,12 @@ export {
1340
1295
  envValues,
1341
1296
  isSafeConnectorUrl,
1342
1297
  dispatchEvent,
1343
- verifySmtp,
1298
+ sendTestNotification,
1344
1299
  notifyNestEvent,
1300
+ nodeWriteKey,
1301
+ withNodeWriteLock,
1345
1302
  submitForReview,
1303
+ repointPendingReview,
1346
1304
  approve,
1347
1305
  reject,
1348
1306
  cancelReview,