@promptowl/contextnest-community 1.0.0 → 1.1.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 +6 -4
- package/README.md +80 -7
- package/dist/{chunk-BLOPZDPL.js → chunk-7UTMBL6Z.js} +22 -7
- package/dist/{chunk-XDCW4HTW.js → chunk-S2EWN2VA.js} +83 -5
- package/dist/{chunk-2TW25QEA.js → chunk-TDAX3JOT.js} +168 -22
- package/dist/chunk-WCOUCBDJ.js +1406 -0
- package/dist/{chunk-7K2LLJXK.js → chunk-XRK6SQSC.js} +1 -1
- package/dist/index.js +1418 -1038
- package/dist/{keys-YV33AJK3.js → keys-73STFJJB.js} +1 -1
- package/dist/{review-service-2JHZHZWJ.js → review-service-3OJIPYNV.js} +4 -3
- package/dist/{stewardship-service-ZJATH6OM.js → stewardship-service-3XGX7QIN.js} +20 -4
- package/dist/{version-service-2MZJGE3H.js → version-service-UODXLAOJ.js} +8 -4
- package/dist/web3/assets/index-BLxRS7jD.js +673 -0
- package/dist/web3/assets/index-DszK6Vkc.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +136 -125
- package/dist/chunk-2FXVMVZJ.js +0 -540
- package/dist/web3/assets/index-BlGzOlFt.css +0 -1
- package/dist/web3/assets/index-C3W5d7fT.js +0 -591
package/dist/index.js
CHANGED
|
@@ -6,16 +6,7 @@ import {
|
|
|
6
6
|
hashPassword,
|
|
7
7
|
parseBearerToken,
|
|
8
8
|
verifyPassword
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import {
|
|
11
|
-
checkConflict,
|
|
12
|
-
createVersion,
|
|
13
|
-
getApprovedVersion,
|
|
14
|
-
getCurrentVersion,
|
|
15
|
-
getDisplayStatus,
|
|
16
|
-
getVersions,
|
|
17
|
-
setApprovedVersion
|
|
18
|
-
} from "./chunk-BLOPZDPL.js";
|
|
9
|
+
} from "./chunk-XRK6SQSC.js";
|
|
19
10
|
import {
|
|
20
11
|
approve,
|
|
21
12
|
cancelReview,
|
|
@@ -23,27 +14,77 @@ import {
|
|
|
23
14
|
getReviewHistory,
|
|
24
15
|
getReviewQueue,
|
|
25
16
|
reject,
|
|
17
|
+
safePublishDocument,
|
|
26
18
|
submitForReview
|
|
27
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-S2EWN2VA.js";
|
|
20
|
+
import {
|
|
21
|
+
checkConflict,
|
|
22
|
+
createVersion,
|
|
23
|
+
getApprovedVersion,
|
|
24
|
+
getCurrentVersion,
|
|
25
|
+
getDisplayStatus,
|
|
26
|
+
getVersions,
|
|
27
|
+
setApprovedVersion
|
|
28
|
+
} from "./chunk-7UTMBL6Z.js";
|
|
28
29
|
import {
|
|
29
|
-
|
|
30
|
+
AppError,
|
|
31
|
+
ConflictError,
|
|
32
|
+
ForbiddenError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
canCreateInNest,
|
|
36
|
+
canManageStewards,
|
|
37
|
+
canManageWith,
|
|
30
38
|
canUserAccess,
|
|
31
39
|
canUserApprove,
|
|
32
40
|
canUserEdit,
|
|
41
|
+
createNest,
|
|
33
42
|
createStewardRecord,
|
|
43
|
+
deleteNest,
|
|
44
|
+
disableStewardshipAndWipeGovernance,
|
|
45
|
+
engineCache,
|
|
46
|
+
getCollaboratorRole,
|
|
47
|
+
getCurrentLicense,
|
|
48
|
+
getNest,
|
|
49
|
+
getStewardRolesForUser,
|
|
34
50
|
getStewardsForNest,
|
|
51
|
+
getStewardsForUser,
|
|
52
|
+
getSuspensionReason,
|
|
53
|
+
importNest,
|
|
54
|
+
installLicenseKey,
|
|
55
|
+
isLicenseAdminEmail,
|
|
56
|
+
isLicenseAdminUserId,
|
|
57
|
+
isPublicReader,
|
|
58
|
+
isStewardshipEnabled,
|
|
35
59
|
isSuperAdmin,
|
|
60
|
+
isSuspended,
|
|
61
|
+
listNests,
|
|
62
|
+
listPublicNests,
|
|
63
|
+
listSharedNests,
|
|
36
64
|
listStewards,
|
|
37
65
|
loadAccessConfig,
|
|
66
|
+
nestAllowsSelfApprove,
|
|
67
|
+
permissionLevel,
|
|
38
68
|
removeSteward,
|
|
69
|
+
resolveNestPermission,
|
|
39
70
|
resolveStewardsForNode,
|
|
40
71
|
resolveStewardsWithFallback,
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
resolveUserRoles,
|
|
73
|
+
setAllowSelfApprove,
|
|
74
|
+
setStewardshipEnabled,
|
|
75
|
+
startLicenseSafetyPoll,
|
|
76
|
+
startTelemetryLoop,
|
|
77
|
+
syncFromConfig,
|
|
78
|
+
trackEvent,
|
|
79
|
+
updateSteward,
|
|
80
|
+
validateLicense
|
|
81
|
+
} from "./chunk-WCOUCBDJ.js";
|
|
43
82
|
import {
|
|
83
|
+
ANON_EMAIL,
|
|
84
|
+
ANON_USER_ID,
|
|
44
85
|
config,
|
|
45
86
|
getDb
|
|
46
|
-
} from "./chunk-
|
|
87
|
+
} from "./chunk-TDAX3JOT.js";
|
|
47
88
|
|
|
48
89
|
// src/index.ts
|
|
49
90
|
import { serve } from "@hono/node-server";
|
|
@@ -168,427 +209,9 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
168
209
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
169
210
|
});
|
|
170
211
|
|
|
171
|
-
// src/shared/errors.ts
|
|
172
|
-
var AppError = class extends Error {
|
|
173
|
-
constructor(statusCode, message) {
|
|
174
|
-
super(message);
|
|
175
|
-
this.statusCode = statusCode;
|
|
176
|
-
this.name = "AppError";
|
|
177
|
-
}
|
|
178
|
-
statusCode;
|
|
179
|
-
};
|
|
180
|
-
var NotFoundError = class extends AppError {
|
|
181
|
-
constructor(message = "Not found") {
|
|
182
|
-
super(404, message);
|
|
183
|
-
this.name = "NotFoundError";
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
var ValidationError = class extends AppError {
|
|
187
|
-
constructor(message) {
|
|
188
|
-
super(400, message);
|
|
189
|
-
this.name = "ValidationError";
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
var ConflictError = class extends AppError {
|
|
193
|
-
constructor(message) {
|
|
194
|
-
super(409, message);
|
|
195
|
-
this.name = "ConflictError";
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
// src/telemetry/tracker.ts
|
|
200
|
-
function trackEvent(event, data) {
|
|
201
|
-
if (!config.TELEMETRY_ENABLED) return;
|
|
202
|
-
try {
|
|
203
|
-
const db = getDb();
|
|
204
|
-
db.prepare(
|
|
205
|
-
"INSERT INTO telemetry_events (event, data_json) VALUES (?, ?)"
|
|
206
|
-
).run(event, data ? JSON.stringify(data) : null);
|
|
207
|
-
} catch {
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
async function flushTelemetry() {
|
|
211
|
-
if (!config.TELEMETRY_ENABLED || !config.PROMPTOWL_KEY) return;
|
|
212
|
-
const db = getDb();
|
|
213
|
-
const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get()?.c || 0;
|
|
214
|
-
const nestCount = db.prepare("SELECT COUNT(*) as c FROM nests").get()?.c || 0;
|
|
215
|
-
const events = db.prepare(
|
|
216
|
-
"SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
|
|
217
|
-
).all();
|
|
218
|
-
if (events.length === 0 && userCount === 0) return;
|
|
219
|
-
const payload = {
|
|
220
|
-
server_key: config.PROMPTOWL_KEY,
|
|
221
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
222
|
-
stats: { users: userCount, nests: nestCount },
|
|
223
|
-
events: events.map((e) => ({
|
|
224
|
-
event: e.event,
|
|
225
|
-
data: e.data_json ? JSON.parse(e.data_json) : null,
|
|
226
|
-
at: e.created_at
|
|
227
|
-
}))
|
|
228
|
-
};
|
|
229
|
-
try {
|
|
230
|
-
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
231
|
-
const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
|
|
232
|
-
method: "POST",
|
|
233
|
-
headers: { "Content-Type": "application/json" },
|
|
234
|
-
body: JSON.stringify(payload)
|
|
235
|
-
});
|
|
236
|
-
if (res.ok && events.length > 0) {
|
|
237
|
-
const ids = events.map((e) => e.id);
|
|
238
|
-
db.prepare(
|
|
239
|
-
`UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
|
|
240
|
-
).run(...ids);
|
|
241
|
-
}
|
|
242
|
-
} catch {
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
var telemetryTimer = null;
|
|
246
|
-
function startTelemetryLoop() {
|
|
247
|
-
if (!config.TELEMETRY_ENABLED) return;
|
|
248
|
-
setTimeout(() => flushTelemetry(), 3e4);
|
|
249
|
-
telemetryTimer = setInterval(
|
|
250
|
-
() => flushTelemetry(),
|
|
251
|
-
config.TELEMETRY_INTERVAL_MS
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// src/auth/license.ts
|
|
256
|
-
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
257
|
-
var currentLicense = null;
|
|
258
|
-
function getCurrentLicense() {
|
|
259
|
-
return currentLicense;
|
|
260
|
-
}
|
|
261
|
-
function isLicenseAdminEmail(email) {
|
|
262
|
-
if (!email) return false;
|
|
263
|
-
const lic = currentLicense;
|
|
264
|
-
if (!lic?.valid || !lic.ownerEmail) return false;
|
|
265
|
-
return lic.ownerEmail.toLowerCase() === email.toLowerCase();
|
|
266
|
-
}
|
|
267
|
-
function isLicenseAdminUserId(userId) {
|
|
268
|
-
try {
|
|
269
|
-
const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
270
|
-
return isLicenseAdminEmail(row?.email);
|
|
271
|
-
} catch {
|
|
272
|
-
return false;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
function upsertEnvVar(filePath, varName, value) {
|
|
276
|
-
const prefix = `${varName}=`;
|
|
277
|
-
let lines = [];
|
|
278
|
-
if (existsSync(filePath)) {
|
|
279
|
-
lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
280
|
-
}
|
|
281
|
-
const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
|
|
282
|
-
if (value !== null) {
|
|
283
|
-
filtered.push(`${prefix}${value}`);
|
|
284
|
-
}
|
|
285
|
-
while (filtered.length && filtered[filtered.length - 1] === "") {
|
|
286
|
-
filtered.pop();
|
|
287
|
-
}
|
|
288
|
-
writeFileSync(filePath, filtered.join("\n") + "\n", "utf8");
|
|
289
|
-
}
|
|
290
|
-
async function installLicenseKey(key) {
|
|
291
|
-
const trimmed = key.trim();
|
|
292
|
-
if (!trimmed.startsWith("pk_")) {
|
|
293
|
-
throw new Error("Invalid license key format. Must start with pk_.");
|
|
294
|
-
}
|
|
295
|
-
const previousKey = process.env.PROMPTOWL_KEY || "";
|
|
296
|
-
process.env.PROMPTOWL_KEY = trimmed;
|
|
297
|
-
const info = await validateLicense({ forceFresh: true });
|
|
298
|
-
if (!info.valid) {
|
|
299
|
-
process.env.PROMPTOWL_KEY = previousKey;
|
|
300
|
-
if (previousKey) {
|
|
301
|
-
await validateLicense({ forceFresh: true });
|
|
302
|
-
}
|
|
303
|
-
return info;
|
|
304
|
-
}
|
|
305
|
-
try {
|
|
306
|
-
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
|
|
307
|
-
} catch (err) {
|
|
308
|
-
console.warn("[license] failed to write .env:", err);
|
|
309
|
-
}
|
|
310
|
-
startLicenseWatcher();
|
|
311
|
-
return info;
|
|
312
|
-
}
|
|
313
|
-
var watcherActive = false;
|
|
314
|
-
var watcherAbort = null;
|
|
315
|
-
var WATCHER_BACKOFF_MIN_MS = 2 * 1e3;
|
|
316
|
-
var WATCHER_BACKOFF_MAX_MS = 60 * 1e3;
|
|
317
|
-
function startLicenseWatcher() {
|
|
318
|
-
if (watcherActive) {
|
|
319
|
-
watcherAbort?.abort();
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
watcherActive = true;
|
|
323
|
-
void runLicenseWatcher();
|
|
324
|
-
}
|
|
325
|
-
async function runLicenseWatcher() {
|
|
326
|
-
let backoff = WATCHER_BACKOFF_MIN_MS;
|
|
327
|
-
while (watcherActive) {
|
|
328
|
-
const key = config.PROMPTOWL_KEY;
|
|
329
|
-
if (!key) {
|
|
330
|
-
await sleep(WATCHER_BACKOFF_MAX_MS);
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
try {
|
|
334
|
-
watcherAbort = new AbortController();
|
|
335
|
-
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
336
|
-
const res = await fetch(`${promptowlUrl}/api/license/listen`, {
|
|
337
|
-
method: "POST",
|
|
338
|
-
headers: { "Content-Type": "application/json" },
|
|
339
|
-
body: JSON.stringify({
|
|
340
|
-
key,
|
|
341
|
-
since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
|
|
342
|
-
}),
|
|
343
|
-
signal: watcherAbort.signal
|
|
344
|
-
});
|
|
345
|
-
if (!res.ok) {
|
|
346
|
-
throw new Error(`listen returned ${res.status}`);
|
|
347
|
-
}
|
|
348
|
-
const data = await res.json();
|
|
349
|
-
backoff = WATCHER_BACKOFF_MIN_MS;
|
|
350
|
-
if (data.event && data.event !== "no_change") {
|
|
351
|
-
console.log(
|
|
352
|
-
`[license] event from PromptOwl: ${data.event} \u2014 revalidating`
|
|
353
|
-
);
|
|
354
|
-
const wasValid = !!currentLicense?.valid;
|
|
355
|
-
await validateLicense({ forceFresh: true });
|
|
356
|
-
const isValid = !!currentLicense?.valid;
|
|
357
|
-
if (wasValid && !isValid) {
|
|
358
|
-
handleLicenseRevoked();
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
} catch (err) {
|
|
362
|
-
if (err.name === "AbortError") {
|
|
363
|
-
continue;
|
|
364
|
-
}
|
|
365
|
-
console.warn(
|
|
366
|
-
`[license] watcher error: ${err.message}; backing off ${backoff}ms`
|
|
367
|
-
);
|
|
368
|
-
await sleep(backoff);
|
|
369
|
-
backoff = Math.min(backoff * 2, WATCHER_BACKOFF_MAX_MS);
|
|
370
|
-
} finally {
|
|
371
|
-
watcherAbort = null;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
function sleep(ms) {
|
|
376
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
377
|
-
}
|
|
378
|
-
function handleLicenseRevoked() {
|
|
379
|
-
try {
|
|
380
|
-
const db = getDb();
|
|
381
|
-
const result = db.prepare("DELETE FROM sessions").run();
|
|
382
|
-
console.warn(
|
|
383
|
-
`[license] revoked \u2014 wiped ${result.changes} active session(s).`
|
|
384
|
-
);
|
|
385
|
-
} catch (err) {
|
|
386
|
-
console.warn("[license] failed to wipe sessions:", err);
|
|
387
|
-
}
|
|
388
|
-
try {
|
|
389
|
-
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
|
|
390
|
-
console.warn(
|
|
391
|
-
`[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
|
|
392
|
-
);
|
|
393
|
-
} catch (err) {
|
|
394
|
-
console.warn("[license] failed to strip key from .env:", err);
|
|
395
|
-
}
|
|
396
|
-
process.env.PROMPTOWL_KEY = "";
|
|
397
|
-
currentLicense = {
|
|
398
|
-
valid: false,
|
|
399
|
-
tier: "none",
|
|
400
|
-
org: null,
|
|
401
|
-
limits: null,
|
|
402
|
-
suspended: false,
|
|
403
|
-
suspendedReason: null,
|
|
404
|
-
ownerEmail: null
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
408
|
-
var suspensionFirstSeen = null;
|
|
409
|
-
var suspensionConfirmed = false;
|
|
410
|
-
var suspensionReason = null;
|
|
411
|
-
var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
|
|
412
|
-
function isSuspended() {
|
|
413
|
-
return suspensionConfirmed;
|
|
414
|
-
}
|
|
415
|
-
function getSuspensionReason() {
|
|
416
|
-
return suspensionReason;
|
|
417
|
-
}
|
|
418
|
-
async function validateLicense(opts = {}) {
|
|
419
|
-
const info = await _validateLicenseImpl(!!opts.forceFresh);
|
|
420
|
-
currentLicense = info;
|
|
421
|
-
return info;
|
|
422
|
-
}
|
|
423
|
-
async function _validateLicenseImpl(forceFresh) {
|
|
424
|
-
const key = config.PROMPTOWL_KEY;
|
|
425
|
-
if (!key) {
|
|
426
|
-
return {
|
|
427
|
-
valid: false,
|
|
428
|
-
tier: "none",
|
|
429
|
-
org: null,
|
|
430
|
-
limits: null,
|
|
431
|
-
suspended: false,
|
|
432
|
-
suspendedReason: null,
|
|
433
|
-
ownerEmail: null
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
const db = getDb();
|
|
437
|
-
const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
|
|
438
|
-
if (cached && !forceFresh) {
|
|
439
|
-
const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
|
|
440
|
-
if (age < CACHE_TTL_MS) {
|
|
441
|
-
return {
|
|
442
|
-
valid: true,
|
|
443
|
-
tier: cached.tier,
|
|
444
|
-
org: cached.org,
|
|
445
|
-
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
446
|
-
suspended: suspensionConfirmed,
|
|
447
|
-
suspendedReason: suspensionReason,
|
|
448
|
-
ownerEmail: cached.owner_email || null
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
try {
|
|
453
|
-
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
454
|
-
const res = await fetch(`${promptowlUrl}/api/license/validate`, {
|
|
455
|
-
method: "POST",
|
|
456
|
-
headers: { "Content-Type": "application/json" },
|
|
457
|
-
body: JSON.stringify({ key })
|
|
458
|
-
});
|
|
459
|
-
if (!res.ok) {
|
|
460
|
-
if (cached) {
|
|
461
|
-
console.warn(
|
|
462
|
-
" PromptOwl unreachable, using cached license (grace period)"
|
|
463
|
-
);
|
|
464
|
-
return {
|
|
465
|
-
valid: true,
|
|
466
|
-
tier: cached.tier,
|
|
467
|
-
org: cached.org,
|
|
468
|
-
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
469
|
-
suspended: suspensionConfirmed,
|
|
470
|
-
suspendedReason: suspensionReason,
|
|
471
|
-
ownerEmail: cached.owner_email || null
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
return {
|
|
475
|
-
valid: false,
|
|
476
|
-
tier: "none",
|
|
477
|
-
org: null,
|
|
478
|
-
limits: null,
|
|
479
|
-
suspended: false,
|
|
480
|
-
suspendedReason: null,
|
|
481
|
-
ownerEmail: null
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
const data = await res.json();
|
|
485
|
-
if (data.suspended === true) {
|
|
486
|
-
if (!suspensionFirstSeen) {
|
|
487
|
-
suspensionFirstSeen = Date.now();
|
|
488
|
-
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
489
|
-
console.warn(
|
|
490
|
-
`
|
|
491
|
-
WARNING: PromptOwl has flagged this server for suspension.`
|
|
492
|
-
);
|
|
493
|
-
console.warn(
|
|
494
|
-
` Reason: ${suspensionReason}`
|
|
495
|
-
);
|
|
496
|
-
console.warn(
|
|
497
|
-
` This will be confirmed in ~1 hour. If this is an error,`
|
|
498
|
-
);
|
|
499
|
-
console.warn(
|
|
500
|
-
` contact support@promptowl.ai to reverse it.
|
|
501
|
-
`
|
|
502
|
-
);
|
|
503
|
-
} else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
|
|
504
|
-
suspensionConfirmed = true;
|
|
505
|
-
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
506
|
-
console.error(
|
|
507
|
-
`
|
|
508
|
-
SERVER SUSPENDED: ${suspensionReason}`
|
|
509
|
-
);
|
|
510
|
-
console.error(
|
|
511
|
-
` Write operations are disabled. Reads still work.`
|
|
512
|
-
);
|
|
513
|
-
console.error(
|
|
514
|
-
` Contact support@promptowl.ai to resolve.
|
|
515
|
-
`
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
} else {
|
|
519
|
-
if (suspensionFirstSeen) {
|
|
520
|
-
console.log(" Suspension flag cleared by PromptOwl.");
|
|
521
|
-
}
|
|
522
|
-
suspensionFirstSeen = null;
|
|
523
|
-
suspensionConfirmed = false;
|
|
524
|
-
suspensionReason = null;
|
|
525
|
-
}
|
|
526
|
-
if (!data.valid && !data.suspended) {
|
|
527
|
-
db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
|
|
528
|
-
return {
|
|
529
|
-
valid: false,
|
|
530
|
-
tier: "none",
|
|
531
|
-
org: null,
|
|
532
|
-
limits: null,
|
|
533
|
-
suspended: false,
|
|
534
|
-
suspendedReason: null,
|
|
535
|
-
ownerEmail: data.owner_email || null
|
|
536
|
-
};
|
|
537
|
-
}
|
|
538
|
-
if (data.valid) {
|
|
539
|
-
const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
|
|
540
|
-
db.prepare(
|
|
541
|
-
`INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
|
|
542
|
-
VALUES (?, ?, ?, ?, ?, datetime('now'))`
|
|
543
|
-
).run(
|
|
544
|
-
key,
|
|
545
|
-
data.tier || "community",
|
|
546
|
-
data.org || null,
|
|
547
|
-
limitsJson,
|
|
548
|
-
data.owner_email || null
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
return {
|
|
552
|
-
valid: data.valid !== false,
|
|
553
|
-
tier: data.tier || "community",
|
|
554
|
-
org: data.org || null,
|
|
555
|
-
limits: data.limits || null,
|
|
556
|
-
suspended: suspensionConfirmed,
|
|
557
|
-
suspendedReason: suspensionReason,
|
|
558
|
-
ownerEmail: data.owner_email || null
|
|
559
|
-
};
|
|
560
|
-
} catch (err) {
|
|
561
|
-
if (cached) {
|
|
562
|
-
console.warn(
|
|
563
|
-
` PromptOwl validation failed (${err.message}), using cached license`
|
|
564
|
-
);
|
|
565
|
-
return {
|
|
566
|
-
valid: true,
|
|
567
|
-
tier: cached.tier,
|
|
568
|
-
org: cached.org,
|
|
569
|
-
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
570
|
-
suspended: suspensionConfirmed,
|
|
571
|
-
suspendedReason: suspensionReason,
|
|
572
|
-
ownerEmail: cached.owner_email || null
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
return {
|
|
576
|
-
valid: false,
|
|
577
|
-
tier: "none",
|
|
578
|
-
org: null,
|
|
579
|
-
limits: null,
|
|
580
|
-
suspended: false,
|
|
581
|
-
suspendedReason: null,
|
|
582
|
-
ownerEmail: null
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
212
|
// src/shared/rate-limit.ts
|
|
588
213
|
var buckets = /* @__PURE__ */ new Map();
|
|
589
|
-
function
|
|
590
|
-
const now = Date.now();
|
|
591
|
-
const cutoff = now - cfg.windowMs;
|
|
214
|
+
function liveBucket(key, cutoff) {
|
|
592
215
|
let bucket = buckets.get(key);
|
|
593
216
|
if (!bucket) {
|
|
594
217
|
bucket = { hits: [] };
|
|
@@ -597,14 +220,29 @@ function tryConsume(key, cfg) {
|
|
|
597
220
|
while (bucket.hits.length && bucket.hits[0] < cutoff) {
|
|
598
221
|
bucket.hits.shift();
|
|
599
222
|
}
|
|
223
|
+
return bucket;
|
|
224
|
+
}
|
|
225
|
+
function tryConsume(key, cfg) {
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const bucket = liveBucket(key, now - cfg.windowMs);
|
|
600
228
|
if (bucket.hits.length >= cfg.max) {
|
|
601
229
|
return false;
|
|
602
230
|
}
|
|
603
231
|
bucket.hits.push(now);
|
|
604
232
|
return true;
|
|
605
233
|
}
|
|
234
|
+
function isLimited(key, cfg) {
|
|
235
|
+
return liveBucket(key, Date.now() - cfg.windowMs).hits.length >= cfg.max;
|
|
236
|
+
}
|
|
237
|
+
function recordFailure(key, cfg) {
|
|
238
|
+
liveBucket(key, Date.now() - cfg.windowMs).hits.push(Date.now());
|
|
239
|
+
}
|
|
240
|
+
function clear(key) {
|
|
241
|
+
buckets.delete(key);
|
|
242
|
+
}
|
|
606
243
|
|
|
607
244
|
// src/auth/routes.ts
|
|
245
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
608
246
|
var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
|
|
609
247
|
var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
|
|
610
248
|
var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
|
|
@@ -613,6 +251,11 @@ function clientIp(c) {
|
|
|
613
251
|
if (xff) return xff.split(",")[0].trim();
|
|
614
252
|
const realIp = c.req.header("x-real-ip");
|
|
615
253
|
if (realIp) return realIp.trim();
|
|
254
|
+
try {
|
|
255
|
+
const addr = getConnInfo(c).remote.address;
|
|
256
|
+
if (addr) return addr;
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
616
259
|
return "unknown";
|
|
617
260
|
}
|
|
618
261
|
function resolveCallerUserId(c) {
|
|
@@ -691,7 +334,10 @@ authRoutes.post("/login", async (c) => {
|
|
|
691
334
|
}
|
|
692
335
|
const ip = clientIp(c);
|
|
693
336
|
const emailLower = body.email.toLowerCase();
|
|
694
|
-
|
|
337
|
+
const hasIp = ip !== "unknown";
|
|
338
|
+
const ipKey = `login:ip:${ip}`;
|
|
339
|
+
const emailKey = `login:email:${emailLower}`;
|
|
340
|
+
if (hasIp && isLimited(ipKey, LOGIN_LIMIT) || isLimited(emailKey, LOGIN_LIMIT)) {
|
|
695
341
|
return c.json({ error: "Too many login attempts, try again later" }, 429);
|
|
696
342
|
}
|
|
697
343
|
const db = getDb();
|
|
@@ -700,8 +346,14 @@ authRoutes.post("/login", async (c) => {
|
|
|
700
346
|
).get(body.email);
|
|
701
347
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
702
348
|
if (!user || !check.ok) {
|
|
349
|
+
if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
|
|
350
|
+
recordFailure(emailKey, LOGIN_LIMIT);
|
|
351
|
+
console.warn(`[auth] login FAIL \u2014 counter++ ip=${ip} email=${emailLower}`);
|
|
703
352
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
704
353
|
}
|
|
354
|
+
console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
|
|
355
|
+
if (hasIp) clear(ipKey);
|
|
356
|
+
clear(emailKey);
|
|
705
357
|
if (check.needsRehash) {
|
|
706
358
|
try {
|
|
707
359
|
const newHash = await hashPassword(body.password);
|
|
@@ -1030,17 +682,22 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1030
682
|
(SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
|
|
1031
683
|
(SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
|
|
1032
684
|
FROM users u
|
|
1033
|
-
WHERE u.id !=
|
|
685
|
+
WHERE u.id != ?
|
|
1034
686
|
ORDER BY u.created_at DESC`
|
|
1035
|
-
).all();
|
|
687
|
+
).all(ANON_USER_ID);
|
|
1036
688
|
const pendingStewards = db.prepare(
|
|
1037
689
|
`SELECT DISTINCT s.user_email AS email
|
|
1038
690
|
FROM stewards s
|
|
1039
691
|
WHERE s.is_active = 1
|
|
1040
692
|
AND NOT EXISTS (
|
|
1041
693
|
SELECT 1 FROM users u
|
|
1042
|
-
JOIN api_keys k ON k.user_id = u.id
|
|
1043
694
|
WHERE lower(u.email) = lower(s.user_email)
|
|
695
|
+
AND (
|
|
696
|
+
u.is_invited = 0
|
|
697
|
+
OR EXISTS (
|
|
698
|
+
SELECT 1 FROM api_keys k WHERE k.user_id = u.id
|
|
699
|
+
)
|
|
700
|
+
)
|
|
1044
701
|
)
|
|
1045
702
|
ORDER BY s.user_email`
|
|
1046
703
|
).all();
|
|
@@ -1054,162 +711,736 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1054
711
|
// src/nests/routes.ts
|
|
1055
712
|
import { Hono as Hono2 } from "hono";
|
|
1056
713
|
|
|
1057
|
-
// src/
|
|
1058
|
-
|
|
1059
|
-
none: 0,
|
|
1060
|
-
read: 1,
|
|
1061
|
-
write: 2,
|
|
1062
|
-
admin: 3,
|
|
1063
|
-
owner: 4
|
|
1064
|
-
};
|
|
1065
|
-
function resolveNestPermission(nestId, userId) {
|
|
1066
|
-
const db = getDb();
|
|
1067
|
-
const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
|
|
1068
|
-
if (!nest) return "none";
|
|
1069
|
-
if (nest.user_id === userId) return "owner";
|
|
1070
|
-
const directGrant = db.prepare(
|
|
1071
|
-
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
1072
|
-
).get(nestId, userId);
|
|
1073
|
-
if (directGrant) return directGrant.permission;
|
|
1074
|
-
if (nest.visibility === "public") return "read";
|
|
1075
|
-
return "none";
|
|
1076
|
-
}
|
|
1077
|
-
function permissionLevel(p) {
|
|
1078
|
-
return PERMISSION_LEVELS[p] ?? 0;
|
|
1079
|
-
}
|
|
714
|
+
// src/nodes/service.ts
|
|
715
|
+
import { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
|
|
1080
716
|
|
|
1081
|
-
// src/
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
import { v4 as uuid2 } from "uuid";
|
|
1085
|
-
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
1086
|
-
function nestPath(nestId) {
|
|
1087
|
-
return join(config.DATA_ROOT, "nests", nestId);
|
|
1088
|
-
}
|
|
1089
|
-
function toSlug(name) {
|
|
1090
|
-
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1091
|
-
}
|
|
1092
|
-
function isStewardshipEnabled(nestId) {
|
|
1093
|
-
const db = getDb();
|
|
1094
|
-
const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
|
|
1095
|
-
return !!row?.stewardship_enabled;
|
|
717
|
+
// src/governance/tag-index-service.ts
|
|
718
|
+
function normalizeTag(raw) {
|
|
719
|
+
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1096
720
|
}
|
|
1097
|
-
function
|
|
721
|
+
function syncNodeTags(nestId, nodeId, tags) {
|
|
1098
722
|
const db = getDb();
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
723
|
+
const normalized = Array.from(
|
|
724
|
+
new Set(
|
|
725
|
+
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
726
|
+
)
|
|
1102
727
|
);
|
|
728
|
+
db.transaction(() => {
|
|
729
|
+
db.prepare(
|
|
730
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
731
|
+
).run(nestId, nodeId);
|
|
732
|
+
const insert = db.prepare(
|
|
733
|
+
"INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
734
|
+
);
|
|
735
|
+
for (const tag of normalized) {
|
|
736
|
+
insert.run(nestId, nodeId, tag);
|
|
737
|
+
}
|
|
738
|
+
})();
|
|
1103
739
|
}
|
|
1104
|
-
|
|
1105
|
-
const id = uuid2();
|
|
1106
|
-
const slug = toSlug(name);
|
|
740
|
+
function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1107
741
|
const db = getDb();
|
|
1108
742
|
db.prepare(
|
|
1109
|
-
"
|
|
1110
|
-
).run(
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
|
|
1117
|
-
}
|
|
1118
|
-
var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
1119
|
-
function listNests(userId) {
|
|
743
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
744
|
+
).run(nestId, nodeId);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/governance/access-guard.ts
|
|
748
|
+
function resolveCallerEmail(userId) {
|
|
749
|
+
if (!userId) return "admin@localhost";
|
|
1120
750
|
const db = getDb();
|
|
1121
|
-
|
|
1122
|
-
|
|
751
|
+
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
752
|
+
return row?.email || "admin@localhost";
|
|
753
|
+
}
|
|
754
|
+
function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
755
|
+
if (isPublicReader(nestId, userId)) {
|
|
756
|
+
return getApprovedVersion(nestId, nodeId) !== null;
|
|
1123
757
|
}
|
|
1124
|
-
return
|
|
1125
|
-
|
|
1126
|
-
).all(userId, ANON_USER_ID);
|
|
758
|
+
if (!isStewardshipEnabled(nestId)) return true;
|
|
759
|
+
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1127
760
|
}
|
|
1128
|
-
function
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
ORDER BY n.created_at DESC`
|
|
1135
|
-
).all(userId, userId);
|
|
1136
|
-
}
|
|
1137
|
-
function getNest(nestId) {
|
|
1138
|
-
const db = getDb();
|
|
1139
|
-
return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId) || null;
|
|
761
|
+
function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
762
|
+
if (isPublicReader(nestId, userId)) {
|
|
763
|
+
return nodes.filter((n) => getApprovedVersion(nestId, n.id) !== null);
|
|
764
|
+
}
|
|
765
|
+
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
766
|
+
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
1140
767
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
768
|
+
|
|
769
|
+
// src/governance/external-edit-service.ts
|
|
770
|
+
import { readFile } from "fs/promises";
|
|
771
|
+
import { join } from "path";
|
|
772
|
+
import {
|
|
773
|
+
detectDrift,
|
|
774
|
+
stageSuggestion,
|
|
775
|
+
approveSuggestion,
|
|
776
|
+
rejectSuggestion,
|
|
777
|
+
listSuggestions,
|
|
778
|
+
readSuggestion,
|
|
779
|
+
parseDocument,
|
|
780
|
+
VersionManager,
|
|
781
|
+
computeContentHash,
|
|
782
|
+
getChecksumContent
|
|
783
|
+
} from "@promptowl/contextnest-engine";
|
|
784
|
+
var communityRbac = {
|
|
785
|
+
isCzar: () => false,
|
|
786
|
+
canIngest: () => true,
|
|
787
|
+
isDocOwner: () => true
|
|
788
|
+
};
|
|
789
|
+
function docPath(nestId, documentId) {
|
|
790
|
+
return join(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
|
|
791
|
+
}
|
|
792
|
+
async function readRaw(nestId, documentId) {
|
|
1146
793
|
try {
|
|
1147
|
-
|
|
794
|
+
return await readFile(docPath(nestId, documentId), "utf-8");
|
|
1148
795
|
} catch {
|
|
796
|
+
return null;
|
|
1149
797
|
}
|
|
1150
|
-
trackEvent("nest.delete", { nestId });
|
|
1151
798
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
799
|
+
async function loadChainHead(storage, documentId) {
|
|
800
|
+
const history = await storage.readHistory(documentId);
|
|
801
|
+
if (!history || history.versions.length === 0) return null;
|
|
802
|
+
const latest = history.versions[history.versions.length - 1];
|
|
803
|
+
try {
|
|
804
|
+
const content = await new VersionManager(storage).reconstructVersion(
|
|
805
|
+
documentId,
|
|
806
|
+
latest.version
|
|
807
|
+
);
|
|
808
|
+
return { version: latest.version, content };
|
|
809
|
+
} catch {
|
|
810
|
+
return null;
|
|
1160
811
|
}
|
|
1161
|
-
return resolveNestPermission(nestId, userId);
|
|
1162
812
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
const permission = effectivePermission(nestId, c.get("userId"));
|
|
1190
|
-
if (permission !== "owner") {
|
|
1191
|
-
throw new NotFoundError("Nest not found");
|
|
1192
|
-
}
|
|
1193
|
-
await deleteNest(nestId);
|
|
1194
|
-
return c.json({ deleted: true });
|
|
1195
|
-
});
|
|
1196
|
-
nestRoutes.get("/:nestId/settings", async (c) => {
|
|
1197
|
-
const nestId = c.req.param("nestId");
|
|
1198
|
-
const permission = effectivePermission(nestId, c.get("userId"));
|
|
1199
|
-
if (permission === "none") {
|
|
1200
|
-
throw new NotFoundError("Nest not found");
|
|
813
|
+
async function loadLatestApprovedNode(nestId, documentId) {
|
|
814
|
+
const { storage } = engineCache.get(nestId);
|
|
815
|
+
const head = await loadChainHead(storage, documentId);
|
|
816
|
+
if (!head) return null;
|
|
817
|
+
return parseDocument(docPath(nestId, documentId), head.content, documentId);
|
|
818
|
+
}
|
|
819
|
+
async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
|
|
820
|
+
const head = await loadChainHead(storage, documentId);
|
|
821
|
+
if (!head) return false;
|
|
822
|
+
const headBodyHash = computeContentHash(getChecksumContent(head.content));
|
|
823
|
+
return headBodyHash === liveBodyHash;
|
|
824
|
+
}
|
|
825
|
+
async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
|
|
826
|
+
const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
|
|
827
|
+
return res?.meta ?? null;
|
|
828
|
+
}
|
|
829
|
+
async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
830
|
+
const { storage } = engineCache.get(nestId);
|
|
831
|
+
const node = await storage.readDocument(documentId).catch(() => null);
|
|
832
|
+
if (!node) return null;
|
|
833
|
+
const raw = await readRaw(nestId, documentId);
|
|
834
|
+
if (raw == null) return null;
|
|
835
|
+
const drift = detectDrift(raw, node.frontmatter.checksum);
|
|
836
|
+
if (!drift.drifted) return null;
|
|
837
|
+
if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
|
|
838
|
+
return null;
|
|
1201
839
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
840
|
+
const approved = await loadChainHead(storage, documentId);
|
|
841
|
+
if (!approved) return null;
|
|
842
|
+
const existing = await listSuggestions(storage, documentId);
|
|
843
|
+
const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
|
|
844
|
+
if (dup) return { meta: dup, created: false };
|
|
845
|
+
const result = await stageSuggestion({
|
|
846
|
+
storage,
|
|
847
|
+
documentId,
|
|
848
|
+
approvedRawContent: approved.content,
|
|
849
|
+
proposedRawContent: raw,
|
|
850
|
+
source: "out-of-band-edit",
|
|
851
|
+
actor,
|
|
852
|
+
docTier: "standard"
|
|
1204
853
|
});
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
const
|
|
1209
|
-
const
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
|
|
854
|
+
return { meta: result.meta, created: true };
|
|
855
|
+
}
|
|
856
|
+
async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
857
|
+
const { storage } = engineCache.get(nestId);
|
|
858
|
+
const docs = await storage.discoverDocuments();
|
|
859
|
+
const results = await Promise.all(
|
|
860
|
+
docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
|
|
861
|
+
);
|
|
862
|
+
const staged = results.filter((r) => r?.created).length;
|
|
863
|
+
return { scanned: docs.length, staged };
|
|
864
|
+
}
|
|
865
|
+
async function getPendingChange(nestId, documentId) {
|
|
866
|
+
const { storage } = engineCache.get(nestId);
|
|
867
|
+
const list = await listSuggestions(storage, documentId);
|
|
868
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
869
|
+
const meta = list[i];
|
|
870
|
+
if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
suggestion_id: meta.suggestion_id,
|
|
875
|
+
detected_at: meta.detected_at,
|
|
876
|
+
source: meta.source,
|
|
877
|
+
proposed_hash: meta.proposed_hash
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
async function listNestExternalEdits(nestId) {
|
|
883
|
+
const { storage } = engineCache.get(nestId);
|
|
884
|
+
const docs = await storage.discoverDocuments();
|
|
885
|
+
const lists = await Promise.all(
|
|
886
|
+
docs.map(async (doc) => {
|
|
887
|
+
const metas = await listSuggestions(storage, doc.id);
|
|
888
|
+
const fresh = [];
|
|
889
|
+
for (const meta of metas) {
|
|
890
|
+
if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
fresh.push(meta);
|
|
894
|
+
}
|
|
895
|
+
return fresh;
|
|
896
|
+
})
|
|
897
|
+
);
|
|
898
|
+
const entries = lists.flat().map((meta) => ({
|
|
899
|
+
suggestion_id: meta.suggestion_id,
|
|
900
|
+
nest_id: nestId,
|
|
901
|
+
document_id: meta.document_id,
|
|
902
|
+
source: meta.source,
|
|
903
|
+
detected_at: meta.detected_at,
|
|
904
|
+
actor: meta.actor,
|
|
905
|
+
target_hash: meta.target_hash,
|
|
906
|
+
proposed_hash: meta.proposed_hash,
|
|
907
|
+
note: meta.note
|
|
908
|
+
}));
|
|
909
|
+
return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
|
|
910
|
+
}
|
|
911
|
+
async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
912
|
+
const { storage } = engineCache.get(nestId);
|
|
913
|
+
const found = await readSuggestion(storage, documentId, suggestionId);
|
|
914
|
+
if (!found) return null;
|
|
915
|
+
return {
|
|
916
|
+
suggestion_id: found.meta.suggestion_id,
|
|
917
|
+
nest_id: nestId,
|
|
918
|
+
document_id: found.meta.document_id,
|
|
919
|
+
source: found.meta.source,
|
|
920
|
+
detected_at: found.meta.detected_at,
|
|
921
|
+
actor: found.meta.actor,
|
|
922
|
+
target_hash: found.meta.target_hash,
|
|
923
|
+
proposed_hash: found.meta.proposed_hash,
|
|
924
|
+
note: found.meta.note,
|
|
925
|
+
patch: found.patch
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
async function approveExternalEdit(input) {
|
|
929
|
+
const { storage } = engineCache.get(input.nestId);
|
|
930
|
+
let result;
|
|
931
|
+
try {
|
|
932
|
+
result = await approveSuggestion({
|
|
933
|
+
storage,
|
|
934
|
+
rbac: communityRbac,
|
|
935
|
+
documentId: input.documentId,
|
|
936
|
+
suggestionId: input.suggestionId,
|
|
937
|
+
actor: input.actor,
|
|
938
|
+
zone: "default",
|
|
939
|
+
comment: input.comment
|
|
940
|
+
});
|
|
941
|
+
} catch (err) {
|
|
942
|
+
console.error(
|
|
943
|
+
`[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
|
|
944
|
+
err
|
|
945
|
+
);
|
|
946
|
+
throw err;
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
const node = await storage.readDocument(input.documentId);
|
|
950
|
+
const versionNum = result.versionEntry.version;
|
|
951
|
+
const tags = node.frontmatter.tags || [];
|
|
952
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
|
|
953
|
+
createVersion2({
|
|
954
|
+
nestId: input.nestId,
|
|
955
|
+
nodeId: input.documentId,
|
|
956
|
+
version: versionNum,
|
|
957
|
+
content: node.body || "",
|
|
958
|
+
author: input.actor,
|
|
959
|
+
status: "published",
|
|
960
|
+
tags,
|
|
961
|
+
changeNote: input.comment || "External edit approved"
|
|
962
|
+
});
|
|
963
|
+
setApprovedVersion2(input.nestId, input.documentId, versionNum, input.actor);
|
|
964
|
+
} catch (err) {
|
|
965
|
+
console.error(
|
|
966
|
+
`[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
|
|
967
|
+
err
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
return result;
|
|
971
|
+
}
|
|
972
|
+
async function rejectExternalEdit(input) {
|
|
973
|
+
const { storage } = engineCache.get(input.nestId);
|
|
974
|
+
const result = await rejectSuggestion({
|
|
975
|
+
storage,
|
|
976
|
+
rbac: communityRbac,
|
|
977
|
+
documentId: input.documentId,
|
|
978
|
+
suggestionId: input.suggestionId,
|
|
979
|
+
actor: input.actor,
|
|
980
|
+
zone: "default",
|
|
981
|
+
reason: input.reason
|
|
982
|
+
});
|
|
983
|
+
try {
|
|
984
|
+
const approved = await loadChainHead(storage, input.documentId);
|
|
985
|
+
if (approved) {
|
|
986
|
+
await storage.writeDocument(input.documentId, approved.content);
|
|
987
|
+
}
|
|
988
|
+
} catch (err) {
|
|
989
|
+
console.error(
|
|
990
|
+
`[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
|
|
991
|
+
err
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
return result;
|
|
995
|
+
}
|
|
996
|
+
var scannerTimer = null;
|
|
997
|
+
async function scanAllNests() {
|
|
998
|
+
const db = getDb();
|
|
999
|
+
const rows = db.prepare("SELECT id FROM nests").all();
|
|
1000
|
+
await Promise.all(
|
|
1001
|
+
rows.map(
|
|
1002
|
+
({ id }) => scanNestForDrift(id).catch(
|
|
1003
|
+
(err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
|
|
1004
|
+
)
|
|
1005
|
+
)
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
function startDriftScanner(intervalMs = 3e4) {
|
|
1009
|
+
if (scannerTimer) return;
|
|
1010
|
+
scannerTimer = setInterval(() => {
|
|
1011
|
+
scanAllNests().catch(
|
|
1012
|
+
(err) => console.error("[external-edit] scanner tick failed:", err)
|
|
1013
|
+
);
|
|
1014
|
+
}, intervalMs);
|
|
1015
|
+
scannerTimer.unref?.();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/nodes/service.ts
|
|
1019
|
+
function userIdFromEmail(email) {
|
|
1020
|
+
const db = getDb();
|
|
1021
|
+
const row = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(email);
|
|
1022
|
+
return row?.id ?? ANON_USER_ID;
|
|
1023
|
+
}
|
|
1024
|
+
var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
|
|
1025
|
+
var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
1026
|
+
function bodyOnly(nodeId, raw) {
|
|
1027
|
+
try {
|
|
1028
|
+
return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
|
|
1029
|
+
} catch {
|
|
1030
|
+
return raw;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function toNodeResponse(node) {
|
|
1034
|
+
const fm = node.frontmatter;
|
|
1035
|
+
const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
|
|
1036
|
+
const tags = Array.isArray(fm.tags) ? fm.tags.map((t) => String(t)) : [];
|
|
1037
|
+
return {
|
|
1038
|
+
id: node.id,
|
|
1039
|
+
title,
|
|
1040
|
+
type: fm.type || "document",
|
|
1041
|
+
tags,
|
|
1042
|
+
status: fm.status || "draft",
|
|
1043
|
+
version: fm.version || 1,
|
|
1044
|
+
author: fm.author,
|
|
1045
|
+
description: fm.description,
|
|
1046
|
+
created_at: fm.created_at,
|
|
1047
|
+
updated_at: fm.updated_at,
|
|
1048
|
+
content: node.body || "",
|
|
1049
|
+
pendingChange: node.pendingChange ?? void 0
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
1053
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1054
|
+
let documents = await storage.discoverDocuments();
|
|
1055
|
+
if (filters.type) {
|
|
1056
|
+
documents = documents.filter((n) => n.frontmatter.type === filters.type);
|
|
1057
|
+
}
|
|
1058
|
+
if (filters.tag) {
|
|
1059
|
+
const tag = normalizeTag2(filters.tag);
|
|
1060
|
+
documents = documents.filter(
|
|
1061
|
+
(n) => (n.frontmatter.tags || []).includes(tag)
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1065
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
1066
|
+
const publicReader = isPublicReader(nestId, userId);
|
|
1067
|
+
const enriched = await Promise.all(
|
|
1068
|
+
accessible.map(async (doc) => {
|
|
1069
|
+
const r = toNodeResponse(doc);
|
|
1070
|
+
if (publicReader) {
|
|
1071
|
+
const approved = getApprovedVersion(nestId, doc.id);
|
|
1072
|
+
if (approved != null) {
|
|
1073
|
+
try {
|
|
1074
|
+
const raw = await versionManager.reconstructVersion(doc.id, approved);
|
|
1075
|
+
r.content = bodyOnly(doc.id, raw);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
console.error("reconstructVersion failed (list)", doc.id, approved, err);
|
|
1078
|
+
r.content = "";
|
|
1079
|
+
}
|
|
1080
|
+
r.version = approved;
|
|
1081
|
+
r.status = "published";
|
|
1082
|
+
}
|
|
1083
|
+
return r;
|
|
1084
|
+
}
|
|
1085
|
+
let pending = null;
|
|
1086
|
+
try {
|
|
1087
|
+
pending = await getPendingChange(nestId, doc.id);
|
|
1088
|
+
} catch {
|
|
1089
|
+
pending = null;
|
|
1090
|
+
}
|
|
1091
|
+
if (pending) {
|
|
1092
|
+
r.pendingChange = pending;
|
|
1093
|
+
r.status = "external_edit_pending";
|
|
1094
|
+
} else {
|
|
1095
|
+
r.status = getDisplayStatus(nestId, r.id);
|
|
1096
|
+
}
|
|
1097
|
+
return r;
|
|
1098
|
+
})
|
|
1099
|
+
);
|
|
1100
|
+
return filters.limit ? enriched.slice(0, filters.limit) : enriched;
|
|
1101
|
+
}
|
|
1102
|
+
async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
1103
|
+
return listNodesForCaller(nestId, userIdFromEmail(userEmail), filters);
|
|
1104
|
+
}
|
|
1105
|
+
async function createNode(nestId, input, userEmail) {
|
|
1106
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1107
|
+
const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1108
|
+
const id = `nodes/${slug}`;
|
|
1109
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1110
|
+
const tags = (input.tags || []).map(normalizeTag2);
|
|
1111
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
1112
|
+
const initialStatus = hasStewards ? "draft" : "published";
|
|
1113
|
+
const initialVersion = hasStewards ? 1 : 0;
|
|
1114
|
+
let node = {
|
|
1115
|
+
id,
|
|
1116
|
+
filePath: "",
|
|
1117
|
+
frontmatter: {
|
|
1118
|
+
title: input.title,
|
|
1119
|
+
type: input.type || "document",
|
|
1120
|
+
tags,
|
|
1121
|
+
status: input.status || initialStatus,
|
|
1122
|
+
version: initialVersion,
|
|
1123
|
+
created_at: now,
|
|
1124
|
+
updated_at: now,
|
|
1125
|
+
metadata: { owners: ["*"], scope: input.scope || "team" }
|
|
1126
|
+
},
|
|
1127
|
+
body: input.content,
|
|
1128
|
+
rawContent: ""
|
|
1129
|
+
};
|
|
1130
|
+
await storage.writeDocument(id, serializeDocument(node));
|
|
1131
|
+
syncNodeTags(nestId, id, tags);
|
|
1132
|
+
let savedVersion = 1;
|
|
1133
|
+
if (hasStewards) {
|
|
1134
|
+
try {
|
|
1135
|
+
await versionManager.createVersion(node, userEmail);
|
|
1136
|
+
} catch (err) {
|
|
1137
|
+
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1138
|
+
}
|
|
1139
|
+
createVersion({
|
|
1140
|
+
nestId,
|
|
1141
|
+
nodeId: id,
|
|
1142
|
+
version: 1,
|
|
1143
|
+
content: input.content,
|
|
1144
|
+
author: userEmail,
|
|
1145
|
+
status: "draft",
|
|
1146
|
+
tags
|
|
1147
|
+
});
|
|
1148
|
+
} else {
|
|
1149
|
+
try {
|
|
1150
|
+
const result = await safePublishDocument(storage, id, {
|
|
1151
|
+
editedBy: userEmail,
|
|
1152
|
+
note: "Auto-published on create (no stewards configured)"
|
|
1153
|
+
});
|
|
1154
|
+
savedVersion = result.node.frontmatter.version || 1;
|
|
1155
|
+
createVersion({
|
|
1156
|
+
nestId,
|
|
1157
|
+
nodeId: id,
|
|
1158
|
+
version: savedVersion,
|
|
1159
|
+
content: result.node.body || "",
|
|
1160
|
+
author: userEmail,
|
|
1161
|
+
status: "published",
|
|
1162
|
+
tags
|
|
1163
|
+
});
|
|
1164
|
+
setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
1165
|
+
node = result.node;
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
1168
|
+
createVersion({
|
|
1169
|
+
nestId,
|
|
1170
|
+
nodeId: id,
|
|
1171
|
+
version: 1,
|
|
1172
|
+
content: input.content,
|
|
1173
|
+
author: userEmail,
|
|
1174
|
+
status: "draft",
|
|
1175
|
+
tags
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
trackEvent("node.create", { nestId, nodeId: id });
|
|
1180
|
+
return { node, version: savedVersion };
|
|
1181
|
+
}
|
|
1182
|
+
async function registerImportedDocuments(nestId, userEmail) {
|
|
1183
|
+
const { storage } = engineCache.get(nestId);
|
|
1184
|
+
let docs;
|
|
1185
|
+
try {
|
|
1186
|
+
docs = await storage.discoverDocuments();
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
console.error("registerImportedDocuments: discovery failed", nestId, err);
|
|
1189
|
+
return 0;
|
|
1190
|
+
}
|
|
1191
|
+
let registered = 0;
|
|
1192
|
+
for (const doc of docs) {
|
|
1193
|
+
const nodeId = doc.id;
|
|
1194
|
+
if (getCurrentVersion(nestId, nodeId) > 0) continue;
|
|
1195
|
+
const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
|
|
1196
|
+
const tags = rawTags.map((t) => normalizeTag2(String(t)));
|
|
1197
|
+
const fmVersion = Number(doc.frontmatter?.version);
|
|
1198
|
+
let version = Number.isFinite(fmVersion) && fmVersion > 0 ? fmVersion : 1;
|
|
1199
|
+
let content = doc.body || "";
|
|
1200
|
+
try {
|
|
1201
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
1202
|
+
editedBy: userEmail,
|
|
1203
|
+
note: "Imported from existing folder"
|
|
1204
|
+
});
|
|
1205
|
+
version = result.node.frontmatter.version || version;
|
|
1206
|
+
content = result.node.body || content;
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
console.error("safePublishDocument failed (import register)", nodeId, err);
|
|
1209
|
+
}
|
|
1210
|
+
createVersion({
|
|
1211
|
+
nestId,
|
|
1212
|
+
nodeId,
|
|
1213
|
+
version,
|
|
1214
|
+
content,
|
|
1215
|
+
author: userEmail,
|
|
1216
|
+
status: "published",
|
|
1217
|
+
changeNote: "Imported from existing folder",
|
|
1218
|
+
tags
|
|
1219
|
+
});
|
|
1220
|
+
setApprovedVersion(nestId, nodeId, version, userEmail);
|
|
1221
|
+
syncNodeTags(nestId, nodeId, tags);
|
|
1222
|
+
registered++;
|
|
1223
|
+
}
|
|
1224
|
+
trackEvent("nest.import.documents", { nestId, registered });
|
|
1225
|
+
return registered;
|
|
1226
|
+
}
|
|
1227
|
+
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
1228
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1229
|
+
let node;
|
|
1230
|
+
try {
|
|
1231
|
+
node = await storage.readDocument(nodeId);
|
|
1232
|
+
} catch {
|
|
1233
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1234
|
+
}
|
|
1235
|
+
if (patch.content !== void 0) {
|
|
1236
|
+
node = { ...node, body: patch.content };
|
|
1237
|
+
}
|
|
1238
|
+
if (patch.append) {
|
|
1239
|
+
node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
|
|
1240
|
+
}
|
|
1241
|
+
if (patch.tags) {
|
|
1242
|
+
const newTags = patch.tags.map(normalizeTag2);
|
|
1243
|
+
const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
|
|
1244
|
+
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1245
|
+
}
|
|
1246
|
+
if (patch.status) {
|
|
1247
|
+
node = { ...node, frontmatter: { ...node.frontmatter, status: patch.status } };
|
|
1248
|
+
}
|
|
1249
|
+
if (patch.title) {
|
|
1250
|
+
node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
|
|
1251
|
+
}
|
|
1252
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
1253
|
+
const currentTags = node.frontmatter.tags || [];
|
|
1254
|
+
if (getPendingReview(nestId, nodeId)) {
|
|
1255
|
+
cancelReview({ nestId, nodeId, cancelledBy: userEmail });
|
|
1256
|
+
}
|
|
1257
|
+
let responseVersion;
|
|
1258
|
+
if (hasStewards) {
|
|
1259
|
+
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1260
|
+
const newVersion = currentVersion + 1;
|
|
1261
|
+
node = {
|
|
1262
|
+
...node,
|
|
1263
|
+
frontmatter: {
|
|
1264
|
+
...node.frontmatter,
|
|
1265
|
+
version: newVersion,
|
|
1266
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1267
|
+
// Drop stale published-state checksum so the next verified read
|
|
1268
|
+
// doesn't flag this write as external drift.
|
|
1269
|
+
checksum: void 0
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1273
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1274
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1275
|
+
try {
|
|
1276
|
+
await versionManager.createVersion(node, userEmail, { note: patch.changeNote });
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1279
|
+
}
|
|
1280
|
+
createVersion({
|
|
1281
|
+
nestId,
|
|
1282
|
+
nodeId,
|
|
1283
|
+
version: newVersion,
|
|
1284
|
+
content: node.body || "",
|
|
1285
|
+
author: userEmail,
|
|
1286
|
+
status: "draft",
|
|
1287
|
+
tags: currentTags,
|
|
1288
|
+
changeNote: patch.changeNote
|
|
1289
|
+
});
|
|
1290
|
+
responseVersion = newVersion;
|
|
1291
|
+
} else {
|
|
1292
|
+
node = {
|
|
1293
|
+
...node,
|
|
1294
|
+
frontmatter: {
|
|
1295
|
+
...node.frontmatter,
|
|
1296
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1297
|
+
checksum: void 0
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1301
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1302
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1303
|
+
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
1304
|
+
try {
|
|
1305
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
1306
|
+
editedBy: userEmail,
|
|
1307
|
+
note: patch.changeNote || "Auto-published on edit (no stewards)"
|
|
1308
|
+
});
|
|
1309
|
+
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
1310
|
+
node = result.node;
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
1313
|
+
}
|
|
1314
|
+
createVersion({
|
|
1315
|
+
nestId,
|
|
1316
|
+
nodeId,
|
|
1317
|
+
version: publishedVersion,
|
|
1318
|
+
content: node.body || "",
|
|
1319
|
+
author: userEmail,
|
|
1320
|
+
status: "published",
|
|
1321
|
+
tags: currentTags,
|
|
1322
|
+
changeNote: patch.changeNote
|
|
1323
|
+
});
|
|
1324
|
+
setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
|
|
1325
|
+
responseVersion = publishedVersion;
|
|
1326
|
+
}
|
|
1327
|
+
return { node, version: responseVersion };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/nests/routes.ts
|
|
1331
|
+
function effectivePermission(nestId, userId) {
|
|
1332
|
+
if (config.AUTH_MODE === "open") {
|
|
1333
|
+
const db = getDb();
|
|
1334
|
+
const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1335
|
+
if (nest && nest.user_id === ANON_USER_ID) return "owner";
|
|
1336
|
+
}
|
|
1337
|
+
return resolveNestPermission(nestId, userId);
|
|
1338
|
+
}
|
|
1339
|
+
var nestRoutes = new Hono2();
|
|
1340
|
+
nestRoutes.get("/", async (c) => {
|
|
1341
|
+
const userId = c.get("userId");
|
|
1342
|
+
const owned = listNests(userId);
|
|
1343
|
+
const shared = listSharedNests(userId);
|
|
1344
|
+
const publicExtras = listPublicNests(userId);
|
|
1345
|
+
const db = getDb();
|
|
1346
|
+
const ownerEmailStmt = db.prepare(
|
|
1347
|
+
"SELECT email FROM users WHERE id = ?"
|
|
1348
|
+
);
|
|
1349
|
+
const callerEmail = resolveCallerEmail(userId);
|
|
1350
|
+
const annotate = (n) => {
|
|
1351
|
+
const permission = effectivePermission(n.id, userId);
|
|
1352
|
+
const is_owner = permission === "owner";
|
|
1353
|
+
let owner_email = null;
|
|
1354
|
+
const roles = is_owner ? ["owner"] : resolveUserRoles(n.id, callerEmail);
|
|
1355
|
+
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1356
|
+
const row = ownerEmailStmt.get(n.user_id);
|
|
1357
|
+
owner_email = row?.email ?? null;
|
|
1358
|
+
}
|
|
1359
|
+
return { ...n, permission, is_owner, owner_email, roles };
|
|
1360
|
+
};
|
|
1361
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1362
|
+
const out = [];
|
|
1363
|
+
for (const n of [...owned, ...shared, ...publicExtras]) {
|
|
1364
|
+
if (seen.has(n.id)) continue;
|
|
1365
|
+
seen.add(n.id);
|
|
1366
|
+
out.push(annotate(n));
|
|
1367
|
+
}
|
|
1368
|
+
return c.json({ nests: out });
|
|
1369
|
+
});
|
|
1370
|
+
nestRoutes.post("/", async (c) => {
|
|
1371
|
+
const body = await c.req.json();
|
|
1372
|
+
if (!body.name) {
|
|
1373
|
+
throw new ValidationError("name is required");
|
|
1374
|
+
}
|
|
1375
|
+
const nest = await createNest(c.get("userId"), body.name, body.description);
|
|
1376
|
+
return c.json({ nest }, 201);
|
|
1377
|
+
});
|
|
1378
|
+
nestRoutes.post("/import", async (c) => {
|
|
1379
|
+
const body = await c.req.json();
|
|
1380
|
+
if (!body.name) {
|
|
1381
|
+
throw new ValidationError("name is required");
|
|
1382
|
+
}
|
|
1383
|
+
const files = Array.isArray(body.files) ? body.files : [];
|
|
1384
|
+
const userId = c.get("userId");
|
|
1385
|
+
const nest = await importNest(userId, body.name, files);
|
|
1386
|
+
const documents = await registerImportedDocuments(
|
|
1387
|
+
nest.id,
|
|
1388
|
+
resolveCallerEmail(userId)
|
|
1389
|
+
);
|
|
1390
|
+
return c.json({ nest, documents }, 201);
|
|
1391
|
+
});
|
|
1392
|
+
nestRoutes.get("/:nestId", async (c) => {
|
|
1393
|
+
const nestId = c.req.param("nestId");
|
|
1394
|
+
const userId = c.get("userId");
|
|
1395
|
+
const permission = effectivePermission(nestId, userId);
|
|
1396
|
+
if (permission === "none") {
|
|
1397
|
+
throw new NotFoundError("Nest not found");
|
|
1398
|
+
}
|
|
1399
|
+
const email = resolveCallerEmail(userId);
|
|
1400
|
+
let roles = resolveUserRoles(nestId, email);
|
|
1401
|
+
if (permission === "owner" && !roles.includes("owner")) {
|
|
1402
|
+
roles = ["owner", ...roles];
|
|
1403
|
+
}
|
|
1404
|
+
const myStewards = getStewardsForUser(nestId, email);
|
|
1405
|
+
const nest = getNest(nestId);
|
|
1406
|
+
return c.json({ nest, permission, roles, myStewards });
|
|
1407
|
+
});
|
|
1408
|
+
nestRoutes.delete("/:nestId", async (c) => {
|
|
1409
|
+
const nestId = c.req.param("nestId");
|
|
1410
|
+
const userId = c.get("userId");
|
|
1411
|
+
const nest = getNest(nestId);
|
|
1412
|
+
if (!nest) {
|
|
1413
|
+
throw new NotFoundError("Nest not found");
|
|
1414
|
+
}
|
|
1415
|
+
const permission = effectivePermission(nestId, userId);
|
|
1416
|
+
const isAnonOwned = nest.user_id === ANON_USER_ID;
|
|
1417
|
+
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
|
|
1418
|
+
if (permission !== "owner" && !adminCaretaker) {
|
|
1419
|
+
throw new ForbiddenError(
|
|
1420
|
+
"You don't have permission to delete this nest. Only the nest owner can delete it."
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
await deleteNest(nestId);
|
|
1424
|
+
return c.json({ deleted: true });
|
|
1425
|
+
});
|
|
1426
|
+
nestRoutes.get("/:nestId/settings", async (c) => {
|
|
1427
|
+
const nestId = c.req.param("nestId");
|
|
1428
|
+
const permission = effectivePermission(nestId, c.get("userId"));
|
|
1429
|
+
if (permission === "none") {
|
|
1430
|
+
throw new NotFoundError("Nest not found");
|
|
1431
|
+
}
|
|
1432
|
+
return c.json({
|
|
1433
|
+
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1434
|
+
allow_self_approve: nestAllowsSelfApprove(nestId)
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
1438
|
+
const nestId = c.req.param("nestId");
|
|
1439
|
+
const userId = c.get("userId");
|
|
1440
|
+
const isServerAdmin = isLicenseAdminUserId(userId);
|
|
1441
|
+
const permission = effectivePermission(nestId, userId);
|
|
1442
|
+
if (!isServerAdmin && permission !== "owner") {
|
|
1443
|
+
return c.json(
|
|
1213
1444
|
{
|
|
1214
1445
|
error: "Only the nest owner or the server license-admin can update nest settings."
|
|
1215
1446
|
},
|
|
@@ -1217,61 +1448,111 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
1217
1448
|
);
|
|
1218
1449
|
}
|
|
1219
1450
|
const body = await c.req.json();
|
|
1451
|
+
let wiped = null;
|
|
1220
1452
|
if (typeof body.stewardship_enabled === "boolean") {
|
|
1221
|
-
|
|
1453
|
+
if (body.stewardship_enabled) {
|
|
1454
|
+
setStewardshipEnabled(nestId, true);
|
|
1455
|
+
} else {
|
|
1456
|
+
wiped = disableStewardshipAndWipeGovernance(nestId);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (typeof body.allow_self_approve === "boolean") {
|
|
1460
|
+
setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
1222
1461
|
}
|
|
1223
1462
|
return c.json({
|
|
1224
|
-
stewardship_enabled: isStewardshipEnabled(nestId)
|
|
1463
|
+
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1464
|
+
allow_self_approve: nestAllowsSelfApprove(nestId),
|
|
1465
|
+
wiped
|
|
1225
1466
|
});
|
|
1226
1467
|
});
|
|
1227
1468
|
|
|
1228
1469
|
// src/nests/sharing-routes.ts
|
|
1229
1470
|
import { Hono as Hono3 } from "hono";
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
WHERE nc.nest_id = ?
|
|
1238
|
-
ORDER BY nc.granted_at`
|
|
1239
|
-
).all(c.req.param("nestId"));
|
|
1240
|
-
return c.json({ collaborators: collabs });
|
|
1241
|
-
});
|
|
1242
|
-
sharingRoutes.post("/collaborators", async (c) => {
|
|
1243
|
-
const body = await c.req.json();
|
|
1244
|
-
if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
|
|
1471
|
+
|
|
1472
|
+
// src/nests/sharing-service.ts
|
|
1473
|
+
import { v4 as uuid2 } from "uuid";
|
|
1474
|
+
var VALID_PERMISSIONS = ["read", "write", "admin"];
|
|
1475
|
+
async function addCollaborator(params) {
|
|
1476
|
+
const { nestId } = params;
|
|
1477
|
+
if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
|
|
1245
1478
|
throw new ValidationError("permission must be read, write, or admin");
|
|
1246
1479
|
}
|
|
1247
1480
|
const db = getDb();
|
|
1248
|
-
let userId =
|
|
1249
|
-
if (!userId &&
|
|
1250
|
-
|
|
1251
|
-
if (
|
|
1252
|
-
|
|
1253
|
-
|
|
1481
|
+
let userId = params.userId;
|
|
1482
|
+
if (!userId && params.email) {
|
|
1483
|
+
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(params.email);
|
|
1484
|
+
if (existing) {
|
|
1485
|
+
userId = existing.id;
|
|
1486
|
+
} else {
|
|
1487
|
+
const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
|
|
1488
|
+
userId = uuid2();
|
|
1254
1489
|
db.prepare(
|
|
1255
1490
|
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
1256
|
-
).run(userId,
|
|
1257
|
-
} else {
|
|
1258
|
-
userId = user.id;
|
|
1491
|
+
).run(userId, params.email, null, await hashPassword2(uuid2()));
|
|
1259
1492
|
}
|
|
1260
1493
|
}
|
|
1261
1494
|
if (!userId) {
|
|
1262
1495
|
throw new ValidationError("user_id or email is required");
|
|
1263
1496
|
}
|
|
1264
|
-
const
|
|
1497
|
+
const ownerRow = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1498
|
+
if (!ownerRow) {
|
|
1499
|
+
throw new ValidationError("Nest not found");
|
|
1500
|
+
}
|
|
1501
|
+
if (ownerRow.user_id === userId) {
|
|
1502
|
+
throw new ValidationError(
|
|
1503
|
+
"The nest owner already has full access and can't be added as a collaborator."
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
const selfByEmail = !!params.email && !!params.grantedByEmail && params.email.trim().toLowerCase() === params.grantedByEmail.trim().toLowerCase();
|
|
1507
|
+
const selfById = !!params.grantedByUserId && params.grantedByUserId === userId;
|
|
1508
|
+
if (selfByEmail || selfById) {
|
|
1509
|
+
throw new ValidationError("You can't add yourself as a collaborator.");
|
|
1510
|
+
}
|
|
1511
|
+
const dupe = db.prepare("SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?").get(nestId, userId);
|
|
1512
|
+
if (dupe) {
|
|
1513
|
+
throw new ConflictError(
|
|
1514
|
+
`${params.email || "This user"} already has access to this nest. Change their permission instead of adding them again.`
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
const granterByEmail = !params.grantedByUserId && params.grantedByEmail ? db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(params.grantedByEmail)?.id : void 0;
|
|
1518
|
+
const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
|
|
1519
|
+
const collabId = uuid2();
|
|
1265
1520
|
db.prepare(
|
|
1266
1521
|
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
|
|
1267
|
-
).run(
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1522
|
+
).run(collabId, nestId, userId, params.permission, granterId);
|
|
1523
|
+
return db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/nests/sharing-routes.ts
|
|
1527
|
+
var sharingRoutes = new Hono3();
|
|
1528
|
+
sharingRoutes.get("/collaborators", async (c) => {
|
|
1529
|
+
const db = getDb();
|
|
1530
|
+
const nestId = c.req.param("nestId");
|
|
1531
|
+
const collabs = db.prepare(
|
|
1532
|
+
`SELECT nc.*, u.email FROM nest_collaborators nc
|
|
1533
|
+
LEFT JOIN users u ON nc.user_id = u.id
|
|
1534
|
+
WHERE nc.nest_id = ?
|
|
1535
|
+
ORDER BY nc.granted_at`
|
|
1536
|
+
).all(nestId);
|
|
1537
|
+
const enriched = collabs.map((collab) => {
|
|
1538
|
+
if (!collab.email) return { ...collab, stewardRoles: [], roles: [] };
|
|
1539
|
+
return {
|
|
1540
|
+
...collab,
|
|
1541
|
+
stewardRoles: getStewardRolesForUser(nestId, collab.email),
|
|
1542
|
+
roles: resolveUserRoles(nestId, collab.email)
|
|
1543
|
+
};
|
|
1544
|
+
});
|
|
1545
|
+
return c.json({ collaborators: enriched });
|
|
1546
|
+
});
|
|
1547
|
+
sharingRoutes.post("/collaborators", async (c) => {
|
|
1548
|
+
const body = await c.req.json();
|
|
1549
|
+
const collab = await addCollaborator({
|
|
1550
|
+
nestId: c.req.param("nestId"),
|
|
1551
|
+
email: body.email,
|
|
1552
|
+
userId: body.user_id,
|
|
1553
|
+
permission: body.permission ?? "",
|
|
1554
|
+
grantedByUserId: c.get("userId")
|
|
1555
|
+
});
|
|
1275
1556
|
return c.json({ collaborator: collab }, 201);
|
|
1276
1557
|
});
|
|
1277
1558
|
sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
@@ -1305,174 +1586,36 @@ sharingRoutes.patch("/visibility", async (c) => {
|
|
|
1305
1586
|
);
|
|
1306
1587
|
return c.json({ visibility: body.visibility });
|
|
1307
1588
|
});
|
|
1308
|
-
|
|
1309
|
-
// src/nodes/routes.ts
|
|
1310
|
-
import { Hono as Hono4 } from "hono";
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
get(nestId) {
|
|
1323
|
-
let engine = this.cache.get(nestId);
|
|
1324
|
-
if (!engine) {
|
|
1325
|
-
const nestPath2 = join2(config.DATA_ROOT, "nests", nestId);
|
|
1326
|
-
const storage = new NestStorage2(nestPath2);
|
|
1327
|
-
const query = new GraphQueryEngine(storage);
|
|
1328
|
-
const versions = new VersionManager(storage);
|
|
1329
|
-
engine = { storage, query, versions };
|
|
1330
|
-
this.cache.set(nestId, engine);
|
|
1331
|
-
}
|
|
1332
|
-
return engine;
|
|
1333
|
-
}
|
|
1334
|
-
evict(nestId) {
|
|
1335
|
-
this.cache.delete(nestId);
|
|
1336
|
-
}
|
|
1337
|
-
};
|
|
1338
|
-
var engineCache = new NestEngineCache();
|
|
1339
|
-
|
|
1340
|
-
// src/governance/tag-index-service.ts
|
|
1341
|
-
function normalizeTag(raw) {
|
|
1342
|
-
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1343
|
-
}
|
|
1344
|
-
function syncNodeTags(nestId, nodeId, tags) {
|
|
1345
|
-
const db = getDb();
|
|
1346
|
-
const normalized = Array.from(
|
|
1347
|
-
new Set(
|
|
1348
|
-
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
1349
|
-
)
|
|
1350
|
-
);
|
|
1351
|
-
db.transaction(() => {
|
|
1352
|
-
db.prepare(
|
|
1353
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
1354
|
-
).run(nestId, nodeId);
|
|
1355
|
-
const insert = db.prepare(
|
|
1356
|
-
"INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
1357
|
-
);
|
|
1358
|
-
for (const tag of normalized) {
|
|
1359
|
-
insert.run(nestId, nodeId, tag);
|
|
1360
|
-
}
|
|
1361
|
-
})();
|
|
1362
|
-
}
|
|
1363
|
-
function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1364
|
-
const db = getDb();
|
|
1365
|
-
db.prepare(
|
|
1366
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
1367
|
-
).run(nestId, nodeId);
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
// src/governance/access-guard.ts
|
|
1371
|
-
function resolveCallerEmail(userId) {
|
|
1372
|
-
if (!userId) return "admin@localhost";
|
|
1373
|
-
const db = getDb();
|
|
1374
|
-
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
1375
|
-
return row?.email || "admin@localhost";
|
|
1376
|
-
}
|
|
1377
|
-
function canReadNode(nestId, nodeId, userEmail) {
|
|
1378
|
-
if (!isStewardshipEnabled(nestId)) return true;
|
|
1379
|
-
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1380
|
-
}
|
|
1381
|
-
function filterAccessible(nestId, userEmail, nodes) {
|
|
1382
|
-
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
1383
|
-
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
// src/nodes/routes.ts
|
|
1387
|
-
var nodeRoutes = new Hono4();
|
|
1388
|
-
function toNodeResponse(node) {
|
|
1389
|
-
return {
|
|
1390
|
-
id: node.id,
|
|
1391
|
-
title: node.frontmatter.title,
|
|
1392
|
-
type: node.frontmatter.type || "document",
|
|
1393
|
-
tags: node.frontmatter.tags || [],
|
|
1394
|
-
// Widen to string so callers can layer review-workflow states like
|
|
1395
|
-
// "pending_review" / "rejected" on top of the on-disk frontmatter
|
|
1396
|
-
// status. The engine's Status enum only knows draft/approved.
|
|
1397
|
-
status: node.frontmatter.status || "draft",
|
|
1398
|
-
version: node.frontmatter.version || 1,
|
|
1399
|
-
author: node.frontmatter.author,
|
|
1400
|
-
description: node.frontmatter.description,
|
|
1401
|
-
created_at: node.frontmatter.created_at,
|
|
1402
|
-
updated_at: node.frontmatter.updated_at,
|
|
1403
|
-
content: node.body
|
|
1404
|
-
};
|
|
1405
|
-
}
|
|
1406
|
-
nodeRoutes.get("/", async (c) => {
|
|
1407
|
-
const nestId = c.req.param("nestId");
|
|
1408
|
-
const { storage } = engineCache.get(nestId);
|
|
1409
|
-
const documents = await storage.discoverDocuments();
|
|
1410
|
-
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
1411
|
-
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
1412
|
-
return c.json({
|
|
1413
|
-
count: accessible.length,
|
|
1414
|
-
nodes: accessible.map((doc) => {
|
|
1415
|
-
const r = toNodeResponse(doc);
|
|
1416
|
-
r.status = getDisplayStatus(nestId, r.id);
|
|
1417
|
-
return r;
|
|
1418
|
-
})
|
|
1419
|
-
});
|
|
1420
|
-
});
|
|
1421
|
-
nodeRoutes.post("/", async (c) => {
|
|
1422
|
-
const body = await c.req.json();
|
|
1423
|
-
if (!body.title || !body.content) {
|
|
1424
|
-
throw new ValidationError("title and content are required");
|
|
1425
|
-
}
|
|
1426
|
-
const nestId = c.req.param("nestId");
|
|
1427
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1428
|
-
const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1429
|
-
const id = `nodes/${slug}`;
|
|
1430
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1431
|
-
const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
|
|
1432
|
-
const hasStewards = isStewardshipEnabled(nestId);
|
|
1433
|
-
const initialStatus = hasStewards ? "draft" : "approved";
|
|
1434
|
-
const node = {
|
|
1435
|
-
id,
|
|
1436
|
-
filePath: "",
|
|
1437
|
-
frontmatter: {
|
|
1438
|
-
title: body.title,
|
|
1439
|
-
type: body.type || "document",
|
|
1440
|
-
tags,
|
|
1441
|
-
status: body.status || initialStatus,
|
|
1442
|
-
version: 1,
|
|
1443
|
-
created_at: now,
|
|
1444
|
-
updated_at: now,
|
|
1445
|
-
metadata: {
|
|
1446
|
-
owners: ["*"],
|
|
1447
|
-
scope: body.scope || "team"
|
|
1448
|
-
}
|
|
1449
|
-
},
|
|
1450
|
-
body: body.content,
|
|
1451
|
-
rawContent: ""
|
|
1452
|
-
};
|
|
1453
|
-
const serialized = serializeDocument(node);
|
|
1454
|
-
await storage.writeDocument(id, serialized);
|
|
1455
|
-
syncNodeTags(nestId, id, tags);
|
|
1456
|
-
const authorEmail = getUserEmail(c);
|
|
1457
|
-
try {
|
|
1458
|
-
await versionManager.createVersion(node, authorEmail);
|
|
1459
|
-
} catch (err) {
|
|
1460
|
-
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1589
|
+
|
|
1590
|
+
// src/nodes/routes.ts
|
|
1591
|
+
import { Hono as Hono4 } from "hono";
|
|
1592
|
+
var nodeRoutes = new Hono4();
|
|
1593
|
+
nodeRoutes.get("/", async (c) => {
|
|
1594
|
+
const nestId = c.req.param("nestId");
|
|
1595
|
+
const userId = c.get("userId");
|
|
1596
|
+
const nodes = await listNodesForCaller(nestId, userId);
|
|
1597
|
+
return c.json({ count: nodes.length, nodes });
|
|
1598
|
+
});
|
|
1599
|
+
nodeRoutes.post("/", async (c) => {
|
|
1600
|
+
const body = await c.req.json();
|
|
1601
|
+
if (!body.title || !body.content) {
|
|
1602
|
+
throw new ValidationError("title and content are required");
|
|
1461
1603
|
}
|
|
1462
|
-
|
|
1604
|
+
const nestId = c.req.param("nestId");
|
|
1605
|
+
const authorEmail = getUserEmail(c);
|
|
1606
|
+
const { node } = await createNode(
|
|
1463
1607
|
nestId,
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
const resolved = resolveStewardsForNode(nestId, id);
|
|
1608
|
+
{
|
|
1609
|
+
title: body.title,
|
|
1610
|
+
content: body.content,
|
|
1611
|
+
type: body.type,
|
|
1612
|
+
tags: body.tags,
|
|
1613
|
+
scope: body.scope,
|
|
1614
|
+
status: body.status
|
|
1615
|
+
},
|
|
1616
|
+
authorEmail
|
|
1617
|
+
);
|
|
1618
|
+
const resolved = resolveStewardsForNode(nestId, node.id);
|
|
1476
1619
|
return c.json({
|
|
1477
1620
|
node: toNodeResponse(node),
|
|
1478
1621
|
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
@@ -1485,7 +1628,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1485
1628
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
1486
1629
|
const nestId = c.req.param("nestId");
|
|
1487
1630
|
const nodeId = c.req.param("nodeId");
|
|
1488
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
1631
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3XGX7QIN.js");
|
|
1489
1632
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
1490
1633
|
nestId,
|
|
1491
1634
|
nodeId
|
|
@@ -1497,8 +1640,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1497
1640
|
role: r.steward.role,
|
|
1498
1641
|
scope: r.steward.scope,
|
|
1499
1642
|
source: r.source,
|
|
1500
|
-
priority: r.priority
|
|
1501
|
-
canApprove: r.steward.canApprove
|
|
1643
|
+
priority: r.priority
|
|
1502
1644
|
})),
|
|
1503
1645
|
fallbackToOwner,
|
|
1504
1646
|
ownerEmail
|
|
@@ -1507,7 +1649,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1507
1649
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
1508
1650
|
const nestId = c.req.param("nestId");
|
|
1509
1651
|
const nodeId = c.req.param("nodeId");
|
|
1510
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
1652
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
|
|
1511
1653
|
const allVersions = getVersions2(nestId, nodeId);
|
|
1512
1654
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
1513
1655
|
const db = getDb();
|
|
@@ -1538,16 +1680,17 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
1538
1680
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
1539
1681
|
const nestId = c.req.param("nestId");
|
|
1540
1682
|
const nodeId = c.req.param("nodeId");
|
|
1541
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
1683
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3OJIPYNV.js");
|
|
1542
1684
|
const history = getReviewHistory2(nestId, nodeId);
|
|
1543
1685
|
return c.json({ reviews: history });
|
|
1544
1686
|
});
|
|
1545
1687
|
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
1546
1688
|
const nestId = c.req.param("nestId");
|
|
1547
1689
|
const nodeId = c.req.param("nodeId");
|
|
1548
|
-
const { storage } = engineCache.get(nestId);
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1690
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1691
|
+
const userId = c.get("userId");
|
|
1692
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1693
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
1551
1694
|
return c.json(
|
|
1552
1695
|
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
1553
1696
|
403
|
|
@@ -1555,18 +1698,61 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
1555
1698
|
}
|
|
1556
1699
|
let node;
|
|
1557
1700
|
try {
|
|
1558
|
-
node = await storage.readDocument(nodeId);
|
|
1701
|
+
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
1559
1702
|
} catch {
|
|
1560
1703
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1561
1704
|
}
|
|
1705
|
+
if (node.pendingChange) {
|
|
1706
|
+
try {
|
|
1707
|
+
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
1708
|
+
const refreshed = await getPendingChange(nestId, nodeId);
|
|
1709
|
+
if (refreshed) node.pendingChange = refreshed;
|
|
1710
|
+
} catch (err) {
|
|
1711
|
+
console.error("[external-edit] stage-on-read failed:", err);
|
|
1712
|
+
}
|
|
1713
|
+
try {
|
|
1714
|
+
const latest = await loadLatestApprovedNode(nestId, nodeId);
|
|
1715
|
+
if (latest) {
|
|
1716
|
+
node = { ...latest, pendingChange: node.pendingChange };
|
|
1717
|
+
}
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
console.error("[external-edit] reconstruct-latest failed:", err);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1562
1722
|
const response = toNodeResponse(node);
|
|
1563
|
-
|
|
1723
|
+
if (isPublicReader(nestId, userId)) {
|
|
1724
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
1725
|
+
if (approved != null) {
|
|
1726
|
+
try {
|
|
1727
|
+
const raw = await versionManager.reconstructVersion(
|
|
1728
|
+
nodeId,
|
|
1729
|
+
approved
|
|
1730
|
+
);
|
|
1731
|
+
response.content = bodyOnly(nodeId, raw);
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
console.error(
|
|
1734
|
+
"reconstructVersion failed (public single)",
|
|
1735
|
+
nodeId,
|
|
1736
|
+
approved,
|
|
1737
|
+
err
|
|
1738
|
+
);
|
|
1739
|
+
response.content = "";
|
|
1740
|
+
}
|
|
1741
|
+
response.version = approved;
|
|
1742
|
+
response.status = "published";
|
|
1743
|
+
return c.json({ node: response });
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
|
|
1747
|
+
if (response.status === "pending_review") {
|
|
1748
|
+
const pending = getPendingReview(nestId, nodeId);
|
|
1749
|
+
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
1750
|
+
}
|
|
1564
1751
|
return c.json({ node: response });
|
|
1565
1752
|
});
|
|
1566
1753
|
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
1567
1754
|
const nestId = c.req.param("nestId");
|
|
1568
1755
|
const nodeId = c.req.param("nodeId");
|
|
1569
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1570
1756
|
const body = await c.req.json();
|
|
1571
1757
|
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
1572
1758
|
if (baseVersionHeader) {
|
|
@@ -1586,80 +1772,21 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
1586
1772
|
);
|
|
1587
1773
|
}
|
|
1588
1774
|
}
|
|
1589
|
-
let node;
|
|
1590
|
-
try {
|
|
1591
|
-
node = await storage.readDocument(nodeId);
|
|
1592
|
-
} catch {
|
|
1593
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1594
|
-
}
|
|
1595
|
-
if (body.content !== void 0) {
|
|
1596
|
-
node = { ...node, body: body.content };
|
|
1597
|
-
}
|
|
1598
|
-
if (body.append) {
|
|
1599
|
-
node = { ...node, body: (node.body || "") + "\n\n" + body.append };
|
|
1600
|
-
}
|
|
1601
|
-
if (body.tags) {
|
|
1602
|
-
const newTags = body.tags.map((t) => t.startsWith("#") ? t : `#${t}`);
|
|
1603
|
-
const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
|
|
1604
|
-
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1605
|
-
}
|
|
1606
|
-
if (body.status) {
|
|
1607
|
-
node = {
|
|
1608
|
-
...node,
|
|
1609
|
-
frontmatter: { ...node.frontmatter, status: body.status }
|
|
1610
|
-
};
|
|
1611
|
-
}
|
|
1612
|
-
if (body.title) {
|
|
1613
|
-
node = {
|
|
1614
|
-
...node,
|
|
1615
|
-
frontmatter: { ...node.frontmatter, title: body.title }
|
|
1616
|
-
};
|
|
1617
|
-
}
|
|
1618
|
-
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1619
|
-
const newVersion = currentVersion + 1;
|
|
1620
|
-
node = {
|
|
1621
|
-
...node,
|
|
1622
|
-
frontmatter: {
|
|
1623
|
-
...node.frontmatter,
|
|
1624
|
-
version: newVersion,
|
|
1625
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1626
|
-
}
|
|
1627
|
-
};
|
|
1628
|
-
const fm = Object.fromEntries(
|
|
1629
|
-
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
1630
|
-
);
|
|
1631
|
-
node = { ...node, frontmatter: fm };
|
|
1632
|
-
const serialized = serializeDocument(node);
|
|
1633
|
-
await storage.writeDocument(nodeId, serialized);
|
|
1634
1775
|
const authorEmail = getUserEmail(c);
|
|
1635
|
-
const
|
|
1636
|
-
const currentTags = node.frontmatter.tags || [];
|
|
1637
|
-
syncNodeTags(nestId, nodeId, currentTags);
|
|
1638
|
-
try {
|
|
1639
|
-
await versionManager.createVersion(node, authorEmail, {
|
|
1640
|
-
note: body.changeNote
|
|
1641
|
-
});
|
|
1642
|
-
} catch (err) {
|
|
1643
|
-
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1644
|
-
}
|
|
1645
|
-
createVersion({
|
|
1776
|
+
const { node, version: responseVersion } = await updateNode(
|
|
1646
1777
|
nestId,
|
|
1647
1778
|
nodeId,
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
}
|
|
1659
|
-
if (!hasStewards) {
|
|
1660
|
-
setApprovedVersion(nestId, nodeId, newVersion, authorEmail);
|
|
1661
|
-
}
|
|
1662
|
-
return c.json({ node: toNodeResponse(node), version: newVersion });
|
|
1779
|
+
{
|
|
1780
|
+
content: body.content,
|
|
1781
|
+
append: body.append,
|
|
1782
|
+
tags: body.tags,
|
|
1783
|
+
title: body.title,
|
|
1784
|
+
status: body.status,
|
|
1785
|
+
changeNote: body.changeNote
|
|
1786
|
+
},
|
|
1787
|
+
authorEmail
|
|
1788
|
+
);
|
|
1789
|
+
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
1663
1790
|
});
|
|
1664
1791
|
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
1665
1792
|
const nestId = c.req.param("nestId");
|
|
@@ -1682,6 +1809,10 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
1682
1809
|
db.prepare(
|
|
1683
1810
|
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
|
|
1684
1811
|
).run(nestId, nodeId);
|
|
1812
|
+
db.prepare(
|
|
1813
|
+
`DELETE FROM stewards
|
|
1814
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`
|
|
1815
|
+
).run(nestId, nodeId);
|
|
1685
1816
|
})();
|
|
1686
1817
|
trackEvent("node.delete", { nestId, nodeId });
|
|
1687
1818
|
return c.json({ deleted: true });
|
|
@@ -1951,9 +2082,10 @@ queryRoutes.post("/context", async (c) => {
|
|
|
1951
2082
|
}
|
|
1952
2083
|
}
|
|
1953
2084
|
}
|
|
1954
|
-
const
|
|
2085
|
+
const userId = c.get("userId");
|
|
2086
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1955
2087
|
const beforePermission = documents.length;
|
|
1956
|
-
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
2088
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
1957
2089
|
const permissionFiltered = beforePermission - accessible.length;
|
|
1958
2090
|
const included = [];
|
|
1959
2091
|
let tokenCount = 0;
|
|
@@ -2010,8 +2142,9 @@ queryRoutes.post("/query", async (c) => {
|
|
|
2010
2142
|
const result = await queryEngine.query(body.query, {
|
|
2011
2143
|
hops: body.hops ?? 2
|
|
2012
2144
|
});
|
|
2013
|
-
const
|
|
2014
|
-
const
|
|
2145
|
+
const userId = c.get("userId");
|
|
2146
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2147
|
+
const accessible = filterAccessible(nestId, userId, userEmail, result.documents);
|
|
2015
2148
|
return c.json({
|
|
2016
2149
|
query: body.query,
|
|
2017
2150
|
count: accessible.length,
|
|
@@ -2042,8 +2175,9 @@ queryRoutes.get("/search", async (c) => {
|
|
|
2042
2175
|
].join(" ").toLowerCase();
|
|
2043
2176
|
return terms.every((term) => haystack.includes(term));
|
|
2044
2177
|
});
|
|
2045
|
-
const
|
|
2046
|
-
const
|
|
2178
|
+
const userId = c.get("userId");
|
|
2179
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2180
|
+
const accessible = filterAccessible(nestId, userId, userEmail, matches);
|
|
2047
2181
|
return c.json({
|
|
2048
2182
|
query: q,
|
|
2049
2183
|
count: accessible.length,
|
|
@@ -2144,7 +2278,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2144
2278
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2145
2279
|
|
|
2146
2280
|
// src/mcp/tools.ts
|
|
2147
|
-
import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
|
|
2148
2281
|
var TOOL_DEFINITIONS = [
|
|
2149
2282
|
{
|
|
2150
2283
|
name: "context_init",
|
|
@@ -2346,17 +2479,32 @@ var TOOL_DEFINITIONS = [
|
|
|
2346
2479
|
},
|
|
2347
2480
|
{
|
|
2348
2481
|
name: "context_assign_steward",
|
|
2349
|
-
description: "Assign a data steward to govern a scope (nest, tag,
|
|
2482
|
+
description: "Assign a data steward to govern a scope (nest, tag, or specific document). Stewards review and approve changes before they go live.",
|
|
2350
2483
|
inputSchema: {
|
|
2351
2484
|
type: "object",
|
|
2352
2485
|
properties: {
|
|
2353
2486
|
email: { type: "string", description: "Email of the person to assign as steward" },
|
|
2354
|
-
scope: { type: "string", description: "Scope: nest (all docs), tag,
|
|
2355
|
-
target: { type: "string", description: "Scope target: tag name (e.g. #architecture)
|
|
2487
|
+
scope: { type: "string", description: "Scope: nest (all docs), tag, or document" },
|
|
2488
|
+
target: { type: "string", description: "Scope target: tag name (e.g. #architecture) or document title" },
|
|
2356
2489
|
role: { type: "string", description: "Role: reviewer (default), editor, or viewer" }
|
|
2357
2490
|
},
|
|
2358
2491
|
required: ["email", "scope"]
|
|
2359
2492
|
}
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
name: "context_share_nest",
|
|
2496
|
+
description: "Share this nest with another person by granting them access. This is plain access sharing (not a steward role): viewer = read, editor = read+edit, admin = full access except deleting the nest.",
|
|
2497
|
+
inputSchema: {
|
|
2498
|
+
type: "object",
|
|
2499
|
+
properties: {
|
|
2500
|
+
email: { type: "string", description: "Email of the person to share with" },
|
|
2501
|
+
permission: {
|
|
2502
|
+
type: "string",
|
|
2503
|
+
description: "Access level: read (viewer, default), write (editor), or admin"
|
|
2504
|
+
}
|
|
2505
|
+
},
|
|
2506
|
+
required: ["email"]
|
|
2507
|
+
}
|
|
2360
2508
|
}
|
|
2361
2509
|
];
|
|
2362
2510
|
async function resolveLlmBody(ctx, node) {
|
|
@@ -2472,24 +2620,14 @@ ${list}`;
|
|
|
2472
2620
|
${body || "(no content)"}`;
|
|
2473
2621
|
}
|
|
2474
2622
|
case "context_list": {
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
}
|
|
2482
|
-
|
|
2483
|
-
docs = docs.filter(
|
|
2484
|
-
(n) => getApprovedVersion(nestId, n.id) != null
|
|
2485
|
-
);
|
|
2486
|
-
}
|
|
2487
|
-
docs = docs.slice(0, args.limit || 50);
|
|
2488
|
-
if (!docs.length) return "No nodes found with the given filters.";
|
|
2489
|
-
const list = docs.map(
|
|
2490
|
-
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}]`
|
|
2491
|
-
).join("\n");
|
|
2492
|
-
return `${docs.length} node(s):
|
|
2623
|
+
const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
|
|
2624
|
+
type: args.type,
|
|
2625
|
+
tag: args.tag,
|
|
2626
|
+
limit: args.limit || 50
|
|
2627
|
+
});
|
|
2628
|
+
if (!nodes.length) return "No nodes found with the given filters.";
|
|
2629
|
+
const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
|
|
2630
|
+
return `${nodes.length} node(s):
|
|
2493
2631
|
|
|
2494
2632
|
${list}`;
|
|
2495
2633
|
}
|
|
@@ -2510,50 +2648,21 @@ ${n.body || ""}`;
|
|
|
2510
2648
|
return resolved.join("\n\n---\n\n") || "No nodes resolved.";
|
|
2511
2649
|
}
|
|
2512
2650
|
case "context_create": {
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2516
|
-
const tags = (args.tags || []).map(
|
|
2517
|
-
(t) => t.startsWith("#") ? t : `#${t}`
|
|
2518
|
-
);
|
|
2519
|
-
const hasStewards = isStewardshipEnabled(nestId);
|
|
2520
|
-
const initialStatus = hasStewards ? "draft" : "approved";
|
|
2521
|
-
const node = {
|
|
2522
|
-
id,
|
|
2523
|
-
filePath: "",
|
|
2524
|
-
frontmatter: {
|
|
2525
|
-
title: args.title,
|
|
2526
|
-
type: args.type || "document",
|
|
2527
|
-
tags,
|
|
2528
|
-
status: initialStatus,
|
|
2529
|
-
version: 1,
|
|
2530
|
-
created_at: now,
|
|
2531
|
-
updated_at: now,
|
|
2532
|
-
metadata: { owners: ["*"], scope: args.scope || "team" }
|
|
2533
|
-
},
|
|
2534
|
-
body: args.content,
|
|
2535
|
-
rawContent: ""
|
|
2536
|
-
};
|
|
2537
|
-
await storage.writeDocument(id, serializeDocument3(node));
|
|
2538
|
-
syncNodeTags(nestId, id, tags);
|
|
2539
|
-
try {
|
|
2540
|
-
await versionManager.createVersion(node, userEmail);
|
|
2541
|
-
} catch (err) {
|
|
2542
|
-
console.error("VersionManager.createVersion failed (mcp create)", err);
|
|
2651
|
+
if (!canCreateInNest(nestId, userEmail)) {
|
|
2652
|
+
return "You don't have permission to create documents in this nest.";
|
|
2543
2653
|
}
|
|
2544
|
-
|
|
2654
|
+
const { node } = await createNode(
|
|
2545
2655
|
nestId,
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
}
|
|
2556
|
-
return `Created node: **${args.title}** (${id}) \u2014 status: ${initialStatus}`;
|
|
2656
|
+
{
|
|
2657
|
+
title: args.title,
|
|
2658
|
+
content: args.content,
|
|
2659
|
+
type: args.type,
|
|
2660
|
+
tags: args.tags,
|
|
2661
|
+
scope: args.scope
|
|
2662
|
+
},
|
|
2663
|
+
userEmail
|
|
2664
|
+
);
|
|
2665
|
+
return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
|
|
2557
2666
|
}
|
|
2558
2667
|
case "context_update": {
|
|
2559
2668
|
const docs = await storage.discoverDocuments();
|
|
@@ -2561,56 +2670,21 @@ ${n.body || ""}`;
|
|
|
2561
2670
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2562
2671
|
);
|
|
2563
2672
|
if (!node) return `Node not found: ${args.title}`;
|
|
2564
|
-
|
|
2565
|
-
if (
|
|
2566
|
-
|
|
2567
|
-
let tags = node.frontmatter.tags || [];
|
|
2568
|
-
if (args.tags) {
|
|
2569
|
-
const newTags = args.tags.map(
|
|
2570
|
-
(t) => t.startsWith("#") ? t : `#${t}`
|
|
2571
|
-
);
|
|
2572
|
-
tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
|
|
2573
|
-
}
|
|
2574
|
-
const prevVersion = getCurrentVersion(nestId, node.id);
|
|
2575
|
-
const newVersion = prevVersion + 1;
|
|
2576
|
-
const hasStewards = isStewardshipEnabled(nestId);
|
|
2577
|
-
const updated = {
|
|
2578
|
-
...node,
|
|
2579
|
-
body,
|
|
2580
|
-
frontmatter: {
|
|
2581
|
-
...node.frontmatter,
|
|
2582
|
-
tags,
|
|
2583
|
-
version: newVersion,
|
|
2584
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2585
|
-
}
|
|
2586
|
-
};
|
|
2587
|
-
await storage.writeDocument(node.id, serializeDocument3(updated));
|
|
2588
|
-
try {
|
|
2589
|
-
await versionManager.createVersion(updated, userEmail);
|
|
2590
|
-
} catch (err) {
|
|
2591
|
-
console.error("VersionManager.createVersion failed (mcp update)", err);
|
|
2673
|
+
const editCheck = canUserEdit(nestId, node.id, userEmail);
|
|
2674
|
+
if (!editCheck.allowed) {
|
|
2675
|
+
return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
|
|
2592
2676
|
}
|
|
2593
|
-
|
|
2594
|
-
createVersion({
|
|
2677
|
+
const { node: updated } = await updateNode(
|
|
2595
2678
|
nestId,
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
nestId,
|
|
2606
|
-
nodeId: node.id,
|
|
2607
|
-
cancelledBy: userEmail
|
|
2608
|
-
});
|
|
2609
|
-
}
|
|
2610
|
-
if (!hasStewards) {
|
|
2611
|
-
setApprovedVersion(nestId, node.id, newVersion, userEmail);
|
|
2612
|
-
}
|
|
2613
|
-
return `Updated node: **${node.frontmatter.title}**`;
|
|
2679
|
+
node.id,
|
|
2680
|
+
{
|
|
2681
|
+
content: args.content,
|
|
2682
|
+
append: args.append,
|
|
2683
|
+
tags: args.tags
|
|
2684
|
+
},
|
|
2685
|
+
userEmail
|
|
2686
|
+
);
|
|
2687
|
+
return `Updated node: **${updated.frontmatter.title}**`;
|
|
2614
2688
|
}
|
|
2615
2689
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
2616
2690
|
case "context_stewards": {
|
|
@@ -2625,24 +2699,27 @@ ${n.body || ""}`;
|
|
|
2625
2699
|
return `No stewards configured for "${args.title}". Changes are auto-approved.`;
|
|
2626
2700
|
}
|
|
2627
2701
|
const list = resolved.map(
|
|
2628
|
-
(r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.
|
|
2702
|
+
(r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.role === "reviewer" ? " \u2713 can approve" : ""}`
|
|
2629
2703
|
).join("\n");
|
|
2630
2704
|
return `# Stewards for "${args.title}"
|
|
2631
2705
|
|
|
2632
2706
|
${list}`;
|
|
2633
2707
|
}
|
|
2634
|
-
|
|
2708
|
+
if (!canManageStewards(ctx.userEmail)) {
|
|
2709
|
+
return "You don't have permission to list stewards. Only the super admin can do this.";
|
|
2710
|
+
}
|
|
2711
|
+
const allStewards = await getStewardsForNest(ctx.nestId);
|
|
2635
2712
|
if (allStewards.length === 0) {
|
|
2636
2713
|
return "No stewards configured. All changes are auto-approved.\n\nTo configure stewards, add a `stewards.yaml` file to the vault root, or use `context_assign_steward`.";
|
|
2637
2714
|
}
|
|
2638
2715
|
const byScope = {};
|
|
2639
2716
|
for (const s of allStewards) {
|
|
2640
|
-
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` :
|
|
2717
|
+
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
|
|
2641
2718
|
(byScope[key] = byScope[key] || []).push(s);
|
|
2642
2719
|
}
|
|
2643
2720
|
const sections = Object.entries(byScope).map(
|
|
2644
2721
|
([scope, stewards]) => `## ${scope}
|
|
2645
|
-
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.
|
|
2722
|
+
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
|
|
2646
2723
|
).join("\n\n");
|
|
2647
2724
|
return `# Data Stewards
|
|
2648
2725
|
|
|
@@ -2650,7 +2727,7 @@ ${sections}`;
|
|
|
2650
2727
|
}
|
|
2651
2728
|
case "context_review_queue": {
|
|
2652
2729
|
const status = args.status || "pending";
|
|
2653
|
-
const result = getReviewQueue({
|
|
2730
|
+
const result = await getReviewQueue({
|
|
2654
2731
|
nestId: ctx.nestId,
|
|
2655
2732
|
status
|
|
2656
2733
|
});
|
|
@@ -2658,7 +2735,7 @@ ${sections}`;
|
|
|
2658
2735
|
return status === "pending" ? "No documents pending review. All caught up!" : `No reviews with status "${status}".`;
|
|
2659
2736
|
}
|
|
2660
2737
|
const list = result.requests.map(
|
|
2661
|
-
(r, i) => `${i + 1}. **${r.nodeId}** v${r.version} \u2014 ${r.priority} priority
|
|
2738
|
+
(r, i) => `${i + 1}. **${r.title || r.nodeId}** v${r.version} \u2014 ${r.priority} priority
|
|
2662
2739
|
Submitted by: ${r.requestedBy} at ${r.requestedAt}${r.requestNote ? `
|
|
2663
2740
|
Note: "${r.requestNote}"` : ""}`
|
|
2664
2741
|
).join("\n\n");
|
|
@@ -2673,6 +2750,10 @@ ${list}`;
|
|
|
2673
2750
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2674
2751
|
);
|
|
2675
2752
|
if (!node) return `Node not found: ${args.title}`;
|
|
2753
|
+
const submitCheck = canUserEdit(ctx.nestId, node.id, userEmail);
|
|
2754
|
+
if (!submitCheck.allowed) {
|
|
2755
|
+
return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
|
|
2756
|
+
}
|
|
2676
2757
|
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
2677
2758
|
if (currentVersion === 0) return `No versions found for "${args.title}"`;
|
|
2678
2759
|
try {
|
|
@@ -2705,7 +2786,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
2705
2786
|
if (!node) return `Node not found: ${args.title}`;
|
|
2706
2787
|
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
2707
2788
|
try {
|
|
2708
|
-
const request = approve({
|
|
2789
|
+
const request = await approve({
|
|
2709
2790
|
nestId: ctx.nestId,
|
|
2710
2791
|
nodeId: node.id,
|
|
2711
2792
|
version: currentVersion,
|
|
@@ -2760,22 +2841,20 @@ ${list}`;
|
|
|
2760
2841
|
}
|
|
2761
2842
|
case "context_assign_steward": {
|
|
2762
2843
|
const scope = args.scope;
|
|
2763
|
-
if (!["nest", "tag", "
|
|
2764
|
-
return `Invalid scope "${args.scope}". Use: nest, tag,
|
|
2844
|
+
if (!["nest", "tag", "document"].includes(scope)) {
|
|
2845
|
+
return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
|
|
2846
|
+
}
|
|
2847
|
+
if (!canManageStewards(ctx.userEmail)) {
|
|
2848
|
+
return "You don't have permission to manage stewards. Only the super admin can do this.";
|
|
2765
2849
|
}
|
|
2766
2850
|
try {
|
|
2767
|
-
|
|
2851
|
+
await createStewardRecord({
|
|
2768
2852
|
nestId: ctx.nestId,
|
|
2769
2853
|
scope,
|
|
2770
|
-
|
|
2854
|
+
documentId: scope === "document" ? args.target : void 0,
|
|
2771
2855
|
tagName: scope === "tag" ? args.target : void 0,
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
canApprove: true,
|
|
2775
|
-
canReject: true,
|
|
2776
|
-
assignedBy: ctx.userEmail,
|
|
2777
|
-
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2778
|
-
isActive: true
|
|
2856
|
+
users: [{ email: args.email, role: args.role || "reviewer" }],
|
|
2857
|
+
assignedBy: ctx.userEmail
|
|
2779
2858
|
});
|
|
2780
2859
|
const targetDesc = scope === "nest" ? "all documents" : `${scope}: ${args.target}`;
|
|
2781
2860
|
return `Assigned **${args.email}** as ${args.role || "reviewer"} for ${targetDesc}.`;
|
|
@@ -2783,6 +2862,25 @@ ${list}`;
|
|
|
2783
2862
|
return `Failed to assign steward: ${err.message}`;
|
|
2784
2863
|
}
|
|
2785
2864
|
}
|
|
2865
|
+
case "context_share_nest": {
|
|
2866
|
+
const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
|
|
2867
|
+
if (!canManageWith(roles)) {
|
|
2868
|
+
return "You don't have permission to share this nest. Only the nest owner or an admin can add people.";
|
|
2869
|
+
}
|
|
2870
|
+
const permission = args.permission || "read";
|
|
2871
|
+
try {
|
|
2872
|
+
await addCollaborator({
|
|
2873
|
+
nestId: ctx.nestId,
|
|
2874
|
+
email: args.email,
|
|
2875
|
+
permission,
|
|
2876
|
+
grantedByEmail: ctx.userEmail
|
|
2877
|
+
});
|
|
2878
|
+
const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
|
|
2879
|
+
return `Shared this nest with **${args.email}** as ${label}.`;
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
return `Failed to share nest: ${err.message}`;
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2786
2884
|
default:
|
|
2787
2885
|
return `Unknown tool: ${toolName}`;
|
|
2788
2886
|
}
|
|
@@ -2851,8 +2949,8 @@ mcpRoutes.all("/", async (c) => {
|
|
|
2851
2949
|
import { Hono as Hono7 } from "hono";
|
|
2852
2950
|
|
|
2853
2951
|
// src/governance/stewards-parser.ts
|
|
2854
|
-
import { readFileSync
|
|
2855
|
-
import { join as
|
|
2952
|
+
import { readFileSync, existsSync } from "fs";
|
|
2953
|
+
import { join as join2 } from "path";
|
|
2856
2954
|
function parseStewardsYaml(content) {
|
|
2857
2955
|
const result = { version: 1 };
|
|
2858
2956
|
const lines = content.split("\n");
|
|
@@ -2863,9 +2961,6 @@ function parseStewardsYaml(content) {
|
|
|
2863
2961
|
if (!currentSection || currentEntries.length === 0) return;
|
|
2864
2962
|
if (currentSection === "nest") {
|
|
2865
2963
|
result.nest = [...result.nest || [], ...currentEntries];
|
|
2866
|
-
} else if (currentSection === "folders" && currentTarget) {
|
|
2867
|
-
result.folders = result.folders || {};
|
|
2868
|
-
result.folders[currentTarget] = currentEntries;
|
|
2869
2964
|
} else if (currentSection === "tags" && currentTarget) {
|
|
2870
2965
|
result.tags = result.tags || {};
|
|
2871
2966
|
result.tags[currentTarget] = currentEntries;
|
|
@@ -2885,12 +2980,12 @@ function parseStewardsYaml(content) {
|
|
|
2885
2980
|
if (key === "version") continue;
|
|
2886
2981
|
if (key === "nest" || key === "data_room") {
|
|
2887
2982
|
currentSection = "nest";
|
|
2888
|
-
} else if (key === "folders") {
|
|
2889
|
-
currentSection = "folders";
|
|
2890
2983
|
} else if (key === "tags") {
|
|
2891
2984
|
currentSection = "tags";
|
|
2892
2985
|
} else if (key === "documents") {
|
|
2893
2986
|
currentSection = "documents";
|
|
2987
|
+
} else if (key === "folders") {
|
|
2988
|
+
currentSection = null;
|
|
2894
2989
|
}
|
|
2895
2990
|
continue;
|
|
2896
2991
|
}
|
|
@@ -2916,25 +3011,19 @@ function parseEntry(str) {
|
|
|
2916
3011
|
const entry = { email: emailMatch[1] };
|
|
2917
3012
|
const roleMatch = str.match(/role:\s*["']?(\w+)["']?/);
|
|
2918
3013
|
if (roleMatch) entry.role = roleMatch[1];
|
|
2919
|
-
if (str.includes("can_approve:")) {
|
|
2920
|
-
entry.can_approve = str.includes("can_approve: true");
|
|
2921
|
-
}
|
|
2922
|
-
if (str.includes("can_reject:")) {
|
|
2923
|
-
entry.can_reject = str.includes("can_reject: true");
|
|
2924
|
-
}
|
|
2925
3014
|
return entry;
|
|
2926
3015
|
}
|
|
2927
3016
|
function loadStewardsConfig(nestId) {
|
|
2928
3017
|
const dataRoot = config.DATA_ROOT;
|
|
2929
|
-
const
|
|
3018
|
+
const nestPath = join2(dataRoot, "nests", nestId);
|
|
2930
3019
|
const candidates = [
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
3020
|
+
join2(nestPath, "stewards.yaml"),
|
|
3021
|
+
join2(nestPath, "stewards.yml"),
|
|
3022
|
+
join2(nestPath, ".context", "stewards.yaml")
|
|
2934
3023
|
];
|
|
2935
3024
|
for (const candidatePath of candidates) {
|
|
2936
|
-
if (
|
|
2937
|
-
const content =
|
|
3025
|
+
if (existsSync(candidatePath)) {
|
|
3026
|
+
const content = readFileSync(candidatePath, "utf-8");
|
|
2938
3027
|
return parseStewardsYaml(content);
|
|
2939
3028
|
}
|
|
2940
3029
|
}
|
|
@@ -2947,24 +3036,41 @@ governanceRoutes.get("/stewards", async (c) => {
|
|
|
2947
3036
|
const nestId = c.req.param("nestId");
|
|
2948
3037
|
const scope = c.req.query("scope");
|
|
2949
3038
|
const search = c.req.query("search");
|
|
2950
|
-
const stewards = listStewards({
|
|
3039
|
+
const stewards = await listStewards({
|
|
2951
3040
|
nestId,
|
|
2952
3041
|
scope: scope || void 0,
|
|
2953
3042
|
search: search || void 0
|
|
2954
3043
|
});
|
|
2955
|
-
|
|
3044
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3045
|
+
const enriched = stewards.map((s) => {
|
|
3046
|
+
const key = s.userEmail.toLowerCase();
|
|
3047
|
+
let merged = cache.get(key);
|
|
3048
|
+
if (!merged) {
|
|
3049
|
+
merged = {
|
|
3050
|
+
collaboratorRole: getCollaboratorRole(nestId, s.userEmail),
|
|
3051
|
+
roles: resolveUserRoles(nestId, s.userEmail)
|
|
3052
|
+
};
|
|
3053
|
+
cache.set(key, merged);
|
|
3054
|
+
}
|
|
3055
|
+
return { ...s, ...merged };
|
|
3056
|
+
});
|
|
3057
|
+
return c.json({ stewards: enriched });
|
|
2956
3058
|
});
|
|
2957
3059
|
governanceRoutes.post("/stewards", async (c) => {
|
|
2958
3060
|
const nestId = c.req.param("nestId");
|
|
2959
3061
|
const body = await c.req.json();
|
|
2960
3062
|
const assignedBy = getUserEmail3(c);
|
|
2961
3063
|
if (!body.scope) throw new ValidationError("scope is required");
|
|
3064
|
+
if (body.scope === "folder") {
|
|
3065
|
+
throw new ValidationError(
|
|
3066
|
+
"folder scope is no longer supported \u2014 use 'nest' or 'document'"
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
2962
3069
|
if (Array.isArray(body.users)) {
|
|
2963
3070
|
const created2 = await createStewardRecord({
|
|
2964
3071
|
nestId,
|
|
2965
3072
|
scope: body.scope,
|
|
2966
3073
|
documentId: body.documentId,
|
|
2967
|
-
folderPath: body.folderPath,
|
|
2968
3074
|
tagName: body.tagName,
|
|
2969
3075
|
users: body.users,
|
|
2970
3076
|
assignedBy
|
|
@@ -2978,20 +3084,31 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
2978
3084
|
nestId,
|
|
2979
3085
|
scope: body.scope,
|
|
2980
3086
|
documentId: body.scope === "document" ? body.nodePattern : void 0,
|
|
2981
|
-
folderPath: body.scope === "folder" ? body.nodePattern : void 0,
|
|
2982
3087
|
tagName: body.scope === "tag" ? body.tagName : void 0,
|
|
2983
3088
|
users: [
|
|
2984
3089
|
{
|
|
2985
3090
|
email: body.email,
|
|
2986
|
-
role: body.role
|
|
2987
|
-
canApprove: body.canApprove,
|
|
2988
|
-
canReject: body.canReject
|
|
3091
|
+
role: body.role
|
|
2989
3092
|
}
|
|
2990
3093
|
],
|
|
2991
3094
|
assignedBy
|
|
2992
3095
|
});
|
|
2993
3096
|
return c.json({ steward: created[0] }, 201);
|
|
2994
3097
|
});
|
|
3098
|
+
governanceRoutes.patch("/stewards/:stewardId", async (c) => {
|
|
3099
|
+
const stewardId = c.req.param("stewardId");
|
|
3100
|
+
const body = await c.req.json();
|
|
3101
|
+
if (!body.role && !body.scope) {
|
|
3102
|
+
throw new ValidationError("role or scope is required");
|
|
3103
|
+
}
|
|
3104
|
+
const steward = updateSteward(stewardId, {
|
|
3105
|
+
role: body.role,
|
|
3106
|
+
scope: body.scope,
|
|
3107
|
+
documentId: body.nodePattern,
|
|
3108
|
+
tagName: body.tagName
|
|
3109
|
+
});
|
|
3110
|
+
return c.json({ steward });
|
|
3111
|
+
});
|
|
2995
3112
|
governanceRoutes.delete("/stewards/:stewardId", async (c) => {
|
|
2996
3113
|
const stewardId = c.req.param("stewardId");
|
|
2997
3114
|
removeSteward(stewardId);
|
|
@@ -3011,7 +3128,7 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
3011
3128
|
const status = c.req.query("status") || "pending";
|
|
3012
3129
|
const limit = parseInt(c.req.query("limit") || "50", 10);
|
|
3013
3130
|
const offset = parseInt(c.req.query("offset") || "0", 10);
|
|
3014
|
-
const result = getReviewQueue({
|
|
3131
|
+
const result = await getReviewQueue({
|
|
3015
3132
|
nestId,
|
|
3016
3133
|
status,
|
|
3017
3134
|
limit,
|
|
@@ -3019,6 +3136,21 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
3019
3136
|
});
|
|
3020
3137
|
return c.json(result);
|
|
3021
3138
|
});
|
|
3139
|
+
governanceRoutes.get("/external-edits", async (c) => {
|
|
3140
|
+
const nestId = c.req.param("nestId");
|
|
3141
|
+
const refresh = c.req.query("refresh") === "true";
|
|
3142
|
+
if (refresh) {
|
|
3143
|
+
await scanNestForDrift(nestId, "user:refresh");
|
|
3144
|
+
}
|
|
3145
|
+
const entries = await listNestExternalEdits(nestId);
|
|
3146
|
+
return c.json({ entries, total: entries.length });
|
|
3147
|
+
});
|
|
3148
|
+
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
3149
|
+
const nestId = c.req.param("nestId");
|
|
3150
|
+
const actor = getUserEmail3(c);
|
|
3151
|
+
const result = await scanNestForDrift(nestId, actor);
|
|
3152
|
+
return c.json(result);
|
|
3153
|
+
});
|
|
3022
3154
|
var governanceNodeRoutes = new Hono7();
|
|
3023
3155
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
3024
3156
|
const nestId = c.req.param("nestId");
|
|
@@ -3031,8 +3163,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
3031
3163
|
role: r.steward.role,
|
|
3032
3164
|
scope: r.steward.scope,
|
|
3033
3165
|
source: r.source,
|
|
3034
|
-
priority: r.priority
|
|
3035
|
-
canApprove: r.steward.canApprove
|
|
3166
|
+
priority: r.priority
|
|
3036
3167
|
})),
|
|
3037
3168
|
fallbackToOwner,
|
|
3038
3169
|
ownerEmail
|
|
@@ -3101,7 +3232,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
3101
3232
|
const userEmail = getUserEmail3(c);
|
|
3102
3233
|
const isAdmin = isSuperAdmin(userEmail);
|
|
3103
3234
|
try {
|
|
3104
|
-
const request = approve({
|
|
3235
|
+
const request = await approve({
|
|
3105
3236
|
nestId,
|
|
3106
3237
|
nodeId,
|
|
3107
3238
|
version: getCurrentVersion(nestId, nodeId),
|
|
@@ -3153,6 +3284,90 @@ governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
|
3153
3284
|
const userEmail = getUserEmail3(c);
|
|
3154
3285
|
return c.json(canUserEdit(nestId, nodeId, userEmail));
|
|
3155
3286
|
});
|
|
3287
|
+
governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
|
|
3288
|
+
const nestId = c.req.param("nestId");
|
|
3289
|
+
const nodeId = c.req.param("nodeId");
|
|
3290
|
+
const pending = await getPendingChange(nestId, nodeId);
|
|
3291
|
+
return c.json({ pending });
|
|
3292
|
+
});
|
|
3293
|
+
governanceNodeRoutes.get(
|
|
3294
|
+
"/:nodeId{.+?}/external-edits/:suggestionId",
|
|
3295
|
+
async (c) => {
|
|
3296
|
+
const nestId = c.req.param("nestId");
|
|
3297
|
+
const nodeId = c.req.param("nodeId");
|
|
3298
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3299
|
+
const detail = await getExternalEditDetail(
|
|
3300
|
+
nestId,
|
|
3301
|
+
nodeId,
|
|
3302
|
+
suggestionId
|
|
3303
|
+
);
|
|
3304
|
+
if (!detail) {
|
|
3305
|
+
return c.json({ error: "Suggestion not found" }, 404);
|
|
3306
|
+
}
|
|
3307
|
+
return c.json({ entry: detail });
|
|
3308
|
+
}
|
|
3309
|
+
);
|
|
3310
|
+
governanceNodeRoutes.post(
|
|
3311
|
+
"/:nodeId{.+?}/external-edits/:suggestionId/approve",
|
|
3312
|
+
async (c) => {
|
|
3313
|
+
const nestId = c.req.param("nestId");
|
|
3314
|
+
const nodeId = c.req.param("nodeId");
|
|
3315
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3316
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3317
|
+
const actor = getUserEmail3(c);
|
|
3318
|
+
try {
|
|
3319
|
+
const result = await approveExternalEdit({
|
|
3320
|
+
nestId,
|
|
3321
|
+
documentId: nodeId,
|
|
3322
|
+
suggestionId,
|
|
3323
|
+
actor,
|
|
3324
|
+
comment: body.comment
|
|
3325
|
+
});
|
|
3326
|
+
return c.json({
|
|
3327
|
+
approved: true,
|
|
3328
|
+
version: result.versionEntry.version,
|
|
3329
|
+
chainEvent: result.chainEvent.event_id
|
|
3330
|
+
});
|
|
3331
|
+
} catch (err) {
|
|
3332
|
+
console.error(
|
|
3333
|
+
`[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
|
|
3334
|
+
{ message: err?.message, name: err?.name, stack: err?.stack }
|
|
3335
|
+
);
|
|
3336
|
+
return c.json(
|
|
3337
|
+
{ error: err.message, name: err?.name },
|
|
3338
|
+
400
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
);
|
|
3343
|
+
governanceNodeRoutes.post(
|
|
3344
|
+
"/:nodeId{.+?}/external-edits/:suggestionId/reject",
|
|
3345
|
+
async (c) => {
|
|
3346
|
+
const nestId = c.req.param("nestId");
|
|
3347
|
+
const nodeId = c.req.param("nodeId");
|
|
3348
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3349
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3350
|
+
if (!body.reason) {
|
|
3351
|
+
throw new ValidationError("Rejection reason is required");
|
|
3352
|
+
}
|
|
3353
|
+
const actor = getUserEmail3(c);
|
|
3354
|
+
try {
|
|
3355
|
+
const result = await rejectExternalEdit({
|
|
3356
|
+
nestId,
|
|
3357
|
+
documentId: nodeId,
|
|
3358
|
+
suggestionId,
|
|
3359
|
+
actor,
|
|
3360
|
+
reason: body.reason
|
|
3361
|
+
});
|
|
3362
|
+
return c.json({
|
|
3363
|
+
rejected: true,
|
|
3364
|
+
chainEvent: result.chainEvent.event_id
|
|
3365
|
+
});
|
|
3366
|
+
} catch (err) {
|
|
3367
|
+
return c.json({ error: err.message }, 400);
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
);
|
|
3156
3371
|
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
3157
3372
|
const nestId = c.req.param("nestId");
|
|
3158
3373
|
const nodeId = c.req.param("nodeId");
|
|
@@ -3173,31 +3388,29 @@ function getUserEmail3(c) {
|
|
|
3173
3388
|
|
|
3174
3389
|
// src/auth/anonymous.ts
|
|
3175
3390
|
import bcrypt from "bcryptjs";
|
|
3176
|
-
var ANON_USER_ID3 = "00000000-0000-0000-0000-000000000000";
|
|
3177
|
-
var ANON_EMAIL = "admin@localhost";
|
|
3178
3391
|
function ensureAnonymousUser() {
|
|
3179
3392
|
const db = getDb();
|
|
3180
|
-
const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(
|
|
3393
|
+
const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID);
|
|
3181
3394
|
if (!exists) {
|
|
3182
3395
|
const placeholder = bcrypt.hashSync("anon-no-login", 4);
|
|
3183
3396
|
db.prepare(
|
|
3184
3397
|
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
3185
|
-
).run(
|
|
3398
|
+
).run(ANON_USER_ID, ANON_EMAIL, "Admin", placeholder);
|
|
3186
3399
|
}
|
|
3187
|
-
return
|
|
3400
|
+
return ANON_USER_ID;
|
|
3188
3401
|
}
|
|
3189
3402
|
|
|
3190
3403
|
// src/app.ts
|
|
3191
3404
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3192
3405
|
import { fileURLToPath } from "url";
|
|
3193
|
-
import { dirname, join as
|
|
3194
|
-
import { existsSync as
|
|
3406
|
+
import { dirname, join as join3, relative } from "path";
|
|
3407
|
+
import { existsSync as existsSync2 } from "fs";
|
|
3195
3408
|
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
3196
3409
|
var UI_DIR_CANDIDATES = [
|
|
3197
|
-
|
|
3198
|
-
|
|
3410
|
+
join3(HERE, "web3"),
|
|
3411
|
+
join3(process.cwd(), "dist", "web3")
|
|
3199
3412
|
];
|
|
3200
|
-
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) =>
|
|
3413
|
+
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
3201
3414
|
var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
|
|
3202
3415
|
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
3203
3416
|
const anonId = ensureAnonymousUser();
|
|
@@ -3205,6 +3418,11 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
|
3205
3418
|
c.set("nestScope", null);
|
|
3206
3419
|
await next();
|
|
3207
3420
|
});
|
|
3421
|
+
function isPublicReadEligiblePath(method, path) {
|
|
3422
|
+
if (method !== "GET") return false;
|
|
3423
|
+
if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
|
|
3424
|
+
return !/\/(collaborators|visibility|settings|mcp)/.test(path);
|
|
3425
|
+
}
|
|
3208
3426
|
var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
3209
3427
|
const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
|
|
3210
3428
|
const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
|
|
@@ -3217,6 +3435,12 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
3217
3435
|
c.set("nestScope", null);
|
|
3218
3436
|
return next();
|
|
3219
3437
|
}
|
|
3438
|
+
if (isPublicReadEligiblePath(c.req.method, c.req.path)) {
|
|
3439
|
+
const anonId = ensureAnonymousUser();
|
|
3440
|
+
c.set("userId", anonId);
|
|
3441
|
+
c.set("nestScope", null);
|
|
3442
|
+
return next();
|
|
3443
|
+
}
|
|
3220
3444
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
3221
3445
|
});
|
|
3222
3446
|
function createApp() {
|
|
@@ -3248,6 +3472,7 @@ function createApp() {
|
|
|
3248
3472
|
service: "contextnest-community",
|
|
3249
3473
|
version: "0.1.0",
|
|
3250
3474
|
auth_mode: config.AUTH_MODE,
|
|
3475
|
+
logo_url: config.LOGO_URL,
|
|
3251
3476
|
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
3252
3477
|
})
|
|
3253
3478
|
);
|
|
@@ -3308,7 +3533,7 @@ function createApp() {
|
|
|
3308
3533
|
return c.json(
|
|
3309
3534
|
{
|
|
3310
3535
|
valid: false,
|
|
3311
|
-
error: "
|
|
3536
|
+
error: "PromptOwl rejected this license key. It wasn't saved. Verify the key is correct and active, then try again."
|
|
3312
3537
|
},
|
|
3313
3538
|
400
|
|
3314
3539
|
);
|
|
@@ -3324,6 +3549,28 @@ function createApp() {
|
|
|
3324
3549
|
return c.json({ error: msg }, 500);
|
|
3325
3550
|
}
|
|
3326
3551
|
});
|
|
3552
|
+
app.use("/stats", flexAuthMiddleware);
|
|
3553
|
+
app.get("/stats", async (c) => {
|
|
3554
|
+
const db = getDb();
|
|
3555
|
+
const userId = c.get("userId");
|
|
3556
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3557
|
+
const visibleNests = [...listNests(userId), ...listSharedNests(userId)];
|
|
3558
|
+
let documents = 0;
|
|
3559
|
+
for (const nest of visibleNests) {
|
|
3560
|
+
try {
|
|
3561
|
+
const { storage } = engineCache.get(nest.id);
|
|
3562
|
+
const docs = await storage.discoverDocuments();
|
|
3563
|
+
documents += filterAccessible(nest.id, userId, userEmail, docs).length;
|
|
3564
|
+
} catch {
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
const usersRow = db.prepare("SELECT COUNT(*) as c FROM users").get();
|
|
3568
|
+
return c.json({
|
|
3569
|
+
nests: visibleNests.length,
|
|
3570
|
+
documents,
|
|
3571
|
+
users: usersRow.c
|
|
3572
|
+
});
|
|
3573
|
+
});
|
|
3327
3574
|
const nestsApp = new Hono8();
|
|
3328
3575
|
nestsApp.use("*", flexAuthMiddleware);
|
|
3329
3576
|
nestsApp.use("*", async (c, next) => {
|
|
@@ -3375,12 +3622,42 @@ function createApp() {
|
|
|
3375
3622
|
let required = "read";
|
|
3376
3623
|
const path = c.req.path;
|
|
3377
3624
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
3625
|
+
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
3626
|
+
if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
|
|
3627
|
+
return c.json(
|
|
3628
|
+
{
|
|
3629
|
+
error: "You don't have permission to manage stewards. Only the super admin can do this."
|
|
3630
|
+
},
|
|
3631
|
+
403
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3378
3634
|
if (path.includes("/collaborators") || path.includes("/visibility")) {
|
|
3379
3635
|
required = "admin";
|
|
3380
3636
|
} else if (c.req.method !== "GET" && !isStewardActionPath) {
|
|
3381
3637
|
required = "write";
|
|
3382
3638
|
}
|
|
3383
|
-
|
|
3639
|
+
let stewardEditorBypass = false;
|
|
3640
|
+
if (required === "write" && permission === "read" && parts[1] === "nodes") {
|
|
3641
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3642
|
+
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE")) {
|
|
3643
|
+
const rawNodeId = parts.slice(2).join("/");
|
|
3644
|
+
let nodeId = rawNodeId;
|
|
3645
|
+
try {
|
|
3646
|
+
nodeId = decodeURIComponent(rawNodeId);
|
|
3647
|
+
} catch {
|
|
3648
|
+
}
|
|
3649
|
+
const resolved = resolveStewardsForNode(nestId, nodeId);
|
|
3650
|
+
stewardEditorBypass = resolved.some(
|
|
3651
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
|
|
3652
|
+
);
|
|
3653
|
+
} else if (parts.length === 2 && c.req.method === "POST") {
|
|
3654
|
+
const resolved = resolveStewardsForNode(nestId, "");
|
|
3655
|
+
stewardEditorBypass = resolved.some(
|
|
3656
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
|
|
3657
|
+
);
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
if (!stewardEditorBypass && permissionLevel(permission) < permissionLevel(required)) {
|
|
3384
3661
|
return c.json(
|
|
3385
3662
|
{
|
|
3386
3663
|
error: `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
|
|
@@ -3479,9 +3756,108 @@ function createApp() {
|
|
|
3479
3756
|
return app;
|
|
3480
3757
|
}
|
|
3481
3758
|
|
|
3759
|
+
// src/db/backfill.ts
|
|
3760
|
+
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
3761
|
+
import { join as join4 } from "path";
|
|
3762
|
+
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
3763
|
+
async function backfillNodeVersionsFromHistory(db) {
|
|
3764
|
+
const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
|
|
3765
|
+
if (already) return;
|
|
3766
|
+
const nests = db.prepare("SELECT id FROM nests").all();
|
|
3767
|
+
const insert = db.prepare(
|
|
3768
|
+
`INSERT OR IGNORE INTO node_versions
|
|
3769
|
+
(nest_id, node_id, version, content_hash, author, status, change_note, created_at)
|
|
3770
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3771
|
+
);
|
|
3772
|
+
const approvedPin = db.prepare(
|
|
3773
|
+
`INSERT OR REPLACE INTO approved_versions
|
|
3774
|
+
(nest_id, node_id, approved_version, approved_by, approved_at)
|
|
3775
|
+
VALUES (?, ?, ?, ?, COALESCE(
|
|
3776
|
+
(SELECT approved_at FROM approved_versions WHERE nest_id = ? AND node_id = ?),
|
|
3777
|
+
datetime('now')))`
|
|
3778
|
+
);
|
|
3779
|
+
let totalInserted = 0;
|
|
3780
|
+
let totalDocs = 0;
|
|
3781
|
+
for (const { id: nestId } of nests) {
|
|
3782
|
+
const nestPath = join4(config.DATA_ROOT, "nests", nestId);
|
|
3783
|
+
const storage = new NestStorage(nestPath);
|
|
3784
|
+
let docs;
|
|
3785
|
+
try {
|
|
3786
|
+
docs = await storage.discoverDocuments();
|
|
3787
|
+
} catch (err) {
|
|
3788
|
+
console.warn(
|
|
3789
|
+
`[backfill] discoverDocuments failed for nest ${nestId}:`,
|
|
3790
|
+
err.message
|
|
3791
|
+
);
|
|
3792
|
+
continue;
|
|
3793
|
+
}
|
|
3794
|
+
for (const doc of docs) {
|
|
3795
|
+
totalDocs += 1;
|
|
3796
|
+
let history;
|
|
3797
|
+
try {
|
|
3798
|
+
history = await storage.readHistory(doc.id);
|
|
3799
|
+
} catch {
|
|
3800
|
+
history = null;
|
|
3801
|
+
}
|
|
3802
|
+
if (!history || history.versions.length === 0) continue;
|
|
3803
|
+
const existing = db.prepare(
|
|
3804
|
+
`SELECT version FROM node_versions WHERE nest_id = ? AND node_id = ?`
|
|
3805
|
+
).all(nestId, doc.id);
|
|
3806
|
+
const known = new Set(existing.map((r) => r.version));
|
|
3807
|
+
const tagsJson = doc.frontmatter.tags ? JSON.stringify(doc.frontmatter.tags) : null;
|
|
3808
|
+
const latestVersion = history.versions[history.versions.length - 1].version;
|
|
3809
|
+
for (const entry of history.versions) {
|
|
3810
|
+
if (known.has(entry.version)) continue;
|
|
3811
|
+
insert.run(
|
|
3812
|
+
nestId,
|
|
3813
|
+
doc.id,
|
|
3814
|
+
entry.version,
|
|
3815
|
+
entry.content_hash || "",
|
|
3816
|
+
entry.edited_by || "system:backfill",
|
|
3817
|
+
"approved",
|
|
3818
|
+
entry.note || null,
|
|
3819
|
+
entry.edited_at || (/* @__PURE__ */ new Date()).toISOString()
|
|
3820
|
+
);
|
|
3821
|
+
totalInserted += 1;
|
|
3822
|
+
}
|
|
3823
|
+
const pin = db.prepare(
|
|
3824
|
+
`SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?`
|
|
3825
|
+
).get(nestId, doc.id);
|
|
3826
|
+
if (!pin || pin.approved_version < latestVersion) {
|
|
3827
|
+
approvedPin.run(
|
|
3828
|
+
nestId,
|
|
3829
|
+
doc.id,
|
|
3830
|
+
latestVersion,
|
|
3831
|
+
history.versions[history.versions.length - 1].edited_by || "system:backfill",
|
|
3832
|
+
nestId,
|
|
3833
|
+
doc.id
|
|
3834
|
+
);
|
|
3835
|
+
}
|
|
3836
|
+
if (tagsJson) {
|
|
3837
|
+
const updateTags = db.prepare(
|
|
3838
|
+
`UPDATE node_versions SET tags_json = ?
|
|
3839
|
+
WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL`
|
|
3840
|
+
);
|
|
3841
|
+
updateTags.run(tagsJson, nestId, doc.id, latestVersion);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)").run(
|
|
3846
|
+
MIGRATION_ID
|
|
3847
|
+
);
|
|
3848
|
+
console.log(
|
|
3849
|
+
`[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
|
|
3850
|
+
);
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3482
3853
|
// src/index.ts
|
|
3483
3854
|
async function main() {
|
|
3484
|
-
getDb();
|
|
3855
|
+
const db = getDb();
|
|
3856
|
+
try {
|
|
3857
|
+
await backfillNodeVersionsFromHistory(db);
|
|
3858
|
+
} catch (err) {
|
|
3859
|
+
console.error("[backfill] node_versions backfill failed:", err);
|
|
3860
|
+
}
|
|
3485
3861
|
const accessCfg = loadAccessConfig();
|
|
3486
3862
|
if (accessCfg) {
|
|
3487
3863
|
console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
|
|
@@ -3502,7 +3878,11 @@ async function main() {
|
|
|
3502
3878
|
`);
|
|
3503
3879
|
}
|
|
3504
3880
|
const app = createApp();
|
|
3505
|
-
|
|
3881
|
+
startLicenseSafetyPoll();
|
|
3882
|
+
const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
|
|
3883
|
+
if (driftScanIntervalMs > 0) {
|
|
3884
|
+
startDriftScanner(driftScanIntervalMs);
|
|
3885
|
+
}
|
|
3506
3886
|
startTelemetryLoop();
|
|
3507
3887
|
trackEvent("server.start", {
|
|
3508
3888
|
tier: license.tier,
|