@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.
@@ -13,8 +13,9 @@ import {
13
13
  } from "./chunk-XQ46F76G.js";
14
14
  import {
15
15
  config,
16
- getDb
17
- } from "./chunk-3M7677XW.js";
16
+ getDb,
17
+ isEmailish
18
+ } from "./chunk-I5KYGMIT.js";
18
19
  import {
19
20
  ANON_USER_ID
20
21
  } from "./chunk-SLTQACJW.js";
@@ -138,28 +139,31 @@ import {
138
139
  } from "@promptowl/contextnest-engine";
139
140
 
140
141
  // src/nests/service.ts
141
- import { rmSync as rmSync2, mkdirSync as mkdirSync2 } from "fs";
142
- import { v4 as uuid } from "uuid";
142
+ import { rmSync as rmSync2, mkdirSync } from "fs";
143
+ import { v4 as uuid2 } from "uuid";
143
144
  import { NestStorage } from "@promptowl/contextnest-engine";
144
145
 
145
146
  // src/fs/vault-import.ts
146
- import { mkdirSync, writeFileSync, rmSync } from "fs";
147
+ import { rmSync } from "fs";
148
+ import { writeFile, mkdir } from "fs/promises";
147
149
  import { join as join2, dirname, isAbsolute } from "path";
148
150
  function isSkippedSegment(segment) {
149
151
  return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
150
152
  }
153
+ var ALLOWED_DOT_DIRS = /* @__PURE__ */ new Set([".versions", ".context"]);
151
154
  function safeSegments(rel) {
152
155
  if (!rel || isAbsolute(rel)) return null;
153
156
  const segs = rel.split(/[\\/]/).filter(Boolean);
154
157
  if (!segs.length) return null;
155
158
  for (const s of segs) {
156
159
  if (s === "..") return null;
157
- if (s !== ".versions" && isSkippedSegment(s)) return null;
160
+ if (!ALLOWED_DOT_DIRS.has(s) && isSkippedSegment(s)) return null;
158
161
  }
159
162
  return segs;
160
163
  }
