@promptowl/contextnest-community 1.2.0 → 1.4.0

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