@promptowl/contextnest-community 1.7.0 → 1.9.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 +191 -181
- package/LICENSE.md +142 -142
- package/README.md +224 -224
- package/dist/chunk-3JTODC3Y.js +48 -0
- package/dist/{chunk-43DOX4LH.js → chunk-BYS4HDME.js} +45 -49
- package/dist/{chunk-QMLAXQES.js → chunk-DPHV6Q26.js} +311 -19
- package/dist/chunk-FRQJWGN3.js +8 -0
- package/dist/{chunk-VD5QX2ZQ.js → chunk-HRQWNRVI.js} +4 -2
- package/dist/chunk-TIO5XOFD.js +103 -0
- package/dist/chunk-VC3QNHHB.js +664 -0
- package/dist/chunk-XQ46F76G.js +16 -0
- package/dist/client-GI74NPIW.js +11 -0
- package/dist/email-R7DFS6E5.js +6 -0
- package/dist/grants-service-MDLRPMZR.js +21 -0
- package/dist/index.js +2579 -230
- package/dist/{migrations.postgres-3HMGLKOV.js → migrations.postgres-ORSV7UFJ.js} +139 -1
- package/dist/{review-service-2FSKW425.js → review-service-A3XFQTJH.js} +6 -4
- package/dist/{stewardship-service-I2JAFYJU.js → stewardship-service-3KCVVXCW.js} +4 -2
- package/dist/{version-service-FJWVTZKR.js → version-service-6J4OWQL6.js} +3 -2
- package/dist/web3/assets/{hootie-_U3ECslt.svg → hootie-C2ocYkn4.svg} +8 -8
- package/dist/web3/assets/index-CHavHPIT.css +1 -0
- package/dist/web3/assets/index-DyvOBH92.js +934 -0
- package/dist/web3/index.html +14 -14
- package/package.json +161 -152
- package/dist/chunk-MOXICJPD.js +0 -303
- package/dist/web3/assets/index-24dBhQSQ.css +0 -1
- package/dist/web3/assets/index-DpM8J4RN.js +0 -889
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createVersion,
|
|
3
|
+
setApprovedVersion
|
|
4
|
+
} from "./chunk-HRQWNRVI.js";
|
|
5
|
+
import {
|
|
6
|
+
buildDocContext,
|
|
7
|
+
buildTitleMap,
|
|
8
|
+
canUserApprove,
|
|
9
|
+
engineCache,
|
|
10
|
+
resolveStewardsForNode
|
|
11
|
+
} from "./chunk-BYS4HDME.js";
|
|
12
|
+
import {
|
|
13
|
+
insertOrReplace,
|
|
14
|
+
nowExpr
|
|
15
|
+
} from "./chunk-XQ46F76G.js";
|
|
16
|
+
import {
|
|
17
|
+
config,
|
|
18
|
+
getDb
|
|
19
|
+
} from "./chunk-DPHV6Q26.js";
|
|
20
|
+
|
|
21
|
+
// src/governance/review-service.ts
|
|
22
|
+
import { v4 as uuid } from "uuid";
|
|
23
|
+
|
|
24
|
+
// src/notify/slack.ts
|
|
25
|
+
var warnedOnce = false;
|
|
26
|
+
async function notifySlackForNest(nestId, message) {
|
|
27
|
+
if (!config.SLACK_WEBHOOK_URL) return;
|
|
28
|
+
let nestName = nestId;
|
|
29
|
+
try {
|
|
30
|
+
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
31
|
+
nestId
|
|
32
|
+
]);
|
|
33
|
+
if (row?.name) nestName = row.name;
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
return notifySlack(`*[${nestName}]* ${message}`);
|
|
37
|
+
}
|
|
38
|
+
function escapeMrkdwn(s) {
|
|
39
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
40
|
+
}
|
|
41
|
+
async function notifySlack(text) {
|
|
42
|
+
const url = config.SLACK_WEBHOOK_URL;
|
|
43
|
+
if (!url) return;
|
|
44
|
+
try {
|
|
45
|
+
const ctrl = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => ctrl.abort(), 3e3);
|
|
47
|
+
try {
|
|
48
|
+
await fetch(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ text: escapeMrkdwn(text) }),
|
|
52
|
+
signal: ctrl.signal
|
|
53
|
+
});
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (!warnedOnce) {
|
|
59
|
+
warnedOnce = true;
|
|
60
|
+
console.warn(
|
|
61
|
+
"[slack] notification failed (suppressing further warnings):",
|
|
62
|
+
err instanceof Error ? err.message : err
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/notify/email-render.ts
|
|
69
|
+
function escapeHtml(s) {
|
|
70
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
71
|
+
}
|
|
72
|
+
var EVENT = {
|
|
73
|
+
":white_check_mark:": { icon: "\u2705", color: "#16a34a" },
|
|
74
|
+
":x:": { icon: "\u274C", color: "#dc2626" },
|
|
75
|
+
":memo:": { icon: "\u{1F4DD}", color: "#d97706" },
|
|
76
|
+
":key:": { icon: "\u{1F511}", color: "#2563eb" }
|
|
77
|
+
};
|
|
78
|
+
var ALL_EMOJI = Object.fromEntries(
|
|
79
|
+
Object.entries(EVENT).map(([code, m]) => [code, m.icon])
|
|
80
|
+
);
|
|
81
|
+
function renderText(message) {
|
|
82
|
+
return message.replace(/:[a-z_]+:/g, (m) => ALL_EMOJI[m] ?? "").replace(/\*/g, "").replace(/[ \t]{2,}/g, " ").trim();
|
|
83
|
+
}
|
|
84
|
+
var STATUS_META = {
|
|
85
|
+
approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
|
|
86
|
+
rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
|
|
87
|
+
pending_review: {
|
|
88
|
+
subjectWord: "awaiting review",
|
|
89
|
+
label: "Pending review",
|
|
90
|
+
color: "#d97706"
|
|
91
|
+
},
|
|
92
|
+
shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" }
|
|
93
|
+
};
|
|
94
|
+
function renderEmail(d) {
|
|
95
|
+
const meta = STATUS_META[d.status];
|
|
96
|
+
const title = d.docTitle.replace(/[\r\n]+/g, " ").trim();
|
|
97
|
+
const heading = d.status === "shared" ? `New collaborator on ${title}` : `${title} is ${meta.subjectWord}`;
|
|
98
|
+
const subject = heading.replace(/[\r\n]+/g, " ").trim();
|
|
99
|
+
const vLabel = d.version != null ? `v${d.version}` : "";
|
|
100
|
+
const detailsHeader = d.status === "shared" ? "Details" : "Document details";
|
|
101
|
+
const linkLabel = d.status === "shared" ? "Open nest" : "Open document";
|
|
102
|
+
const byT = d.actor ? ` by ${d.actor}` : "";
|
|
103
|
+
const noteT = d.note ? ` with the note: "${d.note}"` : "";
|
|
104
|
+
const vT = vLabel ? ` ${vLabel}` : "";
|
|
105
|
+
let lineT;
|
|
106
|
+
switch (d.status) {
|
|
107
|
+
case "approved":
|
|
108
|
+
lineT = `${title}${vT} has been approved${byT}${noteT}.`;
|
|
109
|
+
break;
|
|
110
|
+
case "rejected":
|
|
111
|
+
lineT = `${title}${vT} has been rejected${byT}${noteT}.`;
|
|
112
|
+
break;
|
|
113
|
+
case "pending_review":
|
|
114
|
+
lineT = `A review has been requested for ${title}${vT}${byT}${noteT}.`;
|
|
115
|
+
break;
|
|
116
|
+
case "shared":
|
|
117
|
+
lineT = `${d.actor || "A user"} was added to ${title} as ${d.permission || "a collaborator"}${d.by ? ` by ${d.by}` : ""}.`;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
const text = [
|
|
121
|
+
"Hi,",
|
|
122
|
+
"",
|
|
123
|
+
lineT,
|
|
124
|
+
"",
|
|
125
|
+
"Document details:",
|
|
126
|
+
` Path: ${d.path}`,
|
|
127
|
+
` Status: ${meta.label}`,
|
|
128
|
+
...d.version != null ? [` Version: v${d.version}`] : [],
|
|
129
|
+
...d.link ? [` Open: ${d.link}`] : [],
|
|
130
|
+
"",
|
|
131
|
+
"Regards,",
|
|
132
|
+
"ContextNest"
|
|
133
|
+
].join("\n");
|
|
134
|
+
const doc = `<strong>${escapeHtml(title)}</strong>`;
|
|
135
|
+
const byH = d.actor ? ` by ${escapeHtml(d.actor)}` : "";
|
|
136
|
+
const noteH = d.note ? ` with the note: “${escapeHtml(d.note)}”` : "";
|
|
137
|
+
const vH = vLabel ? ` ${vLabel}` : "";
|
|
138
|
+
let lineH;
|
|
139
|
+
switch (d.status) {
|
|
140
|
+
case "approved":
|
|
141
|
+
lineH = `${doc}${vH} has been approved${byH}${noteH}.`;
|
|
142
|
+
break;
|
|
143
|
+
case "rejected":
|
|
144
|
+
lineH = `${doc}${vH} has been rejected${byH}${noteH}.`;
|
|
145
|
+
break;
|
|
146
|
+
case "pending_review":
|
|
147
|
+
lineH = `A review has been requested for ${doc}${vH}${byH}${noteH}.`;
|
|
148
|
+
break;
|
|
149
|
+
case "shared":
|
|
150
|
+
lineH = `${escapeHtml(d.actor || "A user")} was added to ${doc} as <strong>${escapeHtml(d.permission || "a collaborator")}</strong>${d.by ? ` by ${escapeHtml(d.by)}` : ""}.`;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
const detailRow = (label, valueHtml) => `<tr>
|
|
154
|
+
<td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
|
|
155
|
+
<td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
|
|
156
|
+
</tr>`;
|
|
157
|
+
const detailRows = detailRow("Path", escapeHtml(d.path)) + detailRow(
|
|
158
|
+
"Status",
|
|
159
|
+
`<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>`
|
|
160
|
+
) + (d.version != null ? detailRow("Version", `v${d.version}`) : "");
|
|
161
|
+
const buttonRow = d.link ? `<tr><td style="padding:20px 24px 0;">
|
|
162
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
|
|
163
|
+
<td bgcolor="${meta.color}" style="border-radius:6px;">
|
|
164
|
+
<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>
|
|
165
|
+
</td>
|
|
166
|
+
</tr></table>
|
|
167
|
+
</td></tr>` : "";
|
|
168
|
+
const html = `<div style="margin:0;padding:24px;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;">
|
|
169
|
+
<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;">
|
|
170
|
+
<tr><td style="padding:20px 24px 0;">
|
|
171
|
+
<div style="font-size:12px;font-weight:700;color:#6b7280;letter-spacing:0.06em;text-transform:uppercase;">ContextNest</div>
|
|
172
|
+
</td></tr>
|
|
173
|
+
<tr><td style="padding:10px 24px 0;">
|
|
174
|
+
<h1 style="margin:0;font-size:19px;line-height:1.35;font-weight:700;color:#111827;">${escapeHtml(heading)}</h1>
|
|
175
|
+
</td></tr>
|
|
176
|
+
<tr><td style="padding:16px 24px 0;font-size:14px;line-height:1.6;color:#374151;">
|
|
177
|
+
Hi,<br><br>${lineH}
|
|
178
|
+
</td></tr>
|
|
179
|
+
<tr><td style="padding:20px 24px 0;">
|
|
180
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #eef0f2;border-radius:8px;">
|
|
181
|
+
<tr><td style="padding:12px 16px;">
|
|
182
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;letter-spacing:0.04em;text-transform:uppercase;padding-bottom:4px;">${detailsHeader}</div>
|
|
183
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${detailRows}</table>
|
|
184
|
+
</td></tr>
|
|
185
|
+
</table>
|
|
186
|
+
</td></tr>
|
|
187
|
+
${buttonRow}
|
|
188
|
+
<tr><td style="padding:22px 24px 24px;font-size:14px;line-height:1.6;color:#374151;">
|
|
189
|
+
Regards,<br>ContextNest
|
|
190
|
+
</td></tr>
|
|
191
|
+
<tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
|
|
192
|
+
Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
|
|
193
|
+
</td></tr>
|
|
194
|
+
</table>
|
|
195
|
+
</div>`;
|
|
196
|
+
return { subject, text, html };
|
|
197
|
+
}
|
|
198
|
+
function renderHtml(message, nestName) {
|
|
199
|
+
const lead = message.match(/^(:[a-z_]+:)\s*/);
|
|
200
|
+
const meta = lead && EVENT[lead[1]] || { icon: "\u{1F514}", color: "#475569" };
|
|
201
|
+
const rest = lead ? message.slice(lead[0].length) : message;
|
|
202
|
+
const body = escapeHtml(rest).replace(/\*([^*]+)\*/g, "<strong>$1</strong>").replace(/\n/g, "<br>");
|
|
203
|
+
const safeNest = escapeHtml(nestName);
|
|
204
|
+
return `<div style="background:#f6f8fa;padding:24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
|
205
|
+
<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;">
|
|
206
|
+
<tr><td style="padding:20px 24px 16px;border-bottom:1px solid #f0f0f0;">
|
|
207
|
+
<div style="font-size:15px;font-weight:700;color:#111827;letter-spacing:-0.01em;">ContextNest</div>
|
|
208
|
+
<div style="font-size:13px;color:#6b7280;margin-top:2px;">${safeNest}</div>
|
|
209
|
+
</td></tr>
|
|
210
|
+
<tr><td style="padding:24px;">
|
|
211
|
+
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
|
212
|
+
<td valign="top" style="font-size:22px;line-height:1;padding-right:14px;">${meta.icon}</td>
|
|
213
|
+
<td valign="top" style="font-size:15px;line-height:1.55;color:#111827;border-left:3px solid ${meta.color};padding-left:14px;">${body}</td>
|
|
214
|
+
</tr></table>
|
|
215
|
+
</td></tr>
|
|
216
|
+
<tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
|
|
217
|
+
Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
|
|
218
|
+
</td></tr>
|
|
219
|
+
</table>
|
|
220
|
+
</div>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/notify/email.ts
|
|
224
|
+
var transporterPromise = null;
|
|
225
|
+
var configuredUrl = null;
|
|
226
|
+
var warnedOnce2 = false;
|
|
227
|
+
var SMTP_TIMEOUT_DEFAULTS = [
|
|
228
|
+
["connectionTimeout", 5e3],
|
|
229
|
+
["greetingTimeout", 5e3],
|
|
230
|
+
["socketTimeout", 1e4]
|
|
231
|
+
];
|
|
232
|
+
function withTimeouts(url) {
|
|
233
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
234
|
+
const missing = SMTP_TIMEOUT_DEFAULTS.filter(
|
|
235
|
+
([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
|
|
236
|
+
).map(([name, ms]) => `${name}=${ms}`);
|
|
237
|
+
if (missing.length === 0) return url;
|
|
238
|
+
return url + (url.includes("?") ? "&" : "?") + missing.join("&");
|
|
239
|
+
}
|
|
240
|
+
async function getTransporter() {
|
|
241
|
+
const url = config.SMTP_URL;
|
|
242
|
+
if (!url) return null;
|
|
243
|
+
if (!transporterPromise || configuredUrl !== url) {
|
|
244
|
+
configuredUrl = url;
|
|
245
|
+
transporterPromise = import("nodemailer").then(
|
|
246
|
+
(nodemailer) => nodemailer.default.createTransport(withTimeouts(url), {
|
|
247
|
+
// Per-message defaults (belt-and-braces; also set on each sendMail).
|
|
248
|
+
disableFileAccess: true,
|
|
249
|
+
disableUrlAccess: true
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
transporterPromise.catch(() => {
|
|
253
|
+
transporterPromise = null;
|
|
254
|
+
configuredUrl = null;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return transporterPromise;
|
|
258
|
+
}
|
|
259
|
+
async function notifyEmailForNest(nestId, message, details) {
|
|
260
|
+
const from = config.NOTIFY_EMAIL_FROM;
|
|
261
|
+
const to = config.NOTIFY_EMAIL_TO;
|
|
262
|
+
if (!config.SMTP_URL || !from || !to) return;
|
|
263
|
+
try {
|
|
264
|
+
const transporter = await getTransporter();
|
|
265
|
+
if (!transporter) return;
|
|
266
|
+
let subject;
|
|
267
|
+
let text;
|
|
268
|
+
let html;
|
|
269
|
+
if (details) {
|
|
270
|
+
({ subject, text, html } = renderEmail(details));
|
|
271
|
+
} else {
|
|
272
|
+
let name = nestId;
|
|
273
|
+
try {
|
|
274
|
+
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
275
|
+
nestId
|
|
276
|
+
]);
|
|
277
|
+
if (row?.name) name = row.name;
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
const safeName = name.replace(/[\r\n]+/g, " ").trim();
|
|
281
|
+
subject = `ContextNest \xB7 ${safeName}`;
|
|
282
|
+
text = renderText(message);
|
|
283
|
+
html = renderHtml(message, safeName);
|
|
284
|
+
}
|
|
285
|
+
await transporter.sendMail({
|
|
286
|
+
from,
|
|
287
|
+
to,
|
|
288
|
+
subject,
|
|
289
|
+
text,
|
|
290
|
+
html,
|
|
291
|
+
disableFileAccess: true,
|
|
292
|
+
disableUrlAccess: true
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (!warnedOnce2) {
|
|
296
|
+
warnedOnce2 = true;
|
|
297
|
+
console.warn(
|
|
298
|
+
"[email] notification failed (suppressing further warnings):",
|
|
299
|
+
err instanceof Error ? err.message : err
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/notify/notify.ts
|
|
306
|
+
var DIGEST_LINE_CAP = 10;
|
|
307
|
+
var buffers = /* @__PURE__ */ new Map();
|
|
308
|
+
function buildText(lines) {
|
|
309
|
+
if (lines.length === 1) return lines[0];
|
|
310
|
+
const shown = lines.slice(0, DIGEST_LINE_CAP).map((l) => `\u2022 ${l}`).join("\n");
|
|
311
|
+
const more = lines.length > DIGEST_LINE_CAP ? `
|
|
312
|
+
\u2026and ${lines.length - DIGEST_LINE_CAP} more` : "";
|
|
313
|
+
return `${lines.length} updates:
|
|
314
|
+
${shown}${more}`;
|
|
315
|
+
}
|
|
316
|
+
async function flush(nestId) {
|
|
317
|
+
const buf = buffers.get(nestId);
|
|
318
|
+
if (!buf) return;
|
|
319
|
+
buffers.delete(nestId);
|
|
320
|
+
const text = buildText(buf.events.map((e) => e.line));
|
|
321
|
+
const details = buf.events.length === 1 ? buf.events[0].details : void 0;
|
|
322
|
+
await Promise.allSettled([
|
|
323
|
+
notifySlackForNest(nestId, text),
|
|
324
|
+
notifyEmailForNest(nestId, text, details)
|
|
325
|
+
]);
|
|
326
|
+
}
|
|
327
|
+
async function notifyNestEvent(nestId, message, details) {
|
|
328
|
+
if (!config.SLACK_WEBHOOK_URL && !config.SMTP_URL) return;
|
|
329
|
+
const buf = buffers.get(nestId);
|
|
330
|
+
if (buf) {
|
|
331
|
+
buf.events.push({ line: message, details });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const timer = setTimeout(() => void flush(nestId), config.NOTIFY_DEBOUNCE_MS);
|
|
335
|
+
timer.unref?.();
|
|
336
|
+
buffers.set(nestId, { events: [{ line: message, details }], timer });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/governance/safe-publish.ts
|
|
340
|
+
import {
|
|
341
|
+
publishDocument,
|
|
342
|
+
serializeDocument
|
|
343
|
+
} from "@promptowl/contextnest-engine";
|
|
344
|
+
async function safePublishDocument(storage, docId, options) {
|
|
345
|
+
const node = await storage.readDocument(docId);
|
|
346
|
+
const cleanedFrontmatter = stripUndefinedDeep(node.frontmatter);
|
|
347
|
+
const cleanedNode = { ...node, frontmatter: cleanedFrontmatter };
|
|
348
|
+
await storage.writeDocument(docId, serializeDocument(cleanedNode));
|
|
349
|
+
return publishDocument(storage, docId, options);
|
|
350
|
+
}
|
|
351
|
+
function stripUndefinedDeep(value) {
|
|
352
|
+
if (Array.isArray(value)) {
|
|
353
|
+
return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
|
|
354
|
+
}
|
|
355
|
+
if (value && typeof value === "object") {
|
|
356
|
+
const out = {};
|
|
357
|
+
for (const [k, v] of Object.entries(value)) {
|
|
358
|
+
if (v === void 0) continue;
|
|
359
|
+
out[k] = stripUndefinedDeep(v);
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/governance/review-service.ts
|
|
367
|
+
async function submitForReview(params) {
|
|
368
|
+
const db = getDb();
|
|
369
|
+
const existing = await db.get(
|
|
370
|
+
"SELECT id FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending'",
|
|
371
|
+
[params.nestId, params.nodeId]
|
|
372
|
+
);
|
|
373
|
+
if (existing) {
|
|
374
|
+
throw new Error("A review is already pending for this node");
|
|
375
|
+
}
|
|
376
|
+
const id = uuid();
|
|
377
|
+
await db.run(
|
|
378
|
+
`INSERT INTO review_requests
|
|
379
|
+
(id, nest_id, node_id, version, requested_by, request_note, priority)
|
|
380
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
381
|
+
[
|
|
382
|
+
id,
|
|
383
|
+
params.nestId,
|
|
384
|
+
params.nodeId,
|
|
385
|
+
params.version,
|
|
386
|
+
params.requestedBy,
|
|
387
|
+
params.note || null,
|
|
388
|
+
params.priority || "normal"
|
|
389
|
+
]
|
|
390
|
+
);
|
|
391
|
+
await db.run(
|
|
392
|
+
"UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
393
|
+
[params.nestId, params.nodeId, params.version]
|
|
394
|
+
);
|
|
395
|
+
const reviewCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
|
|
396
|
+
void notifyNestEvent(
|
|
397
|
+
params.nestId,
|
|
398
|
+
`:memo: Review requested on *${reviewCtx.label}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`,
|
|
399
|
+
{
|
|
400
|
+
status: "pending_review",
|
|
401
|
+
docTitle: reviewCtx.docTitle,
|
|
402
|
+
path: reviewCtx.path,
|
|
403
|
+
link: reviewCtx.link,
|
|
404
|
+
actor: params.requestedBy,
|
|
405
|
+
version: params.version,
|
|
406
|
+
note: params.note || null
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
return await getReviewRequest(id);
|
|
410
|
+
}
|
|
411
|
+
async function approve(params) {
|
|
412
|
+
const db = getDb();
|
|
413
|
+
const pending = await db.get(
|
|
414
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
415
|
+
[params.nestId, params.nodeId]
|
|
416
|
+
);
|
|
417
|
+
if (!pending) {
|
|
418
|
+
throw new Error("No pending review for this node");
|
|
419
|
+
}
|
|
420
|
+
if (!params.override) {
|
|
421
|
+
const check = await canUserApprove(
|
|
422
|
+
params.nestId,
|
|
423
|
+
params.nodeId,
|
|
424
|
+
params.approvedBy
|
|
425
|
+
);
|
|
426
|
+
if (!check.allowed) {
|
|
427
|
+
throw new Error(check.reason);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
await db.run(
|
|
431
|
+
`UPDATE review_requests
|
|
432
|
+
SET status = 'approved', resolved_by = ?, resolved_at = ${nowExpr(db)},
|
|
433
|
+
resolution_note = ?, is_override = ?
|
|
434
|
+
WHERE id = ?`,
|
|
435
|
+
[params.approvedBy, params.note || null, params.override ? 1 : 0, pending.id]
|
|
436
|
+
);
|
|
437
|
+
await db.run(
|
|
438
|
+
"UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
439
|
+
[params.nestId, params.nodeId, params.version]
|
|
440
|
+
);
|
|
441
|
+
await db.run(
|
|
442
|
+
insertOrReplace(
|
|
443
|
+
db,
|
|
444
|
+
`INSERT INTO approved_versions (nest_id, node_id, approved_version, approved_by)
|
|
445
|
+
VALUES (?, ?, ?, ?)`,
|
|
446
|
+
["nest_id", "node_id"],
|
|
447
|
+
`approved_version = excluded.approved_version, approved_by = excluded.approved_by, approved_at = ${nowExpr(db)}`
|
|
448
|
+
),
|
|
449
|
+
[params.nestId, params.nodeId, params.version, params.approvedBy]
|
|
450
|
+
);
|
|
451
|
+
try {
|
|
452
|
+
const { storage } = await engineCache.get(params.nestId);
|
|
453
|
+
const result = await safePublishDocument(storage, params.nodeId, {
|
|
454
|
+
editedBy: params.approvedBy,
|
|
455
|
+
note: params.note || `Approved review request ${pending.id}`
|
|
456
|
+
});
|
|
457
|
+
const engineVersion = result.versionEntry.version;
|
|
458
|
+
if (engineVersion !== params.version) {
|
|
459
|
+
const node = result.node;
|
|
460
|
+
const tags = node.frontmatter.tags || [];
|
|
461
|
+
await createVersion({
|
|
462
|
+
nestId: params.nestId,
|
|
463
|
+
nodeId: params.nodeId,
|
|
464
|
+
version: engineVersion,
|
|
465
|
+
content: node.body || "",
|
|
466
|
+
author: params.approvedBy,
|
|
467
|
+
status: "published",
|
|
468
|
+
tags,
|
|
469
|
+
changeNote: params.note || `Approved review request ${pending.id}`
|
|
470
|
+
});
|
|
471
|
+
await setApprovedVersion(
|
|
472
|
+
params.nestId,
|
|
473
|
+
params.nodeId,
|
|
474
|
+
engineVersion,
|
|
475
|
+
params.approvedBy
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
console.error(
|
|
480
|
+
`publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
|
|
481
|
+
err
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const approveCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
|
|
485
|
+
void notifyNestEvent(
|
|
486
|
+
params.nestId,
|
|
487
|
+
`:white_check_mark: *${approveCtx.label}* v${params.version} approved by ${params.approvedBy}`,
|
|
488
|
+
{
|
|
489
|
+
status: "approved",
|
|
490
|
+
docTitle: approveCtx.docTitle,
|
|
491
|
+
path: approveCtx.path,
|
|
492
|
+
link: approveCtx.link,
|
|
493
|
+
actor: params.approvedBy,
|
|
494
|
+
version: params.version
|
|
495
|
+
}
|
|
496
|
+
);
|
|
497
|
+
return await getReviewRequest(pending.id);
|
|
498
|
+
}
|
|
499
|
+
async function reject(params) {
|
|
500
|
+
const db = getDb();
|
|
501
|
+
const pending = await db.get(
|
|
502
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
503
|
+
[params.nestId, params.nodeId]
|
|
504
|
+
);
|
|
505
|
+
if (!pending) {
|
|
506
|
+
throw new Error("No pending review for this node");
|
|
507
|
+
}
|
|
508
|
+
const check = await canUserApprove(
|
|
509
|
+
params.nestId,
|
|
510
|
+
params.nodeId,
|
|
511
|
+
params.rejectedBy
|
|
512
|
+
);
|
|
513
|
+
if (!check.allowed) {
|
|
514
|
+
throw new Error(check.reason);
|
|
515
|
+
}
|
|
516
|
+
await db.run(
|
|
517
|
+
`UPDATE review_requests
|
|
518
|
+
SET status = 'rejected', resolved_by = ?, resolved_at = ${nowExpr(db)}, resolution_note = ?
|
|
519
|
+
WHERE id = ?`,
|
|
520
|
+
[params.rejectedBy, params.note, pending.id]
|
|
521
|
+
);
|
|
522
|
+
await db.run(
|
|
523
|
+
"UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
524
|
+
[params.nestId, params.nodeId, params.version]
|
|
525
|
+
);
|
|
526
|
+
const rejectCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
|
|
527
|
+
void notifyNestEvent(
|
|
528
|
+
params.nestId,
|
|
529
|
+
`:x: *${rejectCtx.label}* v${params.version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`,
|
|
530
|
+
{
|
|
531
|
+
status: "rejected",
|
|
532
|
+
docTitle: rejectCtx.docTitle,
|
|
533
|
+
path: rejectCtx.path,
|
|
534
|
+
link: rejectCtx.link,
|
|
535
|
+
actor: params.rejectedBy,
|
|
536
|
+
version: params.version,
|
|
537
|
+
note: params.note || null
|
|
538
|
+
}
|
|
539
|
+
);
|
|
540
|
+
return await getReviewRequest(pending.id);
|
|
541
|
+
}
|
|
542
|
+
async function cancelReview(params) {
|
|
543
|
+
const db = getDb();
|
|
544
|
+
const pending = await db.get(
|
|
545
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
546
|
+
[params.nestId, params.nodeId]
|
|
547
|
+
);
|
|
548
|
+
if (!pending) return null;
|
|
549
|
+
await db.run(
|
|
550
|
+
`UPDATE review_requests
|
|
551
|
+
SET status = 'cancelled', resolved_by = ?, resolved_at = ${nowExpr(db)}
|
|
552
|
+
WHERE id = ?`,
|
|
553
|
+
[params.cancelledBy, pending.id]
|
|
554
|
+
);
|
|
555
|
+
await db.run(
|
|
556
|
+
"UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
557
|
+
[params.nestId, params.nodeId, pending.version]
|
|
558
|
+
);
|
|
559
|
+
return await getReviewRequest(pending.id);
|
|
560
|
+
}
|
|
561
|
+
async function getReviewQueue(params) {
|
|
562
|
+
const db = getDb();
|
|
563
|
+
let whereClauses = [];
|
|
564
|
+
const args = [];
|
|
565
|
+
if (params.nestId) {
|
|
566
|
+
whereClauses.push("nest_id = ?");
|
|
567
|
+
args.push(params.nestId);
|
|
568
|
+
}
|
|
569
|
+
if (params.status) {
|
|
570
|
+
const statuses = Array.isArray(params.status) ? params.status : [params.status];
|
|
571
|
+
whereClauses.push(
|
|
572
|
+
`status IN (${statuses.map(() => "?").join(",")})`
|
|
573
|
+
);
|
|
574
|
+
args.push(...statuses);
|
|
575
|
+
}
|
|
576
|
+
const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
577
|
+
const total = (await db.get(
|
|
578
|
+
`SELECT COUNT(*) as c FROM review_requests ${where}`,
|
|
579
|
+
args
|
|
580
|
+
)).c;
|
|
581
|
+
const limit = params.limit || 50;
|
|
582
|
+
const offset = params.offset || 0;
|
|
583
|
+
const rows = await db.all(
|
|
584
|
+
`SELECT * FROM review_requests ${where}
|
|
585
|
+
ORDER BY
|
|
586
|
+
CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,
|
|
587
|
+
requested_at DESC
|
|
588
|
+
LIMIT ? OFFSET ?`,
|
|
589
|
+
[...args, limit, offset]
|
|
590
|
+
);
|
|
591
|
+
let requests = rows.map(rowToReviewRequest);
|
|
592
|
+
if (params.stewardEmail) {
|
|
593
|
+
const filtered = [];
|
|
594
|
+
for (const r of requests) {
|
|
595
|
+
const stewards = await resolveStewardsForNode(r.nestId, r.nodeId);
|
|
596
|
+
if (stewards.some((s) => s.steward.userEmail === params.stewardEmail)) {
|
|
597
|
+
filtered.push(r);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
requests = filtered;
|
|
601
|
+
}
|
|
602
|
+
const titleMapsByNest = /* @__PURE__ */ new Map();
|
|
603
|
+
const nestIds = new Set(requests.map((r) => r.nestId));
|
|
604
|
+
for (const nid of nestIds) {
|
|
605
|
+
titleMapsByNest.set(nid, await buildTitleMap(nid));
|
|
606
|
+
}
|
|
607
|
+
requests = requests.map((r) => ({
|
|
608
|
+
...r,
|
|
609
|
+
title: titleMapsByNest.get(r.nestId)?.get(r.nodeId)
|
|
610
|
+
}));
|
|
611
|
+
return { requests, total };
|
|
612
|
+
}
|
|
613
|
+
async function getReviewHistory(nestId, nodeId) {
|
|
614
|
+
const db = getDb();
|
|
615
|
+
const rows = await db.all(
|
|
616
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? ORDER BY requested_at DESC",
|
|
617
|
+
[nestId, nodeId]
|
|
618
|
+
);
|
|
619
|
+
return rows.map(rowToReviewRequest);
|
|
620
|
+
}
|
|
621
|
+
async function getPendingReview(nestId, nodeId) {
|
|
622
|
+
const db = getDb();
|
|
623
|
+
const row = await db.get(
|
|
624
|
+
"SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
|
|
625
|
+
[nestId, nodeId]
|
|
626
|
+
);
|
|
627
|
+
return row ? rowToReviewRequest(row) : null;
|
|
628
|
+
}
|
|
629
|
+
async function getReviewRequest(id) {
|
|
630
|
+
const db = getDb();
|
|
631
|
+
const row = await db.get(
|
|
632
|
+
"SELECT * FROM review_requests WHERE id = ?",
|
|
633
|
+
[id]
|
|
634
|
+
);
|
|
635
|
+
return row ? rowToReviewRequest(row) : null;
|
|
636
|
+
}
|
|
637
|
+
function rowToReviewRequest(row) {
|
|
638
|
+
return {
|
|
639
|
+
id: row.id,
|
|
640
|
+
nestId: row.nest_id,
|
|
641
|
+
nodeId: row.node_id,
|
|
642
|
+
version: row.version,
|
|
643
|
+
requestedBy: row.requested_by,
|
|
644
|
+
requestedAt: row.requested_at,
|
|
645
|
+
requestNote: row.request_note || void 0,
|
|
646
|
+
status: row.status,
|
|
647
|
+
resolvedBy: row.resolved_by || void 0,
|
|
648
|
+
resolvedAt: row.resolved_at || void 0,
|
|
649
|
+
resolutionNote: row.resolution_note || void 0,
|
|
650
|
+
priority: row.priority
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export {
|
|
655
|
+
safePublishDocument,
|
|
656
|
+
notifyNestEvent,
|
|
657
|
+
submitForReview,
|
|
658
|
+
approve,
|
|
659
|
+
reject,
|
|
660
|
+
cancelReview,
|
|
661
|
+
getReviewQueue,
|
|
662
|
+
getReviewHistory,
|
|
663
|
+
getPendingReview
|
|
664
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/db/sql.ts
|
|
2
|
+
function nowExpr(db) {
|
|
3
|
+
return db.dialect === "sqlite" ? "datetime('now')" : "to_char((now() AT TIME ZONE 'utc'), 'YYYY-MM-DD HH24:MI:SS')";
|
|
4
|
+
}
|
|
5
|
+
function insertOrIgnore(db, insertSql) {
|
|
6
|
+
return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR IGNORE INTO") : `${insertSql} ON CONFLICT DO NOTHING`;
|
|
7
|
+
}
|
|
8
|
+
function insertOrReplace(db, insertSql, conflictCols, set) {
|
|
9
|
+
return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR REPLACE INTO") : `${insertSql} ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${set}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
nowExpr,
|
|
14
|
+
insertOrIgnore,
|
|
15
|
+
insertOrReplace
|
|
16
|
+
};
|