@promptowl/contextnest-community 1.0.0 → 1.1.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,16 +6,7 @@ import {
6
6
  hashPassword,
7
7
  parseBearerToken,
8
8
  verifyPassword
9
- } from "./chunk-7K2LLJXK.js";
10
- import {
11
- checkConflict,
12
- createVersion,
13
- getApprovedVersion,
14
- getCurrentVersion,
15
- getDisplayStatus,
16
- getVersions,
17
- setApprovedVersion
18
- } from "./chunk-BLOPZDPL.js";
9
+ } from "./chunk-XRK6SQSC.js";
19
10
  import {
20
11
  approve,
21
12
  cancelReview,
@@ -23,27 +14,77 @@ import {
23
14
  getReviewHistory,
24
15
  getReviewQueue,
25
16
  reject,
17
+ safePublishDocument,
26
18
  submitForReview
27
- } from "./chunk-XDCW4HTW.js";
19
+ } from "./chunk-S2EWN2VA.js";
20
+ import {
21
+ checkConflict,
22
+ createVersion,
23
+ getApprovedVersion,
24
+ getCurrentVersion,
25
+ getDisplayStatus,
26
+ getVersions,
27
+ setApprovedVersion
28
+ } from "./chunk-7UTMBL6Z.js";
28
29
  import {
29
- assignSteward,
30
+ AppError,
31
+ ConflictError,
32
+ ForbiddenError,
33
+ NotFoundError,
34
+ ValidationError,
35
+ canCreateInNest,
36
+ canManageStewards,
37
+ canManageWith,
30
38
  canUserAccess,
31
39
  canUserApprove,
32
40
  canUserEdit,
41
+ createNest,
33
42
  createStewardRecord,
43
+ deleteNest,
44
+ disableStewardshipAndWipeGovernance,
45
+ engineCache,
46
+ getCollaboratorRole,
47
+ getCurrentLicense,
48
+ getNest,
49
+ getStewardRolesForUser,
34
50
  getStewardsForNest,
51
+ getStewardsForUser,
52
+ getSuspensionReason,
53
+ importNest,
54
+ installLicenseKey,
55
+ isLicenseAdminEmail,
56
+ isLicenseAdminUserId,
57
+ isPublicReader,
58
+ isStewardshipEnabled,
35
59
  isSuperAdmin,
60
+ isSuspended,
61
+ listNests,
62
+ listPublicNests,
63
+ listSharedNests,
36
64
  listStewards,
37
65
  loadAccessConfig,
66
+ nestAllowsSelfApprove,
67
+ permissionLevel,
38
68
  removeSteward,
69
+ resolveNestPermission,
39
70
  resolveStewardsForNode,
40
71
  resolveStewardsWithFallback,
41
- syncFromConfig
42
- } from "./chunk-2FXVMVZJ.js";
72
+ resolveUserRoles,
73
+ setAllowSelfApprove,
74
+ setStewardshipEnabled,
75
+ startLicenseSafetyPoll,
76
+ startTelemetryLoop,
77
+ syncFromConfig,
78
+ trackEvent,
79
+ updateSteward,
80
+ validateLicense
81
+ } from "./chunk-WCOUCBDJ.js";
43
82
  import {
83
+ ANON_EMAIL,
84
+ ANON_USER_ID,
44
85
  config,
45
86
  getDb
46
- } from "./chunk-2TW25QEA.js";
87
+ } from "./chunk-TDAX3JOT.js";
47
88
 
48
89
  // src/index.ts
49
90
  import { serve } from "@hono/node-server";
@@ -168,427 +209,9 @@ var authMiddleware = createMiddleware(async (c, next) => {
168
209
  return c.json({ error: "Missing or invalid credentials" }, 401);
169
210
  });
170
211
 
171
- // src/shared/errors.ts
172
- var AppError = class extends Error {
173
- constructor(statusCode, message) {
174
- super(message);
175
- this.statusCode = statusCode;
176
- this.name = "AppError";
177
- }
178
- statusCode;
179
- };
180
- var NotFoundError = class extends AppError {
181
- constructor(message = "Not found") {
182
- super(404, message);
183
- this.name = "NotFoundError";
184
- }
185
- };
186
- var ValidationError = class extends AppError {
187
- constructor(message) {
188
- super(400, message);
189
- this.name = "ValidationError";
190
- }
191
- };
192
- var ConflictError = class extends AppError {
193
- constructor(message) {
194
- super(409, message);
195
- this.name = "ConflictError";
196
- }
197
- };
198
-
199
- // src/telemetry/tracker.ts
200
- function trackEvent(event, data) {
201
- if (!config.TELEMETRY_ENABLED) return;
202
- try {
203
- const db = getDb();
204
- db.prepare(
205
- "INSERT INTO telemetry_events (event, data_json) VALUES (?, ?)"
206
- ).run(event, data ? JSON.stringify(data) : null);
207
- } catch {
208
- }
209
- }
210
- async function flushTelemetry() {
211
- if (!config.TELEMETRY_ENABLED || !config.PROMPTOWL_KEY) return;
212
- const db = getDb();
213
- const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get()?.c || 0;
214
- const nestCount = db.prepare("SELECT COUNT(*) as c FROM nests").get()?.c || 0;
215
- const events = db.prepare(
216
- "SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
217
- ).all();
218
- if (events.length === 0 && userCount === 0) return;
219
- const payload = {
220
- server_key: config.PROMPTOWL_KEY,
221
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
222
- stats: { users: userCount, nests: nestCount },
223
- events: events.map((e) => ({
224
- event: e.event,
225
- data: e.data_json ? JSON.parse(e.data_json) : null,
226
- at: e.created_at
227
- }))
228
- };
229
- try {
230
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
231
- const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
232
- method: "POST",
233
- headers: { "Content-Type": "application/json" },
234
- body: JSON.stringify(payload)
235
- });
236
- if (res.ok && events.length > 0) {
237
- const ids = events.map((e) => e.id);
238
- db.prepare(
239
- `UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
240
- ).run(...ids);
241
- }
242
- } catch {
243
- }
244
- }
245
- var telemetryTimer = null;
246
- function startTelemetryLoop() {
247
- if (!config.TELEMETRY_ENABLED) return;
248
- setTimeout(() => flushTelemetry(), 3e4);
249
- telemetryTimer = setInterval(
250
- () => flushTelemetry(),
251
- config.TELEMETRY_INTERVAL_MS
252
- );
253
- }
254
-
255
- // src/auth/license.ts
256
- import { existsSync, readFileSync, writeFileSync } from "fs";
257
- var currentLicense = null;
258
- function getCurrentLicense() {
259
- return currentLicense;
260
- }
261
- function isLicenseAdminEmail(email) {
262
- if (!email) return false;
263
- const lic = currentLicense;
264
- if (!lic?.valid || !lic.ownerEmail) return false;
265
- return lic.ownerEmail.toLowerCase() === email.toLowerCase();
266
- }
267
- function isLicenseAdminUserId(userId) {
268
- try {
269
- const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
270
- return isLicenseAdminEmail(row?.email);
271
- } catch {
272
- return false;
273
- }
274
- }
275
- function upsertEnvVar(filePath, varName, value) {
276
- const prefix = `${varName}=`;
277
- let lines = [];
278
- if (existsSync(filePath)) {
279
- lines = readFileSync(filePath, "utf8").split(/\r?\n/);
280
- }
281
- const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
282
- if (value !== null) {
283
- filtered.push(`${prefix}${value}`);
284
- }
285
- while (filtered.length && filtered[filtered.length - 1] === "") {
286
- filtered.pop();
287
- }
288
- writeFileSync(filePath, filtered.join("\n") + "\n", "utf8");
289
- }
290
- async function installLicenseKey(key) {
291
- const trimmed = key.trim();
292
- if (!trimmed.startsWith("pk_")) {
293
- throw new Error("Invalid license key format. Must start with pk_.");
294
- }
295
- const previousKey = process.env.PROMPTOWL_KEY || "";
296
- process.env.PROMPTOWL_KEY = trimmed;
297
- const info = await validateLicense({ forceFresh: true });
298
- if (!info.valid) {
299
- process.env.PROMPTOWL_KEY = previousKey;
300
- if (previousKey) {
301
- await validateLicense({ forceFresh: true });
302
- }
303
- return info;
304
- }
305
- try {
306
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
307
- } catch (err) {
308
- console.warn("[license] failed to write .env:", err);
309
- }
310
- startLicenseWatcher();
311
- return info;
312
- }
313
- var watcherActive = false;
314
- var watcherAbort = null;
315
- var WATCHER_BACKOFF_MIN_MS = 2 * 1e3;
316
- var WATCHER_BACKOFF_MAX_MS = 60 * 1e3;
317
- function startLicenseWatcher() {
318
- if (watcherActive) {
319
- watcherAbort?.abort();
320
- return;
321
- }
322
- watcherActive = true;
323
- void runLicenseWatcher();
324
- }
325
- async function runLicenseWatcher() {
326
- let backoff = WATCHER_BACKOFF_MIN_MS;
327
- while (watcherActive) {
328
- const key = config.PROMPTOWL_KEY;
329
- if (!key) {
330
- await sleep(WATCHER_BACKOFF_MAX_MS);
331
- continue;
332
- }
333
- try {
334
- watcherAbort = new AbortController();
335
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
336
- const res = await fetch(`${promptowlUrl}/api/license/listen`, {
337
- method: "POST",
338
- headers: { "Content-Type": "application/json" },
339
- body: JSON.stringify({
340
- key,
341
- since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
342
- }),
343
- signal: watcherAbort.signal
344
- });
345
- if (!res.ok) {
346
- throw new Error(`listen returned ${res.status}`);
347
- }
348
- const data = await res.json();
349
- backoff = WATCHER_BACKOFF_MIN_MS;
350
- if (data.event && data.event !== "no_change") {
351
- console.log(
352
- `[license] event from PromptOwl: ${data.event} \u2014 revalidating`
353
- );
354
- const wasValid = !!currentLicense?.valid;
355
- await validateLicense({ forceFresh: true });
356
- const isValid = !!currentLicense?.valid;
357
- if (wasValid && !isValid) {
358
- handleLicenseRevoked();
359
- }
360
- }
361
- } catch (err) {
362
- if (err.name === "AbortError") {
363
- continue;
364
- }
365
- console.warn(
366
- `[license] watcher error: ${err.message}; backing off ${backoff}ms`
367
- );
368
- await sleep(backoff);
369
- backoff = Math.min(backoff * 2, WATCHER_BACKOFF_MAX_MS);
370
- } finally {
371
- watcherAbort = null;
372
- }
373
- }
374
- }
375
- function sleep(ms) {
376
- return new Promise((r) => setTimeout(r, ms));
377
- }
378
- function handleLicenseRevoked() {
379
- try {
380
- const db = getDb();
381
- const result = db.prepare("DELETE FROM sessions").run();
382
- console.warn(
383
- `[license] revoked \u2014 wiped ${result.changes} active session(s).`
384
- );
385
- } catch (err) {
386
- console.warn("[license] failed to wipe sessions:", err);
387
- }
388
- try {
389
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
390
- console.warn(
391
- `[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
392
- );
393
- } catch (err) {
394
- console.warn("[license] failed to strip key from .env:", err);
395
- }
396
- process.env.PROMPTOWL_KEY = "";
397
- currentLicense = {
398
- valid: false,
399
- tier: "none",
400
- org: null,
401
- limits: null,
402
- suspended: false,
403
- suspendedReason: null,
404
- ownerEmail: null
405
- };
406
- }
407
- var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
408
- var suspensionFirstSeen = null;
409
- var suspensionConfirmed = false;
410
- var suspensionReason = null;
411
- var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
412
- function isSuspended() {
413
- return suspensionConfirmed;
414
- }
415
- function getSuspensionReason() {
416
- return suspensionReason;
417
- }
418
- async function validateLicense(opts = {}) {
419
- const info = await _validateLicenseImpl(!!opts.forceFresh);
420
- currentLicense = info;
421
- return info;
422
- }
423
- async function _validateLicenseImpl(forceFresh) {
424
- const key = config.PROMPTOWL_KEY;
425
- if (!key) {
426
- return {
427
- valid: false,
428
- tier: "none",
429
- org: null,
430
- limits: null,
431
- suspended: false,
432
- suspendedReason: null,
433
- ownerEmail: null
434
- };
435
- }
436
- const db = getDb();
437
- const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
438
- if (cached && !forceFresh) {
439
- const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
440
- if (age < CACHE_TTL_MS) {
441
- return {
442
- valid: true,
443
- tier: cached.tier,
444
- org: cached.org,
445
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
446
- suspended: suspensionConfirmed,
447
- suspendedReason: suspensionReason,
448
- ownerEmail: cached.owner_email || null
449
- };
450
- }
451
- }
452
- try {
453
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
454
- const res = await fetch(`${promptowlUrl}/api/license/validate`, {
455
- method: "POST",
456
- headers: { "Content-Type": "application/json" },
457
- body: JSON.stringify({ key })
458
- });
459
- if (!res.ok) {
460
- if (cached) {
461
- console.warn(
462
- " PromptOwl unreachable, using cached license (grace period)"
463
- );
464
- return {
465
- valid: true,
466
- tier: cached.tier,
467
- org: cached.org,
468
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
469
- suspended: suspensionConfirmed,
470
- suspendedReason: suspensionReason,
471
- ownerEmail: cached.owner_email || null
472
- };
473
- }
474
- return {
475
- valid: false,
476
- tier: "none",
477
- org: null,
478
- limits: null,
479
- suspended: false,
480
- suspendedReason: null,
481
- ownerEmail: null
482
- };
483
- }
484
- const data = await res.json();
485
- if (data.suspended === true) {
486
- if (!suspensionFirstSeen) {
487
- suspensionFirstSeen = Date.now();
488
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
489
- console.warn(
490
- `
491
- WARNING: PromptOwl has flagged this server for suspension.`
492
- );
493
- console.warn(
494
- ` Reason: ${suspensionReason}`
495
- );
496
- console.warn(
497
- ` This will be confirmed in ~1 hour. If this is an error,`
498
- );
499
- console.warn(
500
- ` contact support@promptowl.ai to reverse it.
501
- `
502
- );
503
- } else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
504
- suspensionConfirmed = true;
505
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
506
- console.error(
507
- `
508
- SERVER SUSPENDED: ${suspensionReason}`
509
- );
510
- console.error(
511
- ` Write operations are disabled. Reads still work.`
512
- );
513
- console.error(
514
- ` Contact support@promptowl.ai to resolve.
515
- `
516
- );
517
- }
518
- } else {
519
- if (suspensionFirstSeen) {
520
- console.log(" Suspension flag cleared by PromptOwl.");
521
- }
522
- suspensionFirstSeen = null;
523
- suspensionConfirmed = false;
524
- suspensionReason = null;
525
- }
526
- if (!data.valid && !data.suspended) {
527
- db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
528
- return {
529
- valid: false,
530
- tier: "none",
531
- org: null,
532
- limits: null,
533
- suspended: false,
534
- suspendedReason: null,
535
- ownerEmail: data.owner_email || null
536
- };
537
- }
538
- if (data.valid) {
539
- const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
540
- db.prepare(
541
- `INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
542
- VALUES (?, ?, ?, ?, ?, datetime('now'))`
543
- ).run(
544
- key,
545
- data.tier || "community",
546
- data.org || null,
547
- limitsJson,
548
- data.owner_email || null
549
- );
550
- }
551
- return {
552
- valid: data.valid !== false,
553
- tier: data.tier || "community",
554
- org: data.org || null,
555
- limits: data.limits || null,
556
- suspended: suspensionConfirmed,
557
- suspendedReason: suspensionReason,
558
- ownerEmail: data.owner_email || null
559
- };
560
- } catch (err) {
561
- if (cached) {
562
- console.warn(
563
- ` PromptOwl validation failed (${err.message}), using cached license`
564
- );
565
- return {
566
- valid: true,
567
- tier: cached.tier,
568
- org: cached.org,
569
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
570
- suspended: suspensionConfirmed,
571
- suspendedReason: suspensionReason,
572
- ownerEmail: cached.owner_email || null
573
- };
574
- }
575
- return {
576
- valid: false,
577
- tier: "none",
578
- org: null,
579
- limits: null,
580
- suspended: false,
581
- suspendedReason: null,
582
- ownerEmail: null
583
- };
584
- }
585
- }
586
-
587
212
  // src/shared/rate-limit.ts
