@promptowl/contextnest-community 1.0.1 → 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,48 +6,85 @@ import {
6
6
  hashPassword,
7
7
  parseBearerToken,
8
8
  verifyPassword
9
- } from "./chunk-7K2LLJXK.js";
9
+ } from "./chunk-XRK6SQSC.js";
10
10
  import {
11
11
  approve,
12
12
  cancelReview,
13
- engineCache,
14
13
  getPendingReview,
15
14
  getReviewHistory,
16
15
  getReviewQueue,
17
16
  reject,
18
17
  safePublishDocument,
19
18
  submitForReview
20
- } from "./chunk-5VHKEIAW.js";
19
+ } from "./chunk-S2EWN2VA.js";
20
+ import {
21
+ checkConflict,
22
+ createVersion,
23
+ getApprovedVersion,
24
+ getCurrentVersion,
25
+ getDisplayStatus,
26
+ getVersions,
27
+ setApprovedVersion
28
+ } from "./chunk-7UTMBL6Z.js";
21
29
  import {
22
- assignSteward,
30
+ AppError,
31
+ ConflictError,
32
+ ForbiddenError,
33
+ NotFoundError,
34
+ ValidationError,
35
+ canCreateInNest,
36
+ canManageStewards,
37
+ canManageWith,
23
38
  canUserAccess,
24
39
  canUserApprove,
25
40
  canUserEdit,
41
+ createNest,
26
42
  createStewardRecord,
43
+ deleteNest,
44
+ disableStewardshipAndWipeGovernance,
45
+ engineCache,
46
+ getCollaboratorRole,
47
+ getCurrentLicense,
48
+ getNest,
49
+ getStewardRolesForUser,
27
50
  getStewardsForNest,
51
+ getStewardsForUser,
52
+ getSuspensionReason,
53
+ importNest,
54
+ installLicenseKey,
55
+ isLicenseAdminEmail,
56
+ isLicenseAdminUserId,
57
+ isPublicReader,
58
+ isStewardshipEnabled,
28
59
  isSuperAdmin,
60
+ isSuspended,
61
+ listNests,
62
+ listPublicNests,
63
+ listSharedNests,
29
64
  listStewards,
30
65
  loadAccessConfig,
66
+ nestAllowsSelfApprove,
67
+ permissionLevel,
31
68
  removeSteward,
69
+ resolveNestPermission,
32
70
  resolveStewardsForNode,
33
71
  resolveStewardsWithFallback,
34
- syncFromConfig
35
- } from "./chunk-K22GWPT4.js";
36
- import {
37
- checkConflict,
38
- createVersion,
39
- getApprovedVersion,
40
- getCurrentVersion,
41
- getDisplayStatus,
42
- getVersions,
43
- setApprovedVersion
44
- } from "./chunk-JMZ75ZCD.js";
72
+ resolveUserRoles,
73
+ setAllowSelfApprove,
74
+ setStewardshipEnabled,
75
+ startLicenseSafetyPoll,
76
+ startTelemetryLoop,
77
+ syncFromConfig,
78
+ trackEvent,
79
+ updateSteward,
80
+ validateLicense
81
+ } from "./chunk-WCOUCBDJ.js";
45
82
  import {
46
83
  ANON_EMAIL,
47
84
  ANON_USER_ID,
48
85
  config,
49
86
  getDb
50
- } from "./chunk-KQCWNHDM.js";
87
+ } from "./chunk-TDAX3JOT.js";
51
88
 
52
89
  // src/index.ts
53
90
  import { serve } from "@hono/node-server";
@@ -172,463 +209,9 @@ var authMiddleware = createMiddleware(async (c, next) => {
172
209
  return c.json({ error: "Missing or invalid credentials" }, 401);
173
210
  });
174
211
 
