@promptowl/contextnest-community 1.3.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/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  reject,
17
17
  safePublishDocument,
18
18
  submitForReview
19
- } from "./chunk-EDJRDWPL.js";
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-6AXBB65N.js";
28
+ } from "./chunk-HIH7I232.js";
29
29
  import {
30
30
  AppError,
31
31
  ConflictError,
@@ -81,19 +81,19 @@ import {
81
81
  uniqueNestName,
82
82
  updateSteward,
83
83
  validateLicense
84
- } from "./chunk-SO74PQWI.js";
84
+ } from "./chunk-XNPD2Q6E.js";
85
85
  import {
86
86
  ANON_EMAIL,
87
87
  ANON_USER_ID,
88
88
  config,
89
89
  getDb
90
- } from "./chunk-UEHFNBNR.js";
90
+ } from "./chunk-RMU3LOPH.js";
91
91
 
92
92
  // src/index.ts
93
93
  import { serve } from "@hono/node-server";
94
94
 
95
95
  // src/app.ts
96
- import { Hono as Hono8 } from "hono";
96
+ import { Hono as Hono9 } from "hono";
97
97
  import { createMiddleware as createMiddleware2 } from "hono/factory";
98
98
  import { cors } from "hono/cors";
99
99
 
@@ -249,11 +249,60 @@ function clear(key) {
249
249
  buckets.delete(key);
250
250
  }
251
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
+
252
300
  // src/auth/routes.ts
253
301
  import { getConnInfo } from "@hono/node-server/conninfo";
254
302
  var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
255
303
  var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
256
304
  var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
305
+ var warnedMissingSsoAudience = false;
257
306
  function clientIp(c) {
258
307
  const xff = c.req.header("x-forwarded-for");
259
308
  if (xff) return xff.split(",")[0].trim();
@@ -301,6 +350,54 @@ function clearSessionCookie(c) {
301
350
  buildClearSessionCookie({ secure: isSecureRequest(c) })
302
351
  );
303
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
+ }
304
401
  var authRoutes = new Hono();
305
402
  authRoutes.post("/register", async (c) => {
306
403
  const body = await c.req.json();
@@ -533,62 +630,76 @@ authRoutes.post("/promptowl", async (c) => {
533
630
  401
534
631
  );
535
632
  }
536
- const gate = config.PROMPTOWL_SIGN_IN_GATE;
537
- if (gate !== "open") {
538
- if (gate === "disabled" || !isLicenseAdminEmail(me.email)) {
539
- return c.json(
540
- {
541
- error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
542
- gate
543
- },
544
- 403
545
- );
546
- }
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);
547
636
  }
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
- };
579
- }
580
- const sessionId = createSession(user.id, c.req.header("User-Agent"));
581
- setSessionCookie(c, sessionId);
582
637
  return c.json({
583
638
  user: {
584
- id: user.id,
585
- email: user.email,
586
- name: user.name,
587
- is_admin: isAdmin
639
+ id: result.user.id,
640
+ email: result.user.email,
641
+ name: result.user.name,
642
+ is_admin: result.isAdmin
588
643
  },
589
- ...claimBlocked ? { claim_blocked: claimBlocked } : {}
644
+ ...result.claimBlocked ? { claim_blocked: result.claimBlocked } : {}
590
645
  });
591
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
+ });
592
703
  authRoutes.get("/admin-status", async (c) => {
593
704
  const db = getDb();
594
705
  const lic = getCurrentLicense();
@@ -1079,7 +1190,7 @@ async function approveExternalEdit(input) {
1079
1190
  const node = await storage.readDocument(input.documentId);
1080
1191
  const versionNum = result.versionEntry.version;
1081
1192
  const tags = node.frontmatter.tags || [];
1082
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-624QTR5K.js");
1193
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-REGL5CUT.js");
1083
1194
  createVersion2({
1084
1195
  nestId: input.nestId,
1085
1196
  nodeId: input.documentId,
@@ -1235,7 +1346,7 @@ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
1235
1346
  async function createNode(nestId, input, userEmail) {
1236
1347
  const { storage, versions: versionManager } = engineCache.get(nestId);
1237
1348
  const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1238
- const id = `nodes/${slug}`;
1349
+ const id = input.id ?? `nodes/${slug}`;
1239
1350
  const now = (/* @__PURE__ */ new Date()).toISOString();
1240
1351
  const tags = (input.tags || []).map(normalizeTag2);
1241
1352
  const hasStewards = isStewardshipEnabled(nestId);
@@ -1830,6 +1941,11 @@ async function addCollaborator(params) {
1830
1941
  if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
1831
1942
  throw new ValidationError("permission must be read, write, or admin");
1832
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
+ }
1833
1949
  const db = getDb();
1834
1950
  let userId = params.userId;
1835
1951
  if (!userId && params.email) {
@@ -1905,7 +2021,10 @@ sharingRoutes.post("/collaborators", async (c) => {
1905
2021
  email: body.email,
1906
2022
  userId: body.user_id,
1907
2023
  permission: body.permission ?? "",
1908
- 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")
1909
2028
  });
1910
2029
  return c.json({ collaborator: collab }, 201);
1911
2030
  });
@@ -1915,17 +2034,22 @@ sharingRoutes.patch("/collaborators/:collabId", async (c) => {
1915
2034
  throw new ValidationError("permission must be read, write, or admin");
1916
2035
  }
1917
2036
  const db = getDb();
1918
- db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
1919
- body.permission,
1920
- c.req.param("collabId")
1921
- );
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
+ }
1922
2043
  return c.json({ updated: true });
1923
2044
  });
1924
2045
  sharingRoutes.delete("/collaborators/:collabId", async (c) => {
1925
2046
  const db = getDb();
1926
- db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
1927
- c.req.param("collabId")
1928
- );
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
+ }
1929
2053
  return c.json({ removed: true });
1930
2054
  });