588
213
  var buckets = /* @__PURE__ */ new Map();
589
- function tryConsume(key, cfg) {
590
- const now = Date.now();
591
- const cutoff = now - cfg.windowMs;
214
+ function liveBucket(key, cutoff) {
592
215
  let bucket = buckets.get(key);
593
216
  if (!bucket) {
594
217
  bucket = { hits: [] };
@@ -597,14 +220,29 @@ function tryConsume(key, cfg) {
597
220
  while (bucket.hits.length && bucket.hits[0] < cutoff) {
598
221
  bucket.hits.shift();
599
222
  }
223
+ return bucket;
224
+ }
225
+ function tryConsume(key, cfg) {
226
+ const now = Date.now();
227
+ const bucket = liveBucket(key, now - cfg.windowMs);
600
228
  if (bucket.hits.length >= cfg.max) {
601
229
  return false;
602
230
  }
603
231
  bucket.hits.push(now);
604
232
  return true;
605
233
  }
234
+ function isLimited(key, cfg) {
235
+ return liveBucket(key, Date.now() - cfg.windowMs).hits.length >= cfg.max;
236
+ }
237
+ function recordFailure(key, cfg) {
238
+ liveBucket(key, Date.now() - cfg.windowMs).hits.push(Date.now());
239
+ }
240
+ function clear(key) {
241
+ buckets.delete(key);
242
+ }
606
243
 
607
244
  // src/auth/routes.ts
245
+ import { getConnInfo } from "@hono/node-server/conninfo";
608
246
  var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
609
247
  var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
610
248
  var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
@@ -613,6 +251,11 @@ function clientIp(c) {
613
251
  if (xff) return xff.split(",")[0].trim();
614
252
  const realIp = c.req.header("x-real-ip");
615
253
  if (realIp) return realIp.trim();
254
+ try {
255
+ const addr = getConnInfo(c).remote.address;
256
+ if (addr) return addr;
257
+ } catch {
258
+ }
616
259
  return "unknown";
617
260
  }
618
261
  function resolveCallerUserId(c) {
@@ -691,7 +334,10 @@ authRoutes.post("/login", async (c) => {
691
334
  }
692
335
  const ip = clientIp(c);
693
336
  const emailLower = body.email.toLowerCase();
694
- if (!tryConsume(`login:ip:${ip}`, LOGIN_LIMIT) || !tryConsume(`login:email:${emailLower}`, LOGIN_LIMIT)) {
337
+ const hasIp = ip !== "unknown";
338
+ const ipKey = `login:ip:${ip}`;
339
+ const emailKey = `login:email:${emailLower}`;
340
+ if (hasIp && isLimited(ipKey, LOGIN_LIMIT) || isLimited(emailKey, LOGIN_LIMIT)) {
695
341
  return c.json({ error: "Too many login attempts, try again later" }, 429);
696
342
  }
697
343
  const db = getDb();
@@ -700,8 +346,14 @@ authRoutes.post("/login", async (c) => {
700
346
  ).get(body.email);
701
347
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
702
348
  if (!user || !check.ok) {
349
+ if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
350
+ recordFailure(emailKey, LOGIN_LIMIT);
351
+ console.warn(`[auth] login FAIL \u2014 counter++ ip=${ip} email=${emailLower}`);
703
352
  return c.json({ error: "Invalid credentials" }, 401);
704
353
  }
354
+ console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
355
+ if (hasIp) clear(ipKey);
356
+ clear(emailKey);
705
357
  if (check.needsRehash) {
706
358
  try {
707
359
  const newHash = await hashPassword(body.password);
@@ -1030,17 +682,22 @@ authRoutes.get("/teammates", async (c) => {
1030
682
  (SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
1031
683
  (SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
1032
684
  FROM users u
1033
- WHERE u.id != '00000000-0000-0000-0000-000000000000'
685
+ WHERE u.id != ?
1034
686
  ORDER BY u.created_at DESC`
1035
- ).all();
687
+ ).all(ANON_USER_ID);
1036
688
  const pendingStewards = db.prepare(
1037
689
  `SELECT DISTINCT s.user_email AS email
1038
690
  FROM stewards s
1039
691
  WHERE s.is_active = 1
1040
692
  AND NOT EXISTS (
1041
693
  SELECT 1 FROM users u
1042
- JOIN api_keys k ON k.user_id = u.id
1043
694
  WHERE lower(u.email) = lower(s.user_email)
695
+ AND (
696
+ u.is_invited = 0
697
+ OR EXISTS (
698
+ SELECT 1 FROM api_keys k WHERE k.user_id = u.id
699
+ )
700
+ )
1044
701
  )
1045
702
  ORDER BY s.user_email`
1046
703
  ).all();
@@ -1054,162 +711,736 @@ authRoutes.get("/teammates", async (c) => {
1054
711
  // src/nests/routes.ts
1055
712
  import { Hono as Hono2 } from "hono";
1056
713
 
1057
- // src/shared/access.ts
1058
- var PERMISSION_LEVELS = {
1059
- none: 0,
1060
- read: 1,
1061
- write: 2,
1062
- admin: 3,
1063
- owner: 4
1064
- };
1065
- function resolveNestPermission(nestId, userId) {
1066
- const db = getDb();
1067
- const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
1068
- if (!nest) return "none";
1069
- if (nest.user_id === userId) return "owner";
1070
- const directGrant = db.prepare(
1071
- "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
1072
- ).get(nestId, userId);
1073
- if (directGrant) return directGrant.permission;
1074
- if (nest.visibility === "public") return "read";
1075
- return "none";
1076
- }
1077
- function permissionLevel(p) {
1078
- return PERMISSION_LEVELS[p] ?? 0;
1079
- }
714
+ // src/nodes/service.ts
715
+ import { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
1080
716
 
1081
- // src/nests/service.ts
1082
- import { join } from "path";
1083
- import { rmSync, mkdirSync } from "fs";
1084
- import { v4 as uuid2 } from "uuid";
1085
- import { NestStorage } from "@promptowl/contextnest-engine";
1086
- function nestPath(nestId) {
1087
- return join(config.DATA_ROOT, "nests", nestId);
1088
- }
1089
- function toSlug(name) {
1090
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1091
- }
1092
- function isStewardshipEnabled(nestId) {
1093
- const db = getDb();
1094
- const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
1095
- return !!row?.stewardship_enabled;
717
+ // src/governance/tag-index-service.ts
718
+ function normalizeTag(raw) {
719
+ return raw.trim().replace(/^#+/, "").toLowerCase();
1096
720
  }
1097
- function setStewardshipEnabled(nestId, enabled) {
721
+ function syncNodeTags(nestId, nodeId, tags) {
1098
722
  const db = getDb();
1099
- db.prepare("UPDATE nests SET stewardship_enabled = ? WHERE id = ?").run(
1100
- enabled ? 1 : 0,
1101
- nestId
723
+ const normalized = Array.from(
724
+ new Set(
725
+ tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
726
+ )
1102
727
  );
728
+ db.transaction(() => {
729
+ db.prepare(
730
+ "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
731
+ ).run(nestId, nodeId);
732
+ const insert = db.prepare(
733
+ "INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
734
+ );
735
+ for (const tag of normalized) {
736
+ insert.run(nestId, nodeId, tag);
737
+ }
738
+ })();
1103
739
  }
1104
- async function createNest(userId, name, description) {
1105
- const id = uuid2();
1106
- const slug = toSlug(name);
740
+ function removeNodeFromTagIndex(nestId, nodeId) {
1107
741
  const db = getDb();
1108
742
  db.prepare(
1109
- "INSERT INTO nests (id, user_id, name, slug, description) VALUES (?, ?, ?, ?, ?)"
1110
- ).run(id, userId, name, slug, description || null);
1111
- const path = nestPath(id);
1112
- mkdirSync(path, { recursive: true });
1113
- const storage = new NestStorage(path);
1114
- await storage.init(name);
1115
- trackEvent("nest.create", { nestId: id, userId });
1116
- return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
1117
- }
1118
- var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
1119
- function listNests(userId) {
743
+ "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
744
+ ).run(nestId, nodeId);
745
+ }
746
+
747
+ // src/governance/access-guard.ts
748
+ function resolveCallerEmail(userId) {
749
+ if (!userId) return "admin@localhost";
1120
750
  const db = getDb();
1121
- if (userId === ANON_USER_ID) {
1122
- return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
751
+ const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
752
+ return row?.email || "admin@localhost";
753
+ }
754
+ function canReadNode(nestId, nodeId, userId, userEmail) {
755
+ if (isPublicReader(nestId, userId)) {
756
+ return getApprovedVersion(nestId, nodeId) !== null;
1123
757
  }
1124
- return db.prepare(
1125
- "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC"
1126
- ).all(userId, ANON_USER_ID);
758
+ if (!isStewardshipEnabled(nestId)) return true;
759
+ return canUserAccess(nestId, nodeId, userEmail).allowed;
1127
760
  }
1128
- function listSharedNests(userId) {
1129
- const db = getDb();
1130
- return db.prepare(
1131
- `SELECT DISTINCT n.* FROM nests n
1132
- JOIN nest_collaborators nc ON nc.nest_id = n.id
1133
- WHERE n.user_id != ? AND nc.user_id = ?
1134
- ORDER BY n.created_at DESC`
1135
- ).all(userId, userId);
1136
- }
1137
- function getNest(nestId) {
1138
- const db = getDb();
1139
- return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId) || null;
761
+ function filterAccessible(nestId, userId, userEmail, nodes) {
762
+ if (isPublicReader(nestId, userId)) {
763
+ return nodes.filter((n) => getApprovedVersion(nestId, n.id) !== null);
764
+ }
765
+ if (!isStewardshipEnabled(nestId)) return nodes;
766
+ return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
1140
767
  }
1141
- async function deleteNest(nestId) {
1142
- const db = getDb();
1143
- db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(nestId);
1144
- db.prepare("DELETE FROM nests WHERE id = ?").run(nestId);
1145
- const path = nestPath(nestId);
768
+
769
+ // src/governance/external-edit-service.ts
770
+ import { readFile } from "fs/promises";
771
+ import { join } from "path";
772
+ import {
773
+ detectDrift,
774
+ stageSuggestion,
775
+ approveSuggestion,
776
+ rejectSuggestion,
777
+ listSuggestions,
778
+ readSuggestion,
779
+ parseDocument,
780
+ VersionManager,
781
+ computeContentHash,
782
+ getChecksumContent
783
+ } from "@promptowl/contextnest-engine";
784
+ var communityRbac = {
785
+ isCzar: () => false,
786
+ canIngest: () => true,
787
+ isDocOwner: () => true
788
+ };
789
+ function docPath(nestId, documentId) {
790
+ return join(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
791
+ }
792
+ async function readRaw(nestId, documentId) {
1146
793
  try {
1147
- rmSync(path, { recursive: true, force: true });
794
+ return await readFile(docPath(nestId, documentId), "utf-8");
1148
795
  } catch {
796
+ return null;
1149
797
  }
1150
- trackEvent("nest.delete", { nestId });
1151
798
  }
1152
-
1153
- // src/nests/routes.ts
1154
- var ANON_USER_ID2 = "00000000-0000-0000-0000-000000000000";
1155
- function effectivePermission(nestId, userId) {
1156
- if (config.AUTH_MODE === "open") {
1157
- const db = getDb();
1158
- const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1159
- if (nest && nest.user_id === ANON_USER_ID2) return "owner";
799
+ async function loadChainHead(storage, documentId) {
800
+ const history = await storage.readHistory(documentId);
801
+ if (!history || history.versions.length === 0) return null;
802
+ const latest = history.versions[history.versions.length - 1];
803
+ try {
804
+ const content = await new VersionManager(storage).reconstructVersion(
805
+ documentId,
806
+ latest.version
807
+ );
808
+ return { version: latest.version, content };
809
+ } catch {
810
+ return null;
1160
811
  }
1161
- return resolveNestPermission(nestId, userId);
1162
812
  }
1163
- var nestRoutes = new Hono2();
1164
- nestRoutes.get("/", async (c) => {
1165
- const userId = c.get("userId");
1166
- const owned = listNests(userId);
1167
- const shared = listSharedNests(userId);
1168
- return c.json({ nests: [...owned, ...shared] });
1169
- });
1170
- nestRoutes.post("/", async (c) => {
1171
- const body = await c.req.json();
1172
- if (!body.name) {
1173
- throw new ValidationError("name is required");
1174
- }
1175
- const nest = await createNest(c.get("userId"), body.name, body.description);
1176
- return c.json({ nest }, 201);
1177
- });
1178
- nestRoutes.get("/:nestId", async (c) => {
1179
- const nestId = c.req.param("nestId");
1180
- const permission = effectivePermission(nestId, c.get("userId"));
1181
- if (permission === "none") {
1182
- throw new NotFoundError("Nest not found");
1183
- }
1184
- const nest = getNest(nestId);
1185
- return c.json({ nest, permission });
1186
- });
1187
- nestRoutes.delete("/:nestId", async (c) => {
1188
- const nestId = c.req.param("nestId");
1189
- const permission = effectivePermission(nestId, c.get("userId"));
1190
- if (permission !== "owner") {
1191
- throw new NotFoundError("Nest not found");
1192
- }
1193
- await deleteNest(nestId);
1194
- return c.json({ deleted: true });
1195
- });
1196
- nestRoutes.get("/:nestId/settings", async (c) => {
1197
- const nestId = c.req.param("nestId");
1198
- const permission = effectivePermission(nestId, c.get("userId"));
1199
- if (permission === "none") {
1200
- throw new NotFoundError("Nest not found");
813
+ async function loadLatestApprovedNode(nestId, documentId) {
814
+ const { storage } = engineCache.get(nestId);
815
+ const head = await loadChainHead(storage, documentId);
816
+ if (!head) return null;
817
+ return parseDocument(docPath(nestId, documentId), head.content, documentId);
818
+ }
819
+ async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
820
+ const head = await loadChainHead(storage, documentId);
821
+ if (!head) return false;
822
+ const headBodyHash = computeContentHash(getChecksumContent(head.content));
823
+ return headBodyHash === liveBodyHash;
824
+ }
825
+ async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
826
+ const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
827
+ return res?.meta ?? null;
828
+ }
829
+ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
830
+ const { storage } = engineCache.get(nestId);
831
+ const node = await storage.readDocument(documentId).catch(() => null);
832
+ if (!node) return null;
833
+ const raw = await readRaw(nestId, documentId);
834
+ if (raw == null) return null;
835
+ const drift = detectDrift(raw, node.frontmatter.checksum);
836
+ if (!drift.drifted) return null;
837
+ if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
838
+ return null;
1201
839
  }
1202
- return c.json({
1203
- stewardship_enabled: isStewardshipEnabled(nestId)
840
+ const approved = await loadChainHead(storage, documentId);
841
+ if (!approved) return null;
842
+ const existing = await listSuggestions(storage, documentId);
843
+ const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
844
+ if (dup) return { meta: dup, created: false };
845
+ const result = await stageSuggestion({
846
+ storage,
847
+ documentId,
848
+ approvedRawContent: approved.content,
849
+ proposedRawContent: raw,
850
+ source: "out-of-band-edit",
851
+ actor,
852
+ docTier: "standard"
1204
853
  });
1205
- });
1206
- nestRoutes.patch("/:nestId/settings", async (c) => {
1207
- const nestId = c.req.param("nestId");
1208
- const userId = c.get("userId");
1209
- const isServerAdmin = isLicenseAdminUserId(userId);
1210
- const permission = effectivePermission(nestId, userId);
1211
- if (!isServerAdmin && permission !== "owner") {
1212
- return c.json(
854
+ return { meta: result.meta, created: true };
855
+ }
856
+ async function scanNestForDrift(nestId, actor = "system:scanner") {
857
+ const { storage } = engineCache.get(nestId);
858
+ const docs = await storage.discoverDocuments();
859
+ const results = await Promise.all(
860
+ docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
861
+ );
862
+ const staged = results.filter((r) => r?.created).length;
863
+ return { scanned: docs.length, staged };
864
+ }
865
+ async function getPendingChange(nestId, documentId) {
866
+ const { storage } = engineCache.get(nestId);
867
+ const list = await listSuggestions(storage, documentId);
868
+ for (let i = list.length - 1; i >= 0; i--) {
869
+ const meta = list[i];
870
+ if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
871
+ continue;
872
+ }
873
+ return {
874
+ suggestion_id: meta.suggestion_id,
875
+ detected_at: meta.detected_at,
876
+ source: meta.source,
877
+ proposed_hash: meta.proposed_hash
878
+ };
879
+ }
880
+ return null;
881
+ }
882
+ async function listNestExternalEdits(nestId) {
883
+ const { storage } = engineCache.get(nestId);
884
+ const docs = await storage.discoverDocuments();
885
+ const lists = await Promise.all(
886
+ docs.map(async (doc) => {
887
+ const metas = await listSuggestions(storage, doc.id);
888
+ const fresh = [];
889
+ for (const meta of metas) {
890
+ if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
891
+ continue;
892
+ }
893
+ fresh.push(meta);
894
+ }
895
+ return fresh;
896
+ })
897
+ );
898
+ const entries = lists.flat().map((meta) => ({
899
+ suggestion_id: meta.suggestion_id,
900
+ nest_id: nestId,
901
+ document_id: meta.document_id,
902
+ source: meta.source,
903
+ detected_at: meta.detected_at,
904
+ actor: meta.actor,
905
+ target_hash: meta.target_hash,
906
+ proposed_hash: meta.proposed_hash,
907
+ note: meta.note
908
+ }));
909
+ return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
910
+ }
911
+ async function getExternalEditDetail(nestId, documentId, suggestionId) {
912
+ const { storage } = engineCache.get(nestId);
913
+ const found = await readSuggestion(storage, documentId, suggestionId);
914
+ if (!found) return null;
915
+ return {
916
+ suggestion_id: found.meta.suggestion_id,
917
+ nest_id: nestId,
918
+ document_id: found.meta.document_id,
919
+ source: found.meta.source,
920
+ detected_at: found.meta.detected_at,
921
+ actor: found.meta.actor,
922
+ target_hash: found.meta.target_hash,
923
+ proposed_hash: found.meta.proposed_hash,
924
+ note: found.meta.note,
925
+ patch: found.patch
926
+ };
927
+ }
928
+ async function approveExternalEdit(input) {
929
+ const { storage } = engineCache.get(input.nestId);
930
+ let result;
931
+ try {
932
+ result = await approveSuggestion({
933
+ storage,
934
+ rbac: communityRbac,
935
+ documentId: input.documentId,
936
+ suggestionId: input.suggestionId,
937
+ actor: input.actor,
938
+ zone: "default",
939
+ comment: input.comment
940
+ });
941
+ } catch (err) {
942
+ console.error(
943
+ `[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
944
+ err
945
+ );
946
+ throw err;
947
+ }
948
+ try {
949
+ const node = await storage.readDocument(input.documentId);
950
+ const versionNum = result.versionEntry.version;
951
+ const tags = node.frontmatter.tags || [];
952
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
953
+ createVersion2({
954
+ nestId: input.nestId,
955
+ nodeId: input.documentId,
956
+ version: versionNum,
957
+ content: node.body || "",
958
+ author: input.actor,
959
+ status: "published",
960
+ tags,
961
+ changeNote: input.comment || "External edit approved"
962
+ });
963
+ setApprovedVersion2(input.nestId, input.documentId, versionNum, input.actor);
964
+ } catch (err) {
965
+ console.error(
966
+ `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
967
+ err
968
+ );
969
+ }
970
+ return result;
971
+ }
972
+ async function rejectExternalEdit(input) {
973
+ const { storage } = engineCache.get(input.nestId);
974
+ const result = await rejectSuggestion({
975
+ storage,
976
+ rbac: communityRbac,
977
+ documentId: input.documentId,
978
+ suggestionId: input.suggestionId,
979
+ actor: input.actor,
980
+ zone: "default",
981
+ reason: input.reason
982
+ });
983
+ try {
984
+ const approved = await loadChainHead(storage, input.documentId);
985
+ if (approved) {
986
+ await storage.writeDocument(input.documentId, approved.content);
987
+ }
988
+ } catch (err) {
989
+ console.error(
990
+ `[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
991
+ err
992
+ );
993
+ }
994
+ return result;
995
+ }
996
+ var scannerTimer = null;
997
+ async function scanAllNests() {
998
+ const db = getDb();
999
+ const rows = db.prepare("SELECT id FROM nests").all();
1000
+ await Promise.all(
1001
+ rows.map(
1002
+ ({ id }) => scanNestForDrift(id).catch(
1003
+ (err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
1004
+ )
1005
+ )
1006
+ );
1007
+ }
1008
+ function startDriftScanner(intervalMs = 3e4) {
1009
+ if (scannerTimer) return;
1010
+ scannerTimer = setInterval(() => {
1011
+ scanAllNests().catch(
1012
+ (err) => console.error("[external-edit] scanner tick failed:", err)
1013
+ );
1014
+ }, intervalMs);
1015
+ scannerTimer.unref?.();
1016
+ }
1017
+
1018
+ // src/nodes/service.ts
1019
+ function userIdFromEmail(email) {
1020
+ const db = getDb();
1021
+ const row = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(email);
1022
+ return row?.id ?? ANON_USER_ID;
1023
+ }
1024
+ var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
1025
+ var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
1026
+ function bodyOnly(nodeId, raw) {
1027
+ try {
1028
+ return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
1029
+ } catch {
1030
+ return raw;
1031
+ }
1032
+ }
1033
+ function toNodeResponse(node) {
1034
+ const fm = node.frontmatter;
1035
+ const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
1036
+ const tags = Array.isArray(fm.tags) ? fm.tags.map((t) => String(t)) : [];
1037
+ return {
1038
+ id: node.id,
1039
+ title,
1040
+ type: fm.type || "document",
1041
+ tags,
1042
+ status: fm.status || "draft",
1043
+ version: fm.version || 1,
1044
+ author: fm.author,
1045
+ description: fm.description,
1046
+ created_at: fm.created_at,
1047
+ updated_at: fm.updated_at,
1048
+ content: node.body || "",
1049
+ pendingChange: node.pendingChange ?? void 0
1050
+ };
1051
+ }
1052
+ async function listNodesForCaller(nestId, userId, filters = {}) {
1053
+ const { storage, versions: versionManager } = engineCache.get(nestId);
1054
+ let documents = await storage.discoverDocuments();
1055
+ if (filters.type) {
1056
+ documents = documents.filter((n) => n.frontmatter.type === filters.type);
1057
+ }
1058
+ if (filters.tag) {
1059
+ const tag = normalizeTag2(filters.tag);
1060
+ documents = documents.filter(
1061
+ (n) => (n.frontmatter.tags || []).includes(tag)
1062
+ );
1063
+ }
1064
+ const userEmail = resolveCallerEmail(userId);
1065
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
1066
+ const publicReader = isPublicReader(nestId, userId);
1067
+ const enriched = await Promise.all(
1068
+ accessible.map(async (doc) => {
1069
+ const r = toNodeResponse(doc);
1070
+ if (publicReader) {
1071
+ const approved = getApprovedVersion(nestId, doc.id);
1072
+ if (approved != null) {
1073
+ try {
1074
+ const raw = await versionManager.reconstructVersion(doc.id, approved);
1075
+ r.content = bodyOnly(doc.id, raw);
1076
+ } catch (err) {
1077
+ console.error("reconstructVersion failed (list)", doc.id, approved, err);
1078
+ r.content = "";
1079
+ }
1080
+ r.version = approved;
1081
+ r.status = "published";
1082
+ }
1083
+ return r;
1084
+ }
1085
+ let pending = null;
1086
+ try {
1087
+ pending = await getPendingChange(nestId, doc.id);
1088
+ } catch {
1089
+ pending = null;
1090
+ }
1091
+ if (pending) {
1092
+ r.pendingChange = pending;
1093
+ r.status = "external_edit_pending";
1094
+ } else {
1095
+ r.status = getDisplayStatus(nestId, r.id);
1096
+ }
1097
+ return r;
1098
+ })
1099
+ );
1100
+ return filters.limit ? enriched.slice(0, filters.limit) : enriched;
1101
+ }
1102
+ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
1103
+ return listNodesForCaller(nestId, userIdFromEmail(userEmail), filters);
1104
+ }
1105
+ async function createNode(nestId, input, userEmail) {
1106
+ const { storage, versions: versionManager } = engineCache.get(nestId);
1107
+ const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1108
+ const id = `nodes/${slug}`;
1109
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1110
+ const tags = (input.tags || []).map(normalizeTag2);
1111
+ const hasStewards = isStewardshipEnabled(nestId);
1112
+ const initialStatus = hasStewards ? "draft" : "published";
1113
+ const initialVersion = hasStewards ? 1 : 0;
1114
+ let node = {
1115
+ id,
1116
+ filePath: "",
1117
+ frontmatter: {
1118
+ title: input.title,
1119
+ type: input.type || "document",
1120
+ tags,
1121
+ status: input.status || initialStatus,
1122
+ version: initialVersion,
1123
+ created_at: now,
1124
+ updated_at: now,
1125
+ metadata: { owners: ["*"], scope: input.scope || "team" }
1126
+ },
1127
+ body: input.content,
1128
+ rawContent: ""
1129
+ };
1130
+ await storage.writeDocument(id, serializeDocument(node));
1131
+ syncNodeTags(nestId, id, tags);
1132
+ let savedVersion = 1;
1133
+ if (hasStewards) {
1134
+ try {
1135
+ await versionManager.createVersion(node, userEmail);
1136
+ } catch (err) {
1137
+ console.error("VersionManager.createVersion failed (node create)", err);
1138
+ }
1139
+ createVersion({
1140
+ nestId,
1141
+ nodeId: id,
1142
+ version: 1,
1143
+ content: input.content,
1144
+ author: userEmail,
1145
+ status: "draft",
1146
+ tags
1147
+ });
1148
+ } else {
1149
+ try {
1150
+ const result = await safePublishDocument(storage, id, {
1151
+ editedBy: userEmail,
1152
+ note: "Auto-published on create (no stewards configured)"
1153
+ });
1154
+ savedVersion = result.node.frontmatter.version || 1;
1155
+ createVersion({
1156
+ nestId,
1157
+ nodeId: id,
1158
+ version: savedVersion,
1159
+ content: result.node.body || "",
1160
+ author: userEmail,
1161
+ status: "published",
1162
+ tags
1163
+ });
1164
+ setApprovedVersion(nestId, id, savedVersion, userEmail);
1165
+ node = result.node;
1166
+ } catch (err) {
1167
+ console.error("publishDocument failed (node create auto-publish)", err);
1168
+ createVersion({
1169
+ nestId,
1170
+ nodeId: id,
1171
+ version: 1,
1172
+ content: input.content,
1173
+ author: userEmail,
1174
+ status: "draft",
1175
+ tags
1176
+ });
1177
+ }
1178
+ }
1179
+ trackEvent("node.create", { nestId, nodeId: id });
1180
+ return { node, version: savedVersion };
1181
+ }
1182
+ async function registerImportedDocuments(nestId, userEmail) {
1183
+ const { storage } = engineCache.get(nestId);
1184
+ let docs;
1185
+ try {
1186
+ docs = await storage.discoverDocuments();
1187
+ } catch (err) {
1188
+ console.error("registerImportedDocuments: discovery failed", nestId, err);
1189
+ return 0;
1190
+ }
1191
+ let registered = 0;
1192
+ for (const doc of docs) {
1193
+ const nodeId = doc.id;
1194
+ if (getCurrentVersion(nestId, nodeId) > 0) continue;
1195
+ const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
1196
+ const tags = rawTags.map((t) => normalizeTag2(String(t)));
1197
+ const fmVersion = Number(doc.frontmatter?.version);
1198
+ let version = Number.isFinite(fmVersion) && fmVersion > 0 ? fmVersion : 1;
1199
+ let content = doc.body || "";
1200
+ try {
1201
+ const result = await safePublishDocument(storage, nodeId, {
1202
+ editedBy: userEmail,
1203
+ note: "Imported from existing folder"
1204
+ });
1205
+ version = result.node.frontmatter.version || version;
1206
+ content = result.node.body || content;
1207
+ } catch (err) {
1208
+ console.error("safePublishDocument failed (import register)", nodeId, err);
1209
+ }
1210
+ createVersion({
1211
+ nestId,
1212
+ nodeId,
1213
+ version,
1214
+ content,
1215
+ author: userEmail,
1216
+ status: "published",
1217
+ changeNote: "Imported from existing folder",
1218
+ tags
1219
+ });
1220
+ setApprovedVersion(nestId, nodeId, version, userEmail);
1221
+ syncNodeTags(nestId, nodeId, tags);
1222
+ registered++;
1223
+ }
1224
+ trackEvent("nest.import.documents", { nestId, registered });
1225
+ return registered;
1226
+ }
1227
+ async function updateNode(nestId, nodeId, patch, userEmail) {
1228
+ const { storage, versions: versionManager } = engineCache.get(nestId);
1229
+ let node;
1230
+ try {
1231
+ node = await storage.readDocument(nodeId);
1232
+ } catch {
1233
+ throw new NotFoundError(`Node not found: ${nodeId}`);
1234
+ }
1235
+ if (patch.content !== void 0) {
1236
+ node = { ...node, body: patch.content };
1237
+ }
1238
+ if (patch.append) {
1239
+ node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
1240
+ }
1241
+ if (patch.tags) {
1242
+ const newTags = patch.tags.map(normalizeTag2);
1243
+ const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
1244
+ node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
1245
+ }
1246
+ if (patch.status) {
1247
+ node = { ...node, frontmatter: { ...node.frontmatter, status: patch.status } };
1248
+ }
1249
+ if (patch.title) {
1250
+ node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
1251
+ }
1252
+ const hasStewards = isStewardshipEnabled(nestId);
1253
+ const currentTags = node.frontmatter.tags || [];
1254
+ if (getPendingReview(nestId, nodeId)) {
1255
+ cancelReview({ nestId, nodeId, cancelledBy: userEmail });
1256
+ }
1257
+ let responseVersion;
1258
+ if (hasStewards) {
1259
+ const currentVersion = getCurrentVersion(nestId, nodeId);
1260
+ const newVersion = currentVersion + 1;
1261
+ node = {
1262
+ ...node,
1263
+ frontmatter: {
1264
+ ...node.frontmatter,
1265
+ version: newVersion,
1266
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1267
+ // Drop stale published-state checksum so the next verified read
1268
+ // doesn't flag this write as external drift.
1269
+ checksum: void 0
1270
+ }
1271
+ };
1272
+ node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
1273
+ await storage.writeDocument(nodeId, serializeDocument(node));
1274
+ syncNodeTags(nestId, nodeId, currentTags);
1275
+ try {
1276
+ await versionManager.createVersion(node, userEmail, { note: patch.changeNote });
1277
+ } catch (err) {
1278
+ console.error("VersionManager.createVersion failed (node patch)", err);
1279
+ }
1280
+ createVersion({
1281
+ nestId,
1282
+ nodeId,
1283
+ version: newVersion,
1284
+ content: node.body || "",
1285
+ author: userEmail,
1286
+ status: "draft",
1287
+ tags: currentTags,
1288
+ changeNote: patch.changeNote
1289
+ });
1290
+ responseVersion = newVersion;
1291
+ } else {
1292
+ node = {
1293
+ ...node,
1294
+ frontmatter: {
1295
+ ...node.frontmatter,
1296
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1297
+ checksum: void 0
1298
+ }
1299
+ };
1300
+ node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
1301
+ await storage.writeDocument(nodeId, serializeDocument(node));
1302
+ syncNodeTags(nestId, nodeId, currentTags);
1303
+ let publishedVersion = (node.frontmatter.version || 0) + 1;
1304
+ try {
1305
+ const result = await safePublishDocument(storage, nodeId, {
1306
+ editedBy: userEmail,
1307
+ note: patch.changeNote || "Auto-published on edit (no stewards)"
1308
+ });
1309
+ publishedVersion = result.node.frontmatter.version || publishedVersion;
1310
+ node = result.node;
1311
+ } catch (err) {
1312
+ console.error("publishDocument failed (node patch auto-publish)", err);
1313
+ }
1314
+ createVersion({
1315
+ nestId,
1316
+ nodeId,
1317
+ version: publishedVersion,
1318
+ content: node.body || "",
1319
+ author: userEmail,
1320
+ status: "published",
1321
+ tags: currentTags,
1322
+ changeNote: patch.changeNote
1323
+ });
1324
+ setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
1325
+ responseVersion = publishedVersion;
1326
+ }
1327
+ return { node, version: responseVersion };
1328
+ }
1329
+
1330
+ // src/nests/routes.ts
1331
+ function effectivePermission(nestId, userId) {
1332
+ if (config.AUTH_MODE === "open") {
1333
+ const db = getDb();
1334
+ const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1335
+ if (nest && nest.user_id === ANON_USER_ID) return "owner";
1336
+ }
1337
+ return resolveNestPermission(nestId, userId);
1338
+ }
1339
+ var nestRoutes = new Hono2();
1340
+ nestRoutes.get("/", async (c) => {
1341
+ const userId = c.get("userId");
1342
+ const owned = listNests(userId);
1343
+ const shared = listSharedNests(userId);
1344
+ const publicExtras = listPublicNests(userId);
1345
+ const db = getDb();
1346
+ const ownerEmailStmt = db.prepare(
1347
+ "SELECT email FROM users WHERE id = ?"
1348
+ );
1349
+ const callerEmail = resolveCallerEmail(userId);
1350
+ const annotate = (n) => {
1351
+ const permission = effectivePermission(n.id, userId);
1352
+ const is_owner = permission === "owner";
1353
+ let owner_email = null;
1354
+ const roles = is_owner ? ["owner"] : resolveUserRoles(n.id, callerEmail);
1355
+ if (!is_owner && n.user_id !== ANON_USER_ID) {
1356
+ const row = ownerEmailStmt.get(n.user_id);
1357
+ owner_email = row?.email ?? null;
1358
+ }
1359
+ return { ...n, permission, is_owner, owner_email, roles };
1360
+ };
1361
+ const seen = /* @__PURE__ */ new Set();
1362
+ const out = [];
1363
+ for (const n of [...owned, ...shared, ...publicExtras]) {
1364
+ if (seen.has(n.id)) continue;
1365
+ seen.add(n.id);
1366
+ out.push(annotate(n));
1367
+ }
1368
+ return c.json({ nests: out });
1369
+ });
1370
+ nestRoutes.post("/", async (c) => {
1371
+ const body = await c.req.json();
1372
+ if (!body.name) {
1373
+ throw new ValidationError("name is required");
1374
+ }
1375
+ const nest = await createNest(c.get("userId"), body.name, body.description);
1376
+ return c.json({ nest }, 201);
1377
+ });
1378
+ nestRoutes.post("/import", async (c) => {
1379
+ const body = await c.req.json();
1380
+ if (!body.name) {
1381
+ throw new ValidationError("name is required");
1382
+ }
1383
+ const files = Array.isArray(body.files) ? body.files : [];
1384
+ const userId = c.get("userId");
1385
+ const nest = await importNest(userId, body.name, files);
1386
+ const documents = await registerImportedDocuments(
1387
+ nest.id,
1388
+ resolveCallerEmail(userId)
1389
+ );
1390
+ return c.json({ nest, documents }, 201);
1391
+ });
1392
+ nestRoutes.get("/:nestId", async (c) => {
1393
+ const nestId = c.req.param("nestId");
1394
+ const userId = c.get("userId");
1395
+ const permission = effectivePermission(nestId, userId);
1396
+ if (permission === "none") {
1397
+ throw new NotFoundError("Nest not found");
1398
+ }
1399
+ const email = resolveCallerEmail(userId);
1400
+ let roles = resolveUserRoles(nestId, email);
1401
+ if (permission === "owner" && !roles.includes("owner")) {
1402
+ roles = ["owner", ...roles];
1403
+ }
1404
+ const myStewards = getStewardsForUser(nestId, email);
1405
+ const nest = getNest(nestId);
1406
+ return c.json({ nest, permission, roles, myStewards });
1407
+ });
1408
+ nestRoutes.delete("/:nestId", async (c) => {
1409
+ const nestId = c.req.param("nestId");
1410
+ const userId = c.get("userId");
1411
+ const nest = getNest(nestId);
1412
+ if (!nest) {
1413
+ throw new NotFoundError("Nest not found");
1414
+ }
1415
+ const permission = effectivePermission(nestId, userId);
1416
+ const isAnonOwned = nest.user_id === ANON_USER_ID;
1417
+ const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
1418
+ if (permission !== "owner" && !adminCaretaker) {
1419
+ throw new ForbiddenError(
1420
+ "You don't have permission to delete this nest. Only the nest owner can delete it."
1421
+ );
1422
+ }
1423
+ await deleteNest(nestId);
1424
+ return c.json({ deleted: true });
1425
+ });
1426
+ nestRoutes.get("/:nestId/settings", async (c) => {
1427
+ const nestId = c.req.param("nestId");
1428
+ const permission = effectivePermission(nestId, c.get("userId"));
1429
+ if (permission === "none") {
1430
+ throw new NotFoundError("Nest not found");
1431
+ }
1432
+ return c.json({
1433
+ stewardship_enabled: isStewardshipEnabled(nestId),
1434
+ allow_self_approve: nestAllowsSelfApprove(nestId)
1435
+ });
1436
+ });
1437
+ nestRoutes.patch("/:nestId/settings", async (c) => {
1438
+ const nestId = c.req.param("nestId");
1439
+ const userId = c.get("userId");
1440
+ const isServerAdmin = isLicenseAdminUserId(userId);
1441
+ const permission = effectivePermission(nestId, userId);
1442
+ if (!isServerAdmin && permission !== "owner") {
1443
+ return c.json(
1213
1444
  {
1214
1445
  error: "Only the nest owner or the server license-admin can update nest settings."
1215
1446
  },
@@ -1217,61 +1448,111 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
1217
1448
  );
1218
1449
  }
1219
1450
  const body = await c.req.json();
1451
+ let wiped = null;
1220
1452
  if (typeof body.stewardship_enabled === "boolean") {
1221
- setStewardshipEnabled(nestId, body.stewardship_enabled);
1453
+ if (body.stewardship_enabled) {
1454
+ setStewardshipEnabled(nestId, true);
1455
+ } else {
1456
+ wiped = disableStewardshipAndWipeGovernance(nestId);
1457
+ }
1458
+ }
1459
+ if (typeof body.allow_self_approve === "boolean") {
1460
+ setAllowSelfApprove(nestId, body.allow_self_approve);
1222
1461
  }
1223
1462
  return c.json({
1224
- stewardship_enabled: isStewardshipEnabled(nestId)
1463
+ stewardship_enabled: isStewardshipEnabled(nestId),
1464
+ allow_self_approve: nestAllowsSelfApprove(nestId),
1465
+ wiped
1225
1466
  });
1226
1467
  });
1227
1468
 
1228
1469
  // src/nests/sharing-routes.ts
1229
1470
  import { Hono as Hono3 } from "hono";
1230
- import { v4 as uuid3 } from "uuid";
1231
- var sharingRoutes = new Hono3();
1232
- sharingRoutes.get("/collaborators", async (c) => {
1233
- const db = getDb();
1234
- const collabs = db.prepare(
1235
- `SELECT nc.*, u.email FROM nest_collaborators nc
1236
- LEFT JOIN users u ON nc.user_id = u.id
1237
- WHERE nc.nest_id = ?
1238
- ORDER BY nc.granted_at`
1239
- ).all(c.req.param("nestId"));
1240
- return c.json({ collaborators: collabs });
1241
- });
1242
- sharingRoutes.post("/collaborators", async (c) => {
1243
- const body = await c.req.json();
1244
- if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
1471
+
1472
+ // src/nests/sharing-service.ts
1473
+ import { v4 as uuid2 } from "uuid";
1474
+ var VALID_PERMISSIONS = ["read", "write", "admin"];
1475
+ async function addCollaborator(params) {
1476
+ const { nestId } = params;
1477
+ if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
1245
1478
  throw new ValidationError("permission must be read, write, or admin");
1246
1479
  }
1247
1480
  const db = getDb();
1248
- let userId = body.user_id;
1249
- if (!userId && body.email) {
1250
- let user = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
1251
- if (!user) {
1252
- const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
1253
- userId = uuid3();
1481
+ let userId = params.userId;
1482
+ if (!userId && params.email) {
1483
+ const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(params.email);
1484
+ if (existing) {
1485
+ userId = existing.id;
1486
+ } else {
1487
+ const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
1488
+ userId = uuid2();
1254
1489
  db.prepare(
1255
1490
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
1256
- ).run(userId, body.email, null, await hashPassword2(uuid3()));
1257
- } else {
1258
- userId = user.id;
1491
+ ).run(userId, params.email, null, await hashPassword2(uuid2()));
1259
1492
  }
1260
1493
  }
1261
1494
  if (!userId) {
1262
1495
  throw new ValidationError("user_id or email is required");
1263
1496
  }
1264
- const collabId = uuid3();
1497
+ const ownerRow = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1498
+ if (!ownerRow) {
1499
+ throw new ValidationError("Nest not found");
1500
+ }
1501
+ if (ownerRow.user_id === userId) {
1502
+ throw new ValidationError(
1503
+ "The nest owner already has full access and can't be added as a collaborator."
1504
+ );
1505
+ }
1506
+ const selfByEmail = !!params.email && !!params.grantedByEmail && params.email.trim().toLowerCase() === params.grantedByEmail.trim().toLowerCase();
1507
+ const selfById = !!params.grantedByUserId && params.grantedByUserId === userId;
1508
+ if (selfByEmail || selfById) {
1509
+ throw new ValidationError("You can't add yourself as a collaborator.");
1510
+ }
1511
+ const dupe = db.prepare("SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?").get(nestId, userId);
1512
+ if (dupe) {
1513
+ throw new ConflictError(
1514
+ `${params.email || "This user"} already has access to this nest. Change their permission instead of adding them again.`
1515
+ );
1516
+ }
1517
+ const granterByEmail = !params.grantedByUserId && params.grantedByEmail ? db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(params.grantedByEmail)?.id : void 0;
1518
+ const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
1519
+ const collabId = uuid2();
1265
1520
  db.prepare(
1266
1521
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
1267
- ).run(
1268
- collabId,
1269
- c.req.param("nestId"),
1270
- userId,
1271
- body.permission,
1272
- c.get("userId")
1273
- );
1274
- const collab = db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
1522
+ ).run(collabId, nestId, userId, params.permission, granterId);
1523
+ return db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
1524
+ }
1525
+
1526
+ // src/nests/sharing-routes.ts
1527
+ var sharingRoutes = new Hono3();
1528
+ sharingRoutes.get("/collaborators", async (c) => {
1529
+ const db = getDb();
1530
+ const nestId = c.req.param("nestId");
1531
+ const collabs = db.prepare(
1532
+ `SELECT nc.*, u.email FROM nest_collaborators nc
1533
+ LEFT JOIN users u ON nc.user_id = u.id
1534
+ WHERE nc.nest_id = ?
1535
+ ORDER BY nc.granted_at`
1536
+ ).all(nestId);
1537
+ const enriched = collabs.map((collab) => {
1538
+ if (!collab.email) return { ...collab, stewardRoles: [], roles: [] };
1539
+ return {
1540
+ ...collab,
1541
+ stewardRoles: getStewardRolesForUser(nestId, collab.email),
1542
+ roles: resolveUserRoles(nestId, collab.email)
1543
+ };
1544
+ });
1545
+ return c.json({ collaborators: enriched });
1546
+ });
1547
+ sharingRoutes.post("/collaborators", async (c) => {
1548
+ const body = await c.req.json();
1549
+ const collab = await addCollaborator({
1550
+ nestId: c.req.param("nestId"),
1551
+ email: body.email,
1552
+ userId: body.user_id,
1553
+ permission: body.permission ?? "",
1554
+ grantedByUserId: c.get("userId")
1555
+ });
1275
1556
  return c.json({ collaborator: collab }, 201);
1276
1557
  });
1277
1558
  sharingRoutes.patch("/collaborators/:collabId", async (c) => {
@@ -1305,174 +1586,36 @@ sharingRoutes.patch("/visibility", async (c) => {
1305
1586
  );
1306
1587
  return c.json({ visibility: body.visibility });
1307
1588
  });
1308
-
1309
- // src/nodes/routes.ts
1310
- import { Hono as Hono4 } from "hono";
1311
- import { serializeDocument } from "@promptowl/contextnest-engine";
1312
-
1313
- // src/nodes/engine.ts
1314
- import { join as join2 } from "path";
1315
- import {
1316
- NestStorage as NestStorage2,
1317
- GraphQueryEngine,
1318
- VersionManager
1319
- } from "@promptowl/contextnest-engine";
1320
- var NestEngineCache = class {
1321
- cache = /* @__PURE__ */ new Map();
1322
- get(nestId) {
1323
- let engine = this.cache.get(nestId);
1324
- if (!engine) {
1325
- const nestPath2 = join2(config.DATA_ROOT, "nests", nestId);
1326
- const storage = new NestStorage2(nestPath2);
1327
- const query = new GraphQueryEngine(storage);
1328
- const versions = new VersionManager(storage);
1329
- engine = { storage, query, versions };
1330
- this.cache.set(nestId, engine);
1331
- }
1332
- return engine;
1333
- }
1334
- evict(nestId) {
1335
- this.cache.delete(nestId);
1336
- }
1337
- };
1338
- var engineCache = new NestEngineCache();
1339
-
1340
- // src/governance/tag-index-service.ts
1341
- function normalizeTag(raw) {
1342
- return raw.trim().replace(/^#+/, "").toLowerCase();
1343
- }
1344
- function syncNodeTags(nestId, nodeId, tags) {
1345
- const db = getDb();
1346
- const normalized = Array.from(
1347
- new Set(
1348
- tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
1349
- )
1350
- );
1351
- db.transaction(() => {
1352
- db.prepare(
1353
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
1354
- ).run(nestId, nodeId);
1355
- const insert = db.prepare(
1356
- "INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
1357
- );
1358
- for (const tag of normalized) {
1359
- insert.run(nestId, nodeId, tag);
1360
- }
1361
- })();
1362
- }
1363
- function removeNodeFromTagIndex(nestId, nodeId) {
1364
- const db = getDb();
1365
- db.prepare(
1366
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
1367
- ).run(nestId, nodeId);
1368
- }
1369
-
1370
- // src/governance/access-guard.ts
1371
- function resolveCallerEmail(userId) {
1372
- if (!userId) return "admin@localhost";
1373
- const db = getDb();
1374
- const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
1375
- return row?.email || "admin@localhost";
1376
- }
1377
- function canReadNode(nestId, nodeId, userEmail) {
1378
- if (!isStewardshipEnabled(nestId)) return true;
1379
- return canUserAccess(nestId, nodeId, userEmail).allowed;
1380
- }
1381
- function filterAccessible(nestId, userEmail, nodes) {
1382
- if (!isStewardshipEnabled(nestId)) return nodes;
1383
- return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
1384
- }
1385
-
1386
- // src/nodes/routes.ts
1387
- var nodeRoutes = new Hono4();
1388
- function toNodeResponse(node) {
1389
- return {
1390
- id: node.id,
1391
- title: node.frontmatter.title,
1392
- type: node.frontmatter.type || "document",
1393
- tags: node.frontmatter.tags || [],
1394
- // Widen to string so callers can layer review-workflow states like
1395
- // "pending_review" / "rejected" on top of the on-disk frontmatter
1396
- // status. The engine's Status enum only knows draft/approved.
1397
- status: node.frontmatter.status || "draft",
1398
- version: node.frontmatter.version || 1,
1399
- author: node.frontmatter.author,
1400
- description: node.frontmatter.description,
1401
- created_at: node.frontmatter.created_at,
1402
- updated_at: node.frontmatter.updated_at,
1403
- content: node.body
1404
- };
1405
- }
1406
- nodeRoutes.get("/", async (c) => {
1407
- const nestId = c.req.param("nestId");
1408
- const { storage } = engineCache.get(nestId);
1409
- const documents = await storage.discoverDocuments();
1410
- const userEmail = resolveCallerEmail(c.get("userId"));
1411
- const accessible = filterAccessible(nestId, userEmail, documents);
1412
- return c.json({
1413
- count: accessible.length,
1414
- nodes: accessible.map((doc) => {
1415
- const r = toNodeResponse(doc);
1416
- r.status = getDisplayStatus(nestId, r.id);
1417
- return r;
1418
- })
1419
- });
1420
- });
1421
- nodeRoutes.post("/", async (c) => {
1422
- const body = await c.req.json();
1423
- if (!body.title || !body.content) {
1424
- throw new ValidationError("title and content are required");
1425
- }
1426
- const nestId = c.req.param("nestId");
1427
- const { storage, versions: versionManager } = engineCache.get(nestId);
1428
- const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1429
- const id = `nodes/${slug}`;
1430
- const now = (/* @__PURE__ */ new Date()).toISOString();
1431
- const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
1432
- const hasStewards = isStewardshipEnabled(nestId);
1433
- const initialStatus = hasStewards ? "draft" : "approved";
1434
- const node = {
1435
- id,
1436
- filePath: "",
1437
- frontmatter: {
1438
- title: body.title,
1439
- type: body.type || "document",
1440
- tags,
1441
- status: body.status || initialStatus,
1442
- version: 1,
1443
- created_at: now,
1444
- updated_at: now,
1445
- metadata: {
1446
- owners: ["*"],
1447
- scope: body.scope || "team"
1448
- }
1449
- },
1450
- body: body.content,
1451
- rawContent: ""
1452
- };
1453
- const serialized = serializeDocument(node);
1454
- await storage.writeDocument(id, serialized);
1455
- syncNodeTags(nestId, id, tags);
1456
- const authorEmail = getUserEmail(c);
1457
- try {
1458
- await versionManager.createVersion(node, authorEmail);
1459
- } catch (err) {
1460
- console.error("VersionManager.createVersion failed (node create)", err);
1589
+
1590
+ // src/nodes/routes.ts
1591
+ import { Hono as Hono4 } from "hono";
1592
+ var nodeRoutes = new Hono4();
1593
+ nodeRoutes.get("/", async (c) => {
1594
+ const nestId = c.req.param("nestId");
1595
+ const userId = c.get("userId");
1596
+ const nodes = await listNodesForCaller(nestId, userId);
1597
+ return c.json({ count: nodes.length, nodes });
1598
+ });
1599
+ nodeRoutes.post("/", async (c) => {
1600
+ const body = await c.req.json();
1601
+ if (!body.title || !body.content) {
1602
+ throw new ValidationError("title and content are required");
1461
1603
  }
1462
- createVersion({
1604
+ const nestId = c.req.param("nestId");
1605
+ const authorEmail = getUserEmail(c);
1606
+ const { node } = await createNode(
1463
1607
  nestId,
1464
- nodeId: id,
1465
- version: 1,
1466
- content: body.content,
1467
- author: authorEmail,
1468
- status: initialStatus,
1469
- tags
1470
- });
1471
- if (initialStatus === "approved") {
1472
- setApprovedVersion(nestId, id, 1, authorEmail);
1473
- }
1474
- trackEvent("node.create", { nestId, nodeId: id });
1475
- const resolved = resolveStewardsForNode(nestId, id);
1608
+ {
1609
+ title: body.title,
1610
+ content: body.content,
1611
+ type: body.type,
1612
+ tags: body.tags,
1613
+ scope: body.scope,
1614
+ status: body.status
1615
+ },
1616
+ authorEmail
1617
+ );
1618
+ const resolved = resolveStewardsForNode(nestId, node.id);
1476
1619
  return c.json({
1477
1620
  node: toNodeResponse(node),
1478
1621
  stewards: resolved.length > 0 ? resolved.map((r) => ({
@@ -1485,7 +1628,7 @@ nodeRoutes.post("/", async (c) => {
1485
1628
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1486
1629
  const nestId = c.req.param("nestId");
1487
1630
  const nodeId = c.req.param("nodeId");
1488
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZJATH6OM.js");
1631
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3XGX7QIN.js");
1489
1632
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
1490
1633
  nestId,
1491
1634
  nodeId
@@ -1497,8 +1640,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1497
1640
  role: r.steward.role,
1498
1641
  scope: r.steward.scope,
1499
1642
  source: r.source,
1500
- priority: r.priority,
1501
- canApprove: r.steward.canApprove
1643
+ priority: r.priority
1502
1644
  })),
1503
1645
  fallbackToOwner,
1504
1646
  ownerEmail
@@ -1507,7 +1649,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1507
1649
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1508
1650
  const nestId = c.req.param("nestId");
1509
1651
  const nodeId = c.req.param("nodeId");
1510
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-2MZJGE3H.js");
1652
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
1511
1653
  const allVersions = getVersions2(nestId, nodeId);
1512
1654
  const approved = getApprovedVersion2(nestId, nodeId);
1513
1655
  const db = getDb();
@@ -1538,16 +1680,17 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1538
1680
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
1539
1681
  const nestId = c.req.param("nestId");
1540
1682
  const nodeId = c.req.param("nodeId");
1541
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-2JHZHZWJ.js");
1683
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3OJIPYNV.js");
1542
1684
  const history = getReviewHistory2(nestId, nodeId);
1543
1685
  return c.json({ reviews: history });
1544
1686
  });
1545
1687
  nodeRoutes.get("/:nodeId{.+}", async (c) => {
1546
1688
  const nestId = c.req.param("nestId");
1547
1689
  const nodeId = c.req.param("nodeId");
1548
- const { storage } = engineCache.get(nestId);
1549
- const userEmail = resolveCallerEmail(c.get("userId"));
1550
- if (!canReadNode(nestId, nodeId, userEmail)) {
1690
+ const { storage, versions: versionManager } = engineCache.get(nestId);
1691
+ const userId = c.get("userId");
1692
+ const userEmail = resolveCallerEmail(userId);
1693
+ if (!canReadNode(nestId, nodeId, userId, userEmail)) {
1551
1694
  return c.json(
1552
1695
  { error: "Access denied \u2014 no steward assignment for this node" },
1553
1696
  403
@@ -1555,18 +1698,61 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1555
1698
  }
1556
1699
  let node;
1557
1700
  try {
1558
- node = await storage.readDocument(nodeId);
1701
+ node = await storage.readDocument(nodeId, { verifyChecksum: true });
1559
1702
  } catch {
1560
1703
  throw new NotFoundError(`Node not found: ${nodeId}`);
1561
1704
  }
1705
+ if (node.pendingChange) {
1706
+ try {
1707
+ await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
1708
+ const refreshed = await getPendingChange(nestId, nodeId);
1709
+ if (refreshed) node.pendingChange = refreshed;
1710
+ } catch (err) {
1711
+ console.error("[external-edit] stage-on-read failed:", err);
1712
+ }
1713
+ try {
1714
+ const latest = await loadLatestApprovedNode(nestId, nodeId);
1715
+ if (latest) {
1716
+ node = { ...latest, pendingChange: node.pendingChange };
1717
+ }
1718
+ } catch (err) {
1719
+ console.error("[external-edit] reconstruct-latest failed:", err);
1720
+ }
1721
+ }
1562
1722
  const response = toNodeResponse(node);
1563
- response.status = getDisplayStatus(nestId, nodeId);
1723
+ if (isPublicReader(nestId, userId)) {
1724
+ const approved = getApprovedVersion(nestId, nodeId);
1725
+ if (approved != null) {
1726
+ try {
1727
+ const raw = await versionManager.reconstructVersion(
1728
+ nodeId,
1729
+ approved
1730
+ );
1731
+ response.content = bodyOnly(nodeId, raw);
1732
+ } catch (err) {
1733
+ console.error(
1734
+ "reconstructVersion failed (public single)",
1735
+ nodeId,
1736
+ approved,
1737
+ err
1738
+ );
1739
+ response.content = "";
1740
+ }
1741
+ response.version = approved;
1742
+ response.status = "published";
1743
+ return c.json({ node: response });
1744
+ }
1745
+ }
1746
+ response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
1747
+ if (response.status === "pending_review") {
1748
+ const pending = getPendingReview(nestId, nodeId);
1749
+ response.pendingReviewBy = pending?.requestedBy ?? null;
1750
+ }
1564
1751
  return c.json({ node: response });
1565
1752
  });
1566
1753
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1567
1754
  const nestId = c.req.param("nestId");
1568
1755
  const nodeId = c.req.param("nodeId");
1569
- const { storage, versions: versionManager } = engineCache.get(nestId);
1570
1756
  const body = await c.req.json();
1571
1757
  const baseVersionHeader = c.req.header("X-Base-Version");
1572
1758
  if (baseVersionHeader) {
@@ -1586,80 +1772,21 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1586
1772
  );
1587
1773
  }
1588
1774
  }
1589
- let node;
1590
- try {
1591
- node = await storage.readDocument(nodeId);
1592
- } catch {
1593
- throw new NotFoundError(`Node not found: ${nodeId}`);
1594
- }
1595
- if (body.content !== void 0) {
1596
- node = { ...node, body: body.content };
1597
- }
1598
- if (body.append) {
1599
- node = { ...node, body: (node.body || "") + "\n\n" + body.append };
1600
- }
1601
- if (body.tags) {
1602
- const newTags = body.tags.map((t) => t.startsWith("#") ? t : `#${t}`);
1603
- const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
1604
- node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
1605
- }
1606
- if (body.status) {
1607
- node = {
1608
- ...node,
1609
- frontmatter: { ...node.frontmatter, status: body.status }
1610
- };
1611
- }
1612
- if (body.title) {
1613
- node = {
1614
- ...node,
1615
- frontmatter: { ...node.frontmatter, title: body.title }
1616
- };
1617
- }
1618
- const currentVersion = getCurrentVersion(nestId, nodeId);
1619
- const newVersion = currentVersion + 1;
1620
- node = {
1621
- ...node,
1622
- frontmatter: {
1623
- ...node.frontmatter,
1624
- version: newVersion,
1625
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
1626
- }
1627
- };
1628
- const fm = Object.fromEntries(
1629
- Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
1630
- );
1631
- node = { ...node, frontmatter: fm };
1632
- const serialized = serializeDocument(node);
1633
- await storage.writeDocument(nodeId, serialized);
1634
1775
  const authorEmail = getUserEmail(c);
1635
- const hasStewards = isStewardshipEnabled(nestId);
1636
- const currentTags = node.frontmatter.tags || [];
1637
- syncNodeTags(nestId, nodeId, currentTags);
1638
- try {
1639
- await versionManager.createVersion(node, authorEmail, {
1640
- note: body.changeNote
1641
- });
1642
- } catch (err) {
1643
- console.error("VersionManager.createVersion failed (node patch)", err);
1644
- }
1645
- createVersion({
1776
+ const { node, version: responseVersion } = await updateNode(
1646
1777
  nestId,
1647
1778
  nodeId,
1648
- version: newVersion,
1649
- content: node.body || "",
1650
- author: authorEmail,
1651
- status: hasStewards ? "draft" : "approved",
1652
- tags: currentTags,
1653
- changeNote: body.changeNote
1654
- });
1655
- const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-2JHZHZWJ.js");
1656
- if (getPendingReview2(nestId, nodeId)) {
1657
- cancelReview2({ nestId, nodeId, cancelledBy: authorEmail });
1658
- }
1659
- if (!hasStewards) {
1660
- setApprovedVersion(nestId, nodeId, newVersion, authorEmail);
1661
- }
1662
- return c.json({ node: toNodeResponse(node), version: newVersion });
1779
+ {
1780
+ content: body.content,
1781
+ append: body.append,
1782
+ tags: body.tags,
1783
+ title: body.title,
1784
+ status: body.status,
1785
+ changeNote: body.changeNote
1786
+ },
1787
+ authorEmail
1788
+ );
1789
+ return c.json({ node: toNodeResponse(node), version: responseVersion });
1663
1790
  });
1664
1791
  nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1665
1792
  const nestId = c.req.param("nestId");
@@ -1682,6 +1809,10 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1682
1809
  db.prepare(
1683
1810
  "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
1684
1811
  ).run(nestId, nodeId);
1812
+ db.prepare(
1813
+ `DELETE FROM stewards
1814
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`
1815
+ ).run(nestId, nodeId);
1685
1816
  })();
1686
1817
  trackEvent("node.delete", { nestId, nodeId });
1687
1818
  return c.json({ deleted: true });
@@ -1951,9 +2082,10 @@ queryRoutes.post("/context", async (c) => {
1951
2082
  }
1952
2083
  }
1953
2084
  }
1954
- const userEmail = resolveCallerEmail(c.get("userId"));
2085
+ const userId = c.get("userId");
2086
+ const userEmail = resolveCallerEmail(userId);
1955
2087
  const beforePermission = documents.length;
1956
- const accessible = filterAccessible(nestId, userEmail, documents);
2088
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
1957
2089
  const permissionFiltered = beforePermission - accessible.length;
1958
2090
  const included = [];
1959
2091
  let tokenCount = 0;
@@ -2010,8 +2142,9 @@ queryRoutes.post("/query", async (c) => {
2010
2142
  const result = await queryEngine.query(body.query, {
2011
2143
  hops: body.hops ?? 2
2012
2144
  });
2013
- const userEmail = resolveCallerEmail(c.get("userId"));
2014
- const accessible = filterAccessible(nestId, userEmail, result.documents);
2145
+ const userId = c.get("userId");
2146
+ const userEmail = resolveCallerEmail(userId);
2147
+ const accessible = filterAccessible(nestId, userId, userEmail, result.documents);
2015
2148
  return c.json({
2016
2149
  query: body.query,
2017
2150
  count: accessible.length,
@@ -2042,8 +2175,9 @@ queryRoutes.get("/search", async (c) => {
2042
2175
  ].join(" ").toLowerCase();
2043
2176
  return terms.every((term) => haystack.includes(term));
2044
2177
  });
2045
- const userEmail = resolveCallerEmail(c.get("userId"));
2046
- const accessible = filterAccessible(nestId, userEmail, matches);
2178
+ const userId = c.get("userId");
2179
+ const userEmail = resolveCallerEmail(userId);
2180
+ const accessible = filterAccessible(nestId, userId, userEmail, matches);
2047
2181
  return c.json({
2048
2182
  query: q,
2049
2183
  count: accessible.length,
@@ -2144,7 +2278,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2144
2278
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2145
2279
 
2146
2280
  // src/mcp/tools.ts
2147
- import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
2148
2281
  var TOOL_DEFINITIONS = [
2149
2282
  {
2150
2283
  name: "context_init",
@@ -2346,17 +2479,32 @@ var TOOL_DEFINITIONS = [
2346
2479
  },
2347
2480
  {
2348
2481
  name: "context_assign_steward",
2349
- description: "Assign a data steward to govern a scope (nest, tag, folder pattern, or specific document). Stewards review and approve changes before they go live.",
2482
+ description: "Assign a data steward to govern a scope (nest, tag, or specific document). Stewards review and approve changes before they go live.",
2350
2483
  inputSchema: {
2351
2484
  type: "object",
2352
2485
  properties: {
2353
2486
  email: { type: "string", description: "Email of the person to assign as steward" },
2354
- scope: { type: "string", description: "Scope: nest (all docs), tag, folder, or document" },
2355
- target: { type: "string", description: "Scope target: tag name (e.g. #architecture), folder pattern (e.g. nodes/api-*), or document title" },
2487
+ scope: { type: "string", description: "Scope: nest (all docs), tag, or document" },
2488
+ target: { type: "string", description: "Scope target: tag name (e.g. #architecture) or document title" },
2356
2489
  role: { type: "string", description: "Role: reviewer (default), editor, or viewer" }
2357
2490
  },
2358
2491
  required: ["email", "scope"]
2359
2492
  }
2493
+ },
2494
+ {
2495
+ name: "context_share_nest",
2496
+ 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.",
2497
+ inputSchema: {
2498
+ type: "object",
2499
+ properties: {
2500
+ email: { type: "string", description: "Email of the person to share with" },
2501
+ permission: {
2502
+ type: "string",
2503
+ description: "Access level: read (viewer, default), write (editor), or admin"
2504
+ }
2505
+ },
2506
+ required: ["email"]
2507
+ }
2360
2508
  }
2361
2509
  ];
2362
2510
  async function resolveLlmBody(ctx, node) {
@@ -2472,24 +2620,14 @@ ${list}`;
2472
2620
  ${body || "(no content)"}`;
2473
2621
  }
2474
2622
  case "context_list": {
2475
- let docs = await storage.discoverDocuments();
2476
- if (args.type)
2477
- docs = docs.filter((n) => n.frontmatter.type === args.type);
2478
- if (args.tag) {
2479
- const tag = args.tag.startsWith("#") ? args.tag : `#${args.tag}`;
2480
- docs = docs.filter((n) => n.frontmatter.tags?.includes(tag));
2481
- }
2482
- if (isStewardshipEnabled(nestId)) {
2483
- docs = docs.filter(
2484
- (n) => getApprovedVersion(nestId, n.id) != null
2485
- );
2486
- }
2487
- docs = docs.slice(0, args.limit || 50);
2488
- if (!docs.length) return "No nodes found with the given filters.";
2489
- const list = docs.map(
2490
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}]`
2491
- ).join("\n");
2492
- return `${docs.length} node(s):
2623
+ const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
2624
+ type: args.type,
2625
+ tag: args.tag,
2626
+ limit: args.limit || 50
2627
+ });
2628
+ if (!nodes.length) return "No nodes found with the given filters.";
2629
+ const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
2630
+ return `${nodes.length} node(s):
2493
2631
 
2494
2632
  ${list}`;
2495
2633
  }
@@ -2510,50 +2648,21 @@ ${n.body || ""}`;
2510
2648
  return resolved.join("\n\n---\n\n") || "No nodes resolved.";
2511
2649
  }
2512
2650
  case "context_create": {
2513
- const slug = args.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2514
- const id = `nodes/${slug}`;
2515
- const now = (/* @__PURE__ */ new Date()).toISOString();
2516
- const tags = (args.tags || []).map(
2517
- (t) => t.startsWith("#") ? t : `#${t}`
2518
- );
2519
- const hasStewards = isStewardshipEnabled(nestId);
2520
- const initialStatus = hasStewards ? "draft" : "approved";
2521
- const node = {
2522
- id,
2523
- filePath: "",
2524
- frontmatter: {
2525
- title: args.title,
2526
- type: args.type || "document",
2527
- tags,
2528
- status: initialStatus,
2529
- version: 1,
2530
- created_at: now,
2531
- updated_at: now,
2532
- metadata: { owners: ["*"], scope: args.scope || "team" }
2533
- },
2534
- body: args.content,
2535
- rawContent: ""
2536
- };
2537
- await storage.writeDocument(id, serializeDocument3(node));
2538
- syncNodeTags(nestId, id, tags);
2539
- try {
2540
- await versionManager.createVersion(node, userEmail);
2541
- } catch (err) {
2542
- console.error("VersionManager.createVersion failed (mcp create)", err);
2651
+ if (!canCreateInNest(nestId, userEmail)) {
2652
+ return "You don't have permission to create documents in this nest.";
2543
2653
  }
2544
- createVersion({
2654
+ const { node } = await createNode(
2545
2655
  nestId,
2546
- nodeId: id,
2547
- version: 1,
2548
- content: args.content,
2549
- author: userEmail,
2550
- status: initialStatus,
2551
- tags
2552
- });
2553
- if (initialStatus === "approved") {
2554
- setApprovedVersion(nestId, id, 1, userEmail);
2555
- }
2556
- return `Created node: **${args.title}** (${id}) \u2014 status: ${initialStatus}`;
2656
+ {
2657
+ title: args.title,
2658
+ content: args.content,
2659
+ type: args.type,
2660
+ tags: args.tags,
2661
+ scope: args.scope
2662
+ },
2663
+ userEmail
2664
+ );
2665
+ return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
2557
2666
  }
2558
2667
  case "context_update": {
2559
2668
  const docs = await storage.discoverDocuments();
@@ -2561,56 +2670,21 @@ ${n.body || ""}`;
2561
2670
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
2562
2671
  );
2563
2672
  if (!node) return `Node not found: ${args.title}`;
2564
- let body = node.body || "";
2565
- if (args.content !== void 0) body = args.content;
2566
- if (args.append) body = body + "\n\n" + args.append;
2567
- let tags = node.frontmatter.tags || [];
2568
- if (args.tags) {
2569
- const newTags = args.tags.map(
2570
- (t) => t.startsWith("#") ? t : `#${t}`
2571
- );
2572
- tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
2573
- }
2574
- const prevVersion = getCurrentVersion(nestId, node.id);
2575
- const newVersion = prevVersion + 1;
2576
- const hasStewards = isStewardshipEnabled(nestId);
2577
- const updated = {
2578
- ...node,
2579
- body,
2580
- frontmatter: {
2581
- ...node.frontmatter,
2582
- tags,
2583
- version: newVersion,
2584
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
2585
- }
2586
- };
2587
- await storage.writeDocument(node.id, serializeDocument3(updated));
2588
- try {
2589
- await versionManager.createVersion(updated, userEmail);
2590
- } catch (err) {
2591
- console.error("VersionManager.createVersion failed (mcp update)", err);
2673
+ const editCheck = canUserEdit(nestId, node.id, userEmail);
2674
+ if (!editCheck.allowed) {
2675
+ return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
2592
2676
  }
2593
- syncNodeTags(nestId, node.id, tags);
2594
- createVersion({
2677
+ const { node: updated } = await updateNode(
2595
2678
  nestId,
2596
- nodeId: node.id,
2597
- version: newVersion,
2598
- content: body,
2599
- author: userEmail,
2600
- status: hasStewards ? "draft" : "approved",
2601
- tags
2602
- });
2603
- if (getPendingReview(nestId, node.id)) {
2604
- cancelReview({
2605
- nestId,
2606
- nodeId: node.id,
2607
- cancelledBy: userEmail
2608
- });
2609
- }
2610
- if (!hasStewards) {
2611
- setApprovedVersion(nestId, node.id, newVersion, userEmail);
2612
- }
2613
- return `Updated node: **${node.frontmatter.title}**`;
2679
+ node.id,
2680
+ {
2681
+ content: args.content,
2682
+ append: args.append,
2683
+ tags: args.tags
2684
+ },
2685
+ userEmail
2686
+ );
2687
+ return `Updated node: **${updated.frontmatter.title}**`;
2614
2688
  }
2615
2689
  // ─── Governance Tool Handlers ──────────────────────────────────────
2616
2690
  case "context_stewards": {
@@ -2625,24 +2699,27 @@ ${n.body || ""}`;
2625
2699
  return `No stewards configured for "${args.title}". Changes are auto-approved.`;
2626
2700
  }
2627
2701
  const list = resolved.map(
2628
- (r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.canApprove ? " \u2713 can approve" : ""}`
2702
+ (r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.role === "reviewer" ? " \u2713 can approve" : ""}`
2629
2703
  ).join("\n");
2630
2704
  return `# Stewards for "${args.title}"
2631
2705
 
2632
2706
  ${list}`;
2633
2707
  }
2634
- const allStewards = getStewardsForNest(ctx.nestId);
2708
+ if (!canManageStewards(ctx.userEmail)) {
2709
+ return "You don't have permission to list stewards. Only the super admin can do this.";
2710
+ }
2711
+ const allStewards = await getStewardsForNest(ctx.nestId);
2635
2712
  if (allStewards.length === 0) {
2636
2713
  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`.";
2637
2714
  }
2638
2715
  const byScope = {};
2639
2716
  for (const s of allStewards) {
2640
- const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : s.scope === "folder" ? `Folder: ${s.nodePattern}` : `Document: ${s.nodePattern}`;
2717
+ const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
2641
2718
  (byScope[key] = byScope[key] || []).push(s);
2642
2719
  }
2643
2720
  const sections = Object.entries(byScope).map(
2644
2721
  ([scope, stewards]) => `## ${scope}
2645
- ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.canApprove ? " \u2014 can approve" : ""}`).join("\n")}`
2722
+ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
2646
2723
  ).join("\n\n");
2647
2724
  return `# Data Stewards
2648
2725
 
@@ -2650,7 +2727,7 @@ ${sections}`;
2650
2727
  }
2651
2728
  case "context_review_queue": {
2652
2729
  const status = args.status || "pending";
2653
- const result = getReviewQueue({
2730
+ const result = await getReviewQueue({
2654
2731
  nestId: ctx.nestId,
2655
2732
  status
2656
2733
  });
@@ -2658,7 +2735,7 @@ ${sections}`;
2658
2735
  return status === "pending" ? "No documents pending review. All caught up!" : `No reviews with status "${status}".`;
2659
2736
  }
2660
2737
  const list = result.requests.map(
2661
- (r, i) => `${i + 1}. **${r.nodeId}** v${r.version} \u2014 ${r.priority} priority
2738
+ (r, i) => `${i + 1}. **${r.title || r.nodeId}** v${r.version} \u2014 ${r.priority} priority
2662
2739
  Submitted by: ${r.requestedBy} at ${r.requestedAt}${r.requestNote ? `
2663
2740
  Note: "${r.requestNote}"` : ""}`
2664
2741
  ).join("\n\n");
@@ -2673,6 +2750,10 @@ ${list}`;
2673
2750
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
2674
2751
  );
2675
2752
  if (!node) return `Node not found: ${args.title}`;
2753
+ const submitCheck = canUserEdit(ctx.nestId, node.id, userEmail);
2754
+ if (!submitCheck.allowed) {
2755
+ return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
2756
+ }
2676
2757
  const currentVersion = getCurrentVersion(ctx.nestId, node.id);
2677
2758
  if (currentVersion === 0) return `No versions found for "${args.title}"`;
2678
2759
  try {
@@ -2705,7 +2786,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
2705
2786
  if (!node) return `Node not found: ${args.title}`;
2706
2787
  const currentVersion = getCurrentVersion(ctx.nestId, node.id);
2707
2788
  try {
2708
- const request = approve({
2789
+ const request = await approve({
2709
2790
  nestId: ctx.nestId,
2710
2791
  nodeId: node.id,
2711
2792
  version: currentVersion,
@@ -2760,22 +2841,20 @@ ${list}`;
2760
2841
  }
2761
2842
  case "context_assign_steward": {
2762
2843
  const scope = args.scope;
2763
- if (!["nest", "tag", "folder", "document"].includes(scope)) {
2764
- return `Invalid scope "${args.scope}". Use: nest, tag, folder, or document.`;
2844
+ if (!["nest", "tag", "document"].includes(scope)) {
2845
+ return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
2846
+ }
2847
+ if (!canManageStewards(ctx.userEmail)) {
2848
+ return "You don't have permission to manage stewards. Only the super admin can do this.";
2765
2849
  }
2766
2850
  try {
2767
- assignSteward({
2851
+ await createStewardRecord({
2768
2852
  nestId: ctx.nestId,
2769
2853
  scope,
2770
- nodePattern: scope === "folder" || scope === "document" ? args.target : void 0,
2854
+ documentId: scope === "document" ? args.target : void 0,
2771
2855
  tagName: scope === "tag" ? args.target : void 0,
2772
- userEmail: args.email,
2773
- role: args.role || "reviewer",
2774
- canApprove: true,
2775
- canReject: true,
2776
- assignedBy: ctx.userEmail,
2777
- assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
2778
- isActive: true
2856
+ users: [{ email: args.email, role: args.role || "reviewer" }],
2857
+ assignedBy: ctx.userEmail
2779
2858
  });
2780
2859
  const targetDesc = scope === "nest" ? "all documents" : `${scope}: ${args.target}`;
2781
2860
  return `Assigned **${args.email}** as ${args.role || "reviewer"} for ${targetDesc}.`;
@@ -2783,6 +2862,25 @@ ${list}`;
2783
2862
  return `Failed to assign steward: ${err.message}`;
2784
2863
  }
2785
2864
  }
2865
+ case "context_share_nest": {
2866
+ const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
2867
+ if (!canManageWith(roles)) {
2868
+ return "You don't have permission to share this nest. Only the nest owner or an admin can add people.";
2869
+ }
2870
+ const permission = args.permission || "read";
2871
+ try {
2872
+ await addCollaborator({
2873
+ nestId: ctx.nestId,
2874
+ email: args.email,
2875
+ permission,
2876
+ grantedByEmail: ctx.userEmail
2877
+ });
2878
+ const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
2879
+ return `Shared this nest with **${args.email}** as ${label}.`;
2880
+ } catch (err) {
2881
+ return `Failed to share nest: ${err.message}`;
2882
+ }
2883
+ }
2786
2884
  default:
2787
2885
  return `Unknown tool: ${toolName}`;
2788
2886
  }
@@ -2851,8 +2949,8 @@ mcpRoutes.all("/", async (c) => {
2851
2949
  import { Hono as Hono7 } from "hono";
2852
2950
 
2853
2951
  // src/governance/stewards-parser.ts
2854
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2855
- import { join as join3 } from "path";
2952
+ import { readFileSync, existsSync } from "fs";
2953
+ import { join as join2 } from "path";
2856
2954
  function parseStewardsYaml(content) {
2857
2955
  const result = { version: 1 };
2858
2956
  const lines = content.split("\n");
@@ -2863,9 +2961,6 @@ function parseStewardsYaml(content) {
2863
2961
  if (!currentSection || currentEntries.length === 0) return;
2864
2962
  if (currentSection === "nest") {
2865
2963
  result.nest = [...result.nest || [], ...currentEntries];
2866
- } else if (currentSection === "folders" && currentTarget) {
2867
- result.folders = result.folders || {};
2868
- result.folders[currentTarget] = currentEntries;
2869
2964
  } else if (currentSection === "tags" && currentTarget) {
2870
2965
  result.tags = result.tags || {};
2871
2966
  result.tags[currentTarget] = currentEntries;
@@ -2885,12 +2980,12 @@ function parseStewardsYaml(content) {
2885
2980
  if (key === "version") continue;
2886
2981
  if (key === "nest" || key === "data_room") {
2887
2982
  currentSection = "nest";
2888
- } else if (key === "folders") {
2889
- currentSection = "folders";
2890
2983
  } else if (key === "tags") {
2891
2984
  currentSection = "tags";
2892
2985
  } else if (key === "documents") {
2893
2986
  currentSection = "documents";
2987
+ } else if (key === "folders") {
2988
+ currentSection = null;
2894
2989
  }
2895
2990
  continue;
2896
2991
  }
@@ -2916,25 +3011,19 @@ function parseEntry(str) {
2916
3011
  const entry = { email: emailMatch[1] };
2917
3012
  const roleMatch = str.match(/role:\s*["']?(\w+)["']?/);
2918
3013
  if (roleMatch) entry.role = roleMatch[1];
2919
- if (str.includes("can_approve:")) {
2920
- entry.can_approve = str.includes("can_approve: true");
2921
- }
2922
- if (str.includes("can_reject:")) {
2923
- entry.can_reject = str.includes("can_reject: true");
2924
- }
2925
3014
  return entry;
2926
3015
  }
2927
3016
  function loadStewardsConfig(nestId) {
2928
3017
  const dataRoot = config.DATA_ROOT;
2929
- const nestPath2 = join3(dataRoot, "nests", nestId);
3018
+ const nestPath = join2(dataRoot, "nests", nestId);
2930
3019
  const candidates = [
2931
- join3(nestPath2, "stewards.yaml"),
2932
- join3(nestPath2, "stewards.yml"),
2933
- join3(nestPath2, ".context", "stewards.yaml")
3020
+ join2(nestPath, "stewards.yaml"),
3021
+ join2(nestPath, "stewards.yml"),
3022
+ join2(nestPath, ".context", "stewards.yaml")
2934
3023
  ];
2935
3024
  for (const candidatePath of candidates) {
2936
- if (existsSync2(candidatePath)) {
2937
- const content = readFileSync2(candidatePath, "utf-8");
3025
+ if (existsSync(candidatePath)) {
3026
+ const content = readFileSync(candidatePath, "utf-8");
2938
3027
  return parseStewardsYaml(content);
2939
3028
  }
2940
3029
  }
@@ -2947,24 +3036,41 @@ governanceRoutes.get("/stewards", async (c) => {
2947
3036
  const nestId = c.req.param("nestId");
2948
3037
  const scope = c.req.query("scope");
2949
3038
  const search = c.req.query("search");
2950
- const stewards = listStewards({
3039
+ const stewards = await listStewards({
2951
3040
  nestId,
2952
3041
  scope: scope || void 0,
2953
3042
  search: search || void 0
2954
3043
  });
2955
- return c.json({ stewards });
3044
+ const cache = /* @__PURE__ */ new Map();
3045
+ const enriched = stewards.map((s) => {
3046
+ const key = s.userEmail.toLowerCase();
3047
+ let merged = cache.get(key);
3048
+ if (!merged) {
3049
+ merged = {
3050
+ collaboratorRole: getCollaboratorRole(nestId, s.userEmail),
3051
+ roles: resolveUserRoles(nestId, s.userEmail)
3052
+ };
3053
+ cache.set(key, merged);
3054
+ }
3055
+ return { ...s, ...merged };
3056
+ });
3057
+ return c.json({ stewards: enriched });
2956
3058
  });
2957
3059
  governanceRoutes.post("/stewards", async (c) => {
2958
3060
  const nestId = c.req.param("nestId");
2959
3061
  const body = await c.req.json();
2960
3062
  const assignedBy = getUserEmail3(c);
2961
3063
  if (!body.scope) throw new ValidationError("scope is required");
3064
+ if (body.scope === "folder") {
3065
+ throw new ValidationError(
3066
+ "folder scope is no longer supported \u2014 use 'nest' or 'document'"
3067
+ );
3068
+ }
2962
3069
  if (Array.isArray(body.users)) {
2963
3070
  const created2 = await createStewardRecord({
2964
3071
  nestId,
2965
3072
  scope: body.scope,
2966
3073
  documentId: body.documentId,
2967
- folderPath: body.folderPath,
2968
3074
  tagName: body.tagName,
2969
3075
  users: body.users,
2970
3076
  assignedBy
@@ -2978,20 +3084,31 @@ governanceRoutes.post("/stewards", async (c) => {
2978
3084
  nestId,
2979
3085
  scope: body.scope,
2980
3086
  documentId: body.scope === "document" ? body.nodePattern : void 0,
2981
- folderPath: body.scope === "folder" ? body.nodePattern : void 0,
2982
3087
  tagName: body.scope === "tag" ? body.tagName : void 0,
2983
3088
  users: [
2984
3089
  {
2985
3090
  email: body.email,
2986
- role: body.role,
2987
- canApprove: body.canApprove,
2988
- canReject: body.canReject
3091
+ role: body.role
2989
3092
  }
2990
3093
  ],
2991
3094
  assignedBy
2992
3095
  });
2993
3096
  return c.json({ steward: created[0] }, 201);
2994
3097
  });
3098
+ governanceRoutes.patch("/stewards/:stewardId", async (c) => {
3099
+ const stewardId = c.req.param("stewardId");
3100
+ const body = await c.req.json();
3101
+ if (!body.role && !body.scope) {
3102
+ throw new ValidationError("role or scope is required");
3103
+ }
3104
+ const steward = updateSteward(stewardId, {
3105
+ role: body.role,
3106
+ scope: body.scope,
3107
+ documentId: body.nodePattern,
3108
+ tagName: body.tagName
3109
+ });
3110
+ return c.json({ steward });
3111
+ });
2995
3112
  governanceRoutes.delete("/stewards/:stewardId", async (c) => {
2996
3113
  const stewardId = c.req.param("stewardId");
2997
3114
  removeSteward(stewardId);
@@ -3011,7 +3128,7 @@ governanceRoutes.get("/review-queue", async (c) => {
3011
3128
  const status = c.req.query("status") || "pending";
3012
3129
  const limit = parseInt(c.req.query("limit") || "50", 10);
3013
3130
  const offset = parseInt(c.req.query("offset") || "0", 10);
3014
- const result = getReviewQueue({
3131
+ const result = await getReviewQueue({
3015
3132
  nestId,
3016
3133
  status,
3017
3134
  limit,
@@ -3019,6 +3136,21 @@ governanceRoutes.get("/review-queue", async (c) => {
3019
3136
  });
3020
3137
  return c.json(result);
3021
3138
  });
3139
+ governanceRoutes.get("/external-edits", async (c) => {
3140
+ const nestId = c.req.param("nestId");
3141
+ const refresh = c.req.query("refresh") === "true";
3142
+ if (refresh) {
3143
+ await scanNestForDrift(nestId, "user:refresh");
3144
+ }
3145
+ const entries = await listNestExternalEdits(nestId);
3146
+ return c.json({ entries, total: entries.length });
3147
+ });
3148
+ governanceRoutes.post("/external-edits/scan", async (c) => {
3149
+ const nestId = c.req.param("nestId");
3150
+ const actor = getUserEmail3(c);
3151
+ const result = await scanNestForDrift(nestId, actor);
3152
+ return c.json(result);
3153
+ });
3022
3154
  var governanceNodeRoutes = new Hono7();
3023
3155
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3024
3156
  const nestId = c.req.param("nestId");
@@ -3031,8 +3163,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3031
3163
  role: r.steward.role,
3032
3164
  scope: r.steward.scope,
3033
3165
  source: r.source,
3034
- priority: r.priority,
3035
- canApprove: r.steward.canApprove
3166
+ priority: r.priority
3036
3167
  })),
3037
3168
  fallbackToOwner,
3038
3169
  ownerEmail
@@ -3101,7 +3232,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
3101
3232
  const userEmail = getUserEmail3(c);
3102
3233
  const isAdmin = isSuperAdmin(userEmail);
3103
3234
  try {
3104
- const request = approve({
3235
+ const request = await approve({
3105
3236
  nestId,
3106
3237
  nodeId,
3107
3238
  version: getCurrentVersion(nestId, nodeId),
@@ -3153,6 +3284,90 @@ governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
3153
3284
  const userEmail = getUserEmail3(c);
3154
3285
  return c.json(canUserEdit(nestId, nodeId, userEmail));
3155
3286
  });
3287
+ governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
3288
+ const nestId = c.req.param("nestId");
3289
+ const nodeId = c.req.param("nodeId");
3290
+ const pending = await getPendingChange(nestId, nodeId);
3291
+ return c.json({ pending });
3292
+ });
3293
+ governanceNodeRoutes.get(
3294
+ "/:nodeId{.+?}/external-edits/:suggestionId",
3295
+ async (c) => {
3296
+ const nestId = c.req.param("nestId");
3297
+ const nodeId = c.req.param("nodeId");
3298
+ const suggestionId = c.req.param("suggestionId");
3299
+ const detail = await getExternalEditDetail(
3300
+ nestId,
3301
+ nodeId,
3302
+ suggestionId
3303
+ );
3304
+ if (!detail) {
3305
+ return c.json({ error: "Suggestion not found" }, 404);
3306
+ }
3307
+ return c.json({ entry: detail });
3308
+ }
3309
+ );
3310
+ governanceNodeRoutes.post(
3311
+ "/:nodeId{.+?}/external-edits/:suggestionId/approve",
3312
+ async (c) => {
3313
+ const nestId = c.req.param("nestId");
3314
+ const nodeId = c.req.param("nodeId");
3315
+ const suggestionId = c.req.param("suggestionId");
3316
+ const body = await c.req.json().catch(() => ({}));
3317
+ const actor = getUserEmail3(c);
3318
+ try {
3319
+ const result = await approveExternalEdit({
3320
+ nestId,
3321
+ documentId: nodeId,
3322
+ suggestionId,
3323
+ actor,
3324
+ comment: body.comment
3325
+ });
3326
+ return c.json({
3327
+ approved: true,
3328
+ version: result.versionEntry.version,
3329
+ chainEvent: result.chainEvent.event_id
3330
+ });
3331
+ } catch (err) {
3332
+ console.error(
3333
+ `[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
3334
+ { message: err?.message, name: err?.name, stack: err?.stack }
3335
+ );
3336
+ return c.json(
3337
+ { error: err.message, name: err?.name },
3338
+ 400
3339
+ );
3340
+ }
3341
+ }
3342
+ );
3343
+ governanceNodeRoutes.post(
3344
+ "/:nodeId{.+?}/external-edits/:suggestionId/reject",
3345
+ async (c) => {
3346
+ const nestId = c.req.param("nestId");
3347
+ const nodeId = c.req.param("nodeId");
3348
+ const suggestionId = c.req.param("suggestionId");
3349
+ const body = await c.req.json().catch(() => ({}));
3350
+ if (!body.reason) {
3351
+ throw new ValidationError("Rejection reason is required");
3352
+ }
3353
+ const actor = getUserEmail3(c);
3354
+ try {
3355
+ const result = await rejectExternalEdit({
3356
+ nestId,
3357
+ documentId: nodeId,
3358
+ suggestionId,
3359
+ actor,
3360
+ reason: body.reason
3361
+ });
3362
+ return c.json({
3363
+ rejected: true,
3364
+ chainEvent: result.chainEvent.event_id
3365
+ });
3366
+ } catch (err) {
3367
+ return c.json({ error: err.message }, 400);
3368
+ }
3369
+ }
3370
+ );
3156
3371
  governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
3157
3372
  const nestId = c.req.param("nestId");
3158
3373
  const nodeId = c.req.param("nodeId");
@@ -3173,31 +3388,29 @@ function getUserEmail3(c) {
3173
3388
 
3174
3389
  // src/auth/anonymous.ts
3175
3390
  import bcrypt from "bcryptjs";
3176
- var ANON_USER_ID3 = "00000000-0000-0000-0000-000000000000";
3177
- var ANON_EMAIL = "admin@localhost";
3178
3391
  function ensureAnonymousUser() {
3179
3392
  const db = getDb();
3180
- const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID3);
3393
+ const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID);
3181
3394
  if (!exists) {
3182
3395
  const placeholder = bcrypt.hashSync("anon-no-login", 4);
3183
3396
  db.prepare(
3184
3397
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
3185
- ).run(ANON_USER_ID3, ANON_EMAIL, "Admin", placeholder);
3398
+ ).run(ANON_USER_ID, ANON_EMAIL, "Admin", placeholder);
3186
3399
  }
3187
- return ANON_USER_ID3;
3400
+ return ANON_USER_ID;
3188
3401
  }
3189
3402
 
3190
3403
  // src/app.ts
3191
3404
  import { serveStatic } from "@hono/node-server/serve-static";
3192
3405
  import { fileURLToPath } from "url";
3193
- import { dirname, join as join4, relative } from "path";
3194
- import { existsSync as existsSync3 } from "fs";
3406
+ import { dirname, join as join3, relative } from "path";
3407
+ import { existsSync as existsSync2 } from "fs";
3195
3408
  var HERE = dirname(fileURLToPath(import.meta.url));
3196
3409
  var UI_DIR_CANDIDATES = [
3197
- join4(HERE, "web3"),
3198
- join4(process.cwd(), "dist", "web3")
3410
+ join3(HERE, "web3"),
3411
+ join3(process.cwd(), "dist", "web3")
3199
3412
  ];
3200
- var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync3(p)) || UI_DIR_CANDIDATES[0];
3413
+ var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
3201
3414
  var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
3202
3415
  var openModeMiddleware = createMiddleware2(async (c, next) => {
3203
3416
  const anonId = ensureAnonymousUser();
@@ -3205,6 +3418,11 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
3205
3418
  c.set("nestScope", null);
3206
3419
  await next();
3207
3420
  });
3421
+ function isPublicReadEligiblePath(method, path) {
3422
+ if (method !== "GET") return false;
3423
+ if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
3424
+ return !/\/(collaborators|visibility|settings|mcp)/.test(path);
3425
+ }
3208
3426
  var flexAuthMiddleware = createMiddleware2(async (c, next) => {
3209
3427
  const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
3210
3428
  const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
@@ -3217,6 +3435,12 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
3217
3435
  c.set("nestScope", null);
3218
3436
  return next();
3219
3437
  }
3438
+ if (isPublicReadEligiblePath(c.req.method, c.req.path)) {
3439
+ const anonId = ensureAnonymousUser();
3440
+ c.set("userId", anonId);
3441
+ c.set("nestScope", null);
3442
+ return next();
3443
+ }
3220
3444
  return c.json({ error: "Missing or invalid credentials" }, 401);
3221
3445
  });
3222
3446
  function createApp() {
@@ -3248,6 +3472,7 @@ function createApp() {
3248
3472
  service: "contextnest-community",
3249
3473
  version: "0.1.0",
3250
3474
  auth_mode: config.AUTH_MODE,
3475
+ logo_url: config.LOGO_URL,
3251
3476
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
3252
3477
  })
3253
3478
  );
@@ -3308,7 +3533,7 @@ function createApp() {
3308
3533
  return c.json(
3309
3534
  {
3310
3535
  valid: false,
3311
- error: "License key was saved but PromptOwl rejected it. Verify the key is correct and active."
3536
+ error: "PromptOwl rejected this license key. It wasn't saved. Verify the key is correct and active, then try again."
3312
3537
  },
3313
3538
  400
3314
3539
  );
@@ -3324,6 +3549,28 @@ function createApp() {
3324
3549
  return c.json({ error: msg }, 500);
3325
3550
  }
3326
3551
  });
3552
+ app.use("/stats", flexAuthMiddleware);
3553
+ app.get("/stats", async (c) => {
3554
+ const db = getDb();
3555
+ const userId = c.get("userId");
3556
+ const userEmail = resolveCallerEmail(userId);
3557
+ const visibleNests = [...listNests(userId), ...listSharedNests(userId)];
3558
+ let documents = 0;
3559
+ for (const nest of visibleNests) {
3560
+ try {
3561
+ const { storage } = engineCache.get(nest.id);
3562
+ const docs = await storage.discoverDocuments();
3563
+ documents += filterAccessible(nest.id, userId, userEmail, docs).length;
3564
+ } catch {
3565
+ }
3566
+ }
3567
+ const usersRow = db.prepare("SELECT COUNT(*) as c FROM users").get();
3568
+ return c.json({
3569
+ nests: visibleNests.length,
3570
+ documents,
3571
+ users: usersRow.c
3572
+ });
3573
+ });
3327
3574
  const nestsApp = new Hono8();
3328
3575
  nestsApp.use("*", flexAuthMiddleware);
3329
3576
  nestsApp.use("*", async (c, next) => {
@@ -3375,12 +3622,42 @@ function createApp() {
3375
3622
  let required = "read";
3376
3623
  const path = c.req.path;
3377
3624
  const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
3625
+ const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
3626
+ if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
3627
+ return c.json(
3628
+ {
3629
+ error: "You don't have permission to manage stewards. Only the super admin can do this."
3630
+ },
3631
+ 403
3632
+ );
3633
+ }
3378
3634
  if (path.includes("/collaborators") || path.includes("/visibility")) {
3379
3635
  required = "admin";
3380
3636
  } else if (c.req.method !== "GET" && !isStewardActionPath) {
3381
3637
  required = "write";
3382
3638
  }
3383
- if (permissionLevel(permission) < permissionLevel(required)) {
3639
+ let stewardEditorBypass = false;
3640
+ if (required === "write" && permission === "read" && parts[1] === "nodes") {
3641
+ const userEmail = resolveCallerEmail(userId);
3642
+ if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE")) {
3643
+ const rawNodeId = parts.slice(2).join("/");
3644
+ let nodeId = rawNodeId;
3645
+ try {
3646
+ nodeId = decodeURIComponent(rawNodeId);
3647
+ } catch {
3648
+ }
3649
+ const resolved = resolveStewardsForNode(nestId, nodeId);
3650
+ stewardEditorBypass = resolved.some(
3651
+ (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
3652
+ );
3653
+ } else if (parts.length === 2 && c.req.method === "POST") {
3654
+ const resolved = resolveStewardsForNode(nestId, "");
3655
+ stewardEditorBypass = resolved.some(
3656
+ (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
3657
+ );
3658
+ }
3659
+ }
3660
+ if (!stewardEditorBypass && permissionLevel(permission) < permissionLevel(required)) {
3384
3661
  return c.json(
3385
3662
  {
3386
3663
  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.`,
@@ -3479,9 +3756,108 @@ function createApp() {
3479
3756
  return app;
3480
3757
  }
3481
3758
 
3759
+ // src/db/backfill.ts
3760
+ import { NestStorage } from "@promptowl/contextnest-engine";
3761
+ import { join as join4 } from "path";
3762
+ var MIGRATION_ID = "005_backfill_node_versions_from_history";
3763
+ async function backfillNodeVersionsFromHistory(db) {
3764
+ const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
3765
+ if (already) return;
3766
+ const nests = db.prepare("SELECT id FROM nests").all();
3767
+ const insert = db.prepare(
3768
+ `INSERT OR IGNORE INTO node_versions
3769
+ (nest_id, node_id, version, content_hash, author, status, change_note, created_at)
3770
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3771
+ );
3772
+ const approvedPin = db.prepare(
3773
+ `INSERT OR REPLACE INTO approved_versions
3774
+ (nest_id, node_id, approved_version, approved_by, approved_at)
3775
+ VALUES (?, ?, ?, ?, COALESCE(
3776
+ (SELECT approved_at FROM approved_versions WHERE nest_id = ? AND node_id = ?),
3777
+ datetime('now')))`
3778
+ );
3779
+ let totalInserted = 0;
3780
+ let totalDocs = 0;
3781
+ for (const { id: nestId } of nests) {
3782
+ const nestPath = join4(config.DATA_ROOT, "nests", nestId);
3783
+ const storage = new NestStorage(nestPath);
3784
+ let docs;
3785
+ try {
3786
+ docs = await storage.discoverDocuments();
3787
+ } catch (err) {
3788
+ console.warn(
3789
+ `[backfill] discoverDocuments failed for nest ${nestId}:`,
3790
+ err.message
3791
+ );
3792
+ continue;
3793
+ }
3794
+ for (const doc of docs) {
3795
+ totalDocs += 1;
3796
+ let history;
3797
+ try {
3798
+ history = await storage.readHistory(doc.id);
3799
+ } catch {
3800
+ history = null;
3801
+ }
3802
+ if (!history || history.versions.length === 0) continue;
3803
+ const existing = db.prepare(
3804
+ `SELECT version FROM node_versions WHERE nest_id = ? AND node_id = ?`
3805
+ ).all(nestId, doc.id);
3806
+ const known = new Set(existing.map((r) => r.version));
3807
+ const tagsJson = doc.frontmatter.tags ? JSON.stringify(doc.frontmatter.tags) : null;
3808
+ const latestVersion = history.versions[history.versions.length - 1].version;
3809
+ for (const entry of history.versions) {
3810
+ if (known.has(entry.version)) continue;
3811
+ insert.run(
3812
+ nestId,
3813
+ doc.id,
3814
+ entry.version,
3815
+ entry.content_hash || "",
3816
+ entry.edited_by || "system:backfill",
3817
+ "approved",
3818
+ entry.note || null,
3819
+ entry.edited_at || (/* @__PURE__ */ new Date()).toISOString()
3820
+ );
3821
+ totalInserted += 1;
3822
+ }
3823
+ const pin = db.prepare(
3824
+ `SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?`
3825
+ ).get(nestId, doc.id);
3826
+ if (!pin || pin.approved_version < latestVersion) {
3827
+ approvedPin.run(
3828
+ nestId,
3829
+ doc.id,
3830
+ latestVersion,
3831
+ history.versions[history.versions.length - 1].edited_by || "system:backfill",
3832
+ nestId,
3833
+ doc.id
3834
+ );
3835
+ }
3836
+ if (tagsJson) {
3837
+ const updateTags = db.prepare(
3838
+ `UPDATE node_versions SET tags_json = ?
3839
+ WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL`
3840
+ );
3841
+ updateTags.run(tagsJson, nestId, doc.id, latestVersion);
3842
+ }
3843
+ }
3844
+ }
3845
+ db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)").run(
3846
+ MIGRATION_ID
3847
+ );
3848
+ console.log(
3849
+ `[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
3850
+ );
3851
+ }
3852
+
3482
3853
  // src/index.ts
3483
3854
  async function main() {
3484
- getDb();
3855
+ const db = getDb();
3856
+ try {
3857
+ await backfillNodeVersionsFromHistory(db);
3858
+ } catch (err) {
3859
+ console.error("[backfill] node_versions backfill failed:", err);
3860
+ }
3485
3861
  const accessCfg = loadAccessConfig();
3486
3862
  if (accessCfg) {
3487
3863
  console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
@@ -3502,7 +3878,11 @@ async function main() {
3502
3878
  `);
3503
3879
  }
3504
3880
  const app = createApp();
3505
- startLicenseWatcher();
3881
+ startLicenseSafetyPoll();
3882
+ const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
3883
+ if (driftScanIntervalMs > 0) {
3884
+ startDriftScanner(driftScanIntervalMs);
3885
+ }
3506
3886
  startTelemetryLoop();
3507
3887
  trackEvent("server.start", {
3508
3888
  tier: license.tier,