@promptowl/contextnest-community 1.2.0 → 1.4.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 +4 -2
- package/README.md +16 -0
- package/dist/{chunk-LO54V4AU.js → chunk-HIH7I232.js} +1 -1
- package/dist/{chunk-G62P54ET.js → chunk-RMU3LOPH.js} +148 -5
- package/dist/{chunk-E7E3JMQR.js → chunk-TTXTEUFT.js} +3 -3
- package/dist/{chunk-5MT4ZBVF.js → chunk-XNPD2Q6E.js} +130 -28
- package/dist/index.js +1378 -111
- package/dist/{review-service-GYX3AW6E.js → review-service-ZBYZYH5H.js} +4 -4
- package/dist/{stewardship-service-VOD5HY3I.js → stewardship-service-HFQPGASU.js} +2 -2
- package/dist/{version-service-OCZUV2QP.js → version-service-REGL5CUT.js} +2 -2
- package/dist/web3/assets/index-BkykvwZ7.css +1 -0
- package/dist/web3/assets/index-z5pGrOrG.js +821 -0
- package/dist/web3/index.html +2 -2
- package/package.json +3 -1
- package/dist/web3/assets/index-72vKyivD.js +0 -756
- package/dist/web3/assets/index-JmSevkg_.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-TTXTEUFT.js";
|
|
20
20
|
import {
|
|
21
21
|
checkConflict,
|
|
22
22
|
createVersion,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
getDisplayStatus,
|
|
26
26
|
getVersions,
|
|
27
27
|
setApprovedVersion
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-HIH7I232.js";
|
|
29
29
|
import {
|
|
30
30
|
AppError,
|
|
31
31
|
ConflictError,
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
nestAllowsSelfApprove,
|
|
68
68
|
permissionLevel,
|
|
69
69
|
removeSteward,
|
|
70
|
+
renameNest,
|
|
70
71
|
resolveNestPermission,
|
|
71
72
|
resolveStewardsForNode,
|
|
72
73
|
resolveStewardsWithFallback,
|
|
@@ -77,21 +78,22 @@ import {
|
|
|
77
78
|
startTelemetryLoop,
|
|
78
79
|
syncFromConfig,
|
|
79
80
|
trackEvent,
|
|
81
|
+
uniqueNestName,
|
|
80
82
|
updateSteward,
|
|
81
83
|
validateLicense
|
|
82
|
-
} from "./chunk-
|
|
84
|
+
} from "./chunk-XNPD2Q6E.js";
|
|
83
85
|
import {
|
|
84
86
|
ANON_EMAIL,
|
|
85
87
|
ANON_USER_ID,
|
|
86
88
|
config,
|
|
87
89
|
getDb
|
|
88
|
-
} from "./chunk-
|
|
90
|
+
} from "./chunk-RMU3LOPH.js";
|
|
89
91
|
|
|
90
92
|
// src/index.ts
|
|
91
93
|
import { serve } from "@hono/node-server";
|
|
92
94
|
|
|
93
95
|
// src/app.ts
|
|
94
|
-
import { Hono as
|
|
96
|
+
import { Hono as Hono9 } from "hono";
|
|
95
97
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
96
98
|
import { cors } from "hono/cors";
|
|
97
99
|
|
|
@@ -210,6 +212,11 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
210
212
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
211
213
|
});
|
|
212
214
|
|
|
215
|
+
// src/shared/email.ts
|
|
216
|
+
function normalizeEmail(email) {
|
|
217
|
+
return email.trim().toLowerCase();
|
|
218
|
+
}
|
|
219
|
+
|
|
213
220
|
// src/shared/rate-limit.ts
|
|
214
221
|
var buckets = /* @__PURE__ */ new Map();
|
|
215
222
|
function liveBucket(key, cutoff) {
|
|
@@ -242,11 +249,60 @@ function clear(key) {
|
|
|
242
249
|
buckets.delete(key);
|
|
243
250
|
}
|
|
244
251
|
|
|
252
|
+
// src/auth/sso-token.ts
|
|
253
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
254
|
+
function b64urlToBuffer(input) {
|
|
255
|
+
const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
|
|
256
|
+
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
257
|
+
}
|
|
258
|
+
var SsoTokenError = class extends Error {
|
|
259
|
+
};
|
|
260
|
+
function verifySsoTicket(token, secret, nowSec = Math.floor(Date.now() / 1e3)) {
|
|
261
|
+
if (!secret) throw new SsoTokenError("SSO secret not configured");
|
|
262
|
+
if (!token || typeof token !== "string") {
|
|
263
|
+
throw new SsoTokenError("Missing ticket");
|
|
264
|
+
}
|
|
265
|
+
const parts = token.split(".");
|
|
266
|
+
if (parts.length !== 3) throw new SsoTokenError("Malformed ticket");
|
|
267
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
268
|
+
let header;
|
|
269
|
+
try {
|
|
270
|
+
header = JSON.parse(b64urlToBuffer(headerB64).toString("utf8"));
|
|
271
|
+
} catch {
|
|
272
|
+
throw new SsoTokenError("Invalid ticket header");
|
|
273
|
+
}
|
|
274
|
+
if (header.alg !== "HS256") {
|
|
275
|
+
throw new SsoTokenError(`Unsupported alg: ${header.alg}`);
|
|
276
|
+
}
|
|
277
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
278
|
+
const expected = createHmac("sha256", secret).update(signingInput).digest();
|
|
279
|
+
const provided = b64urlToBuffer(sigB64);
|
|
280
|
+
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
|
|
281
|
+
throw new SsoTokenError("Bad signature");
|
|
282
|
+
}
|
|
283
|
+
let claims;
|
|
284
|
+
try {
|
|
285
|
+
claims = JSON.parse(b64urlToBuffer(payloadB64).toString("utf8"));
|
|
286
|
+
} catch {
|
|
287
|
+
throw new SsoTokenError("Invalid ticket payload");
|
|
288
|
+
}
|
|
289
|
+
const LEEWAY = 30;
|
|
290
|
+
if (typeof claims.exp !== "number") {
|
|
291
|
+
throw new SsoTokenError("Ticket missing exp");
|
|
292
|
+
}
|
|
293
|
+
if (nowSec > claims.exp + LEEWAY) {
|
|
294
|
+
throw new SsoTokenError("Ticket expired");
|
|
295
|
+
}
|
|
296
|
+
if (!claims.sub) throw new SsoTokenError("Ticket missing sub");
|
|
297
|
+
return claims;
|
|
298
|
+
}
|
|
299
|
+
|
|
245
300
|
// src/auth/routes.ts
|
|
246
301
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
247
302
|
var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
|
|
248
303
|
var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
|
|
249
304
|
var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
|
|
305
|
+
var warnedMissingSsoAudience = false;
|
|
250
306
|
function clientIp(c) {
|
|
251
307
|
const xff = c.req.header("x-forwarded-for");
|
|
252
308
|
if (xff) return xff.split(",")[0].trim();
|
|
@@ -294,18 +350,67 @@ function clearSessionCookie(c) {
|
|
|
294
350
|
buildClearSessionCookie({ secure: isSecureRequest(c) })
|
|
295
351
|
);
|
|
296
352
|
}
|
|
353
|
+
async function provisionPromptowlUser(c, rawEmail, rawName) {
|
|
354
|
+
const gate = config.PROMPTOWL_SIGN_IN_GATE;
|
|
355
|
+
if (gate !== "open") {
|
|
356
|
+
if (gate === "disabled" || !isLicenseAdminEmail(rawEmail)) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
status: 403,
|
|
360
|
+
error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
|
|
361
|
+
gate
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const db = getDb();
|
|
366
|
+
const meEmail = normalizeEmail(rawEmail);
|
|
367
|
+
let user = db.prepare("SELECT id, email, name FROM users WHERE LOWER(email) = ?").get(meEmail);
|
|
368
|
+
if (!user) {
|
|
369
|
+
const userId = uuid();
|
|
370
|
+
const placeholderHash = await hashPassword(uuid());
|
|
371
|
+
db.prepare(
|
|
372
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
373
|
+
).run(userId, meEmail, rawName || null, placeholderHash);
|
|
374
|
+
user = { id: userId, email: meEmail, name: rawName || null };
|
|
375
|
+
trackEvent("user.register", {
|
|
376
|
+
userId,
|
|
377
|
+
email: rawEmail,
|
|
378
|
+
method: "promptowl"
|
|
379
|
+
});
|
|
380
|
+
} else {
|
|
381
|
+
trackEvent("user.login", { userId: user.id, method: "promptowl" });
|
|
382
|
+
}
|
|
383
|
+
let claimBlocked = null;
|
|
384
|
+
const lic = getCurrentLicense();
|
|
385
|
+
let isAdmin = false;
|
|
386
|
+
if (!lic?.valid) {
|
|
387
|
+
claimBlocked = { reason: "license_required" };
|
|
388
|
+
} else if (lic.ownerEmail && lic.ownerEmail.toLowerCase() === rawEmail.toLowerCase()) {
|
|
389
|
+
isAdmin = true;
|
|
390
|
+
trackEvent("admin.claim", { userId: user.id, email: user.email });
|
|
391
|
+
} else {
|
|
392
|
+
claimBlocked = {
|
|
393
|
+
reason: "email_mismatch",
|
|
394
|
+
license_owner_email: lic.ownerEmail
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
398
|
+
setSessionCookie(c, sessionId);
|
|
399
|
+
return { ok: true, user, isAdmin, claimBlocked };
|
|
400
|
+
}
|
|
297
401
|
var authRoutes = new Hono();
|
|
298
402
|
authRoutes.post("/register", async (c) => {
|
|
299
403
|
const body = await c.req.json();
|
|
300
404
|
if (!body.email || !body.password) {
|
|
301
405
|
throw new ValidationError("email and password are required");
|
|
302
406
|
}
|
|
407
|
+
const email = normalizeEmail(body.email);
|
|
303
408
|
const ip = clientIp(c);
|
|
304
409
|
if (!tryConsume(`register:ip:${ip}`, REGISTER_LIMIT)) {
|
|
305
410
|
return c.json({ error: "Too many registration attempts, try again later" }, 429);
|
|
306
411
|
}
|
|
307
412
|
const db = getDb();
|
|
308
|
-
const existing = db.prepare("SELECT id, is_invited FROM users WHERE email = ?").get(
|
|
413
|
+
const existing = db.prepare("SELECT id, is_invited FROM users WHERE LOWER(email) = ?").get(email);
|
|
309
414
|
let userId;
|
|
310
415
|
const passwordHash = await hashPassword(body.password);
|
|
311
416
|
if (existing && existing.is_invited === 1) {
|
|
@@ -313,15 +418,15 @@ authRoutes.post("/register", async (c) => {
|
|
|
313
418
|
db.prepare(
|
|
314
419
|
"UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?"
|
|
315
420
|
).run(passwordHash, body.name || null, userId);
|
|
316
|
-
trackEvent("user.register", { userId, email
|
|
421
|
+
trackEvent("user.register", { userId, email, claimed: true });
|
|
317
422
|
} else if (existing) {
|
|
318
423
|
throw new ValidationError("Email already registered");
|
|
319
424
|
} else {
|
|
320
425
|
userId = uuid();
|
|
321
426
|
db.prepare(
|
|
322
427
|
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
323
|
-
).run(userId,
|
|
324
|
-
trackEvent("user.register", { userId, email
|
|
428
|
+
).run(userId, email, body.name || null, passwordHash);
|
|
429
|
+
trackEvent("user.register", { userId, email });
|
|
325
430
|
}
|
|
326
431
|
const sessionId = createSession(userId, c.req.header("User-Agent"));
|
|
327
432
|
setSessionCookie(c, sessionId);
|
|
@@ -329,9 +434,9 @@ authRoutes.post("/register", async (c) => {
|
|
|
329
434
|
{
|
|
330
435
|
user: {
|
|
331
436
|
id: userId,
|
|
332
|
-
email
|
|
437
|
+
email,
|
|
333
438
|
name: body.name || null,
|
|
334
|
-
is_admin: isLicenseAdminEmail(
|
|
439
|
+
is_admin: isLicenseAdminEmail(email)
|
|
335
440
|
}
|
|
336
441
|
},
|
|
337
442
|
201
|
|
@@ -352,8 +457,8 @@ authRoutes.post("/login", async (c) => {
|
|
|
352
457
|
}
|
|
353
458
|
const db = getDb();
|
|
354
459
|
const user = db.prepare(
|
|
355
|
-
"SELECT id, email, name, password_hash, is_admin FROM users WHERE email = ?"
|
|
356
|
-
).get(
|
|
460
|
+
"SELECT id, email, name, password_hash, is_admin FROM users WHERE LOWER(email) = ?"
|
|
461
|
+
).get(emailLower);
|
|
357
462
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
358
463
|
if (!user || !check.ok) {
|
|
359
464
|
if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
|
|
@@ -525,61 +630,76 @@ authRoutes.post("/promptowl", async (c) => {
|
|
|
525
630
|
401
|
|
526
631
|
);
|
|
527
632
|
}
|
|
528
|
-
const
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
return c.json(
|
|
532
|
-
{
|
|
533
|
-
error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
|
|
534
|
-
gate
|
|
535
|
-
},
|
|
536
|
-
403
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
const db = getDb();
|
|
541
|
-
let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(me.email);
|
|
542
|
-
if (!user) {
|
|
543
|
-
const userId = uuid();
|
|
544
|
-
const placeholderHash = await hashPassword(uuid());
|
|
545
|
-
db.prepare(
|
|
546
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
547
|
-
).run(userId, me.email, me.name || null, placeholderHash);
|
|
548
|
-
user = { id: userId, email: me.email, name: me.name || null };
|
|
549
|
-
trackEvent("user.register", {
|
|
550
|
-
userId,
|
|
551
|
-
email: me.email,
|
|
552
|
-
method: "promptowl"
|
|
553
|
-
});
|
|
554
|
-
} else {
|
|
555
|
-
trackEvent("user.login", { userId: user.id, method: "promptowl" });
|
|
633
|
+
const result = await provisionPromptowlUser(c, me.email, me.name);
|
|
634
|
+
if (!result.ok) {
|
|
635
|
+
return c.json({ error: result.error, gate: result.gate }, result.status);
|
|
556
636
|
}
|
|
557
|
-
let claimBlocked = null;
|
|
558
|
-
const lic = getCurrentLicense();
|
|
559
|
-
let isAdmin = false;
|
|
560
|
-
if (!lic?.valid) {
|
|
561
|
-
claimBlocked = { reason: "license_required" };
|
|
562
|
-
} else if (lic.ownerEmail && lic.ownerEmail.toLowerCase() === me.email.toLowerCase()) {
|
|
563
|
-
isAdmin = true;
|
|
564
|
-
trackEvent("admin.claim", { userId: user.id, email: user.email });
|
|
565
|
-
} else {
|
|
566
|
-
claimBlocked = {
|
|
567
|
-
reason: "email_mismatch",
|
|
568
|
-
license_owner_email: lic.ownerEmail
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
572
|
-
setSessionCookie(c, sessionId);
|
|
573
637
|
return c.json({
|
|
574
638
|
user: {
|
|
575
|
-
id: user.id,
|
|
576
|
-
email: user.email,
|
|
577
|
-
name: user.name,
|
|
578
|
-
is_admin: isAdmin
|
|
639
|
+
id: result.user.id,
|
|
640
|
+
email: result.user.email,
|
|
641
|
+
name: result.user.name,
|
|
642
|
+
is_admin: result.isAdmin
|
|
579
643
|
},
|
|
580
|
-
...claimBlocked ? { claim_blocked: claimBlocked } : {}
|
|
644
|
+
...result.claimBlocked ? { claim_blocked: result.claimBlocked } : {}
|
|
581
645
|
});
|
|
582
646
|
});
|
|
647
|
+
authRoutes.get("/sso", async (c) => {
|
|
648
|
+
const ssoError = (code) => c.redirect(`/?sso_error=${code}`, 302);
|
|
649
|
+
const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
|
|
650
|
+
if (!secret) return ssoError("not_supported");
|
|
651
|
+
if (!tryConsume(`sso:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
|
|
652
|
+
return ssoError("rate_limited");
|
|
653
|
+
}
|
|
654
|
+
const ticket = c.req.query("ticket");
|
|
655
|
+
if (!ticket) return ssoError("missing_ticket");
|
|
656
|
+
let claims;
|
|
657
|
+
try {
|
|
658
|
+
claims = verifySsoTicket(ticket, secret);
|
|
659
|
+
} catch {
|
|
660
|
+
return ssoError("invalid_ticket");
|
|
661
|
+
}
|
|
662
|
+
const expectedAud = config.PUBLIC_BASE_URL;
|
|
663
|
+
if (!expectedAud && !warnedMissingSsoAudience) {
|
|
664
|
+
warnedMissingSsoAudience = true;
|
|
665
|
+
console.warn(
|
|
666
|
+
"[sso] OFFICIAL_COMMUNITY_SSO_SECRET is set but PUBLIC_BASE_URL is empty \u2014 ticket audience binding is DISABLED. Set PUBLIC_BASE_URL so a ticket minted for another server cannot be replayed against this one."
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
if (expectedAud) {
|
|
670
|
+
const aud = (claims.aud || "").replace(/\/$/, "");
|
|
671
|
+
if (aud !== expectedAud) return ssoError("bad_audience");
|
|
672
|
+
}
|
|
673
|
+
const sub = (claims.sub || "").trim();
|
|
674
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) || sub.length > 254) {
|
|
675
|
+
return ssoError("invalid_ticket");
|
|
676
|
+
}
|
|
677
|
+
if (!claims.jti) return ssoError("invalid_ticket");
|
|
678
|
+
const db = getDb();
|
|
679
|
+
db.prepare("DELETE FROM sso_used_jti WHERE expires_at < datetime('now')").run();
|
|
680
|
+
const expiresAtIso = new Date((claims.exp ?? 0) * 1e3).toISOString();
|
|
681
|
+
try {
|
|
682
|
+
db.prepare(
|
|
683
|
+
"INSERT INTO sso_used_jti (jti, expires_at) VALUES (?, ?)"
|
|
684
|
+
).run(claims.jti, expiresAtIso);
|
|
685
|
+
} catch (err) {
|
|
686
|
+
if (err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
687
|
+
return ssoError("ticket_used");
|
|
688
|
+
}
|
|
689
|
+
console.error("[sso] failed to record ticket jti:", err);
|
|
690
|
+
return ssoError("service_error");
|
|
691
|
+
}
|
|
692
|
+
let result;
|
|
693
|
+
try {
|
|
694
|
+
result = await provisionPromptowlUser(c, sub, claims.name);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
db.prepare("DELETE FROM sso_used_jti WHERE jti = ?").run(claims.jti);
|
|
697
|
+
console.error("[sso] provisioning failed; released jti for retry:", err);
|
|
698
|
+
return ssoError("service_error");
|
|
699
|
+
}
|
|
700
|
+
if (!result.ok) return ssoError("sign_in_restricted");
|
|
701
|
+
return c.redirect("/", 302);
|
|
702
|
+
});
|
|
583
703
|
authRoutes.get("/admin-status", async (c) => {
|
|
584
704
|
const db = getDb();
|
|
585
705
|
const lic = getCurrentLicense();
|
|
@@ -727,6 +847,7 @@ authRoutes.delete("/users/:userId", async (c) => {
|
|
|
727
847
|
authRoutes.post("/invite", async (c) => {
|
|
728
848
|
const body = await c.req.json();
|
|
729
849
|
if (!body.email) throw new ValidationError("email is required");
|
|
850
|
+
const email = normalizeEmail(body.email);
|
|
730
851
|
const callerId = resolveCallerUserId(c);
|
|
731
852
|
if (!callerId) {
|
|
732
853
|
return c.json(
|
|
@@ -745,14 +866,14 @@ authRoutes.post("/invite", async (c) => {
|
|
|
745
866
|
403
|
|
746
867
|
);
|
|
747
868
|
}
|
|
748
|
-
let user = db.prepare("SELECT id, email FROM users WHERE email = ?").get(
|
|
869
|
+
let user = db.prepare("SELECT id, email FROM users WHERE LOWER(email) = ?").get(email);
|
|
749
870
|
if (!user) {
|
|
750
871
|
const userId = uuid();
|
|
751
872
|
const placeholderHash = await hashPassword(uuid());
|
|
752
873
|
db.prepare(
|
|
753
874
|
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
754
|
-
).run(userId,
|
|
755
|
-
user = { id: userId, email
|
|
875
|
+
).run(userId, email, null, placeholderHash);
|
|
876
|
+
user = { id: userId, email };
|
|
756
877
|
}
|
|
757
878
|
const apiKey = generateApiKey();
|
|
758
879
|
const keyId = uuid();
|
|
@@ -1069,7 +1190,7 @@ async function approveExternalEdit(input) {
|
|
|
1069
1190
|
const node = await storage.readDocument(input.documentId);
|
|
1070
1191
|
const versionNum = result.versionEntry.version;
|
|
1071
1192
|
const tags = node.frontmatter.tags || [];
|
|
1072
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1193
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-REGL5CUT.js");
|
|
1073
1194
|
createVersion2({
|
|
1074
1195
|
nestId: input.nestId,
|
|
1075
1196
|
nodeId: input.documentId,
|
|
@@ -1225,7 +1346,7 @@ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
|
1225
1346
|
async function createNode(nestId, input, userEmail) {
|
|
1226
1347
|
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1227
1348
|
const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1228
|
-
const id = `nodes/${slug}`;
|
|
1349
|
+
const id = input.id ?? `nodes/${slug}`;
|
|
1229
1350
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1230
1351
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
1231
1352
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
@@ -1449,6 +1570,185 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1449
1570
|
return { node, version: responseVersion };
|
|
1450
1571
|
}
|
|
1451
1572
|
|
|
1573
|
+
// src/nests/unsynced-service.ts
|
|
1574
|
+
import { readdirSync, readFileSync, rmSync, statSync } from "fs";
|
|
1575
|
+
import { join as join2, relative } from "path";
|
|
1576
|
+
var RESERVED = /* @__PURE__ */ new Set(["nests"]);
|
|
1577
|
+
function scanMarkdown(dir) {
|
|
1578
|
+
let count = 0;
|
|
1579
|
+
let size = 0;
|
|
1580
|
+
let entries = [];
|
|
1581
|
+
try {
|
|
1582
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1583
|
+
} catch {
|
|
1584
|
+
return { count, size };
|
|
1585
|
+
}
|
|
1586
|
+
for (const e of entries) {
|
|
1587
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1588
|
+
const full = join2(dir, e.name);
|
|
1589
|
+
if (e.isDirectory()) {
|
|
1590
|
+
const sub = scanMarkdown(full);
|
|
1591
|
+
count += sub.count;
|
|
1592
|
+
size += sub.size;
|
|
1593
|
+
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
|
|
1594
|
+
try {
|
|
1595
|
+
size += statSync(full).size;
|
|
1596
|
+
count++;
|
|
1597
|
+
} catch {
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
return { count, size };
|
|
1602
|
+
}
|
|
1603
|
+
function hasDirectMarkdown(dir) {
|
|
1604
|
+
let entries = [];
|
|
1605
|
+
try {
|
|
1606
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1607
|
+
} catch {
|
|
1608
|
+
return false;
|
|
1609
|
+
}
|
|
1610
|
+
return entries.some(
|
|
1611
|
+
(e) => e.isFile() && e.name.toLowerCase().endsWith(".md")
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
function collectLeafFolders(rel, abs) {
|
|
1615
|
+
if (hasDirectMarkdown(abs)) {
|
|
1616
|
+
const { count, size } = scanMarkdown(abs);
|
|
1617
|
+
const segments = rel.split("/").filter(Boolean);
|
|
1618
|
+
return [
|
|
1619
|
+
{
|
|
1620
|
+
name: rel,
|
|
1621
|
+
label: segments[segments.length - 1] || rel,
|
|
1622
|
+
mdCount: count,
|
|
1623
|
+
sizeBytes: size
|
|
1624
|
+
}
|
|
1625
|
+
];
|
|
1626
|
+
}
|
|
1627
|
+
let entries = [];
|
|
1628
|
+
try {
|
|
1629
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
1630
|
+
} catch {
|
|
1631
|
+
return [];
|
|
1632
|
+
}
|
|
1633
|
+
const out = [];
|
|
1634
|
+
for (const e of entries) {
|
|
1635
|
+
if (!e.isDirectory()) continue;
|
|
1636
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1637
|
+
out.push(
|
|
1638
|
+
...collectLeafFolders(`${rel}/${e.name}`, join2(abs, e.name))
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
return out;
|
|
1642
|
+
}
|
|
1643
|
+
function listUnsyncedFolders() {
|
|
1644
|
+
const root = config.DATA_ROOT;
|
|
1645
|
+
let entries = [];
|
|
1646
|
+
try {
|
|
1647
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
1648
|
+
} catch {
|
|
1649
|
+
return [];
|
|
1650
|
+
}
|
|
1651
|
+
const out = [];
|
|
1652
|
+
for (const e of entries) {
|
|
1653
|
+
if (!e.isDirectory()) continue;
|
|
1654
|
+
if (e.name.startsWith(".")) continue;
|
|
1655
|
+
if (RESERVED.has(e.name)) continue;
|
|
1656
|
+
out.push(...collectLeafFolders(e.name, join2(root, e.name)));
|
|
1657
|
+
}
|
|
1658
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
1659
|
+
console.log(
|
|
1660
|
+
`[unsynced] discovered ${out.length} candidate folder(s) under ${root}`
|
|
1661
|
+
);
|
|
1662
|
+
return out;
|
|
1663
|
+
}
|
|
1664
|
+
function assertSafeFolderName(name) {
|
|
1665
|
+
if (!name || name.includes("\\")) {
|
|
1666
|
+
throw new ValidationError("Invalid folder name");
|
|
1667
|
+
}
|
|
1668
|
+
const segments = name.split("/").filter(Boolean);
|
|
1669
|
+
if (segments.length === 0) {
|
|
1670
|
+
throw new ValidationError("Invalid folder name");
|
|
1671
|
+
}
|
|
1672
|
+
if (RESERVED.has(segments[0])) {
|
|
1673
|
+
throw new ValidationError("Folder is not eligible for sync");
|
|
1674
|
+
}
|
|
1675
|
+
for (const seg of segments) {
|
|
1676
|
+
if (seg === ".." || seg.startsWith(".") || seg === "node_modules") {
|
|
1677
|
+
throw new ValidationError("Folder is not eligible for sync");
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
function collectMarkdownFiles(dir, root) {
|
|
1682
|
+
const out = [];
|
|
1683
|
+
let entries = [];
|
|
1684
|
+
try {
|
|
1685
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1686
|
+
} catch {
|
|
1687
|
+
return out;
|
|
1688
|
+
}
|
|
1689
|
+
for (const e of entries) {
|
|
1690
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
1691
|
+
const full = join2(dir, e.name);
|
|
1692
|
+
if (e.isDirectory()) {
|
|
1693
|
+
out.push(...collectMarkdownFiles(full, root));
|
|
1694
|
+
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
|
|
1695
|
+
try {
|
|
1696
|
+
const rel = relative(root, full);
|
|
1697
|
+
out.push({ path: rel, content: readFileSync(full, "utf-8") });
|
|
1698
|
+
} catch {
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return out;
|
|
1703
|
+
}
|
|
1704
|
+
async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
1705
|
+
console.log(
|
|
1706
|
+
`[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
|
|
1707
|
+
);
|
|
1708
|
+
assertSafeFolderName(folderName);
|
|
1709
|
+
const src = join2(config.DATA_ROOT, folderName);
|
|
1710
|
+
let stat;
|
|
1711
|
+
try {
|
|
1712
|
+
stat = statSync(src);
|
|
1713
|
+
} catch {
|
|
1714
|
+
console.warn(`[unsynced] source missing: ${src}`);
|
|
1715
|
+
throw new NotFoundError(`Folder not found: ${folderName}`);
|
|
1716
|
+
}
|
|
1717
|
+
if (!stat.isDirectory()) {
|
|
1718
|
+
throw new ValidationError(`Not a directory: ${folderName}`);
|
|
1719
|
+
}
|
|
1720
|
+
const files = collectMarkdownFiles(src, src);
|
|
1721
|
+
console.log(
|
|
1722
|
+
`[unsynced] collected ${files.length} markdown file(s) from ${src}`
|
|
1723
|
+
);
|
|
1724
|
+
if (files.length === 0) {
|
|
1725
|
+
throw new ValidationError(`Folder has no markdown to sync: ${folderName}`);
|
|
1726
|
+
}
|
|
1727
|
+
const segments = folderName.split("/").filter(Boolean);
|
|
1728
|
+
const baseName = segments[segments.length - 1] || folderName;
|
|
1729
|
+
const nestName = uniqueNestName(userId, baseName);
|
|
1730
|
+
if (nestName !== baseName) {
|
|
1731
|
+
console.log(
|
|
1732
|
+
`[unsynced] name "${baseName}" already in use, using "${nestName}" instead`
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
const nest = await importNest(userId, nestName, files);
|
|
1736
|
+
console.log(
|
|
1737
|
+
`[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
|
|
1738
|
+
);
|
|
1739
|
+
const documents = await registerImportedDocuments(nest.id, callerEmail);
|
|
1740
|
+
console.log(
|
|
1741
|
+
`[unsynced] registered ${documents} document(s) for nest ${nest.id}`
|
|
1742
|
+
);
|
|
1743
|
+
try {
|
|
1744
|
+
rmSync(src, { recursive: true, force: true });
|
|
1745
|
+
console.log(`[unsynced] removed source folder ${src}`);
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
console.error("[unsynced] failed to remove source folder", src, err);
|
|
1748
|
+
}
|
|
1749
|
+
return { nest, documents };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1452
1752
|
// src/nests/routes.ts
|
|
1453
1753
|
function effectivePermission(nestId, userId) {
|
|
1454
1754
|
if (config.AUTH_MODE === "open") {
|
|
@@ -1511,6 +1811,29 @@ nestRoutes.post("/import", async (c) => {
|
|
|
1511
1811
|
);
|
|
1512
1812
|
return c.json({ nest, documents }, 201);
|
|
1513
1813
|
});
|
|
1814
|
+
nestRoutes.get("/unsynced", async (c) => {
|
|
1815
|
+
const userId = c.get("userId");
|
|
1816
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
1817
|
+
throw new ForbiddenError("Only the server admin can list unsynced folders");
|
|
1818
|
+
}
|
|
1819
|
+
return c.json({ folders: listUnsyncedFolders() });
|
|
1820
|
+
});
|
|
1821
|
+
nestRoutes.post("/unsynced/sync", async (c) => {
|
|
1822
|
+
const userId = c.get("userId");
|
|
1823
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
1824
|
+
throw new ForbiddenError("Only the server admin can sync folders");
|
|
1825
|
+
}
|
|
1826
|
+
const body = await c.req.json();
|
|
1827
|
+
if (!body?.name) {
|
|
1828
|
+
throw new ValidationError("name is required");
|
|
1829
|
+
}
|
|
1830
|
+
const result = await syncUnsyncedFolder(
|
|
1831
|
+
userId,
|
|
1832
|
+
body.name,
|
|
1833
|
+
resolveCallerEmail(userId)
|
|
1834
|
+
);
|
|
1835
|
+
return c.json(result, 201);
|
|
1836
|
+
});
|
|
1514
1837
|
nestRoutes.get("/:nestId", async (c) => {
|
|
1515
1838
|
const nestId = c.req.param("nestId");
|
|
1516
1839
|
const userId = c.get("userId");
|
|
@@ -1527,6 +1850,25 @@ nestRoutes.get("/:nestId", async (c) => {
|
|
|
1527
1850
|
const nest = getNest(nestId);
|
|
1528
1851
|
return c.json({ nest, permission, roles, myStewards });
|
|
1529
1852
|
});
|
|
1853
|
+
nestRoutes.patch("/:nestId", async (c) => {
|
|
1854
|
+
const nestId = c.req.param("nestId");
|
|
1855
|
+
const userId = c.get("userId");
|
|
1856
|
+
const permission = effectivePermission(nestId, userId);
|
|
1857
|
+
if (permission === "none") {
|
|
1858
|
+
throw new NotFoundError("Nest not found");
|
|
1859
|
+
}
|
|
1860
|
+
if (permission !== "owner" && permission !== "admin") {
|
|
1861
|
+
throw new ForbiddenError(
|
|
1862
|
+
"Only the nest owner or an admin can rename a nest."
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
const body = await c.req.json();
|
|
1866
|
+
const nest = renameNest(nestId, {
|
|
1867
|
+
name: body.name,
|
|
1868
|
+
description: body.description
|
|
1869
|
+
});
|
|
1870
|
+
return c.json({ nest });
|
|
1871
|
+
});
|
|
1530
1872
|
nestRoutes.delete("/:nestId", async (c) => {
|
|
1531
1873
|
const nestId = c.req.param("nestId");
|
|
1532
1874
|
const userId = c.get("userId");
|
|
@@ -1599,10 +1941,16 @@ async function addCollaborator(params) {
|
|
|
1599
1941
|
if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
|
|
1600
1942
|
throw new ValidationError("permission must be read, write, or admin");
|
|
1601
1943
|
}
|
|
1944
|
+
if (params.callerPermission && permissionLevel(params.permission) > permissionLevel(params.callerPermission)) {
|
|
1945
|
+
throw new ForbiddenError(
|
|
1946
|
+
`You can only grant access up to your own level ('${params.callerPermission}'). Ask a nest admin to grant '${params.permission}'.`
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1602
1949
|
const db = getDb();
|
|
1603
1950
|
let userId = params.userId;
|
|
1604
1951
|
if (!userId && params.email) {
|
|
1605
|
-
const
|
|
1952
|
+
const email = normalizeEmail(params.email);
|
|
1953
|
+
const existing = db.prepare("SELECT id FROM users WHERE LOWER(email) = ?").get(email);
|
|
1606
1954
|
if (existing) {
|
|
1607
1955
|
userId = existing.id;
|
|
1608
1956
|
} else {
|
|
@@ -1610,7 +1958,7 @@ async function addCollaborator(params) {
|
|
|
1610
1958
|
userId = uuid2();
|
|
1611
1959
|
db.prepare(
|
|
1612
1960
|
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
1613
|
-
).run(userId,
|
|
1961
|
+
).run(userId, email, null, await hashPassword2(uuid2()));
|
|
1614
1962
|
}
|
|
1615
1963
|
}
|
|
1616
1964
|
if (!userId) {
|
|
@@ -1673,7 +2021,10 @@ sharingRoutes.post("/collaborators", async (c) => {
|
|
|
1673
2021
|
email: body.email,
|
|
1674
2022
|
userId: body.user_id,
|
|
1675
2023
|
permission: body.permission ?? "",
|
|
1676
|
-
grantedByUserId: c.get("userId")
|
|
2024
|
+
grantedByUserId: c.get("userId"),
|
|
2025
|
+
// Enforce the escalation cap against the caller's own nest permission
|
|
2026
|
+
// (set by the access guard) — a write collaborator can't grant admin.
|
|
2027
|
+
callerPermission: c.get("nestPermission")
|
|
1677
2028
|
});
|
|
1678
2029
|
return c.json({ collaborator: collab }, 201);
|
|
1679
2030
|
});
|
|
@@ -1683,17 +2034,22 @@ sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
|
1683
2034
|
throw new ValidationError("permission must be read, write, or admin");
|
|
1684
2035
|
}
|
|
1685
2036
|
const db = getDb();
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
)
|
|
2037
|
+
const info = db.prepare(
|
|
2038
|
+
"UPDATE nest_collaborators SET permission = ? WHERE id = ? AND nest_id = ?"
|
|
2039
|
+
).run(body.permission, c.req.param("collabId"), c.req.param("nestId"));
|
|
2040
|
+
if (info.changes === 0) {
|
|
2041
|
+
throw new NotFoundError("Collaborator not found");
|
|
2042
|
+
}
|
|
1690
2043
|
return c.json({ updated: true });
|
|
1691
2044
|
});
|
|
1692
2045
|
sharingRoutes.delete("/collaborators/:collabId", async (c) => {
|
|
1693
2046
|
const db = getDb();
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
);
|
|
2047
|
+
const info = db.prepare(
|
|
2048
|
+
"DELETE FROM nest_collaborators WHERE id = ? AND nest_id = ?"
|
|
2049
|
+
).run(c.req.param("collabId"), c.req.param("nestId"));
|
|
2050
|
+
if (info.changes === 0) {
|
|
2051
|
+
throw new NotFoundError("Collaborator not found");
|
|
2052
|
+
}
|
|
1697
2053
|
return c.json({ removed: true });
|
|
1698
2054
|
});
|
|
1699
2055
|
sharingRoutes.patch("/visibility", async (c) => {
|
|
@@ -1781,7 +2137,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1781
2137
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
1782
2138
|
const nestId = c.req.param("nestId");
|
|
1783
2139
|
const nodeId = c.req.param("nodeId");
|
|
1784
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
2140
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-HFQPGASU.js");
|
|
1785
2141
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
1786
2142
|
nestId,
|
|
1787
2143
|
nodeId
|
|
@@ -1802,7 +2158,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1802
2158
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
1803
2159
|
const nestId = c.req.param("nestId");
|
|
1804
2160
|
const nodeId = c.req.param("nodeId");
|
|
1805
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
2161
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-REGL5CUT.js");
|
|
1806
2162
|
const allVersions = getVersions2(nestId, nodeId);
|
|
1807
2163
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
1808
2164
|
const db = getDb();
|
|
@@ -1833,7 +2189,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
1833
2189
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
1834
2190
|
const nestId = c.req.param("nestId");
|
|
1835
2191
|
const nodeId = c.req.param("nodeId");
|
|
1836
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
2192
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-ZBYZYH5H.js");
|
|
1837
2193
|
const history = getReviewHistory2(nestId, nodeId);
|
|
1838
2194
|
return c.json({ reviews: history });
|
|
1839
2195
|
});
|
|
@@ -2027,8 +2383,407 @@ function getUserEmail(c) {
|
|
|
2027
2383
|
return user?.email || "anonymous@localhost";
|
|
2028
2384
|
}
|
|
2029
2385
|
|
|
2030
|
-
// src/
|
|
2386
|
+
// src/annotations/routes.ts
|
|
2031
2387
|
import { Hono as Hono5 } from "hono";
|
|
2388
|
+
|
|
2389
|
+
// src/annotations/service.ts
|
|
2390
|
+
import { v4 as uuid3 } from "uuid";
|
|
2391
|
+
|
|
2392
|
+
// src/annotations/projection.ts
|
|
2393
|
+
var MAX_CONTEXT_CHARS = 250;
|
|
2394
|
+
var MAX_QUOTE_CHARS = 1e3;
|
|
2395
|
+
function clampAnchor(raw) {
|
|
2396
|
+
if (!raw) return null;
|
|
2397
|
+
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
2398
|
+
if (!quote) return null;
|
|
2399
|
+
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
2400
|
+
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
2401
|
+
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
2402
|
+
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
2403
|
+
}
|
|
2404
|
+
function oneLine(s) {
|
|
2405
|
+
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
2406
|
+
}
|
|
2407
|
+
function renderAnchor(anchor) {
|
|
2408
|
+
if (!anchor) {
|
|
2409
|
+
return "<!-- anchor: whole-artifact -->";
|
|
2410
|
+
}
|
|
2411
|
+
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
2412
|
+
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
2413
|
+
anchor.after
|
|
2414
|
+
)}\u2026`;
|
|
2415
|
+
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
2416
|
+
}
|
|
2417
|
+
function statusHeading(thread) {
|
|
2418
|
+
if (thread.status === "resolved") {
|
|
2419
|
+
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
2420
|
+
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
2421
|
+
const meta = who || when ? ` (${who}${when})` : "";
|
|
2422
|
+
return `\u2705 RESOLVED${meta}`;
|
|
2423
|
+
}
|
|
2424
|
+
return "\u{1F7E0} OPEN";
|
|
2425
|
+
}
|
|
2426
|
+
function snapshotLabel(v) {
|
|
2427
|
+
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
2428
|
+
}
|
|
2429
|
+
function orderThreads(threads) {
|
|
2430
|
+
return [...threads].sort((a, b) => {
|
|
2431
|
+
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
2432
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
2436
|
+
const lines = [];
|
|
2437
|
+
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
2438
|
+
lines.push("");
|
|
2439
|
+
lines.push(
|
|
2440
|
+
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
2441
|
+
);
|
|
2442
|
+
lines.push("");
|
|
2443
|
+
if (threads.length === 0) {
|
|
2444
|
+
lines.push("_No annotations yet._");
|
|
2445
|
+
lines.push("");
|
|
2446
|
+
return lines.join("\n");
|
|
2447
|
+
}
|
|
2448
|
+
const buckets2 = /* @__PURE__ */ new Map();
|
|
2449
|
+
for (const t of threads) {
|
|
2450
|
+
const key = t.snapshotVersion ?? null;
|
|
2451
|
+
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
2452
|
+
}
|
|
2453
|
+
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
2454
|
+
if (a == null) return 1;
|
|
2455
|
+
if (b == null) return -1;
|
|
2456
|
+
return b - a;
|
|
2457
|
+
});
|
|
2458
|
+
for (const key of keys) {
|
|
2459
|
+
lines.push(`## ${snapshotLabel(key)}`);
|
|
2460
|
+
lines.push("");
|
|
2461
|
+
for (const thread of orderThreads(buckets2.get(key))) {
|
|
2462
|
+
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
2463
|
+
lines.push(renderAnchor(thread.anchor));
|
|
2464
|
+
for (const comment of thread.comments) {
|
|
2465
|
+
lines.push(
|
|
2466
|
+
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
lines.push("");
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
return lines.join("\n");
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// src/annotations/service.ts
|
|
2476
|
+
function loadComments(threadId) {
|
|
2477
|
+
const db = getDb();
|
|
2478
|
+
const rows = db.prepare(
|
|
2479
|
+
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, rowid ASC"
|
|
2480
|
+
).all(threadId);
|
|
2481
|
+
return rows.map((r) => ({
|
|
2482
|
+
id: r.id,
|
|
2483
|
+
author: r.author,
|
|
2484
|
+
body: r.body,
|
|
2485
|
+
createdAt: r.created_at
|
|
2486
|
+
}));
|
|
2487
|
+
}
|
|
2488
|
+
function rowToThread(row) {
|
|
2489
|
+
let anchor = null;
|
|
2490
|
+
if (row.anchor_json) {
|
|
2491
|
+
try {
|
|
2492
|
+
anchor = JSON.parse(row.anchor_json);
|
|
2493
|
+
} catch {
|
|
2494
|
+
anchor = null;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
return {
|
|
2498
|
+
id: row.id,
|
|
2499
|
+
nestId: row.nest_id,
|
|
2500
|
+
nodeId: row.node_id,
|
|
2501
|
+
snapshotVersion: row.snapshot_version,
|
|
2502
|
+
anchor,
|
|
2503
|
+
status: row.status,
|
|
2504
|
+
createdBy: row.created_by,
|
|
2505
|
+
createdAt: row.created_at,
|
|
2506
|
+
resolvedBy: row.resolved_by,
|
|
2507
|
+
resolvedAt: row.resolved_at,
|
|
2508
|
+
comments: loadComments(row.id)
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
function getThreadRow(threadId) {
|
|
2512
|
+
return getDb().prepare("SELECT * FROM annotation_threads WHERE id = ?").get(threadId);
|
|
2513
|
+
}
|
|
2514
|
+
function listThreads(nestId, nodeId) {
|
|
2515
|
+
const rows = getDb().prepare(
|
|
2516
|
+
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, rowid ASC"
|
|
2517
|
+
).all(nestId, nodeId);
|
|
2518
|
+
return rows.map(rowToThread);
|
|
2519
|
+
}
|
|
2520
|
+
function createThread(nestId, nodeId, input, authorEmail) {
|
|
2521
|
+
const body = (input.body ?? "").trim();
|
|
2522
|
+
if (!body) {
|
|
2523
|
+
throw new Error("comment body is required");
|
|
2524
|
+
}
|
|
2525
|
+
const db = getDb();
|
|
2526
|
+
const id = uuid3();
|
|
2527
|
+
const anchor = clampAnchor(input.anchor);
|
|
2528
|
+
const snapshot = input.snapshotVersion ?? getApprovedVersion(nestId, nodeId) ?? null;
|
|
2529
|
+
const tx = db.transaction(() => {
|
|
2530
|
+
db.prepare(
|
|
2531
|
+
`INSERT INTO annotation_threads
|
|
2532
|
+
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
2533
|
+
VALUES (?, ?, ?, ?, ?, 'open', ?)`
|
|
2534
|
+
).run(
|
|
2535
|
+
id,
|
|
2536
|
+
nestId,
|
|
2537
|
+
nodeId,
|
|
2538
|
+
snapshot,
|
|
2539
|
+
anchor ? JSON.stringify(anchor) : null,
|
|
2540
|
+
authorEmail
|
|
2541
|
+
);
|
|
2542
|
+
db.prepare(
|
|
2543
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)"
|
|
2544
|
+
).run(uuid3(), id, authorEmail, body);
|
|
2545
|
+
});
|
|
2546
|
+
tx();
|
|
2547
|
+
return rowToThread(getThreadRow(id));
|
|
2548
|
+
}
|
|
2549
|
+
function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
2550
|
+
const row = getThreadRow(threadId);
|
|
2551
|
+
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
2552
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
2553
|
+
}
|
|
2554
|
+
return row;
|
|
2555
|
+
}
|
|
2556
|
+
function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
2557
|
+
const trimmed = (body ?? "").trim();
|
|
2558
|
+
if (!trimmed) {
|
|
2559
|
+
throw new Error("comment body is required");
|
|
2560
|
+
}
|
|
2561
|
+
getScopedThreadRow(threadId, nestId, nodeId);
|
|
2562
|
+
getDb().prepare(
|
|
2563
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)"
|
|
2564
|
+
).run(uuid3(), threadId, authorEmail, trimmed);
|
|
2565
|
+
return rowToThread(getThreadRow(threadId));
|
|
2566
|
+
}
|
|
2567
|
+
function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
2568
|
+
getScopedThreadRow(threadId, nestId, nodeId);
|
|
2569
|
+
if (status === "resolved") {
|
|
2570
|
+
getDb().prepare(
|
|
2571
|
+
"UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = datetime('now') WHERE id = ?"
|
|
2572
|
+
).run(byEmail, threadId);
|
|
2573
|
+
} else {
|
|
2574
|
+
getDb().prepare(
|
|
2575
|
+
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?"
|
|
2576
|
+
).run(threadId);
|
|
2577
|
+
}
|
|
2578
|
+
return rowToThread(getThreadRow(threadId));
|
|
2579
|
+
}
|
|
2580
|
+
async function readArtifact(nestId, nodeId, version) {
|
|
2581
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
2582
|
+
const target = version ?? getApprovedVersion(nestId, nodeId) ?? null;
|
|
2583
|
+
let title = nodeId;
|
|
2584
|
+
let type = null;
|
|
2585
|
+
let liveHtml = "";
|
|
2586
|
+
let liveExists = true;
|
|
2587
|
+
try {
|
|
2588
|
+
const node = await storage.readDocument(nodeId);
|
|
2589
|
+
title = node.frontmatter?.title || nodeId;
|
|
2590
|
+
type = node.frontmatter?.type || null;
|
|
2591
|
+
liveHtml = node.body || "";
|
|
2592
|
+
} catch {
|
|
2593
|
+
liveExists = false;
|
|
2594
|
+
}
|
|
2595
|
+
if (target != null) {
|
|
2596
|
+
try {
|
|
2597
|
+
const raw = await versionManager.reconstructVersion(nodeId, target);
|
|
2598
|
+
return { title, html: bodyOnly(nodeId, raw), version: target, type };
|
|
2599
|
+
} catch (err) {
|
|
2600
|
+
console.warn(
|
|
2601
|
+
`[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
|
|
2602
|
+
err
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
if (!liveExists) {
|
|
2607
|
+
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
2608
|
+
}
|
|
2609
|
+
return { title, html: liveHtml, version: target, type };
|
|
2610
|
+
}
|
|
2611
|
+
function derivedAnnotationsId(sourceNodeId) {
|
|
2612
|
+
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
2613
|
+
return `nodes/${base}--annotations`;
|
|
2614
|
+
}
|
|
2615
|
+
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
2616
|
+
try {
|
|
2617
|
+
let title = nodeId;
|
|
2618
|
+
try {
|
|
2619
|
+
const { storage } = engineCache.get(nestId);
|
|
2620
|
+
const node = await storage.readDocument(nodeId);
|
|
2621
|
+
title = node.frontmatter?.title || nodeId;
|
|
2622
|
+
} catch {
|
|
2623
|
+
}
|
|
2624
|
+
const threads = listThreads(nestId, nodeId);
|
|
2625
|
+
const derivedTitle = `${title} \u2014 Annotations`;
|
|
2626
|
+
const markdown = projectThreadsToMarkdown(title, threads);
|
|
2627
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
2628
|
+
try {
|
|
2629
|
+
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
if (err instanceof NotFoundError) {
|
|
2632
|
+
await createNode(
|
|
2633
|
+
nestId,
|
|
2634
|
+
{
|
|
2635
|
+
id: derivedId,
|
|
2636
|
+
title: derivedTitle,
|
|
2637
|
+
content: markdown,
|
|
2638
|
+
type: "document",
|
|
2639
|
+
tags: ["annotations"]
|
|
2640
|
+
},
|
|
2641
|
+
userEmail
|
|
2642
|
+
);
|
|
2643
|
+
} else {
|
|
2644
|
+
throw err;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
console.warn(
|
|
2649
|
+
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
2650
|
+
err
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// src/annotations/types.ts
|
|
2656
|
+
var ARTIFACT_NODE_TYPE = "artifact";
|
|
2657
|
+
|
|
2658
|
+
// src/annotations/routes.ts
|
|
2659
|
+
var annotationRoutes = new Hono5();
|
|
2660
|
+
function getNodeId(c) {
|
|
2661
|
+
const raw = c.req.param("nodeId");
|
|
2662
|
+
try {
|
|
2663
|
+
return decodeURIComponent(raw);
|
|
2664
|
+
} catch {
|
|
2665
|
+
return raw;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
annotationRoutes.get("/:nodeId{.+}/annotations", async (c) => {
|
|
2669
|
+
const nestId = c.req.param("nestId");
|
|
2670
|
+
const nodeId = getNodeId(c);
|
|
2671
|
+
const userId = c.get("userId");
|
|
2672
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2673
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2674
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2675
|
+
}
|
|
2676
|
+
return c.json({ threads: listThreads(nestId, nodeId) });
|
|
2677
|
+
});
|
|
2678
|
+
annotationRoutes.post("/:nodeId{.+}/annotations", async (c) => {
|
|
2679
|
+
const nestId = c.req.param("nestId");
|
|
2680
|
+
const nodeId = getNodeId(c);
|
|
2681
|
+
const userId = c.get("userId");
|
|
2682
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2683
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2684
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2685
|
+
}
|
|
2686
|
+
const input = await c.req.json();
|
|
2687
|
+
if (!input.body || !input.body.trim()) {
|
|
2688
|
+
throw new ValidationError("body is required");
|
|
2689
|
+
}
|
|
2690
|
+
const thread = createThread(
|
|
2691
|
+
nestId,
|
|
2692
|
+
nodeId,
|
|
2693
|
+
{
|
|
2694
|
+
body: input.body,
|
|
2695
|
+
anchor: input.anchor ?? null,
|
|
2696
|
+
snapshotVersion: input.snapshotVersion ?? null
|
|
2697
|
+
},
|
|
2698
|
+
userEmail
|
|
2699
|
+
);
|
|
2700
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2701
|
+
return c.json({ thread }, 201);
|
|
2702
|
+
});
|
|
2703
|
+
annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/comments", async (c) => {
|
|
2704
|
+
const nestId = c.req.param("nestId");
|
|
2705
|
+
const nodeId = getNodeId(c);
|
|
2706
|
+
const threadId = c.req.param("threadId");
|
|
2707
|
+
const userId = c.get("userId");
|
|
2708
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2709
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2710
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2711
|
+
}
|
|
2712
|
+
const input = await c.req.json();
|
|
2713
|
+
if (!input.body || !input.body.trim()) {
|
|
2714
|
+
throw new ValidationError("body is required");
|
|
2715
|
+
}
|
|
2716
|
+
const thread = addComment(nestId, nodeId, threadId, userEmail, input.body);
|
|
2717
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2718
|
+
return c.json({ thread });
|
|
2719
|
+
});
|
|
2720
|
+
annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/resolve", async (c) => {
|
|
2721
|
+
const nestId = c.req.param("nestId");
|
|
2722
|
+
const nodeId = getNodeId(c);
|
|
2723
|
+
const threadId = c.req.param("threadId");
|
|
2724
|
+
const userId = c.get("userId");
|
|
2725
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2726
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2727
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2728
|
+
}
|
|
2729
|
+
const thread = setThreadStatus(nestId, nodeId, threadId, "resolved", userEmail);
|
|
2730
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2731
|
+
return c.json({ thread });
|
|
2732
|
+
});
|
|
2733
|
+
annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/reopen", async (c) => {
|
|
2734
|
+
const nestId = c.req.param("nestId");
|
|
2735
|
+
const nodeId = getNodeId(c);
|
|
2736
|
+
const threadId = c.req.param("threadId");
|
|
2737
|
+
const userId = c.get("userId");
|
|
2738
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2739
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2740
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2741
|
+
}
|
|
2742
|
+
const thread = setThreadStatus(nestId, nodeId, threadId, "open", userEmail);
|
|
2743
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2744
|
+
return c.json({ thread });
|
|
2745
|
+
});
|
|
2746
|
+
annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
|
|
2747
|
+
const nestId = c.req.param("nestId");
|
|
2748
|
+
const nodeId = getNodeId(c);
|
|
2749
|
+
const userId = c.get("userId");
|
|
2750
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2751
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2752
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2753
|
+
}
|
|
2754
|
+
const vRaw = c.req.query("v");
|
|
2755
|
+
const vNum = vRaw !== void 0 ? Number(vRaw) : NaN;
|
|
2756
|
+
const version = Number.isFinite(vNum) && vNum > 0 ? Math.floor(vNum) : void 0;
|
|
2757
|
+
const { html, version: served, type } = await readArtifact(
|
|
2758
|
+
nestId,
|
|
2759
|
+
nodeId,
|
|
2760
|
+
version
|
|
2761
|
+
);
|
|
2762
|
+
if (type !== null && type !== ARTIFACT_NODE_TYPE) {
|
|
2763
|
+
return c.json({ error: "Not a hosted artifact" }, 404);
|
|
2764
|
+
}
|
|
2765
|
+
c.header(
|
|
2766
|
+
"Content-Security-Policy",
|
|
2767
|
+
[
|
|
2768
|
+
"sandbox allow-scripts allow-popups allow-modals",
|
|
2769
|
+
"default-src 'none'",
|
|
2770
|
+
"script-src 'unsafe-inline'",
|
|
2771
|
+
"style-src 'unsafe-inline'",
|
|
2772
|
+
"img-src data: blob:",
|
|
2773
|
+
"font-src data:",
|
|
2774
|
+
"connect-src 'none'",
|
|
2775
|
+
"base-uri 'none'",
|
|
2776
|
+
"form-action 'none'"
|
|
2777
|
+
].join("; ")
|
|
2778
|
+
);
|
|
2779
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
2780
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
2781
|
+
if (served != null) c.header("X-Artifact-Snapshot", String(served));
|
|
2782
|
+
return c.html(html);
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
// src/nodes/query-routes.ts
|
|
2786
|
+
import { Hono as Hono6 } from "hono";
|
|
2032
2787
|
import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
|
|
2033
2788
|
|
|
2034
2789
|
// src/nodes/prompt-compiler.ts
|
|
@@ -2246,8 +3001,158 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
|
|
|
2246
3001
|
}
|
|
2247
3002
|
}
|
|
2248
3003
|
|
|
3004
|
+
// src/nodes/graph-service.ts
|
|
3005
|
+
var MAX_GRAPH_NODES = 150;
|
|
3006
|
+
var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
3007
|
+
function extractWikiTargets(body) {
|
|
3008
|
+
const out = [];
|
|
3009
|
+
if (!body) return out;
|
|
3010
|
+
WIKILINK_RE.lastIndex = 0;
|
|
3011
|
+
let m;
|
|
3012
|
+
while ((m = WIKILINK_RE.exec(body)) !== null) {
|
|
3013
|
+
const t = (m[1] || "").trim();
|
|
3014
|
+
if (t) out.push(t);
|
|
3015
|
+
}
|
|
3016
|
+
return out;
|
|
3017
|
+
}
|
|
3018
|
+
async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
3019
|
+
const { storage } = engineCache.get(nestId);
|
|
3020
|
+
const docs = await storage.discoverDocuments();
|
|
3021
|
+
const accessible = filterAccessible(nestId, userId, userEmail, docs);
|
|
3022
|
+
const idSet = new Set(accessible.map((d) => d.id));
|
|
3023
|
+
const titleToId = /* @__PURE__ */ new Map();
|
|
3024
|
+
for (const d of accessible) {
|
|
3025
|
+
const t = (d.frontmatter?.title || "").toLowerCase().trim();
|
|
3026
|
+
if (t && !titleToId.has(t)) titleToId.set(t, d.id);
|
|
3027
|
+
}
|
|
3028
|
+
const allNodes = accessible.map((d) => ({
|
|
3029
|
+
id: d.id,
|
|
3030
|
+
title: d.frontmatter?.title || d.id,
|
|
3031
|
+
type: d.frontmatter?.type || "document",
|
|
3032
|
+
tags: (d.frontmatter?.tags || []).map(normalizeTag)
|
|
3033
|
+
}));
|
|
3034
|
+
const allLinks = [];
|
|
3035
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3036
|
+
for (const d of accessible) {
|
|
3037
|
+
for (const raw of extractWikiTargets(d.body || "")) {
|
|
3038
|
+
const targetId = idSet.has(raw) ? raw : titleToId.get(raw.toLowerCase().trim());
|
|
3039
|
+
if (!targetId || targetId === d.id) continue;
|
|
3040
|
+
const key = `${d.id}->${targetId}`;
|
|
3041
|
+
if (seen.has(key)) continue;
|
|
3042
|
+
seen.add(key);
|
|
3043
|
+
allLinks.push({ source: d.id, target: targetId });
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
const tagCounts = /* @__PURE__ */ new Map();
|
|
3047
|
+
const tagToDocs = /* @__PURE__ */ new Map();
|
|
3048
|
+
for (const n of allNodes) {
|
|
3049
|
+
for (const t of n.tags) {
|
|
3050
|
+
tagCounts.set(t, (tagCounts.get(t) || 0) + 1);
|
|
3051
|
+
let s = tagToDocs.get(t);
|
|
3052
|
+
if (!s) tagToDocs.set(t, s = /* @__PURE__ */ new Set());
|
|
3053
|
+
s.add(n.id);
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
const tags = [...tagCounts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
3057
|
+
const exposeStewards = isStewardshipEnabled(nestId) && !isPublicReader(nestId, userId);
|
|
3058
|
+
const stewardRows = exposeStewards ? (await listStewards({ nestId })).filter(
|
|
3059
|
+
// Drop document-scoped rows whose node the caller can't see.
|
|
3060
|
+
(s) => s.scope !== "document" || idSet.has(s.nodePattern || "")
|
|
3061
|
+
) : [];
|
|
3062
|
+
const maskId = /* @__PURE__ */ new Map();
|
|
3063
|
+
if (!canSeeIdentities) {
|
|
3064
|
+
const emails = [...new Set(stewardRows.map((s) => s.userEmail))].sort();
|
|
3065
|
+
emails.forEach((e, i) => maskId.set(e, `s${i + 1}`));
|
|
3066
|
+
}
|
|
3067
|
+
const identity = (email) => {
|
|
3068
|
+
if (canSeeIdentities) return { id: email, label: email };
|
|
3069
|
+
const id = maskId.get(email);
|
|
3070
|
+
return { id, label: `Steward ${id.slice(1)}` };
|
|
3071
|
+
};
|
|
3072
|
+
const stewardRowsMapped = stewardRows.map((s) => {
|
|
3073
|
+
const { id, label } = identity(s.userEmail);
|
|
3074
|
+
return {
|
|
3075
|
+
id,
|
|
3076
|
+
label,
|
|
3077
|
+
role: s.role,
|
|
3078
|
+
scope: s.scope,
|
|
3079
|
+
target: s.scope === "document" ? s.nodePattern || "" : s.scope === "tag" ? normalizeTag(s.tagName || "") : "*"
|
|
3080
|
+
};
|
|
3081
|
+
});
|
|
3082
|
+
const stewardEdges = stewardRowsMapped.map((s) => ({
|
|
3083
|
+
steward: s.id,
|
|
3084
|
+
target: s.scope === "tag" ? `tag:${s.target}` : s.scope === "document" ? s.target : "*",
|
|
3085
|
+
scope: s.scope
|
|
3086
|
+
}));
|
|
3087
|
+
const seenSteward = /* @__PURE__ */ new Set();
|
|
3088
|
+
const stewards = stewardRowsMapped.filter(
|
|
3089
|
+
(s) => seenSteward.has(s.id) ? false : (seenSteward.add(s.id), true)
|
|
3090
|
+
);
|
|
3091
|
+
const linkedIds = /* @__PURE__ */ new Set();
|
|
3092
|
+
for (const l of allLinks) {
|
|
3093
|
+
linkedIds.add(l.source);
|
|
3094
|
+
linkedIds.add(l.target);
|
|
3095
|
+
}
|
|
3096
|
+
const orphans = allNodes.filter((n) => !linkedIds.has(n.id));
|
|
3097
|
+
const stewardedTags = new Set(
|
|
3098
|
+
stewardRows.filter((s) => s.scope === "tag").map((s) => normalizeTag(s.tagName || ""))
|
|
3099
|
+
);
|
|
3100
|
+
const unstewardedTags = tags.filter((t) => !stewardedTags.has(t.name));
|
|
3101
|
+
const coveredBySteward = /* @__PURE__ */ new Map();
|
|
3102
|
+
const labelById = /* @__PURE__ */ new Map();
|
|
3103
|
+
for (const s of stewardRowsMapped) {
|
|
3104
|
+
labelById.set(s.id, s.label);
|
|
3105
|
+
let cov = coveredBySteward.get(s.id);
|
|
3106
|
+
if (!cov) coveredBySteward.set(s.id, cov = /* @__PURE__ */ new Set());
|
|
3107
|
+
if (s.scope === "nest") for (const id of idSet) cov.add(id);
|
|
3108
|
+
else if (s.scope === "tag")
|
|
3109
|
+
for (const id of tagToDocs.get(s.target) || []) cov.add(id);
|
|
3110
|
+
else if (idSet.has(s.target)) cov.add(s.target);
|
|
3111
|
+
}
|
|
3112
|
+
const denom = idSet.size;
|
|
3113
|
+
const bottlenecks = denom === 0 ? [] : [...coveredBySteward.entries()].map(([steward, cov]) => ({
|
|
3114
|
+
steward,
|
|
3115
|
+
label: labelById.get(steward) || steward,
|
|
3116
|
+
share: Math.round(cov.size / denom * 100)
|
|
3117
|
+
})).filter((b) => b.share >= 50).sort((a, b) => b.share - a.share);
|
|
3118
|
+
const signals = { orphans, unstewardedTags, bottlenecks };
|
|
3119
|
+
const truncated = allNodes.length > MAX_GRAPH_NODES;
|
|
3120
|
+
let nodes = allNodes;
|
|
3121
|
+
let links = allLinks;
|
|
3122
|
+
if (truncated) {
|
|
3123
|
+
const degree = /* @__PURE__ */ new Map();
|
|
3124
|
+
for (const l of allLinks) {
|
|
3125
|
+
degree.set(l.source, (degree.get(l.source) || 0) + 1);
|
|
3126
|
+
degree.set(l.target, (degree.get(l.target) || 0) + 1);
|
|
3127
|
+
}
|
|
3128
|
+
nodes = [...allNodes].sort(
|
|
3129
|
+
(a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0) || (a.id < b.id ? -1 : 1)
|
|
3130
|
+
).slice(0, MAX_GRAPH_NODES);
|
|
3131
|
+
const kept = new Set(nodes.map((n) => n.id));
|
|
3132
|
+
links = allLinks.filter((l) => kept.has(l.source) && kept.has(l.target));
|
|
3133
|
+
}
|
|
3134
|
+
return {
|
|
3135
|
+
nodes,
|
|
3136
|
+
links,
|
|
3137
|
+
stewards,
|
|
3138
|
+
stewardEdges,
|
|
3139
|
+
tags,
|
|
3140
|
+
signals,
|
|
3141
|
+
truncated,
|
|
3142
|
+
totalNodes: allNodes.length
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
|
|
2249
3146
|
// src/nodes/query-routes.ts
|
|
2250
|
-
var queryRoutes = new
|
|
3147
|
+
var queryRoutes = new Hono6();
|
|
3148
|
+
queryRoutes.get("/graph", async (c) => {
|
|
3149
|
+
const nestId = c.req.param("nestId");
|
|
3150
|
+
const userId = c.get("userId");
|
|
3151
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3152
|
+
const canSeeIdentities = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
3153
|
+
const graph = await buildNestGraph(nestId, userId, userEmail, canSeeIdentities);
|
|
3154
|
+
return c.json(graph);
|
|
3155
|
+
});
|
|
2251
3156
|
function approxTokens(text) {
|
|
2252
3157
|
return Math.ceil(text.length / 4);
|
|
2253
3158
|
}
|
|
@@ -2566,7 +3471,7 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
2566
3471
|
});
|
|
2567
3472
|
|
|
2568
3473
|
// src/mcp/routes.ts
|
|
2569
|
-
import { Hono as
|
|
3474
|
+
import { Hono as Hono7 } from "hono";
|
|
2570
3475
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2571
3476
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2572
3477
|
|
|
@@ -2798,6 +3703,26 @@ var TOOL_DEFINITIONS = [
|
|
|
2798
3703
|
},
|
|
2799
3704
|
required: ["email"]
|
|
2800
3705
|
}
|
|
3706
|
+
},
|
|
3707
|
+
// ─── Unsynced Folder Tools ─────────────────────────────────────────
|
|
3708
|
+
{
|
|
3709
|
+
name: "context_unsynced_list",
|
|
3710
|
+
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.",
|
|
3711
|
+
inputSchema: { type: "object", properties: {} }
|
|
3712
|
+
},
|
|
3713
|
+
{
|
|
3714
|
+
name: "context_sync_folder",
|
|
3715
|
+
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.",
|
|
3716
|
+
inputSchema: {
|
|
3717
|
+
type: "object",
|
|
3718
|
+
properties: {
|
|
3719
|
+
name: {
|
|
3720
|
+
type: "string",
|
|
3721
|
+
description: "Folder path relative to the data root (e.g. 'nodes/architecture'), as returned by context_unsynced_list."
|
|
3722
|
+
}
|
|
3723
|
+
},
|
|
3724
|
+
required: ["name"]
|
|
3725
|
+
}
|
|
2801
3726
|
}
|
|
2802
3727
|
];
|
|
2803
3728
|
async function resolveLlmBody(ctx, node) {
|
|
@@ -2812,7 +3737,7 @@ async function resolveLlmBody(ctx, node) {
|
|
|
2812
3737
|
}
|
|
2813
3738
|
}
|
|
2814
3739
|
async function handleToolCall(toolName, args, ctx) {
|
|
2815
|
-
const { storage, queryEngine, versionManager, nestId, userEmail } = ctx;
|
|
3740
|
+
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
2816
3741
|
switch (toolName) {
|
|
2817
3742
|
case "context_init": {
|
|
2818
3743
|
const content = await storage.readContextMd();
|
|
@@ -3158,7 +4083,7 @@ ${list}`;
|
|
|
3158
4083
|
case "context_share_nest": {
|
|
3159
4084
|
const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
|
|
3160
4085
|
if (!canManageWith(roles)) {
|
|
3161
|
-
return "You don't have permission to share this nest.
|
|
4086
|
+
return "You don't have permission to share this nest via this tool (admin only). You can still invite from the UI with write access, or ask a nest admin.";
|
|
3162
4087
|
}
|
|
3163
4088
|
const permission = args.permission || "read";
|
|
3164
4089
|
try {
|
|
@@ -3174,6 +4099,34 @@ ${list}`;
|
|
|
3174
4099
|
return `Failed to share nest: ${err.message}`;
|
|
3175
4100
|
}
|
|
3176
4101
|
}
|
|
4102
|
+
case "context_unsynced_list": {
|
|
4103
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
4104
|
+
return "You don't have permission to list unsynced folders. Server admin only.";
|
|
4105
|
+
}
|
|
4106
|
+
const folders = listUnsyncedFolders();
|
|
4107
|
+
if (!folders.length) return "No unsynced folders found.";
|
|
4108
|
+
const list = folders.map(
|
|
4109
|
+
(f, i) => `${i + 1}. **${f.label}** \`${f.name}\` \u2014 ${f.mdCount} md file(s), ${f.sizeBytes} bytes`
|
|
4110
|
+
).join("\n");
|
|
4111
|
+
return `# Unsynced Folders (${folders.length})
|
|
4112
|
+
|
|
4113
|
+
${list}`;
|
|
4114
|
+
}
|
|
4115
|
+
case "context_sync_folder": {
|
|
4116
|
+
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
4117
|
+
return "You don't have permission to sync folders. Server admin only.";
|
|
4118
|
+
}
|
|
4119
|
+
try {
|
|
4120
|
+
const { nest, documents } = await syncUnsyncedFolder(
|
|
4121
|
+
userId,
|
|
4122
|
+
args.name,
|
|
4123
|
+
userEmail
|
|
4124
|
+
);
|
|
4125
|
+
return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
|
|
4126
|
+
} catch (err) {
|
|
4127
|
+
return `Failed to sync folder: ${err.message}`;
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
3177
4130
|
default:
|
|
3178
4131
|
return `Unknown tool: ${toolName}`;
|
|
3179
4132
|
}
|
|
@@ -3181,13 +4134,13 @@ ${list}`;
|
|
|
3181
4134
|
|
|
3182
4135
|
// src/mcp/routes.ts
|
|
3183
4136
|
import { z } from "zod";
|
|
3184
|
-
var mcpRoutes = new
|
|
4137
|
+
var mcpRoutes = new Hono7();
|
|
3185
4138
|
function getUserEmail2(userId) {
|
|
3186
4139
|
const db = getDb();
|
|
3187
4140
|
const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
3188
4141
|
return user?.email || "anonymous@localhost";
|
|
3189
4142
|
}
|
|
3190
|
-
function createMcpServerForNest(nestId, userEmail) {
|
|
4143
|
+
function createMcpServerForNest(nestId, userId, userEmail) {
|
|
3191
4144
|
const server = new McpServer(
|
|
3192
4145
|
{ name: `contextnest-${nestId}`, version: "1.0.0" },
|
|
3193
4146
|
{ capabilities: { tools: {} } }
|
|
@@ -3212,6 +4165,7 @@ function createMcpServerForNest(nestId, userEmail) {
|
|
|
3212
4165
|
queryEngine: engine.query,
|
|
3213
4166
|
versionManager: engine.versions,
|
|
3214
4167
|
nestId,
|
|
4168
|
+
userId,
|
|
3215
4169
|
userEmail
|
|
3216
4170
|
});
|
|
3217
4171
|
return { content: [{ type: "text", text }] };
|
|
@@ -3223,7 +4177,7 @@ mcpRoutes.all("/", async (c) => {
|
|
|
3223
4177
|
const nestId = c.req.param("nestId");
|
|
3224
4178
|
const userId = c.get("userId");
|
|
3225
4179
|
const userEmail = getUserEmail2(userId);
|
|
3226
|
-
const server = createMcpServerForNest(nestId, userEmail);
|
|
4180
|
+
const server = createMcpServerForNest(nestId, userId, userEmail);
|
|
3227
4181
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
3228
4182
|
sessionIdGenerator: void 0,
|
|
3229
4183
|
enableJsonResponse: true
|
|
@@ -3239,11 +4193,218 @@ mcpRoutes.all("/", async (c) => {
|
|
|
3239
4193
|
});
|
|
3240
4194
|
|
|
3241
4195
|
// src/governance/routes.ts
|
|
3242
|
-
import { Hono as
|
|
4196
|
+
import { Hono as Hono8 } from "hono";
|
|
4197
|
+
|
|
4198
|
+
// src/governance/comment-service.ts
|
|
4199
|
+
import { v4 as uuid4 } from "uuid";
|
|
4200
|
+
function createComment(params) {
|
|
4201
|
+
const db = getDb();
|
|
4202
|
+
const body = (params.body ?? "").trim();
|
|
4203
|
+
if (!body) {
|
|
4204
|
+
throw new Error("Comment body is required");
|
|
4205
|
+
}
|
|
4206
|
+
if (params.parentId) {
|
|
4207
|
+
const parent = db.prepare(
|
|
4208
|
+
"SELECT id FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?"
|
|
4209
|
+
).get(params.parentId, params.nestId, params.nodeId);
|
|
4210
|
+
if (!parent) {
|
|
4211
|
+
throw new Error("Parent comment not found on this node");
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
const id = uuid4();
|
|
4215
|
+
db.prepare(
|
|
4216
|
+
`INSERT INTO comments
|
|
4217
|
+
(id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
|
|
4218
|
+
parent_id, author, body)
|
|
4219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
4220
|
+
).run(
|
|
4221
|
+
id,
|
|
4222
|
+
params.nestId,
|
|
4223
|
+
params.nodeId,
|
|
4224
|
+
params.version ?? null,
|
|
4225
|
+
params.anchor?.start ?? null,
|
|
4226
|
+
params.anchor?.end ?? null,
|
|
4227
|
+
params.anchor?.text ?? null,
|
|
4228
|
+
params.parentId ?? null,
|
|
4229
|
+
params.author,
|
|
4230
|
+
body
|
|
4231
|
+
);
|
|
4232
|
+
return getComment(id);
|
|
4233
|
+
}
|
|
4234
|
+
function listComments(nestId, nodeId, opts = {}) {
|
|
4235
|
+
const db = getDb();
|
|
4236
|
+
const args = [nestId, nodeId];
|
|
4237
|
+
let statusClause = "";
|
|
4238
|
+
if (opts.status) {
|
|
4239
|
+
statusClause = " AND status = ?";
|
|
4240
|
+
args.push(opts.status);
|
|
4241
|
+
}
|
|
4242
|
+
const rows = db.prepare(
|
|
4243
|
+
`SELECT * FROM comments
|
|
4244
|
+
WHERE nest_id = ? AND node_id = ?${statusClause}
|
|
4245
|
+
ORDER BY created_at ASC`
|
|
4246
|
+
).all(...args);
|
|
4247
|
+
return rows.map(rowToComment);
|
|
4248
|
+
}
|
|
4249
|
+
function getComment(id) {
|
|
4250
|
+
const db = getDb();
|
|
4251
|
+
const row = db.prepare("SELECT * FROM comments WHERE id = ?").get(id);
|
|
4252
|
+
return row ? rowToComment(row) : null;
|
|
4253
|
+
}
|
|
4254
|
+
function resolveComment(params) {
|
|
4255
|
+
const db = getDb();
|
|
4256
|
+
const existing = db.prepare(
|
|
4257
|
+
"SELECT id, status FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?"
|
|
4258
|
+
).get(params.commentId, params.nestId, params.nodeId);
|
|
4259
|
+
if (!existing) {
|
|
4260
|
+
throw new Error("Comment not found");
|
|
4261
|
+
}
|
|
4262
|
+
if (existing.status === "resolved") {
|
|
4263
|
+
throw new Error("Comment is already resolved");
|
|
4264
|
+
}
|
|
4265
|
+
db.prepare(
|
|
4266
|
+
`UPDATE comments
|
|
4267
|
+
SET status = 'resolved', resolved_by = ?, resolved_at = datetime('now')
|
|
4268
|
+
WHERE id = ?`
|
|
4269
|
+
).run(params.resolvedBy, params.commentId);
|
|
4270
|
+
return getComment(params.commentId);
|
|
4271
|
+
}
|
|
4272
|
+
async function getActivity(params) {
|
|
4273
|
+
const db = getDb();
|
|
4274
|
+
const limit = Number.isFinite(params.limit) && params.limit > 0 ? Math.min(params.limit, 1e3) : 100;
|
|
4275
|
+
const entries = [];
|
|
4276
|
+
const nodeFilter = params.nodeId ? " AND node_id = ?" : "";
|
|
4277
|
+
const baseArgs = params.nodeId ? [params.nestId, params.nodeId] : [params.nestId];
|
|
4278
|
+
const commentRows = db.prepare(
|
|
4279
|
+
`SELECT id, node_id, author, body, status, created_at, resolved_by, resolved_at
|
|
4280
|
+
FROM comments
|
|
4281
|
+
WHERE nest_id = ?${nodeFilter}
|
|
4282
|
+
ORDER BY COALESCE(resolved_at, created_at) DESC
|
|
4283
|
+
LIMIT ?`
|
|
4284
|
+
).all(...baseArgs, limit);
|
|
4285
|
+
for (const r of commentRows) {
|
|
4286
|
+
entries.push({
|
|
4287
|
+
type: "comment",
|
|
4288
|
+
nodeId: r.node_id,
|
|
4289
|
+
actor: r.author,
|
|
4290
|
+
at: r.created_at,
|
|
4291
|
+
detail: excerpt(r.body),
|
|
4292
|
+
refId: r.id
|
|
4293
|
+
});
|
|
4294
|
+
if (r.status === "resolved" && r.resolved_by && r.resolved_at) {
|
|
4295
|
+
entries.push({
|
|
4296
|
+
type: "comment_resolved",
|
|
4297
|
+
nodeId: r.node_id,
|
|
4298
|
+
actor: r.resolved_by,
|
|
4299
|
+
at: r.resolved_at,
|
|
4300
|
+
detail: excerpt(r.body),
|
|
4301
|
+
refId: r.id
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
const versionRows = db.prepare(
|
|
4306
|
+
`SELECT node_id, version, author, change_note, created_at
|
|
4307
|
+
FROM node_versions
|
|
4308
|
+
WHERE nest_id = ?${nodeFilter}
|
|
4309
|
+
ORDER BY created_at DESC
|
|
4310
|
+
LIMIT ?`
|
|
4311
|
+
).all(...baseArgs, limit);
|
|
4312
|
+
for (const r of versionRows) {
|
|
4313
|
+
entries.push({
|
|
4314
|
+
type: "edit",
|
|
4315
|
+
nodeId: r.node_id,
|
|
4316
|
+
actor: r.author,
|
|
4317
|
+
at: r.created_at,
|
|
4318
|
+
detail: r.change_note || `v${r.version}`,
|
|
4319
|
+
refId: String(r.version)
|
|
4320
|
+
});
|
|
4321
|
+
}
|
|
4322
|
+
const reviewRows = db.prepare(
|
|
4323
|
+
`SELECT id, node_id, requested_by, requested_at, status, resolved_by, resolved_at
|
|
4324
|
+
FROM review_requests
|
|
4325
|
+
WHERE nest_id = ?${nodeFilter}
|
|
4326
|
+
ORDER BY COALESCE(resolved_at, requested_at) DESC
|
|
4327
|
+
LIMIT ?`
|
|
4328
|
+
).all(...baseArgs, limit);
|
|
4329
|
+
for (const r of reviewRows) {
|
|
4330
|
+
entries.push({
|
|
4331
|
+
type: "review_requested",
|
|
4332
|
+
nodeId: r.node_id,
|
|
4333
|
+
actor: r.requested_by,
|
|
4334
|
+
at: r.requested_at,
|
|
4335
|
+
detail: "requested review",
|
|
4336
|
+
refId: r.id
|
|
4337
|
+
});
|
|
4338
|
+
if (r.resolved_by && r.resolved_at) {
|
|
4339
|
+
entries.push({
|
|
4340
|
+
type: "review_resolved",
|
|
4341
|
+
nodeId: r.node_id,
|
|
4342
|
+
actor: r.resolved_by,
|
|
4343
|
+
at: r.resolved_at,
|
|
4344
|
+
detail: r.status,
|
|
4345
|
+
// approved | rejected | cancelled
|
|
4346
|
+
refId: r.id
|
|
4347
|
+
});
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
try {
|
|
4351
|
+
const pending = await listNestExternalEdits(params.nestId);
|
|
4352
|
+
for (const e of pending) {
|
|
4353
|
+
if (params.nodeId && e.document_id !== params.nodeId) continue;
|
|
4354
|
+
entries.push({
|
|
4355
|
+
type: "edit_proposed",
|
|
4356
|
+
nodeId: e.document_id,
|
|
4357
|
+
actor: e.actor,
|
|
4358
|
+
at: e.detected_at,
|
|
4359
|
+
detail: e.note || `proposed an edit (${e.source})`,
|
|
4360
|
+
refId: e.suggestion_id
|
|
4361
|
+
});
|
|
4362
|
+
}
|
|
4363
|
+
} catch (err) {
|
|
4364
|
+
console.error(
|
|
4365
|
+
`[comments] failed to load pending external edits for activity feed (nest ${params.nestId}):`,
|
|
4366
|
+
err
|
|
4367
|
+
);
|
|
4368
|
+
}
|
|
4369
|
+
const ms = (at) => {
|
|
4370
|
+
if (!at) return 0;
|
|
4371
|
+
const norm = at.includes("T") ? at : at.replace(" ", "T") + "Z";
|
|
4372
|
+
const t = Date.parse(norm);
|
|
4373
|
+
return Number.isNaN(t) ? 0 : t;
|
|
4374
|
+
};
|
|
4375
|
+
entries.sort((a, b) => ms(b.at) - ms(a.at));
|
|
4376
|
+
return entries.slice(0, limit);
|
|
4377
|
+
}
|
|
4378
|
+
function excerpt(body, max = 80) {
|
|
4379
|
+
const s = (body || "").replace(/\s+/g, " ").trim();
|
|
4380
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
4381
|
+
}
|
|
4382
|
+
function rowToComment(row) {
|
|
4383
|
+
const hasOffsets = row.anchor_start !== null && row.anchor_end !== null;
|
|
4384
|
+
const hasText = row.anchor_text !== null && row.anchor_text !== void 0;
|
|
4385
|
+
const anchor = hasOffsets || hasText ? {
|
|
4386
|
+
...hasOffsets ? { start: row.anchor_start, end: row.anchor_end } : {},
|
|
4387
|
+
...hasText ? { text: row.anchor_text } : {}
|
|
4388
|
+
} : void 0;
|
|
4389
|
+
return {
|
|
4390
|
+
id: row.id,
|
|
4391
|
+
nestId: row.nest_id,
|
|
4392
|
+
nodeId: row.node_id,
|
|
4393
|
+
version: row.version ?? void 0,
|
|
4394
|
+
anchor,
|
|
4395
|
+
parentId: row.parent_id ?? void 0,
|
|
4396
|
+
author: row.author,
|
|
4397
|
+
body: row.body,
|
|
4398
|
+
status: row.status,
|
|
4399
|
+
createdAt: row.created_at,
|
|
4400
|
+
resolvedBy: row.resolved_by ?? void 0,
|
|
4401
|
+
resolvedAt: row.resolved_at ?? void 0
|
|
4402
|
+
};
|
|
4403
|
+
}
|
|
3243
4404
|
|
|
3244
4405
|
// src/governance/stewards-parser.ts
|
|
3245
|
-
import { readFileSync, existsSync } from "fs";
|
|
3246
|
-
import { join as
|
|
4406
|
+
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
4407
|
+
import { join as join3 } from "path";
|
|
3247
4408
|
function parseStewardsYaml(content) {
|
|
3248
4409
|
const result = { version: 1 };
|
|
3249
4410
|
const lines = content.split("\n");
|
|
@@ -3308,15 +4469,15 @@ function parseEntry(str) {
|
|
|
3308
4469
|
}
|
|
3309
4470
|
function loadStewardsConfig(nestId) {
|
|
3310
4471
|
const dataRoot = config.DATA_ROOT;
|
|
3311
|
-
const nestPath =
|
|
4472
|
+
const nestPath = join3(dataRoot, "nests", nestId);
|
|
3312
4473
|
const candidates = [
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
4474
|
+
join3(nestPath, "stewards.yaml"),
|
|
4475
|
+
join3(nestPath, "stewards.yml"),
|
|
4476
|
+
join3(nestPath, ".context", "stewards.yaml")
|
|
3316
4477
|
];
|
|
3317
4478
|
for (const candidatePath of candidates) {
|
|
3318
4479
|
if (existsSync(candidatePath)) {
|
|
3319
|
-
const content =
|
|
4480
|
+
const content = readFileSync2(candidatePath, "utf-8");
|
|
3320
4481
|
return parseStewardsYaml(content);
|
|
3321
4482
|
}
|
|
3322
4483
|
}
|
|
@@ -3324,7 +4485,12 @@ function loadStewardsConfig(nestId) {
|
|
|
3324
4485
|
}
|
|
3325
4486
|
|
|
3326
4487
|
// src/governance/routes.ts
|
|
3327
|
-
|
|
4488
|
+
function parseLimit(raw, def = 100, max = 1e3) {
|
|
4489
|
+
const n = parseInt(raw ?? "", 10);
|
|
4490
|
+
if (Number.isNaN(n) || n < 1) return def;
|
|
4491
|
+
return Math.min(n, max);
|
|
4492
|
+
}
|
|
4493
|
+
var governanceRoutes = new Hono8();
|
|
3328
4494
|
governanceRoutes.get("/stewards", async (c) => {
|
|
3329
4495
|
const nestId = c.req.param("nestId");
|
|
3330
4496
|
const scope = c.req.query("scope");
|
|
@@ -3427,7 +4593,17 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
3427
4593
|
limit,
|
|
3428
4594
|
offset
|
|
3429
4595
|
});
|
|
3430
|
-
|
|
4596
|
+
const email = getUserEmail3(c);
|
|
4597
|
+
const canReviewCache = /* @__PURE__ */ new Map();
|
|
4598
|
+
const requests = result.requests.map((r) => {
|
|
4599
|
+
let canReview = canReviewCache.get(r.nodeId);
|
|
4600
|
+
if (canReview === void 0) {
|
|
4601
|
+
canReview = canUserApprove(nestId, r.nodeId, email).allowed;
|
|
4602
|
+
canReviewCache.set(r.nodeId, canReview);
|
|
4603
|
+
}
|
|
4604
|
+
return { ...r, canReview };
|
|
4605
|
+
});
|
|
4606
|
+
return c.json({ ...result, requests });
|
|
3431
4607
|
});
|
|
3432
4608
|
governanceRoutes.get("/external-edits", async (c) => {
|
|
3433
4609
|
const nestId = c.req.param("nestId");
|
|
@@ -3444,7 +4620,13 @@ governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
|
3444
4620
|
const result = await scanNestForDrift(nestId, actor);
|
|
3445
4621
|
return c.json(result);
|
|
3446
4622
|
});
|
|
3447
|
-
|
|
4623
|
+
governanceRoutes.get("/activity", async (c) => {
|
|
4624
|
+
const nestId = c.req.param("nestId");
|
|
4625
|
+
const limit = parseLimit(c.req.query("limit"));
|
|
4626
|
+
const activity = await getActivity({ nestId, limit });
|
|
4627
|
+
return c.json({ activity });
|
|
4628
|
+
});
|
|
4629
|
+
var governanceNodeRoutes = new Hono8();
|
|
3448
4630
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
3449
4631
|
const nestId = c.req.param("nestId");
|
|
3450
4632
|
const nodeId = c.req.param("nodeId");
|
|
@@ -3490,6 +4672,68 @@ governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
|
3490
4672
|
const history = getReviewHistory(nestId, nodeId);
|
|
3491
4673
|
return c.json({ reviews: history });
|
|
3492
4674
|
});
|
|
4675
|
+
governanceNodeRoutes.get("/:nodeId{.+}/comments", async (c) => {
|
|
4676
|
+
const nestId = c.req.param("nestId");
|
|
4677
|
+
const nodeId = c.req.param("nodeId");
|
|
4678
|
+
const status = c.req.query("status");
|
|
4679
|
+
const list = listComments(nestId, nodeId, {
|
|
4680
|
+
status: status === "open" || status === "resolved" ? status : void 0
|
|
4681
|
+
});
|
|
4682
|
+
return c.json({ comments: list });
|
|
4683
|
+
});
|
|
4684
|
+
governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
4685
|
+
const nestId = c.req.param("nestId");
|
|
4686
|
+
const nodeId = c.req.param("nodeId");
|
|
4687
|
+
const body = await c.req.json();
|
|
4688
|
+
const author = getUserEmail3(c);
|
|
4689
|
+
try {
|
|
4690
|
+
const comment = createComment({
|
|
4691
|
+
nestId,
|
|
4692
|
+
nodeId,
|
|
4693
|
+
author,
|
|
4694
|
+
body: body.body ?? "",
|
|
4695
|
+
version: body.version,
|
|
4696
|
+
anchor: body.anchor,
|
|
4697
|
+
parentId: body.parentId
|
|
4698
|
+
});
|
|
4699
|
+
return c.json({ comment }, 201);
|
|
4700
|
+
} catch (err) {
|
|
4701
|
+
throw new ValidationError(
|
|
4702
|
+
err instanceof Error ? err.message : String(err)
|
|
4703
|
+
);
|
|
4704
|
+
}
|
|
4705
|
+
});
|
|
4706
|
+
governanceNodeRoutes.post(
|
|
4707
|
+
"/:nodeId{.+?}/comments/:commentId/resolve",
|
|
4708
|
+
async (c) => {
|
|
4709
|
+
const nestId = c.req.param("nestId");
|
|
4710
|
+
const nodeId = c.req.param("nodeId");
|
|
4711
|
+
const commentId = c.req.param("commentId");
|
|
4712
|
+
const resolvedBy = getUserEmail3(c);
|
|
4713
|
+
try {
|
|
4714
|
+
const comment = resolveComment({
|
|
4715
|
+
nestId,
|
|
4716
|
+
nodeId,
|
|
4717
|
+
commentId,
|
|
4718
|
+
resolvedBy
|
|
4719
|
+
});
|
|
4720
|
+
return c.json({ comment });
|
|
4721
|
+
} catch (err) {
|
|
4722
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4723
|
+
if (msg === "Comment not found") return c.json({ error: msg }, 404);
|
|
4724
|
+
if (msg === "Comment is already resolved")
|
|
4725
|
+
return c.json({ error: msg }, 409);
|
|
4726
|
+
throw err;
|
|
4727
|
+
}
|
|
4728
|
+
}
|
|
4729
|
+
);
|
|
4730
|
+
governanceNodeRoutes.get("/:nodeId{.+}/activity", async (c) => {
|
|
4731
|
+
const nestId = c.req.param("nestId");
|
|
4732
|
+
const nodeId = c.req.param("nodeId");
|
|
4733
|
+
const limit = parseLimit(c.req.query("limit"));
|
|
4734
|
+
const activity = await getActivity({ nestId, nodeId, limit });
|
|
4735
|
+
return c.json({ activity });
|
|
4736
|
+
});
|
|
3493
4737
|
governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
3494
4738
|
const nestId = c.req.param("nestId");
|
|
3495
4739
|
const nodeId = c.req.param("nodeId");
|
|
@@ -3707,15 +4951,15 @@ function ensureAnonymousUser() {
|
|
|
3707
4951
|
// src/app.ts
|
|
3708
4952
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3709
4953
|
import { fileURLToPath } from "url";
|
|
3710
|
-
import { dirname, join as
|
|
4954
|
+
import { dirname, join as join4, relative as relative2 } from "path";
|
|
3711
4955
|
import { existsSync as existsSync2 } from "fs";
|
|
3712
4956
|
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
3713
4957
|
var UI_DIR_CANDIDATES = [
|
|
3714
|
-
|
|
3715
|
-
|
|
4958
|
+
join4(HERE, "web3"),
|
|
4959
|
+
join4(process.cwd(), "dist", "web3")
|
|
3716
4960
|
];
|
|
3717
4961
|
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
3718
|
-
var UI_DIR_REL =
|
|
4962
|
+
var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
|
|
3719
4963
|
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
3720
4964
|
const anonId = ensureAnonymousUser();
|
|
3721
4965
|
c.set("userId", anonId);
|
|
@@ -3748,7 +4992,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
3748
4992
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
3749
4993
|
});
|
|
3750
4994
|
function createApp() {
|
|
3751
|
-
const app = new
|
|
4995
|
+
const app = new Hono9();
|
|
3752
4996
|
const corsOrigins = config.CORS_ORIGINS;
|
|
3753
4997
|
app.use(
|
|
3754
4998
|
"*",
|
|
@@ -3847,7 +5091,14 @@ function createApp() {
|
|
|
3847
5091
|
valid: true,
|
|
3848
5092
|
tier: info.tier,
|
|
3849
5093
|
org: info.org,
|
|
3850
|
-
limits: info.limits
|
|
5094
|
+
limits: info.limits,
|
|
5095
|
+
// The key validated and is live now, but if it couldn't be written to
|
|
5096
|
+
// disk it'll be lost on restart — surface that instead of pretending
|
|
5097
|
+
// the setup is durable (the old behavior silently swallowed this).
|
|
5098
|
+
persisted: info.persisted,
|
|
5099
|
+
...info.persisted ? {} : {
|
|
5100
|
+
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.`
|
|
5101
|
+
}
|
|
3851
5102
|
});
|
|
3852
5103
|
} catch (err) {
|
|
3853
5104
|
const msg = err instanceof Error ? err.message : "Failed to install key";
|
|
@@ -3876,12 +5127,13 @@ function createApp() {
|
|
|
3876
5127
|
users: usersRow.c
|
|
3877
5128
|
});
|
|
3878
5129
|
});
|
|
3879
|
-
const nestsApp = new
|
|
5130
|
+
const nestsApp = new Hono9();
|
|
3880
5131
|
nestsApp.use("*", flexAuthMiddleware);
|
|
3881
5132
|
nestsApp.use("*", async (c, next) => {
|
|
3882
5133
|
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
3883
5134
|
const parts = localPath.split("/").filter(Boolean);
|
|
3884
5135
|
if (parts.length < 2) return next();
|
|
5136
|
+
if (parts[0] === "unsynced") return next();
|
|
3885
5137
|
const nestId = parts[0];
|
|
3886
5138
|
const userId = c.get("userId");
|
|
3887
5139
|
const nestScope = c.get("nestScope");
|
|
@@ -3900,7 +5152,9 @@ function createApp() {
|
|
|
3900
5152
|
}
|
|
3901
5153
|
{
|
|
3902
5154
|
const path2 = c.req.path;
|
|
3903
|
-
const
|
|
5155
|
+
const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
|
|
5156
|
+
const isActivityPath = /\/activity$/.test(path2);
|
|
5157
|
+
const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit") || isCommentPath || isActivityPath;
|
|
3904
5158
|
const needsLicense = c.req.method !== "GET" || isGovernance;
|
|
3905
5159
|
if (needsLicense) {
|
|
3906
5160
|
const lic = getCurrentLicense();
|
|
@@ -3927,6 +5181,8 @@ function createApp() {
|
|
|
3927
5181
|
let required = "read";
|
|
3928
5182
|
const path = c.req.path;
|
|
3929
5183
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
5184
|
+
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
5185
|
+
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
3930
5186
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
3931
5187
|
if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
|
|
3932
5188
|
return c.json(
|
|
@@ -3936,9 +5192,12 @@ function createApp() {
|
|
|
3936
5192
|
403
|
|
3937
5193
|
);
|
|
3938
5194
|
}
|
|
3939
|
-
|
|
5195
|
+
const resource = parts[1];
|
|
5196
|
+
if (resource === "visibility") {
|
|
3940
5197
|
required = "admin";
|
|
3941
|
-
} else if (
|
|
5198
|
+
} else if (resource === "collaborators") {
|
|
5199
|
+
required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
|
|
5200
|
+
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction) {
|
|
3942
5201
|
required = "write";
|
|
3943
5202
|
}
|
|
3944
5203
|
const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
|
|
@@ -3980,6 +5239,7 @@ function createApp() {
|
|
|
3980
5239
|
nestsApp.route("/", nestRoutes);
|
|
3981
5240
|
nestsApp.route("/:nestId", governanceRoutes);
|
|
3982
5241
|
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5242
|
+
nestsApp.route("/:nestId/nodes", annotationRoutes);
|
|
3983
5243
|
nestsApp.route("/:nestId/nodes", nodeRoutes);
|
|
3984
5244
|
nestsApp.route("/:nestId", queryRoutes);
|
|
3985
5245
|
nestsApp.route("/:nestId", sharingRoutes);
|
|
@@ -4065,7 +5325,7 @@ function createApp() {
|
|
|
4065
5325
|
|
|
4066
5326
|
// src/db/backfill.ts
|
|
4067
5327
|
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
4068
|
-
import { join as
|
|
5328
|
+
import { join as join5 } from "path";
|
|
4069
5329
|
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
4070
5330
|
async function backfillNodeVersionsFromHistory(db) {
|
|
4071
5331
|
const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
|
|
@@ -4086,7 +5346,7 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
4086
5346
|
let totalInserted = 0;
|
|
4087
5347
|
let totalDocs = 0;
|
|
4088
5348
|
for (const { id: nestId } of nests) {
|
|
4089
|
-
const nestPath =
|
|
5349
|
+
const nestPath = join5(config.DATA_ROOT, "nests", nestId);
|
|
4090
5350
|
const storage = new NestStorage(nestPath);
|
|
4091
5351
|
let docs;
|
|
4092
5352
|
try {
|
|
@@ -4184,6 +5444,13 @@ async function main() {
|
|
|
4184
5444
|
or set PROMPTOWL_KEY=pk_... in your environment and restart.
|
|
4185
5445
|
`);
|
|
4186
5446
|
}
|
|
5447
|
+
if (config.OFFICIAL_COMMUNITY_SSO_SECRET && !config.PUBLIC_BASE_URL) {
|
|
5448
|
+
console.warn(`
|
|
5449
|
+
WARNING: OFFICIAL_COMMUNITY_SSO_SECRET is set but PUBLIC_BASE_URL is not.
|
|
5450
|
+
One-click SSO will skip the audience check, weakening cross-server replay
|
|
5451
|
+
protection. Set PUBLIC_BASE_URL to this server's public URL.
|
|
5452
|
+
`);
|
|
5453
|
+
}
|
|
4187
5454
|
const app = createApp();
|
|
4188
5455
|
startLicenseSafetyPoll();
|
|
4189
5456
|
const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
|