1931
2055
  sharingRoutes.patch("/visibility", async (c) => {
@@ -2013,7 +2137,7 @@ nodeRoutes.post("/", async (c) => {
2013
2137
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2014
2138
  const nestId = c.req.param("nestId");
2015
2139
  const nodeId = c.req.param("nodeId");
2016
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZUSHJCNR.js");
2140
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-HFQPGASU.js");
2017
2141
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
2018
2142
  nestId,
2019
2143
  nodeId
@@ -2034,7 +2158,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2034
2158
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2035
2159
  const nestId = c.req.param("nestId");
2036
2160
  const nodeId = c.req.param("nodeId");
2037
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-624QTR5K.js");
2161
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-REGL5CUT.js");
2038
2162
  const allVersions = getVersions2(nestId, nodeId);
2039
2163
  const approved = getApprovedVersion2(nestId, nodeId);
2040
2164
  const db = getDb();
@@ -2065,7 +2189,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2065
2189
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
2066
2190
  const nestId = c.req.param("nestId");
2067
2191
  const nodeId = c.req.param("nodeId");
2068
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-QU7Q7XX2.js");
2192
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-ZBYZYH5H.js");
2069
2193
  const history = getReviewHistory2(nestId, nodeId);
2070
2194
  return c.json({ reviews: history });
2071
2195
  });
@@ -2259,8 +2383,407 @@ function getUserEmail(c) {
2259
2383
  return user?.email || "anonymous@localhost";
2260
2384
  }
2261
2385
 
2262
- // src/nodes/query-routes.ts
2386
+ // src/annotations/routes.ts
2263
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";
2264
2787
  import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
2265
2788
 
2266
2789
  // src/nodes/prompt-compiler.ts
@@ -2478,8 +3001,158 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
2478
3001
  }
2479
3002
  }
2480
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
+
2481
3146
  // src/nodes/query-routes.ts
2482
- var queryRoutes = new Hono5();
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
+ });
2483
3156
  function approxTokens(text) {
2484
3157
  return Math.ceil(text.length / 4);
2485
3158
  }
@@ -2798,7 +3471,7 @@ queryRoutes.post("/publish", async (c) => {
2798
3471
  });
2799
3472
 
2800
3473
  // src/mcp/routes.ts
2801
- import { Hono as Hono6 } from "hono";
3474
+ import { Hono as Hono7 } from "hono";
2802
3475
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2803
3476
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2804
3477
 
@@ -3410,7 +4083,7 @@ ${list}`;
3410
4083
  case "context_share_nest": {
3411
4084
  const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
3412
4085
  if (!canManageWith(roles)) {
3413
- return "You don't have permission to share this nest. Only the nest owner or an admin can add people.";
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.";
3414
4087
  }
3415
4088
  const permission = args.permission || "read";
3416
4089
  try {
@@ -3461,7 +4134,7 @@ ${list}`;
3461
4134
 
3462
4135
  // src/mcp/routes.ts
3463
4136
  import { z } from "zod";
3464
- var mcpRoutes = new Hono6();
4137
+ var mcpRoutes = new Hono7();
3465
4138
  function getUserEmail2(userId) {
3466
4139
  const db = getDb();
3467
4140
  const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
@@ -3520,7 +4193,214 @@ mcpRoutes.all("/", async (c) => {
3520
4193
  });
3521
4194
 
3522
4195
  // src/governance/routes.ts
3523
- import { Hono as Hono7 } from "hono";
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
+ }
3524
4404
 