175
- // src/shared/errors.ts
176
- var AppError = class extends Error {
177
- constructor(statusCode, message) {
178
- super(message);
179
- this.statusCode = statusCode;
180
- this.name = "AppError";
181
- }
182
- statusCode;
183
- };
184
- var NotFoundError = class extends AppError {
185
- constructor(message = "Not found") {
186
- super(404, message);
187
- this.name = "NotFoundError";
188
- }
189
- };
190
- var ForbiddenError = class extends AppError {
191
- constructor(message = "Forbidden") {
192
- super(403, message);
193
- this.name = "ForbiddenError";
194
- }
195
- };
196
- var ValidationError = class extends AppError {
197
- constructor(message) {
198
- super(400, message);
199
- this.name = "ValidationError";
200
- }
201
- };
202
- var ConflictError = class extends AppError {
203
- constructor(message) {
204
- super(409, message);
205
- this.name = "ConflictError";
206
- }
207
- };
208
-
209
- // src/telemetry/tracker.ts
210
- function trackEvent(event, data) {
211
- if (!config.TELEMETRY_ENABLED) return;
212
- try {
213
- const db = getDb();
214
- db.prepare(
215
- "INSERT INTO telemetry_events (event, data_json) VALUES (?, ?)"
216
- ).run(event, data ? JSON.stringify(data) : null);
217
- } catch {
218
- }
219
- }
220
- async function flushTelemetry() {
221
- if (!config.TELEMETRY_ENABLED || !config.PROMPTOWL_KEY) return;
222
- const db = getDb();
223
- const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get()?.c || 0;
224
- const nestCount = db.prepare("SELECT COUNT(*) as c FROM nests").get()?.c || 0;
225
- const events = db.prepare(
226
- "SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
227
- ).all();
228
- if (events.length === 0 && userCount === 0) return;
229
- const payload = {
230
- server_key: config.PROMPTOWL_KEY,
231
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
232
- stats: { users: userCount, nests: nestCount },
233
- events: events.map((e) => ({
234
- event: e.event,
235
- data: e.data_json ? JSON.parse(e.data_json) : null,
236
- at: e.created_at
237
- }))
238
- };
239
- try {
240
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
241
- const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
242
- method: "POST",
243
- headers: { "Content-Type": "application/json" },
244
- body: JSON.stringify(payload)
245
- });
246
- if (res.ok && events.length > 0) {
247
- const ids = events.map((e) => e.id);
248
- db.prepare(
249
- `UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
250
- ).run(...ids);
251
- }
252
- } catch {
253
- }
254
- }
255
- var telemetryTimer = null;
256
- function startTelemetryLoop() {
257
- if (!config.TELEMETRY_ENABLED) return;
258
- setTimeout(() => flushTelemetry(), 3e4);
259
- telemetryTimer = setInterval(
260
- () => flushTelemetry(),
261
- config.TELEMETRY_INTERVAL_MS
262
- );
263
- }
264
-
265
- // src/auth/license.ts
266
- import { existsSync, readFileSync, writeFileSync } from "fs";
267
- var currentLicense = null;
268
- function getCurrentLicense() {
269
- return currentLicense;
270
- }
271
- function isLicenseAdminEmail(email) {
272
- if (!email) return false;
273
- const lic = currentLicense;
274
- if (!lic?.valid || !lic.ownerEmail) return false;
275
- return lic.ownerEmail.toLowerCase() === email.toLowerCase();
276
- }
277
- function isLicenseAdminUserId(userId) {
278
- try {
279
- const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
280
- return isLicenseAdminEmail(row?.email);
281
- } catch {
282
- return false;
283
- }
284
- }
285
- function upsertEnvVar(filePath, varName, value) {
286
- const prefix = `${varName}=`;
287
- let lines = [];
288
- if (existsSync(filePath)) {
289
- lines = readFileSync(filePath, "utf8").split(/\r?\n/);
290
- }
291
- const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
292
- if (value !== null) {
293
- filtered.push(`${prefix}${value}`);
294
- }
295
- while (filtered.length && filtered[filtered.length - 1] === "") {
296
- filtered.pop();
297
- }
298
- writeFileSync(filePath, filtered.join("\n") + "\n", "utf8");
299
- }
300
- async function installLicenseKey(key) {
301
- const trimmed = key.trim();
302
- if (!trimmed.startsWith("pk_")) {
303
- throw new Error("Invalid license key format. Must start with pk_.");
304
- }
305
- const previousKey = process.env.PROMPTOWL_KEY || "";
306
- process.env.PROMPTOWL_KEY = trimmed;
307
- const info = await validateLicense({ forceFresh: true });
308
- if (!info.valid) {
309
- process.env.PROMPTOWL_KEY = previousKey;
310
- if (previousKey) {
311
- await validateLicense({ forceFresh: true });
312
- }
313
- return info;
314
- }
315
- try {
316
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
317
- } catch (err) {
318
- console.warn("[license] failed to write .env:", err);
319
- }
320
- startLicenseWatcher();
321
- return info;
322
- }
323
- var watcherActive = false;
324
- var watcherAbort = null;
325
- var WATCHER_BACKOFF_MIN_MS = 2 * 1e3;
326
- var WATCHER_BACKOFF_MAX_MS = 60 * 1e3;
327
- function startLicenseWatcher() {
328
- if (watcherActive) {
329
- watcherAbort?.abort();
330
- return;
331
- }
332
- watcherActive = true;
333
- void runLicenseWatcher();
334
- }
335
- async function runLicenseWatcher() {
336
- let backoff = WATCHER_BACKOFF_MIN_MS;
337
- while (watcherActive) {
338
- const key = config.PROMPTOWL_KEY;
339
- if (!key) {
340
- await sleep(WATCHER_BACKOFF_MAX_MS);
341
- continue;
342
- }
343
- try {
344
- watcherAbort = new AbortController();
345
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
346
- const fetchTimeout = AbortSignal.timeout(30 * 1e3);
347
- const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([watcherAbort.signal, fetchTimeout]) : watcherAbort.signal;
348
- const res = await fetch(`${promptowlUrl}/api/license/listen`, {
349
- method: "POST",
350
- headers: { "Content-Type": "application/json" },
351
- body: JSON.stringify({
352
- key,
353
- since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
354
- }),
355
- signal
356
- });
357
- if (!res.ok) {
358
- if (res.status === 504 || res.status === 408 || res.status === 502) {
359
- backoff = WATCHER_BACKOFF_MIN_MS;
360
- continue;
361
- }
362
- throw new Error(`listen returned ${res.status}`);
363
- }
364
- const data = await res.json();
365
- backoff = WATCHER_BACKOFF_MIN_MS;
366
- if (data.event && data.event !== "no_change") {
367
- console.log(
368
- `[license] event from PromptOwl: ${data.event} \u2014 revalidating`
369
- );
370
- const wasValid = !!currentLicense?.valid;
371
- await validateLicense({ forceFresh: true });
372
- const isValid = !!currentLicense?.valid;
373
- if (wasValid && !isValid) {
374
- handleLicenseRevoked();
375
- }
376
- }
377
- } catch (err) {
378
- if (err.name === "AbortError") {
379
- continue;
380
- }
381
- console.warn(
382
- `[license] watcher error: ${err.message}; backing off ${backoff}ms`
383
- );
384
- await sleep(backoff);
385
- backoff = Math.min(backoff * 2, WATCHER_BACKOFF_MAX_MS);
386
- } finally {
387
- watcherAbort = null;
388
- }
389
- }
390
- }
391
- function sleep(ms) {
392
- return new Promise((r) => setTimeout(r, ms));
393
- }
394
- var safetyPollHandle = null;
395
- var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
396
- function startLicenseSafetyPoll() {
397
- if (safetyPollHandle) return;
398
- safetyPollHandle = setInterval(async () => {
399
- if (!config.PROMPTOWL_KEY) return;
400
- try {
401
- const wasValid = !!currentLicense?.valid;
402
- await validateLicense({ forceFresh: true });
403
- const isValid = !!currentLicense?.valid;
404
- if (wasValid && !isValid) {
405
- console.log("[license] safety poll detected revocation");
406
- handleLicenseRevoked();
407
- }
408
- } catch (err) {
409
- console.warn(
410
- `[license] safety poll error: ${err.message}`
411
- );
412
- }
413
- }, SAFETY_POLL_INTERVAL_MS);
414
- if (typeof safetyPollHandle.unref === "function") {
415
- safetyPollHandle.unref();
416
- }
417
- }
418
- function handleLicenseRevoked() {
419
- try {
420
- const db = getDb();
421
- const result = db.prepare("DELETE FROM sessions").run();
422
- console.warn(
423
- `[license] revoked \u2014 wiped ${result.changes} active session(s).`
424
- );
425
- } catch (err) {
426
- console.warn("[license] failed to wipe sessions:", err);
427
- }
428
- try {
429
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
430
- console.warn(
431
- `[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
432
- );
433
- } catch (err) {
434
- console.warn("[license] failed to strip key from .env:", err);
435
- }
436
- process.env.PROMPTOWL_KEY = "";
437
- currentLicense = {
438
- valid: false,
439
- tier: "none",
440
- org: null,
441
- limits: null,
442
- suspended: false,
443
- suspendedReason: null,
444
- ownerEmail: null
445
- };
446
- }
447
- var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
448
- var suspensionFirstSeen = null;
449
- var suspensionConfirmed = false;
450
- var suspensionReason = null;
451
- var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
452
- function isSuspended() {
453
- return suspensionConfirmed;
454
- }
455
- function getSuspensionReason() {
456
- return suspensionReason;
457
- }
458
- async function validateLicense(opts = {}) {
459
- const info = await _validateLicenseImpl(!!opts.forceFresh);
460
- currentLicense = info;
461
- return info;
462
- }
463
- async function _validateLicenseImpl(forceFresh) {
464
- const key = config.PROMPTOWL_KEY;
465
- if (!key) {
466
- return {
467
- valid: false,
468
- tier: "none",
469
- org: null,
470
- limits: null,
471
- suspended: false,
472
- suspendedReason: null,
473
- ownerEmail: null
474
- };
475
- }
476
- const db = getDb();
477
- const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
478
- if (cached && !forceFresh) {
479
- const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
480
- if (age < CACHE_TTL_MS) {
481
- return {
482
- valid: true,
483
- tier: cached.tier,
484
- org: cached.org,
485
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
486
- suspended: suspensionConfirmed,
487
- suspendedReason: suspensionReason,
488
- ownerEmail: cached.owner_email || null
489
- };
490
- }
491
- }
492
- try {
493
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
494
- const res = await fetch(`${promptowlUrl}/api/license/validate`, {
495
- method: "POST",
496
- headers: { "Content-Type": "application/json" },
497
- body: JSON.stringify({ key })
498
- });
499
- if (!res.ok) {
500
- if (cached) {
501
- console.warn(
502
- " PromptOwl unreachable, using cached license (grace period)"
503
- );
504
- return {
505
- valid: true,
506
- tier: cached.tier,
507
- org: cached.org,
508
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
509
- suspended: suspensionConfirmed,
510
- suspendedReason: suspensionReason,
511
- ownerEmail: cached.owner_email || null
512
- };
513
- }
514
- return {
515
- valid: false,
516
- tier: "none",
517
- org: null,
518
- limits: null,
519
- suspended: false,
520
- suspendedReason: null,
521
- ownerEmail: null
522
- };
523
- }
524
- const data = await res.json();
525
- if (data.suspended === true) {
526
- if (!suspensionFirstSeen) {
527
- suspensionFirstSeen = Date.now();
528
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
529
- console.warn(
530
- `
531
- WARNING: PromptOwl has flagged this server for suspension.`
532
- );
533
- console.warn(
534
- ` Reason: ${suspensionReason}`
535
- );
536
- console.warn(
537
- ` This will be confirmed in ~1 hour. If this is an error,`
538
- );
539
- console.warn(
540
- ` contact support@promptowl.ai to reverse it.
541
- `
542
- );
543
- } else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
544
- suspensionConfirmed = true;
545
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
546
- console.error(
547
- `
548
- SERVER SUSPENDED: ${suspensionReason}`
549
- );
550
- console.error(
551
- ` Write operations are disabled. Reads still work.`
552
- );
553
- console.error(
554
- ` Contact support@promptowl.ai to resolve.
555
- `
556
- );
557
- }
558
- } else {
559
- if (suspensionFirstSeen) {
560
- console.log(" Suspension flag cleared by PromptOwl.");
561
- }
562
- suspensionFirstSeen = null;
563
- suspensionConfirmed = false;
564
- suspensionReason = null;
565
- }
566
- if (!data.valid && !data.suspended) {
567
- db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
568
- return {
569
- valid: false,
570
- tier: "none",
571
- org: null,
572
- limits: null,
573
- suspended: false,
574
- suspendedReason: null,
575
- ownerEmail: data.owner_email || null
576
- };
577
- }
578
- if (data.valid) {
579
- const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
580
- db.prepare(
581
- `INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
582
- VALUES (?, ?, ?, ?, ?, datetime('now'))`
583
- ).run(
584
- key,
585
- data.tier || "community",
586
- data.org || null,
587
- limitsJson,
588
- data.owner_email || null
589
- );
590
- }
591
- return {
592
- valid: data.valid !== false,
593
- tier: data.tier || "community",
594
- org: data.org || null,
595
- limits: data.limits || null,
596
- suspended: suspensionConfirmed,
597
- suspendedReason: suspensionReason,
598
- ownerEmail: data.owner_email || null
599
- };
600
- } catch (err) {
601
- if (cached) {
602
- console.warn(
603
- ` PromptOwl validation failed (${err.message}), using cached license`
604
- );
605
- return {
606
- valid: true,
607
- tier: cached.tier,
608
- org: cached.org,
609
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
610
- suspended: suspensionConfirmed,
611
- suspendedReason: suspensionReason,
612
- ownerEmail: cached.owner_email || null
613
- };
614
- }
615
- return {
616
- valid: false,
617
- tier: "none",
618
- org: null,
619
- limits: null,
620
- suspended: false,
621
- suspendedReason: null,
622
- ownerEmail: null
623
- };
624
- }
625
- }
626
-
627
212
  // src/shared/rate-limit.ts
628
213
  var buckets = /* @__PURE__ */ new Map();