161
- function writeImportedFiles(dest, files) {
162
- let written = 0;
164
+ var WRITE_CONCURRENCY = 32;
165
+ async function writeImportedFiles(dest, files) {
166
+ const targets = [];
163
167
  const skipped = [];
164
168
  for (const f of files) {
165
169
  const segs = safeSegments(f?.path || "");
@@ -167,10 +171,19 @@ function writeImportedFiles(dest, files) {
167
171
  skipped.push(f?.path || "(empty)");
168
172
  continue;
169
173
  }
170
- const full = join2(dest, ...segs);
171
- mkdirSync(dirname(full), { recursive: true });
172
- writeFileSync(full, f.content ?? "", "utf-8");
173
- written++;
174
+ targets.push({ full: join2(dest, ...segs), content: f.content ?? "" });
175
+ }
176
+ const dirs = new Set(targets.map((t) => dirname(t.full)));
177
+ await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
178
+ let written = 0;
179
+ for (let i = 0; i < targets.length; i += WRITE_CONCURRENCY) {
180
+ const batch = targets.slice(i, i + WRITE_CONCURRENCY);
181
+ await Promise.all(
182
+ batch.map(async (t) => {
183
+ await writeFile(t.full, t.content, "utf-8");
184
+ written++;
185
+ })
186
+ );
174
187
  }
175
188
  console.log(
176
189
  `[import] writeImportedFiles: received=${files.length} written=${written} skipped=${skipped.length}`
@@ -627,412 +640,645 @@ async function _validateLicenseImpl(forceFresh) {
627
640
  }
628
641
  }
629
642
 
630
- // src/nests/service.ts
631
- async function isImportedNest(nestId) {
632
- const row = await getDb().get(
633
- "SELECT is_imported FROM nests WHERE id = ?",
634
- [nestId]
635
- );
636
- return !!row?.is_imported;
643
+ // src/governance/teams-service.ts
644
+ import { v4 as uuid } from "uuid";
645
+
646
+ // src/notify/email-render.ts
647
+ function escapeHtml(s) {
648
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
649
+ }
650
+ var EVENT = {
651
+ ":white_check_mark:": { icon: "\u2705", color: "#16a34a" },
652
+ ":x:": { icon: "\u274C", color: "#dc2626" },
653
+ ":memo:": { icon: "\u{1F4DD}", color: "#d97706" },
654
+ ":key:": { icon: "\u{1F511}", color: "#2563eb" }
655
+ };
656
+ var ALL_EMOJI = Object.fromEntries(
657
+ Object.entries(EVENT).map(([code, m]) => [code, m.icon])
658
+ );
659
+ function renderText(message) {
660
+ return message.replace(/:[a-z_]+:/g, (m) => ALL_EMOJI[m] ?? "").replace(/\*/g, "").replace(/[ \t]{2,}/g, " ").trim();
661
+ }
662
+ var STATUS_META = {
663
+ approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
664
+ rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
665
+ pending_review: {
666
+ subjectWord: "awaiting review",
667
+ label: "Pending review",
668
+ color: "#d97706"
669
+ },
670
+ shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" },
671
+ invited: { subjectWord: "invited you", label: "Invitation", color: "#2563eb" },
672
+ steward: { subjectWord: "steward", label: "Steward", color: "#7c3aed" }
673
+ };
674
+ var cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
675
+ var detailRow = (label, valueHtml) => `<tr>
676
+ <td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
677
+ <td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
678
+ </tr>`;
679
+ var plainRow = (label, value) => ({
680
+ label,
681
+ text: value,
682
+ html: escapeHtml(value)
683
+ });
684
+ var governanceRows = (x) => [
685
+ plainRow("Path", x.d.path),
686
+ { label: "Status", text: x.meta.label, html: x.statusBadge },
687
+ ...x.d.version != null ? [plainRow("Version", `v${x.d.version}`)] : []
688
+ ];
689
+ var TEMPLATES = {
690
+ approved: {
691
+ heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
692
+ detailsHeader: "Document details",
693
+ linkLabel: "Open document",
694
+ lineT: (x) => `${x.title}${x.vT} has been approved${x.byT}${x.noteT}.`,
695
+ lineH: (x) => `${x.doc}${x.vH} has been approved${x.byH}${x.noteH}.`,
696
+ rows: governanceRows
697
+ },
698
+ rejected: {
699
+ heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
700
+ detailsHeader: "Document details",
701
+ linkLabel: "Open document",
702
+ lineT: (x) => `${x.title}${x.vT} has been rejected${x.byT}${x.noteT}.`,
703
+ lineH: (x) => `${x.doc}${x.vH} has been rejected${x.byH}${x.noteH}.`,
704
+ rows: governanceRows
705
+ },
706
+ pending_review: {
707
+ heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
708
+ detailsHeader: "Document details",
709
+ linkLabel: "Open document",
710
+ lineT: (x) => `A review has been requested for ${x.title}${x.vT}${x.byT}${x.noteT}.`,
711
+ lineH: (x) => `A review has been requested for ${x.doc}${x.vH}${x.byH}${x.noteH}.`,
712
+ rows: governanceRows
713
+ },
714
+ shared: {
715
+ heading: (x) => `You've been added to ${x.title}`,
716
+ detailsHeader: "Details",
717
+ linkLabel: "Open nest",
718
+ lineT: (x) => `You've been given ${x.d.permission || "collaborator"} access to ${x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Open it below to start collaborating.`,
719
+ lineH: (x) => `You've been given <strong>${escapeHtml(x.d.permission || "collaborator")}</strong> access to ${x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Open it below to start collaborating.`,
720
+ rows: (x) => [
721
+ plainRow("Nest", x.d.path),
722
+ ...x.d.permission ? [plainRow("Role", cap(x.d.permission))] : []
723
+ ]
724
+ },
725
+ invited: {
726
+ heading: (x) => `You've been invited to ${x.title}`,
727
+ detailsHeader: "Sign-in details",
728
+ linkLabel: "Sign in",
729
+ lineT: (x) => `You've been invited to ${x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Sign in with your email and the temporary password below, then choose your own password.`,
730
+ lineH: (x) => `You've been invited to ${x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Sign in with your email and the temporary password below, then choose your own password.`,
731
+ rows: (x) => [
732
+ plainRow("Email", x.d.actor || ""),
733
+ ...x.d.tempPassword ? [
734
+ {
735
+ label: "Temporary password",
736
+ text: x.d.tempPassword,
737
+ html: x.codeValue(x.d.tempPassword)
738
+ }
739
+ ] : []
740
+ ]
741
+ },
742
+ steward: {
743
+ heading: (x) => `You're now a steward of ${x.title}`,
744
+ detailsHeader: "Details",
745
+ linkLabel: "Open nest",
746
+ lineT: (x) => `You've been assigned as a ${x.d.permission || "reviewer"} steward of ${x.d.scopeLabel || x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Open the nest below to start reviewing.`,
747
+ lineH: (x) => `You've been assigned as a <strong>${escapeHtml(x.d.permission || "reviewer")}</strong> steward of ${x.d.scopeLabel ? escapeHtml(x.d.scopeLabel) : x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Open the nest below to start reviewing.`,
748
+ rows: (x) => [
749
+ plainRow("Nest", x.d.path),
750
+ ...x.d.permission ? [plainRow("Role", cap(x.d.permission))] : [],
751
+ ...x.d.scopeLabel ? [plainRow("Scope", cap(x.d.scopeLabel))] : []
752
+ ]
753
+ }
754
+ };
755
+ function renderEmail(d) {
756
+ const meta = STATUS_META[d.status];
757
+ const t = TEMPLATES[d.status];
758
+ const title = d.docTitle.replace(/[\r\n]+/g, " ").trim();
759
+ const vLabel = d.version != null ? `v${d.version}` : "";
760
+ const ctx = {
761
+ d,
762
+ title,
763
+ doc: `<strong>${escapeHtml(title)}</strong>`,
764
+ byT: d.actor ? ` by ${d.actor}` : "",
765
+ byH: d.actor ? ` by ${escapeHtml(d.actor)}` : "",
766
+ noteT: d.note ? ` with the note: "${d.note}"` : "",
767
+ noteH: d.note ? ` with the note: &ldquo;${escapeHtml(d.note)}&rdquo;` : "",
768
+ vT: vLabel ? ` ${vLabel}` : "",
769
+ vH: vLabel ? ` ${vLabel}` : "",
770
+ meta,
771
+ statusBadge: `<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>`,
772
+ codeValue: (v) => `<span style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:#111827;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${escapeHtml(v)}</span>`
773
+ };
774
+ const heading = t.heading(ctx).replace(/[\r\n]+/g, " ").trim();
775
+ const subject = heading;
776
+ const detailsHeader = t.detailsHeader;
777
+ const linkLabel = t.linkLabel;
778
+ const lineH = t.lineH(ctx);
779
+ const rows = t.rows(ctx);
780
+ const detailLines = [
781
+ `${detailsHeader}:`,
782
+ ...rows.map((r) => ` ${r.label}: ${r.text}`),
783
+ ...d.link ? [` ${linkLabel}: ${d.link}`] : []
784
+ ];
785
+ const text = ["Hi,", "", t.lineT(ctx), "", ...detailLines, "", "Regards,", "Community Nest"].join("\n");
786
+ const detailRows = rows.map((r) => detailRow(r.label, r.html)).join("");
787
+ const buttonRow = d.link ? `<tr><td style="padding:20px 24px 0;">
788
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
789
+ <td bgcolor="${meta.color}" style="border-radius:6px;">
790
+ <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>
791
+ </td>
792
+ </tr></table>
793
+ </td></tr>` : "";
794
+ const html = `<div style="margin:0;padding:24px;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;">
795
+ <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;">
796
+ <tr><td style="padding:20px 24px 0;">
797
+ <div style="font-size:12px;font-weight:700;color:#6b7280;letter-spacing:0.06em;text-transform:uppercase;">ContextNest</div>
798
+ </td></tr>
799
+ <tr><td style="padding:10px 24px 0;">
800
+ <h1 style="margin:0;font-size:19px;line-height:1.35;font-weight:700;color:#111827;">${escapeHtml(heading)}</h1>
801
+ </td></tr>
802
+ <tr><td style="padding:16px 24px 0;font-size:14px;line-height:1.6;color:#374151;">
803
+ Hi,<br><br>${lineH}
804
+ </td></tr>
805
+ <tr><td style="padding:20px 24px 0;">
806
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #eef0f2;border-radius:8px;">
807
+ <tr><td style="padding:12px 16px;">
808
+ <div style="font-size:11px;font-weight:600;color:#9ca3af;letter-spacing:0.04em;text-transform:uppercase;padding-bottom:4px;">${detailsHeader}</div>
809
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${detailRows}</table>
810
+ </td></tr>
811
+ </table>
812
+ </td></tr>
813
+ ${buttonRow}
814
+ <tr><td style="padding:22px 24px 24px;font-size:14px;line-height:1.6;color:#374151;">
815
+ Regards,<br>Community Nest
816
+ </td></tr>
817
+ </table>
818
+ </div>`;
819
+ return { subject, text, html };
820
+ }
821
+ function renderHtml(message, nestName2) {
822
+ const lead = message.match(/^(:[a-z_]+:)\s*/);
823
+ const meta = lead && EVENT[lead[1]] || { icon: "\u{1F514}", color: "#475569" };
824
+ const rest = lead ? message.slice(lead[0].length) : message;
825
+ const body = escapeHtml(rest).replace(/\*([^*]+)\*/g, "<strong>$1</strong>").replace(/\n/g, "<br>");
826
+ const safeNest = escapeHtml(nestName2);
827
+ return `<div style="background:#f6f8fa;padding:24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
828
+ <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;">
829
+ <tr><td style="padding:20px 24px 16px;border-bottom:1px solid #f0f0f0;">
830
+ <div style="font-size:15px;font-weight:700;color:#111827;letter-spacing:-0.01em;">ContextNest</div>
831
+ <div style="font-size:13px;color:#6b7280;margin-top:2px;">${safeNest}</div>
832
+ </td></tr>
833
+ <tr><td style="padding:24px;">
834
+ <table role="presentation" cellpadding="0" cellspacing="0"><tr>
835
+ <td valign="top" style="font-size:22px;line-height:1;padding-right:14px;">${meta.icon}</td>
836
+ <td valign="top" style="font-size:15px;line-height:1.55;color:#111827;border-left:3px solid ${meta.color};padding-left:14px;">${body}</td>
837
+ </tr></table>
838
+ </td></tr>
839
+ </table>
840
+ </div>`;
637
841
  }
638
- function toSlug(name) {
639
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
842
+
843
+ // src/notify/email.ts
844
+ var transporterPromise = null;
845
+ var configuredUrl = null;
846
+ var warnedOnce = false;
847
+ var SMTP_TIMEOUT_DEFAULTS = [
848
+ ["connectionTimeout", 5e3],
849
+ ["greetingTimeout", 5e3],
850
+ ["socketTimeout", 1e4]
851
+ ];
852
+ function withTimeouts(url) {
853
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
854
+ const missing = SMTP_TIMEOUT_DEFAULTS.filter(
855
+ ([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
856
+ ).map(([name, ms]) => `${name}=${ms}`);
857
+ if (missing.length === 0) return url;
858
+ return url + (url.includes("?") ? "&" : "?") + missing.join("&");
859
+ }
860
+ var POOL_DEFAULTS = [
861
+ ["pool", "true"],
862
+ ["maxConnections", "1"],
863
+ ["rateDelta", "1000"],
864
+ ["rateLimit", "8"]
865
+ // messages per second
866
+ ];
867
+ function withPool(url) {
868
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
869
+ const missing = POOL_DEFAULTS.filter(
870
+ ([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
871
+ ).map(([name, val]) => `${name}=${val}`);
872
+ if (missing.length === 0) return url;
873
+ return url + (url.includes("?") ? "&" : "?") + missing.join("&");
874
+ }
875
+ async function verifySmtp(url) {
876
+ let transporter = null;
877
+ try {
878
+ const nodemailer = await import("nodemailer");
879
+ transporter = nodemailer.default.createTransport(withTimeouts(url), {
880
+ disableFileAccess: true,
881
+ disableUrlAccess: true
882
+ });
883
+ await transporter.verify();
884
+ return { ok: true };
885
+ } catch (err) {
886
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
887
+ } finally {
888
+ try {
889
+ transporter?.close?.();
890
+ } catch {
891
+ }
892
+ }
640
893
  }
641
- async function uniqueNestName(userId, baseName) {
642
- const db = getDb();
643
- let candidate = baseName;
644
- let n = 0;
645
- while (n < 1e3) {
646
- const slug = toSlug(candidate);
647
- const hit = await db.get(
648
- "SELECT 1 FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
649
- [userId, slug]
894
+ async function getTransporter() {
895
+ const url = config.SMTP_URL;
896
+ if (!url) return null;
897
+ if (!transporterPromise || configuredUrl !== url) {
898
+ configuredUrl = url;
899
+ transporterPromise = import("nodemailer").then(
900
+ (nodemailer) => (
901
+ // withPool connection pool + per-second rate limit, so a burst queues
902
+ // instead of tripping the provider's "too many emails" 550.
903
+ nodemailer.default.createTransport(withPool(withTimeouts(url)), {
904
+ // Per-message defaults (belt-and-braces; also set on each sendMail).
905
+ disableFileAccess: true,
906
+ disableUrlAccess: true
907
+ })
908
+ )
650
909
  );
651
- if (!hit) return candidate;
652
- n++;
653
- candidate = `${baseName} (${n})`;
910
+ transporterPromise.catch(() => {
911
+ transporterPromise = null;
912
+ configuredUrl = null;
913
+ });
654
914
  }
655
- return candidate;
656
- }
657
- async function isStewardshipEnabled(nestId) {
658
- const db = getDb();
659
- const row = await db.get(
660
- "SELECT stewardship_enabled FROM nests WHERE id = ?",
661
- [nestId]
662
- );
663
- return !!row?.stewardship_enabled;
664
- }
665
- async function setStewardshipEnabled(nestId, enabled) {
666
- const db = getDb();
667
- await db.run("UPDATE nests SET stewardship_enabled = ? WHERE id = ?", [
668
- enabled ? 1 : 0,
669
- nestId
670
- ]);
915
+ return transporterPromise;
671
916
  }
672
- async function nestAllowsSelfApprove(nestId) {
673
- const db = getDb();
674
- const row = await db.get(
675
- "SELECT allow_self_approve FROM nests WHERE id = ?",
676
- [nestId]
677
- );
678
- return !!row?.allow_self_approve;
917
+ async function notifyEmailForNest(nestId, message, details) {
918
+ const from = config.NOTIFY_EMAIL_FROM;
919
+ const to = config.NOTIFY_EMAIL_TO;
920
+ if (!config.SMTP_URL || !from || !to) return;
921
+ try {
922
+ const transporter = await getTransporter();
923
+ if (!transporter) return;
924
+ let subject;
925
+ let text;
926
+ let html;
927
+ if (details) {
928
+ ({ subject, text, html } = renderEmail(details));
929
+ } else {
930
+ let name = nestId;
931
+ try {
932
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
933
+ nestId
934
+ ]);
935
+ if (row?.name) name = row.name;
936
+ } catch {
937
+ }
938
+ const safeName = name.replace(/[\r\n]+/g, " ").trim();
939
+ subject = `ContextNest \xB7 ${safeName}`;
940
+ text = renderText(message);
941
+ html = renderHtml(message, safeName);
942
+ }
943
+ await transporter.sendMail({
944
+ from,
945
+ to,
946
+ subject,
947
+ text,
948
+ html,
949
+ disableFileAccess: true,
950
+ disableUrlAccess: true
951
+ });
952
+ } catch (err) {
953
+ if (!warnedOnce) {
954
+ warnedOnce = true;
955
+ console.warn(
956
+ "[email] notification failed (suppressing further warnings):",
957
+ err instanceof Error ? err.message : err
958
+ );
959
+ }
960
+ }
679
961
  }
680
- async function setAllowSelfApprove(nestId, allow) {
681
- const db = getDb();
682
- await db.run("UPDATE nests SET allow_self_approve = ? WHERE id = ?", [
683
- allow ? 1 : 0,
684
- nestId
685
- ]);
962
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
963
+ function recipientEnvelope(to, from) {
964
+ const recipients = Array.isArray(to) ? to : [to];
965
+ return recipients.length > 1 ? { to: from, bcc: recipients } : { to: recipients[0] };
686
966
  }
687
- async function disableStewardshipAndWipeGovernance(nestId) {
688
- const db = getDb();
689
- return db.transaction(async (tx) => {
690
- const id = nestId;
691
- const stewards = (await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id])).changes;
692
- const pendingReviews = (await tx.run(
693
- "DELETE FROM review_requests WHERE nest_id = ? AND status = 'pending'",
694
- [id]
695
- )).changes;
696
- await tx.run("UPDATE nests SET stewardship_enabled = 0 WHERE id = ?", [id]);
697
- return { stewards, pendingReviews };
698
- });
967
+ function isRateLimited(err) {
968
+ const msg = String(err?.message ?? err);
969
+ const code = Number(err?.responseCode);
970
+ return /too many|rate limit|throttl|try again|4\.7\.\d|too many emails/i.test(msg) || code === 421 || code === 429 || code === 450 || code === 451 || code === 550 && /too many|rate/i.test(msg);
699
971
  }
700
- async function renameNest(nestId, patch) {
701
- const db = getDb();
702
- const existing = await db.get("SELECT * FROM nests WHERE id = ?", [
703
- nestId
704
- ]);
705
- if (!existing) {
706
- throw new ValidationError("Nest not found");
972
+ async function sendMailWithRetry(transporter, msg, attempts = 5) {
973
+ for (let i = 0; ; i++) {
974
+ try {
975
+ return await transporter.sendMail(msg);
976
+ } catch (err) {
977
+ if (i >= attempts - 1 || !isRateLimited(err)) throw err;
978
+ const wait = 1e3 * 2 ** i;
979
+ await sleep(wait);
980
+ }
707
981
  }
708
- const updates = [];
709
- const params = [];
710
- if (patch.name !== void 0) {
711
- const trimmed = patch.name.trim();
712
- if (!trimmed) throw new ValidationError("Name cannot be empty");
713
- const slug = toSlug(trimmed);
714
- if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
715
- const conflict = await db.get(
716
- "SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1",
717
- [existing.user_id, slug, nestId]
718
- );
719
- if (conflict) {
720
- throw new ValidationError(
721
- `Another nest already uses this name. Pick a different one.`
982
+ }
983
+ async function sendEmailToRecipient(to, details) {
984
+ const from = config.NOTIFY_EMAIL_FROM;
985
+ const hasRecipient = Array.isArray(to) ? to.length > 0 : !!to;
986
+ if (!config.SMTP_URL || !from || !hasRecipient) return;
987
+ try {
988
+ const transporter = await getTransporter();
989
+ if (!transporter) return;
990
+ const { subject, text, html } = renderEmail(details);
991
+ await sendMailWithRetry(transporter, {
992
+ from,
993
+ ...recipientEnvelope(to, from),
994
+ subject,
995
+ text,
996
+ html,
997
+ disableFileAccess: true,
998
+ disableUrlAccess: true
999
+ });
1000
+ } catch (err) {
1001
+ if (!warnedOnce) {
1002
+ warnedOnce = true;
1003
+ console.warn(
1004
+ "[email] notification failed (suppressing further warnings):",
1005
+ err instanceof Error ? err.message : err
722
1006
  );
723
1007
  }
724
- updates.push("name = ?", "slug = ?");
725
- params.push(trimmed, slug);
726
- }
727
- if (patch.description !== void 0) {
728
- updates.push("description = ?");
729
- params.push(patch.description || null);
730
- }
731
- if (updates.length === 0) {
732
- return existing;
733
1008
  }
734
- params.push(nestId);
735
- await db.run(
736
- `UPDATE nests SET ${updates.join(", ")} WHERE id = ?`,
737
- params
738
- );
739
- return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]);
740
- }
741
- async function createNest(userId, name, description) {
742
- const id = uuid();
743
- const slug = toSlug(name);
744
- const db = getDb();
745
- const visibility = userId === ANON_USER_ID ? "public" : "private";
746
- await db.run(
747
- "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)",
748
- [id, userId, name, slug, description || null, visibility]
749
- );
750
- const path = resolveNestPath(id);
751
- mkdirSync2(path, { recursive: true });
752
- const storage = new NestStorage(path);
753
- await storage.init(name);
754
- trackEvent("nest.create", { nestId: id, userId });
755
- return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
756
1009
  }
757
- async function importNest(userId, name, files) {
758
- const nm = (name || "").trim();
759
- if (!nm) {
760
- throw new ValidationError("name is required");
761
- }
762
- if (!Array.isArray(files)) {
763
- throw new ValidationError("files are required");
764
- }
765
- const db = getDb();
766
- const id = uuid();
767
- const slug = toSlug(nm);
768
- const visibility = userId === ANON_USER_ID ? "public" : "private";
769
- const dest = resolveNestPath(id);
770
- mkdirSync2(dest, { recursive: true });
1010
+
1011
+ // src/governance/teams-service.ts
1012
+ var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
1013
+ function parseMembers(json) {
1014
+ if (!json) return [];
771
1015
  try {
772
- writeImportedFiles(dest, files);
773
- } catch (err) {
774
- console.error("[nests] import write failed", dest, err);
775
- discardImportDir(dest);
776
- throw new ValidationError("Failed to import folder");
1016
+ const parsed = JSON.parse(json);
1017
+ return Array.isArray(parsed) ? parsed : [];
1018
+ } catch {
1019
+ return [];
777
1020
  }
778
- await db.run(
779
- "INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)",
780
- [id, userId, nm, slug, null, visibility]
781
- );
782
- trackEvent("nest.import", { nestId: id, userId });
783
- return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
784
1021
  }
785
- async function listNests(userId) {
1022
+ function rowToTeam(row) {
1023
+ return {
1024
+ id: row.id,
1025
+ name: row.name,
1026
+ owner_id: row.owner_id,
1027
+ members: parseMembers(row.members),
1028
+ source: row.source ?? null,
1029
+ external_id: row.external_id ?? null,
1030
+ created_at: row.created_at,
1031
+ updated_at: row.updated_at
1032
+ };
1033
+ }
1034
+ async function enrichTeam(team) {
786
1035
  const db = getDb();
787
- if (userId === ANON_USER_ID) {
788
- return await db.all(
789
- "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
790
- [ANON_USER_ID]
1036
+ const ids = team.members.map((m) => m.userId);
1037
+ const invited = /* @__PURE__ */ new Map();
1038
+ if (ids.length) {
1039
+ const rows = await db.all(
1040
+ `SELECT id, is_invited FROM users WHERE id IN (${ids.map(() => "?").join(",")})`,
1041
+ ids
791
1042
  );
1043
+ for (const r of rows) invited.set(r.id, r.is_invited);
792
1044
  }
793
- const includeAnon = config.AUTH_MODE === "open" || await isLicenseAdminUserId(userId);
794
- if (!includeAnon) {
795
- return await db.all(
796
- "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
797
- [userId]
1045
+ const owner = await db.get("SELECT email FROM users WHERE id = ?", [
1046
+ team.owner_id
1047
+ ]);
1048
+ return {
1049
+ ...team,
1050
+ owner_email: owner?.email,
1051
+ // Unknown id (deleted user) → treat as not registered.
1052
+ members: team.members.map((m) => ({
1053
+ ...m,
1054
+ registered: (invited.get(m.userId) ?? 1) === 0
1055
+ }))
1056
+ };
1057
+ }
1058
+ function assertValidRole(role) {
1059
+ if (!VALID_TEAM_ROLES.includes(role)) {
1060
+ throw new ValidationError(
1061
+ "role must be admin, editor, or viewer"
798
1062
  );
799
1063
  }
800
- return await db.all(
801
- "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC",
802
- [userId, ANON_USER_ID]
803
- );
804
1064
  }
805
- async function nestsSharedViaTeams(userId) {
1065
+ async function loadTeamRow(teamId) {
806
1066
  const db = getDb();
807
- const teams = await db.all(
808
- "SELECT id, members FROM teams WHERE members LIKE ?",
809
- [`%${userId}%`]
810
- );
811
- const myTeamIds = /* @__PURE__ */ new Set();
812
- for (const t of teams) {
813
- try {
814
- const members = JSON.parse(t.members || "[]");
815
- if (Array.isArray(members) && members.some((m) => m?.userId === userId)) {
816
- myTeamIds.add(t.id);
817
- }
818
- } catch {
819
- }
820
- }
821
- if (myTeamIds.size === 0) return [];
822
- const ids = [...myTeamIds];
823
- const candidates = await db.all(
824
- `SELECT * FROM nests
825
- WHERE user_id != ?
826
- AND (${ids.map(() => "shared_teams LIKE ?").join(" OR ")})`,
827
- [userId, ...ids.map((id) => `%${id}%`)]
828
- );
829
- return candidates.filter((n) => {
830
- try {
831
- const refs = JSON.parse(n.shared_teams || "[]");
832
- return Array.isArray(refs) && refs.some((r) => myTeamIds.has(r?.teamId));
833
- } catch {
834
- return false;
835
- }
836
- });
1067
+ const row = await db.get("SELECT * FROM teams WHERE id = ?", [
1068
+ teamId
1069
+ ]);
1070
+ if (!row) throw new NotFoundError("Team not found");
1071
+ return row;
837
1072
  }
838
- async function listSharedNests(userId) {
1073
+ var ROLE_RANK = {
1074
+ viewer: 1,
1075
+ editor: 2,
1076
+ admin: 3
1077
+ };
1078
+ var OWNER_RANK = 4;
1079
+ function managementRank(row, callerUserId, allowAdmin) {
1080
+ if (row.owner_id === callerUserId || allowAdmin) return OWNER_RANK;
1081
+ const m = parseMembers(row.members).find((x) => x.userId === callerUserId);
1082
+ return m ? ROLE_RANK[m.role] ?? 0 : 0;
1083
+ }
1084
+ async function createTeam(params) {
1085
+ const name = params.name?.trim();
1086
+ if (!name) throw new ValidationError("Team name is required");
839
1087
  const db = getDb();
840
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [
841
- userId
842
- ]);
843
- const isServerAdmin = await isLicenseAdminUserId(userId) || (caller?.email ? isSuperAdmin(caller.email) : false);
844
- if (isServerAdmin) {
845
- return await db.all(
846
- "SELECT * FROM nests WHERE user_id != ? ORDER BY created_at DESC",
847
- [userId]
848
- );
849
- }
850
- const direct = await db.all(
851
- `SELECT DISTINCT n.* FROM nests n
852
- LEFT JOIN nest_collaborators nc
853
- ON nc.nest_id = n.id AND nc.user_id = ?
854
- LEFT JOIN users u ON u.id = ?
855
- LEFT JOIN stewards s
856
- ON s.nest_id = n.id AND s.is_active = 1
857
- AND LOWER(s.user_email) = LOWER(u.email)
858
- WHERE n.user_id != ?
859
- AND (nc.user_id IS NOT NULL OR s.id IS NOT NULL)
860
- ORDER BY n.created_at DESC`,
861
- [userId, userId, userId]
862
- );
863
- const seen = new Set(direct.map((n) => n.id));
864
- for (const n of await nestsSharedViaTeams(userId)) {
865
- if (!seen.has(n.id)) {
866
- direct.push(n);
867
- seen.add(n.id);
868
- }
869
- }
870
- direct.sort(
871
- (a, b) => a.created_at === b.created_at ? 0 : a.created_at < b.created_at ? 1 : -1
1088
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1089
+ const id = uuid();
1090
+ await db.run(
1091
+ "INSERT INTO teams (id, name, owner_id, members, source, external_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1092
+ [
1093
+ id,
1094
+ name,
1095
+ params.ownerId,
1096
+ "[]",
1097
+ params.source ?? null,
1098
+ params.externalId ?? null,
1099
+ now,
1100
+ now
1101
+ ]
872
1102
  );
873
- return direct;
1103
+ return enrichTeam(rowToTeam(await loadTeamRow(id)));
874
1104
  }
875
- async function listPublicNests(userId) {
1105
+ async function findTeamByExternalId(params) {
876
1106
  const db = getDb();
877
- return await db.all(
878
- `SELECT n.* FROM nests n
879
- WHERE n.visibility = 'public'
880
- AND n.user_id != ?
881
- AND NOT EXISTS (
882
- SELECT 1 FROM nest_collaborators nc
883
- WHERE nc.nest_id = n.id AND nc.user_id = ?
884
- )
885
- ORDER BY n.created_at DESC`,
886
- [userId, userId]
1107
+ const row = await db.get(
1108
+ "SELECT * FROM teams WHERE owner_id = ? AND source = ? AND external_id = ?",
1109
+ [params.ownerId, params.source, params.externalId]
887
1110
  );
1111
+ return row ? enrichTeam(rowToTeam(row)) : null;
888
1112
  }
889
- async function getNest(nestId) {
1113
+ async function listTeamsForUser(userId) {
890
1114
  const db = getDb();
891
- return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]) || null;
1115
+ const rows = await db.all(
1116
+ "SELECT * FROM teams WHERE owner_id = ? OR members LIKE ? ORDER BY created_at",
1117
+ [userId, `%${userId}%`]
1118
+ );
1119
+ const mine = rows.filter(
1120
+ (r) => r.owner_id === userId || parseMembers(r.members).some((m) => m.userId === userId)
1121
+ );
1122
+ return Promise.all(mine.map((r) => enrichTeam(rowToTeam(r))));
892
1123
  }
893
- async function deleteNest(nestId) {
1124
+ async function getTeam(teamId) {
1125
+ return enrichTeam(rowToTeam(await loadTeamRow(teamId)));
1126
+ }
1127
+ async function renameTeam(params) {
1128
+ const name = params.name?.trim();
1129
+ if (!name) throw new ValidationError("Team name is required");
1130
+ const row = await loadTeamRow(params.teamId);
1131
+ if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1132
+ throw new ForbiddenError("Only a team admin or the owner can rename this team.");
1133
+ }
894
1134
  const db = getDb();
895
1135
  await db.transaction(async (tx) => {
896
- const id = nestId;
897
- await tx.run("DELETE FROM approved_versions WHERE nest_id = ?", [id]);
898
- await tx.run("DELETE FROM node_versions WHERE nest_id = ?", [id]);
899
- await tx.run("DELETE FROM review_requests WHERE nest_id = ?", [id]);
900
- await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id]);
901
- await tx.run("DELETE FROM nest_collaborators WHERE nest_id = ?", [id]);
902
- try {
903
- await tx.run("DELETE FROM node_tag_index WHERE nest_id = ?", [id]);
904
- } catch {
905
- }
906
- await tx.run("DELETE FROM api_keys WHERE nest_id = ?", [id]);
907
- await tx.run("DELETE FROM nests WHERE id = ?", [id]);
1136
+ await tx.run("UPDATE teams SET name = ?, updated_at = ? WHERE id = ?", [
1137
+ name,
1138
+ (/* @__PURE__ */ new Date()).toISOString(),
1139
+ params.teamId
1140
+ ]);
1141
+ await refreshTeamNameRefs(tx, params.teamId, name);
908
1142
  });
909
- const path = resolveNestPath(nestId);
910
- try {
911
- rmSync2(path, { recursive: true, force: true });
912
- } catch (err) {
913
- console.warn(`[nests] failed to remove nest directory ${path}:`, err);
914
- }
915
- trackEvent("nest.delete", { nestId });
1143
+ return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
916
1144
  }
917
-
918
- // src/nodes/flat-storage.ts
919
- import { readdirSync } from "fs";
920
- import { join as join4, relative, sep } from "path";
921
- import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
922
- var META_FILES = /* @__PURE__ */ new Set([
923
- "INDEX.md",
924
- "CONTEXT.md",
925
- "CLAUDE.md",
926
- "GEMINI.md",
927
- "AGENTS.md",
928
- "QWEN.md"
929
- ]);
930
- var FlatNestStorage = class extends NestStorage2 {
931
- async discoverDocuments() {
932
- const root = this.root;
933
- let entries;
934
- try {
935
- entries = readdirSync(root, { recursive: true, withFileTypes: true });
936
- } catch (err) {
937
- console.error("[FlatNestStorage] cannot read folder", root, err);
938
- return [];
939
- }
940
- const ids = [];
941
- for (const e of entries) {
942
- if (!e.isFile() || !e.name.endsWith(".md")) continue;
943
- if (META_FILES.has(e.name)) continue;
944
- const dir = e.parentPath ?? e.path ?? root;
945
- const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
946
- if (rel.split("/").some(isSkippedSegment)) continue;
947
- ids.push(rel.replace(/\.md$/, ""));
948
- }
949
- const nodes = [];
950
- for (const id of ids.sort()) {
951
- try {
952
- nodes.push(await this.readDocument(id));
953
- } catch (err) {
954
- console.error("[FlatNestStorage] skipped unreadable doc", id, err);
955
- }
956
- }
957
- return nodes;
1145
+ async function deleteTeam(params) {
1146
+ const row = await loadTeamRow(params.teamId);
1147
+ if (managementRank(row, params.callerUserId, !!params.allowAdmin) < OWNER_RANK) {
1148
+ throw new ForbiddenError("Only the team owner can delete this team.");
958
1149
  }
959
- };
960
-
961
- // src/nodes/engine.ts
962
- var NestEngineCache = class {
963
- cache = /* @__PURE__ */ new Map();
964
- async get(nestId) {
965
- let engine = this.cache.get(nestId);
966
- if (!engine) {
967
- const nestPath = resolveNestPath(nestId);
968
- const storage = await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage3(nestPath);
969
- const query = new GraphQueryEngine(storage);
970
- const versions = new VersionManager(storage);
971
- engine = { storage, query, versions };
972
- this.cache.set(nestId, engine);
973
- }
974
- return engine;
1150
+ const db = getDb();
1151
+ await db.transaction(async (tx) => {
1152
+ await tx.run("DELETE FROM teams WHERE id = ?", [params.teamId]);
1153
+ await removeTeamRefs(tx, params.teamId);
1154
+ });
1155
+ }
1156
+ async function addMember(params) {
1157
+ assertValidRole(params.role);
1158
+ const row = await loadTeamRow(params.teamId);
1159
+ const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
1160
+ if (rank < ROLE_RANK.editor) {
1161
+ throw new ForbiddenError(
1162
+ "Only an editor or admin member (or the owner) can add people to this team."
1163
+ );
975
1164
  }
976
- evict(nestId) {
977
- this.cache.delete(nestId);
1165
+ if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
1166
+ throw new ForbiddenError("You can't give a member a higher role than your own.");
978
1167
  }
979
- };
980
- var engineCache = new NestEngineCache();
981
-
982
- // src/governance/title-resolver.ts
983
- async function buildTitleMap(nestId) {
984
- try {
985
- const { storage } = await engineCache.get(nestId);
986
- const docs = await storage.discoverDocuments();
987
- return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
988
- } catch {
989
- return /* @__PURE__ */ new Map();
1168
+ if (!params.userId && params.email && !isEmailish(params.email)) {
1169
+ throw new ValidationError("Enter a valid email address.");
990
1170
  }
1171
+ const { userId: resolvedId, email: resolvedEmail } = await resolveMemberIdentity(params);
1172
+ await mutateMembers(params.teamId, (members) => {
1173
+ if (members.some((m) => m.userId === resolvedId)) {
1174
+ throw new ConflictError(
1175
+ `${resolvedEmail || "This user"} is already a member of this team. Change their role instead of adding them again.`
1176
+ );
1177
+ }
1178
+ return [
1179
+ ...members,
1180
+ { userId: resolvedId, email: resolvedEmail, role: params.role }
1181
+ ];
1182
+ });
1183
+ return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
991
1184
  }
992
- function folderForNode(nodeId) {
993
- return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
994
- }
995
- async function describeNode(nestId, nodeId) {
996
- const titles = await buildTitleMap(nestId);
997
- const title = titles.get(nodeId) || nodeId;
998
- const folder = folderForNode(nodeId);
999
- return folder ? `${title} (in ${folder})` : title;
1000
- }
1001
- async function nestName(nestId) {
1002
- try {
1003
- const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
1004
- nestId
1185
+ async function resolveMemberIdentity(params) {
1186
+ const db = getDb();
1187
+ if (params.userId) {
1188
+ const u = await db.get("SELECT email FROM users WHERE id = ?", [
1189
+ params.userId
1005
1190
  ]);
1006
- return row?.name || nestId;
1007
- } catch {
1008
- return nestId;
1191
+ if (!u) throw new ValidationError("User not found");
1192
+ return { userId: params.userId, email: normalizeEmail(u.email ?? "") };
1009
1193
  }
1194
+ const requested = params.email ? normalizeEmail(params.email) : void 0;
1195
+ if (!requested) {
1196
+ throw new ValidationError("user_id or email is required");
1197
+ }
1198
+ const existing = await db.get(
1199
+ "SELECT id, email FROM users WHERE LOWER(email) = ?",
1200
+ [requested]
1201
+ );
1202
+ if (existing) {
1203
+ return {
1204
+ userId: existing.id,
1205
+ email: normalizeEmail(existing.email ?? requested)
1206
+ };
1207
+ }
1208
+ const { hashPassword } = await import("./keys-73STFJJB.js");
1209
+ const userId = uuid();
1210
+ await db.run(
1211
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1212
+ [userId, requested, null, await hashPassword(uuid())]
1213
+ );
1214
+ return { userId, email: requested };
1010
1215
  }
1011
- function docLink(nestId, nodeId, baseUrl) {
1012
- const base = baseUrl || config.PUBLIC_BASE_URL;
1013
- if (!base) return "";
1014
- const q = new URLSearchParams({ nest: nestId });
1015
- if (nodeId) q.set("doc", nodeId);
1016
- return `${base.replace(/\/$/, "")}/?${q.toString()}`;
1216
+ async function replaceTeamRoster(params) {
1217
+ for (const m of params.members) assertValidRole(m.role);
1218
+ const row = await loadTeamRow(params.teamId);
1219
+ if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1220
+ throw new ForbiddenError(
1221
+ "Only a team admin or the owner can replace this team's roster."
1222
+ );
1223
+ }
1224
+ await mutateMembers(
1225
+ params.teamId,
1226
+ () => params.members.map((m) => ({
1227
+ userId: m.userId,
1228
+ email: m.email,
1229
+ role: m.role
1230
+ }))
1231
+ );
1232
+ return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1017
1233
  }
1018
- async function buildDocContext(nestId, nodeId, baseUrl) {
1019
- const titles = await buildTitleMap(nestId);
1020
- const docTitle = titles.get(nodeId) || nodeId;
1021
- const folder = folderForNode(nodeId);
1022
- const name = await nestName(nestId);
1023
- return {
1024
- docTitle,
1025
- // Slack one-liner label ("Title (in Folder)"), same as describeNode().
1026
- label: folder ? `${docTitle} (in ${folder})` : docTitle,
1027
- path: folder ? `${name} / ${folder}` : name,
1028
- link: docLink(nestId, nodeId, baseUrl)
1029
- };
1234
+ async function updateMemberRole(params) {
1235
+ assertValidRole(params.role);
1236
+ const row = await loadTeamRow(params.teamId);
1237
+ const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
1238
+ if (rank < ROLE_RANK.admin) {
1239
+ throw new ForbiddenError("Only a team admin or the owner can change roles.");
1240
+ }
1241
+ if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
1242
+ throw new ForbiddenError("You can't set a role higher than your own.");
1243
+ }
1244
+ await mutateMembers(params.teamId, (members) => {
1245
+ const member = members.find((m) => m.userId === params.userId);
1246
+ if (!member) throw new NotFoundError("Team member not found");
1247
+ member.role = params.role;
1248
+ return members;
1249
+ });
1250
+ return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1030
1251
  }
1031
-
1032
- // src/governance/teams-service.ts
1033
- import { v4 as uuid2 } from "uuid";
1034
- var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
1035
- function parseMembers(json) {
1252
+ async function removeMember(params) {
1253
+ const row = await loadTeamRow(params.teamId);
1254
+ if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1255
+ throw new ForbiddenError("Only a team admin or the owner can remove members.");
1256
+ }
1257
+ await mutateMembers(params.teamId, (members) => {
1258
+ const next = members.filter((m) => m.userId !== params.userId);
1259
+ if (next.length === members.length) {
1260
+ throw new NotFoundError("Team member not found");
1261
+ }
1262
+ return next;
1263
+ });
1264
+ return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1265
+ }
1266
+ async function mutateMembers(teamId, mutate) {
1267
+ const db = getDb();
1268
+ await db.transaction(async (tx) => {
1269
+ const row = await tx.get("SELECT members FROM teams WHERE id = ?", [
1270
+ teamId
1271
+ ]);
1272
+ if (!row) throw new NotFoundError("Team not found");
1273
+ const next = mutate(parseMembers(row.members));
1274
+ await tx.run("UPDATE teams SET members = ?, updated_at = ? WHERE id = ?", [
1275
+ JSON.stringify(next),
1276
+ (/* @__PURE__ */ new Date()).toISOString(),
1277
+ teamId
1278
+ ]);
1279
+ });
1280
+ }
1281
+ function parseSharedTeams(json) {
1036
1282
  if (!json) return [];
1037
1283
  try {
1038
1284
  const parsed = JSON.parse(json);
@@ -1041,512 +1287,727 @@ function parseMembers(json) {
1041
1287
  return [];
1042
1288
  }
1043
1289
  }
1044
- function rowToTeam(row) {
1045
- return {
1046
- id: row.id,
1047
- name: row.name,
1048
- owner_id: row.owner_id,
1049
- members: parseMembers(row.members),
1050
- source: row.source ?? null,
1051
- external_id: row.external_id ?? null,
1052
- created_at: row.created_at,
1053
- updated_at: row.updated_at
1054
- };
1290
+ async function listNestTeams(nestId) {
1291
+ const db = getDb();
1292
+ const row = await db.get("SELECT shared_teams FROM nests WHERE id = ?", [
1293
+ nestId
1294
+ ]);
1295
+ if (!row) throw new NotFoundError("Nest not found");
1296
+ return parseSharedTeams(row.shared_teams);
1055
1297
  }
1056
- async function enrichTeam(team) {
1298
+ async function shareTeamWithNest(params) {
1057
1299
  const db = getDb();
1058
- const ids = team.members.map((m) => m.userId);
1059
- const invited = /* @__PURE__ */ new Map();
1060
- if (ids.length) {
1061
- const rows = await db.all(
1062
- `SELECT id, is_invited FROM users WHERE id IN (${ids.map(() => "?").join(",")})`,
1063
- ids
1300
+ let sharedMembers = [];
1301
+ const refs = await db.transaction(async (tx) => {
1302
+ const team = await tx.get(
1303
+ "SELECT id, name, owner_id, members FROM teams WHERE id = ?",
1304
+ [params.teamId]
1064
1305
  );
1065
- for (const r of rows) invited.set(r.id, r.is_invited);
1306
+ if (!team) throw new NotFoundError("Team not found");
1307
+ if (managementRank(team, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.editor) {
1308
+ throw new ForbiddenError(
1309
+ "You need to be an editor, admin, or owner of a team to share it. View-only members can't."
1310
+ );
1311
+ }
1312
+ const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
1313
+ params.nestId
1314
+ ]);
1315
+ if (!nest) throw new NotFoundError("Nest not found");
1316
+ const refs2 = parseSharedTeams(nest.shared_teams);
1317
+ if (refs2.some((r) => r.teamId === params.teamId)) {
1318
+ throw new ConflictError("This team is already shared with the nest.");
1319
+ }
1320
+ refs2.push({ teamId: team.id, teamName: team.name });
1321
+ await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1322
+ JSON.stringify(refs2),
1323
+ params.nestId
1324
+ ]);
1325
+ const members = parseMembers(team.members);
1326
+ sharedMembers = members;
1327
+ const hasGovernanceRole = members.some(
1328
+ (m) => m.role === "viewer" || m.role === "editor"
1329
+ );
1330
+ if (hasGovernanceRole) {
1331
+ await tx.run("UPDATE nests SET stewardship_enabled = 1 WHERE id = ?", [
1332
+ params.nestId
1333
+ ]);
1334
+ }
1335
+ return refs2;
1336
+ });
1337
+ try {
1338
+ const caller = await db.get("SELECT email FROM users WHERE id = ?", [
1339
+ params.callerUserId
1340
+ ]);
1341
+ const callerEmail = caller?.email?.toLowerCase();
1342
+ const shareNestName = await nestName(params.nestId);
1343
+ const link = docLink(params.nestId, void 0, params.baseUrl);
1344
+ const byRole = /* @__PURE__ */ new Map();
1345
+ for (const m of sharedMembers) {
1346
+ if (!m.email || m.email.toLowerCase() === callerEmail) continue;
1347
+ const arr = byRole.get(m.role) ?? [];
1348
+ arr.push(m.email);
1349
+ byRole.set(m.role, arr);
1350
+ }
1351
+ for (const [role, emails] of byRole) {
1352
+ if (emails.length === 0) continue;
1353
+ void sendEmailToRecipient(emails, {
1354
+ status: "shared",
1355
+ docTitle: shareNestName,
1356
+ path: shareNestName,
1357
+ link,
1358
+ permission: role,
1359
+ by: caller?.email || void 0
1360
+ });
1361
+ }
1362
+ } catch (err) {
1363
+ console.error("[notify] team-share email fan-out failed", params.nestId, err);
1066
1364
  }
1067
- const owner = await db.get("SELECT email FROM users WHERE id = ?", [
1068
- team.owner_id
1069
- ]);
1070
- return {
1071
- ...team,
1072
- owner_email: owner?.email,
1073
- // Unknown id (deleted user) → treat as not registered.
1074
- members: team.members.map((m) => ({
1075
- ...m,
1076
- registered: (invited.get(m.userId) ?? 1) === 0
1077
- }))
1078
- };
1365
+ return refs;
1079
1366
  }
1080
- function assertValidRole(role) {
1081
- if (!VALID_TEAM_ROLES.includes(role)) {
1082
- throw new ValidationError(
1083
- "role must be admin, editor, or viewer"
1367
+ async function unshareTeamFromNest(params) {
1368
+ const db = getDb();
1369
+ return db.transaction(async (tx) => {
1370
+ const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
1371
+ params.nestId
1372
+ ]);
1373
+ if (!nest) throw new NotFoundError("Nest not found");
1374
+ const refs = parseSharedTeams(nest.shared_teams);
1375
+ const next = refs.filter((r) => r.teamId !== params.teamId);
1376
+ if (next.length === refs.length) {
1377
+ throw new NotFoundError("Team is not shared with this nest.");
1378
+ }
1379
+ await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1380
+ JSON.stringify(next),
1381
+ params.nestId
1382
+ ]);
1383
+ return next;
1384
+ });
1385
+ }
1386
+ async function refreshTeamNameRefs(tx, teamId, newName) {
1387
+ const nests = await tx.all(
1388
+ "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
1389
+ [`%${teamId}%`]
1390
+ );
1391
+ for (const n of nests) {
1392
+ const refs = parseSharedTeams(n.shared_teams);
1393
+ let changed = false;
1394
+ for (const r of refs) {
1395
+ if (r.teamId === teamId && r.teamName !== newName) {
1396
+ r.teamName = newName;
1397
+ changed = true;
1398
+ }
1399
+ }
1400
+ if (changed) {
1401
+ await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1402
+ JSON.stringify(refs),
1403
+ n.id
1404
+ ]);
1405
+ }
1406
+ }
1407
+ }
1408
+ async function removeTeamRefs(tx, teamId) {
1409
+ const nests = await tx.all(
1410
+ "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
1411
+ [`%${teamId}%`]
1412
+ );
1413
+ for (const n of nests) {
1414
+ const refs = parseSharedTeams(n.shared_teams);
1415
+ const next = refs.filter((r) => r.teamId !== teamId);
1416
+ if (next.length !== refs.length) {
1417
+ await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1418
+ JSON.stringify(next),
1419
+ n.id
1420
+ ]);
1421
+ }
1422
+ }
1423
+ }
1424
+ async function resolveTeamRolesForUser(nestId, userId) {
1425
+ const refs = await listNestTeams(nestId).catch(() => []);
1426
+ if (refs.length === 0) return [];
1427
+ const db = getDb();
1428
+ const roles = /* @__PURE__ */ new Set();
1429
+ for (const ref of refs) {
1430
+ const row = await db.get("SELECT members FROM teams WHERE id = ?", [
1431
+ ref.teamId
1432
+ ]);
1433
+ if (!row) continue;
1434
+ const member = parseMembers(row.members).find((m) => m.userId === userId);
1435
+ if (member) roles.add(member.role);
1436
+ }
1437
+ return [...roles];
1438
+ }
1439
+
1440
+ // src/governance/roles.ts
1441
+ function collabPermToRole(permission) {
1442
+ switch (permission) {
1443
+ case "owner":
1444
+ return "owner";
1445
+ case "admin":
1446
+ return "admin";
1447
+ case "write":
1448
+ return "editor";
1449
+ case "read":
1450
+ return "viewer";
1451
+ default:
1452
+ return null;
1453
+ }
1454
+ }
1455
+ function teamRoleToPermission(role) {
1456
+ switch (role) {
1457
+ case "admin":
1458
+ return "admin";
1459
+ case "editor":
1460
+ return "write";
1461
+ case "viewer":
1462
+ return "read";
1463
+ default:
1464
+ return null;
1465
+ }
1466
+ }
1467
+ var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
1468
+ var canViewWith = (roles) => roles.length > 0;
1469
+ var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
1470
+ var PRECEDENCE = [
1471
+ "owner",
1472
+ "admin",
1473
+ "editor",
1474
+ "reviewer",
1475
+ "viewer"
1476
+ ];
1477
+ function primaryRole(roles) {
1478
+ for (const r of PRECEDENCE) if (roles.includes(r)) return r;
1479
+ return null;
1480
+ }
1481
+
1482
+ // src/shared/access.ts
1483
+ async function isServerAdminUserId(userId) {
1484
+ if (await isLicenseAdminUserId(userId)) return true;
1485
+ const row = await getDb().get(
1486
+ "SELECT email FROM users WHERE id = ?",
1487
+ [userId]
1488
+ );
1489
+ return !!row?.email && isSuperAdmin(row.email);
1490
+ }
1491
+ var PERMISSION_LEVELS = {
1492
+ none: 0,
1493
+ read: 1,
1494
+ write: 2,
1495
+ admin: 3,
1496
+ owner: 4
1497
+ };
1498
+ async function resolveNestPermission(nestId, userId) {
1499
+ const db = getDb();
1500
+ const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
1501
+ if (!nest) return "none";
1502
+ if (nest.user_id === userId) return "owner";
1503
+ if (nest.user_id === ANON_USER_ID && await isLicenseAdminUserId(userId)) {
1504
+ return "owner";
1505
+ }
1506
+ if (await isLicenseAdminUserId(userId)) return "admin";
1507
+ const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
1508
+ if (caller?.email && isSuperAdmin(caller.email)) {
1509
+ return "admin";
1510
+ }
1511
+ const higher = (a, b) => b && PERMISSION_LEVELS[b] > PERMISSION_LEVELS[a] ? b : a;
1512
+ let best = "none";
1513
+ const directGrant = await db.get(
1514
+ "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1515
+ [nestId, userId]
1516
+ );
1517
+ if (directGrant) best = higher(best, directGrant.permission);
1518
+ if (best === "admin") return best;
1519
+ for (const role of await resolveTeamRolesForUser(nestId, userId)) {
1520
+ best = higher(best, teamRoleToPermission(role));
1521
+ }
1522
+ if (best === "admin") return best;
1523
+ const stewardGrant = await db.get(
1524
+ `SELECT 1 FROM stewards s
1525
+ JOIN users u ON u.id = ?
1526
+ WHERE s.nest_id = ? AND s.is_active = 1
1527
+ AND LOWER(s.user_email) = LOWER(u.email)
1528
+ LIMIT 1`,
1529
+ [userId, nestId]
1530
+ );
1531
+ if (stewardGrant) best = higher(best, "read");
1532
+ if (nest.visibility === "public") best = higher(best, "read");
1533
+ return best;
1534
+ }
1535
+ function permissionLevel(p) {
1536
+ return PERMISSION_LEVELS[p] ?? 0;
1537
+ }
1538
+ async function isPublicReader(nestId, userId) {
1539
+ const db = getDb();
1540
+ const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
1541
+ if (!nest) return false;
1542
+ if (nest.visibility !== "public") return false;
1543
+ if (nest.user_id === userId) return false;
1544
+ const directGrant = await db.get(
1545
+ "SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1546
+ [nestId, userId]
1547
+ );
1548
+ if (directGrant) return false;
1549
+ if (await isLicenseAdminUserId(userId)) return false;
1550
+ const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
1551
+ if (caller?.email && isSuperAdmin(caller.email)) return false;
1552
+ const stewardGrant = await db.get(
1553
+ `SELECT 1 FROM stewards s
1554
+ JOIN users u ON u.id = ?
1555
+ WHERE s.nest_id = ? AND s.is_active = 1
1556
+ AND LOWER(s.user_email) = LOWER(u.email)
1557
+ LIMIT 1`,
1558
+ [userId, nestId]
1559
+ );
1560
+ if (stewardGrant) return false;
1561
+ if ((await resolveTeamRolesForUser(nestId, userId)).length > 0) return false;
1562
+ return true;
1563
+ }
1564
+
1565
+ // src/nests/service.ts
1566
+ async function isImportedNest(nestId) {
1567
+ const row = await getDb().get(
1568
+ "SELECT is_imported FROM nests WHERE id = ?",
1569
+ [nestId]
1570
+ );
1571
+ return !!row?.is_imported;
1572
+ }
1573
+ function toSlug(name) {
1574
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1575
+ }
1576
+ async function uniqueNestName(userId, baseName) {
1577
+ const db = getDb();
1578
+ let candidate = baseName;
1579
+ let n = 0;
1580
+ while (n < 1e3) {
1581
+ const slug = toSlug(candidate);
1582
+ const hit = await db.get(
1583
+ "SELECT 1 FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
1584
+ [userId, slug]
1084
1585
  );
1586
+ if (!hit) return candidate;
1587
+ n++;
1588
+ candidate = `${baseName} (${n})`;
1085
1589
  }
1590
+ return candidate;
1086
1591
  }
1087
- async function loadTeamRow(teamId) {
1592
+ async function isStewardshipEnabled(nestId) {
1088
1593
  const db = getDb();
1089
- const row = await db.get("SELECT * FROM teams WHERE id = ?", [
1090
- teamId
1091
- ]);
1092
- if (!row) throw new NotFoundError("Team not found");
1093
- return row;
1094
- }
1095
- var ROLE_RANK = {
1096
- viewer: 1,
1097
- editor: 2,
1098
- admin: 3
1099
- };
1100
- var OWNER_RANK = 4;
1101
- function managementRank(row, callerUserId, allowAdmin) {
1102
- if (row.owner_id === callerUserId || allowAdmin) return OWNER_RANK;
1103
- const m = parseMembers(row.members).find((x) => x.userId === callerUserId);
1104
- return m ? ROLE_RANK[m.role] ?? 0 : 0;
1594
+ const row = await db.get(
1595
+ "SELECT stewardship_enabled FROM nests WHERE id = ?",
1596
+ [nestId]
1597
+ );
1598
+ return !!row?.stewardship_enabled;
1105
1599
  }
1106
- async function createTeam(params) {
1107
- const name = params.name?.trim();
1108
- if (!name) throw new ValidationError("Team name is required");
1600
+ async function setStewardshipEnabled(nestId, enabled) {
1109
1601
  const db = getDb();
1110
- const now = (/* @__PURE__ */ new Date()).toISOString();
1111
- const id = uuid2();
1112
- await db.run(
1113
- "INSERT INTO teams (id, name, owner_id, members, source, external_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1114
- [
1115
- id,
1116
- name,
1117
- params.ownerId,
1118
- "[]",
1119
- params.source ?? null,
1120
- params.externalId ?? null,
1121
- now,
1122
- now
1123
- ]
1124
- );
1125
- return enrichTeam(rowToTeam(await loadTeamRow(id)));
1602
+ await db.run("UPDATE nests SET stewardship_enabled = ? WHERE id = ?", [
1603
+ enabled ? 1 : 0,
1604
+ nestId
1605
+ ]);
1126
1606
  }
1127
- async function findTeamByExternalId(params) {
1607
+ async function nestAllowsSelfApprove(nestId) {
1128
1608
  const db = getDb();
1129
1609
  const row = await db.get(
1130
- "SELECT * FROM teams WHERE owner_id = ? AND source = ? AND external_id = ?",
1131
- [params.ownerId, params.source, params.externalId]
1610
+ "SELECT allow_self_approve FROM nests WHERE id = ?",
1611
+ [nestId]
1132
1612
  );
1133
- return row ? enrichTeam(rowToTeam(row)) : null;
1613
+ return !!row?.allow_self_approve;
1134
1614
  }
1135
- async function listTeamsForUser(userId) {
1615
+ async function setAllowSelfApprove(nestId, allow) {
1136
1616
  const db = getDb();
1137
- const rows = await db.all(
1138
- "SELECT * FROM teams WHERE owner_id = ? OR members LIKE ? ORDER BY created_at",
1139
- [userId, `%${userId}%`]
1140
- );
1141
- const mine = rows.filter(
1142
- (r) => r.owner_id === userId || parseMembers(r.members).some((m) => m.userId === userId)
1143
- );
1144
- return Promise.all(mine.map((r) => enrichTeam(rowToTeam(r))));
1145
- }
1146
- async function getTeam(teamId) {
1147
- return enrichTeam(rowToTeam(await loadTeamRow(teamId)));
1617
+ await db.run("UPDATE nests SET allow_self_approve = ? WHERE id = ?", [
1618
+ allow ? 1 : 0,
1619
+ nestId
1620
+ ]);
1148
1621
  }
1149
- async function renameTeam(params) {
1150
- const name = params.name?.trim();
1151
- if (!name) throw new ValidationError("Team name is required");
1152
- const row = await loadTeamRow(params.teamId);
1153
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1154
- throw new ForbiddenError("Only a team admin or the owner can rename this team.");
1155
- }
1622
+ async function disableStewardshipAndWipeGovernance(nestId) {
1156
1623
  const db = getDb();
1157
- await db.transaction(async (tx) => {
1158
- await tx.run("UPDATE teams SET name = ?, updated_at = ? WHERE id = ?", [
1159
- name,
1160
- (/* @__PURE__ */ new Date()).toISOString(),
1161
- params.teamId
1162
- ]);
1163
- await refreshTeamNameRefs(tx, params.teamId, name);
1624
+ return db.transaction(async (tx) => {
1625
+ const id = nestId;
1626
+ const stewards = (await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id])).changes;
1627
+ const pendingReviews = (await tx.run(
1628
+ "DELETE FROM review_requests WHERE nest_id = ? AND status = 'pending'",
1629
+ [id]
1630
+ )).changes;
1631
+ await tx.run("UPDATE nests SET stewardship_enabled = 0 WHERE id = ?", [id]);
1632
+ return { stewards, pendingReviews };
1164
1633
  });
1165
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1166
1634
  }
1167
- async function deleteTeam(params) {
1168
- const row = await loadTeamRow(params.teamId);
1169
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < OWNER_RANK) {
1170
- throw new ForbiddenError("Only the team owner can delete this team.");
1171
- }
1635
+ async function renameNest(nestId, patch) {
1172
1636
  const db = getDb();
1173
- await db.transaction(async (tx) => {
1174
- await tx.run("DELETE FROM teams WHERE id = ?", [params.teamId]);
1175
- await removeTeamRefs(tx, params.teamId);
1176
- });
1177
- }
1178
- async function addMember(params) {
1179
- assertValidRole(params.role);
1180
- const row = await loadTeamRow(params.teamId);
1181
- const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
1182
- if (rank < ROLE_RANK.editor) {
1183
- throw new ForbiddenError(
1184
- "Only an editor or admin member (or the owner) can add people to this team."
1185
- );
1186
- }
1187
- if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
1188
- throw new ForbiddenError("You can't give a member a higher role than your own.");
1637
+ const existing = await db.get("SELECT * FROM nests WHERE id = ?", [
1638
+ nestId
1639
+ ]);
1640
+ if (!existing) {
1641
+ throw new ValidationError("Nest not found");
1189
1642
  }
1190
- const { userId: resolvedId, email: resolvedEmail } = await resolveMemberIdentity(params);
1191
- await mutateMembers(params.teamId, (members) => {
1192
- if (members.some((m) => m.userId === resolvedId)) {
1193
- throw new ConflictError(
1194
- `${resolvedEmail || "This user"} is already a member of this team. Change their role instead of adding them again.`
1643
+ const updates = [];
1644
+ const params = [];
1645
+ if (patch.name !== void 0) {
1646
+ const trimmed = patch.name.trim();
1647
+ if (!trimmed) throw new ValidationError("Name cannot be empty");
1648
+ const slug = toSlug(trimmed);
1649
+ if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
1650
+ const conflict = await db.get(
1651
+ "SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1",
1652
+ [existing.user_id, slug, nestId]
1653
+ );
1654
+ if (conflict) {
1655
+ throw new ValidationError(
1656
+ `Another nest already uses this name. Pick a different one.`
1195
1657
  );
1196
1658
  }
1197
- return [
1198
- ...members,
1199
- { userId: resolvedId, email: resolvedEmail, role: params.role }
1200
- ];
1201
- });
1202
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1659
+ updates.push("name = ?", "slug = ?");
1660
+ params.push(trimmed, slug);
1661
+ }
1662
+ if (patch.description !== void 0) {
1663
+ updates.push("description = ?");
1664
+ params.push(patch.description || null);
1665
+ }
1666
+ if (updates.length === 0) {
1667
+ return existing;
1668
+ }
1669
+ params.push(nestId);
1670
+ await db.run(
1671
+ `UPDATE nests SET ${updates.join(", ")} WHERE id = ?`,
1672
+ params
1673
+ );
1674
+ return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]);
1203
1675
  }
1204
- async function resolveMemberIdentity(params) {
1676
+ async function createNest(userId, name, description) {
1677
+ const id = uuid2();
1205
1678
  const db = getDb();
1206
- if (params.userId) {
1207
- const u = await db.get("SELECT email FROM users WHERE id = ?", [
1208
- params.userId
1209
- ]);
1210
- if (!u) throw new ValidationError("User not found");
1211
- return { userId: params.userId, email: normalizeEmail(u.email ?? "") };
1212
- }
1213
- const requested = params.email ? normalizeEmail(params.email) : void 0;
1214
- if (!requested) {
1215
- throw new ValidationError("user_id or email is required");
1679
+ const trimmed = name.trim();
1680
+ if (!trimmed) throw new ValidationError("Name cannot be empty");
1681
+ const slug = toSlug(trimmed);
1682
+ if (!slug) {
1683
+ throw new ValidationError(
1684
+ "Name must contain at least one alphanumeric character"
1685
+ );
1216
1686
  }
1217
- const existing = await db.get(
1218
- "SELECT id, email FROM users WHERE LOWER(email) = ?",
1219
- [requested]
1687
+ const conflict = await db.get(
1688
+ "SELECT id FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
1689
+ [userId, slug]
1220
1690
  );
1221
- if (existing) {
1222
- return {
1223
- userId: existing.id,
1224
- email: normalizeEmail(existing.email ?? requested)
1225
- };
1691
+ if (conflict) {
1692
+ throw new ValidationError(
1693
+ "You already have a nest with this name. Pick a different one."
1694
+ );
1226
1695
  }
1227
- const { hashPassword } = await import("./keys-73STFJJB.js");
1228
- const userId = uuid2();
1696
+ const visibility = userId === ANON_USER_ID ? "public" : "private";
1229
1697
  await db.run(
1230
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1231
- [userId, requested, null, await hashPassword(uuid2())]
1698
+ "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)",
1699
+ [id, userId, trimmed, slug, description || null, visibility]
1232
1700
  );
1233
- return { userId, email: requested };
1701
+ const path = resolveNestPath(id);
1702
+ mkdirSync(path, { recursive: true });
1703
+ const storage = new NestStorage(path);
1704
+ await storage.init(trimmed);
1705
+ trackEvent("nest.create", { nestId: id, userId });
1706
+ return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
1234
1707
  }
1235
- async function replaceTeamRoster(params) {
1236
- for (const m of params.members) assertValidRole(m.role);
1237
- const row = await loadTeamRow(params.teamId);
1238
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1239
- throw new ForbiddenError(
1240
- "Only a team admin or the owner can replace this team's roster."
1241
- );
1708
+ async function importNest(userId, name, files) {
1709
+ const nm = (name || "").trim();
1710
+ if (!nm) {
1711
+ throw new ValidationError("name is required");
1242
1712
  }
1243
- await mutateMembers(
1244
- params.teamId,
1245
- () => params.members.map((m) => ({
1246
- userId: m.userId,
1247
- email: m.email,
1248
- role: m.role
1249
- }))
1713
+ if (!Array.isArray(files)) {
1714
+ throw new ValidationError("files are required");
1715
+ }
1716
+ const db = getDb();
1717
+ const id = uuid2();
1718
+ const slug = toSlug(nm);
1719
+ const visibility = userId === ANON_USER_ID ? "public" : "private";
1720
+ const dest = resolveNestPath(id);
1721
+ mkdirSync(dest, { recursive: true });
1722
+ try {
1723
+ await writeImportedFiles(dest, files);
1724
+ } catch (err) {
1725
+ console.error("[nests] import write failed", dest, err);
1726
+ discardImportDir(dest);
1727
+ throw new ValidationError("Failed to import folder");
1728
+ }
1729
+ await db.run(
1730
+ "INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)",
1731
+ [id, userId, nm, slug, null, visibility]
1250
1732
  );
1251
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1733
+ trackEvent("nest.import", { nestId: id, userId });
1734
+ return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
1252
1735
  }
1253
- async function updateMemberRole(params) {
1254
- assertValidRole(params.role);
1255
- const row = await loadTeamRow(params.teamId);
1256
- const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
1257
- if (rank < ROLE_RANK.admin) {
1258
- throw new ForbiddenError("Only a team admin or the owner can change roles.");
1736
+ async function listNests(userId) {
1737
+ const db = getDb();
1738
+ if (userId === ANON_USER_ID) {
1739
+ return await db.all(
1740
+ "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
1741
+ [ANON_USER_ID]
1742
+ );
1259
1743
  }
1260
- if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
1261
- throw new ForbiddenError("You can't set a role higher than your own.");
1744
+ const includeAnon = config.AUTH_MODE === "open" || await isLicenseAdminUserId(userId);
1745
+ if (!includeAnon) {
1746
+ return await db.all(
1747
+ "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
1748
+ [userId]
1749
+ );
1262
1750
  }
1263
- await mutateMembers(params.teamId, (members) => {
1264
- const member = members.find((m) => m.userId === params.userId);
1265
- if (!member) throw new NotFoundError("Team member not found");
1266
- member.role = params.role;
1267
- return members;
1268
- });
1269
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1751
+ return await db.all(
1752
+ "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC",
1753
+ [userId, ANON_USER_ID]
1754
+ );
1270
1755
  }
1271
- async function removeMember(params) {
1272
- const row = await loadTeamRow(params.teamId);
1273
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1274
- throw new ForbiddenError("Only a team admin or the owner can remove members.");
1756
+ async function nestsSharedViaTeams(userId) {
1757
+ const db = getDb();
1758
+ const teams = await db.all(
1759
+ "SELECT id, members FROM teams WHERE members LIKE ?",
1760
+ [`%${userId}%`]
1761
+ );
1762
+ const myTeamIds = /* @__PURE__ */ new Set();
1763
+ for (const t of teams) {
1764
+ try {
1765
+ const members = JSON.parse(t.members || "[]");
1766
+ if (Array.isArray(members) && members.some((m) => m?.userId === userId)) {
1767
+ myTeamIds.add(t.id);
1768
+ }
1769
+ } catch {
1770
+ }
1275
1771
  }
1276
- await mutateMembers(params.teamId, (members) => {
1277
- const next = members.filter((m) => m.userId !== params.userId);
1278
- if (next.length === members.length) {
1279
- throw new NotFoundError("Team member not found");
1772
+ if (myTeamIds.size === 0) return [];
1773
+ const ids = [...myTeamIds];
1774
+ const candidates = await db.all(
1775
+ `SELECT * FROM nests
1776
+ WHERE user_id != ?
1777
+ AND (${ids.map(() => "shared_teams LIKE ?").join(" OR ")})`,
1778
+ [userId, ...ids.map((id) => `%${id}%`)]
1779
+ );
1780
+ return candidates.filter((n) => {
1781
+ try {
1782
+ const refs = JSON.parse(n.shared_teams || "[]");
1783
+ return Array.isArray(refs) && refs.some((r) => myTeamIds.has(r?.teamId));
1784
+ } catch {
1785
+ return false;
1280
1786
  }
1281
- return next;
1282
1787
  });
1283
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1284
1788
  }
1285
- async function mutateMembers(teamId, mutate) {
1789
+ async function listSharedNests(userId) {
1286
1790
  const db = getDb();
1287
- await db.transaction(async (tx) => {
1288
- const row = await tx.get("SELECT members FROM teams WHERE id = ?", [
1289
- teamId
1290
- ]);
1291
- if (!row) throw new NotFoundError("Team not found");
1292
- const next = mutate(parseMembers(row.members));
1293
- await tx.run("UPDATE teams SET members = ?, updated_at = ? WHERE id = ?", [
1294
- JSON.stringify(next),
1295
- (/* @__PURE__ */ new Date()).toISOString(),
1296
- teamId
1297
- ]);
1298
- });
1791
+ const direct = await db.all(
1792
+ `SELECT DISTINCT n.* FROM nests n
1793
+ LEFT JOIN nest_collaborators nc
1794
+ ON nc.nest_id = n.id AND nc.user_id = ?
1795
+ LEFT JOIN users u ON u.id = ?
1796
+ LEFT JOIN stewards s
1797
+ ON s.nest_id = n.id AND s.is_active = 1
1798
+ AND LOWER(s.user_email) = LOWER(u.email)
1799
+ WHERE n.user_id != ?
1800
+ AND (nc.user_id IS NOT NULL OR s.id IS NOT NULL)
1801
+ ORDER BY n.created_at DESC`,
1802
+ [userId, userId, userId]
1803
+ );
1804
+ const seen = new Set(direct.map((n) => n.id));
1805
+ for (const n of await nestsSharedViaTeams(userId)) {
1806
+ if (!seen.has(n.id)) {
1807
+ direct.push(n);
1808
+ seen.add(n.id);
1809
+ }
1810
+ }
1811
+ if (await isServerAdminUserId(userId)) {
1812
+ const anon = await db.all(
1813
+ "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
1814
+ [ANON_USER_ID]
1815
+ );
1816
+ for (const n of anon) {
1817
+ if (!seen.has(n.id)) {
1818
+ direct.push(n);
1819
+ seen.add(n.id);
1820
+ }
1821
+ }
1822
+ }
1823
+ direct.sort(
1824
+ (a, b) => a.created_at === b.created_at ? 0 : a.created_at < b.created_at ? 1 : -1
1825
+ );
1826
+ return direct;
1299
1827
  }
1300
- function parseSharedTeams(json) {
1301
- if (!json) return [];
1302
- try {
1303
- const parsed = JSON.parse(json);
1304
- return Array.isArray(parsed) ? parsed : [];
1305
- } catch {
1306
- return [];
1828
+ async function listVisibleNests(userId) {
1829
+ const db = getDb();
1830
+ const includeAnon = config.AUTH_MODE === "open" || await isServerAdminUserId(userId);
1831
+ const params = includeAnon ? [userId, userId, userId, ANON_USER_ID] : [userId, userId, userId];
1832
+ const rows = await db.all(
1833
+ `SELECT DISTINCT n.* FROM nests n
1834
+ LEFT JOIN nest_collaborators nc
1835
+ ON nc.nest_id = n.id AND nc.user_id = ?
1836
+ LEFT JOIN users u ON u.id = ?
1837
+ LEFT JOIN stewards s
1838
+ ON s.nest_id = n.id AND s.is_active = 1
1839
+ AND LOWER(s.user_email) = LOWER(u.email)
1840
+ WHERE n.user_id = ?
1841
+ OR nc.user_id IS NOT NULL
1842
+ OR s.id IS NOT NULL
1843
+ ${includeAnon ? "OR n.user_id = ?" : ""}
1844
+ ORDER BY n.created_at DESC`,
1845
+ params
1846
+ );
1847
+ const seen = new Set(rows.map((n) => n.id));
1848
+ for (const n of await nestsSharedViaTeams(userId)) {
1849
+ if (!seen.has(n.id)) {
1850
+ rows.push(n);
1851
+ seen.add(n.id);
1852
+ }
1307
1853
  }
1854
+ return rows;
1308
1855
  }
1309
- async function listNestTeams(nestId) {
1856
+ async function listPublicNests(userId) {
1310
1857
  const db = getDb();
1311
- const row = await db.get("SELECT shared_teams FROM nests WHERE id = ?", [
1312
- nestId
1313
- ]);
1314
- if (!row) throw new NotFoundError("Nest not found");
1315
- return parseSharedTeams(row.shared_teams);
1858
+ return await db.all(
1859
+ `SELECT n.* FROM nests n
1860
+ WHERE n.visibility = 'public'
1861
+ AND n.user_id != ?
1862
+ AND NOT EXISTS (
1863
+ SELECT 1 FROM nest_collaborators nc
1864
+ WHERE nc.nest_id = n.id AND nc.user_id = ?
1865
+ )
1866
+ ORDER BY n.created_at DESC`,
1867
+ [userId, userId]
1868
+ );
1316
1869
  }
1317
- async function shareTeamWithNest(params) {
1870
+ async function getNest(nestId) {
1318
1871
  const db = getDb();
1319
- return db.transaction(async (tx) => {
1320
- const team = await tx.get(
1321
- "SELECT id, name, owner_id, members FROM teams WHERE id = ?",
1322
- [params.teamId]
1323
- );
1324
- if (!team) throw new NotFoundError("Team not found");
1325
- if (managementRank(team, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.editor) {
1326
- throw new ForbiddenError(
1327
- "You need to be an editor, admin, or owner of a team to share it. View-only members can't."
1328
- );
1329
- }
1330
- const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
1331
- params.nestId
1332
- ]);
1333
- if (!nest) throw new NotFoundError("Nest not found");
1334
- const refs = parseSharedTeams(nest.shared_teams);
1335
- if (refs.some((r) => r.teamId === params.teamId)) {
1336
- throw new ConflictError("This team is already shared with the nest.");
1337
- }
1338
- refs.push({ teamId: team.id, teamName: team.name });
1339
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1340
- JSON.stringify(refs),
1341
- params.nestId
1342
- ]);
1343
- const hasGovernanceRole = parseMembers(team.members).some(
1344
- (m) => m.role === "viewer" || m.role === "editor"
1345
- );
1346
- if (hasGovernanceRole) {
1347
- await tx.run("UPDATE nests SET stewardship_enabled = 1 WHERE id = ?", [
1348
- params.nestId
1349
- ]);
1350
- }
1351
- return refs;
1352
- });
1872
+ return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]) || null;
1353
1873
  }
1354
- async function unshareTeamFromNest(params) {
1874
+ async function deleteNest(nestId) {
1355
1875
  const db = getDb();
1356
- return db.transaction(async (tx) => {
1357
- const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
1358
- params.nestId
1359
- ]);
1360
- if (!nest) throw new NotFoundError("Nest not found");
1361
- const refs = parseSharedTeams(nest.shared_teams);
1362
- const next = refs.filter((r) => r.teamId !== params.teamId);
1363
- if (next.length === refs.length) {
1364
- throw new NotFoundError("Team is not shared with this nest.");
1876
+ await db.transaction(async (tx) => {
1877
+ const id = nestId;
1878
+ await tx.run("DELETE FROM approved_versions WHERE nest_id = ?", [id]);
1879
+ await tx.run("DELETE FROM node_versions WHERE nest_id = ?", [id]);
1880
+ await tx.run("DELETE FROM review_requests WHERE nest_id = ?", [id]);
1881
+ await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id]);
1882
+ await tx.run("DELETE FROM nest_collaborators WHERE nest_id = ?", [id]);
1883
+ try {
1884
+ await tx.run("DELETE FROM node_tag_index WHERE nest_id = ?", [id]);
1885
+ } catch {
1365
1886
  }
1366
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1367
- JSON.stringify(next),
1368
- params.nestId
1369
- ]);
1370
- return next;
1887
+ await tx.run("DELETE FROM api_keys WHERE nest_id = ?", [id]);
1888
+ await tx.run("DELETE FROM nests WHERE id = ?", [id]);
1371
1889
  });
1890
+ const path = resolveNestPath(nestId);
1891
+ try {
1892
+ rmSync2(path, { recursive: true, force: true });
1893
+ } catch (err) {
1894
+ console.warn(`[nests] failed to remove nest directory ${path}:`, err);
1895
+ }
1896
+ trackEvent("nest.delete", { nestId });
1372
1897
  }
1373
- async function refreshTeamNameRefs(tx, teamId, newName) {
1374
- const nests = await tx.all(
1375
- "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
1376
- [`%${teamId}%`]
1377
- );
1378
- for (const n of nests) {
1379
- const refs = parseSharedTeams(n.shared_teams);
1380
- let changed = false;
1381
- for (const r of refs) {
1382
- if (r.teamId === teamId && r.teamName !== newName) {
1383
- r.teamName = newName;
1384
- changed = true;
1385
- }
1898
+
1899
+ // src/nodes/flat-storage.ts
1900
+ import { readdirSync } from "fs";
1901
+ import { join as join4, relative, sep } from "path";
1902
+ import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
1903
+ var META_FILES = /* @__PURE__ */ new Set([
1904
+ "INDEX.md",
1905
+ "CONTEXT.md",
1906
+ "CLAUDE.md",
1907
+ "GEMINI.md",
1908
+ "AGENTS.md",
1909
+ "QWEN.md"
1910
+ ]);
1911
+ var FlatNestStorage = class extends NestStorage2 {
1912
+ async discoverDocuments() {
1913
+ const root = this.root;
1914
+ let entries;
1915
+ try {
1916
+ entries = readdirSync(root, { recursive: true, withFileTypes: true });
1917
+ } catch (err) {
1918
+ console.error("[FlatNestStorage] cannot read folder", root, err);
1919
+ return [];
1386
1920
  }
1387
- if (changed) {
1388
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1389
- JSON.stringify(refs),
1390
- n.id
1391
- ]);
1921
+ const ids = [];
1922
+ for (const e of entries) {
1923
+ if (!e.isFile() || !e.name.endsWith(".md")) continue;
1924
+ if (META_FILES.has(e.name)) continue;
1925
+ const dir = e.parentPath ?? e.path ?? root;
1926
+ const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
1927
+ if (rel.split("/").some(isSkippedSegment)) continue;
1928
+ ids.push(rel.replace(/\.md$/, ""));
1929
+ }
1930
+ const nodes = [];
1931
+ for (const id of ids.sort()) {
1932
+ try {
1933
+ nodes.push(await this.readDocument(id));
1934
+ } catch (err) {
1935
+ console.error("[FlatNestStorage] skipped unreadable doc", id, err);
1936
+ }
1392
1937
  }
1938
+ return nodes;
1393
1939
  }
1394
- }
1395
- async function removeTeamRefs(tx, teamId) {
1396
- const nests = await tx.all(
1397
- "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
1398
- [`%${teamId}%`]
1399
- );
1400
- for (const n of nests) {
1401
- const refs = parseSharedTeams(n.shared_teams);
1402
- const next = refs.filter((r) => r.teamId !== teamId);
1403
- if (next.length !== refs.length) {
1404
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1405
- JSON.stringify(next),
1406
- n.id
1407
- ]);
1940
+ };
1941
+
1942
+ // src/nodes/engine.ts
1943
+ var NestEngineCache = class {
1944
+ cache = /* @__PURE__ */ new Map();
1945
+ async get(nestId) {
1946
+ let engine = this.cache.get(nestId);
1947
+ if (!engine) {
1948
+ const nestPath = resolveNestPath(nestId);
1949
+ const storage = await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage3(nestPath);
1950
+ const query = new GraphQueryEngine(storage);
1951
+ const versions = new VersionManager(storage);
1952
+ engine = { storage, query, versions };
1953
+ this.cache.set(nestId, engine);
1408
1954
  }
1955
+ return engine;
1409
1956
  }
1410
- }
1411
- async function resolveTeamRolesForUser(nestId, userId) {
1412
- const refs = await listNestTeams(nestId).catch(() => []);
1413
- if (refs.length === 0) return [];
1414
- const db = getDb();
1415
- const roles = /* @__PURE__ */ new Set();
1416
- for (const ref of refs) {
1417
- const row = await db.get("SELECT members FROM teams WHERE id = ?", [
1418
- ref.teamId
1419
- ]);
1420
- if (!row) continue;
1421
- const member = parseMembers(row.members).find((m) => m.userId === userId);
1422
- if (member) roles.add(member.role);
1957
+ evict(nestId) {
1958
+ this.cache.delete(nestId);
1423
1959
  }
1424
- return [...roles];
1425
- }
1960
+ };
1961
+ var engineCache = new NestEngineCache();
1426
1962
 
1427
- // src/governance/roles.ts
1428
- function collabPermToRole(permission) {
1429
- switch (permission) {
1430
- case "owner":
1431
- return "owner";
1432
- case "admin":
1433
- return "admin";
1434
- case "write":
1435
- return "editor";
1436
- case "read":
1437
- return "viewer";
1438
- default:
1439
- return null;
1440
- }
1441
- }
1442
- function teamRoleToPermission(role) {
1443
- switch (role) {
1444
- case "admin":
1445
- return "admin";
1446
- case "editor":
1447
- return "write";
1448
- case "viewer":
1449
- return "read";
1450
- default:
1451
- return null;
1963
+ // src/governance/title-resolver.ts
1964
+ async function buildTitleMap(nestId) {
1965
+ try {
1966
+ const { storage } = await engineCache.get(nestId);
1967
+ const docs = await storage.discoverDocuments();
1968
+ return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
1969
+ } catch {
1970
+ return /* @__PURE__ */ new Map();
1452
1971
  }
1453
1972
  }
1454
- var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
1455
- var canViewWith = (roles) => roles.length > 0;
1456
- var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
1457
- var PRECEDENCE = [
1458
- "owner",
1459
- "admin",
1460
- "editor",
1461
- "reviewer",
1462
- "viewer"
1463
- ];
1464
- function primaryRole(roles) {
1465
- for (const r of PRECEDENCE) if (roles.includes(r)) return r;
1466
- return null;
1973
+ function folderForNode(nodeId) {
1974
+ return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
1467
1975
  }
1468
-
1469
- // src/shared/access.ts
1470
- async function isServerAdminUserId(userId) {
1471
- if (await isLicenseAdminUserId(userId)) return true;
1472
- const row = await getDb().get(
1473
- "SELECT email FROM users WHERE id = ?",
1474
- [userId]
1475
- );
1476
- return !!row?.email && isSuperAdmin(row.email);
1976
+ async function describeNode(nestId, nodeId) {
1977
+ const titles = await buildTitleMap(nestId);
1978
+ const title = titles.get(nodeId) || nodeId;
1979
+ const folder = folderForNode(nodeId);
1980
+ return folder ? `${title} (in ${folder})` : title;
1477
1981
  }
1478
- var PERMISSION_LEVELS = {
1479
- none: 0,
1480
- read: 1,
1481
- write: 2,
1482
- admin: 3,
1483
- owner: 4
1484
- };
1485
- async function resolveNestPermission(nestId, userId) {
1486
- const db = getDb();
1487
- const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
1488
- if (!nest) return "none";
1489
- if (nest.user_id === userId) return "owner";
1490
- if (nest.user_id === ANON_USER_ID && await isLicenseAdminUserId(userId)) {
1491
- return "owner";
1492
- }
1493
- if (await isLicenseAdminUserId(userId)) return "admin";
1494
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
1495
- if (caller?.email && isSuperAdmin(caller.email)) {
1496
- return "admin";
1497
- }
1498
- const higher = (a, b) => b && PERMISSION_LEVELS[b] > PERMISSION_LEVELS[a] ? b : a;
1499
- let best = "none";
1500
- const directGrant = await db.get(
1501
- "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1502
- [nestId, userId]
1503
- );
1504
- if (directGrant) best = higher(best, directGrant.permission);
1505
- if (best === "admin") return best;
1506
- for (const role of await resolveTeamRolesForUser(nestId, userId)) {
1507
- best = higher(best, teamRoleToPermission(role));
1982
+ async function nestName(nestId) {
1983
+ try {
1984
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
1985
+ nestId
1986
+ ]);
1987
+ return row?.name || nestId;
1988
+ } catch {
1989
+ return nestId;
1508
1990
  }
1509
- if (best === "admin") return best;
1510
- const stewardGrant = await db.get(
1511
- `SELECT 1 FROM stewards s
1512
- JOIN users u ON u.id = ?
1513
- WHERE s.nest_id = ? AND s.is_active = 1
1514
- AND LOWER(s.user_email) = LOWER(u.email)
1515
- LIMIT 1`,
1516
- [userId, nestId]
1517
- );
1518
- if (stewardGrant) best = higher(best, "read");
1519
- if (nest.visibility === "public") best = higher(best, "read");
1520
- return best;
1521
1991
  }
1522
- function permissionLevel(p) {
1523
- return PERMISSION_LEVELS[p] ?? 0;
1992
+ function docLink(nestId, nodeId, baseUrl) {
1993
+ const base = baseUrl || config.PUBLIC_BASE_URL;
1994
+ if (!base) return "";
1995
+ const q = new URLSearchParams({ nest: nestId });
1996
+ if (nodeId) q.set("doc", nodeId);
1997
+ return `${base.replace(/\/$/, "")}/?${q.toString()}`;
1524
1998
  }
1525
- async function isPublicReader(nestId, userId) {
1526
- const db = getDb();
1527
- const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
1528
- if (!nest) return false;
1529
- if (nest.visibility !== "public") return false;
1530
- if (nest.user_id === userId) return false;
1531
- const directGrant = await db.get(
1532
- "SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1533
- [nestId, userId]
1534
- );
1535
- if (directGrant) return false;
1536
- if (await isLicenseAdminUserId(userId)) return false;
1537
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
1538
- if (caller?.email && isSuperAdmin(caller.email)) return false;
1539
- const stewardGrant = await db.get(
1540
- `SELECT 1 FROM stewards s
1541
- JOIN users u ON u.id = ?
1542
- WHERE s.nest_id = ? AND s.is_active = 1
1543
- AND LOWER(s.user_email) = LOWER(u.email)
1544
- LIMIT 1`,
1545
- [userId, nestId]
1546
- );
1547
- if (stewardGrant) return false;
1548
- if ((await resolveTeamRolesForUser(nestId, userId)).length > 0) return false;
1549
- return true;
1999
+ async function buildDocContext(nestId, nodeId, baseUrl) {
2000
+ const titles = await buildTitleMap(nestId);
2001
+ const docTitle = titles.get(nodeId) || nodeId;
2002
+ const folder = folderForNode(nodeId);
2003
+ const name = await nestName(nestId);
2004
+ return {
2005
+ docTitle,
2006
+ // Slack one-liner label ("Title (in Folder)"), same as describeNode().
2007
+ label: folder ? `${docTitle} (in ${folder})` : docTitle,
2008
+ path: folder ? `${name} / ${folder}` : name,
2009
+ link: docLink(nestId, nodeId, baseUrl)
2010
+ };
1550
2011
  }
1551
2012
 
1552
2013
  // src/governance/stewardship-service.ts
@@ -1717,6 +2178,9 @@ async function createStewardRecord(params) {
1717
2178
  for (const user of params.users) {
1718
2179
  const email = user.email.trim().toLowerCase();
1719
2180
  if (!email) continue;
2181
+ if (!isEmailish(email)) {
2182
+ throw new ValidationError(`"${user.email}" is not a valid email address.`);
2183
+ }
1720
2184
  if (email === actor) {
1721
2185
  throw new ValidationError(
1722
2186
  "You already manage this nest, so you can't add yourself as a steward."
@@ -1777,6 +2241,32 @@ async function createStewardRecord(params) {
1777
2241
  "UPDATE nests SET stewardship_enabled = 1 WHERE id = ? AND stewardship_enabled = 0",
1778
2242
  [params.nestId]
1779
2243
  );
2244
+ if (results.length > 0) {
2245
+ let scopeLabel;
2246
+ if (params.scope === "document") {
2247
+ const titles = await buildTitleMap(params.nestId);
2248
+ const docTitle = titles.get(nodePattern) || nodePattern;
2249
+ const capitalized = docTitle.charAt(0).toUpperCase() + docTitle.slice(1);
2250
+ scopeLabel = `"${capitalized}" document`;
2251
+ } else if (params.scope === "tag") {
2252
+ scopeLabel = `#${tagName}`;
2253
+ } else {
2254
+ scopeLabel = "the whole nest";
2255
+ }
2256
+ const shareNestName = await nestName(params.nestId);
2257
+ const link = docLink(params.nestId, void 0, params.baseUrl);
2258
+ for (const s of results) {
2259
+ void sendEmailToRecipient(s.userEmail, {
2260
+ status: "steward",
2261
+ docTitle: shareNestName,
2262
+ path: shareNestName,
2263
+ permission: s.role,
2264
+ scopeLabel,
2265
+ by: params.assignedBy || void 0,
2266
+ link
2267
+ });
2268
+ }
2269
+ }
1780
2270
  return results;
1781
2271
  }
1782
2272
  async function resolveStewardsForNode(nestId, nodeId) {
@@ -1872,6 +2362,20 @@ async function getCollaboratorRole(nestId, userEmail) {
1872
2362
  );
1873
2363
  return row?.permission ?? null;
1874
2364
  }
2365
+ async function resolveGrantRoles(nestId, userEmail, userId) {
2366
+ const [grantRaw, stewardRoles, teamRoles] = await Promise.all([
2367
+ getCollaboratorRole(nestId, userEmail),
2368
+ getStewardRolesForUser(nestId, userEmail),
2369
+ resolveTeamRolesForUser(nestId, userId)
2370
+ ]);
2371
+ return [
2372
+ ...new Set(
2373
+ [collabPermToRole(grantRaw), ...stewardRoles, ...teamRoles].filter(
2374
+ Boolean
2375
+ )
2376
+ )
2377
+ ];
2378
+ }
1875
2379
  async function resolveUserRoles(nestId, userEmail, opts) {
1876
2380
  const roles = /* @__PURE__ */ new Set();
1877
2381
  if (isSuperAdmin(userEmail)) roles.add("admin");
@@ -2099,26 +2603,9 @@ export {
2099
2603
  getAccessConfig,
2100
2604
  isSuperAdmin,
2101
2605
  isConfigSuperAdmin,
2102
- createTeam,
2103
- findTeamByExternalId,
2104
- listTeamsForUser,
2105
- getTeam,
2106
- renameTeam,
2107
- deleteTeam,
2108
- addMember,
2109
- resolveMemberIdentity,
2110
- replaceTeamRoster,
2111
- updateMemberRole,
2112
- removeMember,
2113
- listNestTeams,
2114
- shareTeamWithNest,
2115
- unshareTeamFromNest,
2116
- resolveTeamRolesForUser,
2117
- collabPermToRole,
2118
- isServerAdminUserId,
2119
- resolveNestPermission,
2120
- permissionLevel,
2121
- isPublicReader,
2606
+ verifySmtp,
2607
+ notifyEmailForNest,
2608
+ sendEmailToRecipient,
2122
2609
  nestStorageRoot,
2123
2610
  resolveNestPath,
2124
2611
  uniqueNestName,
@@ -2132,6 +2619,7 @@ export {
2132
2619
  importNest,
2133
2620
  listNests,
2134
2621
  listSharedNests,
2622
+ listVisibleNests,
2135
2623
  listPublicNests,
2136
2624
  getNest,
2137
2625
  deleteNest,
@@ -2141,6 +2629,24 @@ export {
2141
2629
  nestName,
2142
2630
  docLink,
2143
2631
  buildDocContext,
2632
+ createTeam,
2633
+ findTeamByExternalId,
2634
+ listTeamsForUser,
2635
+ getTeam,
2636
+ renameTeam,
2637
+ deleteTeam,
2638
+ addMember,
2639
+ resolveMemberIdentity,
2640
+ replaceTeamRoster,
2641
+ updateMemberRole,
2642
+ removeMember,
2643
+ listNestTeams,
2644
+ shareTeamWithNest,
2645
+ unshareTeamFromNest,
2646
+ isServerAdminUserId,
2647
+ resolveNestPermission,
2648
+ permissionLevel,
2649
+ isPublicReader,
2144
2650
  assignSteward,
2145
2651
  removeSteward,
2146
2652
  updateSteward,
@@ -2155,6 +2661,7 @@ export {
2155
2661
  getStewardRolesForUser,
2156
2662
  getStewardsForUser,
2157
2663
  getCollaboratorRole,
2664
+ resolveGrantRoles,
2158
2665
  resolveUserRoles,
2159
2666
  canManageStewards,
2160
2667
  canCreateInNest,