@promptowl/contextnest-community 1.0.1 → 1.2.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
@@ -6,48 +6,86 @@ import {
6
6
  hashPassword,
7
7
  parseBearerToken,
8
8
  verifyPassword
9
- } from "./chunk-7K2LLJXK.js";
9
+ } from "./chunk-XRK6SQSC.js";
10
10
  import {
11
11
  approve,
12
12
  cancelReview,
13
- engineCache,
14
13
  getPendingReview,
15
14
  getReviewHistory,
16
15
  getReviewQueue,
17
16
  reject,
18
17
  safePublishDocument,
19
18
  submitForReview
20
- } from "./chunk-5VHKEIAW.js";
19
+ } from "./chunk-E7E3JMQR.js";
20
+ import {
21
+ checkConflict,
22
+ createVersion,
23
+ getApprovedVersion,
24
+ getCurrentVersion,
25
+ getDisplayStatus,
26
+ getVersions,
27
+ setApprovedVersion
28
+ } from "./chunk-LO54V4AU.js";
21
29
  import {
22
- assignSteward,
30
+ AppError,
31
+ ConflictError,
32
+ ForbiddenError,
33
+ LockedError,
34
+ NotFoundError,
35
+ ValidationError,
36
+ canCreateInNest,
37
+ canManageStewards,
38
+ canManageWith,
23
39
  canUserAccess,
24
40
  canUserApprove,
25
41
  canUserEdit,
42
+ createNest,
26
43
  createStewardRecord,
44
+ deleteNest,
45
+ disableStewardshipAndWipeGovernance,
46
+ engineCache,
47
+ getCollaboratorRole,
48
+ getCurrentLicense,
49
+ getNest,
50
+ getStewardRolesForUser,
27
51
  getStewardsForNest,
52
+ getStewardsForUser,
53
+ getSuspensionReason,
54
+ importNest,
55
+ installLicenseKey,
56
+ isLicenseAdminEmail,
57
+ isLicenseAdminUserId,
58
+ isPublicReader,
59
+ isStewardshipEnabled,
28
60
  isSuperAdmin,
61
+ isSuspended,
62
+ listNests,
63
+ listPublicNests,
64
+ listSharedNests,
29
65
  listStewards,
30
66
  loadAccessConfig,
67
+ nestAllowsSelfApprove,
68
+ permissionLevel,
31
69
  removeSteward,
70
+ resolveNestPermission,
32
71
  resolveStewardsForNode,
33
72
  resolveStewardsWithFallback,
34
- syncFromConfig
35
- } from "./chunk-K22GWPT4.js";
36
- import {
37
- checkConflict,
38
- createVersion,
39
- getApprovedVersion,
40
- getCurrentVersion,
41
- getDisplayStatus,
42
- getVersions,
43
- setApprovedVersion
44
- } from "./chunk-JMZ75ZCD.js";
73
+ resolveUserRoles,
74
+ setAllowSelfApprove,
75
+ setStewardshipEnabled,
76
+ startLicenseSafetyPoll,
77
+ startTelemetryLoop,
78
+ syncFromConfig,
79
+ trackEvent,
80
+ updateSteward,
81
+ validateLicense
82
+ } from "./chunk-5MT4ZBVF.js";
45
83
  import {
46
84
  ANON_EMAIL,
47
85
  ANON_USER_ID,
48
86
  config,
49
87
  getDb
50
- } from "./chunk-KQCWNHDM.js";
88
+ } from "./chunk-G62P54ET.js";
51
89
 
52
90
  // src/index.ts
53
91
  import { serve } from "@hono/node-server";
@@ -172,463 +210,9 @@ var authMiddleware = createMiddleware(async (c, next) => {
172
210
  return c.json({ error: "Missing or invalid credentials" }, 401);
173
211
  });
174
212
 
175
- // src/shared/errors.ts
176
- var AppError = class extends Error {
177
- constructor(statusCode, message) {
178
- super(message);
179
- this.statusCode = statusCode;
180
- this.name = "AppError";
181
- }
182
- statusCode;
183
- };
184
- var NotFoundError = class extends AppError {
185
- constructor(message = "Not found") {
186
- super(404, message);
187
- this.name = "NotFoundError";
188
- }
189
- };
190
- var ForbiddenError = class extends AppError {
191
- constructor(message = "Forbidden") {
192
- super(403, message);
193
- this.name = "ForbiddenError";
194
- }
195
- };
196
- var ValidationError = class extends AppError {
197
- constructor(message) {
198
- super(400, message);
199
- this.name = "ValidationError";
200
- }
201
- };
202
- var ConflictError = class extends AppError {
203
- constructor(message) {
204
- super(409, message);
205
- this.name = "ConflictError";
206
- }
207
- };
208
-
209
- // src/telemetry/tracker.ts
210
- function trackEvent(event, data) {
211
- if (!config.TELEMETRY_ENABLED) return;
212
- try {
213
- const db = getDb();
214
- db.prepare(
215
- "INSERT INTO telemetry_events (event, data_json) VALUES (?, ?)"
216
- ).run(event, data ? JSON.stringify(data) : null);
217
- } catch {
218
- }
219
- }
220
- async function flushTelemetry() {
221
- if (!config.TELEMETRY_ENABLED || !config.PROMPTOWL_KEY) return;
222
- const db = getDb();
223
- const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get()?.c || 0;
224
- const nestCount = db.prepare("SELECT COUNT(*) as c FROM nests").get()?.c || 0;
225
- const events = db.prepare(
226
- "SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
227
- ).all();
228
- if (events.length === 0 && userCount === 0) return;
229
- const payload = {
230
- server_key: config.PROMPTOWL_KEY,
231
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
232
- stats: { users: userCount, nests: nestCount },
233
- events: events.map((e) => ({
234
- event: e.event,
235
- data: e.data_json ? JSON.parse(e.data_json) : null,
236
- at: e.created_at
237
- }))
238
- };
239
- try {
240
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
241
- const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
242
- method: "POST",
243
- headers: { "Content-Type": "application/json" },
244
- body: JSON.stringify(payload)
245
- });
246
- if (res.ok && events.length > 0) {
247
- const ids = events.map((e) => e.id);
248
- db.prepare(
249
- `UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
250
- ).run(...ids);
251
- }
252
- } catch {
253
- }
254
- }
255
- var telemetryTimer = null;
256
- function startTelemetryLoop() {
257
- if (!config.TELEMETRY_ENABLED) return;
258
- setTimeout(() => flushTelemetry(), 3e4);
259
- telemetryTimer = setInterval(
260
- () => flushTelemetry(),
261
- config.TELEMETRY_INTERVAL_MS
262
- );
263
- }
264
-
265
- // src/auth/license.ts
266
- import { existsSync, readFileSync, writeFileSync } from "fs";
267
- var currentLicense = null;
268
- function getCurrentLicense() {
269
- return currentLicense;
270
- }
271
- function isLicenseAdminEmail(email) {
272
- if (!email) return false;
273
- const lic = currentLicense;
274
- if (!lic?.valid || !lic.ownerEmail) return false;
275
- return lic.ownerEmail.toLowerCase() === email.toLowerCase();
276
- }
277
- function isLicenseAdminUserId(userId) {
278
- try {
279
- const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
280
- return isLicenseAdminEmail(row?.email);
281
- } catch {
282
- return false;
283
- }
284
- }
285
- function upsertEnvVar(filePath, varName, value) {
286
- const prefix = `${varName}=`;
287
- let lines = [];
288
- if (existsSync(filePath)) {
289
- lines = readFileSync(filePath, "utf8").split(/\r?\n/);
290
- }
291
- const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
292
- if (value !== null) {
293
- filtered.push(`${prefix}${value}`);
294
- }
295
- while (filtered.length && filtered[filtered.length - 1] === "") {
296
- filtered.pop();
297
- }
298
- writeFileSync(filePath, filtered.join("\n") + "\n", "utf8");
299
- }
300
- async function installLicenseKey(key) {
301
- const trimmed = key.trim();
302
- if (!trimmed.startsWith("pk_")) {
303
- throw new Error("Invalid license key format. Must start with pk_.");
304
- }
305
- const previousKey = process.env.PROMPTOWL_KEY || "";
306
- process.env.PROMPTOWL_KEY = trimmed;
307
- const info = await validateLicense({ forceFresh: true });
308
- if (!info.valid) {
309
- process.env.PROMPTOWL_KEY = previousKey;
310
- if (previousKey) {
311
- await validateLicense({ forceFresh: true });
312
- }
313
- return info;
314
- }
315
- try {
316
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
317
- } catch (err) {
318
- console.warn("[license] failed to write .env:", err);
319
- }
320
- startLicenseWatcher();
321
- return info;
322
- }
323
- var watcherActive = false;
324
- var watcherAbort = null;
325
- var WATCHER_BACKOFF_MIN_MS = 2 * 1e3;
326
- var WATCHER_BACKOFF_MAX_MS = 60 * 1e3;
327
- function startLicenseWatcher() {
328
- if (watcherActive) {
329
- watcherAbort?.abort();
330
- return;
331
- }
332
- watcherActive = true;
333
- void runLicenseWatcher();
334
- }
335
- async function runLicenseWatcher() {
336
- let backoff = WATCHER_BACKOFF_MIN_MS;
337
- while (watcherActive) {
338
- const key = config.PROMPTOWL_KEY;
339
- if (!key) {
340
- await sleep(WATCHER_BACKOFF_MAX_MS);
341
- continue;
342
- }
343
- try {
344
- watcherAbort = new AbortController();
345
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
346
- const fetchTimeout = AbortSignal.timeout(30 * 1e3);
347
- const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([watcherAbort.signal, fetchTimeout]) : watcherAbort.signal;
348
- const res = await fetch(`${promptowlUrl}/api/license/listen`, {
349
- method: "POST",
350
- headers: { "Content-Type": "application/json" },
351
- body: JSON.stringify({
352
- key,
353
- since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
354
- }),
355
- signal
356
- });
357
- if (!res.ok) {
358
- if (res.status === 504 || res.status === 408 || res.status === 502) {
359
- backoff = WATCHER_BACKOFF_MIN_MS;
360
- continue;
361
- }
362
- throw new Error(`listen returned ${res.status}`);
363
- }
364
- const data = await res.json();
365
- backoff = WATCHER_BACKOFF_MIN_MS;
366
- if (data.event && data.event !== "no_change") {
367
- console.log(
368
- `[license] event from PromptOwl: ${data.event} \u2014 revalidating`
369
- );
370
- const wasValid = !!currentLicense?.valid;
371
- await validateLicense({ forceFresh: true });
372
- const isValid = !!currentLicense?.valid;
373
- if (wasValid && !isValid) {
374
- handleLicenseRevoked();
375
- }
376
- }
377
- } catch (err) {
378
- if (err.name === "AbortError") {
379
- continue;
380
- }
381
- console.warn(
382
- `[license] watcher error: ${err.message}; backing off ${backoff}ms`
383
- );
384
- await sleep(backoff);
385
- backoff = Math.min(backoff * 2, WATCHER_BACKOFF_MAX_MS);
386
- } finally {
387
- watcherAbort = null;
388
- }
389
- }
390
- }
391
- function sleep(ms) {
392
- return new Promise((r) => setTimeout(r, ms));
393
- }
394
- var safetyPollHandle = null;
395
- var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
396
- function startLicenseSafetyPoll() {
397
- if (safetyPollHandle) return;
398
- safetyPollHandle = setInterval(async () => {
399
- if (!config.PROMPTOWL_KEY) return;
400
- try {
401
- const wasValid = !!currentLicense?.valid;
402
- await validateLicense({ forceFresh: true });
403
- const isValid = !!currentLicense?.valid;
404
- if (wasValid && !isValid) {
405
- console.log("[license] safety poll detected revocation");
406
- handleLicenseRevoked();
407
- }
408
- } catch (err) {
409
- console.warn(
410
- `[license] safety poll error: ${err.message}`
411
- );
412
- }
413
- }, SAFETY_POLL_INTERVAL_MS);
414
- if (typeof safetyPollHandle.unref === "function") {
415
- safetyPollHandle.unref();
416
- }
417
- }
418
- function handleLicenseRevoked() {
419
- try {
420
- const db = getDb();
421
- const result = db.prepare("DELETE FROM sessions").run();
422
- console.warn(
423
- `[license] revoked \u2014 wiped ${result.changes} active session(s).`
424
- );
425
- } catch (err) {
426
- console.warn("[license] failed to wipe sessions:", err);
427
- }
428
- try {
429
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
430
- console.warn(
431
- `[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
432
- );
433
- } catch (err) {
434
- console.warn("[license] failed to strip key from .env:", err);
435
- }
436
- process.env.PROMPTOWL_KEY = "";
437
- currentLicense = {
438
- valid: false,
439
- tier: "none",
440
- org: null,
441
- limits: null,
442
- suspended: false,
443
- suspendedReason: null,
444
- ownerEmail: null
445
- };
446
- }
447
- var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
448
- var suspensionFirstSeen = null;
449
- var suspensionConfirmed = false;
450
- var suspensionReason = null;
451
- var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
452
- function isSuspended() {
453
- return suspensionConfirmed;
454
- }
455
- function getSuspensionReason() {
456
- return suspensionReason;
457
- }
458
- async function validateLicense(opts = {}) {
459
- const info = await _validateLicenseImpl(!!opts.forceFresh);
460
- currentLicense = info;
461
- return info;
462
- }
463
- async function _validateLicenseImpl(forceFresh) {
464
- const key = config.PROMPTOWL_KEY;
465
- if (!key) {
466
- return {
467
- valid: false,
468
- tier: "none",
469
- org: null,
470
- limits: null,
471
- suspended: false,
472
- suspendedReason: null,
473
- ownerEmail: null
474
- };
475
- }
476
- const db = getDb();
477
- const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
478
- if (cached && !forceFresh) {
479
- const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
480
- if (age < CACHE_TTL_MS) {
481
- return {
482
- valid: true,
483
- tier: cached.tier,
484
- org: cached.org,
485
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
486
- suspended: suspensionConfirmed,
487
- suspendedReason: suspensionReason,
488
- ownerEmail: cached.owner_email || null
489
- };
490
- }
491
- }
492
- try {
493
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
494
- const res = await fetch(`${promptowlUrl}/api/license/validate`, {
495
- method: "POST",
496
- headers: { "Content-Type": "application/json" },
497
- body: JSON.stringify({ key })
498
- });
499
- if (!res.ok) {
500
- if (cached) {
501
- console.warn(
502
- " PromptOwl unreachable, using cached license (grace period)"
503
- );
504
- return {
505
- valid: true,
506
- tier: cached.tier,
507
- org: cached.org,
508
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
509
- suspended: suspensionConfirmed,
510
- suspendedReason: suspensionReason,
511
- ownerEmail: cached.owner_email || null
512
- };
513
- }
514
- return {
515
- valid: false,
516
- tier: "none",
517
- org: null,
518
- limits: null,
519
- suspended: false,
520
- suspendedReason: null,
521
- ownerEmail: null
522
- };
523
- }
524
- const data = await res.json();
525
- if (data.suspended === true) {
526
- if (!suspensionFirstSeen) {
527
- suspensionFirstSeen = Date.now();
528
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
529
- console.warn(
530
- `
531
- WARNING: PromptOwl has flagged this server for suspension.`
532
- );
533
- console.warn(
534
- ` Reason: ${suspensionReason}`
535
- );
536
- console.warn(
537
- ` This will be confirmed in ~1 hour. If this is an error,`
538
- );
539
- console.warn(
540
- ` contact support@promptowl.ai to reverse it.
541
- `
542
- );
543
- } else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
544
- suspensionConfirmed = true;
545
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
546
- console.error(
547
- `
548
- SERVER SUSPENDED: ${suspensionReason}`
549
- );
550
- console.error(
551
- ` Write operations are disabled. Reads still work.`
552
- );
553
- console.error(
554
- ` Contact support@promptowl.ai to resolve.
555
- `
556
- );
557
- }
558
- } else {
559
- if (suspensionFirstSeen) {
560
- console.log(" Suspension flag cleared by PromptOwl.");
561
- }
562
- suspensionFirstSeen = null;
563
- suspensionConfirmed = false;
564
- suspensionReason = null;
565
- }
566
- if (!data.valid && !data.suspended) {
567
- db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
568
- return {
569
- valid: false,
570
- tier: "none",
571
- org: null,
572
- limits: null,
573
- suspended: false,
574
- suspendedReason: null,
575
- ownerEmail: data.owner_email || null
576
- };
577
- }
578
- if (data.valid) {
579
- const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
580
- db.prepare(
581
- `INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
582
- VALUES (?, ?, ?, ?, ?, datetime('now'))`
583
- ).run(
584
- key,
585
- data.tier || "community",
586
- data.org || null,
587
- limitsJson,
588
- data.owner_email || null
589
- );
590
- }
591
- return {
592
- valid: data.valid !== false,
593
- tier: data.tier || "community",
594
- org: data.org || null,
595
- limits: data.limits || null,
596
- suspended: suspensionConfirmed,
597
- suspendedReason: suspensionReason,
598
- ownerEmail: data.owner_email || null
599
- };
600
- } catch (err) {
601
- if (cached) {
602
- console.warn(
603
- ` PromptOwl validation failed (${err.message}), using cached license`
604
- );
605
- return {
606
- valid: true,
607
- tier: cached.tier,
608
- org: cached.org,
609
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
610
- suspended: suspensionConfirmed,
611
- suspendedReason: suspensionReason,
612
- ownerEmail: cached.owner_email || null
613
- };
614
- }
615
- return {
616
- valid: false,
617
- tier: "none",
618
- org: null,
619
- limits: null,
620
- suspended: false,
621
- suspendedReason: null,
622
- ownerEmail: null
623
- };
624
- }
625
- }
626
-
627
213
  // src/shared/rate-limit.ts