3525
4405
  // src/governance/stewards-parser.ts
3526
4406
  import { readFileSync as readFileSync2, existsSync } from "fs";
@@ -3605,7 +4485,12 @@ function loadStewardsConfig(nestId) {
3605
4485
  }
3606
4486
 
3607
4487
  // src/governance/routes.ts
3608
- var governanceRoutes = new Hono7();
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();
3609
4494
  governanceRoutes.get("/stewards", async (c) => {
3610
4495
  const nestId = c.req.param("nestId");
3611
4496
  const scope = c.req.query("scope");
@@ -3708,7 +4593,17 @@ governanceRoutes.get("/review-queue", async (c) => {
3708
4593
  limit,
3709
4594
  offset
3710
4595
  });
3711
- return c.json(result);
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 });
3712
4607
  });
3713
4608
  governanceRoutes.get("/external-edits", async (c) => {
3714
4609
  const nestId = c.req.param("nestId");
@@ -3725,7 +4620,13 @@ governanceRoutes.post("/external-edits/scan", async (c) => {
3725
4620
  const result = await scanNestForDrift(nestId, actor);
3726
4621
  return c.json(result);
3727
4622
  });
3728
- var governanceNodeRoutes = new Hono7();
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();
3729
4630
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3730
4631
  const nestId = c.req.param("nestId");
3731
4632
  const nodeId = c.req.param("nodeId");
@@ -3771,6 +4672,68 @@ governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
3771
4672
  const history = getReviewHistory(nestId, nodeId);
3772
4673
  return c.json({ reviews: history });
3773
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
+ });
3774
4737
  governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
3775
4738
  const nestId = c.req.param("nestId");
3776
4739
  const nodeId = c.req.param("nodeId");
@@ -4029,7 +4992,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
4029
4992
  return c.json({ error: "Missing or invalid credentials" }, 401);
4030
4993
  });
4031
4994
  function createApp() {
4032
- const app = new Hono8();
4995
+ const app = new Hono9();
4033
4996
  const corsOrigins = config.CORS_ORIGINS;
4034
4997
  app.use(
4035
4998
  "*",
@@ -4164,7 +5127,7 @@ function createApp() {
4164
5127
  users: usersRow.c
4165
5128
  });
4166
5129
  });
4167
- const nestsApp = new Hono8();
5130
+ const nestsApp = new Hono9();
4168
5131
  nestsApp.use("*", flexAuthMiddleware);
4169
5132
  nestsApp.use("*", async (c, next) => {
4170
5133
  const localPath = c.req.path.replace(/^\/nests\//, "");
@@ -4189,7 +5152,9 @@ function createApp() {
4189
5152
  }
4190
5153
  {
4191
5154
  const path2 = c.req.path;
4192
- 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");
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;
4193
5158
  const needsLicense = c.req.method !== "GET" || isGovernance;
4194
5159
  if (needsLicense) {
4195
5160
  const lic = getCurrentLicense();
@@ -4216,6 +5181,8 @@ function createApp() {
4216
5181
  let required = "read";
4217
5182
  const path = c.req.path;
4218
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);
4219
5186
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
4220
5187
  if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
4221
5188
  return c.json(
@@ -4225,9 +5192,12 @@ function createApp() {
4225
5192
  403
4226
5193
  );
4227
5194
  }
4228
- if (path.includes("/collaborators") || path.includes("/visibility")) {
5195
+ const resource = parts[1];
5196
+ if (resource === "visibility") {
4229
5197
  required = "admin";
4230
- } else if (c.req.method !== "GET" && !isStewardActionPath) {
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) {
4231
5201
  required = "write";
4232
5202
  }
4233
5203
  const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
@@ -4269,6 +5239,7 @@ function createApp() {
4269
5239
  nestsApp.route("/", nestRoutes);
4270
5240
  nestsApp.route("/:nestId", governanceRoutes);
4271
5241
  nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
5242
+ nestsApp.route("/:nestId/nodes", annotationRoutes);
4272
5243
  nestsApp.route("/:nestId/nodes", nodeRoutes);
4273
5244
  nestsApp.route("/:nestId", queryRoutes);
4274
5245
  nestsApp.route("/:nestId", sharingRoutes);
@@ -4473,6 +5444,13 @@ async function main() {
4473
5444
  or set PROMPTOWL_KEY=pk_... in your environment and restart.
4474
5445
  `);
4475
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
+ }
4476
5454
  const app = createApp();
4477
5455
  startLicenseSafetyPoll();
4478
5456
  const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;