@promptowl/contextnest-community 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +59 -2
- package/README.md +3 -1
- package/dist/{chunk-3M7677XW.js → chunk-I5KYGMIT.js} +36 -1
- package/dist/{chunk-ZMSU7BCC.js → chunk-IHGPZ6NY.js} +1 -1
- package/dist/{chunk-JHIU6RZU.js → chunk-JP67WXLA.js} +34 -1
- package/dist/{chunk-F7EKYNXG.js → chunk-KPK656BH.js} +1336 -829
- package/dist/{chunk-XB7T3THE.js → chunk-MZ4IR5KH.js} +471 -504
- package/dist/{client-YUOEVUFU.js → client-3MGIP57D.js} +1 -1
- package/dist/{grants-service-PS5RMMEA.js → grants-service-5ZFISCBH.js} +2 -2
- package/dist/index.js +311 -108
- package/dist/{review-service-DPN26C47.js → review-service-6G7MVTY6.js} +7 -5
- package/dist/{stewardship-service-7BHI2YXB.js → stewardship-service-G7IF7ZNP.js} +4 -2
- package/dist/{version-service-YQIIQ7FN.js → version-service-FGL47APL.js} +6 -4
- package/dist/web3/assets/index-BYmOkm4p.js +1052 -0
- package/dist/web3/assets/index-p6uQyhml.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +2 -1
- package/dist/web3/assets/index-BiGiarBx.css +0 -1
- package/dist/web3/assets/index-CUVRdYvH.js +0 -1026
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
} from "./chunk-XQ46F76G.js";
|
|
14
14
|
import {
|
|
15
15
|
config,
|
|
16
|
-
getDb
|
|
17
|
-
|
|
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
|
|
142
|
-
import { v4 as
|
|
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 {
|
|
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
|
|
160
|
+
if (!ALLOWED_DOT_DIRS.has(s) && isSkippedSegment(s)) return null;
|
|
158
161
|
}
|
|
159
162
|
return segs;
|
|
160
163
|
}
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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/
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
);
|
|
636
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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: “${escapeHtml(d.note)}”` : "",
|
|
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
|
-
|
|
639
|
-
|
|
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
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
910
|
+
transporterPromise.catch(() => {
|
|
911
|
+
transporterPromise = null;
|
|
912
|
+
configuredUrl = null;
|
|
913
|
+
});
|
|
654
914
|
}
|
|
655
|
-
return
|
|
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
|
|
673
|
-
const
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
1065
|
+
async function loadTeamRow(teamId) {
|
|
806
1066
|
const db = getDb();
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
);
|
|
811
|
-
|
|
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
|
-
|
|
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
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
|
1103
|
+
return enrichTeam(rowToTeam(await loadTeamRow(id)));
|
|
874
1104
|
}
|
|
875
|
-
async function
|
|
1105
|
+
async function findTeamByExternalId(params) {
|
|
876
1106
|
const db = getDb();
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
|
1113
|
+
async function listTeamsForUser(userId) {
|
|
890
1114
|
const db = getDb();
|
|
891
|
-
|
|
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
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
await tx.
|
|
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
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
977
|
-
|
|
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
|
-
|
|
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
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
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
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
|
1019
|
-
|
|
1020
|
-
const
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
|
1298
|
+
async function shareTeamWithNest(params) {
|
|
1057
1299
|
const db = getDb();
|
|
1058
|
-
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
|
1592
|
+
async function isStewardshipEnabled(nestId) {
|
|
1088
1593
|
const db = getDb();
|
|
1089
|
-
const row = await db.get(
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
|
1607
|
+
async function nestAllowsSelfApprove(nestId) {
|
|
1128
1608
|
const db = getDb();
|
|
1129
1609
|
const row = await db.get(
|
|
1130
|
-
"SELECT
|
|
1131
|
-
[
|
|
1610
|
+
"SELECT allow_self_approve FROM nests WHERE id = ?",
|
|
1611
|
+
[nestId]
|
|
1132
1612
|
);
|
|
1133
|
-
return row
|
|
1613
|
+
return !!row?.allow_self_approve;
|
|
1134
1614
|
}
|
|
1135
|
-
async function
|
|
1615
|
+
async function setAllowSelfApprove(nestId, allow) {
|
|
1136
1616
|
const db = getDb();
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
|
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.
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
|
1676
|
+
async function createNest(userId, name, description) {
|
|
1677
|
+
const id = uuid2();
|
|
1205
1678
|
const db = getDb();
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
|
1218
|
-
"SELECT id
|
|
1219
|
-
[
|
|
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 (
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
|
1228
|
-
const userId = uuid2();
|
|
1696
|
+
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
1229
1697
|
await db.run(
|
|
1230
|
-
"INSERT INTO
|
|
1231
|
-
[userId,
|
|
1698
|
+
"INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)",
|
|
1699
|
+
[id, userId, trimmed, slug, description || null, visibility]
|
|
1232
1700
|
);
|
|
1233
|
-
|
|
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
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
1733
|
+
trackEvent("nest.import", { nestId: id, userId });
|
|
1734
|
+
return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
|
|
1252
1735
|
}
|
|
1253
|
-
async function
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1261
|
-
|
|
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
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
|
1789
|
+
async function listSharedNests(userId) {
|
|
1286
1790
|
const db = getDb();
|
|
1287
|
-
await db.
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
|
1856
|
+
async function listPublicNests(userId) {
|
|
1310
1857
|
const db = getDb();
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
|
1870
|
+
async function getNest(nestId) {
|
|
1318
1871
|
const db = getDb();
|
|
1319
|
-
return db.
|
|
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
|
|
1874
|
+
async function deleteNest(nestId) {
|
|
1355
1875
|
const db = getDb();
|
|
1356
|
-
|
|
1357
|
-
const
|
|
1358
|
-
|
|
1359
|
-
]);
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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("
|
|
1367
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
)
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1425
|
-
|
|
1960
|
+
};
|
|
1961
|
+
var engineCache = new NestEngineCache();
|
|
1426
1962
|
|
|
1427
|
-
// src/governance/
|
|
1428
|
-
function
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
}
|
|
1485
|
-
|
|
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
|
|
1523
|
-
|
|
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
|
|
1526
|
-
const
|
|
1527
|
-
const
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
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,
|