629
- function tryConsume(key, cfg) {
630
- const now = Date.now();
631
- const cutoff = now - cfg.windowMs;
214
+ function liveBucket(key, cutoff) {
632
215
  let bucket = buckets.get(key);
633
216
  if (!bucket) {
634
217
  bucket = { hits: [] };
@@ -637,14 +220,29 @@ function tryConsume(key, cfg) {
637
220
  while (bucket.hits.length && bucket.hits[0] < cutoff) {
638
221
  bucket.hits.shift();
639
222
  }
223
+ return bucket;
224
+ }
225
+ function tryConsume(key, cfg) {
226
+ const now = Date.now();
227
+ const bucket = liveBucket(key, now - cfg.windowMs);
640
228
  if (bucket.hits.length >= cfg.max) {
641
229
  return false;
642
230
  }
643
231
  bucket.hits.push(now);
644
232
  return true;
645
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
+ }
646
243
 
647
244
  // src/auth/routes.ts
245
+ import { getConnInfo } from "@hono/node-server/conninfo";
648
246
  var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
649
247
  var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
650
248
  var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
@@ -653,6 +251,11 @@ function clientIp(c) {
653
251
  if (xff) return xff.split(",")[0].trim();
654
252
  const realIp = c.req.header("x-real-ip");
655
253
  if (realIp) return realIp.trim();
254
+ try {
255
+ const addr = getConnInfo(c).remote.address;
256
+ if (addr) return addr;
257
+ } catch {
258
+ }
656
259
  return "unknown";
657
260
  }
658
261
  function resolveCallerUserId(c) {
@@ -731,7 +334,10 @@ authRoutes.post("/login", async (c) => {
731
334
  }
732
335
  const ip = clientIp(c);
733
336
  const emailLower = body.email.toLowerCase();
734
- 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)) {
735
341
  return c.json({ error: "Too many login attempts, try again later" }, 429);
736
342
  }
737
343
  const db = getDb();
@@ -740,8 +346,14 @@ authRoutes.post("/login", async (c) => {
740
346
  ).get(body.email);
741
347
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
742
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}`);
743
352
  return c.json({ error: "Invalid credentials" }, 401);
744
353
  }
354
+ console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
355
+ if (hasIp) clear(ipKey);
356
+ clear(emailKey);
745
357
  if (check.needsRehash) {
746
358
  try {
747
359
  const newHash = await hashPassword(body.password);
@@ -1079,8 +691,13 @@ authRoutes.get("/teammates", async (c) => {
1079
691
  WHERE s.is_active = 1
1080
692
  AND NOT EXISTS (
1081
693
  SELECT 1 FROM users u
1082
- JOIN api_keys k ON k.user_id = u.id
1083
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
+ )
1084
701
  )
1085
702
  ORDER BY s.user_email`
1086
703
  ).all();
@@ -1094,392 +711,116 @@ authRoutes.get("/teammates", async (c) => {
1094
711
  // src/nests/routes.ts
1095
712
  import { Hono as Hono2 } from "hono";
1096
713
 
1097
- // src/shared/access.ts
1098
- var PERMISSION_LEVELS = {
1099
- none: 0,
1100
- read: 1,
1101
- write: 2,
1102
- admin: 3,
1103
- owner: 4
1104
- };
1105
- function resolveNestPermission(nestId, userId) {
1106
- const db = getDb();
1107
- const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
1108
- if (!nest) return "none";
1109
- if (nest.user_id === userId) return "owner";
1110
- if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
1111
- return "owner";
1112
- }
1113
- const directGrant = db.prepare(
1114
- "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
1115
- ).get(nestId, userId);
1116
- if (directGrant) return directGrant.permission;
1117
- if (nest.visibility === "public") return "read";
1118
- return "none";
1119
- }
1120
- function permissionLevel(p) {
1121
- return PERMISSION_LEVELS[p] ?? 0;
1122
- }
714
+ // src/nodes/service.ts
715
+ import { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
1123
716
 
1124
- // src/nests/service.ts
1125
- import { join } from "path";
1126
- import { rmSync, mkdirSync } from "fs";
1127
- import { v4 as uuid2 } from "uuid";
1128
- import { NestStorage } from "@promptowl/contextnest-engine";
1129
- function nestPath(nestId) {
1130
- return join(config.DATA_ROOT, "nests", nestId);
1131
- }
1132
- function toSlug(name) {
1133
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1134
- }
1135
- function isStewardshipEnabled(nestId) {
1136
- const db = getDb();
1137
- const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
1138
- return !!row?.stewardship_enabled;
717
+ // src/governance/tag-index-service.ts
718
+ function normalizeTag(raw) {
719
+ return raw.trim().replace(/^#+/, "").toLowerCase();
1139
720
  }
1140
- function setStewardshipEnabled(nestId, enabled) {
721
+ function syncNodeTags(nestId, nodeId, tags) {
1141
722
  const db = getDb();
1142
- db.prepare("UPDATE nests SET stewardship_enabled = ? WHERE id = ?").run(
1143
- enabled ? 1 : 0,
1144
- nestId
723
+ const normalized = Array.from(
724
+ new Set(
725
+ tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
726
+ )
1145
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
+ })();
1146
739
  }
1147
- async function createNest(userId, name, description) {
1148
- const id = uuid2();
1149
- const slug = toSlug(name);
740
+ function removeNodeFromTagIndex(nestId, nodeId) {
1150
741
  const db = getDb();
1151
- const visibility = userId === ANON_USER_ID ? "public" : "private";
1152
742
  db.prepare(
1153
- "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)"
1154
- ).run(id, userId, name, slug, description || null, visibility);
1155
- const path = nestPath(id);
1156
- mkdirSync(path, { recursive: true });
1157
- const storage = new NestStorage(path);
1158
- await storage.init(name);
1159
- trackEvent("nest.create", { nestId: id, userId });
1160
- return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
743
+ "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
744
+ ).run(nestId, nodeId);
1161
745
  }
1162
- function listNests(userId) {
746
+
747
+ // src/governance/access-guard.ts
748
+ function resolveCallerEmail(userId) {
749
+ if (!userId) return "admin@localhost";
1163
750
  const db = getDb();
1164
- if (userId === ANON_USER_ID) {
1165
- return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
1166
- }
1167
- const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
1168
- if (!includeAnon) {
1169
- return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
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;
1170
757
  }
1171
- return db.prepare(
1172
- "SELECT * FROM nests WHERE user_id = ? OR (user_id = ? AND visibility = 'public') ORDER BY created_at DESC"
1173
- ).all(userId, ANON_USER_ID);
758
+ if (!isStewardshipEnabled(nestId)) return true;
759
+ return canUserAccess(nestId, nodeId, userEmail).allowed;
1174
760
  }
1175
- function listSharedNests(userId) {
1176
- const db = getDb();
1177
- return db.prepare(
1178
- `SELECT DISTINCT n.* FROM nests n
1179
- JOIN nest_collaborators nc ON nc.nest_id = n.id
1180
- WHERE n.user_id != ? AND nc.user_id = ?
1181
- ORDER BY n.created_at DESC`
1182
- ).all(userId, userId);
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);
1183
767
  }
1184
- function getNest(nestId) {
1185
- const db = getDb();
1186
- return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId) || null;
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`);
1187
791
  }
1188
- async function deleteNest(nestId) {
1189
- const db = getDb();
1190
- const wipe = db.transaction((id) => {
1191
- db.prepare("DELETE FROM approved_versions WHERE nest_id = ?").run(id);
1192
- db.prepare("DELETE FROM node_versions WHERE nest_id = ?").run(id);
1193
- db.prepare("DELETE FROM review_requests WHERE nest_id = ?").run(id);
1194
- db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id);
1195
- db.prepare("DELETE FROM nest_collaborators WHERE nest_id = ?").run(id);
1196
- try {
1197
- db.prepare("DELETE FROM node_tag_index WHERE nest_id = ?").run(id);
1198
- } catch {
1199
- }
1200
- db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(id);
1201
- db.prepare("DELETE FROM nests WHERE id = ?").run(id);
1202
- });
1203
- wipe(nestId);
1204
- const path = nestPath(nestId);
792
+ async function readRaw(nestId, documentId) {
1205
793
  try {
1206
- rmSync(path, { recursive: true, force: true });
1207
- } catch (err) {
1208
- console.warn(`[nests] failed to remove nest directory ${path}:`, err);
794
+ return await readFile(docPath(nestId, documentId), "utf-8");
795
+ } catch {
796
+ return null;
1209
797
  }
1210
- trackEvent("nest.delete", { nestId });
1211
798
  }
1212
-
1213
- // src/nests/routes.ts
1214
- function effectivePermission(nestId, userId) {
1215
- if (config.AUTH_MODE === "open") {
1216
- const db = getDb();
1217
- const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1218
- if (nest && nest.user_id === ANON_USER_ID) return "owner";
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;
1219
811
  }
1220
- return resolveNestPermission(nestId, userId);
1221
- }
1222
- var nestRoutes = new Hono2();
1223
- nestRoutes.get("/", async (c) => {
1224
- const userId = c.get("userId");
1225
- const owned = listNests(userId);
1226
- const shared = listSharedNests(userId);
1227
- const db = getDb();
1228
- const ownerEmailStmt = db.prepare(
1229
- "SELECT email FROM users WHERE id = ?"
1230
- );
1231
- const annotate = (n) => {
1232
- const permission = effectivePermission(n.id, userId);
1233
- const is_owner = permission === "owner";
1234
- let owner_email = null;
1235
- if (!is_owner && n.user_id !== ANON_USER_ID) {
1236
- const row = ownerEmailStmt.get(n.user_id);
1237
- owner_email = row?.email ?? null;
1238
- }
1239
- return { ...n, permission, is_owner, owner_email };
1240
- };
1241
- return c.json({
1242
- nests: [...owned.map(annotate), ...shared.map(annotate)]
1243
- });
1244
- });
1245
- nestRoutes.post("/", async (c) => {
1246
- const body = await c.req.json();
1247
- if (!body.name) {
1248
- throw new ValidationError("name is required");
1249
- }
1250
- const nest = await createNest(c.get("userId"), body.name, body.description);
1251
- return c.json({ nest }, 201);
1252
- });
1253
- nestRoutes.get("/:nestId", async (c) => {
1254
- const nestId = c.req.param("nestId");
1255
- const permission = effectivePermission(nestId, c.get("userId"));
1256
- if (permission === "none") {
1257
- throw new NotFoundError("Nest not found");
1258
- }
1259
- const nest = getNest(nestId);
1260
- return c.json({ nest, permission });
1261
- });
1262
- nestRoutes.delete("/:nestId", async (c) => {
1263
- const nestId = c.req.param("nestId");
1264
- const userId = c.get("userId");
1265
- const nest = getNest(nestId);
1266
- if (!nest) {
1267
- throw new NotFoundError("Nest not found");
1268
- }
1269
- const permission = effectivePermission(nestId, userId);
1270
- const isAnonOwned = nest.user_id === ANON_USER_ID;
1271
- const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
1272
- if (permission !== "owner" && !adminCaretaker) {
1273
- throw new ForbiddenError(
1274
- "You don't have permission to delete this nest. Only the nest owner can delete it."
1275
- );
1276
- }
1277
- await deleteNest(nestId);
1278
- return c.json({ deleted: true });
1279
- });
1280
- nestRoutes.get("/:nestId/settings", async (c) => {
1281
- const nestId = c.req.param("nestId");
1282
- const permission = effectivePermission(nestId, c.get("userId"));
1283
- if (permission === "none") {
1284
- throw new NotFoundError("Nest not found");
1285
- }
1286
- return c.json({
1287
- stewardship_enabled: isStewardshipEnabled(nestId)
1288
- });
1289
- });
1290
- nestRoutes.patch("/:nestId/settings", async (c) => {
1291
- const nestId = c.req.param("nestId");
1292
- const userId = c.get("userId");
1293
- const isServerAdmin = isLicenseAdminUserId(userId);
1294
- const permission = effectivePermission(nestId, userId);
1295
- if (!isServerAdmin && permission !== "owner") {
1296
- return c.json(
1297
- {
1298
- error: "Only the nest owner or the server license-admin can update nest settings."
1299
- },
1300
- 403
1301
- );
1302
- }
1303
- const body = await c.req.json();
1304
- if (typeof body.stewardship_enabled === "boolean") {
1305
- setStewardshipEnabled(nestId, body.stewardship_enabled);
1306
- }
1307
- return c.json({
1308
- stewardship_enabled: isStewardshipEnabled(nestId)
1309
- });
1310
- });
1311
-
1312
- // src/nests/sharing-routes.ts
1313
- import { Hono as Hono3 } from "hono";
1314
- import { v4 as uuid3 } from "uuid";
1315
- var sharingRoutes = new Hono3();
1316
- sharingRoutes.get("/collaborators", async (c) => {
1317
- const db = getDb();
1318
- const collabs = db.prepare(
1319
- `SELECT nc.*, u.email FROM nest_collaborators nc
1320
- LEFT JOIN users u ON nc.user_id = u.id
1321
- WHERE nc.nest_id = ?
1322
- ORDER BY nc.granted_at`
1323
- ).all(c.req.param("nestId"));
1324
- return c.json({ collaborators: collabs });
1325
- });
1326
- sharingRoutes.post("/collaborators", async (c) => {
1327
- const body = await c.req.json();
1328
- if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
1329
- throw new ValidationError("permission must be read, write, or admin");
1330
- }
1331
- const db = getDb();
1332
- let userId = body.user_id;
1333
- if (!userId && body.email) {
1334
- let user = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
1335
- if (!user) {
1336
- const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
1337
- userId = uuid3();
1338
- db.prepare(
1339
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
1340
- ).run(userId, body.email, null, await hashPassword2(uuid3()));
1341
- } else {
1342
- userId = user.id;
1343
- }
1344
- }
1345
- if (!userId) {
1346
- throw new ValidationError("user_id or email is required");
1347
- }
1348
- const collabId = uuid3();
1349
- db.prepare(
1350
- "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
1351
- ).run(
1352
- collabId,
1353
- c.req.param("nestId"),
1354
- userId,
1355
- body.permission,
1356
- c.get("userId")
1357
- );
1358
- const collab = db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
1359
- return c.json({ collaborator: collab }, 201);
1360
- });
1361
- sharingRoutes.patch("/collaborators/:collabId", async (c) => {
1362
- const body = await c.req.json();
1363
- if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
1364
- throw new ValidationError("permission must be read, write, or admin");
1365
- }
1366
- const db = getDb();
1367
- db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
1368
- body.permission,
1369
- c.req.param("collabId")
1370
- );
1371
- return c.json({ updated: true });
1372
- });
1373
- sharingRoutes.delete("/collaborators/:collabId", async (c) => {
1374
- const db = getDb();
1375
- db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
1376
- c.req.param("collabId")
1377
- );
1378
- return c.json({ removed: true });
1379
- });
1380
- sharingRoutes.patch("/visibility", async (c) => {
1381
- const body = await c.req.json();
1382
- if (!body.visibility || !["private", "public"].includes(body.visibility)) {
1383
- throw new ValidationError("visibility must be private or public");
1384
- }
1385
- const db = getDb();
1386
- db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
1387
- body.visibility,
1388
- c.req.param("nestId")
1389
- );
1390
- return c.json({ visibility: body.visibility });
1391
- });
1392
-
1393
- // src/governance/access-guard.ts
1394
- function resolveCallerEmail(userId) {
1395
- if (!userId) return "admin@localhost";
1396
- const db = getDb();
1397
- const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
1398
- return row?.email || "admin@localhost";
1399
- }
1400
- function canReadNode(nestId, nodeId, userEmail) {
1401
- if (!isStewardshipEnabled(nestId)) return true;
1402
- return canUserAccess(nestId, nodeId, userEmail).allowed;
1403
- }
1404
- function filterAccessible(nestId, userEmail, nodes) {
1405
- if (!isStewardshipEnabled(nestId)) return nodes;
1406
- return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
1407
- }
1408
-
1409
- // src/nodes/routes.ts
1410
- import { Hono as Hono4 } from "hono";
1411
- import { serializeDocument } from "@promptowl/contextnest-engine";
1412
-
1413
- // src/governance/tag-index-service.ts
1414
- function normalizeTag(raw) {
1415
- return raw.trim().replace(/^#+/, "").toLowerCase();
1416
- }
1417
- function syncNodeTags(nestId, nodeId, tags) {
1418
- const db = getDb();
1419
- const normalized = Array.from(
1420
- new Set(
1421
- tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
1422
- )
1423
- );
1424
- db.transaction(() => {
1425
- db.prepare(
1426
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
1427
- ).run(nestId, nodeId);
1428
- const insert = db.prepare(
1429
- "INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
1430
- );
1431
- for (const tag of normalized) {
1432
- insert.run(nestId, nodeId, tag);
1433
- }
1434
- })();
1435
- }
1436
- function removeNodeFromTagIndex(nestId, nodeId) {
1437
- const db = getDb();
1438
- db.prepare(
1439
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
1440
- ).run(nestId, nodeId);
1441
- }
1442
-
1443
- // src/governance/external-edit-service.ts
1444
- import { readFile } from "fs/promises";
1445
- import { join as join2 } from "path";
1446
- import {
1447
- detectDrift,
1448
- stageSuggestion,
1449
- approveSuggestion,
1450
- rejectSuggestion,
1451
- listSuggestions,
1452
- readSuggestion,
1453
- VersionManager
1454
- } from "@promptowl/contextnest-engine";
1455
- var communityRbac = {
1456
- isCzar: () => false,
1457
- canIngest: () => true,
1458
- isDocOwner: () => true
1459
- };
1460
- function docPath(nestId, documentId) {
1461
- return join2(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
1462
812
  }
1463
- async function readRaw(nestId, documentId) {
1464
- try {
1465
- return await readFile(docPath(nestId, documentId), "utf-8");
1466
- } catch {
1467
- return null;
1468
- }
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);
1469
818
  }
1470
- async function loadChainHead(storage, documentId) {
1471
- const history = await storage.readHistory(documentId);
1472
- if (!history || history.versions.length === 0) return null;
1473
- const latest = history.versions[history.versions.length - 1];
1474
- try {
1475
- const content = await new VersionManager(storage).reconstructVersion(
1476
- documentId,
1477
- latest.version
1478
- );
1479
- return { version: latest.version, content };
1480
- } catch {
1481
- return null;
1482
- }
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;
1483
824
  }
1484
825
  async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
1485
826
  const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
@@ -1493,6 +834,9 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1493
834
  if (raw == null) return null;
1494
835
  const drift = detectDrift(raw, node.frontmatter.checksum);
1495
836
  if (!drift.drifted) return null;
837
+ if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
838
+ return null;
839
+ }
1496
840
  const approved = await loadChainHead(storage, documentId);
1497
841
  if (!approved) return null;
1498
842
  const existing = await listSuggestions(storage, documentId);
@@ -1521,20 +865,35 @@ async function scanNestForDrift(nestId, actor = "system:scanner") {
1521
865
  async function getPendingChange(nestId, documentId) {
1522
866
  const { storage } = engineCache.get(nestId);
1523
867
  const list = await listSuggestions(storage, documentId);
1524
- if (list.length === 0) return null;
1525
- const latest = list[list.length - 1];
1526
- return {
1527
- suggestion_id: latest.suggestion_id,
1528
- detected_at: latest.detected_at,
1529
- source: latest.source,
1530
- proposed_hash: latest.proposed_hash
1531
- };
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;
1532
881
  }
1533
882
  async function listNestExternalEdits(nestId) {
1534
883
  const { storage } = engineCache.get(nestId);
1535
884
  const docs = await storage.discoverDocuments();
1536
885
  const lists = await Promise.all(
1537
- docs.map((doc) => listSuggestions(storage, doc.id))
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
+ })
1538
897
  );
1539
898
  const entries = lists.flat().map((meta) => ({
1540
899
  suggestion_id: meta.suggestion_id,
@@ -1590,7 +949,7 @@ async function approveExternalEdit(input) {
1590
949
  const node = await storage.readDocument(input.documentId);
1591
950
  const versionNum = result.versionEntry.version;
1592
951
  const tags = node.frontmatter.tags || [];
1593
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
952
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
1594
953
  createVersion2({
1595
954
  nestId: input.nestId,
1596
955
  nodeId: input.documentId,
@@ -1656,48 +1015,79 @@ function startDriftScanner(intervalMs = 3e4) {
1656
1015
  scannerTimer.unref?.();
1657
1016
  }
1658
1017
 
1659
- // src/nodes/routes.ts
1660
- var nodeRoutes = new Hono4();
1661
- function toNodeResponse(node) {
1662
- return {
1663
- id: node.id,
1664
- title: node.frontmatter.title,
1665
- type: node.frontmatter.type || "document",
1666
- tags: node.frontmatter.tags || [],
1667
- // Widen to string so callers can layer review-workflow states like
1668
- // "pending_review" / "rejected" on top of the on-disk frontmatter
1669
- // status. The engine's Status enum only knows draft/approved.
1670
- status: node.frontmatter.status || "draft",
1671
- version: node.frontmatter.version || 1,
1672
- author: node.frontmatter.author,
1673
- description: node.frontmatter.description,
1674
- created_at: node.frontmatter.created_at,
1675
- updated_at: node.frontmatter.updated_at,
1676
- content: node.body,
1677
- pendingChange: node.pendingChange ?? void 0
1678
- };
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;
1679
1023
  }
1680
- nodeRoutes.get("/", async (c) => {
1681
- const nestId = c.req.param("nestId");
1682
- const { storage } = engineCache.get(nestId);
1683
- const documents = await storage.discoverDocuments();
1684
- const userEmail = resolveCallerEmail(c.get("userId"));
1685
- const accessible = filterAccessible(nestId, userEmail, documents);
1686
- const pendingByDoc = /* @__PURE__ */ new Map();
1687
- await Promise.all(
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(
1688
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;
1689
1086
  try {
1690
- pendingByDoc.set(doc.id, await getPendingChange(nestId, doc.id));
1087
+ pending = await getPendingChange(nestId, doc.id);
1691
1088
  } catch {
1692
- pendingByDoc.set(doc.id, null);
1089
+ pending = null;
1693
1090
  }
1694
- })
1695
- );
1696
- return c.json({
1697
- count: accessible.length,
1698
- nodes: accessible.map((doc) => {
1699
- const r = toNodeResponse(doc);
1700
- const pending = pendingByDoc.get(doc.id);
1701
1091
  if (pending) {
1702
1092
  r.pendingChange = pending;
1703
1093
  r.status = "external_edit_pending";
@@ -1706,93 +1096,526 @@ nodeRoutes.get("/", async (c) => {
1706
1096
  }
1707
1097
  return r;
1708
1098
  })
1709
- });
1710
- });
1711
- nodeRoutes.post("/", async (c) => {
1712
- const body = await c.req.json();
1713
- if (!body.title || !body.content) {
1714
- throw new ValidationError("title and content are required");
1715
- }
1716
- const nestId = c.req.param("nestId");
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) {
1717
1106
  const { storage, versions: versionManager } = engineCache.get(nestId);
1718
- const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1107
+ const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1719
1108
  const id = `nodes/${slug}`;
1720
1109
  const now = (/* @__PURE__ */ new Date()).toISOString();
1721
- const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
1110
+ const tags = (input.tags || []).map(normalizeTag2);
1722
1111
  const hasStewards = isStewardshipEnabled(nestId);
1723
- const initialStatus = hasStewards ? "draft" : "approved";
1112
+ const initialStatus = hasStewards ? "draft" : "published";
1724
1113
  const initialVersion = hasStewards ? 1 : 0;
1725
1114
  let node = {
1726
1115
  id,
1727
1116
  filePath: "",
1728
1117
  frontmatter: {
1729
- title: body.title,
1730
- type: body.type || "document",
1118
+ title: input.title,
1119
+ type: input.type || "document",
1731
1120
  tags,
1732
- status: body.status || initialStatus,
1121
+ status: input.status || initialStatus,
1733
1122
  version: initialVersion,
1734
1123
  created_at: now,
1735
1124
  updated_at: now,
1736
- metadata: {
1737
- owners: ["*"],
1738
- scope: body.scope || "team"
1739
- }
1125
+ metadata: { owners: ["*"], scope: input.scope || "team" }
1740
1126
  },
1741
- body: body.content,
1127
+ body: input.content,
1742
1128
  rawContent: ""
1743
1129
  };
1744
- const serialized = serializeDocument(node);
1745
- await storage.writeDocument(id, serialized);
1130
+ await storage.writeDocument(id, serializeDocument(node));
1746
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(
1444
+ {
1445
+ error: "Only the nest owner or the server license-admin can update nest settings."
1446
+ },
1447
+ 403
1448
+ );
1449
+ }
1450
+ const body = await c.req.json();
1451
+ let wiped = null;
1452
+ if (typeof body.stewardship_enabled === "boolean") {
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);
1461
+ }
1462
+ return c.json({
1463
+ stewardship_enabled: isStewardshipEnabled(nestId),
1464
+ allow_self_approve: nestAllowsSelfApprove(nestId),
1465
+ wiped
1466
+ });
1467
+ });
1468
+
1469
+ // src/nests/sharing-routes.ts
1470
+ import { Hono as Hono3 } from "hono";
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)) {
1478
+ throw new ValidationError("permission must be read, write, or admin");
1479
+ }
1480
+ const db = getDb();
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();
1489
+ db.prepare(
1490
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
1491
+ ).run(userId, params.email, null, await hashPassword2(uuid2()));
1492
+ }
1493
+ }
1494
+ if (!userId) {
1495
+ throw new ValidationError("user_id or email is required");
1496
+ }
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();
1520
+ db.prepare(
1521
+ "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
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
+ });
1556
+ return c.json({ collaborator: collab }, 201);
1557
+ });
1558
+ sharingRoutes.patch("/collaborators/:collabId", async (c) => {
1559
+ const body = await c.req.json();
1560
+ if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
1561
+ throw new ValidationError("permission must be read, write, or admin");
1562
+ }
1563
+ const db = getDb();
1564
+ db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
1565
+ body.permission,
1566
+ c.req.param("collabId")
1567
+ );
1568
+ return c.json({ updated: true });
1569
+ });
1570
+ sharingRoutes.delete("/collaborators/:collabId", async (c) => {
1571
+ const db = getDb();
1572
+ db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
1573
+ c.req.param("collabId")
1574
+ );
1575
+ return c.json({ removed: true });
1576
+ });
1577
+ sharingRoutes.patch("/visibility", async (c) => {
1578
+ const body = await c.req.json();
1579
+ if (!body.visibility || !["private", "public"].includes(body.visibility)) {
1580
+ throw new ValidationError("visibility must be private or public");
1581
+ }
1582
+ const db = getDb();
1583
+ db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
1584
+ body.visibility,
1585
+ c.req.param("nestId")
1586
+ );
1587
+ return c.json({ visibility: body.visibility });
1588
+ });
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");
1603
+ }
1604
+ const nestId = c.req.param("nestId");
1747
1605
  const authorEmail = getUserEmail(c);
1748
- if (hasStewards) {
1749
- try {
1750
- await versionManager.createVersion(node, authorEmail);
1751
- } catch (err) {
1752
- console.error("VersionManager.createVersion failed (node create)", err);
1753
- }
1754
- createVersion({
1755
- nestId,
1756
- nodeId: id,
1757
- version: 1,
1606
+ const { node } = await createNode(
1607
+ nestId,
1608
+ {
1609
+ title: body.title,
1758
1610
  content: body.content,
1759
- author: authorEmail,
1760
- status: "draft",
1761
- tags
1762
- });
1763
- } else {
1764
- try {
1765
- const result = await safePublishDocument(storage, id, {
1766
- editedBy: authorEmail,
1767
- note: "Auto-published on create (no stewards configured)"
1768
- });
1769
- const publishedVersion = result.node.frontmatter.version || 2;
1770
- createVersion({
1771
- nestId,
1772
- nodeId: id,
1773
- version: publishedVersion,
1774
- content: result.node.body || "",
1775
- author: authorEmail,
1776
- status: "published",
1777
- tags
1778
- });
1779
- setApprovedVersion(nestId, id, publishedVersion, authorEmail);
1780
- node = result.node;
1781
- } catch (err) {
1782
- console.error("publishDocument failed (node create auto-publish)", err);
1783
- createVersion({
1784
- nestId,
1785
- nodeId: id,
1786
- version: 1,
1787
- content: body.content,
1788
- author: authorEmail,
1789
- status: "draft",
1790
- tags
1791
- });
1792
- }
1793
- }
1794
- trackEvent("node.create", { nestId, nodeId: id });
1795
- const resolved = resolveStewardsForNode(nestId, id);
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);
1796
1619
  return c.json({
1797
1620
  node: toNodeResponse(node),
1798
1621
  stewards: resolved.length > 0 ? resolved.map((r) => ({
@@ -1805,7 +1628,7 @@ nodeRoutes.post("/", async (c) => {
1805
1628
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1806
1629
  const nestId = c.req.param("nestId");
1807
1630
  const nodeId = c.req.param("nodeId");
1808
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-C5D2O7ZE.js");
1631
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3XGX7QIN.js");
1809
1632
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
1810
1633
  nestId,
1811
1634
  nodeId
@@ -1826,7 +1649,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1826
1649
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1827
1650
  const nestId = c.req.param("nestId");
1828
1651
  const nodeId = c.req.param("nodeId");
1829
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
1652
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
1830
1653
  const allVersions = getVersions2(nestId, nodeId);
1831
1654
  const approved = getApprovedVersion2(nestId, nodeId);
1832
1655
  const db = getDb();
@@ -1857,16 +1680,17 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1857
1680
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
1858
1681
  const nestId = c.req.param("nestId");
1859
1682
  const nodeId = c.req.param("nodeId");
1860
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4WS3XL6K.js");
1683
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3OJIPYNV.js");
1861
1684
  const history = getReviewHistory2(nestId, nodeId);
1862
1685
  return c.json({ reviews: history });
1863
1686
  });
1864
1687
  nodeRoutes.get("/:nodeId{.+}", async (c) => {
1865
1688
  const nestId = c.req.param("nestId");
1866
1689
  const nodeId = c.req.param("nodeId");
1867
- const { storage } = engineCache.get(nestId);
1868
- const userEmail = resolveCallerEmail(c.get("userId"));
1869
- if (!canReadNode(nestId, nodeId, userEmail)) {
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)) {
1870
1694
  return c.json(
1871
1695
  { error: "Access denied \u2014 no steward assignment for this node" },
1872
1696
  403
@@ -1886,15 +1710,49 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1886
1710
  } catch (err) {
1887
1711
  console.error("[external-edit] stage-on-read failed:", err);
1888
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
+ }
1889
1721
  }
1890
1722
  const response = toNodeResponse(node);
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
+ }
1891
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
+ }
1892
1751
  return c.json({ node: response });
1893
1752
  });
1894
1753
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1895
1754
  const nestId = c.req.param("nestId");
1896
1755
  const nodeId = c.req.param("nodeId");
1897
- const { storage, versions: versionManager } = engineCache.get(nestId);
1898
1756
  const body = await c.req.json();
1899
1757
  const baseVersionHeader = c.req.header("X-Base-Version");
1900
1758
  if (baseVersionHeader) {
@@ -1914,127 +1772,20 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1914
1772
  );
1915
1773
  }
1916
1774
  }
1917
- let node;
1918
- try {
1919
- node = await storage.readDocument(nodeId);
1920
- } catch {
1921
- throw new NotFoundError(`Node not found: ${nodeId}`);
1922
- }
1923
- if (body.content !== void 0) {
1924
- node = { ...node, body: body.content };
1925
- }
1926
- if (body.append) {
1927
- node = { ...node, body: (node.body || "") + "\n\n" + body.append };
1928
- }
1929
- if (body.tags) {
1930
- const newTags = body.tags.map((t) => t.startsWith("#") ? t : `#${t}`);
1931
- const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
1932
- node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
1933
- }
1934
- if (body.status) {
1935
- node = {
1936
- ...node,
1937
- frontmatter: { ...node.frontmatter, status: body.status }
1938
- };
1939
- }
1940
- if (body.title) {
1941
- node = {
1942
- ...node,
1943
- frontmatter: { ...node.frontmatter, title: body.title }
1944
- };
1945
- }
1946
1775
  const authorEmail = getUserEmail(c);
1947
- const hasStewards = isStewardshipEnabled(nestId);
1948
- const currentTags = node.frontmatter.tags || [];
1949
- const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-4WS3XL6K.js");
1950
- if (getPendingReview2(nestId, nodeId)) {
1951
- cancelReview2({ nestId, nodeId, cancelledBy: authorEmail });
1952
- }
1953
- let responseVersion;
1954
- if (hasStewards) {
1955
- const currentVersion = getCurrentVersion(nestId, nodeId);
1956
- const newVersion = currentVersion + 1;
1957
- node = {
1958
- ...node,
1959
- frontmatter: {
1960
- ...node.frontmatter,
1961
- version: newVersion,
1962
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1963
- // Strip the stale published-state checksum — the body just
1964
- // changed, so leaving the old hash would make the next GET (with
1965
- // verifyChecksum: true) flag the file as an external edit even
1966
- // though *this* app wrote it. Engine treats absent checksum as
1967
- // "no baseline" and skips drift detection until publish runs.
1968
- checksum: void 0
1969
- }
1970
- };
1971
- const fm = Object.fromEntries(
1972
- Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
1973
- );
1974
- node = { ...node, frontmatter: fm };
1975
- await storage.writeDocument(nodeId, serializeDocument(node));
1976
- syncNodeTags(nestId, nodeId, currentTags);
1977
- try {
1978
- await versionManager.createVersion(node, authorEmail, {
1979
- note: body.changeNote
1980
- });
1981
- } catch (err) {
1982
- console.error("VersionManager.createVersion failed (node patch)", err);
1983
- }
1984
- createVersion({
1985
- nestId,
1986
- nodeId,
1987
- version: newVersion,
1988
- content: node.body || "",
1989
- author: authorEmail,
1990
- status: "draft",
1991
- tags: currentTags,
1992
- changeNote: body.changeNote
1993
- });
1994
- responseVersion = newVersion;
1995
- } else {
1996
- node = {
1997
- ...node,
1998
- frontmatter: {
1999
- ...node.frontmatter,
2000
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2001
- // Drop the prior published-state checksum before the interim
2002
- // write — publish recomputes it from the new body, but if
2003
- // publish errors out the file would otherwise be left with new
2004
- // body + stale checksum and drift on next read.
2005
- checksum: void 0
2006
- }
2007
- };
2008
- const fm = Object.fromEntries(
2009
- Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
2010
- );
2011
- node = { ...node, frontmatter: fm };
2012
- await storage.writeDocument(nodeId, serializeDocument(node));
2013
- syncNodeTags(nestId, nodeId, currentTags);
2014
- let publishedVersion = (node.frontmatter.version || 0) + 1;
2015
- try {
2016
- const result = await safePublishDocument(storage, nodeId, {
2017
- editedBy: authorEmail,
2018
- note: body.changeNote || "Auto-published on edit (no stewards)"
2019
- });
2020
- publishedVersion = result.node.frontmatter.version || publishedVersion;
2021
- node = result.node;
2022
- } catch (err) {
2023
- console.error("publishDocument failed (node patch auto-publish)", err);
2024
- }
2025
- createVersion({
2026
- nestId,
2027
- nodeId,
2028
- version: publishedVersion,
2029
- content: node.body || "",
2030
- author: authorEmail,
2031
- status: "published",
2032
- tags: currentTags,
1776
+ const { node, version: responseVersion } = await updateNode(
1777
+ nestId,
1778
+ nodeId,
1779
+ {
1780
+ content: body.content,
1781
+ append: body.append,
1782
+ tags: body.tags,
1783
+ title: body.title,
1784
+ status: body.status,
2033
1785
  changeNote: body.changeNote
2034
- });
2035
- setApprovedVersion(nestId, nodeId, publishedVersion, authorEmail);
2036
- responseVersion = publishedVersion;
2037
- }
1786
+ },
1787
+ authorEmail
1788
+ );
2038
1789
  return c.json({ node: toNodeResponse(node), version: responseVersion });
2039
1790
  });
2040
1791
  nodeRoutes.delete("/:nodeId{.+}", async (c) => {
@@ -2058,6 +1809,10 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2058
1809
  db.prepare(
2059
1810
  "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
2060
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);
2061
1816
  })();
2062
1817
  trackEvent("node.delete", { nestId, nodeId });
2063
1818
  return c.json({ deleted: true });
@@ -2327,9 +2082,10 @@ queryRoutes.post("/context", async (c) => {
2327
2082
  }
2328
2083
  }
2329
2084
  }
2330
- const userEmail = resolveCallerEmail(c.get("userId"));
2085
+ const userId = c.get("userId");
2086
+ const userEmail = resolveCallerEmail(userId);
2331
2087
  const beforePermission = documents.length;
2332
- const accessible = filterAccessible(nestId, userEmail, documents);
2088
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
2333
2089
  const permissionFiltered = beforePermission - accessible.length;
2334
2090
  const included = [];
2335
2091
  let tokenCount = 0;
@@ -2386,8 +2142,9 @@ queryRoutes.post("/query", async (c) => {
2386
2142
  const result = await queryEngine.query(body.query, {
2387
2143
  hops: body.hops ?? 2
2388
2144
  });
2389
- const userEmail = resolveCallerEmail(c.get("userId"));
2390
- 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);
2391
2148
  return c.json({
2392
2149
  query: body.query,
2393
2150
  count: accessible.length,
@@ -2418,8 +2175,9 @@ queryRoutes.get("/search", async (c) => {
2418
2175
  ].join(" ").toLowerCase();
2419
2176
  return terms.every((term) => haystack.includes(term));
2420
2177
  });
2421
- const userEmail = resolveCallerEmail(c.get("userId"));
2422
- 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);
2423
2181
  return c.json({
2424
2182
  query: q,
2425
2183
  count: accessible.length,
@@ -2520,7 +2278,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2520
2278
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2521
2279
 
2522
2280
  // src/mcp/tools.ts
2523
- import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
2524
2281
  var TOOL_DEFINITIONS = [
2525
2282
  {
2526
2283
  name: "context_init",
@@ -2733,6 +2490,21 @@ var TOOL_DEFINITIONS = [
2733
2490
  },
2734
2491
  required: ["email", "scope"]
2735
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
+ }
2736
2508
  }
2737
2509
  ];
2738
2510
  async function resolveLlmBody(ctx, node) {
@@ -2848,24 +2620,14 @@ ${list}`;
2848
2620
  ${body || "(no content)"}`;
2849
2621
  }
2850
2622
  case "context_list": {
2851
- let docs = await storage.discoverDocuments();
2852
- if (args.type)
2853
- docs = docs.filter((n) => n.frontmatter.type === args.type);
2854
- if (args.tag) {
2855
- const tag = args.tag.startsWith("#") ? args.tag : `#${args.tag}`;
2856
- docs = docs.filter((n) => n.frontmatter.tags?.includes(tag));
2857
- }
2858
- if (isStewardshipEnabled(nestId)) {
2859
- docs = docs.filter(
2860
- (n) => getApprovedVersion(nestId, n.id) != null
2861
- );
2862
- }
2863
- docs = docs.slice(0, args.limit || 50);
2864
- if (!docs.length) return "No nodes found with the given filters.";
2865
- const list = docs.map(
2866
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}]`
2867
- ).join("\n");
2868
- return `${docs.length} node(s):
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):
2869
2631
 
2870
2632
  ${list}`;
2871
2633
  }
@@ -2886,50 +2648,21 @@ ${n.body || ""}`;
2886
2648
  return resolved.join("\n\n---\n\n") || "No nodes resolved.";
2887
2649
  }
2888
2650
  case "context_create": {
2889
- const slug = args.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2890
- const id = `nodes/${slug}`;
2891
- const now = (/* @__PURE__ */ new Date()).toISOString();
2892
- const tags = (args.tags || []).map(
2893
- (t) => t.startsWith("#") ? t : `#${t}`
2894
- );
2895
- const hasStewards = isStewardshipEnabled(nestId);
2896
- const initialStatus = hasStewards ? "draft" : "approved";
2897
- const node = {
2898
- id,
2899
- filePath: "",
2900
- frontmatter: {
2901
- title: args.title,
2902
- type: args.type || "document",
2903
- tags,
2904
- status: initialStatus,
2905
- version: 1,
2906
- created_at: now,
2907
- updated_at: now,
2908
- metadata: { owners: ["*"], scope: args.scope || "team" }
2909
- },
2910
- body: args.content,
2911
- rawContent: ""
2912
- };
2913
- await storage.writeDocument(id, serializeDocument3(node));
2914
- syncNodeTags(nestId, id, tags);
2915
- try {
2916
- await versionManager.createVersion(node, userEmail);
2917
- } catch (err) {
2918
- console.error("VersionManager.createVersion failed (mcp create)", err);
2651
+ if (!canCreateInNest(nestId, userEmail)) {
2652
+ return "You don't have permission to create documents in this nest.";
2919
2653
  }
2920
- createVersion({
2654
+ const { node } = await createNode(
2921
2655
  nestId,
2922
- nodeId: id,
2923
- version: 1,
2924
- content: args.content,
2925
- author: userEmail,
2926
- status: initialStatus,
2927
- tags
2928
- });
2929
- if (initialStatus === "approved") {
2930
- setApprovedVersion(nestId, id, 1, userEmail);
2931
- }
2932
- return `Created node: **${args.title}** (${id}) \u2014 status: ${initialStatus}`;
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}`;
2933
2666
  }
2934
2667
  case "context_update": {
2935
2668
  const docs = await storage.discoverDocuments();
@@ -2937,56 +2670,21 @@ ${n.body || ""}`;
2937
2670
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
2938
2671
  );
2939
2672
  if (!node) return `Node not found: ${args.title}`;
2940
- let body = node.body || "";
2941
- if (args.content !== void 0) body = args.content;
2942
- if (args.append) body = body + "\n\n" + args.append;
2943
- let tags = node.frontmatter.tags || [];
2944
- if (args.tags) {
2945
- const newTags = args.tags.map(
2946
- (t) => t.startsWith("#") ? t : `#${t}`
2947
- );
2948
- tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
2949
- }
2950
- const prevVersion = getCurrentVersion(nestId, node.id);
2951
- const newVersion = prevVersion + 1;
2952
- const hasStewards = isStewardshipEnabled(nestId);
2953
- const updated = {
2954
- ...node,
2955
- body,
2956
- frontmatter: {
2957
- ...node.frontmatter,
2958
- tags,
2959
- version: newVersion,
2960
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
2961
- }
2962
- };
2963
- await storage.writeDocument(node.id, serializeDocument3(updated));
2964
- try {
2965
- await versionManager.createVersion(updated, userEmail);
2966
- } catch (err) {
2967
- console.error("VersionManager.createVersion failed (mcp update)", err);
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}`;
2968
2676
  }
2969
- syncNodeTags(nestId, node.id, tags);
2970
- createVersion({
2677
+ const { node: updated } = await updateNode(
2971
2678
  nestId,
2972
- nodeId: node.id,
2973
- version: newVersion,
2974
- content: body,
2975
- author: userEmail,
2976
- status: hasStewards ? "draft" : "approved",
2977
- tags
2978
- });
2979
- if (getPendingReview(nestId, node.id)) {
2980
- cancelReview({
2981
- nestId,
2982
- nodeId: node.id,
2983
- cancelledBy: userEmail
2984
- });
2985
- }
2986
- if (!hasStewards) {
2987
- setApprovedVersion(nestId, node.id, newVersion, userEmail);
2988
- }
2989
- return `Updated node: **${node.frontmatter.title}**`;
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}**`;
2990
2688
  }
2991
2689
  // ─── Governance Tool Handlers ──────────────────────────────────────
2992
2690
  case "context_stewards": {
@@ -3007,13 +2705,16 @@ ${n.body || ""}`;
3007
2705
 
3008
2706
  ${list}`;
3009
2707
  }
3010
- 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);
3011
2712
  if (allStewards.length === 0) {
3012
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`.";
3013
2714
  }
3014
2715
  const byScope = {};
3015
2716
  for (const s of allStewards) {
3016
- const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
2717
+ const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
3017
2718
  (byScope[key] = byScope[key] || []).push(s);
3018
2719
  }
3019
2720
  const sections = Object.entries(byScope).map(
@@ -3026,7 +2727,7 @@ ${sections}`;
3026
2727
  }
3027
2728
  case "context_review_queue": {
3028
2729
  const status = args.status || "pending";
3029
- const result = getReviewQueue({
2730
+ const result = await getReviewQueue({
3030
2731
  nestId: ctx.nestId,
3031
2732
  status
3032
2733
  });
@@ -3034,7 +2735,7 @@ ${sections}`;
3034
2735
  return status === "pending" ? "No documents pending review. All caught up!" : `No reviews with status "${status}".`;
3035
2736
  }
3036
2737
  const list = result.requests.map(
3037
- (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
3038
2739
  Submitted by: ${r.requestedBy} at ${r.requestedAt}${r.requestNote ? `
3039
2740
  Note: "${r.requestNote}"` : ""}`
3040
2741
  ).join("\n\n");
@@ -3049,6 +2750,10 @@ ${list}`;
3049
2750
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
3050
2751
  );
3051
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
+ }
3052
2757
  const currentVersion = getCurrentVersion(ctx.nestId, node.id);
3053
2758
  if (currentVersion === 0) return `No versions found for "${args.title}"`;
3054
2759
  try {
@@ -3139,17 +2844,17 @@ ${list}`;
3139
2844
  if (!["nest", "tag", "document"].includes(scope)) {
3140
2845
  return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
3141
2846
  }
2847
+ if (!canManageStewards(ctx.userEmail)) {
2848
+ return "You don't have permission to manage stewards. Only the super admin can do this.";
2849
+ }
3142
2850
  try {
3143
- assignSteward({
2851
+ await createStewardRecord({
3144
2852
  nestId: ctx.nestId,
3145
2853
  scope,
3146
- nodePattern: scope === "document" ? args.target : void 0,
2854
+ documentId: scope === "document" ? args.target : void 0,
3147
2855
  tagName: scope === "tag" ? args.target : void 0,
3148
- userEmail: args.email,
3149
- role: args.role || "reviewer",
3150
- assignedBy: ctx.userEmail,
3151
- assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
3152
- isActive: true
2856
+ users: [{ email: args.email, role: args.role || "reviewer" }],
2857
+ assignedBy: ctx.userEmail
3153
2858
  });
3154
2859
  const targetDesc = scope === "nest" ? "all documents" : `${scope}: ${args.target}`;
3155
2860
  return `Assigned **${args.email}** as ${args.role || "reviewer"} for ${targetDesc}.`;
@@ -3157,6 +2862,25 @@ ${list}`;
3157
2862
  return `Failed to assign steward: ${err.message}`;
3158
2863
  }
3159
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
+ }
3160
2884
  default:
3161
2885
  return `Unknown tool: ${toolName}`;
3162
2886
  }
@@ -3225,8 +2949,8 @@ mcpRoutes.all("/", async (c) => {
3225
2949
  import { Hono as Hono7 } from "hono";
3226
2950
 
3227
2951
  // src/governance/stewards-parser.ts
3228
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3229
- import { join as join3 } from "path";
2952
+ import { readFileSync, existsSync } from "fs";
2953
+ import { join as join2 } from "path";
3230
2954
  function parseStewardsYaml(content) {
3231
2955
  const result = { version: 1 };
3232
2956
  const lines = content.split("\n");
@@ -3291,15 +3015,15 @@ function parseEntry(str) {
3291
3015
  }
3292
3016
  function loadStewardsConfig(nestId) {
3293
3017
  const dataRoot = config.DATA_ROOT;
3294
- const nestPath2 = join3(dataRoot, "nests", nestId);
3018
+ const nestPath = join2(dataRoot, "nests", nestId);
3295
3019
  const candidates = [
3296
- join3(nestPath2, "stewards.yaml"),
3297
- join3(nestPath2, "stewards.yml"),
3298
- join3(nestPath2, ".context", "stewards.yaml")
3020
+ join2(nestPath, "stewards.yaml"),
3021
+ join2(nestPath, "stewards.yml"),
3022
+ join2(nestPath, ".context", "stewards.yaml")
3299
3023
  ];
3300
3024
  for (const candidatePath of candidates) {
3301
- if (existsSync2(candidatePath)) {
3302
- const content = readFileSync2(candidatePath, "utf-8");
3025
+ if (existsSync(candidatePath)) {
3026
+ const content = readFileSync(candidatePath, "utf-8");
3303
3027
  return parseStewardsYaml(content);
3304
3028
  }
3305
3029
  }
@@ -3312,12 +3036,25 @@ governanceRoutes.get("/stewards", async (c) => {
3312
3036
  const nestId = c.req.param("nestId");
3313
3037
  const scope = c.req.query("scope");
3314
3038
  const search = c.req.query("search");
3315
- const stewards = listStewards({
3039
+ const stewards = await listStewards({
3316
3040
  nestId,
3317
3041
  scope: scope || void 0,
3318
3042
  search: search || void 0
3319
3043
  });
3320
- 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 });
3321
3058
  });
3322
3059
  governanceRoutes.post("/stewards", async (c) => {
3323
3060
  const nestId = c.req.param("nestId");
@@ -3358,6 +3095,20 @@ governanceRoutes.post("/stewards", async (c) => {
3358
3095
  });
3359
3096
  return c.json({ steward: created[0] }, 201);
3360
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
+ });
3361
3112
  governanceRoutes.delete("/stewards/:stewardId", async (c) => {
3362
3113
  const stewardId = c.req.param("stewardId");
3363
3114
  removeSteward(stewardId);
@@ -3377,7 +3128,7 @@ governanceRoutes.get("/review-queue", async (c) => {
3377
3128
  const status = c.req.query("status") || "pending";
3378
3129
  const limit = parseInt(c.req.query("limit") || "50", 10);
3379
3130
  const offset = parseInt(c.req.query("offset") || "0", 10);
3380
- const result = getReviewQueue({
3131
+ const result = await getReviewQueue({
3381
3132
  nestId,
3382
3133
  status,
3383
3134
  limit,
@@ -3652,14 +3403,14 @@ function ensureAnonymousUser() {
3652
3403
  // src/app.ts
3653
3404
  import { serveStatic } from "@hono/node-server/serve-static";
3654
3405
  import { fileURLToPath } from "url";
3655
- import { dirname, join as join4, relative } from "path";
3656
- import { existsSync as existsSync3 } from "fs";
3406
+ import { dirname, join as join3, relative } from "path";
3407
+ import { existsSync as existsSync2 } from "fs";
3657
3408
  var HERE = dirname(fileURLToPath(import.meta.url));
3658
3409
  var UI_DIR_CANDIDATES = [
3659
- join4(HERE, "web3"),
3660
- join4(process.cwd(), "dist", "web3")
3410
+ join3(HERE, "web3"),
3411
+ join3(process.cwd(), "dist", "web3")
3661
3412
  ];
3662
- 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];
3663
3414
  var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
3664
3415
  var openModeMiddleware = createMiddleware2(async (c, next) => {
3665
3416
  const anonId = ensureAnonymousUser();
@@ -3667,6 +3418,11 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
3667
3418
  c.set("nestScope", null);
3668
3419
  await next();
3669
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
+ }
3670
3426
  var flexAuthMiddleware = createMiddleware2(async (c, next) => {
3671
3427
  const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
3672
3428
  const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
@@ -3679,6 +3435,12 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
3679
3435
  c.set("nestScope", null);
3680
3436
  return next();
3681
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
+ }
3682
3444
  return c.json({ error: "Missing or invalid credentials" }, 401);
3683
3445
  });
3684
3446
  function createApp() {
@@ -3710,6 +3472,7 @@ function createApp() {
3710
3472
  service: "contextnest-community",
3711
3473
  version: "0.1.0",
3712
3474
  auth_mode: config.AUTH_MODE,
3475
+ logo_url: config.LOGO_URL,
3713
3476
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
3714
3477
  })
3715
3478
  );
@@ -3797,7 +3560,7 @@ function createApp() {
3797
3560
  try {
3798
3561
  const { storage } = engineCache.get(nest.id);
3799
3562
  const docs = await storage.discoverDocuments();
3800
- documents += filterAccessible(nest.id, userEmail, docs).length;
3563
+ documents += filterAccessible(nest.id, userId, userEmail, docs).length;
3801
3564
  } catch {
3802
3565
  }
3803
3566
  }
@@ -3859,12 +3622,42 @@ function createApp() {
3859
3622
  let required = "read";
3860
3623
  const path = c.req.path;
3861
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
+ }
3862
3634
  if (path.includes("/collaborators") || path.includes("/visibility")) {
3863
3635
  required = "admin";
3864
3636
  } else if (c.req.method !== "GET" && !isStewardActionPath) {
3865
3637
  required = "write";
3866
3638
  }
3867
- 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)) {
3868
3661
  return c.json(
3869
3662
  {
3870
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.`,
@@ -3964,8 +3757,8 @@ function createApp() {
3964
3757
  }
3965
3758
 
3966
3759
  // src/db/backfill.ts
3967
- import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
3968
- import { join as join5 } from "path";
3760
+ import { NestStorage } from "@promptowl/contextnest-engine";
3761
+ import { join as join4 } from "path";
3969
3762
  var MIGRATION_ID = "005_backfill_node_versions_from_history";
3970
3763
  async function backfillNodeVersionsFromHistory(db) {
3971
3764
  const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
@@ -3986,8 +3779,8 @@ async function backfillNodeVersionsFromHistory(db) {
3986
3779
  let totalInserted = 0;
3987
3780
  let totalDocs = 0;
3988
3781
  for (const { id: nestId } of nests) {
3989
- const nestPath2 = join5(config.DATA_ROOT, "nests", nestId);
3990
- const storage = new NestStorage2(nestPath2);
3782
+ const nestPath = join4(config.DATA_ROOT, "nests", nestId);
3783
+ const storage = new NestStorage(nestPath);
3991
3784
  let docs;
3992
3785
  try {
3993
3786
  docs = await storage.discoverDocuments();
@@ -4085,7 +3878,6 @@ async function main() {
4085
3878
  `);
4086
3879
  }
4087
3880
  const app = createApp();
4088
- startLicenseWatcher();
4089
3881
  startLicenseSafetyPoll();
4090
3882
  const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
4091
3883
  if (driftScanIntervalMs > 0) {