@promptowl/contextnest-community 1.1.0 → 1.3.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 +3 -2
- package/README.md +33 -0
- package/dist/{chunk-7UTMBL6Z.js → chunk-6AXBB65N.js} +1 -1
- package/dist/{chunk-S2EWN2VA.js → chunk-EDJRDWPL.js} +3 -3
- package/dist/{chunk-WCOUCBDJ.js → chunk-SO74PQWI.js} +137 -28
- package/dist/{chunk-TDAX3JOT.js → chunk-UEHFNBNR.js} +63 -5
- package/dist/index.js +645 -49
- package/dist/{review-service-3OJIPYNV.js → review-service-QU7Q7XX2.js} +4 -4
- package/dist/{stewardship-service-3XGX7QIN.js → stewardship-service-ZUSHJCNR.js} +2 -2
- package/dist/{version-service-UODXLAOJ.js → version-service-624QTR5K.js} +2 -2
- package/dist/web3/assets/index-DJH4nUEV.js +776 -0
- package/dist/web3/assets/index-uR0ua3Ak.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +13 -1
- package/dist/web3/assets/index-BLxRS7jD.js +0 -673
- package/dist/web3/assets/index-DszK6Vkc.css +0 -1
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
reject,
|
|
17
17
|
safePublishDocument,
|
|
18
18
|
submitForReview
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-EDJRDWPL.js";
|
|
20
20
|
import {
|
|
21
21
|
checkConflict,
|
|
22
22
|
createVersion,
|
|
@@ -25,11 +25,12 @@ import {
|
|
|
25
25
|
getDisplayStatus,
|
|
26
26
|
getVersions,
|
|
27
27
|
setApprovedVersion
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-6AXBB65N.js";
|
|
29
29
|
import {
|
|
30
30
|
AppError,
|
|
31
31
|
ConflictError,
|
|
32
32
|
ForbiddenError,
|
|
33
|
+
LockedError,
|
|
33
34
|
NotFoundError,
|
|
34
35
|
ValidationError,
|
|
35
36
|
canCreateInNest,
|
|
@@ -66,6 +67,7 @@ import {
|
|
|
66
67
|
nestAllowsSelfApprove,
|
|
67
68
|
permissionLevel,
|
|
68
69
|
removeSteward,
|
|
70
|
+
renameNest,
|
|
69
71
|
resolveNestPermission,
|
|
70
72
|
resolveStewardsForNode,
|
|
71
73
|
resolveStewardsWithFallback,
|
|
@@ -76,15 +78,16 @@ import {
|
|
|
76
78
|
startTelemetryLoop,
|
|
77
79
|
syncFromConfig,
|
|
78
80
|
trackEvent,
|
|
81
|
+
uniqueNestName,
|
|
79
82
|
updateSteward,
|
|
80
83
|
validateLicense
|
|
81
|
-
} from "./chunk-
|
|
84
|
+
} from "./chunk-SO74PQWI.js";
|
|
82
85
|
import {
|
|
83
86
|
ANON_EMAIL,
|
|
84
87
|
ANON_USER_ID,
|
|
85
88
|
config,
|
|
86
89
|
getDb
|
|
87
|
-
} from "./chunk-
|
|
90
|
+
} from "./chunk-UEHFNBNR.js";
|
|
88
91
|
|
|
89
92
|
// src/index.ts
|
|
90
93
|
import { serve } from "@hono/node-server";
|
|
@@ -209,6 +212,11 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
209
212
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
210
213
|
});
|
|
211
214
|
|
|
215
|
+
// src/shared/email.ts
|
|
216
|
+
function normalizeEmail(email) {
|
|
217
|
+
return email.trim().toLowerCase();
|
|
218
|
+
}
|
|
219
|
+
|
|
212
220
|
// src/shared/rate-limit.ts
|
|
213
221
|
var buckets = /* @__PURE__ */ new Map();
|
|
214
222
|
function liveBucket(key, cutoff) {
|
|
@@ -272,6 +280,15 @@ function resolveCallerUserId(c) {
|
|
|
272
280
|
}
|
|
273
281
|
return null;
|
|
274
282
|
}
|
|
283
|
+
function deviceGateBlocked(c) {
|
|
284
|
+
const gate = config.PROMPTOWL_SIGN_IN_GATE;
|
|
285
|
+
if (gate === "open") return null;
|
|
286
|
+
const error = "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.";
|
|
287
|
+
if (gate === "disabled") return { error, gate };
|
|
288
|
+
const callerId = resolveCallerUserId(c);
|
|
289
|
+
if (callerId && !isLicenseAdminUserId(callerId)) return { error, gate };
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
275
292
|
function setSessionCookie(c, sessionId) {
|
|
276
293
|
c.header(
|
|
277
294
|
"Set-Cookie",
|
|
@@ -290,12 +307,13 @@ authRoutes.post("/register", async (c) => {
|
|
|
290
307
|
if (!body.email || !body.password) {
|
|
291
308
|
throw new ValidationError("email and password are required");
|
|
292
309
|
}
|
|
310
|
+
const email = normalizeEmail(body.email);
|
|
293
311
|
const ip = clientIp(c);
|
|
294
312
|
if (!tryConsume(`register:ip:${ip}`, REGISTER_LIMIT)) {
|
|
295
313
|
return c.json({ error: "Too many registration attempts, try again later" }, 429);
|
|
296
314
|
}
|
|
297
315
|
const db = getDb();
|
|
298
|
-
const existing = db.prepare("SELECT id, is_invited FROM users WHERE email = ?").get(
|
|
316
|
+
const existing = db.prepare("SELECT id, is_invited FROM users WHERE LOWER(email) = ?").get(email);
|
|
299
317
|
let userId;
|
|
300
318
|
const passwordHash = await hashPassword(body.password);
|
|
301
319
|
if (existing && existing.is_invited === 1) {
|
|
@@ -303,15 +321,15 @@ authRoutes.post("/register", async (c) => {
|
|
|
303
321
|
db.prepare(
|
|
304
322
|
"UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?"
|
|
305
323
|
).run(passwordHash, body.name || null, userId);
|
|
306
|
-
trackEvent("user.register", { userId, email
|
|
324
|
+
trackEvent("user.register", { userId, email, claimed: true });
|
|
307
325
|
} else if (existing) {
|
|
308
326
|
throw new ValidationError("Email already registered");
|
|
309
327
|
} else {
|
|
310
328
|
userId = uuid();
|
|
311
329
|
db.prepare(
|
|
312
330
|
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
313
|
-
).run(userId,
|
|
314
|
-
trackEvent("user.register", { userId, email
|
|
331
|
+
).run(userId, email, body.name || null, passwordHash);
|
|
332
|
+
trackEvent("user.register", { userId, email });
|
|
315
333
|
}
|
|
316
334
|
const sessionId = createSession(userId, c.req.header("User-Agent"));
|
|
317
335
|
setSessionCookie(c, sessionId);
|
|
@@ -319,9 +337,9 @@ authRoutes.post("/register", async (c) => {
|
|
|
319
337
|
{
|
|
320
338
|
user: {
|
|
321
339
|
id: userId,
|
|
322
|
-
email
|
|
340
|
+
email,
|
|
323
341
|
name: body.name || null,
|
|
324
|
-
is_admin: isLicenseAdminEmail(
|
|
342
|
+
is_admin: isLicenseAdminEmail(email)
|
|
325
343
|
}
|
|
326
344
|
},
|
|
327
345
|
201
|
|
@@ -342,8 +360,8 @@ authRoutes.post("/login", async (c) => {
|
|
|
342
360
|
}
|
|
343
361
|
const db = getDb();
|
|
344
362
|
const user = db.prepare(
|
|
345
|
-
"SELECT id, email, name, password_hash, is_admin FROM users WHERE email = ?"
|
|
346
|
-
).get(
|
|
363
|
+
"SELECT id, email, name, password_hash, is_admin FROM users WHERE LOWER(email) = ?"
|
|
364
|
+
).get(emailLower);
|
|
347
365
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
348
366
|
if (!user || !check.ok) {
|
|
349
367
|
if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
|
|
@@ -455,6 +473,8 @@ authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
|
|
|
455
473
|
return c.json({ deleted: true });
|
|
456
474
|
});
|
|
457
475
|
authRoutes.post("/device", async (c) => {
|
|
476
|
+
const blocked = deviceGateBlocked(c);
|
|
477
|
+
if (blocked) return c.json(blocked, 403);
|
|
458
478
|
if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
|
|
459
479
|
return c.json({ error: "Too many device auth attempts, try again later" }, 429);
|
|
460
480
|
}
|
|
@@ -476,6 +496,8 @@ authRoutes.post("/device", async (c) => {
|
|
|
476
496
|
return c.json(data);
|
|
477
497
|
});
|
|
478
498
|
authRoutes.get("/device/poll", async (c) => {
|
|
499
|
+
const blocked = deviceGateBlocked(c);
|
|
500
|
+
if (blocked) return c.json(blocked, 403);
|
|
479
501
|
const code = c.req.query("code");
|
|
480
502
|
const clientSecret = c.req.query("client_secret");
|
|
481
503
|
if (!code || !clientSecret) {
|
|
@@ -511,15 +533,28 @@ authRoutes.post("/promptowl", async (c) => {
|
|
|
511
533
|
401
|
|
512
534
|
);
|
|
513
535
|
}
|
|
536
|
+
const gate = config.PROMPTOWL_SIGN_IN_GATE;
|
|
537
|
+
if (gate !== "open") {
|
|
538
|
+
if (gate === "disabled" || !isLicenseAdminEmail(me.email)) {
|
|
539
|
+
return c.json(
|
|
540
|
+
{
|
|
541
|
+
error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
|
|
542
|
+
gate
|
|
543
|
+
},
|
|
544
|
+
403
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
514
548
|
const db = getDb();
|
|
515
|
-
|
|
549
|
+
const meEmail = normalizeEmail(me.email);
|
|
550
|
+
let user = db.prepare("SELECT id, email, name FROM users WHERE LOWER(email) = ?").get(meEmail);
|
|
516
551
|
if (!user) {
|
|
517
552
|
const userId = uuid();
|
|
518
553
|
const placeholderHash = await hashPassword(uuid());
|
|
519
554
|
db.prepare(
|
|
520
555
|
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
521
|
-
).run(userId,
|
|
522
|
-
user = { id: userId, email:
|
|
556
|
+
).run(userId, meEmail, me.name || null, placeholderHash);
|
|
557
|
+
user = { id: userId, email: meEmail, name: me.name || null };
|
|
523
558
|
trackEvent("user.register", {
|
|
524
559
|
userId,
|
|
525
560
|
email: me.email,
|
|
@@ -592,9 +627,17 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
592
627
|
const db = getDb();
|
|
593
628
|
const userId = c.get("userId");
|
|
594
629
|
const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
|
595
|
-
if (!user)
|
|
630
|
+
if (!user) throw new ValidationError("User not found");
|
|
596
631
|
const check = await verifyPassword(body.current, user.password_hash);
|
|
597
|
-
if (!check.ok)
|
|
632
|
+
if (!check.ok) {
|
|
633
|
+
throw new ValidationError("Current password is incorrect");
|
|
634
|
+
}
|
|
635
|
+
const sameAsCurrent = await verifyPassword(body.next, user.password_hash);
|
|
636
|
+
if (sameAsCurrent.ok) {
|
|
637
|
+
throw new ValidationError(
|
|
638
|
+
"new password must be different from the current password"
|
|
639
|
+
);
|
|
640
|
+
}
|
|
598
641
|
const newHash = await hashPassword(body.next);
|
|
599
642
|
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
|
|
600
643
|
newHash,
|
|
@@ -604,9 +647,96 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
604
647
|
clearSessionCookie(c);
|
|
605
648
|
return c.json({ ok: true });
|
|
606
649
|
});
|
|
650
|
+
authRoutes.post("/admin/reset-password/:userId", async (c) => {
|
|
651
|
+
const callerId = resolveCallerUserId(c);
|
|
652
|
+
if (!callerId) {
|
|
653
|
+
return c.json({ error: "Authentication required." }, 401);
|
|
654
|
+
}
|
|
655
|
+
if (!isLicenseAdminUserId(callerId)) {
|
|
656
|
+
return c.json(
|
|
657
|
+
{ error: "Only the license-admin user can reset passwords." },
|
|
658
|
+
403
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
const targetId = c.req.param("userId");
|
|
662
|
+
const db = getDb();
|
|
663
|
+
const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
|
|
664
|
+
if (!target) return c.json({ error: "User not found" }, 404);
|
|
665
|
+
let supplied;
|
|
666
|
+
try {
|
|
667
|
+
supplied = (await c.req.json()).password;
|
|
668
|
+
} catch {
|
|
669
|
+
supplied = void 0;
|
|
670
|
+
}
|
|
671
|
+
if (supplied && supplied.length < 8) {
|
|
672
|
+
throw new ValidationError("password must be at least 8 characters");
|
|
673
|
+
}
|
|
674
|
+
const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
|
|
675
|
+
const newPassword = supplied ?? generated;
|
|
676
|
+
const newHash = await hashPassword(newPassword);
|
|
677
|
+
db.transaction(() => {
|
|
678
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
|
|
679
|
+
newHash,
|
|
680
|
+
target.id
|
|
681
|
+
);
|
|
682
|
+
db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
|
|
683
|
+
})();
|
|
684
|
+
deleteAllSessionsForUser(target.id);
|
|
685
|
+
trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
|
|
686
|
+
return c.json({
|
|
687
|
+
ok: true,
|
|
688
|
+
email: target.email,
|
|
689
|
+
keys_revoked: true,
|
|
690
|
+
// Plaintext returned ONCE, only when the server generated it.
|
|
691
|
+
temporary_password: generated ?? void 0
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
authRoutes.delete("/users/:userId", async (c) => {
|
|
695
|
+
const callerId = resolveCallerUserId(c);
|
|
696
|
+
if (!callerId) {
|
|
697
|
+
return c.json({ error: "Authentication required." }, 401);
|
|
698
|
+
}
|
|
699
|
+
if (!isLicenseAdminUserId(callerId)) {
|
|
700
|
+
return c.json(
|
|
701
|
+
{ error: "Only the license-admin user can remove users." },
|
|
702
|
+
403
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const targetId = c.req.param("userId");
|
|
706
|
+
if (targetId === callerId) {
|
|
707
|
+
return c.json({ error: "You can't remove your own admin account." }, 400);
|
|
708
|
+
}
|
|
709
|
+
const db = getDb();
|
|
710
|
+
const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
|
|
711
|
+
if (!target) return c.json({ error: "User not found" }, 404);
|
|
712
|
+
const ownedNests = db.prepare("SELECT COUNT(*) AS c FROM nests WHERE user_id = ?").get(target.id).c;
|
|
713
|
+
if (ownedNests > 0) {
|
|
714
|
+
return c.json(
|
|
715
|
+
{
|
|
716
|
+
error: `This user owns ${ownedNests} nest${ownedNests === 1 ? "" : "s"}. Transfer or delete them before removing the user.`,
|
|
717
|
+
owned_nests: ownedNests
|
|
718
|
+
},
|
|
719
|
+
409
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
db.transaction(() => {
|
|
723
|
+
db.prepare(
|
|
724
|
+
"DELETE FROM stewards WHERE user_id = ? OR lower(user_email) = lower(?)"
|
|
725
|
+
).run(target.id, target.email);
|
|
726
|
+
db.prepare("DELETE FROM nest_collaborators WHERE user_id = ?").run(
|
|
727
|
+
target.id
|
|
728
|
+
);
|
|
729
|
+
db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
|
|
730
|
+
db.prepare("DELETE FROM users WHERE id = ?").run(target.id);
|
|
731
|
+
})();
|
|
732
|
+
deleteAllSessionsForUser(target.id);
|
|
733
|
+
trackEvent("admin.remove_user", { adminId: callerId, userId: target.id });
|
|
734
|
+
return c.json({ ok: true, email: target.email });
|
|
735
|
+
});
|
|
607
736
|
authRoutes.post("/invite", async (c) => {
|
|
608
737
|
const body = await c.req.json();
|
|
609
738
|
if (!body.email) throw new ValidationError("email is required");
|
|
739
|
+
const email = normalizeEmail(body.email);
|
|
610
740
|
const callerId = resolveCallerUserId(c);
|
|
611
741
|
if (!callerId) {
|
|
612
742
|
return c.json(
|
|
@@ -625,14 +755,14 @@ authRoutes.post("/invite", async (c) => {
|
|
|
625
755
|
403
|
|
626
756
|
);
|
|
627
757
|
}
|
|
628
|
-
let user = db.prepare("SELECT id, email FROM users WHERE email = ?").get(
|
|
758
|
+
let user = db.prepare("SELECT id, email FROM users WHERE LOWER(email) = ?").get(email);
|
|
629
759
|
if (!user) {
|
|
630
760
|
const userId = uuid();
|
|
631
761
|
const placeholderHash = await hashPassword(uuid());
|
|
632
762
|
db.prepare(
|
|
633
763
|
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
634
|
-
).run(userId,
|
|
635
|
-
user = { id: userId, email
|
|
764
|
+
).run(userId, email, null, placeholderHash);
|
|
765
|
+
user = { id: userId, email };
|
|
636
766
|
}
|
|
637
767
|
const apiKey = generateApiKey();
|
|
638
768
|
const keyId = uuid();
|
|
@@ -949,7 +1079,7 @@ async function approveExternalEdit(input) {
|
|
|
949
1079
|
const node = await storage.readDocument(input.documentId);
|
|
950
1080
|
const versionNum = result.versionEntry.version;
|
|
951
1081
|
const tags = node.frontmatter.tags || [];
|
|
952
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1082
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-624QTR5K.js");
|
|
953
1083
|
createVersion2({
|
|
954
1084
|
nestId: input.nestId,
|
|
955
1085
|
nodeId: input.documentId,
|
|
@@ -1251,8 +1381,10 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1251
1381
|
}
|
|
1252
1382
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
1253
1383
|
const currentTags = node.frontmatter.tags || [];
|
|
1254
|
-
if (getPendingReview(nestId, nodeId)) {
|
|
1255
|
-
|
|
1384
|
+
if (hasStewards && getPendingReview(nestId, nodeId)) {
|
|
1385
|
+
throw new LockedError(
|
|
1386
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
|
|
1387
|
+
);
|
|
1256
1388
|
}
|
|
1257
1389
|
let responseVersion;
|
|
1258
1390
|
if (hasStewards) {
|
|
@@ -1327,6 +1459,185 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1327
1459
|
return { node, version: responseVersion };
|
|
1328
1460
|
}
|
|
1329
1461
|
|
|
1462
|
+
// src/nests/unsynced-service.ts
|
|
1463
|
+
import { readdirSync, readFileSync, rmSync, statSync } from "fs";
|
|
1464
|
+
import { join as join2, relative } from "path";
|
|
1465
|
+
var RESERVED = /* @__PURE__ */ new Set(["nests"]);
|
|
1466
|
+
function scanMarkdown(dir) {
|
|
1467
|
+
let count = 0;
|
|
1468
|
+
let size = 0;
|
|
1469
|
+
let entries = [];
|
|
1470
|
+
try {
|
|
1471
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1472
|
+
} catch {
|
|
1473
|
+
return { count, size };
|
|
1474
|
+
}
|
|
1475
|
+
for (const e of entries) {
|
|
1476
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1477
|
+
const full = join2(dir, e.name);
|
|
1478
|
+
if (e.isDirectory()) {
|
|
1479
|
+
const sub = scanMarkdown(full);
|
|
1480
|
+
count += sub.count;
|
|
1481
|
+
size += sub.size;
|
|
1482
|
+
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
|
|
1483
|
+
try {
|
|
1484
|
+
size += statSync(full).size;
|
|
1485
|
+
count++;
|
|
1486
|
+
} catch {
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return { count, size };
|
|
1491
|
+
}
|
|
1492
|
+
function hasDirectMarkdown(dir) {
|
|
1493
|
+
let entries = [];
|
|
1494
|
+
try {
|
|
1495
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1496
|
+
} catch {
|
|
1497
|
+
return false;
|
|
1498
|
+
}
|
|
1499
|
+
return entries.some(
|
|
1500
|
+
(e) => e.isFile() && e.name.toLowerCase().endsWith(".md")
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
function collectLeafFolders(rel, abs) {
|
|
1504
|
+
if (hasDirectMarkdown(abs)) {
|
|
1505
|
+
const { count, size } = scanMarkdown(abs);
|
|
1506
|
+
const segments = rel.split("/").filter(Boolean);
|
|
1507
|
+
return [
|
|
1508
|
+
{
|
|
1509
|
+
name: rel,
|
|
1510
|
+
label: segments[segments.length - 1] || rel,
|
|
1511
|
+
mdCount: count,
|
|
1512
|
+
sizeBytes: size
|
|
1513
|
+
}
|
|
1514
|
+
];
|
|
1515
|
+
}
|
|
1516
|
+
let entries = [];
|
|
1517
|
+
try {
|
|
1518
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
1519
|
+
} catch {
|
|
1520
|
+
return [];
|
|
1521
|
+
}
|
|
1522
|
+
const out = [];
|
|
1523
|
+
for (const e of entries) {
|
|
1524
|
+
if (!e.isDirectory()) continue;
|
|
1525
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1526
|
+
out.push(
|
|
1527
|
+
...collectLeafFolders(`${rel}/${e.name}`, join2(abs, e.name))
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
return out;
|
|
1531
|
+
}
|
|
1532
|
+
function listUnsyncedFolders() {
|
|
1533
|
+
const root = config.DATA_ROOT;
|
|
1534
|
+
let entries = [];
|
|
1535
|
+
try {
|
|
1536
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
1537
|
+
} catch {
|
|
1538
|
+
return [];
|
|
1539
|
+
}
|
|
1540
|
+
const out = [];
|
|
1541
|
+
for (const e of entries) {
|
|
1542
|
+
if (!e.isDirectory()) continue;
|
|
1543
|
+
if (e.name.startsWith(".")) continue;
|
|
1544
|
+
if (RESERVED.has(e.name)) continue;
|
|
1545
|
+
out.push(...collectLeafFolders(e.name, join2(root, e.name)));
|
|
1546
|
+
}
|
|
1547
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
1548
|
+
console.log(
|
|
1549
|
+
`[unsynced] discovered ${out.length} candidate folder(s) under ${root}`
|
|
1550
|
+
);
|
|
1551
|
+
return out;
|
|
1552
|
+
}
|
|
1553
|
+
function assertSafeFolderName(name) {
|
|
1554
|
+
if (!name || name.includes("\\")) {
|
|
1555
|
+
throw new ValidationError("Invalid folder name");
|
|
1556
|
+
}
|
|
1557
|
+
const segments = name.split("/").filter(Boolean);
|
|
1558
|
+
if (segments.length === 0) {
|
|
1559
|
+
throw new ValidationError("Invalid folder name");
|
|
1560
|
+
}
|
|
1561
|
+
if (RESERVED.has(segments[0])) {
|
|
1562
|
+
throw new ValidationError("Folder is not eligible for sync");
|
|
1563
|
+
}
|
|
1564
|
+
for (const seg of segments) {
|
|
1565
|
+
if (seg === ".." || seg.startsWith(".") || seg === "node_modules") {
|
|
1566
|
+
throw new ValidationError("Folder is not eligible for sync");
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
function collectMarkdownFiles(dir, root) {
|
|
1571
|
+
const out = [];
|
|
1572
|
+
let entries = [];
|
|
1573
|
+
try {
|
|
1574
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1575
|
+
} catch {
|
|
1576
|
+
return out;
|
|
1577
|
+
}
|
|
1578
|
+
for (const e of entries) {
|
|
1579
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1580
|
+
const full = join2(dir, e.name);
|
|
1581
|
+
if (e.isDirectory()) {
|
|
1582
|
+
out.push(...collectMarkdownFiles(full, root));
|
|
1583
|
+
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
|
|
1584
|
+
try {
|
|
1585
|
+
const rel = relative(root, full);
|
|
1586
|
+
out.push({ path: rel, content: readFileSync(full, "utf-8") });
|
|
1587
|
+
} catch {
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return out;
|
|
1592
|
+
}
|
|
1593
|
+
async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
1594
|
+
console.log(
|
|
1595
|
+
`[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
|
|
1596
|
+
);
|
|
1597
|
+
assertSafeFolderName(folderName);
|
|
1598
|
+
const src = join2(config.DATA_ROOT, folderName);
|
|
1599
|
+
let stat;
|
|
1600
|
+
try {
|
|
1601
|
+
stat = statSync(src);
|
|
1602
|
+
} catch {
|
|
1603
|
+
console.warn(`[unsynced] source missing: ${src}`);
|
|
1604
|
+
throw new NotFoundError(`Folder not found: ${folderName}`);
|
|
1605
|
+
}
|
|
1606
|
+
if (!stat.isDirectory()) {
|
|
1607
|
+
throw new ValidationError(`Not a directory: ${folderName}`);
|
|
1608
|
+
}
|
|
1609
|
+
const files = collectMarkdownFiles(src, src);
|
|
1610
|
+
console.log(
|
|
1611
|
+
`[unsynced] collected ${files.length} markdown file(s) from ${src}`
|
|
1612
|
+
);
|
|
1613
|
+
if (files.length === 0) {
|
|
1614
|
+
throw new ValidationError(`Folder has no markdown to sync: ${folderName}`);
|
|
1615
|
+
}
|
|
1616
|
+
const segments = folderName.split("/").filter(Boolean);
|
|
1617
|
+
const baseName = segments[segments.length - 1] || folderName;
|
|
1618
|
+
const nestName = uniqueNestName(userId, baseName);
|
|
1619
|
+
if (nestName !== baseName) {
|
|
1620
|
+
console.log(
|
|
1621
|
+
`[unsynced] name "${baseName}" already in use, using "${nestName}" instead`
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
const nest = await importNest(userId, nestName, files);
|
|
1625
|
+
console.log(
|
|
1626
|
+
`[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
|
|
1627
|
+
);
|
|
1628
|
+
const documents = await registerImportedDocuments(nest.id, callerEmail);
|
|
1629
|
+
console.log(
|
|
1630
|
+
`[unsynced] registered ${documents} document(s) for nest ${nest.id}`
|
|
1631
|
+
);
|
|
1632
|
+
try {
|
|
1633
|
+
rmSync(src, { recursive: true, force: true });
|
|
1634
|
+
console.log(`[unsynced] removed source folder ${src}`);
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
console.error("[unsynced] failed to remove source folder", src, err);
|
|
1637
|
+
}
|
|
1638
|
+
return { nest, documents };
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1330
1641
|
// src/nests/routes.ts
|
|
1331
1642
|
function effectivePermission(nestId, userId) {
|
|
1332
1643
|
if (config.AUTH_MODE === "open") {
|
|
@@ -1389,6 +1700,29 @@ nestRoutes.post("/import", async (c) => {
|
|
|
1389
1700
|
);
|
|
1390
1701
|
return c.json({ nest, documents }, 201);
|
|
1391
1702
|
});
|
|
1703
|
+
nestRoutes.get("/unsynced", async (c) => {
|
|
1704
|
+
const userId = c.get("userId");
|
|
1705
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
1706
|
+
throw new ForbiddenError("Only the server admin can list unsynced folders");
|
|
1707
|
+
}
|
|
1708
|
+
return c.json({ folders: listUnsyncedFolders() });
|
|
1709
|
+
});
|
|
1710
|
+
nestRoutes.post("/unsynced/sync", async (c) => {
|
|
1711
|
+
const userId = c.get("userId");
|
|
1712
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
1713
|
+
throw new ForbiddenError("Only the server admin can sync folders");
|
|
1714
|
+
}
|
|
1715
|
+
const body = await c.req.json();
|
|
1716
|
+
if (!body?.name) {
|
|
1717
|
+
throw new ValidationError("name is required");
|
|
1718
|
+
}
|
|
1719
|
+
const result = await syncUnsyncedFolder(
|
|
1720
|
+
userId,
|
|
1721
|
+
body.name,
|
|
1722
|
+
resolveCallerEmail(userId)
|
|
1723
|
+
);
|
|
1724
|
+
return c.json(result, 201);
|
|
1725
|
+
});
|
|
1392
1726
|
nestRoutes.get("/:nestId", async (c) => {
|
|
1393
1727
|
const nestId = c.req.param("nestId");
|
|
1394
1728
|
const userId = c.get("userId");
|
|
@@ -1405,6 +1739,25 @@ nestRoutes.get("/:nestId", async (c) => {
|
|
|
1405
1739
|
const nest = getNest(nestId);
|
|
1406
1740
|
return c.json({ nest, permission, roles, myStewards });
|
|
1407
1741
|
});
|
|
1742
|
+
nestRoutes.patch("/:nestId", async (c) => {
|
|
1743
|
+
const nestId = c.req.param("nestId");
|
|
1744
|
+
const userId = c.get("userId");
|
|
1745
|
+
const permission = effectivePermission(nestId, userId);
|
|
1746
|
+
if (permission === "none") {
|
|
1747
|
+
throw new NotFoundError("Nest not found");
|
|
1748
|
+
}
|
|
1749
|
+
if (permission !== "owner" && permission !== "admin") {
|
|
1750
|
+
throw new ForbiddenError(
|
|
1751
|
+
"Only the nest owner or an admin can rename a nest."
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
const body = await c.req.json();
|
|
1755
|
+
const nest = renameNest(nestId, {
|
|
1756
|
+
name: body.name,
|
|
1757
|
+
description: body.description
|
|
1758
|
+
});
|
|
1759
|
+
return c.json({ nest });
|
|
1760
|
+
});
|
|
1408
1761
|
nestRoutes.delete("/:nestId", async (c) => {
|
|
1409
1762
|
const nestId = c.req.param("nestId");
|
|
1410
1763
|
const userId = c.get("userId");
|
|
@@ -1480,7 +1833,8 @@ async function addCollaborator(params) {
|
|
|
1480
1833
|
const db = getDb();
|
|
1481
1834
|
let userId = params.userId;
|
|
1482
1835
|
if (!userId && params.email) {
|
|
1483
|
-
const
|
|
1836
|
+
const email = normalizeEmail(params.email);
|
|
1837
|
+
const existing = db.prepare("SELECT id FROM users WHERE LOWER(email) = ?").get(email);
|
|
1484
1838
|
if (existing) {
|
|
1485
1839
|
userId = existing.id;
|
|
1486
1840
|
} else {
|
|
@@ -1488,7 +1842,7 @@ async function addCollaborator(params) {
|
|
|
1488
1842
|
userId = uuid2();
|
|
1489
1843
|
db.prepare(
|
|
1490
1844
|
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
1491
|
-
).run(userId,
|
|
1845
|
+
).run(userId, email, null, await hashPassword2(uuid2()));
|
|
1492
1846
|
}
|
|
1493
1847
|
}
|
|
1494
1848
|
if (!userId) {
|
|
@@ -1589,7 +1943,38 @@ sharingRoutes.patch("/visibility", async (c) => {
|
|
|
1589
1943
|
|
|
1590
1944
|
// src/nodes/routes.ts
|
|
1591
1945
|
import { Hono as Hono4 } from "hono";
|
|
1946
|
+
|
|
1947
|
+
// src/nodes/markdown-export.ts
|
|
1948
|
+
function nodeToMarkdown(node) {
|
|
1949
|
+
const tags = node.tags ?? [];
|
|
1950
|
+
return [
|
|
1951
|
+
"---",
|
|
1952
|
+
`title: ${JSON.stringify(node.title ?? "")}`,
|
|
1953
|
+
`tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
|
|
1954
|
+
`status: ${JSON.stringify(node.status ?? "")}`,
|
|
1955
|
+
`id: ${JSON.stringify(node.id)}`,
|
|
1956
|
+
"---",
|
|
1957
|
+
""
|
|
1958
|
+
].join("\n") + (node.body ?? "");
|
|
1959
|
+
}
|
|
1960
|
+
function nodesToMarkdown(nodes) {
|
|
1961
|
+
return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
|
|
1962
|
+
}
|
|
1963
|
+
function isMarkdownFormat(c) {
|
|
1964
|
+
return c.req.query("format") === "markdown";
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// src/nodes/routes.ts
|
|
1592
1968
|
var nodeRoutes = new Hono4();
|
|
1969
|
+
function nodeAsMarkdown(response, nodeId) {
|
|
1970
|
+
return nodeToMarkdown({
|
|
1971
|
+
id: nodeId,
|
|
1972
|
+
title: response.title,
|
|
1973
|
+
tags: response.tags,
|
|
1974
|
+
status: response.status,
|
|
1975
|
+
body: response.content
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1593
1978
|
nodeRoutes.get("/", async (c) => {
|
|
1594
1979
|
const nestId = c.req.param("nestId");
|
|
1595
1980
|
const userId = c.get("userId");
|
|
@@ -1628,7 +2013,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1628
2013
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
1629
2014
|
const nestId = c.req.param("nestId");
|
|
1630
2015
|
const nodeId = c.req.param("nodeId");
|
|
1631
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
2016
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZUSHJCNR.js");
|
|
1632
2017
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
1633
2018
|
nestId,
|
|
1634
2019
|
nodeId
|
|
@@ -1649,7 +2034,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1649
2034
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
1650
2035
|
const nestId = c.req.param("nestId");
|
|
1651
2036
|
const nodeId = c.req.param("nodeId");
|
|
1652
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
2037
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-624QTR5K.js");
|
|
1653
2038
|
const allVersions = getVersions2(nestId, nodeId);
|
|
1654
2039
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
1655
2040
|
const db = getDb();
|
|
@@ -1680,10 +2065,45 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
1680
2065
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
1681
2066
|
const nestId = c.req.param("nestId");
|
|
1682
2067
|
const nodeId = c.req.param("nodeId");
|
|
1683
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
2068
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-QU7Q7XX2.js");
|
|
1684
2069
|
const history = getReviewHistory2(nestId, nodeId);
|
|
1685
2070
|
return c.json({ reviews: history });
|
|
1686
2071
|
});
|
|
2072
|
+
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
2073
|
+
const nestId = c.req.param("nestId");
|
|
2074
|
+
const nodeId = c.req.param("nodeId");
|
|
2075
|
+
const { versions: versionManager } = engineCache.get(nestId);
|
|
2076
|
+
const userId = c.get("userId");
|
|
2077
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2078
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2079
|
+
return c.json(
|
|
2080
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2081
|
+
403
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2085
|
+
const targetVersion = Number(body.targetVersion);
|
|
2086
|
+
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
2087
|
+
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
2088
|
+
}
|
|
2089
|
+
let raw;
|
|
2090
|
+
try {
|
|
2091
|
+
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
2092
|
+
} catch {
|
|
2093
|
+
throw new NotFoundError(
|
|
2094
|
+
`Version ${targetVersion} not found for ${nodeId}`
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
const content = bodyOnly(nodeId, raw);
|
|
2098
|
+
const { node, version } = await updateNode(
|
|
2099
|
+
nestId,
|
|
2100
|
+
nodeId,
|
|
2101
|
+
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
2102
|
+
userEmail
|
|
2103
|
+
);
|
|
2104
|
+
trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
2105
|
+
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
2106
|
+
});
|
|
1687
2107
|
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
1688
2108
|
const nestId = c.req.param("nestId");
|
|
1689
2109
|
const nodeId = c.req.param("nodeId");
|
|
@@ -1740,6 +2160,11 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
1740
2160
|
}
|
|
1741
2161
|
response.version = approved;
|
|
1742
2162
|
response.status = "published";
|
|
2163
|
+
if (isMarkdownFormat(c)) {
|
|
2164
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2165
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
1743
2168
|
return c.json({ node: response });
|
|
1744
2169
|
}
|
|
1745
2170
|
}
|
|
@@ -1748,6 +2173,11 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
1748
2173
|
const pending = getPendingReview(nestId, nodeId);
|
|
1749
2174
|
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
1750
2175
|
}
|
|
2176
|
+
if (isMarkdownFormat(c)) {
|
|
2177
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2178
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
1751
2181
|
return c.json({ node: response });
|
|
1752
2182
|
});
|
|
1753
2183
|
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
@@ -1792,6 +2222,11 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
1792
2222
|
const nestId = c.req.param("nestId");
|
|
1793
2223
|
const nodeId = c.req.param("nodeId");
|
|
1794
2224
|
const { storage } = engineCache.get(nestId);
|
|
2225
|
+
if (isStewardshipEnabled(nestId) && getPendingReview(nestId, nodeId)) {
|
|
2226
|
+
throw new LockedError(
|
|
2227
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
1795
2230
|
try {
|
|
1796
2231
|
await storage.deleteDocument(nodeId);
|
|
1797
2232
|
} catch {
|
|
@@ -2017,6 +2452,32 @@ function compilePrompt(prompt, nestId, titles) {
|
|
|
2017
2452
|
};
|
|
2018
2453
|
}
|
|
2019
2454
|
|
|
2455
|
+
// src/nodes/readable-body.ts
|
|
2456
|
+
async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
|
|
2457
|
+
if (!isPublicReader(nestId, userId)) return workingBody;
|
|
2458
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
2459
|
+
if (approved == null) return "";
|
|
2460
|
+
try {
|
|
2461
|
+
const { versions } = engineCache.get(nestId);
|
|
2462
|
+
const raw = await versions.reconstructVersion(nodeId, approved);
|
|
2463
|
+
return bodyOnly(nodeId, raw);
|
|
2464
|
+
} catch {
|
|
2465
|
+
return "";
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
async function resolveExportBody(nestId, nodeId, workingBody) {
|
|
2469
|
+
if (!isStewardshipEnabled(nestId)) return workingBody;
|
|
2470
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
2471
|
+
if (approved == null) return null;
|
|
2472
|
+
try {
|
|
2473
|
+
const { versions } = engineCache.get(nestId);
|
|
2474
|
+
const raw = await versions.reconstructVersion(nodeId, approved);
|
|
2475
|
+
return bodyOnly(nodeId, raw);
|
|
2476
|
+
} catch {
|
|
2477
|
+
return null;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2020
2481
|
// src/nodes/query-routes.ts
|
|
2021
2482
|
var queryRoutes = new Hono5();
|
|
2022
2483
|
function approxTokens(text) {
|
|
@@ -2087,9 +2548,15 @@ queryRoutes.post("/context", async (c) => {
|
|
|
2087
2548
|
const beforePermission = documents.length;
|
|
2088
2549
|
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
2089
2550
|
const permissionFiltered = beforePermission - accessible.length;
|
|
2551
|
+
const readable = await Promise.all(
|
|
2552
|
+
accessible.map(async (doc) => ({
|
|
2553
|
+
...doc,
|
|
2554
|
+
body: await resolveReadableBody(nestId, doc.id, userId, doc.body || "")
|
|
2555
|
+
}))
|
|
2556
|
+
);
|
|
2090
2557
|
const included = [];
|
|
2091
2558
|
let tokenCount = 0;
|
|
2092
|
-
for (const doc of
|
|
2559
|
+
for (const doc of readable) {
|
|
2093
2560
|
const block = formatContextBlock(doc);
|
|
2094
2561
|
const blockTokens = approxTokens(block);
|
|
2095
2562
|
if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
|
|
@@ -2220,6 +2687,64 @@ queryRoutes.get("/context", async (c) => {
|
|
|
2220
2687
|
const content = await storage.readContextMd();
|
|
2221
2688
|
return c.json({ content: content || "" });
|
|
2222
2689
|
});
|
|
2690
|
+
queryRoutes.get("/export", async (c) => {
|
|
2691
|
+
if (!isMarkdownFormat(c)) {
|
|
2692
|
+
throw new ValidationError("format=markdown is required");
|
|
2693
|
+
}
|
|
2694
|
+
const nestId = c.req.param("nestId");
|
|
2695
|
+
const { storage, query: queryEngine } = engineCache.get(nestId);
|
|
2696
|
+
const selector = c.req.query("selector")?.trim() || null;
|
|
2697
|
+
let documents;
|
|
2698
|
+
if (selector) {
|
|
2699
|
+
const result = await queryEngine.query(selector, { hops: 2, full: true });
|
|
2700
|
+
documents = result.documents;
|
|
2701
|
+
} else {
|
|
2702
|
+
documents = await storage.discoverDocuments();
|
|
2703
|
+
}
|
|
2704
|
+
const userId = c.get("userId");
|
|
2705
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2706
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
2707
|
+
const governed = isStewardshipEnabled(nestId);
|
|
2708
|
+
const resolved = await Promise.all(
|
|
2709
|
+
accessible.map(async (n) => {
|
|
2710
|
+
const body = await resolveExportBody(nestId, n.id, n.body || "");
|
|
2711
|
+
if (body == null) return null;
|
|
2712
|
+
return {
|
|
2713
|
+
id: n.id,
|
|
2714
|
+
title: n.frontmatter.title,
|
|
2715
|
+
tags: n.frontmatter.tags || [],
|
|
2716
|
+
status: governed ? "published" : n.frontmatter.status,
|
|
2717
|
+
body
|
|
2718
|
+
};
|
|
2719
|
+
})
|
|
2720
|
+
);
|
|
2721
|
+
const fields = resolved.filter(
|
|
2722
|
+
(f) => f != null
|
|
2723
|
+
);
|
|
2724
|
+
const maxParam = parseInt(c.req.query("max_tokens") ?? "", 10);
|
|
2725
|
+
const maxTokens = Number.isFinite(maxParam) && maxParam > 0 ? maxParam : null;
|
|
2726
|
+
let included = fields;
|
|
2727
|
+
if (maxTokens) {
|
|
2728
|
+
const kept = [];
|
|
2729
|
+
let tokens = 0;
|
|
2730
|
+
for (const f of fields) {
|
|
2731
|
+
const t = approxTokens(nodeToMarkdown(f));
|
|
2732
|
+
if (tokens + t > maxTokens && kept.length > 0) break;
|
|
2733
|
+
kept.push(f);
|
|
2734
|
+
tokens += t;
|
|
2735
|
+
}
|
|
2736
|
+
included = kept;
|
|
2737
|
+
}
|
|
2738
|
+
trackEvent("nest.export", {
|
|
2739
|
+
nestId,
|
|
2740
|
+
count: included.length,
|
|
2741
|
+
selector,
|
|
2742
|
+
truncated_by_budget: fields.length - included.length
|
|
2743
|
+
});
|
|
2744
|
+
return c.body(nodesToMarkdown(included), 200, {
|
|
2745
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2746
|
+
});
|
|
2747
|
+
});
|
|
2223
2748
|
queryRoutes.post("/publish", async (c) => {
|
|
2224
2749
|
const body = await c.req.json();
|
|
2225
2750
|
if (!body.documents?.length && !body.context_md) {
|
|
@@ -2505,6 +3030,26 @@ var TOOL_DEFINITIONS = [
|
|
|
2505
3030
|
},
|
|
2506
3031
|
required: ["email"]
|
|
2507
3032
|
}
|
|
3033
|
+
},
|
|
3034
|
+
// ─── Unsynced Folder Tools ─────────────────────────────────────────
|
|
3035
|
+
{
|
|
3036
|
+
name: "context_unsynced_list",
|
|
3037
|
+
description: "List folders under the data root that are sync candidates \u2014 dropped in by the starter CLI or copied in manually, not yet converted into a nest. Server-admin only in non-open mode.",
|
|
3038
|
+
inputSchema: { type: "object", properties: {} }
|
|
3039
|
+
},
|
|
3040
|
+
{
|
|
3041
|
+
name: "context_sync_folder",
|
|
3042
|
+
description: "Convert a sibling folder under the data root into a real nest. Imports its markdown, seeds governance, and removes the source folder. Use context_unsynced_list to discover folder names. Server-admin only in non-open mode.",
|
|
3043
|
+
inputSchema: {
|
|
3044
|
+
type: "object",
|
|
3045
|
+
properties: {
|
|
3046
|
+
name: {
|
|
3047
|
+
type: "string",
|
|
3048
|
+
description: "Folder path relative to the data root (e.g. 'nodes/architecture'), as returned by context_unsynced_list."
|
|
3049
|
+
}
|
|
3050
|
+
},
|
|
3051
|
+
required: ["name"]
|
|
3052
|
+
}
|
|
2508
3053
|
}
|
|
2509
3054
|
];
|
|
2510
3055
|
async function resolveLlmBody(ctx, node) {
|
|
@@ -2519,7 +3064,7 @@ async function resolveLlmBody(ctx, node) {
|
|
|
2519
3064
|
}
|
|
2520
3065
|
}
|
|
2521
3066
|
async function handleToolCall(toolName, args, ctx) {
|
|
2522
|
-
const { storage, queryEngine, versionManager, nestId, userEmail } = ctx;
|
|
3067
|
+
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
2523
3068
|
switch (toolName) {
|
|
2524
3069
|
case "context_init": {
|
|
2525
3070
|
const content = await storage.readContextMd();
|
|
@@ -2881,6 +3426,34 @@ ${list}`;
|
|
|
2881
3426
|
return `Failed to share nest: ${err.message}`;
|
|
2882
3427
|
}
|
|
2883
3428
|
}
|
|
3429
|
+
case "context_unsynced_list": {
|
|
3430
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
3431
|
+
return "You don't have permission to list unsynced folders. Server admin only.";
|
|
3432
|
+
}
|
|
3433
|
+
const folders = listUnsyncedFolders();
|
|
3434
|
+
if (!folders.length) return "No unsynced folders found.";
|
|
3435
|
+
const list = folders.map(
|
|
3436
|
+
(f, i) => `${i + 1}. **${f.label}** \`${f.name}\` \u2014 ${f.mdCount} md file(s), ${f.sizeBytes} bytes`
|
|
3437
|
+
).join("\n");
|
|
3438
|
+
return `# Unsynced Folders (${folders.length})
|
|
3439
|
+
|
|
3440
|
+
${list}`;
|
|
3441
|
+
}
|
|
3442
|
+
case "context_sync_folder": {
|
|
3443
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
3444
|
+
return "You don't have permission to sync folders. Server admin only.";
|
|
3445
|
+
}
|
|
3446
|
+
try {
|
|
3447
|
+
const { nest, documents } = await syncUnsyncedFolder(
|
|
3448
|
+
userId,
|
|
3449
|
+
args.name,
|
|
3450
|
+
userEmail
|
|
3451
|
+
);
|
|
3452
|
+
return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
|
|
3453
|
+
} catch (err) {
|
|
3454
|
+
return `Failed to sync folder: ${err.message}`;
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
2884
3457
|
default:
|
|
2885
3458
|
return `Unknown tool: ${toolName}`;
|
|
2886
3459
|
}
|
|
@@ -2894,7 +3467,7 @@ function getUserEmail2(userId) {
|
|
|
2894
3467
|
const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
2895
3468
|
return user?.email || "anonymous@localhost";
|
|
2896
3469
|
}
|
|
2897
|
-
function createMcpServerForNest(nestId, userEmail) {
|
|
3470
|
+
function createMcpServerForNest(nestId, userId, userEmail) {
|
|
2898
3471
|
const server = new McpServer(
|
|
2899
3472
|
{ name: `contextnest-${nestId}`, version: "1.0.0" },
|
|
2900
3473
|
{ capabilities: { tools: {} } }
|
|
@@ -2919,6 +3492,7 @@ function createMcpServerForNest(nestId, userEmail) {
|
|
|
2919
3492
|
queryEngine: engine.query,
|
|
2920
3493
|
versionManager: engine.versions,
|
|
2921
3494
|
nestId,
|
|
3495
|
+
userId,
|
|
2922
3496
|
userEmail
|
|
2923
3497
|
});
|
|
2924
3498
|
return { content: [{ type: "text", text }] };
|
|
@@ -2930,7 +3504,7 @@ mcpRoutes.all("/", async (c) => {
|
|
|
2930
3504
|
const nestId = c.req.param("nestId");
|
|
2931
3505
|
const userId = c.get("userId");
|
|
2932
3506
|
const userEmail = getUserEmail2(userId);
|
|
2933
|
-
const server = createMcpServerForNest(nestId, userEmail);
|
|
3507
|
+
const server = createMcpServerForNest(nestId, userId, userEmail);
|
|
2934
3508
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
2935
3509
|
sessionIdGenerator: void 0,
|
|
2936
3510
|
enableJsonResponse: true
|
|
@@ -2949,8 +3523,8 @@ mcpRoutes.all("/", async (c) => {
|
|
|
2949
3523
|
import { Hono as Hono7 } from "hono";
|
|
2950
3524
|
|
|
2951
3525
|
// src/governance/stewards-parser.ts
|
|
2952
|
-
import { readFileSync, existsSync } from "fs";
|
|
2953
|
-
import { join as
|
|
3526
|
+
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
3527
|
+
import { join as join3 } from "path";
|
|
2954
3528
|
function parseStewardsYaml(content) {
|
|
2955
3529
|
const result = { version: 1 };
|
|
2956
3530
|
const lines = content.split("\n");
|
|
@@ -3015,15 +3589,15 @@ function parseEntry(str) {
|
|
|
3015
3589
|
}
|
|
3016
3590
|
function loadStewardsConfig(nestId) {
|
|
3017
3591
|
const dataRoot = config.DATA_ROOT;
|
|
3018
|
-
const nestPath =
|
|
3592
|
+
const nestPath = join3(dataRoot, "nests", nestId);
|
|
3019
3593
|
const candidates = [
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3594
|
+
join3(nestPath, "stewards.yaml"),
|
|
3595
|
+
join3(nestPath, "stewards.yml"),
|
|
3596
|
+
join3(nestPath, ".context", "stewards.yaml")
|
|
3023
3597
|
];
|
|
3024
3598
|
for (const candidatePath of candidates) {
|
|
3025
3599
|
if (existsSync(candidatePath)) {
|
|
3026
|
-
const content =
|
|
3600
|
+
const content = readFileSync2(candidatePath, "utf-8");
|
|
3027
3601
|
return parseStewardsYaml(content);
|
|
3028
3602
|
}
|
|
3029
3603
|
}
|
|
@@ -3174,8 +3748,19 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
|
3174
3748
|
const nodeId = c.req.param("nodeId");
|
|
3175
3749
|
const allVersions = getVersions(nestId, nodeId);
|
|
3176
3750
|
const approved = getApprovedVersion(nestId, nodeId);
|
|
3751
|
+
const { versions: versionManager } = engineCache.get(nestId);
|
|
3752
|
+
const withContent = await Promise.all(
|
|
3753
|
+
allVersions.map(async (v) => {
|
|
3754
|
+
try {
|
|
3755
|
+
const raw = await versionManager.reconstructVersion(nodeId, v.version);
|
|
3756
|
+
return { ...v, content: bodyOnly(nodeId, raw) };
|
|
3757
|
+
} catch {
|
|
3758
|
+
return v;
|
|
3759
|
+
}
|
|
3760
|
+
})
|
|
3761
|
+
);
|
|
3177
3762
|
return c.json({
|
|
3178
|
-
versions:
|
|
3763
|
+
versions: withContent,
|
|
3179
3764
|
approvedVersion: approved,
|
|
3180
3765
|
currentVersion: allVersions[0]?.version || 0
|
|
3181
3766
|
});
|
|
@@ -3403,15 +3988,15 @@ function ensureAnonymousUser() {
|
|
|
3403
3988
|
// src/app.ts
|
|
3404
3989
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3405
3990
|
import { fileURLToPath } from "url";
|
|
3406
|
-
import { dirname, join as
|
|
3991
|
+
import { dirname, join as join4, relative as relative2 } from "path";
|
|
3407
3992
|
import { existsSync as existsSync2 } from "fs";
|
|
3408
3993
|
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
3409
3994
|
var UI_DIR_CANDIDATES = [
|
|
3410
|
-
|
|
3411
|
-
|
|
3995
|
+
join4(HERE, "web3"),
|
|
3996
|
+
join4(process.cwd(), "dist", "web3")
|
|
3412
3997
|
];
|
|
3413
3998
|
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
3414
|
-
var UI_DIR_REL =
|
|
3999
|
+
var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
|
|
3415
4000
|
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
3416
4001
|
const anonId = ensureAnonymousUser();
|
|
3417
4002
|
c.set("userId", anonId);
|
|
@@ -3473,6 +4058,7 @@ function createApp() {
|
|
|
3473
4058
|
version: "0.1.0",
|
|
3474
4059
|
auth_mode: config.AUTH_MODE,
|
|
3475
4060
|
logo_url: config.LOGO_URL,
|
|
4061
|
+
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
3476
4062
|
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
3477
4063
|
})
|
|
3478
4064
|
);
|
|
@@ -3542,7 +4128,14 @@ function createApp() {
|
|
|
3542
4128
|
valid: true,
|
|
3543
4129
|
tier: info.tier,
|
|
3544
4130
|
org: info.org,
|
|
3545
|
-
limits: info.limits
|
|
4131
|
+
limits: info.limits,
|
|
4132
|
+
// The key validated and is live now, but if it couldn't be written to
|
|
4133
|
+
// disk it'll be lost on restart — surface that instead of pretending
|
|
4134
|
+
// the setup is durable (the old behavior silently swallowed this).
|
|
4135
|
+
persisted: info.persisted,
|
|
4136
|
+
...info.persisted ? {} : {
|
|
4137
|
+
warning: `License validated, but it could not be saved to disk (${info.persistError || "unknown error"}). It will be LOST on the next restart. Set ENV_FILE_PATH to a writable, persisted path (e.g. under DATA_ROOT) or provide PROMPTOWL_KEY via the container environment.`
|
|
4138
|
+
}
|
|
3546
4139
|
});
|
|
3547
4140
|
} catch (err) {
|
|
3548
4141
|
const msg = err instanceof Error ? err.message : "Failed to install key";
|
|
@@ -3577,6 +4170,7 @@ function createApp() {
|
|
|
3577
4170
|
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
3578
4171
|
const parts = localPath.split("/").filter(Boolean);
|
|
3579
4172
|
if (parts.length < 2) return next();
|
|
4173
|
+
if (parts[0] === "unsynced") return next();
|
|
3580
4174
|
const nestId = parts[0];
|
|
3581
4175
|
const userId = c.get("userId");
|
|
3582
4176
|
const nestScope = c.get("nestScope");
|
|
@@ -3636,11 +4230,13 @@ function createApp() {
|
|
|
3636
4230
|
} else if (c.req.method !== "GET" && !isStewardActionPath) {
|
|
3637
4231
|
required = "write";
|
|
3638
4232
|
}
|
|
4233
|
+
const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
|
|
3639
4234
|
let stewardEditorBypass = false;
|
|
3640
4235
|
if (required === "write" && permission === "read" && parts[1] === "nodes") {
|
|
3641
4236
|
const userEmail = resolveCallerEmail(userId);
|
|
3642
|
-
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE")) {
|
|
3643
|
-
const
|
|
4237
|
+
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeRevert)) {
|
|
4238
|
+
const idParts = isNodeRevert ? parts.slice(2, -1) : parts.slice(2);
|
|
4239
|
+
const rawNodeId = idParts.join("/");
|
|
3644
4240
|
let nodeId = rawNodeId;
|
|
3645
4241
|
try {
|
|
3646
4242
|
nodeId = decodeURIComponent(rawNodeId);
|
|
@@ -3758,7 +4354,7 @@ function createApp() {
|
|
|
3758
4354
|
|
|
3759
4355
|
// src/db/backfill.ts
|
|
3760
4356
|
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
3761
|
-
import { join as
|
|
4357
|
+
import { join as join5 } from "path";
|
|
3762
4358
|
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
3763
4359
|
async function backfillNodeVersionsFromHistory(db) {
|
|
3764
4360
|
const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
|
|
@@ -3779,7 +4375,7 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
3779
4375
|
let totalInserted = 0;
|
|
3780
4376
|
let totalDocs = 0;
|
|
3781
4377
|
for (const { id: nestId } of nests) {
|
|
3782
|
-
const nestPath =
|
|
4378
|
+
const nestPath = join5(config.DATA_ROOT, "nests", nestId);
|
|
3783
4379
|
const storage = new NestStorage(nestPath);
|
|
3784
4380
|
let docs;
|
|
3785
4381
|
try {
|