628
214
  var buckets = /* @__PURE__ */ new Map();
629
- function tryConsume(key, cfg) {
630
- const now = Date.now();
631
- const cutoff = now - cfg.windowMs;
215
+ function liveBucket(key, cutoff) {
632
216
  let bucket = buckets.get(key);
633
217
  if (!bucket) {
634
218
  bucket = { hits: [] };
@@ -637,14 +221,29 @@ function tryConsume(key, cfg) {
637
221
  while (bucket.hits.length && bucket.hits[0] < cutoff) {
638
222
  bucket.hits.shift();
639
223
  }
224
+ return bucket;
225
+ }
226
+ function tryConsume(key, cfg) {
227
+ const now = Date.now();
228
+ const bucket = liveBucket(key, now - cfg.windowMs);
640
229
  if (bucket.hits.length >= cfg.max) {
641
230
  return false;
642
231
  }
643
232
  bucket.hits.push(now);
644
233
  return true;
645
234
  }
235
+ function isLimited(key, cfg) {
236
+ return liveBucket(key, Date.now() - cfg.windowMs).hits.length >= cfg.max;
237
+ }
238
+ function recordFailure(key, cfg) {
239
+ liveBucket(key, Date.now() - cfg.windowMs).hits.push(Date.now());
240
+ }
241
+ function clear(key) {
242
+ buckets.delete(key);
243
+ }
646
244
 
647
245
  // src/auth/routes.ts
246
+ import { getConnInfo } from "@hono/node-server/conninfo";
648
247
  var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
649
248
  var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
650
249
  var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
@@ -653,6 +252,11 @@ function clientIp(c) {
653
252
  if (xff) return xff.split(",")[0].trim();
654
253
  const realIp = c.req.header("x-real-ip");
655
254
  if (realIp) return realIp.trim();
255
+ try {
256
+ const addr = getConnInfo(c).remote.address;
257
+ if (addr) return addr;
258
+ } catch {
259
+ }
656
260
  return "unknown";
657
261
  }
658
262
  function resolveCallerUserId(c) {
@@ -669,6 +273,15 @@ function resolveCallerUserId(c) {
669
273
  }
670
274
  return null;
671
275
  }
276
+ function deviceGateBlocked(c) {
277
+ const gate = config.PROMPTOWL_SIGN_IN_GATE;
278
+ if (gate === "open") return null;
279
+ const error = "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.";
280
+ if (gate === "disabled") return { error, gate };
281
+ const callerId = resolveCallerUserId(c);
282
+ if (callerId && !isLicenseAdminUserId(callerId)) return { error, gate };
283
+ return null;
284
+ }
672
285
  function setSessionCookie(c, sessionId) {
673
286
  c.header(
674
287
  "Set-Cookie",
@@ -731,7 +344,10 @@ authRoutes.post("/login", async (c) => {
731
344
  }
732
345
  const ip = clientIp(c);
733
346
  const emailLower = body.email.toLowerCase();
734
- if (!tryConsume(`login:ip:${ip}`, LOGIN_LIMIT) || !tryConsume(`login:email:${emailLower}`, LOGIN_LIMIT)) {
347
+ const hasIp = ip !== "unknown";
348
+ const ipKey = `login:ip:${ip}`;
349
+ const emailKey = `login:email:${emailLower}`;
350
+ if (hasIp && isLimited(ipKey, LOGIN_LIMIT) || isLimited(emailKey, LOGIN_LIMIT)) {
735
351
  return c.json({ error: "Too many login attempts, try again later" }, 429);
736
352
  }
737
353
  const db = getDb();
@@ -740,8 +356,14 @@ authRoutes.post("/login", async (c) => {
740
356
  ).get(body.email);
741
357
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
742
358
  if (!user || !check.ok) {
359
+ if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
360
+ recordFailure(emailKey, LOGIN_LIMIT);
361
+ console.warn(`[auth] login FAIL \u2014 counter++ ip=${ip} email=${emailLower}`);
743
362
  return c.json({ error: "Invalid credentials" }, 401);
744
363
  }
364
+ console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
365
+ if (hasIp) clear(ipKey);
366
+ clear(emailKey);
745
367
  if (check.needsRehash) {
746
368
  try {
747
369
  const newHash = await hashPassword(body.password);
@@ -843,6 +465,8 @@ authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
843
465
  return c.json({ deleted: true });
844
466
  });
845
467
  authRoutes.post("/device", async (c) => {
468
+ const blocked = deviceGateBlocked(c);
469
+ if (blocked) return c.json(blocked, 403);
846
470
  if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
847
471
  return c.json({ error: "Too many device auth attempts, try again later" }, 429);
848
472
  }
@@ -864,6 +488,8 @@ authRoutes.post("/device", async (c) => {
864
488
  return c.json(data);
865
489
  });
866
490
  authRoutes.get("/device/poll", async (c) => {
491
+ const blocked = deviceGateBlocked(c);
492
+ if (blocked) return c.json(blocked, 403);
867
493
  const code = c.req.query("code");
868
494
  const clientSecret = c.req.query("client_secret");
869
495
  if (!code || !clientSecret) {
@@ -899,6 +525,18 @@ authRoutes.post("/promptowl", async (c) => {
899
525
  401
900
526
  );
901
527
  }
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
+ }
902
540
  const db = getDb();
903
541
  let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(me.email);
904
542
  if (!user) {
@@ -980,9 +618,17 @@ authRoutes.post("/password", authMiddleware, async (c) => {
980
618
  const db = getDb();
981
619
  const userId = c.get("userId");
982
620
  const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
983
- if (!user) return c.json({ error: "User not found" }, 404);
621
+ if (!user) throw new ValidationError("User not found");
984
622
  const check = await verifyPassword(body.current, user.password_hash);
985
- if (!check.ok) return c.json({ error: "Invalid current password" }, 401);
623
+ if (!check.ok) {
624
+ throw new ValidationError("Current password is incorrect");
625
+ }
626
+ const sameAsCurrent = await verifyPassword(body.next, user.password_hash);
627
+ if (sameAsCurrent.ok) {
628
+ throw new ValidationError(
629
+ "new password must be different from the current password"
630
+ );
631
+ }
986
632
  const newHash = await hashPassword(body.next);
987
633
  db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
988
634
  newHash,
@@ -992,6 +638,92 @@ authRoutes.post("/password", authMiddleware, async (c) => {
992
638
  clearSessionCookie(c);
993
639
  return c.json({ ok: true });
994
640
  });
641
+ authRoutes.post("/admin/reset-password/:userId", async (c) => {
642
+ const callerId = resolveCallerUserId(c);
643
+ if (!callerId) {
644
+ return c.json({ error: "Authentication required." }, 401);
645
+ }
646
+ if (!isLicenseAdminUserId(callerId)) {
647
+ return c.json(
648
+ { error: "Only the license-admin user can reset passwords." },
649
+ 403
650
+ );
651
+ }
652
+ const targetId = c.req.param("userId");
653
+ const db = getDb();
654
+ const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
655
+ if (!target) return c.json({ error: "User not found" }, 404);
656
+ let supplied;
657
+ try {
658
+ supplied = (await c.req.json()).password;
659
+ } catch {
660
+ supplied = void 0;
661
+ }
662
+ if (supplied && supplied.length < 8) {
663
+ throw new ValidationError("password must be at least 8 characters");
664
+ }
665
+ const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
666
+ const newPassword = supplied ?? generated;
667
+ const newHash = await hashPassword(newPassword);
668
+ db.transaction(() => {
669
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
670
+ newHash,
671
+ target.id
672
+ );
673
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
674
+ })();
675
+ deleteAllSessionsForUser(target.id);
676
+ trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
677
+ return c.json({
678
+ ok: true,
679
+ email: target.email,
680
+ keys_revoked: true,
681
+ // Plaintext returned ONCE, only when the server generated it.
682
+ temporary_password: generated ?? void 0
683
+ });
684
+ });
685
+ authRoutes.delete("/users/:userId", async (c) => {
686
+ const callerId = resolveCallerUserId(c);
687
+ if (!callerId) {
688
+ return c.json({ error: "Authentication required." }, 401);
689
+ }
690
+ if (!isLicenseAdminUserId(callerId)) {
691
+ return c.json(
692
+ { error: "Only the license-admin user can remove users." },
693
+ 403
694
+ );
695
+ }
696
+ const targetId = c.req.param("userId");
697
+ if (targetId === callerId) {
698
+ return c.json({ error: "You can't remove your own admin account." }, 400);
699
+ }
700
+ const db = getDb();
701
+ const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
702
+ if (!target) return c.json({ error: "User not found" }, 404);
703
+ const ownedNests = db.prepare("SELECT COUNT(*) AS c FROM nests WHERE user_id = ?").get(target.id).c;
704
+ if (ownedNests > 0) {
705
+ return c.json(
706
+ {
707
+ error: `This user owns ${ownedNests} nest${ownedNests === 1 ? "" : "s"}. Transfer or delete them before removing the user.`,
708
+ owned_nests: ownedNests
709
+ },
710
+ 409
711
+ );
712
+ }
713
+ db.transaction(() => {
714
+ db.prepare(
715
+ "DELETE FROM stewards WHERE user_id = ? OR lower(user_email) = lower(?)"
716
+ ).run(target.id, target.email);
717
+ db.prepare("DELETE FROM nest_collaborators WHERE user_id = ?").run(
718
+ target.id
719
+ );
720
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
721
+ db.prepare("DELETE FROM users WHERE id = ?").run(target.id);
722
+ })();
723
+ deleteAllSessionsForUser(target.id);
724
+ trackEvent("admin.remove_user", { adminId: callerId, userId: target.id });
725
+ return c.json({ ok: true, email: target.email });
726
+ });
995
727
  authRoutes.post("/invite", async (c) => {
996
728
  const body = await c.req.json();
997
729
  if (!body.email) throw new ValidationError("email is required");
@@ -1079,8 +811,13 @@ authRoutes.get("/teammates", async (c) => {
1079
811
  WHERE s.is_active = 1
1080
812
  AND NOT EXISTS (
1081
813
  SELECT 1 FROM users u
1082
- JOIN api_keys k ON k.user_id = u.id
1083
814
  WHERE lower(u.email) = lower(s.user_email)
815
+ AND (
816
+ u.is_invited = 0
817
+ OR EXISTS (
818
+ SELECT 1 FROM api_keys k WHERE k.user_id = u.id
819
+ )
820
+ )
1084
821
  )
1085
822
  ORDER BY s.user_email`
1086
823
  ).all();
@@ -1094,355 +831,64 @@ authRoutes.get("/teammates", async (c) => {
1094
831
  // src/nests/routes.ts
1095
832
  import { Hono as Hono2 } from "hono";
1096
833
 
1097
- // src/shared/access.ts
1098
- var PERMISSION_LEVELS = {
1099
- none: 0,
1100
- read: 1,
1101
- write: 2,
1102
- admin: 3,
1103
- owner: 4
1104
- };
1105
- function resolveNestPermission(nestId, userId) {
1106
- const db = getDb();
1107
- const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
1108
- if (!nest) return "none";
1109
- if (nest.user_id === userId) return "owner";
1110
- if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
1111
- return "owner";
1112
- }
1113
- const directGrant = db.prepare(
1114
- "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
1115
- ).get(nestId, userId);
1116
- if (directGrant) return directGrant.permission;
1117
- if (nest.visibility === "public") return "read";
1118
- return "none";
1119
- }
1120
- function permissionLevel(p) {
1121
- return PERMISSION_LEVELS[p] ?? 0;
1122
- }
834
+ // src/nodes/service.ts
835
+ import { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
1123
836
 
1124
- // src/nests/service.ts
1125
- import { join } from "path";
1126
- import { rmSync, mkdirSync } from "fs";
1127
- import { v4 as uuid2 } from "uuid";
1128
- import { NestStorage } from "@promptowl/contextnest-engine";
1129
- function nestPath(nestId) {
1130
- return join(config.DATA_ROOT, "nests", nestId);
1131
- }
1132
- function toSlug(name) {
1133
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1134
- }
1135
- function isStewardshipEnabled(nestId) {
1136
- const db = getDb();
1137
- const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
1138
- return !!row?.stewardship_enabled;
837
+ // src/governance/tag-index-service.ts
838
+ function normalizeTag(raw) {
839
+ return raw.trim().replace(/^#+/, "").toLowerCase();
1139
840
  }
1140
- function setStewardshipEnabled(nestId, enabled) {
841
+ function syncNodeTags(nestId, nodeId, tags) {
1141
842
  const db = getDb();
1142
- db.prepare("UPDATE nests SET stewardship_enabled = ? WHERE id = ?").run(
1143
- enabled ? 1 : 0,
1144
- nestId
843
+ const normalized = Array.from(
844
+ new Set(
845
+ tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
846
+ )
1145
847
  );
848
+ db.transaction(() => {
849
+ db.prepare(
850
+ "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
851
+ ).run(nestId, nodeId);
852
+ const insert = db.prepare(
853
+ "INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
854
+ );
855
+ for (const tag of normalized) {
856
+ insert.run(nestId, nodeId, tag);
857
+ }
858
+ })();
1146
859
  }
1147
- async function createNest(userId, name, description) {
1148
- const id = uuid2();
1149
- const slug = toSlug(name);
860
+ function removeNodeFromTagIndex(nestId, nodeId) {
1150
861
  const db = getDb();
1151
- const visibility = userId === ANON_USER_ID ? "public" : "private";
1152
862
  db.prepare(
1153
- "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)"
1154
- ).run(id, userId, name, slug, description || null, visibility);
1155
- const path = nestPath(id);
1156
- mkdirSync(path, { recursive: true });
1157
- const storage = new NestStorage(path);
1158
- await storage.init(name);
1159
- trackEvent("nest.create", { nestId: id, userId });
1160
- return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
863
+ "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
864
+ ).run(nestId, nodeId);
1161
865
  }
1162
- function listNests(userId) {
1163
- const db = getDb();
1164
- if (userId === ANON_USER_ID) {
1165
- return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
1166
- }
1167
- const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
1168
- if (!includeAnon) {
1169
- return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
1170
- }
1171
- return db.prepare(
1172
- "SELECT * FROM nests WHERE user_id = ? OR (user_id = ? AND visibility = 'public') ORDER BY created_at DESC"
1173
- ).all(userId, ANON_USER_ID);
1174
- }
1175
- function listSharedNests(userId) {
1176
- const db = getDb();
1177
- return db.prepare(
1178
- `SELECT DISTINCT n.* FROM nests n
1179
- JOIN nest_collaborators nc ON nc.nest_id = n.id
1180
- WHERE n.user_id != ? AND nc.user_id = ?
1181
- ORDER BY n.created_at DESC`
1182
- ).all(userId, userId);
1183
- }
1184
- function getNest(nestId) {
1185
- const db = getDb();
1186
- return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId) || null;
1187
- }
1188
- async function deleteNest(nestId) {
1189
- const db = getDb();
1190
- const wipe = db.transaction((id) => {
1191
- db.prepare("DELETE FROM approved_versions WHERE nest_id = ?").run(id);
1192
- db.prepare("DELETE FROM node_versions WHERE nest_id = ?").run(id);
1193
- db.prepare("DELETE FROM review_requests WHERE nest_id = ?").run(id);
1194
- db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id);
1195
- db.prepare("DELETE FROM nest_collaborators WHERE nest_id = ?").run(id);
1196
- try {
1197
- db.prepare("DELETE FROM node_tag_index WHERE nest_id = ?").run(id);
1198
- } catch {
1199
- }
1200
- db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(id);
1201
- db.prepare("DELETE FROM nests WHERE id = ?").run(id);
1202
- });
1203
- wipe(nestId);
1204
- const path = nestPath(nestId);
1205
- try {
1206
- rmSync(path, { recursive: true, force: true });
1207
- } catch (err) {
1208
- console.warn(`[nests] failed to remove nest directory ${path}:`, err);
1209
- }
1210
- trackEvent("nest.delete", { nestId });
1211
- }
1212
-
1213
- // src/nests/routes.ts
1214
- function effectivePermission(nestId, userId) {
1215
- if (config.AUTH_MODE === "open") {
1216
- const db = getDb();
1217
- const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1218
- if (nest && nest.user_id === ANON_USER_ID) return "owner";
1219
- }
1220
- return resolveNestPermission(nestId, userId);
1221
- }
1222
- var nestRoutes = new Hono2();
1223
- nestRoutes.get("/", async (c) => {
1224
- const userId = c.get("userId");
1225
- const owned = listNests(userId);
1226
- const shared = listSharedNests(userId);
1227
- const db = getDb();
1228
- const ownerEmailStmt = db.prepare(
1229
- "SELECT email FROM users WHERE id = ?"
1230
- );
1231
- const annotate = (n) => {
1232
- const permission = effectivePermission(n.id, userId);
1233
- const is_owner = permission === "owner";
1234
- let owner_email = null;
1235
- if (!is_owner && n.user_id !== ANON_USER_ID) {
1236
- const row = ownerEmailStmt.get(n.user_id);
1237
- owner_email = row?.email ?? null;
1238
- }
1239
- return { ...n, permission, is_owner, owner_email };
1240
- };
1241
- return c.json({
1242
- nests: [...owned.map(annotate), ...shared.map(annotate)]
1243
- });
1244
- });
1245
- nestRoutes.post("/", async (c) => {
1246
- const body = await c.req.json();
1247
- if (!body.name) {
1248
- throw new ValidationError("name is required");
1249
- }
1250
- const nest = await createNest(c.get("userId"), body.name, body.description);
1251
- return c.json({ nest }, 201);
1252
- });
1253
- nestRoutes.get("/:nestId", async (c) => {
1254
- const nestId = c.req.param("nestId");
1255
- const permission = effectivePermission(nestId, c.get("userId"));
1256
- if (permission === "none") {
1257
- throw new NotFoundError("Nest not found");
1258
- }
1259
- const nest = getNest(nestId);
1260
- return c.json({ nest, permission });
1261
- });
1262
- nestRoutes.delete("/:nestId", async (c) => {
1263
- const nestId = c.req.param("nestId");
1264
- const userId = c.get("userId");
1265
- const nest = getNest(nestId);
1266
- if (!nest) {
1267
- throw new NotFoundError("Nest not found");
1268
- }
1269
- const permission = effectivePermission(nestId, userId);
1270
- const isAnonOwned = nest.user_id === ANON_USER_ID;
1271
- const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
1272
- if (permission !== "owner" && !adminCaretaker) {
1273
- throw new ForbiddenError(
1274
- "You don't have permission to delete this nest. Only the nest owner can delete it."
1275
- );
1276
- }
1277
- await deleteNest(nestId);
1278
- return c.json({ deleted: true });
1279
- });
1280
- nestRoutes.get("/:nestId/settings", async (c) => {
1281
- const nestId = c.req.param("nestId");
1282
- const permission = effectivePermission(nestId, c.get("userId"));
1283
- if (permission === "none") {
1284
- throw new NotFoundError("Nest not found");
1285
- }
1286
- return c.json({
1287
- stewardship_enabled: isStewardshipEnabled(nestId)
1288
- });
1289
- });
1290
- nestRoutes.patch("/:nestId/settings", async (c) => {
1291
- const nestId = c.req.param("nestId");
1292
- const userId = c.get("userId");
1293
- const isServerAdmin = isLicenseAdminUserId(userId);
1294
- const permission = effectivePermission(nestId, userId);
1295
- if (!isServerAdmin && permission !== "owner") {
1296
- return c.json(
1297
- {
1298
- error: "Only the nest owner or the server license-admin can update nest settings."
1299
- },
1300
- 403
1301
- );
1302
- }
1303
- const body = await c.req.json();
1304
- if (typeof body.stewardship_enabled === "boolean") {
1305
- setStewardshipEnabled(nestId, body.stewardship_enabled);
1306
- }
1307
- return c.json({
1308
- stewardship_enabled: isStewardshipEnabled(nestId)
1309
- });
1310
- });
1311
-
1312
- // src/nests/sharing-routes.ts
1313
- import { Hono as Hono3 } from "hono";
1314
- import { v4 as uuid3 } from "uuid";
1315
- var sharingRoutes = new Hono3();
1316
- sharingRoutes.get("/collaborators", async (c) => {
1317
- const db = getDb();
1318
- const collabs = db.prepare(
1319
- `SELECT nc.*, u.email FROM nest_collaborators nc
1320
- LEFT JOIN users u ON nc.user_id = u.id
1321
- WHERE nc.nest_id = ?
1322
- ORDER BY nc.granted_at`
1323
- ).all(c.req.param("nestId"));
1324
- return c.json({ collaborators: collabs });
1325
- });
1326
- sharingRoutes.post("/collaborators", async (c) => {
1327
- const body = await c.req.json();
1328
- if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
1329
- throw new ValidationError("permission must be read, write, or admin");
1330
- }
1331
- const db = getDb();
1332
- let userId = body.user_id;
1333
- if (!userId && body.email) {
1334
- let user = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
1335
- if (!user) {
1336
- const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
1337
- userId = uuid3();
1338
- db.prepare(
1339
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
1340
- ).run(userId, body.email, null, await hashPassword2(uuid3()));
1341
- } else {
1342
- userId = user.id;
1343
- }
1344
- }
1345
- if (!userId) {
1346
- throw new ValidationError("user_id or email is required");
1347
- }
1348
- const collabId = uuid3();
1349
- db.prepare(
1350
- "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
1351
- ).run(
1352
- collabId,
1353
- c.req.param("nestId"),
1354
- userId,
1355
- body.permission,
1356
- c.get("userId")
1357
- );
1358
- const collab = db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
1359
- return c.json({ collaborator: collab }, 201);
1360
- });
1361
- sharingRoutes.patch("/collaborators/:collabId", async (c) => {
1362
- const body = await c.req.json();
1363
- if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
1364
- throw new ValidationError("permission must be read, write, or admin");
1365
- }
1366
- const db = getDb();
1367
- db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
1368
- body.permission,
1369
- c.req.param("collabId")
1370
- );
1371
- return c.json({ updated: true });
1372
- });
1373
- sharingRoutes.delete("/collaborators/:collabId", async (c) => {
1374
- const db = getDb();
1375
- db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
1376
- c.req.param("collabId")
1377
- );
1378
- return c.json({ removed: true });
1379
- });
1380
- sharingRoutes.patch("/visibility", async (c) => {
1381
- const body = await c.req.json();
1382
- if (!body.visibility || !["private", "public"].includes(body.visibility)) {
1383
- throw new ValidationError("visibility must be private or public");
1384
- }
1385
- const db = getDb();
1386
- db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
1387
- body.visibility,
1388
- c.req.param("nestId")
1389
- );
1390
- return c.json({ visibility: body.visibility });
1391
- });
1392
-
1393
- // src/governance/access-guard.ts
1394
- function resolveCallerEmail(userId) {
1395
- if (!userId) return "admin@localhost";
866
+
867
+ // src/governance/access-guard.ts
868
+ function resolveCallerEmail(userId) {
869
+ if (!userId) return "admin@localhost";
1396
870
  const db = getDb();
1397
871
  const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
1398
872
  return row?.email || "admin@localhost";
1399
873
  }
1400
- function canReadNode(nestId, nodeId, userEmail) {
874
+ function canReadNode(nestId, nodeId, userId, userEmail) {
875
+ if (isPublicReader(nestId, userId)) {
876
+ return getApprovedVersion(nestId, nodeId) !== null;
877
+ }
1401
878
  if (!isStewardshipEnabled(nestId)) return true;
1402
879
  return canUserAccess(nestId, nodeId, userEmail).allowed;
1403
880
  }
1404
- function filterAccessible(nestId, userEmail, nodes) {
881
+ function filterAccessible(nestId, userId, userEmail, nodes) {
882
+ if (isPublicReader(nestId, userId)) {
883
+ return nodes.filter((n) => getApprovedVersion(nestId, n.id) !== null);
884
+ }
1405
885
  if (!isStewardshipEnabled(nestId)) return nodes;
1406
886
  return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
1407
887
  }
1408
888
 
1409
- // src/nodes/routes.ts
1410
- import { Hono as Hono4 } from "hono";
1411
- import { serializeDocument } from "@promptowl/contextnest-engine";
1412
-
1413
- // src/governance/tag-index-service.ts
1414
- function normalizeTag(raw) {
1415
- return raw.trim().replace(/^#+/, "").toLowerCase();
1416
- }
1417
- function syncNodeTags(nestId, nodeId, tags) {
1418
- const db = getDb();
1419
- const normalized = Array.from(
1420
- new Set(
1421
- tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
1422
- )
1423
- );
1424
- db.transaction(() => {
1425
- db.prepare(
1426
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
1427
- ).run(nestId, nodeId);
1428
- const insert = db.prepare(
1429
- "INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
1430
- );
1431
- for (const tag of normalized) {
1432
- insert.run(nestId, nodeId, tag);
1433
- }
1434
- })();
1435
- }
1436
- function removeNodeFromTagIndex(nestId, nodeId) {
1437
- const db = getDb();
1438
- db.prepare(
1439
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
1440
- ).run(nestId, nodeId);
1441
- }
1442
-
1443
889
  // src/governance/external-edit-service.ts
1444
890
  import { readFile } from "fs/promises";
1445
- import { join as join2 } from "path";
891
+ import { join } from "path";
1446
892
  import {
1447
893
  detectDrift,
1448
894
  stageSuggestion,
@@ -1450,7 +896,10 @@ import {
1450
896
  rejectSuggestion,
1451
897
  listSuggestions,
1452
898
  readSuggestion,
1453
- VersionManager
899
+ parseDocument,
900
+ VersionManager,
901
+ computeContentHash,
902
+ getChecksumContent
1454
903
  } from "@promptowl/contextnest-engine";
1455
904
  var communityRbac = {
1456
905
  isCzar: () => false,
@@ -1458,7 +907,7 @@ var communityRbac = {
1458
907
  isDocOwner: () => true
1459
908
  };
1460
909
  function docPath(nestId, documentId) {
1461
- return join2(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
910
+ return join(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
1462
911
  }
1463
912
  async function readRaw(nestId, documentId) {
1464
913
  try {
@@ -1481,6 +930,18 @@ async function loadChainHead(storage, documentId) {
1481
930
  return null;
1482
931
  }
1483
932
  }
933
+ async function loadLatestApprovedNode(nestId, documentId) {
934
+ const { storage } = engineCache.get(nestId);
935
+ const head = await loadChainHead(storage, documentId);
936
+ if (!head) return null;
937
+ return parseDocument(docPath(nestId, documentId), head.content, documentId);
938
+ }
939
+ async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
940
+ const head = await loadChainHead(storage, documentId);
941
+ if (!head) return false;
942
+ const headBodyHash = computeContentHash(getChecksumContent(head.content));
943
+ return headBodyHash === liveBodyHash;
944
+ }
1484
945
  async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
1485
946
  const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
1486
947
  return res?.meta ?? null;
@@ -1493,6 +954,9 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1493
954
  if (raw == null) return null;
1494
955
  const drift = detectDrift(raw, node.frontmatter.checksum);
1495
956
  if (!drift.drifted) return null;
957
+ if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
958
+ return null;
959
+ }
1496
960
  const approved = await loadChainHead(storage, documentId);
1497
961
  if (!approved) return null;
1498
962
  const existing = await listSuggestions(storage, documentId);
@@ -1521,20 +985,35 @@ async function scanNestForDrift(nestId, actor = "system:scanner") {
1521
985
  async function getPendingChange(nestId, documentId) {
1522
986
  const { storage } = engineCache.get(nestId);
1523
987
  const list = await listSuggestions(storage, documentId);
1524
- if (list.length === 0) return null;
1525
- const latest = list[list.length - 1];
1526
- return {
1527
- suggestion_id: latest.suggestion_id,
1528
- detected_at: latest.detected_at,
1529
- source: latest.source,
1530
- proposed_hash: latest.proposed_hash
1531
- };
988
+ for (let i = list.length - 1; i >= 0; i--) {
989
+ const meta = list[i];
990
+ if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
991
+ continue;
992
+ }
993
+ return {
994
+ suggestion_id: meta.suggestion_id,
995
+ detected_at: meta.detected_at,
996
+ source: meta.source,
997
+ proposed_hash: meta.proposed_hash
998
+ };
999
+ }
1000
+ return null;
1532
1001
  }
1533
1002
  async function listNestExternalEdits(nestId) {
1534
1003
  const { storage } = engineCache.get(nestId);
1535
1004
  const docs = await storage.discoverDocuments();
1536
1005
  const lists = await Promise.all(
1537
- docs.map((doc) => listSuggestions(storage, doc.id))
1006
+ docs.map(async (doc) => {
1007
+ const metas = await listSuggestions(storage, doc.id);
1008
+ const fresh = [];
1009
+ for (const meta of metas) {
1010
+ if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
1011
+ continue;
1012
+ }
1013
+ fresh.push(meta);
1014
+ }
1015
+ return fresh;
1016
+ })
1538
1017
  );
1539
1018
  const entries = lists.flat().map((meta) => ({
1540
1019
  suggestion_id: meta.suggestion_id,
@@ -1590,7 +1069,7 @@ async function approveExternalEdit(input) {
1590
1069
  const node = await storage.readDocument(input.documentId);
1591
1070
  const versionNum = result.versionEntry.version;
1592
1071
  const tags = node.frontmatter.tags || [];
1593
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
1072
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-OCZUV2QP.js");
1594
1073
  createVersion2({
1595
1074
  nestId: input.nestId,
1596
1075
  nodeId: input.documentId,
@@ -1656,48 +1135,79 @@ function startDriftScanner(intervalMs = 3e4) {
1656
1135
  scannerTimer.unref?.();
1657
1136
  }
1658
1137
 
1659
- // src/nodes/routes.ts
1660
- var nodeRoutes = new Hono4();
1138
+ // src/nodes/service.ts
1139
+ function userIdFromEmail(email) {
1140
+ const db = getDb();
1141
+ const row = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(email);
1142
+ return row?.id ?? ANON_USER_ID;
1143
+ }
1144
+ var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
1145
+ var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
1146
+ function bodyOnly(nodeId, raw) {
1147
+ try {
1148
+ return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
1149
+ } catch {
1150
+ return raw;
1151
+ }
1152
+ }
1661
1153
  function toNodeResponse(node) {
1154
+ const fm = node.frontmatter;
1155
+ const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
1156
+ const tags = Array.isArray(fm.tags) ? fm.tags.map((t) => String(t)) : [];
1662
1157
  return {
1663
1158
  id: node.id,
1664
- title: node.frontmatter.title,
1665
- type: node.frontmatter.type || "document",
1666
- tags: node.frontmatter.tags || [],
1667
- // Widen to string so callers can layer review-workflow states like
1668
- // "pending_review" / "rejected" on top of the on-disk frontmatter
1669
- // status. The engine's Status enum only knows draft/approved.
1670
- status: node.frontmatter.status || "draft",
1671
- version: node.frontmatter.version || 1,
1672
- author: node.frontmatter.author,
1673
- description: node.frontmatter.description,
1674
- created_at: node.frontmatter.created_at,
1675
- updated_at: node.frontmatter.updated_at,
1676
- content: node.body,
1159
+ title,
1160
+ type: fm.type || "document",
1161
+ tags,
1162
+ status: fm.status || "draft",
1163
+ version: fm.version || 1,
1164
+ author: fm.author,
1165
+ description: fm.description,
1166
+ created_at: fm.created_at,
1167
+ updated_at: fm.updated_at,
1168
+ content: node.body || "",
1677
1169
  pendingChange: node.pendingChange ?? void 0
1678
1170
  };
1679
1171
  }
1680
- nodeRoutes.get("/", async (c) => {
1681
- const nestId = c.req.param("nestId");
1682
- const { storage } = engineCache.get(nestId);
1683
- const documents = await storage.discoverDocuments();
1684
- const userEmail = resolveCallerEmail(c.get("userId"));
1685
- const accessible = filterAccessible(nestId, userEmail, documents);
1686
- const pendingByDoc = /* @__PURE__ */ new Map();
1687
- await Promise.all(
1172
+ async function listNodesForCaller(nestId, userId, filters = {}) {
1173
+ const { storage, versions: versionManager } = engineCache.get(nestId);
1174
+ let documents = await storage.discoverDocuments();
1175
+ if (filters.type) {
1176
+ documents = documents.filter((n) => n.frontmatter.type === filters.type);
1177
+ }
1178
+ if (filters.tag) {
1179
+ const tag = normalizeTag2(filters.tag);
1180
+ documents = documents.filter(
1181
+ (n) => (n.frontmatter.tags || []).includes(tag)
1182
+ );
1183
+ }
1184
+ const userEmail = resolveCallerEmail(userId);
1185
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
1186
+ const publicReader = isPublicReader(nestId, userId);
1187
+ const enriched = await Promise.all(
1688
1188
  accessible.map(async (doc) => {
1189
+ const r = toNodeResponse(doc);
1190
+ if (publicReader) {
1191
+ const approved = getApprovedVersion(nestId, doc.id);
1192
+ if (approved != null) {
1193
+ try {
1194
+ const raw = await versionManager.reconstructVersion(doc.id, approved);
1195
+ r.content = bodyOnly(doc.id, raw);
1196
+ } catch (err) {
1197
+ console.error("reconstructVersion failed (list)", doc.id, approved, err);
1198
+ r.content = "";
1199
+ }
1200
+ r.version = approved;
1201
+ r.status = "published";
1202
+ }
1203
+ return r;
1204
+ }
1205
+ let pending = null;
1689
1206
  try {
1690
- pendingByDoc.set(doc.id, await getPendingChange(nestId, doc.id));
1207
+ pending = await getPendingChange(nestId, doc.id);
1691
1208
  } catch {
1692
- pendingByDoc.set(doc.id, null);
1209
+ pending = null;
1693
1210
  }
1694
- })
1695
- );
1696
- return c.json({
1697
- count: accessible.length,
1698
- nodes: accessible.map((doc) => {
1699
- const r = toNodeResponse(doc);
1700
- const pending = pendingByDoc.get(doc.id);
1701
1211
  if (pending) {
1702
1212
  r.pendingChange = pending;
1703
1213
  r.status = "external_edit_pending";
@@ -1706,48 +1216,43 @@ nodeRoutes.get("/", async (c) => {
1706
1216
  }
1707
1217
  return r;
1708
1218
  })
1709
- });
1710
- });
1711
- nodeRoutes.post("/", async (c) => {
1712
- const body = await c.req.json();
1713
- if (!body.title || !body.content) {
1714
- throw new ValidationError("title and content are required");
1715
- }
1716
- const nestId = c.req.param("nestId");
1219
+ );
1220
+ return filters.limit ? enriched.slice(0, filters.limit) : enriched;
1221
+ }
1222
+ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
1223
+ return listNodesForCaller(nestId, userIdFromEmail(userEmail), filters);
1224
+ }
1225
+ async function createNode(nestId, input, userEmail) {
1717
1226
  const { storage, versions: versionManager } = engineCache.get(nestId);
1718
- const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1227
+ const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1719
1228
  const id = `nodes/${slug}`;
1720
1229
  const now = (/* @__PURE__ */ new Date()).toISOString();
1721
- const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
1230
+ const tags = (input.tags || []).map(normalizeTag2);
1722
1231
  const hasStewards = isStewardshipEnabled(nestId);
1723
- const initialStatus = hasStewards ? "draft" : "approved";
1232
+ const initialStatus = hasStewards ? "draft" : "published";
1724
1233
  const initialVersion = hasStewards ? 1 : 0;
1725
1234
  let node = {
1726
1235
  id,
1727
1236
  filePath: "",
1728
1237
  frontmatter: {
1729
- title: body.title,
1730
- type: body.type || "document",
1238
+ title: input.title,
1239
+ type: input.type || "document",
1731
1240
  tags,
1732
- status: body.status || initialStatus,
1241
+ status: input.status || initialStatus,
1733
1242
  version: initialVersion,
1734
1243
  created_at: now,
1735
1244
  updated_at: now,
1736
- metadata: {
1737
- owners: ["*"],
1738
- scope: body.scope || "team"
1739
- }
1245
+ metadata: { owners: ["*"], scope: input.scope || "team" }
1740
1246
  },
1741
- body: body.content,
1247
+ body: input.content,
1742
1248
  rawContent: ""
1743
1249
  };
1744
- const serialized = serializeDocument(node);
1745
- await storage.writeDocument(id, serialized);
1250
+ await storage.writeDocument(id, serializeDocument(node));
1746
1251
  syncNodeTags(nestId, id, tags);
1747
- const authorEmail = getUserEmail(c);
1252
+ let savedVersion = 1;
1748
1253
  if (hasStewards) {
1749
1254
  try {
1750
- await versionManager.createVersion(node, authorEmail);
1255
+ await versionManager.createVersion(node, userEmail);
1751
1256
  } catch (err) {
1752
1257
  console.error("VersionManager.createVersion failed (node create)", err);
1753
1258
  }
@@ -1755,28 +1260,28 @@ nodeRoutes.post("/", async (c) => {
1755
1260
  nestId,
1756
1261
  nodeId: id,
1757
1262
  version: 1,
1758
- content: body.content,
1759
- author: authorEmail,
1263
+ content: input.content,
1264
+ author: userEmail,
1760
1265
  status: "draft",
1761
1266
  tags
1762
1267
  });
1763
1268
  } else {
1764
1269
  try {
1765
1270
  const result = await safePublishDocument(storage, id, {
1766
- editedBy: authorEmail,
1271
+ editedBy: userEmail,
1767
1272
  note: "Auto-published on create (no stewards configured)"
1768
1273
  });
1769
- const publishedVersion = result.node.frontmatter.version || 2;
1274
+ savedVersion = result.node.frontmatter.version || 1;
1770
1275
  createVersion({
1771
1276
  nestId,
1772
1277
  nodeId: id,
1773
- version: publishedVersion,
1278
+ version: savedVersion,
1774
1279
  content: result.node.body || "",
1775
- author: authorEmail,
1280
+ author: userEmail,
1776
1281
  status: "published",
1777
1282
  tags
1778
1283
  });
1779
- setApprovedVersion(nestId, id, publishedVersion, authorEmail);
1284
+ setApprovedVersion(nestId, id, savedVersion, userEmail);
1780
1285
  node = result.node;
1781
1286
  } catch (err) {
1782
1287
  console.error("publishDocument failed (node create auto-publish)", err);
@@ -1784,15 +1289,486 @@ nodeRoutes.post("/", async (c) => {
1784
1289
  nestId,
1785
1290
  nodeId: id,
1786
1291
  version: 1,
1787
- content: body.content,
1788
- author: authorEmail,
1292
+ content: input.content,
1293
+ author: userEmail,
1789
1294
  status: "draft",
1790
1295
  tags
1791
1296
  });
1792
1297
  }
1793
1298
  }
1794
1299
  trackEvent("node.create", { nestId, nodeId: id });
1795
- const resolved = resolveStewardsForNode(nestId, id);
1300
+ return { node, version: savedVersion };
1301
+ }
1302
+ async function registerImportedDocuments(nestId, userEmail) {
1303
+ const { storage } = engineCache.get(nestId);
1304
+ let docs;
1305
+ try {
1306
+ docs = await storage.discoverDocuments();
1307
+ } catch (err) {
1308
+ console.error("registerImportedDocuments: discovery failed", nestId, err);
1309
+ return 0;
1310
+ }
1311
+ let registered = 0;
1312
+ for (const doc of docs) {
1313
+ const nodeId = doc.id;
1314
+ if (getCurrentVersion(nestId, nodeId) > 0) continue;
1315
+ const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
1316
+ const tags = rawTags.map((t) => normalizeTag2(String(t)));
1317
+ const fmVersion = Number(doc.frontmatter?.version);
1318
+ let version = Number.isFinite(fmVersion) && fmVersion > 0 ? fmVersion : 1;
1319
+ let content = doc.body || "";
1320
+ try {
1321
+ const result = await safePublishDocument(storage, nodeId, {
1322
+ editedBy: userEmail,
1323
+ note: "Imported from existing folder"
1324
+ });
1325
+ version = result.node.frontmatter.version || version;
1326
+ content = result.node.body || content;
1327
+ } catch (err) {
1328
+ console.error("safePublishDocument failed (import register)", nodeId, err);
1329
+ }
1330
+ createVersion({
1331
+ nestId,
1332
+ nodeId,
1333
+ version,
1334
+ content,
1335
+ author: userEmail,
1336
+ status: "published",
1337
+ changeNote: "Imported from existing folder",
1338
+ tags
1339
+ });
1340
+ setApprovedVersion(nestId, nodeId, version, userEmail);
1341
+ syncNodeTags(nestId, nodeId, tags);
1342
+ registered++;
1343
+ }
1344
+ trackEvent("nest.import.documents", { nestId, registered });
1345
+ return registered;
1346
+ }
1347
+ async function updateNode(nestId, nodeId, patch, userEmail) {
1348
+ const { storage, versions: versionManager } = engineCache.get(nestId);
1349
+ let node;
1350
+ try {
1351
+ node = await storage.readDocument(nodeId);
1352
+ } catch {
1353
+ throw new NotFoundError(`Node not found: ${nodeId}`);
1354
+ }
1355
+ if (patch.content !== void 0) {
1356
+ node = { ...node, body: patch.content };
1357
+ }
1358
+ if (patch.append) {
1359
+ node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
1360
+ }
1361
+ if (patch.tags) {
1362
+ const newTags = patch.tags.map(normalizeTag2);
1363
+ const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
1364
+ node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
1365
+ }
1366
+ if (patch.status) {
1367
+ node = { ...node, frontmatter: { ...node.frontmatter, status: patch.status } };
1368
+ }
1369
+ if (patch.title) {
1370
+ node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
1371
+ }
1372
+ const hasStewards = isStewardshipEnabled(nestId);
1373
+ const currentTags = node.frontmatter.tags || [];
1374
+ if (hasStewards && getPendingReview(nestId, nodeId)) {
1375
+ throw new LockedError(
1376
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
1377
+ );
1378
+ }
1379
+ let responseVersion;
1380
+ if (hasStewards) {
1381
+ const currentVersion = getCurrentVersion(nestId, nodeId);
1382
+ const newVersion = currentVersion + 1;
1383
+ node = {
1384
+ ...node,
1385
+ frontmatter: {
1386
+ ...node.frontmatter,
1387
+ version: newVersion,
1388
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1389
+ // Drop stale published-state checksum so the next verified read
1390
+ // doesn't flag this write as external drift.
1391
+ checksum: void 0
1392
+ }
1393
+ };
1394
+ node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
1395
+ await storage.writeDocument(nodeId, serializeDocument(node));
1396
+ syncNodeTags(nestId, nodeId, currentTags);
1397
+ try {
1398
+ await versionManager.createVersion(node, userEmail, { note: patch.changeNote });
1399
+ } catch (err) {
1400
+ console.error("VersionManager.createVersion failed (node patch)", err);
1401
+ }
1402
+ createVersion({
1403
+ nestId,
1404
+ nodeId,
1405
+ version: newVersion,
1406
+ content: node.body || "",
1407
+ author: userEmail,
1408
+ status: "draft",
1409
+ tags: currentTags,
1410
+ changeNote: patch.changeNote
1411
+ });
1412
+ responseVersion = newVersion;
1413
+ } else {
1414
+ node = {
1415
+ ...node,
1416
+ frontmatter: {
1417
+ ...node.frontmatter,
1418
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1419
+ checksum: void 0
1420
+ }
1421
+ };
1422
+ node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
1423
+ await storage.writeDocument(nodeId, serializeDocument(node));
1424
+ syncNodeTags(nestId, nodeId, currentTags);
1425
+ let publishedVersion = (node.frontmatter.version || 0) + 1;
1426
+ try {
1427
+ const result = await safePublishDocument(storage, nodeId, {
1428
+ editedBy: userEmail,
1429
+ note: patch.changeNote || "Auto-published on edit (no stewards)"
1430
+ });
1431
+ publishedVersion = result.node.frontmatter.version || publishedVersion;
1432
+ node = result.node;
1433
+ } catch (err) {
1434
+ console.error("publishDocument failed (node patch auto-publish)", err);
1435
+ }
1436
+ createVersion({
1437
+ nestId,
1438
+ nodeId,
1439
+ version: publishedVersion,
1440
+ content: node.body || "",
1441
+ author: userEmail,
1442
+ status: "published",
1443
+ tags: currentTags,
1444
+ changeNote: patch.changeNote
1445
+ });
1446
+ setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
1447
+ responseVersion = publishedVersion;
1448
+ }
1449
+ return { node, version: responseVersion };
1450
+ }
1451
+
1452
+ // src/nests/routes.ts
1453
+ function effectivePermission(nestId, userId) {
1454
+ if (config.AUTH_MODE === "open") {
1455
+ const db = getDb();
1456
+ const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1457
+ if (nest && nest.user_id === ANON_USER_ID) return "owner";
1458
+ }
1459
+ return resolveNestPermission(nestId, userId);
1460
+ }
1461
+ var nestRoutes = new Hono2();
1462
+ nestRoutes.get("/", async (c) => {
1463
+ const userId = c.get("userId");
1464
+ const owned = listNests(userId);
1465
+ const shared = listSharedNests(userId);
1466
+ const publicExtras = listPublicNests(userId);
1467
+ const db = getDb();
1468
+ const ownerEmailStmt = db.prepare(
1469
+ "SELECT email FROM users WHERE id = ?"
1470
+ );
1471
+ const callerEmail = resolveCallerEmail(userId);
1472
+ const annotate = (n) => {
1473
+ const permission = effectivePermission(n.id, userId);
1474
+ const is_owner = permission === "owner";
1475
+ let owner_email = null;
1476
+ const roles = is_owner ? ["owner"] : resolveUserRoles(n.id, callerEmail);
1477
+ if (!is_owner && n.user_id !== ANON_USER_ID) {
1478
+ const row = ownerEmailStmt.get(n.user_id);
1479
+ owner_email = row?.email ?? null;
1480
+ }
1481
+ return { ...n, permission, is_owner, owner_email, roles };
1482
+ };
1483
+ const seen = /* @__PURE__ */ new Set();
1484
+ const out = [];
1485
+ for (const n of [...owned, ...shared, ...publicExtras]) {
1486
+ if (seen.has(n.id)) continue;
1487
+ seen.add(n.id);
1488
+ out.push(annotate(n));
1489
+ }
1490
+ return c.json({ nests: out });
1491
+ });
1492
+ nestRoutes.post("/", async (c) => {
1493
+ const body = await c.req.json();
1494
+ if (!body.name) {
1495
+ throw new ValidationError("name is required");
1496
+ }
1497
+ const nest = await createNest(c.get("userId"), body.name, body.description);
1498
+ return c.json({ nest }, 201);
1499
+ });
1500
+ nestRoutes.post("/import", async (c) => {
1501
+ const body = await c.req.json();
1502
+ if (!body.name) {
1503
+ throw new ValidationError("name is required");
1504
+ }
1505
+ const files = Array.isArray(body.files) ? body.files : [];
1506
+ const userId = c.get("userId");
1507
+ const nest = await importNest(userId, body.name, files);
1508
+ const documents = await registerImportedDocuments(
1509
+ nest.id,
1510
+ resolveCallerEmail(userId)
1511
+ );
1512
+ return c.json({ nest, documents }, 201);
1513
+ });
1514
+ nestRoutes.get("/:nestId", async (c) => {
1515
+ const nestId = c.req.param("nestId");
1516
+ const userId = c.get("userId");
1517
+ const permission = effectivePermission(nestId, userId);
1518
+ if (permission === "none") {
1519
+ throw new NotFoundError("Nest not found");
1520
+ }
1521
+ const email = resolveCallerEmail(userId);
1522
+ let roles = resolveUserRoles(nestId, email);
1523
+ if (permission === "owner" && !roles.includes("owner")) {
1524
+ roles = ["owner", ...roles];
1525
+ }
1526
+ const myStewards = getStewardsForUser(nestId, email);
1527
+ const nest = getNest(nestId);
1528
+ return c.json({ nest, permission, roles, myStewards });
1529
+ });
1530
+ nestRoutes.delete("/:nestId", async (c) => {
1531
+ const nestId = c.req.param("nestId");
1532
+ const userId = c.get("userId");
1533
+ const nest = getNest(nestId);
1534
+ if (!nest) {
1535
+ throw new NotFoundError("Nest not found");
1536
+ }
1537
+ const permission = effectivePermission(nestId, userId);
1538
+ const isAnonOwned = nest.user_id === ANON_USER_ID;
1539
+ const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
1540
+ if (permission !== "owner" && !adminCaretaker) {
1541
+ throw new ForbiddenError(
1542
+ "You don't have permission to delete this nest. Only the nest owner can delete it."
1543
+ );
1544
+ }
1545
+ await deleteNest(nestId);
1546
+ return c.json({ deleted: true });
1547
+ });
1548
+ nestRoutes.get("/:nestId/settings", async (c) => {
1549
+ const nestId = c.req.param("nestId");
1550
+ const permission = effectivePermission(nestId, c.get("userId"));
1551
+ if (permission === "none") {
1552
+ throw new NotFoundError("Nest not found");
1553
+ }
1554
+ return c.json({
1555
+ stewardship_enabled: isStewardshipEnabled(nestId),
1556
+ allow_self_approve: nestAllowsSelfApprove(nestId)
1557
+ });
1558
+ });
1559
+ nestRoutes.patch("/:nestId/settings", async (c) => {
1560
+ const nestId = c.req.param("nestId");
1561
+ const userId = c.get("userId");
1562
+ const isServerAdmin = isLicenseAdminUserId(userId);
1563
+ const permission = effectivePermission(nestId, userId);
1564
+ if (!isServerAdmin && permission !== "owner") {
1565
+ return c.json(
1566
+ {
1567
+ error: "Only the nest owner or the server license-admin can update nest settings."
1568
+ },
1569
+ 403
1570
+ );
1571
+ }
1572
+ const body = await c.req.json();
1573
+ let wiped = null;
1574
+ if (typeof body.stewardship_enabled === "boolean") {
1575
+ if (body.stewardship_enabled) {
1576
+ setStewardshipEnabled(nestId, true);
1577
+ } else {
1578
+ wiped = disableStewardshipAndWipeGovernance(nestId);
1579
+ }
1580
+ }
1581
+ if (typeof body.allow_self_approve === "boolean") {
1582
+ setAllowSelfApprove(nestId, body.allow_self_approve);
1583
+ }
1584
+ return c.json({
1585
+ stewardship_enabled: isStewardshipEnabled(nestId),
1586
+ allow_self_approve: nestAllowsSelfApprove(nestId),
1587
+ wiped
1588
+ });
1589
+ });
1590
+
1591
+ // src/nests/sharing-routes.ts
1592
+ import { Hono as Hono3 } from "hono";
1593
+
1594
+ // src/nests/sharing-service.ts
1595
+ import { v4 as uuid2 } from "uuid";
1596
+ var VALID_PERMISSIONS = ["read", "write", "admin"];
1597
+ async function addCollaborator(params) {
1598
+ const { nestId } = params;
1599
+ if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
1600
+ throw new ValidationError("permission must be read, write, or admin");
1601
+ }
1602
+ const db = getDb();
1603
+ let userId = params.userId;
1604
+ if (!userId && params.email) {
1605
+ const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(params.email);
1606
+ if (existing) {
1607
+ userId = existing.id;
1608
+ } else {
1609
+ const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
1610
+ userId = uuid2();
1611
+ db.prepare(
1612
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
1613
+ ).run(userId, params.email, null, await hashPassword2(uuid2()));
1614
+ }
1615
+ }
1616
+ if (!userId) {
1617
+ throw new ValidationError("user_id or email is required");
1618
+ }
1619
+ const ownerRow = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1620
+ if (!ownerRow) {
1621
+ throw new ValidationError("Nest not found");
1622
+ }
1623
+ if (ownerRow.user_id === userId) {
1624
+ throw new ValidationError(
1625
+ "The nest owner already has full access and can't be added as a collaborator."
1626
+ );
1627
+ }
1628
+ const selfByEmail = !!params.email && !!params.grantedByEmail && params.email.trim().toLowerCase() === params.grantedByEmail.trim().toLowerCase();
1629
+ const selfById = !!params.grantedByUserId && params.grantedByUserId === userId;
1630
+ if (selfByEmail || selfById) {
1631
+ throw new ValidationError("You can't add yourself as a collaborator.");
1632
+ }
1633
+ const dupe = db.prepare("SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?").get(nestId, userId);
1634
+ if (dupe) {
1635
+ throw new ConflictError(
1636
+ `${params.email || "This user"} already has access to this nest. Change their permission instead of adding them again.`
1637
+ );
1638
+ }
1639
+ const granterByEmail = !params.grantedByUserId && params.grantedByEmail ? db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(params.grantedByEmail)?.id : void 0;
1640
+ const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
1641
+ const collabId = uuid2();
1642
+ db.prepare(
1643
+ "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
1644
+ ).run(collabId, nestId, userId, params.permission, granterId);
1645
+ return db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
1646
+ }
1647
+
1648
+ // src/nests/sharing-routes.ts
1649
+ var sharingRoutes = new Hono3();
1650
+ sharingRoutes.get("/collaborators", async (c) => {
1651
+ const db = getDb();
1652
+ const nestId = c.req.param("nestId");
1653
+ const collabs = db.prepare(
1654
+ `SELECT nc.*, u.email FROM nest_collaborators nc
1655
+ LEFT JOIN users u ON nc.user_id = u.id
1656
+ WHERE nc.nest_id = ?
1657
+ ORDER BY nc.granted_at`
1658
+ ).all(nestId);
1659
+ const enriched = collabs.map((collab) => {
1660
+ if (!collab.email) return { ...collab, stewardRoles: [], roles: [] };
1661
+ return {
1662
+ ...collab,
1663
+ stewardRoles: getStewardRolesForUser(nestId, collab.email),
1664
+ roles: resolveUserRoles(nestId, collab.email)
1665
+ };
1666
+ });
1667
+ return c.json({ collaborators: enriched });
1668
+ });
1669
+ sharingRoutes.post("/collaborators", async (c) => {
1670
+ const body = await c.req.json();
1671
+ const collab = await addCollaborator({
1672
+ nestId: c.req.param("nestId"),
1673
+ email: body.email,
1674
+ userId: body.user_id,
1675
+ permission: body.permission ?? "",
1676
+ grantedByUserId: c.get("userId")
1677
+ });
1678
+ return c.json({ collaborator: collab }, 201);
1679
+ });
1680
+ sharingRoutes.patch("/collaborators/:collabId", async (c) => {
1681
+ const body = await c.req.json();
1682
+ if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
1683
+ throw new ValidationError("permission must be read, write, or admin");
1684
+ }
1685
+ const db = getDb();
1686
+ db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
1687
+ body.permission,
1688
+ c.req.param("collabId")
1689
+ );
1690
+ return c.json({ updated: true });
1691
+ });
1692
+ sharingRoutes.delete("/collaborators/:collabId", async (c) => {
1693
+ const db = getDb();
1694
+ db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
1695
+ c.req.param("collabId")
1696
+ );
1697
+ return c.json({ removed: true });
1698
+ });
1699
+ sharingRoutes.patch("/visibility", async (c) => {
1700
+ const body = await c.req.json();
1701
+ if (!body.visibility || !["private", "public"].includes(body.visibility)) {
1702
+ throw new ValidationError("visibility must be private or public");
1703
+ }
1704
+ const db = getDb();
1705
+ db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
1706
+ body.visibility,
1707
+ c.req.param("nestId")
1708
+ );
1709
+ return c.json({ visibility: body.visibility });
1710
+ });
1711
+
1712
+ // src/nodes/routes.ts
1713
+ import { Hono as Hono4 } from "hono";
1714
+
1715
+ // src/nodes/markdown-export.ts
1716
+ function nodeToMarkdown(node) {
1717
+ const tags = node.tags ?? [];
1718
+ return [
1719
+ "---",
1720
+ `title: ${JSON.stringify(node.title ?? "")}`,
1721
+ `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
1722
+ `status: ${JSON.stringify(node.status ?? "")}`,
1723
+ `id: ${JSON.stringify(node.id)}`,
1724
+ "---",
1725
+ ""
1726
+ ].join("\n") + (node.body ?? "");
1727
+ }
1728
+ function nodesToMarkdown(nodes) {
1729
+ return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
1730
+ }
1731
+ function isMarkdownFormat(c) {
1732
+ return c.req.query("format") === "markdown";
1733
+ }
1734
+
1735
+ // src/nodes/routes.ts
1736
+ var nodeRoutes = new Hono4();
1737
+ function nodeAsMarkdown(response, nodeId) {
1738
+ return nodeToMarkdown({
1739
+ id: nodeId,
1740
+ title: response.title,
1741
+ tags: response.tags,
1742
+ status: response.status,
1743
+ body: response.content
1744
+ });
1745
+ }
1746
+ nodeRoutes.get("/", async (c) => {
1747
+ const nestId = c.req.param("nestId");
1748
+ const userId = c.get("userId");
1749
+ const nodes = await listNodesForCaller(nestId, userId);
1750
+ return c.json({ count: nodes.length, nodes });
1751
+ });
1752
+ nodeRoutes.post("/", async (c) => {
1753
+ const body = await c.req.json();
1754
+ if (!body.title || !body.content) {
1755
+ throw new ValidationError("title and content are required");
1756
+ }
1757
+ const nestId = c.req.param("nestId");
1758
+ const authorEmail = getUserEmail(c);
1759
+ const { node } = await createNode(
1760
+ nestId,
1761
+ {
1762
+ title: body.title,
1763
+ content: body.content,
1764
+ type: body.type,
1765
+ tags: body.tags,
1766
+ scope: body.scope,
1767
+ status: body.status
1768
+ },
1769
+ authorEmail
1770
+ );
1771
+ const resolved = resolveStewardsForNode(nestId, node.id);
1796
1772
  return c.json({
1797
1773
  node: toNodeResponse(node),
1798
1774
  stewards: resolved.length > 0 ? resolved.map((r) => ({
@@ -1805,7 +1781,7 @@ nodeRoutes.post("/", async (c) => {
1805
1781
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1806
1782
  const nestId = c.req.param("nestId");
1807
1783
  const nodeId = c.req.param("nodeId");
1808
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-C5D2O7ZE.js");
1784
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-VOD5HY3I.js");
1809
1785
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
1810
1786
  nestId,
1811
1787
  nodeId
@@ -1826,7 +1802,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1826
1802
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1827
1803
  const nestId = c.req.param("nestId");
1828
1804
  const nodeId = c.req.param("nodeId");
1829
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
1805
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-OCZUV2QP.js");
1830
1806
  const allVersions = getVersions2(nestId, nodeId);
1831
1807
  const approved = getApprovedVersion2(nestId, nodeId);
1832
1808
  const db = getDb();
@@ -1857,16 +1833,52 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1857
1833
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
1858
1834
  const nestId = c.req.param("nestId");
1859
1835
  const nodeId = c.req.param("nodeId");
1860
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4WS3XL6K.js");
1836
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-GYX3AW6E.js");
1861
1837
  const history = getReviewHistory2(nestId, nodeId);
1862
1838
  return c.json({ reviews: history });
1863
1839
  });
1840
+ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
1841
+ const nestId = c.req.param("nestId");
1842
+ const nodeId = c.req.param("nodeId");
1843
+ const { versions: versionManager } = engineCache.get(nestId);
1844
+ const userId = c.get("userId");
1845
+ const userEmail = resolveCallerEmail(userId);
1846
+ if (!canReadNode(nestId, nodeId, userId, userEmail)) {
1847
+ return c.json(
1848
+ { error: "Access denied \u2014 no steward assignment for this node" },
1849
+ 403
1850
+ );
1851
+ }
1852
+ const body = await c.req.json().catch(() => ({}));
1853
+ const targetVersion = Number(body.targetVersion);
1854
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
1855
+ throw new ValidationError("targetVersion (a positive integer) is required");
1856
+ }
1857
+ let raw;
1858
+ try {
1859
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
1860
+ } catch {
1861
+ throw new NotFoundError(
1862
+ `Version ${targetVersion} not found for ${nodeId}`
1863
+ );
1864
+ }
1865
+ const content = bodyOnly(nodeId, raw);
1866
+ const { node, version } = await updateNode(
1867
+ nestId,
1868
+ nodeId,
1869
+ { content, changeNote: `Restored from version ${targetVersion}` },
1870
+ userEmail
1871
+ );
1872
+ trackEvent("node.revert", { nestId, nodeId, targetVersion });
1873
+ return c.json({ ok: true, version, node: toNodeResponse(node) });
1874
+ });
1864
1875
  nodeRoutes.get("/:nodeId{.+}", async (c) => {
1865
1876
  const nestId = c.req.param("nestId");
1866
1877
  const nodeId = c.req.param("nodeId");
1867
- const { storage } = engineCache.get(nestId);
1868
- const userEmail = resolveCallerEmail(c.get("userId"));
1869
- if (!canReadNode(nestId, nodeId, userEmail)) {
1878
+ const { storage, versions: versionManager } = engineCache.get(nestId);
1879
+ const userId = c.get("userId");
1880
+ const userEmail = resolveCallerEmail(userId);
1881
+ if (!canReadNode(nestId, nodeId, userId, userEmail)) {
1870
1882
  return c.json(
1871
1883
  { error: "Access denied \u2014 no steward assignment for this node" },
1872
1884
  403
@@ -1886,15 +1898,59 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1886
1898
  } catch (err) {
1887
1899
  console.error("[external-edit] stage-on-read failed:", err);
1888
1900
  }
1901
+ try {
1902
+ const latest = await loadLatestApprovedNode(nestId, nodeId);
1903
+ if (latest) {
1904
+ node = { ...latest, pendingChange: node.pendingChange };
1905
+ }
1906
+ } catch (err) {
1907
+ console.error("[external-edit] reconstruct-latest failed:", err);
1908
+ }
1889
1909
  }
1890
1910
  const response = toNodeResponse(node);
1911
+ if (isPublicReader(nestId, userId)) {
1912
+ const approved = getApprovedVersion(nestId, nodeId);
1913
+ if (approved != null) {
1914
+ try {
1915
+ const raw = await versionManager.reconstructVersion(
1916
+ nodeId,
1917
+ approved
1918
+ );
1919
+ response.content = bodyOnly(nodeId, raw);
1920
+ } catch (err) {
1921
+ console.error(
1922
+ "reconstructVersion failed (public single)",
1923
+ nodeId,
1924
+ approved,
1925
+ err
1926
+ );
1927
+ response.content = "";
1928
+ }
1929
+ response.version = approved;
1930
+ response.status = "published";
1931
+ if (isMarkdownFormat(c)) {
1932
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
1933
+ "Content-Type": "text/markdown; charset=utf-8"
1934
+ });
1935
+ }
1936
+ return c.json({ node: response });
1937
+ }
1938
+ }
1891
1939
  response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
1940
+ if (response.status === "pending_review") {
1941
+ const pending = getPendingReview(nestId, nodeId);
1942
+ response.pendingReviewBy = pending?.requestedBy ?? null;
1943
+ }
1944
+ if (isMarkdownFormat(c)) {
1945
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
1946
+ "Content-Type": "text/markdown; charset=utf-8"
1947
+ });
1948
+ }
1892
1949
  return c.json({ node: response });
1893
1950
  });
1894
1951
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1895
1952
  const nestId = c.req.param("nestId");
1896
1953
  const nodeId = c.req.param("nodeId");
1897
- const { storage, versions: versionManager } = engineCache.get(nestId);
1898
1954
  const body = await c.req.json();
1899
1955
  const baseVersionHeader = c.req.header("X-Base-Version");
1900
1956
  if (baseVersionHeader) {
@@ -1914,133 +1970,31 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1914
1970
  );
1915
1971
  }
1916
1972
  }
1917
- let node;
1918
- try {
1919
- node = await storage.readDocument(nodeId);
1920
- } catch {
1921
- throw new NotFoundError(`Node not found: ${nodeId}`);
1922
- }
1923
- if (body.content !== void 0) {
1924
- node = { ...node, body: body.content };
1925
- }
1926
- if (body.append) {
1927
- node = { ...node, body: (node.body || "") + "\n\n" + body.append };
1928
- }
1929
- if (body.tags) {
1930
- const newTags = body.tags.map((t) => t.startsWith("#") ? t : `#${t}`);
1931
- const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
1932
- node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
1933
- }
1934
- if (body.status) {
1935
- node = {
1936
- ...node,
1937
- frontmatter: { ...node.frontmatter, status: body.status }
1938
- };
1939
- }
1940
- if (body.title) {
1941
- node = {
1942
- ...node,
1943
- frontmatter: { ...node.frontmatter, title: body.title }
1944
- };
1945
- }
1946
1973
  const authorEmail = getUserEmail(c);
1947
- const hasStewards = isStewardshipEnabled(nestId);
1948
- const currentTags = node.frontmatter.tags || [];
1949
- const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-4WS3XL6K.js");
1950
- if (getPendingReview2(nestId, nodeId)) {
1951
- cancelReview2({ nestId, nodeId, cancelledBy: authorEmail });
1952
- }
1953
- let responseVersion;
1954
- if (hasStewards) {
1955
- const currentVersion = getCurrentVersion(nestId, nodeId);
1956
- const newVersion = currentVersion + 1;
1957
- node = {
1958
- ...node,
1959
- frontmatter: {
1960
- ...node.frontmatter,
1961
- version: newVersion,
1962
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1963
- // Strip the stale published-state checksum — the body just
1964
- // changed, so leaving the old hash would make the next GET (with
1965
- // verifyChecksum: true) flag the file as an external edit even
1966
- // though *this* app wrote it. Engine treats absent checksum as
1967
- // "no baseline" and skips drift detection until publish runs.
1968
- checksum: void 0
1969
- }
1970
- };
1971
- const fm = Object.fromEntries(
1972
- Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
1973
- );
1974
- node = { ...node, frontmatter: fm };
1975
- await storage.writeDocument(nodeId, serializeDocument(node));
1976
- syncNodeTags(nestId, nodeId, currentTags);
1977
- try {
1978
- await versionManager.createVersion(node, authorEmail, {
1979
- note: body.changeNote
1980
- });
1981
- } catch (err) {
1982
- console.error("VersionManager.createVersion failed (node patch)", err);
1983
- }
1984
- createVersion({
1985
- nestId,
1986
- nodeId,
1987
- version: newVersion,
1988
- content: node.body || "",
1989
- author: authorEmail,
1990
- status: "draft",
1991
- tags: currentTags,
1992
- changeNote: body.changeNote
1993
- });
1994
- responseVersion = newVersion;
1995
- } else {
1996
- node = {
1997
- ...node,
1998
- frontmatter: {
1999
- ...node.frontmatter,
2000
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2001
- // Drop the prior published-state checksum before the interim
2002
- // write — publish recomputes it from the new body, but if
2003
- // publish errors out the file would otherwise be left with new
2004
- // body + stale checksum and drift on next read.
2005
- checksum: void 0
2006
- }
2007
- };
2008
- const fm = Object.fromEntries(
2009
- Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
2010
- );
2011
- node = { ...node, frontmatter: fm };
2012
- await storage.writeDocument(nodeId, serializeDocument(node));
2013
- syncNodeTags(nestId, nodeId, currentTags);
2014
- let publishedVersion = (node.frontmatter.version || 0) + 1;
2015
- try {
2016
- const result = await safePublishDocument(storage, nodeId, {
2017
- editedBy: authorEmail,
2018
- note: body.changeNote || "Auto-published on edit (no stewards)"
2019
- });
2020
- publishedVersion = result.node.frontmatter.version || publishedVersion;
2021
- node = result.node;
2022
- } catch (err) {
2023
- console.error("publishDocument failed (node patch auto-publish)", err);
2024
- }
2025
- createVersion({
2026
- nestId,
2027
- nodeId,
2028
- version: publishedVersion,
2029
- content: node.body || "",
2030
- author: authorEmail,
2031
- status: "published",
2032
- tags: currentTags,
1974
+ const { node, version: responseVersion } = await updateNode(
1975
+ nestId,
1976
+ nodeId,
1977
+ {
1978
+ content: body.content,
1979
+ append: body.append,
1980
+ tags: body.tags,
1981
+ title: body.title,
1982
+ status: body.status,
2033
1983
  changeNote: body.changeNote
2034
- });
2035
- setApprovedVersion(nestId, nodeId, publishedVersion, authorEmail);
2036
- responseVersion = publishedVersion;
2037
- }
1984
+ },
1985
+ authorEmail
1986
+ );
2038
1987
  return c.json({ node: toNodeResponse(node), version: responseVersion });
2039
1988
  });
2040
1989
  nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2041
1990
  const nestId = c.req.param("nestId");
2042
1991
  const nodeId = c.req.param("nodeId");
2043
1992
  const { storage } = engineCache.get(nestId);
1993
+ if (isStewardshipEnabled(nestId) && getPendingReview(nestId, nodeId)) {
1994
+ throw new LockedError(
1995
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
1996
+ );
1997
+ }
2044
1998
  try {
2045
1999
  await storage.deleteDocument(nodeId);
2046
2000
  } catch {
@@ -2058,6 +2012,10 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2058
2012
  db.prepare(
2059
2013
  "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
2060
2014
  ).run(nestId, nodeId);
2015
+ db.prepare(
2016
+ `DELETE FROM stewards
2017
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`
2018
+ ).run(nestId, nodeId);
2061
2019
  })();
2062
2020
  trackEvent("node.delete", { nestId, nodeId });
2063
2021
  return c.json({ deleted: true });
@@ -2262,6 +2220,32 @@ function compilePrompt(prompt, nestId, titles) {
2262
2220
  };
2263
2221
  }
2264
2222
 
2223
+ // src/nodes/readable-body.ts
2224
+ async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
2225
+ if (!isPublicReader(nestId, userId)) return workingBody;
2226
+ const approved = getApprovedVersion(nestId, nodeId);
2227
+ if (approved == null) return "";
2228
+ try {
2229
+ const { versions } = engineCache.get(nestId);
2230
+ const raw = await versions.reconstructVersion(nodeId, approved);
2231
+ return bodyOnly(nodeId, raw);
2232
+ } catch {
2233
+ return "";
2234
+ }
2235
+ }
2236
+ async function resolveExportBody(nestId, nodeId, workingBody) {
2237
+ if (!isStewardshipEnabled(nestId)) return workingBody;
2238
+ const approved = getApprovedVersion(nestId, nodeId);
2239
+ if (approved == null) return null;
2240
+ try {
2241
+ const { versions } = engineCache.get(nestId);
2242
+ const raw = await versions.reconstructVersion(nodeId, approved);
2243
+ return bodyOnly(nodeId, raw);
2244
+ } catch {
2245
+ return null;
2246
+ }
2247
+ }
2248
+
2265
2249
  // src/nodes/query-routes.ts
2266
2250
  var queryRoutes = new Hono5();
2267
2251
  function approxTokens(text) {
@@ -2327,13 +2311,20 @@ queryRoutes.post("/context", async (c) => {
2327
2311
  }
2328
2312
  }
2329
2313
  }
2330
- const userEmail = resolveCallerEmail(c.get("userId"));
2314
+ const userId = c.get("userId");
2315
+ const userEmail = resolveCallerEmail(userId);
2331
2316
  const beforePermission = documents.length;
2332
- const accessible = filterAccessible(nestId, userEmail, documents);
2317
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
2333
2318
  const permissionFiltered = beforePermission - accessible.length;
2319
+ const readable = await Promise.all(
2320
+ accessible.map(async (doc) => ({
2321
+ ...doc,
2322
+ body: await resolveReadableBody(nestId, doc.id, userId, doc.body || "")
2323
+ }))
2324
+ );
2334
2325
  const included = [];
2335
2326
  let tokenCount = 0;
2336
- for (const doc of accessible) {
2327
+ for (const doc of readable) {
2337
2328
  const block = formatContextBlock(doc);
2338
2329
  const blockTokens = approxTokens(block);
2339
2330
  if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
@@ -2386,8 +2377,9 @@ queryRoutes.post("/query", async (c) => {
2386
2377
  const result = await queryEngine.query(body.query, {
2387
2378
  hops: body.hops ?? 2
2388
2379
  });
2389
- const userEmail = resolveCallerEmail(c.get("userId"));
2390
- const accessible = filterAccessible(nestId, userEmail, result.documents);
2380
+ const userId = c.get("userId");
2381
+ const userEmail = resolveCallerEmail(userId);
2382
+ const accessible = filterAccessible(nestId, userId, userEmail, result.documents);
2391
2383
  return c.json({
2392
2384
  query: body.query,
2393
2385
  count: accessible.length,
@@ -2418,8 +2410,9 @@ queryRoutes.get("/search", async (c) => {
2418
2410
  ].join(" ").toLowerCase();
2419
2411
  return terms.every((term) => haystack.includes(term));
2420
2412
  });
2421
- const userEmail = resolveCallerEmail(c.get("userId"));
2422
- const accessible = filterAccessible(nestId, userEmail, matches);
2413
+ const userId = c.get("userId");
2414
+ const userEmail = resolveCallerEmail(userId);
2415
+ const accessible = filterAccessible(nestId, userId, userEmail, matches);
2423
2416
  return c.json({
2424
2417
  query: q,
2425
2418
  count: accessible.length,
@@ -2462,6 +2455,64 @@ queryRoutes.get("/context", async (c) => {
2462
2455
  const content = await storage.readContextMd();
2463
2456
  return c.json({ content: content || "" });
2464
2457
  });
2458
+ queryRoutes.get("/export", async (c) => {
2459
+ if (!isMarkdownFormat(c)) {
2460
+ throw new ValidationError("format=markdown is required");
2461
+ }
2462
+ const nestId = c.req.param("nestId");
2463
+ const { storage, query: queryEngine } = engineCache.get(nestId);
2464
+ const selector = c.req.query("selector")?.trim() || null;
2465
+ let documents;
2466
+ if (selector) {
2467
+ const result = await queryEngine.query(selector, { hops: 2, full: true });
2468
+ documents = result.documents;
2469
+ } else {
2470
+ documents = await storage.discoverDocuments();
2471
+ }
2472
+ const userId = c.get("userId");
2473
+ const userEmail = resolveCallerEmail(userId);
2474
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
2475
+ const governed = isStewardshipEnabled(nestId);
2476
+ const resolved = await Promise.all(
2477
+ accessible.map(async (n) => {
2478
+ const body = await resolveExportBody(nestId, n.id, n.body || "");
2479
+ if (body == null) return null;
2480
+ return {
2481
+ id: n.id,
2482
+ title: n.frontmatter.title,
2483
+ tags: n.frontmatter.tags || [],
2484
+ status: governed ? "published" : n.frontmatter.status,
2485
+ body
2486
+ };
2487
+ })
2488
+ );
2489
+ const fields = resolved.filter(
2490
+ (f) => f != null
2491
+ );
2492
+ const maxParam = parseInt(c.req.query("max_tokens") ?? "", 10);
2493
+ const maxTokens = Number.isFinite(maxParam) && maxParam > 0 ? maxParam : null;
2494
+ let included = fields;
2495
+ if (maxTokens) {
2496
+ const kept = [];
2497
+ let tokens = 0;
2498
+ for (const f of fields) {
2499
+ const t = approxTokens(nodeToMarkdown(f));
2500
+ if (tokens + t > maxTokens && kept.length > 0) break;
2501
+ kept.push(f);
2502
+ tokens += t;
2503
+ }
2504
+ included = kept;
2505
+ }
2506
+ trackEvent("nest.export", {
2507
+ nestId,
2508
+ count: included.length,
2509
+ selector,
2510
+ truncated_by_budget: fields.length - included.length
2511
+ });
2512
+ return c.body(nodesToMarkdown(included), 200, {
2513
+ "Content-Type": "text/markdown; charset=utf-8"
2514
+ });
2515
+ });
2465
2516
  queryRoutes.post("/publish", async (c) => {
2466
2517
  const body = await c.req.json();
2467
2518
  if (!body.documents?.length && !body.context_md) {
@@ -2520,7 +2571,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2520
2571
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2521
2572
 
2522
2573
  // src/mcp/tools.ts
2523
- import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
2524
2574
  var TOOL_DEFINITIONS = [
2525
2575
  {
2526
2576
  name: "context_init",
@@ -2733,6 +2783,21 @@ var TOOL_DEFINITIONS = [
2733
2783
  },
2734
2784
  required: ["email", "scope"]
2735
2785
  }
2786
+ },
2787
+ {
2788
+ name: "context_share_nest",
2789
+ description: "Share this nest with another person by granting them access. This is plain access sharing (not a steward role): viewer = read, editor = read+edit, admin = full access except deleting the nest.",
2790
+ inputSchema: {
2791
+ type: "object",
2792
+ properties: {
2793
+ email: { type: "string", description: "Email of the person to share with" },
2794
+ permission: {
2795
+ type: "string",
2796
+ description: "Access level: read (viewer, default), write (editor), or admin"
2797
+ }
2798
+ },
2799
+ required: ["email"]
2800
+ }
2736
2801
  }
2737
2802
  ];
2738
2803
  async function resolveLlmBody(ctx, node) {
@@ -2848,24 +2913,14 @@ ${list}`;
2848
2913
  ${body || "(no content)"}`;
2849
2914
  }
2850
2915
  case "context_list": {
2851
- let docs = await storage.discoverDocuments();
2852
- if (args.type)
2853
- docs = docs.filter((n) => n.frontmatter.type === args.type);
2854
- if (args.tag) {
2855
- const tag = args.tag.startsWith("#") ? args.tag : `#${args.tag}`;
2856
- docs = docs.filter((n) => n.frontmatter.tags?.includes(tag));
2857
- }
2858
- if (isStewardshipEnabled(nestId)) {
2859
- docs = docs.filter(
2860
- (n) => getApprovedVersion(nestId, n.id) != null
2861
- );
2862
- }
2863
- docs = docs.slice(0, args.limit || 50);
2864
- if (!docs.length) return "No nodes found with the given filters.";
2865
- const list = docs.map(
2866
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}]`
2867
- ).join("\n");
2868
- return `${docs.length} node(s):
2916
+ const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
2917
+ type: args.type,
2918
+ tag: args.tag,
2919
+ limit: args.limit || 50
2920
+ });
2921
+ if (!nodes.length) return "No nodes found with the given filters.";
2922
+ const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
2923
+ return `${nodes.length} node(s):
2869
2924
 
2870
2925
  ${list}`;
2871
2926
  }
@@ -2886,50 +2941,21 @@ ${n.body || ""}`;
2886
2941
  return resolved.join("\n\n---\n\n") || "No nodes resolved.";
2887
2942
  }
2888
2943
  case "context_create": {
2889
- const slug = args.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2890
- const id = `nodes/${slug}`;
2891
- const now = (/* @__PURE__ */ new Date()).toISOString();
2892
- const tags = (args.tags || []).map(
2893
- (t) => t.startsWith("#") ? t : `#${t}`
2894
- );
2895
- const hasStewards = isStewardshipEnabled(nestId);
2896
- const initialStatus = hasStewards ? "draft" : "approved";
2897
- const node = {
2898
- id,
2899
- filePath: "",
2900
- frontmatter: {
2901
- title: args.title,
2902
- type: args.type || "document",
2903
- tags,
2904
- status: initialStatus,
2905
- version: 1,
2906
- created_at: now,
2907
- updated_at: now,
2908
- metadata: { owners: ["*"], scope: args.scope || "team" }
2909
- },
2910
- body: args.content,
2911
- rawContent: ""
2912
- };
2913
- await storage.writeDocument(id, serializeDocument3(node));
2914
- syncNodeTags(nestId, id, tags);
2915
- try {
2916
- await versionManager.createVersion(node, userEmail);
2917
- } catch (err) {
2918
- console.error("VersionManager.createVersion failed (mcp create)", err);
2944
+ if (!canCreateInNest(nestId, userEmail)) {
2945
+ return "You don't have permission to create documents in this nest.";
2919
2946
  }
2920
- createVersion({
2947
+ const { node } = await createNode(
2921
2948
  nestId,
2922
- nodeId: id,
2923
- version: 1,
2924
- content: args.content,
2925
- author: userEmail,
2926
- status: initialStatus,
2927
- tags
2928
- });
2929
- if (initialStatus === "approved") {
2930
- setApprovedVersion(nestId, id, 1, userEmail);
2931
- }
2932
- return `Created node: **${args.title}** (${id}) \u2014 status: ${initialStatus}`;
2949
+ {
2950
+ title: args.title,
2951
+ content: args.content,
2952
+ type: args.type,
2953
+ tags: args.tags,
2954
+ scope: args.scope
2955
+ },
2956
+ userEmail
2957
+ );
2958
+ return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
2933
2959
  }
2934
2960
  case "context_update": {
2935
2961
  const docs = await storage.discoverDocuments();
@@ -2937,56 +2963,21 @@ ${n.body || ""}`;
2937
2963
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
2938
2964
  );
2939
2965
  if (!node) return `Node not found: ${args.title}`;
2940
- let body = node.body || "";
2941
- if (args.content !== void 0) body = args.content;
2942
- if (args.append) body = body + "\n\n" + args.append;
2943
- let tags = node.frontmatter.tags || [];
2944
- if (args.tags) {
2945
- const newTags = args.tags.map(
2946
- (t) => t.startsWith("#") ? t : `#${t}`
2947
- );
2948
- tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
2949
- }
2950
- const prevVersion = getCurrentVersion(nestId, node.id);
2951
- const newVersion = prevVersion + 1;
2952
- const hasStewards = isStewardshipEnabled(nestId);
2953
- const updated = {
2954
- ...node,
2955
- body,
2956
- frontmatter: {
2957
- ...node.frontmatter,
2958
- tags,
2959
- version: newVersion,
2960
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
2961
- }
2962
- };
2963
- await storage.writeDocument(node.id, serializeDocument3(updated));
2964
- try {
2965
- await versionManager.createVersion(updated, userEmail);
2966
- } catch (err) {
2967
- console.error("VersionManager.createVersion failed (mcp update)", err);
2966
+ const editCheck = canUserEdit(nestId, node.id, userEmail);
2967
+ if (!editCheck.allowed) {
2968
+ return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
2968
2969
  }
2969
- syncNodeTags(nestId, node.id, tags);
2970
- createVersion({
2970
+ const { node: updated } = await updateNode(
2971
2971
  nestId,
2972
- nodeId: node.id,
2973
- version: newVersion,
2974
- content: body,
2975
- author: userEmail,
2976
- status: hasStewards ? "draft" : "approved",
2977
- tags
2978
- });
2979
- if (getPendingReview(nestId, node.id)) {
2980
- cancelReview({
2981
- nestId,
2982
- nodeId: node.id,
2983
- cancelledBy: userEmail
2984
- });
2985
- }
2986
- if (!hasStewards) {
2987
- setApprovedVersion(nestId, node.id, newVersion, userEmail);
2988
- }
2989
- return `Updated node: **${node.frontmatter.title}**`;
2972
+ node.id,
2973
+ {
2974
+ content: args.content,
2975
+ append: args.append,
2976
+ tags: args.tags
2977
+ },
2978
+ userEmail
2979
+ );
2980
+ return `Updated node: **${updated.frontmatter.title}**`;
2990
2981
  }
2991
2982
  // ─── Governance Tool Handlers ──────────────────────────────────────
2992
2983
  case "context_stewards": {
@@ -3007,13 +2998,16 @@ ${n.body || ""}`;
3007
2998
 
3008
2999
  ${list}`;
3009
3000
  }
3010
- const allStewards = getStewardsForNest(ctx.nestId);
3001
+ if (!canManageStewards(ctx.userEmail)) {
3002
+ return "You don't have permission to list stewards. Only the super admin can do this.";
3003
+ }
3004
+ const allStewards = await getStewardsForNest(ctx.nestId);
3011
3005
  if (allStewards.length === 0) {
3012
3006
  return "No stewards configured. All changes are auto-approved.\n\nTo configure stewards, add a `stewards.yaml` file to the vault root, or use `context_assign_steward`.";
3013
3007
  }
3014
3008
  const byScope = {};
3015
3009
  for (const s of allStewards) {
3016
- const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
3010
+ const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
3017
3011
  (byScope[key] = byScope[key] || []).push(s);
3018
3012
  }
3019
3013
  const sections = Object.entries(byScope).map(
@@ -3026,7 +3020,7 @@ ${sections}`;
3026
3020
  }
3027
3021
  case "context_review_queue": {
3028
3022
  const status = args.status || "pending";
3029
- const result = getReviewQueue({
3023
+ const result = await getReviewQueue({
3030
3024
  nestId: ctx.nestId,
3031
3025
  status
3032
3026
  });
@@ -3034,7 +3028,7 @@ ${sections}`;
3034
3028
  return status === "pending" ? "No documents pending review. All caught up!" : `No reviews with status "${status}".`;
3035
3029
  }
3036
3030
  const list = result.requests.map(
3037
- (r, i) => `${i + 1}. **${r.nodeId}** v${r.version} \u2014 ${r.priority} priority
3031
+ (r, i) => `${i + 1}. **${r.title || r.nodeId}** v${r.version} \u2014 ${r.priority} priority
3038
3032
  Submitted by: ${r.requestedBy} at ${r.requestedAt}${r.requestNote ? `
3039
3033
  Note: "${r.requestNote}"` : ""}`
3040
3034
  ).join("\n\n");
@@ -3049,6 +3043,10 @@ ${list}`;
3049
3043
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
3050
3044
  );
3051
3045
  if (!node) return `Node not found: ${args.title}`;
3046
+ const submitCheck = canUserEdit(ctx.nestId, node.id, userEmail);
3047
+ if (!submitCheck.allowed) {
3048
+ return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
3049
+ }
3052
3050
  const currentVersion = getCurrentVersion(ctx.nestId, node.id);
3053
3051
  if (currentVersion === 0) return `No versions found for "${args.title}"`;
3054
3052
  try {
@@ -3139,17 +3137,17 @@ ${list}`;
3139
3137
  if (!["nest", "tag", "document"].includes(scope)) {
3140
3138
  return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
3141
3139
  }
3140
+ if (!canManageStewards(ctx.userEmail)) {
3141
+ return "You don't have permission to manage stewards. Only the super admin can do this.";
3142
+ }
3142
3143
  try {
3143
- assignSteward({
3144
+ await createStewardRecord({
3144
3145
  nestId: ctx.nestId,
3145
3146
  scope,
3146
- nodePattern: scope === "document" ? args.target : void 0,
3147
+ documentId: scope === "document" ? args.target : void 0,
3147
3148
  tagName: scope === "tag" ? args.target : void 0,
3148
- userEmail: args.email,
3149
- role: args.role || "reviewer",
3150
- assignedBy: ctx.userEmail,
3151
- assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
3152
- isActive: true
3149
+ users: [{ email: args.email, role: args.role || "reviewer" }],
3150
+ assignedBy: ctx.userEmail
3153
3151
  });
3154
3152
  const targetDesc = scope === "nest" ? "all documents" : `${scope}: ${args.target}`;
3155
3153
  return `Assigned **${args.email}** as ${args.role || "reviewer"} for ${targetDesc}.`;
@@ -3157,6 +3155,25 @@ ${list}`;
3157
3155
  return `Failed to assign steward: ${err.message}`;
3158
3156
  }
3159
3157
  }
3158
+ case "context_share_nest": {
3159
+ const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
3160
+ if (!canManageWith(roles)) {
3161
+ return "You don't have permission to share this nest. Only the nest owner or an admin can add people.";
3162
+ }
3163
+ const permission = args.permission || "read";
3164
+ try {
3165
+ await addCollaborator({
3166
+ nestId: ctx.nestId,
3167
+ email: args.email,
3168
+ permission,
3169
+ grantedByEmail: ctx.userEmail
3170
+ });
3171
+ const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
3172
+ return `Shared this nest with **${args.email}** as ${label}.`;
3173
+ } catch (err) {
3174
+ return `Failed to share nest: ${err.message}`;
3175
+ }
3176
+ }
3160
3177
  default:
3161
3178
  return `Unknown tool: ${toolName}`;
3162
3179
  }
@@ -3225,8 +3242,8 @@ mcpRoutes.all("/", async (c) => {
3225
3242
  import { Hono as Hono7 } from "hono";
3226
3243
 
3227
3244
  // src/governance/stewards-parser.ts
3228
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3229
- import { join as join3 } from "path";
3245
+ import { readFileSync, existsSync } from "fs";
3246
+ import { join as join2 } from "path";
3230
3247
  function parseStewardsYaml(content) {
3231
3248
  const result = { version: 1 };
3232
3249
  const lines = content.split("\n");
@@ -3291,15 +3308,15 @@ function parseEntry(str) {
3291
3308
  }
3292
3309
  function loadStewardsConfig(nestId) {
3293
3310
  const dataRoot = config.DATA_ROOT;
3294
- const nestPath2 = join3(dataRoot, "nests", nestId);
3311
+ const nestPath = join2(dataRoot, "nests", nestId);
3295
3312
  const candidates = [
3296
- join3(nestPath2, "stewards.yaml"),
3297
- join3(nestPath2, "stewards.yml"),
3298
- join3(nestPath2, ".context", "stewards.yaml")
3313
+ join2(nestPath, "stewards.yaml"),
3314
+ join2(nestPath, "stewards.yml"),
3315
+ join2(nestPath, ".context", "stewards.yaml")
3299
3316
  ];
3300
3317
  for (const candidatePath of candidates) {
3301
- if (existsSync2(candidatePath)) {
3302
- const content = readFileSync2(candidatePath, "utf-8");
3318
+ if (existsSync(candidatePath)) {
3319
+ const content = readFileSync(candidatePath, "utf-8");
3303
3320
  return parseStewardsYaml(content);
3304
3321
  }
3305
3322
  }
@@ -3312,12 +3329,25 @@ governanceRoutes.get("/stewards", async (c) => {
3312
3329
  const nestId = c.req.param("nestId");
3313
3330
  const scope = c.req.query("scope");
3314
3331
  const search = c.req.query("search");
3315
- const stewards = listStewards({
3332
+ const stewards = await listStewards({
3316
3333
  nestId,
3317
3334
  scope: scope || void 0,
3318
3335
  search: search || void 0
3319
3336
  });
3320
- return c.json({ stewards });
3337
+ const cache = /* @__PURE__ */ new Map();
3338
+ const enriched = stewards.map((s) => {
3339
+ const key = s.userEmail.toLowerCase();
3340
+ let merged = cache.get(key);
3341
+ if (!merged) {
3342
+ merged = {
3343
+ collaboratorRole: getCollaboratorRole(nestId, s.userEmail),
3344
+ roles: resolveUserRoles(nestId, s.userEmail)
3345
+ };
3346
+ cache.set(key, merged);
3347
+ }
3348
+ return { ...s, ...merged };
3349
+ });
3350
+ return c.json({ stewards: enriched });
3321
3351
  });
3322
3352
  governanceRoutes.post("/stewards", async (c) => {
3323
3353
  const nestId = c.req.param("nestId");
@@ -3358,6 +3388,20 @@ governanceRoutes.post("/stewards", async (c) => {
3358
3388
  });
3359
3389
  return c.json({ steward: created[0] }, 201);
3360
3390
  });
3391
+ governanceRoutes.patch("/stewards/:stewardId", async (c) => {
3392
+ const stewardId = c.req.param("stewardId");
3393
+ const body = await c.req.json();
3394
+ if (!body.role && !body.scope) {
3395
+ throw new ValidationError("role or scope is required");
3396
+ }
3397
+ const steward = updateSteward(stewardId, {
3398
+ role: body.role,
3399
+ scope: body.scope,
3400
+ documentId: body.nodePattern,
3401
+ tagName: body.tagName
3402
+ });
3403
+ return c.json({ steward });
3404
+ });
3361
3405
  governanceRoutes.delete("/stewards/:stewardId", async (c) => {
3362
3406
  const stewardId = c.req.param("stewardId");
3363
3407
  removeSteward(stewardId);
@@ -3377,7 +3421,7 @@ governanceRoutes.get("/review-queue", async (c) => {
3377
3421
  const status = c.req.query("status") || "pending";
3378
3422
  const limit = parseInt(c.req.query("limit") || "50", 10);
3379
3423
  const offset = parseInt(c.req.query("offset") || "0", 10);
3380
- const result = getReviewQueue({
3424
+ const result = await getReviewQueue({
3381
3425
  nestId,
3382
3426
  status,
3383
3427
  limit,
@@ -3423,8 +3467,19 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
3423
3467
  const nodeId = c.req.param("nodeId");
3424
3468
  const allVersions = getVersions(nestId, nodeId);
3425
3469
  const approved = getApprovedVersion(nestId, nodeId);
3470
+ const { versions: versionManager } = engineCache.get(nestId);
3471
+ const withContent = await Promise.all(
3472
+ allVersions.map(async (v) => {
3473
+ try {
3474
+ const raw = await versionManager.reconstructVersion(nodeId, v.version);
3475
+ return { ...v, content: bodyOnly(nodeId, raw) };
3476
+ } catch {
3477
+ return v;
3478
+ }
3479
+ })
3480
+ );
3426
3481
  return c.json({
3427
- versions: allVersions,
3482
+ versions: withContent,
3428
3483
  approvedVersion: approved,
3429
3484
  currentVersion: allVersions[0]?.version || 0
3430
3485
  });
@@ -3652,14 +3707,14 @@ function ensureAnonymousUser() {
3652
3707
  // src/app.ts
3653
3708
  import { serveStatic } from "@hono/node-server/serve-static";
3654
3709
  import { fileURLToPath } from "url";
3655
- import { dirname, join as join4, relative } from "path";
3656
- import { existsSync as existsSync3 } from "fs";
3710
+ import { dirname, join as join3, relative } from "path";
3711
+ import { existsSync as existsSync2 } from "fs";
3657
3712
  var HERE = dirname(fileURLToPath(import.meta.url));
3658
3713
  var UI_DIR_CANDIDATES = [
3659
- join4(HERE, "web3"),
3660
- join4(process.cwd(), "dist", "web3")
3714
+ join3(HERE, "web3"),
3715
+ join3(process.cwd(), "dist", "web3")
3661
3716
  ];
3662
- var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync3(p)) || UI_DIR_CANDIDATES[0];
3717
+ var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
3663
3718
  var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
3664
3719
  var openModeMiddleware = createMiddleware2(async (c, next) => {
3665
3720
  const anonId = ensureAnonymousUser();
@@ -3667,6 +3722,11 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
3667
3722
  c.set("nestScope", null);
3668
3723
  await next();
3669
3724
  });
3725
+ function isPublicReadEligiblePath(method, path) {
3726
+ if (method !== "GET") return false;
3727
+ if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
3728
+ return !/\/(collaborators|visibility|settings|mcp)/.test(path);
3729
+ }
3670
3730
  var flexAuthMiddleware = createMiddleware2(async (c, next) => {
3671
3731
  const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
3672
3732
  const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
@@ -3679,6 +3739,12 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
3679
3739
  c.set("nestScope", null);
3680
3740
  return next();
3681
3741
  }
3742
+ if (isPublicReadEligiblePath(c.req.method, c.req.path)) {
3743
+ const anonId = ensureAnonymousUser();
3744
+ c.set("userId", anonId);
3745
+ c.set("nestScope", null);
3746
+ return next();
3747
+ }
3682
3748
  return c.json({ error: "Missing or invalid credentials" }, 401);
3683
3749
  });
3684
3750
  function createApp() {
@@ -3710,6 +3776,8 @@ function createApp() {
3710
3776
  service: "contextnest-community",
3711
3777
  version: "0.1.0",
3712
3778
  auth_mode: config.AUTH_MODE,
3779
+ logo_url: config.LOGO_URL,
3780
+ promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
3713
3781
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
3714
3782
  })
3715
3783
  );
@@ -3797,7 +3865,7 @@ function createApp() {
3797
3865
  try {
3798
3866
  const { storage } = engineCache.get(nest.id);
3799
3867
  const docs = await storage.discoverDocuments();
3800
- documents += filterAccessible(nest.id, userEmail, docs).length;
3868
+ documents += filterAccessible(nest.id, userId, userEmail, docs).length;
3801
3869
  } catch {
3802
3870
  }
3803
3871
  }
@@ -3859,12 +3927,44 @@ function createApp() {
3859
3927
  let required = "read";
3860
3928
  const path = c.req.path;
3861
3929
  const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
3930
+ const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
3931
+ if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
3932
+ return c.json(
3933
+ {
3934
+ error: "You don't have permission to manage stewards. Only the super admin can do this."
3935
+ },
3936
+ 403
3937
+ );
3938
+ }
3862
3939
  if (path.includes("/collaborators") || path.includes("/visibility")) {
3863
3940
  required = "admin";
3864
3941
  } else if (c.req.method !== "GET" && !isStewardActionPath) {
3865
3942
  required = "write";
3866
3943
  }
3867
- if (permissionLevel(permission) < permissionLevel(required)) {
3944
+ const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
3945
+ let stewardEditorBypass = false;
3946
+ if (required === "write" && permission === "read" && parts[1] === "nodes") {
3947
+ const userEmail = resolveCallerEmail(userId);
3948
+ if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeRevert)) {
3949
+ const idParts = isNodeRevert ? parts.slice(2, -1) : parts.slice(2);
3950
+ const rawNodeId = idParts.join("/");
3951
+ let nodeId = rawNodeId;
3952
+ try {
3953
+ nodeId = decodeURIComponent(rawNodeId);
3954
+ } catch {
3955
+ }
3956
+ const resolved = resolveStewardsForNode(nestId, nodeId);
3957
+ stewardEditorBypass = resolved.some(
3958
+ (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
3959
+ );
3960
+ } else if (parts.length === 2 && c.req.method === "POST") {
3961
+ const resolved = resolveStewardsForNode(nestId, "");
3962
+ stewardEditorBypass = resolved.some(
3963
+ (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
3964
+ );
3965
+ }
3966
+ }
3967
+ if (!stewardEditorBypass && permissionLevel(permission) < permissionLevel(required)) {
3868
3968
  return c.json(
3869
3969
  {
3870
3970
  error: `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
@@ -3964,8 +4064,8 @@ function createApp() {
3964
4064
  }
3965
4065
 
3966
4066
  // src/db/backfill.ts
3967
- import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
3968
- import { join as join5 } from "path";
4067
+ import { NestStorage } from "@promptowl/contextnest-engine";
4068
+ import { join as join4 } from "path";
3969
4069
  var MIGRATION_ID = "005_backfill_node_versions_from_history";
3970
4070
  async function backfillNodeVersionsFromHistory(db) {
3971
4071
  const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
@@ -3986,8 +4086,8 @@ async function backfillNodeVersionsFromHistory(db) {
3986
4086
  let totalInserted = 0;
3987
4087
  let totalDocs = 0;
3988
4088
  for (const { id: nestId } of nests) {
3989
- const nestPath2 = join5(config.DATA_ROOT, "nests", nestId);
3990
- const storage = new NestStorage2(nestPath2);
4089
+ const nestPath = join4(config.DATA_ROOT, "nests", nestId);
4090
+ const storage = new NestStorage(nestPath);
3991
4091
  let docs;
3992
4092
  try {
3993
4093
  docs = await storage.discoverDocuments();
@@ -4085,7 +4185,6 @@ async function main() {
4085
4185
  `);
4086
4186
  }
4087
4187
  const app = createApp();
4088
- startLicenseWatcher();
4089
4188
  startLicenseSafetyPoll();
4090
4189
  const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
4091
4190
  if (driftScanIntervalMs > 0) {