@promptowl/contextnest-community 0.1.0-alpha.2 → 1.0.1

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
@@ -7,22 +7,17 @@ import {
7
7
  parseBearerToken,
8
8
  verifyPassword
9
9
  } from "./chunk-7K2LLJXK.js";
10
- import {
11
- checkConflict,
12
- createVersion,
13
- getApprovedVersion,
14
- getCurrentVersion,
15
- getVersions,
16
- setApprovedVersion
17
- } from "./chunk-P6NG56CO.js";
18
10
  import {
19
11
  approve,
20
12
  cancelReview,
13
+ engineCache,
14
+ getPendingReview,
21
15
  getReviewHistory,
22
16
  getReviewQueue,
23
17
  reject,
18
+ safePublishDocument,
24
19
  submitForReview
25
- } from "./chunk-DJFEV4ET.js";
20
+ } from "./chunk-5VHKEIAW.js";
26
21
  import {
27
22
  assignSteward,
28
23
  canUserAccess,
@@ -37,11 +32,22 @@ import {
37
32
  resolveStewardsForNode,
38
33
  resolveStewardsWithFallback,
39
34
  syncFromConfig
40
- } from "./chunk-Q2DCOS7V.js";
35
+ } from "./chunk-K22GWPT4.js";
41
36
  import {
37
+ checkConflict,
38
+ createVersion,
39
+ getApprovedVersion,
40
+ getCurrentVersion,
41
+ getDisplayStatus,
42
+ getVersions,
43
+ setApprovedVersion
44
+ } from "./chunk-JMZ75ZCD.js";
45
+ import {
46
+ ANON_EMAIL,
47
+ ANON_USER_ID,
42
48
  config,
43
49
  getDb
44
- } from "./chunk-USIDOGVJ.js";
50
+ } from "./chunk-KQCWNHDM.js";
45
51
 
46
52
  // src/index.ts
47
53
  import { serve } from "@hono/node-server";
@@ -49,7 +55,6 @@ import { serve } from "@hono/node-server";
49
55
  // src/app.ts
50
56
  import { Hono as Hono8 } from "hono";
51
57
  import { createMiddleware as createMiddleware2 } from "hono/factory";
52
- import { logger } from "hono/logger";
53
58
  import { cors } from "hono/cors";
54
59
 
55
60
  // src/auth/routes.ts
@@ -58,23 +63,113 @@ import { v4 as uuid } from "uuid";
58
63
 
59
64
  // src/auth/middleware.ts
60
65
  import { createMiddleware } from "hono/factory";
