@promptowl/contextnest-community 1.3.0 → 1.5.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 +2 -0
- package/dist/{chunk-SO74PQWI.js → chunk-EMOE53KX.js} +2 -1
- package/dist/{chunk-6AXBB65N.js → chunk-HIH7I232.js} +1 -1
- package/dist/{chunk-EDJRDWPL.js → chunk-IWA2UDAT.js} +3 -3
- package/dist/{chunk-UEHFNBNR.js → chunk-RMU3LOPH.js} +99 -0
- package/dist/index.js +1143 -87
- package/dist/{review-service-QU7Q7XX2.js → review-service-XNXHF6Q7.js} +4 -4
- package/dist/{stewardship-service-ZUSHJCNR.js → stewardship-service-P4M5UEJW.js} +2 -2
- package/dist/{version-service-624QTR5K.js → version-service-REGL5CUT.js} +2 -2
- package/dist/web3/assets/index-C2dfT3Et.css +1 -0
- package/dist/web3/assets/index-DEKnE-ty.js +887 -0
- package/dist/web3/index.html +2 -2
- package/package.json +3 -1
- package/dist/web3/assets/index-DJH4nUEV.js +0 -776
- package/dist/web3/assets/index-uR0ua3Ak.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-IWA2UDAT.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,
|
|
@@ -80,20 +80,21 @@ import {
|
|
|
80
80
|
trackEvent,
|
|
81
81
|
uniqueNestName,
|
|
82
82
|
updateSteward,
|
|
83
|
+
upsertEnvVar,
|
|
83
84
|
validateLicense
|
|
84
|
-
} from "./chunk-
|
|
85
|
+
} from "./chunk-EMOE53KX.js";
|
|
85
86
|
import {
|
|
86
87
|
ANON_EMAIL,
|
|
87
88
|
ANON_USER_ID,
|
|
88
89
|
config,
|
|
89
90
|
getDb
|
|
90
|
-
} from "./chunk-
|
|
91
|
+
} from "./chunk-RMU3LOPH.js";
|
|
91
92
|
|
|
92
93
|
// src/index.ts
|
|
93
94
|
import { serve } from "@hono/node-server";
|
|
94
95
|
|
|
95
96
|
// src/app.ts
|
|
96
|
-
import { Hono as
|
|
97
|
+
import { Hono as Hono9 } from "hono";
|
|
97
98
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
98
99
|
import { cors } from "hono/cors";
|
|
99
100
|
|
|
@@ -249,11 +250,71 @@ function clear(key) {
|
|
|
249
250
|
buckets.delete(key);
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
// src/auth/sso-token.ts
|
|
254
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
255
|
+
function b64urlToBuffer(input) {
|
|
256
|
+
const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
|
|
257
|
+
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
258
|
+
}
|
|
259
|
+
var SsoTokenError = class extends Error {
|
|
260
|
+
};
|
|
261
|
+
function verifySsoTicket(token, secret, nowSec = Math.floor(Date.now() / 1e3)) {
|
|
262
|
+
if (!secret) throw new SsoTokenError("SSO secret not configured");
|
|
263
|
+
if (!token || typeof token !== "string") {
|
|
264
|
+
throw new SsoTokenError("Missing ticket");
|
|
265
|
+
}
|
|
266
|
+
const parts = token.split(".");
|
|
267
|
+
if (parts.length !== 3) throw new SsoTokenError("Malformed ticket");
|
|
268
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
269
|
+
let header;
|
|
270
|
+
try {
|
|
271
|
+
header = JSON.parse(b64urlToBuffer(headerB64).toString("utf8"));
|
|
272
|
+
} catch {
|
|
273
|
+
throw new SsoTokenError("Invalid ticket header");
|
|
274
|
+
}
|
|
275
|
+
if (header.alg !== "HS256") {
|
|
276
|
+
throw new SsoTokenError(`Unsupported alg: ${header.alg}`);
|
|
277
|
+
}
|
|
278
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
279
|
+
const expected = createHmac("sha256", secret).update(signingInput).digest();
|
|
280
|
+
const provided = b64urlToBuffer(sigB64);
|
|
281
|
+
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
|
|
282
|
+
throw new SsoTokenError("Bad signature");
|
|
283
|
+
}
|
|
284
|
+
let claims;
|
|
285
|
+
try {
|
|
286
|
+
claims = JSON.parse(b64urlToBuffer(payloadB64).toString("utf8"));
|
|
287
|
+
} catch {
|
|
288
|
+
throw new SsoTokenError("Invalid ticket payload");
|
|
289
|
+
}
|
|
290
|
+
const LEEWAY = 30;
|
|
291
|
+
if (typeof claims.exp !== "number") {
|
|
292
|
+
throw new SsoTokenError("Ticket missing exp");
|
|
293
|
+
}
|
|
294
|
+
if (nowSec > claims.exp + LEEWAY) {
|
|
295
|
+
throw new SsoTokenError("Ticket expired");
|
|
296
|
+
}
|
|
297
|
+
if (!claims.sub) throw new SsoTokenError("Ticket missing sub");
|
|
298
|
+
return claims;
|
|
299
|
+
}
|
|
300
|
+
|
|
252
301
|
// src/auth/routes.ts
|
|
253
302
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
254
303
|
var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
|
|
255
304
|
var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
|
|
256
305
|
var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
|
|
306
|
+
var warnedMissingSsoAudience = false;
|
|
307
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
308
|
+
function assertValidPassword(password, label = "password") {
|
|
309
|
+
if (password.trim().length === 0) {
|
|
310
|
+
throw new ValidationError(`${label} cannot be empty or only whitespace`);
|
|
311
|
+
}
|
|
312
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
313
|
+
throw new ValidationError(
|
|
314
|
+
`${label} must be at least ${MIN_PASSWORD_LENGTH} characters`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
257
318
|
function clientIp(c) {
|
|
258
319
|
const xff = c.req.header("x-forwarded-for");
|
|
259
320
|
if (xff) return xff.split(",")[0].trim();
|
|
@@ -301,12 +362,61 @@ function clearSessionCookie(c) {
|
|
|
301
362
|
buildClearSessionCookie({ secure: isSecureRequest(c) })
|
|
302
363
|
);
|
|
303
364
|
}
|
|
365
|
+
async function provisionPromptowlUser(c, rawEmail, rawName) {
|
|
366
|
+
const gate = config.PROMPTOWL_SIGN_IN_GATE;
|
|
367
|
+
if (gate !== "open") {
|
|
368
|
+
if (gate === "disabled" || !isLicenseAdminEmail(rawEmail)) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
status: 403,
|
|
372
|
+
error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
|
|
373
|
+
gate
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const db = getDb();
|
|
378
|
+
const meEmail = normalizeEmail(rawEmail);
|
|
379
|
+
let user = db.prepare("SELECT id, email, name FROM users WHERE LOWER(email) = ?").get(meEmail);
|
|
380
|
+
if (!user) {
|
|
381
|
+
const userId = uuid();
|
|
382
|
+
const placeholderHash = await hashPassword(uuid());
|
|
383
|
+
db.prepare(
|
|
384
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
385
|
+
).run(userId, meEmail, rawName || null, placeholderHash);
|
|
386
|
+
user = { id: userId, email: meEmail, name: rawName || null };
|
|
387
|
+
trackEvent("user.register", {
|
|
388
|
+
userId,
|
|
389
|
+
email: rawEmail,
|
|
390
|
+
method: "promptowl"
|
|
391
|
+
});
|
|
392
|
+
} else {
|
|
393
|
+
trackEvent("user.login", { userId: user.id, method: "promptowl" });
|
|
394
|
+
}
|
|
395
|
+
let claimBlocked = null;
|
|
396
|
+
const lic = getCurrentLicense();
|
|
397
|
+
let isAdmin = false;
|
|
398
|
+
if (!lic?.valid) {
|
|
399
|
+
claimBlocked = { reason: "license_required" };
|
|
400
|
+
} else if (lic.ownerEmail && lic.ownerEmail.toLowerCase() === rawEmail.toLowerCase()) {
|
|
401
|
+
isAdmin = true;
|
|
402
|
+
trackEvent("admin.claim", { userId: user.id, email: user.email });
|
|
403
|
+
} else {
|
|
404
|
+
claimBlocked = {
|
|
405
|
+
reason: "email_mismatch",
|
|
406
|
+
license_owner_email: lic.ownerEmail
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
410
|
+
setSessionCookie(c, sessionId);
|
|
411
|
+
return { ok: true, user, isAdmin, claimBlocked };
|
|
412
|
+
}
|
|
304
413
|
var authRoutes = new Hono();
|
|
305
414
|
authRoutes.post("/register", async (c) => {
|
|
306
415
|
const body = await c.req.json();
|
|
307
416
|
if (!body.email || !body.password) {
|
|
308
417
|
throw new ValidationError("email and password are required");
|
|
309
418
|
}
|
|
419
|
+
assertValidPassword(body.password);
|
|
310
420
|
const email = normalizeEmail(body.email);
|
|
311
421
|
const ip = clientIp(c);
|
|
312
422
|
if (!tryConsume(`register:ip:${ip}`, REGISTER_LIMIT)) {
|
|
@@ -533,62 +643,76 @@ authRoutes.post("/promptowl", async (c) => {
|
|
|
533
643
|
401
|
|
534
644
|
);
|
|
535
645
|
}
|
|
536
|
-
const
|
|
537
|
-
if (
|
|
538
|
-
|
|
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
|
-
}
|
|
548
|
-
const db = getDb();
|
|
549
|
-
const meEmail = normalizeEmail(me.email);
|
|
550
|
-
let user = db.prepare("SELECT id, email, name FROM users WHERE LOWER(email) = ?").get(meEmail);
|
|
551
|
-
if (!user) {
|
|
552
|
-
const userId = uuid();
|
|
553
|
-
const placeholderHash = await hashPassword(uuid());
|
|
554
|
-
db.prepare(
|
|
555
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
556
|
-
).run(userId, meEmail, me.name || null, placeholderHash);
|
|
557
|
-
user = { id: userId, email: meEmail, name: me.name || null };
|
|
558
|
-
trackEvent("user.register", {
|
|
559
|
-
userId,
|
|
560
|
-
email: me.email,
|
|
561
|
-
method: "promptowl"
|
|
562
|
-
});
|
|
563
|
-
} else {
|
|
564
|
-
trackEvent("user.login", { userId: user.id, method: "promptowl" });
|
|
565
|
-
}
|
|
566
|
-
let claimBlocked = null;
|
|
567
|
-
const lic = getCurrentLicense();
|
|
568
|
-
let isAdmin = false;
|
|
569
|
-
if (!lic?.valid) {
|
|
570
|
-
claimBlocked = { reason: "license_required" };
|
|
571
|
-
} else if (lic.ownerEmail && lic.ownerEmail.toLowerCase() === me.email.toLowerCase()) {
|
|
572
|
-
isAdmin = true;
|
|
573
|
-
trackEvent("admin.claim", { userId: user.id, email: user.email });
|
|
574
|
-
} else {
|
|
575
|
-
claimBlocked = {
|
|
576
|
-
reason: "email_mismatch",
|
|
577
|
-
license_owner_email: lic.ownerEmail
|
|
578
|
-
};
|
|
646
|
+
const result = await provisionPromptowlUser(c, me.email, me.name);
|
|
647
|
+
if (!result.ok) {
|
|
648
|
+
return c.json({ error: result.error, gate: result.gate }, result.status);
|
|
579
649
|
}
|
|
580
|
-
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
581
|
-
setSessionCookie(c, sessionId);
|
|
582
650
|
return c.json({
|
|
583
651
|
user: {
|
|
584
|
-
id: user.id,
|
|
585
|
-
email: user.email,
|
|
586
|
-
name: user.name,
|
|
587
|
-
is_admin: isAdmin
|
|
652
|
+
id: result.user.id,
|
|
653
|
+
email: result.user.email,
|
|
654
|
+
name: result.user.name,
|
|
655
|
+
is_admin: result.isAdmin
|
|
588
656
|
},
|
|
589
|
-
...claimBlocked ? { claim_blocked: claimBlocked } : {}
|
|
657
|
+
...result.claimBlocked ? { claim_blocked: result.claimBlocked } : {}
|
|
590
658
|
});
|
|
591
659
|
});
|
|
660
|
+
authRoutes.get("/sso", async (c) => {
|
|
661
|
+
const ssoError = (code) => c.redirect(`/?sso_error=${code}`, 302);
|
|
662
|
+
const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
|
|
663
|
+
if (!secret) return ssoError("not_supported");
|
|
664
|
+
if (!tryConsume(`sso:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
|
|
665
|
+
return ssoError("rate_limited");
|
|
666
|
+
}
|
|
667
|
+
const ticket = c.req.query("ticket");
|
|
668
|
+
if (!ticket) return ssoError("missing_ticket");
|
|
669
|
+
let claims;
|
|
670
|
+
try {
|
|
671
|
+
claims = verifySsoTicket(ticket, secret);
|
|
672
|
+
} catch {
|
|
673
|
+
return ssoError("invalid_ticket");
|
|
674
|
+
}
|
|
675
|
+
const expectedAud = config.PUBLIC_BASE_URL;
|
|
676
|
+
if (!expectedAud && !warnedMissingSsoAudience) {
|
|
677
|
+
warnedMissingSsoAudience = true;
|
|
678
|
+
console.warn(
|
|
679
|
+
"[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."
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
if (expectedAud) {
|
|
683
|
+
const aud = (claims.aud || "").replace(/\/$/, "");
|
|
684
|
+
if (aud !== expectedAud) return ssoError("bad_audience");
|
|
685
|
+
}
|
|
686
|
+
const sub = (claims.sub || "").trim();
|
|
687
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) || sub.length > 254) {
|
|
688
|
+
return ssoError("invalid_ticket");
|
|
689
|
+
}
|
|
690
|
+
if (!claims.jti) return ssoError("invalid_ticket");
|
|
691
|
+
const db = getDb();
|
|
692
|
+
db.prepare("DELETE FROM sso_used_jti WHERE expires_at < datetime('now')").run();
|
|
693
|
+
const expiresAtIso = new Date((claims.exp ?? 0) * 1e3).toISOString();
|
|
694
|
+
try {
|
|
695
|
+
db.prepare(
|
|
696
|
+
"INSERT INTO sso_used_jti (jti, expires_at) VALUES (?, ?)"
|
|
697
|
+
).run(claims.jti, expiresAtIso);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
700
|
+
return ssoError("ticket_used");
|
|
701
|
+
}
|
|
702
|
+
console.error("[sso] failed to record ticket jti:", err);
|
|
703
|
+
return ssoError("service_error");
|
|
704
|
+
}
|
|
705
|
+
let result;
|
|
706
|
+
try {
|
|
707
|
+
result = await provisionPromptowlUser(c, sub, claims.name);
|
|
708
|
+
} catch (err) {
|
|
709
|
+
db.prepare("DELETE FROM sso_used_jti WHERE jti = ?").run(claims.jti);
|
|
710
|
+
console.error("[sso] provisioning failed; released jti for retry:", err);
|
|
711
|
+
return ssoError("service_error");
|
|
712
|
+
}
|
|
713
|
+
if (!result.ok) return ssoError("sign_in_restricted");
|
|
714
|
+
return c.redirect("/", 302);
|
|
715
|
+
});
|
|
592
716
|
authRoutes.get("/admin-status", async (c) => {
|
|
593
717
|
const db = getDb();
|
|
594
718
|
const lic = getCurrentLicense();
|
|
@@ -621,9 +745,7 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
621
745
|
if (!body.current || !body.next) {
|
|
622
746
|
throw new ValidationError("current and next are required");
|
|
623
747
|
}
|
|
624
|
-
|
|
625
|
-
throw new ValidationError("new password must be at least 8 characters");
|
|
626
|
-
}
|
|
748
|
+
assertValidPassword(body.next, "new password");
|
|
627
749
|
const db = getDb();
|
|
628
750
|
const userId = c.get("userId");
|
|
629
751
|
const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
|
@@ -668,8 +790,8 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
|
|
|
668
790
|
} catch {
|
|
669
791
|
supplied = void 0;
|
|
670
792
|
}
|
|
671
|
-
if (supplied
|
|
672
|
-
|
|
793
|
+
if (supplied !== void 0) {
|
|
794
|
+
assertValidPassword(supplied);
|
|
673
795
|
}
|
|
674
796
|
const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
|
|
675
797
|
const newPassword = supplied ?? generated;
|
|
@@ -1079,7 +1201,7 @@ async function approveExternalEdit(input) {
|
|
|
1079
1201
|
const node = await storage.readDocument(input.documentId);
|
|
1080
1202
|
const versionNum = result.versionEntry.version;
|
|
1081
1203
|
const tags = node.frontmatter.tags || [];
|
|
1082
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1204
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-REGL5CUT.js");
|
|
1083
1205
|
createVersion2({
|
|
1084
1206
|
nestId: input.nestId,
|
|
1085
1207
|
nodeId: input.documentId,
|
|
@@ -1235,7 +1357,7 @@ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
|
1235
1357
|
async function createNode(nestId, input, userEmail) {
|
|
1236
1358
|
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1237
1359
|
const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1238
|
-
const id = `nodes/${slug}`;
|
|
1360
|
+
const id = input.id ?? `nodes/${slug}`;
|
|
1239
1361
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1240
1362
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
1241
1363
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
@@ -1830,6 +1952,11 @@ async function addCollaborator(params) {
|
|
|
1830
1952
|
if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
|
|
1831
1953
|
throw new ValidationError("permission must be read, write, or admin");
|
|
1832
1954
|
}
|
|
1955
|
+
if (params.callerPermission && permissionLevel(params.permission) > permissionLevel(params.callerPermission)) {
|
|
1956
|
+
throw new ForbiddenError(
|
|
1957
|
+
`You can only grant access up to your own level ('${params.callerPermission}'). Ask a nest admin to grant '${params.permission}'.`
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1833
1960
|
const db = getDb();
|
|
1834
1961
|
let userId = params.userId;
|
|
1835
1962
|
if (!userId && params.email) {
|
|
@@ -1905,7 +2032,10 @@ sharingRoutes.post("/collaborators", async (c) => {
|
|
|
1905
2032
|
email: body.email,
|
|
1906
2033
|
userId: body.user_id,
|
|
1907
2034
|
permission: body.permission ?? "",
|
|
1908
|
-
grantedByUserId: c.get("userId")
|
|
2035
|
+
grantedByUserId: c.get("userId"),
|
|
2036
|
+
// Enforce the escalation cap against the caller's own nest permission
|
|
2037
|
+
// (set by the access guard) — a write collaborator can't grant admin.
|
|
2038
|
+
callerPermission: c.get("nestPermission")
|
|
1909
2039
|
});
|
|
1910
2040
|
return c.json({ collaborator: collab }, 201);
|
|
1911
2041
|
});
|
|
@@ -1915,17 +2045,22 @@ sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
|
1915
2045
|
throw new ValidationError("permission must be read, write, or admin");
|
|
1916
2046
|
}
|
|
1917
2047
|
const db = getDb();
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
)
|
|
2048
|
+
const info = db.prepare(
|
|
2049
|
+
"UPDATE nest_collaborators SET permission = ? WHERE id = ? AND nest_id = ?"
|
|
2050
|
+
).run(body.permission, c.req.param("collabId"), c.req.param("nestId"));
|
|
2051
|
+
if (info.changes === 0) {
|
|
2052
|
+
throw new NotFoundError("Collaborator not found");
|
|
2053
|
+
}
|
|
1922
2054
|
return c.json({ updated: true });
|
|
1923
2055
|
});
|
|
1924
2056
|
sharingRoutes.delete("/collaborators/:collabId", async (c) => {
|
|
1925
2057
|
const db = getDb();
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
);
|
|
2058
|
+
const info = db.prepare(
|
|
2059
|
+
"DELETE FROM nest_collaborators WHERE id = ? AND nest_id = ?"
|
|
2060
|
+
).run(c.req.param("collabId"), c.req.param("nestId"));
|
|
2061
|
+
if (info.changes === 0) {
|
|
2062
|
+
throw new NotFoundError("Collaborator not found");
|
|
2063
|
+
}
|
|
1929
2064
|
return c.json({ removed: true });
|
|
1930
2065
|
});
|
|
1931
2066
|
sharingRoutes.patch("/visibility", async (c) => {
|
|
@@ -2013,7 +2148,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
2013
2148
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
2014
2149
|
const nestId = c.req.param("nestId");
|
|
2015
2150
|
const nodeId = c.req.param("nodeId");
|
|
2016
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
2151
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-P4M5UEJW.js");
|
|
2017
2152
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
2018
2153
|
nestId,
|
|
2019
2154
|
nodeId
|
|
@@ -2034,7 +2169,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
2034
2169
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
2035
2170
|
const nestId = c.req.param("nestId");
|
|
2036
2171
|
const nodeId = c.req.param("nodeId");
|
|
2037
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
2172
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-REGL5CUT.js");
|
|
2038
2173
|
const allVersions = getVersions2(nestId, nodeId);
|
|
2039
2174
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
2040
2175
|
const db = getDb();
|
|
@@ -2065,7 +2200,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
2065
2200
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
2066
2201
|
const nestId = c.req.param("nestId");
|
|
2067
2202
|
const nodeId = c.req.param("nodeId");
|
|
2068
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
2203
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-XNXHF6Q7.js");
|
|
2069
2204
|
const history = getReviewHistory2(nestId, nodeId);
|
|
2070
2205
|
return c.json({ reviews: history });
|
|
2071
2206
|
});
|
|
@@ -2259,8 +2394,407 @@ function getUserEmail(c) {
|
|
|
2259
2394
|
return user?.email || "anonymous@localhost";
|
|
2260
2395
|
}
|
|
2261
2396
|
|
|
2262
|
-
// src/
|
|
2397
|
+
// src/annotations/routes.ts
|
|
2263
2398
|
import { Hono as Hono5 } from "hono";
|
|
2399
|
+
|
|
2400
|
+
// src/annotations/service.ts
|
|
2401
|
+
import { v4 as uuid3 } from "uuid";
|
|
2402
|
+
|
|
2403
|
+
// src/annotations/projection.ts
|
|
2404
|
+
var MAX_CONTEXT_CHARS = 250;
|
|
2405
|
+
var MAX_QUOTE_CHARS = 1e3;
|
|
2406
|
+
function clampAnchor(raw) {
|
|
2407
|
+
if (!raw) return null;
|
|
2408
|
+
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
2409
|
+
if (!quote) return null;
|
|
2410
|
+
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
2411
|
+
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
2412
|
+
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
2413
|
+
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
2414
|
+
}
|
|
2415
|
+
function oneLine(s) {
|
|
2416
|
+
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
2417
|
+
}
|
|
2418
|
+
function renderAnchor(anchor) {
|
|
2419
|
+
if (!anchor) {
|
|
2420
|
+
return "<!-- anchor: whole-artifact -->";
|
|
2421
|
+
}
|
|
2422
|
+
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
2423
|
+
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
2424
|
+
anchor.after
|
|
2425
|
+
)}\u2026`;
|
|
2426
|
+
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
2427
|
+
}
|
|
2428
|
+
function statusHeading(thread) {
|
|
2429
|
+
if (thread.status === "resolved") {
|
|
2430
|
+
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
2431
|
+
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
2432
|
+
const meta = who || when ? ` (${who}${when})` : "";
|
|
2433
|
+
return `\u2705 RESOLVED${meta}`;
|
|
2434
|
+
}
|
|
2435
|
+
return "\u{1F7E0} OPEN";
|
|
2436
|
+
}
|
|
2437
|
+
function snapshotLabel(v) {
|
|
2438
|
+
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
2439
|
+
}
|
|
2440
|
+
function orderThreads(threads) {
|
|
2441
|
+
return [...threads].sort((a, b) => {
|
|
2442
|
+
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
2443
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
2447
|
+
const lines = [];
|
|
2448
|
+
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
2449
|
+
lines.push("");
|
|
2450
|
+
lines.push(
|
|
2451
|
+
"> 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)."
|
|
2452
|
+
);
|
|
2453
|
+
lines.push("");
|
|
2454
|
+
if (threads.length === 0) {
|
|
2455
|
+
lines.push("_No annotations yet._");
|
|
2456
|
+
lines.push("");
|
|
2457
|
+
return lines.join("\n");
|
|
2458
|
+
}
|
|
2459
|
+
const buckets2 = /* @__PURE__ */ new Map();
|
|
2460
|
+
for (const t of threads) {
|
|
2461
|
+
const key = t.snapshotVersion ?? null;
|
|
2462
|
+
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
2463
|
+
}
|
|
2464
|
+
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
2465
|
+
if (a == null) return 1;
|
|
2466
|
+
if (b == null) return -1;
|
|
2467
|
+
return b - a;
|
|
2468
|
+
});
|
|
2469
|
+
for (const key of keys) {
|
|
2470
|
+
lines.push(`## ${snapshotLabel(key)}`);
|
|
2471
|
+
lines.push("");
|
|
2472
|
+
for (const thread of orderThreads(buckets2.get(key))) {
|
|
2473
|
+
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
2474
|
+
lines.push(renderAnchor(thread.anchor));
|
|
2475
|
+
for (const comment of thread.comments) {
|
|
2476
|
+
lines.push(
|
|
2477
|
+
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
lines.push("");
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
return lines.join("\n");
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// src/annotations/service.ts
|
|
2487
|
+
function loadComments(threadId) {
|
|
2488
|
+
const db = getDb();
|
|
2489
|
+
const rows = db.prepare(
|
|
2490
|
+
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, rowid ASC"
|
|
2491
|
+
).all(threadId);
|
|
2492
|
+
return rows.map((r) => ({
|
|
2493
|
+
id: r.id,
|
|
2494
|
+
author: r.author,
|
|
2495
|
+
body: r.body,
|
|
2496
|
+
createdAt: r.created_at
|
|
2497
|
+
}));
|
|
2498
|
+
}
|
|
2499
|
+
function rowToThread(row) {
|
|
2500
|
+
let anchor = null;
|
|
2501
|
+
if (row.anchor_json) {
|
|
2502
|
+
try {
|
|
2503
|
+
anchor = JSON.parse(row.anchor_json);
|
|
2504
|
+
} catch {
|
|
2505
|
+
anchor = null;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
return {
|
|
2509
|
+
id: row.id,
|
|
2510
|
+
nestId: row.nest_id,
|
|
2511
|
+
nodeId: row.node_id,
|
|
2512
|
+
snapshotVersion: row.snapshot_version,
|
|
2513
|
+
anchor,
|
|
2514
|
+
status: row.status,
|
|
2515
|
+
createdBy: row.created_by,
|
|
2516
|
+
createdAt: row.created_at,
|
|
2517
|
+
resolvedBy: row.resolved_by,
|
|
2518
|
+
resolvedAt: row.resolved_at,
|
|
2519
|
+
comments: loadComments(row.id)
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
function getThreadRow(threadId) {
|
|
2523
|
+
return getDb().prepare("SELECT * FROM annotation_threads WHERE id = ?").get(threadId);
|
|
2524
|
+
}
|
|
2525
|
+
function listThreads(nestId, nodeId) {
|
|
2526
|
+
const rows = getDb().prepare(
|
|
2527
|
+
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, rowid ASC"
|
|
2528
|
+
).all(nestId, nodeId);
|
|
2529
|
+
return rows.map(rowToThread);
|
|
2530
|
+
}
|
|
2531
|
+
function createThread(nestId, nodeId, input, authorEmail) {
|
|
2532
|
+
const body = (input.body ?? "").trim();
|
|
2533
|
+
if (!body) {
|
|
2534
|
+
throw new Error("comment body is required");
|
|
2535
|
+
}
|
|
2536
|
+
const db = getDb();
|
|
2537
|
+
const id = uuid3();
|
|
2538
|
+
const anchor = clampAnchor(input.anchor);
|
|
2539
|
+
const snapshot = input.snapshotVersion ?? getApprovedVersion(nestId, nodeId) ?? null;
|
|
2540
|
+
const tx = db.transaction(() => {
|
|
2541
|
+
db.prepare(
|
|
2542
|
+
`INSERT INTO annotation_threads
|
|
2543
|
+
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
2544
|
+
VALUES (?, ?, ?, ?, ?, 'open', ?)`
|
|
2545
|
+
).run(
|
|
2546
|
+
id,
|
|
2547
|
+
nestId,
|
|
2548
|
+
nodeId,
|
|
2549
|
+
snapshot,
|
|
2550
|
+
anchor ? JSON.stringify(anchor) : null,
|
|
2551
|
+
authorEmail
|
|
2552
|
+
);
|
|
2553
|
+
db.prepare(
|
|
2554
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)"
|
|
2555
|
+
).run(uuid3(), id, authorEmail, body);
|
|
2556
|
+
});
|
|
2557
|
+
tx();
|
|
2558
|
+
return rowToThread(getThreadRow(id));
|
|
2559
|
+
}
|
|
2560
|
+
function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
2561
|
+
const row = getThreadRow(threadId);
|
|
2562
|
+
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
2563
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
2564
|
+
}
|
|
2565
|
+
return row;
|
|
2566
|
+
}
|
|
2567
|
+
function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
2568
|
+
const trimmed = (body ?? "").trim();
|
|
2569
|
+
if (!trimmed) {
|
|
2570
|
+
throw new Error("comment body is required");
|
|
2571
|
+
}
|
|
2572
|
+
getScopedThreadRow(threadId, nestId, nodeId);
|
|
2573
|
+
getDb().prepare(
|
|
2574
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)"
|
|
2575
|
+
).run(uuid3(), threadId, authorEmail, trimmed);
|
|
2576
|
+
return rowToThread(getThreadRow(threadId));
|
|
2577
|
+
}
|
|
2578
|
+
function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
2579
|
+
getScopedThreadRow(threadId, nestId, nodeId);
|
|
2580
|
+
if (status === "resolved") {
|
|
2581
|
+
getDb().prepare(
|
|
2582
|
+
"UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = datetime('now') WHERE id = ?"
|
|
2583
|
+
).run(byEmail, threadId);
|
|
2584
|
+
} else {
|
|
2585
|
+
getDb().prepare(
|
|
2586
|
+
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?"
|
|
2587
|
+
).run(threadId);
|
|
2588
|
+
}
|
|
2589
|
+
return rowToThread(getThreadRow(threadId));
|
|
2590
|
+
}
|
|
2591
|
+
async function readArtifact(nestId, nodeId, version) {
|
|
2592
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
2593
|
+
const target = version ?? getApprovedVersion(nestId, nodeId) ?? null;
|
|
2594
|
+
let title = nodeId;
|
|
2595
|
+
let type = null;
|
|
2596
|
+
let liveHtml = "";
|
|
2597
|
+
let liveExists = true;
|
|
2598
|
+
try {
|
|
2599
|
+
const node = await storage.readDocument(nodeId);
|
|
2600
|
+
title = node.frontmatter?.title || nodeId;
|
|
2601
|
+
type = node.frontmatter?.type || null;
|
|
2602
|
+
liveHtml = node.body || "";
|
|
2603
|
+
} catch {
|
|
2604
|
+
liveExists = false;
|
|
2605
|
+
}
|
|
2606
|
+
if (target != null) {
|
|
2607
|
+
try {
|
|
2608
|
+
const raw = await versionManager.reconstructVersion(nodeId, target);
|
|
2609
|
+
return { title, html: bodyOnly(nodeId, raw), version: target, type };
|
|
2610
|
+
} catch (err) {
|
|
2611
|
+
console.warn(
|
|
2612
|
+
`[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
|
|
2613
|
+
err
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
if (!liveExists) {
|
|
2618
|
+
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
2619
|
+
}
|
|
2620
|
+
return { title, html: liveHtml, version: target, type };
|
|
2621
|
+
}
|
|
2622
|
+
function derivedAnnotationsId(sourceNodeId) {
|
|
2623
|
+
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
2624
|
+
return `nodes/${base}--annotations`;
|
|
2625
|
+
}
|
|
2626
|
+
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
2627
|
+
try {
|
|
2628
|
+
let title = nodeId;
|
|
2629
|
+
try {
|
|
2630
|
+
const { storage } = engineCache.get(nestId);
|
|
2631
|
+
const node = await storage.readDocument(nodeId);
|
|
2632
|
+
title = node.frontmatter?.title || nodeId;
|
|
2633
|
+
} catch {
|
|
2634
|
+
}
|
|
2635
|
+
const threads = listThreads(nestId, nodeId);
|
|
2636
|
+
const derivedTitle = `${title} \u2014 Annotations`;
|
|
2637
|
+
const markdown = projectThreadsToMarkdown(title, threads);
|
|
2638
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
2639
|
+
try {
|
|
2640
|
+
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
2641
|
+
} catch (err) {
|
|
2642
|
+
if (err instanceof NotFoundError) {
|
|
2643
|
+
await createNode(
|
|
2644
|
+
nestId,
|
|
2645
|
+
{
|
|
2646
|
+
id: derivedId,
|
|
2647
|
+
title: derivedTitle,
|
|
2648
|
+
content: markdown,
|
|
2649
|
+
type: "document",
|
|
2650
|
+
tags: ["annotations"]
|
|
2651
|
+
},
|
|
2652
|
+
userEmail
|
|
2653
|
+
);
|
|
2654
|
+
} else {
|
|
2655
|
+
throw err;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
} catch (err) {
|
|
2659
|
+
console.warn(
|
|
2660
|
+
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
2661
|
+
err
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
// src/annotations/types.ts
|
|
2667
|
+
var ARTIFACT_NODE_TYPE = "artifact";
|
|
2668
|
+
|
|
2669
|
+
// src/annotations/routes.ts
|
|
2670
|
+
var annotationRoutes = new Hono5();
|
|
2671
|
+
function getNodeId(c) {
|
|
2672
|
+
const raw = c.req.param("nodeId");
|
|
2673
|
+
try {
|
|
2674
|
+
return decodeURIComponent(raw);
|
|
2675
|
+
} catch {
|
|
2676
|
+
return raw;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
annotationRoutes.get("/:nodeId{.+}/annotations", async (c) => {
|
|
2680
|
+
const nestId = c.req.param("nestId");
|
|
2681
|
+
const nodeId = getNodeId(c);
|
|
2682
|
+
const userId = c.get("userId");
|
|
2683
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2684
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2685
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2686
|
+
}
|
|
2687
|
+
return c.json({ threads: listThreads(nestId, nodeId) });
|
|
2688
|
+
});
|
|
2689
|
+
annotationRoutes.post("/:nodeId{.+}/annotations", async (c) => {
|
|
2690
|
+
const nestId = c.req.param("nestId");
|
|
2691
|
+
const nodeId = getNodeId(c);
|
|
2692
|
+
const userId = c.get("userId");
|
|
2693
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2694
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2695
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2696
|
+
}
|
|
2697
|
+
const input = await c.req.json();
|
|
2698
|
+
if (!input.body || !input.body.trim()) {
|
|
2699
|
+
throw new ValidationError("body is required");
|
|
2700
|
+
}
|
|
2701
|
+
const thread = createThread(
|
|
2702
|
+
nestId,
|
|
2703
|
+
nodeId,
|
|
2704
|
+
{
|
|
2705
|
+
body: input.body,
|
|
2706
|
+
anchor: input.anchor ?? null,
|
|
2707
|
+
snapshotVersion: input.snapshotVersion ?? null
|
|
2708
|
+
},
|
|
2709
|
+
userEmail
|
|
2710
|
+
);
|
|
2711
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2712
|
+
return c.json({ thread }, 201);
|
|
2713
|
+
});
|
|
2714
|
+
annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/comments", async (c) => {
|
|
2715
|
+
const nestId = c.req.param("nestId");
|
|
2716
|
+
const nodeId = getNodeId(c);
|
|
2717
|
+
const threadId = c.req.param("threadId");
|
|
2718
|
+
const userId = c.get("userId");
|
|
2719
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2720
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2721
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2722
|
+
}
|
|
2723
|
+
const input = await c.req.json();
|
|
2724
|
+
if (!input.body || !input.body.trim()) {
|
|
2725
|
+
throw new ValidationError("body is required");
|
|
2726
|
+
}
|
|
2727
|
+
const thread = addComment(nestId, nodeId, threadId, userEmail, input.body);
|
|
2728
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2729
|
+
return c.json({ thread });
|
|
2730
|
+
});
|
|
2731
|
+
annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/resolve", async (c) => {
|
|
2732
|
+
const nestId = c.req.param("nestId");
|
|
2733
|
+
const nodeId = getNodeId(c);
|
|
2734
|
+
const threadId = c.req.param("threadId");
|
|
2735
|
+
const userId = c.get("userId");
|
|
2736
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2737
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2738
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2739
|
+
}
|
|
2740
|
+
const thread = setThreadStatus(nestId, nodeId, threadId, "resolved", userEmail);
|
|
2741
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2742
|
+
return c.json({ thread });
|
|
2743
|
+
});
|
|
2744
|
+
annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/reopen", async (c) => {
|
|
2745
|
+
const nestId = c.req.param("nestId");
|
|
2746
|
+
const nodeId = getNodeId(c);
|
|
2747
|
+
const threadId = c.req.param("threadId");
|
|
2748
|
+
const userId = c.get("userId");
|
|
2749
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2750
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2751
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2752
|
+
}
|
|
2753
|
+
const thread = setThreadStatus(nestId, nodeId, threadId, "open", userEmail);
|
|
2754
|
+
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2755
|
+
return c.json({ thread });
|
|
2756
|
+
});
|
|
2757
|
+
annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
|
|
2758
|
+
const nestId = c.req.param("nestId");
|
|
2759
|
+
const nodeId = getNodeId(c);
|
|
2760
|
+
const userId = c.get("userId");
|
|
2761
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2762
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2763
|
+
return c.json({ error: "Access denied" }, 403);
|
|
2764
|
+
}
|
|
2765
|
+
const vRaw = c.req.query("v");
|
|
2766
|
+
const vNum = vRaw !== void 0 ? Number(vRaw) : NaN;
|
|
2767
|
+
const version = Number.isFinite(vNum) && vNum > 0 ? Math.floor(vNum) : void 0;
|
|
2768
|
+
const { html, version: served, type } = await readArtifact(
|
|
2769
|
+
nestId,
|
|
2770
|
+
nodeId,
|
|
2771
|
+
version
|
|
2772
|
+
);
|
|
2773
|
+
if (type !== null && type !== ARTIFACT_NODE_TYPE) {
|
|
2774
|
+
return c.json({ error: "Not a hosted artifact" }, 404);
|
|
2775
|
+
}
|
|
2776
|
+
c.header(
|
|
2777
|
+
"Content-Security-Policy",
|
|
2778
|
+
[
|
|
2779
|
+
"sandbox allow-scripts allow-popups allow-modals",
|
|
2780
|
+
"default-src 'none'",
|
|
2781
|
+
"script-src 'unsafe-inline'",
|
|
2782
|
+
"style-src 'unsafe-inline'",
|
|
2783
|
+
"img-src data: blob:",
|
|
2784
|
+
"font-src data:",
|
|
2785
|
+
"connect-src 'none'",
|
|
2786
|
+
"base-uri 'none'",
|
|
2787
|
+
"form-action 'none'"
|
|
2788
|
+
].join("; ")
|
|
2789
|
+
);
|
|
2790
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
2791
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
2792
|
+
if (served != null) c.header("X-Artifact-Snapshot", String(served));
|
|
2793
|
+
return c.html(html);
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
// src/nodes/query-routes.ts
|
|
2797
|
+
import { Hono as Hono6 } from "hono";
|
|
2264
2798
|
import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
|
|
2265
2799
|
|
|
2266
2800
|
// src/nodes/prompt-compiler.ts
|
|
@@ -2478,8 +3012,158 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
|
|
|
2478
3012
|
}
|
|
2479
3013
|
}
|
|
2480
3014
|
|
|
3015
|
+
// src/nodes/graph-service.ts
|
|
3016
|
+
var MAX_GRAPH_NODES = 150;
|
|
3017
|
+
var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
3018
|
+
function extractWikiTargets(body) {
|
|
3019
|
+
const out = [];
|
|
3020
|
+
if (!body) return out;
|
|
3021
|
+
WIKILINK_RE.lastIndex = 0;
|
|
3022
|
+
let m;
|
|
3023
|
+
while ((m = WIKILINK_RE.exec(body)) !== null) {
|
|
3024
|
+
const t = (m[1] || "").trim();
|
|
3025
|
+
if (t) out.push(t);
|
|
3026
|
+
}
|
|
3027
|
+
return out;
|
|
3028
|
+
}
|
|
3029
|
+
async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
3030
|
+
const { storage } = engineCache.get(nestId);
|
|
3031
|
+
const docs = await storage.discoverDocuments();
|
|
3032
|
+
const accessible = filterAccessible(nestId, userId, userEmail, docs);
|
|
3033
|
+
const idSet = new Set(accessible.map((d) => d.id));
|
|
3034
|
+
const titleToId = /* @__PURE__ */ new Map();
|
|
3035
|
+
for (const d of accessible) {
|
|
3036
|
+
const t = (d.frontmatter?.title || "").toLowerCase().trim();
|
|
3037
|
+
if (t && !titleToId.has(t)) titleToId.set(t, d.id);
|
|
3038
|
+
}
|
|
3039
|
+
const allNodes = accessible.map((d) => ({
|
|
3040
|
+
id: d.id,
|
|
3041
|
+
title: d.frontmatter?.title || d.id,
|
|
3042
|
+
type: d.frontmatter?.type || "document",
|
|
3043
|
+
tags: (d.frontmatter?.tags || []).map(normalizeTag)
|
|
3044
|
+
}));
|
|
3045
|
+
const allLinks = [];
|
|
3046
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3047
|
+
for (const d of accessible) {
|
|
3048
|
+
for (const raw of extractWikiTargets(d.body || "")) {
|
|
3049
|
+
const targetId = idSet.has(raw) ? raw : titleToId.get(raw.toLowerCase().trim());
|
|
3050
|
+
if (!targetId || targetId === d.id) continue;
|
|
3051
|
+
const key = `${d.id}->${targetId}`;
|
|
3052
|
+
if (seen.has(key)) continue;
|
|
3053
|
+
seen.add(key);
|
|
3054
|
+
allLinks.push({ source: d.id, target: targetId });
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
const tagCounts = /* @__PURE__ */ new Map();
|
|
3058
|
+
const tagToDocs = /* @__PURE__ */ new Map();
|
|
3059
|
+
for (const n of allNodes) {
|
|
3060
|
+
for (const t of n.tags) {
|
|
3061
|
+
tagCounts.set(t, (tagCounts.get(t) || 0) + 1);
|
|
3062
|
+
let s = tagToDocs.get(t);
|
|
3063
|
+
if (!s) tagToDocs.set(t, s = /* @__PURE__ */ new Set());
|
|
3064
|
+
s.add(n.id);
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
const tags = [...tagCounts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
3068
|
+
const exposeStewards = isStewardshipEnabled(nestId) && !isPublicReader(nestId, userId);
|
|
3069
|
+
const stewardRows = exposeStewards ? (await listStewards({ nestId })).filter(
|
|
3070
|
+
// Drop document-scoped rows whose node the caller can't see.
|
|
3071
|
+
(s) => s.scope !== "document" || idSet.has(s.nodePattern || "")
|
|
3072
|
+
) : [];
|
|
3073
|
+
const maskId = /* @__PURE__ */ new Map();
|
|
3074
|
+
if (!canSeeIdentities) {
|
|
3075
|
+
const emails = [...new Set(stewardRows.map((s) => s.userEmail))].sort();
|
|
3076
|
+
emails.forEach((e, i) => maskId.set(e, `s${i + 1}`));
|
|
3077
|
+
}
|
|
3078
|
+
const identity = (email) => {
|
|
3079
|
+
if (canSeeIdentities) return { id: email, label: email };
|
|
3080
|
+
const id = maskId.get(email);
|
|
3081
|
+
return { id, label: `Steward ${id.slice(1)}` };
|
|
3082
|
+
};
|
|
3083
|
+
const stewardRowsMapped = stewardRows.map((s) => {
|
|
3084
|
+
const { id, label } = identity(s.userEmail);
|
|
3085
|
+
return {
|
|
3086
|
+
id,
|
|
3087
|
+
label,
|
|
3088
|
+
role: s.role,
|
|
3089
|
+
scope: s.scope,
|
|
3090
|
+
target: s.scope === "document" ? s.nodePattern || "" : s.scope === "tag" ? normalizeTag(s.tagName || "") : "*"
|
|
3091
|
+
};
|
|
3092
|
+
});
|
|
3093
|
+
const stewardEdges = stewardRowsMapped.map((s) => ({
|
|
3094
|
+
steward: s.id,
|
|
3095
|
+
target: s.scope === "tag" ? `tag:${s.target}` : s.scope === "document" ? s.target : "*",
|
|
3096
|
+
scope: s.scope
|
|
3097
|
+
}));
|
|
3098
|
+
const seenSteward = /* @__PURE__ */ new Set();
|
|
3099
|
+
const stewards = stewardRowsMapped.filter(
|
|
3100
|
+
(s) => seenSteward.has(s.id) ? false : (seenSteward.add(s.id), true)
|
|
3101
|
+
);
|
|
3102
|
+
const linkedIds = /* @__PURE__ */ new Set();
|
|
3103
|
+
for (const l of allLinks) {
|
|
3104
|
+
linkedIds.add(l.source);
|
|
3105
|
+
linkedIds.add(l.target);
|
|
3106
|
+
}
|
|
3107
|
+
const orphans = allNodes.filter((n) => !linkedIds.has(n.id));
|
|
3108
|
+
const stewardedTags = new Set(
|
|
3109
|
+
stewardRows.filter((s) => s.scope === "tag").map((s) => normalizeTag(s.tagName || ""))
|
|
3110
|
+
);
|
|
3111
|
+
const unstewardedTags = tags.filter((t) => !stewardedTags.has(t.name));
|
|
3112
|
+
const coveredBySteward = /* @__PURE__ */ new Map();
|
|
3113
|
+
const labelById = /* @__PURE__ */ new Map();
|
|
3114
|
+
for (const s of stewardRowsMapped) {
|
|
3115
|
+
labelById.set(s.id, s.label);
|
|
3116
|
+
let cov = coveredBySteward.get(s.id);
|
|
3117
|
+
if (!cov) coveredBySteward.set(s.id, cov = /* @__PURE__ */ new Set());
|
|
3118
|
+
if (s.scope === "nest") for (const id of idSet) cov.add(id);
|
|
3119
|
+
else if (s.scope === "tag")
|
|
3120
|
+
for (const id of tagToDocs.get(s.target) || []) cov.add(id);
|
|
3121
|
+
else if (idSet.has(s.target)) cov.add(s.target);
|
|
3122
|
+
}
|
|
3123
|
+
const denom = idSet.size;
|
|
3124
|
+
const bottlenecks = denom === 0 ? [] : [...coveredBySteward.entries()].map(([steward, cov]) => ({
|
|
3125
|
+
steward,
|
|
3126
|
+
label: labelById.get(steward) || steward,
|
|
3127
|
+
share: Math.round(cov.size / denom * 100)
|
|
3128
|
+
})).filter((b) => b.share >= 50).sort((a, b) => b.share - a.share);
|
|
3129
|
+
const signals = { orphans, unstewardedTags, bottlenecks };
|
|
3130
|
+
const truncated = allNodes.length > MAX_GRAPH_NODES;
|
|
3131
|
+
let nodes = allNodes;
|
|
3132
|
+
let links = allLinks;
|
|
3133
|
+
if (truncated) {
|
|
3134
|
+
const degree = /* @__PURE__ */ new Map();
|
|
3135
|
+
for (const l of allLinks) {
|
|
3136
|
+
degree.set(l.source, (degree.get(l.source) || 0) + 1);
|
|
3137
|
+
degree.set(l.target, (degree.get(l.target) || 0) + 1);
|
|
3138
|
+
}
|
|
3139
|
+
nodes = [...allNodes].sort(
|
|
3140
|
+
(a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0) || (a.id < b.id ? -1 : 1)
|
|
3141
|
+
).slice(0, MAX_GRAPH_NODES);
|
|
3142
|
+
const kept = new Set(nodes.map((n) => n.id));
|
|
3143
|
+
links = allLinks.filter((l) => kept.has(l.source) && kept.has(l.target));
|
|
3144
|
+
}
|
|
3145
|
+
return {
|
|
3146
|
+
nodes,
|
|
3147
|
+
links,
|
|
3148
|
+
stewards,
|
|
3149
|
+
stewardEdges,
|
|
3150
|
+
tags,
|
|
3151
|
+
signals,
|
|
3152
|
+
truncated,
|
|
3153
|
+
totalNodes: allNodes.length
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
|
|
2481
3157
|
// src/nodes/query-routes.ts
|
|
2482
|
-
var queryRoutes = new
|
|
3158
|
+
var queryRoutes = new Hono6();
|
|
3159
|
+
queryRoutes.get("/graph", async (c) => {
|
|
3160
|
+
const nestId = c.req.param("nestId");
|
|
3161
|
+
const userId = c.get("userId");
|
|
3162
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3163
|
+
const canSeeIdentities = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
3164
|
+
const graph = await buildNestGraph(nestId, userId, userEmail, canSeeIdentities);
|
|
3165
|
+
return c.json(graph);
|
|
3166
|
+
});
|
|
2483
3167
|
function approxTokens(text) {
|
|
2484
3168
|
return Math.ceil(text.length / 4);
|
|
2485
3169
|
}
|
|
@@ -2798,7 +3482,7 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
2798
3482
|
});
|
|
2799
3483
|
|
|
2800
3484
|
// src/mcp/routes.ts
|
|
2801
|
-
import { Hono as
|
|
3485
|
+
import { Hono as Hono7 } from "hono";
|
|
2802
3486
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2803
3487
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2804
3488
|
|
|
@@ -3410,7 +4094,7 @@ ${list}`;
|
|
|
3410
4094
|
case "context_share_nest": {
|
|
3411
4095
|
const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
|
|
3412
4096
|
if (!canManageWith(roles)) {
|
|
3413
|
-
return "You don't have permission to share this nest.
|
|
4097
|
+
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.";
|
|
3414
4098
|
}
|
|
3415
4099
|
const permission = args.permission || "read";
|
|
3416
4100
|
try {
|
|
@@ -3461,7 +4145,7 @@ ${list}`;
|
|
|
3461
4145
|
|
|
3462
4146
|
// src/mcp/routes.ts
|
|
3463
4147
|
import { z } from "zod";
|
|
3464
|
-
var mcpRoutes = new
|
|
4148
|
+
var mcpRoutes = new Hono7();
|
|
3465
4149
|
function getUserEmail2(userId) {
|
|
3466
4150
|
const db = getDb();
|
|
3467
4151
|
const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
@@ -3520,7 +4204,214 @@ mcpRoutes.all("/", async (c) => {
|
|
|
3520
4204
|
});
|
|
3521
4205
|
|
|
3522
4206
|
// src/governance/routes.ts
|
|
3523
|
-
import { Hono as
|
|
4207
|
+
import { Hono as Hono8 } from "hono";
|
|
4208
|
+
|
|
4209
|
+
// src/governance/comment-service.ts
|
|
4210
|
+
import { v4 as uuid4 } from "uuid";
|
|
4211
|
+
function createComment(params) {
|
|
4212
|
+
const db = getDb();
|
|
4213
|
+
const body = (params.body ?? "").trim();
|
|
4214
|
+
if (!body) {
|
|
4215
|
+
throw new Error("Comment body is required");
|
|
4216
|
+
}
|
|
4217
|
+
if (params.parentId) {
|
|
4218
|
+
const parent = db.prepare(
|
|
4219
|
+
"SELECT id FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?"
|
|
4220
|
+
).get(params.parentId, params.nestId, params.nodeId);
|
|
4221
|
+
if (!parent) {
|
|
4222
|
+
throw new Error("Parent comment not found on this node");
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
const id = uuid4();
|
|
4226
|
+
db.prepare(
|
|
4227
|
+
`INSERT INTO comments
|
|
4228
|
+
(id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
|
|
4229
|
+
parent_id, author, body)
|
|
4230
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
4231
|
+
).run(
|
|
4232
|
+
id,
|
|
4233
|
+
params.nestId,
|
|
4234
|
+
params.nodeId,
|
|
4235
|
+
params.version ?? null,
|
|
4236
|
+
params.anchor?.start ?? null,
|
|
4237
|
+
params.anchor?.end ?? null,
|
|
4238
|
+
params.anchor?.text ?? null,
|
|
4239
|
+
params.parentId ?? null,
|
|
4240
|
+
params.author,
|
|
4241
|
+
body
|
|
4242
|
+
);
|
|
4243
|
+
return getComment(id);
|
|
4244
|
+
}
|
|
4245
|
+
function listComments(nestId, nodeId, opts = {}) {
|
|
4246
|
+
const db = getDb();
|
|
4247
|
+
const args = [nestId, nodeId];
|
|
4248
|
+
let statusClause = "";
|
|
4249
|
+
if (opts.status) {
|
|
4250
|
+
statusClause = " AND status = ?";
|
|
4251
|
+
args.push(opts.status);
|
|
4252
|
+
}
|
|
4253
|
+
const rows = db.prepare(
|
|
4254
|
+
`SELECT * FROM comments
|
|
4255
|
+
WHERE nest_id = ? AND node_id = ?${statusClause}
|
|
4256
|
+
ORDER BY created_at ASC`
|
|
4257
|
+
).all(...args);
|
|
4258
|
+
return rows.map(rowToComment);
|
|
4259
|
+
}
|
|
4260
|
+
function getComment(id) {
|
|
4261
|
+
const db = getDb();
|
|
4262
|
+
const row = db.prepare("SELECT * FROM comments WHERE id = ?").get(id);
|
|
4263
|
+
return row ? rowToComment(row) : null;
|
|
4264
|
+
}
|
|
4265
|
+
function resolveComment(params) {
|
|
4266
|
+
const db = getDb();
|
|
4267
|
+
const existing = db.prepare(
|
|
4268
|
+
"SELECT id, status FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?"
|
|
4269
|
+
).get(params.commentId, params.nestId, params.nodeId);
|
|
4270
|
+
if (!existing) {
|
|
4271
|
+
throw new Error("Comment not found");
|
|
4272
|
+
}
|
|
4273
|
+
if (existing.status === "resolved") {
|
|
4274
|
+
throw new Error("Comment is already resolved");
|
|
4275
|
+
}
|
|
4276
|
+
db.prepare(
|
|
4277
|
+
`UPDATE comments
|
|
4278
|
+
SET status = 'resolved', resolved_by = ?, resolved_at = datetime('now')
|
|
4279
|
+
WHERE id = ?`
|
|
4280
|
+
).run(params.resolvedBy, params.commentId);
|
|
4281
|
+
return getComment(params.commentId);
|
|
4282
|
+
}
|
|
4283
|
+
async function getActivity(params) {
|
|
4284
|
+
const db = getDb();
|
|
4285
|
+
const limit = Number.isFinite(params.limit) && params.limit > 0 ? Math.min(params.limit, 1e3) : 100;
|
|
4286
|
+
const entries = [];
|
|
4287
|
+
const nodeFilter = params.nodeId ? " AND node_id = ?" : "";
|
|
4288
|
+
const baseArgs = params.nodeId ? [params.nestId, params.nodeId] : [params.nestId];
|
|
4289
|
+
const commentRows = db.prepare(
|
|
4290
|
+
`SELECT id, node_id, author, body, status, created_at, resolved_by, resolved_at
|
|
4291
|
+
FROM comments
|
|
4292
|
+
WHERE nest_id = ?${nodeFilter}
|
|
4293
|
+
ORDER BY COALESCE(resolved_at, created_at) DESC
|
|
4294
|
+
LIMIT ?`
|
|
4295
|
+
).all(...baseArgs, limit);
|
|
4296
|
+
for (const r of commentRows) {
|
|
4297
|
+
entries.push({
|
|
4298
|
+
type: "comment",
|
|
4299
|
+
nodeId: r.node_id,
|
|
4300
|
+
actor: r.author,
|
|
4301
|
+
at: r.created_at,
|
|
4302
|
+
detail: excerpt(r.body),
|
|
4303
|
+
refId: r.id
|
|
4304
|
+
});
|
|
4305
|
+
if (r.status === "resolved" && r.resolved_by && r.resolved_at) {
|
|
4306
|
+
entries.push({
|
|
4307
|
+
type: "comment_resolved",
|
|
4308
|
+
nodeId: r.node_id,
|
|
4309
|
+
actor: r.resolved_by,
|
|
4310
|
+
at: r.resolved_at,
|
|
4311
|
+
detail: excerpt(r.body),
|
|
4312
|
+
refId: r.id
|
|
4313
|
+
});
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
const versionRows = db.prepare(
|
|
4317
|
+
`SELECT node_id, version, author, change_note, created_at
|
|
4318
|
+
FROM node_versions
|
|
4319
|
+
WHERE nest_id = ?${nodeFilter}
|
|
4320
|
+
ORDER BY created_at DESC
|
|
4321
|
+
LIMIT ?`
|
|
4322
|
+
).all(...baseArgs, limit);
|
|
4323
|
+
for (const r of versionRows) {
|
|
4324
|
+
entries.push({
|
|
4325
|
+
type: "edit",
|
|
4326
|
+
nodeId: r.node_id,
|
|
4327
|
+
actor: r.author,
|
|
4328
|
+
at: r.created_at,
|
|
4329
|
+
detail: r.change_note || `v${r.version}`,
|
|
4330
|
+
refId: String(r.version)
|
|
4331
|
+
});
|
|
4332
|
+
}
|
|
4333
|
+
const reviewRows = db.prepare(
|
|
4334
|
+
`SELECT id, node_id, requested_by, requested_at, status, resolved_by, resolved_at
|
|
4335
|
+
FROM review_requests
|
|
4336
|
+
WHERE nest_id = ?${nodeFilter}
|
|
4337
|
+
ORDER BY COALESCE(resolved_at, requested_at) DESC
|
|
4338
|
+
LIMIT ?`
|
|
4339
|
+
).all(...baseArgs, limit);
|
|
4340
|
+
for (const r of reviewRows) {
|
|
4341
|
+
entries.push({
|
|
4342
|
+
type: "review_requested",
|
|
4343
|
+
nodeId: r.node_id,
|
|
4344
|
+
actor: r.requested_by,
|
|
4345
|
+
at: r.requested_at,
|
|
4346
|
+
detail: "requested review",
|
|
4347
|
+
refId: r.id
|
|
4348
|
+
});
|
|
4349
|
+
if (r.resolved_by && r.resolved_at) {
|
|
4350
|
+
entries.push({
|
|
4351
|
+
type: "review_resolved",
|
|
4352
|
+
nodeId: r.node_id,
|
|
4353
|
+
actor: r.resolved_by,
|
|
4354
|
+
at: r.resolved_at,
|
|
4355
|
+
detail: r.status,
|
|
4356
|
+
// approved | rejected | cancelled
|
|
4357
|
+
refId: r.id
|
|
4358
|
+
});
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
try {
|
|
4362
|
+
const pending = await listNestExternalEdits(params.nestId);
|
|
4363
|
+
for (const e of pending) {
|
|
4364
|
+
if (params.nodeId && e.document_id !== params.nodeId) continue;
|
|
4365
|
+
entries.push({
|
|
4366
|
+
type: "edit_proposed",
|
|
4367
|
+
nodeId: e.document_id,
|
|
4368
|
+
actor: e.actor,
|
|
4369
|
+
at: e.detected_at,
|
|
4370
|
+
detail: e.note || `proposed an edit (${e.source})`,
|
|
4371
|
+
refId: e.suggestion_id
|
|
4372
|
+
});
|
|
4373
|
+
}
|
|
4374
|
+
} catch (err) {
|
|
4375
|
+
console.error(
|
|
4376
|
+
`[comments] failed to load pending external edits for activity feed (nest ${params.nestId}):`,
|
|
4377
|
+
err
|
|
4378
|
+
);
|
|
4379
|
+
}
|
|
4380
|
+
const ms = (at) => {
|
|
4381
|
+
if (!at) return 0;
|
|
4382
|
+
const norm = at.includes("T") ? at : at.replace(" ", "T") + "Z";
|
|
4383
|
+
const t = Date.parse(norm);
|
|
4384
|
+
return Number.isNaN(t) ? 0 : t;
|
|
4385
|
+
};
|
|
4386
|
+
entries.sort((a, b) => ms(b.at) - ms(a.at));
|
|
4387
|
+
return entries.slice(0, limit);
|
|
4388
|
+
}
|
|
4389
|
+
function excerpt(body, max = 80) {
|
|
4390
|
+
const s = (body || "").replace(/\s+/g, " ").trim();
|
|
4391
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
4392
|
+
}
|
|
4393
|
+
function rowToComment(row) {
|
|
4394
|
+
const hasOffsets = row.anchor_start !== null && row.anchor_end !== null;
|
|
4395
|
+
const hasText = row.anchor_text !== null && row.anchor_text !== void 0;
|
|
4396
|
+
const anchor = hasOffsets || hasText ? {
|
|
4397
|
+
...hasOffsets ? { start: row.anchor_start, end: row.anchor_end } : {},
|
|
4398
|
+
...hasText ? { text: row.anchor_text } : {}
|
|
4399
|
+
} : void 0;
|
|
4400
|
+
return {
|
|
4401
|
+
id: row.id,
|
|
4402
|
+
nestId: row.nest_id,
|
|
4403
|
+
nodeId: row.node_id,
|
|
4404
|
+
version: row.version ?? void 0,
|
|
4405
|
+
anchor,
|
|
4406
|
+
parentId: row.parent_id ?? void 0,
|
|
4407
|
+
author: row.author,
|
|
4408
|
+
body: row.body,
|
|
4409
|
+
status: row.status,
|
|
4410
|
+
createdAt: row.created_at,
|
|
4411
|
+
resolvedBy: row.resolved_by ?? void 0,
|
|
4412
|
+
resolvedAt: row.resolved_at ?? void 0
|
|
4413
|
+
};
|
|
4414
|
+
}
|
|
3524
4415
|
|
|
3525
4416
|
// src/governance/stewards-parser.ts
|
|
3526
4417
|
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
@@ -3605,7 +4496,12 @@ function loadStewardsConfig(nestId) {
|
|
|
3605
4496
|
}
|
|
3606
4497
|
|
|
3607
4498
|
// src/governance/routes.ts
|
|
3608
|
-
|
|
4499
|
+
function parseLimit(raw, def = 100, max = 1e3) {
|
|
4500
|
+
const n = parseInt(raw ?? "", 10);
|
|
4501
|
+
if (Number.isNaN(n) || n < 1) return def;
|
|
4502
|
+
return Math.min(n, max);
|
|
4503
|
+
}
|
|
4504
|
+
var governanceRoutes = new Hono8();
|
|
3609
4505
|
governanceRoutes.get("/stewards", async (c) => {
|
|
3610
4506
|
const nestId = c.req.param("nestId");
|
|
3611
4507
|
const scope = c.req.query("scope");
|
|
@@ -3708,7 +4604,17 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
3708
4604
|
limit,
|
|
3709
4605
|
offset
|
|
3710
4606
|
});
|
|
3711
|
-
|
|
4607
|
+
const email = getUserEmail3(c);
|
|
4608
|
+
const canReviewCache = /* @__PURE__ */ new Map();
|
|
4609
|
+
const requests = result.requests.map((r) => {
|
|
4610
|
+
let canReview = canReviewCache.get(r.nodeId);
|
|
4611
|
+
if (canReview === void 0) {
|
|
4612
|
+
canReview = canUserApprove(nestId, r.nodeId, email).allowed;
|
|
4613
|
+
canReviewCache.set(r.nodeId, canReview);
|
|
4614
|
+
}
|
|
4615
|
+
return { ...r, canReview };
|
|
4616
|
+
});
|
|
4617
|
+
return c.json({ ...result, requests });
|
|
3712
4618
|
});
|
|
3713
4619
|
governanceRoutes.get("/external-edits", async (c) => {
|
|
3714
4620
|
const nestId = c.req.param("nestId");
|
|
@@ -3725,7 +4631,13 @@ governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
|
3725
4631
|
const result = await scanNestForDrift(nestId, actor);
|
|
3726
4632
|
return c.json(result);
|
|
3727
4633
|
});
|
|
3728
|
-
|
|
4634
|
+
governanceRoutes.get("/activity", async (c) => {
|
|
4635
|
+
const nestId = c.req.param("nestId");
|
|
4636
|
+
const limit = parseLimit(c.req.query("limit"));
|
|
4637
|
+
const activity = await getActivity({ nestId, limit });
|
|
4638
|
+
return c.json({ activity });
|
|
4639
|
+
});
|
|
4640
|
+
var governanceNodeRoutes = new Hono8();
|
|
3729
4641
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
3730
4642
|
const nestId = c.req.param("nestId");
|
|
3731
4643
|
const nodeId = c.req.param("nodeId");
|
|
@@ -3771,6 +4683,68 @@ governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
|
3771
4683
|
const history = getReviewHistory(nestId, nodeId);
|
|
3772
4684
|
return c.json({ reviews: history });
|
|
3773
4685
|
});
|
|
4686
|
+
governanceNodeRoutes.get("/:nodeId{.+}/comments", async (c) => {
|
|
4687
|
+
const nestId = c.req.param("nestId");
|
|
4688
|
+
const nodeId = c.req.param("nodeId");
|
|
4689
|
+
const status = c.req.query("status");
|
|
4690
|
+
const list = listComments(nestId, nodeId, {
|
|
4691
|
+
status: status === "open" || status === "resolved" ? status : void 0
|
|
4692
|
+
});
|
|
4693
|
+
return c.json({ comments: list });
|
|
4694
|
+
});
|
|
4695
|
+
governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
4696
|
+
const nestId = c.req.param("nestId");
|
|
4697
|
+
const nodeId = c.req.param("nodeId");
|
|
4698
|
+
const body = await c.req.json();
|
|
4699
|
+
const author = getUserEmail3(c);
|
|
4700
|
+
try {
|
|
4701
|
+
const comment = createComment({
|
|
4702
|
+
nestId,
|
|
4703
|
+
nodeId,
|
|
4704
|
+
author,
|
|
4705
|
+
body: body.body ?? "",
|
|
4706
|
+
version: body.version,
|
|
4707
|
+
anchor: body.anchor,
|
|
4708
|
+
parentId: body.parentId
|
|
4709
|
+
});
|
|
4710
|
+
return c.json({ comment }, 201);
|
|
4711
|
+
} catch (err) {
|
|
4712
|
+
throw new ValidationError(
|
|
4713
|
+
err instanceof Error ? err.message : String(err)
|
|
4714
|
+
);
|
|
4715
|
+
}
|
|
4716
|
+
});
|
|
4717
|
+
governanceNodeRoutes.post(
|
|
4718
|
+
"/:nodeId{.+?}/comments/:commentId/resolve",
|
|
4719
|
+
async (c) => {
|
|
4720
|
+
const nestId = c.req.param("nestId");
|
|
4721
|
+
const nodeId = c.req.param("nodeId");
|
|
4722
|
+
const commentId = c.req.param("commentId");
|
|
4723
|
+
const resolvedBy = getUserEmail3(c);
|
|
4724
|
+
try {
|
|
4725
|
+
const comment = resolveComment({
|
|
4726
|
+
nestId,
|
|
4727
|
+
nodeId,
|
|
4728
|
+
commentId,
|
|
4729
|
+
resolvedBy
|
|
4730
|
+
});
|
|
4731
|
+
return c.json({ comment });
|
|
4732
|
+
} catch (err) {
|
|
4733
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4734
|
+
if (msg === "Comment not found") return c.json({ error: msg }, 404);
|
|
4735
|
+
if (msg === "Comment is already resolved")
|
|
4736
|
+
return c.json({ error: msg }, 409);
|
|
4737
|
+
throw err;
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
);
|
|
4741
|
+
governanceNodeRoutes.get("/:nodeId{.+}/activity", async (c) => {
|
|
4742
|
+
const nestId = c.req.param("nestId");
|
|
4743
|
+
const nodeId = c.req.param("nodeId");
|
|
4744
|
+
const limit = parseLimit(c.req.query("limit"));
|
|
4745
|
+
const activity = await getActivity({ nestId, nodeId, limit });
|
|
4746
|
+
return c.json({ activity });
|
|
4747
|
+
});
|
|
3774
4748
|
governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
3775
4749
|
const nestId = c.req.param("nestId");
|
|
3776
4750
|
const nodeId = c.req.param("nodeId");
|
|
@@ -4029,7 +5003,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
4029
5003
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
4030
5004
|
});
|
|
4031
5005
|
function createApp() {
|
|
4032
|
-
const app = new
|
|
5006
|
+
const app = new Hono9();
|
|
4033
5007
|
const corsOrigins = config.CORS_ORIGINS;
|
|
4034
5008
|
app.use(
|
|
4035
5009
|
"*",
|
|
@@ -4142,6 +5116,73 @@ function createApp() {
|
|
|
4142
5116
|
return c.json({ error: msg }, 500);
|
|
4143
5117
|
}
|
|
4144
5118
|
});
|
|
5119
|
+
app.use("/admin/settings", flexAuthMiddleware);
|
|
5120
|
+
const adminSettingsAllowed = (c) => config.AUTH_MODE === "open" || isLicenseAdminUserId(c.get("userId"));
|
|
5121
|
+
const currentServerSettings = () => ({
|
|
5122
|
+
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
5123
|
+
logo_url: config.LOGO_URL,
|
|
5124
|
+
telemetry_enabled: config.TELEMETRY_ENABLED,
|
|
5125
|
+
public_base_url: config.PUBLIC_BASE_URL,
|
|
5126
|
+
max_body_bytes: config.MAX_BODY_BYTES
|
|
5127
|
+
});
|
|
5128
|
+
app.get("/admin/settings", (c) => {
|
|
5129
|
+
if (!adminSettingsAllowed(c))
|
|
5130
|
+
return c.json({ error: "Only the server admin can view this." }, 403);
|
|
5131
|
+
return c.json(currentServerSettings());
|
|
5132
|
+
});
|
|
5133
|
+
app.patch("/admin/settings", async (c) => {
|
|
5134
|
+
if (!adminSettingsAllowed(c))
|
|
5135
|
+
return c.json({ error: "Only the server admin can change this." }, 403);
|
|
5136
|
+
let body;
|
|
5137
|
+
try {
|
|
5138
|
+
body = await c.req.json();
|
|
5139
|
+
} catch {
|
|
5140
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
5141
|
+
}
|
|
5142
|
+
const errors = [];
|
|
5143
|
+
const pending = [];
|
|
5144
|
+
if ("promptowl_sign_in_gate" in body) {
|
|
5145
|
+
const v = String(body.promptowl_sign_in_gate ?? "").trim().toLowerCase();
|
|
5146
|
+
if (!["open", "admin-only", "disabled"].includes(v))
|
|
5147
|
+
errors.push("promptowl_sign_in_gate must be open | admin-only | disabled");
|
|
5148
|
+
else pending.push({ name: "PROMPTOWL_SIGN_IN_GATE", value: v });
|
|
5149
|
+
}
|
|
5150
|
+
if ("logo_url" in body) {
|
|
5151
|
+
const v = String(body.logo_url ?? "").trim();
|
|
5152
|
+
pending.push({ name: "LOGO_URL", value: v || null });
|
|
5153
|
+
}
|
|
5154
|
+
if ("public_base_url" in body) {
|
|
5155
|
+
const v = String(body.public_base_url ?? "").trim().replace(/\/$/, "");
|
|
5156
|
+
pending.push({ name: "PUBLIC_BASE_URL", value: v || null });
|
|
5157
|
+
}
|
|
5158
|
+
if ("telemetry_enabled" in body) {
|
|
5159
|
+
pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
|
|
5160
|
+
}
|
|
5161
|
+
if ("max_body_bytes" in body) {
|
|
5162
|
+
const n = Number(body.max_body_bytes);
|
|
5163
|
+
if (!Number.isFinite(n) || n < 1024 * 1024 || n > 500 * 1024 * 1024)
|
|
5164
|
+
errors.push("max_body_bytes must be between 1MB and 500MB");
|
|
5165
|
+
else pending.push({ name: "MAX_BODY_BYTES", value: String(Math.floor(n)) });
|
|
5166
|
+
}
|
|
5167
|
+
if (errors.length)
|
|
5168
|
+
return c.json({ error: errors.join("; "), settings: currentServerSettings() }, 400);
|
|
5169
|
+
for (const { name, value } of pending) {
|
|
5170
|
+
try {
|
|
5171
|
+
upsertEnvVar(config.ENV_FILE_PATH, name, value);
|
|
5172
|
+
} catch (e) {
|
|
5173
|
+
return c.json(
|
|
5174
|
+
{
|
|
5175
|
+
error: `Could not persist ${name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
5176
|
+
settings: currentServerSettings()
|
|
5177
|
+
},
|
|
5178
|
+
500
|
|
5179
|
+
);
|
|
5180
|
+
}
|
|
5181
|
+
if (value === null) delete process.env[name];
|
|
5182
|
+
else process.env[name] = value;
|
|
5183
|
+
}
|
|
5184
|
+
return c.json({ settings: currentServerSettings() });
|
|
5185
|
+
});
|
|
4145
5186
|
app.use("/stats", flexAuthMiddleware);
|
|
4146
5187
|
app.get("/stats", async (c) => {
|
|
4147
5188
|
const db = getDb();
|
|
@@ -4164,7 +5205,7 @@ function createApp() {
|
|
|
4164
5205
|
users: usersRow.c
|
|
4165
5206
|
});
|
|
4166
5207
|
});
|
|
4167
|
-
const nestsApp = new
|
|
5208
|
+
const nestsApp = new Hono9();
|
|
4168
5209
|
nestsApp.use("*", flexAuthMiddleware);
|
|
4169
5210
|
nestsApp.use("*", async (c, next) => {
|
|
4170
5211
|
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
@@ -4189,7 +5230,9 @@ function createApp() {
|
|
|
4189
5230
|
}
|
|
4190
5231
|
{
|
|
4191
5232
|
const path2 = c.req.path;
|
|
4192
|
-
const
|
|
5233
|
+
const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
|
|
5234
|
+
const isActivityPath = /\/activity$/.test(path2);
|
|
5235
|
+
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;
|
|
4193
5236
|
const needsLicense = c.req.method !== "GET" || isGovernance;
|
|
4194
5237
|
if (needsLicense) {
|
|
4195
5238
|
const lic = getCurrentLicense();
|
|
@@ -4216,6 +5259,8 @@ function createApp() {
|
|
|
4216
5259
|
let required = "read";
|
|
4217
5260
|
const path = c.req.path;
|
|
4218
5261
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
5262
|
+
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
5263
|
+
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
4219
5264
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
4220
5265
|
if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
|
|
4221
5266
|
return c.json(
|
|
@@ -4225,9 +5270,12 @@ function createApp() {
|
|
|
4225
5270
|
403
|
|
4226
5271
|
);
|
|
4227
5272
|
}
|
|
4228
|
-
|
|
5273
|
+
const resource = parts[1];
|
|
5274
|
+
if (resource === "visibility") {
|
|
4229
5275
|
required = "admin";
|
|
4230
|
-
} else if (
|
|
5276
|
+
} else if (resource === "collaborators") {
|
|
5277
|
+
required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
|
|
5278
|
+
} else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction) {
|
|
4231
5279
|
required = "write";
|
|
4232
5280
|
}
|
|
4233
5281
|
const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
|
|
@@ -4269,6 +5317,7 @@ function createApp() {
|
|
|
4269
5317
|
nestsApp.route("/", nestRoutes);
|
|
4270
5318
|
nestsApp.route("/:nestId", governanceRoutes);
|
|
4271
5319
|
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5320
|
+
nestsApp.route("/:nestId/nodes", annotationRoutes);
|
|
4272
5321
|
nestsApp.route("/:nestId/nodes", nodeRoutes);
|
|
4273
5322
|
nestsApp.route("/:nestId", queryRoutes);
|
|
4274
5323
|
nestsApp.route("/:nestId", sharingRoutes);
|
|
@@ -4473,6 +5522,13 @@ async function main() {
|
|
|
4473
5522
|
or set PROMPTOWL_KEY=pk_... in your environment and restart.
|
|
4474
5523
|
`);
|
|
4475
5524
|
}
|
|
5525
|
+
if (config.OFFICIAL_COMMUNITY_SSO_SECRET && !config.PUBLIC_BASE_URL) {
|
|
5526
|
+
console.warn(`
|
|
5527
|
+
WARNING: OFFICIAL_COMMUNITY_SSO_SECRET is set but PUBLIC_BASE_URL is not.
|
|
5528
|
+
One-click SSO will skip the audience check, weakening cross-server replay
|
|
5529
|
+
protection. Set PUBLIC_BASE_URL to this server's public URL.
|
|
5530
|
+
`);
|
|
5531
|
+
}
|
|
4476
5532
|
const app = createApp();
|
|
4477
5533
|
startLicenseSafetyPoll();
|
|
4478
5534
|
const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
|