61
- var authMiddleware = createMiddleware(async (c, next) => {
62
- const key = parseBearerToken(c.req.header("Authorization"));
63
- if (!key) {
64
- return c.json({ error: "Missing or invalid API key" }, 401);
65
- }
66
- const keyHash = hashApiKey(key);
66
+
67
+ // src/auth/sessions.ts
68
+ import { randomBytes } from "crypto";
69
+ var SESSION_COOKIE = "cnst_session";
70
+ var SESSION_TTL_DAYS = 30;
71
+ var SESSION_TTL_SECS = SESSION_TTL_DAYS * 24 * 60 * 60;
72
+ function newSessionId() {
73
+ return randomBytes(32).toString("hex");
74
+ }
75
+ function expiryIso(ttlSeconds = SESSION_TTL_SECS) {
76
+ return new Date(Date.now() + ttlSeconds * 1e3).toISOString();
77
+ }
78
+ function createSession(userId, userAgent) {
79
+ const db = getDb();
80
+ const id = newSessionId();
81
+ db.prepare(
82
+ `INSERT INTO sessions (id, user_id, expires_at, user_agent)
83
+ VALUES (?, ?, ?, ?)`
84
+ ).run(id, userId, expiryIso(), userAgent || null);
85
+ return id;
86
+ }
87
+ function resolveSession(id) {
67
88
  const db = getDb();
68
- const record = db.prepare("SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?").get(keyHash);
69
- if (!record) {
70
- return c.json({ error: "Invalid API key" }, 401);
89
+ const row = db.prepare(
90
+ `SELECT user_id, expires_at FROM sessions WHERE id = ?`
91
+ ).get(id);
92
+ if (!row) return null;
93
+ if (new Date(row.expires_at).getTime() <= Date.now()) {
94
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
95
+ return null;
71
96
  }
72
97
  db.prepare(
73
- "UPDATE api_keys SET last_used_at = datetime('now') WHERE key_hash = ?"
74
- ).run(keyHash);
75
- c.set("userId", record.user_id);
76
- c.set("nestScope", record.nest_id);
77
- await next();
98
+ "UPDATE sessions SET last_seen_at = datetime('now') WHERE id = ?"
99
+ ).run(id);
100
+ return row.user_id;
101
+ }
102
+ function deleteSession(id) {
103
+ const db = getDb();
104
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
105
+ }
106
+ function deleteAllSessionsForUser(userId) {
107
+ const db = getDb();
108
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
109
+ }
110
+ function getSessionIdFromRequest(c) {
111
+ const cookieHeader = c.req.header("Cookie");
112
+ if (!cookieHeader) return null;
113
+ for (const part of cookieHeader.split(";")) {
114
+ const [k, ...rest] = part.trim().split("=");
115
+ if (k === SESSION_COOKIE) {
116
+ const v = rest.join("=").trim();
117
+ return /^[0-9a-f]{64}$/.test(v) ? v : null;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ function buildSessionCookie(id, opts = {}) {
123
+ const maxAge = opts.maxAgeSeconds ?? SESSION_TTL_SECS;
124
+ const parts = [
125
+ `${SESSION_COOKIE}=${id}`,
126
+ "HttpOnly",
127
+ "Path=/",
128
+ "SameSite=Lax",
129
+ `Max-Age=${maxAge}`
130
+ ];
131
+ if (opts.secure) parts.push("Secure");
132
+ return parts.join("; ");
133
+ }
134
+ function buildClearSessionCookie(opts = {}) {
135
+ return buildSessionCookie("", { secure: opts.secure, maxAgeSeconds: 0 });
136
+ }
137
+ function isSecureRequest(c) {
138
+ const proto = c.req.header("x-forwarded-proto");
139
+ if (proto) return proto.split(",")[0].trim() === "https";
140
+ try {
141
+ return new URL(c.req.url).protocol === "https:";
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ // src/auth/middleware.ts
148
+ var authMiddleware = createMiddleware(async (c, next) => {
149
+ const db = getDb();
150
+ const sessionId = getSessionIdFromRequest(c);
151
+ if (sessionId) {
152
+ const userId = resolveSession(sessionId);
153
+ if (userId) {
154
+ c.set("userId", userId);
155
+ c.set("nestScope", null);
156
+ return next();
157
+ }
158
+ }
159
+ const key = parseBearerToken(c.req.header("Authorization"));
160
+ if (key) {
161
+ const keyHash = hashApiKey(key);
162
+ const record = db.prepare("SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?").get(keyHash);
163
+ if (record) {
164
+ db.prepare(
165
+ "UPDATE api_keys SET last_used_at = datetime('now') WHERE key_hash = ?"
166
+ ).run(keyHash);
167
+ c.set("userId", record.user_id);
168
+ c.set("nestScope", record.nest_id);
169
+ return next();
170
+ }
171
+ }
172
+ return c.json({ error: "Missing or invalid credentials" }, 401);
78
173
  });
79
174
 
80
175
  // src/shared/errors.ts
@@ -84,6 +179,7 @@ var AppError = class extends Error {
84
179
  this.statusCode = statusCode;
85
180
  this.name = "AppError";
86
181
  }
182
+ statusCode;
87
183
  };
88
184
  var NotFoundError = class extends AppError {
89
185
  constructor(message = "Not found") {
@@ -91,12 +187,24 @@ var NotFoundError = class extends AppError {
91
187
  this.name = "NotFoundError";
92
188
  }
93
189
  };
190
+ var ForbiddenError = class extends AppError {
191
+ constructor(message = "Forbidden") {
192
+ super(403, message);
193
+ this.name = "ForbiddenError";
194
+ }
195
+ };
94
196
  var ValidationError = class extends AppError {
95
197
  constructor(message) {
96
198
  super(400, message);
97
199
  this.name = "ValidationError";
98
200
  }
99
201
  };
202
+ var ConflictError = class extends AppError {
203
+ constructor(message) {
204
+ super(409, message);
205
+ this.name = "ConflictError";
206
+ }
207
+ };
100
208
 
101
209
  // src/telemetry/tracker.ts
102
210
  function trackEvent(event, data) {
@@ -154,6 +262,368 @@ function startTelemetryLoop() {
154
262
  );
155
263
  }
156
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
+
157
627
  // src/shared/rate-limit.ts
158
628
  var buckets = /* @__PURE__ */ new Map();
159
629
  function tryConsume(key, cfg) {
@@ -185,6 +655,32 @@ function clientIp(c) {
185
655
  if (realIp) return realIp.trim();
186
656
  return "unknown";
187
657
  }
658
+ function resolveCallerUserId(c) {
659
+ const sessionId = getSessionIdFromRequest(c);
660
+ if (sessionId) {
661
+ const uid = resolveSession(sessionId);
662
+ if (uid) return uid;
663
+ }
664
+ const key = parseBearerToken(c.req.header("Authorization"));
665
+ if (key) {
666
+ const db = getDb();
667
+ const row = db.prepare("SELECT user_id FROM api_keys WHERE key_hash = ?").get(hashApiKey(key));
668
+ if (row) return row.user_id;
669
+ }
670
+ return null;
671
+ }
672
+ function setSessionCookie(c, sessionId) {
673
+ c.header(
674
+ "Set-Cookie",
675
+ buildSessionCookie(sessionId, { secure: isSecureRequest(c) })
676
+ );
677
+ }
678
+ function clearSessionCookie(c) {
679
+ c.header(
680
+ "Set-Cookie",
681
+ buildClearSessionCookie({ secure: isSecureRequest(c) })
682
+ );
683
+ }
188
684
  var authRoutes = new Hono();
189
685
  authRoutes.post("/register", async (c) => {
190
686
  const body = await c.req.json();
@@ -196,25 +692,34 @@ authRoutes.post("/register", async (c) => {
196
692
  return c.json({ error: "Too many registration attempts, try again later" }, 429);
197
693
  }
198
694
  const db = getDb();
199
- const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
200
- if (existing) {
695
+ const existing = db.prepare("SELECT id, is_invited FROM users WHERE email = ?").get(body.email);
696
+ let userId;
697
+ const passwordHash = await hashPassword(body.password);
698
+ if (existing && existing.is_invited === 1) {
699
+ userId = existing.id;
700
+ db.prepare(
701
+ "UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?"
702
+ ).run(passwordHash, body.name || null, userId);
703
+ trackEvent("user.register", { userId, email: body.email, claimed: true });
704
+ } else if (existing) {
201
705
  throw new ValidationError("Email already registered");
706
+ } else {
707
+ userId = uuid();
708
+ db.prepare(
709
+ "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
710
+ ).run(userId, body.email, body.name || null, passwordHash);
711
+ trackEvent("user.register", { userId, email: body.email });
202
712
  }
203
- const userId = uuid();
204
- const passwordHash = await hashPassword(body.password);
205
- db.prepare(
206
- "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
207
- ).run(userId, body.email, body.name || null, passwordHash);
208
- const apiKey = generateApiKey();
209
- const keyId = uuid();
210
- db.prepare(
211
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
212
- ).run(keyId, userId, hashApiKey(apiKey), getKeyPrefix(apiKey), "default");
213
- trackEvent("user.register", { userId, email: body.email });
713
+ const sessionId = createSession(userId, c.req.header("User-Agent"));
714
+ setSessionCookie(c, sessionId);
214
715
  return c.json(
215
716
  {
216
- user: { id: userId, email: body.email, name: body.name || null },
217
- api_key: apiKey
717
+ user: {
718
+ id: userId,
719
+ email: body.email,
720
+ name: body.name || null,
721
+ is_admin: isLicenseAdminEmail(body.email)
722
+ }
218
723
  },
219
724
  201
220
725
  );
@@ -231,7 +736,7 @@ authRoutes.post("/login", async (c) => {
231
736
  }
232
737
  const db = getDb();
233
738
  const user = db.prepare(
234
- "SELECT id, email, name, password_hash FROM users WHERE email = ?"
739
+ "SELECT id, email, name, password_hash, is_admin FROM users WHERE email = ?"
235
740
  ).get(body.email);
236
741
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
237
742
  if (!user || !check.ok) {
@@ -247,30 +752,47 @@ authRoutes.post("/login", async (c) => {
247
752
  } catch {
248
753
  }
249
754
  }
250
- const apiKey = generateApiKey();
251
- const keyId = uuid();
252
- db.prepare(
253
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
254
- ).run(
255
- keyId,
256
- user.id,
257
- hashApiKey(apiKey),
258
- getKeyPrefix(apiKey),
259
- body.label || "login"
260
- );
261
755
  trackEvent("user.login", { userId: user.id });
262
- return c.json({ api_key: apiKey });
756
+ const sessionId = createSession(user.id, c.req.header("User-Agent"));
757
+ setSessionCookie(c, sessionId);
758
+ return c.json({
759
+ user: {
760
+ id: user.id,
761
+ email: user.email,
762
+ name: user.name,
763
+ is_admin: isLicenseAdminEmail(user.email)
764
+ }
765
+ });
766
+ });
767
+ authRoutes.post("/logout", async (c) => {
768
+ const sessionId = getSessionIdFromRequest(c);
769
+ if (sessionId) deleteSession(sessionId);
770
+ clearSessionCookie(c);
771
+ return c.json({ ok: true });
263
772
  });
264
773
  authRoutes.post("/keys", authMiddleware, async (c) => {
265
- const body = await c.req.json();
774
+ const body = await c.req.json().catch(
775
+ () => ({})
776
+ );
266
777
  const db = getDb();
778
+ const userId = c.get("userId");
779
+ const existing = db.prepare("SELECT key_prefix FROM api_keys WHERE user_id = ?").get(userId);
780
+ if (existing) {
781
+ return c.json(
782
+ {
783
+ error: "An API key already exists for this user. Rotate it instead.",
784
+ key_prefix: existing.key_prefix
785
+ },
786
+ 409
787
+ );
788
+ }
267
789
  const apiKey = generateApiKey();
268
790
  const keyId = uuid();
269
791
  db.prepare(
270
792
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
271
793
  ).run(
272
794
  keyId,
273
- c.get("userId"),
795
+ userId,
274
796
  hashApiKey(apiKey),
275
797
  getKeyPrefix(apiKey),
276
798
  body.nest_id || null,
@@ -281,6 +803,30 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
281
803
  201
282
804
  );
283
805
  });
806
+ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
807
+ const body = await c.req.json().catch(
808
+ () => ({})
809
+ );
810
+ const db = getDb();
811
+ const userId = c.get("userId");
812
+ const apiKey = generateApiKey();
813
+ const keyId = uuid();
814
+ const prior = db.prepare("SELECT label, nest_id FROM api_keys WHERE user_id = ?").get(userId);
815
+ db.transaction(() => {
816
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(userId);
817
+ db.prepare(
818
+ "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
819
+ ).run(
820
+ keyId,
821
+ userId,
822
+ hashApiKey(apiKey),
823
+ getKeyPrefix(apiKey),
824
+ body.nest_id ?? prior?.nest_id ?? null,
825
+ body.label ?? prior?.label ?? null
826
+ );
827
+ })();
828
+ return c.json({ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) });
829
+ });
284
830
  authRoutes.get("/keys", authMiddleware, async (c) => {
285
831
  const db = getDb();
286
832
  const keys = db.prepare(
@@ -370,97 +916,127 @@ authRoutes.post("/promptowl", async (c) => {
370
916
  } else {
371
917
  trackEvent("user.login", { userId: user.id, method: "promptowl" });
372
918
  }
919
+ let claimBlocked = null;
920
+ const lic = getCurrentLicense();
373
921
  let isAdmin = false;
374
- const claim = db.transaction((userId) => {
375
- const result = db.prepare(
376
- `UPDATE users SET is_admin = 1
377
- WHERE id = ?
378
- AND NOT EXISTS (SELECT 1 FROM users WHERE is_admin = 1)`
379
- ).run(userId);
380
- return result.changes > 0;
381
- });
382
- if (claim(user.id)) {
922
+ if (!lic?.valid) {
923
+ claimBlocked = { reason: "license_required" };
924
+ } else if (lic.ownerEmail && lic.ownerEmail.toLowerCase() === me.email.toLowerCase()) {
383
925
  isAdmin = true;
384
926
  trackEvent("admin.claim", { userId: user.id, email: user.email });
385
927
  } else {
386
- const userRow = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(user.id);
387
- isAdmin = !!userRow?.is_admin;
928
+ claimBlocked = {
929
+ reason: "email_mismatch",
930
+ license_owner_email: lic.ownerEmail
931
+ };
388
932
  }
389
- const apiKey = generateApiKey();
390
- const keyId = uuid();
391
- db.prepare(
392
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
393
- ).run(
394
- keyId,
395
- user.id,
396
- hashApiKey(apiKey),
397
- getKeyPrefix(apiKey),
398
- "promptowl"
399
- );
933
+ const sessionId = createSession(user.id, c.req.header("User-Agent"));
934
+ setSessionCookie(c, sessionId);
400
935
  return c.json({
401
- api_key: apiKey,
402
936
  user: {
403
937
  id: user.id,
404
938
  email: user.email,
405
939
  name: user.name,
406
940
  is_admin: isAdmin
407
- }
941
+ },
942
+ ...claimBlocked ? { claim_blocked: claimBlocked } : {}
408
943
  });
409
944
  });
410
945
  authRoutes.get("/admin-status", async (c) => {
411
946
  const db = getDb();
412
- const admin = db.prepare("SELECT email, name FROM users WHERE is_admin = 1 LIMIT 1").get();
413
- const callerKey = parseBearerToken(c.req.header("Authorization"));
947
+ const lic = getCurrentLicense();
948
+ const ownerEmail = lic?.valid ? lic.ownerEmail : null;
949
+ let admin = null;
950
+ if (ownerEmail) {
951
+ const ownerRow = db.prepare("SELECT name FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1").get(ownerEmail);
952
+ admin = { email: ownerEmail, name: ownerRow?.name ?? null };
953
+ }
954
+ const callerId = resolveCallerUserId(c);
414
955
  let me = null;
415
- if (callerKey) {
416
- const keyHash = hashApiKey(callerKey);
417
- const row = db.prepare(
418
- `SELECT u.email, u.is_admin FROM api_keys k
419
- JOIN users u ON u.id = k.user_id
420
- WHERE k.key_hash = ?`
421
- ).get(keyHash);
956
+ if (callerId) {
957
+ const row = db.prepare("SELECT email, name FROM users WHERE id = ?").get(callerId);
422
958
  if (row) {
423
- me = { email: row.email, is_admin: !!row.is_admin };
959
+ me = {
960
+ email: row.email,
961
+ name: row.name,
962
+ is_admin: isLicenseAdminEmail(row.email)
963
+ };
424
964
  }
425
965
  }
426
966
  return c.json({
427
967
  claimed: !!admin,
428
- admin: admin ? { email: admin.email, name: admin.name } : null,
968
+ admin,
429
969
  me
430
970
  });
431
971
  });
972
+ authRoutes.post("/password", authMiddleware, async (c) => {
973
+ const body = await c.req.json();
974
+ if (!body.current || !body.next) {
975
+ throw new ValidationError("current and next are required");
976
+ }
977
+ if (body.next.length < 8) {
978
+ throw new ValidationError("new password must be at least 8 characters");
979
+ }
980
+ const db = getDb();
981
+ const userId = c.get("userId");
982
+ const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
983
+ if (!user) return c.json({ error: "User not found" }, 404);
984
+ const check = await verifyPassword(body.current, user.password_hash);
985
+ if (!check.ok) return c.json({ error: "Invalid current password" }, 401);
986
+ const newHash = await hashPassword(body.next);
987
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
988
+ newHash,
989
+ userId
990
+ );
991
+ deleteAllSessionsForUser(userId);
992
+ clearSessionCookie(c);
993
+ return c.json({ ok: true });
994
+ });
432
995
  authRoutes.post("/invite", async (c) => {
433
996
  const body = await c.req.json();
434
997
  if (!body.email) throw new ValidationError("email is required");
435
- const inviteKey = parseBearerToken(c.req.header("Authorization"));
436
- if (!inviteKey) {
437
- return c.json({ error: "Admin authentication required" }, 401);
998
+ const callerId = resolveCallerUserId(c);
999
+ if (!callerId) {
1000
+ return c.json(
1001
+ {
1002
+ error: "Authentication required. Sign in with the admin account that owns the installed PromptOwl license to invite teammates."
1003
+ },
1004
+ 401
1005
+ );
438
1006
  }
439
1007
  const db = getDb();
440
- const keyHash = hashApiKey(inviteKey);
441
- const caller = db.prepare(
442
- `SELECT u.id, u.is_admin FROM api_keys k
443
- JOIN users u ON u.id = k.user_id
444
- WHERE k.key_hash = ?`
445
- ).get(keyHash);
446
- if (!caller || !caller.is_admin) {
447
- return c.json({ error: "Admin only" }, 403);
1008
+ if (!isLicenseAdminUserId(callerId)) {
1009
+ return c.json(
1010
+ {
1011
+ error: "Only the license-admin user can invite teammates. Contact the admin who installed the PromptOwl license on this server to issue invitations."
1012
+ },
1013
+ 403
1014
+ );
448
1015
  }
449
1016
  let user = db.prepare("SELECT id, email FROM users WHERE email = ?").get(body.email);
450
1017
  if (!user) {
451
1018
  const userId = uuid();
452
1019
  const placeholderHash = await hashPassword(uuid());
453
1020
  db.prepare(
454
- "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
1021
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
455
1022
  ).run(userId, body.email, null, placeholderHash);
456
1023
  user = { id: userId, email: body.email };
457
1024
  }
458
1025
  const apiKey = generateApiKey();
459
1026
  const keyId = uuid();
460
- db.prepare(
461
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
462
- ).run(keyId, user.id, hashApiKey(apiKey), getKeyPrefix(apiKey), body.label || "teammate");
463
- trackEvent("admin.invite", { adminId: caller.id, email: body.email });
1027
+ db.transaction(() => {
1028
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(user.id);
1029
+ db.prepare(
1030
+ "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
1031
+ ).run(
1032
+ keyId,
1033
+ user.id,
1034
+ hashApiKey(apiKey),
1035
+ getKeyPrefix(apiKey),
1036
+ body.label || "teammate"
1037
+ );
1038
+ })();
1039
+ trackEvent("admin.invite", { adminId: callerId, email: body.email });
464
1040
  return c.json(
465
1041
  {
466
1042
  api_key: apiKey,
@@ -471,28 +1047,32 @@ authRoutes.post("/invite", async (c) => {
471
1047
  );
472
1048
  });
473
1049
  authRoutes.get("/teammates", async (c) => {
474
- const teammatesKey = parseBearerToken(c.req.header("Authorization"));
475
- if (!teammatesKey) {
476
- return c.json({ error: "Admin authentication required" }, 401);
1050
+ const callerId = resolveCallerUserId(c);
1051
+ if (!callerId) {
1052
+ return c.json(
1053
+ {
1054
+ error: "Authentication required. Sign in with the admin account that owns the installed PromptOwl license to view teammates."
1055
+ },
1056
+ 401
1057
+ );
477
1058
  }
478
1059
  const db = getDb();
479
- const keyHash = hashApiKey(teammatesKey);
480
- const caller = db.prepare(
481
- `SELECT u.is_admin FROM api_keys k
482
- JOIN users u ON u.id = k.user_id
483
- WHERE k.key_hash = ?`
484
- ).get(keyHash);
485
- if (!caller || !caller.is_admin) {
486
- return c.json({ error: "Admin only" }, 403);
1060
+ if (!isLicenseAdminUserId(callerId)) {
1061
+ return c.json(
1062
+ {
1063
+ error: "Only the license-admin user can view the teammates list. Contact the admin who installed the PromptOwl license on this server."
1064
+ },
1065
+ 403
1066
+ );
487
1067
  }
488
1068
  const teammates = db.prepare(
489
- `SELECT u.id, u.email, u.name, u.is_admin,
1069
+ `SELECT u.id, u.email, u.name, u.is_invited,
490
1070
  (SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
491
1071
  (SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
492
1072
  FROM users u
493
- WHERE u.id != '00000000-0000-0000-0000-000000000000'
494
- ORDER BY u.is_admin DESC, u.created_at DESC`
495
- ).all();
1073
+ WHERE u.id != ?
1074
+ ORDER BY u.created_at DESC`
1075
+ ).all(ANON_USER_ID);
496
1076
  const pendingStewards = db.prepare(
497
1077
  `SELECT DISTINCT s.user_email AS email
498
1078
  FROM stewards s
@@ -504,11 +1084,9 @@ authRoutes.get("/teammates", async (c) => {
504
1084
  )
505
1085
  ORDER BY s.user_email`
506
1086
  ).all();
1087
+ const enriched = teammates.map((t) => ({ ...t, is_admin: isLicenseAdminEmail(t.email) })).sort((a, b) => Number(b.is_admin) - Number(a.is_admin));
507
1088
  return c.json({
508
- teammates: teammates.map((t) => ({
509
- ...t,
510
- is_admin: !!t.is_admin
511
- })),
1089
+ teammates: enriched,
512
1090
  pending_stewards: pendingStewards.map((p) => p.email)
513
1091
  });
514
1092
  });
@@ -529,6 +1107,9 @@ function resolveNestPermission(nestId, userId) {
529
1107
  const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
530
1108
  if (!nest) return "none";
531
1109
  if (nest.user_id === userId) return "owner";
1110
+ if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
1111
+ return "owner";
1112
+ }
532
1113
  const directGrant = db.prepare(
533
1114
  "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
534
1115
  ).get(nestId, userId);
@@ -567,9 +1148,10 @@ async function createNest(userId, name, description) {
567
1148
  const id = uuid2();
568
1149
  const slug = toSlug(name);
569
1150
  const db = getDb();
1151
+ const visibility = userId === ANON_USER_ID ? "public" : "private";
570
1152
  db.prepare(
571
- "INSERT INTO nests (id, user_id, name, slug, description) VALUES (?, ?, ?, ?, ?)"
572
- ).run(id, userId, name, slug, description || null);
1153
+ "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)"
1154
+ ).run(id, userId, name, slug, description || null, visibility);
573
1155
  const path = nestPath(id);
574
1156
  mkdirSync(path, { recursive: true });
575
1157
  const storage = new NestStorage(path);
@@ -577,14 +1159,17 @@ async function createNest(userId, name, description) {
577
1159
  trackEvent("nest.create", { nestId: id, userId });
578
1160
  return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
579
1161
  }
580
- var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
581
1162
  function listNests(userId) {
582
1163
  const db = getDb();
583
1164
  if (userId === ANON_USER_ID) {
584
1165
  return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
585
1166
  }
1167
+ const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
1168
+ if (!includeAnon) {
1169
+ return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
1170
+ }
586
1171
  return db.prepare(
587
- "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC"
1172
+ "SELECT * FROM nests WHERE user_id = ? OR (user_id = ? AND visibility = 'public') ORDER BY created_at DESC"
588
1173
  ).all(userId, ANON_USER_ID);
589
1174
  }
590
1175
  function listSharedNests(userId) {
@@ -602,23 +1187,35 @@ function getNest(nestId) {
602
1187
  }
603
1188
  async function deleteNest(nestId) {
604
1189
  const db = getDb();
605
- db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(nestId);
606
- db.prepare("DELETE FROM nests WHERE id = ?").run(nestId);
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);
607
1204
  const path = nestPath(nestId);
608
1205
  try {
609
1206
  rmSync(path, { recursive: true, force: true });
610
- } catch {
1207
+ } catch (err) {
1208
+ console.warn(`[nests] failed to remove nest directory ${path}:`, err);
611
1209
  }
612
1210
  trackEvent("nest.delete", { nestId });
613
1211
  }
614
1212
 
615
1213
  // src/nests/routes.ts
616
- var ANON_USER_ID2 = "00000000-0000-0000-0000-000000000000";
617
1214
  function effectivePermission(nestId, userId) {
618
1215
  if (config.AUTH_MODE === "open") {
619
1216
  const db = getDb();
620
1217
  const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
621
- if (nest && nest.user_id === ANON_USER_ID2) return "owner";
1218
+ if (nest && nest.user_id === ANON_USER_ID) return "owner";
622
1219
  }
623
1220
  return resolveNestPermission(nestId, userId);
624
1221
  }
@@ -627,7 +1224,23 @@ nestRoutes.get("/", async (c) => {
627
1224
  const userId = c.get("userId");
628
1225
  const owned = listNests(userId);
629
1226
  const shared = listSharedNests(userId);
630
- return c.json({ nests: [...owned, ...shared] });
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
+ });
631
1244
  });
632
1245
  nestRoutes.post("/", async (c) => {
633
1246
  const body = await c.req.json();
@@ -648,10 +1261,19 @@ nestRoutes.get("/:nestId", async (c) => {
648
1261
  });
649
1262
  nestRoutes.delete("/:nestId", async (c) => {
650
1263
  const nestId = c.req.param("nestId");
651
- const permission = effectivePermission(nestId, c.get("userId"));
652
- if (permission !== "owner") {
1264
+ const userId = c.get("userId");
1265
+ const nest = getNest(nestId);
1266
+ if (!nest) {
653
1267
  throw new NotFoundError("Nest not found");
654
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
+ }
655
1277
  await deleteNest(nestId);
656
1278
  return c.json({ deleted: true });
657
1279
  });
@@ -668,12 +1290,15 @@ nestRoutes.get("/:nestId/settings", async (c) => {
668
1290
  nestRoutes.patch("/:nestId/settings", async (c) => {
669
1291
  const nestId = c.req.param("nestId");
670
1292
  const userId = c.get("userId");
671
- const db = getDb();
672
- const userRow = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(userId);
673
- const isServerAdmin = !!userRow?.is_admin;
1293
+ const isServerAdmin = isLicenseAdminUserId(userId);
674
1294
  const permission = effectivePermission(nestId, userId);
675
1295
  if (!isServerAdmin && permission !== "owner") {
676
- return c.json({ error: "Admin only" }, 403);
1296
+ return c.json(
1297
+ {
1298
+ error: "Only the nest owner or the server license-admin can update nest settings."
1299
+ },
1300
+ 403
1301
+ );
677
1302
  }
678
1303
  const body = await c.req.json();
679
1304
  if (typeof body.stewardship_enabled === "boolean") {
@@ -711,7 +1336,7 @@ sharingRoutes.post("/collaborators", async (c) => {
711
1336
  const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
712
1337
  userId = uuid3();
713
1338
  db.prepare(
714
- "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
1339
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
715
1340
  ).run(userId, body.email, null, await hashPassword2(uuid3()));
716
1341
  } else {
717
1342
  userId = user.id;
@@ -765,32 +1390,26 @@ sharingRoutes.patch("/visibility", async (c) => {
765
1390
  return c.json({ visibility: body.visibility });
766
1391
  });
767
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
+
768
1409
  // src/nodes/routes.ts
769
1410
  import { Hono as Hono4 } from "hono";
770
1411
  import { serializeDocument } from "@promptowl/contextnest-engine";
771
1412
 
772
- // src/nodes/engine.ts
773
- import { join as join2 } from "path";
774
- import { NestStorage as NestStorage2, GraphQueryEngine } from "@promptowl/contextnest-engine";
775
- var NestEngineCache = class {
776
- cache = /* @__PURE__ */ new Map();
777
- get(nestId) {
778
- let engine = this.cache.get(nestId);
779
- if (!engine) {
780
- const nestPath2 = join2(config.DATA_ROOT, "nests", nestId);
781
- const storage = new NestStorage2(nestPath2);
782
- const query = new GraphQueryEngine(storage);
783
- engine = { storage, query };
784
- this.cache.set(nestId, engine);
785
- }
786
- return engine;
787
- }
788
- evict(nestId) {
789
- this.cache.delete(nestId);
790
- }
791
- };
792
- var engineCache = new NestEngineCache();
793
-
794
1413
  // src/governance/tag-index-service.ts
795
1414
  function normalizeTag(raw) {
796
1415
  return raw.trim().replace(/^#+/, "").toLowerCase();
@@ -821,20 +1440,220 @@ function removeNodeFromTagIndex(nestId, nodeId) {
821
1440
  ).run(nestId, nodeId);
822
1441
  }
823
1442
 
824
- // src/governance/access-guard.ts
825
- function resolveCallerEmail(userId) {
826
- if (!userId) return "admin@localhost";
827
- const db = getDb();
828
- const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
829
- return row?.email || "admin@localhost";
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`);
830
1462
  }
831
- function canReadNode(nestId, nodeId, userEmail) {
832
- if (!isStewardshipEnabled(nestId)) return true;
833
- return canUserAccess(nestId, nodeId, userEmail).allowed;
1463
+ async function readRaw(nestId, documentId) {
1464
+ try {
1465
+ return await readFile(docPath(nestId, documentId), "utf-8");
1466
+ } catch {
1467
+ return null;
1468
+ }
834
1469
  }
835
- function filterAccessible(nestId, userEmail, nodes) {
836
- if (!isStewardshipEnabled(nestId)) return nodes;
837
- return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
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
+ }
1483
+ }
1484
+ async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
1485
+ const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
1486
+ return res?.meta ?? null;
1487
+ }
1488
+ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1489
+ const { storage } = engineCache.get(nestId);
1490
+ const node = await storage.readDocument(documentId).catch(() => null);
1491
+ if (!node) return null;
1492
+ const raw = await readRaw(nestId, documentId);
1493
+ if (raw == null) return null;
1494
+ const drift = detectDrift(raw, node.frontmatter.checksum);
1495
+ if (!drift.drifted) return null;
1496
+ const approved = await loadChainHead(storage, documentId);
1497
+ if (!approved) return null;
1498
+ const existing = await listSuggestions(storage, documentId);
1499
+ const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
1500
+ if (dup) return { meta: dup, created: false };
1501
+ const result = await stageSuggestion({
1502
+ storage,
1503
+ documentId,
1504
+ approvedRawContent: approved.content,
1505
+ proposedRawContent: raw,
1506
+ source: "out-of-band-edit",
1507
+ actor,
1508
+ docTier: "standard"
1509
+ });
1510
+ return { meta: result.meta, created: true };
1511
+ }
1512
+ async function scanNestForDrift(nestId, actor = "system:scanner") {
1513
+ const { storage } = engineCache.get(nestId);
1514
+ const docs = await storage.discoverDocuments();
1515
+ const results = await Promise.all(
1516
+ docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
1517
+ );
1518
+ const staged = results.filter((r) => r?.created).length;
1519
+ return { scanned: docs.length, staged };
1520
+ }
1521
+ async function getPendingChange(nestId, documentId) {
1522
+ const { storage } = engineCache.get(nestId);
1523
+ 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
+ };
1532
+ }
1533
+ async function listNestExternalEdits(nestId) {
1534
+ const { storage } = engineCache.get(nestId);
1535
+ const docs = await storage.discoverDocuments();
1536
+ const lists = await Promise.all(
1537
+ docs.map((doc) => listSuggestions(storage, doc.id))
1538
+ );
1539
+ const entries = lists.flat().map((meta) => ({
1540
+ suggestion_id: meta.suggestion_id,
1541
+ nest_id: nestId,
1542
+ document_id: meta.document_id,
1543
+ source: meta.source,
1544
+ detected_at: meta.detected_at,
1545
+ actor: meta.actor,
1546
+ target_hash: meta.target_hash,
1547
+ proposed_hash: meta.proposed_hash,
1548
+ note: meta.note
1549
+ }));
1550
+ return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1551
+ }
1552
+ async function getExternalEditDetail(nestId, documentId, suggestionId) {
1553
+ const { storage } = engineCache.get(nestId);
1554
+ const found = await readSuggestion(storage, documentId, suggestionId);
1555
+ if (!found) return null;
1556
+ return {
1557
+ suggestion_id: found.meta.suggestion_id,
1558
+ nest_id: nestId,
1559
+ document_id: found.meta.document_id,
1560
+ source: found.meta.source,
1561
+ detected_at: found.meta.detected_at,
1562
+ actor: found.meta.actor,
1563
+ target_hash: found.meta.target_hash,
1564
+ proposed_hash: found.meta.proposed_hash,
1565
+ note: found.meta.note,
1566
+ patch: found.patch
1567
+ };
1568
+ }
1569
+ async function approveExternalEdit(input) {
1570
+ const { storage } = engineCache.get(input.nestId);
1571
+ let result;
1572
+ try {
1573
+ result = await approveSuggestion({
1574
+ storage,
1575
+ rbac: communityRbac,
1576
+ documentId: input.documentId,
1577
+ suggestionId: input.suggestionId,
1578
+ actor: input.actor,
1579
+ zone: "default",
1580
+ comment: input.comment
1581
+ });
1582
+ } catch (err) {
1583
+ console.error(
1584
+ `[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
1585
+ err
1586
+ );
1587
+ throw err;
1588
+ }
1589
+ try {
1590
+ const node = await storage.readDocument(input.documentId);
1591
+ const versionNum = result.versionEntry.version;
1592
+ const tags = node.frontmatter.tags || [];
1593
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
1594
+ createVersion2({
1595
+ nestId: input.nestId,
1596
+ nodeId: input.documentId,
1597
+ version: versionNum,
1598
+ content: node.body || "",
1599
+ author: input.actor,
1600
+ status: "published",
1601
+ tags,
1602
+ changeNote: input.comment || "External edit approved"
1603
+ });
1604
+ setApprovedVersion2(input.nestId, input.documentId, versionNum, input.actor);
1605
+ } catch (err) {
1606
+ console.error(
1607
+ `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
1608
+ err
1609
+ );
1610
+ }
1611
+ return result;
1612
+ }
1613
+ async function rejectExternalEdit(input) {
1614
+ const { storage } = engineCache.get(input.nestId);
1615
+ const result = await rejectSuggestion({
1616
+ storage,
1617
+ rbac: communityRbac,
1618
+ documentId: input.documentId,
1619
+ suggestionId: input.suggestionId,
1620
+ actor: input.actor,
1621
+ zone: "default",
1622
+ reason: input.reason
1623
+ });
1624
+ try {
1625
+ const approved = await loadChainHead(storage, input.documentId);
1626
+ if (approved) {
1627
+ await storage.writeDocument(input.documentId, approved.content);
1628
+ }
1629
+ } catch (err) {
1630
+ console.error(
1631
+ `[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
1632
+ err
1633
+ );
1634
+ }
1635
+ return result;
1636
+ }
1637
+ var scannerTimer = null;
1638
+ async function scanAllNests() {
1639
+ const db = getDb();
1640
+ const rows = db.prepare("SELECT id FROM nests").all();
1641
+ await Promise.all(
1642
+ rows.map(
1643
+ ({ id }) => scanNestForDrift(id).catch(
1644
+ (err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
1645
+ )
1646
+ )
1647
+ );
1648
+ }
1649
+ function startDriftScanner(intervalMs = 3e4) {
1650
+ if (scannerTimer) return;
1651
+ scannerTimer = setInterval(() => {
1652
+ scanAllNests().catch(
1653
+ (err) => console.error("[external-edit] scanner tick failed:", err)
1654
+ );
1655
+ }, intervalMs);
1656
+ scannerTimer.unref?.();
838
1657
  }
839
1658
 
840
1659
  // src/nodes/routes.ts
@@ -845,13 +1664,17 @@ function toNodeResponse(node) {
845
1664
  title: node.frontmatter.title,
846
1665
  type: node.frontmatter.type || "document",
847
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.
848
1670
  status: node.frontmatter.status || "draft",
849
1671
  version: node.frontmatter.version || 1,
850
1672
  author: node.frontmatter.author,
851
1673
  description: node.frontmatter.description,
852
1674
  created_at: node.frontmatter.created_at,
853
1675
  updated_at: node.frontmatter.updated_at,
854
- content: node.body
1676
+ content: node.body,
1677
+ pendingChange: node.pendingChange ?? void 0
855
1678
  };
856
1679
  }
857
1680
  nodeRoutes.get("/", async (c) => {
@@ -860,9 +1683,29 @@ nodeRoutes.get("/", async (c) => {
860
1683
  const documents = await storage.discoverDocuments();
861
1684
  const userEmail = resolveCallerEmail(c.get("userId"));
862
1685
  const accessible = filterAccessible(nestId, userEmail, documents);
1686
+ const pendingByDoc = /* @__PURE__ */ new Map();
1687
+ await Promise.all(
1688
+ accessible.map(async (doc) => {
1689
+ try {
1690
+ pendingByDoc.set(doc.id, await getPendingChange(nestId, doc.id));
1691
+ } catch {
1692
+ pendingByDoc.set(doc.id, null);
1693
+ }
1694
+ })
1695
+ );
863
1696
  return c.json({
864
1697
  count: accessible.length,
865
- nodes: accessible.map(toNodeResponse)
1698
+ nodes: accessible.map((doc) => {
1699
+ const r = toNodeResponse(doc);
1700
+ const pending = pendingByDoc.get(doc.id);
1701
+ if (pending) {
1702
+ r.pendingChange = pending;
1703
+ r.status = "external_edit_pending";
1704
+ } else {
1705
+ r.status = getDisplayStatus(nestId, r.id);
1706
+ }
1707
+ return r;
1708
+ })
866
1709
  });
867
1710
  });
868
1711
  nodeRoutes.post("/", async (c) => {
@@ -871,14 +1714,15 @@ nodeRoutes.post("/", async (c) => {
871
1714
  throw new ValidationError("title and content are required");
872
1715
  }
873
1716
  const nestId = c.req.param("nestId");
874
- const { storage } = engineCache.get(nestId);
1717
+ const { storage, versions: versionManager } = engineCache.get(nestId);
875
1718
  const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
876
1719
  const id = `nodes/${slug}`;
877
1720
  const now = (/* @__PURE__ */ new Date()).toISOString();
878
1721
  const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
879
1722
  const hasStewards = isStewardshipEnabled(nestId);
880
1723
  const initialStatus = hasStewards ? "draft" : "approved";
881
- const node = {
1724
+ const initialVersion = hasStewards ? 1 : 0;
1725
+ let node = {
882
1726
  id,
883
1727
  filePath: "",
884
1728
  frontmatter: {
@@ -886,7 +1730,7 @@ nodeRoutes.post("/", async (c) => {
886
1730
  type: body.type || "document",
887
1731
  tags,
888
1732
  status: body.status || initialStatus,
889
- version: 1,
1733
+ version: initialVersion,
890
1734
  created_at: now,
891
1735
  updated_at: now,
892
1736
  metadata: {
@@ -901,17 +1745,51 @@ nodeRoutes.post("/", async (c) => {
901
1745
  await storage.writeDocument(id, serialized);
902
1746
  syncNodeTags(nestId, id, tags);
903
1747
  const authorEmail = getUserEmail(c);
904
- createVersion({
905
- nestId,
906
- nodeId: id,
907
- version: 1,
908
- content: body.content,
909
- author: authorEmail,
910
- status: initialStatus,
911
- tags
912
- });
913
- if (initialStatus === "approved") {
914
- setApprovedVersion(nestId, id, 1, authorEmail);
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,
1758
+ 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
+ }
915
1793
  }
916
1794
  trackEvent("node.create", { nestId, nodeId: id });
917
1795
  const resolved = resolveStewardsForNode(nestId, id);
@@ -927,7 +1805,7 @@ nodeRoutes.post("/", async (c) => {
927
1805
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
928
1806
  const nestId = c.req.param("nestId");
929
1807
  const nodeId = c.req.param("nodeId");
930
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-NC67XBYO.js");
1808
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-C5D2O7ZE.js");
931
1809
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
932
1810
  nestId,
933
1811
  nodeId
@@ -939,8 +1817,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
939
1817
  role: r.steward.role,
940
1818
  scope: r.steward.scope,
941
1819
  source: r.source,
942
- priority: r.priority,
943
- canApprove: r.steward.canApprove
1820
+ priority: r.priority
944
1821
  })),
945
1822
  fallbackToOwner,
946
1823
  ownerEmail
@@ -949,11 +1826,30 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
949
1826
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
950
1827
  const nestId = c.req.param("nestId");
951
1828
  const nodeId = c.req.param("nodeId");
952
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-Z6FYJRAG.js");
1829
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
953
1830
  const allVersions = getVersions2(nestId, nodeId);
954
1831
  const approved = getApprovedVersion2(nestId, nodeId);
1832
+ const db = getDb();
1833
+ const resolutions = db.prepare(
1834
+ `SELECT version, status, resolved_by, resolved_at
1835
+ FROM review_requests
1836
+ WHERE nest_id = ? AND node_id = ?
1837
+ AND status IN ('approved', 'rejected')
1838
+ AND resolved_by IS NOT NULL
1839
+ ORDER BY resolved_at DESC`
1840
+ ).all(nestId, nodeId);
1841
+ const byVersion = /* @__PURE__ */ new Map();
1842
+ for (const r of resolutions) {
1843
+ if (!byVersion.has(r.version)) {
1844
+ byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
1845
+ }
1846
+ }
1847
+ const enriched = allVersions.map((v) => {
1848
+ const r = byVersion.get(v.version);
1849
+ return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
1850
+ });
955
1851
  return c.json({
956
- versions: allVersions,
1852
+ versions: enriched,
957
1853
  approvedVersion: approved,
958
1854
  currentVersion: allVersions[0]?.version || 0
959
1855
  });
@@ -961,7 +1857,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
961
1857
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
962
1858
  const nestId = c.req.param("nestId");
963
1859
  const nodeId = c.req.param("nodeId");
964
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-5CLVZKAR.js");
1860
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4WS3XL6K.js");
965
1861
  const history = getReviewHistory2(nestId, nodeId);
966
1862
  return c.json({ reviews: history });
967
1863
  });
@@ -976,17 +1872,29 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
976
1872
  403
977
1873
  );
978
1874
  }
1875
+ let node;
979
1876
  try {
980
- const node = await storage.readDocument(nodeId);
981
- return c.json({ node: toNodeResponse(node) });
1877
+ node = await storage.readDocument(nodeId, { verifyChecksum: true });
982
1878
  } catch {
983
1879
  throw new NotFoundError(`Node not found: ${nodeId}`);
984
1880
  }
1881
+ if (node.pendingChange) {
1882
+ try {
1883
+ await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
1884
+ const refreshed = await getPendingChange(nestId, nodeId);
1885
+ if (refreshed) node.pendingChange = refreshed;
1886
+ } catch (err) {
1887
+ console.error("[external-edit] stage-on-read failed:", err);
1888
+ }
1889
+ }
1890
+ const response = toNodeResponse(node);
1891
+ response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
1892
+ return c.json({ node: response });
985
1893
  });
986
1894
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
987
1895
  const nestId = c.req.param("nestId");
988
1896
  const nodeId = c.req.param("nodeId");
989
- const { storage } = engineCache.get(nestId);
1897
+ const { storage, versions: versionManager } = engineCache.get(nestId);
990
1898
  const body = await c.req.json();
991
1899
  const baseVersionHeader = c.req.header("X-Base-Version");
992
1900
  if (baseVersionHeader) {
@@ -1035,40 +1943,99 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1035
1943
  frontmatter: { ...node.frontmatter, title: body.title }
1036
1944
  };
1037
1945
  }
1038
- const currentVersion = getCurrentVersion(nestId, nodeId);
1039
- const newVersion = currentVersion + 1;
1040
- node = {
1041
- ...node,
1042
- frontmatter: {
1043
- ...node.frontmatter,
1044
- version: newVersion,
1045
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
1046
- }
1047
- };
1048
- const fm = Object.fromEntries(
1049
- Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
1050
- );
1051
- node = { ...node, frontmatter: fm };
1052
- const serialized = serializeDocument(node);
1053
- await storage.writeDocument(nodeId, serialized);
1054
1946
  const authorEmail = getUserEmail(c);
1055
1947
  const hasStewards = isStewardshipEnabled(nestId);
1056
1948
  const currentTags = node.frontmatter.tags || [];
1057
- syncNodeTags(nestId, nodeId, currentTags);
1058
- createVersion({
1059
- nestId,
1060
- nodeId,
1061
- version: newVersion,
1062
- content: node.body || "",
1063
- author: authorEmail,
1064
- status: hasStewards ? "draft" : "approved",
1065
- tags: currentTags,
1066
- changeNote: body.changeNote
1067
- });
1068
- if (!hasStewards) {
1069
- setApprovedVersion(nestId, nodeId, newVersion, authorEmail);
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,
2033
+ changeNote: body.changeNote
2034
+ });
2035
+ setApprovedVersion(nestId, nodeId, publishedVersion, authorEmail);
2036
+ responseVersion = publishedVersion;
1070
2037
  }
1071
- return c.json({ node: toNodeResponse(node), version: newVersion });
2038
+ return c.json({ node: toNodeResponse(node), version: responseVersion });
1072
2039
  });
1073
2040
  nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1074
2041
  const nestId = c.req.param("nestId");
@@ -1080,6 +2047,18 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1080
2047
  throw new NotFoundError(`Node not found: ${nodeId}`);
1081
2048
  }
1082
2049
  removeNodeFromTagIndex(nestId, nodeId);
2050
+ const db = getDb();
2051
+ db.transaction(() => {
2052
+ db.prepare(
2053
+ "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?"
2054
+ ).run(nestId, nodeId);
2055
+ db.prepare(
2056
+ "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?"
2057
+ ).run(nestId, nodeId);
2058
+ db.prepare(
2059
+ "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
2060
+ ).run(nestId, nodeId);
2061
+ })();
1083
2062
  trackEvent("node.delete", { nestId, nodeId });
1084
2063
  return c.json({ deleted: true });
1085
2064
  });
@@ -1743,21 +2722,32 @@ var TOOL_DEFINITIONS = [
1743
2722
  },
1744
2723
  {
1745
2724
  name: "context_assign_steward",
1746
- description: "Assign a data steward to govern a scope (nest, tag, folder pattern, or specific document). Stewards review and approve changes before they go live.",
2725
+ description: "Assign a data steward to govern a scope (nest, tag, or specific document). Stewards review and approve changes before they go live.",
1747
2726
  inputSchema: {
1748
2727
  type: "object",
1749
2728
  properties: {
1750
2729
  email: { type: "string", description: "Email of the person to assign as steward" },
1751
- scope: { type: "string", description: "Scope: nest (all docs), tag, folder, or document" },
1752
- target: { type: "string", description: "Scope target: tag name (e.g. #architecture), folder pattern (e.g. nodes/api-*), or document title" },
2730
+ scope: { type: "string", description: "Scope: nest (all docs), tag, or document" },
2731
+ target: { type: "string", description: "Scope target: tag name (e.g. #architecture) or document title" },
1753
2732
  role: { type: "string", description: "Role: reviewer (default), editor, or viewer" }
1754
2733
  },
1755
2734
  required: ["email", "scope"]
1756
2735
  }
1757
2736
  }
1758
2737
  ];
2738
+ async function resolveLlmBody(ctx, node) {
2739
+ if (!isStewardshipEnabled(ctx.nestId)) return node.body || "";
2740
+ const approved = getApprovedVersion(ctx.nestId, node.id);
2741
+ if (approved == null) return null;
2742
+ try {
2743
+ return await ctx.versionManager.reconstructVersion(node.id, approved);
2744
+ } catch (err) {
2745
+ console.error("reconstructVersion failed", node.id, approved, err);
2746
+ return null;
2747
+ }
2748
+ }
1759
2749
  async function handleToolCall(toolName, args, ctx) {
1760
- const { storage, queryEngine, nestId, userEmail } = ctx;
2750
+ const { storage, queryEngine, versionManager, nestId, userEmail } = ctx;
1761
2751
  switch (toolName) {
1762
2752
  case "context_init": {
1763
2753
  const content = await storage.readContextMd();
@@ -1798,14 +2788,23 @@ ${nodeList}`;
1798
2788
  case "context_search": {
1799
2789
  const docs = await storage.discoverDocuments();
1800
2790
  const terms = (args.query || "").toLowerCase().split(/\s+/).filter(Boolean);
1801
- const matches = docs.filter((n) => {
1802
- const hay = [n.frontmatter.title, n.body || "", n.frontmatter.type || "", ...n.frontmatter.tags || []].join(" ").toLowerCase();
2791
+ const enriched = await Promise.all(
2792
+ docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
2793
+ );
2794
+ const visible = enriched.filter((e) => e.body !== null);
2795
+ const matches = visible.filter(({ node: n, body }) => {
2796
+ const hay = [
2797
+ n.frontmatter.title,
2798
+ body || "",
2799
+ n.frontmatter.type || "",
2800
+ ...n.frontmatter.tags || []
2801
+ ].join(" ").toLowerCase();
1803
2802
  return terms.every((t) => hay.includes(t));
1804
2803
  });
1805
2804
  if (!matches.length) return `No nodes matched search: "${args.query}"`;
1806
2805
  const results = matches.map(
1807
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
1808
- ${(n.body || "").slice(0, 200).replace(/\n/g, " ")}`
2806
+ ({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
2807
+ ${(body || "").slice(0, 200).replace(/\n/g, " ")}`
1809
2808
  ).join("\n\n");
1810
2809
  return `Found ${matches.length} node(s) matching "${args.query}":
1811
2810
 
@@ -1833,6 +2832,10 @@ ${list}`;
1833
2832
  node = docs.find((n) => n.id === args.id);
1834
2833
  }
1835
2834
  if (!node) return `Node not found: ${args.title || args.id}`;
2835
+ const body = await resolveLlmBody(ctx, node);
2836
+ if (body === null) {
2837
+ return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
2838
+ }
1836
2839
  const meta = [
1837
2840
  `**Title:** ${node.frontmatter.title}`,
1838
2841
  `**Type:** ${node.frontmatter.type || "document"}`,
@@ -1842,7 +2845,7 @@ ${list}`;
1842
2845
 
1843
2846
  ---
1844
2847
 
1845
- ${node.body || "(no content)"}`;
2848
+ ${body || "(no content)"}`;
1846
2849
  }
1847
2850
  case "context_list": {
1848
2851
  let docs = await storage.discoverDocuments();
@@ -1852,6 +2855,11 @@ ${node.body || "(no content)"}`;
1852
2855
  const tag = args.tag.startsWith("#") ? args.tag : `#${args.tag}`;
1853
2856
  docs = docs.filter((n) => n.frontmatter.tags?.includes(tag));
1854
2857
  }
2858
+ if (isStewardshipEnabled(nestId)) {
2859
+ docs = docs.filter(
2860
+ (n) => getApprovedVersion(nestId, n.id) != null
2861
+ );
2862
+ }
1855
2863
  docs = docs.slice(0, args.limit || 50);
1856
2864
  if (!docs.length) return "No nodes found with the given filters.";
1857
2865
  const list = docs.map(
@@ -1904,6 +2912,11 @@ ${n.body || ""}`;
1904
2912
  };
1905
2913
  await storage.writeDocument(id, serializeDocument3(node));
1906
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);
2919
+ }
1907
2920
  createVersion({
1908
2921
  nestId,
1909
2922
  nodeId: id,
@@ -1934,16 +2947,45 @@ ${n.body || ""}`;
1934
2947
  );
1935
2948
  tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
1936
2949
  }
2950
+ const prevVersion = getCurrentVersion(nestId, node.id);
2951
+ const newVersion = prevVersion + 1;
2952
+ const hasStewards = isStewardshipEnabled(nestId);
1937
2953
  const updated = {
1938
2954
  ...node,
1939
2955
  body,
1940
2956
  frontmatter: {
1941
2957
  ...node.frontmatter,
1942
2958
  tags,
2959
+ version: newVersion,
1943
2960
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1944
2961
  }
1945
2962
  };
1946
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);
2968
+ }
2969
+ syncNodeTags(nestId, node.id, tags);
2970
+ createVersion({
2971
+ nestId,
2972
+ nodeId: node.id,
2973
+ version: newVersion,
2974
+ content: body,
2975
+ author: userEmail,
2976
+ status: hasStewards ? "draft" : "approved",
2977
+ tags
2978
+ });
2979
+ if (getPendingReview(nestId, node.id)) {
2980
+ cancelReview({
2981
+ nestId,
2982
+ nodeId: node.id,
2983
+ cancelledBy: userEmail
2984
+ });
2985
+ }
2986
+ if (!hasStewards) {
2987
+ setApprovedVersion(nestId, node.id, newVersion, userEmail);
2988
+ }
1947
2989
  return `Updated node: **${node.frontmatter.title}**`;
1948
2990
  }
1949
2991
  // ─── Governance Tool Handlers ──────────────────────────────────────
@@ -1959,7 +3001,7 @@ ${n.body || ""}`;
1959
3001
  return `No stewards configured for "${args.title}". Changes are auto-approved.`;
1960
3002
  }
1961
3003
  const list = resolved.map(
1962
- (r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.canApprove ? " \u2713 can approve" : ""}`
3004
+ (r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.role === "reviewer" ? " \u2713 can approve" : ""}`
1963
3005
  ).join("\n");
1964
3006
  return `# Stewards for "${args.title}"
1965
3007
 
@@ -1971,12 +3013,12 @@ ${list}`;
1971
3013
  }
1972
3014
  const byScope = {};
1973
3015
  for (const s of allStewards) {
1974
- const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : s.scope === "folder" ? `Folder: ${s.nodePattern}` : `Document: ${s.nodePattern}`;
3016
+ const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
1975
3017
  (byScope[key] = byScope[key] || []).push(s);
1976
3018
  }
1977
3019
  const sections = Object.entries(byScope).map(
1978
3020
  ([scope, stewards]) => `## ${scope}
1979
- ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.canApprove ? " \u2014 can approve" : ""}`).join("\n")}`
3021
+ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
1980
3022
  ).join("\n\n");
1981
3023
  return `# Data Stewards
1982
3024
 
@@ -2039,7 +3081,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
2039
3081
  if (!node) return `Node not found: ${args.title}`;
2040
3082
  const currentVersion = getCurrentVersion(ctx.nestId, node.id);
2041
3083
  try {
2042
- const request = approve({
3084
+ const request = await approve({
2043
3085
  nestId: ctx.nestId,
2044
3086
  nodeId: node.id,
2045
3087
  version: currentVersion,
@@ -2094,19 +3136,17 @@ ${list}`;
2094
3136
  }
2095
3137
  case "context_assign_steward": {
2096
3138
  const scope = args.scope;
2097
- if (!["nest", "tag", "folder", "document"].includes(scope)) {
2098
- return `Invalid scope "${args.scope}". Use: nest, tag, folder, or document.`;
3139
+ if (!["nest", "tag", "document"].includes(scope)) {
3140
+ return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
2099
3141
  }
2100
3142
  try {
2101
3143
  assignSteward({
2102
3144
  nestId: ctx.nestId,
2103
3145
  scope,
2104
- nodePattern: scope === "folder" || scope === "document" ? args.target : void 0,
3146
+ nodePattern: scope === "document" ? args.target : void 0,
2105
3147
  tagName: scope === "tag" ? args.target : void 0,
2106
3148
  userEmail: args.email,
2107
3149
  role: args.role || "reviewer",
2108
- canApprove: true,
2109
- canReject: true,
2110
3150
  assignedBy: ctx.userEmail,
2111
3151
  assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
2112
3152
  isActive: true
@@ -2153,6 +3193,7 @@ function createMcpServerForNest(nestId, userEmail) {
2153
3193
  const text = await handleToolCall(tool.name, args, {
2154
3194
  storage: engine.storage,
2155
3195
  queryEngine: engine.query,
3196
+ versionManager: engine.versions,
2156
3197
  nestId,
2157
3198
  userEmail
2158
3199
  });
@@ -2184,7 +3225,7 @@ mcpRoutes.all("/", async (c) => {
2184
3225
  import { Hono as Hono7 } from "hono";
2185
3226
 
2186
3227
  // src/governance/stewards-parser.ts
2187
- import { readFileSync, existsSync } from "fs";
3228
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2188
3229
  import { join as join3 } from "path";
2189
3230
  function parseStewardsYaml(content) {
2190
3231
  const result = { version: 1 };
@@ -2196,9 +3237,6 @@ function parseStewardsYaml(content) {
2196
3237
  if (!currentSection || currentEntries.length === 0) return;
2197
3238
  if (currentSection === "nest") {
2198
3239
  result.nest = [...result.nest || [], ...currentEntries];
2199
- } else if (currentSection === "folders" && currentTarget) {
2200
- result.folders = result.folders || {};
2201
- result.folders[currentTarget] = currentEntries;
2202
3240
  } else if (currentSection === "tags" && currentTarget) {
2203
3241
  result.tags = result.tags || {};
2204
3242
  result.tags[currentTarget] = currentEntries;
@@ -2218,12 +3256,12 @@ function parseStewardsYaml(content) {
2218
3256
  if (key === "version") continue;
2219
3257
  if (key === "nest" || key === "data_room") {
2220
3258
  currentSection = "nest";
2221
- } else if (key === "folders") {
2222
- currentSection = "folders";
2223
3259
  } else if (key === "tags") {
2224
3260
  currentSection = "tags";
2225
3261
  } else if (key === "documents") {
2226
3262
  currentSection = "documents";
3263
+ } else if (key === "folders") {
3264
+ currentSection = null;
2227
3265
  }
2228
3266
  continue;
2229
3267
  }
@@ -2249,12 +3287,6 @@ function parseEntry(str) {
2249
3287
  const entry = { email: emailMatch[1] };
2250
3288
  const roleMatch = str.match(/role:\s*["']?(\w+)["']?/);
2251
3289
  if (roleMatch) entry.role = roleMatch[1];
2252
- if (str.includes("can_approve:")) {
2253
- entry.can_approve = str.includes("can_approve: true");
2254
- }
2255
- if (str.includes("can_reject:")) {
2256
- entry.can_reject = str.includes("can_reject: true");
2257
- }
2258
3290
  return entry;
2259
3291
  }
2260
3292
  function loadStewardsConfig(nestId) {
@@ -2266,8 +3298,8 @@ function loadStewardsConfig(nestId) {
2266
3298
  join3(nestPath2, ".context", "stewards.yaml")
2267
3299
  ];
2268
3300
  for (const candidatePath of candidates) {
2269
- if (existsSync(candidatePath)) {
2270
- const content = readFileSync(candidatePath, "utf-8");
3301
+ if (existsSync2(candidatePath)) {
3302
+ const content = readFileSync2(candidatePath, "utf-8");
2271
3303
  return parseStewardsYaml(content);
2272
3304
  }
2273
3305
  }
@@ -2292,12 +3324,16 @@ governanceRoutes.post("/stewards", async (c) => {
2292
3324
  const body = await c.req.json();
2293
3325
  const assignedBy = getUserEmail3(c);
2294
3326
  if (!body.scope) throw new ValidationError("scope is required");
3327
+ if (body.scope === "folder") {
3328
+ throw new ValidationError(
3329
+ "folder scope is no longer supported \u2014 use 'nest' or 'document'"
3330
+ );
3331
+ }
2295
3332
  if (Array.isArray(body.users)) {
2296
- const created2 = createStewardRecord({
3333
+ const created2 = await createStewardRecord({
2297
3334
  nestId,
2298
3335
  scope: body.scope,
2299
3336
  documentId: body.documentId,
2300
- folderPath: body.folderPath,
2301
3337
  tagName: body.tagName,
2302
3338
  users: body.users,
2303
3339
  assignedBy
@@ -2307,18 +3343,15 @@ governanceRoutes.post("/stewards", async (c) => {
2307
3343
  if (!body.email) {
2308
3344
  throw new ValidationError("users[] or email is required");
2309
3345
  }
2310
- const created = createStewardRecord({
3346
+ const created = await createStewardRecord({
2311
3347
  nestId,
2312
3348
  scope: body.scope,
2313
3349
  documentId: body.scope === "document" ? body.nodePattern : void 0,
2314
- folderPath: body.scope === "folder" ? body.nodePattern : void 0,
2315
3350
  tagName: body.scope === "tag" ? body.tagName : void 0,
2316
3351
  users: [
2317
3352
  {
2318
3353
  email: body.email,
2319
- role: body.role,
2320
- canApprove: body.canApprove,
2321
- canReject: body.canReject
3354
+ role: body.role
2322
3355
  }
2323
3356
  ],
2324
3357
  assignedBy
@@ -2352,6 +3385,21 @@ governanceRoutes.get("/review-queue", async (c) => {
2352
3385
  });
2353
3386
  return c.json(result);
2354
3387
  });
3388
+ governanceRoutes.get("/external-edits", async (c) => {
3389
+ const nestId = c.req.param("nestId");
3390
+ const refresh = c.req.query("refresh") === "true";
3391
+ if (refresh) {
3392
+ await scanNestForDrift(nestId, "user:refresh");
3393
+ }
3394
+ const entries = await listNestExternalEdits(nestId);
3395
+ return c.json({ entries, total: entries.length });
3396
+ });
3397
+ governanceRoutes.post("/external-edits/scan", async (c) => {
3398
+ const nestId = c.req.param("nestId");
3399
+ const actor = getUserEmail3(c);
3400
+ const result = await scanNestForDrift(nestId, actor);
3401
+ return c.json(result);
3402
+ });
2355
3403
  var governanceNodeRoutes = new Hono7();
2356
3404
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
2357
3405
  const nestId = c.req.param("nestId");
@@ -2364,8 +3412,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
2364
3412
  role: r.steward.role,
2365
3413
  scope: r.steward.scope,
2366
3414
  source: r.source,
2367
- priority: r.priority,
2368
- canApprove: r.steward.canApprove
3415
+ priority: r.priority
2369
3416
  })),
2370
3417
  fallbackToOwner,
2371
3418
  ownerEmail
@@ -2397,14 +3444,23 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
2397
3444
  throw new ValidationError("Node has no versions to review");
2398
3445
  }
2399
3446
  const userEmail = getUserEmail3(c);
2400
- const request = submitForReview({
2401
- nestId,
2402
- nodeId,
2403
- version: currentVersion,
2404
- requestedBy: userEmail,
2405
- note: body.note,
2406
- priority: body.priority
2407
- });
3447
+ let request;
3448
+ try {
3449
+ request = submitForReview({
3450
+ nestId,
3451
+ nodeId,
3452
+ version: currentVersion,
3453
+ requestedBy: userEmail,
3454
+ note: body.note,
3455
+ priority: body.priority
3456
+ });
3457
+ } catch (err) {
3458
+ const msg = err instanceof Error ? err.message : String(err);
3459
+ if (/already pending/i.test(msg)) {
3460
+ throw new ConflictError(msg);
3461
+ }
3462
+ throw err;
3463
+ }
2408
3464
  const resolved = resolveStewardsForNode(nestId, nodeId);
2409
3465
  return c.json(
2410
3466
  {
@@ -2425,7 +3481,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
2425
3481
  const userEmail = getUserEmail3(c);
2426
3482
  const isAdmin = isSuperAdmin(userEmail);
2427
3483
  try {
2428
- const request = approve({
3484
+ const request = await approve({
2429
3485
  nestId,
2430
3486
  nodeId,
2431
3487
  version: getCurrentVersion(nestId, nodeId),
@@ -2477,6 +3533,90 @@ governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
2477
3533
  const userEmail = getUserEmail3(c);
2478
3534
  return c.json(canUserEdit(nestId, nodeId, userEmail));
2479
3535
  });
3536
+ governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
3537
+ const nestId = c.req.param("nestId");
3538
+ const nodeId = c.req.param("nodeId");
3539
+ const pending = await getPendingChange(nestId, nodeId);
3540
+ return c.json({ pending });
3541
+ });
3542
+ governanceNodeRoutes.get(
3543
+ "/:nodeId{.+?}/external-edits/:suggestionId",
3544
+ async (c) => {
3545
+ const nestId = c.req.param("nestId");
3546
+ const nodeId = c.req.param("nodeId");
3547
+ const suggestionId = c.req.param("suggestionId");
3548
+ const detail = await getExternalEditDetail(
3549
+ nestId,
3550
+ nodeId,
3551
+ suggestionId
3552
+ );
3553
+ if (!detail) {
3554
+ return c.json({ error: "Suggestion not found" }, 404);
3555
+ }
3556
+ return c.json({ entry: detail });
3557
+ }
3558
+ );
3559
+ governanceNodeRoutes.post(
3560
+ "/:nodeId{.+?}/external-edits/:suggestionId/approve",
3561
+ async (c) => {
3562
+ const nestId = c.req.param("nestId");
3563
+ const nodeId = c.req.param("nodeId");
3564
+ const suggestionId = c.req.param("suggestionId");
3565
+ const body = await c.req.json().catch(() => ({}));
3566
+ const actor = getUserEmail3(c);
3567
+ try {
3568
+ const result = await approveExternalEdit({
3569
+ nestId,
3570
+ documentId: nodeId,
3571
+ suggestionId,
3572
+ actor,
3573
+ comment: body.comment
3574
+ });
3575
+ return c.json({
3576
+ approved: true,
3577
+ version: result.versionEntry.version,
3578
+ chainEvent: result.chainEvent.event_id
3579
+ });
3580
+ } catch (err) {
3581
+ console.error(
3582
+ `[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
3583
+ { message: err?.message, name: err?.name, stack: err?.stack }
3584
+ );
3585
+ return c.json(
3586
+ { error: err.message, name: err?.name },
3587
+ 400
3588
+ );
3589
+ }
3590
+ }
3591
+ );
3592
+ governanceNodeRoutes.post(
3593
+ "/:nodeId{.+?}/external-edits/:suggestionId/reject",
3594
+ async (c) => {
3595
+ const nestId = c.req.param("nestId");
3596
+ const nodeId = c.req.param("nodeId");
3597
+ const suggestionId = c.req.param("suggestionId");
3598
+ const body = await c.req.json().catch(() => ({}));
3599
+ if (!body.reason) {
3600
+ throw new ValidationError("Rejection reason is required");
3601
+ }
3602
+ const actor = getUserEmail3(c);
3603
+ try {
3604
+ const result = await rejectExternalEdit({
3605
+ nestId,
3606
+ documentId: nodeId,
3607
+ suggestionId,
3608
+ actor,
3609
+ reason: body.reason
3610
+ });
3611
+ return c.json({
3612
+ rejected: true,
3613
+ chainEvent: result.chainEvent.event_id
3614
+ });
3615
+ } catch (err) {
3616
+ return c.json({ error: err.message }, 400);
3617
+ }
3618
+ }
3619
+ );
2480
3620
  governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
2481
3621
  const nestId = c.req.param("nestId");
2482
3622
  const nodeId = c.req.param("nodeId");
@@ -2497,186 +3637,29 @@ function getUserEmail3(c) {
2497
3637
 
2498
3638
  // src/auth/anonymous.ts
2499
3639
  import bcrypt from "bcryptjs";
2500
- var ANON_USER_ID3 = "00000000-0000-0000-0000-000000000000";
2501
- var ANON_EMAIL = "admin@localhost";
2502
3640
  function ensureAnonymousUser() {
2503
3641
  const db = getDb();
2504
- const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID3);
3642
+ const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID);
2505
3643
  if (!exists) {
2506
3644
  const placeholder = bcrypt.hashSync("anon-no-login", 4);
2507
3645
  db.prepare(
2508
3646
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
2509
- ).run(ANON_USER_ID3, ANON_EMAIL, "Admin", placeholder);
2510
- }
2511
- return ANON_USER_ID3;
2512
- }
2513
-
2514
- // src/auth/license.ts
2515
- var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2516
- var suspensionFirstSeen = null;
2517
- var suspensionConfirmed = false;
2518
- var suspensionReason = null;
2519
- var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
2520
- function isSuspended() {
2521
- return suspensionConfirmed;
2522
- }
2523
- function getSuspensionReason() {
2524
- return suspensionReason;
2525
- }
2526
- async function validateLicense() {
2527
- const key = config.PROMPTOWL_KEY;
2528
- if (!key) {
2529
- return {
2530
- valid: false,
2531
- tier: "none",
2532
- org: null,
2533
- limits: null,
2534
- suspended: false,
2535
- suspendedReason: null
2536
- };
2537
- }
2538
- const db = getDb();
2539
- const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
2540
- if (cached) {
2541
- const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
2542
- if (age < CACHE_TTL_MS) {
2543
- return {
2544
- valid: true,
2545
- tier: cached.tier,
2546
- org: cached.org,
2547
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
2548
- suspended: suspensionConfirmed,
2549
- suspendedReason: suspensionReason
2550
- };
2551
- }
2552
- }
2553
- try {
2554
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
2555
- const res = await fetch(`${promptowlUrl}/api/license/validate`, {
2556
- method: "POST",
2557
- headers: { "Content-Type": "application/json" },
2558
- body: JSON.stringify({ key })
2559
- });
2560
- if (!res.ok) {
2561
- if (cached) {
2562
- console.warn(
2563
- " PromptOwl unreachable, using cached license (grace period)"
2564
- );
2565
- return {
2566
- valid: true,
2567
- tier: cached.tier,
2568
- org: cached.org,
2569
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
2570
- suspended: suspensionConfirmed,
2571
- suspendedReason: suspensionReason
2572
- };
2573
- }
2574
- return {
2575
- valid: false,
2576
- tier: "none",
2577
- org: null,
2578
- limits: null,
2579
- suspended: false,
2580
- suspendedReason: null
2581
- };
2582
- }
2583
- const data = await res.json();
2584
- if (data.suspended === true) {
2585
- if (!suspensionFirstSeen) {
2586
- suspensionFirstSeen = Date.now();
2587
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
2588
- console.warn(
2589
- `
2590
- WARNING: PromptOwl has flagged this server for suspension.`
2591
- );
2592
- console.warn(
2593
- ` Reason: ${suspensionReason}`
2594
- );
2595
- console.warn(
2596
- ` This will be confirmed in ~1 hour. If this is an error,`
2597
- );
2598
- console.warn(
2599
- ` contact support@promptowl.ai to reverse it.
2600
- `
2601
- );
2602
- } else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
2603
- suspensionConfirmed = true;
2604
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
2605
- console.error(
2606
- `
2607
- SERVER SUSPENDED: ${suspensionReason}`
2608
- );
2609
- console.error(
2610
- ` Write operations are disabled. Reads still work.`
2611
- );
2612
- console.error(
2613
- ` Contact support@promptowl.ai to resolve.
2614
- `
2615
- );
2616
- }
2617
- } else {
2618
- if (suspensionFirstSeen) {
2619
- console.log(" Suspension flag cleared by PromptOwl.");
2620
- }
2621
- suspensionFirstSeen = null;
2622
- suspensionConfirmed = false;
2623
- suspensionReason = null;
2624
- }
2625
- if (!data.valid && !data.suspended) {
2626
- return {
2627
- valid: false,
2628
- tier: "none",
2629
- org: null,
2630
- limits: null,
2631
- suspended: false,
2632
- suspendedReason: null
2633
- };
2634
- }
2635
- if (data.valid) {
2636
- const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
2637
- db.prepare(
2638
- `INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, validated_at)
2639
- VALUES (?, ?, ?, ?, datetime('now'))`
2640
- ).run(key, data.tier || "community", data.org || null, limitsJson);
2641
- }
2642
- return {
2643
- valid: data.valid !== false,
2644
- tier: data.tier || "community",
2645
- org: data.org || null,
2646
- limits: data.limits || null,
2647
- suspended: suspensionConfirmed,
2648
- suspendedReason: suspensionReason
2649
- };
2650
- } catch (err) {
2651
- if (cached) {
2652
- console.warn(
2653
- ` PromptOwl validation failed (${err.message}), using cached license`
2654
- );
2655
- return {
2656
- valid: true,
2657
- tier: cached.tier,
2658
- org: cached.org,
2659
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
2660
- suspended: suspensionConfirmed,
2661
- suspendedReason: suspensionReason
2662
- };
2663
- }
2664
- return {
2665
- valid: false,
2666
- tier: "none",
2667
- org: null,
2668
- limits: null,
2669
- suspended: false,
2670
- suspendedReason: null
2671
- };
3647
+ ).run(ANON_USER_ID, ANON_EMAIL, "Admin", placeholder);
2672
3648
  }
3649
+ return ANON_USER_ID;
2673
3650
  }
2674
3651
 
2675
3652
  // src/app.ts
2676
3653
  import { serveStatic } from "@hono/node-server/serve-static";
2677
3654
  import { fileURLToPath } from "url";
2678
3655
  import { dirname, join as join4, relative } from "path";
2679
- var UI_DIR_ABS = join4(dirname(fileURLToPath(import.meta.url)), "web3");
3656
+ import { existsSync as existsSync3 } from "fs";
3657
+ var HERE = dirname(fileURLToPath(import.meta.url));
3658
+ var UI_DIR_CANDIDATES = [
3659
+ join4(HERE, "web3"),
3660
+ join4(process.cwd(), "dist", "web3")
3661
+ ];
3662
+ var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync3(p)) || UI_DIR_CANDIDATES[0];
2680
3663
  var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
2681
3664
  var openModeMiddleware = createMiddleware2(async (c, next) => {
2682
3665
  const anonId = ensureAnonymousUser();
@@ -2685,8 +3668,9 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
2685
3668
  await next();
2686
3669
  });
2687
3670
  var flexAuthMiddleware = createMiddleware2(async (c, next) => {
2688
- const header = c.req.header("Authorization");
2689
- if (header?.startsWith("Bearer cnst_")) {
3671
+ const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
3672
+ const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
3673
+ if (hasBearer || hasCookie) {
2690
3674
  return authMiddleware(c, next);
2691
3675
  }
2692
3676
  if (config.AUTH_MODE === "open") {
@@ -2695,11 +3679,10 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
2695
3679
  c.set("nestScope", null);
2696
3680
  return next();
2697
3681
  }
2698
- return c.json({ error: "Missing or invalid API key" }, 401);
3682
+ return c.json({ error: "Missing or invalid credentials" }, 401);
2699
3683
  });
2700
3684
  function createApp() {
2701
3685
  const app = new Hono8();
2702
- app.use("*", logger());
2703
3686
  const corsOrigins = config.CORS_ORIGINS;
2704
3687
  app.use(
2705
3688
  "*",
@@ -2730,7 +3713,101 @@ function createApp() {
2730
3713
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
2731
3714
  })
2732
3715
  );
3716
+ app.use("/auth/invite", async (c, next) => {
3717
+ if (!getCurrentLicense()?.valid) {
3718
+ return c.json(
3719
+ {
3720
+ error: "License required",
3721
+ reason: "Install a valid PromptOwl license key before inviting teammates."
3722
+ },
3723
+ 503
3724
+ );
3725
+ }
3726
+ return next();
3727
+ });
3728
+ app.use("/auth/teammates", async (c, next) => {
3729
+ if (!getCurrentLicense()?.valid) {
3730
+ return c.json(
3731
+ {
3732
+ error: "License required",
3733
+ reason: "Install a valid PromptOwl license key to view teammates."
3734
+ },
3735
+ 503
3736
+ );
3737
+ }
3738
+ return next();
3739
+ });
2733
3740
  app.route("/auth", authRoutes);
3741
+ app.get("/license/status", (c) => {
3742
+ const lic = getCurrentLicense();
3743
+ return c.json({
3744
+ valid: !!lic?.valid,
3745
+ tier: lic?.tier || "none",
3746
+ org: lic?.org || null,
3747
+ owner_email: lic?.ownerEmail || null,
3748
+ suspended: !!lic?.suspended,
3749
+ suspended_reason: lic?.suspendedReason || null,
3750
+ limits: lic?.limits || null
3751
+ });
3752
+ });
3753
+ app.post("/license/install", async (c) => {
3754
+ let body;
3755
+ try {
3756
+ body = await c.req.json();
3757
+ } catch {
3758
+ return c.json({ error: "Invalid JSON body" }, 400);
3759
+ }
3760
+ const key = body?.key?.trim();
3761
+ if (!key || !key.startsWith("pk_")) {
3762
+ return c.json(
3763
+ { error: "Invalid license key. Must start with pk_." },
3764
+ 400
3765
+ );
3766
+ }
3767
+ try {
3768
+ const info = await installLicenseKey(key);
3769
+ if (!info.valid) {
3770
+ return c.json(
3771
+ {
3772
+ valid: false,
3773
+ error: "PromptOwl rejected this license key. It wasn't saved. Verify the key is correct and active, then try again."
3774
+ },
3775
+ 400
3776
+ );
3777
+ }
3778
+ return c.json({
3779
+ valid: true,
3780
+ tier: info.tier,
3781
+ org: info.org,
3782
+ limits: info.limits
3783
+ });
3784
+ } catch (err) {
3785
+ const msg = err instanceof Error ? err.message : "Failed to install key";
3786
+ return c.json({ error: msg }, 500);
3787
+ }
3788
+ });
3789
+ app.use("/stats", flexAuthMiddleware);
3790
+ app.get("/stats", async (c) => {
3791
+ const db = getDb();
3792
+ const userId = c.get("userId");
3793
+ const userEmail = resolveCallerEmail(userId);
3794
+ const visibleNests = [...listNests(userId), ...listSharedNests(userId)];
3795
+ let documents = 0;
3796
+ for (const nest of visibleNests) {
3797
+ try {
3798
+ const { storage } = engineCache.get(nest.id);
3799
+ const docs = await storage.discoverDocuments();
3800
+ documents += filterAccessible(nest.id, userEmail, docs).length;
3801
+ } catch {
3802
+ }
3803
+ }
3804
+ const usersRow = db.prepare("SELECT COUNT(*) as c FROM users").get();
3805
+ return c.json({
3806
+ nests: visibleNests.length,
3807
+ documents,
3808
+ users: usersRow.c
3809
+ });
3810
+ });
2734
3811
  const nestsApp = new Hono8();
2735
3812
  nestsApp.use("*", flexAuthMiddleware);
2736
3813
  nestsApp.use("*", async (c, next) => {
@@ -2753,6 +3830,24 @@ function createApp() {
2753
3830
  503
2754
3831
  );
2755
3832
  }
3833
+ {
3834
+ const path2 = c.req.path;
3835
+ const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit");
3836
+ const needsLicense = c.req.method !== "GET" || isGovernance;
3837
+ if (needsLicense) {
3838
+ const lic = getCurrentLicense();
3839
+ if (!lic?.valid) {
3840
+ return c.json(
3841
+ {
3842
+ error: "License required",
3843
+ reason: "Install a PromptOwl license key via the setup screen or POST /license/install.",
3844
+ setup_url: "/setup"
3845
+ },
3846
+ 503
3847
+ );
3848
+ }
3849
+ }
3850
+ }
2756
3851
  if (config.AUTH_MODE === "open") {
2757
3852
  c.set("nestPermission", "owner");
2758
3853
  return next();
@@ -2763,13 +3858,21 @@ function createApp() {
2763
3858
  }
2764
3859
  let required = "read";
2765
3860
  const path = c.req.path;
3861
+ const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
2766
3862
  if (path.includes("/collaborators") || path.includes("/visibility")) {
2767
3863
  required = "admin";
2768
- } else if (c.req.method !== "GET") {
3864
+ } else if (c.req.method !== "GET" && !isStewardActionPath) {
2769
3865
  required = "write";
2770
3866
  }
2771
3867
  if (permissionLevel(permission) < permissionLevel(required)) {
2772
- return c.json({ error: "Insufficient permissions" }, 403);
3868
+ return c.json(
3869
+ {
3870
+ 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.`,
3871
+ required_permission: required,
3872
+ your_permission: permission
3873
+ },
3874
+ 403
3875
+ );
2773
3876
  }
2774
3877
  c.set("nestPermission", permission);
2775
3878
  return next();
@@ -2788,15 +3891,180 @@ function createApp() {
2788
3891
  if (err instanceof AppError) {
2789
3892
  return c.json({ error: err.message }, err.statusCode);
2790
3893
  }
3894
+ const e = err;
3895
+ const code = e.code;
3896
+ const msg = e.message || "";
3897
+ if (code === "SQLITE_CONSTRAINT_UNIQUE") {
3898
+ let friendly = "That conflicts with existing data. Please change a value and try again.";
3899
+ if (msg.includes("node_versions")) {
3900
+ friendly = "A version record for this node already exists. The document may have been recreated with the same name \u2014 pick a different title or remove the old node first.";
3901
+ } else if (msg.includes("review_requests")) {
3902
+ friendly = "A review request for this node already exists.";
3903
+ } else if (msg.includes("nests")) {
3904
+ friendly = "A nest with that name already exists.";
3905
+ } else if (msg.includes("users")) {
3906
+ friendly = "An account with that email already exists.";
3907
+ } else if (msg.includes("stewards")) {
3908
+ friendly = "That steward is already assigned with the same scope.";
3909
+ }
3910
+ return c.json({ error: friendly }, 409);
3911
+ }
3912
+ if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
3913
+ return c.json(
3914
+ {
3915
+ error: "Required related record is missing. Ensure the parent resource still exists."
3916
+ },
3917
+ 409
3918
+ );
3919
+ }
3920
+ if (code === "SQLITE_CONSTRAINT_CHECK") {
3921
+ return c.json(
3922
+ {
3923
+ error: "Provided value isn't allowed for one of the fields. Double-check your input."
3924
+ },
3925
+ 400
3926
+ );
3927
+ }
3928
+ if (code === "SQLITE_CONSTRAINT_NOTNULL") {
3929
+ return c.json(
3930
+ { error: "A required field is missing. Please fill it in and retry." },
3931
+ 400
3932
+ );
3933
+ }
3934
+ if (code === "ENOENT") {
3935
+ return c.json(
3936
+ { error: "The requested file or folder doesn't exist." },
3937
+ 404
3938
+ );
3939
+ }
3940
+ if (code === "EACCES" || code === "EPERM") {
3941
+ return c.json(
3942
+ { error: "Server doesn't have permission to access that file." },
3943
+ 500
3944
+ );
3945
+ }
3946
+ if (code === "ENOSPC") {
3947
+ return c.json(
3948
+ { error: "Out of disk space on the server. Free up space and retry." },
3949
+ 507
3950
+ );
3951
+ }
3952
+ if (err instanceof SyntaxError && msg.toLowerCase().includes("json")) {
3953
+ return c.json({ error: "Request body isn't valid JSON." }, 400);
3954
+ }
2791
3955
  console.error("Unhandled error:", err);
2792
- return c.json({ error: "Internal server error" }, 500);
3956
+ return c.json(
3957
+ {
3958
+ error: "Something went wrong on the server. Try again \u2014 if it keeps failing, check the server log."
3959
+ },
3960
+ 500
3961
+ );
2793
3962
  });
2794
3963
  return app;
2795
3964
  }
2796
3965
 
3966
+ // src/db/backfill.ts
3967
+ import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
3968
+ import { join as join5 } from "path";
3969
+ var MIGRATION_ID = "005_backfill_node_versions_from_history";
3970
+ async function backfillNodeVersionsFromHistory(db) {
3971
+ const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
3972
+ if (already) return;
3973
+ const nests = db.prepare("SELECT id FROM nests").all();
3974
+ const insert = db.prepare(
3975
+ `INSERT OR IGNORE INTO node_versions
3976
+ (nest_id, node_id, version, content_hash, author, status, change_note, created_at)
3977
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3978
+ );
3979
+ const approvedPin = db.prepare(
3980
+ `INSERT OR REPLACE INTO approved_versions
3981
+ (nest_id, node_id, approved_version, approved_by, approved_at)
3982
+ VALUES (?, ?, ?, ?, COALESCE(
3983
+ (SELECT approved_at FROM approved_versions WHERE nest_id = ? AND node_id = ?),
3984
+ datetime('now')))`
3985
+ );
3986
+ let totalInserted = 0;
3987
+ let totalDocs = 0;
3988
+ for (const { id: nestId } of nests) {
3989
+ const nestPath2 = join5(config.DATA_ROOT, "nests", nestId);
3990
+ const storage = new NestStorage2(nestPath2);
3991
+ let docs;
3992
+ try {
3993
+ docs = await storage.discoverDocuments();
3994
+ } catch (err) {
3995
+ console.warn(
3996
+ `[backfill] discoverDocuments failed for nest ${nestId}:`,
3997
+ err.message
3998
+ );
3999
+ continue;
4000
+ }
4001
+ for (const doc of docs) {
4002
+ totalDocs += 1;
4003
+ let history;
4004
+ try {
4005
+ history = await storage.readHistory(doc.id);
4006
+ } catch {
4007
+ history = null;
4008
+ }
4009
+ if (!history || history.versions.length === 0) continue;
4010
+ const existing = db.prepare(
4011
+ `SELECT version FROM node_versions WHERE nest_id = ? AND node_id = ?`
4012
+ ).all(nestId, doc.id);
4013
+ const known = new Set(existing.map((r) => r.version));
4014
+ const tagsJson = doc.frontmatter.tags ? JSON.stringify(doc.frontmatter.tags) : null;
4015
+ const latestVersion = history.versions[history.versions.length - 1].version;
4016
+ for (const entry of history.versions) {
4017
+ if (known.has(entry.version)) continue;
4018
+ insert.run(
4019
+ nestId,
4020
+ doc.id,
4021
+ entry.version,
4022
+ entry.content_hash || "",
4023
+ entry.edited_by || "system:backfill",
4024
+ "approved",
4025
+ entry.note || null,
4026
+ entry.edited_at || (/* @__PURE__ */ new Date()).toISOString()
4027
+ );
4028
+ totalInserted += 1;
4029
+ }
4030
+ const pin = db.prepare(
4031
+ `SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?`
4032
+ ).get(nestId, doc.id);
4033
+ if (!pin || pin.approved_version < latestVersion) {
4034
+ approvedPin.run(
4035
+ nestId,
4036
+ doc.id,
4037
+ latestVersion,
4038
+ history.versions[history.versions.length - 1].edited_by || "system:backfill",
4039
+ nestId,
4040
+ doc.id
4041
+ );
4042
+ }
4043
+ if (tagsJson) {
4044
+ const updateTags = db.prepare(
4045
+ `UPDATE node_versions SET tags_json = ?
4046
+ WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL`
4047
+ );
4048
+ updateTags.run(tagsJson, nestId, doc.id, latestVersion);
4049
+ }
4050
+ }
4051
+ }
4052
+ db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)").run(
4053
+ MIGRATION_ID
4054
+ );
4055
+ console.log(
4056
+ `[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
4057
+ );
4058
+ }
4059
+
2797
4060
  // src/index.ts
2798
4061
  async function main() {
2799
- getDb();
4062
+ const db = getDb();
4063
+ try {
4064
+ await backfillNodeVersionsFromHistory(db);
4065
+ } catch (err) {
4066
+ console.error("[backfill] node_versions backfill failed:", err);
4067
+ }
2800
4068
  const accessCfg = loadAccessConfig();
2801
4069
  if (accessCfg) {
2802
4070
  console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
@@ -2805,16 +4073,24 @@ async function main() {
2805
4073
  if (!license.valid) {
2806
4074
  console.warn(`
2807
4075
  WARNING: No valid PromptOwl license key found.
2808
- The server will run, but some features may be limited.
4076
+ Server boots in SETUP MODE \u2014 writes return 503 and admin features
4077
+ (stewards, teammates, governance) are locked until a key is installed.
2809
4078
 
2810
- To register:
4079
+ To activate:
2811
4080
  1. Sign up at https://app.promptowl.ai
2812
- 2. Go to Settings > License Keys
2813
- 3. Create a Community Server key
2814
- 4. Set PROMPTOWL_KEY=pk_... in your environment
4081
+ 2. Open Overview > Community License
4082
+ 3. Generate a key
4083
+ 4. Paste it into the setup screen (UI) or POST /license/install,
4084
+ or set PROMPTOWL_KEY=pk_... in your environment and restart.
2815
4085
  `);
2816
4086
  }
2817
4087
  const app = createApp();
4088
+ startLicenseWatcher();
4089
+ startLicenseSafetyPoll();
4090
+ const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
4091
+ if (driftScanIntervalMs > 0) {
4092
+ startDriftScanner(driftScanIntervalMs);
4093
+ }
2818
4094
  startTelemetryLoop();
2819
4095
  trackEvent("server.start", {
2820
4096
  tier: license.tier,